Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added `/exit` as a built-in interactive slash command alias for graceful app shutdown from the main chat.

## [0.8.24-alpha.2] - 2026-06-03

### Added
Expand Down
3 changes: 2 additions & 1 deletion packages/coding-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files (themes hot-reload automatically) |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display version history |
| `/quit` | Quit pi |
| `/exit` | Exit Atomic |
| `/quit` | Quit Atomic |

### Keyboard Shortcuts

Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ Type `/` in the editor to open command completion. Extensions can register custo
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display version history |
| `/exit` | Exit Atomic |
| `/quit` | Quit Atomic |

## Message Queue
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,6 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "compact", description: "Manually compact the session context" },
{ name: "resume", description: "Resume a different session" },
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
{ name: "exit", description: `Exit ${APP_NAME}` },
{ name: "quit", description: `Quit ${APP_NAME}` },
];
Original file line number Diff line number Diff line change
Expand Up @@ -3212,7 +3212,7 @@ export class InteractiveMode {
this.editor.setText("");
return;
}
if (text === "/quit") {
if (text === "/quit" || text === "/exit") {
this.editor.setText("");
await this.shutdown();
return;
Expand Down
63 changes: 63 additions & 0 deletions packages/coding-agent/test/interactive-mode-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,53 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
});
});

describe("InteractiveMode submit routing", () => {
function installSubmitHandler(options: { onInput?: (text: string) => void } = {}) {
const defaultEditor: { onSubmit?: (text: string) => Promise<void> } = {};
const fakeThis: any = {
defaultEditor,
editor: {
setText: vi.fn(),
addToHistory: vi.fn(),
},
shutdown: vi.fn(async () => {}),
session: {
isBashRunning: false,
isCompacting: false,
isStreaming: false,
},
isExtensionCommand: vi.fn(() => false),
flushPendingBashComponents: vi.fn(),
onInputCallback: options.onInput,
};

(InteractiveMode as any).prototype.setupEditorSubmitHandler.call(fakeThis);
return { fakeThis, submit: defaultEditor.onSubmit! };
}

test("routes exact /exit and /quit to graceful shutdown", async () => {
for (const command of ["/exit", "/quit"]) {
const { fakeThis, submit } = installSubmitHandler();

await submit(command);

expect(fakeThis.editor.setText).toHaveBeenCalledWith("");
expect(fakeThis.shutdown).toHaveBeenCalledTimes(1);
}
});

test("does not treat /exit with arguments as the exit command", async () => {
const onInput = vi.fn();
const { fakeThis, submit } = installSubmitHandler({ onInput });

await submit("/exit now");

expect(fakeThis.shutdown).not.toHaveBeenCalled();
expect(onInput).toHaveBeenCalledWith("/exit now");
expect(fakeThis.editor.addToHistory).toHaveBeenCalledWith("/exit now");
});
});

