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
7 changes: 6 additions & 1 deletion src/server/chat/adapters/claude.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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. */
Expand Down
15 changes: 15 additions & 0 deletions src/shared/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
20 changes: 19 additions & 1 deletion test/chat-claude.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines 296 to 300
thinking: true,
toolCalls: true,
Expand All @@ -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);
});
});
});
Loading