diff --git a/.claude/skills/add-slash-command/SKILL.md b/.claude/skills/add-slash-command/SKILL.md index fbd2ac7..bfeffb2 100644 --- a/.claude/skills/add-slash-command/SKILL.md +++ b/.claude/skills/add-slash-command/SKILL.md @@ -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` @@ -35,21 +39,23 @@ 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 にロジックを書かない)。 @@ -57,8 +63,10 @@ const commands = useCommandRunner( ## 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. ドキュメント diff --git a/README.ja.md b/README.ja.md index a39f51a..dcff52d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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` を打ってアプリごと落ちてしまうことがないようにしています。 ## トラブルシューティング diff --git a/README.md b/README.md index a9438be..24faf10 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands.spec.ts b/src/core/commands.spec.ts index 320a58b..f01aa1c 100644 --- a/src/core/commands.spec.ts +++ b/src/core/commands.spec.ts @@ -5,6 +5,7 @@ import { isCommandInput, matchCommands, parseCommand, + resolveCommand, runCommand, toCommandInput, } from './commands'; @@ -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 @@ -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', () => { diff --git a/src/core/commands.ts b/src/core/commands.ts index 0915855..3b2ce6b 100644 --- a/src/core/commands.ts +++ b/src/core/commands.ts @@ -1,7 +1,8 @@ /** * スラッシュコマンドのレジストリと解析(純粋・I/O 非依存)。 * - * 入力欄の先頭が `/` のとき、通常の指示ではなくコマンドとして扱う。コマンドは + * 入力欄の先頭が `/` で、**かつ既知のコマンド名に一致する**ときだけコマンドとして扱う + * (一致しないスラッシュ始まりの文はそのまま指示として送る)。コマンドは * この 1 ファイルの `COMMANDS` に 1 エントリ足すだけで増やせる設計にしている: * 1. `CommandAction` に新しい動作を足す(UI 側が switch で受ける) * 2. `COMMANDS` に `{ name, action, describe }` を足す @@ -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; @@ -142,7 +148,8 @@ export type CommandResult = /** * コマンド入力文字列を解決する。`/` のみ(名前が空)は help 扱いにして、誤爆で - * unknown エラーを出さない。未知の名前は `unknown` を返し、UI がエラー表示する。 + * 何も起きない状態にしない。未知の名前は `unknown` を返す — 呼び出し側 + * (`toCommandInput`)はそれを「コマンドではない = 通常の指示」として扱う。 */ export function runCommand(value: string): CommandResult { const parsed = parseCommand(value); @@ -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; +} diff --git a/src/core/i18n.ts b/src/core/i18n.ts index d6131af..37dc980 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -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 の説明(一覧ビュー = アプリ終了) */ @@ -810,10 +811,9 @@ const ja: Messages = { }, command: { paletteTitle: 'コマンド', - paletteEmpty: '一致するコマンドがありません', + paletteEmpty: '一致するコマンドがありません(Enter でそのまま指示として送信します)', paletteMore: (n) => `他 ${n} 件(入力で絞り込めます)`, helpTitle: '利用可能なコマンド', - unknown: (name) => (name ? `不明なコマンド: /${name}` : '不明なコマンドです'), help: 'コマンド一覧を表示', exit: 'codiva を終了', exitDetail: '詳細を閉じて一覧へ戻る', @@ -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)', diff --git a/src/ui/hooks.ts b/src/ui/hooks.ts index 290ea13..1545011 100644 --- a/src/ui/hooks.ts +++ b/src/ui/hooks.ts @@ -14,6 +14,7 @@ import { type AgentId, COMPOSER_PREFIX_CELLS, type CommandAction, + type CommandSpec, type DisplayLine, emptyBuffer, emptyInputHistory, @@ -26,6 +27,7 @@ import { logSelectionText, type Messages, type ModelOption, + matchCommands, normalizeLogSelection, normalizeSelection, type RateLimitWindow, @@ -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'; @@ -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 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; }, }; diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx index a811f22..644fb74 100644 --- a/src/ui/session-detail.tsx +++ b/src/ui/session-detail.tsx @@ -27,7 +27,6 @@ import { logViewportRows, logWindow, type ModelOption, - matchCommands, paletteMaxRows, parseSgrMouse, resumeInstruction, @@ -343,7 +342,6 @@ export const SessionDetail: FC<{ // (ハンドラの無いコマンドは昇格しないので、打っても通常の指示として流れる)。 }, setActionError, - m.command.unknown, ); // 詳細ビューの `/exit` は一覧へ戻る動作なので、パレット/ヘルプの説明も差し替える // (既定は「codiva を終了」= 一覧ビューの意味)。 @@ -795,8 +793,8 @@ export const SessionDetail: FC<{ : panel === 'actions' ? m.detail.helpActions : m.detail.helpInput; - // コマンドとして解決される入力か(`/` 付き、または詳細で使える名前と完全一致)。 - const commandPreview = commands.preview(buffer.value); + // パレットに出す候補(`/` 付きなら前方一致、裸の名前は詳細で実行できるときだけ)。 + const paletteCommands = commands.palette(buffer.value); return ( @@ -1017,13 +1015,14 @@ export const SessionDetail: FC<{ ) : ( - {/* Enter の判定(`commands.preview`)と同じ条件で出す。スラッシュ無しの - `exit` でも確定前に何が起きるか見えるようにするため。**入力欄の計測 Box の - 外**に置く(中に入れると実測した上端がずれてクリックが別の文字に当たる)。 */} - {commandPreview !== null ? ( + {/* パレットは `commands.palette` が出す(`/` 付きは常に、裸の名前は実際に + 実行されるときだけ)。確定前に何が起きるか見えるようにするため。 + **入力欄の計測 Box の外**に置く(中に入れると実測した上端がずれて + クリックが別の文字に当たる)。 */} + {paletteCommands !== null ? ( diff --git a/src/ui/session-list.tsx b/src/ui/session-list.tsx index 9b3c7fe..51c8f80 100644 --- a/src/ui/session-list.tsx +++ b/src/ui/session-list.tsx @@ -35,7 +35,6 @@ import { listViewportRows, type ModelOption, type MouseEvent, - matchCommands, needsAttention, noAgentInstalled, otherPrs, @@ -464,7 +463,6 @@ export const SessionList: FC<{ }, }, setActionError, - m.command.unknown, ); // 選択中セッションが待っている決定(ツール許可 / 質問)。 // @@ -1002,9 +1000,9 @@ export const SessionList: FC<{ composer.reset(); }); - // 入力がコマンドとして解決されるか(`/` 付き、または `exit` のような完全一致)。 - // null なら通常の指示。Enter と同じ判定を使うのでパレットの内容が実行結果と一致する。 - const commandPreview = commands.preview(buffer.value); + // パレットに出す候補(`/` 付きなら前方一致、裸の `exit` なら実行されるコマンド)。 + // null ならパレットを出さない。空配列は「一致なし」= 指示として送られる予告。 + const paletteCommands = commands.palette(buffer.value); const footerHint = modelSelect ? m.model.help @@ -1299,14 +1297,14 @@ export const SessionList: FC<{ /> ) : ( - {/* パレットの出す条件は Enter の判定と同じ(`toCommandInput`)にする。 - スラッシュ無しの `exit` が無言で終了しないよう、確定前に何が起きるかを見せる。 - **入力欄の計測 Box の外**に置く: 中に入れると実測した上端がパレットの分だけ - ずれ、クリックが別の文字に当たる。 */} - {zone === 'composer' && commandPreview !== null ? ( + {/* パレットは `commands.palette` が出す(`/` 付きは常に、裸の名前は実際に + 実行されるときだけ)。スラッシュ無しの `exit` が無言で終了しないよう、 + 確定前に何が起きるかを見せるのが目的。**入力欄の計測 Box の外**に置く: + 中に入れると実測した上端がパレットの分だけずれ、クリックが別の文字に当たる。 */} + {zone === 'composer' && paletteCommands !== null ? ( ) : null} diff --git a/tests/app.test.tsx b/tests/app.test.tsx index f53649f..55dc6e4 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -1561,6 +1561,32 @@ describe('App detail view (in-app connection)', () => { expect(lastFrame()).toContain('実装してほしいこと'); // list composer placeholder }); + // `/` で始まってもコマンド名に一致しない入力は追加指示として送る(チャットできること)。 + // かつては「不明なコマンド」で止まり、パスや見出しをセッションへ渡す手段が無かった。 + it('sends slash text that matches no command as a follow-up instruction', async () => { + const { manager, out } = drivenManager(); + const send = vi.spyOn(manager, 'send'); + const { stdin, lastFrame } = render(); + stdin.write('slash chat'); + await flush(); + stdin.write('\r'); + await flush(); + out.push(asMsg({ type: 'system', subtype: 'init', session_id: 'sdk-slash' })); + await flush(); + + stdin.write('\t'); // focus the list + await flush(); + stdin.write('\r'); // open detail + await flush(); + + stdin.write('/v3/chats です。'); + await flush(); + stdin.write('\r'); + await flush(); + expect(send).toHaveBeenCalledWith(expect.any(String), '/v3/chats です。'); + expect(lastFrame()).toContain('追加の指示を入力'); // 詳細のまま(一覧へ戻らない) + }); + it('restores list selection and focus after returning from the detail view', async () => { const { manager, out } = drivenManager(); const { stdin, lastFrame } = render(); diff --git a/tests/commands.test.tsx b/tests/commands.test.tsx index 342df34..f8f2e99 100644 --- a/tests/commands.test.tsx +++ b/tests/commands.test.tsx @@ -464,14 +464,19 @@ describe('slash commands', () => { app.unmount(); }); - it('reports an unknown command as an error', async () => { + // スラッシュ始まりでもコマンド名に一致しない入力は**通常の指示**として送る。 + // 「不明なコマンド」で止めていた頃は `/v3/chats です。` のような文を送る手段が + // 一切なかった(チャットできない)。パレットは一致なしを予告するだけ。 + it('sends slash text that matches no command as a normal instruction', async () => { const manager = makeManager(); const { stdin, lastFrame } = render(); - stdin.write('/frobnicate'); + stdin.write('/v3/chats です。'); await flush(); + expect(stripAnsi(lastFrame() ?? '')).toContain(messages.ja.command.paletteEmpty); stdin.write('\r'); await flush(); - expect(lastFrame() ?? '').toContain(messages.ja.command.unknown('frobnicate')); - expect(manager.getSnapshot()).toHaveLength(0); + const sessions = manager.getSnapshot(); + expect(sessions).toHaveLength(1); + expect(sessions[0]?.prompt).toBe('/v3/chats です。'); }); });