feat(tui): run bang commands through the gateway

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent c5d2e0ddf1
commit c320d08dfe
12 changed files with 299 additions and 13 deletions
+29
View File
@@ -454,6 +454,35 @@ describe("NanobotTui layout", () => {
expect(sent).toEqual([])
})
test("runs bang commands through the gateway without steering the agent", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const sent: string[] = []
const sentOptions: MessageOptions[] = []
const app = NanobotTui.mount(
setup.renderer,
options,
client(sent, [], [], sentOptions),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
app.accept({ event: "attached", chat_id: "chat" })
const ui = app as unknown as { ready: boolean; composer: TextareaRenderable; activeTurn: boolean }
await waitUntil(() => ui.ready)
app.accept({ event: "goal_status", chat_id: "chat", status: "running" })
ui.composer.setText("!pwd")
ui.composer.submit()
await waitUntil(() => sent.length === 1)
expect(sent).toEqual(["!pwd"])
expect(sentOptions).toEqual([{ userShell: true }])
expect(ui.activeTurn).toBe(true)
app.accept({ event: "message", chat_id: "chat", text: "/tmp/project", turn_id: "turn" })
await setup.flush()
expect(setup.captureCharFrame()).toContain("/tmp/project")
expect(ui.activeTurn).toBe(true)
})
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
+6 -1
View File
@@ -752,6 +752,10 @@ export class NanobotTui {
if (lifecycle) this.sendGatewayCommand(visibleContent, lifecycle)
return
}
if (visibleContent.startsWith("!")) {
this.sendGatewayCommand(visibleContent, "side_channel", false, { userShell: true })
return
}
if (!this.ready) {
this.status.content = "Preparing chat…"
return
@@ -1812,6 +1816,7 @@ export class NanobotTui {
content: string,
lifecycle: ResolvedSlashCommandLifecycle,
silent = false,
options: MessageOptions = {},
): void {
if (!this.ready) {
this.status.content = "Preparing chat…"
@@ -1823,7 +1828,7 @@ export class NanobotTui {
}
let turnId: string
try {
turnId = this.client.send(content)
turnId = this.client.send(content, options)
} catch (error) {
this.status.content = error instanceof Error ? error.message : String(error)
return
+2
View File
@@ -96,6 +96,7 @@ describe("gateway protocol", () => {
client.send("hello", {
cliApps: [{ name: "github" }],
sessionMentions: [{ name: "plan", session_key: "websocket:plan" }],
userShell: true,
})
client.attach("other-chat")
client.newChat()
@@ -110,6 +111,7 @@ describe("gateway protocol", () => {
expect(outbound[1]?.type).toBe("message")
expect(outbound[1]?.chat_id).toBe("terminal")
expect(outbound[1]?.content).toBe("hello")
expect(outbound[1]?.user_shell).toBe(true)
expect(outbound[1]?.cli_apps).toEqual([{ name: "github" }])
expect(outbound[1]?.session_mentions).toEqual([
{ name: "plan", session_key: "websocket:plan" },
+2
View File
@@ -214,6 +214,7 @@ export interface MessageOptions {
cliApps?: Array<{ name: string }>
mcpPresets?: Array<{ name: string }>
sessionMentions?: SessionMention[]
userShell?: boolean
}
export interface SlashCommand {
@@ -799,6 +800,7 @@ export class NanobotClient {
content,
turn_id: turnId,
webui: true,
...(options.userShell ? { user_shell: true } : {}),
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options.sessionMentions?.length