From 870c36a55685a447eb738a0ab4c3c7642788fbe3 Mon Sep 17 00:00:00 2001 From: takecchi Date: Fri, 7 Aug 2026 18:11:52 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A9=B3=E7=B4=B0=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E3=82=92=E4=B8=8A=E3=81=B8=E3=82=B9=E3=82=AF=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=81=99=E3=82=8B=E3=81=A8=E3=82=AC=E3=82=AF=E3=82=AC?= =?UTF-8?q?=E3=82=AF=E3=81=99=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E7=9B=B4?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ログの可視域の高さが「スクロール位置」と「ストリーミング中かどうか」で 1 行ぶん変わっていたため、見えているログ全体が跳ねていた。 原因は、ログの直下に出る 1 行を条件付きで描いていたこと: - ストリーミングのプレビュー行はログの可視域を**共有**していて、描くときだけ `logCap` から 1 行引いていた - 「過去ログを表示中」の案内はログ枠の外に**条件付きで**現れていた(flex の 兄弟なので、出ると flexGrow のログ領域が 1 行縮む) 結果、末尾から ↑ を 1 回押しても案内行が増えたぶんビューポートが縮むだけで 上端の行は動かず(1 回目のキーが効いていないように見える)、ターンが流れ始める / 終わるたびにプレビュー行が出入りしてログ全体が上下に揺れていた。 ログ直下を**常に 1 行**の状態行にして解決する。中身の選択は純粋な `logStatusRow`(`core/scroll.ts` の `LogStatusRow` = preview / scrollback / idle。 該当なしは空行)に寄せ、UI は 3 分岐を描くだけにした。これで `logCap` は 実測した可視高さそのものになり、`capFor` とプレビューのぶんの引き算は不要になる。 一覧の `listView` がインジケータに 1 行を予約して描画行数を常に `cap` に保って いるのと同じ考え方。 ターンごとに出入りする操作ヒント行(Ctrl+C / 再開 / 認証)も同じ理由で常に 1 行 にした。状態行がフッタとの間の余白も兼ねるので、下のブロックの marginTop は外す (ログの高さは従来の「末尾追従時」と同じまま)。 - `LogViewport.preview` は意味を失ったので削除(プレビューは可視域の外になった) - `DETAIL_CHROME_ROWS` の内訳コメントを実態に合わせて更新 --- .claude/rules/ink-components.md | 19 ++++++-- docs/ARCHITECTURE.md | 14 ++++-- src/core/layout.ts | 15 +++--- src/core/log-selection.spec.ts | 10 ++-- src/core/log-selection.ts | 12 ++--- src/core/scroll.spec.ts | 29 +++++++++++ src/core/scroll.ts | 45 +++++++++++++++++ src/ui/log-line.tsx | 5 +- src/ui/session-detail.tsx | 85 +++++++++++++++++---------------- tests/app.test.tsx | 48 ++++++++++++++++++- 10 files changed, 214 insertions(+), 68 deletions(-) diff --git a/.claude/rules/ink-components.md b/.claude/rules/ink-components.md index 20120e6..21142ae 100644 --- a/.claude/rules/ink-components.md +++ b/.claude/rules/ink-components.md @@ -221,11 +221,20 @@ **上端に空行の本数ぶんの隙間**が残る(「表示できる行があるのに上が空いている」)。行を描く コンポーネントは空行を半角スペース 1 つ等に置き換えて必ず 1 行ぶんの高さを持たせる (`LogLine` の `BLANK_ROW`)。行ごとに非空のプレフィックスを持つ `PromptInput` は影響を受けない。 -- **ビューポートを共有する行の予約は「実際に描くときだけ」引く**。ストリーミングのプレビュー行は - ログと同じ可視域を使うが、末尾追従中しか描かない。常に 1 行引くと描かない行を予約して上端に - 隙間ができ、逆に引き忘れると上端が 1 行クリップされる。`logWindow` に渡す行数(`logCap`)と - `scrollUp`/`scrollDown` に渡す行数は**必ず同じ値**にする(食い違うと最上部でアンカーが 1 行手前で - 止まり、先頭行に到達できなくなる)。 +- **スクロールする領域の高さを、その領域の状態で変えない**。ログの直下に出る行 + (ストリーミングのプレビュー / 「過去ログを表示中」の案内)は**常に 1 行**を占める + (該当なしのときは `BLANK_ROW` の空行)。中身の選択は純粋な `logStatusRow` + (`core/scroll.ts` の `LogStatusRow`)に寄せ、UI は 3 分岐を描くだけにする。 + かつてはプレビューがログの可視域を共有し(描くときだけ 1 行引く)、案内はログ枠の外に + 条件付きで現れていたため、**末尾から `↑` を 1 回押しても上端が動かず**(案内行が増えた + ぶんビューポートが縮み、末尾の 1 行が消えるだけ)、ターンが流れ始める/終わるたびに + ログ全体が 1 行上下に揺れていた(= 「スクロールがガクガクする」)。 + 同じ理由で、ターンごとに出入りする操作ヒント行(`Ctrl+C` / 再開 / 認証)も常に 1 行にする。 + 条件付きで出す行はログの**下**にあってもログの高さを変えることに注意(flex の兄弟なので、 + `flexGrow` のログ領域が縮む)。一覧の `listView` がインジケータに 1 行を予約して描画行数を + 常に `cap` に保っているのと同じ考え方。 + なお `logWindow` に渡す行数(`logCap`)と `scrollUp`/`scrollDown` に渡す行数は**必ず同じ値**に + する(食い違うと最上部でアンカーが 1 行手前で止まり、先頭行に到達できなくなる)。 - スクロール操作は **PgUp/PgDn(半画面)** と **↑/↓(1行 = `ARROW_SCROLL_LINES`)** と ホイール(`WHEEL_SCROLL_LINES`)。**マウスレポートは useInput の先頭で `parseSgrMouse` に 先取り解釈させる**(一覧と同じ。これをしないとエスケープ列が生テキストとしてコンポーザへ入る)。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dedb7c3..a38d229 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -493,13 +493,19 @@ Claude Code の実画面に寄せる: 画面は**端末の縦幅いっぱい** 行 index はスクロールしても意味が変わらないので、**可視域の外へドラッグすると自動スクロール しながら選択が伸び続ける**(`logEdgeAt` → 1 tick = 1 行の `edgeStep`。?1002 は静止中に移動を 報告しないので `LOG_EDGE_SCROLL_MS` のタイマーで継続)。当たり判定(`LogViewport`)は描画に - 使った実測値と同じウィンドウから組み、末尾寄せの隙間・プレビュー行を勘案する。 + 使った実測値と同じウィンドウから組み、末尾寄せの隙間を勘案する。 1 行ぶんの描画(kind ごとの prefix / dim / Markdown スパン / 選択ハイライト)は `ui/log-line.tsx` の `LogLine` に分けてある(`SessionDetail` は行の並べ方と入力に専念)。 描く行数は**実測した可視高さ**(`useBoxHeight`)に収める — Ink/Yoga は溢れた子を縮小するため、 - 多く描くと行が虫食いで欠落する)、 - `streamingText` のタイピング風プレビュー、 - 下部の追加指示コンポーザ(`manager.send(id, text)`)を持つ。Tab で入力↔操作パネルを切替し、 + 多く描くと行が虫食いで欠落する。 + **ログのすぐ下は常に 1 行の状態行**(`core/scroll.ts` の `logStatusRow` → `LogStatusRow`)で、 + `streamingText` のタイピング風プレビュー / 「過去ログを表示中」の案内 / 空行のいずれかを描く。 + 出し入れしないのが要点で、以前はプレビューがログの可視域を共有し(描くときだけ 1 行引く) + 案内はログ枠の外に条件付きで現れていたため、**末尾から `↑` を 1 回押しても上端が動かず** + (案内行のぶんビューポートが 1 行縮み、末尾の 1 行が消えるだけ)、ターンの開始/終了ごとに + ログ全体が 1 行上下に揺れていた(= 「上へスクロールするとガクガクする」)。ターンごとに + 出入りする操作ヒント行(`Ctrl+C` / 再開 / 認証)も同じ理由で常に 1 行にしてある。 + 下部には追加指示コンポーザ(`manager.send(id, text)`)を持つ。Tab で入力↔操作パネルを切替し、 操作パネルで m/d/x = マージ/破棄/削除(`x` は行ごと消すので成功時は一覧へ戻る)。 `pendingPermission` があれば `PermissionDialog` に委譲。単一 `useInput` の state machine(panel = input | actions)でタイピングとキー操作の衝突を防ぐ。 diff --git a/src/core/layout.ts b/src/core/layout.ts index b065420..82032aa 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -78,14 +78,15 @@ export function dialogContentWidth(columns: number): number { /** * 詳細ビューでログ以外に消費される固定の縦幅の見積り: 上下パディング 2 + - * コンポーザ上の余白 1 + 入力欄 3(上下ボーダー付き) + フッタ 1 + - * スクロールヒント 1(スクロール中のみ)。 + * 状態行 1(プレビュー / スクロール案内 / 空行。**常に 1 行**)+ 操作ヒント行 1 + * (Ctrl+C / 再開 / 空行。同じく常に 1 行)+ 入力欄 3(上下ボーダー付き)+ フッタ 1。 * - * ヒントが出ている状態(= スクロール中で、ログ高さが最も小さくなる状態)を基準に - * 取り、必ず**実際の可視高さ以下**になるようにする。過大評価するとログ行を可視域 - * より多く描いてしまい、Yoga が溢れた子を「クリップ」ではなく「縮小」するため - * 行が虫食いで欠落する。実測できる場合は `ui/hooks.ts` の `useBoxHeight` を優先し、 - * これは初回描画までのフォールバックとして使う。 + * どちらの 1 行も条件付きにしない(出し入れするとログの高さが変わり、見えている + * ログ全体が跳ねる = スクロールがガクガクする。`core/scroll.ts` の `LogStatusRow`)。 + * おかげでこの見積りは実測と一致するが、PR サマリ等の任意表示が出るぶん**過大評価に + * ならない**ことのほうが重要: 過大だとログ行を可視域より多く描いてしまい、Yoga が + * 溢れた子を「クリップ」ではなく「縮小」するため行が虫食いで欠落する。実測できる + * 場合は `ui/hooks.ts` の `useBoxHeight` を優先し、これは初回描画までのフォールバック。 */ export const DETAIL_CHROME_ROWS = 8; diff --git a/src/core/log-selection.spec.ts b/src/core/log-selection.spec.ts index 219ca59..4fdbe2b 100644 --- a/src/core/log-selection.spec.ts +++ b/src/core/log-selection.spec.ts @@ -22,7 +22,7 @@ function line(text: string, i = 0): DisplayLine { const LINES: DisplayLine[] = ['alpha', 'bravo', '', 'delta', '日本語の行'].map(line); /** 可視域: 上端 y=5, 左端 x=2, 高さ 3, 文書の 1 行目から 3 行ぶんを描いている。 */ -const VIEW: LogViewport = { top: 5, left: 2, height: 3, firstRow: 1, rows: 3, preview: false }; +const VIEW: LogViewport = { top: 5, left: 2, height: 3, firstRow: 1, rows: 3 }; describe('compareLogPoints / normalizeLogSelection', () => { const cases: [LogPoint, LogPoint, number][] = [ @@ -67,11 +67,13 @@ describe('logRowAt / logCaretAt', () => { expect(logRowAt(view, 10)).toBeUndefined(); }); - it('プレビュー行はログ行として当たらない(1 行ぶん上へ詰まる)', () => { - const view: LogViewport = { ...VIEW, height: 3, rows: 2, preview: true }; + // 状態行(プレビュー / スクロール案内)は**ログ枠の外**にあるので、ログの可視域は + // それに左右されない(`rows` はいつでも描いたログ行数そのもの)。 + it('可視域の下(状態行の側)はログ行として当たらない', () => { + const view: LogViewport = { ...VIEW, height: 2, rows: 2 }; expect(logRowAt(view, 5)).toBe(1); expect(logRowAt(view, 6)).toBe(2); - expect(logRowAt(view, 7)).toBeUndefined(); // プレビュー行 + expect(logRowAt(view, 7)).toBeUndefined(); // 可視域の外 = 状態行 }); it('桁は表示幅で逆算し、行末より右は行末に丸める', () => { diff --git a/src/core/log-selection.ts b/src/core/log-selection.ts index e4cb8e7..50204ed 100644 --- a/src/core/log-selection.ts +++ b/src/core/log-selection.ts @@ -61,10 +61,11 @@ export interface LogViewport { readonly height: number; /** 描いているウィンドウの先頭行の文書 index(`LogWindow.hiddenAbove`)。 */ readonly firstRow: number; - /** 描いているログ行数(プレビュー行は含めない)。 */ + /** + * 描いているログ行数。ストリーミングのプレビュー行・スクロール案内は**この可視域の + * 外**(`core/scroll.ts` の `LogStatusRow`。ログ枠の下に常に 1 行)なので含まれない。 + */ readonly rows: number; - /** ログの下にストリーミングのプレビュー行を描いているか(末尾寄せの計算に入る)。 */ - readonly preview: boolean; } /** @@ -72,8 +73,7 @@ export interface LogViewport { * なので、行数が高さに足りないぶんの隙間は**上**に空く(下端ではなく上端がズレる)。 */ function contentTop(view: LogViewport): number { - const drawn = view.rows + (view.preview ? 1 : 0); - return view.top + Math.max(0, view.height - drawn); + return view.top + Math.max(0, view.height - view.rows); } /** 画面上の `y` に描かれているログ行の文書 index(ログ行の外なら undefined)。 */ @@ -142,7 +142,7 @@ export type LogEdge = 'up' | 'down'; /** * ドラッグ位置が可視域のどちら側へ出たか。ログ行の上なら undefined(通常の選択延長)。 - * プレビュー行・コンポーザ側(下端より下)はまとめて `'down'` に倒す。 + * 状態行(プレビュー / スクロール案内)・コンポーザ側(下端より下)はまとめて `'down'` に倒す。 */ export function logEdgeAt(view: LogViewport, y: number): LogEdge | undefined { const top = contentTop(view); diff --git a/src/core/scroll.spec.ts b/src/core/scroll.spec.ts index d279f48..94018e4 100644 --- a/src/core/scroll.spec.ts +++ b/src/core/scroll.spec.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { clearLogLinesCache, clipToWidth, + type LogStatusRow, logLines, + logStatusRow, logWindow, MAX_CACHED_ROWS, pageStep, @@ -84,6 +86,33 @@ describe('logWindow (scrolled up, numeric anchor)', () => { }); }); +describe('logStatusRow', () => { + // ログ直下は**常に 1 行**。3 状態のどれかが必ず返る(undefined を返さない)ことが + // 「ログの高さがスクロール位置・ストリーミングで変わらない」の担保になっている。 + const cases: [string, boolean, number, string, LogStatusRow][] = [ + [ + '末尾追従 + ストリーミング中 → プレビュー', + true, + 0, + 'typing…', + { kind: 'preview', text: 'typing…' }, + ], + ['末尾追従 + 何も流れていない → 空行', true, 0, '', { kind: 'idle' }], + ['スクロール中 → 残り行数の案内', false, 7, '', { kind: 'scrollback', hiddenBelow: 7 }], + // 過去ログを読んでいる間は末尾のタイピングより「最新まであと何行か」を優先する。 + [ + 'スクロール中はプレビューより案内を優先', + false, + 3, + 'typing…', + { kind: 'scrollback', hiddenBelow: 3 }, + ], + ]; + it.each(cases)('%s', (_name, atBottom, hiddenBelow, preview, expected) => { + expect(logStatusRow({ atBottom, hiddenBelow }, preview)).toEqual(expected); + }); +}); + describe('wrapDisplayLines', () => { it('keeps short single-line text as one line', () => { expect(wrapDisplayLines('hello', 10)).toEqual(['hello']); diff --git a/src/core/scroll.ts b/src/core/scroll.ts index df9ffe5..cf20d40 100644 --- a/src/core/scroll.ts +++ b/src/core/scroll.ts @@ -452,6 +452,51 @@ export function logWindow( }; } +/** + * ログのすぐ下に詳細ビューが描く**1 行だけの状態行**の中身。 + * + * - `'preview'`: 末尾追従中で、ターンがストリーミング中(タイピング風プレビュー) + * - `'scrollback'`: 末尾から離れている(あと何行下にあるかの案内) + * - `'idle'`: どちらでもない(**空行を 1 行描く**) + * + * なぜ 3 値を 1 つの行に畳むか: この行が出たり消えたりすると、その上のログ + * ビューポートの高さが 1 行変わり、**見えているログ全体が 1 行ぶん跳ねる**。 + * かつてはプレビューがログの可視域を共有し(描くときだけ 1 行引く)、スクロール + * 案内はログ枠の外に条件付きで現れていたため、 + * + * 1. 末尾から `↑` を 1 回押しても、案内行が増えたぶんビューポートが 1 行縮み、 + * 上端の行は動かず末尾の 1 行が消えるだけ(= 1 回目のキーが効いていないように見える) + * 2. ターンが流れ始める / 終わるたびにプレビュー行が出入りし、ログ全体が上下に揺れる + * + * という「スクロールがガクガクする」挙動になっていた。**常に 1 行**にしておけば + * ログの高さはスクロール位置にもストリーミングにも依存しない。 + * + * 一覧の `listView`(`core/layout.ts`)が「さらに N 件」インジケータに 1 行を + * 予約して描画行数を常に `cap` に保つのと同じ考え方。 + */ +export type LogStatusRow = + | { readonly kind: 'preview'; readonly text: string } + | { readonly kind: 'scrollback'; readonly hiddenBelow: number } + | { readonly kind: 'idle' }; + +/** + * ログ直下の状態行に何を描くかを決める(純粋)。`preview` は `streamTail` で + * 表示幅に切った 1 行(空文字 = ストリーミングしていない)。 + * + * 末尾から離れているときはプレビューより**スクロール案内を優先**する。過去ログを + * 読んでいる最中に末尾のタイピングを出しても行き先が分からないうえ、案内が無いと + * 「最新まであと何行か」を知る手段が無くなるため。 + */ +export function logStatusRow( + win: Pick, 'atBottom' | 'hiddenBelow'>, + preview: string, +): LogStatusRow { + if (!win.atBottom) { + return { kind: 'scrollback', hiddenBelow: win.hiddenBelow }; + } + return preview.length > 0 ? { kind: 'preview', text: preview } : { kind: 'idle' }; +} + /** * New anchor after scrolling toward older lines. `rows` is the viewport height — * it bounds how far up the anchor may go (a full page always stays on screen, diff --git a/src/ui/log-line.tsx b/src/ui/log-line.tsx index c53bcde..00bef2a 100644 --- a/src/ui/log-line.tsx +++ b/src/ui/log-line.tsx @@ -91,8 +91,11 @@ const RichLogLine: FC<{ line: DisplayLine; sel?: RowSelection }> = ({ line, sel * **可視域の上端に隙間が生まれる**(表示できる行があるのに空白のままになる) * * という不具合になる。半角スペース 1 つを描いて必ず 1 行ぶんの高さを確保する。 + * + * ログの外でも、**常に 1 行を占めたい行**(`SessionDetail` の状態行・操作ヒント行。 + * 出し入れするとログの高さが変わってスクロールが跳ねる)はこれを描く。 */ -const BLANK_ROW = ' '; +export const BLANK_ROW = ' '; /** * One physical row of the detail-view log. `line.text` already carries the kind's diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx index e4fedbc..f7f3554 100644 --- a/src/ui/session-detail.tsx +++ b/src/ui/session-detail.tsx @@ -20,6 +20,7 @@ import { logLines, logLinkAt, logRowSelection, + logStatusRow, logViewportRows, logWindow, type ModelOption, @@ -49,7 +50,7 @@ import { } from './hooks'; import { useMessages } from './i18n-context'; import { normalizeChord } from './input'; -import { LOG_PREFIX, LogLine } from './log-line'; +import { BLANK_ROW, LOG_PREFIX, LogLine } from './log-line'; import { ModelSelect } from './model-select'; import { PermissionDialog } from './permission-dialog'; import { PrSummary } from './pr-cell'; @@ -262,39 +263,35 @@ export const SessionDetail: FC<{ // scroll smoothly instead of jumping an entry at a time. Width accounts for // the view's horizontal padding (1 cell each side). const messages = session?.messages; - // ログ行の折返し幅。プレビュー行も**同じ幅**で切る(食い違うと 1 行に収まらず - // ビューポートの予約行数とズレる)。 + // ログ行の折返し幅。状態行のプレビューも**同じ幅**で切る(食い違うと 1 行に + // 収まらず、状態行が 2 行になってログの高さを削ってしまう)。 const logWidth = Math.max(1, columns - 2); const lines = useMemo( () => (messages ? logLines(messages, logWidth, (kind) => LOG_PREFIX[kind]) : []), [messages, logWidth], ); const total = lines.length; - // ログを描く行数 = スクロール1回の移動量の基準 = アンカーの下限。 + // ログを描く行数 = スクロール1回の移動量の基準 = アンカーの下限。`logWindow` と + // スクロール(移動量・アンカーの下限)で必ずこの同じ値を使う — 食い違うと最上部で + // アンカーが 1 行手前で止まり、先頭行に到達できなくなる。 // 全画面時は実測した可視高さに収める(実測が入るまでの1フレームだけ見積りで代用)。 // インライン描画時(端末が低くて全画面化しない)はクリップされず端末スクロールに // 任せるため実測は使わず(高さ=内容なので測っても自分自身になる)、再描画コストの // 上限として端末 rows を使う。 - const viewport = isFullscreenViewport(rows) + // **スクロール位置にもストリーミングにも依存しない**のが要点: プレビュー行も + // スクロール案内もログ枠の外の状態行(常に 1 行)へ出す(`logStatusRow`)。 + // ここを可変にすると見えているログ全体が 1 行跳ねる(= ガクガクする)。 + const logCap = isFullscreenViewport(rows) ? Math.max(1, Math.floor(measuredLogRows ?? logViewportRows(rows))) : Math.max(1, rows); - // ライブ入力中のプレビュー行はログと同じビューポートを共有するので、**実際に描く - // ときだけ** 1 行を差し引く(末尾追従中のみ描画する)。スクロール中も差し引くと - // 描かない行を予約してしまい、可視域の上端に 1 行の隙間が残る。 - // 幅で切ってから渡す。Ink は測った文字列をプロセスグローバルな上限なしキャッシュへ - // 永久に積むので、デルタごとに変わる長い行をそのまま渡すとヒープが単調増加する - // (`streamTail` の注記参照)。 + // ライブ入力中のプレビュー。幅で切ってから渡す — Ink は測った文字列をプロセス + // グローバルな上限なしキャッシュへ永久に積むので、デルタごとに変わる長い行を + // そのまま渡すとヒープが単調増加する(`streamTail` の注記参照)。 const preview = session?.streamingText ? streamTail(session.streamingText, logWidth) : ''; - const showPreview = preview.length > 0 && anchor === 'bottom'; - // ログを描ける行数。logWindow とスクロール(移動量・アンカーの下限)で必ず同じ値を - // 使う — 食い違うと最上部でアンカーが 1 行手前で止まり、先頭行に到達できなくなる。 - // プレビュー行は末尾追従中しか描かないので、行数はアンカーの関数になる(自動スクロールは - // スクロール後のアンカーで数え直す必要がある → capFor)。 - const capFor = (at: ScrollAnchor): number => - Math.max(1, viewport - (preview.length > 0 && at === 'bottom' ? 1 : 0)); - const logCap = capFor(anchor); // 実際に描くウィンドウ。当たり判定(どの行をクリックしたか)と描画で**同じ結果**を使う。 const win = logWindow(lines, logCap, anchor); + // ログ直下に必ず 1 行描く状態行(プレビュー / スクロール案内 / 空行)。 + const logStatus = logStatusRow(win, preview); /** * ログ可視域の幾何。すべて描画に使った実測値・同じウィンドウから組むので、クリック位置の * 逆算が別の行に当たらない。実測前とインライン描画時(低い端末=マウス捕捉もしない)は @@ -308,7 +305,6 @@ export const SessionDetail: FC<{ height: Math.max(1, Math.floor(measuredLogRows)), firstRow: win.hiddenAbove, rows: win.entries.length, - preview: showPreview, } : undefined; @@ -330,10 +326,9 @@ export const SessionDetail: FC<{ ? scrollUp(current, total, logCap, ARROW_SCROLL_LINES) : scrollDown(current, total, logCap, ARROW_SCROLL_LINES); applyAnchor(next); - // 終点は**次に描かれる**ウィンドウの端の行。行数は `capFor(next)` で数え直す — - // 末尾追従を外れるとプレビュー行が消えて 1 行増えるため、`logCap` のままだと - // 上端の 1 行が選択から漏れる。 - logSel.extend(logEdgePoint(logWindow(lines, capFor(next), next), dir)); + // 終点は**次に描かれる**ウィンドウの端の行。行数(`logCap`)はスクロール位置に + // 依存しないので、そのまま次のアンカーで数え直せばよい。 + logSel.extend(logEdgePoint(logWindow(lines, logCap, next), dir)); if (next === current) { // 文書の端まで来た(もう動かない): タイマーを止める。release のレポートを取り逃した // ときに永久にスクロールし続けないための保険にもなっている。 @@ -659,25 +654,31 @@ export const SessionDetail: FC<{ } /> ))} - {/* Live streaming preview, only while following the tail. */} - {showPreview ? ( - - {preview} - - ) : null} - {/* Scrollback indicator: shown only when the view is lifted off the tail. */} - {!win.atBottom ? ( - - - {m.detail.scrollHint(win.hiddenBelow)} + {/* + * ログ直下の状態行。**常に 1 行**を占める(中身が無いときは空行)。ここを + * 条件付きで出し入れすると、その上のログビューポートの高さが 1 行変わって + * 見えているログ全体が跳ねる(= スクロールがガクガクする)。詳細は + * `core/scroll.ts` の `LogStatusRow`。この行がフッタとの間の余白も兼ねるので、 + * 下のブロックに `marginTop` は付けない(付けると空行が 2 行並ぶ)。 + */} + + {logStatus.kind === 'preview' ? ( + + {logStatus.text} - - ) : null} + ) : logStatus.kind === 'scrollback' ? ( + + {m.detail.scrollHint(logStatus.hiddenBelow)} + + ) : ( + {BLANK_ROW} + )} + - + {/* 複数 PR を出したセッションだけ、全件の番号をここに出す(一覧の行末セルは `#12 +1` としか書けないので、`+1` の中身を確かめられる唯一の場所)。 1 本しか無いセッションでは何も描かない = ログの縦幅を削らない。 */} @@ -703,7 +704,9 @@ export const SessionDetail: FC<{ 開かずに効くので、パネル内の `r` だけでは気づけない。まだ走っている(中断できる) セッションでは同じ位置に Ctrl+C の案内を出す(1行を状態で使い分ける)。 */} {/* `flexShrink={0}`: Yoga は溢れた子を縮小するので、付けないと低い端末で案内が - 高さ0に潰れて消える。縮む役は flexGrow のログ領域(内部スクロールで収まる)。 */} + 高さ0に潰れて消える。縮む役は flexGrow のログ領域(内部スクロールで収まる)。 + ログ直下の状態行と同じ理由で**常に 1 行**にする(該当なしのときも空行)。 + ここはターンが終わるたびに出入りするので、条件付きにするとログが 1 行跳ねる。 */} {status === 'needs_login' ? ( {m.auth.hint} @@ -713,7 +716,9 @@ export const SessionDetail: FC<{ // 中断も Ctrl+R と同じフォーカス横断の chord なので、フッタではなく独立した // 行で案内する(フッタヒントは入力欄/操作パネルで切り替わってしまう)。 {m.detail.cancelHint} - ) : null} + ) : ( + {BLANK_ROW} + )} {recovering ? ( {m.recover.running} diff --git a/tests/app.test.tsx b/tests/app.test.tsx index 39af93c..1a24fb5 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -1205,7 +1205,7 @@ describe('App detail view (in-app connection)', () => { await flush(); /** Log line numbers currently visible, in render order. */ const visible = () => [...lastFrame().matchAll(/log-(\d+)/g)].map((match) => Number(match[1])); - return { app, stdin, lastFrame, visible }; + return { app, stdin, lastFrame, visible, out }; } /** @@ -1398,6 +1398,52 @@ describe('App detail view (in-app connection)', () => { app.unmount(); }, 30000); + /** + * Regression(上へスクロールするとガクガクする): ログの可視域の高さが + * スクロール位置やストリーミングで変わってはいけない。 + * + * かつてはスクロール案内がログ枠の外に**条件付きで**現れ、ストリーミングの + * プレビュー行はログの可視域を**共有**していた。そのため + * + * - 末尾から `↑` を 1 回押すと案内行のぶんビューポートが 1 行縮み、上端の行は + * 動かないまま末尾の 1 行が消えるだけ(= 1 回目のキーが効いていないように見える) + * - ターンが流れ始める / 終わるたびにプレビュー行が出入りし、ログ全体が上下に揺れる + * + * という挙動になっていた。今は `LogStatusRow` として**常に 1 行**を占める。 + */ + it('ログの表示行数はスクロール位置・ストリーミングで変わらない(1 行目から実際に動く)', async () => { + const { app, stdin, lastFrame, visible, out } = await detailWithLog(40); + const tail = visible(); + expect(tail.at(-1)).toBe(39); + + // 1 回目の ↑ で**上端も**1 行ぶん古い行へ動く(行数は変わらない)。 + stdin.write('\x1b[A'); + await flush(); + const up = visible(); + expect(up.length).toBe(tail.length); + expect(up[0]).toBe((tail[0] ?? 0) - 1); + expectUnbrokenRun(up); + expect(lastFrame()).toContain('過去ログを表示中'); + + // 末尾へ戻しても行数は同じ(案内行が消えた勢いで 1 行増えない)。 + stdin.write('\x1b[B'); + await flush(); + expect(visible()).toEqual(tail); + expect(lastFrame()).not.toContain('過去ログを表示中'); + + // ストリーミングのプレビューが出てもログは 1 行も削られない(= 跳ねない)。 + out.push( + asMsg({ + type: 'stream_event', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'typing-now' } }, + }), + ); + await flush(); + expect(lastFrame()).toContain('typing-now'); // プレビューは出ている + expect(visible()).toEqual(tail); // が、ログの行は 1 つも入れ替わっていない + app.unmount(); + }, 30000); + it('ログをドラッグすると複数行を範囲選択し、離した時点でコピーする', async () => { const copied: string[] = []; const { app, stdin, lastFrame } = await detailWithLog(40, 24, 80, undefined, (t) =>