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.
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.
+4
View File
@@ -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",
@@ -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,
+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 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.
+92 -18
View File
@@ -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")
+89 -20
View File
@@ -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<string, ResolvedSlashCommandLifecycle>()
private readonly modelCommandTurns = new Set<string>()
private readonly silentCommandTurns = new Set<string>()
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<void> {
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.
+4
View File
@@ -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")]
+19
View File
@@ -10,6 +10,7 @@ export interface PickerMenuTheme {
text: string
muted: string
border: string
selectedBackground?: string
}
interface PickerMenuOptions<T> {
@@ -17,6 +18,8 @@ interface PickerMenuOptions<T> {
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<T> {
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<T> {
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)
},
}))
}
}
+55
View File
@@ -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<string, unknown>)
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
+73 -5
View File
@@ -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<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: "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<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(
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 {
+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
}