From 4263bc00ec9ab603be6b9309bd9fdcca06ec6ad6 Mon Sep 17 00:00:00 2001 From: dnviti Date: Sun, 26 Jul 2026 16:42:04 +0200 Subject: [PATCH] fix(chat): show Claude's slash commands from the moment a session opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude's CLI does not report `slash_commands` until it has processed a first turn's `system/init`, which only arrives after the first message is written to its stdin. The chat session spawns the process as soon as a chat is opened, well before any message is sent, so the command menu and its composer button stayed empty — looking broken — until a throwaway message unlocked them. The adapter now advertises a static baseline built from this app's own table of Claude's built-in commands as soon as it starts, so the menu and button are populated immediately. The real `init` list — including any project or plugin commands — still arrives with the first turn and replaces this baseline outright, exactly as before. ACP runtimes (kimi, omp) already report commands during their handshake, before any message is sent, so they were never affected. codex, grok and pi have no command support at all; the composer already hides the button entirely rather than showing an empty menu, which is the honest behavior for a runtime that offers none. Closes #30 Co-Authored-By: Claude Sonnet 5 --- src/server/chat/adapters/claude.ts | 7 ++++++- src/shared/slash-commands.ts | 15 +++++++++++++++ test/chat-claude.test.js | 20 +++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) 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); + }); }); });