mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
fix(tui): reflow layout on terminal resize
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
+89
-84
@@ -3,6 +3,7 @@ import {
|
||||
CliRenderEvents,
|
||||
MarkdownRenderable,
|
||||
RGBA,
|
||||
ScrollBoxRenderable,
|
||||
SyntaxStyle,
|
||||
TextareaRenderable,
|
||||
TextAttributes,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
getTreeSitterClient,
|
||||
type CliRenderer,
|
||||
type KeyEvent,
|
||||
type ScrollbackSurface,
|
||||
} from "@opentui/core"
|
||||
|
||||
import {
|
||||
@@ -99,28 +99,52 @@ function syntaxStyle(palette: Palette): SyntaxStyle {
|
||||
}
|
||||
|
||||
class Transcript {
|
||||
private writeChain = Promise.resolve()
|
||||
private live: { surface: ScrollbackSurface; text: TextRenderable; content: string } | null = null
|
||||
readonly root: ScrollBoxRenderable
|
||||
private live: { row: BoxRenderable; text: TextRenderable; content: string } | null = null
|
||||
private wrote = false
|
||||
private nextId = 0
|
||||
|
||||
constructor(
|
||||
private readonly renderer: CliRenderer,
|
||||
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 {
|
||||
this.palette = palette
|
||||
}
|
||||
|
||||
header(options: AppOptions): void {
|
||||
this.enqueue(async () => {
|
||||
const lines = [
|
||||
`>_ nanobot v${options.version}`,
|
||||
`${options.model} · ${options.access}`,
|
||||
options.workspace,
|
||||
]
|
||||
await this.writeText(lines.join("\n"), this.palette.text, true, true)
|
||||
})
|
||||
const lines = [
|
||||
`>_ nanobot v${options.version}`,
|
||||
`${options.model} · ${options.access}`,
|
||||
options.workspace,
|
||||
]
|
||||
this.writeText(lines.join("\n"), this.palette.text, true, true)
|
||||
}
|
||||
|
||||
async history(messages: HistoryMessage[]): Promise<void> {
|
||||
@@ -128,90 +152,84 @@ class Transcript {
|
||||
if (message.role === "user") this.user(message.content)
|
||||
else this.assistant(message.content)
|
||||
}
|
||||
await this.writeChain
|
||||
}
|
||||
|
||||
user(content: string): void {
|
||||
this.enqueue(() => this.writeText(`› ${content}`, this.palette.user, true))
|
||||
this.writeText(`› ${content}`, this.palette.user, true)
|
||||
}
|
||||
|
||||
assistant(content: string): void {
|
||||
if (!content.trim()) return
|
||||
this.enqueue(() => this.writeMarkdown(content))
|
||||
this.writeMarkdown(content)
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!delta) return
|
||||
if (!this.live) {
|
||||
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote })
|
||||
const text = new TextRenderable(surface.renderContext, {
|
||||
id: `assistant-stream-${Date.now()}`,
|
||||
const row = this.createRow()
|
||||
const text = new TextRenderable(this.renderer, {
|
||||
id: this.id("assistant-stream"),
|
||||
content: "",
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
fg: this.palette.text,
|
||||
})
|
||||
surface.root.add(text)
|
||||
this.live = { surface, text, content: "" }
|
||||
row.add(text)
|
||||
this.root.add(row)
|
||||
this.live = { row, text, content: "" }
|
||||
this.wrote = true
|
||||
}
|
||||
this.live.content += delta
|
||||
this.live.text.content = this.live.content
|
||||
this.live.surface.render()
|
||||
}
|
||||
|
||||
finishStream(fallback = ""): void {
|
||||
const content = this.live?.content || fallback
|
||||
if (this.live) {
|
||||
this.live.surface.destroy()
|
||||
this.root.remove(this.live.row)
|
||||
this.live.row.destroy()
|
||||
this.live = null
|
||||
}
|
||||
if (content.trim()) this.assistant(content)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.live?.surface.destroy()
|
||||
this.live = null
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): void {
|
||||
this.writeChain = this.writeChain.then(operation).catch((error) => {
|
||||
console.error("transcript render failed", error)
|
||||
private id(prefix: string): string {
|
||||
this.nextId += 1
|
||||
return `${prefix}-${this.nextId}`
|
||||
}
|
||||
|
||||
private createRow(framed = false): BoxRenderable {
|
||||
return new BoxRenderable(this.renderer, {
|
||||
id: this.id(framed ? "text-frame" : "text-row"),
|
||||
width: "100%",
|
||||
marginTop: this.wrote ? 1 : 0,
|
||||
border: framed,
|
||||
borderStyle: "rounded",
|
||||
borderColor: this.palette.border,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
flexDirection: "column",
|
||||
})
|
||||
}
|
||||
|
||||
private async writeText(
|
||||
private writeText(
|
||||
content: string,
|
||||
color: string,
|
||||
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%",
|
||||
border: true,
|
||||
borderStyle: "rounded",
|
||||
borderColor: this.palette.border,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
flexDirection: "column",
|
||||
})
|
||||
: new BoxRenderable(surface.renderContext, {
|
||||
id: `text-row-${Date.now()}`,
|
||||
width: "100%",
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
flexDirection: "column",
|
||||
})
|
||||
root.add(
|
||||
new TextRenderable(surface.renderContext, {
|
||||
id: `text-${Date.now()}`,
|
||||
): void {
|
||||
const row = this.createRow(framed)
|
||||
row.add(
|
||||
new TextRenderable(this.renderer, {
|
||||
id: this.id("text"),
|
||||
content,
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
@@ -219,18 +237,14 @@ class Transcript {
|
||||
attributes: bold ? TextAttributes.BOLD : 0,
|
||||
}),
|
||||
)
|
||||
surface.root.add(root)
|
||||
surface.render()
|
||||
surface.commitRows(0, surface.height, { trailingNewline: true })
|
||||
surface.destroy()
|
||||
this.root.add(row)
|
||||
this.wrote = true
|
||||
}
|
||||
|
||||
private async writeMarkdown(content: string): Promise<void> {
|
||||
this.spacer()
|
||||
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote })
|
||||
const markdown = new MarkdownRenderable(surface.renderContext, {
|
||||
id: `markdown-${Date.now()}`,
|
||||
private writeMarkdown(content: string): void {
|
||||
const row = this.createRow()
|
||||
const markdown = new MarkdownRenderable(this.renderer, {
|
||||
id: this.id("markdown"),
|
||||
content,
|
||||
width: "100%",
|
||||
syntaxStyle: syntaxStyle(this.palette),
|
||||
@@ -238,25 +252,10 @@ class Transcript {
|
||||
internalBlockMode: "top-level",
|
||||
treeSitterClient: getTreeSitterClient(),
|
||||
})
|
||||
surface.root.add(markdown)
|
||||
await surface.settle()
|
||||
surface.commitRows(0, surface.height, { trailingNewline: true })
|
||||
surface.destroy()
|
||||
row.add(markdown)
|
||||
this.root.add(row)
|
||||
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 {
|
||||
@@ -304,13 +303,14 @@ export class NanobotTui {
|
||||
id: "nanobot-tui-title",
|
||||
content: `nanobot · ${options.model}`,
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
fg: this.palette.muted,
|
||||
})
|
||||
this.composerFrame = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-composer-frame",
|
||||
width: "100%",
|
||||
minHeight: 3,
|
||||
flexGrow: 1,
|
||||
height: 3,
|
||||
flexShrink: 0,
|
||||
border: true,
|
||||
borderStyle: "rounded",
|
||||
borderColor: this.palette.border,
|
||||
@@ -356,12 +356,14 @@ export class NanobotTui {
|
||||
id: "nanobot-tui-status-row",
|
||||
width: "100%",
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
})
|
||||
this.composerFrame.add(this.composer)
|
||||
statusRow.add(this.status)
|
||||
statusRow.add(this.meta)
|
||||
this.shell.add(this.transcript.root)
|
||||
this.shell.add(this.title)
|
||||
this.shell.add(this.composerFrame)
|
||||
this.shell.add(statusRow)
|
||||
@@ -374,7 +376,6 @@ export class NanobotTui {
|
||||
this.handleResize()
|
||||
this.composer.focus()
|
||||
this.transcript.header(options)
|
||||
this.client.connect()
|
||||
}
|
||||
|
||||
static async create(options: AppOptions): Promise<NanobotTui> {
|
||||
@@ -382,15 +383,19 @@ export class NanobotTui {
|
||||
targetFps: 30,
|
||||
exitOnCtrlC: false,
|
||||
useMouse: true,
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 7,
|
||||
externalOutputMode: "capture-stdout",
|
||||
screenMode: "alternate-screen",
|
||||
externalOutputMode: "passthrough",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
return NanobotTui.mount(renderer, options)
|
||||
}
|
||||
|
||||
static mount(renderer: CliRenderer, options: AppOptions): NanobotTui {
|
||||
return new NanobotTui(renderer, options)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.client.connect()
|
||||
this.renderer.start()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user