Format TUI paths relative to session directory (#26648)

This commit is contained in:
Dax
2026-05-10 04:29:02 +00:00
committed by GitHub
parent fb4bab8a66
commit 3753601f87
3 changed files with 83 additions and 80 deletions
@@ -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
}
@@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt" import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit" import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Global } from "@opencode-ai/core/global"
import { PermissionPrompt } from "./permission" import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question" import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportOptions } from "../../ui/dialog-export-options"
@@ -90,6 +89,7 @@ import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff" import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette" import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap" import { useBindings, useCommandShortcut } from "../../keymap"
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -1078,23 +1078,24 @@ export function Session() {
createEffect(on(() => route.sessionID, toBottom)) createEffect(on(() => route.sessionID, toBottom))
return ( return (
<context.Provider <PathFormatterProvider path={session()?.directory}>
value={{ <context.Provider
get width() { value={{
return contentWidth() get width() {
}, return contentWidth()
sessionID: route.sessionID, },
conceal, sessionID: route.sessionID,
showThinking, conceal,
showTimestamps, showThinking,
showDetails, showTimestamps,
showGenericToolOutput, showDetails,
diffWrapMode, showGenericToolOutput,
providers, diffWrapMode,
sync, providers,
tui: tuiConfig, sync,
}} tui: tuiConfig,
> }}
>
<box flexDirection="row" flexGrow={1} minHeight={0}> <box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}> <box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}> <Show when={session()}>
@@ -1270,7 +1271,8 @@ export function Session() {
</Switch> </Switch>
</Show> </Show>
</box> </box>
</context.Provider> </context.Provider>
</PathFormatterProvider>
) )
} }
@@ -1827,7 +1829,7 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) { function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme() const { theme } = useTheme()
const sync = useSync() const pathFormatter = usePathFormatter()
const isRunning = createMemo(() => props.part.state.status === "running") const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false) const [expanded, setExpanded] = createSignal(false)
@@ -1841,18 +1843,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
const workdirDisplay = createMemo(() => { const workdirDisplay = createMemo(() => {
const workdir = props.input.workdir const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined if (!workdir || workdir === ".") return undefined
return pathFormatter.format(workdir)
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
}) })
const title = createMemo(() => { const title = createMemo(() => {
@@ -1894,6 +1885,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
function Write(props: ToolProps<typeof WriteTool>) { function Write(props: ToolProps<typeof WriteTool>) {
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const code = createMemo(() => { const code = createMemo(() => {
if (!props.input.content) return "" if (!props.input.content) return ""
return props.input.content return props.input.content
@@ -1902,7 +1894,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
return ( return (
<Switch> <Switch>
<Match when={props.metadata.diagnostics !== undefined}> <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}> <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code <code
conceal={false} conceal={false}
@@ -1917,7 +1909,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
</Match> </Match>
<Match when={true}> <Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}> <InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
Write {normalizePath(props.input.filePath!)} Write {pathFormatter.format(props.input.filePath)}
</InlineTool> </InlineTool>
</Match> </Match>
</Switch> </Switch>
@@ -1925,9 +1917,10 @@ function Write(props: ToolProps<typeof WriteTool>) {
} }
function Glob(props: ToolProps<typeof GlobTool>) { function Glob(props: ToolProps<typeof GlobTool>) {
const pathFormatter = usePathFormatter()
return ( return (
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}> <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}> <Show when={props.metadata.count}>
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
</Show> </Show>
@@ -1937,6 +1930,7 @@ function Glob(props: ToolProps<typeof GlobTool>) {
function Read(props: ToolProps<typeof ReadTool>) { function Read(props: ToolProps<typeof ReadTool>) {
const { theme } = useTheme() const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const isRunning = createMemo(() => props.part.state.status === "running") const isRunning = createMemo(() => props.part.state.status === "running")
const loaded = createMemo(() => { const loaded = createMemo(() => {
if (props.part.state.status !== "completed") return [] if (props.part.state.status !== "completed") return []
@@ -1954,13 +1948,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
spinner={isRunning()} spinner={isRunning()}
part={props.part} part={props.part}
> >
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
</InlineTool> </InlineTool>
<For each={loaded()}> <For each={loaded()}>
{(filepath) => ( {(filepath) => (
<box paddingLeft={3}> <box paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}> <text paddingLeft={3} fg={theme.textMuted}>
Loaded {normalizePath(filepath)} Loaded {pathFormatter.format(filepath)}
</text> </text>
</box> </box>
)} )}
@@ -1970,9 +1964,10 @@ function Read(props: ToolProps<typeof ReadTool>) {
} }
function Grep(props: ToolProps<typeof GrepTool>) { function Grep(props: ToolProps<typeof GrepTool>) {
const pathFormatter = usePathFormatter()
return ( return (
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}> <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}> <Show when={props.metadata.matches}>
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"}) ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
</Show> </Show>
@@ -2071,6 +2066,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
function Edit(props: ToolProps<typeof EditTool>) { function Edit(props: ToolProps<typeof EditTool>) {
const ctx = use() const ctx = use()
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const view = createMemo(() => { const view = createMemo(() => {
const diffStyle = ctx.tui.diff_style const diffStyle = ctx.tui.diff_style
@@ -2086,7 +2082,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
return ( return (
<Switch> <Switch>
<Match when={props.metadata.diff !== undefined}> <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}> <box paddingLeft={1}>
<diff <diff
diff={diffContent()} diff={diffContent()}
@@ -2113,7 +2109,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match> </Match>
<Match when={true}> <Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}> <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> </InlineTool>
</Match> </Match>
</Switch> </Switch>
@@ -2123,6 +2119,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) { function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const ctx = use() const ctx = use()
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const files = createMemo(() => props.metadata.files ?? []) 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 }) { function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
if (file.type === "delete") return "# Deleted " + file.relativePath if (file.type === "delete") return "# Deleted " + file.relativePath
if (file.type === "add") return "# Created " + 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 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 { function input(input: Record<string, any>, omit?: string[]): string {
const primitives = Object.entries(input).filter(([key, value]) => { const primitives = Object.entries(input).filter(([key, value]) => {
if (omit?.includes(key)) return false if (omit?.includes(key)) return false
@@ -11,34 +11,16 @@ import { useProject } from "../../context/project"
import path from "path" import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global"
import { ShellID } from "@/tool/shell/id" import { ShellID } from "@/tool/shell/id"
import { webSearchProviderLabel } from "@/tool/websearch" import { webSearchProviderLabel } from "@/tool/websearch"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useCommandShortcut } from "../../keymap" import { useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject" 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) { function filetype(input?: string) {
if (!input) return "none" if (!input) return "none"
const ext = path.extname(input) const ext = path.extname(input)
@@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
stage: "permission" as PermissionStage, stage: "permission" as PermissionStage,
}) })
const pathFormatter = usePathFormatter()
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) 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 : "" const filepath = typeof raw === "string" ? raw : ""
return { return {
icon: "→", icon: "→",
title: `Edit ${normalizePath(filepath)}`, title: `Edit ${pathFormatter.format(filepath)}`,
body: <EditBody request={props.request} />, body: <EditBody request={props.request} />,
} }
} }
@@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const filePath = typeof raw === "string" ? raw : "" const filePath = typeof raw === "string" ? raw : ""
return { return {
icon: "→", icon: "→",
title: `Read ${normalizePath(filePath)}`, title: `Read ${pathFormatter.format(filePath)}`,
body: ( body: (
<Show when={filePath}> <Show when={filePath}>
<box paddingLeft={1}> <box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text> <text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
</box> </box>
</Show> </Show>
), ),
@@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const dir = typeof raw === "string" ? raw : "" const dir = typeof raw === "string" ? raw : ""
return { return {
icon: "→", icon: "→",
title: `List ${normalizePath(dir)}`, title: `List ${pathFormatter.format(dir)}`,
body: ( body: (
<Show when={dir}> <Show when={dir}>
<box paddingLeft={1}> <box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text> <text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
</box> </box>
</Show> </Show>
), ),
@@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived 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") const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
return { return {