Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton e23c0552aa feat(tui): add semantic file path truncation 2026-07-10 22:58:48 -04:00
5 changed files with 143 additions and 11 deletions
@@ -3,7 +3,7 @@ import { useKeyboard } 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"
@@ -93,9 +93,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>
@@ -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>
+2 -3
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"
@@ -1435,9 +1436,7 @@ 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={60} fg={theme.text} />
<Show when={file.additions > 0}>
<text fg={theme.diffAdded}>+{file.additions}</text>
</Show>
+86
View File
@@ -0,0 +1,86 @@
import type { RGBA } from "@opentui/core"
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
export interface FilePathProps {
value: string
maxWidth: number
fg?: RGBA
}
export function FilePath(props: FilePathProps) {
return (
<text fg={props.fg} wrapMode="none" truncate>
{truncateFilePath(props.value, props.maxWidth)}
</text>
)
}
export function truncateFilePath(value: string, maxWidth: number) {
if (maxWidth <= 0) return ""
if (Bun.stringWidth(value) <= maxWidth) return value
const separator = value.includes("/") ? "/" : "\\"
const drive = separator === "\\" ? value.match(/^[A-Za-z]:\\/)?.[0] : undefined
const root = drive ?? (value.startsWith(separator) ? separator : "")
const segments = value.slice(root.length).split(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 = `${separator}`
const basenameWidth = maxWidth - Bun.stringWidth(prefix)
if (basenameWidth <= 0) return takeStart("…", 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("")
}
+47
View File
@@ -0,0 +1,47 @@
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 root-level absolute paths", () => {
expect(truncateFilePath("/file.ts", 7)).toBe("/fi….ts")
expect(truncateFilePath("C:\\file.ts", 9)).toBe("C:\\fi….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)
}
})
})