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