diff --git a/packages/tui/src/component/dialog-workspace-file-changes.tsx b/packages/tui/src/component/dialog-workspace-file-changes.tsx
index 2babeecf8fcc..8918a2e7e8a0 100644
--- a/packages/tui/src/component/dialog-workspace-file-changes.tsx
+++ b/packages/tui/src/component/dialog-workspace-file-changes.tsx
@@ -1,9 +1,9 @@
import { TextAttributes } from "@opentui/core"
-import { useKeyboard } from "@opentui/solid"
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
-import { Locale } from "../util/locale"
+import { FilePath } from "../ui/file-path"
import { useTheme } from "../context/theme"
import { useTuiConfig } from "../config"
import { useDialog, type DialogContext } from "../ui/dialog"
@@ -33,10 +33,13 @@ export function DialogWorkspaceFileChanges(props: {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
+ const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
const height = createMemo(() => Math.min(props.files.length, 8))
- const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
+ const fileNameWidth = createMemo(
+ () => Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
+ )
function confirm() {
props.onSelect(store.active)
@@ -93,9 +96,7 @@ export function DialogWorkspaceFileChanges(props: {
{statusLabel(item.status)}
-
- {Locale.truncateLeft(item.file, fileNameWidth())}
-
+
diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx
index 41bee5da5a49..af1277b5c217 100644
--- a/packages/tui/src/feature-plugins/home/footer.tsx
+++ b/packages/tui/src/feature-plugins/home/footer.tsx
@@ -4,24 +4,45 @@ import { createMemo, Match, Show, Switch } from "solid-js"
import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
+import { FilePath } from "../../ui/file-path"
+import { useTerminalDimensions } from "@opentui/solid"
const id = "internal:home-footer"
-function Directory(props: { api: TuiPluginApi }) {
+function Directory(props: { api: TuiPluginApi; maxWidth: number }) {
const theme = () => props.api.theme.current
const destination = useHomeSessionDestination()
const paths = useTuiPaths()
const dir = createMemo(() => {
const selected = destination?.destination()
if (!selected || selected.type === "new") return
- const out = abbreviateHome(selected.directory, paths.home)
const branch =
selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
- if (branch) return out + ":" + branch
- return out
+ return { path: abbreviateHome(selected.directory, paths.home), branch }
})
- return {(value) => {value()}}
+ return (
+
+ {(value) => {
+ const suffix = () => (value().branch ? `:${value().branch}` : "")
+ const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2))
+ return (
+
+
+
+
+ {suffix()}
+
+
+
+ )
+ }}
+
+ )
}
function Mcp(props: { api: TuiPluginApi }) {
@@ -62,6 +83,16 @@ function Version(props: { api: TuiPluginApi }) {
}
function View(props: { api: TuiPluginApi }) {
+ const dimensions = useTerminalDimensions()
+ const mcpWidth = createMemo(() => {
+ const list = props.api.state.mcp()
+ if (list.length === 0) return 0
+ const count = list.filter((item) => item.status === "connected").length
+ return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2
+ })
+ const directoryWidth = createMemo(() =>
+ Math.max(2, dimensions().width - 8 - Bun.stringWidth(props.api.app.version) - mcpWidth()),
+ )
return (
-
+
diff --git a/packages/tui/src/feature-plugins/sidebar/files.tsx b/packages/tui/src/feature-plugins/sidebar/files.tsx
index 01f33f647d69..e76db914440d 100644
--- a/packages/tui/src/feature-plugins/sidebar/files.tsx
+++ b/packages/tui/src/feature-plugins/sidebar/files.tsx
@@ -1,7 +1,7 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, For, Show, createSignal } from "solid-js"
-import { Locale } from "../../util/locale"
+import { FilePath } from "../../ui/file-path"
const id = "internal:sidebar-files"
@@ -31,9 +31,11 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
{(item) => (
-
- {Locale.truncateLeft(item.file, Math.max(2, 36 - changeCountWidth(item)))}
-
+
+{item.additions}
diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx
index 6fb51ffafc91..b0fb68115354 100644
--- a/packages/tui/src/feature-plugins/sidebar/footer.tsx
+++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx
@@ -3,6 +3,7 @@ import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Show } from "solid-js"
import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
+import { FilePath } from "../../ui/file-path"
const id = "internal:sidebar-footer"
@@ -16,16 +17,12 @@ function View(props: { api: TuiPluginApi; directory: string }) {
)
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
- const path = createMemo(() => {
- const out = abbreviateHome(props.directory, paths.home)
+ const location = createMemo(() => {
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
- const text = branch ? out + ":" + branch : out
- const list = text.split("/")
- return {
- parent: list.slice(0, -1).join("/"),
- name: list.at(-1) ?? "",
- }
+ return { path: abbreviateHome(props.directory, paths.home), branch }
})
+ const suffix = createMemo(() => (location().branch ? `:${location().branch}` : ""))
+ const suffixWidth = createMemo(() => Math.min(Bun.stringWidth(suffix()), 36))
return (
@@ -62,10 +59,19 @@ function View(props: { api: TuiPluginApi; directory: string }) {
-
- {path().parent}/
- {path().name}
-
+
+
+
+
+ {suffix()}
+
+
+
• Open
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
index 13c200101a2d..b008c48cdc31 100644
--- a/packages/tui/src/routes/session/index.tsx
+++ b/packages/tui/src/routes/session/index.tsx
@@ -38,6 +38,7 @@ import type {
} from "@opencode-ai/sdk/v2"
import { useLocal } from "../../context/local"
import { Locale } from "../../util/locale"
+import { FilePath } from "../../ui/file-path"
import { webSearchProviderLabel } from "../../util/tool-display"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "../../context/sdk"
@@ -1393,6 +1394,7 @@ function RevertMessage(props: {
readonly deletions: number
}>
}) {
+ const ctx = use()
const { theme } = useTheme()
const route = useRouteData("session")
const sdk = useSDK()
@@ -1435,9 +1437,17 @@ function RevertMessage(props: {
{(file) => (
{statusLabel(file.status)}
-
- {Locale.truncateLeft(file.file, 60)}
-
+ 0 ? Bun.stringWidth(`+${file.additions}`) + 1 : 0) -
+ (file.deletions > 0 ? Bun.stringWidth(`-${file.deletions}`) + 1 : 0),
+ )}
+ fg={theme.text}
+ />
0}>
+{file.additions}
@@ -2129,6 +2139,7 @@ export function InlineToolRow(props: {
function BlockTool(props: {
title?: string
+ path?: { label: string; value: string }
children?: JSX.Element
onClick?: () => void
part?: SessionMessageAssistantTool
@@ -2162,18 +2173,45 @@ function BlockTool(props: {
props.onClick?.()
}}
>
-
- {(title) => (
-
- {title()}
-
- }
- >
- {title().replace(/^# /, "")}
+
+ {(title) => (
+
+ {title()}
+
+ }
+ >
+ {title().replace(/^# /, "")}
+
+ )}
+ }
+ >
+ {(path) => (
+
+
+ {path().label}
+
+ }
+ >
+
+ {path().label.replace(/^# /, "")}
+
+
+
+
)}
{props.children}
@@ -2268,7 +2306,10 @@ function Write(props: ToolProps) {
return (
-
+
{(item) => (
-
+
@@ -2567,7 +2609,10 @@ function ApplyPatch(props: ToolProps) {
{(file) => (
@@ -2601,10 +2646,17 @@ function ApplyPatch(props: ToolProps) {
{(file) => (
- {file.resource}
+
)}
@@ -2612,15 +2664,23 @@ function ApplyPatch(props: ToolProps) {
diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx
index bd415768a25e..a0282007006b 100644
--- a/packages/tui/src/routes/session/sidebar.tsx
+++ b/packages/tui/src/routes/session/sidebar.tsx
@@ -3,7 +3,7 @@ import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../config"
-import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { usePluginRuntime } from "../../plugin/runtime"
import { getScrollAcceleration } from "../../util/scroll"
@@ -56,9 +56,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
{session()!.title}
-
- {props.sessionID}
-
{
+ const value = truncateFilePath(props.value, props.maxWidth)
+ const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"))
+ return {
+ parent: value.slice(0, index + 1),
+ basename: value.slice(index + 1),
+ }
+ })
+ return (
+
+ {display().parent}
+ {display().basename}
+
+ )
+}
+
+export function truncateFilePath(value: string, maxWidth: number) {
+ if (maxWidth <= 0) return ""
+ if (Bun.stringWidth(value) <= maxWidth) return value
+
+ const drive = value.match(/^([A-Za-z]:)([\\/])/)
+ const unc = value.match(/^(\\\\|\/\/)([^\\/]+)[\\/]([^\\/]+)(?:[\\/]|$)/)
+ const windows = drive !== null || unc !== null || (!value.includes("/") && value.includes("\\"))
+ const separator = drive?.[2] ?? (unc?.[1] === "//" ? "/" : windows ? "\\" : "/")
+ const root = drive
+ ? drive[1] + separator
+ : unc
+ ? unc[1] + unc[2] + separator + unc[3] + separator
+ : value.startsWith("/")
+ ? "/"
+ : ""
+ const source = value.slice(drive?.[0].length ?? unc?.[0].length ?? root.length)
+ const segments = source.split(windows ? /[\\/]/ : separator).filter(Boolean)
+ const basename = segments.at(-1) ?? value
+ if (segments.length < 2) {
+ const rootWidth = Bun.stringWidth(root)
+ if (rootWidth >= maxWidth) return takeStart(root, maxWidth)
+ return root + truncateBasename(basename, maxWidth - rootWidth)
+ }
+
+ const prefix = `${root}…${separator}`
+ const basenameWidth = maxWidth - Bun.stringWidth(prefix)
+ if (basenameWidth <= 0) return takeStart(prefix, maxWidth)
+ const compact = truncateBasename(basename, basenameWidth)
+ if (compact !== basename) return prefix + compact
+
+ const selected = [basename]
+ const separatorWidth = Bun.stringWidth(separator)
+ let width = Bun.stringWidth(prefix + basename)
+ for (let index = segments.length - 2; index >= 0; index--) {
+ const next = Bun.stringWidth(segments[index]!) + separatorWidth
+ if (width + next > maxWidth) break
+ selected.unshift(segments[index]!)
+ width += next
+ }
+ return prefix + selected.join(separator)
+}
+
+function truncateBasename(value: string, maxWidth: number) {
+ if (Bun.stringWidth(value) <= maxWidth) return value
+ if (maxWidth <= 1) return takeStart("…", maxWidth)
+
+ const dot = value.lastIndexOf(".")
+ const extension = dot > 0 ? value.slice(dot) : ""
+ const extensionWidth = Bun.stringWidth(extension)
+ if (extensionWidth >= maxWidth) return "…" + takeEnd(extension, maxWidth - 1)
+
+ const stem = extension ? value.slice(0, dot) : value
+ return takeStart(stem, maxWidth - extensionWidth - 1) + "…" + extension
+}
+
+function takeStart(value: string, maxWidth: number) {
+ return take(value, maxWidth, false)
+}
+
+function takeEnd(value: string, maxWidth: number) {
+ return take(value, maxWidth, true)
+}
+
+function take(value: string, maxWidth: number, reverse: boolean) {
+ const segments = Array.from(graphemeSegmenter.segment(value), (item) => item.segment)
+ if (reverse) segments.reverse()
+ const selected: string[] = []
+ let width = 0
+ for (const segment of segments) {
+ const next = Bun.stringWidth(segment)
+ if (width + next > maxWidth) break
+ selected.push(segment)
+ width += next
+ }
+ if (reverse) selected.reverse()
+ return selected.join("")
+}
diff --git a/packages/tui/test/ui/file-path.test.ts b/packages/tui/test/ui/file-path.test.ts
new file mode 100644
index 000000000000..05d2dbe3e264
--- /dev/null
+++ b/packages/tui/test/ui/file-path.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from "bun:test"
+import { truncateFilePath } from "../../src/ui/file-path"
+
+describe("truncateFilePath", () => {
+ const path = "packages/tui/src/ui/dialog-select.tsx"
+
+ test("keeps the full path when it fits", () => {
+ expect(truncateFilePath(path, 37)).toBe(path)
+ })
+
+ test("adds nearest parent segments from right to left", () => {
+ expect(truncateFilePath(path, 26)).toBe("…/src/ui/dialog-select.tsx")
+ expect(truncateFilePath(path, 22)).toBe("…/ui/dialog-select.tsx")
+ expect(truncateFilePath(path, 19)).toBe("…/dialog-select.tsx")
+ })
+
+ test("preserves the extension when the basename must shrink", () => {
+ expect(truncateFilePath(path, 16)).toBe("…/dialog-se….tsx")
+ expect(truncateFilePath("dialog-select.tsx", 12)).toBe("dialog-….tsx")
+ })
+
+ test("preserves the input separator", () => {
+ expect(truncateFilePath("packages\\tui\\src\\ui\\dialog-select.tsx", 22)).toBe("…\\ui\\dialog-select.tsx")
+ })
+
+ test("does not treat a backslash in a POSIX filename as a separator", () => {
+ expect(truncateFilePath("dir/file\\name.ts", 14)).toBe("…/file\\name.ts")
+ })
+
+ test("preserves absolute roots", () => {
+ expect(truncateFilePath("/file.ts", 7)).toBe("/fi….ts")
+ expect(truncateFilePath("C:\\file.ts", 9)).toBe("C:\\fi….ts")
+ expect(truncateFilePath("/usr/local/bin/file.ts", 14)).toBe("/…/bin/file.ts")
+ expect(truncateFilePath("C:\\Users\\kit\\src\\file.ts", 16)).toBe("C:\\…\\src\\file.ts")
+ expect(truncateFilePath("C:/Users/kit/src/file.ts", 16)).toBe("C:/…/src/file.ts")
+ expect(truncateFilePath("C:\\Users\\kit/src/file.ts", 16)).toBe("C:\\…\\src\\file.ts")
+ expect(truncateFilePath("\\\\server\\share\\src\\file.ts", 25)).toBe("\\\\server\\share\\…\\file.ts")
+ })
+
+ test("measures terminal columns without splitting graphemes", () => {
+ expect(truncateFilePath("packages/组件/对话框.tsx", 12)).toBe("…/对话框.tsx")
+ expect(truncateFilePath("src/👩💻-notes.tsx", 12)).toContain("👩💻")
+ expect(truncateFilePath("中a.txt", 6)).toBe("….txt")
+ expect(truncateFilePath("file.中a", 3)).toBe("…a")
+ })
+
+ test("never exceeds the requested width", () => {
+ for (let width = 0; width <= Bun.stringWidth(path); width++) {
+ expect(Bun.stringWidth(truncateFilePath(path, width))).toBeLessThanOrEqual(width)
+ }
+ })
+})