fix(tui): preserve slash command semantics

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 2b666934ba
commit b3c3a82075
9 changed files with 349 additions and 57 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
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:
+1 -1
View File
@@ -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.
`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`.
+3 -2
View File
@@ -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
to move, `Tab` to complete, and `Esc` to close the menu.
Use `/sessions` or `/resume` to search and switch persisted conversations without leaving the
terminal. `/new` starts a separate conversation while preserving the current one in history.
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
`/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
View File
@@ -7,6 +7,7 @@ import {
} from "@opentui/core/testing"
import { NanobotTui, type AppOptions } from "./app"
import type { SlashCommand } from "./protocol"
const options: AppOptions = {
wsUrl: "ws://localhost.invalid/ws",
@@ -211,13 +212,7 @@ describe("NanobotTui layout", () => {
composer: TextareaRenderable
commandMenu: {
visible: boolean
setCommands(commands: Array<{
command: string
title: string
description: string
argHint: string
acceptsArgs: boolean
}>): void
setCommands(commands: SlashCommand[]): void
}
}
ui.commandMenu.setCommands([{
@@ -225,6 +220,7 @@ describe("NanobotTui layout", () => {
title: "History",
description: "Show recent messages",
argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true,
}])
@@ -237,7 +233,7 @@ describe("NanobotTui layout", () => {
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" })
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
@@ -285,7 +281,7 @@ describe("NanobotTui layout", () => {
app.accept({ event: "attached", chat_id: "other" })
await Bun.sleep(1)
ui.composer.setText("/new")
ui.composer.setText("/new-chat")
ui.composer.submit()
await waitUntil(() => newChats.length === 1)
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 () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
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 () => {
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
const app = mount(setup)
+100 -29
View File
@@ -22,7 +22,13 @@ import {
type InboundEvent,
type SlashCommand,
} 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 { Transcript, type TranscriptTheme } from "./transcript"
@@ -90,27 +96,18 @@ const LIGHT: Palette = {
}
const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
const LOCAL_COMMANDS: SlashCommand[] = [
const LOCAL_COMMANDS: TuiCommand[] = [
{
command: "/sessions",
title: "Sessions",
description: "Find and switch conversations",
argHint: "",
acceptsArgs: false,
action: "sessions",
},
{
command: "/resume",
title: "Resume",
description: "Find and switch conversations",
argHint: "",
acceptsArgs: false,
},
{
command: "/new",
title: "New chat",
description: "Start a separate conversation",
argHint: "",
acceptsArgs: false,
command: "/new-chat",
title: "New saved chat",
description: "Keep this conversation and start another",
action: "new-chat",
},
]
@@ -221,6 +218,7 @@ export class NanobotTui {
private quitting = false
private sessionLoadId = 0
private sessionLoading = false
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
private constructor(
renderer: CliRenderer,
@@ -234,6 +232,7 @@ export class NanobotTui {
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
this.commandMenu.setCommands([], LOCAL_COMMANDS)
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
this.client = client || new NanobotClient({
url: options.wsUrl,
@@ -423,12 +422,15 @@ export class NanobotTui {
this.updateMeta()
return
}
if (["/sessions", "/resume", "/continue"].includes(content.toLocaleLowerCase())) {
void this.openSessions()
const command = this.commandMenu.resolve(content)
if (command?.source === "tui") {
if (command.command.action === "sessions") void this.openSessions()
else this.startNewChat()
return
}
if (content.toLocaleLowerCase() === "/new") {
this.startNewChat()
if (command?.source === "gateway") {
const lifecycle = resolveSlashCommandLifecycle(content, command.command)
if (lifecycle) this.sendGatewayCommand(content, lifecycle)
return
}
if (!this.ready) {
@@ -451,10 +453,7 @@ export class NanobotTui {
}
this.composer.setText("")
this.commandMenu.hide()
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 = ""
this.recordPrompt(content)
this.transcript.user(content)
this.finalMessage = ""
this.turnHadAnswer = false
@@ -465,6 +464,7 @@ export class NanobotTui {
accept(event: InboundEvent): void {
if (event.event === "attached") {
this.commandTurns.clear()
const restoring = this.attachedOnce
this.attachedOnce = true
if (restoring) this.setActive(false)
@@ -500,6 +500,15 @@ export class NanobotTui {
this.transcript.stream(event.text)
return
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) {
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
this.lastProgress = this.transcript.progress(event.text, event.tool_events)
@@ -528,6 +537,7 @@ export class NanobotTui {
}
return
case "turn_end":
if (event.turn_id) this.commandTurns.delete(event.turn_id)
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
this.transcript.finishActivity()
this.finalMessage = ""
@@ -554,6 +564,7 @@ export class NanobotTui {
this.setModel(event.model_name)
return
case "error":
if (event.turn_id) this.commandTurns.delete(event.turn_id)
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
this.finalMessage = ""
@@ -870,8 +881,7 @@ export class NanobotTui {
// Local navigation remains available against older gateways.
}
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()])
this.commandMenu.setCommands([...commands.values()], LOCAL_COMMANDS)
this.syncCommandMenu()
}
@@ -891,6 +901,7 @@ export class NanobotTui {
this.sessionLoading = false
const limit = this.renderer.height >= 20 ? 8 : 4
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
this.sessionMenu.update(this.composer.plainText, limit)
this.syncComposerPlaceholder()
this.updateMeta()
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"
return
}
this.closeSessions()
if (chatId === this.client.activeChatId) {
this.closeSessions()
this.status.content = "Ready"
return
}
if (!this.ready) {
this.status.content = "Preparing chat…"
return
}
this.closeSessions()
try {
this.ready = false
this.status.content = "Opening session…"
this.client.attach(chatId)
} catch (error) {
this.ready = true
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"
return
}
if (!this.ready) {
this.status.content = "Preparing chat…"
return
}
this.commandMenu.hide()
this.sessionMenu.hide()
this.composer.setText("")
@@ -934,11 +953,63 @@ export class NanobotTui {
this.status.content = "Starting a new chat…"
this.client.newChat()
} catch (error) {
this.ready = true
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 {
this.sessionLoadId += 1
this.sessionLoading = false
+44 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { CommandMenu } from "./command-menu"
import { CommandMenu, resolveSlashCommandLifecycle } from "./command-menu"
import type { SlashCommand } from "./protocol"
const commands: SlashCommand[] = [
@@ -10,6 +10,7 @@ const commands: SlashCommand[] = [
title: "Help",
description: "Show available commands",
argHint: "",
lifecycle: "side_channel",
acceptsArgs: false,
},
{
@@ -17,6 +18,7 @@ const commands: SlashCommand[] = [
title: "History",
description: "Show recent messages",
argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true,
},
]
@@ -64,4 +66,45 @@ describe("CommandMenu", () => {
menu.update("/history 5")
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
View File
@@ -1,15 +1,48 @@
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"
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. */
export class CommandMenu {
readonly root: BoxRenderable
private readonly picker: PickerMenu<SlashCommand>
private commands: SlashCommand[] = []
private readonly picker: PickerMenu<CommandChoice>
private commands: CommandChoice[] = []
private query = ""
constructor(
@@ -18,9 +51,14 @@ export class CommandMenu {
) {
this.picker = new PickerMenu(renderer, theme, {
id: "nanobot-tui-command-menu",
searchText: (command) => `${command.command} ${command.title}`,
render: (command) => {
const hint = command.argHint ? ` ${command.argHint}` : ""
searchText: (choice) => {
const command = descriptor(choice)
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, " ")
return `${command.command}${hint} ${detail}`
},
@@ -32,11 +70,28 @@ export class CommandMenu {
return this.picker.visible
}
setCommands(commands: SlashCommand[]): void {
this.commands = [...commands].sort((left, right) => left.command.localeCompare(right.command))
setCommands(commands: SlashCommand[], local: TuiCommand[] = []): void {
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)
}
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 {
const changed = input !== this.query
this.query = input
@@ -55,17 +110,22 @@ export class CommandMenu {
completion(input: string): string | null {
if (!this.visible) return null
const command = this.picker.current()
if (!command || input.trim() === command.command) return null
return `${command.command}${command.acceptsArgs ? " " : ""}`
const choice = this.picker.current()
if (!choice) return null
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 {
if (!this.visible) return null
const command = this.picker.current()
if (!command) return null
const choice = this.picker.current()
if (!choice) return null
const command = descriptor(choice)
this.hide()
return `${command.command}${command.acceptsArgs ? " " : ""}`
const acceptsArgs = choice.source === "gateway" && choice.command.acceptsArgs
return `${command.command}${acceptsArgs ? " " : ""}`
}
hide(): void {
+19
View File
@@ -238,6 +238,7 @@ describe("gateway protocol", () => {
title: "History",
description: "Show recent messages",
argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true,
}])
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 () => {
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
+9
View File
@@ -92,9 +92,17 @@ export interface SlashCommand {
title: string
description: string
argHint: string
lifecycle: SlashCommandLifecycle
acceptsArgs: boolean
}
export type SlashCommandLifecycle =
| "side_channel"
| "finalize_active_turn"
| "stop_active_turn"
| "agent_turn"
| "agent_turn_with_args"
export interface SessionSummary {
chatId: string
title: string
@@ -275,6 +283,7 @@ export async function fetchSlashCommands(
title: typeof value.title === "string" ? value.title : value.command,
description: typeof value.description === "string" ? value.description : "",
argHint: typeof value.arg_hint === "string" ? value.arg_hint : "",
lifecycle: value.lifecycle as SlashCommandLifecycle,
acceptsArgs: value.accepts_args === true,
}]
})