diff --git a/README.md b/README.md index f57577d..678972b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ codiva - 回答の途中(何問目か・チェック済みの選択肢・書きかけの自由記述)は `Tab` で往復しても、選択肢をクリックして「自分で入力する」に戻っても保持されます。 - 回答し終えるとフォーカスは入力欄へ戻るので、次の質問が来ても**タイピング中のキーを奪われません**(`Tab` 1 回でまた回答できます)。 +> **日本語 IME をオンにしたままでも `Space` でチェックを切り替えられます。** macOS の日本語入力では、かな入力中の `Space` は**全角スペース**として(`Shift+Space` のときだけ半角スペースとして)アプリに届きます。codiva は全角スペースも `Space` キーとして扱うので、チェックのために IME を切ったり `Shift` を足したりする必要はありません(ツール許可の `y`/`n` のような英字キーは IME が変換に吸ってしまうため、そちらは IME を切ってから押してください)。 + ### 詳細ビューのフォーカス移動(`Tab`)— 回答する前にログを遡る セッション詳細で**質問・許可のダイアログが出ている間**も、`Tab` で **ダイアログ ⇄ 会話ログ** を行き来できます。質問だけを見せられて「何の話か分からないまま答える」ことがないように、背景を読み返してから回答するための切り替えです。 @@ -424,7 +426,7 @@ claude.ai の設定「**Help improve our AI models**」が ON のアカウント | キー | 動作 | |---|---| | `↑` `↓` | 項目を選ぶ(選んだ項目の説明が下に 1 行出ます) | -| `Enter` / `Space` | その項目の ON / OFF を切り替える(`[x]` / `[ ]`) | +| `Enter` / `Space` | その項目の ON / OFF を切り替える(`[x]` / `[ ]`)。日本語 IME をオンにしたままの `Space`(全角スペース)でも切り替わります | | `Esc` | 閉じる | 切り替えは**その場で `~/.codiva/config.json` に保存**されます(キャンセルはありません。既定値に戻した項目はキーごと消えるので、設定ファイルは「既定から変えたものだけ」に保たれます)。ただしこれらの設定は起動時に読まれてセッションや端末の設定に焼き込まれるため、**反映は次回の起動から**です(ダイアログにもその旨が出ます)。 diff --git a/docs/TECH_NOTES.md b/docs/TECH_NOTES.md index 4ade0fd..b904caf 100644 --- a/docs/TECH_NOTES.md +++ b/docs/TECH_NOTES.md @@ -323,6 +323,7 @@ Claude Code のプリセット(全ツール定義)と拡張思考が丸ご - **空文字の `` は高さ 0**(実測 / ink 7): Ink の `measureText()` は `text.length === 0` のとき `{width: 0, height: 0}` を返す(`node_modules/ink/build/measure-text.js`)。そのため `{''}` は**行として一切場所を取らない**(`squashTextNodes` の結果が空なら描画もスキップされる)。ログの空行(Markdown の段落間、コードブロック内の空行)がこれに当たり、スクロール計算(`core/scroll.ts` は空行も 1 物理行として数える)が確保した高さより実際の描画が短くなって、**末尾寄せのビューポート上端に空行の本数ぶんの隙間が残る**(「表示できる行があるのに上が空いている」)。同時に段落の区切りも消えて行が詰まって見える。対策は行の描画側(`ui/session-detail.tsx` の `LogLine`)で空行を半角スペース 1 つに置き換え、必ず 1 行ぶんの高さを確保すること。`PromptInput` は各行を非空のプレフィックス `` と同じ `` に入れているため元から影響を受けない。 - **複数行入力**: 純粋モデルは `core/text-buffer.ts`(value + cursor、insert/backspace/move*/`visibleLineRange`)。キー→操作の対応は `ui/input.ts`(`editText`/`resolveEnter`)。Shift/Meta+Enter か末尾バックスラッシュ+Enter で改行、それ以外は送信(バックスラッシュは Shift+Enter を送れない端末向けの堅牢なフォールバック)。一覧ビューは矢印を行選択に温存するためカーソル移動なし(末尾編集+改行のみ)、詳細ビューは矢印でフルにカーソル移動。`PromptInput` は `INPUT_MAX_ROWS` まで伸び、超過分は `visibleLineRange` でカーソル付近を内部スクロール(空/1行時は従来どおり1行高)。幅を超えた行は**ソフト折り返し**する(`core/composer-layout.ts` の `composerLayout`。空白があれば単語境界、無ければ表示幅で強制改行。CJK は 2 セル)。折り返し幅は Box の computed layout を実測して得る(`useComposerWidth` = 実測幅 − `❯ ` の 2 セル。ダイアログ内は端末幅と一致しないため引き算では求まらない)。以降「行」は表示行の意味になり、`visibleLineRange` / `caretIndexAtClick` / `rowSelection` / ↑↓(`moveRowUp`・`moveRowDown`)はすべて同じ幅の同じ幾何を通す。 - **SGR マウス ?1002 は「セルが変わったときだけ」移動を報告する**(実測): ボタンを押したまま静止していると drag レポートが来ない。そのため「可視域の外へドラッグしたらスクロールを続ける」自動スクロールは、レポート駆動だけでは端で止まってしまう — `setInterval`(`LOG_EDGE_SCROLL_MS` = 50ms で 1 行)で回す必要がある。タイマーは向きが変わったときだけ張り替え、最新のステップ関数は ref 経由で渡す(ログの追記や再描画ごとに張り替えると 1 tick も進まないことがある)。スクロール位置(アンカー)も ref に持つ — 1 tick は「次のアンカー」から選択の終点を組み、「動かなかったか」でタイマーを止める判定もするため、`setState` の関数形(次の描画まで値が見えない)では書けない(値の**同期的な読み**が必要)。ログの範囲選択そのものは行 index + 桁(`core/log-selection.ts` の `LogPoint`)で持つので、スクロールしても選択位置の意味が変わらない。 +- **日本語 IME がオンのあいだ、素の `Space` は全角スペース(U+3000)で届く**(macOS の日本語入力の仕様。かな入力モードで未変換のまま `Space` を押すと全角スペースが確定して入り、半角スペースを入れるには `Shift+Space` を押す必要がある)。端末は IME が確定した文字列をそのまま送ってくるだけなので、アプリ側からは「押されたのは Space キー」だとは分からず、`input === ' '` の比較は**必ず外れる**。トグル系のキー操作(質問ダイアログの複数選択チェック・`/config` の ON/OFF)で `Shift+Space` を強いられていたのはこれが原因で、判定は共通の `isSpaceKey`(`core/key-sequence.ts`。半角と U+3000 の両方を Space とみなす)に寄せてある。**文字として挿入する経路(コンポーザ)はこの正規化を通さない** — 打った全角スペースは全角のまま入るのが正しい。なお `y`/`n` のような英字キーは IME が preedit に吸うため原理的に届かない(回避策は IME を切ること)。 - **`useInput`**: グローバルキーハンドラ。フォーカス管理は `useFocus` もあるが、MVP はビュー単位の単純な状態分岐で足りる。 - **`useApp().exit()`**: 終了。終了前に SessionManager.dispose()(全 abort)を呼ぶ。 - 再描画スロットリング: コアからの onChange を UI 側で ~100ms デバウンス。`useSyncExternalStore` の getSnapshot が返す参照が変わらなければ再描画されない点を利用する。 diff --git a/src/core/key-sequence.spec.ts b/src/core/key-sequence.spec.ts index 5b17bd8..59edf4f 100644 --- a/src/core/key-sequence.spec.ts +++ b/src/core/key-sequence.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { decodeKeySequence } from './key-sequence'; +import { decodeKeySequence, isSpaceKey } from './key-sequence'; const ESC = String.fromCharCode(27); @@ -66,3 +66,18 @@ describe('decodeKeySequence', () => { expect(decodeKeySequence(input)).toBeUndefined(); }); }); + +describe('isSpaceKey', () => { + it.each([ + ['半角スペース', ' ', true], + // 日本語 IME がオンのあいだ、素の Space はこれで届く(実機で再現した不具合)。 + ['全角スペース (U+3000)', ' ', true], + ['空文字', '', false], + ['タブ', '\t', false], + ['複数のスペース(ペースト等)', ' ', false], + ['通常の文字', 'a', false], + ['スペースを含むテキスト', 'a b', false], + ])('%s', (_desc, input, expected) => { + expect(isSpaceKey(input)).toBe(expected); + }); +}); diff --git a/src/core/key-sequence.ts b/src/core/key-sequence.ts index 6acfc7a..61b1214 100644 --- a/src/core/key-sequence.ts +++ b/src/core/key-sequence.ts @@ -73,3 +73,22 @@ export function decodeKeySequence(input: string): DecodedKey | undefined { } return undefined; } + +/** IDEOGRAPHIC SPACE (U+3000) = 全角スペース。 */ +const IDEOGRAPHIC_SPACE = ' '; + +/** + * 「Space が押された」の判定(トグル系のキー操作用)。 + * + * 半角スペースだけを見てはいけない: **日本語 IME がオンのあいだ、素の Space は + * 全角スペース(U+3000)として届く**(macOS のかな入力の既定。未変換のまま Space を + * 押すと 全角スペースが確定して入る)。Shift+Space だけが半角で届くため、 + * `input === ' '` だけを見ていると「IME を切るか Shift を足さないとチェックが + * 付かない」= 日本語で作業している間ずっと押しにくいキーになる。 + * + * 文字として挿入する経路(コンポーザ)はこの正規化を通さない — 打った全角スペースは + * 全角のまま入るのが正しい。ここは**キー操作としての Space** を判定する場所だけで使う。 + */ +export function isSpaceKey(input: string): boolean { + return input === ' ' || input === IDEOGRAPHIC_SPACE; +} diff --git a/src/ui/config-select.tsx b/src/ui/config-select.tsx index fa979cc..bb52522 100644 --- a/src/ui/config-select.tsx +++ b/src/ui/config-select.tsx @@ -4,10 +4,12 @@ import { type ConfigToggleId, type ConfigToggleRow, dialogContentWidth, + isSpaceKey, parseSgrMouse, } from '@/core'; import { ChoiceRow } from './choice-row'; import { useMessages } from './i18n-context'; +import { normalizeChord } from './input'; import { glyph, theme } from './theme'; /** @@ -32,11 +34,14 @@ export const ConfigSelect: FC<{ const [cursor, setCursor] = useState(0); const active = Math.min(cursor, Math.max(0, rows.length - 1)); - useInput((rawInput, key) => { + useInput((rawInput, rawKey) => { // モーダルは自分の useInput を持つので、マウスレポートは先頭で握り潰す。 if (parseSgrMouse(rawInput)) { return; } + // 他の画面と同じく chord を復号する(modifyOtherKeys / CSI-u を送る端末では + // Enter や Space が生のエスケープ列で届き、素の比較が外れる)。 + const { input, key } = normalizeChord(rawInput, rawKey); if (key.escape) { onClose(); return; @@ -53,8 +58,9 @@ export const ConfigSelect: FC<{ return; } // Enter と Space のどちらでも切り替えられるようにする(チェックリストの慣習が - // 端末によって違うため)。Space は印字キーなので rawInput で見る。 - if (key.return || rawInput === ' ') { + // 端末によって違うため)。Space は印字キーなので `input` を見る(日本語 IME が + // オンだと全角スペースで届くため判定は `isSpaceKey`)。 + if (key.return || isSpaceKey(input)) { const row = rows[active]; if (row) { onToggle(row.id); diff --git a/src/ui/permission-dialog.spec.tsx b/src/ui/permission-dialog.spec.tsx index 9c03481..e334ace 100644 --- a/src/ui/permission-dialog.spec.tsx +++ b/src/ui/permission-dialog.spec.tsx @@ -395,6 +395,19 @@ describe('PermissionDialog — question', () => { expect(lastFrame()).toContain('❯ [x] English'); }); + // 日本語 IME がオンのあいだ、素の Space は**全角スペース(U+3000)**として届く + // (macOS のかな入力の既定)。半角だけを見ていると Shift+Space でしかチェックが + // 付けられない = 日本語で作業している間ずっと押しにくい(実機で再現した不具合)。 + it('全角スペース(IME オンの Space)でもトグルできる', async () => { + const { stdin, lastFrame } = render( + , + ); + await flush(); + stdin.write(' '); // U+3000 → toggle English + await flush(); + expect(lastFrame()).toContain('❯ [x] English'); + }); + /** * `active={false}`(一覧の list ゾーン)では表示だけ。ここでキーを取ると、一覧の * ↑↓ が選択肢移動に食われて**セッションを切り替えられない**(元の不具合)。 diff --git a/src/ui/permission-dialog.tsx b/src/ui/permission-dialog.tsx index 516b925..183ba3c 100644 --- a/src/ui/permission-dialog.tsx +++ b/src/ui/permission-dialog.tsx @@ -9,6 +9,7 @@ import { choiceView, DIALOG_CHOICE_MIN_ROWS, dialogContentWidth, + isSpaceKey, type PermissionRequest, parseSgrMouse, wrapDisplayLines, @@ -447,7 +448,9 @@ const QuestionDialog: FC<{ return; } // Space は複数選択の実選択肢に対してのみトグル(特別項目には効かない)。 - if (input === ' ' && current.multiSelect && cursor < optionCount) { + // 判定は `isSpaceKey`: 日本語 IME がオンだと素の Space は全角スペース(U+3000)で + // 届くため、半角だけを見ると Shift+Space でしかチェックが付けられない。 + if (isSpaceKey(input) && current.multiSelect && cursor < optionCount) { const label = current.options[cursor]?.label; if (label) { setMulti((prev) => { diff --git a/tests/commands.test.tsx b/tests/commands.test.tsx index af567cf..342df34 100644 --- a/tests/commands.test.tsx +++ b/tests/commands.test.tsx @@ -404,6 +404,15 @@ describe('slash commands', () => { stdin.write(' '); // Space でも切り替えられる(既定へ戻る = キー削除) await flush(); expect(patches).toEqual([{ notifications: false }, { notifications: undefined }]); + + // 日本語 IME がオンのあいだ素の Space は全角(U+3000)で届くので、それでも効く。 + stdin.write(' '); + await flush(); + expect(patches).toEqual([ + { notifications: false }, + { notifications: undefined }, + { notifications: false }, + ]); }); it('/config closes on Esc and stops owning the keys', async () => {