Skip to content

fix: 入力欄が画面幅を超えたら折り返して全文が見えるようにする - #77

Merged
takecchi merged 3 commits into
mainfrom
codiva/task-11
Jul 31, 2026
Merged

fix: 入力欄が画面幅を超えたら折り返して全文が見えるようにする#77
takecchi merged 3 commits into
mainfrom
codiva/task-11

Conversation

@takecchi

Copy link
Copy Markdown
Owner

背景

改行せずに入力し続けると、画面幅に達したところで に切り捨てられ、打っている文字もキャレットも見えなくなる問題がありました(PromptInput が論理行を 1 表示行として wrap="truncate-end" で描いていたため)。

横スクロールではなくソフト折り返しにして、入力した全文が常に見える状態にします(行頭側も読み返せるため)。

変更内容

折り返しの幾何を純粋モジュールへ集約

src/core/composer-layout.ts(新規):

関数 役割
wrapComposerRows(value, width?) 表示幅で折り返して ComposerRow[] へ。空白があれば単語境界、無ければ強制改行。CJK/絵文字は string-width で 2 セル
composerLayout(buffer, width?) 折り返し + キャレットの表示行・列
composerRowCount(value, width?) 表示行数
caretIndexAtClick(...) クリック位置 → キャレット index(text-buffer.ts から移設し折り返し対応)
rowSelection(range, row) 表示行ごとの選択ハイライト範囲
moveRowUp / moveRowDown 表示行での ↑↓ 移動(表示幅で列を保持)
  • 行は value を正確に分割する(value.slice(row.start, row.end) === row.text)ので、位置はすべて value への index のまま扱えます。
  • 折り返し境界のキャレットは後続行の先頭に置きます(次の文字が現れる位置)。行が幅ぴったりで後続が無いときは、端末のカーソル折り返しと同じく空の表示行を 1 行開ける(幅の外に列を描かない)。
  • 幅未指定(実測前)は「折り返さない」= 従来の挙動にフォールバックします。

折り返し幅は実測して共有する

  • useBoxWidth / useComposerWidthui/hooks.ts)で Box の computed layout を実測。端末幅からの引き算ではダイアログ内(枠 + padding)で合いません。
  • PromptInput の Box は width: '100%' に固定。row 方向の親(PermissionDialog の 1 行 Box)では Box 幅が中身の幅になり、それを測って折り返すと「測る→狭い→狭く折り返す→狭いまま」の自己参照で 1 行に数文字しか入らなくなるため(回帰テストあり)。
  • 描画 / クリック逆算 / ↑↓ / 詳細ビューの「複数行編集中か」判定をすべて同じ実測幅・同じ関数に通します(食い違うとクリックが別の文字に当たる)。

挙動の変化

  • 長い指示は自動で折り返り、全文が見える(INPUT_MAX_ROWS = 8 表示行まで伸び、超過はキャレット付近を内部スクロール)。
  • / 見えている行でキャレットが動く(従来は論理行なので長い 1 行の途中から行頭へ飛んでいた)。
  • 詳細ビューで ↑↓ がログスクロールに回るのは、コンポーザが表示行で 1 行のときだけになりました。
  • /prompt エディタ・許可ダイアログの入力欄も同じ折り返しになります。

テスト

  • src/core/composer-layout.spec.ts(新規・テーブルドリブン): 折り返し(単語境界 / 強制改行 / CJK / 空行 / 幅ぴったり)、キャレット位置、クリック逆算(内部スクロール込み)、選択範囲、表示行 ↑↓。
  • src/ui/prompt-input.spec.tsx: 折り返しで全文字が描かれる( が出ない)/ 単語境界 / 折り返し行へのカーソル追従(IME 用の実端末カーソル)/ row 方向の親でも幅いっぱいを使う。
  • 既存 1581 テスト緑・lint / typecheck / build 緑(coverage: core/composer-layout.ts は statements 100% / branch 89.6%)。

動作確認 TODO(要 TTY・認証のためユーザー確認)

  • 一覧の入力欄で改行せずに長文を打ち、画面端で折り返って全文が見えること(キャレットも見える)
  • 日本語入力(IME)の未確定文字が折り返し後の行でも正しい位置に出ること
  • 折り返した行をクリック / ドラッグ選択したとき、狙った文字に当たること
  • が見えている行単位で動くこと、詳細ビューでログスクロールと競合しないこと
  • /prompt エディタと許可ダイアログの入力欄でも折り返ること

