feat(tui): add semantic file path truncation (#36352)

This commit is contained in:
Kit Langton 2026-07-11 13:57:29 -04:00 committed by GitHub
commit e3f7637eb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 312 additions and 57 deletions

View file

@ -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: {
<box width={2} flexShrink={0}>
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
</box>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateLeft(item.file, fileNameWidth())}
</text>
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={theme.textMuted} />
</box>
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
<text>

View file

@ -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 <Show when={dir()}>{(value) => <text fg={theme().textMuted}>{value()}</text>}</Show>
return (
<Show when={dir()}>
{(value) => {
const suffix = () => (value().branch ? `:${value().branch}` : "")
const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2))
return (
<box flexDirection="row" minWidth={0}>
<FilePath
value={value().path}
maxWidth={Math.max(2, props.maxWidth - suffixWidth())}
fg={theme().textMuted}
/>
<Show when={suffix()}>
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
{suffix()}
</text>
</Show>
</box>
)
}}
</Show>
)
}
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 (
<box
width="100%"
@ -73,7 +104,7 @@ function View(props: { api: TuiPluginApi }) {
flexShrink={0}
gap={2}
>
<Directory api={props.api} />
<Directory api={props.api} maxWidth={directoryWidth()} />
<Mcp api={props.api} />
<box flexGrow={1} />
<Version api={props.api} />

View file

@ -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 }) {
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme().textMuted} wrapMode="none">
{Locale.truncateLeft(item.file, Math.max(2, 36 - changeCountWidth(item)))}
</text>
<FilePath
value={item.file}
maxWidth={Math.max(2, 36 - changeCountWidth(item))}
fg={theme().textMuted}
/>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme().diffAdded}>+{item.additions}</text>

View file

