Skip to content

Commit 9dd47e1

Browse files
committed
Format TUI paths relative to session directory
1 parent fb4bab8 commit 9dd47e1

3 files changed

Lines changed: 83 additions & 80 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import path from "path"
2+
import { createContext, useContext, type ParentProps } from "solid-js"
3+
import { Global } from "@opencode-ai/core/global"
4+
5+
const context = createContext<{
6+
path: () => string
7+
format: (input?: string) => string
8+
}>()
9+
10+
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
11+
return (
12+
<context.Provider value={{ path: () => props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}>
13+
{props.children}
14+
</context.Provider>
15+
)
16+
}
17+
18+
export function usePathFormatter() {
19+
const value = useContext(context)
20+
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider")
21+
return value
22+
}
23+
24+
function formatPath(input: string | undefined, base: string | undefined) {
25+
if (!input) return ""
26+
27+
const root = base || process.cwd()
28+
const absolute = path.isAbsolute(input) ? input : path.resolve(root, input)
29+
const relative = path.relative(root, absolute)
30+
31+
if (!relative) return "."
32+
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
33+
if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) {
34+
return absolute.replace(Global.Path.home, "~")
35+
}
36+
return absolute
37+
}

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 38 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi"
7575
import { usePromptRef } from "../../context/prompt"
7676
import { useExit } from "../../context/exit"
7777
import { Filesystem } from "@/util/filesystem"
78-
import { Global } from "@opencode-ai/core/global"
7978
import { PermissionPrompt } from "./permission"
8079
import { QuestionPrompt } from "./question"
8180
import { DialogExportOptions } from "../../ui/dialog-export-options"
@@ -90,6 +89,7 @@ import { SessionRetry } from "@/session/retry"
9089
import { getRevertDiffFiles } from "../../util/revert-diff"
9190
import { useCommandPalette } from "../../context/command-palette"
9291
import { useBindings, useCommandShortcut } from "../../keymap"
92+
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
9393

9494
addDefaultParsers(parsers.parsers)
9595