describe("InteractiveMode /fast autocomplete", () => {
function createModel(provider: string, id = `${provider}-model`): Model<Api> {
return {
Expand Down Expand Up @@ -358,6 +405,22 @@ describe("InteractiveMode /fast autocomplete", () => {
expect(labels).not.toContain("fast");
expect(labels).toContain("faster");
});

test("shows built-in /exit for /ex and hides conflicting extension /exit", async () => {
const labels = await slashLabels(
createProvider([createModel("openai")], [], {
extensionCommands: [
{ name: "exit", description: "Extension exit command" },
{ name: "explain", description: "Non-conflicting extension command" },
],
}),
"/ex",
);

expect(labels).toContain("exit");
expect(labels.filter((label) => label === "exit")).toHaveLength(1);
expect(labels).toContain("explain");
});
});

describe("InteractiveMode.showLoadedResources", () => {
Expand Down
1 change: 0 additions & 1 deletion packages/workflows/src/tui/stage-chat-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,6 @@ export class StageChatView implements Component, Focusable {
return true;
}
case "/quit":
case "/exit":
this.onClose();
return true;
default:
Expand Down

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions test/unit/slash-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, test } from "bun:test";
import assert from "node:assert/strict";
import { APP_NAME } from "../../packages/coding-agent/src/config.js";
import { BUILTIN_SLASH_COMMANDS } from "../../packages/coding-agent/src/core/slash-commands.js";

describe("built-in slash commands", () => {
test("lists /exit as a graceful shutdown command", () => {
const command = BUILTIN_SLASH_COMMANDS.find((item) => item.name === "exit");

assert.ok(command, "expected /exit to be listed as a built-in command");
assert.equal(command.description, `Exit ${APP_NAME}`);
});
});
68 changes: 68 additions & 0 deletions test/unit/stage-chat-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,29 @@ async function flush(): Promise<void> {
return new Promise<void>((resolve) => queueMicrotask(resolve));
}

function submitStageChatText(view: StageChatView, text: string): void {
for (const ch of text) view.handleInput(ch);
view.handleInput("\r");
}

function makeStageChatViewForSlashCommand(callbacks: {
onClose?: () => void;
} = {}): StageChatView {
const store = createStore();
setupRun(store, "run-1", "stage-a");
const { handle } = makeHandle();
return new StageChatView({
store,
graphTheme: deriveGraphTheme({}),
runId: "run-1",
stageId: "stage-a",
workflowName: "test-wf",
handle,
onDetach: () => {},
onClose: callbacks.onClose ?? (() => {}),
});
}

function fakeFooterAgentSession(isStreaming = false): AgentSession {
return {
state: {
Expand Down Expand Up @@ -1929,6 +1952,51 @@ describe("StageChatView", () => {
view.dispose();
});

test("stage chat /exit is not a local workflow slash command", async () => {
for (const input of ["/exit", "/exit now", "/exit 1"]) {
const store = createStore();
setupRun(store, "run-1", "stage-a");
const { handle, state } = makeHandle();
let closeCalls = 0;
const view = new StageChatView({
store,
graphTheme: deriveGraphTheme({}),
runId: "run-1",
stageId: "stage-a",
workflowName: "test-wf",
handle,
onDetach: () => {},
onClose: () => {
closeCalls += 1;
},
});

submitStageChatText(view, input);
await flush();
await flush();

assert.equal(closeCalls, 0, `${input} should not close the overlay`);
assert.deepEqual(state.promptCalls, [input]);
view.dispose();
}
});

test("stage chat /quit still closes only the overlay", async () => {
let closeCalls = 0;
const view = makeStageChatViewForSlashCommand({
onClose: () => {
closeCalls += 1;
},
});

submitStageChatText(view, "/quit");
await flush();
await flush();

assert.equal(closeCalls, 1);
view.dispose();
});

test("idle Enter calls handle.prompt", async () => {
const store = createStore();
setupRun(store, "run-1", "stage-a", "pending");
Expand Down
54 changes: 54 additions & 0 deletions test/unit/workflow-attach-pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,19 @@ async function flush(): Promise<void> {
await Promise.resolve();
}

type AttachedStageChat = { handleInput(data: string): boolean };

function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat {
const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView;
assert.ok(chatView, "expected initialAttachStageId to create a stage chat");
return chatView;
}

function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void {
for (const ch of text) chatView.handleInput(ch);
chatView.handleInput("\r");
}

function setupTwoPromptAttachPane(
firstPrompt: PendingPrompt,
opts: { piKeybindings?: unknown; now?: () => number } = {},
Expand Down Expand Up @@ -230,6 +243,47 @@ describe("WorkflowAttachPane", () => {
pane.dispose();
});

test("attached stage chat /exit is not a workflow-local shutdown command", async () => {
for (const input of ["/exit", "/exit now"]) {
const store = createStore();
setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]);
const registry = createStageControlRegistry();
const promptCalls: Array<string> = [];
registry.register({
...makeHandle("run-1", "stage-a"),
async prompt(text: string) {
promptCalls.push(text);
},
});
let closeCalls = 0;
const clock = makeClock();
const pane = new WorkflowAttachPane({
store,
graphTheme: deriveGraphTheme({}),
runId: "run-1",
stageControlRegistry: registry,
initialAttachStageId: "stage-a",
onClose: () => {
closeCalls += 1;
},
now: clock.now,
});
clock.advance(250);

const chatView = getAttachedStageChat(pane);
submitAttachedStageChatText(chatView, input);
await flush();
await flush();
await flush();
await flush();

assert.equal(closeCalls, 0);
assert.equal(promptCalls.length, 1);
assert.equal(promptCalls[0], input);
pane.dispose();
}
});

test("forwards piKeybindings to GraphView run-level prompt cards", () => {
const store = createStore();
setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]);
Expand Down
Loading