authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 19:04:17-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 19:36:40-08:00
logccaa58e393d94226ec5e63a5dd1d51b1071d2821
tree232d912b1632211025753a23d769e6b7f288555f
parent6d6991bca199d9100ae152b5db1d55195d395688

terminal links


5 files changed, 384 insertions(+), 145 deletions(-)

apps/server/src/codexTextGenerator.test.ts deleted-123
...@@ -1,123 +0,0 @@
1import fs from "node:fs/promises";
2
3import { describe, expect, it } from "vitest";
4
5import { CodexTextGenerator } from "./codexTextGenerator";
6import type { ProcessRunOptions, ProcessRunResult } from "./processRunner";
7
8type ProcessRunner = (
9 command: string,
10 args: readonly string[],
11 options?: ProcessRunOptions,
12) => Promise<ProcessRunResult>;
13
14function getArgValue(args: readonly string[], flag: string): string {
15 const index = args.indexOf(flag);
16 if (index < 0 || index + 1 >= args.length) {
17 throw new Error(`Missing argument value for ${flag}`);
18 }
19 const value = args[index + 1];
20 if (!value) {
21 throw new Error(`Missing argument value for ${flag}`);
22 }
23 return value;
24}
25
26function okResult(): ProcessRunResult {
27 return {
28 stdout: "",
29 stderr: "",
30 code: 0,
31 signal: null,
32 timedOut: false,
33 };
34}
35
36function commitInput() {
37 return {
38 cwd: process.cwd(),
39 branch: "feat/example",
40 stagedSummary: "M apps/server/src/gitManager.ts",
41 stagedPatch: "diff --git a/file b/file",
42 };
43}
44
45describe("CodexTextGenerator", () => {
46 it("uses gpt-5.3-codex-spark when available with medium reasoning effort", async () => {
47 const models: string[] = [];
48 const configs: string[] = [];
49
50 const runner: ProcessRunner = async (command, args) => {
51 expect(command).toBe("codex");
52 models.push(getArgValue(args, "--model"));
53 configs.push(getArgValue(args, "--config"));
54
55 const outputPath = getArgValue(args, "--output-last-message");
56 await fs.writeFile(
57 outputPath,
58 JSON.stringify({
59 subject: "Add stacked git actions menu behavior",
60 body: "- Keep menu actions visible\n- Improve disabled states",
61 }),
62 "utf8",
63 );
64 return okResult();
65 };
66
67 const generator = new CodexTextGenerator({ runProcess: runner });
68 const result = await generator.generateCommitMessage(commitInput());
69
70 expect(result.subject).toBe("Add stacked git actions menu behavior");
71 expect(models).toEqual(["gpt-5.3-codex-spark"]);
72 expect(configs).toEqual(['model_reasoning_effort="medium"']);
73 });
74
75 it("uses gpt-5.3-codex-spark for PR content generation", async () => {
76 const models: string[] = [];
77
78 const runner: ProcessRunner = async (command, args) => {
79 expect(command).toBe("codex");
80 models.push(getArgValue(args, "--model"));
81
82 const outputPath = getArgValue(args, "--output-last-message");
83 await fs.writeFile(
84 outputPath,
85 JSON.stringify({
86 title: "Improve Git action modal behavior",
87 body: "## Summary\n- Update PR generation model\n\n## Testing\n- Not run",
88 }),
89 "utf8",
90 );
91 return okResult();
92 };
93
94 const generator = new CodexTextGenerator({ runProcess: runner });
95 const result = await generator.generatePrContent({
96 cwd: process.cwd(),
97 baseBranch: "main",
98 headBranch: "feat/example",
99 commitSummary: "abc123 Update model",
100 diffSummary: "1 file changed",
101 diffPatch: "diff --git a/file b/file",
102 });
103
104 expect(result.title).toBe("Improve Git action modal behavior");
105 expect(models).toEqual(["gpt-5.3-codex-spark"]);
106 });
107
108 it("propagates generation failures without retrying a second model", async () => {
109 const models: string[] = [];
110
111 const runner: ProcessRunner = async (_command, args) => {
112 models.push(getArgValue(args, "--model"));
113 throw new Error("Request timed out while contacting Codex.");
114 };
115
116 const generator = new CodexTextGenerator({ runProcess: runner });
117
118 await expect(generator.generateCommitMessage(commitInput())).rejects.toThrow(
119 "Request timed out while contacting Codex.",
120 );
121 expect(models).toEqual(["gpt-5.3-codex-spark"]);
122 });
123});
apps/server/src/codexTextGenerator.ts+7-22
...@@ -10,11 +10,7 @@ import type {...@@ -10,11 +10,7 @@ import type {
10 PrContentGenerationResult,10 PrContentGenerationResult,
11 TextGenerationService,11 TextGenerationService,
12} from "./coreServices";12} from "./coreServices";
13import {13import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner";
14 type ProcessRunOptions,
15 type ProcessRunResult,
16 runProcess,
17} from "./processRunner";
1814
19type ProcessRunner = (15type ProcessRunner = (
20 command: string,16 command: string,
...@@ -22,8 +18,8 @@ type ProcessRunner = (...@@ -22,8 +18,8 @@ type ProcessRunner = (
22 options?: ProcessRunOptions,18 options?: ProcessRunOptions,
23) => Promise<ProcessRunResult>;19) => Promise<ProcessRunResult>;
2420
25const CODEX_MODEL = "gpt-5.3-codex-spark";21const CODEX_MODEL = "gpt-5.3-codex";
26const CODEX_REASONING_EFFORT = "medium";22const CODEX_REASONING_EFFORT = "low";
2723
28const COMMIT_OUTPUT_SCHEMA_JSON = {24const COMMIT_OUTPUT_SCHEMA_JSON = {
29 type: "object",25 type: "object",
...@@ -78,10 +74,7 @@ function limitSection(value: string, maxChars: number): string {...@@ -78,10 +74,7 @@ function limitSection(value: string, maxChars: number): string {
78}74}
7975
80async function writeTempFile(prefix: string, content: string): Promise<string> {76async function writeTempFile(prefix: string, content: string): Promise<string> {
81 const filePath = path.join(77 const filePath = path.join(os.tmpdir(), `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`);
82 os.tmpdir(),
83 `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`,
84 );
85 await fs.writeFile(filePath, content, "utf8");78 await fs.writeFile(filePath, content, "utf8");
86 return filePath;79 return filePath;
87}80}
...@@ -128,10 +121,7 @@ async function runCodexJson<T>({...@@ -128,10 +121,7 @@ async function runCodexJson<T>({
128 parse: (raw: unknown) => T;121 parse: (raw: unknown) => T;
129 run: ProcessRunner;122 run: ProcessRunner;
130}): Promise<T> {123}): Promise<T> {
131 const schemaPath = await writeTempFile(124 const schemaPath = await writeTempFile("codex-schema", JSON.stringify(outputSchemaJson));
132 "codex-schema",
133 JSON.stringify(outputSchemaJson),
134 );
135 let outputPath: string | null = null;125 let outputPath: string | null = null;
136126
137 try {127 try {
...@@ -174,10 +164,7 @@ async function runCodexJson<T>({...@@ -174,10 +164,7 @@ async function runCodexJson<T>({
174164
175 return parse(parsedJson);165 return parse(parsedJson);
176 } finally {166 } finally {
177 await Promise.all([167 await Promise.all([safeUnlink(schemaPath), ...(outputPath ? [safeUnlink(outputPath)] : [])]);
178 safeUnlink(schemaPath),
179 ...(outputPath ? [safeUnlink(outputPath)] : []),
180 ]);
181 }168 }
182}169}
183170
...@@ -226,9 +213,7 @@ export class CodexTextGenerator implements TextGenerationService {...@@ -226,9 +213,7 @@ export class CodexTextGenerator implements TextGenerationService {
226 };213 };
227 }214 }
228215
229 async generatePrContent(216 async generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult> {
230 input: PrContentGenerationInput,
231 ): Promise<PrContentGenerationResult> {
232 const prompt = [217 const prompt = [
233 "You write GitHub pull request content.",218 "You write GitHub pull request content.",
234 "Return a JSON object with keys: title, body.",219 "Return a JSON object with keys: title, body.",
apps/web/src/components/ThreadTerminalDrawer.tsx+68
...@@ -8,6 +8,12 @@ import {...@@ -8,6 +8,12 @@ import {
8 useRef,8 useRef,
9 useState,9 useState,
10} from "react";10} from "react";
11import {
12 extractTerminalLinks,
13 isTerminalLinkActivation,
14 preferredTerminalEditor,
15 resolvePathLinkTarget,
16} from "../terminal-links";
11import { isTerminalClearShortcut } from "../terminal-shortcuts";17import { isTerminalClearShortcut } from "../terminal-shortcuts";
12import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types";18import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types";
1319
...@@ -298,6 +304,67 @@ export default function ThreadTerminalDrawer({...@@ -298,6 +304,67 @@ export default function ThreadTerminalDrawer({
298 return false;304 return false;
299 });305 });
300306
307 const terminalLinksDisposable = terminal.registerLinkProvider({
308 provideLinks: (bufferLineNumber, callback) => {
309 const activeTerminal = terminalRef.current;
310 if (!activeTerminal) {
311 callback(undefined);
312 return;
313 }
314
315 const line = activeTerminal.buffer.active.getLine(bufferLineNumber - 1);
316 if (!line) {
317 callback(undefined);
318 return;
319 }
320
321 const lineText = line.translateToString(true);
322 const matches = extractTerminalLinks(lineText);
323 if (matches.length === 0) {
324 callback(undefined);
325 return;
326 }
327
328 callback(
329 matches.map((match) => ({
330 text: match.text,
331 range: {
332 start: { x: match.start + 1, y: bufferLineNumber },
333 end: { x: match.end, y: bufferLineNumber },
334 },
335 activate: (event: MouseEvent) => {
336 if (!isTerminalLinkActivation(event)) {
337 return;
338 }
339
340 const latestTerminal = terminalRef.current;
341 if (!latestTerminal) {
342 return;
343 }
344
345 if (match.kind === "url") {
346 void api.shell.openExternal(match.text).catch((error) => {
347 writeSystemMessage(
348 latestTerminal,
349 error instanceof Error ? error.message : "Unable to open link",
350 );
351 });
352 return;
353 }
354
355 const target = resolvePathLinkTarget(match.text, cwd);
356 void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => {
357 writeSystemMessage(
358 latestTerminal,
359 error instanceof Error ? error.message : "Unable to open path",
360 );
361 });
362 },
363 })),
364 );
365 },
366 });
367
301 const inputDisposable = terminal.onData((data) => {368 const inputDisposable = terminal.onData((data) => {
302 void api.terminal369 void api.terminal
303 .write({ threadId, data })370 .write({ threadId, data })
...@@ -393,6 +460,7 @@ export default function ThreadTerminalDrawer({...@@ -393,6 +460,7 @@ export default function ThreadTerminalDrawer({
393 window.clearTimeout(fitTimer);460 window.clearTimeout(fitTimer);
394 unsubscribe();461 unsubscribe();
395 inputDisposable.dispose();462 inputDisposable.dispose();
463 terminalLinksDisposable.dispose();
396 themeObserver.disconnect();464 themeObserver.disconnect();
397 terminalRef.current = null;465 terminalRef.current = null;
398 fitAddonRef.current = null;466 fitAddonRef.current = null;
apps/web/src/terminal-links.test.ts created+110
...@@ -0,0 +1,110 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 extractTerminalLinks,
5 isTerminalLinkActivation,
6 resolvePathLinkTarget,
7} from "./terminal-links";
8
9describe("extractTerminalLinks", () => {
10 it("finds http urls and path tokens", () => {
11 const line =
12 "failed at https://example.com/docs and src/components/ThreadTerminalDrawer.tsx:42";
13 expect(extractTerminalLinks(line)).toEqual([
14 {
15 kind: "url",
16 text: "https://example.com/docs",
17 start: 10,
18 end: 34,
19 },
20 {
21 kind: "path",
22 text: "src/components/ThreadTerminalDrawer.tsx:42",
23 start: 39,
24 end: 81,
25 },
26 ]);
27 });
28
29 it("trims trailing punctuation from links", () => {
30 const line = "(https://example.com/docs), ./src/main.ts:12.";
31 expect(extractTerminalLinks(line)).toEqual([
32 {
33 kind: "url",
34 text: "https://example.com/docs",
35 start: 1,
36 end: 25,
37 },
38 {
39 kind: "path",
40 text: "./src/main.ts:12",
41 start: 28,
42 end: 44,
43 },
44 ]);
45 });
46});
47
48describe("resolvePathLinkTarget", () => {
49 it("resolves relative paths against cwd", () => {
50 expect(
51 resolvePathLinkTarget(
52 "src/components/ThreadTerminalDrawer.tsx:42:7",
53 "/Users/julius/project",
54 ),
55 ).toBe("/Users/julius/project/src/components/ThreadTerminalDrawer.tsx:42:7");
56 });
57
58 it("keeps absolute paths unchanged", () => {
59 expect(
60 resolvePathLinkTarget(
61 "/Users/julius/project/src/main.ts:12",
62 "/Users/julius/project",
63 ),
64 ).toBe("/Users/julius/project/src/main.ts:12");
65 });
66});
67
68describe("isTerminalLinkActivation", () => {
69 it("requires cmd on macOS", () => {
70 expect(
71 isTerminalLinkActivation(
72 {
73 metaKey: true,
74 ctrlKey: false,
75 },
76 "MacIntel",
77 ),
78 ).toBe(true);
79 expect(
80 isTerminalLinkActivation(
81 {
82 metaKey: false,
83 ctrlKey: true,
84 },
85 "MacIntel",
86 ),
87 ).toBe(false);
88 });
89
90 it("requires ctrl on non-macOS", () => {
91 expect(
92 isTerminalLinkActivation(
93 {
94 metaKey: false,
95 ctrlKey: true,
96 },
97 "Win32",
98 ),
99 ).toBe(true);
100 expect(
101 isTerminalLinkActivation(
102 {
103 metaKey: true,
104 ctrlKey: false,
105 },
106 "Linux",
107 ),
108 ).toBe(false);
109 });
110});
apps/web/src/terminal-links.ts created+199
...@@ -0,0 +1,199 @@
1import { EDITORS, type EditorId } from "@t3tools/contracts";
2
3export type TerminalLinkKind = "url" | "path";
4
5export interface TerminalLinkMatch {
6 kind: TerminalLinkKind;
7 text: string;
8 start: number;
9 end: number;
10}
11
12const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g;
13const FILE_PATH_PATTERN =
14 /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:\\|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g;
15const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/;
16const LAST_EDITOR_KEY = "t3code:last-editor";
17
18function isMacPlatform(platform: string): boolean {
19 return /mac|iphone|ipad|ipod/i.test(platform);
20}
21
22function trimClosingDelimiters(value: string): string {
23 let output = value.replace(TRAILING_PUNCTUATION_PATTERN, "");
24 if (output.length === 0) return output;
25
26 const trimUnbalanced = (open: string, close: string) => {
27 while (output.endsWith(close)) {
28 const opens = output.split(open).length - 1;
29 const closes = output.split(close).length - 1;
30 if (opens >= closes) return;
31 output = output.slice(0, -1);
32 }
33 };
34
35 trimUnbalanced("(", ")");
36 trimUnbalanced("[", "]");
37 trimUnbalanced("{", "}");
38 return output;
39}
40
41function overlaps(a: { start: number; end: number }, b: { start: number; end: number }): boolean {
42 return a.start < b.end && b.start < a.end;
43}
44
45function collectMatches(
46 line: string,
47 kind: TerminalLinkKind,
48 pattern: RegExp,
49 existing: TerminalLinkMatch[],
50): TerminalLinkMatch[] {
51 const matches: TerminalLinkMatch[] = [];
52 pattern.lastIndex = 0;
53
54 for (const rawMatch of line.matchAll(pattern)) {
55 const raw = rawMatch[0];
56 const start = rawMatch.index ?? -1;
57 if (start < 0 || raw.length === 0) continue;
58
59 const trimmed = trimClosingDelimiters(raw);
60 if (trimmed.length === 0) continue;
61 if (kind === "path" && /^https?:\/\//i.test(trimmed)) continue;
62
63 const candidate: TerminalLinkMatch = {
64 kind,
65 text: trimmed,
66 start,
67 end: start + trimmed.length,
68 };
69
70 const collides = [...existing, ...matches].some((other) => overlaps(candidate, other));
71 if (collides) continue;
72
73 matches.push(candidate);
74 }
75
76 return matches;
77}
78
79function isWindowsAbsolutePath(value: string): boolean {
80 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
81}
82
83function isAbsolutePath(value: string): boolean {
84 return value.startsWith("/") || isWindowsAbsolutePath(value);
85}
86
87function isWindowsPathStyle(value: string): boolean {
88 return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value);
89}
90
91function joinPath(base: string, next: string, separator: "/" | "\\"): string {
92 const cleanBase = base.replace(/[\\/]+$/, "");
93 if (separator === "\\") {
94 return `${cleanBase}\\${next.replaceAll("/", "\\")}`;
95 }
96 return `${cleanBase}/${next.replace(/^\/+/, "")}`;
97}
98
99function inferHomeFromCwd(cwd: string): string | undefined {
100 const posixUser = cwd.match(/^\/Users\/([^/]+)/);
101 if (posixUser?.[1]) {
102 return `/Users/${posixUser[1]}`;
103 }
104
105 const posixHome = cwd.match(/^\/home\/([^/]+)/);
106 if (posixHome?.[1]) {
107 return `/home/${posixHome[1]}`;
108 }
109
110 const windowsUser = cwd.match(/^([A-Za-z]:\\Users\\[^\\]+)/);
111 if (windowsUser?.[1]) {
112 return windowsUser[1];
113 }
114
115 return undefined;
116}
117
118function splitPathAndPosition(value: string): { path: string; line?: string; column?: string } {
119 let path = value;
120 let column: string | undefined;
121 let line: string | undefined;
122
123 const columnMatch = path.match(/:(\d+)$/);
124 if (!columnMatch?.[1]) {
125 return { path };
126 }
127
128 column = columnMatch[1];
129 path = path.slice(0, -columnMatch[0].length);
130
131 const lineMatch = path.match(/:(\d+)$/);
132 if (lineMatch?.[1]) {
133 line = lineMatch[1];
134 path = path.slice(0, -lineMatch[0].length);
135 } else {
136 line = column;
137 column = undefined;
138 }
139
140 return { path, line, column };
141}
142
143export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
144 const urlMatches = collectMatches(line, "url", URL_PATTERN, []);
145 const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches);
146 return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start);
147}
148
149export function isTerminalLinkActivation(
150 event: Pick<MouseEvent, "metaKey" | "ctrlKey">,
151 platform = typeof navigator === "undefined" ? "" : navigator.platform,
152): boolean {
153 if (platform.length === 0) return false;
154 return isMacPlatform(platform)
155 ? event.metaKey && !event.ctrlKey
156 : event.ctrlKey && !event.metaKey;
157}
158
159export function resolvePathLinkTarget(rawPath: string, cwd: string): string {
160 const { path, line, column } = splitPathAndPosition(rawPath);
161
162 let resolvedPath = path;
163 if (path.startsWith("~/")) {
164 const home = inferHomeFromCwd(cwd);
165 if (home) {
166 const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/";
167 resolvedPath = joinPath(home, path.slice(2), separator);
168 }
169 } else if (!isAbsolutePath(path)) {
170 const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/";
171 resolvedPath = joinPath(cwd, path, separator);
172 }
173
174 if (!line) return resolvedPath;
175 return `${resolvedPath}:${line}${column ? `:${column}` : ""}`;
176}
177
178export function preferredTerminalEditor(): EditorId {
179 const fallback =
180 EDITORS.find((editor) => editor.command)?.id ??
181 EDITORS[0]?.id ??
182 "cursor";
183
184 if (typeof window === "undefined") {
185 return fallback;
186 }
187
188 const storedEditor = window.localStorage.getItem(LAST_EDITOR_KEY);
189 if (!storedEditor) {
190 return fallback;
191 }
192
193 const configured = EDITORS.find((editor) => editor.id === storedEditor);
194 if (!configured?.command) {
195 return fallback;
196 }
197
198 return configured.id;
199}