From 3753601f87559ab0d6e6ad75e63f8f765cffd5eb Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 10 May 2026 00:29:02 -0400 Subject: [PATCH] Format TUI paths relative to session directory (#26648) --- .../src/cli/cmd/tui/context/path-format.tsx | 37 ++++++++ .../src/cli/cmd/tui/routes/session/index.tsx | 93 ++++++++----------- .../cli/cmd/tui/routes/session/permission.tsx | 33 ++----- 3 files changed, 83 insertions(+), 80 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/path-format.tsx diff --git a/packages/opencode/src/cli/cmd/tui/context/path-format.tsx b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx new file mode 100644 index 0000000000..eb9293cbc9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx @@ -0,0 +1,37 @@ +import path from "path" +import { createContext, useContext, type ParentProps } from "solid-js" +import { Global } from "@opencode-ai/core/global" + +const context = createContext<{ + path: () => string + format: (input?: string) => string +}>() + +export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { + return ( + props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}> + {props.children} + + ) +} + +export function usePathFormatter() { + const value = useContext(context) + if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider") + return value +} + +function formatPath(input: string | undefined, base: string | undefined) { + if (!input) return "" + + const root = base || process.cwd() + const absolute = path.isAbsolute(input) ? input : path.resolve(root, input) + const relative = path.relative(root, absolute) + + if (!relative) return "." + if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative + if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) { + return absolute.replace(Global.Path.home, "~") + } + return absolute +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 508ba49416..b0fd9ed219 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" -import { Global } from "@opencode-ai/core/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" @@ -90,6 +89,7 @@ import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import { useCommandPalette } from "../../context/command-palette" import { useBindings, useCommandShortcut } from "../../keymap" +import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" addDefaultParsers(parsers.parsers) @@ -1078,23 +1078,24 @@ export function Session() { createEffect(on(() => route.sessionID, toBottom)) return ( - + + @@ -1270,7 +1271,8 @@ export function Session() { - + + ) } @@ -1827,7 +1829,7 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() - const sync = useSync() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) @@ -1841,18 +1843,7 @@ function Shell(props: ToolProps) { const workdirDisplay = createMemo(() => { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined - - const base = sync.path.directory - if (!base) return undefined - - const absolute = path.resolve(base, workdir) - if (absolute === base) return undefined - - const home = Global.Path.home - if (!home) return absolute - - const match = absolute === home || absolute.startsWith(home + path.sep) - return match ? absolute.replace(home, "~") : absolute + return pathFormatter.format(workdir) }) const title = createMemo(() => { @@ -1894,6 +1885,7 @@ function Shell(props: ToolProps) { function Write(props: ToolProps) { const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const code = createMemo(() => { if (!props.input.content) return "" return props.input.content @@ -1902,7 +1894,7 @@ function Write(props: ToolProps) { return ( - + ) { - Write {normalizePath(props.input.filePath!)} + Write {pathFormatter.format(props.input.filePath)} @@ -1925,9 +1917,10 @@ function Write(props: ToolProps) { } function Glob(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} + Glob "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) @@ -1937,6 +1930,7 @@ function Glob(props: ToolProps) { function Read(props: ToolProps) { const { theme } = useTheme() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] @@ -1954,13 +1948,13 @@ function Read(props: ToolProps) { spinner={isRunning()} part={props.part} > - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])} {(filepath) => ( - ↳ Loaded {normalizePath(filepath)} + ↳ Loaded {pathFormatter.format(filepath)} )} @@ -1970,9 +1964,10 @@ function Read(props: ToolProps) { } function Grep(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} + Grep "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"}) @@ -2071,6 +2066,7 @@ function Task(props: ToolProps) { function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const view = createMemo(() => { const diffStyle = ctx.tui.diff_style @@ -2086,7 +2082,7 @@ function Edit(props: ToolProps) { return ( - + ) { - Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} + Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })} @@ -2123,6 +2119,7 @@ function Edit(props: ToolProps) { function ApplyPatch(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const files = createMemo(() => props.metadata.files ?? []) @@ -2161,7 +2158,7 @@ function ApplyPatch(props: ToolProps) { function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { if (file.type === "delete") return "# Deleted " + file.relativePath if (file.type === "add") return "# Created " + file.relativePath - if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath + if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath return "← Patched " + file.relativePath } @@ -2281,20 +2278,6 @@ function Diagnostics(props: { diagnostics?: Record[] ) } -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use absolute - return absolute -} - function input(input: Record, omit?: string[]): string { const primitives = Object.entries(input).filter(([key, value]) => { if (omit?.includes(key)) return false diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 0ccc3d7262..5b40c3c318 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -11,34 +11,16 @@ import { useProject } from "../../context/project" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" -import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" import { webSearchProviderLabel } from "@/tool/websearch" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { useBindings, useCommandShortcut } from "../../keymap" +import { usePathFormatter } from "../../context/path-format" type PermissionStage = "permission" | "always" | "reject" -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const home = Global.Path.home - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use ~ or absolute - if (home && (absolute === home || absolute.startsWith(home + path.sep))) { - return absolute.replace(home, "~") - } - return absolute -} - function filetype(input?: string) { if (!input) return "none" const ext = path.extname(input) @@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) + const pathFormatter = usePathFormatter() const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) @@ -220,7 +203,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filepath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Edit ${normalizePath(filepath)}`, + title: `Edit ${pathFormatter.format(filepath)}`, body: , } } @@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filePath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Read ${normalizePath(filePath)}`, + title: `Read ${pathFormatter.format(filePath)}`, body: ( - {"Path: " + normalizePath(filePath)} + {"Path: " + pathFormatter.format(filePath)} ), @@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const dir = typeof raw === "string" ? raw : "" return { icon: "→", - title: `List ${normalizePath(dir)}`, + title: `List ${pathFormatter.format(dir)}`, body: ( - {"Path: " + normalizePath(dir)} + {"Path: " + pathFormatter.format(dir)} ), @@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined const raw = parent ?? filepath ?? derived - const dir = normalizePath(raw) + const dir = pathFormatter.format(raw) const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") return {