fix: 入力欄が画面幅を超えたら折り返して全文が見えるようにする - #77
Merged
Merged
Conversation
改行せずに打ち続けると画面端で `…` に切り捨てられ、テキストもキャレットも 見えなくなっていた(`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
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
改行せずに入力し続けると、画面幅に達したところで
…に切り捨てられ、打っている文字もキャレットも見えなくなる問題がありました(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(...)text-buffer.tsから移設し折り返し対応)rowSelection(range, row)moveRowUp/moveRowDownvalue.slice(row.start, row.end) === row.text)ので、位置はすべてvalueへの index のまま扱えます。折り返し幅は実測して共有する
useBoxWidth/useComposerWidth(ui/hooks.ts)で Box の computed layout を実測。端末幅からの引き算ではダイアログ内(枠 + padding)で合いません。PromptInputの Box はwidth: '100%'に固定。row 方向の親(PermissionDialogの 1 行 Box)では Box 幅が中身の幅になり、それを測って折り返すと「測る→狭い→狭く折り返す→狭いまま」の自己参照で 1 行に数文字しか入らなくなるため(回帰テストあり)。挙動の変化
INPUT_MAX_ROWS= 8 表示行まで伸び、超過はキャレット付近を内部スクロール)。↑/↓は見えている行でキャレットが動く(従来は論理行なので長い 1 行の途中から行頭へ飛んでいた)。/promptエディタ・許可ダイアログの入力欄も同じ折り返しになります。テスト
src/core/composer-layout.spec.ts(新規・テーブルドリブン): 折り返し(単語境界 / 強制改行 / CJK / 空行 / 幅ぴったり)、キャレット位置、クリック逆算(内部スクロール込み)、選択範囲、表示行 ↑↓。src/ui/prompt-input.spec.tsx: 折り返しで全文字が描かれる(…が出ない)/ 単語境界 / 折り返し行へのカーソル追従(IME 用の実端末カーソル)/ row 方向の親でも幅いっぱいを使う。lint/typecheck/build緑(coverage:core/composer-layout.tsは statements 100% / branch 89.6%)。動作確認 TODO(要 TTY・認証のためユーザー確認)
↑↓が見えている行単位で動くこと、詳細ビューでログスクロールと競合しないこと/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