Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .claude/rules/ink-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` に
先取り解釈させる**(一覧と同じ。これをしないとエスケープ列が生テキストとしてコンポーザへ入る)。
Expand Down
14 changes: 10 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)でタイピングとキー操作の衝突を防ぐ。
Expand Down
15 changes: 8 additions & 7 deletions src/core/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
10 changes: 6 additions & 4 deletions src/core/log-selection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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][] = [
Expand Down Expand Up @@ -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('桁は表示幅で逆算し、行末より右は行末に丸める', () => {
Expand Down
12 changes: 6 additions & 6 deletions src/core/log-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,19 @@ export interface LogViewport {
readonly height: number;
/** 描いているウィンドウの先頭行の文書 index(`LogWindow.hiddenAbove`)。 */
readonly firstRow: number;
/** 描いているログ行数(プレビュー行は含めない)。 */
/**
* 描いているログ行数。ストリーミングのプレビュー行・スクロール案内は**この可視域の
* 外**(`core/scroll.ts` の `LogStatusRow`。ログ枠の下に常に 1 行)なので含まれない。
*/
readonly rows: number;
/** ログの下にストリーミングのプレビュー行を描いているか(末尾寄せの計算に入る)。 */
readonly preview: boolean;
}

/**
* 実際に 1 行目が描かれる絶対行。ビューポートは `justifyContent="flex-end"` の末尾寄せ
* なので、行数が高さに足りないぶんの隙間は**上**に空く(下端ではなく上端がズレる)。
*/
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)。 */
Expand Down Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions src/core/scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
import {
clearLogLinesCache,
clipToWidth,
type LogStatusRow,
logLines,
logStatusRow,
logWindow,
MAX_CACHED_ROWS,
pageStep,
Expand Down Expand Up @@ -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']);
Expand Down
45 changes: 45 additions & 0 deletions src/core/scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,51 @@ export function logWindow<T>(
};
}

/**
* ログのすぐ下に詳細ビューが描く**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<LogWindow<unknown>, '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,
Expand Down
5 changes: 4 additions & 1 deletion src/ui/log-line.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading