diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts index 68afba6751b6..54967b668968 100644 --- a/packages/tui/src/editor.ts +++ b/packages/tui/src/editor.ts @@ -53,6 +53,46 @@ export async function openEditor(input: { value: string; renderer: CliRenderer; } } +const GOTO_CAPABLE_BINARIES = new Set(["code", "code-insiders", "cursor", "codium", "windsurf"]) + +/** + * Opens a file (optionally at a specific line/column) using the user's + * configured editor (`$VISUAL`/`$EDITOR`, falling back to `code`). + * + * This spawns the editor detached and does not wait for it to exit, so it is + * safe to call from a click handler without blocking or suspending the TUI + * renderer (unlike `openEditor`, which is used for the blocking "compose in + * external editor" flow). + */ +export function openFileAtLocation(input: { filePath: string; line?: number; column?: number; cwd?: string }) { + const editorEnv = process.env.VISUAL || process.env.EDITOR + const parts = editorEnv ? editorEnv.trim().split(/\s+/) : ["code"] + const bin = parts[0]! + const baseArgs = parts.slice(1) + const binName = path.basename(bin).replace(/\.(cmd|exe)$/i, "") + + const useGoto = input.line !== undefined && GOTO_CAPABLE_BINARIES.has(binName) + const target = useGoto + ? `${input.filePath}:${input.line}${input.column ? `:${input.column}` : ""}` + : input.filePath + + const args = useGoto ? [...baseArgs, "--goto", target] : [...baseArgs, target] + + try { + const child = spawn(bin, args, { + cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(), + stdio: "ignore", + detached: true, + shell: process.platform === "win32", + }) + child.on("error", () => {}) + child.unref() + return true + } catch { + return false + } +} + export function discoverEditorConnection(directory: string) { const root = path.join(os.homedir(), ".claude", "ide") const contains = (parent: string) => { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6d77b0ea58fd..0f1676897af2 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -43,7 +43,7 @@ import { webSearchProviderLabel } from "../../util/tool-display" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "../../context/sdk" import { useEditorContext } from "../../context/editor" -import { openEditor } from "../../editor" +import { openEditor, openFileAtLocation } from "../../editor" import { useDialog } from "../../ui/dialog" import { DialogAlert } from "../../ui/dialog-alert" import { TodoItem } from "../../component/todo-item" @@ -2134,8 +2134,21 @@ function Write(props: ToolProps) { function Glob(props: ToolProps) { const pathFormatter = usePathFormatter() + const paths = useTuiPaths() + const targetPath = createMemo(() => stringValue(props.input.path)) return ( - + { + const target = targetPath() + if (!target) return + const filePath = path.isAbsolute(target) ? target : path.resolve(paths.cwd, target) + openFileAtLocation({ filePath, cwd: paths.cwd }) + }} + > Glob "{stringValue(props.input.pattern)}"{" "} in {pathFormatter.format(stringValue(props.input.path))} @@ -2148,6 +2161,7 @@ function Glob(props: ToolProps) { function Read(props: ToolProps) { const { theme } = useTheme() const pathFormatter = usePathFormatter() + const paths = useTuiPaths() const isRunning = createMemo(() => props.part.state.status === "running") const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] @@ -2164,13 +2178,27 @@ function Read(props: ToolProps) { complete={stringValue(props.input.filePath)} spinner={isRunning()} part={props.part} + onClick={() => { + const target = stringValue(props.input.filePath) + if (!target) return + const filePath = path.isAbsolute(target) ? target : path.resolve(paths.cwd, target) + const line = numberValue(props.input.offset) + openFileAtLocation({ filePath, line, cwd: paths.cwd }) + }} > Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])} {(filepath) => ( - + { + const filePath = path.isAbsolute(filepath) ? filepath : path.resolve(paths.cwd, filepath) + openFileAtLocation({ filePath, cwd: paths.cwd }) + }} + > ↳ Loaded {pathFormatter.format(filepath)} @@ -2182,8 +2210,21 @@ function Read(props: ToolProps) { function Grep(props: ToolProps) { const pathFormatter = usePathFormatter() + const paths = useTuiPaths() + const targetPath = createMemo(() => stringValue(props.input.path)) return ( - + { + const target = targetPath() + if (!target) return + const filePath = path.isAbsolute(target) ? target : path.resolve(paths.cwd, target) + openFileAtLocation({ filePath, cwd: paths.cwd }) + }} + > Grep "{stringValue(props.input.pattern)}"{" "} in {pathFormatter.format(stringValue(props.input.path))}