perf(tui): reduce cold-start latency

This commit is contained in:
chengyongru
2026-08-18 16:28:46 +08:00
committed by chengyongru
parent 369a3443eb
commit df14259717
19 changed files with 669 additions and 160 deletions
+23
View File
@@ -1488,7 +1488,9 @@ describe("NanobotTui layout", () => {
test("overlaps automatic terminal detection with connection startup", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
let connected = false
let rendered = false
let resolveMode: (mode: "light") => void = () => undefined
setup.renderer.start = () => { rendered = true }
setup.renderer.waitForThemeMode = () => new Promise((resolve) => {
resolveMode = resolve
})
@@ -1505,6 +1507,7 @@ describe("NanobotTui layout", () => {
const starting = app.start()
await Bun.sleep(1)
expect(connected).toBe(true)
expect(rendered).toBe(true)
resolveMode("light")
await starting
@@ -1994,6 +1997,26 @@ describe("NanobotTui layout", () => {
expect(closed).toBe(true)
expect(setup.renderer.isDestroyed).toBe(true)
})
test("accepts the exit command before the gateway connection is ready", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
let closed = false
const transport = client()
transport.close = () => { closed = true }
const app = NanobotTui.mount(
setup.renderer,
options,
transport,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
const composer = (app as unknown as { composer: TextareaRenderable }).composer
composer.setText("exit")
composer.submit()
await waitUntil(() => closed)
expect(setup.renderer.isDestroyed).toBe(true)
})
})
describe("NanobotTui in a Herdr pane", () => {
+41 -12
View File
@@ -21,6 +21,7 @@ import {
import {
NanobotClient,
fetchHistory,
fetchGatewayConnection,
fetchMentionCandidates,
fetchSessionContext,
fetchSessions,
@@ -78,7 +79,9 @@ import {
import { createTuiHost, currentGitBranch, type TuiHost } from "./host"
interface AppOptions {
wsUrl: string
wsUrl?: string
bootstrapUrl?: string
bootstrapSecret?: string
apiUrl: string
apiToken: string
chatId?: string
@@ -472,7 +475,23 @@ export class NanobotTui {
)
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
this.client = client || new NanobotClient({
url: options.wsUrl,
...(options.bootstrapUrl
? {
resolveConnection: () => fetchGatewayConnection(
options.bootstrapUrl || "",
options.bootstrapSecret || "",
options.apiUrl,
`tui-${process.pid}`,
),
onConnection: (connection) => this.useGatewayConnection(
connection.apiUrl,
connection.apiToken,
),
connectionRetryLabel: "Starting local gateway",
reconnectDelayMs: 100,
startupRetryMaxDelayMs: 250,
}
: { url: options.wsUrl }),
chatId: options.chatId,
onEvent: (event) => this.accept(event),
onStatus: (status, detail) => this.handleStatus(status, detail),
@@ -719,16 +738,15 @@ export class NanobotTui {
void this.loadCommands()
void this.loadMentions()
this.runtimeControls.preload()
this.renderer.start()
// 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
// the renderer here, so a signal during the probe can still restore it.
// that bounded probe after first paint. The neutral terminal background is
// safe to render immediately, and the detected palette can be applied later.
if (this.options.theme === "auto") await this.renderer.waitForThemeMode(1_000)
if (this.quitting) return
if (this.options.theme === "auto" && this.renderer.themeMode) {
this.applyTheme(this.renderer.themeMode)
}
this.renderer.start()
}
stop(): void {
@@ -775,6 +793,10 @@ export class NanobotTui {
return
}
if (!visibleContent) return
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
this.quit()
return
}
const completion = this.commandMenu.completion(visibleContent)
if (completion) {
this.setComposer(completion)
@@ -804,10 +826,6 @@ export class NanobotTui {
this.status.content = "Preparing chat…"
return
}
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
this.quit()
return
}
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
if (this.activeTurn) {
this.sendPrompt(prompt, true)
@@ -1132,6 +1150,14 @@ export class NanobotTui {
for (const event of events || []) this.accept(event)
}
private useGatewayConnection(apiUrl: string, apiToken: string): void {
this.options.apiUrl = apiUrl
this.options.apiToken = apiToken
this.runtimeControls.useApiConnection(apiUrl, apiToken)
void this.loadCommands()
void this.loadMentions()
}
private handleStatus(status: ConnectionStatus, detail?: string): void {
if (status === "connected") {
this.ready = false
@@ -1141,9 +1167,12 @@ export class NanobotTui {
}
if (status === "connecting") {
this.ready = false
this.host.reportState("unknown", detail ? "Reconnecting" : "Connecting")
const label = detail === "Starting local gateway"
? detail
: detail ? "Reconnecting" : "Connecting"
this.host.reportState("unknown", label)
if (detail) this.setActive(false)
this.status.content = detail ? "Reconnecting…" : "Connecting…"
this.status.content = `${label}`
return
}
if (status === "error") {
+11 -7
View File
@@ -1,12 +1,6 @@
import { NanobotTui, type AppOptions } from "./app"
import { currentGitBranch } from "./host"
function required(name: string): string {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required`)
return value
}
function themePreference(): AppOptions["theme"] {
const value = process.env.NANOBOT_TUI_THEME?.trim() || "auto"
if (value === "auto" || value === "dark" || value === "light") return value
@@ -15,8 +9,18 @@ function themePreference(): AppOptions["theme"] {
const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
const hostWorkspace = process.cwd()
const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || ""
if (!bootstrapUrl && !wsUrl) {
throw new Error("NANOBOT_TUI_BOOTSTRAP_URL or NANOBOT_TUI_WS_URL is required")
}
const options: AppOptions = {
wsUrl: required("NANOBOT_TUI_WS_URL"),
...(bootstrapUrl
? {
bootstrapUrl,
bootstrapSecret: process.env.NANOBOT_TUI_BOOTSTRAP_SECRET?.trim() || "",
}
: { wsUrl }),
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "",
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
+152
View File
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
import {
NanobotClient,
GatewayConnectionError,
fetchGatewayConnection,
fetchHistory,
fetchMentionCandidates,
fetchRuntimeControls,
@@ -37,6 +39,156 @@ class FakeSocket {
}
describe("gateway protocol", () => {
test("bootstraps fresh websocket and API credentials", async () => {
const original = globalThis.fetch
let headers: Headers | undefined
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
headers = new Headers(init?.headers)
return new Response(JSON.stringify({
ws_url: "ws://nanobot.test/ws?mode=local",
token: "socket token",
api_token: "api-token",
}))
}) as typeof fetch
try {
const connection = await fetchGatewayConnection(
"http://nanobot.test/webui/bootstrap",
"bootstrap-secret",
"http://nanobot.test",
"tui-42",
)
expect(headers?.get("X-Nanobot-Auth")).toBe("bootstrap-secret")
expect(connection).toEqual({
wsUrl: "ws://nanobot.test/ws?mode=local&token=socket+token&client_id=tui-42",
apiUrl: "http://nanobot.test",
apiToken: "api-token",
})
} finally {
globalThis.fetch = original
}
})
test("rejects malformed bootstrap responses without retrying", async () => {
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response("not json"))) as unknown as typeof fetch
try {
await expect(fetchGatewayConnection(
"http://nanobot.test/webui/bootstrap",
"bootstrap-secret",
"http://nanobot.test",
"tui-42",
)).rejects.toMatchObject({
message: "gateway bootstrap response is invalid",
retryable: false,
})
} finally {
globalThis.fetch = original
}
})
test("waits for bootstrap before opening the websocket", async () => {
const original = globalThis.WebSocket
let resolveConnection: ((value: {
wsUrl: string
apiUrl: string
apiToken: string
}) => void) | undefined
let requestedUrl = ""
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor(url: string) {
super()
requestedUrl = url
}
},
})
try {
const connections: string[] = []
const client = new NanobotClient({
resolveConnection: () => new Promise((resolve) => { resolveConnection = resolve }),
onConnection: (connection) => connections.push(connection.apiToken),
onEvent: () => undefined,
onStatus: () => undefined,
})
client.connect()
await Bun.sleep(1)
expect(requestedUrl).toBe("")
resolveConnection?.({
wsUrl: "ws://nanobot.test/ws?token=fresh",
apiUrl: "http://nanobot.test",
apiToken: "fresh-api-token",
})
await Bun.sleep(1)
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=fresh")
expect(connections).toEqual(["fresh-api-token"])
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("retries bootstrap while the local gateway starts", async () => {
const original = globalThis.WebSocket
let attempts = 0
let requestedUrl = ""
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor(url: string) {
super()
requestedUrl = url
}
},
})
try {
const client = new NanobotClient({
resolveConnection: async () => {
attempts += 1
if (attempts === 1) throw new Error("gateway is still starting")
return {
wsUrl: "ws://nanobot.test/ws?token=second",
apiUrl: "http://nanobot.test",
apiToken: "second-api-token",
}
},
reconnectDelayMs: 1,
onEvent: () => undefined,
onStatus: () => undefined,
})
client.connect()
for (let index = 0; index < 20 && !requestedUrl; index += 1) await Bun.sleep(2)
expect(attempts).toBe(2)
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=second")
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("reports a permanent bootstrap rejection without retrying", async () => {
let attempts = 0
const statuses: string[] = []
const client = new NanobotClient({
resolveConnection: async () => {
attempts += 1
throw new GatewayConnectionError("gateway bootstrap failed: HTTP 401", false)
},
reconnectDelayMs: 1,
onEvent: () => undefined,
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
})
client.connect()
await Bun.sleep(5)
expect(attempts).toBe(1)
expect(statuses.at(-1)).toBe("error:gateway bootstrap failed: HTTP 401")
client.close()
})
test("represents lifecycle frames without browser state", () => {
const events: InboundEvent[] = [
{
+110 -9
View File
@@ -151,13 +151,30 @@ type OutboundEvent =
}
export interface ClientOptions {
url: string
url?: string
resolveConnection?: () => Promise<GatewayConnection>
onConnection?: (connection: GatewayConnection) => void
connectionRetryLabel?: string
startupRetryMaxDelayMs?: number
chatId?: string
reconnectDelayMs?: number
onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string) => void
}
export interface GatewayConnection {
wsUrl: string
apiUrl: string
apiToken: string
}
export class GatewayConnectionError extends Error {
constructor(message: string, readonly retryable: boolean) {
super(message)
this.name = "GatewayConnectionError"
}
}
export interface HistoryMessage {
role: "user" | "assistant" | "activity"
content: string
@@ -747,12 +764,63 @@ function sessionLabelForMention(session: SessionSummary): string {
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim()
}
/** Resolve fresh short-lived credentials once the local gateway is reachable. */
export async function fetchGatewayConnection(
bootstrapUrl: string,
bootstrapSecret: string,
apiUrl: string,
clientId: string,
): Promise<GatewayConnection> {
const response = await fetch(bootstrapUrl, {
headers: bootstrapSecret ? { "X-Nanobot-Auth": bootstrapSecret } : {},
})
if (!response.ok) {
const retryable = response.status === 408 || response.status === 429 || response.status >= 500
throw new GatewayConnectionError(
`gateway bootstrap failed: HTTP ${response.status}`,
retryable,
)
}
let payload: unknown
try {
payload = await response.json()
} catch {
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
}
if (!isRecord(payload)) {
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
}
if (typeof payload.ws_url !== "string" || !payload.ws_url.trim()) {
throw new GatewayConnectionError("gateway bootstrap response is missing ws_url", false)
}
let wsUrl: URL
try {
wsUrl = new URL(payload.ws_url)
} catch {
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
}
if (wsUrl.protocol !== "ws:" && wsUrl.protocol !== "wss:") {
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
}
if (typeof payload.token === "string" && payload.token) {
wsUrl.searchParams.append("token", payload.token)
}
wsUrl.searchParams.append("client_id", clientId)
return {
wsUrl: wsUrl.toString(),
apiUrl,
apiToken: typeof payload.api_token === "string" ? payload.api_token : "",
}
}
export class NanobotClient {
private socket: WebSocket | null = null
private chatId = ""
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempt = 0
private closedByClient = false
private opening = false
private connectedOnce = false
constructor(private readonly options: ClientOptions) {}
@@ -762,16 +830,46 @@ export class NanobotClient {
connect(): void {
this.closedByClient = false
this.open()
void this.open()
}
private open(): void {
if (this.socket) return
private async open(): Promise<void> {
if (this.socket || this.opening || this.closedByClient) return
this.opening = true
this.options.onStatus("connecting")
const socket = new WebSocket(this.options.url)
let url = this.options.url
try {
if (this.options.resolveConnection) {
const connection = await this.options.resolveConnection()
if (this.closedByClient) return
this.options.onConnection?.(connection)
url = connection.wsUrl
}
} catch (error) {
if (!this.closedByClient) {
if (error instanceof GatewayConnectionError && !error.retryable) {
this.options.onStatus("error", error.message)
return
}
this.options.onStatus(
"connecting",
this.options.connectionRetryLabel || "gateway unavailable",
)
this.scheduleReconnect(false)
}
return
} finally {
this.opening = false
}
if (!url) {
this.options.onStatus("error", "gateway URL is not configured")
return
}
const socket = new WebSocket(url)
this.socket = socket
socket.addEventListener("open", () => {
if (this.socket !== socket) return
this.connectedOnce = true
this.reconnectAttempt = 0
this.options.onStatus("connected")
})
@@ -872,14 +970,17 @@ export class NanobotClient {
this.options.onEvent(event)
}
private scheduleReconnect(): void {
private scheduleReconnect(announce = true): void {
if (this.reconnectTimer || this.closedByClient) return
const base = this.options.reconnectDelayMs ?? 500
const delay = Math.min(8_000, base * 2 ** Math.min(this.reconnectAttempt++, 4))
this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
const maxDelay = this.connectedOnce
? 8_000
: this.options.startupRetryMaxDelayMs ?? 8_000
const delay = Math.min(maxDelay, base * 2 ** Math.min(this.reconnectAttempt++, 4))
if (announce) this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.open()
void this.open()
}, delay)
}
+8
View File
@@ -112,6 +112,14 @@ export class RuntimeControls {
void this.load().catch(() => {})
}
useApiConnection(apiUrl: string, apiToken: string): void {
this.options.apiUrl = apiUrl
this.options.apiToken = apiToken
this.controlsLoaded = false
this.controlsLoadedAt = 0
this.preload()
}
updateWorkspaceScope(scope: WorkspaceScopePayload): void {
this.scope = scope
this.render()