Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ dist/
web-build/
expo-env.d.ts

# Signing / store credentials (personal fork)
credentials/

# Native
.kotlin/
*.orig.*
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/app.config.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",

Setting T3CODE_EAS_PROJECT_ID changes extra.eas.projectId but leaves updates.url hard-coded to https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454, so binaries built for an overridden project query the upstream project's Expo update service and never receive OTA updates published to the configured project. updates.url must contain the same project ID, so derive it from the resolved value instead of using a separate hard-coded constant.

-    url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",
+    url: `https://u.expo.dev/${process.env.T3CODE_EAS_PROJECT_ID ?? "d763fcb8-d37c-41ea-a773-b54a0ab4a454"}`,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/app.config.ts around line 177:

Setting `T3CODE_EAS_PROJECT_ID` changes `extra.eas.projectId` but leaves `updates.url` hard-coded to `https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454`, so binaries built for an overridden project query the upstream project's Expo update service and never receive OTA updates published to the configured project. `updates.url` must contain the same project ID, so derive it from the resolved value instead of using a separate hard-coded constant.

Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ const config: ExpoConfig = {
},
android: {
icon: variant.assets.appIcon,
package: variant.androidPackage,
package: process.env.T3CODE_ANDROID_PACKAGE ?? variant.androidPackage,
adaptiveIcon: {
backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
foregroundImage: variant.assets.androidAdaptiveForeground,
Expand Down Expand Up @@ -345,10 +345,10 @@ const config: ExpoConfig = {
tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null,
},
eas: {
projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
projectId: process.env.T3CODE_EAS_PROJECT_ID ?? "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
},
},
owner: "pingdotgg",
owner: process.env.T3CODE_EAS_OWNER ?? "pingdotgg",
};

