mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
fix(tui): reduce redundant runtime chrome
This commit is contained in:
+5
-1
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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}` : ""
|
||||||
|
|||||||
Reference in New Issue
Block a user