Skip to content

Commit fd375ef

Browse files
authored
feat: alt screen でスクロールバックを無効化し上へのスクロールをロック (#5)
## 概要 全画面レイアウト(#3)でも端末を**上へスクロールできてしまう**問題を修正する。原因は通常スクリーンバッファのまま描画しており、シェルの過去出力などのスクロールバックが残っていたこと。 起動時に **alt screen(代替スクリーンバッファ, `\x1b[?1049h`)** へ入るようにした。alt screen にはスクロールバックが存在しないため、vim / htop と同様にスクロールがロックされる。終了時は `\x1b[?1049l` で leave し、元のシェル画面を復元する。 ## 変更点 - **`src/utils/alt-screen.ts`(新規)**: enter/leave の薄い I/O ラッパ。`leave` は冪等。クラッシュ時に端末を alt screen に取り残さないよう `process.on('exit')` に保険登録し、明示 leave で解除する。 - **`src/utils/alt-screen.spec.ts`(新規)**: enter/leave シーケンス・冪等性・exit フックの登録/解除・exit イベント経由の復元をカバー。 - **`src/index.tsx`**: 合成ルートで配線。`process.stdout.isTTY` かつ起動時 `rows >= MIN_FULLSCREEN_ROWS` のときだけ enter(インライン描画フォールバック時は端末スクロールに頼るため通常バッファのまま)。判定は起動時の一度きり(途中リサイズでバッファを切り替えると画面が壊れるため追従しない)。終了時の残存 worktree 案内は **leave 後**に書き、シェル履歴へ残す。 - **docs / rules**: `TECH_NOTES.md` / `ARCHITECTURE.md` / `TASKS.md` / `.claude/rules/ink-components.md` を実装に合わせて更新。 ## 設計判断 - alt screen の enter/leave は純粋ロジックではなく端末への I/O なので `utils/` に配置(アーキテクチャ規約: 純粋ロジックと I/O の分離)。 - フォールバック時(非 TTY / 低い端末)は enter しない — インライン描画は端末スクロールに依存するため。 ## テスト計画 - [x] `npm test` — 147件 全緑(coverage 閾値クリア) - [x] `npm run lint` / `npm run typecheck` / `npm run build` — 緑 - [ ] 手動: 使い捨てリポジトリで `npm run dev` 起動 → マウスホイールで上へスクロールできないこと - [ ] 手動: 終了後にシェル画面が復元され、残存 worktree 案内が履歴に残ること - [ ] 手動: 低い端末(< 16 行)でインライン描画へフォールバックし、通常どおりスクロールできること
1 parent 4b44354 commit fd375ef

8 files changed

Lines changed: 100 additions & 2 deletions

File tree

.claude/rules/ink-components.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
やめてインライン描画へフォールバックする(クリップで入力欄・フッタが消えるのを防ぐ)。
2424
判定は `isFullscreenViewport(rows)`
2525
- 各 view は `flexGrow={1}` の縦 flex にし、入力欄+フッタは flexGrow スペーサで最下部に固定する。
26+
- 全画面で描くときは起動時に **alt screen**`\x1b[?1049h``utils/alt-screen.ts`)へ入る。
27+
通常バッファのままだとスクロールバックが残り上へスクロールできてしまうため。
28+
インライン描画フォールバック時(TTY でない / 起動時 rows が閾値未満)は enter しない。
29+
配線は `src/index.tsx`(合成ルート)で行い、終了メッセージは leave 後に書く。
2630
- **`<Static>` は使わない**。Static はスクロールバック側に書き出すため、全画面レイアウトでは
2731
ビューポート外に消えて見えなくなる。追記ログは「末尾ビューポート」
2832
`flexGrow={1}` + `overflowY="hidden"` + `justifyContent="flex-end"`)で最新行を下端に表示し、

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ interface SessionState {
151151

152152
### UI (ui/)
153153

154-
Claude Code の実画面に寄せる: 画面は**端末の縦幅いっぱい**(web の 100dvh 相当。`App` が root Box に `useWindowSize()` の rows を指定。極端に低い端末では `isFullscreenViewport` が false になりインライン描画へフォールバック)に描画し、下部に**上下の全幅横罫線だけ**の入力欄(`PromptInput`、角丸枠ではない)、その下にモード行(`StatusFooter` = `⏵⏵ auto mode on (shift+tab to cycle)` + 文脈ヒント)を flexGrow スペーサで**最下部に固定**。ヘッダは枠なしのワードマーク。色とグリフは `theme.ts` に集約。
154+
Claude Code の実画面に寄せる: 画面は**端末の縦幅いっぱい**(web の 100dvh 相当。`App` が root Box に `useWindowSize()` の rows を指定。極端に低い端末では `isFullscreenViewport` が false になりインライン描画へフォールバック)に描画し、全画面時は起動時に **alt screen**`utils/alt-screen.ts`)へ入ってスクロールバックを無効化(上へのスクロールをロック)し、下部に**上下の全幅横罫線だけ**の入力欄(`PromptInput`、角丸枠ではない)、その下にモード行(`StatusFooter` = `⏵⏵ auto mode on (shift+tab to cycle)` + 文脈ヒント)を flexGrow スペーサで**最下部に固定**。ヘッダは枠なしのワードマーク。色とグリフは `theme.ts` に集約。
155155

