diff --git a/apps/server/src/codexTextGenerator.test.ts b/apps/server/src/codexTextGenerator.test.ts deleted file mode 100644 index b1d679c88c25..000000000000 --- a/apps/server/src/codexTextGenerator.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import fs from "node:fs/promises"; - -import { describe, expect, it } from "vitest"; - -import { CodexTextGenerator } from "./codexTextGenerator"; -import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; - -type ProcessRunner = ( - command: string, - args: readonly string[], - options?: ProcessRunOptions, -) => Promise; - -function getArgValue(args: readonly string[], flag: string): string { - const index = args.indexOf(flag); - if (index < 0 || index + 1 >= args.length) { - throw new Error(`Missing argument value for ${flag}`); - } - const value = args[index + 1]; - if (!value) { - throw new Error(`Missing argument value for ${flag}`); - } - return value; -} - -function okResult(): ProcessRunResult { - return { - stdout: "", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }; -} - -function commitInput() { - return { - cwd: process.cwd(), - branch: "feat/example", - stagedSummary: "M apps/server/src/gitManager.ts", - stagedPatch: "diff --git a/file b/file", - }; -} - -describe("CodexTextGenerator", () => { - it("uses gpt-5.3-codex-spark when available with medium reasoning effort", async () => { - const models: string[] = []; - const configs: string[] = []; - - const runner: ProcessRunner = async (command, args) => { - expect(command).toBe("codex"); - models.push(getArgValue(args, "--model")); - configs.push(getArgValue(args, "--config")); - - const outputPath = getArgValue(args, "--output-last-message"); - await fs.writeFile( - outputPath, - JSON.stringify({ - subject: "Add stacked git actions menu behavior", - body: "- Keep menu actions visible\n- Improve disabled states", - }), - "utf8", - ); - return okResult(); - }; - - const generator = new CodexTextGenerator({ runProcess: runner }); - const result = await generator.generateCommitMessage(commitInput()); - - expect(result.subject).toBe("Add stacked git actions menu behavior"); - expect(models).toEqual(["gpt-5.3-codex-spark"]); - expect(configs).toEqual(['model_reasoning_effort="medium"']); - }); - - it("uses gpt-5.3-codex-spark for PR content generation", async () => { - const models: string[] = []; - - const runner: ProcessRunner = async (command, args) => { - expect(command).toBe("codex"); - models.push(getArgValue(args, "--model")); - - const outputPath = getArgValue(args, "--output-last-message"); - await fs.writeFile( - outputPath, - JSON.stringify({ - title: "Improve Git action modal behavior", - body: "## Summary\n- Update PR generation model\n\n## Testing\n- Not run", - }), - "utf8", - ); - return okResult(); - }; - - const generator = new CodexTextGenerator({ runProcess: runner }); - const result = await generator.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feat/example", - commitSummary: "abc123 Update model", - diffSummary: "1 file changed", - diffPatch: "diff --git a/file b/file", - }); - - expect(result.title).toBe("Improve Git action modal behavior"); - expect(models).toEqual(["gpt-5.3-codex-spark"]); - }); - - it("propagates generation failures without retrying a second model", async () => { - const models: string[] = []; - - const runner: ProcessRunner = async (_command, args) => { - models.push(getArgValue(args, "--model")); - throw new Error("Request timed out while contacting Codex."); - }; - - const generator = new CodexTextGenerator({ runProcess: runner }); - - await expect(generator.generateCommitMessage(commitInput())).rejects.toThrow( - "Request timed out while contacting Codex.", - ); - expect(models).toEqual(["gpt-5.3-codex-spark"]); - }); -}); diff --git a/apps/server/src/codexTextGenerator.ts b/apps/server/src/codexTextGenerator.ts index 5797c3fb4034..51166b380fa0 100644 --- a/apps/server/src/codexTextGenerator.ts +++ b/apps/server/src/codexTextGenerator.ts @@ -10,11 +10,7 @@ import type { PrContentGenerationResult, TextGenerationService, } from "./coreServices"; -import { - type ProcessRunOptions, - type ProcessRunResult, - runProcess, -} from "./processRunner"; +import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; type ProcessRunner = ( command: string, @@ -22,8 +18,8 @@ type ProcessRunner = ( options?: ProcessRunOptions, ) => Promise; -const CODEX_MODEL = "gpt-5.3-codex-spark"; -const CODEX_REASONING_EFFORT = "medium"; +const CODEX_MODEL = "gpt-5.3-codex"; +const CODEX_REASONING_EFFORT = "low"; const COMMIT_OUTPUT_SCHEMA_JSON = { type: "object", @@ -78,10 +74,7 @@ function limitSection(value: string, maxChars: number): string { } async function writeTempFile(prefix: string, content: string): Promise { - const filePath = path.join( - os.tmpdir(), - `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`, - ); + const filePath = path.join(os.tmpdir(), `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); await fs.writeFile(filePath, content, "utf8"); return filePath; } @@ -128,10 +121,7 @@ async function runCodexJson({ parse: (raw: unknown) => T; run: ProcessRunner; }): Promise { - const schemaPath = await writeTempFile( - "codex-schema", - JSON.stringify(outputSchemaJson), - ); + const schemaPath = await writeTempFile("codex-schema", JSON.stringify(outputSchemaJson)); let outputPath: string | null = null; try { @@ -174,10 +164,7 @@ async function runCodexJson({ return parse(parsedJson); } finally { - await Promise.all([ - safeUnlink(schemaPath), - ...(outputPath ? [safeUnlink(outputPath)] : []), - ]); + await Promise.all([safeUnlink(schemaPath), ...(outputPath ? [safeUnlink(outputPath)] : [])]); } } @@ -226,9 +213,7 @@ export class CodexTextGenerator implements TextGenerationService { }; } - async generatePrContent( - input: PrContentGenerationInput, - ): Promise { + async generatePrContent(input: PrContentGenerationInput): Promise { const prompt = [ "You write GitHub pull request content.", "Return a JSON object with keys: title, body.", diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index fedebfb6ab68..311a932a139c 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -8,6 +8,12 @@ import { useRef, useState, } from "react"; +import { + extractTerminalLinks, + isTerminalLinkActivation, + preferredTerminalEditor, + resolvePathLinkTarget, +} from "../terminal-links"; import { isTerminalClearShortcut } from "../terminal-shortcuts"; import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types"; @@ -298,6 +304,67 @@ export default function ThreadTerminalDrawer({ return false; }); + const terminalLinksDisposable = terminal.registerLinkProvider({ + provideLinks: (bufferLineNumber, callback) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) { + callback(undefined); + return; + } + + const line = activeTerminal.buffer.active.getLine(bufferLineNumber - 1); + if (!line) { + callback(undefined); + return; + } + + const lineText = line.translateToString(true); + const matches = extractTerminalLinks(lineText); + if (matches.length === 0) { + callback(undefined); + return; + } + + callback( + matches.map((match) => ({ + text: match.text, + range: { + start: { x: match.start + 1, y: bufferLineNumber }, + end: { x: match.end, y: bufferLineNumber }, + }, + activate: (event: MouseEvent) => { + if (!isTerminalLinkActivation(event)) { + return; + } + + const latestTerminal = terminalRef.current; + if (!latestTerminal) { + return; + } + + if (match.kind === "url") { + void api.shell.openExternal(match.text).catch((error) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open link", + ); + }); + return; + } + + const target = resolvePathLinkTarget(match.text, cwd); + void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open path", + ); + }); + }, + })), + ); + }, + }); + const inputDisposable = terminal.onData((data) => { void api.terminal .write({ threadId, data }) @@ -393,6 +460,7 @@ export default function ThreadTerminalDrawer({ window.clearTimeout(fitTimer); unsubscribe(); inputDisposable.dispose(); + terminalLinksDisposable.dispose(); themeObserver.disconnect(); terminalRef.current = null; fitAddonRef.current = null; diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts new file mode 100644 index 000000000000..4764fa3d977c --- /dev/null +++ b/apps/web/src/terminal-links.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { + extractTerminalLinks, + isTerminalLinkActivation, + resolvePathLinkTarget, +} from "./terminal-links"; + +describe("extractTerminalLinks", () => { + it("finds http urls and path tokens", () => { + const line = + "failed at https://example.com/docs and src/components/ThreadTerminalDrawer.tsx:42"; + expect(extractTerminalLinks(line)).toEqual([ + { + kind: "url", + text: "https://example.com/docs", + start: 10, + end: 34, + }, + { + kind: "path", + text: "src/components/ThreadTerminalDrawer.tsx:42", + start: 39, + end: 81, + }, + ]); + }); + + it("trims trailing punctuation from links", () => { + const line = "(https://example.com/docs), ./src/main.ts:12."; + expect(extractTerminalLinks(line)).toEqual([ + { + kind: "url", + text: "https://example.com/docs", + start: 1, + end: 25, + }, + { + kind: "path", + text: "./src/main.ts:12", + start: 28, + end: 44, + }, + ]); + }); +}); + +describe("resolvePathLinkTarget", () => { + it("resolves relative paths against cwd", () => { + expect( + resolvePathLinkTarget( + "src/components/ThreadTerminalDrawer.tsx:42:7", + "/Users/julius/project", + ), + ).toBe("/Users/julius/project/src/components/ThreadTerminalDrawer.tsx:42:7"); + }); + + it("keeps absolute paths unchanged", () => { + expect( + resolvePathLinkTarget( + "/Users/julius/project/src/main.ts:12", + "/Users/julius/project", + ), + ).toBe("/Users/julius/project/src/main.ts:12"); + }); +}); + +describe("isTerminalLinkActivation", () => { + it("requires cmd on macOS", () => { + expect( + isTerminalLinkActivation( + { + metaKey: true, + ctrlKey: false, + }, + "MacIntel", + ), + ).toBe(true); + expect( + isTerminalLinkActivation( + { + metaKey: false, + ctrlKey: true, + }, + "MacIntel", + ), + ).toBe(false); + }); + + it("requires ctrl on non-macOS", () => { + expect( + isTerminalLinkActivation( + { + metaKey: false, + ctrlKey: true, + }, + "Win32", + ), + ).toBe(true); + expect( + isTerminalLinkActivation( + { + metaKey: true, + ctrlKey: false, + }, + "Linux", + ), + ).toBe(false); + }); +}); diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts new file mode 100644 index 000000000000..f46cc6563bf9 --- /dev/null +++ b/apps/web/src/terminal-links.ts @@ -0,0 +1,200 @@ +import { EDITORS, type EditorId } from "@t3tools/contracts"; + +export type TerminalLinkKind = "url" | "path"; + +export interface TerminalLinkMatch { + kind: TerminalLinkKind; + text: string; + start: number; + end: number; +} + +const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; +const FILE_PATH_PATTERN = + /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:\\|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; +const LAST_EDITOR_KEY = "t3code:last-editor"; + +function isMacPlatform(platform: string): boolean { + return /mac|iphone|ipad|ipod/i.test(platform); +} + +function trimClosingDelimiters(value: string): string { + let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); + if (output.length === 0) return output; + + const trimUnbalanced = (open: string, close: string) => { + while (output.endsWith(close)) { + const opens = output.split(open).length - 1; + const closes = output.split(close).length - 1; + if (opens >= closes) return; + output = output.slice(0, -1); + } + }; + + trimUnbalanced("(", ")"); + trimUnbalanced("[", "]"); + trimUnbalanced("{", "}"); + return output; +} + +function overlaps(a: { start: number; end: number }, b: { start: number; end: number }): boolean { + return a.start < b.end && b.start < a.end; +} + +function collectMatches( + line: string, + kind: TerminalLinkKind, + pattern: RegExp, + existing: TerminalLinkMatch[], +): TerminalLinkMatch[] { + const matches: TerminalLinkMatch[] = []; + pattern.lastIndex = 0; + + for (const rawMatch of line.matchAll(pattern)) { + const raw = rawMatch[0]; + const start = rawMatch.index ?? -1; + if (start < 0 || raw.length === 0) continue; + + const trimmed = trimClosingDelimiters(raw); + if (trimmed.length === 0) continue; + if (kind === "path" && /^https?:\/\//i.test(trimmed)) continue; + + const candidate: TerminalLinkMatch = { + kind, + text: trimmed, + start, + end: start + trimmed.length, + }; + + const collides = [...existing, ...matches].some((other) => overlaps(candidate, other)); + if (collides) continue; + + matches.push(candidate); + } + + return matches; +} + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); +} + +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || isWindowsAbsolutePath(value); +} + +function isWindowsPathStyle(value: string): boolean { + return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); +} + +function joinPath(base: string, next: string, separator: "/" | "\\"): string { + const cleanBase = base.replace(/[\\/]+$/, ""); + if (separator === "\\") { + return `${cleanBase}\\${next.replaceAll("/", "\\")}`; + } + return `${cleanBase}/${next.replace(/^\/+/, "")}`; +} + +function inferHomeFromCwd(cwd: string): string | undefined { + const posixUser = cwd.match(/^\/Users\/([^/]+)/); + if (posixUser?.[1]) { + return `/Users/${posixUser[1]}`; + } + + const posixHome = cwd.match(/^\/home\/([^/]+)/); + if (posixHome?.[1]) { + return `/home/${posixHome[1]}`; + } + + const windowsUser = cwd.match(/^([A-Za-z]:\\Users\\[^\\]+)/); + if (windowsUser?.[1]) { + return windowsUser[1]; + } + + return undefined; +} + +function splitPathAndPosition(value: string): { + path: string; + line: string | undefined; + column: string | undefined; +} { + let path = value; + let column: string | undefined; + let line: string | undefined; + + const columnMatch = path.match(/:(\d+)$/); + if (!columnMatch?.[1]) { + return { path, line: undefined, column: undefined }; + } + + column = columnMatch[1]; + path = path.slice(0, -columnMatch[0].length); + + const lineMatch = path.match(/:(\d+)$/); + if (lineMatch?.[1]) { + line = lineMatch[1]; + path = path.slice(0, -lineMatch[0].length); + } else { + line = column; + column = undefined; + } + + return { path, line, column }; +} + +export function extractTerminalLinks(line: string): TerminalLinkMatch[] { + const urlMatches = collectMatches(line, "url", URL_PATTERN, []); + const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); + return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start); +} + +export function isTerminalLinkActivation( + event: Pick, + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): boolean { + if (platform.length === 0) return false; + return isMacPlatform(platform) + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +export function resolvePathLinkTarget(rawPath: string, cwd: string): string { + const { path, line, column } = splitPathAndPosition(rawPath); + + let resolvedPath = path; + if (path.startsWith("~/")) { + const home = inferHomeFromCwd(cwd); + if (home) { + const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/"; + resolvedPath = joinPath(home, path.slice(2), separator); + } + } else if (!isAbsolutePath(path)) { + const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; + resolvedPath = joinPath(cwd, path, separator); + } + + if (!line) return resolvedPath; + return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; +} + +export function preferredTerminalEditor(): EditorId { + const fallback = EDITORS.find((editor) => editor.command)?.id ?? EDITORS[0]?.id ?? "cursor"; + + if (typeof window === "undefined") { + return fallback; + } + + const storedEditor = window.localStorage.getItem(LAST_EDITOR_KEY); + if (!storedEditor) { + return fallback; + } + + const configured = EDITORS.find((editor) => editor.id === storedEditor); + if (!configured?.command) { + return fallback; + } + + return configured.id; +}