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
+5 -1
View File
@@ -15,7 +15,7 @@ Standalone terminals use OpenTUI's retained full-screen layout: the transcript r
## Herdr host mode ## Herdr host mode
When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model chrome, and keeps only the transcript, last user task, current progress, and composer. Herdr remains responsible for workspace, tab, pane, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands. When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model/task chrome, and keeps only the transcript, compact progress, and composer. Herdr remains responsible for workspace, tab, pane, task, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands.
The TUI reports its WebSocket session ID, model, Git branch, workspace, last task, and current action through Herdr's supported pane CLI. Sending work reports `working`; a persisted explicit nanobot goal block reports `blocked`; a completed turn reports `idle`; exit releases lifecycle authority. The gateway session remains the durable transcript and resume path. Standalone terminals keep the richer full-screen navigation described below. The TUI reports its WebSocket session ID, model, Git branch, workspace, last task, and current action through Herdr's supported pane CLI. Sending work reports `working`; a persisted explicit nanobot goal block reports `blocked`; a completed turn reports `idle`; exit releases lifecycle authority. The gateway session remains the durable transcript and resume path. Standalone terminals keep the richer full-screen navigation described below.
@@ -27,6 +27,10 @@ When you scroll away from the latest output, the scrollbar and `Ctrl+End` hint a
you return to the bottom. Large pastes are represented by a short editable placeholder in the you return to the bottom. Large pastes are represented by a short editable placeholder in the
composer; nanobot sends the original text unchanged. composer; nanobot sends the original text unchanged.
While a turn is running, the composer remains available for steering and uses `Steer this turn…`
as its prompt. The footer shows the lifecycle and elapsed time without repeating the latest tool
activity already visible in the transcript.
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys Type `/` to discover slash commands published by the connected gateway. Use the arrow keys
to move, `Tab` to complete, and `Esc` to close the menu. to move, `Tab` to complete, and `Esc` to close the menu.
+59 -7
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core" import { BoxRenderable, CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core"
import { import {
MockTreeSitterClient, MockTreeSitterClient,
createTestRenderer, createTestRenderer,
@@ -1279,9 +1279,9 @@ describe("NanobotTui layout", () => {
expect(setup.renderer.width).toBe(width) expect(setup.renderer.width).toBe(width)
expect(setup.renderer.height).toBe(height) expect(setup.renderer.height).toBe(height)
expect(frame).not.toContain("undefined") 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) { 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) 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).toMatch(/Thinking\s+0s/u)
expect(frame).not.toMatch(/[]/u) expect(frame).not.toMatch(/[]/u)
expect(frame).not.toContain("hidden reasoning") expect(frame).not.toContain("hidden reasoning")
const status = (app as unknown as { const ui = app as unknown as {
status: { status: {
content: { chunks: Array<{ fg?: { toInts(): number[] } }> } content: { chunks: Array<{ fg?: { toInts(): number[] } }> }
plainText: string plainText: string
} }
}).status composer: TextareaRenderable
composerFrame: BoxRenderable
}
const status = ui.status
expect(status.plainText).toMatch(/^Thinking\s+0s/u) 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( const shimmerColors = new Set(
status.content.chunks status.content.chunks
.slice(0, "Thinking".length) .slice(0, "Thinking".length)
@@ -1803,7 +1808,11 @@ describe("NanobotTui layout", () => {
expect(frame).toMatch(/Working\s+0s/u) expect(frame).toMatch(/Working\s+0s/u)
expect(frame).not.toMatch(/[]/u) expect(frame).not.toMatch(/[]/u)
expect(frame).toContain(" Running pwd") 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" }) 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 () => { test("folds long tool traces without discarding their details", async () => {
@@ -1823,8 +1832,9 @@ describe("NanobotTui layout", () => {
await setup.renderOnce() await setup.renderOnce()
let frame = setup.captureCharFrame() 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).not.toContain("tool_0")
expect(frame).toContain("tool_7")
expect(frame).toContain("tool_9") expect(frame).toContain("tool_9")
setup.mockInput.pressKey("O", { ctrl: true }) setup.mockInput.pressKey("O", { ctrl: true })
@@ -1860,6 +1870,37 @@ describe("NanobotTui layout", () => {
app.accept({ event: "turn_end", chat_id: "chat" }) 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 () => { test("supports keyboard transcript navigation without rebuilding the layout", async () => {
setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" })
const app = mount(setup) const app = mount(setup)
@@ -2235,6 +2276,10 @@ describe("NanobotTui in a Herdr pane", () => {
new MockTreeSitterClient({ autoResolveTimeout: 0 }), new MockTreeSitterClient({ autoResolveTimeout: 0 }),
host, host,
) )
const ui = app as unknown as {
composer: TextareaRenderable
composerFrame: BoxRenderable
}
await setup.mockInput.typeText("/") await setup.mockInput.typeText("/")
await setup.flush() await setup.flush()
@@ -2243,6 +2288,7 @@ describe("NanobotTui in a Herdr pane", () => {
expect(commandFrame).toContain("/new-chat") expect(commandFrame).toContain("/new-chat")
expect(commandFrame).toContain("/branch") expect(commandFrame).toContain("/branch")
setup.mockInput.pressEscape() setup.mockInput.pressEscape()
ui.composer.setText("")
app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "attached", chat_id: "chat" })
app.accept({ app.accept({
@@ -2259,6 +2305,12 @@ describe("NanobotTui in a Herdr pane", () => {
kind: "tool_hint", kind: "tool_hint",
tool_events: [{ phase: "end", call_id: "read", name: "read_file", arguments: { path: "app.ts" } }], 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({ app.accept({
event: "turn_end", event: "turn_end",
chat_id: "chat", chat_id: "chat",
@@ -2273,7 +2325,7 @@ describe("NanobotTui in a Herdr pane", () => {
const frame = setup.captureCharFrame() const frame = setup.captureCharFrame()
expect(sessions).toEqual(["chat"]) 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(">_ nanobot")
expect(frame).not.toContain("test/model") expect(frame).not.toContain("test/model")
expect(states.some(({ state }) => state === "working")).toBe(true) 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 COMPOSER_PLACEHOLDER = "Ask nanobot anything"
const ACTIVE_COMPOSER_PLACEHOLDER = "Steer this turn…"
const SHIMMER_PAUSE = 16 const SHIMMER_PAUSE = 16
const SHIMMER_BAND = 4 const SHIMMER_BAND = 4
const SHIMMER_INTERVAL_MS = 80 const SHIMMER_INTERVAL_MS = 80
@@ -476,6 +477,7 @@ export class NanobotTui {
treeSitterClient, treeSitterClient,
(state) => this.handleTranscriptNavigation(state), (state) => this.handleTranscriptNavigation(state),
!host.hosted, !host.hosted,
options.workspace,
) )
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
this.commandMenu.setCommands([], LOCAL_COMMANDS) this.commandMenu.setCommands([], LOCAL_COMMANDS)
@@ -719,7 +721,7 @@ export class NanobotTui {
this.shell.add(this.branchMenu.root) this.shell.add(this.branchMenu.root)
this.shell.add(this.contextPanel.root) this.shell.add(this.contextPanel.root)
this.shell.add(this.runtimeControls.menuRoot) 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.queuePreview.root)
this.shell.add(this.composerFrame) this.shell.add(this.composerFrame)
this.shell.add(statusRow) this.shell.add(statusRow)
@@ -1262,6 +1264,7 @@ export class NanobotTui {
} }
this.activeTurn = active this.activeTurn = active
this.updateMeta() this.updateMeta()
this.syncComposerPlaceholder()
if (active) { if (active) {
this.activeStartedAt = startedAt ?? Date.now() this.activeStartedAt = startedAt ?? Date.now()
this.shimmerFrame = 0 this.shimmerFrame = 0
@@ -1280,14 +1283,11 @@ export class NanobotTui {
private renderActiveStatus(): void { private renderActiveStatus(): void {
const elapsed = formatElapsed(Date.now() - this.activeStartedAt) 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 navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : "" const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
this.status.content = shimmerStatus( this.status.content = shimmerStatus(
this.activeLabel, this.activeLabel,
` ${elapsed}${progress}${queued}${navigation}`, ` ${elapsed}${queued}${navigation}`,
this.shimmerFrame, this.shimmerFrame,
this.palette, this.palette,
) )
@@ -1659,11 +1659,6 @@ export class NanobotTui {
private updateTitle(): void { private updateTitle(): void {
if (this.host.hosted) { 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() this.syncHostMetadata()
return return
} }
@@ -1774,7 +1769,9 @@ export class NanobotTui {
? null ? null
: this.sessionMenu.visible : this.sessionMenu.visible
? "Search sessions" ? "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 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" } })) expect(renderToolEvent({ phase: "end", name: "apply_patch", arguments: { path: "app.ts" } }))
.toBe(" ✓ Edited 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" import type { ToolProgressEvent } from "./protocol"
export interface ToolRenderOptions {
workspace?: string
}
const PATH_DETAIL_LIMIT = 52
export function mergeToolEvent( export function mergeToolEvent(
previous: ToolProgressEvent | undefined, previous: ToolProgressEvent | undefined,
next: ToolProgressEvent, 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 phase = event.phase || "start"
const marker = phase === "error" ? "×" : phase === "end" ? "✓" : "" const marker = phase === "error" ? "×" : phase === "end" ? "✓" : ""
const name = (event.name || "tool").trim() const name = (event.name || "tool").trim()
const args = record(event.arguments) const args = record(event.arguments)
const result = record(event.result) 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}` : ""}` return ` ${marker} ${toolLabel(name, phase, args)}${detail ? ` ${detail}` : ""}`
} }
@@ -39,6 +50,9 @@ function toolLabel(
if (/^(?:read_file|read)$/u.test(name)) return "Read" if (/^(?:read_file|read)$/u.test(name)) return "Read"
if (/^(?:write_file|write)$/u.test(name)) return phase === "end" ? "Edited" : "Editing" 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 (/^(?: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_search") return "Search web"
if (name === "web_fetch") return "Fetch" if (name === "web_fetch") return "Fetch"
return name return name
@@ -48,13 +62,15 @@ function toolDetail(
name: string, name: string,
args: Record<string, unknown>, args: Record<string, unknown>,
result: Record<string, unknown>, result: Record<string, unknown>,
options: ToolRenderOptions,
): string { ): string {
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) { if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) {
return compact(args.command ?? args.cmd ?? result.output) return compact(args.command ?? args.cmd ?? result.output)
} }
if (/^(?:read_file|write_file|edit_file|apply_patch|read|write|edit)$/u.test(name)) { 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_search") return compact(args.query ?? args.q)
if (name === "web_fetch") return compact(args.url) if (name === "web_fetch") return compact(args.url)
if (/session/u.test(name)) return compact(args.session_key ?? args.chat_id ?? args.query) 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 "" if (value == null || value === "") return ""
const text = typeof value === "string" ? value : JSON.stringify(value) const serialized = typeof value === "string" ? value : JSON.stringify(value)
return text.length > 88 ? `${text.slice(0, 85)}` : text 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> 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 // OpenTUI renders at 30 FPS. Re-parsing the entire Markdown buffer for every
// provider token turns long answers into quadratic work without producing any // provider token turns long answers into quadratic work without producing any
// additional visible frames. Paint the first token immediately, then coalesce // additional visible frames. Paint the first token immediately, then coalesce
@@ -78,6 +84,7 @@ export class Transcript {
private readonly treeSitterClient: TreeSitterClient, private readonly treeSitterClient: TreeSitterClient,
private readonly onNavigationChange?: (state: TranscriptNavigation) => void, private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
private readonly showHeader = true, private readonly showHeader = true,
private readonly workspace = "",
) { ) {
this.root = new ScrollBoxRenderable(renderer, { this.root = new ScrollBoxRenderable(renderer, {
id: "nanobot-tui-transcript", id: "nanobot-tui-transcript",
@@ -424,7 +431,7 @@ export class Transcript {
const key = event.call_id ? `tool:${event.call_id}` : "" const key = event.call_id ? `tool:${event.call_id}` : ""
const merged = key ? mergeToolEvent(activity.events.get(key), event) : event const merged = key ? mergeToolEvent(activity.events.get(key), event) : event
if (key) activity.events.set(key, merged) 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 const lines = events.length > 0
? projected.map(({ line }) => line).filter(Boolean) ? projected.map(({ line }) => line).filter(Boolean)
@@ -448,9 +455,14 @@ export class Transcript {
activity.text.content = activity.lines.join("\n") activity.text.content = activity.lines.join("\n")
return return
} }
const visible = activity.lines.slice(-(ACTIVITY_PREVIEW_LINES - 1)) const visible = activityPreview(activity.lines).slice(-(ACTIVITY_PREVIEW_LINES - 1))
const hidden = activity.lines.length - visible.length const visibleSteps = visible.reduce((total, item) => total + item.steps, 0)
activity.text.content = [`${hidden} earlier steps · Ctrl+O expand`, ...visible].join("\n") 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( private createText(
@@ -542,6 +554,22 @@ function cleanProgress(value: string): string {
return text ? ` · ${text}` : "" 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 { function formatDiffStat(edit: FileEditEvent): string {
const added = typeof edit.added === "number" ? `+${edit.added}` : "" const added = typeof edit.added === "number" ? `+${edit.added}` : ""
const deleted = typeof edit.deleted === "number" ? `-${edit.deleted}` : "" const deleted = typeof edit.deleted === "number" ? `-${edit.deleted}` : ""