156156
- `App`: ビュー状態(`list` | `detail:<id>`)と全体キーバインドを管理。`cwd` を受け取りバナーへ渡す。
157157
- `Banner`: 起動時ヘッダ(`✻ codiva` + サブタイトル + cwd)。枠なし3行、一覧上部に表示。

docs/TASKS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ UI なし。すべてユニットテストで駆動する。
116116
## Phase 6: Backlog(MVP後、着手前にユーザーと相談)
117117

118118
- [x] 全画面(100dvh 相当)レイアウト: root に端末 rows を指定、入力欄+フッタを下部固定、詳細ログは `<Static>` から末尾ビューポートへ置換
119+
- [x] alt screen(`\x1b[?1049h`/`\x1b[?1049l`)でスクロールバックを無効化し、上へのスクロールをロック(`utils/alt-screen.ts`
119120
- [ ] 詳細ビューのログスクロール(末尾ビューポートは最新行のみ表示。過去ログを遡る手段が未実装)
120121
- [ ] 入力欄の複数行化(Shift+Enter 改行、行数に応じて上に伸び、上限超過で内部スクロール — Claude Code 同等)
121122
- [x] アプリ再起動後のセッション復元(`.codiva/state.json` + SDK `resume`

docs/TECH_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ function toUserMessage(text: string): SDKUserMessage {
174174
- **`useInput`**: グローバルキーハンドラ。フォーカス管理は `useFocus` もあるが、MVP はビュー単位の単純な状態分岐で足りる。
175175
- **`useApp().exit()`**: 終了。終了前に SessionManager.dispose()(全 abort)を呼ぶ。
176176
- 再描画スロットリング: コアからの onChange を UI 側で ~100ms デバウンス。`useSyncExternalStore` の getSnapshot が返す参照が変わらなければ再描画されない点を利用する。
177-
- alt-screen(全画面)にしたい場合は起動時に `\x1b[?1049h`、終了時に `\x1b[?1049l` を書く。MVP では必須ではない
177+
- **alt screen(代替スクリーンバッファ)**: 全画面レイアウトでも通常バッファのままだとシェルの過去出力がスクロールバックに残り、上へスクロールできてしまう。起動時に `\x1b[?1049h` で alt screen に入り、終了時に `\x1b[?1049l` で抜ける(`utils/alt-screen.ts`)。alt screen にはスクロールバックが存在しないため vim / htop と同様にスクロールがロックされ、終了すると元の画面が復元される。enter するのは「TTY かつ起動時の rows が `MIN_FULLSCREEN_ROWS` 以上」のときだけ(インライン描画フォールバック時はスクロールバックに頼るため通常バッファのまま)。終了時の残存 worktree 案内は leave 後に書き、通常バッファに残す。クラッシュ時の取り残し防止に `process.on('exit')` で leave を保険登録する
178178

179179
## git worktree の実装メモ
180180

src/index.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { query } from '@anthropic-ai/claude-agent-sdk';
22
import { render } from 'ink';
33
import {
4+
isFullscreenViewport,
45
messages,
56
notificationFor,
67
resolveLang,
@@ -10,6 +11,7 @@ import {
1011
} from '@/core';
1112
import {
1213
defaultStatePath,
14+
enterAltScreen,
1315
loadConfig,
1416
loadState,
1517
notify,
@@ -93,6 +95,13 @@ async function main(): Promise<void> {
9395
process.once('SIGTERM', flushSyncAndExit(143));
9496
process.once('SIGHUP', flushSyncAndExit(129));
9597

98+
// 全画面レイアウトで描くときは alt screen に入り、スクロールバックを無効化する
99+
// (上へのスクロールをロック)。低すぎる端末はインライン描画へフォールバックし
100+
// 端末スクロールに頼るため、通常バッファのまま。判定は起動時の一度きり
101+
// (途中のリサイズでバッファを切り替えると画面が壊れるため追従しない)。
102+
const useAltScreen = process.stdout.isTTY && isFullscreenViewport(process.stdout.rows ?? 0);
103+
const leaveAltScreen = useAltScreen ? enterAltScreen(process.stdout) : undefined;
104+
96105
const { waitUntilExit } = render(<App manager={manager} cwd={repoRoot} messages={t} />, {
97106
exitOnCtrlC: false,
98107
});
@@ -105,6 +114,9 @@ async function main(): Promise<void> {
105114
}
106115
await saveState(manager.persistableState(), statePath).catch(() => undefined);
107116

117+
// 終了メッセージは alt screen を抜けてから書き、通常バッファ(シェルの履歴)に残す。
118+
leaveAltScreen?.();
119+
108120
// Sessions are aborted on quit but their worktrees are intentionally kept so
109121
// no work is lost. Tell the user where they are.
110122
const remaining = manager.activeWorktreePaths();

src/utils/alt-screen.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { enterAltScreen } from './alt-screen';
3+
4+
const ENTER = '\x1b[?1049h';
5+
const LEAVE = '\x1b[?1049l';
6+
7+
function fakeStream(): { writes: string[]; stream: { write(text: string): void } } {
8+
const writes: string[] = [];
9+
return { writes, stream: { write: (text: string) => writes.push(text) } };
10+
}
11+
12+
describe('enterAltScreen', () => {
13+
it('enter で ?1049h を書き、leave で ?1049l を書く', () => {
14+
const { writes, stream } = fakeStream();
15+
const leave = enterAltScreen(stream);
16+
expect(writes).toEqual([ENTER]);
17+
leave();
18+
expect(writes).toEqual([ENTER, LEAVE]);
19+
});
20+
21+
it('leave は冪等(2回呼んでも ?1049l は1回だけ)', () => {
22+
const { writes, stream } = fakeStream();
23+
const leave = enterAltScreen(stream);
24+
leave();
25+
leave();
26+
expect(writes).toEqual([ENTER, LEAVE]);
27+
});
28+
29+
it('クラッシュ保険の exit フックを登録し、leave で解除する', () => {
30+
const before = process.listenerCount('exit');
31+
const { stream } = fakeStream();
32+
const leave = enterAltScreen(stream);
33+
expect(process.listenerCount('exit')).toBe(before + 1);
34+
leave();
35+
expect(process.listenerCount('exit')).toBe(before);
36+
});
37+
38+
it('process の exit イベントで leave される(明示 leave を忘れても復元)', () => {
39+
const { writes, stream } = fakeStream();
40+
enterAltScreen(stream);
41+
process.emit('exit', 0);
42+
expect(writes).toEqual([ENTER, LEAVE]);
43+
// exit フック経由でも解除・冪等化されている
44+
process.emit('exit', 0);
45+
expect(writes).toEqual([ENTER, LEAVE]);
46+
});
47+
});

src/utils/alt-screen.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* 代替スクリーンバッファ(alternate screen buffer)の enter/leave。
3+
* 通常バッファのまま全画面描画するとシェルの過去出力がスクロールバックに残り
4+
* 上へスクロールできてしまう。alt screen にはスクロールバックが存在しないため、
5+
* vim / htop と同様にスクロールがロックされ、leave すると元の画面が復元される。
6+
*/
7+
const ENTER_ALT_SCREEN = '\x1b[?1049h';
8+
const LEAVE_ALT_SCREEN = '\x1b[?1049l';
9+
10+
/** テストでフェイクを注入できるよう、必要な write だけに絞ったストリーム型。 */
11+
export interface AltScreenStream {
12+
write(text: string): unknown;
13+
}
14+
15+
/**
16+
* alt screen に入り、抜けるための関数を返す。leave は冪等。
17+
* クラッシュ(uncaughtException 等)で明示 leave を通らなくても端末を
18+
* alt screen に取り残さないよう、process の exit イベントにも保険で登録する。
19+
*/
20+
export function enterAltScreen(stream: AltScreenStream = process.stdout): () => void {
21+
stream.write(ENTER_ALT_SCREEN);
22+
let left = false;
23+
const leave = (): void => {
24+
if (left) {
25+
return;
26+
}
27+
left = true;
28+
process.removeListener('exit', leave);
29+
stream.write(LEAVE_ALT_SCREEN);
30+
};
31+
process.on('exit', leave);
32+
return leave;
33+
}

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './alt-screen';
12
export * from './config';
23
export * from './git';
34
export * from './notify';

0 commit comments

Comments
 (0)