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
22 changes: 15 additions & 7 deletions .claude/skills/add-slash-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ export const COMMANDS: readonly CommandSpec[] = [
```

- `name` は小文字(`parseCommand` が lowercase する)。
- **別名(`aliases`)はパレットの前方一致にだけ効き、スラッシュ無しの昇格には効かない**
(`toCommandInput` は正式名のみ昇格。`?` や `changes` を打っても指示として送れる余地を残す設計)。
- **別名(`aliases`)はパレットの前方一致とスラッシュ付きの実行に効き、スラッシュ無しの
昇格には効かない**(`toCommandInput` は正式名のみ昇格。`?` や `changes` を打っても
指示として送れる余地を残す設計)。
- **レジストリに無い名前はコマンドではなく通常の指示**(`/v3/chats です。` を送れること)。
つまり `COMMANDS` に足すまでは `/newcmd` を打ってもセッションへ送られるだけなので、
「打ったのに何も起きない」ときはまずレジストリへの追加漏れを疑う。
- `describe` は必ずカタログ関数で書く(文言直書き禁止)。

## 2. 文言を ja / en 両方に足す — `src/core/i18n.ts`
Expand All @@ -35,30 +39,34 @@ export const COMMANDS: readonly CommandSpec[] = [

## 3. ハンドラを配線する — `src/ui/session-list.tsx` / `src/ui/session-detail.tsx`

各ビューは `useCommandRunner(handlers, setActionError, m.command.unknown)` に
各ビューは `useCommandRunner(handlers, setActionError)` に
**自分が実装するアクションだけ**を渡す。

```ts
const commands = useCommandRunner(
{ exit: onQuit, help: () => setShowHelp(true), model: …, prompt: …, clear: … }, // 一覧
setActionError,
m.command.unknown,
);
```

- **重要**: ハンドラを持たないビューでは、スラッシュ無しの入力を昇格させない(`clear` を
詳細ビューで打っても無言に消えず、通常の指示として送られる)。この挙動は
`useCommandRunner` が担保しているので、`run` / `preview` を自前判定に置き換えない
`useCommandRunner` が担保しているので、`run` / `palette` を自前判定に置き換えない
(パレットの予告と実際の動作を必ず一致させる)。
- パレットの表示は `commands.palette(value)`(`null` = 出さない / `[]` = 「一致なし」を
出す)。**スラッシュ入力は候補ゼロでもパレットを出す** — 一致しない入力はそのまま
指示として送られるので、Enter の前にそれを伝えるため(`m.command.paletteEmpty`)。
- ビュー固有の説明差し替えは `CommandPalette` の `describeOverrides`(キー = コマンド名)に渡す。
詳細ビューの例: `useMemo(() => ({ exit: m.command.exitDetail }), [...])`。
- 副作用の実体は core/manager 側のメソッドを呼ぶだけにする(UI にロジックを書かない)。

## 4. テスト

- `src/core/commands.spec.ts` … テーブルドリブンで追加(`parseCommand` / `findCommand` /
`matchCommands` / `toCommandInput` / `runCommand`)。別名の非昇格も明示的にケース化する。
- `tests/commands.test.tsx` … UI 配線(パレット表示・前方一致・実行結果・未知コマンドのエラー)。
`matchCommands` / `toCommandInput` / `runCommand` / `resolveCommand`)。別名の非昇格も
明示的にケース化する。
- `tests/commands.test.tsx` … UI 配線(パレット表示・前方一致・実行結果・**一致しない
スラッシュ入力が指示として送られること**)。
- 既存の `tests/app.test.tsx` に該当シナリオがあるなら合わせて更新。

## 5. ドキュメント
Expand Down
2 changes: 2 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,8 @@ claude.ai の設定「**Help improve our AI models**」が ON のアカウント

スラッシュを打ち忘れても、**その画面で使えるコマンド名と完全に一致する入力**(`exit` / `help` など)はそのコマンドとして実行されます。実行されるときはコマンドパレットに出るので、`Enter` の前に何が起きるか分かります。`exit の挙動を直して` のように後ろに文字が続く場合、また `?`・`changes` のような別名は通常の指示として扱うので、指示が誤ってコマンドになることはありません。

逆に、**`/` で始まっていてもコマンド名に一致しない入力はそのまま指示として送られます**(`/v3/chats です。`・`/tmp/foo に置いて` など)。パレットには「一致するコマンドがありません」と出るので、`Enter` がコマンドではなく指示になることが確定前に分かります。この場合コマンドの打ち間違い(`/modle`)もそのままセッションへ送られます。

**`/exit`** は画面によって意味が変わります。一覧画面では codiva を終了し、セッション詳細画面では**詳細を閉じて一覧へ戻ります**(`Esc` と同じ)。詳細を見ている途中に `/exit` を打ってアプリごと落ちてしまうことがないようにしています。

## トラブルシューティング
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,8 @@ All three only delete the local worktree and branch — **pushed remote branches

Even if you forget the slash, **input that exactly matches a command name available on that screen** (`exit`, `help`, …) runs as that command. When it will, the command palette shows it, so you know what `Enter` is about to do. Anything with trailing text (`fix how exit behaves`) and aliases like `?` or `changes` are treated as ordinary instructions, so an instruction never turns into a command by accident.

The other way round, **text that starts with `/` but matches no command name is sent as an ordinary instruction** (`/v3/chats please`, `put it in /tmp/foo`, …). The palette says "No matching command", so you can see before pressing `Enter` that it will go to the session rather than run a command. The flip side is that a mistyped command (`/modle`) is sent to the session too.

**`/exit` means different things on different screens.** In the list view it quits codiva; in the session detail view it **closes the detail view and returns to the list** (same as `Esc`), so you can't accidentally kill the app by typing `/exit` while reading a session.

## Troubleshooting
Expand Down
40 changes: 40 additions & 0 deletions src/core/commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isCommandInput,
matchCommands,
parseCommand,
resolveCommand,
runCommand,
toCommandInput,
} from './commands';
Expand All @@ -27,6 +28,14 @@ describe('toCommandInput', () => {
it.each([
['/exit', '/exit'], // already a command input — passes through
['/help me now', '/help me now'],
['/', '/'], // a bare slash is help (the palette lists everything)
['/settings', '/settings'], // aliases run when typed with the slash
// スラッシュ始まりでもコマンド名に一致しなければ**通常の指示**(チャットできること)。
['/v3/chats です。', null],
['/tmp/foo に置いて', null],
['/frobnicate', null],
['/modle', null], // 打ち間違いもエラーにせず指示として送る(送れないほうが害が大きい)
['/mod', null], // 前方一致はパレットに出るだけ。実行は完全一致のときだけ
['exit', '/exit'], // bare command name counts as the command
['EXIT', '/exit'], // case-insensitive
[' exit ', '/exit'], // surrounding whitespace/newlines only
Expand All @@ -52,6 +61,37 @@ describe('toCommandInput', () => {
}
}
});

it('every name and alias runs when typed with a slash', () => {
for (const command of COMMANDS) {
for (const name of [command.name, ...(command.aliases ?? [])]) {
expect(toCommandInput(`/${name}`)).toBe(`/${name}`);
}
}
});
});

describe('resolveCommand', () => {
it.each([
['/exit', 'exit'],
['/settings', 'config'], // alias
['/', 'help'], // bare slash
['exit', 'exit'], // bare canonical name
['/EXIT', 'exit'], // case-insensitive
] as const)('%s → %s', (value, expected) => {
expect(resolveCommand(value)?.name).toBe(expected);
});

it.each([
'/v3/chats です。', // スラッシュ始まりの普通の文はコマンドではない
'/frobnicate',
'/mod', // 前方一致だけでは実行しない
'?', // 別名は昇格しない
'build the thing',
'',
])('%o is a normal instruction', (value) => {
expect(resolveCommand(value)).toBeNull();
});
});

describe('parseCommand', () => {
Expand Down
45 changes: 33 additions & 12 deletions src/core/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* スラッシュコマンドのレジストリと解析(純粋・I/O 非依存)。
*
* 入力欄の先頭が `/` のとき、通常の指示ではなくコマンドとして扱う。コマンドは
* 入力欄の先頭が `/` で、**かつ既知のコマンド名に一致する**ときだけコマンドとして扱う
* (一致しないスラッシュ始まりの文はそのまま指示として送る)。コマンドは
* この 1 ファイルの `COMMANDS` に 1 エントリ足すだけで増やせる設計にしている:
* 1. `CommandAction` に新しい動作を足す(UI 側が switch で受ける)
* 2. `COMMANDS` に `{ name, action, describe }` を足す
Expand Down Expand Up @@ -67,25 +68,30 @@ export const COMMANDS: readonly CommandSpec[] = [
{ name: 'exit', action: 'exit', describe: (m) => m.command.exit },
];

/** 先頭が `/` ならコマンド入力とみなす(引数の有無は問わない)。 */
/** 先頭が `/` なら「コマンドとして打たれた」入力(実行できるかは別問題)。 */
export function isCommandInput(value: string): boolean {
return value.startsWith('/');
}

/**
* スラッシュを打ち忘れた入力(`exit` など)をコマンド入力に正規化する。
* 入力を「実行するコマンド入力」に正規化する。コマンドでなければ null
* (呼び出し側は通常の指示として扱う)。
*
* `/` 付きならそのまま返す。`/` なしでも **コマンドの正式名と完全一致** するなら
* `/name` 形式に直して返す。コマンドでなければ null(呼び出し側は通常の指示として扱う)。
*
* 完全一致に限るのが要点で、`exit したあとの挙動を直して` のように後続テキストが
* ある入力は指示である可能性が高いため対象外。別名(`?` = help、`changes` = diff)も
* 対象外にしている: `?` の 1 文字だけをセッションへ送りたいことは十分あり得るのに、
* 昇格させるとその入力手段が失われる(正式名は `/` 無しでも意図が明らかなので許す)。
* - **`/` 付き**: `runCommand` が解決できるとき(= 既知の名前・別名に完全一致、または
* `/` のみ)だけコマンド扱い。それ以外はそのまま指示として流す。`/v3/chats です。`
* `/tmp/foo に置いて` のようにスラッシュで始まる普通の文はコマンドではないので、
* 「不明なコマンド」で止めると**その文をセッションへ送る手段が無くなる**(実際に
* チャットできない不具合になった)。副作用として打ち間違い(`/modle`)も指示として
* 送られるが、送り先はエージェントなので取り返しがつく。
* - **`/` なし**: **正式名と完全一致**するときだけ `/name` へ昇格する。
* 完全一致に限るのが要点で、`exit したあとの挙動を直して` のように後続テキストが
* ある入力は指示である可能性が高いため対象外。別名(`?` = help、`changes` = diff)も
* 対象外にしている: `?` の 1 文字だけをセッションへ送りたいことは十分あり得るのに、
* 昇格させるとその入力手段が失われる(正式名は `/` 無しでも意図が明らかなので許す)。
*/
export function toCommandInput(value: string): string | null {
if (isCommandInput(value)) {
return value;
return runCommand(value).kind === 'run' ? value : null;
}
const name = value.trim().toLowerCase();
return COMMANDS.some((c) => c.name === name) ? `/${name}` : null;
Expand Down Expand Up @@ -142,7 +148,8 @@ export type CommandResult =

/**
* コマンド入力文字列を解決する。`/` のみ(名前が空)は help 扱いにして、誤爆で
* unknown エラーを出さない。未知の名前は `unknown` を返し、UI がエラー表示する。
* 何も起きない状態にしない。未知の名前は `unknown` を返す — 呼び出し側
* (`toCommandInput`)はそれを「コマンドではない = 通常の指示」として扱う。
*/
export function runCommand(value: string): CommandResult {
const parsed = parseCommand(value);
Expand All @@ -158,3 +165,17 @@ export function runCommand(value: string): CommandResult {
const spec = findCommand(parsed.name);
return spec ? { kind: 'run', command: spec } : { kind: 'unknown', name: parsed.name };
}

/**
* 入力(`/name …` でも裸の `exit` でも)を実行するコマンドへ解決する。コマンドで
* なければ null = 通常の指示。`toCommandInput` → `runCommand` の 2 段を 1 本にした
* 糖衣で、呼び出し側に「解決できない場合」の分岐を 2 度書かせないためにある。
*/
export function resolveCommand(value: string): CommandSpec | null {
const command = toCommandInput(value);
if (command === null) {
return null;
}
const result = runCommand(command);
return result.kind === 'run' ? result.command : null;
}
13 changes: 6 additions & 7 deletions src/core/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,14 +460,15 @@ export interface Messages {
command: {
/** 入力中に出るコマンドパレットの見出し */
paletteTitle: string;
/** 前方一致するコマンドが無いときの表示 */
/**
* 前方一致するコマンドが無いときの表示。コマンドに一致しないスラッシュ入力は
* **そのまま指示として送られる**ので、Enter で何が起きるかもここで伝える。
*/
paletteEmpty: string;
/** 端末が低くて全件描けないときに畳んだ件数(n は隠れている数) */
paletteMore: (n: number) => string;
/** /help のヘルプ一覧の見出し */
helpTitle: string;
/** 未知のコマンドを打ったときのエラー */
unknown: (name: string) => string;
/** /help の説明 */
help: string;
/** /exit の説明(一覧ビュー = アプリ終了) */
Expand Down Expand Up @@ -810,10 +811,9 @@ const ja: Messages = {
},
command: {
paletteTitle: 'コマンド',
paletteEmpty: '一致するコマンドがありません',
paletteEmpty: '一致するコマンドがありません(Enter でそのまま指示として送信します)',
paletteMore: (n) => `他 ${n} 件(入力で絞り込めます)`,
helpTitle: '利用可能なコマンド',
unknown: (name) => (name ? `不明なコマンド: /${name}` : '不明なコマンドです'),
help: 'コマンド一覧を表示',
exit: 'codiva を終了',
exitDetail: '詳細を閉じて一覧へ戻る',
Expand Down Expand Up @@ -1110,10 +1110,9 @@ const en: Messages = {
},
command: {
paletteTitle: 'Commands',
paletteEmpty: 'No matching command',
paletteEmpty: 'No matching command (Enter sends this as an instruction)',
paletteMore: (n) => `+${n} more (keep typing to filter)`,
helpTitle: 'Available commands',
unknown: (name) => (name ? `Unknown command: /${name}` : 'Unknown command'),
help: 'Show available commands',
exit: 'Quit codiva',
exitDetail: 'Close the session view (back to the list)',
Expand Down
58 changes: 31 additions & 27 deletions src/ui/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type AgentId,
COMPOSER_PREFIX_CELLS,
type CommandAction,
type CommandSpec,
type DisplayLine,
emptyBuffer,
emptyInputHistory,
Expand All @@ -26,6 +27,7 @@ import {
logSelectionText,
type Messages,
type ModelOption,
matchCommands,
normalizeLogSelection,
normalizeSelection,
type RateLimitWindow,
Expand All @@ -36,14 +38,13 @@ import {
recordInput,
recoveryNotice,
resetHistoryBrowse,
runCommand,
resolveCommand,
type SelectionRange,
type SessionManager,
type SessionState,
selectionText,
type TextBuffer,
type TrainingOptIn,
toCommandInput,
type UpdateCheck,
type UpdateInfo,
} from '@/core';
Expand Down Expand Up @@ -544,54 +545,57 @@ export interface CommandRunner {
*/
run: (text: string) => boolean;
/**
* The same resolution without side effects, for the palette: the normalized
* command input (`/name ...`) or null when the text is a normal instruction.
* Sharing it with `run` keeps the preview honest — whatever the palette shows
* is exactly what Enter will do.
* Rows for the palette above the composer, or null when no palette is shown.
* An empty array is NOT null: slash-prefixed text always opens the palette, so
* text that matches nothing (`/v3/chats です。`) says so instead of silently
* looking like a command — Enter will send it to the session as an instruction.
* Bare names only show up when this view actually runs them, keeping that
* preview honest.
*/
preview: (value: string) => string | null;
palette: (value: string) => CommandSpec[] | null;
}

/**
* Resolve a command typed in a composer and dispatch its effect. Known actions
* run the matching handler (a view supplies only the ones it implements — e.g.
* `/diff` is detail-only); an unknown name surfaces via `onError`. Clears the
* error on any recognized command. Shared by the list and detail composers.
* `/diff` is detail-only). Clears the action error on every recognized command.
* Shared by the list and detail composers.
*
* Slash-prefixed text is always a command (an unknown name becomes an error, not
* a prompt). A bare command name (`exit`) counts as one too, but only when this
* Slash-prefixed text is a command **only when the name matches a known one**;
* anything else falls through to the session as an instruction (`/v3/chats です。`
* must be sendable — refusing it with "unknown command" left no way to say it at
* all). A bare command name (`exit`) counts as a command too, but only when this
* view implements it — otherwise typing `clear` in the detail view would vanish
* with no feedback at all instead of reaching the session as an instruction.
* with no feedback at all instead of reaching the session.
*/
export function useCommandRunner(
handlers: Partial<Record<CommandAction, () => void>>,
onError: (message: string | undefined) => void,
unknownLabel: (name: string) => string,
): CommandRunner {
/** Command input this view would act on, or null → normal instruction. */
const resolve = (text: string): string | null => {
const command = toCommandInput(text);
if (command === null || isCommandInput(text)) {
return command;
/** The command this view would act on, or null → normal instruction. */
const resolve = (text: string): CommandSpec | null => {
const command = resolveCommand(text);
if (command === null) {
return null;
}
const result = runCommand(command);
// Bare names only resolve to commands this view implements.
return result.kind === 'run' && handlers[result.command.action] ? command : null;
return isCommandInput(text) || handlers[command.action] ? command : null;
};
return {
preview: resolve,
palette: (value: string) => {
if (isCommandInput(value)) {
return matchCommands(value);
}
const command = resolve(value);
return command ? [command] : null;
},
run: (text: string) => {
const command = resolve(text);
if (command === null) {
return false;
}
const result = runCommand(command);
if (result.kind === 'unknown') {
onError(unknownLabel(result.name));
return true;
}
onError(undefined);
handlers[result.command.action]?.();
handlers[command.action]?.();
return true;
},
};
Expand Down
Loading
Loading