diff --git a/src/server/chat/adapters/claude.ts b/src/server/chat/adapters/claude.ts index 4eb482d..828866a 100644 --- a/src/server/chat/adapters/claude.ts +++ b/src/server/chat/adapters/claude.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { BaseChatAdapter } from '../adapter.js'; -import { describeSlashCommand } from '../../../shared/slash-commands.js'; +import { defaultSlashCommands, describeSlashCommand } from '../../../shared/slash-commands.js'; import { ChatAttachment, ChatBlock, @@ -48,6 +48,11 @@ export class ClaudeChatAdapter extends BaseChatAdapter { usage: true, cost: true, plan: false, + // The real list only arrives with the first turn's `init` (see + // handleInit below); until then this is what makes the command menu and + // its composer button available from the moment the session opens, + // rather than staying empty until a message has already been sent. + commands: defaultSlashCommands(), }; /** Session id we generated for a fresh launch, before init echoes it back. */ diff --git a/src/shared/slash-commands.ts b/src/shared/slash-commands.ts index 6cb2ac5..550dd6c 100644 --- a/src/shared/slash-commands.ts +++ b/src/shared/slash-commands.ts @@ -48,6 +48,21 @@ export function describeSlashCommand(name: string): string | undefined { return BUILT_IN[String(name || '').trim().toLowerCase().replace(/^\//, '')]; } +/** + * The built-ins, as a menu the picker can show before a runtime has said + * anything about itself. + * + * Claude does not report `slash_commands` until it has processed a first + * turn (see claude.ts), which used to mean a brand-new session showed no + * command menu at all until one message had already been sent. This table is + * this app's own knowledge of what a fresh Claude session accepts, not + * something Claude told it — a project or plugin command still only appears + * once the real `init` arrives and replaces this list outright. + */ +export function defaultSlashCommands(): { name: string; description: string }[] { + return Object.entries(BUILT_IN).map(([name, description]) => ({ name, description })); +} + /** * Commands that empty the conversation. * diff --git a/test/chat-claude.test.js b/test/chat-claude.test.js index 28543fc..7e2e70f 100644 --- a/test/chat-claude.test.js +++ b/test/chat-claude.test.js @@ -295,7 +295,8 @@ describe('claude chat adapter', function () { describe('capabilities', function () { it('advertises streaming/thinking/toolCalls/resume/interrupt/attachments but not diffs or permissions', function () { const { adapter } = makeAdapter(); - assert.deepStrictEqual(adapter.capabilities, { + const { commands, ...rest } = adapter.capabilities; + assert.deepStrictEqual(rest, { streaming: true, thinking: true, toolCalls: true, @@ -310,5 +311,22 @@ describe('claude chat adapter', function () { plan: false, }); }); + + it('advertises a baseline command list before the runtime has said anything', function () { + const { adapter } = makeAdapter(); + assert.ok(adapter.capabilities.commands.length > 0); + assert.ok(adapter.capabilities.commands.some((c) => c.name === 'resume')); + assert.ok(adapter.capabilities.commands.every((c) => c.description)); + }); + }); + + describe('before the first turn', function () { + it('a freshly constructed adapter already has a non-empty command list, so the menu never starts empty', function () { + const { adapter } = makeAdapter(); + // No handleMessage call at all here: this is the state the session sees + // the instant the process is spawned, before Claude has said a word. + assert.ok(Array.isArray(adapter.capabilities.commands)); + assert.ok(adapter.capabilities.commands.length > 0); + }); }); });