perf(tui): keep long streams responsive

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent cf82b89307
commit 0d54ad96e2
6 changed files with 126 additions and 28 deletions
+43 -12
View File
@@ -651,17 +651,25 @@ describe("NanobotTui layout", () => {
const original = globalThis.fetch
const sent: string[] = []
const scopes: WorkspaceScopePayload[] = []
let settingsRequests = 0
let workspaceRequests = 0
globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input)
if (url.endsWith("/api/settings")) return new Response(JSON.stringify({
model_presets: [
{ name: "default", model: "test/model" },
{ name: "fast", model: "fast/model" },
],
}))
if (url.endsWith("/api/workspaces")) return new Response(JSON.stringify({
controls: { can_use_full_access: true },
}))
if (url.endsWith("/api/settings")) {
settingsRequests += 1
return new Response(JSON.stringify({
model_presets: [
{ name: "default", model: "test/model" },
{ name: "fast", model: "fast/model" },
],
}))
}
if (url.endsWith("/api/workspaces")) {
workspaceRequests += 1
return new Response(JSON.stringify({
controls: { can_use_full_access: true },
}))
}
return new Response(JSON.stringify({ sessions: [] }))
}) as typeof fetch
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
@@ -756,6 +764,8 @@ describe("NanobotTui layout", () => {
await setup.mockMouse.click(ui.status.x, ui.status.y)
expect(ui.runtimeControls.visible).toBe(false)
expect(ui.composer.focused).toBe(true)
expect(settingsRequests).toBe(1)
expect(workspaceRequests).toBe(1)
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBe(true)
app.accept({ event: "goal_status", chat_id: "chat", status: "idle" })
@@ -1475,7 +1485,7 @@ describe("NanobotTui layout", () => {
expect(internals.palette.referenceBackground).toBe("#FAFAFA")
})
test("waits for automatic terminal detection before connecting or painting", async () => {
test("overlaps automatic terminal detection with connection startup", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
let connected = false
let resolveMode: (mode: "light") => void = () => undefined
@@ -1494,11 +1504,10 @@ describe("NanobotTui layout", () => {
const starting = app.start()
await Bun.sleep(1)
expect(connected).toBe(false)
expect(connected).toBe(true)
resolveMode("light")
await starting
expect(connected).toBe(true)
expect((app as unknown as { palette: { referenceBackground: string } }).palette.referenceBackground).toBe("#FAFAFA")
})
@@ -1585,6 +1594,28 @@ describe("NanobotTui layout", () => {
expect(frame).not.toContain("draft signed://expired")
})
test("paints the first token immediately and coalesces the rest per frame", async () => {
setup = await createRenderer({ width: 80, height: 20, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({ event: "delta", chat_id: "chat", text: "first" })
const transcript = (app as unknown as {
transcript: {
live: { markdown: { content: string; streaming: boolean } } | null
}
}).transcript
const markdown = transcript.live?.markdown
expect(markdown?.content).toBe("first")
for (let index = 0; index < 1_000; index += 1) {
app.accept({ event: "delta", chat_id: "chat", text: " token" })
}
expect(markdown?.content).toBe("first")
app.accept({ event: "stream_end", chat_id: "chat" })
expect(markdown?.content).toBe(`first${" token".repeat(1_000)}`)
expect(markdown?.streaming).toBe(false)
})
test("copies full-screen selections through OSC 52", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = mount(setup)
+8 -4
View File
@@ -711,6 +711,14 @@ export class NanobotTui {
}
async start(): Promise<void> {
// Network setup and small menu payloads do not depend on terminal colors.
// Start them while OSC theme detection is in flight instead of serializing
// up to one second of otherwise independent startup work.
this.host.reportState("unknown", "Connecting")
this.client.connect()
void this.loadCommands()
void this.loadMentions()
this.runtimeControls.preload()
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
// that bounded probe before first paint, as OpenCode does, so a light
// terminal does not briefly render the dark palette. The app already owns
@@ -720,10 +728,6 @@ export class NanobotTui {
if (this.options.theme === "auto" && this.renderer.themeMode) {
this.applyTheme(this.renderer.themeMode)
}
this.host.reportState("unknown", "Connecting")
this.client.connect()
void this.loadCommands()
void this.loadMentions()
this.renderer.start()
}
+5 -5
View File
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"
import { createTuiHost, currentGitBranch } from "./host"
async function settle(): Promise<void> {
await Bun.sleep(0)
await Bun.sleep(40)
await Bun.sleep(0)
}
@@ -74,9 +74,9 @@ describe("TUI host integration", () => {
host.reportMetadata({ model: "gpt", branch: "" })
await settle()
expect(commands).toHaveLength(2)
expect(commands[1]).toContain("--clear-token")
expect(commands[1]).toContain("branch")
expect(commands[1]).not.toContain("model=gpt")
expect(commands).toHaveLength(1)
expect(commands[0]).toContain("model=gpt")
expect(commands[0]).toContain("--clear-token")
expect(commands[0]).toContain("branch")
})
})
+22 -4
View File
@@ -36,6 +36,7 @@ const AGENT = "nanobot"
const LIFECYCLE_SOURCE = "nanobot:tui"
const METADATA_SOURCE = "nanobot:tui:metadata"
const METADATA_KEYS = ["model", "branch", "workspace", "task", "action"] as const
const METADATA_FLUSH_MS = 32
class StandaloneHost implements TuiHost {
readonly hosted = false
@@ -52,6 +53,8 @@ class HerdrHost implements TuiHost {
private lastState = ""
private lastSession = ""
private metadata: HostMetadata = {}
private readonly pendingMetadata = new Set<typeof METADATA_KEYS[number]>()
private metadataTimer: ReturnType<typeof setTimeout> | null = null
private queue: Promise<void> = Promise.resolve()
constructor(
@@ -66,6 +69,9 @@ class HerdrHost implements TuiHost {
const fingerprint = `${state}\0${cleanMessage}\0${this.lastSession}`
if (fingerprint === this.lastState) return
this.lastState = fingerprint
// Preserve causal ordering when a semantic state transition follows a
// pending metadata snapshot; repeated working heartbeats still stay free.
this.flushMetadata()
const args = [
"pane", "report-agent", this.paneId,
"--source", LIFECYCLE_SOURCE,
@@ -84,6 +90,7 @@ class HerdrHost implements TuiHost {
if (!cleanSession || cleanSession === this.lastSession) return
this.lastSession = cleanSession
this.lastState = ""
this.flushMetadata()
this.enqueue([
"pane", "report-agent-session", this.paneId,
"--source", LIFECYCLE_SOURCE,
@@ -95,14 +102,22 @@ class HerdrHost implements TuiHost {
reportMetadata(next: HostMetadata): void {
if (this.released) return
const changed: Array<[typeof METADATA_KEYS[number], string]> = []
for (const key of METADATA_KEYS) {
if (!(key in next)) continue
const value = normalize(next[key])
if (value === normalize(this.metadata[key])) continue
changed.push([key, value])
this.pendingMetadata.add(key)
}
if (!changed.length) return
if (!this.pendingMetadata.size) return
this.metadata = { ...this.metadata, ...next }
if (this.metadataTimer) return
this.metadataTimer = setTimeout(() => this.flushMetadata(), METADATA_FLUSH_MS)
}
private flushMetadata(): void {
if (this.metadataTimer) clearTimeout(this.metadataTimer)
this.metadataTimer = null
if (!this.pendingMetadata.size) return
const args = [
"pane", "report-metadata", this.paneId,
"--source", METADATA_SOURCE,
@@ -113,14 +128,17 @@ class HerdrHost implements TuiHost {
const task = normalize(this.metadata.task)
args.push(task ? "--title" : "--clear-title")
if (task) args.push(task)
for (const [key, value] of changed) {
for (const key of this.pendingMetadata) {
const value = normalize(this.metadata[key])
args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key)
}
this.pendingMetadata.clear()
this.enqueue(args)
}
release(): void {
if (this.released) return
this.flushMetadata()
this.released = true
const clear = [
"pane", "report-metadata", this.paneId,
+13 -1
View File
@@ -20,6 +20,8 @@ type Choice =
| { kind: "model"; name: string; label: string; detail: string }
| { kind: "access"; mode: "restricted" | "full"; label: string; detail: string }
const CONTROLS_CACHE_MS = 10_000
interface RuntimeControlsOptions {
apiUrl: string
apiToken: string
@@ -49,6 +51,7 @@ export class RuntimeControls {
private modelPresets: Array<{ name: string; model: string }>
private canUseFullAccess: boolean
private controlsLoaded = false
private controlsLoadedAt = 0
private controlsPromise: Promise<void> | null = null
private scope: WorkspaceScopePayload
@@ -104,6 +107,11 @@ export class RuntimeControls {
this.contextText.content = text
}
/** Warm the small settings payload while the user is reading the first frame. */
preload(): void {
void this.load().catch(() => {})
}
updateWorkspaceScope(scope: WorkspaceScopePayload): void {
this.scope = scope
this.render()
@@ -175,11 +183,14 @@ export class RuntimeControls {
}
private async load(force = false): Promise<void> {
if (force) this.controlsLoaded = false
if (force && Date.now() - this.controlsLoadedAt >= CONTROLS_CACHE_MS) {
this.controlsLoaded = false
}
if (this.controlsLoaded) return
if (this.controlsPromise) return this.controlsPromise
if (!this.options.apiUrl || !this.options.apiToken) {
this.controlsLoaded = true
this.controlsLoadedAt = Date.now()
return
}
this.controlsPromise = (async () => {
@@ -197,6 +208,7 @@ export class RuntimeControls {
this.modelPresets = [...presets.values()]
this.canUseFullAccess = controls.canUseFullAccess
this.controlsLoaded = true
this.controlsLoadedAt = Date.now()
})().finally(() => { this.controlsPromise = null })
return this.controlsPromise
}
+35 -2
View File
@@ -45,6 +45,11 @@ interface Activity {
}
const ACTIVITY_PREVIEW_LINES = 6
// OpenTUI renders at 30 FPS. Re-parsing the entire Markdown buffer for every
// provider token turns long answers into quadratic work without producing any
// additional visible frames. Paint the first token immediately, then coalesce
// subsequent deltas to the renderer cadence.
const STREAM_FLUSH_MS = 32
/** Projects gateway events into retained, reflowable conversation cells. */
export class Transcript {
@@ -64,6 +69,8 @@ export class Transcript {
private nextId = 0
private navigation: TranscriptNavigation = { awayFromBottom: false, unseenOutput: false }
private navigationTimer: ReturnType<typeof setTimeout> | null = null
private pendingStream = ""
private streamTimer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly renderer: CliRenderer,
@@ -142,6 +149,8 @@ export class Transcript {
reset(header: TranscriptHeader): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
this.navigationTimer = null
this.clearStreamTimer()
this.pendingStream = ""
for (const child of [...this.root.getChildren()]) {
this.root.remove(child)
child.destroyRecursively()
@@ -237,11 +246,18 @@ export class Transcript {
const row = this.writeAssistant(markdown)
this.live = { row, markdown, content: "" }
}
this.live.content += delta
this.live.markdown.content = this.live.content
if (!this.live.content && !this.pendingStream) {
this.live.content = delta
this.live.markdown.content = delta
return
}
this.pendingStream += delta
if (this.streamTimer) return
this.streamTimer = setTimeout(() => this.flushStream(), STREAM_FLUSH_MS)
}
finishStream(fallback = ""): void {
this.flushStream()
if (this.live) {
const content = fallback || this.live.content
// Finalize the retained Markdown node in place. This preserves scroll
@@ -256,6 +272,8 @@ export class Transcript {
reconcileStream(content: string): void {
if (!content || !this.live) return
this.clearStreamTimer()
this.pendingStream = ""
this.live.content = content
this.live.markdown.content = content
}
@@ -303,6 +321,8 @@ export class Transcript {
destroy(): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
this.clearStreamTimer()
this.pendingStream = ""
this.live = null
this.activity = null
this.frames.clear()
@@ -310,6 +330,19 @@ export class Transcript {
this.theme.syntax.destroy()
}
private flushStream(): void {
this.clearStreamTimer()
if (!this.live || !this.pendingStream) return
this.live.content += this.pendingStream
this.pendingStream = ""
this.live.markdown.content = this.live.content
}
private clearStreamTimer(): void {
if (this.streamTimer) clearTimeout(this.streamTimer)
this.streamTimer = null
}
private noteOutput(): void {
this.updateNavigation(true)
}