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
+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
}