mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
perf(tui): keep long streams responsive
This commit is contained in:
+43
-12
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user