From e77eed76c9f6a975b2063ff03005ed3b52fccabc Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:17:46 +0800 Subject: [PATCH] fix(tui): track canonical model presets --- nanobot/channels/websocket/runtime.py | 35 +++++- .../websocket/tests/test_websocket_channel.py | 28 +++++ nanobot/cli/tui_launcher.py | 1 + tests/cli/test_tui_launcher.py | 52 ++++++++- tui/src/app.test.ts | 104 ++++++++++++++++++ tui/src/app.ts | 74 +++++++++++-- tui/src/index.ts | 1 + tui/src/protocol.test.ts | 20 +++- tui/src/protocol.ts | 27 ++++- tui/src/session-menu.test.ts | 2 + tui/src/session-menu.ts | 13 ++- 11 files changed, 339 insertions(+), 18 deletions(-) diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index b27fd18ff..957b51abe 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -52,6 +52,7 @@ from nanobot.security.workspace_access import ( WorkspaceScopeError, ) from nanobot.session.goal_state import goal_state_ws_blob +from nanobot.session.model_selection import model_preset_from_metadata from nanobot.session.webui_turns import ( clear_websocket_turn_if_current, clear_websocket_turns, @@ -433,6 +434,19 @@ class WebSocketChannel(BaseChannel): self._subs.setdefault(chat_id, set()).add(connection) self._conn_chats.setdefault(connection, set()).add(chat_id) + def _attached_model_fields(self, chat_id: str) -> dict[str, str | None]: + """Expose the session's canonical preset on the attach handshake.""" + sessions = self.gateway.session_manager + if sessions is None: + return {} + snapshot = sessions.read_session_metadata(f"websocket:{chat_id}") + metadata = snapshot.get("metadata") if isinstance(snapshot, dict) else None + try: + return {"model_preset": model_preset_from_metadata(metadata)} + except ValueError: + self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id) + return {"model_preset": None} + def _detach(self, connection: ServerConnection, chat_id: str) -> None: chats = self._conn_chats.get(connection) if chats is not None: @@ -478,7 +492,12 @@ class WebSocketChannel(BaseChannel): """Attach and hydrate a newly created WebUI chat fork.""" scope = self._workspaces.scope_for_session_key(fork_key) self._attach(connection, fork_id) - await self._send_event(connection, "attached", chat_id=fork_id) + await self._send_event( + connection, + "attached", + chat_id=fork_id, + **self._attached_model_fields(fork_id), + ) await self._send_event( connection, "session_updated", @@ -810,7 +829,12 @@ class WebSocketChannel(BaseChannel): return self._workspaces.persist_scope(new_id, scope) self._attach(connection, new_id) - await self._send_event(connection, "attached", chat_id=new_id) + await self._send_event( + connection, + "attached", + chat_id=new_id, + **self._attached_model_fields(new_id), + ) await self._send_event( connection, "session_updated", @@ -861,7 +885,12 @@ class WebSocketChannel(BaseChannel): await self._send_event(connection, "error", detail=exc.detail, chat_id=cid) return self._attach(connection, cid) - await self._send_event(connection, "attached", chat_id=cid) + await self._send_event( + connection, + "attached", + chat_id=cid, + **self._attached_model_fields(cid), + ) await self._hydrate_after_subscribe(cid) return if t == "set_sidebar_state": diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index d44155ab1..c90693b14 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -45,6 +45,7 @@ from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOUR from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY from nanobot.session import webui_turns as wth from nanobot.session.manager import SessionManager +from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY from nanobot.webui.gateway_services import GatewayServices, build_gateway_services from nanobot.webui.http_utils import ( http_error as _http_error, @@ -262,6 +263,33 @@ async def _new_temporary_chat( return payload["chat_id"] +@pytest.mark.asyncio +async def test_attach_exposes_the_session_canonical_model_preset(bus, tmp_path) -> None: + sessions = SessionManager(tmp_path) + session = sessions.get_or_create("websocket:pinned-model") + session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "Deep Research" + sessions.save(session) + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), + ) + connection = AsyncMock() + + await channel._dispatch_envelope( + connection, + "tui-client", + {"type": "attach", "chat_id": "pinned-model"}, + ) + + payload = json.loads(connection.send.await_args_list[0].args[0]) + assert payload == { + "event": "attached", + "chat_id": "pinned-model", + "model_preset": "Deep Research", + } + + @pytest.mark.asyncio async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None: sessions = SessionManager(tmp_path) diff --git a/nanobot/cli/tui_launcher.py b/nanobot/cli/tui_launcher.py index 93fa3fbff..85532b8a0 100644 --- a/nanobot/cli/tui_launcher.py +++ b/nanobot/cli/tui_launcher.py @@ -70,6 +70,7 @@ def launch_tui( "NANOBOT_TUI_API_URL": lease.base_url, "NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""), "NANOBOT_TUI_MODEL": _model_display(config)[0], + "NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default", "NANOBOT_TUI_WORKSPACE": str(config.workspace_path), "NANOBOT_TUI_VERSION": __version__, "NANOBOT_TUI_ACCESS": ( diff --git a/tests/cli/test_tui_launcher.py b/tests/cli/test_tui_launcher.py index f62e4cabc..3caa9509c 100644 --- a/tests/cli/test_tui_launcher.py +++ b/tests/cli/test_tui_launcher.py @@ -17,8 +17,9 @@ from nanobot.cli.tui_launcher import ( _resolve_source_tui_command, _resolve_tui_command, _websocket_chat_id, + launch_tui, ) -from nanobot.config.schema import Config +from nanobot.config.schema import Config, ModelPresetConfig def test_authenticated_ws_url_preserves_existing_query(monkeypatch: pytest.MonkeyPatch) -> None: @@ -63,6 +64,55 @@ def test_default_tui_resumes_but_explicit_session_wins(tmp_path: Path) -> None: assert _initial_tui_chat_id("websocket:chosen", path) == "chosen" +def test_launcher_passes_the_canonical_model_preset_to_the_tui( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = Config() + config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6") + config.agents.defaults.model_preset = "Deep Research" + closed: list[bool] = [] + captured: dict[str, str] = {} + + monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"]) + monkeypatch.setattr( + "nanobot.cli.tui_launcher._ensure_gateway", + lambda *args, **kwargs: SimpleNamespace( + base_url="http://127.0.0.1:8765", + close=lambda: closed.append(True), + ), + ) + monkeypatch.setattr( + "nanobot.cli.tui_launcher._fetch_bootstrap", + lambda *args, **kwargs: { + "ws_url": "ws://127.0.0.1:8765/ws", + "token": "socket-token", + "api_token": "api-token", + }, + ) + + def run(command: list[str], *, env: dict[str, str], check: bool) -> subprocess.CompletedProcess: + assert command == ["nanobot-tui"] + assert check is False + captured.update(env) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", run) + + result = launch_tui( + config, + config_path=tmp_path / "config.json", + workspace_override=None, + session_id=None, + theme="auto", + ) + + assert result == 0 + assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6" + assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research" + assert closed == [True] + + def test_explicit_tui_binary_must_exist( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index d602b8ba8..ca026017b 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -14,6 +14,7 @@ const options: AppOptions = { apiUrl: "", apiToken: "", model: "test/model", + modelPreset: "default", workspace: "/tmp/nanobot-workspace", version: "test", access: "workspace access", @@ -284,6 +285,7 @@ describe("NanobotTui layout", () => { title: "Release checklist", preview: "Prepare stable release", updated_at: "2026-08-12T10:00:00Z", + model_preset: "Deep Research", }, ], })))) as unknown as typeof fetch @@ -302,6 +304,7 @@ describe("NanobotTui layout", () => { composer: TextareaRenderable sessionMenu: { visible: boolean } titleText: { plainText: string } + modelText: { plainText: string } } try { @@ -315,6 +318,8 @@ describe("NanobotTui layout", () => { await waitUntil(() => attached.length === 1) expect(attached).toEqual(["other"]) expect(ui.titleText.plainText).toContain("Release checklist") + expect(ui.modelText.plainText).toContain("Deep Research") + expect(ui.modelText.plainText).not.toContain("test/model") app.accept({ event: "attached", chat_id: "other" }) await Bun.sleep(1) @@ -323,6 +328,105 @@ describe("NanobotTui layout", () => { await waitUntil(() => newChats.length === 1) expect(newChats).toEqual(["new"]) expect(ui.titleText.plainText).toContain("New chat") + expect(ui.modelText.plainText).toContain("test/model") + } finally { + globalThis.fetch = original + } + }) + + test("tracks canonical presets without overwriting a session override", async () => { + setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" }) + const app = mount(setup) + const ui = app as unknown as { modelText: { plainText: string } } + + app.accept({ event: "attached", chat_id: "chat", model_preset: "Codex" }) + app.accept({ + event: "turn_model_updated", + chat_id: "chat", + model_name: "openai/gpt-5.6", + model_preset: "Codex", + }) + await setup.flush() + expect(ui.modelText.plainText).toContain("Codex · openai/gpt-5.6") + + app.accept({ + event: "runtime_model_updated", + model_name: "deepseek/deepseek-chat", + model_preset: "DeepSeek", + }) + await setup.flush() + expect(ui.modelText.plainText).toContain("Codex · openai/gpt-5.6") + expect(ui.modelText.plainText).not.toContain("DeepSeek") + }) + + test("returns a default-following chat to the canonical default preset", async () => { + setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" }) + const app = NanobotTui.mount( + setup.renderer, + { ...options, model: "openai/gpt-5.6", modelPreset: "Codex" }, + client(), + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + const ui = app as unknown as { modelText: { plainText: string } } + + app.accept({ event: "attached", chat_id: "chat", model_preset: null }) + app.accept({ + event: "runtime_model_updated", + model_name: "deepseek/deepseek-chat", + model_preset: null, + }) + await setup.flush() + + expect(ui.modelText.plainText).toContain("deepseek/deepseek-chat") + expect(ui.modelText.plainText).not.toContain("Codex") + }) + + test("refreshes the canonical preset after the model command completes", async () => { + setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" }) + const original = globalThis.fetch + globalThis.fetch = ((input: string | URL | Request) => { + if (String(input).endsWith("/api/webui/sidebar-state")) { + return Promise.resolve(new Response(JSON.stringify({}))) + } + return Promise.resolve(new Response(JSON.stringify({ + sessions: [{ key: "websocket:chat", model_preset: "Deep Research" }], + }))) + }) as typeof fetch + const sent: string[] = [] + const app = NanobotTui.mount( + setup.renderer, + { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, + client(sent), + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + const ui = app as unknown as { + composer: TextareaRenderable + commandMenu: { setCommands(commands: SlashCommand[]): void } + modelText: { plainText: string } + } + + try { + app.accept({ event: "attached", chat_id: "chat", model_preset: null }) + ui.commandMenu.setCommands([{ + command: "/model", + title: "Model", + description: "Show or switch model presets", + argHint: "[preset]", + lifecycle: "side_channel", + acceptsArgs: true, + }]) + ui.composer.setText("/model deep research") + ui.composer.submit() + await waitUntil(() => sent.length === 1) + app.accept({ + event: "message", + chat_id: "chat", + text: "Switched model preset to Deep Research.", + turn_id: "turn", + }) + await waitUntil(() => ui.modelText.plainText.includes("Deep Research")) + + expect(sent).toEqual(["/model deep research"]) } finally { globalThis.fetch = original } diff --git a/tui/src/app.ts b/tui/src/app.ts index e137009b3..7854ec8c2 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -60,6 +60,7 @@ interface AppOptions { apiToken: string chatId?: string model: string + modelPreset: string workspace: string version: string access: string @@ -324,7 +325,11 @@ export class NanobotTui { private readonly promptHistory: string[] = [] private historyCursor = 0 private historyDraft = "" + private defaultModelName: string + private defaultModelPreset: string private modelName: string + private modelPreset: string + private sessionModelPreset: string | null | undefined private sessionTitle = "" private sessionMetadataId = 0 private contextTokens: number | null = null @@ -336,6 +341,7 @@ export class NanobotTui { private sessionLoadId = 0 private sessionLoading = false private readonly commandTurns = new Map() + private readonly modelCommandTurns = new Set() private currentFileEdits: FileEditEvent[] = [] private lastFileEdits: FileEditEvent[] = [] @@ -346,7 +352,11 @@ export class NanobotTui { treeSitterClient = getTreeSitterClient(), ) { this.renderer = renderer + this.defaultModelName = options.model + this.defaultModelPreset = options.modelPreset this.modelName = options.model + this.modelPreset = options.modelPreset + this.sessionModelPreset = options.chatId ? undefined : null this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null this.activeThemeMode = this.resolveThemeMode(renderer.themeMode) this.palette = this.activeThemeMode === "light" ? LIGHT : DARK @@ -623,7 +633,12 @@ export class NanobotTui { accept(event: InboundEvent): void { if (event.event === "attached") { void rememberChat(this.options.statePath, event.chat_id) + if (event.model_preset !== undefined) { + this.applyModelPreset(event.model_preset) + this.updateTitle() + } this.commandTurns.clear() + this.modelCommandTurns.clear() const restoring = this.attachedOnce this.attachedOnce = true if (restoring) this.setActive(false) @@ -663,6 +678,9 @@ export class NanobotTui { const lifecycle = this.commandTurns.get(event.turn_id) if (lifecycle !== "agent_turn") { this.commandTurns.delete(event.turn_id) + if (this.modelCommandTurns.delete(event.turn_id)) { + void this.refreshSessionMetadata(event.chat_id) + } this.transcript.assistant(event.text) if (!this.activeTurn) this.status.content = "Ready" return @@ -698,7 +716,10 @@ export class NanobotTui { } return case "turn_end": - if (event.turn_id) this.commandTurns.delete(event.turn_id) + if (event.turn_id) { + this.commandTurns.delete(event.turn_id) + this.modelCommandTurns.delete(event.turn_id) + } this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage) this.transcript.finishActivity() if (this.currentFileEdits.length) this.lastFileEdits = this.currentFileEdits @@ -723,10 +744,10 @@ export class NanobotTui { case "goal_state": return case "turn_model_updated": - this.setModel(event.model_name) + this.setTurnModel(event.model_name, event.model_preset) return case "runtime_model_updated": - this.setModel(event.model_name) + this.setDefaultModel(event.model_name, event.model_preset) return case "session_updated": if ( @@ -740,7 +761,10 @@ export class NanobotTui { return case "error": const commandLifecycle = event.turn_id ? this.commandTurns.get(event.turn_id) : undefined - if (event.turn_id) this.commandTurns.delete(event.turn_id) + if (event.turn_id) { + this.commandTurns.delete(event.turn_id) + this.modelCommandTurns.delete(event.turn_id) + } this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage) this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true) if (!commandLifecycle || commandLifecycle === "agent_turn") { @@ -766,7 +790,7 @@ export class NanobotTui { this.historyHasMore = false this.historyLoadingOlder = false this.transcript.reset({ - model: this.modelName, + model: this.modelName || this.modelPreset, workspace: this.options.workspace, version: this.options.version, access: this.options.access, @@ -1096,17 +1120,45 @@ export class NanobotTui { : "" } - private setModel(model: string): void { + private setTurnModel(model: string, preset?: string | null): void { this.modelName = model + this.modelPreset = preset?.trim() || "default" this.updateTitle() } + private setDefaultModel(model: string, preset?: string | null): void { + this.defaultModelName = model + this.defaultModelPreset = preset?.trim() || "default" + if (this.sessionModelPreset === null) { + this.modelName = this.defaultModelName + this.modelPreset = this.defaultModelPreset + this.updateTitle() + } + } + + private applySessionModel(session: SessionSummary): void { + this.applyModelPreset(session.modelPreset) + } + + private applyModelPreset(preset: string | null): void { + const currentModel = this.modelName + const currentPreset = this.modelPreset + this.sessionModelPreset = preset + this.modelPreset = preset || this.defaultModelPreset + this.modelName = this.modelPreset === this.defaultModelPreset + ? this.defaultModelName + : this.modelPreset === currentPreset ? currentModel : "" + } + private updateTitle(): void { const identity = this.sessionTitle.trim() || "nanobot" this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38)) this.titleText.content = identity const context = this.contextTokens === null ? "" : ` · ~${formatTokenCount(this.contextTokens)} ctx` - this.modelText.content = ` · ${this.modelName}${context}` + const runtime = this.modelPreset !== "default" + ? [this.modelPreset, this.modelName].filter(Boolean).join(" · ") + : this.modelName + this.modelText.content = ` · ${runtime}${context}` } private resizeComposer(): void { @@ -1205,6 +1257,7 @@ export class NanobotTui { const current = sessions.find((session) => session.chatId === this.client.activeChatId) if (current) { this.sessionTitle = sessionLabel(current) + this.applySessionModel(current) this.updateTitle() } const limit = this.renderer.height >= 20 ? 8 : 4 @@ -1227,6 +1280,7 @@ export class NanobotTui { } if (session.chatId === this.client.activeChatId) { this.sessionTitle = sessionLabel(session) + this.applySessionModel(session) this.updateTitle() this.closeSessions() this.status.content = this.readyStatus() @@ -1241,6 +1295,7 @@ export class NanobotTui { this.ready = false this.sessionMetadataId += 1 this.sessionTitle = sessionLabel(session) + this.applySessionModel(session) this.contextTokens = null this.updateTitle() this.status.content = "Opening session…" @@ -1267,6 +1322,9 @@ export class NanobotTui { this.ready = false this.sessionMetadataId += 1 this.sessionTitle = "New chat" + this.sessionModelPreset = null + this.modelName = this.defaultModelName + this.modelPreset = this.defaultModelPreset this.contextTokens = null this.updateTitle() this.status.content = "Starting a new chat…" @@ -1296,6 +1354,7 @@ export class NanobotTui { return } this.commandTurns.set(turnId, lifecycle) + if (/^\/model(?:\s|$)/iu.test(content)) this.modelCommandTurns.add(turnId) this.clearComposer() this.commandMenu.hide() if (lifecycle !== "stop_active_turn") this.transcript.user(content) @@ -1389,6 +1448,7 @@ export class NanobotTui { const session = sessions.find((candidate) => candidate.chatId === chatId) if (!session) return this.sessionTitle = sessionLabel(session) + this.applySessionModel(session) this.updateTitle() } catch { // Session metadata is decorative; conversation transport stays authoritative. diff --git a/tui/src/index.ts b/tui/src/index.ts index f8c317152..789dce1bd 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -18,6 +18,7 @@ const options: AppOptions = { apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "", chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined, model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model", + modelPreset: process.env.NANOBOT_TUI_MODEL_PRESET?.trim() || "default", workspace: process.env.NANOBOT_TUI_WORKSPACE?.trim() || "", version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev", access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access", diff --git a/tui/src/protocol.test.ts b/tui/src/protocol.test.ts index 0827c596d..27be076c6 100644 --- a/tui/src/protocol.test.ts +++ b/tui/src/protocol.test.ts @@ -70,7 +70,13 @@ describe("gateway protocol", () => { socket.emit("message", { data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }), }) - socket.emit("message", { data: JSON.stringify({ event: "attached", chat_id: "terminal" }) }) + socket.emit("message", { + data: JSON.stringify({ + event: "attached", + chat_id: "terminal", + model_preset: "Deep Research", + }), + }) client.send("hello") client.attach("other-chat") client.newChat() @@ -83,6 +89,11 @@ describe("gateway protocol", () => { expect(outbound[2]).toEqual({ type: "attach", chat_id: "other-chat" }) expect(outbound[3]).toEqual({ type: "new_chat" }) expect(events.map((event) => event.event)).toEqual(["ready", "attached"]) + expect(events[1]).toEqual({ + event: "attached", + chat_id: "terminal", + model_preset: "Deep Research", + }) } finally { Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original }) } @@ -127,13 +138,16 @@ describe("gateway protocol", () => { socket.emit("message", { data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: 42 }), }) + socket.emit("message", { + data: JSON.stringify({ event: "attached", chat_id: "one", model_preset: 42 }), + }) socket.emit("message", { data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: "metadata" }), }) socket.emit("message", { data: JSON.stringify({ event: "future_gateway_event" }) }) socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) }) expect(statuses).toContain("error:gateway sent an invalid event") - expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(5) + expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(6) expect(events).toContainEqual({ event: "session_updated", chat_id: "one", @@ -383,6 +397,7 @@ describe("gateway protocol", () => { created_at: "2026-08-12T10:00:00Z", updated_at: "2026-08-13T10:00:00Z", run_started_at: 123, + model_preset: "Deep Research", }, { key: "cli:direct", title: "Not a WebUI session" }, { key: 42 }, @@ -398,6 +413,7 @@ describe("gateway protocol", () => { createdAt: "2026-08-12T10:00:00Z", updatedAt: "2026-08-13T10:00:00Z", runStartedAt: 123, + modelPreset: "Deep Research", pinned: true, archived: false, }]) diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index dc4d17a13..3d116b763 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -38,7 +38,7 @@ export interface FileDiff { export type InboundEvent = | { event: "ready"; chat_id: string; client_id: string } - | { event: "attached"; chat_id: string } + | { event: "attached"; chat_id: string; model_preset?: string | null } | { event: "message_accepted"; chat_id: string; turn_id: string } | { event: "message" @@ -72,7 +72,12 @@ export type InboundEvent = | { event: "goal_state"; chat_id: string; goal_state: Record } | { event: "session_updated"; chat_id: string; scope?: string } | { event: "runtime_model_updated"; model_name: string; model_preset?: string | null } - | { event: "turn_model_updated"; chat_id: string; model_name: string } + | { + event: "turn_model_updated" + chat_id: string + model_name: string + model_preset?: string | null + } | { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string } type OutboundEvent = @@ -135,6 +140,7 @@ export interface SessionSummary { createdAt: string | null updatedAt: string | null runStartedAt: number | null + modelPreset: string | null pinned: boolean archived: boolean } @@ -232,6 +238,12 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined { } if (!CHAT_EVENTS.has(name)) return undefined // Forward-compatible additive event. if (typeof record.chat_id !== "string") return null + if ( + name === "attached" + && record.model_preset !== undefined + && record.model_preset !== null + && typeof record.model_preset !== "string" + ) return null if (["message", "delta", "reasoning_delta"].includes(name) && typeof record.text !== "string") { return null } @@ -253,7 +265,13 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined { if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null if (name === "session_updated" && !optional(record.scope, "string")) return null - if (name === "turn_model_updated" && typeof record.model_name !== "string") return null + if ( + name === "turn_model_updated" + && (typeof record.model_name !== "string" + || (record.model_preset !== undefined + && record.model_preset !== null + && typeof record.model_preset !== "string")) + ) return null return value as InboundEvent } @@ -411,6 +429,9 @@ export async function fetchSessions( createdAt: typeof value.created_at === "string" ? value.created_at : null, updatedAt: typeof value.updated_at === "string" ? value.updated_at : null, runStartedAt: typeof value.run_started_at === "number" ? value.run_started_at : null, + modelPreset: typeof value.model_preset === "string" && value.model_preset.trim() + ? value.model_preset.trim() + : null, pinned: pinned.has(value.key), archived: archived.has(value.key), }] diff --git a/tui/src/session-menu.test.ts b/tui/src/session-menu.test.ts index a5d67d0bd..7976dc1fe 100644 --- a/tui/src/session-menu.test.ts +++ b/tui/src/session-menu.test.ts @@ -12,6 +12,7 @@ const sessions: SessionSummary[] = [ createdAt: "2026-08-12T10:00:00Z", updatedAt: "2026-08-13T10:00:00Z", runStartedAt: null, + modelPreset: "Codex", pinned: true, archived: false, }, @@ -22,6 +23,7 @@ const sessions: SessionSummary[] = [ createdAt: "2026-08-11T10:00:00Z", updatedAt: "2026-08-12T10:00:00Z", runStartedAt: null, + modelPreset: null, pinned: false, archived: false, }, diff --git a/tui/src/session-menu.ts b/tui/src/session-menu.ts index d65546d52..6d53d30e4 100644 --- a/tui/src/session-menu.ts +++ b/tui/src/session-menu.ts @@ -29,11 +29,20 @@ export class SessionMenu { constructor(renderer: CliRenderer, theme: PickerMenuTheme) { this.picker = new PickerMenu(renderer, theme, { id: "nanobot-tui-session-menu", - searchText: (session) => `${sessionLabel(session)} ${session.preview} ${session.chatId}`, + searchText: (session) => [ + sessionLabel(session), + session.modelPreset || "", + session.preview, + session.chatId, + ].join(" "), render: (session) => { const age = updatedLabel(session.updatedAt) const preview = session.preview.trim() - const detail = [age, preview && preview !== sessionLabel(session) ? preview : ""] + const detail = [ + session.modelPreset, + age, + preview && preview !== sessionLabel(session) ? preview : "", + ] .filter(Boolean) .join(" · ") const marker = session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""