1import { DEFAULT_HOSTED_APP_URL } from "./connectAuth.ts";
2
3/**
4 * Prompt links open T3 Code from outside it with a prompt ready to send. They never send it:
5 * a link from a stranger fills a draft, and the user reads it and presses Send. A link also
6 * cannot pick a runtime mode, branch, or worktree. Those stay the user's own defaults.
7 *
8 * Shared links are https on the hosted app, because that is what Twitter and GitHub make
9 * clickable. The `t3code://` scheme carries the same paths into desktop and mobile:
10 *
11 * https://app.t3.codes/new?prompt=…&repo=owner/name&provider=codex&model=gpt-5
12 * https://app.t3.codes/new?pr=owner/name/123&prompt=…
13 * https://app.t3.codes/<environmentId>/<threadId>?prompt=…
14 * t3code://new?… t3code://threads/<environmentId>/<threadId>?…
15 */
16
17/** URLs stay well under the limits browsers and link shorteners apply. */
18export const PROMPT_LINK_MAX_PROMPT_LENGTH = 8_000;
19
20export interface PromptLinkRepository {
21 readonly owner: string;
22 readonly name: string;
23}
24
25export interface PromptLinkPullRequest extends PromptLinkRepository {
26 readonly number: number;
27}
28
29/** A prompt as it arrived, after hidden characters were removed. */
30export interface LinkedPrompt {
31 readonly text: string;
32 /** How many invisible characters were removed. Any count above zero is a warning sign. */
33 readonly hiddenCharacters: number;
34}
35
36export type NewThreadLink =
37 | {
38 readonly kind: "new";
39 readonly prompt: LinkedPrompt;
40 readonly repository: PromptLinkRepository | null;
41 /** A provider driver kind such as `codex`. Users map it to one of their own instances. */
42 readonly provider: string | null;
43 readonly model: string | null;
44 }
45 | {
46 readonly kind: "pull-request";
47 readonly pullRequest: PromptLinkPullRequest;
48 readonly prompt: LinkedPrompt | null;
49 };
50
51export type PromptLinkResult<T> =
52 | { readonly ok: true; readonly value: T }
53 | { readonly ok: false; readonly error: string };
54
55// Zero-width and bidi controls, tag and variation-selector blocks, and other characters that
56// render as nothing. A model reads them; the person checking the draft cannot see them, which
57// makes them the usual way to hide instructions in a prompt. U+FE0E/U+FE0F stay: emoji use them.
58// Line and paragraph separators become plain newlines, so the draft shows the break the model reads.
59const HIDDEN_CHARACTERS =
60 // oxlint-disable-next-line no-control-regex, no-misleading-character-class -- these are the characters it removes.
61 /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B\u200C\u200E\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0D\uFEFF\uFFA0\uFFF0-\uFFFB\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E0FFF}]/gu;
62// Emoji and the pieces emoji sequences are built from: pictographs, symbols, skin tones, and the
63// emoji presentation selector. Numeric ranges, because Hermes lacks Unicode property escapes.
64const isEmojiPart = (codePoint: number | undefined) =>
65 codePoint !== undefined &&
66 ((codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
67 (codePoint >= 0x2190 && codePoint <= 0x2bff) ||
68 codePoint === 0xfe0f);
69
70/** Removes characters a reader cannot see, and counts them. */
71export function sanitizeLinkedPrompt(raw: string): LinkedPrompt {
72 let hiddenCharacters = 0;
73 const visible = Array.from(
74 raw.replace(/\r\n?|[\u2028\u2029]/g, "\n").replace(HIDDEN_CHARACTERS, () => {
75 hiddenCharacters++;
76 return "";
77 }),
78 );
79 // A zero-width joiner is visible work only inside an emoji sequence, such as a family.
80 const text = visible
81 .filter((character, index) => {
82 if (character !== "\u200D") return true;
83 const kept =
84 isEmojiPart(visible[index - 1]?.codePointAt(0)) &&
85 isEmojiPart(visible[index + 1]?.codePointAt(0));
86 if (!kept) hiddenCharacters++;
87 return kept;
88 })
89 .join("");
90 return { text: text.trim(), hiddenCharacters };
91}
92
93const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
94const NAME = /^[A-Za-z0-9._-]{1,100}$/;
95
96function repositoryOf(owner: string, name: string): PromptLinkRepository | null {
97 const trimmedName = name.replace(/\.git$/i, "");
98 if (!OWNER.test(owner) || !NAME.test(trimmedName) || /^\.+$/.test(trimmedName)) return null;
99 return { owner, name: trimmedName };
100}
101
102/** `owner/name`, or a GitHub repository URL. */
103export function parsePromptLinkRepository(value: string): PromptLinkRepository | null {
104 const trimmed = value.trim();
105 const url = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/?$/i.exec(trimmed);
106 const short = /^([^/\s]+)\/([^/\s]+)$/.exec(trimmed);
107 const match = url ?? short;
108 return match?.[1] && match[2] ? repositoryOf(match[1], match[2]) : null;
109}
110
111/** `owner/name/123`, `owner/name#123`, or a GitHub pull request URL. */
112function parsePromptLinkPullRequest(value: string): PromptLinkPullRequest | null {
113 const trimmed = value.trim();
114 const match =
115 /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(
116 trimmed,
117 ) ?? /^([^/\s#]+)\/([^/\s#]+)(?:\/|#)(\d+)$/.exec(trimmed);
118 if (!match?.[1] || !match[2] || !match[3]) return null;
119 const repository = repositoryOf(match[1], match[2]);
120 const number = Number(match[3]);
121 return repository && Number.isSafeInteger(number) && number > 0
122 ? { ...repository, number }
123 : null;
124}
125
126/** The pull request's page on GitHub, the reference `preparePullRequestThread` takes. */
127export function promptLinkPullRequestUrl(pullRequest: PromptLinkPullRequest): string {
128 return `https://github.com/${pullRequest.owner}/${pullRequest.name}/pull/${pullRequest.number}`;
129}
130
131function readPrompt(params: URLSearchParams): PromptLinkResult<LinkedPrompt | null> {
132 const raw = params.get("prompt");
133 if (raw === null) return { ok: true, value: null };
134 if (raw.length > PROMPT_LINK_MAX_PROMPT_LENGTH) {
135 return {
136 ok: false,
137 error: `This prompt is longer than ${PROMPT_LINK_MAX_PROMPT_LENGTH.toLocaleString("en-US")} characters, so T3 Code did not open it.`,
138 };
139 }
140 const prompt = sanitizeLinkedPrompt(raw);
141 if (prompt.text.length === 0 && prompt.hiddenCharacters > 0) {
142 return {
143 ok: false,
144 error:
145 "This prompt had only hidden characters, so it may be malicious. T3 Code did not open it.",
146 };
147 }
148 return { ok: true, value: prompt.text.length > 0 ? prompt : null };
149}
150
151const optionalParam = (params: URLSearchParams, key: string) => params.get(key)?.trim() || null;
152
153/** Reads the query of a `/new` link. */
154export function parseNewThreadLink(search: string): PromptLinkResult<NewThreadLink> {
155 const params = new URLSearchParams(search);
156 const prompt = readPrompt(params);
157 if (!prompt.ok) return prompt;
158 const pr = params.get("pr");
159 if (pr !== null) {
160 const pullRequest = parsePromptLinkPullRequest(pr);
161 if (pullRequest === null) {
162 return { ok: false, error: "This link names a pull request T3 Code cannot read." };
163 }
164 return { ok: true, value: { kind: "pull-request", pullRequest, prompt: prompt.value } };
165 }
166 if (prompt.value === null) return { ok: false, error: "This link has no prompt." };
167 const repo = params.get("repo");
168 const repository = repo === null ? null : parsePromptLinkRepository(repo);
169 if (repo !== null && repository === null) {
170 return { ok: false, error: "This link names a repository T3 Code cannot read." };
171 }
172 return {
173 ok: true,
174 value: {
175 kind: "new",
176 prompt: prompt.value,
177 repository,
178 provider: optionalParam(params, "provider")?.toLowerCase() ?? null,
179 model: optionalParam(params, "model"),
180 },
181 };
182}
183
184/** Reads the `prompt` of a thread link. Null when the link carries none. */
185export function parseThreadLinkPrompt(search: string): PromptLinkResult<LinkedPrompt | null> {
186 return readPrompt(new URLSearchParams(search));
187}
188
189/** Drops the link's own parameters from a query, keeping anything else the route uses. */
190export function withoutPromptLinkParams(search: string): string {
191 const params = new URLSearchParams(search);
192 for (const key of ["prompt", "repo", "pr", "provider", "model"]) params.delete(key);
193 const rest = params.toString();
194 return rest ? `?${rest}` : "";
195}
196
197/** A shareable `/new` link on the hosted app. */
198export function buildNewThreadLink(input: {
199 readonly prompt: string;
200 readonly repository?: PromptLinkRepository | null;
201 readonly provider?: string | null;
202 readonly model?: string | null;
203 readonly origin?: string;
204}): string {
205 const url = new URL("/new", input.origin ?? DEFAULT_HOSTED_APP_URL);
206 url.searchParams.set("prompt", input.prompt);
207 if (input.repository) {
208 url.searchParams.set("repo", `${input.repository.owner}/${input.repository.name}`);
209 }
210 if (input.provider) url.searchParams.set("provider", input.provider);
211 if (input.provider && input.model) url.searchParams.set("model", input.model);
212 return url.toString();
213}
214
215const APP_SCHEME = /^t3code(?:-[a-z]+)?:$/;
216const THREAD_PATH = /^\/([^/]+)\/([^/]+)\/?$/;
217
218/**
219 * The in-app path a prompt link or thread deep link opens, or null for any other URL.
220 * Desktop uses it for links the OS hands over, both `t3code://` ones and, as the default
221 * browser on macOS, hosted-app ones.
222 */
223export function promptLinkAppPath(
224 value: string,
225 hostedAppUrl: string = DEFAULT_HOSTED_APP_URL,
226): string | null {
227 if (!URL.canParse(value)) return null;
228 const url = new URL(value);
229 if (APP_SCHEME.test(url.protocol)) {
230 if (url.host === "new" && (url.pathname === "" || url.pathname === "/")) {
231 return `/new${url.search}`;
232 }
233 const thread = url.host === "threads" ? THREAD_PATH.exec(url.pathname) : null;
234 return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null;
235 }
236 if (!URL.canParse(hostedAppUrl) || url.origin !== new URL(hostedAppUrl).origin) return null;
237 if (url.pathname === "/new") return `/new${url.search}`;
238 // Hosted thread links only open in the app when they carry a prompt. A plain one is just a
239 // page on the hosted app.
240 const thread = url.searchParams.has("prompt") ? THREAD_PATH.exec(url.pathname) : null;
241 return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null;
242}
243
244/** The `t3code://` link that hands an in-app path to an installed desktop or mobile app. */
245export function promptLinkDeepLink(appPath: string, scheme = "t3code"): string {
246 const url = new URL(appPath, "https://app.invalid");
247 const host = url.pathname === "/new" ? "new" : `threads${url.pathname}`;
248 return `${scheme}://${host}${url.search}`;
249}