Format TUI paths relative to session directory (#26648)

This commit is contained in:
Dax
2026-05-10 00:29:02 -04:00
committed by GitHub
parent fb4bab8a66
commit 3753601f87
3 changed files with 83 additions and 80 deletions

View File

@@ -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 (
<context.Provider value={{ path: () => props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}>
{props.children}
</context.Provider>
)
}
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
}

View File

@@ -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 (
<context.Provider
value={{
get width() {
return contentWidth()
},
sessionID: route.sessionID,
conceal,
showThinking,
showTimestamps,
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
tui: tuiConfig,
}}
>
<PathFormatterProvider path={session()?.directory}>
<context.Provider
value={{
get width() {
return contentWidth()
},
sessionID: route.sessionID,
conceal,
showThinking,
showTimestamps,
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
tui: tuiConfig,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
@@ -1270,7 +1271,8 @@ export function Session() {
</Switch>
</Show>
</box>
</context.Provider>
</context.Provider>
</PathFormatterProvider>
)
}
@@ -1827,7 +1829,7 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) {
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<typeof ShellTool>) {
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<typeof ShellTool>) {
function Write(props: ToolProps<typeof WriteTool>) {
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<typeof WriteTool>) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
@@ -1917,7 +1909,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
Write {normalizePath(props.input.filePath!)}
Write {pathFormatter.format(props.input.filePath)}
</InlineTool>
</Match>
</Switch>
@@ -1925,9 +1917,10 @@ function Write(props: ToolProps<typeof WriteTool>) {
}
function Glob(props: ToolProps<typeof GlobTool>) {
const pathFormatter = usePathFormatter()
return (
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
<Show when={props.metadata.count}>
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
</Show>
@@ -1937,6 +1930,7 @@ function Glob(props: ToolProps<typeof GlobTool>) {
function Read(props: ToolProps<typeof ReadTool>) {
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<typeof ReadTool>) {
spinner={isRunning()}
part={props.part}
>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
</InlineTool>
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}>
Loaded {normalizePath(filepath)}
Loaded {pathFormatter.format(filepath)}
</text>
</box>
)}
@@ -1970,9 +1964,10 @@ function Read(props: ToolProps<typeof ReadTool>) {
}
function Grep(props: ToolProps<typeof GrepTool>) {
const pathFormatter = usePathFormatter()
return (
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
<Show when={props.metadata.matches}>
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
</Show>
@@ -2071,6 +2066,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
function Edit(props: ToolProps<typeof EditTool>) {
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<typeof EditTool>) {
return (
<Switch>
<Match when={props.metadata.diff !== undefined}>
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
<box paddingLeft={1}>
<diff
diff={diffContent()}
@@ -2113,7 +2109,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
</InlineTool>
</Match>
</Switch>
@@ -2123,6 +2119,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const ctx = use()
const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const files = createMemo(() => props.metadata.files ?? [])
@@ -2161,7 +2158,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
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<string, Record<string, any>[]
)
}
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<string, any>, omit?: string[]): string {
const primitives = Object.entries(input).filter(([key, value]) => {
if (omit?.includes(key)) return false

View File

@@ -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: <EditBody request={props.request} />,
}
}
@@ -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: (
<Show when={filePath}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
</box>
</Show>
),
@@ -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: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
</box>
</Show>
),
@@ -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 {