fix(tui): track canonical model presets

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 9d7453a53e
commit e77eed76c9
11 changed files with 339 additions and 18 deletions
+32 -3
View File
@@ -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":
@@ -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)
+1
View File
@@ -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": (
+51 -1
View File
@@ -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,
+104
View File
@@ -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
}
+67 -7
View File
@@ -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<string, ResolvedSlashCommandLifecycle>()
private readonly modelCommandTurns = new Set<string>()
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.
+1
View File
@@ -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",
+18 -2
View File
@@ -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,
}])
+24 -3
View File
@@ -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<string, unknown> }
| { 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),
}]
+2
View File
@@ -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,
},
+11 -2
View File
@@ -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 ? "◇ " : ""