From dd8ab8dbcbd14160078419fbf39053742a62be0c Mon Sep 17 00:00:00 2001 From: albertofrzara <> Date: Fri, 24 Jul 2026 09:49:26 +0200 Subject: [PATCH] feat(tui): make file paths in read/grep/glob tool output clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the summary line of the Read, Grep, and Glob tool blocks now opens the referenced file (or search root) in the user's configured editor ($VISUAL/$EDITOR, falling back to `code`). - Adds `openFileAtLocation` in packages/tui/src/editor.ts: spawns the editor detached (non-blocking) and appends `--goto file:line:col` for VS Code/Cursor/Codium/Windsurf when a line number is known, otherwise passes the plain path so directories (grep/glob search roots) open correctly too. - Wires `onClick` on the existing InlineTool for Read, Grep, and Glob, reusing the same hover/click affordance already used by the Task tool block. - Read's individual "Loaded " lines (shown for subagent/session compaction) are also made clickable. This does not change TUI mouse capture behavior (mouse: true stays the default) — the click is now handled natively by the TUI's own OpenTUI-based mouse routing, so terminal-level Cmd/Shift-click file detection (which cannot see clicks while opencode holds the mouse capture) is no longer needed for this specific use case. Related: #37891 --- packages/tui/src/editor.ts | 40 ++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 49 +++++++++++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) 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))}