diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af583a995f..eee7e0dddab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- CLI/Crestodian: open interactive Crestodian in the full OpenClaw TUI shell instead of a basic readline prompt. - Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#71450) Thanks @vincentkoc and @jlapenna. diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md index 2e4d6cd5bf6..f6880516699 100644 --- a/docs/cli/crestodian.md +++ b/docs/cli/crestodian.md @@ -17,7 +17,9 @@ Running `openclaw crestodian` starts the same helper explicitly. ## What Crestodian shows -On startup, Crestodian prints a compact system overview: +On startup, interactive Crestodian opens the same TUI shell used by +`openclaw tui`, with a Crestodian chat backend. The chat log starts with a +compact system overview: - config path and validity - configured agents and the default agent @@ -30,7 +32,9 @@ On startup, Crestodian prints a compact system overview: - gateway reachability - the immediate recommended next step -It does not dump secrets or load plugin CLI commands just to start. +It does not dump secrets or load plugin CLI commands just to start. The TUI +still provides the normal header, chat log, status line, footer, autocomplete, +and editor controls. Crestodian uses the same OpenClaw reference discovery as regular agents. In a Git checkout, it points itself at local `docs/` and the local source tree. In an npm package install, it @@ -51,7 +55,7 @@ openclaw crestodian --message "set default model openai/gpt-5.5" --yes openclaw onboard --modern ``` -Inside the interactive prompt: +Inside the Crestodian TUI: ```text status diff --git a/docs/web/tui.md b/docs/web/tui.md index e26ca1bb86d..04da9f7c8a5 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -47,6 +47,7 @@ Notes: - `openclaw chat` and `openclaw terminal` are aliases for `openclaw tui --local`. - `--local` cannot be combined with `--url`, `--token`, or `--password`. - Local mode uses the embedded agent runtime directly. Most local tools work, but Gateway-only features are unavailable. +- `openclaw` and `openclaw crestodian` also use this TUI shell, with Crestodian as the local setup and repair chat backend. ## What you see diff --git a/src/crestodian/crestodian.test.ts b/src/crestodian/crestodian.test.ts index b0509595a74..af836f0094f 100644 --- a/src/crestodian/crestodian.test.ts +++ b/src/crestodian/crestodian.test.ts @@ -68,4 +68,27 @@ describe("runCrestodian", () => { expect(planner).not.toHaveBeenCalled(); expect(lines.join("\n")).toContain("Default model:"); }); + + it("starts interactive Crestodian in the TUI shell", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-tui-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + const { runtime, lines } = createRuntime(); + const runInteractiveTui = vi.fn(async () => {}); + + await runCrestodian( + { + input: { isTTY: true } as unknown as NodeJS.ReadableStream, + output: { isTTY: true } as unknown as NodeJS.WritableStream, + runInteractiveTui, + }, + runtime, + ); + + expect(runInteractiveTui).toHaveBeenCalledWith( + expect.objectContaining({ runInteractiveTui }), + runtime, + ); + expect(lines.join("\n")).not.toContain("Say: status"); + }); }); diff --git a/src/crestodian/crestodian.ts b/src/crestodian/crestodian.ts index 833951783f0..a07bd274163 100644 --- a/src/crestodian/crestodian.ts +++ b/src/crestodian/crestodian.ts @@ -1,24 +1,14 @@ import { stdin as defaultStdin, stdout as defaultStdout } from "node:process"; -import readline from "node:readline/promises"; import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; -import { - planCrestodianCommand, - type CrestodianAssistantPlan, - type CrestodianAssistantPlanner, -} from "./assistant.js"; +import type { CrestodianAssistantPlanner } from "./assistant.js"; +import { resolveCrestodianOperation } from "./dialogue.js"; import { executeCrestodianOperation, - describeCrestodianPersistentOperation, isPersistentCrestodianOperation, - parseCrestodianOperation, type CrestodianCommandDeps, - type CrestodianOperation, } from "./operations.js"; -import { - formatCrestodianOverview, - loadCrestodianOverview, - type CrestodianOverview, -} from "./overview.js"; +import { formatCrestodianOverview, loadCrestodianOverview } from "./overview.js"; +import { runCrestodianTui } from "./tui-backend.js"; export type RunCrestodianOptions = { message?: string; @@ -29,16 +19,9 @@ export type RunCrestodianOptions = { planWithAssistant?: CrestodianAssistantPlanner; input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; + runInteractiveTui?: typeof runCrestodianTui; }; -function approvalQuestion(operation: CrestodianOperation): string { - return `Apply this operation: ${describeCrestodianPersistentOperation(operation)}?`; -} - -function isYes(input: string): boolean { - return /^(y|yes|apply|do it|approved?)$/i.test(input.trim()); -} - async function runOneShot( input: string, runtime: RuntimeEnv, @@ -51,84 +34,20 @@ async function runOneShot( }); } -async function runResolvedOperation( - operation: CrestodianOperation, - runtime: RuntimeEnv, - opts: RunCrestodianOptions, -): Promise<{ exitsInteractive: boolean; nextInput?: string }> { - const result = await executeCrestodianOperation(operation, runtime, { - approved: opts.yes === true || !isPersistentCrestodianOperation(operation), - deps: opts.deps, - }); - return { - exitsInteractive: result.exitsInteractive === true, - nextInput: result.nextInput, - }; -} - -async function resolveCrestodianOperation( - input: string, - runtime: RuntimeEnv, - opts: RunCrestodianOptions, -): Promise { - const operation = parseCrestodianOperation(input); - if (!shouldAskAssistant(input, operation)) { - return operation; - } - const overview = await loadCrestodianOverview(); - const planner = opts.planWithAssistant ?? planCrestodianCommand; - const plan = await planner({ input, overview }); - if (!plan) { - return operation; - } - const planned = parseCrestodianOperation(plan.command); - if (planned.kind === "none") { - return operation; - } - logAssistantPlan(runtime, plan, overview); - return planned; -} - -function shouldAskAssistant(input: string, operation: CrestodianOperation): boolean { - if (operation.kind !== "none") { - return false; - } - const trimmed = input.trim().toLowerCase(); - if (!trimmed || trimmed === "quit" || trimmed === "exit") { - return false; - } - return true; -} - -function logAssistantPlan( - runtime: RuntimeEnv, - plan: CrestodianAssistantPlan, - overview: CrestodianOverview, -): void { - const modelLabel = plan.modelLabel ?? overview.defaultModel ?? "configured model"; - runtime.log(`[crestodian] planner: ${modelLabel}`); - if (plan.reply) { - runtime.log(plan.reply); - } - runtime.log(`[crestodian] interpreted: ${plan.command}`); -} - export async function runCrestodian( opts: RunCrestodianOptions = {}, runtime: RuntimeEnv = defaultRuntime, ): Promise { - const overview = await loadCrestodianOverview(); if (opts.json) { + const overview = await loadCrestodianOverview(); writeRuntimeJson(runtime, overview); return; } - runtime.log(formatCrestodianOverview(overview)); - runtime.log(""); - runtime.log( - "Say: status, doctor, health, gateway status, restart gateway, agents, models, set default model , talk to agent, audit, or quit.", - ); if (opts.message?.trim()) { + const overview = await loadCrestodianOverview(); + runtime.log(formatCrestodianOverview(overview)); + runtime.log(""); await runOneShot(opts.message, runtime, opts); return; } @@ -143,51 +62,6 @@ export async function runCrestodian( return; } - const rl = readline.createInterface({ input, output }); - let pending: CrestodianOperation | null = null; - try { - for (;;) { - const answer = await rl.question("crestodian> "); - if (pending) { - if (isYes(answer)) { - const result = await executeCrestodianOperation(pending, runtime, { - approved: true, - deps: opts.deps, - }); - pending = null; - if (result.exitsInteractive) { - break; - } - continue; - } - runtime.log("Skipped. No barnacles on config today."); - pending = null; - continue; - } - const operation = await resolveCrestodianOperation(answer, runtime, opts); - if (isPersistentCrestodianOperation(operation) && !opts.yes) { - runtime.log(approvalQuestion(operation)); - pending = operation; - continue; - } - const result = await runResolvedOperation(operation, runtime, opts); - if (result.exitsInteractive) { - break; - } - if (result.nextInput?.trim()) { - const followUp = await resolveCrestodianOperation(result.nextInput, runtime, opts); - if (isPersistentCrestodianOperation(followUp) && !opts.yes) { - runtime.log(approvalQuestion(followUp)); - pending = followUp; - continue; - } - const followUpResult = await runResolvedOperation(followUp, runtime, opts); - if (followUpResult.exitsInteractive) { - break; - } - } - } - } finally { - rl.close(); - } + const runInteractiveTui = opts.runInteractiveTui ?? runCrestodianTui; + await runInteractiveTui(opts, runtime); } diff --git a/src/crestodian/dialogue.ts b/src/crestodian/dialogue.ts new file mode 100644 index 00000000000..4c607946667 --- /dev/null +++ b/src/crestodian/dialogue.ts @@ -0,0 +1,71 @@ +import type { RuntimeEnv } from "../runtime.js"; +import { + planCrestodianCommand, + type CrestodianAssistantPlan, + type CrestodianAssistantPlanner, +} from "./assistant.js"; +import { + describeCrestodianPersistentOperation, + parseCrestodianOperation, + type CrestodianOperation, +} from "./operations.js"; +import { loadCrestodianOverview, type CrestodianOverview } from "./overview.js"; + +export type CrestodianDialogueOptions = { + planWithAssistant?: CrestodianAssistantPlanner; +}; + +export function approvalQuestion(operation: CrestodianOperation): string { + return `Apply this operation: ${describeCrestodianPersistentOperation(operation)}?`; +} + +export function isYes(input: string): boolean { + return /^(y|yes|apply|do it|approved?)$/i.test(input.trim()); +} + +export async function resolveCrestodianOperation( + input: string, + runtime: RuntimeEnv, + opts: CrestodianDialogueOptions, +): Promise { + const operation = parseCrestodianOperation(input); + if (!shouldAskAssistant(input, operation)) { + return operation; + } + const overview = await loadCrestodianOverview(); + const planner = opts.planWithAssistant ?? planCrestodianCommand; + const plan = await planner({ input, overview }); + if (!plan) { + return operation; + } + const planned = parseCrestodianOperation(plan.command); + if (planned.kind === "none") { + return operation; + } + logAssistantPlan(runtime, plan, overview); + return planned; +} + +function shouldAskAssistant(input: string, operation: CrestodianOperation): boolean { + if (operation.kind !== "none") { + return false; + } + const trimmed = input.trim().toLowerCase(); + if (!trimmed || trimmed === "quit" || trimmed === "exit") { + return false; + } + return true; +} + +function logAssistantPlan( + runtime: RuntimeEnv, + plan: CrestodianAssistantPlan, + overview: CrestodianOverview, +): void { + const modelLabel = plan.modelLabel ?? overview.defaultModel ?? "configured model"; + runtime.log(`[crestodian] planner: ${modelLabel}`); + if (plan.reply) { + runtime.log(plan.reply); + } + runtime.log(`[crestodian] interpreted: ${plan.command}`); +} diff --git a/src/crestodian/tui-backend.test.ts b/src/crestodian/tui-backend.test.ts new file mode 100644 index 00000000000..b3676464e03 --- /dev/null +++ b/src/crestodian/tui-backend.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; + +const mocks = vi.hoisted(() => ({ + runTui: vi.fn(async (_opts: unknown) => ({ exitReason: "exit" as const })), +})); + +vi.mock("../tui/tui.js", () => ({ + runTui: mocks.runTui, +})); + +import { runCrestodianTui } from "./tui-backend.js"; + +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: (code) => { + throw new Error(`exit ${code}`); + }, + }; +} + +describe("runCrestodianTui", () => { + afterEach(() => { + vi.unstubAllEnvs(); + mocks.runTui.mockClear(); + }); + + it("runs Crestodian inside the shared TUI shell", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-tui-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + + await runCrestodianTui({}, createRuntime()); + + expect(mocks.runTui).toHaveBeenCalledWith( + expect.objectContaining({ + local: true, + session: "agent:crestodian:main", + historyLimit: 200, + config: {}, + title: "openclaw crestodian", + }), + ); + const callOptions = mocks.runTui.mock.calls[0]?.[0] as { backend?: unknown } | undefined; + expect(callOptions?.backend).toBeTruthy(); + }); +}); diff --git a/src/crestodian/tui-backend.ts b/src/crestodian/tui-backend.ts new file mode 100644 index 00000000000..7ac896dc279 --- /dev/null +++ b/src/crestodian/tui-backend.ts @@ -0,0 +1,355 @@ +import { randomUUID } from "node:crypto"; +import type { SessionsPatchParams, SessionsPatchResult } from "../gateway/protocol/index.js"; +import { buildAgentMainSessionKey } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { + ChatSendOptions, + TuiAgentsList, + TuiBackend, + TuiEvent, + TuiModelChoice, + TuiSessionList, +} from "../tui/tui-backend.js"; +import { runTui } from "../tui/tui.js"; +import type { CrestodianAssistantPlanner } from "./assistant.js"; +import { approvalQuestion, isYes, resolveCrestodianOperation } from "./dialogue.js"; +import { + executeCrestodianOperation, + isPersistentCrestodianOperation, + type CrestodianCommandDeps, + type CrestodianOperation, +} from "./operations.js"; +import { formatCrestodianOverview, loadCrestodianOverview } from "./overview.js"; + +export type CrestodianTuiOptions = { + yes?: boolean; + deps?: CrestodianCommandDeps; + planWithAssistant?: CrestodianAssistantPlanner; +}; + +type CrestodianHistoryMessage = { + role: "assistant" | "user"; + content: Array<{ type: "text"; text: string }>; + timestamp: number; +}; + +type CaptureRuntime = RuntimeEnv & { + read: () => string; +}; + +const CRESTODIAN_AGENT_ID = "crestodian"; +const CRESTODIAN_SESSION_KEY = buildAgentMainSessionKey({ agentId: CRESTODIAN_AGENT_ID }); + +function createCaptureRuntime(): CaptureRuntime { + const lines: string[] = []; + return { + log: (...args) => lines.push(args.join(" ")), + error: (...args) => lines.push(args.join(" ")), + exit: (code) => { + throw new Error(`Crestodian operation exited with code ${String(code)}`); + }, + read: () => lines.join("\n").trim(), + }; +} + +function message(role: "assistant" | "user", text: string): CrestodianHistoryMessage { + return { + role, + content: [{ type: "text", text }], + timestamp: Date.now(), + }; +} + +function splitModelRef(ref: string | undefined): { provider?: string; model?: string } { + const trimmed = ref?.trim(); + if (!trimmed) { + return {}; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return { model: trimmed }; + } + return { + provider: trimmed.slice(0, slash), + model: trimmed.slice(slash + 1), + }; +} + +function crestodianWelcome(overviewText: string): string { + return [ + overviewText, + "", + "Say: status, doctor, health, gateway status, restart gateway, agents, models, set default model , talk to agent, audit, or quit.", + ].join("\n"); +} + +class CrestodianTuiBackend implements TuiBackend { + readonly connection = { url: "crestodian local" }; + + onEvent?: (evt: TuiEvent) => void; + onConnected?: () => void; + onDisconnected?: (reason: string) => void; + onGap?: (info: { expected: number; received: number }) => void; + + private seq = 0; + private pending: CrestodianOperation | null = null; + private handoff: CrestodianOperation | null = null; + private requestExit: (() => void) | null = null; + private readonly messages: CrestodianHistoryMessage[] = []; + + constructor( + private readonly opts: CrestodianTuiOptions, + welcome: string, + ) { + this.messages.push(message("assistant", welcome)); + } + + setRequestExitHandler(handler: () => void): void { + this.requestExit = handler; + } + + consumeHandoff(): CrestodianOperation | null { + const handoff = this.handoff; + this.handoff = null; + return handoff; + } + + start(): void { + queueMicrotask(() => { + this.onConnected?.(); + }); + } + + stop(): void { + // The enclosing TUI owns terminal shutdown; Crestodian has no transport to close. + } + + async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> { + const runId = opts.runId ?? randomUUID(); + const text = opts.message.trim(); + this.messages.push(message("user", opts.message)); + void this.respond(runId, opts.sessionKey, text); + return { runId }; + } + + async abortChat(): Promise<{ ok: boolean; aborted: boolean }> { + return { ok: true, aborted: false }; + } + + async loadHistory(): Promise<{ + sessionId: string; + messages: CrestodianHistoryMessage[]; + thinkingLevel: string; + verboseLevel: string; + }> { + return { + sessionId: "crestodian", + messages: this.messages, + thinkingLevel: "off", + verboseLevel: "off", + }; + } + + async listSessions(): Promise { + const overview = await loadCrestodianOverview(); + const model = splitModelRef(overview.defaultModel); + return { + ts: Date.now(), + path: "crestodian", + count: 1, + defaults: { + model: model.model ?? null, + modelProvider: model.provider ?? null, + contextTokens: null, + }, + sessions: [ + { + key: CRESTODIAN_SESSION_KEY, + sessionId: "crestodian", + displayName: "Crestodian", + updatedAt: Date.now(), + thinkingLevel: "off", + verboseLevel: "off", + model: model.model, + modelProvider: model.provider, + }, + ], + }; + } + + async listAgents(): Promise { + return { + defaultId: CRESTODIAN_AGENT_ID, + mainKey: "main", + scope: "per-sender", + agents: [{ id: CRESTODIAN_AGENT_ID, name: "Crestodian" }], + }; + } + + async patchSession(opts: SessionsPatchParams): Promise { + const model = splitModelRef(typeof opts.model === "string" ? opts.model : undefined); + return { + ok: true, + path: "crestodian", + key: CRESTODIAN_SESSION_KEY, + entry: { + sessionId: "crestodian", + displayName: "Crestodian", + updatedAt: Date.now(), + ...(model.model ? { model: model.model } : {}), + ...(model.provider ? { modelProvider: model.provider } : {}), + }, + resolved: { + modelProvider: model.provider, + model: model.model, + }, + }; + } + + async resetSession(): Promise<{ ok: boolean }> { + this.pending = null; + const overview = await loadCrestodianOverview(); + this.messages.splice( + 0, + this.messages.length, + message("assistant", crestodianWelcome(formatCrestodianOverview(overview))), + ); + return { ok: true }; + } + + async getGatewayStatus(): Promise { + const overview = await loadCrestodianOverview(); + return overview.gateway.reachable ? "Gateway reachable" : "Gateway unreachable"; + } + + async listModels(): Promise { + return []; + } + + private nextSeq(): number { + this.seq += 1; + return this.seq; + } + + private emit(event: string, payload: unknown): void { + this.onEvent?.({ + event, + payload, + seq: this.nextSeq(), + }); + } + + private emitFinal(runId: string, sessionKey: string, text: string): void { + const assistant = message( + "assistant", + text || "Crestodian listened and found nothing to change.", + ); + this.messages.push(assistant); + this.emit("chat", { + runId, + sessionKey, + state: "final", + message: assistant, + }); + } + + private emitError(runId: string, sessionKey: string, error: unknown): void { + const errorMessage = error instanceof Error ? error.message : String(error); + this.emit("chat", { + runId, + sessionKey, + state: "error", + errorMessage, + }); + } + + private async respond(runId: string, sessionKey: string, text: string): Promise { + try { + const reply = await this.resolveReply(text); + this.emitFinal(runId, sessionKey, reply); + } catch (error) { + this.emitError(runId, sessionKey, error); + } + } + + private async resolveReply(text: string): Promise { + if (this.pending) { + if (isYes(text)) { + const pending = this.pending; + this.pending = null; + const capture = createCaptureRuntime(); + await executeCrestodianOperation(pending, capture, { + approved: true, + deps: this.opts.deps, + }); + return capture.read() || "Applied. Audit entry written."; + } + this.pending = null; + return "Skipped. No barnacles on config today."; + } + + const capture = createCaptureRuntime(); + const operation = await resolveCrestodianOperation(text, capture, this.opts); + + if (operation.kind === "open-tui") { + this.handoff = operation; + queueMicrotask(() => this.requestExit?.()); + return "Opening your normal agent TUI. Use /crestodian there to come back."; + } + + if (isPersistentCrestodianOperation(operation) && !this.opts.yes) { + this.pending = operation; + await executeCrestodianOperation(operation, capture, { + approved: false, + deps: this.opts.deps, + }); + return [capture.read(), approvalQuestion(operation)].filter(Boolean).join("\n\n"); + } + + await executeCrestodianOperation(operation, capture, { + approved: this.opts.yes === true || !isPersistentCrestodianOperation(operation), + deps: this.opts.deps, + }); + const reply = capture.read(); + if (operation.kind === "none" && reply.includes("Bye.")) { + queueMicrotask(() => this.requestExit?.()); + } + return reply; + } +} + +export async function runCrestodianTui( + opts: CrestodianTuiOptions, + runtime: RuntimeEnv, +): Promise { + let nextInput: string | undefined; + for (;;) { + const overview = await loadCrestodianOverview(); + const backend = new CrestodianTuiBackend( + opts, + crestodianWelcome(formatCrestodianOverview(overview)), + ); + await runTui({ + local: true, + session: CRESTODIAN_SESSION_KEY, + historyLimit: 200, + backend, + config: {}, + title: "openclaw crestodian", + ...(nextInput ? { message: nextInput } : {}), + }); + + const handoff = backend.consumeHandoff(); + if (!handoff) { + return; + } + const result = await executeCrestodianOperation(handoff, runtime, { + approved: true, + deps: opts.deps, + }); + nextInput = result.nextInput; + if (!nextInput?.trim()) { + return; + } + } +} diff --git a/src/tui/tui.ts b/src/tui/tui.ts index c5cb27c7edb..ab5fc65a53b 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -70,6 +70,12 @@ const OPENCLAW_DIST_ENTRY_MJS_PATH = fileURLToPath( const OPENAI_CODEX_PROVIDER = "openai-codex"; +type RunTuiOptions = TuiOptions & { + backend?: TuiBackend; + config?: OpenClawConfig; + title?: string; +}; + /** Resolve the absolute path to the `codex` CLI binary, or `null` if not installed. */ export function resolveCodexCliBin(): string | null { try { @@ -284,9 +290,9 @@ export function resolveCtrlCAction(params: { }; } -export async function runTui(opts: TuiOptions): Promise { - const isLocalMode = opts.local === true; - const config = loadConfig(); +export async function runTui(opts: RunTuiOptions): Promise { + const isLocalMode = opts.local === true || opts.backend !== undefined; + const config = opts.config ?? loadConfig(); const initialSessionInput = (opts.session ?? "").trim(); let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; let sessionMainKey = normalizeMainKey(config.session?.mainKey); @@ -496,13 +502,15 @@ export async function runTui(opts: TuiOptions): Promise { localBtwRunIds.clear(); }; - const client: TuiBackend = opts.local - ? new EmbeddedTuiBackend() - : await GatewayChatClient.connect({ - url: opts.url, - token: opts.token, - password: opts.password, - }); + const client: TuiBackend = opts.backend + ? opts.backend + : opts.local + ? new EmbeddedTuiBackend() + : await GatewayChatClient.connect({ + url: opts.url, + token: opts.token, + password: opts.password, + }); const previousConsoleSubsystemFilter = isLocalMode ? loggingState.consoleSubsystemFilter ? [...loggingState.consoleSubsystemFilter] @@ -577,9 +585,10 @@ export async function runTui(opts: TuiOptions): Promise { const updateHeader = () => { const sessionLabel = formatSessionKey(currentSessionKey); const agentLabel = formatAgentLabel(currentAgentId); + const title = opts.title ?? "openclaw tui"; header.setText( theme.header( - `openclaw tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`, + `${title} - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`, ), ); }; @@ -923,6 +932,10 @@ export async function runTui(opts: TuiOptions): Promise { finishTui?.(); }); }; + const exitAwareClient = client as TuiBackend & { + setRequestExitHandler?: (handler: () => void) => void; + }; + exitAwareClient.setRequestExitHandler?.(() => requestExit()); const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } = createCommandHandlers({