From 03d982023affcba12c0a7e929488fcef095870f2 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:27:57 +0800 Subject: [PATCH] feat(tui): add clickable runtime controls --- docs/cli-reference.md | 2 +- nanobot/channels/websocket/runtime.py | 4 + .../websocket/tests/test_websocket_channel.py | 41 +++ tui/README.md | 4 + tui/src/app.test.ts | 110 ++++++- tui/src/app.ts | 109 +++++-- tui/src/footer-hints.ts | 4 + tui/src/picker-menu.ts | 19 ++ tui/src/protocol.test.ts | 55 ++++ tui/src/protocol.ts | 78 ++++- tui/src/runtime-controls.ts | 307 ++++++++++++++++++ 11 files changed, 689 insertions(+), 44 deletions(-) create mode 100644 tui/src/runtime-controls.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 65f41256d..4929ecb61 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -123,7 +123,7 @@ workspace file. Back up both the config directory and workspace before changing Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, the command starts the shared background gateway. Exiting one TUI disconnects only that client, so other terminals and the WebUI stay connected; use `nanobot gateway stop` when you want to stop the shared process. -The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. +The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. The model preset and workspace access labels above the composer can be clicked to open their selectors; arrow keys, `Enter`, and `Esc` provide the same controls without a mouse. Access changes still pass through the gateway's local-trust and active-turn policy checks. `Enter` sends the current message. While a turn is active, `Enter` steers it immediately, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message to the composer. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback when a terminal cannot distinguish modified Enter keys. `Alt+Enter` and `Ctrl+Enter` are also accepted when distinguishable. Use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen. diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index ab09925c0..58ebd826f 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -1013,6 +1013,10 @@ class WebSocketChannel(BaseChannel): if scope is None: return self._workspaces.persist_scope(cid, scope) + # Other clients on the same gateway only need an invalidation; they + # can reload the authoritative session row without receiving a + # local project path that belongs to another connection. + await self.send_session_updated(cid, scope="metadata") await self._send_event( connection, "session_updated", diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index f721fef6d..d24338920 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -1465,6 +1465,47 @@ async def test_webui_message_scope_inherits_persisted_session_scope( } +@pytest.mark.asyncio +async def test_workspace_scope_change_invalidates_other_attached_clients( + bus: MagicMock, + tmp_path, +) -> None: + sessions = SessionManager(tmp_path / "sessions") + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), + ) + origin = AsyncMock() + origin.remote_address = ("127.0.0.1", 50123) + peer = AsyncMock() + peer.remote_address = ("127.0.0.1", 50124) + channel._attach(origin, "shared") + channel._attach(peer, "shared") + + await channel._dispatch_envelope( + origin, + "terminal-a", + { + "type": "set_workspace_scope", + "chat_id": "shared", + "workspace_scope": { + "project_path": str(tmp_path), + "access_mode": "full", + }, + }, + ) + + peer_event = json.loads(peer.send.await_args.args[0]) + assert peer_event == { + "event": "session_updated", + "chat_id": "shared", + "scope": "metadata", + } + origin_event = json.loads(origin.send.await_args.args[0]) + assert origin_event["workspace_scope"]["access_mode"] == "full" + + @pytest.mark.asyncio async def test_webui_scope_expands_home_project_path( bus: MagicMock, diff --git a/tui/README.md b/tui/README.md index aef126fe3..f83ee5f54 100644 --- a/tui/README.md +++ b/tui/README.md @@ -13,6 +13,10 @@ bun run --cwd tui build The renderer uses 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. +The model preset and workspace access labels above the composer are live controls. Click either +label, then click a choice; arrow keys, `Enter`, and `Esc` provide the same flow without a mouse. +Changes reuse the gateway's normal model command and workspace policy checks. + When you scroll away from the latest output, the scrollbar and `Ctrl+End` hint appear only until you return to the bottom. Large pastes are represented by a short editable placeholder in the composer; nanobot sends the original text unchanged. diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 9d66f0f97..ba07aafca 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" -import { CliRenderEvents, TextareaRenderable } from "@opentui/core" +import { CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core" import { MockTreeSitterClient, createTestRenderer, @@ -7,7 +7,7 @@ import { } from "@opentui/core/testing" import { NanobotTui, type AppOptions } from "./app" -import type { MessageOptions, SlashCommand } from "./protocol" +import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol" const options: AppOptions = { wsUrl: "ws://localhost.invalid/ws", @@ -61,6 +61,7 @@ function client( newChats: string[] = [], sentOptions: MessageOptions[] = [], forks: Array<{ source: string; before: number; title?: string }> = [], + scopes: WorkspaceScopePayload[] = [], ) { return { activeChatId: "chat", @@ -74,12 +75,15 @@ function client( attach(chatId: string) { attached.push(chatId) }, - newChat() { + newChat(_scope?: WorkspaceScopePayload) { newChats.push("new") }, forkChat(source: string, before: number, title?: string) { forks.push({ source, before, ...(title ? { title } : {}) }) }, + setWorkspaceScope(scope: WorkspaceScopePayload) { + scopes.push(scope) + }, } } @@ -485,7 +489,7 @@ describe("NanobotTui layout", () => { composer: TextareaRenderable sessionMenu: { visible: boolean } titleText: { plainText: string } - modelText: { plainText: string } + runtimeControls: { modelText: { plainText: string } } } try { @@ -499,8 +503,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") + expect(ui.runtimeControls.modelText.plainText).toContain("Deep Research") + expect(ui.runtimeControls.modelText.plainText).not.toContain("test/model") app.accept({ event: "attached", chat_id: "other" }) await Bun.sleep(1) @@ -509,7 +513,7 @@ 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") + expect(ui.runtimeControls.modelText.plainText).toContain("test/model") } finally { globalThis.fetch = original } @@ -518,7 +522,7 @@ describe("NanobotTui layout", () => { 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 } } + const ui = app as unknown as { runtimeControls: { modelText: { plainText: string } } } app.accept({ event: "attached", chat_id: "chat", model_preset: "Codex" }) app.accept({ @@ -528,7 +532,7 @@ describe("NanobotTui layout", () => { model_preset: "Codex", }) await setup.flush() - expect(ui.modelText.plainText).toContain("Codex · openai/gpt-5.6") + expect(ui.runtimeControls.modelText.plainText).toContain("Codex · openai/gpt-5.6") app.accept({ event: "runtime_model_updated", @@ -536,8 +540,8 @@ describe("NanobotTui layout", () => { model_preset: "DeepSeek", }) await setup.flush() - expect(ui.modelText.plainText).toContain("Codex · openai/gpt-5.6") - expect(ui.modelText.plainText).not.toContain("DeepSeek") + expect(ui.runtimeControls.modelText.plainText).toContain("Codex · openai/gpt-5.6") + expect(ui.runtimeControls.modelText.plainText).not.toContain("DeepSeek") }) test("returns a default-following chat to the canonical default preset", async () => { @@ -548,7 +552,7 @@ describe("NanobotTui layout", () => { client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) - const ui = app as unknown as { modelText: { plainText: string } } + const ui = app as unknown as { runtimeControls: { modelText: { plainText: string } } } app.accept({ event: "attached", chat_id: "chat", model_preset: null }) app.accept({ @@ -558,8 +562,8 @@ describe("NanobotTui layout", () => { }) await setup.flush() - expect(ui.modelText.plainText).toContain("deepseek/deepseek-chat") - expect(ui.modelText.plainText).not.toContain("Codex") + expect(ui.runtimeControls.modelText.plainText).toContain("deepseek/deepseek-chat") + expect(ui.runtimeControls.modelText.plainText).not.toContain("Codex") }) test("refreshes the canonical preset after the model command completes", async () => { @@ -583,7 +587,7 @@ describe("NanobotTui layout", () => { const ui = app as unknown as { composer: TextareaRenderable commandMenu: { setCommands(commands: SlashCommand[]): void } - modelText: { plainText: string } + runtimeControls: { modelText: { plainText: string } } } try { @@ -605,7 +609,7 @@ describe("NanobotTui layout", () => { text: "Switched model preset to Deep Research.", turn_id: "turn", }) - await waitUntil(() => ui.modelText.plainText.includes("Deep Research")) + await waitUntil(() => ui.runtimeControls.modelText.plainText.includes("Deep Research")) expect(sent).toEqual(["/model deep research"]) } finally { @@ -613,6 +617,76 @@ describe("NanobotTui layout", () => { } }) + test("opens composer runtime controls by mouse and applies canonical choices", async () => { + const original = globalThis.fetch + const sent: string[] = [] + const scopes: WorkspaceScopePayload[] = [] + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + if (url.endsWith("/api/settings")) return new Response(JSON.stringify({ + model_presets: [ + { name: "default", model: "test/model" }, + { name: "fast", model: "fast/model" }, + ], + })) + if (url.endsWith("/api/workspaces")) return new Response(JSON.stringify({ + controls: { can_use_full_access: true }, + })) + return new Response(JSON.stringify({ sessions: [] })) + }) as typeof fetch + setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" }) + const app = NanobotTui.mount( + setup.renderer, + { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, + client(sent, [], [], [], [], scopes), + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + app.accept({ event: "attached", chat_id: "chat" }) + const ui = app as unknown as { + runtimeControls: { + modelText: TextRenderable + accessText: TextRenderable + visible: boolean + menuRoot: { getChildren(): unknown[] } + } + } + + try { + await waitUntil(() => (app as unknown as { ready: boolean }).ready) + await setup.flush() + await setup.mockMouse.click( + ui.runtimeControls.modelText.x + 2, + ui.runtimeControls.modelText.y, + ) + await waitUntil(() => ui.runtimeControls.visible) + await setup.flush() + const modelRows = ui.runtimeControls.menuRoot.getChildren() as TextRenderable[] + const fast = modelRows.find((row) => row.plainText.includes("fast")) + if (!fast) throw new Error("fast model row was not rendered") + await setup.mockMouse.click(fast.x + 2, fast.y) + await waitUntil(() => sent.includes("/model fast")) + + await setup.mockMouse.click( + ui.runtimeControls.accessText.x + 2, + ui.runtimeControls.accessText.y, + ) + await waitUntil(() => ui.runtimeControls.visible) + await setup.flush() + const accessRows = ui.runtimeControls.menuRoot.getChildren() as TextRenderable[] + const full = accessRows.find((row) => row.plainText.includes("Full access")) + if (!full) throw new Error("full access row was not rendered") + await setup.mockMouse.click(full.x + 2, full.y) + + expect(scopes).toEqual([{ + project_path: "/tmp/nanobot-workspace", + access_mode: "full", + restrict_to_workspace: false, + }]) + } finally { + globalThis.fetch = original + } + }) + test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const sent: string[] = [] @@ -784,7 +858,7 @@ describe("NanobotTui layout", () => { const ui = app as unknown as { composer: TextareaRenderable contextPanel: { visible: boolean } - modelText: { plainText: string } + runtimeControls: { contextText: { plainText: string } } } try { @@ -792,7 +866,7 @@ describe("NanobotTui layout", () => { ui.composer.submit() await waitUntil(() => ui.contextPanel.visible) await setup.flush() - expect(ui.modelText.plainText).toContain("~2.2k ctx") + expect(ui.runtimeControls.contextText.plainText).toContain("~2.2k ctx") const frame = setup.captureCharFrame() expect(frame).toContain("Agent context") diff --git a/tui/src/app.ts b/tui/src/app.ts index a42acbc21..b779c6468 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -34,6 +34,7 @@ import { type SlashCommand, type SessionSummary, type TokenUsage, + type WorkspaceScopePayload, } from "./protocol" import { CommandMenu, @@ -67,6 +68,7 @@ import { } from "./mention-menu" import { PromptQueue, type QueuedPrompt } from "./prompt-queue" import { QueuePreview, type QueuePreviewTheme } from "./queue-preview" +import { RuntimeControls } from "./runtime-controls" import { contextualFooterHints, type FooterMode, @@ -93,8 +95,9 @@ interface ChatClient { close(): void send(content: string, options?: MessageOptions): string attach(chatId: string): void - newChat(): void + newChat(scope?: WorkspaceScopePayload): void forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void + setWorkspaceScope(scope: WorkspaceScopePayload): void } interface Palette { @@ -227,6 +230,15 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme { text: palette.text, muted: palette.muted, border: palette.border, + selectedBackground: palette.userBackground, + } +} + +function runtimeControlsTheme(palette: Palette) { + return { + ...commandMenuTheme(palette), + accent: palette.accent, + faint: palette.faint, } } @@ -350,6 +362,7 @@ export class NanobotTui { private readonly sessionMenu: SessionMenu private readonly mentionMenu: MentionMenu private readonly branchMenu: BranchMenu + private readonly runtimeControls: RuntimeControls private readonly contextPanel: ContextPanel private readonly diffViewer: DiffViewer private readonly queuePreview: QueuePreview @@ -357,7 +370,6 @@ export class NanobotTui { private readonly shell: BoxRenderable private readonly title: BoxRenderable private readonly titleText: TextRenderable - private readonly modelText: TextRenderable private readonly composerFrame: BoxRenderable private readonly composer: TextareaRenderable private readonly status: TextRenderable @@ -411,6 +423,7 @@ export class NanobotTui { private sessionLoading = false private readonly commandTurns = new Map() private readonly modelCommandTurns = new Set() + private readonly silentCommandTurns = new Set() private currentFileEdits: FileEditEvent[] = [] private lastFileEdits: FileEditEvent[] = [] @@ -484,15 +497,36 @@ export class NanobotTui { truncate: true, fg: this.palette.muted, }) - this.modelText = new TextRenderable(renderer, { - id: "nanobot-tui-model-text", - content: ` · ${this.modelName}`, - height: 1, - flexShrink: 1, - fg: this.palette.muted, - }) + this.runtimeControls = new RuntimeControls( + renderer, + runtimeControlsTheme(this.palette), + { + apiUrl: options.apiUrl, + apiToken: options.apiToken, + model: this.modelName, + modelPreset: this.modelPreset, + workspace: options.workspace, + access: options.access, + available: () => ({ ready: this.ready, active: this.activeTurn }), + beforeOpen: () => this.closeTransientMenus(), + refreshScope: () => this.refreshSessionMetadata(this.client.activeChatId), + onModel: (preset) => this.sendGatewayCommand(`/model ${preset}`, "side_channel", true), + onAccess: (scope) => { + try { + this.client.setWorkspaceScope(scope) + this.status.content = "Changing access…" + } catch (error) { + this.status.content = error instanceof Error ? error.message : String(error) + } + }, + onStatus: (message) => { this.status.content = message }, + onVisibilityChange: () => this.updateMeta(), + }, + ) this.title.add(this.titleText) - this.title.add(this.modelText) + this.title.add(this.runtimeControls.modelText) + this.title.add(this.runtimeControls.accessText) + this.title.add(this.runtimeControls.contextText) const composerSurface = this.composerSurface() this.composerFrame = new BoxRenderable(renderer, { id: "nanobot-tui-composer-frame", @@ -528,6 +562,7 @@ export class NanobotTui { ], onContentChange: () => { this.draft.prune(this.composer.plainText) + this.runtimeControls.hide() if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide() this.syncComposerPlaceholder() if (this.sessionMenu.visible) this.syncSessionMenu() @@ -577,6 +612,7 @@ export class NanobotTui { this.shell.add(this.mentionMenu.root) this.shell.add(this.branchMenu.root) this.shell.add(this.contextPanel.root) + this.shell.add(this.runtimeControls.menuRoot) this.shell.add(this.title) this.shell.add(this.queuePreview.root) this.shell.add(this.composerFrame) @@ -656,6 +692,10 @@ export class NanobotTui { this.status.content = "Loading sessions…" return } + if (this.runtimeControls.visible) { + this.runtimeControls.choose() + return + } if (this.sessionMenu.visible) { const session = this.sessionMenu.choose() if (session) this.switchSession(session) @@ -821,10 +861,11 @@ export class NanobotTui { const lifecycle = this.commandTurns.get(event.turn_id) if (lifecycle !== "agent_turn") { this.commandTurns.delete(event.turn_id) + const silent = this.silentCommandTurns.delete(event.turn_id) if (this.modelCommandTurns.delete(event.turn_id)) { void this.refreshSessionMetadata(event.chat_id) } - this.transcript.assistant(event.text) + if (!silent) this.transcript.assistant(event.text) if (!this.activeTurn) this.status.content = "Ready" return } @@ -907,6 +948,7 @@ export class NanobotTui { this.setDefaultModel(event.model_name, event.model_preset) return case "session_updated": + if (event.workspace_scope) this.applyWorkspaceScope(event.workspace_scope) if ( !this.sessionTitle || this.sessionTitle === "New chat" @@ -921,6 +963,7 @@ export class NanobotTui { if (event.turn_id) { this.commandTurns.delete(event.turn_id) this.modelCommandTurns.delete(event.turn_id) + this.silentCommandTurns.delete(event.turn_id) } if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) { this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true) @@ -1147,6 +1190,7 @@ export class NanobotTui { key.preventDefault() return } + if (this.runtimeControls.handleKey(key)) return if (this.sessionMenu.visible) { if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) { this.sessionMenu.move(key.name === "up" ? -1 : 1) @@ -1328,6 +1372,7 @@ export class NanobotTui { this.sessionMenu.setTheme(commandMenuTheme(this.palette)) this.mentionMenu.setTheme(commandMenuTheme(this.palette)) this.branchMenu.setTheme(commandMenuTheme(this.palette)) + this.runtimeControls.setTheme(runtimeControlsTheme(this.palette)) this.contextPanel.setTheme(contextPanelTheme(this.palette)) this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown)) this.queuePreview.setTheme(queuePreviewTheme(this.palette)) @@ -1336,7 +1381,6 @@ export class NanobotTui { this.composer.focusedTextColor = this.palette.text this.composer.cursorColor = this.palette.accent this.titleText.fg = this.palette.muted - this.modelText.fg = this.palette.muted this.status.fg = this.palette.muted this.meta.fg = this.palette.faint this.updateMeta() @@ -1347,12 +1391,14 @@ export class NanobotTui { this.contextPanel.resize(this.renderer.height) this.diffViewer.resize(this.renderer.width) this.title.visible = this.renderer.height >= 14 + this.runtimeControls.resize(this.renderer.width) this.updateTitle() this.updateMeta() } private updateMeta(): void { - const mode: FooterMode = this.mentionMenu.visible ? "mention" + const mode: FooterMode = this.runtimeControls.visible ? "runtime" + : this.mentionMenu.visible ? "mention" : this.activeTurn ? "active" : this.branchMenu.visible ? "branch" : this.commandMenu.visible ? "command" @@ -1389,6 +1435,10 @@ export class NanobotTui { this.applyModelPreset(session.modelPreset) } + private applySessionScope(session: SessionSummary): void { + if (session.workspaceScope) this.applyWorkspaceScope(session.workspaceScope) + } + private applyModelPreset(preset: string | null): void { const currentModel = this.modelName const currentPreset = this.modelPreset @@ -1408,10 +1458,8 @@ export class NanobotTui { : ` · ~${formatTokenCount(this.contextTokens)}${this.contextWindowTokens ? `/${formatTokenCount(this.contextWindowTokens)}` : ""} ctx` - const runtime = this.modelPreset !== "default" - ? [this.modelPreset, this.modelName].filter(Boolean).join(" · ") - : this.modelName - this.modelText.content = ` · ${runtime}${context}` + this.runtimeControls.updateModel(this.modelName, this.modelPreset) + this.runtimeControls.updateContext(context) } private resizeComposer(): void { @@ -1530,6 +1578,21 @@ export class NanobotTui { this.syncCommandMenu() } + private closeTransientMenus(): void { + this.commandMenu.hide() + this.sessionMenu.hide() + this.mentionMenu.hide() + this.branchMenu.hide() + this.contextPanel.hide() + this.activeMentionQuery = null + } + + private applyWorkspaceScope(scope: WorkspaceScopePayload): void { + this.runtimeControls.updateWorkspaceScope(scope) + this.updateTitle() + if (!this.activeTurn && this.ready) this.status.content = this.readyStatus() + } + private async loadMentions(): Promise { try { this.mentionCandidates = await fetchMentionCandidates( @@ -1639,6 +1702,7 @@ export class NanobotTui { if (current) { this.sessionTitle = sessionLabel(current) this.applySessionModel(current) + this.applySessionScope(current) this.updateTitle() } const limit = this.renderer.height >= 20 ? 8 : 4 @@ -1662,6 +1726,7 @@ export class NanobotTui { if (session.chatId === this.client.activeChatId) { this.sessionTitle = sessionLabel(session) this.applySessionModel(session) + this.applySessionScope(session) this.updateTitle() this.closeSessions() this.status.content = this.readyStatus() @@ -1678,6 +1743,7 @@ export class NanobotTui { this.sessionMetadataId += 1 this.sessionTitle = sessionLabel(session) this.applySessionModel(session) + this.applySessionScope(session) this.contextTokens = null this.lastUsage = null this.readyDetail = "" @@ -1717,7 +1783,7 @@ export class NanobotTui { this.readyDetail = "" this.updateTitle() this.status.content = "Starting a new chat…" - this.client.newChat() + this.client.newChat(this.runtimeControls.workspaceScope) } catch (error) { this.status.content = error instanceof Error ? error.message : String(error) } @@ -1726,6 +1792,7 @@ export class NanobotTui { private sendGatewayCommand( content: string, lifecycle: ResolvedSlashCommandLifecycle, + silent = false, ): void { if (!this.ready) { this.status.content = "Preparing chat…" @@ -1743,11 +1810,12 @@ export class NanobotTui { return } this.commandTurns.set(turnId, lifecycle) + if (silent) this.silentCommandTurns.add(turnId) if (/^\/model(?:\s|$)/iu.test(content)) this.modelCommandTurns.add(turnId) this.clearComposer() this.commandMenu.hide() - if (lifecycle !== "stop_active_turn") this.transcript.user(content) - this.recordPrompt(content) + if (!silent && lifecycle !== "stop_active_turn") this.transcript.user(content) + if (!silent) this.recordPrompt(content) if (lifecycle === "agent_turn") { this.activeTurnId = turnId @@ -1844,6 +1912,7 @@ export class NanobotTui { if (!session) return this.sessionTitle = sessionLabel(session) this.applySessionModel(session) + this.applySessionScope(session) this.updateTitle() } catch { // Session metadata is decorative; conversation transport stays authoritative. diff --git a/tui/src/footer-hints.ts b/tui/src/footer-hints.ts index 1d4390f2c..80fd758c1 100644 --- a/tui/src/footer-hints.ts +++ b/tui/src/footer-hints.ts @@ -17,6 +17,7 @@ export interface FooterHintTheme { export type FooterMode = | "mention" + | "runtime" | "active" | "branch" | "command" @@ -53,6 +54,9 @@ function hintsFor( platform: string, shiftedEnter: boolean, ): FooterHint[] { + if (mode === "runtime") return width >= 64 + ? [hint("↑↓/click", "choose"), hint("enter", "apply"), hint("esc", "close")] + : [hint("enter", "apply"), hint("esc", "close")] if (mode === "mention") return width >= 64 ? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")] : [hint("enter", "insert"), hint("esc", "close")] diff --git a/tui/src/picker-menu.ts b/tui/src/picker-menu.ts index 24ec93309..ae60a3106 100644 --- a/tui/src/picker-menu.ts +++ b/tui/src/picker-menu.ts @@ -10,6 +10,7 @@ export interface PickerMenuTheme { text: string muted: string border: string + selectedBackground?: string } interface PickerMenuOptions { @@ -17,6 +18,8 @@ interface PickerMenuOptions { searchText: (item: T) => string render: (item: T) => string emptyText?: string + maxWidth?: number + onSelect?: (item: T) => void } /** Shared retained picker for command discovery and session navigation. */ @@ -36,6 +39,7 @@ export class PickerMenu { this.root = new BoxRenderable(renderer, { id: options.id, width: "100%", + ...(options.maxWidth ? { maxWidth: options.maxWidth } : {}), flexShrink: 0, flexDirection: "column", border: true, @@ -121,7 +125,22 @@ export class PickerMenu { height: 1, wrapMode: "none", fg: selected ? this.theme.text : this.theme.muted, + ...(selected && this.theme.selectedBackground + ? { backgroundColor: RGBA.fromHex(this.theme.selectedBackground) } + : {}), attributes: selected ? TextAttributes.BOLD : 0, + onMouseOver: () => { + if (this.selected === index) return + this.selected = index + this.render() + }, + onMouseDown: (event) => { + if (event.button !== 0) return + event.preventDefault() + event.stopPropagation() + this.selected = index + this.options.onSelect?.(item) + }, })) } } diff --git a/tui/src/protocol.test.ts b/tui/src/protocol.test.ts index fe1349fdd..03c1d693f 100644 --- a/tui/src/protocol.test.ts +++ b/tui/src/protocol.test.ts @@ -4,6 +4,7 @@ import { NanobotClient, fetchHistory, fetchMentionCandidates, + fetchRuntimeControls, fetchSessionContext, fetchSessions, fetchSlashCommands, @@ -99,6 +100,10 @@ describe("gateway protocol", () => { client.attach("other-chat") client.newChat() client.forkChat("terminal", 3, "Alternative") + client.setWorkspaceScope({ + project_path: "/tmp/project", + access_mode: "restricted", + }) const outbound = socket.sent.map((value) => JSON.parse(value) as Record) expect(outbound[0]).toEqual({ type: "attach", chat_id: "terminal" }) @@ -117,6 +122,14 @@ describe("gateway protocol", () => { before_user_index: 3, title: "Alternative", }) + expect(outbound[5]).toEqual({ + type: "set_workspace_scope", + chat_id: "terminal", + workspace_scope: { + project_path: "/tmp/project", + access_mode: "restricted", + }, + }) expect(events.map((event) => event.event)).toEqual(["ready", "attached"]) expect(events[1]).toEqual({ event: "attached", @@ -128,6 +141,48 @@ describe("gateway protocol", () => { } }) + test("loads runtime model and access controls from canonical APIs", async () => { + const original = globalThis.fetch + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + if (url.endsWith("/api/settings")) return new Response(JSON.stringify({ + model_presets: [ + { name: "Codex", model: "openai-codex/gpt-5.6" }, + { name: "broken" }, + ], + })) + return new Response(JSON.stringify({ controls: { can_use_full_access: true } })) + }) as typeof fetch + + try { + expect(await fetchRuntimeControls("http://nanobot.test", "secret")).toEqual({ + modelPresets: [{ name: "Codex", model: "openai-codex/gpt-5.6" }], + canUseFullAccess: true, + }) + } finally { + globalThis.fetch = original + } + }) + + test("keeps model selection available when workspace controls are unavailable", async () => { + const original = globalThis.fetch + globalThis.fetch = ((input: string | URL | Request) => Promise.resolve( + String(input).endsWith("/api/settings") + ? new Response(JSON.stringify({ + model_presets: [{ name: "fast", model: "openai/gpt-5.6" }], + })) + : new Response("", { status: 404 }), + )) as typeof fetch + try { + expect(await fetchRuntimeControls("http://nanobot.test", "secret")).toEqual({ + modelPresets: [{ name: "fast", model: "openai/gpt-5.6" }], + canUseFullAccess: false, + }) + } finally { + globalThis.fetch = original + } + }) + test("rejects malformed gateway events", () => { const original = globalThis.WebSocket let socket: FakeSocket | undefined diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index a293d4977..abd79bb74 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -42,6 +42,18 @@ export interface MediaAttachment { name?: string } +export interface WorkspaceScopePayload { + project_path: string + project_name?: string + access_mode: "restricted" | "full" + restrict_to_workspace?: boolean +} + +export interface RuntimeControls { + modelPresets: Array<{ name: string; model: string }> + canUseFullAccess: boolean +} + export type InboundEvent = | { event: "ready"; chat_id: string; client_id: string } | { @@ -105,7 +117,12 @@ export type InboundEvent = turn_id?: string } | { event: "goal_state"; chat_id: string; goal_state: Record } - | { event: "session_updated"; chat_id: string; scope?: string } + | { + event: "session_updated" + chat_id: string + scope?: string + workspace_scope?: WorkspaceScopePayload + } | { event: "runtime_model_updated"; model_name: string; model_preset?: string | null } | { event: "turn_model_updated" @@ -117,9 +134,10 @@ export type InboundEvent = | { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string } type OutboundEvent = - | { type: "new_chat" } + | { type: "new_chat"; workspace_scope?: WorkspaceScopePayload } | { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string } | { type: "attach"; chat_id: string } + | { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload } | { type: "message" chat_id: string @@ -222,6 +240,7 @@ export interface SessionSummary { updatedAt: string | null runStartedAt: number | null modelPreset: string | null + workspaceScope?: WorkspaceScopePayload | null pinned: boolean archived: boolean } @@ -315,6 +334,14 @@ function isMediaAttachment(value: unknown): value is MediaAttachment { && optional(value.name, "string") } +function isWorkspaceScope(value: unknown): value is WorkspaceScopePayload { + return isRecord(value) + && typeof value.project_path === "string" + && (value.access_mode === "restricted" || value.access_mode === "full") + && optional(value.project_name, "string") + && optional(value.restrict_to_workspace, "boolean") +} + function decodeInboundEvent(value: unknown): InboundEvent | null | undefined { if (!isRecord(value)) return null const record = value @@ -389,7 +416,11 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined { ) return null 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 === "session_updated" + && (!optional(record.scope, "string") + || (record.workspace_scope !== undefined && !isWorkspaceScope(record.workspace_scope))) + ) return null if ( name === "turn_model_updated" && (typeof record.model_name !== "string" @@ -538,6 +569,37 @@ export async function fetchSlashCommands( }) } +export async function fetchRuntimeControls( + apiUrl: string, + apiToken: string, +): Promise { + if (!apiUrl || !apiToken) return { modelPresets: [], canUseFullAccess: false } + const headers = { Authorization: `Bearer ${apiToken}` } + const [settingsResponse, workspacesResponse] = await Promise.all([ + fetch(`${apiUrl}/api/settings`, { headers }), + fetch(`${apiUrl}/api/workspaces`, { headers }).catch(() => null), + ]) + if (!settingsResponse.ok) { + throw new Error(`settings request failed: HTTP ${settingsResponse.status}`) + } + const settings = await settingsResponse.json() as { model_presets?: unknown[] } + const workspaces = workspacesResponse?.ok + ? await workspacesResponse.json() as { controls?: unknown } + : {} + const modelPresets = (settings.model_presets || []).flatMap((value) => { + if (!isRecord(value) || typeof value.name !== "string" || typeof value.model !== "string") { + return [] + } + const name = value.name.trim() + return name ? [{ name, model: value.model.trim() }] : [] + }) + const controls = isRecord(workspaces.controls) ? workspaces.controls : {} + return { + modelPresets, + canUseFullAccess: controls.can_use_full_access === true, + } +} + export async function fetchSessions( apiUrl: string, apiToken: string, @@ -581,6 +643,7 @@ export async function fetchSessions( modelPreset: typeof value.model_preset === "string" && value.model_preset.trim() ? value.model_preset.trim() : null, + ...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}), pinned: pinned.has(value.key), archived: archived.has(value.key), }] @@ -750,8 +813,8 @@ export class NanobotClient { this.write({ type: "attach", chat_id: chatId }) } - newChat(): void { - this.write({ type: "new_chat" }) + newChat(scope?: WorkspaceScopePayload): void { + this.write({ type: "new_chat", ...(scope ? { workspace_scope: scope } : {}) }) } forkChat(sourceChatId: string, beforeUserIndex: number, title?: string): void { @@ -763,6 +826,11 @@ export class NanobotClient { }) } + setWorkspaceScope(scope: WorkspaceScopePayload): void { + if (!this.chatId) throw new Error("chat is not ready") + this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope }) + } + private handleMessage(raw: string): void { let value: unknown try { diff --git a/tui/src/runtime-controls.ts b/tui/src/runtime-controls.ts new file mode 100644 index 000000000..ab3cea82c --- /dev/null +++ b/tui/src/runtime-controls.ts @@ -0,0 +1,307 @@ +import { + TextRenderable, + type BoxRenderable, + type CliRenderer, + type KeyEvent, +} from "@opentui/core" + +import { + fetchRuntimeControls, + type WorkspaceScopePayload, +} from "./protocol" +import { PickerMenu, type PickerMenuTheme } from "./picker-menu" + +interface RuntimeControlsTheme extends PickerMenuTheme { + accent: string + faint: string +} + +type Choice = + | { kind: "model"; name: string; label: string; detail: string } + | { kind: "access"; mode: "restricted" | "full"; label: string; detail: string } + +interface RuntimeControlsOptions { + apiUrl: string + apiToken: string + model: string + modelPreset: string + workspace: string + access: string + available: () => { ready: boolean; active: boolean } + beforeOpen: () => void + refreshScope: () => Promise + onModel: (name: string) => void + onAccess: (scope: WorkspaceScopePayload) => void + onStatus: (message: string) => void + onVisibilityChange: () => void +} + +/** Model and workspace policy controls expose one small, mouse-optional interface. */ +export class RuntimeControls { + readonly modelText: TextRenderable + readonly accessText: TextRenderable + readonly contextText: TextRenderable + readonly menuRoot: BoxRenderable + private readonly menu: PickerMenu + private kind: Choice["kind"] | null = null + private model: string + private modelPreset: string + private modelPresets: Array<{ name: string; model: string }> + private canUseFullAccess: boolean + private controlsLoaded = false + private controlsPromise: Promise | null = null + private scope: WorkspaceScopePayload + + constructor( + private readonly renderer: CliRenderer, + private theme: RuntimeControlsTheme, + private readonly options: RuntimeControlsOptions, + ) { + this.model = options.model + this.modelPreset = options.modelPreset + this.modelPresets = [{ name: options.modelPreset, model: options.model }] + this.scope = { + project_path: options.workspace, + access_mode: options.access.toLocaleLowerCase().includes("full") ? "full" : "restricted", + } + this.canUseFullAccess = this.scope.access_mode === "full" + this.menu = new PickerMenu(renderer, theme, { + id: "nanobot-tui-runtime-menu", + maxWidth: 64, + searchText: (choice) => `${choice.label} ${choice.detail}`, + render: (choice) => `${choice.label}${choice.detail ? ` ${choice.detail}` : ""}`, + onSelect: (choice) => this.apply(choice), + }) + this.menuRoot = this.menu.root + this.modelText = this.controlText(renderer, "model", () => void this.openModel()) + this.accessText = this.controlText(renderer, "access", () => void this.openAccess()) + this.contextText = new TextRenderable(renderer, { + id: "nanobot-tui-context-text", + content: "", + height: 1, + flexShrink: 0, + fg: theme.faint, + }) + this.render() + } + + get visible(): boolean { + return this.menu.visible + } + + get workspaceScope(): WorkspaceScopePayload { + return this.scope + } + + updateModel(model: string, preset: string): void { + this.model = model + this.modelPreset = preset + this.render() + } + + updateContext(text: string): void { + this.contextText.content = text + } + + updateWorkspaceScope(scope: WorkspaceScopePayload): void { + this.scope = scope + this.render() + } + + resize(width: number): void { + this.accessText.visible = width >= 58 + this.contextText.visible = width >= 96 + } + + choose(): boolean { + const choice = this.menu.current() + if (!choice) return false + this.apply(choice) + return true + } + + handleKey(key: KeyEvent): boolean { + if (!this.visible) return false + if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) { + this.menu.move(key.name === "up" ? -1 : 1) + } else if (key.name === "escape") { + this.hide() + } else { + return false + } + key.preventDefault() + return true + } + + hide(): void { + if (!this.visible) return + this.kind = null + this.menu.hide() + this.renderColors() + this.options.onVisibilityChange() + } + + setTheme(theme: RuntimeControlsTheme): void { + this.theme = theme + this.menu.setTheme(theme) + this.contextText.fg = theme.faint + this.renderColors() + } + + private controlText( + renderer: CliRenderer, + id: "model" | "access", + open: () => void, + ): TextRenderable { + const text = new TextRenderable(renderer, { + id: `nanobot-tui-${id}-text`, + content: "", + height: 1, + flexShrink: id === "model" ? 1 : 0, + fg: this.theme.muted, + onMouseOver: () => { text.fg = this.theme.accent }, + onMouseOut: () => this.renderColors(), + onMouseDown: (event) => { + if (event.button !== 0) return + event.preventDefault() + event.stopPropagation() + open() + }, + }) + return text + } + + private async load(force = false): Promise { + if (force) this.controlsLoaded = false + if (this.controlsLoaded) return + if (this.controlsPromise) return this.controlsPromise + if (!this.options.apiUrl || !this.options.apiToken) { + this.controlsLoaded = true + return + } + this.controlsPromise = (async () => { + const controls = await fetchRuntimeControls(this.options.apiUrl, this.options.apiToken) + const presets = new Map(controls.modelPresets.map((preset) => [ + preset.name.toLocaleLowerCase(), + preset, + ])) + if (!presets.has(this.modelPreset.toLocaleLowerCase())) { + presets.set(this.modelPreset.toLocaleLowerCase(), { + name: this.modelPreset, + model: this.model, + }) + } + this.modelPresets = [...presets.values()] + this.canUseFullAccess = controls.canUseFullAccess + this.controlsLoaded = true + })().finally(() => { this.controlsPromise = null }) + return this.controlsPromise + } + + private async openModel(): Promise { + if (!this.canOpen("Model")) return + try { + await this.load(true) + } catch (error) { + this.options.onStatus(error instanceof Error ? error.message : String(error)) + return + } + this.options.beforeOpen() + this.kind = "model" + const choices: Choice[] = this.modelPresets + .map((preset) => ({ + kind: "model" as const, + name: preset.name, + label: `${preset.name === this.modelPreset ? "●" : " "} ${preset.name}`, + detail: preset.model, + })) + .sort((left, right) => Number(right.name === this.modelPreset) + - Number(left.name === this.modelPreset)) + this.menu.show(choices, "", rendererLimit(this.renderer.height)) + this.opened() + } + + private async openAccess(): Promise { + if (!this.canOpen("Access")) return + try { + await Promise.all([this.load(true), this.options.refreshScope()]) + } catch (error) { + this.options.onStatus(error instanceof Error ? error.message : String(error)) + return + } + this.options.beforeOpen() + this.kind = "access" + const choices: Choice[] = [{ + kind: "access", + mode: "restricted", + label: `${this.scope.access_mode === "restricted" ? "●" : " "} Workspace access`, + detail: "project files only", + }] + if (this.canUseFullAccess || this.scope.access_mode === "full") choices.push({ + kind: "access", + mode: "full", + label: `${this.scope.access_mode === "full" ? "●" : " "} Full access`, + detail: "all local files and tools", + }) + choices.sort((left, right) => Number( + right.kind === "access" && right.mode === this.scope.access_mode, + ) - Number(left.kind === "access" && left.mode === this.scope.access_mode)) + this.menu.show(choices) + this.opened() + } + + private canOpen(label: string): boolean { + const state = this.options.available() + if (state.ready && !state.active) return true + this.options.onStatus(state.active + ? `${label} can be changed between turns` + : "Preparing chat…") + return false + } + + private opened(): void { + this.renderColors() + this.options.onVisibilityChange() + } + + private apply(choice: Choice): void { + this.hide() + if (choice.kind === "model") { + if (choice.name !== this.modelPreset) this.options.onModel(choice.name) + return + } + if (choice.mode === this.scope.access_mode) return + if (choice.mode === "full" && !this.canUseFullAccess) { + this.options.onStatus("Full access is only available from a trusted local gateway") + return + } + this.options.onAccess({ + ...this.scope, + access_mode: choice.mode, + restrict_to_workspace: choice.mode === "restricted", + }) + } + + private render(): void { + const runtime = this.modelPreset !== "default" + ? [this.modelPreset, this.model].filter(Boolean).join(" · ") + : this.model + this.modelText.content = ` · ${runtime} ▾` + const access = this.scope.access_mode === "full" ? "full access" : "workspace access" + this.accessText.content = ` · ${access} ▾` + this.renderColors() + } + + private renderColors(): void { + this.modelText.fg = this.kind === "model" && this.visible + ? this.theme.accent + : this.theme.muted + this.accessText.fg = ( + (this.kind === "access" && this.visible) || this.scope.access_mode === "full" + ) ? this.theme.accent : this.theme.muted + } +} + +function rendererLimit(height: number): number { + return height >= 20 ? 8 : 4 +}