mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat(tui): add clickable runtime controls
This commit is contained in:
+92
-18
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user