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
42 changes: 42 additions & 0 deletions .claude/rules/ink-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,48 @@
- **行数はアンカーの関数**(プレビュー行は末尾追従中だけ描く)。自動スクロールの終点は
**スクロール後のアンカー**で数え直す(`capFor(next)`)— `logCap` のままだと末尾追従を
外れた瞬間に 1 行増えるぶん、上端の 1 行が選択から漏れる。
- **折り返し・幅・クリック位置の逆算は必ず同じ単位(グラフェム)で数える**。共有の
分割器は `core/graphemes.ts` の `GRAPHEMES` で、`wrapDisplayLines` / `wrapRichLine` /
`caretIndexForColumn` / `charIndexAtColumn` が全部これを通る。コードポイント単位で
数えると `stringWidth` と食い違う: `⚠️`(U+26A0 + U+FE0F)は 1 グラフェム = 2 セルだが、
コードポイントごとに測ると 1 + 0 = 1 セルになる。この 1 セルのズレで**クリックした文字と
当たった文字が違う**(実際に URL の手前の空白でブラウザが開き、URL の最後の文字は
反応しなかった)。加えてグラフェムの途中に caret が入り、`slice` した選択が壊れた
絵文字を含む。**「幅を別に測って比べる」もしない** — 判定と逆算を別々の測り方で
やると同じズレが再発するので、1 回の走査で両方出す(`charIndexAtColumn`)。

- **ログ内の URL は codiva 自身がクリックを取って開く**(端末の Cmd+click に任せない)。
理由は主端末の Ghostty が**マウスレポート中はリンク検出そのものを止める**こと
(`Surface.zig` が `mouse_event != .none` でホバー判定を skip する)と、SGR マウス
レポートに **Cmd/Super のビットが無い**こと(修飾は shift=4 / alt=8 / ctrl=16 だけ。
しかも bit 8 は Ghostty では Option、iTerm2 では Cmd と**端末で意味が違う**)。
結果、全端末で同じに動く経路は「素のクリック(press → 動かさず release)」だけになる。
- **リンク範囲は行に持たせる**(`DisplayLine.links` = `core/url.ts` の `LinkRange[]`)。
テキストから URL を引き直さないのは、(1) Markdown の `[label](url)` は**見えているのが
label** なので復元できない(href は `RichSpan.link` で運ぶ)、(2) 折り返しで割れた
半分は URL として解析できない、の 2 点。範囲なら**両方の行が URL 全体を指せる**ので
どちらをクリックしても同じ先へ飛ぶ。検出は必ず**論理行**に対して行い、
`linksInSlice` で各物理行の座標(prefix / 字下げのぶんずらす)へ移す。
- **press では開かず、release で開く**。press した位置の URL を ref に保留し、
`drag` が来たら取り消す。押した時点で開くと、URL の上からドラッグして範囲選択を
始めるたびにブラウザが立ち上がる。
- **副作用のある操作は左ボタンだけ**(`MouseEvent.button`)。右クリック(端末の
コンテキストメニューを期待した操作)や中クリック(貼り付け)でブラウザを開かない。
選択・フォーカス移動は無害なのでどのボタンでも受けてよい。
- **モーダル表示中は詳細ビューもマウスレポートを飲む**(`modelSelect || pending` で
早期 return。一覧と同じ)。飲まないと許可待ちのダイアログの上での 1 クリックで
背後のログの選択が動き、URL の上ならブラウザまで開く。
- **当たり判定は行末で丸めない**(`logLinkAt` は `column >= stringWidth(text)` を弾く)。
選択のアンカー(`logCaretAt`)は短い行の右の余白からドラッグを始めたいので丸めてよいが、
リンクを丸めると URL で終わる行の**右の余白をクリックしただけで開く**。
- **OSC 8 は描画時にだけ混ぜる**(`ui/log-line.tsx` の `linkedText`)。`LogEntry.text` /
`RichSpan.text` に入れると `wrapDisplayLines` / `wrapRichLine` が**エスケープを可視幅として
数える**ので折り返しが壊れ、URI が行の途中で断ち切られる(実測: 幅 20 で 4 行に割れた)。
パラメータ形(`id=`)は使わない(wrap-ansi 10 が壊す)。Ink 7 の計測・再構築
(string-width / slice-ansi / ansi-tokenize ≥0.3)は OSC 8 を幅 0 として扱うので安全。
- 選択境界とリンク境界は直交するので、**純粋な関数で 2 段に切る**
(`linkPieces` → `selectionSlices`)。片方だけで切ると、選択がリンクの途中で
終わったときにどちらかのスタイルが行全体へ漏れる。
- 1 SDK セッション 1 ライター。詳細ビューを開いても codiva が唯一のライターであり続ける
(外部 CLI との二重接続はしない)。マージ/破棄は一覧・詳細のどちらからでも可能。

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。
| 詰まった PR の立て直し | `core/pr-recovery.ts`(判定・指示文・純粋)/ `SessionManager.recover()` / `utils/worktree-manager.ts` の `syncBase`(ベース取り込み)/ `ui/hooks.ts` の `useRecovery` |
| 一覧画面 | `ui/session-list.tsx`(composer / list の2フォーカス) |
| 詳細画面 | `ui/session-detail.tsx`(ログ + 追加指示 + 操作パネル) |
| 表示幅・グラフェム | `core/graphemes.ts`(共有の分割器。折り返し・幅・クリック逆算で**同じ単位**を使う) |
| 入力欄・キー処理 | `core/text-buffer.ts`(純粋モデル)/ `core/composer-layout.ts`(折り返し・表示行の幾何)/ `core/input-history.ts`(↑↓ の入力履歴)/ `ui/input.ts`(キー→操作)/ `ui/prompt-input.tsx` |
| ログ描画・スクロール | `core/scroll.ts`(`logLines` は**エントリ単位でメモ化**)/ `core/markdown.ts` / `core/ansi.ts` / `ui/log-line.tsx`(1 行の描画) |
| ログの上限・メモリ | `core/log-buffer.ts`(件数/文字数の上限・`pushLogEntry` が唯一の追記経路) |
| マウス・範囲選択 | `core/mouse.ts` / `core/list-hit.ts` / `core/text-selection.ts` / `core/log-selection.ts`(詳細ログの選択・端の自動スクロール) / `utils/mouse.ts` / `utils/clipboard.ts` |
| ログ内 URL のクリック | `core/url.ts`(検出・範囲・OSC 8・純粋)/ `core/log-selection.ts` の `logLinkAt`(当たり判定)/ `utils/open-url.ts`(ブラウザ起動) |
| 文言・言語 | `core/i18n.ts`(カタログ)/ `ui/i18n-context.tsx`(`useMessages`) |
| 色・記号 | `ui/theme.ts`(`.tsx` に生 ANSI 名を書かない) |
| スラッシュコマンド | `core/commands.ts`(レジストリ)/ `ui/command-palette.tsx` / `ui/hooks.ts` の `useCommandRunner` |
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
- **現在ブランチの表示** — ヘッダに対象リポジトリがチェックアウトしているブランチ(= 新しいセッションの分岐元・マージ先)を表示。別ターミナルで切り替えても数秒で追従します。
- **学習データ利用の警告** — claude.ai の「Help improve our AI models」(モデル学習へのデータ提供)が ON のときだけ、起動時のヘッダに注意行を出す。
- **アップデート通知** — 起動時に npm の最新版を確認し、新しいバージョンがあればヘッダに 1 行表示。`/update` コマンドで確認の上その場で更新できる。
- **キーボード完結** — マウス不要。入力欄・ヘッダ・セッション詳細のログはドラッグで範囲選択してコピーもできます(ログは画面外へドラッグすると自動スクロールしながら選択が続きます)。
- **キーボード完結** — マウス不要。入力欄・ヘッダ・セッション詳細のログはドラッグで範囲選択してコピーもできます(ログは画面外へドラッグすると自動スクロールしながら選択が続きます)。ログ内の URL はクリックでブラウザで開けます。
- **日本語 / 英語 UI** — `~/.codiva/config.json` または `CODIVA_LANG` で切替。

