From 065eb810b5a0f5a6811347333399d91b25bfd630 Mon Sep 17 00:00:00 2001 From: takecchi Date: Sun, 19 Jul 2026 19:15:11 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A9=B3=E7=B4=B0=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=81=AE=E3=83=9E=E3=82=A6=E3=82=B9=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=AB=E3=81=A7=E6=96=87=E5=AD=97=E5=85=A5?= =?UTF-8?q?=E5=8A=9B=E3=81=95=E3=82=8C=E3=82=8B=E4=B8=8D=E5=85=B7=E5=90=88?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit セッション詳細の useInput がマウスの SGR レポートを解釈しておらず、 マウスホイールでスクロールしようとするとレポート列(`[<64;..M` 等)が 生テキストとしてコンポーザへ入力されていた。一覧と同じく useInput 先頭で `parseSgrMouse` により先取り解釈し、ホイールはログスクロール(新設の `WHEEL_SCROLL_ROWS`)に割り当てて漏れを防ぐ。 あわせて詳細ビューのレイアウトを「コンテンツ(スクロール可能なログ)+ フッタ(コンポーザ、固定)」に変更し、ステータスヘッダ(タイトル/バッジ/ 進捗/コスト/エラー)を撤去してログ用の縦幅を最大化。ヘッダ撤去に伴い `DETAIL_CHROME_ROWS` を 10→7 に調整。 - src/ui/session-detail.tsx: マウス先取り解釈 + ホイールスクロール、ヘッダ撤去 - src/core/scroll.ts: WHEEL_SCROLL_ROWS を追加 - src/core/layout.ts: DETAIL_CHROME_ROWS を調整 - tests/app.test.tsx: ホイールが文字入力に漏れない回帰テストを追加、ヘッダ撤去に追従 - docs/ARCHITECTURE.md, .claude/rules/ink-components.md: 実装に追従 --- .claude/rules/ink-components.md | 8 +++- docs/ARCHITECTURE.md | 7 +++- src/core/layout.ts | 9 +++-- src/core/scroll.ts | 8 ++++ src/ui/session-detail.tsx | 71 ++++++++++++--------------------- tests/app.test.tsx | 32 ++++++++++++++- 6 files changed, 80 insertions(+), 55 deletions(-) diff --git a/.claude/rules/ink-components.md b/.claude/rules/ink-components.md index 11c6768..affed2e 100644 --- a/.claude/rules/ink-components.md +++ b/.claude/rules/ink-components.md @@ -59,9 +59,13 @@ - ビュー切替は `App` の `View` state(`{mode:'list'}` | `{mode:'detail', id}`)。Enter/→ で `onOpen(id)`、 Esc で `onBack`。詳細ビューは単一 `useInput` の state machine(panel = input | actions)で、 タイピング(追加指示)と操作キー(m/d = マージ/破棄)の衝突を防ぐ。 +- 詳細ビューは**ステータスヘッダを持たない**。コンテンツ(ログ)+ フッタ(コンポーザ)だけにし、 + ログ用の縦幅を最大化する(一覧はヘッダ=Banner + コンテンツ + フッタだが、詳細はヘッダ抜き)。 - ログは末尾ビューポート(`justifyContent="flex-end"` + `overflowY="hidden"`)に描き、`` は使わない - (全画面では画面外へ消えるため)。PgUp/PgDn のスクロールは純関数 `core/scroll.ts` - (`logWindow`/`scrollUp`/`scrollDown`)に委譲し、移動量は可視ログ高さ(`logViewportRows`)から導く。 + (全画面では画面外へ消えるため)。PgUp/PgDn とマウスホイールのスクロールは純関数 `core/scroll.ts` + (`logWindow`/`scrollUp`/`scrollDown`)に委譲し、移動量は可視ログ高さ(`logViewportRows`)/ホイールは + `WHEEL_SCROLL_ROWS` から導く。**マウスホイールのレポート列は `parseSgrMouse` で useInput 先頭で先取り解釈** + する(一覧と同じ)。これをしないとホイールのエスケープ列が生テキストとしてコンポーザへ入力されてしまう。 - 1 SDK セッション 1 ライター。詳細ビューを開いても codiva が唯一のライターであり続ける (外部 CLI との二重接続はしない)。マージ/破棄は一覧・詳細のどちらからでも可能。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 92ef78b..1ce79fb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -166,8 +166,11 @@ Claude Code の実画面に寄せる: 画面は**端末の縦幅いっぱい** `composer`(起動時既定。タイピング + 矢印キャレット移動)と `list`(↑↓選択・Enter/→ = 詳細を開く・ m/d = マージ/破棄)の2ゾーンで Tab 切替。選択セッションの `PermissionDialog` は list フォーカス時のみ アクティブ。マウスクリック(`core/mouse.ts` + `useAbsolutePosition`)で行選択・キャレット移動。 -- `SessionDetail`: 詳細画面。SDK セッションに**直結**し、末尾ビューポートにログを描画(`core/scroll.ts` の - `logWindow`/`scrollUp`/`scrollDown` で PgUp/PgDn スクロール)、`streamingText` のタイピング風プレビュー、 +- `SessionDetail`: 詳細画面。**ステータスヘッダは持たず**、コンテンツ(末尾ビューポートのログ)+ フッタ + (追加指示コンポーザ)だけの構成。SDK セッションに**直結**し、末尾ビューポートにログを描画(`core/scroll.ts` の + `logWindow`/`scrollUp`/`scrollDown` で PgUp/PgDn とマウスホイール(`WHEEL_SCROLL_ROWS`)スクロール。 + ホイールは `parseSgrMouse` で先取り解釈し、レポート列がコンポーザへ文字入力として漏れないようにする)、 + `streamingText` のタイピング風プレビュー、 下部の追加指示コンポーザ(`manager.send(id, text)`)を持つ。Tab で入力↔操作パネルを切替し、 操作パネルで m/d = マージ/破棄。`pendingPermission` があれば `PermissionDialog` に委譲。単一 `useInput` の state machine(panel = input | actions)でタイピングとキー操作の衝突を防ぐ。 diff --git a/src/core/layout.ts b/src/core/layout.ts index 441a185..7ebeaa1 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -17,11 +17,12 @@ export function isFullscreenViewport(rows: number): boolean { } /** - * 詳細ビューでログ以外に消費される固定の縦幅(ステータスヘッダ + 余白 + 入力欄 + - * フッタ)のおおよその見積り。実測値の下限をやや大きめに取る(過小評価すると - * スクロール1回の移動量が実際の可視ログ高さを超え、未表示の行を飛ばしてしまうため)。 + * 詳細ビューでログ以外に消費される固定の縦幅(余白 + スクロールヒント + 入力欄 + + * フッタ)のおおよその見積り。ステータスヘッダを廃したぶん従来より小さい。 + * 実測値の下限をやや大きめに取る(過小評価するとスクロール1回の移動量が実際の + * 可視ログ高さを超え、未表示の行を飛ばしてしまうため)。 */ -export const DETAIL_CHROME_ROWS = 10; +export const DETAIL_CHROME_ROWS = 7; /** * 詳細ビューで実際にログが見える行数のおおよその見積り。端末全体の rows から diff --git a/src/core/scroll.ts b/src/core/scroll.ts index 44731c9..c0bffef 100644 --- a/src/core/scroll.ts +++ b/src/core/scroll.ts @@ -31,6 +31,14 @@ export function pageStep(rows: number): number { return Math.max(1, Math.floor(Math.max(1, rows) / 2)); } +/** + * The `rows` value passed to {@link scrollUp}/{@link scrollDown} for a single + * mouse-wheel tick. `pageStep` halves it, so this yields ~3 lines per tick — a + * fine-grained step (terminals emit several wheel reports per physical scroll), + * distinct from PageUp/PageDown's half-viewport jump. + */ +export const WHEEL_SCROLL_ROWS = 6; + /** * Resolve an anchor into a concrete window over `messages`. At most ~`rows` * entries are rendered (Ink would otherwise render the whole, possibly huge, log); diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx index a3bdc9b..6bb4b27 100644 --- a/src/ui/session-detail.tsx +++ b/src/ui/session-detail.tsx @@ -3,21 +3,21 @@ import { type FC, useEffect, useRef, useState } from 'react'; import { type DiffStat, emptyBuffer, - formatUsd, type LogEntry, logViewportRows, logWindow, + parseSgrMouse, type ScrollAnchor, type SessionManager, scrollDown, scrollUp, type TextBuffer, + WHEEL_SCROLL_ROWS, } from '@/core'; import { useRunMode, useSessions } from './hooks'; import { useMessages } from './i18n-context'; import { editText, resolveEnter } from './input'; import { PermissionDialog } from './permission-dialog'; -import { ProgressBadge } from './progress-badge'; import { PromptInput } from './prompt-input'; import { StatusFooter } from './status-footer'; import { glyph, theme } from './theme'; @@ -130,7 +130,23 @@ export const SessionDetail: FC<{ }); }; + const total = session?.messages.length ?? 0; + useInput((input, key) => { + // SGR マウスレポートはキー入力より先に解釈する。これをしないと(マウス有効時に) + // ホイールスクロールのエスケープ列が生テキストとして editText に流れ込み、 + // 「スクロールしようとすると文字が入力される」バグになる(一覧の useInput と同じ対策)。 + const mouse = parseSgrMouse(input); + if (mouse) { + if (mouse.kind === 'wheel') { + setAnchor((a) => + mouse.dir === 'up' + ? scrollUp(a, total, WHEEL_SCROLL_ROWS) + : scrollDown(a, total, WHEEL_SCROLL_ROWS), + ); + } + return; // press/release はログビューでは無視(クリック操作はない) + } if (key.escape) { if (confirm) { setConfirm(null); @@ -157,11 +173,11 @@ export const SessionDetail: FC<{ // step is derived from the *visible* log height, not the full terminal, so a // page never jumps past unseen lines. if (key.pageUp) { - setAnchor((a) => scrollUp(a, session?.messages.length ?? 0, logViewportRows(rows))); + setAnchor((a) => scrollUp(a, total, logViewportRows(rows))); return; } if (key.pageDown) { - setAnchor((a) => scrollDown(a, session?.messages.length ?? 0, logViewportRows(rows))); + setAnchor((a) => scrollDown(a, total, logViewportRows(rows))); return; } if (confirm) { @@ -212,7 +228,6 @@ export const SessionDetail: FC<{ ); } - const activeForm = session.todos.find((t) => t.status === 'in_progress')?.activeForm; const footerHint = pending ? m.detail.helpPending : panel === 'actions' @@ -223,48 +238,14 @@ export const SessionDetail: FC<{ return ( - {/* ステータスヘッダ(画面上部に固定) */} - - - {glyph.star} - {session.title} - - - {' '} - {session.branch} - - - - {session.progress ? ( - - {m.detail.progress(session.progress.done, session.progress.total, activeForm)} - - ) : null} - - {session.totalCostUsd ? ( - {m.detail.cost(formatUsd(session.totalCostUsd))} - ) : null} - - {session.error ? ( - - {m.detail.errorLabel}: {session.error} - - ) : null} - - {/* - * メッセージログの末尾ビューポート。flexGrow で残り高さを占め、 - * justifyContent="flex-end" + overflowY="hidden" で「最新行が下端、 - * 溢れた古い行は上へクリップ」にする。 はスクロールバック側に - * 書くため全画面レイアウトでは画面外に消えてしまい使えない。 + * ヘッダは持たない(要件: セッション詳細はコンテンツ + フッタのみ)。 + * メッセージログの末尾ビューポートが上端いっぱいまで残り高さを占める。 + * flexGrow で残りを占め、justifyContent="flex-end" + overflowY="hidden" で + * 「最新行が下端、溢れた古い行は上へクリップ」にする。 はスクロール + * バック側に書くため全画面レイアウトでは画面外に消えてしまい使えない。 */} - + {win.entries.map((entry) => ( ))} diff --git a/tests/app.test.tsx b/tests/app.test.tsx index 4112fc3..1a9db05 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -517,15 +517,43 @@ describe('App detail view (in-app connection)', () => { await flush(); stdin.write('\r'); // Enter → open detail in-app (no external CLI) await flush(); - // Detail chrome: the follow-up composer placeholder + the session title header. + // Detail chrome: the follow-up composer placeholder is shown, and the list + // composer placeholder is gone (no status header — content + footer only). expect(lastFrame()).toContain('追加の指示を入力'); - expect(lastFrame()).toContain('open me'); + expect(lastFrame()).not.toContain('実装してほしいこと'); stdin.write('\x1b'); // Esc → back to the list await flush(); expect(lastFrame()).toContain('実装してほしいこと'); // list composer placeholder }); + it('mouse-wheel reports scroll the log instead of typing into the composer', async () => { + const { manager, out } = drivenManager(); + const { stdin, lastFrame } = render(); + stdin.write('scroll me'); + await flush(); + stdin.write('\r'); + await flush(); + out.push(asMsg({ type: 'system', subtype: 'init', session_id: 'sdk-w' })); + await flush(); + + stdin.write('\t'); // focus the list + await flush(); + stdin.write('\r'); // open detail + await flush(); + + // Wheel up/down SGR reports (button 64/65). They must be consumed as scroll + // gestures, never inserted as text — the composer stays empty (placeholder shown). + stdin.write('\x1b[<64;10;3M'); + await flush(); + stdin.write('\x1b[<65;10;3M'); + await flush(); + + const frame = lastFrame(); + expect(frame).toContain('追加の指示を入力'); // empty composer → placeholder still visible + expect(frame).not.toMatch(/64|65/); // no escape-report fragments leaked as text + }); + it('sends a follow-up from the detail composer to the live session', async () => { const { manager, out } = drivenManager(); const { stdin, lastFrame } = render();