feat(tui): complete session-native terminal workflow

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 6301c0ab57
commit c0e9ce77ef
11 changed files with 766 additions and 25 deletions
+1 -1
View File
@@ -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:
+3 -2
View File
@@ -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`.
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
}
+1 -1
View File
@@ -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
+47
View File
@@ -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])
})
})
+323
View File
@@ -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",
})
}
}
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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)