export default config;
13 changes: 13 additions & 0 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@
"android": {
"track": "internal"
}
},
"personal": {
"ios": {
"ascAppId": "6793805091",
"appleTeamId": "C7X9BCC7LP",
"ascApiKeyPath": "./credentials/ios/AuthKey_5287YYUU8B.p8",
"ascApiKeyId": "5287YYUU8B",
"ascApiKeyIssuerId": "69a6de96-923d-47e3-e053-5b8c7c11a4d1"
},
"android": {
"serviceAccountKeyPath": "./credentials/android/barbellry-45151cd5babe.json",
"track": "internal"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal fork config committed

Medium Severity

This PR adds a personal EAS submit profile with private Apple team/ASC credentials and a Play service account path, along with docs/personal/* runbooks. This personal fork-specific configuration and documentation are unrelated to the PR's stated purpose and do not belong in the upstream repository.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a7c82dd. Configure here.

}
}
}
73 changes: 73 additions & 0 deletions apps/mobile/src/features/terminal/TerminalLinkBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { memo, useMemo } from "react";
import { Pressable, ScrollView, View } from "react-native";

import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
import { extractTerminalBufferLinks, terminalLinkLabel } from "./terminalBufferLinks";
import type { TerminalTheme } from "./terminalTheme";

interface TerminalLinkBarProps {
readonly buffer: string;
readonly theme?: Pick<TerminalTheme, "border" | "foreground" | "mutedForeground">;
}

const DEFAULT_THEME = {
border: "rgba(255, 255, 255, 0.1)",
foreground: "#e5e5e5",
mutedForeground: "#a3a3a3",
};

/**
* Tappable chips for URLs printed in the terminal. The Ghostty surface draws
* text on the GPU without link hit-testing, so this is how terminal URLs
* (e.g. the `claude` OAuth login URL) are opened on mobile. Tap opens the
* full reassembled URL; long-press copies it.
*/
export const TerminalLinkBar = memo(function TerminalLinkBar(props: TerminalLinkBarProps) {
const links = useMemo(() => extractTerminalBufferLinks(props.buffer), [props.buffer]);
const theme = props.theme ?? DEFAULT_THEME;

if (links.length === 0) {
return null;
}

return (
<View className="border-b" style={{ borderBottomColor: theme.border }}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="flex-row items-center gap-2 px-3 py-2"
keyboardShouldPersistTaps="handled"
>
{links.map((url) => (
<Pressable
key={url}
accessibilityRole="link"
accessibilityLabel={`Open ${url}`}
accessibilityHint="Long press to copy the link"
style={({ pressed }) => ({
flexDirection: "row",
alignItems: "center",
gap: 6,
borderRadius: 999,
borderWidth: 1,
borderColor: theme.border,
paddingHorizontal: 12,
paddingVertical: 6,
opacity: pressed ? 0.6 : 1,
})}
onPress={() => void tryOpenExternalUrl(url, "terminal-link")}
onLongPress={() => copyTextWithHaptic(url, { target: "terminal link" })}
>
<SymbolView name="link" size={11} tintColor={theme.mutedForeground} type="monochrome" />
<Text className="text-2xs" numberOfLines={1} style={{ color: theme.foreground }}>
{terminalLinkLabel(url)}
</Text>
</Pressable>
))}
</ScrollView>
</View>
);
});
2 changes: 2 additions & 0 deletions apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { terminalEnvironment } from "../../state/terminal";
import { useAtomCommand } from "../../state/use-atom-command";
import { useAttachedTerminalSession } from "../../state/use-terminal-session";
import { TerminalSurface } from "./NativeTerminalSurface";
import { TerminalLinkBar } from "./TerminalLinkBar";
import { hasNativeTerminalSurface } from "./nativeTerminalModule";
import {
buildThreadTerminalAttachInput,
Expand Down Expand Up @@ -238,6 +239,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
</Pressable>
</View>
</View>
<TerminalLinkBar buffer={terminal.buffer} />
<TerminalSurface
terminalKey={terminalKey}
buffer={terminal.buffer}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail";
import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { TerminalSurface } from "./NativeTerminalSurface";
import { TerminalLinkBar } from "./TerminalLinkBar";
import { getPierreTerminalTheme } from "./terminalTheme";
import { terminalDebugLog } from "./terminalDebugLog";
import {
Expand Down Expand Up @@ -1217,6 +1218,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
/>
) : (
<>
<TerminalLinkBar buffer={terminal.buffer} theme={terminalTheme} />
<View className="flex-1" style={{ paddingBottom: terminalBottomInset }}>
<TerminalSurface
autoFocus={!SHOWCASE_ENABLED}
Expand Down
110 changes: 110 additions & 0 deletions apps/mobile/src/features/terminal/terminalBufferLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vite-plus/test";

import {
extractTerminalBufferLinks,
terminalLinkLabel,
TERMINAL_LINK_MAX_RESULTS,
TERMINAL_LINK_SCAN_WINDOW_BYTES,
} from "./terminalBufferLinks";

// Claude Code's OAuth login URL shape: long enough to wrap on any phone.
const LOGIN_URL =
"https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile&state=Iv1supDq4Neyq6BB0nWfHhtCU9BuJqz";

function wrapToWidth(text: string, width: number): string {
const lines: string[] = [];
for (let index = 0; index < text.length; index += width) {
lines.push(text.slice(index, index + width));
}
return lines.join("\n");
}

describe("extractTerminalBufferLinks", () => {
it("finds a plain URL on its own line", () => {
const buffer =
"Browser didn't open? Use the url below to sign in:\n\nhttps://example.com/auth\n";
expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/auth"]);
});

it("reassembles a URL hard-wrapped to the terminal width", () => {
const width = 48;
const buffer = `Use the url below to sign in:\n\n${wrapToWidth(LOGIN_URL, width)}\n\nPaste code here if prompted:\n`;
expect(extractTerminalBufferLinks(buffer)).toEqual([LOGIN_URL]);
});

it("does not glue following prose onto a URL that merely ends a short line", () => {
const buffer = "Docs: https://example.com/docs\nthanks for reading\n";
expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/docs"]);
});

it("ignores ANSI styling wrapped around and inside the URL", () => {
const buffer =
"\x1b[2J\x1b[H\x1b[1;34mSign in:\x1b[0m \x1b[4mhttps://example.com/\x1b[36mauth?state=abc\x1b[0m\n";
expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/auth?state=abc"]);
});

it("reassembles a wrapped URL whose rows are painted with cursor-positioning sequences", () => {
const width = 40;
const rows = wrapToWidth(LOGIN_URL, width).split("\n");
const padded = `Open the link below to continue login now`;
const buffer = rows
.map((row, index) => `\x1b[${index + 3};1H\x1b[K${row}`)
.join("\r\n")
.concat(`\r\n${padded}\r\n`);
expect(extractTerminalBufferLinks(buffer)).toEqual([LOGIN_URL]);
});

it("extracts OSC 8 hyperlink targets exactly", () => {
const buffer =
"See \x1b]8;;https://example.com/very/long/target\x1b\\the docs\x1b]8;;\x1b\\ page\n";
expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/very/long/target"]);
});

it("trims trailing punctuation and unbalanced closers", () => {
const buffer = "Read https://example.com/a). Then https://example.com/b,\n";
expect(extractTerminalBufferLinks(buffer)).toEqual([
"https://example.com/b",
"https://example.com/a",
]);
});

it("drops spinner-truncated prefixes of the same URL, keeping most recent first", () => {
const buffer =
"https://example.com/oauth?state=ab\rhttps://example.com/oauth?state=abcdef\nDone. https://example.com/next\n";
expect(extractTerminalBufferLinks(buffer)).toEqual([
"https://example.com/next",
"https://example.com/oauth?state=abcdef",
]);
});

it("caps results and prefers the most recent URLs", () => {
const buffer = Array.from({ length: 6 }, (_, index) => `https://example.com/${index}`).join(
"\n",
);
const links = extractTerminalBufferLinks(buffer);
expect(links).toHaveLength(TERMINAL_LINK_MAX_RESULTS);
expect(links[0]).toBe("https://example.com/5");
});

it("ignores a URL cut in half by the scan window", () => {
const filler = `${"x".repeat(200)}\n`;
const buffer = `https://truncated.example.com/${"a".repeat(TERMINAL_LINK_SCAN_WINDOW_BYTES)}\n${filler.repeat(4)}https://kept.example.com/ok\n`;
expect(extractTerminalBufferLinks(buffer)).toEqual(["https://kept.example.com/ok"]);
});

it("returns nothing for an empty or URL-free buffer", () => {
expect(extractTerminalBufferLinks("")).toEqual([]);
expect(extractTerminalBufferLinks("$ ls\nsrc package.json\n")).toEqual([]);
});
});

describe("terminalLinkLabel", () => {
it("shows only the host for bare origins", () => {
expect(terminalLinkLabel("https://claude.ai")).toBe("claude.ai");
expect(terminalLinkLabel("https://claude.ai/")).toBe("claude.ai");
});

it("marks URLs that carry a path or query", () => {
expect(terminalLinkLabel(LOGIN_URL)).toBe("claude.ai/…");
});
});
Loading
Loading