mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
feat(tui): complete session-native terminal workflow
This commit is contained in:
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, or `/context` to inspect the compacted summary and raw message suffix available to the agent. Press `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another one. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts a local gateway only when needed and releases it when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
||||
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Press `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another one. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts a local gateway only when needed and releases it when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
||||
|
||||
For one request and an immediate exit, use:
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
|
||||
|
||||
Inside the native TUI, `/sessions` switches saved conversations, `/new-chat` starts another saved
|
||||
conversation, and `/context` explains the compacted summary and raw session suffix available to
|
||||
the next agent turn. `PageUp` loads older transcript pages when you reach the top. The default
|
||||
the next agent turn. `/diff` opens the latest turn's file changes as a full-screen unified diff.
|
||||
`PageUp` loads older transcript pages when you reach the top. The default
|
||||
launch returns to the last attached TUI session; `--session` selects a specific session instead.
|
||||
|
||||
## Session Storage and Rollback
|
||||
@@ -123,7 +124,7 @@ Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the s
|
||||
|
||||
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably.
|
||||
|
||||
`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall recent prompts. Type `/` to discover nanobot commands and terminal navigation in one palette, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` opens a searchable conversation picker, while `/new-chat` preserves the current conversation and starts another one. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||
`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Type `/` to discover nanobot commands and terminal navigation in one palette, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` opens a searchable conversation picker, while `/new-chat` preserves the current conversation and starts another one. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||
|
||||
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
|
||||
|
||||
|
||||
@@ -26,3 +26,7 @@ press `PageUp` at the top to load them in place.
|
||||
summary, replayable raw suffix, and an estimated token count. It deliberately does not expose
|
||||
private reasoning and does not pretend to be the complete model prompt; workspace instructions,
|
||||
memory, and skills are assembled separately by the Python runtime.
|
||||
|
||||
`/diff` opens the latest turn's file changes in a full-screen unified diff. Use `Left`/`Right`
|
||||
to switch edits, `PageUp`/`PageDown` or `Home`/`End` to navigate, and `Esc` to return to chat.
|
||||
The gateway remains the source of the patch; the TUI never rereads workspace files to rebuild it.
|
||||
|
||||
+148
-5
@@ -417,9 +417,9 @@ describe("NanobotTui layout", () => {
|
||||
try {
|
||||
ui.composer.setText("/sessions")
|
||||
ui.composer.submit()
|
||||
await Bun.sleep(10)
|
||||
await waitUntil(() => resolveFetch !== undefined)
|
||||
ui.composer.setText("release")
|
||||
resolveFetch?.(new Response(JSON.stringify({
|
||||
resolveFetch!(new Response(JSON.stringify({
|
||||
sessions: [
|
||||
{ key: "websocket:chat", title: "Current chat", preview: "Current work" },
|
||||
{ key: "websocket:other", title: "Release checklist", preview: "Ship it" },
|
||||
@@ -489,6 +489,93 @@ describe("NanobotTui layout", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("opens the latest turn diff as a full-screen, navigable view", async () => {
|
||||
setup = await createRenderer({ width: 96, height: 28, screenMode: "alternate-screen" })
|
||||
const app = mount(setup)
|
||||
app.accept({ event: "attached", chat_id: "chat" })
|
||||
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
||||
app.accept({ event: "message_accepted", chat_id: "chat", turn_id: "edit-turn" })
|
||||
app.accept({
|
||||
event: "file_edit",
|
||||
chat_id: "chat",
|
||||
edits: [{
|
||||
call_id: "edit-1",
|
||||
tool: "edit_file",
|
||||
path: "src/first.ts",
|
||||
status: "done",
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
diff: {
|
||||
format: "unified",
|
||||
truncated: true,
|
||||
text: [
|
||||
"--- a/src/first.ts",
|
||||
"+++ b/src/first.ts",
|
||||
"@@ -1 +1,2 @@",
|
||||
"-const oldValue = 1",
|
||||
"+const newValue = 2",
|
||||
"+export { newValue }",
|
||||
].join("\n"),
|
||||
},
|
||||
}, {
|
||||
call_id: "edit-2",
|
||||
tool: "write_file",
|
||||
path: "src/second.py",
|
||||
status: "done",
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
diff: {
|
||||
format: "unified",
|
||||
text: [
|
||||
"--- a/src/second.py",
|
||||
"+++ b/src/second.py",
|
||||
"@@ -0,0 +1 @@",
|
||||
"+print('hello')",
|
||||
].join("\n"),
|
||||
},
|
||||
}],
|
||||
})
|
||||
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "edit-turn" })
|
||||
const ui = app as unknown as {
|
||||
composer: TextareaRenderable
|
||||
diffViewer: {
|
||||
visible: boolean
|
||||
scroll: { getChildren(): Array<{ addedBg?: { toInts(): number[] } }> }
|
||||
}
|
||||
}
|
||||
|
||||
ui.composer.setText("/diff")
|
||||
ui.composer.submit()
|
||||
await waitUntil(() => ui.diffViewer.visible)
|
||||
await setup.flush()
|
||||
let frame = setup.captureCharFrame()
|
||||
expect(frame).toContain("Diff · Last turn · 2 changes · +3 -1")
|
||||
expect(frame).toContain("1/2 · src/first.ts · +2 -1")
|
||||
expect(frame).toContain("const newValue = 2")
|
||||
expect(frame).toContain("Diff truncated by the gateway")
|
||||
expect(frame).not.toContain("Ask nanobot anything")
|
||||
|
||||
setup.mockInput.pressArrow("right")
|
||||
await setup.flush()
|
||||
frame = setup.captureCharFrame()
|
||||
expect(frame).toContain("2/2 · src/second.py · +1 -0")
|
||||
expect(frame).toContain("print('hello')")
|
||||
|
||||
setup.renderer.emit(CliRenderEvents.THEME_MODE, "light")
|
||||
await setup.flush()
|
||||
expect(ui.diffViewer.scroll.getChildren()[0]?.addedBg?.toInts().slice(0, 3)).toEqual([231, 246, 236])
|
||||
expect(setup.captureCharFrame()).toContain("print('hello')")
|
||||
|
||||
setup.resize(52, 18)
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("←/→ file · pgup/pgdn · esc")
|
||||
|
||||
setup.mockInput.pressEscape()
|
||||
await waitUntil(() => !ui.diffViewer.visible)
|
||||
await setup.flush()
|
||||
expect(setup.captureCharFrame()).toContain("Ask nanobot anything")
|
||||
})
|
||||
|
||||
test("loads earlier transcript pages in place when PageUp reaches the top", async () => {
|
||||
setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
|
||||
const original = globalThis.fetch
|
||||
@@ -531,6 +618,12 @@ describe("NanobotTui layout", () => {
|
||||
expect(frame.indexOf("oldest question")).toBeLessThan(frame.indexOf("recent question"))
|
||||
expect(frame.indexOf("oldest answer")).toBeLessThan(frame.indexOf("recent answer"))
|
||||
expect((app as unknown as { historyHasMore: boolean }).historyHasMore).toBe(false)
|
||||
|
||||
const composer = (app as unknown as { composer: TextareaRenderable }).composer
|
||||
setup.mockInput.pressArrow("up")
|
||||
expect(composer.plainText).toBe("recent question")
|
||||
setup.mockInput.pressArrow("up")
|
||||
expect(composer.plainText).toBe("oldest question")
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
@@ -598,13 +691,17 @@ describe("NanobotTui layout", () => {
|
||||
composerFrame: { backgroundColor: { intent: string } }
|
||||
composer: { backgroundColor: { intent: string } }
|
||||
}
|
||||
const spans = setup.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const lines = setup.captureSpans().lines
|
||||
const spans = lines.flatMap((line) => line.spans)
|
||||
const brandedRows = lines.filter((line) => (
|
||||
line.spans.some((span) => span.bg.intent !== "default")
|
||||
))
|
||||
|
||||
expect(internals.shell.backgroundColor.intent).toBe("default")
|
||||
expect(internals.composerFrame.backgroundColor.intent).toBe("default")
|
||||
expect(internals.composer.backgroundColor.intent).toBe("default")
|
||||
expect(spans.length).toBeGreaterThan(0)
|
||||
expect(spans.every((span) => span.bg.intent === "default")).toBe(true)
|
||||
expect(brandedRows).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("rethemes the complete retained interface when the terminal appearance changes", async () => {
|
||||
@@ -623,12 +720,18 @@ describe("NanobotTui layout", () => {
|
||||
transcript: {
|
||||
markdown: Set<{ syntaxStyle: object }>
|
||||
frames: Set<{ borderColor: { toInts(): number[] } }>
|
||||
userRows: Set<{ backgroundColor: { intent: string; toInts(): number[] } }>
|
||||
user(content: string): void
|
||||
}
|
||||
}
|
||||
internals.transcript.user("Existing question")
|
||||
const userRow = [...internals.transcript.userRows][0]
|
||||
const markdown = [...internals.transcript.markdown][0]
|
||||
const sessionFrame = [...internals.transcript.frames][0]
|
||||
const darkSyntax = markdown?.syntaxStyle
|
||||
|
||||
expect(userRow?.backgroundColor.intent).toBe("default")
|
||||
|
||||
setup.renderer.emit(CliRenderEvents.THEME_MODE, "light")
|
||||
await setup.flush()
|
||||
|
||||
@@ -641,12 +744,18 @@ describe("NanobotTui layout", () => {
|
||||
expect(internals.composer.backgroundColor.intent).toBe("default")
|
||||
expect(internals.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27])
|
||||
expect(sessionFrame?.borderColor.toInts().slice(0, 3)).toEqual([212, 212, 216])
|
||||
expect(userRow?.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240])
|
||||
expect(markdown?.syntaxStyle).not.toBe(darkSyntax)
|
||||
})
|
||||
|
||||
test("uses asymmetric roles instead of chat bubbles", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 24, screenMode: "alternate-screen" })
|
||||
const app = mount(setup)
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
{ ...options, theme: "dark" },
|
||||
client(),
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
app.accept({ event: "attached", chat_id: "chat" })
|
||||
app.accept({ event: "delta", chat_id: "chat", text: "Agent **answer**" })
|
||||
app.accept({ event: "stream_end", chat_id: "chat" })
|
||||
@@ -666,6 +775,25 @@ describe("NanobotTui layout", () => {
|
||||
expect(agentLine).not.toContain("│")
|
||||
expect(headerLine).toContain("│")
|
||||
expect(headerBorder.trim().length).toBeLessThanOrEqual(62)
|
||||
|
||||
const transcript = (app as unknown as {
|
||||
transcript: {
|
||||
userRows: Set<{ backgroundColor: { intent: string; toInts(): number[] } }>
|
||||
styledText: Array<{
|
||||
renderable: { id: string; fg: { toInts(): number[] } }
|
||||
tone: string
|
||||
}>
|
||||
}
|
||||
}).transcript
|
||||
const userRow = [...transcript.userRows][0]
|
||||
const assistantMarker = transcript.styledText.find(({ renderable, tone }) => (
|
||||
tone === "muted" && renderable.id.includes("role-marker")
|
||||
))
|
||||
|
||||
expect(userRow?.backgroundColor.intent).toBe("rgb")
|
||||
expect(userRow?.backgroundColor.toInts().slice(0, 3)).toEqual([43, 44, 46])
|
||||
expect(assistantMarker?.tone).toBe("muted")
|
||||
expect(assistantMarker?.renderable.fg.toInts().slice(0, 3)).toEqual([161, 161, 170])
|
||||
})
|
||||
|
||||
test("keeps footer status and shortcuts visually separated", async () => {
|
||||
@@ -751,8 +879,17 @@ describe("NanobotTui layout", () => {
|
||||
|
||||
await app.start()
|
||||
|
||||
const transcript = (app as unknown as {
|
||||
transcript: {
|
||||
userRows: Set<{ backgroundColor: { intent: string } }>
|
||||
user(content: string): void
|
||||
}
|
||||
}).transcript
|
||||
transcript.user("Unknown terminal background")
|
||||
|
||||
expect(connected).toBe(true)
|
||||
expect((app as unknown as { palette: { referenceBackground: string } }).palette.referenceBackground).toBe("#0E0F11")
|
||||
expect([...transcript.userRows][0]?.backgroundColor.intent).toBe("default")
|
||||
})
|
||||
|
||||
test("keeps semantic colors legible in both terminal appearances", async () => {
|
||||
@@ -766,6 +903,12 @@ describe("NanobotTui layout", () => {
|
||||
expect(contrastRatio(internals.palette[tone] ?? "", internals.palette.referenceBackground)).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
expect(contrastRatio(internals.palette.faint, internals.palette.referenceBackground)).toBeGreaterThanOrEqual(3)
|
||||
const turnContrast = contrastRatio(
|
||||
internals.palette.userBackground ?? "",
|
||||
internals.palette.referenceBackground,
|
||||
)
|
||||
expect(turnContrast).toBeGreaterThan(1.05)
|
||||
expect(turnContrast).toBeLessThan(1.5)
|
||||
}
|
||||
|
||||
assertContrast()
|
||||
|
||||
+143
-10
@@ -20,6 +20,8 @@ import {
|
||||
fetchSessions,
|
||||
fetchSlashCommands,
|
||||
type ConnectionStatus,
|
||||
type FileEditEvent,
|
||||
type HistoryMessage,
|
||||
type InboundEvent,
|
||||
type SlashCommand,
|
||||
} from "./protocol"
|
||||
@@ -32,6 +34,12 @@ import {
|
||||
} from "./command-menu"
|
||||
import { SessionMenu } from "./session-menu"
|
||||
import { ContextPanel, type ContextPanelTheme } from "./context-panel"
|
||||
import {
|
||||
DiffViewer,
|
||||
latestTurnFileEdits,
|
||||
mergeFileEdits,
|
||||
type DiffViewerTheme,
|
||||
} from "./diff-viewer"
|
||||
import { Transcript, type TranscriptTheme } from "./transcript"
|
||||
import { rememberChat } from "./session-state"
|
||||
|
||||
@@ -67,6 +75,7 @@ interface Palette {
|
||||
success: string
|
||||
error: string
|
||||
user: string
|
||||
userBackground: string
|
||||
warm: string
|
||||
cool: string
|
||||
}
|
||||
@@ -81,6 +90,8 @@ const DARK: Palette = {
|
||||
success: "#5CC489",
|
||||
error: "#F87171",
|
||||
user: "#60A5FA",
|
||||
// Codex-style turn anchor: 12% white over the reference dark background.
|
||||
userBackground: "#2B2C2E",
|
||||
warm: "#C26A25",
|
||||
cool: "#1795A2",
|
||||
}
|
||||
@@ -95,6 +106,8 @@ const LIGHT: Palette = {
|
||||
success: "#166534",
|
||||
error: "#B91C1C",
|
||||
user: "#1D4ED8",
|
||||
// Codex-style turn anchor: 4% black over the reference light background.
|
||||
userBackground: "#F0F0F0",
|
||||
warm: "#C2410C",
|
||||
cool: "#0F766E",
|
||||
}
|
||||
@@ -119,6 +132,12 @@ const LOCAL_COMMANDS: TuiCommand[] = [
|
||||
description: "Explain what this session contributes to the next prompt",
|
||||
action: "context",
|
||||
},
|
||||
{
|
||||
command: "/diff",
|
||||
title: "Last turn diff",
|
||||
description: "Inspect file changes from the latest turn",
|
||||
action: "diff",
|
||||
},
|
||||
]
|
||||
|
||||
function syntaxStyle(palette: Palette): SyntaxStyle {
|
||||
@@ -147,13 +166,13 @@ function syntaxStyle(palette: Palette): SyntaxStyle {
|
||||
})
|
||||
}
|
||||
|
||||
function transcriptTheme(palette: Palette): TranscriptTheme {
|
||||
function transcriptTheme(palette: Palette, backgroundKnown: boolean): TranscriptTheme {
|
||||
return {
|
||||
text: palette.text,
|
||||
muted: palette.muted,
|
||||
error: palette.error,
|
||||
user: palette.user,
|
||||
assistant: palette.accent,
|
||||
userBackground: backgroundKnown ? palette.userBackground : null,
|
||||
border: palette.border,
|
||||
syntax: syntaxStyle(palette),
|
||||
}
|
||||
@@ -176,6 +195,21 @@ function contextPanelTheme(palette: Palette): ContextPanelTheme {
|
||||
}
|
||||
}
|
||||
|
||||
function diffViewerTheme(palette: Palette, backgroundKnown: boolean): DiffViewerTheme {
|
||||
const light = palette === LIGHT
|
||||
return {
|
||||
text: palette.text,
|
||||
muted: palette.muted,
|
||||
border: palette.border,
|
||||
accent: palette.accent,
|
||||
success: palette.success,
|
||||
error: palette.error,
|
||||
addedBackground: backgroundKnown ? light ? "#E7F6EC" : "#142D22" : null,
|
||||
removedBackground: backgroundKnown ? light ? "#FCE8EA" : "#352024" : null,
|
||||
syntax: syntaxStyle(palette),
|
||||
}
|
||||
}
|
||||
|
||||
function formatElapsed(milliseconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
@@ -207,15 +241,19 @@ export class NanobotTui {
|
||||
private readonly commandMenu: CommandMenu
|
||||
private readonly sessionMenu: SessionMenu
|
||||
private readonly contextPanel: ContextPanel
|
||||
private readonly diffViewer: DiffViewer
|
||||
private readonly client: ChatClient
|
||||
private readonly shell: BoxRenderable
|
||||
private readonly title: TextRenderable
|
||||
private readonly title: BoxRenderable
|
||||
private readonly titleText: TextRenderable
|
||||
private readonly modelText: TextRenderable
|
||||
private readonly composerFrame: BoxRenderable
|
||||
private readonly composer: TextareaRenderable
|
||||
private readonly status: TextRenderable
|
||||
private readonly meta: TextRenderable
|
||||
private palette: Palette
|
||||
private activeThemeMode: ThemeMode
|
||||
private backgroundKnown: boolean
|
||||
private activeTurn = false
|
||||
private activeLabel = "Thinking"
|
||||
private activeStartedAt = 0
|
||||
@@ -242,6 +280,8 @@ export class NanobotTui {
|
||||
private sessionLoadId = 0
|
||||
private sessionLoading = false
|
||||
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
||||
private currentFileEdits: FileEditEvent[] = []
|
||||
private lastFileEdits: FileEditEvent[] = []
|
||||
|
||||
private constructor(
|
||||
renderer: CliRenderer,
|
||||
@@ -251,13 +291,23 @@ export class NanobotTui {
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.modelName = options.model
|
||||
this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null
|
||||
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
|
||||
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
|
||||
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
|
||||
this.transcript = new Transcript(
|
||||
renderer,
|
||||
transcriptTheme(this.palette, this.backgroundKnown),
|
||||
treeSitterClient,
|
||||
)
|
||||
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
|
||||
this.commandMenu.setCommands([], LOCAL_COMMANDS)
|
||||
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
|
||||
this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
|
||||
this.diffViewer = new DiffViewer(
|
||||
renderer,
|
||||
diffViewerTheme(this.palette, this.backgroundKnown),
|
||||
treeSitterClient,
|
||||
)
|
||||
this.client = client || new NanobotClient({
|
||||
url: options.wsUrl,
|
||||
chatId: options.chatId,
|
||||
@@ -278,13 +328,31 @@ export class NanobotTui {
|
||||
flexDirection: "column",
|
||||
backgroundColor: RGBA.defaultBackground(),
|
||||
})
|
||||
this.title = new TextRenderable(renderer, {
|
||||
this.title = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-title",
|
||||
content: `nanobot · ${this.modelName}`,
|
||||
width: "100%",
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: RGBA.defaultBackground(),
|
||||
})
|
||||
this.titleText = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-title-text",
|
||||
content: "nanobot · ",
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
fg: this.palette.muted,
|
||||
})
|
||||
this.modelText = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-model-text",
|
||||
content: this.modelName,
|
||||
height: 1,
|
||||
flexShrink: 1,
|
||||
fg: this.palette.muted,
|
||||
})
|
||||
this.title.add(this.titleText)
|
||||
this.title.add(this.modelText)
|
||||
this.composerFrame = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-composer-frame",
|
||||
width: "100%",
|
||||
@@ -364,6 +432,7 @@ export class NanobotTui {
|
||||
this.shell.add(this.title)
|
||||
this.shell.add(this.composerFrame)
|
||||
this.shell.add(statusRow)
|
||||
this.shell.add(this.diffViewer.root)
|
||||
this.renderer.root.add(this.shell)
|
||||
|
||||
this.renderer.keyInput.on("keypress", this.handleKey)
|
||||
@@ -452,6 +521,7 @@ export class NanobotTui {
|
||||
if (command?.source === "tui") {
|
||||
if (command.command.action === "sessions") void this.openSessions()
|
||||
else if (command.command.action === "context") void this.openContext()
|
||||
else if (command.command.action === "diff") this.openDiff()
|
||||
else this.startNewChat()
|
||||
return
|
||||
}
|
||||
@@ -486,6 +556,7 @@ export class NanobotTui {
|
||||
this.turnHadAnswer = false
|
||||
this.lastProgress = ""
|
||||
this.activeLabel = "Thinking"
|
||||
this.currentFileEdits = []
|
||||
this.setActive(true)
|
||||
}
|
||||
|
||||
@@ -547,6 +618,8 @@ export class NanobotTui {
|
||||
return
|
||||
case "file_edit":
|
||||
this.activeLabel = "Editing"
|
||||
this.currentFileEdits = mergeFileEdits(this.currentFileEdits, event.edits)
|
||||
if (this.diffViewer.visible) this.diffViewer.update(this.currentFileEdits)
|
||||
this.lastProgress = this.transcript.fileEdits(event.edits)
|
||||
this.setActive(true)
|
||||
return
|
||||
@@ -568,6 +641,9 @@ export class NanobotTui {
|
||||
if (event.turn_id) this.commandTurns.delete(event.turn_id)
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
this.transcript.finishActivity()
|
||||
if (this.currentFileEdits.length) this.lastFileEdits = this.currentFileEdits
|
||||
this.currentFileEdits = []
|
||||
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
|
||||
this.finalMessage = ""
|
||||
this.turnHadAnswer = false
|
||||
this.setActive(false)
|
||||
@@ -592,9 +668,15 @@ export class NanobotTui {
|
||||
this.setModel(event.model_name)
|
||||
return
|
||||
case "error":
|
||||
const commandLifecycle = event.turn_id ? this.commandTurns.get(event.turn_id) : undefined
|
||||
if (event.turn_id) this.commandTurns.delete(event.turn_id)
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
||||
if (!commandLifecycle || commandLifecycle === "agent_turn") {
|
||||
if (this.currentFileEdits.length) this.lastFileEdits = this.currentFileEdits
|
||||
this.currentFileEdits = []
|
||||
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
|
||||
}
|
||||
this.finalMessage = ""
|
||||
this.turnHadAnswer = false
|
||||
this.setActive(false)
|
||||
@@ -606,6 +688,9 @@ export class NanobotTui {
|
||||
try {
|
||||
if (restoring) {
|
||||
this.contextPanel.hide()
|
||||
this.diffViewer.hide()
|
||||
this.currentFileEdits = []
|
||||
this.lastFileEdits = []
|
||||
this.historyBeforeCursor = null
|
||||
this.historyHasMore = false
|
||||
this.historyLoadingOlder = false
|
||||
@@ -623,6 +708,9 @@ export class NanobotTui {
|
||||
this.historyBeforeCursor = history.beforeCursor
|
||||
this.historyHasMore = history.hasMoreBefore
|
||||
this.transcript.history(history.messages)
|
||||
this.restorePromptHistory(history.messages)
|
||||
this.lastFileEdits = latestTurnFileEdits(history.messages)
|
||||
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
|
||||
}
|
||||
} catch (error) {
|
||||
if (hydrationId !== this.hydrationId) return
|
||||
@@ -691,6 +779,18 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
private handleKey = (key: KeyEvent): void => {
|
||||
if (this.diffViewer.visible) {
|
||||
if (key.ctrl && key.name === "c") {
|
||||
const selected = this.renderer.getSelection()?.getSelectedText()
|
||||
if (selected) void this.copySelection(selected)
|
||||
} else if (this.diffViewer.handleKey(key) && !this.diffViewer.visible) {
|
||||
this.composer.focus()
|
||||
this.status.content = "Ready"
|
||||
this.updateMeta()
|
||||
}
|
||||
key.preventDefault()
|
||||
return
|
||||
}
|
||||
if (this.contextPanel.visible && key.name === "escape") {
|
||||
this.contextPanel.hide()
|
||||
this.updateMeta()
|
||||
@@ -827,18 +927,22 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
private applyTheme(mode: ThemeMode): void {
|
||||
if (this.activeThemeMode === mode) return
|
||||
const backgroundWasUnknown = !this.backgroundKnown
|
||||
this.backgroundKnown = true
|
||||
if (this.activeThemeMode === mode && !backgroundWasUnknown) return
|
||||
this.activeThemeMode = mode
|
||||
this.palette = mode === "light" ? LIGHT : DARK
|
||||
this.transcript.setTheme(transcriptTheme(this.palette))
|
||||
this.transcript.setTheme(transcriptTheme(this.palette, this.backgroundKnown))
|
||||
this.commandMenu.setTheme(commandMenuTheme(this.palette))
|
||||
this.sessionMenu.setTheme(commandMenuTheme(this.palette))
|
||||
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
||||
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
||||
this.composerFrame.borderColor = this.palette.border
|
||||
this.composer.textColor = this.palette.text
|
||||
this.composer.focusedTextColor = this.palette.text
|
||||
this.composer.cursorColor = this.palette.accent
|
||||
this.title.fg = this.palette.muted
|
||||
this.titleText.fg = this.palette.muted
|
||||
this.modelText.fg = this.palette.muted
|
||||
this.status.fg = this.palette.muted
|
||||
this.meta.fg = this.palette.faint
|
||||
}
|
||||
@@ -846,6 +950,7 @@ export class NanobotTui {
|
||||
private handleResize = (): void => {
|
||||
this.resizeComposer()
|
||||
this.contextPanel.resize(this.renderer.height)
|
||||
this.diffViewer.resize(this.renderer.width)
|
||||
this.title.visible = this.renderer.height >= 14
|
||||
this.updateMeta()
|
||||
}
|
||||
@@ -882,7 +987,7 @@ export class NanobotTui {
|
||||
|
||||
private setModel(model: string): void {
|
||||
this.modelName = model
|
||||
this.title.content = `nanobot · ${model}`
|
||||
this.modelText.content = model
|
||||
}
|
||||
|
||||
private resizeComposer(): void {
|
||||
@@ -1037,6 +1142,7 @@ export class NanobotTui {
|
||||
this.turnHadAnswer = false
|
||||
this.lastProgress = ""
|
||||
this.activeLabel = "Thinking"
|
||||
this.currentFileEdits = []
|
||||
this.setActive(true)
|
||||
} else if (lifecycle === "finalize_active_turn") {
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
@@ -1060,6 +1166,18 @@ export class NanobotTui {
|
||||
this.historyDraft = ""
|
||||
}
|
||||
|
||||
private restorePromptHistory(messages: HistoryMessage[], prepend = false): void {
|
||||
const restored = messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.content.trim())
|
||||
.filter(Boolean)
|
||||
const combined = prepend ? [...restored, ...this.promptHistory] : restored
|
||||
const compacted = combined.filter((content, index) => content !== combined[index - 1])
|
||||
this.promptHistory.splice(0, this.promptHistory.length, ...compacted.slice(-50))
|
||||
this.historyCursor = this.promptHistory.length
|
||||
this.historyDraft = ""
|
||||
}
|
||||
|
||||
private closeSessions(): void {
|
||||
this.sessionLoadId += 1
|
||||
this.sessionLoading = false
|
||||
@@ -1092,6 +1210,19 @@ export class NanobotTui {
|
||||
}
|
||||
}
|
||||
|
||||
private openDiff(): void {
|
||||
this.commandMenu.hide()
|
||||
this.sessionMenu.hide()
|
||||
this.contextPanel.hide()
|
||||
this.composer.setText("")
|
||||
this.composer.blur()
|
||||
const edits = this.currentFileEdits.length ? this.currentFileEdits : this.lastFileEdits
|
||||
this.diffViewer.show(edits)
|
||||
this.diffViewer.resize(this.renderer.width)
|
||||
this.status.content = edits.length ? "Last turn diff" : "No file changes in the last turn"
|
||||
this.updateMeta()
|
||||
}
|
||||
|
||||
private async loadOlderHistory(): Promise<void> {
|
||||
if (
|
||||
this.historyLoadingOlder
|
||||
@@ -1112,6 +1243,7 @@ export class NanobotTui {
|
||||
)
|
||||
if (hydrationId !== this.hydrationId || chatId !== this.client.activeChatId) return
|
||||
await this.transcript.prependHistory(history.messages)
|
||||
this.restorePromptHistory(history.messages, true)
|
||||
this.historyBeforeCursor = history.beforeCursor
|
||||
this.historyHasMore = history.hasMoreBefore
|
||||
this.status.content = history.hasMoreBefore
|
||||
@@ -1148,6 +1280,7 @@ export class NanobotTui {
|
||||
private handleDestroy = (): void => {
|
||||
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
||||
this.transcript.destroy()
|
||||
this.diffViewer.destroy()
|
||||
this.client.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||
|
||||
export type CommandMenuTheme = PickerMenuTheme
|
||||
|
||||
export type TuiCommandAction = "sessions" | "new-chat" | "context"
|
||||
export type TuiCommandAction = "sessions" | "new-chat" | "context" | "diff"
|
||||
|
||||
export interface TuiCommand {
|
||||
command: string
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { latestTurnFileEdits, mergeFileEdits } from "./diff-viewer"
|
||||
|
||||
describe("DiffViewer projection", () => {
|
||||
test("merges lifecycle frames without losing the final unified patch", () => {
|
||||
const edits = mergeFileEdits(
|
||||
[{ call_id: "edit-1", tool: "edit_file", path: "src/app.ts", status: "editing" }],
|
||||
[{
|
||||
call_id: "edit-1",
|
||||
tool: "edit_file",
|
||||
path: "src/app.ts",
|
||||
status: "done",
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
diff: { format: "unified", text: "--- a/src/app.ts\n+++ b/src/app.ts" },
|
||||
}],
|
||||
)
|
||||
|
||||
expect(edits).toHaveLength(1)
|
||||
expect(edits[0]?.status).toBe("done")
|
||||
expect(edits[0]?.diff?.text).toContain("+++ b/src/app.ts")
|
||||
})
|
||||
|
||||
test("uses only the newest user turn instead of leaking an older diff", () => {
|
||||
const oldEdit = {
|
||||
call_id: "old",
|
||||
tool: "edit_file",
|
||||
path: "old.ts",
|
||||
status: "done",
|
||||
diff: { format: "unified", text: "old" },
|
||||
}
|
||||
expect(latestTurnFileEdits([
|
||||
{ role: "user", content: "edit the old file" },
|
||||
{ role: "activity", content: "", fileEdits: [oldEdit] },
|
||||
{ role: "assistant", content: "done" },
|
||||
{ role: "user", content: "just explain it" },
|
||||
{ role: "assistant", content: "explained" },
|
||||
])).toEqual([])
|
||||
|
||||
expect(latestTurnFileEdits([
|
||||
{ role: "user", content: "edit the old file" },
|
||||
{ role: "activity", content: "", fileEdits: [oldEdit] },
|
||||
{ role: "assistant", content: "done" },
|
||||
])).toEqual([oldEdit])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
DiffRenderable,
|
||||
RGBA,
|
||||
ScrollBoxRenderable,
|
||||
SyntaxStyle,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type CliRenderer,
|
||||
type KeyEvent,
|
||||
type TreeSitterClient,
|
||||
} from "@opentui/core"
|
||||
|
||||
import type { FileEditEvent, HistoryMessage } from "./protocol"
|
||||
|
||||
export interface DiffViewerTheme {
|
||||
text: string
|
||||
muted: string
|
||||
border: string
|
||||
accent: string
|
||||
success: string
|
||||
error: string
|
||||
addedBackground: string | null
|
||||
removedBackground: string | null
|
||||
syntax: SyntaxStyle
|
||||
}
|
||||
|
||||
interface DiffItem {
|
||||
key: string
|
||||
edit: FileEditEvent
|
||||
}
|
||||
|
||||
function editKey(edit: FileEditEvent): string {
|
||||
return [edit.call_id, edit.tool, edit.path].filter(Boolean).join("|") || "unknown"
|
||||
}
|
||||
|
||||
/** Merge start/end frames without discarding the final patch payload. */
|
||||
export function mergeFileEdits(
|
||||
previous: FileEditEvent[],
|
||||
incoming: FileEditEvent[],
|
||||
): FileEditEvent[] {
|
||||
const edits = new Map(previous.map((edit) => [editKey(edit), edit]))
|
||||
for (const edit of incoming) {
|
||||
const key = editKey(edit)
|
||||
edits.set(key, { ...edits.get(key), ...edit })
|
||||
}
|
||||
return [...edits.values()]
|
||||
}
|
||||
|
||||
/** File edits from the newest user turn only; never leak an older turn into /diff. */
|
||||
export function latestTurnFileEdits(messages: HistoryMessage[]): FileEditEvent[] {
|
||||
let edits: FileEditEvent[] = []
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index]
|
||||
if (!message) continue
|
||||
if (message.role === "user") break
|
||||
if (message.fileEdits?.length) edits = mergeFileEdits(edits, message.fileEdits)
|
||||
}
|
||||
return edits
|
||||
}
|
||||
|
||||
function filetype(path: string): string | undefined {
|
||||
const extension = path.split(".").at(-1)?.toLocaleLowerCase()
|
||||
return ({
|
||||
c: "c",
|
||||
cc: "cpp",
|
||||
cpp: "cpp",
|
||||
css: "css",
|
||||
go: "go",
|
||||
html: "html",
|
||||
java: "java",
|
||||
js: "javascript",
|
||||
json: "json",
|
||||
jsx: "javascript",
|
||||
md: "markdown",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
rs: "rust",
|
||||
sh: "bash",
|
||||
sql: "sql",
|
||||
toml: "toml",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
} as Record<string, string | undefined>)[extension || ""]
|
||||
}
|
||||
|
||||
function stat(edit: FileEditEvent): string {
|
||||
const added = Math.max(0, edit.added || 0)
|
||||
const deleted = Math.max(0, edit.deleted || 0)
|
||||
return `+${added} -${deleted}`
|
||||
}
|
||||
|
||||
/** Full-screen, read-only projection of file-edit events owned by the session. */
|
||||
export class DiffViewer {
|
||||
readonly root: BoxRenderable
|
||||
private readonly header: TextRenderable
|
||||
private readonly fileHeader: TextRenderable
|
||||
private readonly scroll: ScrollBoxRenderable
|
||||
private readonly footer: TextRenderable
|
||||
private items: DiffItem[] = []
|
||||
private selected = 0
|
||||
|
||||
constructor(
|
||||
private readonly renderer: CliRenderer,
|
||||
private theme: DiffViewerTheme,
|
||||
private readonly treeSitterClient: TreeSitterClient,
|
||||
) {
|
||||
this.root = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-diff-viewer",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: 100,
|
||||
flexDirection: "column",
|
||||
padding: 1,
|
||||
backgroundColor: RGBA.defaultBackground(),
|
||||
visible: false,
|
||||
})
|
||||
this.header = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-diff-header",
|
||||
content: "Diff · Last turn",
|
||||
width: "100%",
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
fg: theme.text,
|
||||
attributes: TextAttributes.BOLD,
|
||||
})
|
||||
this.fileHeader = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-diff-file",
|
||||
content: "",
|
||||
width: "100%",
|
||||
minHeight: 1,
|
||||
maxHeight: 2,
|
||||
flexShrink: 0,
|
||||
fg: theme.muted,
|
||||
wrapMode: "word",
|
||||
})
|
||||
this.scroll = new ScrollBoxRenderable(renderer, {
|
||||
id: "nanobot-tui-diff-scroll",
|
||||
width: "100%",
|
||||
minHeight: 0,
|
||||
flexGrow: 1,
|
||||
scrollX: false,
|
||||
scrollY: true,
|
||||
viewportCulling: true,
|
||||
contentOptions: {
|
||||
flexDirection: "column",
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
},
|
||||
verticalScrollbarOptions: { visible: true },
|
||||
horizontalScrollbarOptions: { visible: false },
|
||||
})
|
||||
this.footer = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-diff-footer",
|
||||
content: "←/→ file · pgup/pgdn scroll · esc close",
|
||||
width: "100%",
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
fg: theme.muted,
|
||||
})
|
||||
this.root.add(this.header)
|
||||
this.root.add(this.fileHeader)
|
||||
this.root.add(this.scroll)
|
||||
this.root.add(this.footer)
|
||||
}
|
||||
|
||||
get visible(): boolean {
|
||||
return this.root.visible
|
||||
}
|
||||
|
||||
show(edits: FileEditEvent[]): void {
|
||||
this.update(edits)
|
||||
this.root.visible = true
|
||||
}
|
||||
|
||||
update(edits: FileEditEvent[]): void {
|
||||
const selectedKey = this.items[this.selected]?.key
|
||||
this.items = edits.map((edit) => ({ key: editKey(edit), edit }))
|
||||
this.selected = Math.max(0, selectedKey
|
||||
? this.items.findIndex((item) => item.key === selectedKey)
|
||||
: Math.min(this.selected, this.items.length - 1))
|
||||
if (this.selected < 0) this.selected = 0
|
||||
this.render()
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.root.visible = false
|
||||
}
|
||||
|
||||
handleKey(key: KeyEvent): boolean {
|
||||
if (!this.visible) return false
|
||||
if (key.name === "escape") {
|
||||
this.hide()
|
||||
return true
|
||||
}
|
||||
if (key.name === "pageup" || key.name === "pagedown") {
|
||||
const direction = key.name === "pageup" ? -1 : 1
|
||||
this.scroll.scrollBy(direction * Math.max(3, Math.floor(this.scroll.height * 0.75)))
|
||||
return true
|
||||
}
|
||||
if (key.name === "home") {
|
||||
this.scroll.scrollTo(0)
|
||||
return true
|
||||
}
|
||||
if (key.name === "end") {
|
||||
this.scroll.scrollTo(this.scroll.scrollHeight)
|
||||
return true
|
||||
}
|
||||
if (["[", "]", "left", "right"].includes(key.name)) {
|
||||
this.select(key.name === "[" || key.name === "left" ? -1 : 1)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
resize(width: number): void {
|
||||
this.footer.content = width >= 58
|
||||
? "←/→ file · pgup/pgdn scroll · home/end · esc close"
|
||||
: "←/→ file · pgup/pgdn · esc"
|
||||
}
|
||||
|
||||
setTheme(theme: DiffViewerTheme): void {
|
||||
const previousSyntax = this.theme.syntax
|
||||
this.theme = theme
|
||||
this.header.fg = theme.text
|
||||
this.fileHeader.fg = theme.muted
|
||||
this.footer.fg = theme.muted
|
||||
this.render()
|
||||
void this.renderer.idle().catch(() => {}).finally(() => previousSyntax.destroy())
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.theme.syntax.destroy()
|
||||
}
|
||||
|
||||
private select(direction: -1 | 1): void {
|
||||
if (this.items.length < 2) return
|
||||
this.selected = (this.selected + direction + this.items.length) % this.items.length
|
||||
this.render()
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
for (const child of [...this.scroll.getChildren()]) {
|
||||
this.scroll.remove(child)
|
||||
child.destroyRecursively()
|
||||
}
|
||||
const totalAdded = this.items.reduce((sum, item) => sum + Math.max(0, item.edit.added || 0), 0)
|
||||
const totalDeleted = this.items.reduce((sum, item) => sum + Math.max(0, item.edit.deleted || 0), 0)
|
||||
const count = this.items.length
|
||||
this.header.content = count
|
||||
? `Diff · Last turn · ${count} ${count === 1 ? "change" : "changes"} · +${totalAdded} -${totalDeleted}`
|
||||
: "Diff · Last turn"
|
||||
const item = this.items[this.selected]
|
||||
if (!item) {
|
||||
this.fileHeader.content = "No file changes in the last turn."
|
||||
this.scroll.add(this.text("Run an editing task, then use /diff to inspect its patch.", "muted"))
|
||||
return
|
||||
}
|
||||
const edit = item.edit
|
||||
const index = this.items.length > 1 ? `${this.selected + 1}/${this.items.length} · ` : ""
|
||||
const state = edit.status === "editing" ? " · editing" : edit.status === "error" ? " · failed" : ""
|
||||
this.fileHeader.content = `${index}${edit.path || "Unknown file"} · ${stat(edit)}${state}`
|
||||
const text = edit.diff?.format === "unified" ? edit.diff.text?.trimEnd() : ""
|
||||
if (text) {
|
||||
this.scroll.add(new DiffRenderable(this.renderer, {
|
||||
id: `nanobot-tui-diff-${this.selected}`,
|
||||
diff: text,
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
flexShrink: 0,
|
||||
view: "unified",
|
||||
wrapMode: "char",
|
||||
showLineNumbers: true,
|
||||
fg: this.theme.text,
|
||||
filetype: filetype(edit.path || ""),
|
||||
syntaxStyle: this.theme.syntax,
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
lineNumberFg: this.theme.muted,
|
||||
lineNumberBg: RGBA.defaultBackground(),
|
||||
contextBg: RGBA.defaultBackground(),
|
||||
contextContentBg: RGBA.defaultBackground(),
|
||||
addedBg: this.theme.addedBackground || RGBA.defaultBackground(),
|
||||
addedContentBg: this.theme.addedBackground || RGBA.defaultBackground(),
|
||||
addedLineNumberBg: this.theme.addedBackground || RGBA.defaultBackground(),
|
||||
removedBg: this.theme.removedBackground || RGBA.defaultBackground(),
|
||||
removedContentBg: this.theme.removedBackground || RGBA.defaultBackground(),
|
||||
removedLineNumberBg: this.theme.removedBackground || RGBA.defaultBackground(),
|
||||
addedSignColor: this.theme.success,
|
||||
removedSignColor: this.theme.error,
|
||||
selectionBg: this.theme.border,
|
||||
selectionFg: this.theme.text,
|
||||
}))
|
||||
if (edit.diff?.truncated) {
|
||||
this.scroll.add(this.text("… Diff truncated by the gateway; line counts may be larger.", "accent"))
|
||||
}
|
||||
return
|
||||
}
|
||||
const message = edit.status === "editing"
|
||||
? "The file is still being edited; its patch will appear when the tool finishes."
|
||||
: edit.status === "error"
|
||||
? edit.error || "The edit failed before a patch was produced."
|
||||
: edit.binary
|
||||
? "Binary file changed; an inline text diff is unavailable."
|
||||
: "This edit has no renderable patch."
|
||||
this.scroll.add(this.text(message, edit.status === "error" ? "error" : "muted"))
|
||||
}
|
||||
|
||||
private text(content: string, tone: "muted" | "error" | "accent"): TextRenderable {
|
||||
return new TextRenderable(this.renderer, {
|
||||
id: `nanobot-tui-diff-note-${this.selected}-${tone}`,
|
||||
content,
|
||||
width: "100%",
|
||||
minHeight: 1,
|
||||
fg: this.theme[tone],
|
||||
wrapMode: "word",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,57 @@ describe("gateway protocol", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("validates nested unified diff payloads at the websocket boundary", () => {
|
||||
const original = globalThis.WebSocket
|
||||
let socket: FakeSocket | undefined
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor() {
|
||||
super()
|
||||
socket = this
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const events: InboundEvent[] = []
|
||||
const statuses: string[] = []
|
||||
const client = new NanobotClient({
|
||||
url: "ws://nanobot.test/ws",
|
||||
onEvent: (event) => events.push(event),
|
||||
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
|
||||
})
|
||||
client.connect()
|
||||
if (!socket) throw new Error("socket was not created")
|
||||
socket.emit("message", { data: JSON.stringify({
|
||||
event: "file_edit",
|
||||
chat_id: "chat",
|
||||
edits: [{
|
||||
call_id: "edit-1",
|
||||
tool: "edit_file",
|
||||
path: "src/app.ts",
|
||||
status: "done",
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
diff: { format: "unified", truncated: false, text: "--- a\n+++ b" },
|
||||
}],
|
||||
}) })
|
||||
socket.emit("message", { data: JSON.stringify({
|
||||
event: "file_edit",
|
||||
chat_id: "chat",
|
||||
edits: [{ diff: { format: "unified", text: ["not", "a", "string"] } }],
|
||||
}) })
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.event).toBe("file_edit")
|
||||
expect(statuses).toContain("error:gateway sent an invalid event")
|
||||
client.close()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
test("reattaches the same generated chat after a transient disconnect", async () => {
|
||||
const original = globalThis.WebSocket
|
||||
const sockets: FakeSocket[] = []
|
||||
|
||||
+26
-2
@@ -17,11 +17,23 @@ export interface FileEditEvent {
|
||||
call_id?: string
|
||||
tool?: string
|
||||
path?: string
|
||||
absolute_path?: string
|
||||
phase?: "start" | "end" | "error" | string
|
||||
added?: number
|
||||
deleted?: number
|
||||
approximate?: boolean
|
||||
status?: "editing" | "done" | "error" | string
|
||||
operation?: "edit" | "delete" | string
|
||||
binary?: boolean
|
||||
error?: string
|
||||
diff?: FileDiff
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
format: "unified" | string
|
||||
context?: number
|
||||
truncated?: boolean
|
||||
text?: string
|
||||
}
|
||||
|
||||
export type InboundEvent =
|
||||
@@ -174,11 +186,23 @@ function isFileEdit(value: unknown): value is FileEditEvent {
|
||||
&& optional(value.call_id, "string")
|
||||
&& optional(value.tool, "string")
|
||||
&& optional(value.path, "string")
|
||||
&& optional(value.absolute_path, "string")
|
||||
&& optional(value.phase, "string")
|
||||
&& optional(value.status, "string")
|
||||
&& optional(value.added, "number")
|
||||
&& optional(value.deleted, "number")
|
||||
&& optional(value.approximate, "boolean")
|
||||
&& optional(value.operation, "string")
|
||||
&& optional(value.binary, "boolean")
|
||||
&& optional(value.error, "string")
|
||||
&& (value.diff === undefined || isFileDiff(value.diff))
|
||||
}
|
||||
|
||||
function isFileDiff(value: unknown): value is FileDiff {
|
||||
if (!isRecord(value) || typeof value.format !== "string") return false
|
||||
return optional(value.context, "number")
|
||||
&& optional(value.truncated, "boolean")
|
||||
&& optional(value.text, "string")
|
||||
}
|
||||
|
||||
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||
@@ -261,10 +285,10 @@ export async function fetchHistory(
|
||||
? message.traces.filter((value): value is string => typeof value === "string")
|
||||
: []
|
||||
const toolEvents = Array.isArray(message.toolEvents)
|
||||
? message.toolEvents as ToolProgressEvent[]
|
||||
? message.toolEvents.filter(isToolEvent)
|
||||
: undefined
|
||||
const fileEdits = Array.isArray(message.fileEdits)
|
||||
? message.fileEdits as FileEditEvent[]
|
||||
? message.fileEdits.filter(isFileEdit)
|
||||
: undefined
|
||||
const activity = traces.join("\n") || (typeof content === "string" ? content : "")
|
||||
return [{ role: "activity", content: activity, toolEvents, fileEdits }]
|
||||
|
||||
+19
-4
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
MarkdownRenderable,
|
||||
RGBA,
|
||||
ScrollBoxRenderable,
|
||||
SyntaxStyle,
|
||||
TextAttributes,
|
||||
@@ -16,7 +17,7 @@ export interface TranscriptTheme {
|
||||
muted: string
|
||||
error: string
|
||||
user: string
|
||||
assistant: string
|
||||
userBackground: string | null
|
||||
border: string
|
||||
syntax: SyntaxStyle
|
||||
}
|
||||
@@ -44,11 +45,12 @@ export class Transcript {
|
||||
private activity: Activity | null = null
|
||||
private readonly styledText: Array<{
|
||||
renderable: TextRenderable
|
||||
tone: "text" | "muted" | "error" | "user" | "assistant"
|
||||
tone: "text" | "muted" | "error" | "user"
|
||||
}> = []
|
||||
private readonly markdown = new Set<MarkdownRenderable>()
|
||||
private readonly activities = new Set<Activity>()
|
||||
private readonly frames = new Set<BoxRenderable>()
|
||||
private readonly userRows = new Set<BoxRenderable>()
|
||||
private wrote = false
|
||||
private nextId = 0
|
||||
|
||||
@@ -87,6 +89,11 @@ export class Transcript {
|
||||
for (const { renderable, tone } of this.styledText) renderable.fg = theme[tone]
|
||||
for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax
|
||||
for (const frame of this.frames) frame.borderColor = theme.border
|
||||
for (const row of this.userRows) {
|
||||
row.backgroundColor = theme.userBackground
|
||||
? RGBA.fromHex(theme.userBackground)
|
||||
: RGBA.defaultBackground()
|
||||
}
|
||||
// Markdown may still be rendering this frame. Release the prior native
|
||||
// style only after the renderer reaches idle, matching OpenCode's retained
|
||||
// theme lifecycle and avoiding both leaks and use-after-free transitions.
|
||||
@@ -129,6 +136,7 @@ export class Transcript {
|
||||
this.markdown.clear()
|
||||
this.activities.clear()
|
||||
this.frames.clear()
|
||||
this.userRows.clear()
|
||||
this.wrote = false
|
||||
this.nextId = 0
|
||||
this.header(header)
|
||||
@@ -264,6 +272,7 @@ export class Transcript {
|
||||
this.live = null
|
||||
this.activity = null
|
||||
this.frames.clear()
|
||||
this.userRows.clear()
|
||||
this.theme.syntax.destroy()
|
||||
}
|
||||
|
||||
@@ -333,7 +342,7 @@ export class Transcript {
|
||||
|
||||
private createText(
|
||||
content: string,
|
||||
tone: "text" | "muted" | "error" | "user" | "assistant",
|
||||
tone: "text" | "muted" | "error" | "user",
|
||||
bold = false,
|
||||
id = "text",
|
||||
): TextRenderable {
|
||||
@@ -356,6 +365,12 @@ export class Transcript {
|
||||
index?: number,
|
||||
): void {
|
||||
const row = this.createRow(tone === "user" ? "user" : "notice", "row")
|
||||
if (tone === "user") {
|
||||
row.backgroundColor = this.theme.userBackground
|
||||
? RGBA.fromHex(this.theme.userBackground)
|
||||
: RGBA.defaultBackground()
|
||||
this.userRows.add(row)
|
||||
}
|
||||
const prefix = this.createText(marker, tone, true, "role-marker")
|
||||
prefix.width = 2
|
||||
prefix.flexShrink = 0
|
||||
@@ -391,7 +406,7 @@ export class Transcript {
|
||||
|
||||
private writeAssistant(markdown: MarkdownRenderable, index?: number): BoxRenderable {
|
||||
const row = this.createRow("assistant", "row")
|
||||
const prefix = this.createText("•", "assistant", false, "role-marker")
|
||||
const prefix = this.createText("•", "muted", false, "role-marker")
|
||||
prefix.width = 2
|
||||
prefix.flexShrink = 0
|
||||
row.add(prefix)
|
||||
|
||||
Reference in New Issue
Block a user