Skip to content

Commit 6b02a78

Browse files
authored
feat: セッション一覧を内部スクロール対応(ヘッダ/フッタ固定) (#16)
## 概要 セッション一覧が端末の高さに収まりきらないとき、これまでは溢れた行がクリップされて下のセッションに到達できなかった。本PRで一覧領域を**内部スクロール**対応にし、選択中のセッションを常に画面内に保ちながらウィンドウを動かす。ヘッダ(バナー)とフッタ(入力欄・ステータス)は最下部に固定したまま。 ## 変更点 - **`src/core/layout.ts`**: 純粋関数 `listView(total, selected, cap)` を追加。 - 高さ `cap` 行に収まる表示範囲 `[start, end)` を、`selected` を常に見える位置に保ちながら算出。 - 項目が溢れる端には「さらに N 件」インジケータ用に 1 行を予約し、**描画行数(項目+インジケータ)は常に `cap` 以下**になるよう保証(極端に低い `cap` ではインジケータを落として内容行を優先)。 - 選択はウィンドウ下端寄りにアンカー(コンポーザの `visibleLineRange` と同じ挙動)。予約でウィンドウが縮んで別の端が溢れるケースは不動点反復で収束。 - **`src/ui/hooks.ts`**: `useBoxHeight` を追加。`flexGrow` ボックスの実測高さ(=一覧が使える行数)を取得する。 - **`src/ui/session-list.tsx`**: - 実測高さぶんだけ内部スクロールして描画。上下端に「さらに N 件」インジケータを表示。 - 全画面でない(`isFullscreenViewport` が false の)インライン描画時はクリップされないため全件描画し、端末側スクロールに委譲。 - マウスのヒットテストを可視ウィンドウ(`view.start..end` + 上インジケータのオフセット)へ写像するよう更新。 - **`src/core/i18n.ts`**: `moreAbove` / `moreBelow` を ja / en 両方に追加(UI 文字列はカタログに集約という i18n 規約に準拠)。 ## テスト計画 - [x] `src/core/layout.spec.ts`: `listView` のテーブルドリブンテスト(先頭/末尾/中央、全 index でのウィンドウ不変条件、`cap` 超過なし、極小 `cap`、範囲外選択のクランプ)。 - [x] `tests/app.test.tsx`: 実 Yoga レイアウトの全画面ハーネスで、12 セッション時に **フレーム高さが端末ぴったり・フッタ固定**、下インジケータ表示、末尾まで選択を下げると上インジケータ表示+末尾セッションが可視・先頭が隠れることを検証。 - [x] `npm test`(358 passed / coverage 閾値クリア)、`npm run lint`(新規エラーなし)、`npm run typecheck` 通過。 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 2d4743c commit 6b02a78

6 files changed

Lines changed: 330 additions & 56 deletions

File tree

src/core/i18n.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export interface Messages {
3535
busySuffix: string;
3636
/** session_id 未確定(creating 直後など)でまだ claude で開けない */
3737
openNotReady: string;
38+
/** 一覧スクロール時、上に隠れている件数のインジケータ */
39+
moreAbove: (n: number) => string;
40+
/** 一覧スクロール時、下に隠れている件数のインジケータ */
41+
moreBelow: (n: number) => string;
3842
};
3943
/** ステータスバッジ(progress-badge.tsx) */
4044
badge: {
@@ -115,6 +119,8 @@ const ja: Messages = {
115119
confirmRun: '実行しますか?',
116120
busySuffix: '…実行中',
117121
openNotReady: 'このセッションはまだ claude で開けません(セッションID未取得)',
122+
moreAbove: (n) => `↑ 他 ${n} 件`,
123+
moreBelow: (n) => `↓ 他 ${n} 件`,
118124
},
119125
badge: {
120126
creating: '準備中',
@@ -182,6 +188,8 @@ const en: Messages = {
182188
confirmRun: 'Proceed?',
183189
busySuffix: '…running',
184190
openNotReady: 'This session cannot be opened in claude yet (no session id).',
191+
moreAbove: (n) => `↑ ${n} more`,
192+
moreBelow: (n) => `↓ ${n} more`,
185193
},
186194
badge: {
187195
creating: 'Preparing',

src/core/layout.spec.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { isFullscreenViewport, MIN_FULLSCREEN_ROWS } from './layout';
2+
import { isFullscreenViewport, listView, MIN_FULLSCREEN_ROWS } from './layout';
33

44
describe('isFullscreenViewport', () => {
55
it.each([
@@ -12,3 +12,94 @@ describe('isFullscreenViewport', () => {
1212
expect(isFullscreenViewport(rows)).toBe(expected);
1313
});
1414
});
15+
16+
describe('listView', () => {
17+
/** 描画行数(項目 + 表示インジケータ)は cap を超えない、という不変条件。 */
18+
const renderedRows = (v: ReturnType<typeof listView>) =>
19+
v.end - v.start + (v.showAbove ? 1 : 0) + (v.showBelow ? 1 : 0);
20+
21+
it('shows everything and no indicators when the list fits', () => {
22+
expect(listView(3, 0, 10)).toEqual({
23+
start: 0,
24+
end: 3,
25+
hiddenAbove: 0,
26+
hiddenBelow: 0,
27+
showAbove: false,
28+
showBelow: false,
29+
});
30+
});
31+
32+
it('shows everything when total equals cap', () => {
33+
const v = listView(5, 4, 5);
34+
expect(v).toEqual({
35+
start: 0,
36+
end: 5,
37+
hiddenAbove: 0,
38+
hiddenBelow: 0,
39+
showAbove: false,
40+
showBelow: false,
41+
});
42+
});
43+
44+
it('at the top: only a below indicator, selection visible', () => {
45+
const v = listView(10, 0, 5);
46+
expect(v.start).toBe(0);
47+
expect(v.showAbove).toBe(false);
48+
expect(v.showBelow).toBe(true);
49+
expect(v.hiddenBelow).toBe(10 - v.end);
50+
expect(0).toBeGreaterThanOrEqual(v.start);
51+
expect(0).toBeLessThan(v.end);
52+
expect(renderedRows(v)).toBe(5);
53+
});
54+
55+
it('at the bottom: only an above indicator, selection visible', () => {
56+
const v = listView(10, 9, 5);
57+
expect(v.end).toBe(10);
58+
expect(v.showAbove).toBe(true);
59+
expect(v.showBelow).toBe(false);
60+
expect(9).toBeGreaterThanOrEqual(v.start);
61+
expect(9).toBeLessThan(v.end);
62+
expect(renderedRows(v)).toBe(5);
63+
});
64+
65+
it('in the middle: both indicators, selection visible', () => {
66+
const v = listView(20, 10, 5);
67+
expect(v.showAbove).toBe(true);
68+
expect(v.showBelow).toBe(true);
69+
expect(10).toBeGreaterThanOrEqual(v.start);
70+
expect(10).toBeLessThan(v.end);
71+
expect(renderedRows(v)).toBe(5);
72+
});
73+
74+
it('keeps the selection visible for every index without overflowing cap', () => {
75+
const total = 30;
76+
const cap = 7;
77+
for (let sel = 0; sel < total; sel++) {
78+
const v = listView(total, sel, cap);
79+
expect(sel, `sel=${sel} start`).toBeGreaterThanOrEqual(v.start);
80+
expect(sel, `sel=${sel} end`).toBeLessThan(v.end);
81+
expect(renderedRows(v), `sel=${sel} rows`).toBeLessThanOrEqual(cap);
82+
expect(v.showAbove).toBe(v.start > 0);
83+
expect(v.showBelow).toBe(v.end < total);
84+
}
85+
});
86+
87+
it('never overflows a tiny cap (drops indicators, keeps one content row)', () => {
88+
const v1 = listView(10, 5, 1);
89+
expect(v1.end - v1.start).toBe(1);
90+
expect(v1.showAbove).toBe(false);
91+
expect(v1.showBelow).toBe(false);
92+
expect(renderedRows(v1)).toBe(1);
93+
94+
const v2 = listView(10, 5, 2);
95+
expect(renderedRows(v2)).toBe(2);
96+
expect(5).toBeGreaterThanOrEqual(v2.start);
97+
expect(5).toBeLessThan(v2.end);
98+
});
99+
100+
it('clamps out-of-range selection', () => {
101+
expect(() => listView(10, 99, 5)).not.toThrow();
102+
const v = listView(10, 99, 5);
103+
expect(v.end).toBe(10);
104+
});
105+
});

src/core/layout.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { visibleLineRange } from './text-buffer';
2+
13
/**
24
* 全画面レイアウトに必要な最小の端末行数。固定部分(バナー6行 + 入力欄3行 +
35
* フッタ・余白・パディング)だけで約15行あり、これ未満で root の height を
@@ -13,3 +15,83 @@ export const MIN_FULLSCREEN_ROWS = 16;
1315
export function isFullscreenViewport(rows: number): boolean {
1416
return rows >= MIN_FULLSCREEN_ROWS;
1517
}
18+
19+
/**
20+
* セッション一覧を高さ `cap` 行のウィンドウに収めるための表示範囲。
21+
* 一覧がヘッダ/フッタの間で内部スクロールするときに使う純粋な計算。
22+
*/
23+
export interface ListView {
24+
/** 表示する最初の項目インデックス(含む) */
25+
start: number;
26+
/** 表示する最後の項目インデックスの次(含まない) */
27+
end: number;
28+
/** ウィンドウより上に隠れている項目数 */
29+
hiddenAbove: number;
30+
/** ウィンドウより下に隠れている項目数 */
31+
hiddenBelow: number;
32+
/** 上端に「さらに N 件」インジケータ行を出すか */
33+
showAbove: boolean;
34+
/** 下端に「さらに N 件」インジケータ行を出すか */
35+
showBelow: boolean;
36+
}
37+
38+
/**
39+
* `total` 件のうち `cap` 行に収まる表示範囲を、`selected` を常に見える位置に
40+
* 保ちながら求める。項目が溢れる端には「さらに N 件」インジケータ用に 1 行を
41+
* 予約するため、描画行数(項目 + インジケータ)は常に `cap` 以下になる。
42+
* 選択はウィンドウ下端寄りにアンカーする(下へ動かすとスクロールする挙動。
43+
* コンポーザの {@link visibleLineRange} と同じ)。
44+
*/
45+
export function listView(total: number, selected: number, cap: number): ListView {
46+
const c = Math.max(1, Math.floor(cap));
47+
if (total <= c) {
48+
return {
49+
start: 0,
50+
end: total,
51+
hiddenAbove: 0,
52+
hiddenBelow: 0,
53+
showAbove: false,
54+
showBelow: false,
55+
};
56+
}
57+
const sel = Math.max(0, Math.min(selected, total - 1));
58+
// 溢れる端ごとにインジケータ 1 行を予約するが、その予約でウィンドウが縮むと
59+
// 別の端が新たに溢れることがある(縮小は隠れ項目を増やすだけなので単調)。
60+
// 予約は増やす方向にのみ更新して不動点まで反復する(最大 3 周で収束)。
61+
let above = false;
62+
let below = false;
63+
let win = { start: 0, end: 0 };
64+
for (let i = 0; i < 3; i++) {
65+
// インジケータで席を使い切らないよう、内容行を必ず 1 行は残す。
66+
const reserved = Math.min((above ? 1 : 0) + (below ? 1 : 0), c - 1);
67+
win = visibleLineRange(total, sel, c - reserved);
68+
const nextAbove = win.start > 0;
69+
const nextBelow = win.end < total;
70+
if (nextAbove === above && nextBelow === below) {
71+
break;
72+
}
73+
above = above || nextAbove;
74+
below = below || nextBelow;
75+
}
76+
// 極端に低い cap では両方は出せない。内容行を守るため下インジケータから捨てる。
77+
const rows = win.end - win.start;
78+
let showAbove = above;
79+
let showBelow = below;
80+
while ((showAbove ? 1 : 0) + (showBelow ? 1 : 0) > c - rows) {
81+
if (showBelow) {
82+
showBelow = false;
83+
} else {
84+
showAbove = false;
85+
}
86+
}
87+
const hiddenAbove = win.start;
88+
const hiddenBelow = total - win.end;
89+
return {
90+
start: win.start,
91+
end: win.end,
92+
hiddenAbove,
93+
hiddenBelow,
94+
showAbove: showAbove && hiddenAbove > 0,
95+
showBelow: showBelow && hiddenBelow > 0,
96+
};
97+
}

src/ui/hooks.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,23 @@ export function useAbsolutePosition(
7878
return pos;
7979
}
8080

81+
/**
82+
* Computed content height (terminal rows) of an Ink box, measured after every
83+
* render. A `flexGrow` box in a height-constrained parent reports its allocated
84+
* height regardless of how much content it holds, so this yields the space a
85+
* scrollable list may fill. Undefined until first measured. Re-renders only when
86+
* the height actually changes.
87+
*/
88+
export function useBoxHeight(ref: RefObject<DOMElement | null>): number | undefined {
89+
const [height, setHeight] = useState<number | undefined>(undefined);
90+
useEffect(() => {
91+
const layout = ref.current?.yogaNode?.getComputedLayout();
92+
const next = layout?.height;
93+
setHeight((prev) => (prev === next ? prev : next));
94+
});
95+
return height;
96+
}
97+
8198
/** A clock that ticks every `ms` so elapsed-time displays stay current. */
8299
export function useClock(ms = 1000): number {
83100
const [now, setNow] = useState(() => Date.now());

0 commit comments

Comments
 (0)