ドキュメント

.claude/rules/ink-components.md(不変条件)/ docs/ARCHITECTURE.md / docs/TECH_NOTES.md / docs/TASKS.md(Phase 15)/ README.md(入力欄の節)/ CLAUDE.md(コードの地図)を更新。

🤖 Generated with Claude Code

takecchi added 3 commits July 31, 2026 09:38
改行せずに打ち続けると画面端で `…` に切り捨てられ、テキストもキャレットも
見えなくなっていた(`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%` に固定して、幅が中身の幅に
  依存して狭まる自己参照を防ぐ
- 描画・クリック逆算・↑↓・詳細ビューの「複数行編集中か」判定を同じ実測幅で
  通す(食い違うとクリックが別の文字に当たる)
README の入力欄セクションを「折り返し」+「キー操作表」に統合し、
editText の doc comment に Ctrl+U と wrapWidth の両方を残す形でコンフリクトを解消。
docs/ARCHITECTURE.md の core ファイル一覧で #76 の choice-lines.ts と
本 PR の composer-layout.ts を両方残す形でコンフリクトを解消。
@takecchi
takecchi merged commit e1ce7d9 into main Jul 31, 2026
1 check passed
@takecchi
takecchi deleted the codiva/task-11 branch July 31, 2026 01:01
takecchi added a commit that referenced this pull request Jul 31, 2026
## 背景 / 症状

1. 一覧の PR ステータス(`#<n>` やチェック)が**出るときと出ないときがある**
2. (レビュー中のご指摘)**セッション数に比例してポーリングが重くなる**/一度取得した情報がキャッシュされない

## 原因(実測で確認)

`utils/pr.ts` の `lookupPr` が **`gh` のあらゆる失敗を「PR が無い」と同じ `undefined`
に丸めていた**ため、`PrCoordinator` が `session.setPr(undefined)` を呼び、**表示中の
`#<n>` を消していた**。20 秒ごとのポーリングなので「消えて、しばらくして復活する」挙動になる。

このリポジトリで再現を確認:

```console
$ gh pr view codiva/github --json number   # exit 1
GraphQL: API rate limit already exceeded for user ID …
$ gh api rate_limit --jq .resources.graphql
{"limit":5000,"remaining":0,"used":5004}
```

- `gh pr view --json mergeable` は REST ではなく **GraphQL API**(5000
ポイント/時、REST とは別枠)を消費する
- このクォータは**同一アカウントの全ツールで共有**で、**セッション内の Claude が実行する `gh`** も同じ枠を食う
- そこへ codiva が「**全セッション × 20 秒 × 2 回**」(PR 情報用と `statusCheckRollup`
用)を上乗せしていた。10 セッションで 3600 req/h = **自分で枯らして自分の表示を壊していた**

加えて「PR が無い」と「確認できていない」が画面上どちらも空セルで区別できず、PR
番号は永続化されていないため再起動直後は毎回ゼロから取得していた。

## 対応

### 1. 失敗と「PR が無い」を区別する(根本原因)

```ts
type PrLookupResult =
  | { kind: 'found'; pr: PrInfo }
  | { kind: 'absent' }                                  // gh が答えた: PR は無い
  | { kind: 'unavailable'; reason: PrUnavailableReason } // gh が答えられなかった
```

失敗理由を stderr の文言から分類(`cli` / `auth` / `rate_limit` / `network` /
`unknown`)。`unavailable` のときは**直前の値を保持**し、`prLookup: 'error'`
を立てるだけにする。パース失敗も `absent` ではなく `unavailable` に倒す。

### 2. 番号とステータスを分けて、番号は永続キャッシュする

```ts
pr?: PrRef;           // { number, url } — ブランチに対して不変 → state.json に永続
prStatus?: PrStatus;  // { mergeStatus, checks, isDraft } — 揺れる → transient
```

- **番号が分かった時点で番号を表示**: 永続するので**再起動直後からグリフ無しの `#42`** が出る(従来は毎回 `⋯` から)
- **ステータスが分かった時点で表示**: 「番号は既知・ステータス未取得」は `prPollIntervalMs` が **0**
を返して即取得 → グリフが後から付く。ステータスは永続しない(前回終了時の古いグリフを見せない)
- reducer は**半分ずつ**比較して参照を維持 → チェックの進行だけで `state.json` が再保存されない

### 3. 表示(読み込み中 / 確認不能 / チェック)

| 表示 | 意味 |
|---|---|
| `⋯` | 確認中(1 回目の問い合わせが未完了) |
| (空) | このブランチに PR は無い |
| `#12` | PR あり、**ステータス未取得**(再起動直後など) |
| `✓ #12` | マージ可能(チェックも通過) |
| `● #12` | **チェック実行中** |
| `✗ #12` | **チェック失敗** またはコンフリクト |
| `⑂ #12` | マージ済み |
| `?` | **確認できなかった**(レート制限 / オフライン / 未認証) |

draft は番号を淡色に(従来は区別なし)。`gh` 未導入のときは印を出さない(全行に警告を出しても直せない)。

### 4. API コストをセッション数から切り離す

- **陳腐化ベースのポーリング**(`core/pr-refresh.ts`)— 20 秒 tick
は**スケジューラに降格**し、実際に叩くのは期限切れのものだけ。チェック実行中 20 秒 / マージ可否計算中 60 秒 / 落ち着いた PR
**3 分** / **merged と archived は永久に問い合わせない**
- **3 件以上まとまったら `gh pr list` 1 回に畳む**(`lookupPrs`)— 各セッションの HEAD
ブランチ(ローカル `git rev-parse`、API 不要)で突き合わせ。**10 セッションでも API 1 回**。1〜2 件は
`pr view` の方が安いので閾値を置く(list は全件の rollup を運ぶ)
- チェック取得を `pr view` 1 回に統合(毎ポーリング 2 回 → 1 回)
- `rate_limit` / `auth` / `cli` を検知したら **5
分**停止(`PR_LOOKUP_BACKOFF_MS`)、サイクルの**多重実行を禁止**(`gh` が 20 秒より遅いと重なっていた)

10 セッションで概算 **3600 → 20〜60 req/h**。

## 不変条件

- 状態遷移は reducer 経由のみ(`pr_lookup` イベント追加。`pr` は「`gh` が答えた」ときだけ流すので
`prLookup` も必ずクリア)
- `prStatus` / `prLookup` は transient(`state.json` にも
`persistRelevantChanged` にも入らない)。永続するのは番号・URL だけで、untrusted JSON も検証する
- `gh` を知るのは `utils/pr.ts` だけ / 色・記号は `theme.ts` 経由 / UI 文字列の追加なし(記号のみ)

## テスト

`lint` / `typecheck` / `test` / `build` 通過(**1621 tests**、statements
95.6%)。

- `src/utils/pr.spec.ts` — 実際の `gh` 文言(レート制限・未認証・ENOENT・オフライン・no
PR)の分類テーブル、チェック集約、バッチ(1 回で解決 / 失敗時に全件 unavailable / open を closed より優先 /
ページ切り詰め時の確認)
- `src/core/pr-refresh.spec.ts`(新規)— 状態ごとの再取得間隔とキャッシュ判定のテーブル
- `src/core/pr-coordinator.spec.ts`(新規)— 失敗時に PR 保持 / バックオフ / 多重実行禁止 /
merged は打ち切り / 点滅しない / バッチ切替 など 29 件
- `src/core/status-reducer.spec.ts` — 2 分割の比較と参照維持
- `src/core/persistence.spec.ts` — 番号だけ永続し、ステータスは永続しない(壊れた JSON も除去)
- `tests/restore.test.tsx` / `tests/app.test.tsx` —
再起動で番号が残り、最初のポーリングでグリフが付くところまで通す

### 実 API での検証(`gh` の実データ)

`gh pr list --json` の形状・並び順を実測し `docs/TECH_NOTES.md` に記録。`lookupPrs` を実
API に通して確認:

| ケース | 結果 |
|---|---|
| HEAD にある PR | `found` #78 mergeable / passing |
| PR 無し | **`absent`**(`unavailable` ではない) |
| マージ済み | `found` #77 **merged**(以後ポーリングしない) |

### 手動確認のお願い

実セッション(TTY + Claude 認証)が必要な体感確認はお願いします。`gh auth logout` 状態やクォータ枯渇時に一覧へ
`?` が出て番号が消えないこと、再起動直後に `#<n>` が即出ることを見ていただければ確実です。

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant