fix(tui): reflow layout on terminal resize

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent ce070c832d
commit 5feb21543c
4 changed files with 138 additions and 86 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ workspace file. Back up both the config directory and workspace before changing
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, the command starts one for the lifetime of the terminal UI and stops it on exit. Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, the command starts one for the lifetime of the terminal UI and stops it on exit.
`Enter` sends the current message. Press `Alt+Enter` to add a newline. `Ctrl+C` stops a running turn, clears a non-empty composer, or exits when idle. Normal terminal scrollback remains available above the fixed composer. `Enter` sends the current message. Press `Alt+Enter` to add a newline. `Ctrl+C` stops a running turn, clears a non-empty composer, or exits when idle. The transcript scrolls inside the TUI and reflows when the terminal is resized; exiting restores the previous terminal screen.
Packaged releases fetch a version-matched, checksummed terminal binary for macOS, Linux, or Windows on first use and cache it under the nanobot data directory. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`. Packaged releases fetch a version-matched, checksummed terminal binary for macOS, Linux, or Windows on first use and cache it under the nanobot data directory. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
+1 -1
View File
@@ -11,4 +11,4 @@ bun run --cwd tui build
`nanobot agent` launches this client, attaches to an existing local gateway or leases one for the process lifetime, and passes an authenticated local endpoint through environment variables. Use `nanobot agent --classic` to run the legacy Python prompt. `nanobot agent` launches this client, attaches to an existing local gateway or leases one for the process lifetime, and passes an authenticated local endpoint through environment variables. Use `nanobot agent --classic` to run the legacy Python prompt.
The renderer uses OpenTUI's split-footer mode: transcript rows are committed to native terminal scrollback while the composer remains fixed at the bottom. This preserves normal terminal selection and scrolling instead of implementing a second scroll model. The renderer uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { NanobotTui, type AppOptions } from "./app"
const options: AppOptions = {
wsUrl: "ws://localhost.invalid/ws",
apiUrl: "",
apiToken: "",
model: "test/model",
workspace: "/tmp/nanobot-workspace",
version: "test",
access: "workspace access",
}
function occurrences(frame: string, value: string): number {
return frame.split(value).length - 1
}
describe("NanobotTui layout", () => {
let setup: TestRendererSetup | undefined
afterEach(() => setup?.renderer.destroy())
test("reflows a single retained layout across terminal resizes", async () => {
setup = await createTestRenderer({
width: 100,
height: 30,
screenMode: "alternate-screen",
consoleMode: "disabled",
})
NanobotTui.mount(setup.renderer, options)
for (const [width, height] of [[100, 30], [56, 18], [118, 36]] as const) {
setup.resize(width, height)
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(setup.renderer.width).toBe(width)
expect(setup.renderer.height).toBe(height)
expect(occurrences(frame, "Ask nanobot anything")).toBe(1)
expect(occurrences(frame, "Ready")).toBe(0)
expect(occurrences(frame, "Connecting…")).toBe(1)
expect(occurrences(frame, "nanobot · test/model")).toBe(1)
}
})
})
+82 -77
View File
@@ -3,6 +3,7 @@ import {
CliRenderEvents, CliRenderEvents,
MarkdownRenderable, MarkdownRenderable,
RGBA, RGBA,
ScrollBoxRenderable,
SyntaxStyle, SyntaxStyle,
TextareaRenderable, TextareaRenderable,
TextAttributes, TextAttributes,
@@ -11,7 +12,6 @@ import {
getTreeSitterClient, getTreeSitterClient,
type CliRenderer, type CliRenderer,
type KeyEvent, type KeyEvent,
type ScrollbackSurface,
} from "@opentui/core" } from "@opentui/core"
import { import {
@@ -99,28 +99,52 @@ function syntaxStyle(palette: Palette): SyntaxStyle {
} }
class Transcript { class Transcript {
private writeChain = Promise.resolve() readonly root: ScrollBoxRenderable
private live: { surface: ScrollbackSurface; text: TextRenderable; content: string } | null = null private live: { row: BoxRenderable; text: TextRenderable; content: string } | null = null
private wrote = false private wrote = false
private nextId = 0
constructor( constructor(
private readonly renderer: CliRenderer, private readonly renderer: CliRenderer,
private palette: Palette, private palette: Palette,
) {} ) {
this.root = new ScrollBoxRenderable(renderer, {
id: "nanobot-tui-transcript",
width: "100%",
minHeight: 0,
flexGrow: 1,
scrollX: false,
scrollY: true,
stickyScroll: true,
stickyStart: "bottom",
viewportCulling: true,
contentOptions: {
flexDirection: "column",
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 1,
paddingRight: 1,
},
verticalScrollbarOptions: { visible: false },
horizontalScrollbarOptions: { visible: false },
})
// Constructor options are applied before ScrollBarRenderable starts managing
// its own visibility. Assigning through the setters keeps both bars hidden.
this.root.verticalScrollBar.visible = false
this.root.horizontalScrollBar.visible = false
}
setPalette(palette: Palette): void { setPalette(palette: Palette): void {
this.palette = palette this.palette = palette
} }
header(options: AppOptions): void { header(options: AppOptions): void {
this.enqueue(async () => {
const lines = [ const lines = [
`>_ nanobot v${options.version}`, `>_ nanobot v${options.version}`,
`${options.model} · ${options.access}`, `${options.model} · ${options.access}`,
options.workspace, options.workspace,
] ]
await this.writeText(lines.join("\n"), this.palette.text, true, true) this.writeText(lines.join("\n"), this.palette.text, true, true)
})
} }
async history(messages: HistoryMessage[]): Promise<void> { async history(messages: HistoryMessage[]): Promise<void> {
@@ -128,90 +152,84 @@ class Transcript {
if (message.role === "user") this.user(message.content) if (message.role === "user") this.user(message.content)
else this.assistant(message.content) else this.assistant(message.content)
} }
await this.writeChain
} }
user(content: string): void { user(content: string): void {
this.enqueue(() => this.writeText(` ${content}`, this.palette.user, true)) this.writeText(` ${content}`, this.palette.user, true)
} }
assistant(content: string): void { assistant(content: string): void {
if (!content.trim()) return if (!content.trim()) return
this.enqueue(() => this.writeMarkdown(content)) this.writeMarkdown(content)
} }
notice(content: string, error = false): void { notice(content: string, error = false): void {
this.enqueue(() => this.writeText(content, error ? this.palette.error : this.palette.muted)) this.writeText(content, error ? this.palette.error : this.palette.muted)
} }
stream(delta: string): void { stream(delta: string): void {
if (!delta) return if (!delta) return
if (!this.live) { if (!this.live) {
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote }) const row = this.createRow()
const text = new TextRenderable(surface.renderContext, { const text = new TextRenderable(this.renderer, {
id: `assistant-stream-${Date.now()}`, id: this.id("assistant-stream"),
content: "", content: "",
width: "100%", width: "100%",
wrapMode: "word", wrapMode: "word",
fg: this.palette.text, fg: this.palette.text,
}) })
surface.root.add(text) row.add(text)
this.live = { surface, text, content: "" } this.root.add(row)
this.live = { row, text, content: "" }
this.wrote = true
} }
this.live.content += delta this.live.content += delta
this.live.text.content = this.live.content this.live.text.content = this.live.content
this.live.surface.render()
} }
finishStream(fallback = ""): void { finishStream(fallback = ""): void {
const content = this.live?.content || fallback const content = this.live?.content || fallback
if (this.live) { if (this.live) {
this.live.surface.destroy() this.root.remove(this.live.row)
this.live.row.destroy()
this.live = null this.live = null
} }
if (content.trim()) this.assistant(content) if (content.trim()) this.assistant(content)
} }
destroy(): void { destroy(): void {
this.live?.surface.destroy()
this.live = null this.live = null
} }
private enqueue(operation: () => Promise<void>): void { private id(prefix: string): string {
this.writeChain = this.writeChain.then(operation).catch((error) => { this.nextId += 1
console.error("transcript render failed", error) return `${prefix}-${this.nextId}`
})
} }
private async writeText( private createRow(framed = false): BoxRenderable {
content: string, return new BoxRenderable(this.renderer, {
color: string, id: this.id(framed ? "text-frame" : "text-row"),
bold = false,
framed = false,
): Promise<void> {
this.spacer()
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote })
const root = framed
? new BoxRenderable(surface.renderContext, {
id: `text-frame-${Date.now()}`,
width: "100%", width: "100%",
border: true, marginTop: this.wrote ? 1 : 0,
border: framed,
borderStyle: "rounded", borderStyle: "rounded",
borderColor: this.palette.border, borderColor: this.palette.border,
paddingLeft: 1, paddingLeft: 1,
paddingRight: 1, paddingRight: 1,
flexDirection: "column", flexDirection: "column",
}) })
: new BoxRenderable(surface.renderContext, { }
id: `text-row-${Date.now()}`,
width: "100%", private writeText(
paddingLeft: 1, content: string,
paddingRight: 1, color: string,
flexDirection: "column", bold = false,
}) framed = false,
root.add( ): void {
new TextRenderable(surface.renderContext, { const row = this.createRow(framed)
id: `text-${Date.now()}`, row.add(
new TextRenderable(this.renderer, {
id: this.id("text"),
content, content,
width: "100%", width: "100%",
wrapMode: "word", wrapMode: "word",
@@ -219,18 +237,14 @@ class Transcript {
attributes: bold ? TextAttributes.BOLD : 0, attributes: bold ? TextAttributes.BOLD : 0,
}), }),
) )
surface.root.add(root) this.root.add(row)
surface.render()
surface.commitRows(0, surface.height, { trailingNewline: true })
surface.destroy()
this.wrote = true this.wrote = true
} }
private async writeMarkdown(content: string): Promise<void> { private writeMarkdown(content: string): void {
this.spacer() const row = this.createRow()
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote }) const markdown = new MarkdownRenderable(this.renderer, {
const markdown = new MarkdownRenderable(surface.renderContext, { id: this.id("markdown"),
id: `markdown-${Date.now()}`,
content, content,
width: "100%", width: "100%",
syntaxStyle: syntaxStyle(this.palette), syntaxStyle: syntaxStyle(this.palette),
@@ -238,25 +252,10 @@ class Transcript {
internalBlockMode: "top-level", internalBlockMode: "top-level",
treeSitterClient: getTreeSitterClient(), treeSitterClient: getTreeSitterClient(),
}) })
surface.root.add(markdown) row.add(markdown)
await surface.settle() this.root.add(row)
surface.commitRows(0, surface.height, { trailingNewline: true })
surface.destroy()
this.wrote = true this.wrote = true
} }
private spacer(): void {
if (!this.wrote) return
this.renderer.writeToScrollback((context) => {
const root = new TextRenderable(context.renderContext, {
id: `spacer-${Date.now()}`,
content: "",
width: Math.max(1, context.width),
height: 1,
})
return { root, width: Math.max(1, context.width), height: 1, startOnNewLine: true, trailingNewline: true }
})
}
} }
export class NanobotTui { export class NanobotTui {
@@ -304,13 +303,14 @@ export class NanobotTui {
id: "nanobot-tui-title", id: "nanobot-tui-title",
content: `nanobot · ${options.model}`, content: `nanobot · ${options.model}`,
height: 1, height: 1,
flexShrink: 0,
fg: this.palette.muted, fg: this.palette.muted,
}) })
this.composerFrame = new BoxRenderable(renderer, { this.composerFrame = new BoxRenderable(renderer, {
id: "nanobot-tui-composer-frame", id: "nanobot-tui-composer-frame",
width: "100%", width: "100%",
minHeight: 3, height: 3,
flexGrow: 1, flexShrink: 0,
border: true, border: true,
borderStyle: "rounded", borderStyle: "rounded",
borderColor: this.palette.border, borderColor: this.palette.border,
@@ -356,12 +356,14 @@ export class NanobotTui {
id: "nanobot-tui-status-row", id: "nanobot-tui-status-row",
width: "100%", width: "100%",
height: 1, height: 1,
flexShrink: 0,
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
}) })
this.composerFrame.add(this.composer) this.composerFrame.add(this.composer)
statusRow.add(this.status) statusRow.add(this.status)
statusRow.add(this.meta) statusRow.add(this.meta)
this.shell.add(this.transcript.root)
this.shell.add(this.title) this.shell.add(this.title)
this.shell.add(this.composerFrame) this.shell.add(this.composerFrame)
this.shell.add(statusRow) this.shell.add(statusRow)
@@ -374,7 +376,6 @@ export class NanobotTui {
this.handleResize() this.handleResize()
this.composer.focus() this.composer.focus()
this.transcript.header(options) this.transcript.header(options)
this.client.connect()
} }
static async create(options: AppOptions): Promise<NanobotTui> { static async create(options: AppOptions): Promise<NanobotTui> {
@@ -382,15 +383,19 @@ export class NanobotTui {
targetFps: 30, targetFps: 30,
exitOnCtrlC: false, exitOnCtrlC: false,
useMouse: true, useMouse: true,
screenMode: "split-footer", screenMode: "alternate-screen",
footerHeight: 7, externalOutputMode: "passthrough",
externalOutputMode: "capture-stdout",
consoleMode: "disabled", consoleMode: "disabled",
}) })
return NanobotTui.mount(renderer, options)
}
static mount(renderer: CliRenderer, options: AppOptions): NanobotTui {
return new NanobotTui(renderer, options) return new NanobotTui(renderer, options)
} }
start(): void { start(): void {
this.client.connect()
this.renderer.start() this.renderer.start()
} }