diff --git a/.claude/rules/ink-components.md b/.claude/rules/ink-components.md index 5796ed4..6843192 100644 --- a/.claude/rules/ink-components.md +++ b/.claude/rules/ink-components.md @@ -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 との二重接続はしない)。マージ/破棄は一覧・詳細のどちらからでも可能。 diff --git a/CLAUDE.md b/CLAUDE.md index 4288697..30bcd66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/README.md b/README.md index 8612d27..461b41d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - **現在ブランチの表示** — ヘッダに対象リポジトリがチェックアウトしているブランチ(= 新しいセッションの分岐元・マージ先)を表示。別ターミナルで切り替えても数秒で追従します。 - **学習データ利用の警告** — claude.ai の「Help improve our AI models」(モデル学習へのデータ提供)が ON のときだけ、起動時のヘッダに注意行を出す。 - **アップデート通知** — 起動時に npm の最新版を確認し、新しいバージョンがあればヘッダに 1 行表示。`/update` コマンドで確認の上その場で更新できる。 -- **キーボード完結** — マウス不要。入力欄・ヘッダ・セッション詳細のログはドラッグで範囲選択してコピーもできます(ログは画面外へドラッグすると自動スクロールしながら選択が続きます)。 +- **キーボード完結** — マウス不要。入力欄・ヘッダ・セッション詳細のログはドラッグで範囲選択してコピーもできます(ログは画面外へドラッグすると自動スクロールしながら選択が続きます)。ログ内の URL はクリックでブラウザで開けます。 - **日本語 / 英語 UI** — `~/.codiva/config.json` または `CODIVA_LANG` で切替。 ## 動作要件 @@ -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` でブラウザで開けます)。 diff --git a/docs/TECH_NOTES.md b/docs/TECH_NOTES.md index c71fe43..865c94c 100644 --- a/docs/TECH_NOTES.md +++ b/docs/TECH_NOTES.md @@ -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 / 既定のヒープ上限): diff --git a/src/app.tsx b/src/app.tsx index 01f7ad0..c9bf2e3 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -56,8 +56,11 @@ export const App: FC<{ * 取得を一覧のときだけに絞るのは詳細ビューで無駄なプロセスを立てないため。 */ loadBranch?: () => Promise; - /** 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; }> = ({ @@ -71,7 +74,7 @@ export const App: FC<{ trainingOptIn, updater, loadBranch, - onOpenPr, + onOpenUrl, onCopy, }) => { const { exit } = useApp(); @@ -119,12 +122,13 @@ export const App: FC<{ models={models} onBack={() => setView({ mode: 'list' })} onCopy={onCopy} + onOpenUrl={onOpenUrl} /> ) : ( setView({ mode: 'detail', id })} - onOpenPr={onOpenPr} + onOpenPr={onOpenUrl} onQuit={quit} cwd={cwd} branch={branch} diff --git a/src/core/graphemes.ts b/src/core/graphemes.ts new file mode 100644 index 0000000..e782b26 --- /dev/null +++ b/src/core/graphemes.ts @@ -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' }); diff --git a/src/core/index.ts b/src/core/index.ts index 5afa6e0..059e7ff 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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'; @@ -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'; diff --git a/src/core/log-selection.spec.ts b/src/core/log-selection.spec.ts index f760262..219ca59 100644 --- a/src/core/log-selection.spec.ts +++ b/src/core/log-selection.spec.ts @@ -6,6 +6,7 @@ import { logCaretAt, logEdgeAt, logEdgePoint, + logLinkAt, logRowAt, logRowSelection, logSelectionText, @@ -168,3 +169,61 @@ describe('logSelectionText', () => { expect(logSelectionText(LINES, range)).toBe('alpha\nbravo\n\ndelta\n日本語の行'); }); }); + +describe('logLinkAt', () => { + const URL = 'https://x.dev/a'; + /** 行 1 = `ab https://x.dev/a` (URL は 3..18)。可視域は VIEW(left=2, 先頭行=1)。 */ + const linked: DisplayLine[] = [ + line('row0'), + { key: '1:0', kind: 'system', text: `ab ${URL}`, links: [{ from: 3, to: 18, url: URL }] }, + line('row2', 2), + ]; + + it('URL の上をクリックすると URL を返す', () => { + // 文書行 1 は可視域の 1 行目(y = top = 5)。x = left + col。 + expect(logLinkAt(linked, VIEW, 2 + 3, 5)).toBe(URL); + expect(logLinkAt(linked, VIEW, 2 + 10, 5)).toBe(URL); + expect(logLinkAt(linked, VIEW, 2 + 17, 5)).toBe(URL); + }); + + it('URL の手前・直後は返さない', () => { + expect(logLinkAt(linked, VIEW, 2 + 0, 5)).toBeUndefined(); + expect(logLinkAt(linked, VIEW, 2 + 2, 5)).toBeUndefined(); + }); + + it('行末より右の余白は当たりにしない(丸めない)', () => { + // 行の表示幅は 18。col 18 以上は URL で終わる行でも undefined。 + expect(logLinkAt(linked, VIEW, 2 + 18, 5)).toBeUndefined(); + expect(logLinkAt(linked, VIEW, 2 + 40, 5)).toBeUndefined(); + }); + + it('可視域の左外は当たりにしない', () => { + expect(logLinkAt(linked, VIEW, 0, 5)).toBeUndefined(); + }); + + it('links を持たない行・ログ行の外は undefined', () => { + expect(logLinkAt(linked, VIEW, 2 + 1, 6)).toBeUndefined(); // 文書行 2(links なし) + expect(logLinkAt(linked, VIEW, 2 + 1, 99)).toBeUndefined(); // ログ行の外 + }); + + it('選択のアンカー(logCaretAt)とは違い、行末で丸めない', () => { + // 同じ座標で logCaretAt は「行末」を返す = 選択は始められる。 + expect(logCaretAt(linked, VIEW, 2 + 40, 5)).toEqual({ row: 1, col: 18 }); + expect(logLinkAt(linked, VIEW, 2 + 40, 5)).toBeUndefined(); + }); + + it('CJK を含む行でも表示幅で当たる', () => { + const lines: DisplayLine[] = [ + line('row0'), + { + key: '1:0', + kind: 'system', + // '日本語 ' = 7 セル(3 文字 + 空白)。URL は文字 index 4..19。 + text: `日本語 ${URL}`, + links: [{ from: 4, to: 19, url: URL }], + }, + ]; + expect(logLinkAt(lines, VIEW, 2 + 7, 5)).toBe(URL); // URL 先頭のセル + expect(logLinkAt(lines, VIEW, 2 + 1, 5)).toBeUndefined(); // 日本語の上 + }); +}); diff --git a/src/core/log-selection.ts b/src/core/log-selection.ts index ddbf693..e4cb8e7 100644 --- a/src/core/log-selection.ts +++ b/src/core/log-selection.ts @@ -1,7 +1,8 @@ import { clamp } from './math'; import type { DisplayLine, LogWindow } from './scroll'; -import { caretIndexForColumn } from './text-buffer'; +import { caretIndexForColumn, charIndexAtColumn } from './text-buffer'; import type { RowSelection } from './text-selection'; +import { linkAt } from './url'; /** * 詳細ビューのログ内の 1 点。**文書全体の表示行 index**(`row`)と、その行のテキスト内の @@ -104,6 +105,38 @@ export function logCaretAt( return { row, col: caretIndexForColumn(lines[row]?.text ?? '', x - view.left) }; } +/** + * マウス位置にあるクリック可能な URL(無ければ undefined)。 + * + * `logCaretAt` と違い**行末より右は当たりにしない**。選択のアンカーは「短い行の右の + * 余白からドラッグを始める」ために行末へ丸めてよいが、リンクを丸めると URL で終わる + * 行の右の余白をクリックしただけでブラウザが開いてしまう(意図しない副作用)。 + * + * 桁の逆算は描画と同じ**グラフェム単位の表示幅**(`charIndexAtColumn`)で行い、得られた + * index の文字が範囲に入っているかを見る。判定(行末か)と逆算を 1 回の走査でまとめて + * やるのが要点 — 別々に測ると単位が食い違い、絵文字を含む行で端が 1 セルずれる + * (`core/graphemes.ts`)。折り返しで URL が割れていても各行が URL 全体を指しているので、 + * どちらの行でも同じ先が返る。 + */ +export function logLinkAt( + lines: readonly DisplayLine[], + view: LogViewport, + x: number, + y: number, +): string | undefined { + const row = logRowAt(view, y); + if (row === undefined) { + return undefined; + } + const line = lines[row]; + if (!line?.links || line.links.length === 0) { + return undefined; + } + // 行末より右・行の左外は当たりにしない(`charIndexAtColumn` が undefined を返す)。 + const index = charIndexAtColumn(line.text, x - view.left); + return index === undefined ? undefined : linkAt(line.links, index); +} + /** ドラッグが可視域の外へ出た向き(自動スクロールの向き)。 */ export type LogEdge = 'up' | 'down'; diff --git a/src/core/markdown.spec.ts b/src/core/markdown.spec.ts index 9639ba7..120255a 100644 --- a/src/core/markdown.spec.ts +++ b/src/core/markdown.spec.ts @@ -41,9 +41,30 @@ describe('renderMarkdown — inline styling', () => { expect(lines[0]).toEqual([{ text: 'a ' }, { text: 'b', strikethrough: true }]); }); - it('renders a link as underlined link-toned text', () => { + it('renders a link as underlined link-toned text, carrying the href', () => { const lines = renderMarkdown('see [docs](https://x.dev)'); - expect(lines[0]).toEqual([{ text: 'see ' }, { text: 'docs', underline: true, tone: 'link' }]); + // href はスパンに載せる: 見えているのは label なので表示テキストから復元できない。 + expect(lines[0]).toEqual([ + { text: 'see ' }, + { text: 'docs', underline: true, tone: 'link', link: 'https://x.dev' }, + ]); + }); + + it('開けないスキームの href は載せない(クリックで開く対象にしない)', () => { + expect(renderMarkdown('[mail](mailto:a@b.test)')[0]).toEqual([ + { text: 'mail', underline: true, tone: 'link', link: undefined }, + ]); + expect(renderMarkdown('[rel](./docs/a.md)')[0]).toEqual([ + { text: 'rel', underline: true, tone: 'link', link: undefined }, + ]); + }); + + it('裸の URL は autolink されて href が付く', () => { + expect(renderMarkdown('go https://x.dev/a now')[0]).toEqual([ + { text: 'go ' }, + { text: 'https://x.dev/a', underline: true, tone: 'link', link: 'https://x.dev/a' }, + { text: ' now' }, + ]); }); it('nests inline styles (bold + code)', () => { diff --git a/src/core/markdown.ts b/src/core/markdown.ts index 8886ff1..138aab6 100644 --- a/src/core/markdown.ts +++ b/src/core/markdown.ts @@ -1,4 +1,5 @@ import { marked, type Token, type Tokens } from 'marked'; +import { openableUrl } from './url'; /** * Markdown rendering for assistant log text. The AI replies in Markdown, so @@ -29,6 +30,13 @@ export interface RichSpan { underline?: boolean; strikethrough?: boolean; tone?: MarkdownTone; + /** + * リンクの飛び先。`[label](url)` は見えているのが label なので、**表示テキストから + * URL を復元できない** — だからスパンに載せて運ぶ。`http(s)` 以外(`mailto:` / + * 相対リンク)は載せない(`openableUrl` で絞る)ので、値があれば必ず開ける。 + * 実際の当たり判定用の範囲は `spanLinks` がここから組み立てる。 + */ + link?: string; } /** One logical (pre-wrap) line of rendered Markdown. Empty array = a blank line. */ @@ -77,12 +85,25 @@ function inlineSpans(tokens: readonly Token[] | undefined, base: RichSpan): Rich break; case 'link': { const lk = token as Tokens.Link; - out.push(...inlineSpans(lk.tokens, { ...base, underline: true, tone: 'link' })); + out.push( + ...inlineSpans(lk.tokens, { + ...base, + underline: true, + tone: 'link', + link: openableUrl(lk.href), + }), + ); break; } case 'image': { const im = token as Tokens.Image; - out.push({ ...base, underline: true, tone: 'link', text: im.text || im.href }); + out.push({ + ...base, + underline: true, + tone: 'link', + link: openableUrl(im.href), + text: im.text || im.href, + }); break; } case 'br': diff --git a/src/core/mouse.spec.ts b/src/core/mouse.spec.ts index 0c19747..ea3f6a0 100644 --- a/src/core/mouse.spec.ts +++ b/src/core/mouse.spec.ts @@ -6,15 +6,26 @@ const ESC = String.fromCharCode(27); describe('parseSgrMouse', () => { it.each([ // [desc, input, expected] - ['left press (ESC stripped by ink)', '[<0;13;5M', { kind: 'press', x: 12, y: 4 }], - ['left release', '[<0;13;5m', { kind: 'release', x: 12, y: 4 }], - ['press with raw ESC prefix', `${ESC}[<0;1;1M`, { kind: 'press', x: 0, y: 0 }], + [ + 'left press (ESC stripped by ink)', + '[<0;13;5M', + { kind: 'press', x: 12, y: 4, button: 'left' }, + ], + ['left release', '[<0;13;5m', { kind: 'release', x: 12, y: 4, button: 'left' }], + ['press with raw ESC prefix', `${ESC}[<0;1;1M`, { kind: 'press', x: 0, y: 0, button: 'left' }], ['wheel up', '[<64;10;3M', { kind: 'wheel', dir: 'up', x: 9, y: 2 }], ['wheel down', '[<65;10;3M', { kind: 'wheel', dir: 'down', x: 9, y: 2 }], - ['right button press still reports position', '[<2;4;2M', { kind: 'press', x: 3, y: 1 }], + // ボタンの種別も返す: 副作用のある操作(URL を開く)を左ボタンだけに限るため。 + ['middle button press', '[<1;4;2M', { kind: 'press', x: 3, y: 1, button: 'middle' }], + [ + 'right button press still reports position', + '[<2;4;2M', + { kind: 'press', x: 3, y: 1, button: 'right' }, + ], + ['right button release', '[<2;4;2m', { kind: 'release', x: 3, y: 1, button: 'right' }], // ?1002 drag: button 0 + motion bit (32) = 32, reported with a trailing `M`. - ['left-button drag (motion bit 32)', '[<32;5;2M', { kind: 'drag', x: 4, y: 1 }], - ['drag report code 35', '[<35;4;2M', { kind: 'drag', x: 3, y: 1 }], + ['left-button drag (motion bit 32)', '[<32;5;2M', { kind: 'drag', x: 4, y: 1, button: 'left' }], + ['drag report code 35', '[<35;4;2M', { kind: 'drag', x: 3, y: 1, button: 'left' }], ])('%s', (_desc, input, expected) => { expect(parseSgrMouse(input)).toEqual(expected); }); diff --git a/src/core/mouse.ts b/src/core/mouse.ts index 186097a..08d54c7 100644 --- a/src/core/mouse.ts +++ b/src/core/mouse.ts @@ -13,11 +13,31 @@ import { stripLeadingEscape } from './ansi'; * report is swallowed by the view rather than leaking in as literal text.) */ export type MouseEvent = - | { kind: 'press'; x: number; y: number } - | { kind: 'release'; x: number; y: number } - | { kind: 'drag'; x: number; y: number } + | { kind: 'press'; x: number; y: number; button: MouseButton } + | { kind: 'release'; x: number; y: number; button: MouseButton } + | { kind: 'drag'; x: number; y: number; button: MouseButton } | { kind: 'wheel'; dir: 'up' | 'down'; x: number; y: number }; +/** + * どのボタンか(低位 2 ビット)。**副作用のある操作は左ボタンだけに限る**ために持つ + * — ログ内 URL のクリックはブラウザを開くので、右クリック(端末のコンテキストメニューを + * 期待した操作)や中クリック(貼り付け)で開いてはいけない。選択やフォーカス移動は + * どのボタンでも従来どおり受ける(無害なため)。 + */ +export type MouseButton = 'left' | 'middle' | 'right'; + +function buttonOf(code: number): MouseButton { + switch (code & 3) { + case 1: + return 'middle'; + case 2: + return 'right'; + default: + // 0 = 左。3(ボタン無しの移動報告)も左扱いで構わない(drag のみで現れる)。 + return 'left'; + } +} + const SGR_MOUSE = /^\[<(\d+);(\d+);(\d+)([Mm])$/; /** @@ -41,9 +61,9 @@ export function parseSgrMouse(input: string): MouseEvent | undefined { } if (button & 32) { // Button-held motion (a drag) under ?1002 — used to extend a selection. - return { kind: 'drag', x, y }; + return { kind: 'drag', x, y, button: buttonOf(button) }; } - return { kind: m[4] === 'M' ? 'press' : 'release', x, y }; + return { kind: m[4] === 'M' ? 'press' : 'release', x, y, button: buttonOf(button) }; } /** diff --git a/src/core/scroll.spec.ts b/src/core/scroll.spec.ts index 1814ff8..62ef8a5 100644 --- a/src/core/scroll.spec.ts +++ b/src/core/scroll.spec.ts @@ -110,6 +110,84 @@ describe('wrapDisplayLines', () => { }); }); +describe('logLines: クリックできる URL の範囲(links)', () => { + const prefixFor = (kind: LogKind) => (kind === 'user' ? '> ' : ''); + + it('プレーン行の裸 URL に範囲が付き、prefix のぶんずれる', () => { + const [row] = logLines([{ seq: 1, kind: 'user', text: 'see https://x.dev/a' }], 60, prefixFor); + // prefix '> ' が 2 文字。'see ' が 4 文字なので 6.. + expect(row?.text).toBe('> see https://x.dev/a'); + expect(row?.links).toEqual([{ from: 6, to: 21, url: 'https://x.dev/a' }]); + expect(row?.text.slice(6, 21)).toBe('https://x.dev/a'); + }); + + it('URL の無い行に links を付けない(大多数の行のコストをゼロに保つ)', () => { + expect(logLines([{ seq: 2, kind: 'user', text: 'no links' }], 60, prefixFor)[0]?.links).toBe( + undefined, + ); + }); + + it('折り返しで URL が割れても、どちらの行も URL 全体を指す', () => { + // 幅 12・prefix なし → content 12。URL が 2 行に割れる。 + const rows = logLines( + [{ seq: 3, kind: 'system', text: 'https://x.dev/abcdefgh' }], + 12, + prefixFor, + ); + expect(rows.length).toBeGreaterThan(1); + for (const row of rows) { + expect(row.links?.[0]?.url).toBe('https://x.dev/abcdefgh'); + } + // 各行の範囲はその行のテキスト内に収まる + for (const row of rows) { + const link = row.links?.[0]; + expect(link?.from).toBeGreaterThanOrEqual(0); + expect(link?.to).toBeLessThanOrEqual(row.text.length); + } + }); + + it('複数行のうち URL がある行だけに範囲が付く', () => { + const rows = logLines( + [{ seq: 4, kind: 'system', text: 'plain\nhttps://x.dev/a\nplain again' }], + 60, + prefixFor, + ); + expect(rows.map((r) => r.links !== undefined)).toEqual([false, true, false]); + }); + + it('Markdown の [label](url) は label の範囲に href が付く', () => { + const [row] = logLines( + [{ seq: 5, kind: 'assistant_text', text: 'see [docs](https://x.dev/d)' }], + 60, + prefixFor, + ); + expect(row?.text).toBe('see docs'); + expect(row?.links).toEqual([{ from: 4, to: 8, url: 'https://x.dev/d' }]); + }); + + it('コードブロック内の裸 URL も拾う(href が付かない経路の受け皿)', () => { + const rows = logLines( + [{ seq: 6, kind: 'assistant_text', text: '```\nhttps://x.dev/c\n```' }], + 60, + prefixFor, + ); + const hit = rows.find((r) => r.text.includes('https://x.dev/c')); + expect(hit?.links?.[0]?.url).toBe('https://x.dev/c'); + }); + + it('隣り合う別リンクを 1 スパンに畳まない', () => { + const [row] = logLines( + [{ seq: 7, kind: 'assistant_text', text: '[a](https://a.test)[b](https://b.test)' }], + 60, + prefixFor, + ); + expect(row?.links).toEqual([ + { from: 0, to: 1, url: 'https://a.test' }, + { from: 1, to: 2, url: 'https://b.test' }, + ]); + }); +}); + describe('logLines (entries → physical rows)', () => { const prefixFor = (kind: LogKind) => (kind === 'user' ? '> ' : ''); diff --git a/src/core/scroll.ts b/src/core/scroll.ts index 482b208..2e4e451 100644 --- a/src/core/scroll.ts +++ b/src/core/scroll.ts @@ -1,7 +1,9 @@ import stringWidth from 'string-width'; +import { GRAPHEMES } from './graphemes'; import { type RichLine, type RichSpan, renderMarkdown } from './markdown'; import { clamp } from './math'; import type { LogEntry, LogKind } from './types'; +import { detectUrls, type LinkRange, linksInSlice, mergeLinks, spanLinks } from './url'; /** * Where the detail-view log viewport is anchored. @@ -58,13 +60,20 @@ export interface DisplayLine { * code / heading color …) instead of the flat single-color `text`. */ spans?: RichSpan[]; + /** + * この行の中のクリックできる URL の範囲(`text` に対する文字オフセット。prefix / + * 字下げを含む位置)。undefined = リンク無し(大多数の行)。 + * + * 出所は 2 つで、Markdown の `[label](url)` は `RichSpan.link`(表示テキストから + * 復元できないため)、それ以外の裸の URL は `detectUrls`。**折り返しで URL が + * 割れても各行が URL 全体を指す**ので、どちらの行をクリックしても同じ先へ飛べる。 + */ + links?: readonly LinkRange[]; } /** LogKinds whose text is Markdown from the assistant and gets rich rendering. */ const MARKDOWN_KINDS: Partial> = { assistant_text: true }; -const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); - /** * Wrap `text` to physical lines of at most `width` display cells, splitting on * embedded newlines first. Widths are display-based (`string-width`): CJK and @@ -74,26 +83,42 @@ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); */ export function wrapDisplayLines(text: string, width: number): string[] { const out: string[] = []; - for (const logical of text.split(/\r\n|[\r\n\v\f]/)) { - if (width <= 0 || stringWidth(logical) <= width) { - out.push(logical); - continue; + for (const logical of text.split(LINE_BREAK)) { + for (const row of wrapLogical(logical, width)) { + out.push(row); } - let line = ''; - let w = 0; - for (const { segment } of GRAPHEMES.segment(logical)) { - const cw = stringWidth(segment); - if (w + cw > width && line.length > 0) { - out.push(line); - line = segment; - w = cw; - } else { - line += segment; - w += cw; - } + } + return out; +} + +/** 改行の並び。`wrapDisplayLines` と `entryLines` が同じ分割を使うため定数にしてある。 */ +const LINE_BREAK = /\r\n|[\r\n\v\f]/; + +/** + * 改行を含まない 1 論理行を物理行へ折り返す。{@link wrapDisplayLines} の中身で、 + * 別関数にしてあるのは `entryLines` が**論理行単位で**回す必要があるため — URL の + * 検出は論理行に対して行い(折り返しで割れた半分は URL として解析できない)、 + * その範囲を各物理行の座標へ移す。 + */ +function wrapLogical(logical: string, width: number): string[] { + if (width <= 0 || stringWidth(logical) <= width) { + return [logical]; + } + const out: string[] = []; + let line = ''; + let w = 0; + for (const { segment } of GRAPHEMES.segment(logical)) { + const cw = stringWidth(segment); + if (w + cw > width && line.length > 0) { + out.push(line); + line = segment; + w = cw; + } else { + line += segment; + w += cw; } - out.push(line); } + out.push(line); return out; } @@ -104,7 +129,10 @@ function sameRichStyle(a: RichSpan, b: RichSpan): boolean { a.dim === b.dim && a.underline === b.underline && a.strikethrough === b.strikethrough && - a.tone === b.tone + a.tone === b.tone && + // link も比較する: 隣り合う別リンク(`[a](x)[b](y)`)を 1 スパンに畳むと + // どちらの URL で開くのか決まらなくなる。 + a.link === b.link ); } @@ -157,6 +185,26 @@ function safeRenderMarkdown(text: string): RichLine[] | undefined { } } +/** + * 折り返し後の 1 行のリンク範囲。`bare` は**論理行**に対して検出した範囲で、 + * `[consumed, consumed + rowLen)` の部分を行内の座標(先頭に `leadLen` 文字の + * prefix / 字下げが付く)へ移す。`spans` 由来(Markdown の href)を優先し、 + * 重なる裸 URL は捨てる。 + */ +function rowLinks( + spans: readonly RichSpan[] | undefined, + bare: readonly LinkRange[], + consumed: number, + rowLen: number, + leadLen: number, +): readonly LinkRange[] | undefined { + const fromBare = bare.length > 0 ? linksInSlice(bare, consumed, consumed + rowLen, leadLen) : []; + // spans のオフセットは lead を含んだ行テキスト基準なので、そのまま使える。 + const fromSpans = spans ? spanLinks(spans) : []; + const links = fromSpans.length > 0 ? mergeLinks(fromSpans, fromBare) : fromBare; + return links.length > 0 ? links : undefined; +} + /** Expand one entry into its physical rows (see {@link logLines}). */ function entryLines(entry: LogEntry, width: number, prefix: string): DisplayLine[] { const out: DisplayLine[] = []; @@ -167,28 +215,44 @@ function entryLines(entry: LogEntry, width: number, prefix: string): DisplayLine if (rich) { let i = 0; for (const line of rich) { + // 裸 URL の検出は論理行に対して行う(折り返しで割れた半分は URL にならない)。 + // Markdown の autolink はここでは href 付きスパンになっているので、これは + // コードブロック等で href が付かない URL の受け皿。 + const bare = detectUrls(line.map((s) => s.text).join('')); + let consumed = 0; for (const rowSpans of wrapRichLine(line, content)) { const lead = i === 0 ? prefix : indent; const spans = lead ? [{ text: lead } as RichSpan, ...rowSpans] : rowSpans; + const rowLen = rowSpans.reduce((n, s) => n + s.text.length, 0); out.push({ key: `${entry.seq}:${i}`, kind: entry.kind, text: spans.map((s) => s.text).join(''), spans, + links: rowLinks(spans, bare, consumed, rowLen, lead.length), }); + consumed += rowLen; i += 1; } } return out; } - const rows = wrapDisplayLines(entry.text, content); - for (let i = 0; i < rows.length; i += 1) { - out.push({ - key: `${entry.seq}:${i}`, - kind: entry.kind, - text: (i === 0 ? prefix : indent) + rows[i], - }); + let i = 0; + for (const logical of entry.text.split(LINE_BREAK)) { + const bare = detectUrls(logical); + let consumed = 0; + for (const row of wrapLogical(logical, content)) { + const lead = i === 0 ? prefix : indent; + out.push({ + key: `${entry.seq}:${i}`, + kind: entry.kind, + text: lead + row, + links: rowLinks(undefined, bare, consumed, row.length, lead.length), + }); + consumed += row.length; + i += 1; + } } return out; } diff --git a/src/core/text-buffer.spec.ts b/src/core/text-buffer.spec.ts index 92fa56e..fd7ec21 100644 --- a/src/core/text-buffer.spec.ts +++ b/src/core/text-buffer.spec.ts @@ -4,6 +4,7 @@ import { bufferLines, bufferOf, caretIndexForColumn, + charIndexAtColumn, clearBuffer, cursorRowCol, emptyBuffer, @@ -186,7 +187,54 @@ describe('caretIndexForColumn', () => { ['boundary between wide chars', 'あい', 2, 1], ['mixed ascii + cjk', 'fix バグ', 6, 5], // 'fix ' (4 cells) + バ (2 cells) -> before グ ['emoji is a 2-cell surrogate pair', '🍣x', 2, 2], + // グラフェム単位で歩く(コードポイント単位だとここから先が 1 セルずれる)。 + // '⚠️' は U+26A0 + U+FE0F の 2 コードポイント = 1 グラフェム = 2 セル。 + ['VS16 emoji occupies 2 cells', '⚠️ab', 0, 0], + ['click after a VS16 emoji is not shifted', '⚠️ab', 2, 2], + ['click on the char after a VS16 emoji', '⚠️ab', 3, 3], ])('%s', (_desc, text, column, expected) => { expect(caretIndexForColumn(text, column)).toBe(expected); }); + + it('グラフェムの途中に caret を置かない', () => { + // U+FE0F(index 1)を指してはいけない。 + for (const column of [0, 1]) { + expect(caretIndexForColumn('⚠️ab', column)).toBe(0); + } + }); +}); + +describe('charIndexAtColumn(行末より右は当たりにしない)', () => { + it.each([ + ['行頭', 'abc', 0, 0], + ['行内', 'abc', 2, 2], + ['最終セル', 'abc', 2, 2], + ['行末のちょうど右 → undefined', 'abc', 3, undefined], + ['さらに右 → undefined', 'abc', 99, undefined], + ['負の列 → undefined', 'abc', -1, undefined], + ['空文字はどの列でも undefined', '', 0, undefined], + ['全角の 2 セル目は同じ文字', 'あい', 1, 0], + ['全角の行末の右 → undefined', 'あい', 4, undefined], + ])('%s', (_desc, text, column, expected) => { + expect(charIndexAtColumn(text, column)).toBe(expected); + }); + + /** + * Regression: 判定(全体の `stringWidth` = グラフェム基準)と逆算(コードポイント基準)で + * 単位が食い違い、VS16 絵文字より後ろの当たり判定が 1 セルずれていた。結果、URL の + * **手前の空白**をクリックするとブラウザが開き、URL の**最後の文字**は反応しなかった。 + */ + it('VS16 絵文字を含む行でも列と文字が 1 対 1 で対応する', () => { + const text = '⚠️ check https://x.dev/a for details'; + const urlFrom = text.indexOf('https'); + const urlTo = urlFrom + 'https://x.dev/a'.length; + const inUrl = (column: number) => { + const index = charIndexAtColumn(text, column); + return index !== undefined && index >= urlFrom && index < urlTo; + }; + expect(inUrl(urlFrom - 1)).toBe(false); // URL 直前の空白では開かない + expect(inUrl(urlFrom)).toBe(true); // URL の先頭 + expect(inUrl(urlTo - 1)).toBe(true); // URL の末尾の文字も当たる + expect(inUrl(urlTo)).toBe(false); // URL の直後 + }); }); diff --git a/src/core/text-buffer.ts b/src/core/text-buffer.ts index bed8121..0a8c920 100644 --- a/src/core/text-buffer.ts +++ b/src/core/text-buffer.ts @@ -1,4 +1,5 @@ import stringWidth from 'string-width'; +import { GRAPHEMES } from './graphemes'; import { clamp } from './math'; /** @@ -146,22 +147,49 @@ export function moveDown(buf: TextBuffer): TextBuffer { * `column` display cells from the start of `text`. A click anywhere on a wide * (2-cell) character places the caret before it; past the end goes to the end. * Display-width based (`string-width`) so CJK/emoji map correctly. + * + * **グラフェム単位で歩く**(`GRAPHEMES`)。折り返し(`wrapDisplayLines` / + * `wrapComposerRows`)も同じ単位で測っているので、これが厳密な逆写像になる。 + * コードポイント単位だと異体字セレクタ付き絵文字(`⚠️`)で 1 セルずつズレ、 + * グラフェムの途中に caret が入る(詳細は `core/graphemes.ts`)。 */ export function caretIndexForColumn(text: string, column: number): number { - if (column <= 0) { - return 0; + return walkToColumn(text, column).index; +} + +/** + * `caretIndexForColumn` の「**行末より右は当たりにしない**」版。範囲(リンク)の + * 当たり判定用で、行の表示幅より右の列は undefined を返す。 + * + * 「幅を別に測って比べる」のではなく 1 回の走査で判定するのが要点 — 判定と逆算で + * 別の測り方をすると(例: 全体の `stringWidth` とコードポイントごとの合計)単位が + * 食い違って端が 1 セルずれる。 + */ +export function charIndexAtColumn(text: string, column: number): number | undefined { + if (column < 0) { + return undefined; } + const { index, cells } = walkToColumn(text, column); + return column < cells ? index : undefined; +} + +/** + * `column` セル目に来るグラフェムの開始 index と、`text` 全体の表示幅を返す。 + * 列が行末より右のときは `index = text.length`(`cells` と併せて呼び出し側が判定する)。 + */ +function walkToColumn(text: string, column: number): { index: number; cells: number } { let cells = 0; let index = 0; - for (const ch of text) { - const w = stringWidth(ch); - if (cells + w > column) { - return index; + let found: number | undefined = column <= 0 ? 0 : undefined; + for (const { segment } of GRAPHEMES.segment(text)) { + const w = stringWidth(segment); + if (found === undefined && cells + w > column) { + found = index; } cells += w; - index += ch.length; + index += segment.length; } - return text.length; + return { index: found ?? text.length, cells }; } /** diff --git a/src/core/url.spec.ts b/src/core/url.spec.ts new file mode 100644 index 0000000..edea2be --- /dev/null +++ b/src/core/url.spec.ts @@ -0,0 +1,265 @@ +import stringWidth from 'string-width'; +import { describe, expect, it } from 'vitest'; +import { + canHyperlink, + detectUrls, + isOpenableUrl, + linkAt, + linkPieces, + linksInSlice, + mergeLinks, + openableUrl, + osc8, + spanLinks, +} from './url'; + +describe('detectUrls', () => { + const cases: [name: string, text: string, expected: [number, number, string][]][] = [ + ['URL が無い行', 'no links here', []], + ['裸の URL 1 本', 'see https://example.com/a for more', [[4, 25, 'https://example.com/a']]], + [ + '2 本', + 'http://a.test/1 and https://b.test/2', + [ + [0, 15, 'http://a.test/1'], + [20, 36, 'https://b.test/2'], + ], + ], + ['行頭・行全体', 'https://example.com', [[0, 19, 'https://example.com']]], + [ + '文末のピリオドは URL に含めない', + 'go to https://example.com/x.', + [[6, 27, 'https://example.com/x']], + ], + [ + '日本語の句点も削る', + 'ここ https://example.com/x を見て。', + [[3, 24, 'https://example.com/x']], + ], + [ + '釣り合った括弧は URL の一部として残す', + 'https://ja.wikipedia.org/wiki/Foo_(bar)', + [[0, 39, 'https://ja.wikipedia.org/wiki/Foo_(bar)']], + ], + ['釣り合わない閉じ括弧は削る', '(https://example.com/x)', [[1, 22, 'https://example.com/x']]], + ['http/https 以外は拾わない', 'ftp://a.test/x mailto:a@b.test', []], + ['スキームだけ・ホスト無しは拾わない', 'https:// and https://', []], + [ + 'ハイフンやクエリを含む URL を途中で切らない', + 'https://my-host.example.com/a-b?q=1&r=2#frag', + [[0, 44, 'https://my-host.example.com/a-b?q=1&r=2#frag']], + ], + [ + '括弧書きの Markdown リンク末尾の ) は削る', + 'see [x](https://example.com/x)', + [[8, 29, 'https://example.com/x']], + ], + ]; + + it.each(cases)('%s', (_name, text, expected) => { + expect(detectUrls(text)).toEqual(expected.map(([from, to, url]) => ({ from, to, url }))); + }); + + it('検出した範囲は元テキストの URL とちょうど重なる', () => { + const text = 'a https://example.com/p?q=1 b'; + const [link] = detectUrls(text); + expect(link).toBeDefined(); + expect(text.slice(link?.from, link?.to)).toBe(link?.url); + }); +}); + +describe('isOpenableUrl / openableUrl', () => { + it.each([ + ['https://a.test', true], + ['http://a.test/x', true], + ['ftp://a.test', false], + ['mailto:a@b.test', false], + ['file:///etc/passwd', false], + ['javascript:alert(1)', false], + ['https://', false], + [`https://a.test/${'x'.repeat(1100)}`, false], + ])('%s → %s', (url, expected) => { + expect(isOpenableUrl(url)).toBe(expected); + }); + + it('制御文字を含む URL は開かない', () => { + expect(isOpenableUrl(`https://a.test/${String.fromCharCode(27)}[0m`)).toBe(false); + }); + + it('openableUrl は絞り込みに使える', () => { + expect(openableUrl('https://a.test')).toBe('https://a.test'); + expect(openableUrl('mailto:a@b.test')).toBeUndefined(); + expect(openableUrl(undefined)).toBeUndefined(); + }); +}); + +describe('linkAt', () => { + const links = [ + { from: 2, to: 5, url: 'https://a.test' }, + { from: 10, to: 12, url: 'https://b.test' }, + ]; + + it.each([ + [0, undefined], + [1, undefined], + [2, 'https://a.test'], + [4, 'https://a.test'], + [5, undefined], // 排他の終端 + [10, 'https://b.test'], + [12, undefined], + ])('index %i → %s', (index, expected) => { + expect(linkAt(links, index)).toBe(expected); + }); + + it('links なしは undefined', () => { + expect(linkAt(undefined, 3)).toBeUndefined(); + }); +}); + +describe('linksInSlice', () => { + const links = [{ from: 5, to: 15, url: 'https://a.test/long' }]; + + it('スライスに掛かる部分だけを、base 基準へ移して返す', () => { + // 行 [0,10) の部分 → 行内 5..10、prefix 2 文字ぶんずらす + expect(linksInSlice(links, 0, 10, 2)).toEqual([ + { from: 7, to: 12, url: 'https://a.test/long' }, + ]); + }); + + it('折り返しの後半にも URL 全体が残る(半分でも開ける)', () => { + expect(linksInSlice(links, 10, 20, 0)).toEqual([ + { from: 0, to: 5, url: 'https://a.test/long' }, + ]); + }); + + it('掛からないスライスは空', () => { + expect(linksInSlice(links, 20, 30)).toEqual([]); + }); +}); + +describe('mergeLinks', () => { + it('重なる extra は捨て、重ならないものは足して文書順に並べる', () => { + const primary = [{ from: 5, to: 10, url: 'https://href.test' }]; + const extra = [ + { from: 6, to: 9, url: 'https://bare.test' }, // 重なる → 捨てる + { from: 0, to: 3, url: 'https://other.test' }, // 重ならない → 残る + ]; + expect(mergeLinks(primary, extra)).toEqual([ + { from: 0, to: 3, url: 'https://other.test' }, + { from: 5, to: 10, url: 'https://href.test' }, + ]); + }); +}); + +describe('spanLinks', () => { + it('スパンの link から行内の範囲を導く', () => { + expect( + spanLinks([{ text: 'ab' }, { text: 'cde', link: 'https://a.test' }, { text: 'f' }]), + ).toEqual([{ from: 2, to: 5, url: 'https://a.test' }]); + }); + + it('隣り合う同じ URL のスパンは 1 本に繋ぐ', () => { + expect( + spanLinks([ + { text: 'ab', link: 'https://a.test' }, + { text: 'cd', link: 'https://a.test' }, + ]), + ).toEqual([{ from: 0, to: 4, url: 'https://a.test' }]); + }); + + it('別 URL は分ける', () => { + expect( + spanLinks([ + { text: 'ab', link: 'https://a.test' }, + { text: 'cd', link: 'https://b.test' }, + ]), + ).toEqual([ + { from: 0, to: 2, url: 'https://a.test' }, + { from: 2, to: 4, url: 'https://b.test' }, + ]); + }); + + it('空スパンは範囲を作らない', () => { + expect(spanLinks([{ text: '', link: 'https://a.test' }])).toEqual([]); + }); +}); + +describe('linkPieces', () => { + it('links が無ければセグメントそのまま', () => { + expect(linkPieces(['ab', 'cd'])).toEqual([ + { text: 'ab', index: 0 }, + { text: 'cd', index: 1 }, + ]); + }); + + it('リンク境界でセグメントを切り、url を付ける', () => { + const pieces = linkPieces( + ['ab https://a.test cd'], + [{ from: 3, to: 17, url: 'https://a.test' }], + ); + expect(pieces).toEqual([ + { text: 'ab ', index: 0, url: undefined }, + { text: 'https://a.test', index: 0, url: 'https://a.test' }, + { text: ' cd', index: 0, url: undefined }, + ]); + }); + + it('セグメントを跨ぐリンクは各セグメントで切れる(スタイルを保つため)', () => { + // spans = ['see ', 'my link'] で 4..11 が 1 本のリンク + const pieces = linkPieces(['see ', 'my link'], [{ from: 4, to: 11, url: 'https://a.test' }]); + expect(pieces).toEqual([ + { text: 'see ', index: 0, url: undefined }, + { text: 'my link', index: 1, url: 'https://a.test' }, + ]); + }); + + it('連結したテキストは元と一致する(文字を落とさない)', () => { + const segments = ['ab ', 'https://a.test', ' cd https://b.test']; + const links = [ + { from: 3, to: 17, url: 'https://a.test' }, + { from: 22, to: 36, url: 'https://b.test' }, + ]; + expect( + linkPieces(segments, links) + .map((p) => p.text) + .join(''), + ).toBe(segments.join('')); + }); + + it('リンクが 2 本連続していても進む(無限ループしない)', () => { + const pieces = linkPieces( + ['abcd'], + [ + { from: 0, to: 2, url: 'https://a.test' }, + { from: 2, to: 4, url: 'https://b.test' }, + ], + ); + expect(pieces).toEqual([ + { text: 'ab', index: 0, url: 'https://a.test' }, + { text: 'cd', index: 0, url: 'https://b.test' }, + ]); + }); +}); + +describe('osc8', () => { + const ESC = String.fromCharCode(27); + + it('OSC 8 で包む(パラメータ無しの形)', () => { + expect(osc8('https://a.test', 'click')).toBe( + `${ESC}]8;;https://a.test${ESC}\\click${ESC}]8;;${ESC}\\`, + ); + }); + + it('端末が数える表示幅は包む前と同じ(レイアウトを狂わせない)', () => { + // Ink の計測(string-width)と再構築(ansi-tokenize)は OSC 8 を幅 0 として扱う。 + // ここが崩れると当たり判定と描画がズレるので、幅で固定しておく。 + expect(stringWidth(osc8('https://a.test', 'click'))).toBe(stringWidth('click')); + expect(stringWidth(osc8('https://a.test', '日本語'))).toBe(stringWidth('日本語')); + }); + + it('canHyperlink は開ける URL だけを通す', () => { + expect(canHyperlink('https://a.test')).toBe(true); + expect(canHyperlink('mailto:a@b.test')).toBe(false); + expect(canHyperlink(undefined)).toBe(false); + }); +}); diff --git a/src/core/url.ts b/src/core/url.ts new file mode 100644 index 0000000..7c9644b --- /dev/null +++ b/src/core/url.ts @@ -0,0 +1,305 @@ +/** + * URL の検出と、行の中のリンク範囲の表現(純粋)。 + * + * 詳細ビューのログは「クリックで URL を開ける」ようにするため、行ごとに + * {@link LinkRange} を持つ(`DisplayLine.links`)。範囲で持つ理由は 2 つ: + * + * 1. **表示テキストと開く先が違うことがある**。Markdown の `[label](url)` は + * 見えているのが label なので、テキストから URL を復元できない。 + * 2. **折り返しで URL が 2 行に割れても両方の行から開ける**。行ごとに「この + * 範囲は元の URL 全体を指す」と持てば、半分だけの文字列を解析し直さずに済む。 + * + * 当たり判定(`logLinkAt`)と描画(`ui/log-line.tsx`)は同じ範囲を通す。 + */ + +/** + * 1 行のテキスト内のリンク範囲。`from`/`to` は**その行のテキストに対する文字 + * オフセット**(`[from, to)`。`DisplayLine.text` と同じ単位=UTF-16)で、 + * kind の prefix / 継続行の字下げを含んだ位置。`url` は開く先。 + */ +export interface LinkRange { + readonly from: number; + readonly to: number; + readonly url: string; +} + +/** + * 裸の URL を拾う正規表現。空白と、URL に現れない引用・山括弧で止める。制御文字も + * 除外する — ツール出力にはエスケープ列の断片が混ざり得るので、それを URL の一部と + * して端末へ返さないため。閉じ括弧は含めて拾い、あとで {@link trimTrailing} が + * 釣り合いを見て落とす(`.../Foo_(bar)` を壊さないため)。 + */ +const BARE_URL = /\bhttps?:\/\/[^\s<>"']+/g; + +/** 文末の句読点・閉じ括弧。URL の一部ではないことが多いので末尾から削る。 */ +const TRAILING = new Set([ + '.', + ',', + ';', + ':', + '!', + '?', + '"', + "'", + '\u0060', + '*', + '_', + '~', + ')', + ']', + '}', + '>', + '、', + '。', + ',', + '.', + ')', + '】', + '」', + '』', + '?', + '!', +]); + +/** + * URL の長さ上限。tmux は OSC 8 の URI を 1024 バイトで打ち切る(`hyperlinks.c`)ので、 + * それを超えるものはハイパーリンクにしない。ブラウザで開く側の実用上の上限も兼ねる。 + */ +const MAX_URL_CHARS = 1024; + +/** + * 制御文字(C0 / DEL)を含むか。ツール出力にはエスケープ列の断片が混ざり得るので、 + * それを URL の一部として端末やブラウザへ渡さないために弾く。正規表現ではなく + * コードポイントで見る(ソースに生の制御文字を書かないため)。 + */ +function hasControlChar(text: string): boolean { + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) { + return true; + } + } + return false; +} + +function countChar(text: string, ch: string): number { + let n = 0; + for (const c of text) { + if (c === ch) { + n += 1; + } + } + return n; +} + +/** + * 末尾の句読点を削る。ただし**括弧が釣り合っているぶんは URL の一部**として残す + * (`https://ja.wikipedia.org/wiki/Foo_(bar)` の `)` を落とさない)。 + */ +function trimTrailing(url: string): string { + let end = url.length; + while (end > 0) { + const ch = url[end - 1] ?? ''; + if (!TRAILING.has(ch)) { + break; + } + if (ch === ')') { + const head = url.slice(0, end); + if (countChar(head, '(') >= countChar(head, ')')) { + break; // 対応する '(' がある = URL の一部 + } + } + end -= 1; + } + return url.slice(0, end); +} + +/** + * ブラウザで開いてよい URL か。**http(s) のみ**を通す(`file:` / `javascript:` / + * `mailto:` は開かない)。ホスト部が空のものと、長すぎるものも弾く。 + */ +export function isOpenableUrl(url: string): boolean { + if (url.length > MAX_URL_CHARS || hasControlChar(url)) { + return false; + } + const m = /^https?:\/\/([^\s/?#]+)/.exec(url); + return m !== null && (m[1]?.length ?? 0) > 0; +} + +/** 開ける URL ならそれ自身、そうでなければ undefined(Markdown の href の絞り込み用)。 */ +export function openableUrl(url: string | undefined): string | undefined { + return url !== undefined && isOpenableUrl(url) ? url : undefined; +} + +/** + * `text` の中の裸の URL を検出する。返る範囲は文書順で重複しない。 + * + * 検出は表示テキスト基準なので、Markdown の `[label](url)` はここでは拾えない + * (あちらは `RichSpan.link` として `core/markdown.ts` が運ぶ)。 + */ +export function detectUrls(text: string): LinkRange[] { + // ログの大半に URL は無い。正規表現を回す前に安価に落とす(logLines は毎フレーム走る)。 + if (!text.includes('://')) { + return []; + } + const out: LinkRange[] = []; + BARE_URL.lastIndex = 0; + for (let m = BARE_URL.exec(text); m !== null; m = BARE_URL.exec(text)) { + const url = trimTrailing(m[0]); + if (url.length > 0 && isOpenableUrl(url)) { + out.push({ from: m.index, to: m.index + url.length, url }); + } + } + return out; +} + +/** `index` の文字を含むリンク範囲(無ければ undefined)。 */ +function rangeAt(links: readonly LinkRange[] | undefined, index: number): LinkRange | undefined { + if (!links) { + return undefined; + } + for (const link of links) { + if (index >= link.from && index < link.to) { + return link; + } + } + return undefined; +} + +/** `index` の文字に掛かっているリンクの URL(無ければ undefined)。 */ +export function linkAt(links: readonly LinkRange[] | undefined, index: number): string | undefined { + return rangeAt(links, index)?.url; +} + +/** + * `[start, end)` に掛かるリンクを切り出し、`start` が `base` に来るよう再基準化する。 + * 論理行に対して検出した範囲を、折り返し後の 1 行(先頭に prefix / 字下げが付く)の + * 座標へ移すのに使う。URL 自体は切らないので、割れた行のどちらからでも全体が開ける。 + */ +export function linksInSlice( + links: readonly LinkRange[], + start: number, + end: number, + base = 0, +): LinkRange[] { + const out: LinkRange[] = []; + for (const link of links) { + const from = Math.max(link.from, start); + const to = Math.min(link.to, end); + if (to > from) { + out.push({ from: from - start + base, to: to - start + base, url: link.url }); + } + } + return out; +} + +/** + * `primary` を優先して 2 つのリンク集合を統合する。重なる `extra` は捨てる + * (Markdown の href が付いている範囲に、裸 URL の検出結果を二重に載せない)。 + */ +export function mergeLinks( + primary: readonly LinkRange[], + extra: readonly LinkRange[], +): LinkRange[] { + const out = [...primary]; + for (const e of extra) { + if (!primary.some((p) => e.from < p.to && p.from < e.to)) { + out.push(e); + } + } + return out.sort((a, b) => a.from - b.from); +} + +/** + * `{text, link}` の並び(`RichSpan` の列)から行内のリンク範囲を導く。隣り合う同じ + * URL のスパンは 1 本に繋ぐ(`wrapRichLine` がスタイル単位で切っているため、 + * 1 つのリンクが複数スパンに割れていることがある)。 + */ +export function spanLinks( + spans: readonly { readonly text: string; readonly link?: string }[], +): LinkRange[] { + const out: LinkRange[] = []; + let at = 0; + for (const span of spans) { + const len = span.text.length; + if (span.link !== undefined && len > 0) { + const last = out[out.length - 1]; + if (last && last.to === at && last.url === span.link) { + out[out.length - 1] = { from: last.from, to: at + len, url: span.link }; + } else { + out.push({ from: at, to: at + len, url: span.link }); + } + } + at += len; + } + return out; +} + +/** リンク境界で切り出した 1 片。`index` は元セグメントの位置(スタイルを引くため)。 */ +export interface LinkPiece { + readonly text: string; + readonly index: number; + /** この片が属するリンクの URL(リンク外なら undefined)。 */ + readonly url?: string; +} + +/** + * 横並びのセグメント列(1 行の `RichSpan` の text、またはプレーン行の 1 要素)を + * **リンクの境界で**切り分ける。`selectionSlices`(選択境界で切る)と同じ発想で、 + * 段を分けてあるのは 2 つの境界が直交するため — こちらを先に通してから + * `selectionSlices` に渡すと、選択とリンクの両方で正しく切れた片が得られる。 + */ +export function linkPieces(segments: readonly string[], links?: readonly LinkRange[]): LinkPiece[] { + if (!links || links.length === 0) { + return segments.map((text, index) => ({ text, index })); + } + const out: LinkPiece[] = []; + let at = 0; + for (const [index, text] of segments.entries()) { + let pos = 0; + while (pos < text.length) { + const abs = at + pos; + const current = rangeAt(links, abs); + // 次の境界: リンクの中ならその終わり、外なら次のリンクの始まり。 + let end = text.length; + if (current) { + end = Math.min(end, current.to - at); + } else { + for (const link of links) { + if (link.from > abs) { + end = Math.min(end, link.from - at); + } + } + } + out.push({ text: text.slice(pos, end), index, url: current?.url }); + pos = end; + } + at += text.length; + } + return out; +} + +const OSC8_OPEN = '\u001b]8;;'; +const OSC8_ST = '\u001b\\'; + +/** + * OSC 8 ハイパーリンクで包む。対応端末(iTerm2 / VTE / Windows Terminal など)では + * これで Cmd/Ctrl+click がネイティブに効く。**あくまで上乗せ**で、主経路は codiva 自身が + * クリックを取って開く方(`logLinkAt` → `openUrl`)— 主端末の Ghostty は + * マウスレポート中はリンク検出そのものを止めるため、OSC 8 だけでは開けない。 + * + * 非対応端末は未知の OSC を黙って捨てるので、出しても表示は壊れない。 + * + * **必ず描画時にだけ通す**。`LogEntry.text` や `RichSpan.text` にこのエスケープを + * 混ぜてはいけない — `wrapDisplayLines` / `wrapRichLine` はエスケープを可視幅として + * 数えるので、折り返し位置が壊れて URI が行の途中で断ち切られる。 + * パラメータ形(`id=`)は使わない(wrap-ansi 10 が壊す)。 + */ +export function osc8(url: string, text: string): string { + return `${OSC8_OPEN}${url}${OSC8_ST}${text}${OSC8_OPEN}${OSC8_ST}`; +} + +/** OSC 8 として出してよい URL か(開ける形式で、長さが端末側の上限に収まる)。 */ +export function canHyperlink(url: string | undefined): url is string { + return url !== undefined && isOpenableUrl(url); +} diff --git a/src/index.tsx b/src/index.tsx index 949f65f..114a1fe 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -214,7 +214,7 @@ async function main(): Promise { trainingOptIn={trainingOptIn} updater={updater} loadBranch={() => worktrees.currentBranch()} - onOpenPr={openUrl} + onOpenUrl={openUrl} onCopy={(text) => copyToClipboard(text)} />, { exitOnCtrlC: false }, diff --git a/src/ui/log-line.tsx b/src/ui/log-line.tsx index 1d48c46..c53bcde 100644 --- a/src/ui/log-line.tsx +++ b/src/ui/log-line.tsx @@ -1,8 +1,12 @@ import { Text } from 'ink'; import type { FC } from 'react'; import { + canHyperlink, type DisplayLine, + type LinkPiece, type LogEntry, + linkPieces, + osc8, type RichSpan, type RowSelection, selectionSlices, @@ -23,36 +27,58 @@ export const LOG_PREFIX: Record = { /** Kinds rendered dimmed (secondary transcript lines). */ const LOG_DIM: Partial> = { tool_result: true }; +/** + * リンクの片を OSC 8 で包む(対応端末ではこれで Cmd/Ctrl+click がネイティブに効く)。 + * 主経路はあくまで codiva 自身がクリックを取って開く方 — 主端末の Ghostty は + * マウス捕捉中にリンク検出を止めるので、OSC 8 だけでは開けない。 + * + * ここ(**描画時**)で初めてエスケープを混ぜるのが要点。`DisplayLine.text` に + * 入れると `wrapDisplayLines` がエスケープを可視幅として数えて折り返しが壊れる。 + */ +function linkedText(piece: LinkPiece): string { + return canHyperlink(piece.url) ? osc8(piece.url, piece.text) : piece.text; +} + // Styled Markdown row: assistant text is rendered to per-span styling in core // (bold/italic/code/heading color …). Each span becomes a nested ; the // `tone` maps to a theme color, everything else is a boolean Ink text prop. -// 選択範囲はスパンの境界と一致しないので、純粋な `selectionSlices` でスパンを選択境界で -// 切り直してから描く(ヘッダの `rowPieces` と同じ仕組み)。反転する片では dim を落とす -// — 反転 + dim は読めなくなる。 -const RichLogLine: FC<{ spans: RichSpan[]; sel?: RowSelection }> = ({ spans, sel }) => ( - - {selectionSlices( - spans.map((s) => s.text), - sel, - ).map((piece) => { - const s = spans[piece.index]; - return ( - - {piece.text} - - ); - })} - -); +// 選択範囲とリンク範囲はどちらもスパンの境界と一致しないので、純粋な関数で 2 段に +// 切り直してから描く: まず `linkPieces`(リンク境界)、次に `selectionSlices`(選択境界)。 +// 反転する片では dim を落とす — 反転 + dim は読めなくなる。 +const RichLogLine: FC<{ line: DisplayLine; sel?: RowSelection }> = ({ line, sel }) => { + const spans: readonly RichSpan[] = line.spans ?? []; + const parts = linkPieces( + spans.map((s) => s.text), + line.links, + ); + return ( + + {selectionSlices( + parts.map((p) => p.text), + sel, + ).map((slice) => { + const part = parts[slice.index]; + const s = part ? spans[part.index] : undefined; + const piece: LinkPiece = { text: slice.text, index: slice.index, url: part?.url }; + return ( + + {linkedText(piece)} + + ); + })} + + ); +}; /** * 空行を描くための最小の中身。Ink の `measureText('')` は **高さ 0** を返すため、 @@ -76,34 +102,43 @@ const BLANK_ROW = ' '; * 空行(`text` が空)はどちらの経路でも高さ 0 になるので BLANK_ROW で埋める。 * * `sel` はマウスのドラッグ選択がこの行に掛かっている範囲(`logRowSelection` の結果)。 - * 掛かっていない行は従来どおり 1 つの `` で描く(余計な入れ子を作らない)。 + * `line.links` はクリックできる URL の範囲(`core/url.ts`)。どちらも無い行は従来どおり + * 1 つの `` で描く(余計な入れ子を作らない)。 */ export const LogLine: FC<{ line: DisplayLine; sel?: RowSelection }> = ({ line, sel }) => { if (line.text.length === 0) { return {BLANK_ROW}; } if (line.spans && line.spans.length > 0) { - return ; + return ; } const dim = LOG_DIM[line.kind]; - if (!sel) { + if (!sel && !line.links) { return ( {line.text} ); } + const parts = linkPieces([line.text], line.links); return ( - {selectionSlices([line.text], sel).map((piece) => ( - - {piece.text} - - ))} + {selectionSlices( + parts.map((p) => p.text), + sel, + ).map((slice) => { + const url = parts[slice.index]?.url; + return ( + + {linkedText({ text: slice.text, index: slice.index, url })} + + ); + })} ); }; diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx index 4a39441..304b65c 100644 --- a/src/ui/session-detail.tsx +++ b/src/ui/session-detail.tsx @@ -22,6 +22,7 @@ import { logEdgeAt, logEdgePoint, logLines, + logLinkAt, logRowSelection, logViewportRows, logWindow, @@ -82,7 +83,16 @@ export const SessionDetail: FC<{ onBack: () => void; /** マウス選択(コンポーザ・ログ)をクリップボードへコピーする(index.tsx が OSC 52 を注入)。 */ onCopy?: (text: string) => void; -}> = ({ manager, id, models, onBack, onCopy }) => { + /** + * ログ内の URL をブラウザで開く(index.tsx が `openUrl` を注入)。 + * + * 端末任せ(Cmd+click)にできないのは、主端末の Ghostty がマウス捕捉中はリンク検出 + * そのものを止めるため。SGR マウスレポートに Cmd/Super のビットも無いので、 + * **codiva 自身がクリックを取って開く**のが全端末で唯一同じに動く経路になる + * (OSC 8 は対応端末向けの上乗せ。`ui/log-line.tsx`)。 + */ + onOpenUrl?: (url: string) => void; +}> = ({ manager, id, models, onBack, onCopy, onOpenUrl }) => { const m = useMessages(); const sessions = useSessions(manager); const mode = useRunMode(manager); @@ -95,6 +105,13 @@ export const SessionDetail: FC<{ const logSel = useLogDragSelection(onCopy); // ドラッグが可視域の外へ出ている向き。ここにあるあいだ自動スクロールし続ける。 const [edge, setEdge] = useState(undefined); + /** + * press した位置にあった URL。**離すまで開かない**ための保留で、途中で drag が + * 来たら取り消す(範囲選択のつもりの操作でブラウザを開かないため)。state ではなく + * ref なのは、同一 tick に複数のマウスレポートがまとまって届いても順に読めるように + * するため(`bufferRef` / `anchorRef` と同じ理由)。 + */ + const pendingLinkRef = useRef(undefined); const composerRef = useRef(null); const composerBox = useAbsolutePosition(composerRef); // 入力欄の折り返し幅(実測)。PromptInput が描いた折り返しと同じ値でクリック位置の @@ -399,6 +416,13 @@ export const SessionDetail: FC<{ // editText に流れ込み「スクロールしようとすると文字が入力される」のを防ぐ)。 const mouse = parseSgrMouse(rawInput); if (mouse) { + // モーダル表示中はマウスも飲む(一覧の `session-list.tsx` と同じ方針)。 + // `parseSgrMouse` で弾くのは自分のハンドラを守るだけで、同じ生入力は兄弟の + // useInput にも届く。飲まないとダイアログ上の 1 クリックで背後のログの選択が + // 動き、URL の上ならブラウザまで開いてしまう(許可待ちの最中に)。 + if (modelSelect || pending) { + return; + } if (mouse.kind === 'wheel') { applyAnchor( mouse.dir === 'up' @@ -410,12 +434,21 @@ export const SessionDetail: FC<{ // 範囲選択を始める(どちらでもなければ両方のハイライトを解除)。 const index = composerCaretAt(mouse.x, mouse.y); if (index !== undefined) { + pendingLinkRef.current = undefined; updateBuffer(bufferOf(bufferRef.current.value, index)); sel.begin(index); clearLogSelection(); } else { sel.clear(); setEdge(undefined); + // URL の上で押したら「離すまでドラッグしなければ開く」候補として覚える。 + // 押した時点では開かない — ドラッグで範囲選択を始めた場合に開いてしまう。 + // **左ボタンだけ**: 右クリック(端末のコンテキストメニューを期待した操作)や + // 中クリック(貼り付け)でブラウザを開くのは意図しない副作用になる。 + pendingLinkRef.current = + logView && mouse.button === 'left' + ? logLinkAt(lines, logView, mouse.x, mouse.y) + : undefined; const point = logAnchorAt(mouse.x, mouse.y); if (point) { logSel.begin(point); @@ -424,6 +457,8 @@ export const SessionDetail: FC<{ } } } else if (mouse.kind === 'drag') { + // ドラッグになった = 範囲選択なので、リンクを開く候補は取り消す。 + pendingLinkRef.current = undefined; if (sel.dragging()) { const index = composerCaretAt(mouse.x, mouse.y); if (index !== undefined) { @@ -439,6 +474,12 @@ export const SessionDetail: FC<{ sel.end(bufferRef.current.value); logSel.end(lines); setEdge(undefined); + // ドラッグにならずに URL の上で離した = 単なるクリック → ブラウザで開く。 + const url = pendingLinkRef.current; + pendingLinkRef.current = undefined; + if (url !== undefined && onOpenUrl) { + onOpenUrl(url); + } } return; } @@ -449,6 +490,8 @@ export const SessionDetail: FC<{ // 何かキーが来たらマウス選択のハイライトは消す(自動スクロールも止める)。 sel.clear(); clearLogSelection(); + // press の release が届かないまま(端末外で離した等)保留が残るのを防ぐ。 + pendingLinkRef.current = undefined; // 立て直しの結果表示は次の操作で引っ込める(エラーと違い一過性の通知)。 recovery.setNotice(undefined); // The model picker is modal: its own useInput owns arrows/Enter/Esc. Swallow diff --git a/tests/app.test.tsx b/tests/app.test.tsx index 38ef5b3..29cc0fe 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -1081,10 +1081,11 @@ describe('App detail view (in-app connection)', () => { columns = 80, textFor: (i: number) => string = (i) => `log-${String(i).padStart(2, '0')}`, onCopy?: (text: string) => void, + onOpenUrl?: (url: string) => void, ) { const { manager, out } = drivenManager(); const { app, stdin, lastFrame } = renderFullscreen( - , + , rows, columns, ); @@ -1128,6 +1129,97 @@ describe('App detail view (in-app connection)', () => { const dragTo = (col: number, row: number) => `\x1b[<32;${col + 1};${row + 1}M`; const release = (col: number, row: number) => `\x1b[<0;${col + 1};${row + 1}m`; + /** + * 詳細ログの URL クリック。端末任せ(Cmd+click)にできない理由は + * `SessionDetail.onOpenUrl` のコメント参照 — Ghostty はマウス捕捉中にリンク検出を + * 止め、SGR に Cmd のビットも無いので、codiva 自身がクリックを取るしかない。 + */ + async function detailWithUrl(url: string) { + const opened: string[] = []; + const ctx = await detailWithLog( + 3, + 24, + 80, + (i) => (i === 1 ? `open ${url} now` : `log-${i}`), + undefined, + (u) => opened.push(u), + ); + const frame = stripAnsi(ctx.lastFrame()).split('\n'); + const row = frame.findIndex((l) => l.includes(url)); + expect(row).toBeGreaterThanOrEqual(0); + const col = frame[row]?.indexOf(url) ?? -1; + expect(col).toBeGreaterThanOrEqual(0); + return { ...ctx, opened, row, col, urlLen: url.length }; + } + + it('詳細ログの URL をクリックするとブラウザで開く', async () => { + const url = 'https://example.com/pr/1'; + const { app, stdin, opened, row, col } = await detailWithUrl(url); + + // URL の上で press → 動かさずに release = 単なるクリック。 + stdin.write(press(col + 2, row)); + await flush(); + stdin.write(release(col + 2, row)); + await flush(); + expect(opened).toEqual([url]); + app.unmount(); + }, 30000); + + it('URL の外・行末より右のクリックでは開かない', async () => { + const url = 'https://example.com/pr/1'; + const { app, stdin, opened, row, col, urlLen } = await detailWithUrl(url); + + // URL の手前('open ' の上) + stdin.write(press(col - 2, row)); + await flush(); + stdin.write(release(col - 2, row)); + await flush(); + // 行末よりずっと右の余白 + stdin.write(press(col + urlLen + 30, row)); + await flush(); + stdin.write(release(col + urlLen + 30, row)); + await flush(); + expect(opened).toEqual([]); + app.unmount(); + }, 30000); + + it('右クリック・中クリックでは開かない(副作用は左ボタンだけ)', async () => { + const url = 'https://example.com/pr/1'; + const { app, stdin, opened, row, col } = await detailWithUrl(url); + + for (const button of [1, 2]) { + // 中(1)/右(2) ボタンの press → release。座標は URL の上。 + stdin.write(`\x1b[<${button};${col + 3};${row + 1}M`); + await flush(); + stdin.write(`\x1b[<${button};${col + 3};${row + 1}m`); + await flush(); + } + expect(opened).toEqual([]); + + // 左ボタンなら開く(同じ座標なので、当たり判定ではなくボタンで弾いている証拠)。 + stdin.write(press(col + 3, row)); + await flush(); + stdin.write(release(col + 3, row)); + await flush(); + expect(opened).toEqual([url]); + app.unmount(); + }, 30000); + + it('ドラッグして範囲選択したときは開かない(選択とクリックを混同しない)', async () => { + const url = 'https://example.com/pr/1'; + const { app, stdin, opened, row, col } = await detailWithUrl(url); + + // URL の上から押して、動かしてから離す = 範囲選択。開いてはいけない。 + stdin.write(press(col + 2, row)); + await flush(); + stdin.write(dragTo(col + 8, row)); + await flush(); + stdin.write(release(col + 8, row)); + await flush(); + expect(opened).toEqual([]); + app.unmount(); + }, 30000); + // Regression (詳細画面のログが上部にスクロールできない): the window was sized from // the whole terminal rather than the log viewport, so every frame overflowed and // rows silently vanished from the middle of the log; and the anchor could fall diff --git a/tests/helpers.ts b/tests/helpers.ts index 886aafa..e894c5d 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -10,16 +10,23 @@ import type { CreateSessionInput, SessionState } from '@/core/types'; export const flush = (ms = 150): Promise => new Promise((r) => setTimeout(r, ms)); // 制御文字を正規表現リテラルに直接書くと Biome の noControlCharactersInRegex に触れるので組み立てる。 -const SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); +const ESC = String.fromCharCode(27); +const SGR = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); +/** + * OSC(`ESC ] … BEL` / `ESC ] … ESC \\`)。詳細ログの URL は OSC 8 ハイパーリンクで + * 包まれて出るので、これを外さないと**エスケープの中の URL 文字列**が + * `indexOf` に引っかかり、そこから求めた列は表示位置とまるで違う場所になる。 + */ +const OSC = new RegExp(`${ESC}\\][\\s\\S]*?(?:${String.fromCharCode(7)}|${ESC}\\\\)`, 'g'); /** - * Drop SGR (color/style) escapes from a frame. Necessary whenever a test derives a - * *column* from `lastFrame()` (e.g. to synthesize a mouse report): with colors - * enabled the raw string index includes escape sequences, so the click would land - * somewhere else entirely. + * Drop SGR (color/style) and OSC escapes from a frame. Necessary whenever a test + * derives a *column* from `lastFrame()` (e.g. to synthesize a mouse report): with + * colors (or OSC 8 links) enabled the raw string index includes escape sequences, + * so the click would land somewhere else entirely. */ export function stripAnsi(frame: string): string { - return frame.replace(SGR, ''); + return frame.replace(OSC, '').replace(SGR, ''); } /** A no-op WorktreeService that reports predictable slugs/paths for the fakes. */