From 8395bc825b4d13080dea059d48efc3ecdd8b0083 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:10:14 +0900 Subject: [PATCH] refactor(tui): clarify conversation hierarchy --- tui/src/app.test.ts | 47 +++++++++++++++++++--- tui/src/app.ts | 40 +++++++++++++++---- tui/src/transcript.ts | 91 ++++++++++++++++++++++++++----------------- 3 files changed, 129 insertions(+), 49 deletions(-) diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index be86ecf93..dcaf12c5e 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -235,7 +235,7 @@ describe("NanobotTui layout", () => { if (width >= 30 && height >= 9) { expect(occurrences(frame, "Ask nanobot anything")).toBe(1) } - expect(occurrences(frame, "nanobot · test/model")).toBe(height >= 12 ? 1 : 0) + expect(occurrences(frame, "nanobot · test/model")).toBe(height >= 14 ? 1 : 0) } }) @@ -282,7 +282,6 @@ describe("NanobotTui layout", () => { shell: { backgroundColor: { intent: string } } composer: { backgroundColor: { intent: string }; textColor: { toInts(): number[] } } transcript: { - frames: Set<{ borderColor: { toInts(): number[] } }> markdown: Set<{ syntaxStyle: object }> } } @@ -300,12 +299,50 @@ describe("NanobotTui layout", () => { expect(internals.shell.backgroundColor.intent).toBe("default") expect(internals.composer.backgroundColor.intent).toBe("default") expect(internals.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27]) - expect([...internals.transcript.frames][0]?.borderColor.toInts().slice(0, 3)).toEqual([ - 212, 212, 216, - ]) 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) + 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" }) + app.accept({ event: "turn_end", chat_id: "chat" }) + ;(app as unknown as { transcript: { user(content: string): void } }).transcript.user("User question") + await setup.flush() + + const frame = setup.captureCharFrame() + const userLine = frame.split("\n").find((line) => line.includes("User question")) || "" + const agentLine = frame.split("\n").find((line) => line.includes("Agent **answer**")) || "" + const headerLine = frame.split("\n").find((line) => line.includes(">_ nanobot")) || "" + + expect(userLine).toContain("› User question") + expect(agentLine).toContain("• Agent **answer**") + expect(headerLine).not.toMatch(/[╭╮│]/u) + }) + + test("keeps footer status and shortcuts visually separated", async () => { + setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) + const app = mount(setup) + app.accept({ event: "attached", chat_id: "chat" }) + app.accept({ event: "turn_end", chat_id: "chat", latency_ms: 1700 }) + await setup.flush() + + const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || "" + expect(footer).toContain("Ready · 1.7s") + expect(footer).toContain("enter send") + expect(footer).not.toContain("1.7senter") + + app.accept({ event: "reasoning_delta", chat_id: "chat", text: "hidden" }) + await Bun.sleep(130) + await setup.renderOnce() + const activeFooter = setup.captureCharFrame().split("\n").find((line) => line.includes("Thinking")) || "" + expect(activeFooter).toContain("ctrl+c stop") + expect(activeFooter).not.toContain("enter send") + app.accept({ event: "turn_end", chat_id: "chat" }) + }) + test("keeps an explicit theme stable when the terminal reports another mode", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = NanobotTui.mount( diff --git a/tui/src/app.ts b/tui/src/app.ts index df59f6f11..86083c61b 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -116,7 +116,7 @@ function transcriptTheme(palette: Palette): TranscriptTheme { muted: palette.muted, error: palette.error, user: palette.user, - border: palette.border, + assistant: palette.accent, syntax: syntaxStyle(palette), } } @@ -176,6 +176,7 @@ export class NanobotTui { private readonly promptHistory: string[] = [] private historyCursor = 0 private historyDraft = "" + private modelName: string private quitting = false private constructor( @@ -185,6 +186,7 @@ export class NanobotTui { treeSitterClient = getTreeSitterClient(), ) { this.renderer = renderer + this.modelName = options.model this.activeThemeMode = this.resolveThemeMode(renderer.themeMode) this.palette = this.activeThemeMode === "light" ? LIGHT : DARK this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient) @@ -210,7 +212,7 @@ export class NanobotTui { }) this.title = new TextRenderable(renderer, { id: "nanobot-tui-title", - content: `nanobot · ${options.model}`, + content: `nanobot · ${this.modelName}`, height: 1, flexShrink: 0, fg: this.palette.muted, @@ -258,13 +260,18 @@ export class NanobotTui { content: "Connecting…", fg: this.palette.muted, height: 1, + width: "auto", + minWidth: 0, flexGrow: 1, + flexShrink: 1, }) this.meta = new TextRenderable(renderer, { id: "nanobot-tui-meta", content: "enter send · alt+enter newline · ctrl+c stop", fg: this.palette.faint, height: 1, + width: "auto", + flexShrink: 1, }) const statusRow = new BoxRenderable(renderer, { @@ -274,6 +281,7 @@ export class NanobotTui { flexShrink: 0, flexDirection: "row", justifyContent: "space-between", + gap: 2, }) this.composerFrame.add(this.composer) statusRow.add(this.status) @@ -465,10 +473,10 @@ export class NanobotTui { case "goal_state": return case "turn_model_updated": - this.title.content = `nanobot · ${event.model_name}` + this.setModel(event.model_name) return case "runtime_model_updated": - this.title.content = `nanobot · ${event.model_name}` + this.setModel(event.model_name) return case "error": this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage) @@ -484,7 +492,7 @@ export class NanobotTui { try { if (restoring) { this.transcript.reset({ - model: this.options.model, + model: this.modelName, workspace: this.options.workspace, version: this.options.version, access: this.options.access, @@ -544,6 +552,7 @@ export class NanobotTui { return } this.activeTurn = active + this.updateMeta() if (active) { this.activeStartedAt = startedAt ?? Date.now() this.shimmerFrame = 0 @@ -664,14 +673,29 @@ export class NanobotTui { private handleResize = (): void => { this.resizeComposer() - this.title.visible = this.renderer.height >= 12 - this.meta.content = this.renderer.width >= 72 + this.title.visible = this.renderer.height >= 14 + this.updateMeta() + } + + private updateMeta(): void { + if (this.activeTurn) { + this.meta.content = this.renderer.width >= 48 ? "ctrl+c stop" : "" + return + } + this.meta.content = this.renderer.width >= 112 ? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop" - : this.renderer.width >= 48 + : this.renderer.width >= 72 + ? "enter send · alt+enter newline · ctrl+c stop" + : this.renderer.width >= 48 ? "enter send · alt+enter newline" : "" } + private setModel(model: string): void { + this.modelName = model + this.title.content = `nanobot · ${model}` + } + private resizeComposer(): void { const maxHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3))) this.composer.maxHeight = maxHeight diff --git a/tui/src/transcript.ts b/tui/src/transcript.ts index d01b607d8..59ae7ea8a 100644 --- a/tui/src/transcript.ts +++ b/tui/src/transcript.ts @@ -16,7 +16,7 @@ export interface TranscriptTheme { muted: string error: string user: string - border: string + assistant: string syntax: SyntaxStyle } @@ -43,10 +43,9 @@ export class Transcript { private activity: Activity | null = null private readonly styledText: Array<{ renderable: TextRenderable - tone: "text" | "muted" | "error" | "user" + tone: "text" | "muted" | "error" | "user" | "assistant" }> = [] private readonly markdown = new Set() - private readonly frames = new Set() private readonly activities = new Set() private wrote = false private nextId = 0 @@ -85,7 +84,6 @@ export class Transcript { this.theme = theme 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 // 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. @@ -93,11 +91,16 @@ export class Transcript { } header(options: TranscriptHeader): void { - this.writeText([ - `>_ nanobot v${options.version}`, + const row = this.createRow("header") + const title = this.createText(`>_ nanobot v${options.version}`, "text", true) + const context = this.createText([ `${options.model} · ${options.access}`, options.workspace, - ].join("\n"), "text", true, true) + ].join("\n"), "muted") + row.add(title) + row.add(context) + this.root.add(row) + this.wrote = true } reset(header: TranscriptHeader): void { @@ -109,7 +112,6 @@ export class Transcript { this.activity = null this.styledText.length = 0 this.markdown.clear() - this.frames.clear() this.activities.clear() this.wrote = false this.nextId = 0 @@ -128,7 +130,7 @@ export class Transcript { user(content: string): void { this.finishActivity() - this.writeText(`› ${content}`, "user", true) + this.writeRole("›", content, "user") } assistant(content: string): void { @@ -139,19 +141,16 @@ export class Transcript { notice(content: string, error = false): void { this.finishActivity() - this.writeText(content, error ? "error" : "muted") + this.writeRole(error ? "×" : "·", content, error ? "error" : "muted") } stream(delta: string): void { if (!delta) return if (!this.live) { this.finishActivity() - const row = this.createRow() const markdown = this.createMarkdown("", true, "assistant-stream") - row.add(markdown) - this.root.add(row) + const row = this.writeAssistant(markdown) this.live = { row, markdown, content: "" } - this.wrote = true } this.live.content += delta this.live.markdown.content = this.live.content @@ -238,24 +237,17 @@ export class Transcript { return `${prefix}-${this.nextId}` } - private createRow(framed = false): BoxRenderable { - const row = new BoxRenderable(this.renderer, { - id: this.id(framed ? "text-frame" : "text-row"), + private createRow(kind = "row", direction: "column" | "row" = "column"): BoxRenderable { + return new BoxRenderable(this.renderer, { + id: this.id(`${kind}-row`), width: "100%", marginTop: this.wrote ? 1 : 0, - border: framed, - borderStyle: "rounded", - borderColor: this.theme.border, - paddingLeft: 1, - paddingRight: 1, - flexDirection: "column", + flexDirection: direction, }) - if (framed) this.frames.add(row) - return row } private createActivity(): Activity { - const row = this.createRow() + const row = this.createRow("activity") const text = new TextRenderable(this.renderer, { id: this.id("agent-activity"), content: "", @@ -282,24 +274,40 @@ export class Transcript { activity.text.content = [` … ${hidden} earlier steps · Ctrl+O expand`, ...visible].join("\n") } - private writeText( + private createText( content: string, - tone: "text" | "muted" | "error" | "user", + tone: "text" | "muted" | "error" | "user" | "assistant", bold = false, - framed = false, - ): void { - const row = this.createRow(framed) + id = "text", + ): TextRenderable { const text = new TextRenderable(this.renderer, { - id: this.id("text"), + id: this.id(id), content, width: "100%", wrapMode: "word", fg: this.theme[tone], attributes: bold ? TextAttributes.BOLD : 0, }) + this.styledText.push({ renderable: text, tone }) + return text + } + + private writeRole( + marker: string, + content: string, + tone: "muted" | "error" | "user", + ): void { + const row = this.createRow(tone === "user" ? "user" : "notice", "row") + const prefix = this.createText(marker, tone, true, "role-marker") + prefix.width = 2 + prefix.flexShrink = 0 + const text = this.createText(content, tone === "user" ? "text" : tone, false, "role-content") + text.width = "auto" + text.minWidth = 0 + text.flexGrow = 1 + row.add(prefix) row.add(text) this.root.add(row) - this.styledText.push({ renderable: text, tone }) this.wrote = true } @@ -307,7 +315,9 @@ export class Transcript { const markdown = new MarkdownRenderable(this.renderer, { id: this.id(id), content, - width: "100%", + width: "auto", + minWidth: 0, + flexGrow: 1, syntaxStyle: this.theme.syntax, streaming, internalBlockMode: "top-level", @@ -318,10 +328,19 @@ export class Transcript { } private writeMarkdown(content: string, streaming: boolean): void { - const row = this.createRow() - row.add(this.createMarkdown(content, streaming)) + this.writeAssistant(this.createMarkdown(content, streaming)) + } + + private writeAssistant(markdown: MarkdownRenderable): BoxRenderable { + const row = this.createRow("assistant", "row") + const prefix = this.createText("•", "assistant", false, "role-marker") + prefix.width = 2 + prefix.flexShrink = 0 + row.add(prefix) + row.add(markdown) this.root.add(row) this.wrote = true + return row } }