mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat(tui): complete session-native terminal workflow
This commit is contained in:
@@ -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