mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(tui): preserve slash command semantics
This commit is contained in:
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
|||||||
nanobot agent
|
nanobot agent
|
||||||
```
|
```
|
||||||
|
|
||||||
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations or `/new` to start another one. It starts a local gateway only when needed and releases that gateway when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations or `/new-chat` to preserve this conversation and start another one. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts a local gateway only when needed and releases it when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
||||||
|
|
||||||
For one request and an immediate exit, use:
|
For one request and an immediate exit, use:
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the s
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall recent prompts. Type `/` to discover gateway-provided commands, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` and `/resume` open a searchable conversation picker; `/new` starts a separate conversation without deleting the current one. `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. 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. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall recent prompts. Type `/` to discover nanobot commands and terminal navigation in one palette, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` opens a searchable conversation picker, while `/new-chat` preserves the current conversation and starts another one. 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. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||||
|
|
||||||
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
|
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -16,5 +16,6 @@ The renderer uses OpenTUI's retained full-screen layout: the transcript reflows
|
|||||||
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys
|
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys
|
||||||
to move, `Tab` to complete, and `Esc` to close the menu.
|
to move, `Tab` to complete, and `Esc` to close the menu.
|
||||||
|
|
||||||
Use `/sessions` or `/resume` to search and switch persisted conversations without leaving the
|
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
|
||||||
terminal. `/new` starts a separate conversation while preserving the current one in history.
|
`/new-chat` preserves the current conversation and starts another one; nanobot's existing `/new`
|
||||||
|
command keeps its cross-channel behavior and resets the current chat.
|
||||||
|
|||||||
+98
-9
@@ -7,6 +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 { SlashCommand } from "./protocol"
|
||||||
|
|
||||||
const options: AppOptions = {
|
const options: AppOptions = {
|
||||||
wsUrl: "ws://localhost.invalid/ws",
|
wsUrl: "ws://localhost.invalid/ws",
|
||||||
@@ -211,13 +212,7 @@ describe("NanobotTui layout", () => {
|
|||||||
composer: TextareaRenderable
|
composer: TextareaRenderable
|
||||||
commandMenu: {
|
commandMenu: {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
setCommands(commands: Array<{
|
setCommands(commands: SlashCommand[]): void
|
||||||
command: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
argHint: string
|
|
||||||
acceptsArgs: boolean
|
|
||||||
}>): void
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ui.commandMenu.setCommands([{
|
ui.commandMenu.setCommands([{
|
||||||
@@ -225,6 +220,7 @@ describe("NanobotTui layout", () => {
|
|||||||
title: "History",
|
title: "History",
|
||||||
description: "Show recent messages",
|
description: "Show recent messages",
|
||||||
argHint: "[n]",
|
argHint: "[n]",
|
||||||
|
lifecycle: "side_channel",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
}])
|
}])
|
||||||
|
|
||||||
@@ -237,7 +233,7 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(sent).toEqual([])
|
expect(sent).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switches and creates gateway chats without sending agent commands", async () => {
|
test("switches and creates gateway chats without replacing core slash commands", async () => {
|
||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
||||||
@@ -285,7 +281,7 @@ describe("NanobotTui layout", () => {
|
|||||||
|
|
||||||
app.accept({ event: "attached", chat_id: "other" })
|
app.accept({ event: "attached", chat_id: "other" })
|
||||||
await Bun.sleep(1)
|
await Bun.sleep(1)
|
||||||
ui.composer.setText("/new")
|
ui.composer.setText("/new-chat")
|
||||||
ui.composer.submit()
|
ui.composer.submit()
|
||||||
await waitUntil(() => newChats.length === 1)
|
await waitUntil(() => newChats.length === 1)
|
||||||
expect(newChats).toEqual(["new"])
|
expect(newChats).toEqual(["new"])
|
||||||
@@ -294,6 +290,58 @@ describe("NanobotTui layout", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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[] = []
|
||||||
|
const app = mount(setup, sent)
|
||||||
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
|
await Bun.sleep(1)
|
||||||
|
const ui = app as unknown as {
|
||||||
|
composer: TextareaRenderable
|
||||||
|
commandMenu: {
|
||||||
|
setCommands(commands: SlashCommand[]): void
|
||||||
|
}
|
||||||
|
activeTurn: boolean
|
||||||
|
}
|
||||||
|
ui.commandMenu.setCommands([{
|
||||||
|
command: "/new",
|
||||||
|
title: "New chat",
|
||||||
|
description: "Reset this chat",
|
||||||
|
argHint: "",
|
||||||
|
lifecycle: "finalize_active_turn",
|
||||||
|
acceptsArgs: false,
|
||||||
|
}, {
|
||||||
|
command: "/status",
|
||||||
|
title: "Status",
|
||||||
|
description: "Show status",
|
||||||
|
argHint: "",
|
||||||
|
lifecycle: "side_channel",
|
||||||
|
acceptsArgs: false,
|
||||||
|
}])
|
||||||
|
|
||||||
|
app.accept({ event: "goal_status", chat_id: "chat", status: "running" })
|
||||||
|
ui.composer.setText("/status")
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => sent.includes("/status"))
|
||||||
|
expect(ui.activeTurn).toBe(true)
|
||||||
|
app.accept({
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat",
|
||||||
|
text: "Runtime healthy",
|
||||||
|
turn_id: "turn",
|
||||||
|
})
|
||||||
|
await setup.flush()
|
||||||
|
expect(setup.captureCharFrame()).toContain("Runtime healthy")
|
||||||
|
|
||||||
|
ui.composer.setText("/new")
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => sent.includes("/new"))
|
||||||
|
expect(ui.activeTurn).toBe(false)
|
||||||
|
expect(sent).toEqual(["/status", "/new"])
|
||||||
|
await setup.flush()
|
||||||
|
expect(setup.captureCharFrame()).toContain("/new")
|
||||||
|
})
|
||||||
|
|
||||||
test("blocks sends and ignores late session results after closing the picker", async () => {
|
test("blocks sends and ignores late session results after closing the picker", async () => {
|
||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
@@ -336,6 +384,47 @@ describe("NanobotTui layout", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("applies a query typed while sessions are still loading", async () => {
|
||||||
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
|
const original = globalThis.fetch
|
||||||
|
let resolveFetch: ((response: Response) => void) | undefined
|
||||||
|
globalThis.fetch = (() => new Promise<Response>((resolve) => {
|
||||||
|
resolveFetch = resolve
|
||||||
|
})) as unknown as typeof fetch
|
||||||
|
const app = NanobotTui.mount(
|
||||||
|
setup.renderer,
|
||||||
|
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
|
||||||
|
client(),
|
||||||
|
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||||
|
)
|
||||||
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
|
await Bun.sleep(1)
|
||||||
|
const ui = app as unknown as {
|
||||||
|
composer: TextareaRenderable
|
||||||
|
sessionMenu: { visible: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ui.composer.setText("/sessions")
|
||||||
|
ui.composer.submit()
|
||||||
|
await Bun.sleep(10)
|
||||||
|
ui.composer.setText("release")
|
||||||
|
resolveFetch?.(new Response(JSON.stringify({
|
||||||
|
sessions: [
|
||||||
|
{ key: "websocket:chat", title: "Current chat", preview: "Current work" },
|
||||||
|
{ key: "websocket:other", title: "Release checklist", preview: "Ship it" },
|
||||||
|
],
|
||||||
|
})))
|
||||||
|
await waitUntil(() => ui.sessionMenu.visible)
|
||||||
|
await setup.flush()
|
||||||
|
const frame = setup.captureCharFrame()
|
||||||
|
expect(frame).toContain("Release checklist")
|
||||||
|
expect(frame).not.toContain("Current chat")
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("survives rapid narrow resizes with long CJK and code", async () => {
|
test("survives rapid narrow resizes with long CJK and code", async () => {
|
||||||
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
|
|||||||
+100
-29
@@ -22,7 +22,13 @@ import {
|
|||||||
type InboundEvent,
|
type InboundEvent,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
} from "./protocol"
|
} from "./protocol"
|
||||||
import { CommandMenu, type CommandMenuTheme } from "./command-menu"
|
import {
|
||||||
|
CommandMenu,
|
||||||
|
resolveSlashCommandLifecycle,
|
||||||
|
type CommandMenuTheme,
|
||||||
|
type ResolvedSlashCommandLifecycle,
|
||||||
|
type TuiCommand,
|
||||||
|
} from "./command-menu"
|
||||||
import { SessionMenu } from "./session-menu"
|
import { SessionMenu } from "./session-menu"
|
||||||
import { Transcript, type TranscriptTheme } from "./transcript"
|
import { Transcript, type TranscriptTheme } from "./transcript"
|
||||||
|
|
||||||
@@ -90,27 +96,18 @@ const LIGHT: Palette = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
|
const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
|
||||||
const LOCAL_COMMANDS: SlashCommand[] = [
|
const LOCAL_COMMANDS: TuiCommand[] = [
|
||||||
{
|
{
|
||||||
command: "/sessions",
|
command: "/sessions",
|
||||||
title: "Sessions",
|
title: "Sessions",
|
||||||
description: "Find and switch conversations",
|
description: "Find and switch conversations",
|
||||||
argHint: "",
|
action: "sessions",
|
||||||
acceptsArgs: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "/resume",
|
command: "/new-chat",
|
||||||
title: "Resume",
|
title: "New saved chat",
|
||||||
description: "Find and switch conversations",
|
description: "Keep this conversation and start another",
|
||||||
argHint: "",
|
action: "new-chat",
|
||||||
acceptsArgs: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: "/new",
|
|
||||||
title: "New chat",
|
|
||||||
description: "Start a separate conversation",
|
|
||||||
argHint: "",
|
|
||||||
acceptsArgs: false,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -221,6 +218,7 @@ export class NanobotTui {
|
|||||||
private quitting = false
|
private quitting = false
|
||||||
private sessionLoadId = 0
|
private sessionLoadId = 0
|
||||||
private sessionLoading = false
|
private sessionLoading = false
|
||||||
|
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
renderer: CliRenderer,
|
renderer: CliRenderer,
|
||||||
@@ -234,6 +232,7 @@ export class NanobotTui {
|
|||||||
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
|
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
|
||||||
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
|
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
|
||||||
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
|
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
|
||||||
|
this.commandMenu.setCommands([], LOCAL_COMMANDS)
|
||||||
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
|
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
|
||||||
this.client = client || new NanobotClient({
|
this.client = client || new NanobotClient({
|
||||||
url: options.wsUrl,
|
url: options.wsUrl,
|
||||||
@@ -423,12 +422,15 @@ export class NanobotTui {
|
|||||||
this.updateMeta()
|
this.updateMeta()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (["/sessions", "/resume", "/continue"].includes(content.toLocaleLowerCase())) {
|
const command = this.commandMenu.resolve(content)
|
||||||
void this.openSessions()
|
if (command?.source === "tui") {
|
||||||
|
if (command.command.action === "sessions") void this.openSessions()
|
||||||
|
else this.startNewChat()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (content.toLocaleLowerCase() === "/new") {
|
if (command?.source === "gateway") {
|
||||||
this.startNewChat()
|
const lifecycle = resolveSlashCommandLifecycle(content, command.command)
|
||||||
|
if (lifecycle) this.sendGatewayCommand(content, lifecycle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.ready) {
|
if (!this.ready) {
|
||||||
@@ -451,10 +453,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
this.composer.setText("")
|
this.composer.setText("")
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content)
|
this.recordPrompt(content)
|
||||||
if (this.promptHistory.length > 50) this.promptHistory.shift()
|
|
||||||
this.historyCursor = this.promptHistory.length
|
|
||||||
this.historyDraft = ""
|
|
||||||
this.transcript.user(content)
|
this.transcript.user(content)
|
||||||
this.finalMessage = ""
|
this.finalMessage = ""
|
||||||
this.turnHadAnswer = false
|
this.turnHadAnswer = false
|
||||||
@@ -465,6 +464,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
accept(event: InboundEvent): void {
|
accept(event: InboundEvent): void {
|
||||||
if (event.event === "attached") {
|
if (event.event === "attached") {
|
||||||
|
this.commandTurns.clear()
|
||||||
const restoring = this.attachedOnce
|
const restoring = this.attachedOnce
|
||||||
this.attachedOnce = true
|
this.attachedOnce = true
|
||||||
if (restoring) this.setActive(false)
|
if (restoring) this.setActive(false)
|
||||||
@@ -500,6 +500,15 @@ export class NanobotTui {
|
|||||||
this.transcript.stream(event.text)
|
this.transcript.stream(event.text)
|
||||||
return
|
return
|
||||||
case "message":
|
case "message":
|
||||||
|
if (event.turn_id && this.commandTurns.has(event.turn_id) && !event.kind) {
|
||||||
|
const lifecycle = this.commandTurns.get(event.turn_id)
|
||||||
|
if (lifecycle !== "agent_turn") {
|
||||||
|
this.commandTurns.delete(event.turn_id)
|
||||||
|
this.transcript.assistant(event.text)
|
||||||
|
if (!this.activeTurn) this.status.content = "Ready"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.kind) {
|
if (event.kind) {
|
||||||
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
|
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
|
||||||
this.lastProgress = this.transcript.progress(event.text, event.tool_events)
|
this.lastProgress = this.transcript.progress(event.text, event.tool_events)
|
||||||
@@ -528,6 +537,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
case "turn_end":
|
case "turn_end":
|
||||||
|
if (event.turn_id) this.commandTurns.delete(event.turn_id)
|
||||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||||
this.transcript.finishActivity()
|
this.transcript.finishActivity()
|
||||||
this.finalMessage = ""
|
this.finalMessage = ""
|
||||||
@@ -554,6 +564,7 @@ export class NanobotTui {
|
|||||||
this.setModel(event.model_name)
|
this.setModel(event.model_name)
|
||||||
return
|
return
|
||||||
case "error":
|
case "error":
|
||||||
|
if (event.turn_id) this.commandTurns.delete(event.turn_id)
|
||||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||||
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
||||||
this.finalMessage = ""
|
this.finalMessage = ""
|
||||||
@@ -870,8 +881,7 @@ export class NanobotTui {
|
|||||||
// Local navigation remains available against older gateways.
|
// Local navigation remains available against older gateways.
|
||||||
}
|
}
|
||||||
const commands = new Map(discovered.map((command) => [command.command, command]))
|
const commands = new Map(discovered.map((command) => [command.command, command]))
|
||||||
for (const command of LOCAL_COMMANDS) commands.set(command.command, command)
|
this.commandMenu.setCommands([...commands.values()], LOCAL_COMMANDS)
|
||||||
this.commandMenu.setCommands([...commands.values()])
|
|
||||||
this.syncCommandMenu()
|
this.syncCommandMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,6 +901,7 @@ export class NanobotTui {
|
|||||||
this.sessionLoading = false
|
this.sessionLoading = false
|
||||||
const limit = this.renderer.height >= 20 ? 8 : 4
|
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||||
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
|
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
|
||||||
|
this.sessionMenu.update(this.composer.plainText, limit)
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
this.updateMeta()
|
this.updateMeta()
|
||||||
this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions"
|
this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions"
|
||||||
@@ -906,17 +917,21 @@ export class NanobotTui {
|
|||||||
this.status.content = "Wait for the current turn or press Ctrl+C"
|
this.status.content = "Wait for the current turn or press Ctrl+C"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.closeSessions()
|
|
||||||
if (chatId === this.client.activeChatId) {
|
if (chatId === this.client.activeChatId) {
|
||||||
|
this.closeSessions()
|
||||||
this.status.content = "Ready"
|
this.status.content = "Ready"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.ready) {
|
||||||
|
this.status.content = "Preparing chat…"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.closeSessions()
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
this.status.content = "Opening session…"
|
this.status.content = "Opening session…"
|
||||||
this.client.attach(chatId)
|
this.client.attach(chatId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.ready = true
|
|
||||||
this.status.content = error instanceof Error ? error.message : String(error)
|
this.status.content = error instanceof Error ? error.message : String(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -926,6 +941,10 @@ export class NanobotTui {
|
|||||||
this.status.content = "Wait for the current turn or press Ctrl+C"
|
this.status.content = "Wait for the current turn or press Ctrl+C"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.ready) {
|
||||||
|
this.status.content = "Preparing chat…"
|
||||||
|
return
|
||||||
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.sessionMenu.hide()
|
||||||
this.composer.setText("")
|
this.composer.setText("")
|
||||||
@@ -934,11 +953,63 @@ export class NanobotTui {
|
|||||||
this.status.content = "Starting a new chat…"
|
this.status.content = "Starting a new chat…"
|
||||||
this.client.newChat()
|
this.client.newChat()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.ready = true
|
|
||||||
this.status.content = error instanceof Error ? error.message : String(error)
|
this.status.content = error instanceof Error ? error.message : String(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendGatewayCommand(
|
||||||
|
content: string,
|
||||||
|
lifecycle: ResolvedSlashCommandLifecycle,
|
||||||
|
): void {
|
||||||
|
if (!this.ready) {
|
||||||
|
this.status.content = "Preparing chat…"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.activeTurn && lifecycle === "agent_turn") {
|
||||||
|
this.status.content = "A turn is already running · Ctrl+C to stop"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let turnId: string
|
||||||
|
try {
|
||||||
|
turnId = this.client.send(content)
|
||||||
|
} catch (error) {
|
||||||
|
this.status.content = error instanceof Error ? error.message : String(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.commandTurns.set(turnId, lifecycle)
|
||||||
|
this.composer.setText("")
|
||||||
|
this.commandMenu.hide()
|
||||||
|
if (lifecycle !== "stop_active_turn") this.transcript.user(content)
|
||||||
|
this.recordPrompt(content)
|
||||||
|
|
||||||
|
if (lifecycle === "agent_turn") {
|
||||||
|
this.finalMessage = ""
|
||||||
|
this.turnHadAnswer = false
|
||||||
|
this.lastProgress = ""
|
||||||
|
this.activeLabel = "Thinking"
|
||||||
|
this.setActive(true)
|
||||||
|
} else if (lifecycle === "finalize_active_turn") {
|
||||||
|
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||||
|
this.transcript.finishActivity()
|
||||||
|
this.finalMessage = ""
|
||||||
|
this.turnHadAnswer = false
|
||||||
|
this.setActive(false)
|
||||||
|
this.status.content = "Resetting chat…"
|
||||||
|
} else if (lifecycle === "stop_active_turn") {
|
||||||
|
this.setActive(false)
|
||||||
|
this.status.content = "Stopping…"
|
||||||
|
} else if (!this.activeTurn) {
|
||||||
|
this.status.content = `Running ${content.split(/\s+/u, 1)[0]}…`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordPrompt(content: string): void {
|
||||||
|
if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content)
|
||||||
|
if (this.promptHistory.length > 50) this.promptHistory.shift()
|
||||||
|
this.historyCursor = this.promptHistory.length
|
||||||
|
this.historyDraft = ""
|
||||||
|
}
|
||||||
|
|
||||||
private closeSessions(): void {
|
private closeSessions(): void {
|
||||||
this.sessionLoadId += 1
|
this.sessionLoadId += 1
|
||||||
this.sessionLoading = false
|
this.sessionLoading = false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
|
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
|
||||||
|
|
||||||
import { CommandMenu } from "./command-menu"
|
import { CommandMenu, resolveSlashCommandLifecycle } from "./command-menu"
|
||||||
import type { SlashCommand } from "./protocol"
|
import type { SlashCommand } from "./protocol"
|
||||||
|
|
||||||
const commands: SlashCommand[] = [
|
const commands: SlashCommand[] = [
|
||||||
@@ -10,6 +10,7 @@ const commands: SlashCommand[] = [
|
|||||||
title: "Help",
|
title: "Help",
|
||||||
description: "Show available commands",
|
description: "Show available commands",
|
||||||
argHint: "",
|
argHint: "",
|
||||||
|
lifecycle: "side_channel",
|
||||||
acceptsArgs: false,
|
acceptsArgs: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -17,6 +18,7 @@ const commands: SlashCommand[] = [
|
|||||||
title: "History",
|
title: "History",
|
||||||
description: "Show recent messages",
|
description: "Show recent messages",
|
||||||
argHint: "[n]",
|
argHint: "[n]",
|
||||||
|
lifecycle: "side_channel",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -64,4 +66,45 @@ describe("CommandMenu", () => {
|
|||||||
menu.update("/history 5")
|
menu.update("/history 5")
|
||||||
expect(menu.visible).toBe(false)
|
expect(menu.visible).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps gateway commands authoritative over local actions", async () => {
|
||||||
|
setup = await createTestRenderer({ width: 60, height: 12, screenMode: "alternate-screen" })
|
||||||
|
const menu = new CommandMenu(setup.renderer, {
|
||||||
|
text: "#FFFFFF",
|
||||||
|
muted: "#999999",
|
||||||
|
border: "#555555",
|
||||||
|
})
|
||||||
|
menu.setCommands(commands, [{
|
||||||
|
command: "/help",
|
||||||
|
title: "Local help",
|
||||||
|
description: "Must not replace nanobot help",
|
||||||
|
action: "sessions",
|
||||||
|
}, {
|
||||||
|
command: "/sessions",
|
||||||
|
title: "Sessions",
|
||||||
|
description: "Switch conversations",
|
||||||
|
action: "sessions",
|
||||||
|
}])
|
||||||
|
|
||||||
|
expect(menu.resolve("/help")?.source).toBe("gateway")
|
||||||
|
expect(menu.resolve("/sessions")).toMatchObject({
|
||||||
|
source: "tui",
|
||||||
|
command: { action: "sessions" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves argument-sensitive command lifecycles", () => {
|
||||||
|
const goal: SlashCommand = {
|
||||||
|
command: "/goal",
|
||||||
|
title: "Goal",
|
||||||
|
description: "Start a long-running goal",
|
||||||
|
argHint: "<goal>",
|
||||||
|
lifecycle: "agent_turn_with_args",
|
||||||
|
acceptsArgs: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(resolveSlashCommandLifecycle("/goal", goal)).toBe("side_channel")
|
||||||
|
expect(resolveSlashCommandLifecycle("/goal ship it", goal)).toBe("agent_turn")
|
||||||
|
expect(resolveSlashCommandLifecycle("/help extra", commands[0]!)).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+74
-14
@@ -1,15 +1,48 @@
|
|||||||
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
|
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
|
||||||
|
|
||||||
import type { SlashCommand } from "./protocol"
|
import type { SlashCommand, SlashCommandLifecycle } from "./protocol"
|
||||||
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||||
|
|
||||||
export type CommandMenuTheme = PickerMenuTheme
|
export type CommandMenuTheme = PickerMenuTheme
|
||||||
|
|
||||||
|
export type TuiCommandAction = "sessions" | "new-chat"
|
||||||
|
|
||||||
|
export interface TuiCommand {
|
||||||
|
command: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
action: TuiCommandAction
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandChoice =
|
||||||
|
| { source: "gateway"; command: SlashCommand }
|
||||||
|
| { source: "tui"; command: TuiCommand }
|
||||||
|
|
||||||
|
export type ResolvedSlashCommandLifecycle = Exclude<SlashCommandLifecycle, "agent_turn_with_args">
|
||||||
|
|
||||||
|
function descriptor(choice: CommandChoice): SlashCommand | TuiCommand {
|
||||||
|
return choice.command
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSlashCommandLifecycle(
|
||||||
|
input: string,
|
||||||
|
command: SlashCommand,
|
||||||
|
): ResolvedSlashCommandLifecycle | null {
|
||||||
|
const name = input.split(/\s+/u, 1)[0] || ""
|
||||||
|
if (name.toLocaleLowerCase() !== command.command.toLocaleLowerCase()) return null
|
||||||
|
const args = input.slice(name.length).trim()
|
||||||
|
if (args && !command.acceptsArgs) return null
|
||||||
|
if (command.lifecycle === "agent_turn_with_args") {
|
||||||
|
return args ? "agent_turn" : "side_channel"
|
||||||
|
}
|
||||||
|
return command.lifecycle
|
||||||
|
}
|
||||||
|
|
||||||
/** Retained slash-command discovery with one small completion interface. */
|
/** Retained slash-command discovery with one small completion interface. */
|
||||||
export class CommandMenu {
|
export class CommandMenu {
|
||||||
readonly root: BoxRenderable
|
readonly root: BoxRenderable
|
||||||
private readonly picker: PickerMenu<SlashCommand>
|
private readonly picker: PickerMenu<CommandChoice>
|
||||||
private commands: SlashCommand[] = []
|
private commands: CommandChoice[] = []
|
||||||
private query = ""
|
private query = ""
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -18,9 +51,14 @@ export class CommandMenu {
|
|||||||
) {
|
) {
|
||||||
this.picker = new PickerMenu(renderer, theme, {
|
this.picker = new PickerMenu(renderer, theme, {
|
||||||
id: "nanobot-tui-command-menu",
|
id: "nanobot-tui-command-menu",
|
||||||
searchText: (command) => `${command.command} ${command.title}`,
|
searchText: (choice) => {
|
||||||
render: (command) => {
|
const command = descriptor(choice)
|
||||||
const hint = command.argHint ? ` ${command.argHint}` : ""
|
return `${command.command} ${command.title}`
|
||||||
|
},
|
||||||
|
render: (choice) => {
|
||||||
|
const command = descriptor(choice)
|
||||||
|
const argHint = "argHint" in command ? command.argHint : ""
|
||||||
|
const hint = argHint ? ` ${argHint}` : ""
|
||||||
const detail = (command.description || command.title).replace(/\s+/gu, " ")
|
const detail = (command.description || command.title).replace(/\s+/gu, " ")
|
||||||
return `${command.command}${hint} ${detail}`
|
return `${command.command}${hint} ${detail}`
|
||||||
},
|
},
|
||||||
@@ -32,11 +70,28 @@ export class CommandMenu {
|
|||||||
return this.picker.visible
|
return this.picker.visible
|
||||||
}
|
}
|
||||||
|
|
||||||
setCommands(commands: SlashCommand[]): void {
|
setCommands(commands: SlashCommand[], local: TuiCommand[] = []): void {
|
||||||
this.commands = [...commands].sort((left, right) => left.command.localeCompare(right.command))
|
const gatewayNames = new Set(commands.map((command) => command.command.toLocaleLowerCase()))
|
||||||
|
this.commands = [
|
||||||
|
...commands.map((command): CommandChoice => ({ source: "gateway", command })),
|
||||||
|
...local
|
||||||
|
.filter((command) => !gatewayNames.has(command.command.toLocaleLowerCase()))
|
||||||
|
.map((command): CommandChoice => ({ source: "tui", command })),
|
||||||
|
].sort((left, right) => descriptor(left).command.localeCompare(descriptor(right).command))
|
||||||
this.update(this.query)
|
this.update(this.query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolve(input: string): CommandChoice | null {
|
||||||
|
const name = input.trim().split(/\s+/u, 1)[0]?.toLocaleLowerCase()
|
||||||
|
if (!name) return null
|
||||||
|
return this.commands.find((choice) => {
|
||||||
|
const command = descriptor(choice)
|
||||||
|
if (command.command.toLocaleLowerCase() !== name) return false
|
||||||
|
if (choice.source === "tui") return input.trim().length === command.command.length
|
||||||
|
return resolveSlashCommandLifecycle(input.trim(), choice.command) !== null
|
||||||
|
}) || null
|
||||||
|
}
|
||||||
|
|
||||||
update(input: string, limit = 6): void {
|
update(input: string, limit = 6): void {
|
||||||
const changed = input !== this.query
|
const changed = input !== this.query
|
||||||
this.query = input
|
this.query = input
|
||||||
@@ -55,17 +110,22 @@ export class CommandMenu {
|
|||||||
|
|
||||||
completion(input: string): string | null {
|
completion(input: string): string | null {
|
||||||
if (!this.visible) return null
|
if (!this.visible) return null
|
||||||
const command = this.picker.current()
|
const choice = this.picker.current()
|
||||||
if (!command || input.trim() === command.command) return null
|
if (!choice) return null
|
||||||
return `${command.command}${command.acceptsArgs ? " " : ""}`
|
const command = descriptor(choice)
|
||||||
|
if (input.trim() === command.command) return null
|
||||||
|
const acceptsArgs = choice.source === "gateway" && choice.command.acceptsArgs
|
||||||
|
return `${command.command}${acceptsArgs ? " " : ""}`
|
||||||
}
|
}
|
||||||
|
|
||||||
complete(): string | null {
|
complete(): string | null {
|
||||||
if (!this.visible) return null
|
if (!this.visible) return null
|
||||||
const command = this.picker.current()
|
const choice = this.picker.current()
|
||||||
if (!command) return null
|
if (!choice) return null
|
||||||
|
const command = descriptor(choice)
|
||||||
this.hide()
|
this.hide()
|
||||||
return `${command.command}${command.acceptsArgs ? " " : ""}`
|
const acceptsArgs = choice.source === "gateway" && choice.command.acceptsArgs
|
||||||
|
return `${command.command}${acceptsArgs ? " " : ""}`
|
||||||
}
|
}
|
||||||
|
|
||||||
hide(): void {
|
hide(): void {
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ describe("gateway protocol", () => {
|
|||||||
title: "History",
|
title: "History",
|
||||||
description: "Show recent messages",
|
description: "Show recent messages",
|
||||||
argHint: "[n]",
|
argHint: "[n]",
|
||||||
|
lifecycle: "side_channel",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
}])
|
}])
|
||||||
expect(authorization).toBe("Bearer secret")
|
expect(authorization).toBe("Bearer secret")
|
||||||
@@ -246,6 +247,24 @@ describe("gateway protocol", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("drops slash commands with unknown lifecycle metadata", async () => {
|
||||||
|
const original = globalThis.fetch
|
||||||
|
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
||||||
|
commands: [{
|
||||||
|
command: "/future",
|
||||||
|
title: "Future",
|
||||||
|
description: "Unknown lifecycle",
|
||||||
|
lifecycle: "future_mode",
|
||||||
|
}],
|
||||||
|
})))) as unknown as typeof fetch
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(await fetchSlashCommands("http://nanobot.test", "secret")).toEqual([])
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("loads and normalizes WebUI sessions", async () => {
|
test("loads and normalizes WebUI sessions", async () => {
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
||||||
|
|||||||
@@ -92,9 +92,17 @@ export interface SlashCommand {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
argHint: string
|
argHint: string
|
||||||
|
lifecycle: SlashCommandLifecycle
|
||||||
acceptsArgs: boolean
|
acceptsArgs: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SlashCommandLifecycle =
|
||||||
|
| "side_channel"
|
||||||
|
| "finalize_active_turn"
|
||||||
|
| "stop_active_turn"
|
||||||
|
| "agent_turn"
|
||||||
|
| "agent_turn_with_args"
|
||||||
|
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
chatId: string
|
chatId: string
|
||||||
title: string
|
title: string
|
||||||
@@ -275,6 +283,7 @@ export async function fetchSlashCommands(
|
|||||||
title: typeof value.title === "string" ? value.title : value.command,
|
title: typeof value.title === "string" ? value.title : value.command,
|
||||||
description: typeof value.description === "string" ? value.description : "",
|
description: typeof value.description === "string" ? value.description : "",
|
||||||
argHint: typeof value.arg_hint === "string" ? value.arg_hint : "",
|
argHint: typeof value.arg_hint === "string" ? value.arg_hint : "",
|
||||||
|
lifecycle: value.lifecycle as SlashCommandLifecycle,
|
||||||
acceptsArgs: value.accepts_args === true,
|
acceptsArgs: value.accepts_args === true,
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user