From 0065e58fefc883211d6980508b4a686a0ac29db1 Mon Sep 17 00:00:00 2001 From: takecchi Date: Fri, 31 Jul 2026 09:38:48 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=A5=E5=8A=9B=E6=AC=84=E3=81=8C?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E5=B9=85=E3=82=92=E8=B6=85=E3=81=88=E3=81=9F?= =?UTF-8?q?=E3=82=89=E6=8A=98=E3=82=8A=E8=BF=94=E3=81=97=E3=81=A6=E5=85=A8?= =?UTF-8?q?=E6=96=87=E3=81=8C=E8=A6=8B=E3=81=88=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改行せずに打ち続けると画面端で `…` に切り捨てられ、テキストもキャレットも 見えなくなっていた(`PromptInput` が論理行を1表示行に `wrap="truncate-end"` で描いていたため)。ソフト折り返しに変更する。 - `core/composer-layout.ts`(純粋)を新設: `wrapComposerRows`(空白があれば 単語境界・無ければ表示幅で強制改行。CJK は 2 セル)/ `composerLayout` (表示行 + キャレットの行・列)/ `composerRowCount` / `rowSelection` / `moveRowUp`・`moveRowDown`(表示行での↑↓)。`caretIndexAtClick` は `text-buffer.ts` から移設して折り返し対応 - 折り返し境界のキャレットは後続行の先頭に置き、行が幅ぴったりで後続が無い ときは端末のカーソル折り返しと同じく空の表示行を1行開ける - 折り返し幅は Box の computed layout を実測(`useBoxWidth` / `useComposerWidth`)。端末幅からの引き算ではダイアログ内(枠+padding)で 合わない。`PromptInput` の Box は `width: 100%` に固定して、幅が中身の幅に 依存して狭まる自己参照を防ぐ - 描画・クリック逆算・↑↓・詳細ビューの「複数行編集中か」判定を同じ実測幅で 通す(食い違うとクリックが別の文字に当たる) --- .claude/rules/ink-components.md | 11 ++ CLAUDE.md | 2 +- README.md | 4 + docs/ARCHITECTURE.md | 4 +- docs/TASKS.md | 29 ++++ docs/TECH_NOTES.md | 2 +- src/core/composer-layout.spec.ts | 259 +++++++++++++++++++++++++++++++ src/core/composer-layout.ts | 244 +++++++++++++++++++++++++++++ src/core/index.ts | 1 + src/core/text-buffer.spec.ts | 24 --- src/core/text-buffer.ts | 30 +--- src/ui/hooks.ts | 32 ++++ src/ui/input.ts | 18 ++- src/ui/prompt-input.spec.tsx | 63 +++++++- src/ui/prompt-input.tsx | 53 ++++--- src/ui/repo-prompt-editor.tsx | 19 ++- src/ui/session-detail.tsx | 27 +++- src/ui/session-list.tsx | 22 ++- 18 files changed, 751 insertions(+), 93 deletions(-) create mode 100644 src/core/composer-layout.spec.ts create mode 100644 src/core/composer-layout.ts diff --git a/.claude/rules/ink-components.md b/.claude/rules/ink-components.md index 84eb745..8136e74 100644 --- a/.claude/rules/ink-components.md +++ b/.claude/rules/ink-components.md @@ -109,6 +109,17 @@ 対応だけ `ui/input.ts`(`editText`/`resolveEnter`)に置く。Shift/Meta+Enter か末尾バックスラッシュ+Enter で 改行、他は送信。`PromptInput` は `INPUT_MAX_ROWS` まで縦に伸び、超過は `visibleLineRange` で カーソル付近を内部スクロール。 +- **入力欄は幅を超えたら折り返す(truncate しない)**。`wrap="truncate-end"` だけだと画面端まで打った + 時点でテキストとキャレットが `…` の裏に消え、何を打っているか読めない。折り返しの幾何は純粋な + `core/composer-layout.ts`(`composerLayout` / `wrapComposerRows`)に集約し、**描画・マウス当たり判定・ + ↑↓ のキャレット移動は必ず同じ幅で同じ関数を通す**(食い違うとクリックが別の文字に当たる)。 + - 幅は**実測**する(`useComposerWidth`)。端末幅から引き算するとダイアログ内(枠+padding)で合わない。 + 未実測の 1 フレームだけ折り返さない(= 従来の truncate)挙動に倒す。 + - 「行」は論理行ではなく**表示行**になる。`visibleLineRange` に渡す行数、クリックの + `caretIndexAtClick`、選択ハイライトの `rowSelection`、詳細ビューの「複数行編集中か」判定 + (`composerRowCount`)はすべて表示行で数える。 + - ↑↓ は表示行で移動する(`editText` の `wrapWidth` → `moveRowUp`/`moveRowDown`)。論理行で動かすと + 長い 1 行の途中から一気に行頭へ飛び、見えている行と操作が食い違う。 ## セッション詳細(codiva 内蔵ビュー) diff --git a/CLAUDE.md b/CLAUDE.md index 9645322..d752b35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 | PR 自動化 | `core/pr-coordinator.ts` / `utils/pr.ts`(`gh` はここだけ) | | 一覧画面 | `ui/session-list.tsx`(composer / list の2フォーカス) | | 詳細画面 | `ui/session-detail.tsx`(ログ + 追加指示 + 操作パネル) | -| 入力欄・キー処理 | `core/text-buffer.ts`(純粋モデル)/ `ui/input.ts`(キー→操作)/ `ui/prompt-input.tsx` | +| 入力欄・キー処理 | `core/text-buffer.ts`(純粋モデル)/ `core/composer-layout.ts`(折り返し・表示行の幾何)/ `ui/input.ts`(キー→操作)/ `ui/prompt-input.tsx` | | ログ描画・スクロール | `core/scroll.ts` / `core/markdown.ts` / `core/ansi.ts` | | マウス・範囲選択 | `core/mouse.ts` / `core/list-hit.ts` / `core/text-selection.ts` / `utils/mouse.ts` / `utils/clipboard.ts` | | 文言・言語 | `core/i18n.ts`(カタログ)/ `ui/i18n-context.tsx`(`useMessages`) | diff --git a/README.md b/README.md index 9cada96..2bd4e98 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ codiva > worktree ディレクトリ `.codiva/` は対象リポジトリの `.git/info/exclude` に自動追記されるため、対象リポジトリの `.gitignore` を汚しません。 +### 入力欄 + +長い指示は画面幅で**自動的に折り返され**、打った文字が切り捨てられずに全部見えます(空白があれば単語の途中で切りません)。`Shift+Enter`(または末尾に `\` を置いて `Enter`)で明示的な改行、`Enter` で投入。入力が縦に伸びるのは 8 行までで、それを超えるとカーソル位置に合わせて内部スクロールします。`↑` / `↓` は**見えている行**(折り返し後の行)でキャレットを移動します。 + ### テキストのコピー 入力欄とヘッダ(ワードマーク / プラン / モデル / cwd)は、**ドラッグで範囲選択して離すとクリップボードへコピー**されます(OSC 52 なので SSH 越しでも動きます)。ヘッダの cwd 行をドラッグすれば、いま作業しているパスをそのまま貼り付けられます。ヘッダのドラッグは入力中のフォーカスや一覧の選択行を動かしません。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3ea130..3799de9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,7 +65,7 @@ codiva/ │ │ ├── format.ts / math.ts / ansi.ts / errors.ts # 小さな純粋ヘルパ(formatDuration/clamp/…) │ │ ├── privacy.ts # 学習データ利用(grove)の判定(JSON→TrainingOptIn・純粋) │ │ ├── async-queue.ts / slug.ts / config.ts / cost.ts / notify.ts / persistence.ts / update.ts -│ │ ├── scroll.ts / text-buffer.ts / layout.ts / mouse.ts / key-sequence.ts / model.ts / models.ts / transcript.ts +│ │ ├── scroll.ts / text-buffer.ts / composer-layout.ts / layout.ts / mouse.ts / key-sequence.ts / model.ts / models.ts / transcript.ts │ │ ├── *.spec.ts # 単体テストは実装の隣に co-located │ │ └── __fixtures__/ # サニタイズ済み実 SDK メッセージ(sdk-parse テスト用) │ ├── ui/ # Ink コンポーネント(kebab-case, 識別子は PascalCase) @@ -436,7 +436,7 @@ Claude Code の実画面に寄せる: 画面は**端末の縦幅いっぱい** 下部の追加指示コンポーザ(`manager.send(id, text)`)を持つ。Tab で入力↔操作パネルを切替し、 操作パネルで m/d = マージ/破棄。`pendingPermission` があれば `PermissionDialog` に委譲。単一 `useInput` の state machine(panel = input | actions)でタイピングとキー操作の衝突を防ぐ。 -- `PromptInput` / `StatusFooter`: presentational。キー処理は view の単一 `useInput` に集約(ロジックは持たない)。`PromptInput` は複数行対応(純粋モデルは `core/text-buffer.ts`、キー対応は `ui/input.ts` の `editText`/`resolveEnter`)。IME 対応で実端末カーソルをキャレットに重ねる(`useCursor`)。 +- `PromptInput` / `StatusFooter`: presentational。キー処理は view の単一 `useInput` に集約(ロジックは持たない)。`PromptInput` は複数行対応(純粋モデルは `core/text-buffer.ts`、キー対応は `ui/input.ts` の `editText`/`resolveEnter`)。幅を超えたテキストは**折り返す**(truncate しない): 折り返し後の表示行・キャレット位置・クリック逆算・選択範囲はすべて純粋な `core/composer-layout.ts`(`composerLayout`)が算出し、折り返し幅は Box の実測値(`useComposerWidth`)を描画・当たり判定・↑↓ 移動で共有する。IME 対応で実端末カーソルをキャレットに重ねる(`useCursor`)。 - 再描画スロットリング: SessionManager の通知を UI 側で ~100ms にスロットルする。 **ランモード(shift+tab トグル)**: `SessionManager.mode`(`auto` | `confirm`)を全セッション共通で保持し、`shift+tab` で `cycleMode()`。`modePolicy` は tool 実行時に `mode` を読むので、切替は稼働中セッションにも即反映される。`auto` = AskUserQuestion 以外を自動承認、`confirm` = 毎回 allow/deny を求める(→ `awaiting_permission`/一覧に「許可待ち」)。UI は `useRunMode()` で購読し、`StatusFooter` が `⏵⏵ auto mode on` / `⏸ confirm mode on` を表示。 diff --git a/docs/TASKS.md b/docs/TASKS.md index c3930cd..a156160 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -490,6 +490,35 @@ UI なし。すべてユニットテストで駆動する。 > codiva 側でリンクを張り替える案は採らなかった(何が書き込み対象かは指示内容次第で、 > 先回りして全部コピーすると symlink モードの利点が消える)。 +## Phase 15: 入力欄のソフト折り返し ✅ + +**課題**: 改行せずに打ち続けると画面幅で `…` に切り捨てられ、いま何を打っているのか読めない +(`PromptInput` が論理行を 1 表示行に `wrap="truncate-end"` で描いていたため、テキストもキャレットも +画面外へ消えていた)。 + +- [x] 純粋な `core/composer-layout.ts` を新設: `wrapComposerRows`(表示幅で折り返し。空白があれば + 単語境界、無ければ強制改行。CJK は string-width で 2 セル)/ `composerLayout`(行 + キャレットの + 表示行・列)/ `composerRowCount` / `caretIndexAtClick`(`text-buffer.ts` から移設して折り返し対応)/ + `rowSelection` / `moveRowUp`・`moveRowDown`(表示行での↑↓) +- [x] 折り返し境界のキャレットは**後続行の先頭**に置く(次の文字が現れる位置)。行が幅ぴったりで + 後続が無いときは、端末のカーソル折り返しと同じく**空の表示行を1行開ける**(列を幅の外に描かない) +- [x] 折り返し幅は Box の computed layout を**実測**(`useBoxWidth` / `useComposerWidth` = + 実測幅 − `COMPOSER_PREFIX_CELLS`)。端末幅からの引き算ではダイアログ内(枠 + padding)で合わない。 + 未実測の 1 フレームだけ折り返さない(従来の truncate)挙動にフォールバック +- [x] `PromptInput` は表示行を描画(`visibleLineRange` に渡す行数も表示行)。一覧・詳細のクリック逆算 + (`caretIndexAtClick`)、↑↓(`editText` の `wrapWidth`)、詳細の「複数行編集中か」判定 + (`composerRowCount`)を**同じ実測幅**で通す(食い違うと別の文字に当たる) +- [x] `/prompt` エディタ(`RepoPromptEditor`)も同じ幅を測って↑↓を表示行に合わせる +- [x] テスト: `core/composer-layout.spec.ts`(折り返し・キャレット・クリック・選択・↑↓ をテーブル + ドリブン)/ `ui/prompt-input.spec.tsx`(折り返しで全文字が描かれる・単語境界・折り返し行への + カーソル追従) +- [x] ドキュメント: `.claude/rules/ink-components.md` / `docs/ARCHITECTURE.md` / `docs/TECH_NOTES.md` / + `README.md`(入力欄の節)/ `CLAUDE.md`(コードの地図) + +> 実績メモ: 全 1580 テスト緑・lint / typecheck / build 緑。折り返しを「横スクロール」で済ませる案は +> 採らなかった(行頭側が見えなくなり、長い指示を投入前に読み返せない)。`wrap="truncate-end"` は +> 実測前の 1 フレーム用の保険として残している。 + --- ## 各 Phase 共通の完了チェック diff --git a/docs/TECH_NOTES.md b/docs/TECH_NOTES.md index 824c9c8..609b51e 100644 --- a/docs/TECH_NOTES.md +++ b/docs/TECH_NOTES.md @@ -276,7 +276,7 @@ function toUserMessage(text: string): SDKUserMessage { - **`` は全画面レイアウトと両立しない**: Static はスクロールバック側に書き出すため、フレームが画面いっぱいだとビューポート外に消える。メッセージログは末尾ビューポート(flexGrow + `justifyContent="flex-end"` + `overflowY="hidden"`)+ `logWindow(lines, rows, anchor)`(`core/scroll.ts`)で再描画コストに上限を掛ける方式にした。スクロールの単位は**物理行**: エントリは `logLines(messages, width, prefixFor)` で CJK 幅(string-width)を考慮して折り返した `DisplayLine[]` に展開してから window する(複数行メッセージ 1 件でビューポートが埋まったり、スクロール量が実際の行数とズレるのを防ぐ)。`anchor` は `'bottom'`(末尾追従)か絶対 end index(上スクロール中は固定=新着で view がぶれない)。PgUp/PgDn(半画面)と ↑/↓(1行)で `scrollUp`/`scrollDown`。alt screen でスクロールバックを無効化しているため、過去ログはこのアプリ内スクロールでのみ辿れる。 - **Yoga は溢れた子を「クリップ」せず「縮小」する**: `overflowY="hidden"` + `justifyContent="flex-end"` の箱に可視高さより多くの行を入れると、上端でクリップされるのではなく子が縮小され、**行が虫食いで欠落する**(`L1, L3, L5…` のように 1 行おきに消える)。詳細ログが「上へスクロールできない/読めない」原因はこれだった。対策は (1) 行の入れ物に `flexShrink={0}` を付けて縮小を禁じる(溢れは flex-end で上端クリップになる)、(2) `logWindow` に渡す行数を `useBoxHeight` で**実測した可視高さ**に合わせる(見積り `logViewportRows` は初回描画までのフォールバック。見積りは必ず実測以下に倒す)。加えてアンカーは 1 画面ぶんで下限を打ち、最上部でも 1 ページ分が埋まるようにする。 - **空文字の `` は高さ 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/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`)はすべて同じ幅の同じ幾何を通す。 - **`useInput`**: グローバルキーハンドラ。フォーカス管理は `useFocus` もあるが、MVP はビュー単位の単純な状態分岐で足りる。 - **`useApp().exit()`**: 終了。終了前に SessionManager.dispose()(全 abort)を呼ぶ。 - 再描画スロットリング: コアからの onChange を UI 側で ~100ms デバウンス。`useSyncExternalStore` の getSnapshot が返す参照が変わらなければ再描画されない点を利用する。 diff --git a/src/core/composer-layout.spec.ts b/src/core/composer-layout.spec.ts new file mode 100644 index 0000000..02b3f1f --- /dev/null +++ b/src/core/composer-layout.spec.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; +import { + caretIndexAtClick, + composerLayout, + composerRowCount, + moveRowDown, + moveRowUp, + rowSelection, + wrapComposerRows, +} from './composer-layout'; +import { bufferOf } from './text-buffer'; + +/** Compact view of a wrap result: the row texts plus their continuation flags. */ +const shape = (value: string, width?: number) => + wrapComposerRows(value, width).map((r) => (r.continuation ? `+${r.text}` : r.text)); + +describe('wrapComposerRows', () => { + const cases: { name: string; value: string; width?: number; rows: string[] }[] = [ + { name: 'empty value is one empty row', value: '', width: 10, rows: [''] }, + { name: 'short text stays one row', value: 'hello', width: 10, rows: ['hello'] }, + { + name: 'no width means no wrapping (one row per logical line)', + value: 'a'.repeat(30), + rows: ['a'.repeat(30)], + }, + { + name: 'non-finite width means no wrapping', + value: 'a'.repeat(30), + width: Number.NaN, + rows: ['a'.repeat(30)], + }, + { + name: 'a long word is hard-broken at the width', + value: 'abcdefghij', + width: 4, + rows: ['abcd', '+efgh', '+ij'], + }, + { + name: 'breaks at the last space instead of mid-word', + value: 'hello world', + width: 8, + rows: ['hello ', '+world'], + }, + { + name: 'a space exactly at the edge is not moved down', + value: 'ab cd', + width: 3, + rows: ['ab ', '+cd'], + }, + { + name: 'CJK counts as two cells per char', + value: 'あいうえお', + width: 4, + rows: ['あい', '+うえ', '+お'], + }, + { + name: 'a char wider than the width still gets its own row', + value: 'あい', + width: 1, + rows: ['あ', '+い'], + }, + { + name: 'each logical line wraps independently', + value: 'abcd\nef', + width: 2, + rows: ['ab', '+cd', 'ef'], + }, + { + name: 'blank logical lines survive as empty rows', + value: 'ab\n\ncd', + width: 4, + rows: ['ab', '', 'cd'], + }, + { + name: 'a line that exactly fills the width does not add a blank row', + value: 'abcd\nef', + width: 4, + rows: ['abcd', 'ef'], + }, + ]; + it.each(cases)('$name', ({ value, width, rows }) => { + expect(shape(value, width)).toEqual(rows); + }); + + it('rows partition the value exactly (indices map back to the text)', () => { + const value = 'hello world\nこんにちは everyone'; + for (const row of wrapComposerRows(value, 6)) { + expect(value.slice(row.start, row.end)).toBe(row.text); + } + }); + + it('counts display rows', () => { + expect(composerRowCount('abcdefghij', 4)).toBe(3); + expect(composerRowCount('abcdefghij')).toBe(1); + }); +}); + +describe('composerLayout caret placement', () => { + const cases: { + name: string; + value: string; + cursor: number; + width?: number; + row: number; + col: number; + }[] = [ + { name: 'start of an empty buffer', value: '', cursor: 0, width: 4, row: 0, col: 0 }, + { name: 'within the first row', value: 'abcdef', cursor: 2, width: 4, row: 0, col: 2 }, + { + name: 'a wrap boundary belongs to the continuing row', + value: 'abcdef', + cursor: 4, + width: 4, + row: 1, + col: 0, + }, + { name: 'inside a continuation row', value: 'abcdef', cursor: 5, width: 4, row: 1, col: 1 }, + { + name: 'end of a newline-terminated line stays on that row', + value: 'ab\ncd', + cursor: 2, + width: 4, + row: 0, + col: 2, + }, + { + name: 'after the newline is the next row', + value: 'ab\ncd', + cursor: 3, + width: 4, + row: 1, + col: 0, + }, + { + name: 'without a width the caret stays on its logical line', + value: 'abcdef', + cursor: 5, + row: 0, + col: 5, + }, + ]; + it.each(cases)('$name', ({ value, cursor, width, row, col }) => { + expect(composerLayout(bufferOf(value, cursor), width).caret).toEqual({ row, col }); + }); + + it('opens a fresh row when the caret is past a completely full row', () => { + // 'abcd' fills width 4 and nothing continues it → the caret wraps to a new row + // (drawing it at column 4 would be one cell outside the input). + const layout = composerLayout(bufferOf('abcd'), 4); + expect(layout.rows.map((r) => r.text)).toEqual(['abcd', '']); + expect(layout.caret).toEqual({ row: 1, col: 0 }); + }); + + it('does not open a fresh row when the full row is followed by more text', () => { + const layout = composerLayout(bufferOf('abcdef', 4), 4); + expect(layout.rows.map((r) => r.text)).toEqual(['abcd', 'ef']); + expect(layout.caret).toEqual({ row: 1, col: 0 }); + }); + + it('clamps an out-of-range cursor', () => { + expect(composerLayout({ value: 'ab', cursor: 99 }, 4).caret).toEqual({ row: 0, col: 2 }); + }); +}); + +describe('caretIndexAtClick', () => { + it('maps a click on a single-line buffer to the caret index', () => { + expect(caretIndexAtClick(bufferOf('hello', 0), 0, 2, 8)).toBe(2); + }); + + it('returns undefined for a click above the visible content', () => { + expect(caretIndexAtClick(bufferOf('hi'), -1, 0, 8)).toBeUndefined(); + }); + + it('returns undefined for a click below the visible content', () => { + expect(caretIndexAtClick(bufferOf('hi'), 2, 0, 8)).toBeUndefined(); + }); + + it('resolves a click on a later line to that line index', () => { + const buf = bufferOf('ab\ncd\nef'); // 3 lines, caret at end (row 2) + expect(caretIndexAtClick(buf, 1, 1, 8)).toBe(4); // 'ab\n' = 3, + col 1 + }); + + it('resolves a click on a wrapped continuation row', () => { + const buf = bufferOf('abcdefghij', 0); + // width 4 → rows 'abcd' / 'efgh' / 'ij'; row 1, column 2 → 'g' at index 6 + expect(caretIndexAtClick(buf, 1, 2, 8, 4)).toBe(6); + }); + + it('clamps a click past the end of a wrapped row to that row', () => { + const buf = bufferOf('abcdefghij', 0); + expect(caretIndexAtClick(buf, 1, 99, 8, 4)).toBe(8); // end of 'efgh' + }); + + it('accounts for the wrap when scrolling internally (maxRows)', () => { + const buf = bufferOf('abcdefghij'); // width 4 → rows 'abcd' / 'efgh' / 'ij', caret on the last + // maxRows 2 shows only the last two rows, so content row 0 is 'efgh' → 'f' at index 5. + expect(caretIndexAtClick(buf, 0, 1, 2, 4)).toBe(5); + }); +}); + +describe('rowSelection', () => { + const row = { text: 'cdef', start: 2, end: 6, continuation: true } as const; + const cases: { + name: string; + range: [number, number]; + expected?: { from: number; to: number }; + }[] = [ + { name: 'fully inside the row', range: [3, 5], expected: { from: 1, to: 3 } }, + { name: 'clipped to the row start', range: [0, 4], expected: { from: 0, to: 2 } }, + { name: 'clipped to the row end', range: [4, 99], expected: { from: 2, to: 4 } }, + { name: 'spanning the whole row', range: [0, 99], expected: { from: 0, to: 4 } }, + { name: 'entirely before the row', range: [0, 2], expected: undefined }, + { name: 'entirely after the row', range: [6, 8], expected: undefined }, + ]; + it.each(cases)('$name', ({ range, expected }) => { + expect(rowSelection({ start: range[0], end: range[1] }, row)).toEqual(expected); + }); +}); + +describe('vertical caret movement by display row', () => { + it('moves up within a wrapped line instead of jumping to the start', () => { + // width 4 → 'abcd' / 'efgh' / 'ij'; caret at index 9 (row 2, col 1) + expect(moveRowUp(bufferOf('abcdefghij', 9), 4).cursor).toBe(5); // row 1, col 1 + }); + + it('moves down within a wrapped line', () => { + expect(moveRowDown(bufferOf('abcdefghij', 1), 4).cursor).toBe(5); + }); + + it('keeps the display column across a CJK row', () => { + // width 4 → 'あい' / 'うえ' / 'お'; caret after 'う' is 2 cells in + expect(moveRowUp(bufferOf('あいうえお', 3), 4).cursor).toBe(1); // after 'あ' + }); + + it('goes to the buffer start above the first row', () => { + expect(moveRowUp(bufferOf('abcd', 2), 10).cursor).toBe(0); + }); + + it('goes to the buffer end below the last row', () => { + expect(moveRowDown(bufferOf('abcd', 2), 10).cursor).toBe(4); + }); + + it('clamps to the end of a shorter target row', () => { + // 'abcdefghij' width 4 → last row 'ij'; from row 1 col 3 → end of 'ij' + expect(moveRowDown(bufferOf('abcdefghij', 7), 4).cursor).toBe(10); + }); + + it('returns the same reference when nothing moves', () => { + const start = bufferOf('abcd', 0); + expect(moveRowUp(start, 10)).toBe(start); + const end = bufferOf('abcd', 4); + expect(moveRowDown(end, 10)).toBe(end); + }); + + it('falls back to logical lines without a width', () => { + const buf = bufferOf('ab\ncdef', 6); // row 1, col 3 + expect(moveRowUp(buf).cursor).toBe(2); // end of 'ab' + }); +}); diff --git a/src/core/composer-layout.ts b/src/core/composer-layout.ts new file mode 100644 index 0000000..1699183 --- /dev/null +++ b/src/core/composer-layout.ts @@ -0,0 +1,244 @@ +import stringWidth from 'string-width'; +import { clamp } from './math'; +import { bufferLines, caretIndexForColumn, type TextBuffer, visibleLineRange } from './text-buffer'; +import type { SelectionRange } from './text-selection'; + +/** + * Display geometry of the composer: how a buffer's text is laid out as *display + * rows* once it is soft-wrapped at the input's width. + * + * The composer used to draw one row per logical line and truncate the overflow, so + * typing past the right edge hid the text (and the caret) behind a `…`. Wrapping is + * the fix, but it means "row" is no longer "line": every caret/click/selection + * calculation has to go through the same wrap. Keeping that in one pure module lets + * the renderer (`ui/prompt-input.tsx`), mouse hit-testing and vertical caret + * movement share exactly one geometry — if they disagreed, clicks would land on the + * wrong character. + * + * Rows partition the buffer exactly: concatenating every row's `text` with a '\n' + * between logical lines reproduces `value`, so every position is an index into + * `value` (the same unit as `TextBuffer.cursor` and `SelectionRange`). + */ +export interface ComposerRow { + /** The row's text (a slice of the buffer value; never contains '\n'). */ + readonly text: string; + /** Index in the buffer value where this row's text starts. */ + readonly start: number; + /** Index in the buffer value just past this row's text (exclusive). */ + readonly end: number; + /** True when this row continues a soft-wrapped logical line. */ + readonly continuation: boolean; +} + +/** + * Display width (cells) of the `❯ ` / ` ` prefix `PromptInput` draws before every + * composer row. Callers that map a mouse x to a text column subtract it, and the + * wrap width is the box width minus it. + */ +export const COMPOSER_PREFIX_CELLS = 2; + +/** + * An unusable width (undefined before the box is measured, non-finite, or < 1) + * means "don't wrap" — one row per logical line, exactly the pre-wrap behavior. + */ +function normalizeWidth(width?: number): number | undefined { + return width === undefined || !Number.isFinite(width) + ? undefined + : Math.max(1, Math.floor(width)); +} + +/** The whole code point at `i` (astral chars are surrogate pairs, never split). */ +function charAt(text: string, i: number): string { + const cp = text.codePointAt(i); + return cp === undefined ? '' : String.fromCodePoint(cp); +} + +/** + * Break one logical line into half-open `[from, to)` index pairs, each fitting in + * `cap` display cells. Greedy, and prefers the last space over cutting mid-word; + * the space stays at the end of its row so the segments still partition the line + * (all caret math is index-based). A single char wider than `cap` still gets its own + * row — progress is guaranteed so this can't loop forever. + */ +function wrapLine(line: string, cap: number): { from: number; to: number }[] { + const segments: { from: number; to: number }[] = []; + let from = 0; + for (;;) { + let cells = 0; + let i = from; + let lastSpace = -1; // index just past the last space that fit on this row + while (i < line.length) { + const ch = charAt(line, i); + const w = stringWidth(ch); + if (cells + w > cap) { + break; + } + cells += w; + i += ch.length; + if (ch === ' ') { + lastSpace = i; + } + } + if (i >= line.length) { + segments.push({ from, to: line.length }); + return segments; + } + let to = lastSpace > from && charAt(line, i) !== ' ' ? lastSpace : i; + if (to <= from) { + to = from + charAt(line, from).length; + } + segments.push({ from, to }); + from = to; + if (from >= line.length) { + return segments; + } + } +} + +/** + * Soft-wrap a buffer value into display rows at `width` cells. Omit `width` (or + * pass a non-finite one) to keep one row per logical line. Always returns ≥ 1 row + * (an empty value is one empty row). + */ +export function wrapComposerRows(value: string, width?: number): ComposerRow[] { + const cap = normalizeWidth(width); + const rows: ComposerRow[] = []; + let offset = 0; + for (const line of bufferLines(value)) { + if (cap === undefined) { + rows.push({ text: line, start: offset, end: offset + line.length, continuation: false }); + } else { + for (const seg of wrapLine(line, cap)) { + rows.push({ + text: line.slice(seg.from, seg.to), + start: offset + seg.from, + end: offset + seg.to, + continuation: seg.from > 0, + }); + } + } + offset += line.length + 1; // the '\n' that ended this line + } + return rows; +} + +/** How many display rows a buffer value occupies at `width` cells. */ +export function composerRowCount(value: string, width?: number): number { + return wrapComposerRows(value, width).length; +} + +export interface ComposerLayout { + readonly rows: readonly ComposerRow[]; + /** The caret's display row and its char offset within that row's `text`. */ + readonly caret: { readonly row: number; readonly col: number }; +} + +/** + * Wrap the buffer and locate the caret in the resulting rows. + * + * At a wrap boundary the caret index belongs to two rows (the end of one, the start + * of the next); it is placed on the *later* one, which is where the next character + * will actually appear. When a row is completely full and nothing continues it (end + * of the buffer, or of a logical line), a synthetic empty row is opened for the + * caret — the way a terminal wraps its cursor — instead of drawing it one cell + * outside the visible width. + */ +export function composerLayout(buffer: TextBuffer, width?: number): ComposerLayout { + const cap = normalizeWidth(width); + const rows = wrapComposerRows(buffer.value, width); + const cursor = clamp(buffer.cursor, 0, buffer.value.length); + let row = 0; + for (let i = 0; i < rows.length; i += 1) { + const r = rows[i]; + if (r && cursor >= r.start && cursor <= r.end) { + row = i; // last match wins — see the boundary rule above + } + } + const current = rows[row]; + if ( + cap !== undefined && + current && + cursor === current.end && + stringWidth(current.text) >= cap && + rows[row + 1]?.continuation !== true + ) { + rows.splice(row + 1, 0, { text: '', start: cursor, end: cursor, continuation: true }); + return { rows, caret: { row: row + 1, col: 0 } }; + } + return { rows, caret: { row, col: cursor - (current?.start ?? 0) } }; +} + +/** + * Caret index for a mouse click inside the (internally-scrolled) composer. + * `contentRow` is the click's 0-based display row within the visible window (i.e. + * `y - contentTop`) and `cells` its display column within that row (`x` minus the + * left edge and {@link COMPOSER_PREFIX_CELLS}). Returns undefined when the click + * lands outside the visible rows. Pure inverse of the composer's geometry — the UI + * supplies only the pixel→cell offsets and the wrap width it rendered with. + */ +export function caretIndexAtClick( + buffer: TextBuffer, + contentRow: number, + cells: number, + maxRows: number, + width?: number, +): number | undefined { + const { rows, caret } = composerLayout(buffer, width); + const { start, end } = visibleLineRange(rows.length, caret.row, maxRows); + const index = start + contentRow; + if (contentRow < 0 || index >= end) { + return undefined; + } + const row = rows[index]; + return row ? row.start + caretIndexForColumn(row.text, cells) : undefined; +} + +/** + * The char offsets `[from, to)` within one display row that fall inside `range`, + * for painting a per-row highlight. Undefined when the row has nothing selected. + */ +export function rowSelection( + range: SelectionRange, + row: ComposerRow, +): { from: number; to: number } | undefined { + const len = row.text.length; + const from = clamp(range.start - row.start, 0, len); + const to = clamp(range.end - row.start, 0, len); + return to > from ? { from, to } : undefined; +} + +/** Display cells before the caret on its own row (CJK/emoji count as 2). */ +function caretCells(layout: ComposerLayout): number { + const row = layout.rows[layout.caret.row]; + return stringWidth((row?.text ?? '').slice(0, layout.caret.col)); +} + +/** + * Move the caret one *display* row up, keeping its column (in cells, so CJK lines + * up). Above the first row the caret goes to the start of the buffer — the same + * end-stop as the logical `moveUp`. Wrapping makes this the movement the user sees: + * a long line is several rows, and a logical `moveUp` would jump over all of them. + * Returns the same reference when nothing changes. + */ +export function moveRowUp(buffer: TextBuffer, width?: number): TextBuffer { + const layout = composerLayout(buffer, width); + const target = layout.rows[layout.caret.row - 1]; + if (!target) { + return buffer.cursor === 0 ? buffer : { value: buffer.value, cursor: 0 }; + } + const cursor = target.start + caretIndexForColumn(target.text, caretCells(layout)); + return cursor === buffer.cursor ? buffer : { value: buffer.value, cursor }; +} + +/** Move the caret one display row down; past the last row it goes to the end. */ +export function moveRowDown(buffer: TextBuffer, width?: number): TextBuffer { + const layout = composerLayout(buffer, width); + const target = layout.rows[layout.caret.row + 1]; + if (!target) { + return buffer.cursor === buffer.value.length + ? buffer + : { value: buffer.value, cursor: buffer.value.length }; + } + const cursor = target.start + caretIndexForColumn(target.text, caretCells(layout)); + return cursor === buffer.cursor ? buffer : { value: buffer.value, cursor }; +} diff --git a/src/core/index.ts b/src/core/index.ts index e166185..b72badf 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,6 +3,7 @@ export * from './ansi'; export * from './async-queue'; export * from './banner-lines'; export * from './commands'; +export * from './composer-layout'; export * from './config'; export * from './cost'; export * from './errors'; diff --git a/src/core/text-buffer.spec.ts b/src/core/text-buffer.spec.ts index ae112b3..070712a 100644 --- a/src/core/text-buffer.spec.ts +++ b/src/core/text-buffer.spec.ts @@ -3,7 +3,6 @@ import { backspace, bufferLines, bufferOf, - caretIndexAtClick, caretIndexForColumn, cursorRowCol, emptyBuffer, @@ -179,26 +178,3 @@ describe('caretIndexForColumn', () => { expect(caretIndexForColumn(text, column)).toBe(expected); }); }); - -describe('caretIndexAtClick', () => { - it('maps a click on a single-line buffer to the caret index', () => { - const buf = bufferOf('hello', 0); - // click on row 0, column 2 -> caret index 2 - expect(caretIndexAtClick(buf, 0, 2, 8)).toBe(2); - }); - - it('returns undefined for a click above the visible content', () => { - expect(caretIndexAtClick(bufferOf('hi'), -1, 0, 8)).toBeUndefined(); - }); - - it('returns undefined for a click below the visible content', () => { - // one physical line, but clicked two rows down - expect(caretIndexAtClick(bufferOf('hi'), 2, 0, 8)).toBeUndefined(); - }); - - it('resolves a click on a later line to that line index', () => { - const buf = bufferOf('ab\ncd\nef'); // 3 lines, caret at end (row 2) - // row offset 1 within the visible window (all 3 lines fit in maxRows 8), col 1 - expect(caretIndexAtClick(buf, 1, 1, 8)).toBe(4); // 'ab\n' = 3, + col 1 = index 4 - }); -}); diff --git a/src/core/text-buffer.ts b/src/core/text-buffer.ts index ddfe683..ce375a6 100644 --- a/src/core/text-buffer.ts +++ b/src/core/text-buffer.ts @@ -157,9 +157,12 @@ export function caretIndexForColumn(text: string, column: number): number { } /** - * Which line range to render so the caret stays visible within `maxRows` lines. + * Which row range to render so the caret stays visible within `maxRows` rows. * Anchors the caret near the bottom of the window (a growing composer), but never * scrolls a short buffer. Returns a half-open range [start, end). + * + * "Row" here is a *display* row: the composer soft-wraps, so the caller counts rows + * with `composerLayout` (see `composer-layout.ts`), not with `bufferLines`. */ export function visibleLineRange( totalLines: number, @@ -173,28 +176,3 @@ export function visibleLineRange( const start = clamp(cursorRow - cap + 1, 0, totalLines - cap); return { start, end: start + cap }; } - -/** - * Caret index for a mouse click inside the (internally-scrolled) composer. - * `contentRow` is the click's 0-based row within the visible window (i.e. - * `y - contentTop`) and `cells` its display column within that line (`x` minus the - * left edge and the caret-prefix width). Returns undefined when the click lands - * outside the visible lines. Pure inverse of the composer's caret geometry — the - * UI supplies only the pixel→cell offsets. - */ -export function caretIndexAtClick( - buffer: TextBuffer, - contentRow: number, - cells: number, - maxRows: number, -): number | undefined { - const lines = bufferLines(buffer.value); - const caret = cursorRowCol(buffer); - const { start, end } = visibleLineRange(lines.length, caret.row, maxRows); - const row = start + contentRow; - if (contentRow < 0 || row >= end) { - return undefined; - } - const line = lines[row] ?? ''; - return indexAtRowCol(buffer.value, row, caretIndexForColumn(line, cells)); -} diff --git a/src/ui/hooks.ts b/src/ui/hooks.ts index 12f2b88..685b3e6 100644 --- a/src/ui/hooks.ts +++ b/src/ui/hooks.ts @@ -9,6 +9,7 @@ import { } from 'react'; import { type AccountSummary, + COMPOSER_PREFIX_CELLS, type CommandAction, emptyBuffer, FALLBACK_MODEL_OPTIONS, @@ -149,6 +150,37 @@ export function useBoxHeight(ref: RefObject): number | undefi return height; } +/** + * Wrap width (cells) available to composer *text* inside a measured box: its + * computed width minus the `❯ `/` ` row prefix. Undefined until the first + * measurement, which every consumer reads as "don't wrap yet" — a long line is + * truncated for that single frame and wraps as soon as the width lands. + * + * Both `PromptInput` (which renders the wrap) and the views that own a composer + * (which hit-test clicks and move the caret by display row) must derive the width + * this way: they measure boxes of equal width, so the geometry agrees. Deriving it + * from `columns` instead would break inside dialogs (borders/padding). + */ +export function useComposerWidth(ref: RefObject): number | undefined { + const width = useBoxWidth(ref); + return width === undefined ? undefined : Math.max(1, width - COMPOSER_PREFIX_CELLS); +} + +/** + * Computed content width (terminal cells) of an Ink box, measured after every + * render (the horizontal twin of {@link useBoxHeight}). Undefined until first + * measured; re-renders only when the width actually changes. + */ +export function useBoxWidth(ref: RefObject): number | undefined { + const [width, setWidth] = useState(undefined); + useEffect(() => { + const layout = ref.current?.yogaNode?.getComputedLayout(); + const next = layout?.width; + setWidth((prev) => (prev === next ? prev : next)); + }); + return width; +} + /** A clock that ticks every `ms` so elapsed-time displays stay current. */ export function useClock(ms = 1000): number { const [now, setNow] = useState(() => Date.now()); diff --git a/src/ui/input.ts b/src/ui/input.ts index 9376cdc..9d49856 100644 --- a/src/ui/input.ts +++ b/src/ui/input.ts @@ -6,6 +6,8 @@ import { moveDown, moveLeft, moveRight, + moveRowDown, + moveRowUp, moveUp, newline, type TextBuffer, @@ -43,14 +45,19 @@ function sanitizeInsertText(text: string): string { * arrows off so they stay free for row navigation; the detail composer turns both * on. Keys the owning view handles itself (Enter, Tab, Esc, modifiers, PageUp/Down) * report `changed: false` and are left untouched. + * + * `opts.wrapWidth` is the composer's soft-wrap width (cells). With it, ↑/↓ move by + * *display* row — what the user sees — instead of by logical line, which would jump + * over every wrapped row of a long line. Omit it (or pass undefined, e.g. before the + * box is measured) to fall back to logical-line movement. */ export function editText( buffer: TextBuffer, input: string, key: Key, - opts: { arrows?: boolean; vertical?: boolean } = {}, + opts: { arrows?: boolean; vertical?: boolean; wrapWidth?: number } = {}, ): EditResult { - const { arrows = false, vertical = false } = opts; + const { arrows = false, vertical = false, wrapWidth } = opts; // macOS reports Backspace as `delete`; treat both as delete-before-caret. if (key.backspace || key.delete) { @@ -63,10 +70,13 @@ export function editText( return result(buffer, moveRight(buffer)); } if (vertical && key.upArrow) { - return result(buffer, moveUp(buffer)); + return result(buffer, wrapWidth === undefined ? moveUp(buffer) : moveRowUp(buffer, wrapWidth)); } if (vertical && key.downArrow) { - return result(buffer, moveDown(buffer)); + return result( + buffer, + wrapWidth === undefined ? moveDown(buffer) : moveRowDown(buffer, wrapWidth), + ); } // Non-text keys the view owns (or that we don't map): no change. if ( diff --git a/src/ui/prompt-input.spec.tsx b/src/ui/prompt-input.spec.tsx index 6e4c647..4f011f0 100644 --- a/src/ui/prompt-input.spec.tsx +++ b/src/ui/prompt-input.spec.tsx @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events'; -import { render as inkRender } from 'ink'; +import { Box, render as inkRender } from 'ink'; +import { render } from 'ink-testing-library'; import type { ReactElement } from 'react'; import { describe, expect, it } from 'vitest'; import { bufferOf, emptyBuffer } from '@/core'; @@ -81,6 +82,18 @@ describe('PromptInput cursor anchoring (IME)', () => { app.unmount(); }); + it('follows the caret onto a soft-wrapped row', async () => { + // 端末 80 桁 → テキスト幅 78。100 文字は 78 + 22 に折り返り、キャレットは 2 行目の + // 22 文字目(列 = プレフィックス2 + 22 → 1-based 25)。折り返さず truncate して + // いた頃はここでキャレットが画面外に消えていた。 + const { app, output } = renderInteractive( + , + ); + await flush(); + expect(lastCursor(output())).toEqual({ up: 2, column: 25 }); + app.unmount(); + }); + it('keeps the cursor hidden when not focused', async () => { const { app, output } = renderInteractive( , @@ -90,3 +103,51 @@ describe('PromptInput cursor anchoring (IME)', () => { app.unmount(); }); }); + +describe('PromptInput soft wrapping', () => { + it('wraps a long line instead of truncating it', async () => { + // ink-testing-library の端末は 100 桁。200 文字は複数行に折り返り、**全文字**が + // 描かれる(以前は幅を超えたぶんが `…` で切り捨てられ、何を打ったか読めなかった)。 + const { lastFrame, unmount } = render( + , + ); + await flush(); + const frame = lastFrame() ?? ''; + expect(frame).not.toContain('…'); + expect((frame.match(/a/g) ?? []).length).toBe(200); + expect(frame.split('\n').filter((l) => l.includes('aaa')).length).toBeGreaterThan(1); + unmount(); + }); + + it('uses the full available width inside a row-direction parent', async () => { + // row 方向の親(`PermissionDialog` の 1 行 Box が該当)では Box の幅が**中身の幅** + // になる。短いテキストを描いたあとの実測値をそのまま折り返し幅にすると、以降は + // その幅より広がれず(測る→狭い→狭く折り返す→狭いまま)1行に数文字しか入らない。 + // `width: 100%` で幅を中身から切り離しているので、伸びたテキストも1行に収まる。 + const { lastFrame, rerender, unmount } = render( + + + , + ); + await flush(); + rerender( + + + , + ); + await flush(); + const rows = (lastFrame() ?? '').split('\n').filter((l) => l.includes('aaa')); + expect(rows.length).toBe(1); // 端末 100 桁なので 40 文字は折り返さない + unmount(); + }); + + it('wraps at word boundaries when there is one', async () => { + // テキスト幅 98 をまたぐ位置に空白がある → 単語の途中で切らず次の行へ送る。 + const words = `${'x'.repeat(96)} tail`; + const { lastFrame, unmount } = render(); + await flush(); + const rows = (lastFrame() ?? '').split('\n').map((l) => l.trim()); + expect(rows.some((l) => l === 'tail')).toBe(true); + unmount(); + }); +}); diff --git a/src/ui/prompt-input.tsx b/src/ui/prompt-input.tsx index b9c397f..d331bc1 100644 --- a/src/ui/prompt-input.tsx +++ b/src/ui/prompt-input.tsx @@ -2,16 +2,15 @@ import { Box, type DOMElement, Text, useCursor } from 'ink'; import { type FC, useRef } from 'react'; import stringWidth from 'string-width'; import { - bufferLines, - cursorRowCol, + composerLayout, INPUT_MAX_ROWS, isEmptyBuffer, - lineSelection, + rowSelection, type SelectionRange, type TextBuffer, visibleLineRange, } from '@/core'; -import { useAbsolutePosition } from './hooks'; +import { useAbsolutePosition, useComposerWidth } from './hooks'; import { glyph, theme } from './theme'; /** @@ -52,8 +51,14 @@ const SelectionLine: FC<{ line: string; from: number; to: number }> = ({ line, f * Claude-Code-style composer: a full-width horizontal rule above and below the * input (no side borders). Purely presentational — key handling lives in the * owning view (a single useInput per screen). Multi-line aware: the box grows with - * the content up to `maxRows` lines, then scrolls internally to keep the caret in - * view (`visibleLineRange`). Empty/single-line input stays exactly one row tall. + * the content up to `maxRows` display rows, then scrolls internally to keep the + * caret in view (`visibleLineRange`). Empty/single-line input stays one row tall. + * + * Text longer than the input is **soft-wrapped** onto the next display row rather + * than truncated: with `wrap="truncate-end"` alone, typing past the right edge hid + * both the text and the caret behind a `…`. The wrap geometry is computed by the + * pure `composerLayout` from the box's measured width, which is also what the + * owning views hit-test clicks against — one geometry, no drift. * * The real terminal cursor is anchored on the caret cell while focused. IME の * 未確定文字列(日本語変換中のプレビュー)は端末がカーソル位置に描画するため、 @@ -70,17 +75,20 @@ export const PromptInput: FC<{ }> = ({ buffer, focused, placeholder = '', maxRows = INPUT_MAX_ROWS, selection }) => { const boxRef = useRef(null); const box = useAbsolutePosition(boxRef); + // 折り返し幅は実測(ダイアログ内では端末幅と一致しないため)。初回描画までは + // undefined = 折り返さない(1フレームだけ従来通り truncate される)。 + const width = useComposerWidth(boxRef); const { setCursorPosition } = useCursor(); - const lines = bufferLines(buffer.value); - const { row, col } = cursorRowCol(buffer); - const { start, end } = visibleLineRange(lines.length, row, maxRows); + const { rows, caret } = composerLayout(buffer, width); + const { row, col } = caret; + const { start, end } = visibleLineRange(rows.length, row, maxRows); if (focused && box) { // y: 上ボーダー1行 + 表示ウィンドウ内でのキャレット行。x: プレフィックス // 2セル + キャレット手前のテキストの表示幅(空バッファは行 '' で列2になる)。 setCursorPosition({ - x: box.left + promptCaretColumn((lines[row] ?? '').slice(0, col)), + x: box.left + promptCaretColumn((rows[row]?.text ?? '').slice(0, col)), y: box.top + 1 + (row - start), }); } else { @@ -94,6 +102,10 @@ export const PromptInput: FC<{ borderBottom: true, borderLeft: false, borderRight: false, + // 幅は必ず「使える幅いっぱい」に固定する。row 方向の親(`PermissionDialog` の + // 一行 Box など)に置かれると Box の幅は**中身の幅**になり、それを測って + // 折り返すと「折り返す→中身が細くなる→さらに折り返す」の自己参照になる。 + width: '100%' as const, }; if (isEmptyBuffer(buffer)) { @@ -110,22 +122,23 @@ export const PromptInput: FC<{ return ( - {lines.slice(start, end).map((line, i) => { - const lineIndex = start + i; - const sel = selection ? lineSelection(buffer.value, selection, lineIndex) : undefined; + {rows.slice(start, end).map((r, i) => { + const rowIndex = start + i; + const sel = selection ? rowSelection(selection, r) : undefined; // While a selection is shown, suppress the block caret so the highlight // reads cleanly (the real terminal cursor still marks the focus end). - const isCaretLine = focused && !selection && lineIndex === row; + const isCaretRow = focused && !selection && rowIndex === row; return ( - // Line index is a stable key within a single render's window. - + // Row index is a stable key within a single render's window. + {i === 0 ? `${glyph.caret} ` : ' '} {sel ? ( - - ) : isCaretLine ? ( - + + ) : isCaretRow ? ( + ) : ( - {line} + // 幅は composerLayout が合わせているので truncate は保険(未実測の1フレーム)。 + {r.text} )} ); diff --git a/src/ui/repo-prompt-editor.tsx b/src/ui/repo-prompt-editor.tsx index 7714c36..0314355 100644 --- a/src/ui/repo-prompt-editor.tsx +++ b/src/ui/repo-prompt-editor.tsx @@ -1,8 +1,8 @@ -import { Box, Text, useInput } from 'ink'; -import type { FC } from 'react'; +import { Box, type DOMElement, Text, useInput } from 'ink'; +import { type FC, useRef } from 'react'; import { bufferOf, parseSgrMouse } from '@/core'; import { DialogBox } from './dialog-box'; -import { useTextBufferRef } from './hooks'; +import { useComposerWidth, useTextBufferRef } from './hooks'; import { useMessages } from './i18n-context'; import { editText, normalizeChord, resolveEnter } from './input'; import { PromptInput } from './prompt-input'; @@ -29,6 +29,9 @@ export const RepoPromptEditor: FC<{ }> = ({ initial, onSave, onCancel }) => { const m = useMessages(); const { buffer, bufferRef, updateBuffer } = useTextBufferRef(bufferOf(initial ?? '')); + // 折り返し幅(実測)。ダイアログ内なので端末幅からは求まらない。 + const editorRef = useRef(null); + const wrapWidth = useComposerWidth(editorRef); useInput((rawInput, rawKey) => { // Swallow SGR mouse reports first so wheel/click escape sequences never leak @@ -51,8 +54,12 @@ export const RepoPromptEditor: FC<{ return; } // Full caret movement (arrows + vertical) — this is a document editor, not a - // list where arrows navigate rows. - const edit = editText(bufferRef.current, input, key, { arrows: true, vertical: true }); + // list where arrows navigate rows. ↑↓ は折り返し後の表示行で動かす。 + const edit = editText(bufferRef.current, input, key, { + arrows: true, + vertical: true, + wrapWidth, + }); if (edit.changed) { updateBuffer(edit.buffer); } @@ -63,7 +70,7 @@ export const RepoPromptEditor: FC<{ {m.prompt.title} - + diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx index 03d87c0..5117045 100644 --- a/src/ui/session-detail.tsx +++ b/src/ui/session-detail.tsx @@ -4,7 +4,9 @@ import { ARROW_SCROLL_LINES, bufferOf, COMMANDS, + COMPOSER_PREFIX_CELLS, caretIndexAtClick, + composerRowCount, type DiffStat, type DisplayLine, emptyBuffer, @@ -36,6 +38,7 @@ import { useAbsolutePosition, useBoxHeight, useCommandRunner, + useComposerWidth, useDragSelection, useLifecycleAction, useRunMode, @@ -157,6 +160,9 @@ export const SessionDetail: FC<{ const sel = useDragSelection(onCopy); const composerRef = useRef(null); const composerBox = useAbsolutePosition(composerRef); + // 入力欄の折り返し幅(実測)。PromptInput が描いた折り返しと同じ値でクリック位置の + // 逆算・↑↓ のキャレット移動を行う(食い違うと別の文字を選ぶ)。 + const composerWidth = useComposerWidth(composerRef); // ログ表示域の実測高さ。ここに描く行数の上限であり、スクロール1回の移動量の基準 // でもある。見積り(logViewportRows)より実測を優先するのは、可視域より多く描くと // Yoga が溢れた行を「上でクリップ」せず「縮小」してしまい、ログの途中の行が @@ -277,7 +283,11 @@ export const SessionDetail: FC<{ // 使う — 食い違うと最上部でアンカーが 1 行手前で止まり、先頭行に到達できなくなる。 const logCap = Math.max(1, viewport - (showPreview ? 1 : 0)); - /** Caret index for a mouse point inside the composer, or undefined if outside. */ + /** + * Caret index for a mouse point inside the composer, or undefined if outside. + * Resolved through the same wrap width the composer rendered with, so a click on + * a soft-wrapped row lands on the character under the pointer. + */ const composerCaretAt = (x: number, y: number): number | undefined => { if (!composerBox) { return undefined; @@ -285,8 +295,9 @@ export const SessionDetail: FC<{ return caretIndexAtClick( bufferRef.current, y - (composerBox.top + 1), - x - composerBox.left - 2, + x - composerBox.left - COMPOSER_PREFIX_CELLS, INPUT_MAX_ROWS, + composerWidth, ); }; @@ -399,9 +410,11 @@ export const SessionDetail: FC<{ // ↑/↓ に変換されて届く(alternate scroll mode)。これを拾わないとホイールが // キャレット移動になるだけで「ログが上へスクロールできない」状態になる。 // 複数行を編集している最中だけはキャレット移動を優先する(ログは PgUp/PgDn で辿れる)。 + // 「複数行」は**折り返し後の表示行**で数える — 長い1行も画面上は複数行なので、 + // ↑↓ がログスクロールに吸われるとその行の中を移動できなくなる。 if ( (key.upArrow || key.downArrow) && - (panel === 'actions' || !bufferRef.current.value.includes('\n')) + (panel === 'actions' || composerRowCount(bufferRef.current.value, composerWidth) <= 1) ) { setAnchor((a) => key.upArrow @@ -440,7 +453,13 @@ export const SessionDetail: FC<{ } return; } - const edit = editText(bufferRef.current, input, key, { arrows: true, vertical: true }); + // ↑↓ は折り返し後の**表示行**で動かす(wrapWidth)。論理行だと長い1行の途中から + // 一気に先頭へ飛び、見えている行と操作が食い違う。 + const edit = editText(bufferRef.current, input, key, { + arrows: true, + vertical: true, + wrapWidth: composerWidth, + }); if (edit.changed) { updateBuffer(edit.buffer); } diff --git a/src/ui/session-list.tsx b/src/ui/session-list.tsx index ac21530..68c4ee5 100644 --- a/src/ui/session-list.tsx +++ b/src/ui/session-list.tsx @@ -8,6 +8,7 @@ import { bannerText, bufferOf, COMMANDS, + COMPOSER_PREFIX_CELLS, canSelfUpdate, caretIndexAtClick, emptyBuffer, @@ -49,6 +50,7 @@ import { useBoxHeight, useClock, useCommandRunner, + useComposerWidth, useDragSelection, useLifecycleAction, useRateLimit, @@ -203,6 +205,9 @@ export const SessionList: FC<{ const rowsBox = useAbsolutePosition(rowsRef); const composerRef = useRef(null); const composerBox = useAbsolutePosition(composerRef); + // 入力欄の折り返し幅(実測)。PromptInput が描いた折り返しと同じ値でクリック位置の + // 逆算・↑↓ のキャレット移動を行う(食い違うと別の文字を選ぶ)。 + const composerWidth = useComposerWidth(composerRef); // ヘッダのテキスト欄(マスコットの右)。左上を実測してマウス座標から文字位置を逆算する。 // 高さも測るのは、低い端末で欄が潰れたときに当たり判定をやめるため(下記 headerCaretAt)。 const headerRef = useRef(null); @@ -431,8 +436,10 @@ export const SessionList: FC<{ /** * Caret index for a mouse point inside the composer, or undefined if the point - * is outside it. `contentTop` skips the top border; the `-2` drops the `❯ ` / - * continuation prefix so `x` becomes the display column within the text. + * is outside it. `contentTop` skips the top border; the prefix width drops the + * `❯ ` / continuation glyph so `x` becomes the display column within the text. + * The wrap width must be the one the composer rendered with — clicks on a + * soft-wrapped row resolve through the same layout. */ const composerCaretAt = (x: number, y: number): number | undefined => { if (!composerBox) { @@ -441,8 +448,9 @@ export const SessionList: FC<{ return caretIndexAtClick( bufferRef.current, y - (composerBox.top + 1), - x - composerBox.left - 2, + x - composerBox.left - COMPOSER_PREFIX_CELLS, INPUT_MAX_ROWS, + composerWidth, ); }; @@ -726,7 +734,13 @@ export const SessionList: FC<{ updateBuffer(emptyBuffer()); return; } - const edit = editText(bufferRef.current, input, key, { arrows: true, vertical: true }); + // ↑↓ は折り返し後の**表示行**で動かす(wrapWidth)。論理行だと長い1行の途中から + // 一気に先頭へ飛び、見えている行と操作が食い違う。 + const edit = editText(bufferRef.current, input, key, { + arrows: true, + vertical: true, + wrapWidth: composerWidth, + }); if (edit.changed) { updateBuffer(edit.buffer); }