## 動作要件
Expand Down Expand Up @@ -112,6 +112,15 @@ codiva
- 選択のハイライトは反転表示され、何かキーを押すと解除されます。
- 端末ネイティブの選択(画面のどこでも選べる代わりにアプリ側の機能が使えない)は **Shift+ドラッグ**、または設定 `"mouse": false` でマウス捕捉を無効化して使えます。

### ログ内の URL を開く

セッション詳細のログに出てくる URL は**下線付きで表示され、クリックするとブラウザで開きます**(ドラッグせずに押して離したときだけ開くので、範囲選択の操作とは競合しません)。

- 対象は `http(s)` の URL です。Markdown のリンク(`[ラベル](URL)`)はラベルの部分をクリックするとリンク先へ飛びます。
- 折り返しで URL が 2 行に分かれていても、**どちらの行をクリックしても URL 全体**が開きます。
- **Cmd+クリック(端末のリンク機能)ではなく、普通のクリックです。** codiva は全画面のあいだ端末のマウスレポートを有効にしており、この状態では端末側のリンク検出が無効になる端末があるためです(Ghostty はマウス捕捉中はリンクのホバー・クリックを一切扱いません。SGR マウスレポートには Cmd/Super を表すビットもありません)。そこで codiva 自身がクリックを受け取って開くようにしてあり、どの端末でも同じ操作で動きます。
- 併せて **OSC 8 ハイパーリンク**も出力しているので、対応端末(iTerm2 / GNOME Terminal / Windows Terminal など)では端末側の Cmd+クリック / Ctrl+クリックも使えます。Ghostty / kitty / WezTerm では端末側のリンク機能は **Shift を足した Shift+Cmd+クリック**(Linux は Shift+Ctrl+クリック)で使えます(Shift がマウス捕捉のバイパスキーになっているため)。非対応の端末はこのエスケープを無視するだけで、表示は変わりません。

