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