diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 723e81842..eaa46744f 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -2106,6 +2106,36 @@ describe("NanobotTui layout", () => { expect(setup.renderer.isDestroyed).toBe(true) }) + + test("discovers and runs the local /exit command", async () => { + setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) + const sent: string[] = [] + let closed = false + const transport = client(sent) + transport.close = () => { closed = true } + const app = NanobotTui.mount( + setup.renderer, + options, + transport, + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + const ui = app as unknown as { + composer: TextareaRenderable + commandMenu: { visible: boolean } + } + + await setup.mockInput.typeText("/exit") + await setup.flush() + expect(ui.commandMenu.visible).toBe(true) + expect(setup.captureCharFrame()).toContain("/exit") + + expect(ui.composer.plainText).toBe("/exit") + ui.composer.submit() + await waitUntil(() => closed) + + expect(sent).toEqual([]) + expect(setup.renderer.isDestroyed).toBe(true) + }) }) describe("NanobotTui in a Herdr pane", () => { diff --git a/tui/src/app.ts b/tui/src/app.ts index 338f1c09d..190a8f498 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -194,6 +194,12 @@ const LOCAL_COMMANDS: TuiCommand[] = [ description: "Continue from an earlier completed reply", action: "branch", }, + { + command: "/exit", + title: "Exit", + description: "Close this terminal UI", + action: "exit", + }, ] function syntaxStyle(palette: Palette): SyntaxStyle { @@ -456,7 +462,9 @@ export class NanobotTui { this.palette = this.activeThemeMode === "light" ? LIGHT : DARK this.host = host this.localCommands = host.hosted - ? LOCAL_COMMANDS.filter(({ command }) => command === "/context" || command === "/diff") + ? LOCAL_COMMANDS.filter(({ command }) => ( + command === "/context" || command === "/diff" || command === "/exit" + )) : LOCAL_COMMANDS this.transcript = new Transcript( renderer, @@ -809,7 +817,7 @@ export class NanobotTui { return } if (!visibleContent) return - if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) { + if (["exit", "quit", "/quit", ":q"].includes(visibleContent.toLowerCase())) { this.quit() return } @@ -826,6 +834,7 @@ export class NanobotTui { else if (command.command.action === "context") void this.openContext() else if (command.command.action === "diff") this.openDiff() else if (command.command.action === "branch") void this.openBranch() + else if (command.command.action === "exit") this.quit() else this.startNewChat() return } diff --git a/tui/src/command-menu.test.ts b/tui/src/command-menu.test.ts index 218fa37de..cfe88fedd 100644 --- a/tui/src/command-menu.test.ts +++ b/tui/src/command-menu.test.ts @@ -93,6 +93,32 @@ describe("CommandMenu", () => { }) }) + test("discovers and completes the local exit action", async () => { + setup = await createTestRenderer({ width: 60, height: 12, screenMode: "alternate-screen" }) + const menu = new CommandMenu(setup.renderer, { + text: "#FFFFFF", + muted: "#999999", + border: "#555555", + }) + setup.renderer.root.add(menu.root) + menu.setCommands([], [{ + command: "/exit", + title: "Exit", + description: "Close this terminal UI", + action: "exit", + }]) + + menu.update("/ex") + await setup.renderOnce() + + expect(setup.captureCharFrame()).toContain("/exit") + expect(menu.completion("/ex")).toBe("/exit") + expect(menu.resolve("/exit")).toMatchObject({ + source: "tui", + command: { action: "exit" }, + }) + }) + test("resolves argument-sensitive command lifecycles", () => { const goal: SlashCommand = { command: "/goal", diff --git a/tui/src/command-menu.ts b/tui/src/command-menu.ts index 7c5122242..ea1292f43 100644 --- a/tui/src/command-menu.ts +++ b/tui/src/command-menu.ts @@ -5,7 +5,7 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu" export type CommandMenuTheme = PickerMenuTheme -export type TuiCommandAction = "sessions" | "new-chat" | "context" | "diff" | "branch" +export type TuiCommandAction = "sessions" | "new-chat" | "context" | "diff" | "branch" | "exit" export interface TuiCommand { command: string