feat(tui): add clickable runtime controls

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent ed796332fe
commit 03d982023a
11 changed files with 689 additions and 44 deletions
+1 -1
View File
@@ -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. 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. `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.
+4
View File
@@ -1013,6 +1013,10 @@ class WebSocketChannel(BaseChannel):
if scope is None: if scope is None:
return return
self._workspaces.persist_scope(cid, scope) 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( await self._send_event(
connection, connection,
"session_updated", "session_updated",
@@ -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 @pytest.mark.asyncio
async def test_webui_scope_expands_home_project_path( async def test_webui_scope_expands_home_project_path(
bus: MagicMock, bus: MagicMock,
+4
View File
@@ -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 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 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 you return to the bottom. Large pastes are represented by a short editable placeholder in the
composer; nanobot sends the original text unchanged. composer; nanobot sends the original text unchanged.
+92 -18
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { CliRenderEvents, TextareaRenderable } from "@opentui/core" import { CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core"
import { import {
MockTreeSitterClient, MockTreeSitterClient,
createTestRenderer, createTestRenderer,
@@ -7,7 +7,7 @@ import {
} from "@opentui/core/testing" } from "@opentui/core/testing"
import { NanobotTui, type AppOptions } from "./app" import { NanobotTui, type AppOptions } from "./app"
import type { MessageOptions, SlashCommand } from "./protocol" import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
const options: AppOptions = { const options: AppOptions = {
wsUrl: "ws://localhost.invalid/ws", wsUrl: "ws://localhost.invalid/ws",
@@ -61,6 +61,7 @@ function client(
newChats: string[] = [], newChats: string[] = [],
sentOptions: MessageOptions[] = [], sentOptions: MessageOptions[] = [],
forks: Array<{ source: string; before: number; title?: string }> = [], forks: Array<{ source: string; before: number; title?: string }> = [],
scopes: WorkspaceScopePayload[] = [],
) { ) {
return { return {
activeChatId: "chat", activeChatId: "chat",
@@ -74,12 +75,15 @@ function client(
attach(chatId: string) { attach(chatId: string) {
attached.push(chatId) attached.push(chatId)
}, },
newChat() { newChat(_scope?: WorkspaceScopePayload) {
newChats.push("new") newChats.push("new")
}, },
forkChat(source: string, before: number, title?: string) { forkChat(source: string, before: number, title?: string) {
forks.push({ source, before, ...(title ? { title } : {}) }) forks.push({ source, before, ...(title ? { title } : {}) })
}, },
setWorkspaceScope(scope: WorkspaceScopePayload) {
scopes.push(scope)
},
} }
} }
@@ -485,7 +489,7 @@ describe("NanobotTui layout", () => {
composer: TextareaRenderable composer: TextareaRenderable
sessionMenu: { visible: boolean } sessionMenu: { visible: boolean }
titleText: { plainText: string } titleText: { plainText: string }
modelText: { plainText: string } runtimeControls: { modelText: { plainText: string } }
} }
try { try {
@@ -499,8 +503,8 @@ describe("NanobotTui layout", () => {
await waitUntil(() => attached.length === 1) await waitUntil(() => attached.length === 1)
expect(attached).toEqual(["other"]) expect(attached).toEqual(["other"])
expect(ui.titleText.plainText).toContain("Release checklist") expect(ui.titleText.plainText).toContain("Release checklist")
expect(ui.modelText.plainText).toContain("Deep Research") expect(ui.runtimeControls.modelText.plainText).toContain("Deep Research")
expect(ui.modelText.plainText).not.toContain("test/model") expect(ui.runtimeControls.modelText.plainText).not.toContain("test/model")
app.accept({ event: "attached", chat_id: "other" }) app.accept({ event: "attached", chat_id: "other" })
await Bun.sleep(1) await Bun.sleep(1)
@@ -509,7 +513,7 @@ describe("NanobotTui layout", () => {
await waitUntil(() => newChats.length === 1) await waitUntil(() => newChats.length === 1)
expect(newChats).toEqual(["new"]) expect(newChats).toEqual(["new"])
expect(ui.titleText.plainText).toContain("New chat") expect(ui.titleText.plainText).toContain("New chat")
expect(ui.modelText.plainText).toContain("test/model") expect(ui.runtimeControls.modelText.plainText).toContain("test/model")
} finally { } finally {
globalThis.fetch = original globalThis.fetch = original
} }
@@ -518,7 +522,7 @@ describe("NanobotTui layout", () => {
test("tracks canonical presets without overwriting a session override", async () => { test("tracks canonical presets without overwriting a session override", async () => {
setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" })
const app = mount(setup) 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({ event: "attached", chat_id: "chat", model_preset: "Codex" })
app.accept({ app.accept({
@@ -528,7 +532,7 @@ describe("NanobotTui layout", () => {
model_preset: "Codex", model_preset: "Codex",
}) })
await setup.flush() 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({ app.accept({
event: "runtime_model_updated", event: "runtime_model_updated",
@@ -536,8 +540,8 @@ describe("NanobotTui layout", () => {
model_preset: "DeepSeek", model_preset: "DeepSeek",
}) })
await setup.flush() await setup.flush()
expect(ui.modelText.plainText).toContain("Codex · openai/gpt-5.6") expect(ui.runtimeControls.modelText.plainText).toContain("Codex · openai/gpt-5.6")
expect(ui.modelText.plainText).not.toContain("DeepSeek") expect(ui.runtimeControls.modelText.plainText).not.toContain("DeepSeek")
}) })
test("returns a default-following chat to the canonical default preset", async () => { test("returns a default-following chat to the canonical default preset", async () => {
@@ -548,7 +552,7 @@ describe("NanobotTui layout", () => {
client(), client(),
new MockTreeSitterClient({ autoResolveTimeout: 0 }), 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({ event: "attached", chat_id: "chat", model_preset: null })
app.accept({ app.accept({
@@ -558,8 +562,8 @@ describe("NanobotTui layout", () => {
}) })
await setup.flush() await setup.flush()
expect(ui.modelText.plainText).toContain("deepseek/deepseek-chat") expect(ui.runtimeControls.modelText.plainText).toContain("deepseek/deepseek-chat")
expect(ui.modelText.plainText).not.toContain("Codex") expect(ui.runtimeControls.modelText.plainText).not.toContain("Codex")
}) })
test("refreshes the canonical preset after the model command completes", async () => { test("refreshes the canonical preset after the model command completes", async () => {
@@ -583,7 +587,7 @@ describe("NanobotTui layout", () => {
const ui = app as unknown as { const ui = app as unknown as {
composer: TextareaRenderable composer: TextareaRenderable
commandMenu: { setCommands(commands: SlashCommand[]): void } commandMenu: { setCommands(commands: SlashCommand[]): void }
modelText: { plainText: string } runtimeControls: { modelText: { plainText: string } }
} }
try { try {
@@ -605,7 +609,7 @@ describe("NanobotTui layout", () => {
text: "Switched model preset to Deep Research.", text: "Switched model preset to Deep Research.",
turn_id: "turn", 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"]) expect(sent).toEqual(["/model deep research"])
} finally { } 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 () => { test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const sent: string[] = [] const sent: string[] = []
@@ -784,7 +858,7 @@ describe("NanobotTui layout", () => {
const ui = app as unknown as { const ui = app as unknown as {
composer: TextareaRenderable composer: TextareaRenderable
contextPanel: { visible: boolean } contextPanel: { visible: boolean }
modelText: { plainText: string } runtimeControls: { contextText: { plainText: string } }
} }
try { try {
@@ -792,7 +866,7 @@ describe("NanobotTui layout", () => {
ui.composer.submit() ui.composer.submit()
await waitUntil(() => ui.contextPanel.visible) await waitUntil(() => ui.contextPanel.visible)
await setup.flush() await setup.flush()
expect(ui.modelText.plainText).toContain("~2.2k ctx") expect(ui.runtimeControls.contextText.plainText).toContain("~2.2k ctx")
const frame = setup.captureCharFrame() const frame = setup.captureCharFrame()
expect(frame).toContain("Agent context") expect(frame).toContain("Agent context")
+89 -20
View File
@@ -34,6 +34,7 @@ import {
type SlashCommand, type SlashCommand,
type SessionSummary, type SessionSummary,
type TokenUsage, type TokenUsage,
type WorkspaceScopePayload,
} from "./protocol" } from "./protocol"
import { import {
CommandMenu, CommandMenu,
@@ -67,6 +68,7 @@ import {
} from "./mention-menu" } from "./mention-menu"
import { PromptQueue, type QueuedPrompt } from "./prompt-queue" import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview" import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
import { RuntimeControls } from "./runtime-controls"
import { import {
contextualFooterHints, contextualFooterHints,
type FooterMode, type FooterMode,
@@ -93,8 +95,9 @@ interface ChatClient {
close(): void close(): void
send(content: string, options?: MessageOptions): string send(content: string, options?: MessageOptions): string
attach(chatId: string): void attach(chatId: string): void
newChat(): void newChat(scope?: WorkspaceScopePayload): void
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
setWorkspaceScope(scope: WorkspaceScopePayload): void
} }
interface Palette { interface Palette {
@@ -227,6 +230,15 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme {
text: palette.text, text: palette.text,
muted: palette.muted, muted: palette.muted,
border: palette.border, 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 sessionMenu: SessionMenu
private readonly mentionMenu: MentionMenu private readonly mentionMenu: MentionMenu
private readonly branchMenu: BranchMenu private readonly branchMenu: BranchMenu
private readonly runtimeControls: RuntimeControls
private readonly contextPanel: ContextPanel private readonly contextPanel: ContextPanel
private readonly diffViewer: DiffViewer private readonly diffViewer: DiffViewer
private readonly queuePreview: QueuePreview private readonly queuePreview: QueuePreview
@@ -357,7 +370,6 @@ export class NanobotTui {
private readonly shell: BoxRenderable private readonly shell: BoxRenderable
private readonly title: BoxRenderable private readonly title: BoxRenderable
private readonly titleText: TextRenderable private readonly titleText: TextRenderable
private readonly modelText: TextRenderable
private readonly composerFrame: BoxRenderable private readonly composerFrame: BoxRenderable
private readonly composer: TextareaRenderable private readonly composer: TextareaRenderable
private readonly status: TextRenderable private readonly status: TextRenderable
@@ -411,6 +423,7 @@ export class NanobotTui {
private sessionLoading = false private sessionLoading = false
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>() private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
private readonly modelCommandTurns = new Set<string>() private readonly modelCommandTurns = new Set<string>()
private readonly silentCommandTurns = new Set<string>()
private currentFileEdits: FileEditEvent[] = [] private currentFileEdits: FileEditEvent[] = []
private lastFileEdits: FileEditEvent[] = [] private lastFileEdits: FileEditEvent[] = []
@@ -484,15 +497,36 @@ export class NanobotTui {
truncate: true, truncate: true,
fg: this.palette.muted, fg: this.palette.muted,
}) })
this.modelText = new TextRenderable(renderer, { this.runtimeControls = new RuntimeControls(
id: "nanobot-tui-model-text", renderer,
content: ` · ${this.modelName}`, runtimeControlsTheme(this.palette),
height: 1, {
flexShrink: 1, apiUrl: options.apiUrl,
fg: this.palette.muted, 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.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() const composerSurface = this.composerSurface()
this.composerFrame = new BoxRenderable(renderer, { this.composerFrame = new BoxRenderable(renderer, {
id: "nanobot-tui-composer-frame", id: "nanobot-tui-composer-frame",
@@ -528,6 +562,7 @@ export class NanobotTui {
], ],
onContentChange: () => { onContentChange: () => {
this.draft.prune(this.composer.plainText) this.draft.prune(this.composer.plainText)
this.runtimeControls.hide()
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide() if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
if (this.sessionMenu.visible) this.syncSessionMenu() if (this.sessionMenu.visible) this.syncSessionMenu()
@@ -577,6 +612,7 @@ export class NanobotTui {
this.shell.add(this.mentionMenu.root) this.shell.add(this.mentionMenu.root)
this.shell.add(this.branchMenu.root) this.shell.add(this.branchMenu.root)
this.shell.add(this.contextPanel.root) this.shell.add(this.contextPanel.root)
this.shell.add(this.runtimeControls.menuRoot)
this.shell.add(this.title) this.shell.add(this.title)
this.shell.add(this.queuePreview.root) this.shell.add(this.queuePreview.root)
this.shell.add(this.composerFrame) this.shell.add(this.composerFrame)
@@ -656,6 +692,10 @@ export class NanobotTui {
this.status.content = "Loading sessions…" this.status.content = "Loading sessions…"
return return
} }
if (this.runtimeControls.visible) {
this.runtimeControls.choose()
return
}
if (this.sessionMenu.visible) { if (this.sessionMenu.visible) {
const session = this.sessionMenu.choose() const session = this.sessionMenu.choose()
if (session) this.switchSession(session) if (session) this.switchSession(session)
@@ -821,10 +861,11 @@ export class NanobotTui {
const lifecycle = this.commandTurns.get(event.turn_id) const lifecycle = this.commandTurns.get(event.turn_id)
if (lifecycle !== "agent_turn") { if (lifecycle !== "agent_turn") {
this.commandTurns.delete(event.turn_id) this.commandTurns.delete(event.turn_id)
const silent = this.silentCommandTurns.delete(event.turn_id)
if (this.modelCommandTurns.delete(event.turn_id)) { if (this.modelCommandTurns.delete(event.turn_id)) {
void this.refreshSessionMetadata(event.chat_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" if (!this.activeTurn) this.status.content = "Ready"
return return
} }
@@ -907,6 +948,7 @@ export class NanobotTui {
this.setDefaultModel(event.model_name, event.model_preset) this.setDefaultModel(event.model_name, event.model_preset)
return return
case "session_updated": case "session_updated":
if (event.workspace_scope) this.applyWorkspaceScope(event.workspace_scope)
if ( if (
!this.sessionTitle !this.sessionTitle
|| this.sessionTitle === "New chat" || this.sessionTitle === "New chat"
@@ -921,6 +963,7 @@ export class NanobotTui {
if (event.turn_id) { if (event.turn_id) {
this.commandTurns.delete(event.turn_id) this.commandTurns.delete(event.turn_id)
this.modelCommandTurns.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) { if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) {
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true) this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
@@ -1147,6 +1190,7 @@ export class NanobotTui {
key.preventDefault() key.preventDefault()
return return
} }
if (this.runtimeControls.handleKey(key)) return
if (this.sessionMenu.visible) { if (this.sessionMenu.visible) {
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) { if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
this.sessionMenu.move(key.name === "up" ? -1 : 1) this.sessionMenu.move(key.name === "up" ? -1 : 1)
@@ -1328,6 +1372,7 @@ export class NanobotTui {
this.sessionMenu.setTheme(commandMenuTheme(this.palette)) this.sessionMenu.setTheme(commandMenuTheme(this.palette))
this.mentionMenu.setTheme(commandMenuTheme(this.palette)) this.mentionMenu.setTheme(commandMenuTheme(this.palette))
this.branchMenu.setTheme(commandMenuTheme(this.palette)) this.branchMenu.setTheme(commandMenuTheme(this.palette))
this.runtimeControls.setTheme(runtimeControlsTheme(this.palette))
this.contextPanel.setTheme(contextPanelTheme(this.palette)) this.contextPanel.setTheme(contextPanelTheme(this.palette))
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown)) this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
this.queuePreview.setTheme(queuePreviewTheme(this.palette)) this.queuePreview.setTheme(queuePreviewTheme(this.palette))
@@ -1336,7 +1381,6 @@ export class NanobotTui {
this.composer.focusedTextColor = this.palette.text this.composer.focusedTextColor = this.palette.text
this.composer.cursorColor = this.palette.accent this.composer.cursorColor = this.palette.accent
this.titleText.fg = this.palette.muted this.titleText.fg = this.palette.muted
this.modelText.fg = this.palette.muted
this.status.fg = this.palette.muted this.status.fg = this.palette.muted
this.meta.fg = this.palette.faint this.meta.fg = this.palette.faint
this.updateMeta() this.updateMeta()
@@ -1347,12 +1391,14 @@ export class NanobotTui {
this.contextPanel.resize(this.renderer.height) this.contextPanel.resize(this.renderer.height)
this.diffViewer.resize(this.renderer.width) this.diffViewer.resize(this.renderer.width)
this.title.visible = this.renderer.height >= 14 this.title.visible = this.renderer.height >= 14
this.runtimeControls.resize(this.renderer.width)
this.updateTitle() this.updateTitle()
this.updateMeta() this.updateMeta()
} }
private updateMeta(): void { 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.activeTurn ? "active"
: this.branchMenu.visible ? "branch" : this.branchMenu.visible ? "branch"
: this.commandMenu.visible ? "command" : this.commandMenu.visible ? "command"
@@ -1389,6 +1435,10 @@ export class NanobotTui {
this.applyModelPreset(session.modelPreset) this.applyModelPreset(session.modelPreset)
} }
private applySessionScope(session: SessionSummary): void {
if (session.workspaceScope) this.applyWorkspaceScope(session.workspaceScope)
}
private applyModelPreset(preset: string | null): void { private applyModelPreset(preset: string | null): void {
const currentModel = this.modelName const currentModel = this.modelName
const currentPreset = this.modelPreset const currentPreset = this.modelPreset
@@ -1408,10 +1458,8 @@ export class NanobotTui {
: ` · ~${formatTokenCount(this.contextTokens)}${this.contextWindowTokens : ` · ~${formatTokenCount(this.contextTokens)}${this.contextWindowTokens
? `/${formatTokenCount(this.contextWindowTokens)}` ? `/${formatTokenCount(this.contextWindowTokens)}`
: ""} ctx` : ""} ctx`
const runtime = this.modelPreset !== "default" this.runtimeControls.updateModel(this.modelName, this.modelPreset)
? [this.modelPreset, this.modelName].filter(Boolean).join(" · ") this.runtimeControls.updateContext(context)
: this.modelName
this.modelText.content = ` · ${runtime}${context}`
} }
private resizeComposer(): void { private resizeComposer(): void {
@@ -1530,6 +1578,21 @@ export class NanobotTui {
this.syncCommandMenu() 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<void> { private async loadMentions(): Promise<void> {
try { try {
this.mentionCandidates = await fetchMentionCandidates( this.mentionCandidates = await fetchMentionCandidates(
@@ -1639,6 +1702,7 @@ export class NanobotTui {
if (current) { if (current) {
this.sessionTitle = sessionLabel(current) this.sessionTitle = sessionLabel(current)
this.applySessionModel(current) this.applySessionModel(current)
this.applySessionScope(current)
this.updateTitle() this.updateTitle()
} }
const limit = this.renderer.height >= 20 ? 8 : 4 const limit = this.renderer.height >= 20 ? 8 : 4
@@ -1662,6 +1726,7 @@ export class NanobotTui {
if (session.chatId === this.client.activeChatId) { if (session.chatId === this.client.activeChatId) {
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
this.applySessionModel(session) this.applySessionModel(session)
this.applySessionScope(session)
this.updateTitle() this.updateTitle()
this.closeSessions() this.closeSessions()
this.status.content = this.readyStatus() this.status.content = this.readyStatus()
@@ -1678,6 +1743,7 @@ export class NanobotTui {
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
this.applySessionModel(session) this.applySessionModel(session)
this.applySessionScope(session)
this.contextTokens = null this.contextTokens = null
this.lastUsage = null this.lastUsage = null
this.readyDetail = "" this.readyDetail = ""
@@ -1717,7 +1783,7 @@ export class NanobotTui {
this.readyDetail = "" this.readyDetail = ""
this.updateTitle() this.updateTitle()
this.status.content = "Starting a new chat…" this.status.content = "Starting a new chat…"
this.client.newChat() this.client.newChat(this.runtimeControls.workspaceScope)
} catch (error) { } catch (error) {
this.status.content = error instanceof Error ? error.message : String(error) this.status.content = error instanceof Error ? error.message : String(error)
} }
@@ -1726,6 +1792,7 @@ export class NanobotTui {
private sendGatewayCommand( private sendGatewayCommand(
content: string, content: string,
lifecycle: ResolvedSlashCommandLifecycle, lifecycle: ResolvedSlashCommandLifecycle,
silent = false,
): void { ): void {
if (!this.ready) { if (!this.ready) {
this.status.content = "Preparing chat…" this.status.content = "Preparing chat…"
@@ -1743,11 +1810,12 @@ export class NanobotTui {
return return
} }
this.commandTurns.set(turnId, lifecycle) this.commandTurns.set(turnId, lifecycle)
if (silent) this.silentCommandTurns.add(turnId)
if (/^\/model(?:\s|$)/iu.test(content)) this.modelCommandTurns.add(turnId) if (/^\/model(?:\s|$)/iu.test(content)) this.modelCommandTurns.add(turnId)
this.clearComposer() this.clearComposer()
this.commandMenu.hide() this.commandMenu.hide()
if (lifecycle !== "stop_active_turn") this.transcript.user(content) if (!silent && lifecycle !== "stop_active_turn") this.transcript.user(content)
this.recordPrompt(content) if (!silent) this.recordPrompt(content)
if (lifecycle === "agent_turn") { if (lifecycle === "agent_turn") {
this.activeTurnId = turnId this.activeTurnId = turnId
@@ -1844,6 +1912,7 @@ export class NanobotTui {
if (!session) return if (!session) return
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
this.applySessionModel(session) this.applySessionModel(session)
this.applySessionScope(session)
this.updateTitle() this.updateTitle()
} catch { } catch {
// Session metadata is decorative; conversation transport stays authoritative. // Session metadata is decorative; conversation transport stays authoritative.
+4
View File
@@ -17,6 +17,7 @@ export interface FooterHintTheme {
export type FooterMode = export type FooterMode =
| "mention" | "mention"
| "runtime"
| "active" | "active"
| "branch" | "branch"
| "command" | "command"
@@ -53,6 +54,9 @@ function hintsFor(
platform: string, platform: string,
shiftedEnter: boolean, shiftedEnter: boolean,
): FooterHint[] { ): 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 if (mode === "mention") return width >= 64
? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")] ? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
: [hint("enter", "insert"), hint("esc", "close")] : [hint("enter", "insert"), hint("esc", "close")]
+19
View File
@@ -10,6 +10,7 @@ export interface PickerMenuTheme {
text: string text: string
muted: string muted: string
border: string border: string
selectedBackground?: string
} }
interface PickerMenuOptions<T> { interface PickerMenuOptions<T> {
@@ -17,6 +18,8 @@ interface PickerMenuOptions<T> {
searchText: (item: T) => string searchText: (item: T) => string
render: (item: T) => string render: (item: T) => string
emptyText?: string emptyText?: string
maxWidth?: number
onSelect?: (item: T) => void
} }
/** Shared retained picker for command discovery and session navigation. */ /** Shared retained picker for command discovery and session navigation. */
@@ -36,6 +39,7 @@ export class PickerMenu<T> {
this.root = new BoxRenderable(renderer, { this.root = new BoxRenderable(renderer, {
id: options.id, id: options.id,
width: "100%", width: "100%",
...(options.maxWidth ? { maxWidth: options.maxWidth } : {}),
flexShrink: 0, flexShrink: 0,
flexDirection: "column", flexDirection: "column",
border: true, border: true,
@@ -121,7 +125,22 @@ export class PickerMenu<T> {
height: 1, height: 1,
wrapMode: "none", wrapMode: "none",
fg: selected ? this.theme.text : this.theme.muted, fg: selected ? this.theme.text : this.theme.muted,
...(selected && this.theme.selectedBackground
? { backgroundColor: RGBA.fromHex(this.theme.selectedBackground) }
: {}),
attributes: selected ? TextAttributes.BOLD : 0, 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)
},
})) }))
} }
} }
+55
View File
@@ -4,6 +4,7 @@ import {
NanobotClient, NanobotClient,
fetchHistory, fetchHistory,
fetchMentionCandidates, fetchMentionCandidates,
fetchRuntimeControls,
fetchSessionContext, fetchSessionContext,
fetchSessions, fetchSessions,
fetchSlashCommands, fetchSlashCommands,
@@ -99,6 +100,10 @@ describe("gateway protocol", () => {
client.attach("other-chat") client.attach("other-chat")
client.newChat() client.newChat()
client.forkChat("terminal", 3, "Alternative") 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<string, unknown>) const outbound = socket.sent.map((value) => JSON.parse(value) as Record<string, unknown>)
expect(outbound[0]).toEqual({ type: "attach", chat_id: "terminal" }) expect(outbound[0]).toEqual({ type: "attach", chat_id: "terminal" })
@@ -117,6 +122,14 @@ describe("gateway protocol", () => {
before_user_index: 3, before_user_index: 3,
title: "Alternative", 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.map((event) => event.event)).toEqual(["ready", "attached"])
expect(events[1]).toEqual({ expect(events[1]).toEqual({
event: "attached", 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", () => { test("rejects malformed gateway events", () => {
const original = globalThis.WebSocket const original = globalThis.WebSocket
let socket: FakeSocket | undefined let socket: FakeSocket | undefined
+73 -5
View File
@@ -42,6 +42,18 @@ export interface MediaAttachment {
name?: string 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 = export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string } | { event: "ready"; chat_id: string; client_id: string }
| { | {
@@ -105,7 +117,12 @@ export type InboundEvent =
turn_id?: string turn_id?: string
} }
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> } | { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
| { 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: "runtime_model_updated"; model_name: string; model_preset?: string | null }
| { | {
event: "turn_model_updated" event: "turn_model_updated"
@@ -117,9 +134,10 @@ export type InboundEvent =
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string } | { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
type OutboundEvent = 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: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string } | { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| { | {
type: "message" type: "message"
chat_id: string chat_id: string
@@ -222,6 +240,7 @@ export interface SessionSummary {
updatedAt: string | null updatedAt: string | null
runStartedAt: number | null runStartedAt: number | null
modelPreset: string | null modelPreset: string | null
workspaceScope?: WorkspaceScopePayload | null
pinned: boolean pinned: boolean
archived: boolean archived: boolean
} }
@@ -315,6 +334,14 @@ function isMediaAttachment(value: unknown): value is MediaAttachment {
&& optional(value.name, "string") && 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 { function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (!isRecord(value)) return null if (!isRecord(value)) return null
const record = value const record = value
@@ -389,7 +416,11 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
) return null ) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") 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 === "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 ( if (
name === "turn_model_updated" name === "turn_model_updated"
&& (typeof record.model_name !== "string" && (typeof record.model_name !== "string"
@@ -538,6 +569,37 @@ export async function fetchSlashCommands(
}) })
} }
export async function fetchRuntimeControls(
apiUrl: string,
apiToken: string,
): Promise<RuntimeControls> {
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( export async function fetchSessions(
apiUrl: string, apiUrl: string,
apiToken: string, apiToken: string,
@@ -581,6 +643,7 @@ export async function fetchSessions(
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim() modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
? value.model_preset.trim() ? value.model_preset.trim()
: null, : null,
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
pinned: pinned.has(value.key), pinned: pinned.has(value.key),
archived: archived.has(value.key), archived: archived.has(value.key),
}] }]
@@ -750,8 +813,8 @@ export class NanobotClient {
this.write({ type: "attach", chat_id: chatId }) this.write({ type: "attach", chat_id: chatId })
} }
newChat(): void { newChat(scope?: WorkspaceScopePayload): void {
this.write({ type: "new_chat" }) this.write({ type: "new_chat", ...(scope ? { workspace_scope: scope } : {}) })
} }
forkChat(sourceChatId: string, beforeUserIndex: number, title?: string): void { 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 { private handleMessage(raw: string): void {
let value: unknown let value: unknown
try { try {
+307
View File
@@ -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<void>
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<Choice>
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<void> | 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<Choice>(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<void> {
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<void> {
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<void> {
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
}