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 {