fix(tui): reduce redundant runtime chrome

This commit is contained in:
chengyongru
2026-08-21 17:11:01 +08:00
committed by chengyongru
parent d28687e2e1
commit e9ac83d03f
6 changed files with 183 additions and 30 deletions
+59 -7
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core"
import { BoxRenderable, CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core"
import {
MockTreeSitterClient,
createTestRenderer,
@@ -1279,9 +1279,9 @@ describe("NanobotTui layout", () => {
expect(setup.renderer.width).toBe(width)
expect(setup.renderer.height).toBe(height)
expect(frame).not.toContain("undefined")
expect(occurrences(frame, "Ask nanobot anything")).toBeLessThanOrEqual(1)
expect(occurrences(frame, "Steer this turn…")).toBeLessThanOrEqual(1)
if (width >= 30 && height >= 9) {
expect(occurrences(frame, "Ask nanobot anything")).toBe(1)
expect(occurrences(frame, "Steer this turn…")).toBe(1)
}
expect(occurrences(frame, "nanobot · test/model")).toBe(height >= 14 ? 1 : 0)
}
@@ -1774,13 +1774,18 @@ describe("NanobotTui layout", () => {
expect(frame).toMatch(/Thinking\s+0s/u)
expect(frame).not.toMatch(/[]/u)
expect(frame).not.toContain("hidden reasoning")
const status = (app as unknown as {
const ui = app as unknown as {
status: {
content: { chunks: Array<{ fg?: { toInts(): number[] } }> }
plainText: string
}
}).status
composer: TextareaRenderable
composerFrame: BoxRenderable
}
const status = ui.status
expect(status.plainText).toMatch(/^Thinking\s+0s/u)
expect(ui.composer.placeholder).toBe("Steer this turn…")
expect(ui.composerFrame.height).toBe(3)
const shimmerColors = new Set(
status.content.chunks
.slice(0, "Thinking".length)
@@ -1803,7 +1808,11 @@ describe("NanobotTui layout", () => {
expect(frame).toMatch(/Working\s+0s/u)
expect(frame).not.toMatch(/[]/u)
expect(frame).toContain(" Running pwd")
expect(occurrences(frame, "pwd")).toBe(1)
expect(status.plainText).not.toContain("pwd")
app.accept({ event: "turn_end", chat_id: "chat" })
await setup.flush()
expect(ui.composer.placeholder).toBe("Ask nanobot anything")
})
test("folds long tool traces without discarding their details", async () => {
@@ -1823,8 +1832,9 @@ describe("NanobotTui layout", () => {
await setup.renderOnce()
let frame = setup.captureCharFrame()
expect(frame).toContain("5 earlier steps · Ctrl+O expand")
expect(frame).toContain("7 earlier steps · Ctrl+O expand")
expect(frame).not.toContain("tool_0")
expect(frame).toContain("tool_7")
expect(frame).toContain("tool_9")
setup.mockInput.pressKey("O", { ctrl: true })
@@ -1860,6 +1870,37 @@ describe("NanobotTui layout", () => {
app.accept({ event: "turn_end", chat_id: "chat" })
})
test("groups consecutive file activity only in the collapsed preview", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({
event: "message",
chat_id: "chat",
text: "file progress",
kind: "tool_hint",
tool_events: Array.from({ length: 6 }, (_, index) => ({
phase: "end" as const,
call_id: `read-${index}`,
name: "read_file",
arguments: { path: `/tmp/nanobot-workspace/src/file-${index}.ts` },
})),
})
await setup.renderOnce()
let frame = setup.captureCharFrame()
expect(frame).toContain("6 steps · Ctrl+O expand")
expect(frame).toContain("✓ Read 6 files")
expect(frame).not.toContain("src/file-0.ts")
setup.mockInput.pressKey("O", { ctrl: true })
await setup.renderOnce()
frame = setup.captureCharFrame()
expect(frame).not.toContain("Read 6 files")
expect(frame).toContain("src/file-0.ts")
expect(frame).toContain("src/file-5.ts")
})
test("supports keyboard transcript navigation without rebuilding the layout", async () => {
setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" })
const app = mount(setup)
@@ -2235,6 +2276,10 @@ describe("NanobotTui in a Herdr pane", () => {
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
host,
)
const ui = app as unknown as {
composer: TextareaRenderable
composerFrame: BoxRenderable
}
await setup.mockInput.typeText("/")
await setup.flush()
@@ -2243,6 +2288,7 @@ describe("NanobotTui in a Herdr pane", () => {
expect(commandFrame).toContain("/new-chat")
expect(commandFrame).toContain("/branch")
setup.mockInput.pressEscape()
ui.composer.setText("")
app.accept({ event: "attached", chat_id: "chat" })
app.accept({
@@ -2259,6 +2305,12 @@ describe("NanobotTui in a Herdr pane", () => {
kind: "tool_hint",
tool_events: [{ phase: "end", call_id: "read", name: "read_file", arguments: { path: "app.ts" } }],
})
await setup.flush()
const activeFrame = setup.captureCharFrame()
expect(occurrences(activeFrame, " Ship the Herdr integration")).toBe(1)
expect(occurrences(activeFrame, "app.ts")).toBe(1)
expect(ui.composer.placeholder).toBe("Steer this turn…")
expect(ui.composerFrame.height).toBe(3)
app.accept({
event: "turn_end",
chat_id: "chat",
@@ -2273,7 +2325,7 @@ describe("NanobotTui in a Herdr pane", () => {
const frame = setup.captureCharFrame()
expect(sessions).toEqual(["chat"])
expect(frame).toContain(" Ship the Herdr integration")
expect(occurrences(frame, " Ship the Herdr integration")).toBe(1)
expect(frame).not.toContain(">_ nanobot")
expect(frame).not.toContain("test/model")
expect(states.some(({ state }) => state === "working")).toBe(true)
+8 -11
View File
@@ -160,6 +160,7 @@ const LIGHT: Palette = {
}
const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
const ACTIVE_COMPOSER_PLACEHOLDER = "Steer this turn…"
const SHIMMER_PAUSE = 16
const SHIMMER_BAND = 4
const SHIMMER_INTERVAL_MS = 80
@@ -476,6 +477,7 @@ export class NanobotTui {
treeSitterClient,
(state) => this.handleTranscriptNavigation(state),
!host.hosted,
options.workspace,
)
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
this.commandMenu.setCommands([], LOCAL_COMMANDS)
@@ -719,7 +721,7 @@ export class NanobotTui {
this.shell.add(this.branchMenu.root)
this.shell.add(this.contextPanel.root)
this.shell.add(this.runtimeControls.menuRoot)
this.shell.add(this.title)
if (!host.hosted) this.shell.add(this.title)
this.shell.add(this.queuePreview.root)
this.shell.add(this.composerFrame)
this.shell.add(statusRow)
@@ -1262,6 +1264,7 @@ export class NanobotTui {
}
this.activeTurn = active
this.updateMeta()
this.syncComposerPlaceholder()
if (active) {
this.activeStartedAt = startedAt ?? Date.now()
this.shimmerFrame = 0
@@ -1280,14 +1283,11 @@ export class NanobotTui {
private renderActiveStatus(): void {
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
const progress = this.lastProgress
? ` · ${this.lastProgress.replace(/^\s*[·×]\s*/u, "")}`
: ""
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
this.status.content = shimmerStatus(
this.activeLabel,
` ${elapsed}${progress}${queued}${navigation}`,
` ${elapsed}${queued}${navigation}`,
this.shimmerFrame,
this.palette,
)
@@ -1659,11 +1659,6 @@ export class NanobotTui {
private updateTitle(): void {
if (this.host.hosted) {
this.titleText.maxWidth = Math.max(8, this.renderer.width - 4)
this.titleText.content = this.currentTask ? ` ${this.currentTask}` : ""
// In a hosted pane this is the resume anchor, not decorative chrome.
// Keep it visible even when Herdr temporarily makes the pane very short.
this.title.visible = Boolean(this.currentTask)
this.syncHostMetadata()
return
}
@@ -1774,7 +1769,9 @@ export class NanobotTui {
? null
: this.sessionMenu.visible
? "Search sessions"
: this.branchMenu.visible ? "Search branch points" : COMPOSER_PLACEHOLDER
: this.branchMenu.visible
? "Search branch points"
: this.activeTurn ? ACTIVE_COMPOSER_PLACEHOLDER : COMPOSER_PLACEHOLDER
if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder
}
+24
View File
@@ -26,4 +26,28 @@ describe("tool renderers", () => {
expect(renderToolEvent({ phase: "end", name: "apply_patch", arguments: { path: "app.ts" } }))
.toBe(" ✓ Edited app.ts")
})
test("shortens file paths relative to the workspace while preserving the useful tail", () => {
const workspace = String.raw`C:\workspace\nanobot`
expect(renderToolEvent({
phase: "end",
name: "read_file",
arguments: { path: String.raw`C:\workspace\nanobot\tui\src\app.ts` },
}, { workspace })).toBe(" ✓ Read tui/src/app.ts")
expect(renderToolEvent({
phase: "end",
name: "read_file",
arguments: {
path: String.raw`C:\workspace\nanobot\.worktrees\feature\nanobot\providers\fallback_provider.py`,
},
}, { workspace })).toBe(" ✓ Read …/feature/nanobot/providers/fallback_provider.py")
})
test("summarizes subagent delegation without exposing raw argument JSON", () => {
expect(renderToolEvent({
phase: "end",
name: "spawn",
arguments: { task: "Simplify the fallback provider implementation" },
})).toBe(" ✓ Delegated Simplify the fallback provider implementation")
})
})
+54 -6
View File
@@ -1,5 +1,11 @@
import type { ToolProgressEvent } from "./protocol"
export interface ToolRenderOptions {
workspace?: string
}
const PATH_DETAIL_LIMIT = 52
export function mergeToolEvent(
previous: ToolProgressEvent | undefined,
next: ToolProgressEvent,
@@ -14,13 +20,18 @@ export function mergeToolEvent(
}
}
export function renderToolEvent(event: ToolProgressEvent): string {
export function renderToolEvent(
event: ToolProgressEvent,
options: ToolRenderOptions = {},
): string {
const phase = event.phase || "start"
const marker = phase === "error" ? "×" : phase === "end" ? "✓" : ""
const name = (event.name || "tool").trim()
const args = record(event.arguments)
const result = record(event.result)
const detail = phase === "error" ? compact(event.error) : toolDetail(name, args, result)
const detail = phase === "error"
? compact(event.error)
: toolDetail(name, args, result, options)
return ` ${marker} ${toolLabel(name, phase, args)}${detail ? ` ${detail}` : ""}`
}
@@ -39,6 +50,9 @@ function toolLabel(
if (/^(?:read_file|read)$/u.test(name)) return "Read"
if (/^(?:write_file|write)$/u.test(name)) return phase === "end" ? "Edited" : "Editing"
if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return phase === "end" ? "Edited" : "Editing"
if (/^(?:spawn|spawn_agent)$/u.test(name)) {
return phase === "end" ? "Delegated" : phase === "error" ? "Delegation failed" : "Delegating"
}
if (name === "web_search") return "Search web"
if (name === "web_fetch") return "Fetch"
return name
@@ -48,13 +62,15 @@ function toolDetail(
name: string,
args: Record<string, unknown>,
result: Record<string, unknown>,
options: ToolRenderOptions,
): string {
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) {
return compact(args.command ?? args.cmd ?? result.output)
}
if (/^(?:read_file|write_file|edit_file|apply_patch|read|write|edit)$/u.test(name)) {
return compact(args.path ?? args.file_path ?? result.path)
return compactPath(args.path ?? args.file_path ?? result.path, options.workspace)
}
if (/^(?:spawn|spawn_agent)$/u.test(name)) return compact(args.label ?? args.task, 56)
if (name === "web_search") return compact(args.query ?? args.q)
if (name === "web_fetch") return compact(args.url)
if (/session/u.test(name)) return compact(args.session_key ?? args.chat_id ?? args.query)
@@ -68,8 +84,40 @@ function record(value: unknown): Record<string, unknown> {
: {}
}
function compact(value: unknown): string {
function compact(value: unknown, limit = 88): string {
if (value == null || value === "") return ""
const text = typeof value === "string" ? value : JSON.stringify(value)
return text.length > 88 ? `${text.slice(0, 85)}` : text
const serialized = typeof value === "string" ? value : JSON.stringify(value)
const text = (serialized || String(value)).replace(/\s+/gu, " ").trim()
return text.length > limit ? `${text.slice(0, limit - 1)}` : text
}
function compactPath(value: unknown, workspace?: string): string {
if (value == null || value === "") return ""
const normalized = String(value).replace(/\\/gu, "/").replace(/\/+$/u, "")
const relative = workspaceRelativePath(normalized, workspace)
if (relative.length <= PATH_DETAIL_LIMIT) return relative
const parts = relative.split("/").filter(Boolean)
let tail = parts.pop() || relative
if (tail.length + 1 >= PATH_DETAIL_LIMIT) {
return `${tail.slice(-(PATH_DETAIL_LIMIT - 1))}`
}
while (parts.length) {
const candidate = `${parts.at(-1)}/${tail}`
if (`…/${candidate}`.length > PATH_DETAIL_LIMIT) break
tail = candidate
parts.pop()
}
return `…/${tail}`
}
function workspaceRelativePath(path: string, workspace?: string): string {
if (!workspace) return path
const base = workspace.replace(/\\/gu, "/").replace(/\/+$/u, "")
const caseInsensitive = /^[a-z]:\//iu.test(path) || /^[a-z]:\//iu.test(base)
const comparedPath = caseInsensitive ? path.toLowerCase() : path
const comparedBase = caseInsensitive ? base.toLowerCase() : base
if (comparedPath === comparedBase) return path.split("/").at(-1) || path
if (comparedPath.startsWith(`${comparedBase}/`)) return path.slice(base.length + 1)
return path
}
+33 -5
View File
@@ -44,7 +44,13 @@ interface Activity {
events: Map<string, ToolProgressEvent>
}
const ACTIVITY_PREVIEW_LINES = 6
interface ActivityPreviewItem {
text: string
steps: number
group?: string
}
const ACTIVITY_PREVIEW_LINES = 4
// OpenTUI renders at 30 FPS. Re-parsing the entire Markdown buffer for every
// provider token turns long answers into quadratic work without producing any
// additional visible frames. Paint the first token immediately, then coalesce
@@ -78,6 +84,7 @@ export class Transcript {
private readonly treeSitterClient: TreeSitterClient,
private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
private readonly showHeader = true,
private readonly workspace = "",
) {
this.root = new ScrollBoxRenderable(renderer, {
id: "nanobot-tui-transcript",
@@ -424,7 +431,7 @@ export class Transcript {
const key = event.call_id ? `tool:${event.call_id}` : ""
const merged = key ? mergeToolEvent(activity.events.get(key), event) : event
if (key) activity.events.set(key, merged)
return { key, line: renderToolEvent(merged) }
return { key, line: renderToolEvent(merged, { workspace: this.workspace }) }
})
const lines = events.length > 0
? projected.map(({ line }) => line).filter(Boolean)
@@ -448,9 +455,14 @@ export class Transcript {
activity.text.content = activity.lines.join("\n")
return
}
const visible = activity.lines.slice(-(ACTIVITY_PREVIEW_LINES - 1))
const hidden = activity.lines.length - visible.length
activity.text.content = [`${hidden} earlier steps · Ctrl+O expand`, ...visible].join("\n")
const visible = activityPreview(activity.lines).slice(-(ACTIVITY_PREVIEW_LINES - 1))
const visibleSteps = visible.reduce((total, item) => total + item.steps, 0)
const hidden = activity.lines.length - visibleSteps
const disclosure = hidden > 0 ? `${hidden} earlier steps` : `${activity.lines.length} steps`
activity.text.content = [
`${disclosure} · Ctrl+O expand`,
...visible.map((item) => item.text),
].join("\n")
}
private createText(
@@ -542,6 +554,22 @@ function cleanProgress(value: string): string {
return text ? ` · ${text}` : ""
}
function activityPreview(lines: readonly string[]): ActivityPreviewItem[] {
const preview: ActivityPreviewItem[] = []
for (const line of lines) {
const match = line.match(/^ ([]) (Read|Edited|Editing) /u)
const group = match ? `${match[1]}:${match[2]}` : undefined
const previous = preview.at(-1)
if (match && group && previous?.group === group) {
previous.steps += 1
previous.text = ` ${match[1]} ${match[2]} ${previous.steps} files`
continue
}
preview.push({ text: line, steps: 1, ...(group ? { group } : {}) })
}
return preview
}
function formatDiffStat(edit: FileEditEvent): string {
const added = typeof edit.added === "number" ? `+${edit.added}` : ""
const deleted = typeof edit.deleted === "number" ? `-${edit.deleted}` : ""