@ -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 (
<box gap={1}>
@ -62,10 +59,19 @@ function View(props: { api: TuiPluginApi; directory: string }) {
</box>
</box>
</Show>
<text>
<span style={{ fg: theme().textMuted }}>{path().parent}/</span>
<span style={{ fg: theme().text }}>{path().name}</span>
</text>
<box flexDirection="row" minWidth={0}>
<FilePath
value={location().path}
maxWidth={Math.max(2, 38 - suffixWidth())}
fg={theme().textMuted}
basenameFg={theme().text}
/>
<Show when={suffix()}>
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
{suffix()}
</text>
</Show>
</box>
<text fg={theme().textMuted}>
<span style={{ fg: theme().success }}></span> <b>Open</b>
<span style={{ fg: theme().text }}>

View file

@ -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"
@ -1402,6 +1403,7 @@ function RevertMessage(props: {
readonly deletions: number
}>
}) {
const ctx = use()
const { theme } = useTheme()
const route = useRouteData("session")
const sdk = useSDK()
@ -1444,9 +1446,17 @@ function RevertMessage(props: {
{(file) => (
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.textMuted}>{statusLabel(file.status)}</text>
<text fg={theme.text} wrapMode="none">
{Locale.truncateLeft(file.file, 60)}
</text>
<FilePath
value={file.file}
maxWidth={Math.max(
2,
ctx.width -
5 -
(file.additions > 0 ? Bun.stringWidth(`+${file.additions}`) + 1 : 0) -
(file.deletions > 0 ? Bun.stringWidth(`-${file.deletions}`) + 1 : 0),
)}
fg={theme.text}
/>
<Show when={file.additions > 0}>
<text fg={theme.diffAdded}>+{file.additions}</text>
</Show>
@ -2138,6 +2148,7 @@ export function InlineToolRow(props: {
function BlockTool(props: {
title?: string
path?: { label: string; value: string }
children?: JSX.Element
onClick?: () => void
part?: SessionMessageAssistantTool
@ -2171,18 +2182,45 @@ function BlockTool(props: {
props.onClick?.()
}}
>
<Show when={props.title}>
{(title) => (
<Show
when={props.spinner}
fallback={
<text paddingLeft={3} fg={permission() ? theme.warning : theme.textMuted}>
{title()}
</text>
}
>
<Spinner color={permission() ? theme.warning : theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
<Show
when={props.path}
fallback={
<Show when={props.title}>
{(title) => (
<Show
when={props.spinner}
fallback={
<text fg={permission() ? theme.warning : theme.textMuted}>
{title()}
</text>
}
>
<Spinner color={permission() ? theme.warning : theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
</Show>
)}
</Show>
}
>
{(path) => (
<box flexDirection="row" gap={1} minWidth={0}>
<Show
when={props.spinner}
fallback={
<text flexShrink={0} fg={permission() ? theme.warning : theme.textMuted}>
{path().label}
</text>
}
>
<Spinner color={permission() ? theme.warning : theme.textMuted}>
{path().label.replace(/^# /, "")}
</Spinner>
</Show>
<FilePath
value={path().value}
maxWidth={Math.max(2, ctx.width - 4 - Bun.stringWidth(path().label) - (props.spinner ? 2 : 0))}
fg={permission() ? theme.warning : theme.textMuted}
/>
</box>
)}
</Show>
{props.children}
@ -2277,7 +2315,10 @@ function Write(props: ToolProps) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + pathFormatter.format(stringValue(props.input.path))} part={props.part}>
<BlockTool
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
part={props.part}
>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
@ -2503,7 +2544,7 @@ function Edit(props: ToolProps) {
<Switch>
<Match when={file()}>
{(item) => (
<BlockTool title={"← Edit " + pathFormatter.format(path())} part={props.part}>
<BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
<box paddingLeft={1}>
<diff
diff={item().patch}
@ -2531,11 +2572,12 @@ function Edit(props: ToolProps) {
</Match>
<Match when={true}>
<BlockTool
title={
path={
stringValue(props.input.path)
? "← Edit " + pathFormatter.format(stringValue(props.input.path))
: "# Preparing edit..."
? { label: "← Edit", value: pathFormatter.format(stringValue(props.input.path)) }
: undefined
}
title={stringValue(props.input.path) ? undefined : "# Preparing edit..."}
part={props.part}
spinner={props.part.state.status === "streaming"}
/>
@ -2576,7 +2618,10 @@ function ApplyPatch(props: ToolProps) {
<For each={files()}>
{(file) => (
<BlockTool
title={`${file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched"} ${pathFormatter.format(file.relativePath)}`}
path={{
label: file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched",
value: pathFormatter.format(file.relativePath),
}}
part={props.part}
>
<box paddingLeft={1}>
@ -2610,10 +2655,17 @@ function ApplyPatch(props: ToolProps) {
<For each={applied()}>
{(file) => (
<BlockTool
title={`${file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched"} ${pathFormatter.format(file.resource)}`}
path={{
label: file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched",
value: pathFormatter.format(file.resource),
}}
part={props.part}
>
<text fg={file.type === "delete" ? theme.diffRemoved : theme.textMuted}>{file.resource}</text>
<FilePath
value={file.resource}
maxWidth={Math.max(2, ctx.width - 3)}
fg={file.type === "delete" ? theme.diffRemoved : theme.textMuted}
/>
</BlockTool>
)}
</For>
@ -2621,15 +2673,23 @@ function ApplyPatch(props: ToolProps) {
</Match>
<Match when={true}>
<BlockTool
path={
targets().length === 1
? {
label: props.part.state.status === "error" ? "# Patch failed" : "Patching",
value: pathFormatter.format(targets()[0]),
}
: undefined
}
title={
targets().length === 1
? `${props.part.state.status === "error" ? "# Patch failed" : "# Preparing patch"} ${pathFormatter.format(targets()[0])}`
? undefined
: props.part.state.status === "error"
? "# Patch failed"
: "# Preparing patch..."
: "Patching"
}
part={props.part}
spinner={props.part.state.status === "streaming"}
spinner={props.part.state.status === "streaming" || props.part.state.status === "running"}
/>
</Match>
</Switch>

View file

@ -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 }) {
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<Show when={session()!.location.workspaceID}>
<text fg={theme.textMuted}>
<Show

View file

@ -0,0 +1,106 @@
import type { RGBA } from "@opentui/core"
import { createMemo } from "solid-js"
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
export interface FilePathProps {
value: string
maxWidth: number
fg?: RGBA
basenameFg?: RGBA
}
export function FilePath(props: FilePathProps) {
const display = createMemo(() => {
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 (
<text fg={props.fg} wrapMode="none" truncate>
<span style={{ fg: props.fg }}>{display().parent}</span>
<span style={{ fg: props.basenameFg ?? props.fg }}>{display().basename}</span>
</text>
)
}
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("")
}

View file

@ -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)
}
})
})