Open History to see the last commit
1import { DEFAULT_HOSTED_APP_URL } from "./connectAuth.ts";
2
3export const PROMPT_LINK_MAX_PROMPT_LENGTH = 8_000;
4
5export interface PromptLinkRepository {
6 readonly owner: string;
7 readonly name: string;
8}
9
10export interface PromptLinkPullRequest extends PromptLinkRepository {
11 readonly number: number;
12}
13
14export interface LinkedPrompt {
15 readonly text: string;
16 readonly hiddenCharacters: number;
17}
18
19export type NewThreadLink =
20 | {
21 readonly kind: "new";
22 readonly prompt: LinkedPrompt;
23 readonly repository: PromptLinkRepository | null;
24 readonly provider: string | null;
25 readonly model: string | null;
26 }
27 | {
28 readonly kind: "pull-request";
29 readonly pullRequest: PromptLinkPullRequest;
30 readonly prompt: LinkedPrompt | null;
31 };
32
33export type PromptLinkResult<T> =
34 | { readonly ok: true; readonly value: T }
35 | { readonly ok: false; readonly error: string };
36
37// Hidden characters let models read instructions invisible to the person reviewing the prompt.
38const HIDDEN_CHARACTERS =
39 // oxlint-disable-next-line no-misleading-character-class -- these are the characters it removes.
40 /[\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;
41// Numeric emoji ranges because Hermes lacks Unicode property escapes.
42const isEmojiPart = (codePoint: number | undefined) =>
43 codePoint !== undefined &&
44 ((codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
45 (codePoint >= 0x2190 && codePoint <= 0x2bff) ||
46 codePoint === 0xfe0f);
47
48export function sanitizeLinkedPrompt(raw: string): LinkedPrompt {
49 let hiddenCharacters = 0;
50 const visible = Array.from(
51 raw.replace(/\r\n?|[\u2028\u2029]/g, "\n").replace(HIDDEN_CHARACTERS, () => {
52 hiddenCharacters++;
53 return "";
54 }),
55 );
56 const text = visible
57 .filter((character, index) => {
58 if (character !== "\u200D") return true;
59 const kept =
60 isEmojiPart(visible[index - 1]?.codePointAt(0)) &&
61 isEmojiPart(visible[index + 1]?.codePointAt(0));
62 if (!kept) hiddenCharacters++;
63 return kept;
64 })
65 .join("");
66 return { text: text.trim(), hiddenCharacters };
67}
68
69const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
70const NAME = /^[A-Za-z0-9._-]{1,100}$/;
71
72function repositoryOf(owner: string, name: string): PromptLinkRepository | null {
73 const trimmedName = name.replace(/\.git$/i, "");
74 if (!OWNER.test(owner) || !NAME.test(trimmedName) || /^\.+$/.test(trimmedName)) return null;
75 return { owner, name: trimmedName };
76}
77
78export function parsePromptLinkRepository(value: string): PromptLinkRepository | null {
79 const trimmed = value.trim();
80 const url = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/?$/i.exec(trimmed);
81 const short = /^([^/\s]+)\/([^/\s]+)$/.exec(trimmed);
82 const match = url ?? short;
83 return match?.[1] && match[2] ? repositoryOf(match[1], match[2]) : null;
84}
85
86function parsePromptLinkPullRequest(value: string): PromptLinkPullRequest | null {
87 const trimmed = value.trim();
88 const match =
89 /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(
90 trimmed,
91 ) ?? /^([^/\s#]+)\/([^/\s#]+)(?:\/|#)(\d+)$/.exec(trimmed);
92 if (!match?.[1] || !match[2] || !match[3]) return null;
93 const repository = repositoryOf(match[1], match[2]);
94 const number = Number(match[3]);
95 return repository && Number.isSafeInteger(number) && number > 0
96 ? { ...repository, number }
97 : null;
98}
99
100export function promptLinkPullRequestUrl(pullRequest: PromptLinkPullRequest): string {
101 return `https://github.com/${pullRequest.owner}/${pullRequest.name}/pull/${pullRequest.number}`;
102}
103
104function readPrompt(params: URLSearchParams): PromptLinkResult<LinkedPrompt | null> {
105 const raw = params.get("prompt");
106 if (raw === null) return { ok: true, value: null };
107 if (raw.length > PROMPT_LINK_MAX_PROMPT_LENGTH) {
108 return {
109 ok: false,
110 error: `This prompt is longer than ${PROMPT_LINK_MAX_PROMPT_LENGTH.toLocaleString("en-US")} characters, so T3 Code did not open it.`,
111 };
112 }
113 const prompt = sanitizeLinkedPrompt(raw);
114 if (prompt.text.length === 0 && prompt.hiddenCharacters > 0) {
115 return {
116 ok: false,
117 error:
118 "This prompt had only hidden characters, so it may be malicious. T3 Code did not open it.",
119 };
120 }
121 return { ok: true, value: prompt.text.length > 0 ? prompt : null };
122}
123
124const optionalParam = (params: URLSearchParams, key: string) => params.get(key)?.trim() || null;
125
126export function parseNewThreadLink(search: string): PromptLinkResult<NewThreadLink> {
127 const params = new URLSearchParams(search);
128 const prompt = readPrompt(params);
129 if (!prompt.ok) return prompt;
130 const pr = params.get("pr");
131 if (pr !== null) {
132 const pullRequest = parsePromptLinkPullRequest(pr);
133 if (pullRequest === null) {
134 return { ok: false, error: "This link names a pull request T3 Code cannot read." };
135 }
136 return { ok: true, value: { kind: "pull-request", pullRequest, prompt: prompt.value } };
137 }
138 if (prompt.value === null) return { ok: false, error: "This link has no prompt." };
139 const repo = params.get("repo");
140 const repository = repo === null ? null : parsePromptLinkRepository(repo);
141 if (repo !== null && repository === null) {
142 return { ok: false, error: "This link names a repository T3 Code cannot read." };
143 }
144 return {
145 ok: true,
146 value: {
147 kind: "new",
148 prompt: prompt.value,
149 repository,
150 provider: optionalParam(params, "provider")?.toLowerCase() ?? null,
151 model: optionalParam(params, "model"),
152 },
153 };
154}
155
156export function parseThreadLinkPrompt(search: string): PromptLinkResult<LinkedPrompt | null> {
157 return readPrompt(new URLSearchParams(search));
158}
159
160export function withoutPromptLinkParams(search: string): string {
161 const params = new URLSearchParams(search);
162 for (const key of ["prompt", "repo", "pr", "provider", "model"]) params.delete(key);
163 const rest = params.toString();
164 return rest ? `?${rest}` : "";
165}
166
167export function buildNewThreadLink(input: {
168 readonly prompt: string;
169 readonly repository?: PromptLinkRepository | null;
170 readonly provider?: string | null;
171 readonly model?: string | null;
172 readonly origin?: string;
173}): string {
174 const url = new URL("/new", input.origin ?? DEFAULT_HOSTED_APP_URL);
175 url.searchParams.set("prompt", input.prompt);
176 if (input.repository) {
177 url.searchParams.set("repo", `${input.repository.owner}/${input.repository.name}`);
178 }
179 if (input.provider) url.searchParams.set("provider", input.provider);
180 if (input.provider && input.model) url.searchParams.set("model", input.model);
181 return url.toString();
182}
183
184const APP_SCHEME = /^t3code(?:-[a-z]+)?:$/;
185const THREAD_PATH = /^\/([^/]+)\/([^/]+)\/?$/;
186
187export function promptLinkAppPath(
188 value: string,
189 hostedAppUrl: string = DEFAULT_HOSTED_APP_URL,
190): string | null {
191 if (!URL.canParse(value)) return null;
192 const url = new URL(value);
193 if (APP_SCHEME.test(url.protocol)) {
194 if (url.host === "new" && (url.pathname === "" || url.pathname === "/")) {
195 return `/new${url.search}`;
196 }
197 const thread = url.host === "threads" ? THREAD_PATH.exec(url.pathname) : null;
198 return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null;
199 }
200 if (!URL.canParse(hostedAppUrl) || url.origin !== new URL(hostedAppUrl).origin) return null;
201 if (url.pathname === "/new") return `/new${url.search}`;
202 // The web app's own thread route is a link only with a prompt, so reloading a tab never asks.
203 const thread = url.pathname.startsWith("/threads/")
204 ? THREAD_PATH.exec(url.pathname.slice("/threads".length))
205 : url.searchParams.has("prompt")
206 ? THREAD_PATH.exec(url.pathname)
207 : null;
208 return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null;
209}
210
211export function promptLinkDeepLink(appPath: string, scheme = "t3code"): string {
212 const url = new URL(appPath, "https://app.invalid");
213 const host = url.pathname === "/new" ? "new" : `threads${url.pathname}`;
214 return `${scheme}://${host}${url.search}`;
215}