@@ -1078,23 +1078,24 @@ export function Session() {
10781078
createEffect(on(() => route.sessionID, toBottom))
10791079

10801080
return (
1081-
<context.Provider
1082-
value={{
1083-
get width() {
1084-
return contentWidth()
1085-
},
1086-
sessionID: route.sessionID,
1087-
conceal,
1088-
showThinking,
1089-
showTimestamps,
1090-
showDetails,
1091-
showGenericToolOutput,
1092-
diffWrapMode,
1093-
providers,
1094-
sync,
1095-
tui: tuiConfig,
1096-
}}
1097-
>
1081+
<PathFormatterProvider path={session()?.directory}>
1082+
<context.Provider
1083+
value={{
1084+
get width() {
1085+
return contentWidth()
1086+
},
1087+
sessionID: route.sessionID,
1088+
conceal,
1089+
showThinking,
1090+
showTimestamps,
1091+
showDetails,
1092+
showGenericToolOutput,
1093+
diffWrapMode,
1094+
providers,
1095+
sync,
1096+
tui: tuiConfig,
1097+
}}
1098+
>
10981099
<box flexDirection="row" flexGrow={1} minHeight={0}>
10991100
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
11001101
<Show when={session()}>
@@ -1270,7 +1271,8 @@ export function Session() {
12701271
</Switch>
12711272
</Show>
12721273
</box>
1273-
</context.Provider>
1274+
</context.Provider>
1275+
</PathFormatterProvider>
12741276
)
12751277
}
12761278

@@ -1827,7 +1829,7 @@ function BlockTool(props: {
18271829

18281830
function Shell(props: ToolProps<typeof ShellTool>) {
18291831
const { theme } = useTheme()
1830-
const sync = useSync()
1832+
const pathFormatter = usePathFormatter()
18311833
const isRunning = createMemo(() => props.part.state.status === "running")
18321834
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
18331835
const [expanded, setExpanded] = createSignal(false)
@@ -1841,18 +1843,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
18411843
const workdirDisplay = createMemo(() => {
18421844
const workdir = props.input.workdir
18431845
if (!workdir || workdir === ".") return undefined
1844-
1845-
const base = sync.path.directory
1846-
if (!base) return undefined
1847-
1848-
const absolute = path.resolve(base, workdir)
1849-
if (absolute === base) return undefined
1850-
1851-
const home = Global.Path.home
1852-
if (!home) return absolute
1853-
1854-
const match = absolute === home || absolute.startsWith(home + path.sep)
1855-
return match ? absolute.replace(home, "~") : absolute
1846+
return pathFormatter.format(workdir)
18561847
})
18571848

18581849
const title = createMemo(() => {
@@ -1894,6 +1885,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
18941885

18951886
function Write(props: ToolProps<typeof WriteTool>) {
18961887
const { theme, syntax } = useTheme()
1888+
const pathFormatter = usePathFormatter()
18971889
const code = createMemo(() => {
18981890
if (!props.input.content) return ""
18991891
return props.input.content
@@ -1902,7 +1894,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
19021894
return (
19031895
<Switch>
19041896
<Match when={props.metadata.diagnostics !== undefined}>
1905-
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
1897+
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
19061898
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
19071899
<code
19081900
conceal={false}
@@ -1917,17 +1909,18 @@ function Write(props: ToolProps<typeof WriteTool>) {
19171909
</Match>
19181910
<Match when={true}>
19191911
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
1920-
Write {normalizePath(props.input.filePath!)}
1912+
Write {pathFormatter.format(props.input.filePath)}
19211913
</InlineTool>
19221914
</Match>
19231915
</Switch>
19241916
)
19251917
}
19261918

19271919
function Glob(props: ToolProps<typeof GlobTool>) {
1920+
const pathFormatter = usePathFormatter()
19281921
return (
19291922
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
1930-
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
1923+
Glob "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
19311924
<Show when={props.metadata.count}>
19321925
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
19331926
</Show>
@@ -1937,6 +1930,7 @@ function Glob(props: ToolProps<typeof GlobTool>) {
19371930

19381931
function Read(props: ToolProps<typeof ReadTool>) {
19391932
const { theme } = useTheme()
1933+
const pathFormatter = usePathFormatter()
19401934
const isRunning = createMemo(() => props.part.state.status === "running")
19411935
const loaded = createMemo(() => {
19421936
if (props.part.state.status !== "completed") return []
@@ -1954,13 +1948,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
19541948
spinner={isRunning()}
19551949
part={props.part}
19561950
>
1957-
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
1951+
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
19581952
</InlineTool>
19591953
<For each={loaded()}>
19601954
{(filepath) => (
19611955
<box paddingLeft={3}>
19621956
<text paddingLeft={3} fg={theme.textMuted}>
1963-
↳ Loaded {normalizePath(filepath)}
1957+
↳ Loaded {pathFormatter.format(filepath)}
19641958
</text>
19651959
</box>
19661960
)}
@@ -1970,9 +1964,10 @@ function Read(props: ToolProps<typeof ReadTool>) {
19701964
}
19711965

19721966
function Grep(props: ToolProps<typeof GrepTool>) {
1967+
const pathFormatter = usePathFormatter()
19731968
return (
19741969
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
1975-
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
1970+
Grep "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
19761971
<Show when={props.metadata.matches}>
19771972
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
19781973
</Show>
@@ -2071,6 +2066,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
20712066
function Edit(props: ToolProps<typeof EditTool>) {
20722067
const ctx = use()
20732068
const { theme, syntax } = useTheme()
2069+
const pathFormatter = usePathFormatter()
20742070

20752071
const view = createMemo(() => {
20762072
const diffStyle = ctx.tui.diff_style
@@ -2086,7 +2082,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
20862082
return (
20872083
<Switch>
20882084
<Match when={props.metadata.diff !== undefined}>
2089-
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
2085+
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
20902086
<box paddingLeft={1}>
20912087
<diff
20922088
diff={diffContent()}
@@ -2113,7 +2109,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
21132109
</Match>
21142110
<Match when={true}>
21152111
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
2116-
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
2112+
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
21172113
</InlineTool>
21182114
</Match>
21192115
</Switch>
@@ -2123,6 +2119,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
21232119
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
21242120
const ctx = use()
21252121
const { theme, syntax } = useTheme()
2122+
const pathFormatter = usePathFormatter()
21262123

21272124
const files = createMemo(() => props.metadata.files ?? [])
21282125

@@ -2161,7 +2158,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
21612158
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
21622159
if (file.type === "delete") return "# Deleted " + file.relativePath
21632160
if (file.type === "add") return "# Created " + file.relativePath
2164-
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
2161+
if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath
21652162
return "← Patched " + file.relativePath
21662163
}
21672164

@@ -2281,20 +2278,6 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
22812278
)
22822279
}
22832280

2284-
function normalizePath(input?: string) {
2285-
if (!input) return ""
2286-
2287-
const cwd = process.cwd()
2288-
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
2289-
const relative = path.relative(cwd, absolute)
2290-
2291-
if (!relative) return "."
2292-
if (!relative.startsWith("..")) return relative
2293-
2294-
// outside cwd - use absolute
2295-
return absolute
2296-
}
2297-
22982281
function input(input: Record<string, any>, omit?: string[]): string {
22992282
const primitives = Object.entries(input).filter(([key, value]) => {
23002283
if (omit?.includes(key)) return false

packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,16 @@ import { useProject } from "../../context/project"
1111
import path from "path"
1212
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
1313
import { Locale } from "@/util/locale"
14-
import { Global } from "@opencode-ai/core/global"
1514
import { ShellID } from "@/tool/shell/id"
1615
import { webSearchProviderLabel } from "@/tool/websearch"
1716
import { useDialog } from "../../ui/dialog"
1817
import { getScrollAcceleration } from "../../util/scroll"
1918
import { useTuiConfig } from "../../context/tui-config"
2019
import { useBindings, useCommandShortcut } from "../../keymap"
20+
import { usePathFormatter } from "../../context/path-format"
2121

2222
type PermissionStage = "permission" | "always" | "reject"
2323

24-
function normalizePath(input?: string) {
25-
if (!input) return ""
26-
27-
const cwd = process.cwd()
28-
const home = Global.Path.home
29-
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
30-
const relative = path.relative(cwd, absolute)
31-
32-
if (!relative) return "."
33-
if (!relative.startsWith("..")) return relative
34-
35-
// outside cwd - use ~ or absolute
36-
if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
37-
return absolute.replace(home, "~")
38-
}
39-
return absolute
40-
}
41-
4224
function filetype(input?: string) {
4325
if (!input) return "none"
4426
const ext = path.extname(input)
@@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
137119
const [store, setStore] = createStore({
138120
stage: "permission" as PermissionStage,
139121
})
122+
const pathFormatter = usePathFormatter()
140123

141124
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
142125

@@ -220,7 +203,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
220203
const filepath = typeof raw === "string" ? raw : ""
221204
return {
222205
icon: "→",
223-
title: `Edit ${normalizePath(filepath)}`,
206+
title: `Edit ${pathFormatter.format(filepath)}`,
224207
body: <EditBody request={props.request} />,
225208
}
226209
}
@@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
230213
const filePath = typeof raw === "string" ? raw : ""
231214
return {
232215
icon: "→",
233-
title: `Read ${normalizePath(filePath)}`,
216+
title: `Read ${pathFormatter.format(filePath)}`,
234217
body: (
235218
<Show when={filePath}>
236219
<box paddingLeft={1}>
237-
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
220+
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
238221
</box>
239222
</Show>
240223
),
@@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
276259
const dir = typeof raw === "string" ? raw : ""
277260
return {
278261
icon: "→",
279-
title: `List ${normalizePath(dir)}`,
262+
title: `List ${pathFormatter.format(dir)}`,
280263
body: (
281264
<Show when={dir}>
282265
<box paddingLeft={1}>
283-
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
266+
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
284267
</box>
285268
</Show>
286269
),
@@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
359342
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
360343

361344
const raw = parent ?? filepath ?? derived
362-
const dir = normalizePath(raw)
345+
const dir = pathFormatter.format(raw)
363346
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
364347

365348
return {

0 commit comments

Comments
 (0)