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
11 changes: 11 additions & 0 deletions .claude/rules/ink-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,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 内蔵ビュー)

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ codiva

> worktree ディレクトリ `.codiva/` は対象リポジトリの `.git/info/exclude` に自動追記されるため、対象リポジトリの `.gitignore` を汚しません。

### 入力欄のキー操作
### 入力欄

長い指示は画面幅で**自動的に折り返され**、打った文字が切り捨てられずに全部見えます(空白があれば単語の途中で切りません)。入力が縦に伸びるのは 8 行までで、それを超えるとカーソル位置に合わせて内部スクロールします。

#### キー操作

一覧のコンポーザ、詳細ビューの追加指示欄、`/prompt` エディタ、許可ダイアログの拒否理由欄で共通です。

Expand All @@ -79,7 +83,8 @@ codiva
| `Enter` | 送信(先頭が `/` のときはコマンド実行) |
| `Shift+Enter` | 改行(効かない端末では行末に `\` を置いて `Enter`) |
| `Ctrl+U` | **書きかけを全消し**(キャレット位置に関係なく入力欄を空にする) |
| `←→↑↓` | キャレット移動 |
| `←→` | キャレット移動 |
| `↑↓` | キャレット移動(**見えている行** = 折り返し後の行の単位で動く) |
| `Backspace` | 1文字削除 |

> macOS の `Cmd+Delete` は端末がアプリへ送らない(kitty keyboard protocol を有効にした端末でしか `super` 修飾が届かない)ため、全消しは `Ctrl+U` に割り当てています。`Cmd+Delete` で使いたい場合は端末側で `Ctrl+U`(`\x15`)へ割り当ててください。Ghostty なら設定ファイルに次の 1 行を足します。
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ codiva/
│ │ ├── privacy.ts # 学習データ利用(grove)の判定(JSON→TrainingOptIn・純粋)
│ │ ├── async-queue.ts / slug.ts / config.ts / cost.ts / notify.ts / persistence.ts / update.ts
│ │ ├── choice-lines.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)
Expand Down Expand Up @@ -437,7 +437,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` を表示。
Expand Down
29 changes: 29 additions & 0 deletions docs/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 共通の完了チェック
Expand Down
2 changes: 1 addition & 1 deletion docs/TECH_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ function toUserMessage(text: string): SDKUserMessage {
- **`<Static>` は全画面レイアウトと両立しない**: 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 ページ分が埋まるようにする。
- **空文字の `<Text>` は高さ 0**(実測 / ink 7): Ink の `measureText()` は `text.length === 0` のとき `{width: 0, height: 0}` を返す(`node_modules/ink/build/measure-text.js`)。そのため `<Text>{''}</Text>` は**行として一切場所を取らない**(`squashTextNodes` の結果が空なら描画もスキップされる)。ログの空行(Markdown の段落間、コードブロック内の空行)がこれに当たり、スクロール計算(`core/scroll.ts` は空行も 1 物理行として数える)が確保した高さより実際の描画が短くなって、**末尾寄せのビューポート上端に空行の本数ぶんの隙間が残る**(「表示できる行があるのに上が空いている」)。同時に段落の区切りも消えて行が詰まって見える。対策は行の描画側(`ui/session-detail.tsx` の `LogLine`)で空行を半角スペース 1 つに置き換え、必ず 1 行ぶんの高さを確保すること。`PromptInput` は各行を非空のプレフィックス `<Text>` と同じ `<Box>` に入れているため元から影響を受けない。
- **複数行入力**: 純粋モデルは `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 が返す参照が変わらなければ再描画されない点を利用する。
Expand Down
Loading
Loading