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
+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)