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
+1 -1
View File
@@ -9,7 +9,7 @@ bun run --cwd tui test
bun run --cwd tui build
```
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes an authenticated local endpoint through environment variables. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
Standalone terminals use 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.
+9 -5
View File
@@ -37,7 +37,9 @@ def _wait_for(process: Any, needle: str, timeout: float) -> str:
output.append(_read(process, min(0.1, deadline - time.monotonic())))
text = "".join(output)
if needle not in text:
raise AssertionError(f"terminal output did not contain {needle!r}")
raise AssertionError(
f"terminal output did not contain {needle!r}; recent output: {text[-2000:]!r}"
)
return text
@@ -47,7 +49,7 @@ def _wait_for_exit(process: Any, timeout: float) -> int:
time.sleep(0.05)
if process.isalive():
process.close(force=True)
raise AssertionError("TUI did not exit after Ctrl+C")
raise AssertionError("TUI did not exit after the exit command")
return int(process.exitstatus or 0)
@@ -78,6 +80,8 @@ def main() -> int:
# width probes. Keep this smoke test focused on application behavior.
"OPENTUI_FORCE_EXPLICIT_WIDTH": "false",
}
env.pop("HERDR_ENV", None)
env.pop("HERDR_PANE_ID", None)
process = PtyProcess.spawn(
[bun, "src/index.ts"],
cwd=str(ROOT),
@@ -100,11 +104,11 @@ def main() -> int:
if "\x1b[18;" not in resized or ";42H" not in resized:
raise AssertionError("TUI did not repaint to the resized ConPTY dimensions")
# First Ctrl+C clears the draft. The second exits the app and must
# restore the alternate screen without an unhandled exception.
# Ctrl+C clears the draft. The local exit command must still work while
# the intentionally unavailable gateway has not attached a chat.
process.sendcontrol("c")
output.append(_read(process, 0.2))
process.sendcontrol("c")
process.write("exit\r")
output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8))
exit_code = _wait_for_exit(process, 8)
finally:
+6 -4
View File
@@ -68,7 +68,7 @@ def _wait_for_exit(pid: int, timeout: float) -> int:
time.sleep(0.05)
os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0)
raise AssertionError("TUI did not exit after Ctrl+C")
raise AssertionError("TUI did not exit after the exit command")
def main() -> int:
@@ -90,6 +90,8 @@ def main() -> int:
# terminal emulator's optional OSC 10/11 response.
"NANOBOT_TUI_THEME": "dark",
}
env.pop("HERDR_ENV", None)
env.pop("HERDR_PANE_ID", None)
pid, master = pty.fork()
if pid == 0:
@@ -117,11 +119,11 @@ def main() -> int:
if b"\x1b[18;" not in resized or b";42H" not in resized:
raise AssertionError("TUI did not repaint to the resized PTY dimensions")
# First Ctrl+C clears the draft; the second exits and must restore the
# alternate screen without a prompt_toolkit-style traceback.
# Ctrl+C clears the draft. The local exit command must still work while
# the intentionally unavailable gateway has not attached a chat.
os.write(master, b"\x03")
output.extend(_read(master, 0.2))
os.write(master, b"\x03")
os.write(master, b"exit\r")
output.extend(_wait_for(master, LEAVE_ALT_SCREEN, 5))
exit_code = _wait_for_exit(pid, 5)
reaped = True
+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()