### GitHub の PR ステータス表示

一覧の各行の右端に、そのセッションのブランチに対応する PR の状態が出ます(`gh` コマンドを 20 秒ごとに実行して検知。クリック / `p` でブラウザで開けます)。
Expand Down
66 changes: 66 additions & 0 deletions docs/TECH_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,72 @@ function toUserMessage(text: string): SDKUserMessage {
この場合 `index.tsx` の shutdown 列(ポーリング停止・`persist.flushAsync()`・teardown)は
丸ごとスキップされるため、クラッシュハンドラ側にも同期 flush と端末復元を持たせている。

## ログ内 URL のクリック: 端末のリンク機能はマウスレポート中に使えない(調査 2026-08-04)

詳細ログの URL を「Cmd+クリックで開く」を端末任せにできるか調べた結論と、その根拠。

### SGR マウスレポートに Cmd/Super のビットは無い

xterm の仕様が定義する修飾ビットは **shift=4 / meta(alt)=8 / ctrl=16** だけ。Super/Cmd は無い。

- **Ghostty** (`src/input/mouse_encode.zig`): `shift→4` / `alt→8` / `ctrl→16` のみ。Cmd は**一切
エンコードされない**ので、Cmd+クリックは素のクリックと**バイト列が同一**。
- **iTerm2** (`sources/VT100/VT100Output.m`): **Cmd → bit 8(meta)** に割り当て、Option は
エンコードしない(Option がマウスレポートのバイパスキーだから)。
→ **bit 8 の意味が端末ごとに違う**(Ghostty では Option、iTerm2 では Cmd)ので、
bit 8 で「Cmd」を判定することはできない。

### マウスレポート有効時のリンククリックは端末で流派が 2 つに割れる

| 流派 | 端末 | 素の Cmd/Ctrl+クリック | 必要な操作 | アプリにもクリックが届くか |
|---|---|---|---|---|
| マウスレポート優先 | **Ghostty** / kitty / WezTerm | **効かない**(ホバー下線も出ない) | **Shift+Cmd+クリック**(Shift がバイパス) | 届かない(端末が飲む) |
| ハイパーリンク優先 | iTerm2 / VTE(gnome-terminal) / Windows Terminal / VS Code | 効く | Cmd / Ctrl+クリック | iTerm2・VS Code は**届くこともある** |

Ghostty の該当箇所(`src/Surface.zig` の `cursorPosCallback`)は、リンクのホバー判定を
「マウスレポートが off、**または** shift が押されていて shift をアプリへ送らない設定のとき」に
限っている。`mouseRefreshLinks` が `mouse.over_link` を立てる唯一の経路で、
`mouseButtonCallback` はそれを見てリンクを開くので、**捕捉中は素の Cmd+クリックでは
ホバーもクリックも成立しない**。裸 URL の自動検出(`link-url`)も同じ経路なので同様。

→ **主端末(Ghostty)が最悪ケース**で、設定でも直せない(`mouse-reporting = false` にするか
Shift を足すしかない)。一方 **素の左クリックはどの端末でもアプリに SGR レポートとして届く**。
そこで codiva は**自分でクリックを取って開く**方式にした(`logLinkAt` → `utils/open-url.ts`)。
OSC 8 は「対応端末では端末側の Cmd+クリックも使える」上乗せとして併せて出す。

### OSC 8 は Ink 7 の計測・再構築を安全に通る(実測)

`\x1b]8;;URL\x1b\\text\x1b]8;;\x1b\\` を実際に測った結果(このリポジトリの依存で確認):

| パッケージ | 版 | 結果 |
|---|---|---|
| `string-width` | 8.2.2 | `stringWidth(OSC8('click'))` = **5**(= 表示テキストぶんだけ) |
| `@alcalzone/ansi-tokenize` | 0.3.0 | セル数 **5**、再構築して OSC 8 が**保持される** |
| `ansi-regex` / `wrap-ansi` / `slice-ansi` / `cli-truncate` | 6.2.2 / 10.0.0 / 9.0.0 / 6.1.1 | OSC 8 対応済み |

必要な最低版は `ansi-regex ≥6.1.0` / `wrap-ansi ≥10` / `slice-ansi ≥8` / `ansi-tokenize ≥0.3.0`
(2024-09 以前のエコシステムは ST 終端の OSC 8 を扱えず壊れていた)。

**ただし codiva 自身の折り返しは通らない。** `core/scroll.ts` の `wrapDisplayLines` は
`Intl.Segmenter` でグラフェム単位に分けて 1 つずつ measure するので、OSC 8 を混ぜた文字列を
渡すと**エスケープのバイトを可視幅として数える**(幅 20 で測ったら可視 21 セルの文字列が 4 行に
割れ、URI が行の途中で断ち切られた)。よって **OSC 8 は `LogEntry.text` / `RichSpan.text` に
入れず、描画時(`ui/log-line.tsx`)にだけ包む**。パラメータ形(`id=`)は `wrap-ansi@10` が
壊す(`ANSI_ESCAPE_LINK` が `]8;;` 決め打ち)ので使わない。

### tmux / その他の注意

- tmux は 3.4+ が OSC 8 を**自前で解釈して再送**する(パススルーではない)。ただし
`Hls` を送る相手を自動判定するのは tmux / iTerm2 / foot / WezTerm / ghostty だけで、
kitty・VTE・Windows Terminal は `set -ga terminal-features "*:hyperlinks"` が必要。
URI は **1024 バイト**で打ち切られる(`core/url.ts` の `MAX_URL_CHARS` がこれに合わせてある)。
- tmux ≤3.3a は OSC 8 を**黙って捨てる**(可視ゴミにはならない)。`screen` は非対応。
- 仕様準拠の端末は未知の OSC を無視するだけなので、**出しても表示は壊れない**
(可視ゴミが出るのは VTE ≤0.48 / Windows Terminal ≤0.9 など相当古い版)。
- `supports-hyperlinks` パッケージは **tmux 内で false を返す**(`TERM_PROGRAM` を tmux が
上書きし `TERM` も `screen-*` になる)。通知の `detectNotifyProtocol` と同じ罠なので、
能力判定に使わない(codiva は判定せず常に出し、非対応端末の無視に任せている)。

## ヒープ枯渇の実測(2026-08-04)

報告された落ち方(node 22 / 既定のヒープ上限):
Expand Down
12 changes: 8 additions & 4 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ export const App: FC<{
* 取得を一覧のときだけに絞るのは詳細ビューで無駄なプロセスを立てないため。
*/
loadBranch?: () => Promise<string | undefined>;
/** Open a PR URL in the browser. Injected from index.tsx (fire-and-forget). */
onOpenPr?: (url: string) => void;
/**
* URL をブラウザで開く(index.tsx が `openUrl` を注入。fire-and-forget)。
* 一覧の PR セルのクリックと、詳細ログ内の URL のクリックが共有する。
*/
onOpenUrl?: (url: string) => void;
/** Copy a mouse selection (composer / header / detail log) to the clipboard (OSC 52). */
onCopy?: (text: string) => void;
}> = ({
Expand All @@ -71,7 +74,7 @@ export const App: FC<{
trainingOptIn,
updater,
loadBranch,
onOpenPr,
onOpenUrl,
onCopy,
}) => {
const { exit } = useApp();
Expand Down Expand Up @@ -119,12 +122,13 @@ export const App: FC<{
models={models}
onBack={() => setView({ mode: 'list' })}
onCopy={onCopy}
onOpenUrl={onOpenUrl}
/>
) : (
<SessionList
manager={manager}
onOpen={(id) => setView({ mode: 'detail', id })}
onOpenPr={onOpenPr}
onOpenPr={onOpenUrl}
onQuit={quit}
cwd={cwd}
branch={branch}
Expand Down
14 changes: 14 additions & 0 deletions src/core/graphemes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* グラフェム(書記素クラスタ)分割器。
*
* **折り返し・幅の計算・クリック位置の逆算は必ず同じ単位で行う**ため、1 つの分割器を
* 共有する。コードポイント単位で数えると `stringWidth` と食い違う: 異体字セレクタ付きの
* 絵文字(`⚠️` = U+26A0 U+FE0F)は 1 グラフェムとして 2 セルだが、コードポイントごとに
* 測ると `⚠` が 1 セル + U+FE0F が 0 セル = 1 セルになる。この 1 セルのズレが積み上がると
* 「クリックした文字と当たった文字が違う」(実際に URL の手前の空白でリンクが開いた)。
* ZWJ で繋いだ絵文字では逆向きにズレる。
*
* 加えて、コードポイント単位ではグラフェムの**途中**に caret を置いてしまい、
* `slice` した選択範囲が壊れた絵文字を含むことになる。
*/
export const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
2 changes: 2 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './crash';
export * from './errors';
export * from './format';
export * from './gauge';
export * from './graphemes';
export * from './i18n';
export * from './input-history';
export * from './key-sequence';
Expand Down Expand Up @@ -50,5 +51,6 @@ export * from './text-selection';
export * from './transcript';
export * from './types';
export * from './update';
export * from './url';
export * from './usage';
export * from './worktree';
Loading
Loading