Browse Source

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

Kit Langton 1 month ago
parent
commit
e3f7637eb2

+ 7 - 6
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: {
                 <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>

+ 37 - 6
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 <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} />

+ 6 - 4
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 }) {
           <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>

+ 18 - 12
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 (
     <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 }}>

+ 85 - 25
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"
@@ -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>

+ 1 - 4
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 }) {
                 <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

+ 106 - 0
packages/tui/src/ui/file-path.tsx

@@ -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("")
+}

+ 52 - 0
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)
+    }
+  })
+})