diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 35813dd10..5caa232ed 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -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) diff --git a/tui/src/app.ts b/tui/src/app.ts index 53983821a..d6edaa9eb 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -711,6 +711,14 @@ export class NanobotTui { } async start(): Promise { + // 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() } diff --git a/tui/src/host.test.ts b/tui/src/host.test.ts index 3afde871d..4b04f2956 100644 --- a/tui/src/host.test.ts +++ b/tui/src/host.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test" import { createTuiHost, currentGitBranch } from "./host" async function settle(): Promise { - 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") }) }) diff --git a/tui/src/host.ts b/tui/src/host.ts index 787c51522..c934f8f30 100644 --- a/tui/src/host.ts +++ b/tui/src/host.ts @@ -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() + private metadataTimer: ReturnType | null = null private queue: Promise = 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, diff --git a/tui/src/runtime-controls.ts b/tui/src/runtime-controls.ts index 4ef54e037..f6316b5ff 100644 --- a/tui/src/runtime-controls.ts +++ b/tui/src/runtime-controls.ts @@ -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 | 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 { - 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 } diff --git a/tui/src/transcript.ts b/tui/src/transcript.ts index 82da9fc8c..0b4675cda 100644 --- a/tui/src/transcript.ts +++ b/tui/src/transcript.ts @@ -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 | null = null + private pendingStream = "" + private streamTimer: ReturnType | 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) }