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
16 changes: 15 additions & 1 deletion .claude/rules/ink-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,23 @@
- **1画面につき `useInput` は1つ**(view コンポーネントに置く)。`PromptInput` 等は presentational にして、キー処理は view 側の単一ハンドラに集約する(複数 `useInput` の競合を避ける)。
- モーダルな状態(`pendingPermission` あり)では、そのダイアログにキーを委譲し、背後の view はキーを処理しない。

## 全画面レイアウト

- アプリは端末の縦幅いっぱい(web の 100dvh 相当)に描画する。Ink はコンテンツの高さぶんしか
描画しないため、`App` の root `<Box>` に `useWindowSize()` の `rows` を `height` 指定する
(リサイズ追従込み)。root には `overflow="hidden"` を付け、フレームが端末高さを超えて
Ink が全画面クリアにフォールバックする(ちらつく)のを防ぐ。
- ただし端末が `MIN_FULLSCREEN_ROWS`(`core/layout.ts`)未満に低い場合は height 固定を
やめてインライン描画へフォールバックする(クリップで入力欄・フッタが消えるのを防ぐ)。
判定は `isFullscreenViewport(rows)`。
- 各 view は `flexGrow={1}` の縦 flex にし、入力欄+フッタは flexGrow スペーサで最下部に固定する。
- **`<Static>` は使わない**。Static はスクロールバック側に書き出すため、全画面レイアウトでは
ビューポート外に消えて見えなくなる。追記ログは「末尾ビューポート」
(`flexGrow={1}` + `overflowY="hidden"` + `justifyContent="flex-end"`)で最新行を下端に表示し、
`tailMessages(messages, rows)`(`core/layout.ts`)で描画ノード数に上限を掛ける。

## 描画パフォーマンス

- 追記ログは `<Static>` を使う(全再描画を避ける)。
- ストア購読(`useSessions`)は ~100ms スロットル。`useSyncExternalStore` の getSnapshot が同一参照を返せば再描画されない性質を使う。
- 経過時間など時間依存表示は `useClock()` で定期再描画する。

Expand Down
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,12 @@ interface SessionState {

### UI (ui/)

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

- `App`: ビュー状態(`list` | `detail:<id>`)と全体キーバインドを管理。`cwd` を受け取りバナーへ渡す。
- `Banner`: 起動時ヘッダ(`✻ codiva` + サブタイトル + cwd)。枠なし3行、一覧上部に表示。
- `SessionList`: `Banner` + 一覧 + 選択カーソル + 下部 `PromptInput`/`StatusFooter`。いつでも新規投入できる。
- `SessionDetail`: `<Static>` メッセージログ(`⏺`/`⎿`/`>` のグリフで転記)+ ステータスヘッダ + `PromptInput` または操作パネル/`PermissionDialog` + `StatusFooter`。
- `SessionDetail`: ステータスヘッダ(上部固定)+ メッセージログの**末尾ビューポート**(`⏺`/`⎿`/`>` のグリフで転記。flexGrow + `justifyContent="flex-end"` + `overflowY="hidden"` で最新行を下端に表示し、溢れた古い行は上へクリップ)+ `PromptInput` または操作パネル/`PermissionDialog` + `StatusFooter`(下部固定)。`<Static>` はスクロールバックに書くため全画面レイアウトと両立せず不使用
- `PromptInput` / `StatusFooter`: presentational。キー処理は各 view の単一 `useInput` に集約(ロジックは持たない)。
- 再描画スロットリング: SessionManager の通知を UI 側で ~100ms にスロットルする。

Expand Down Expand Up @@ -182,7 +182,7 @@ UI 文字列は日本語/英語を設定で切り替えられる。規約は [.c
| リスク | 対応 |
|--------|------|
| SDK メッセージ形式の想定違い | Phase 1 のスパイクで実メッセージを JSONL 収集し、reducer のテストフィクスチャに使う(想定で書かない) |
| 大量ストリームで Ink 再描画が重い | 詳細ビューは選択中セッションのみ描画 + `<Static>` + スロットリング |
| 大量ストリームで Ink 再描画が重い | 詳細ビューは選択中セッションのみ描画 + ログは `slice(-rows)` で端末高さぶんに限定 + スロットリング |
| 質問検出の誤判定 | MVP はヒューリスティック + 詳細ビューでいつでも追加入力可能なので誤判定の実害は小さい。スパイク結果で改善 |
| 並列セッションのAPIコスト | Backlog でコスト表示を追加。MVP では result メッセージの usage をログに残すのみ |
| ユーザーのメインworktreeが dirty | worktree は HEAD から切るため影響なし。起動時チェックで警告のみ表示 |
3 changes: 3 additions & 0 deletions docs/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ UI なし。すべてユニットテストで駆動する。

## Phase 6: Backlog(MVP後、着手前にユーザーと相談)

- [x] 全画面(100dvh 相当)レイアウト: root に端末 rows を指定、入力欄+フッタを下部固定、詳細ログは `<Static>` から末尾ビューポートへ置換
- [ ] 詳細ビューのログスクロール(末尾ビューポートは最新行のみ表示。過去ログを遡る手段が未実装)
- [ ] 入力欄の複数行化(Shift+Enter 改行、行数に応じて上に伸び、上限超過で内部スクロール — Claude Code 同等)
- [ ] アプリ再起動後のセッション復元(`.codiva/state.json` + SDK `resume`)
- [ ] 設定ファイル(model / effort / permissionMode / maxBudgetUsd)
- [ ] コスト表示(result の total_cost_usd 累計)
Expand Down
3 changes: 2 additions & 1 deletion docs/TECH_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ function toUserMessage(text: string): SDKUserMessage {
## Ink 7 の実装メモ

- Ink 7 は React 19 前提。コンポーネントは通常の React。`render(<App/>)` で起動。
- **`<Static>`**: 一度描画したら再描画しない追記専用領域。セッション詳細のメッセージログに必須(全ログ再描画は即座に破綻する)。
- **全画面(100dvh 相当)**: Ink はコンテンツの高さぶんしか描画しないインラインレンダラ。root Box に `useWindowSize()` の rows を `height` 指定すると Ink 7 がフルスクリーンフレームとして扱う(末尾改行なし・インクリメンタル消去。ただしフレームが端末高さを**超える**と全画面クリアにフォールバックしてちらつくので、root に `overflow="hidden"` を付けて超過を防ぐ)。端末が極端に低い(`MIN_FULLSCREEN_ROWS` 未満)ときは height 固定をやめてインライン描画へフォールバックする — クリップで入力欄・フッタが消えて操作不能になるより、端末スクロールに任せる方が安全。
- **`<Static>` は全画面レイアウトと両立しない**: Static はスクロールバック側に書き出すため、フレームが画面いっぱいだとビューポート外に消える。メッセージログは末尾ビューポート(flexGrow + `justifyContent="flex-end"` + `overflowY="hidden"`)+ `tailMessages(messages, rows)`(`core/layout.ts`)で再描画コストに上限を掛ける方式に変更した。
- **`useInput`**: グローバルキーハンドラ。フォーカス管理は `useFocus` もあるが、MVP はビュー単位の単純な状態分岐で足りる。
- **`useApp().exit()`**: 終了。終了前に SessionManager.dispose()(全 abort)を呼ぶ。
- 再描画スロットリング: コアからの onChange を UI 側で ~100ms デバウンス。`useSyncExternalStore` の getSnapshot が返す参照が変わらなければ再描画されない点を利用する。
Expand Down
43 changes: 31 additions & 12 deletions src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useApp, useInput } from 'ink';
import { Box, useApp, useInput, useWindowSize } from 'ink';
import { type FC, useState } from 'react';
import { messages as catalogs, type Messages, type SessionManager } from '@/core';
import {
messages as catalogs,
isFullscreenViewport,
type Messages,
type SessionManager,
} from '@/core';
import { MessagesProvider, SessionDetail, SessionList } from '@/ui';

type View = { mode: 'list' } | { mode: 'detail'; id: string };
Expand All @@ -13,6 +18,14 @@ export const App: FC<{ manager: SessionManager; cwd?: string; messages?: Message
}) => {
const { exit } = useApp();
const [view, setView] = useState<View>({ mode: 'list' });
// Ink はコンテンツの高さぶんしか描画しない(インラインレンダラ)ため、端末の
// 行数を root に明示して全画面(web の 100dvh 相当)にする。リサイズにも追従。
// overflow="hidden" は保険: フレームが端末高さを超えると Ink が全画面クリアに
// フォールバックしてちらつくので、超過分は必ずクリップする。
// ただし端末が極端に低いと固定部分(バナー+入力欄+フッタ)だけで rows を超え、
// クリップすると操作不能になるため、その場合はインライン描画へフォールバックする。
const { rows } = useWindowSize();
const fullscreen = isFullscreenViewport(rows);

const quit = () => {
manager.dispose();
Expand All @@ -28,16 +41,22 @@ export const App: FC<{ manager: SessionManager; cwd?: string; messages?: Message

return (
<MessagesProvider value={messages}>
{view.mode === 'detail' ? (
<SessionDetail manager={manager} id={view.id} onBack={() => setView({ mode: 'list' })} />
) : (
<SessionList
manager={manager}
onOpen={(id) => setView({ mode: 'detail', id })}
onQuit={quit}
cwd={cwd}
/>
)}
<Box
flexDirection="column"
height={fullscreen ? rows : undefined}
overflow={fullscreen ? 'hidden' : undefined}
>
{view.mode === 'detail' ? (
<SessionDetail manager={manager} id={view.id} onBack={() => setView({ mode: 'list' })} />
) : (
<SessionList
manager={manager}
onOpen={(id) => setView({ mode: 'detail', id })}
onQuit={quit}
cwd={cwd}
/>
)}
</Box>
</MessagesProvider>
);
};
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './async-queue';
export * from './config';
export * from './i18n';
export * from './layout';
export * from './session';
export * from './session-manager';
export * from './slug';
Expand Down
46 changes: 46 additions & 0 deletions src/core/layout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { isFullscreenViewport, MIN_FULLSCREEN_ROWS, tailMessages } from './layout';
import type { LogEntry } from './types';

function entries(n: number): LogEntry[] {
return Array.from({ length: n }, (_, i) => ({
seq: i,
kind: 'assistant_text',
text: `line ${i}`,
}));
}

describe('isFullscreenViewport', () => {
it.each([
[MIN_FULLSCREEN_ROWS - 1, false],
[MIN_FULLSCREEN_ROWS, true],
[8, false],
[24, true],
[0, false],
])('rows=%d → %s', (rows, expected) => {
expect(isFullscreenViewport(rows)).toBe(expected);
});
});

describe('tailMessages', () => {
it('returns everything when messages fit within rows', () => {
const all = entries(3);
expect(tailMessages(all, 10)).toEqual(all);
});

it('keeps only the newest rows entries when overflowing', () => {
const tail = tailMessages(entries(30), 5);
expect(tail).toHaveLength(5);
expect(tail[0]?.seq).toBe(25);
expect(tail[4]?.seq).toBe(29);
});

it('returns the newest entry even when rows is 0 or negative', () => {
expect(tailMessages(entries(4), 0).map((e) => e.seq)).toEqual([3]);
expect(tailMessages(entries(4), -2).map((e) => e.seq)).toEqual([3]);
});

it('returns an empty array for an empty log', () => {
expect(tailMessages([], 10)).toEqual([]);
});
});
26 changes: 26 additions & 0 deletions src/core/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { LogEntry } from './types';

/**
* 全画面レイアウトに必要な最小の端末行数。固定部分(バナー6行 + 入力欄3行 +
* フッタ・余白・パディング)だけで約15行あり、これ未満で root の height を
* 固定するとクリップで入力欄やフッタが消えて操作不能になる。
*/
export const MIN_FULLSCREEN_ROWS = 16;

/**
* 端末の行数で全画面レイアウト(root の height 固定 + 超過クリップ)を使うか判定する。
* 閾値未満は従来どおりのインライン描画(コンテンツ高さぶん描画し、溢れは端末の
* スクロールに任せる)へフォールバックする。
*/
export function isFullscreenViewport(rows: number): boolean {
return rows >= MIN_FULLSCREEN_ROWS;
}

/**
* 詳細ビューの末尾ビューポートへ渡すログ行。画面には端末の行数ぶんしか映らない
* ため末尾 rows 件に絞り、Ink の描画ノード数に上限を掛ける。rows が 0 以下でも
* 最新 1 件は返す(slice(-0) が全件になる事故の防止)。
*/
export function tailMessages(messages: LogEntry[], rows: number): LogEntry[] {
return messages.slice(-Math.max(1, rows));
}
41 changes: 28 additions & 13 deletions src/ui/session-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Box, Static, Text, useInput } from 'ink';
import { Box, Text, useInput, useWindowSize } from 'ink';
import { type FC, useEffect, useState } from 'react';
import type { DiffStat, LogEntry, SessionManager } from '@/core';
import { type DiffStat, type LogEntry, type SessionManager, tailMessages } from '@/core';
import { useRunMode, useSessions } from './hooks';
import { useMessages } from './i18n-context';
import { editBuffer } from './input';
Expand Down Expand Up @@ -41,6 +41,7 @@ export const SessionDetail: FC<{
const m = useMessages();
const sessions = useSessions(manager);
const mode = useRunMode(manager);
const { rows } = useWindowSize();
const session = sessions.find((s) => s.id === id);
const [buffer, setBuffer] = useState('');
const [panel, setPanel] = useState<'input' | 'actions'>('input');
Expand Down Expand Up @@ -151,7 +152,7 @@ export const SessionDetail: FC<{

if (!session) {
return (
<Box padding={1}>
<Box flexGrow={1} padding={1}>
<Text dimColor>{m.detail.notFound}</Text>
</Box>
);
Expand All @@ -165,12 +166,9 @@ export const SessionDetail: FC<{
: m.detail.helpInput;

return (
<Box flexDirection="column" padding={1}>
<Static items={session.messages}>
{(entry) => <LogLine key={entry.seq} entry={entry} />}
</Static>

<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column" flexGrow={1} padding={1}>
{/* ステータスヘッダ(画面上部に固定) */}
<Box flexDirection="column" flexShrink={0}>
<Box>
<Text color={theme.accent}>{glyph.star} </Text>
<Text bold>{session.title} </Text>
Expand All @@ -192,9 +190,29 @@ export const SessionDetail: FC<{
{m.detail.errorLabel}: {session.error}
</Text>
) : null}
</Box>

{/*
* メッセージログの末尾ビューポート。flexGrow で残り高さを占め、
* justifyContent="flex-end" + overflowY="hidden" で「最新行が下端、
* 溢れた古い行は上へクリップ」にする。<Static> はスクロールバック側に
* 書くため全画面レイアウトでは画面外に消えてしまい使えない。
*/}
<Box
flexDirection="column"
flexGrow={1}
marginTop={1}
overflowY="hidden"
justifyContent="flex-end"
>
{tailMessages(session.messages, rows).map((entry) => (
<LogLine key={entry.seq} entry={entry} />
))}
</Box>

<Box flexDirection="column" marginTop={1} flexShrink={0}>
{isTerminal && diff ? (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column" marginBottom={1}>
<Text dimColor>{m.detail.changesTitle(session.branch)}</Text>
{diff.committed ? (
<Text>{diff.committed}</Text>
Expand All @@ -212,9 +230,6 @@ export const SessionDetail: FC<{
{m.detail.actionErrorLabel}: {actionError}
</Text>
) : null}
</Box>

<Box flexDirection="column" marginTop={1}>
{pending ? (
<PermissionDialog
request={pending}
Expand Down
5 changes: 3 additions & 2 deletions src/ui/session-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,11 @@ export const SessionList: FC<{
});

return (
<Box flexDirection="column" padding={1}>
<Box flexDirection="column" flexGrow={1} padding={1}>
<Banner cwd={cwd} sessionCount={sessions.length} />

<Box flexDirection="column" marginY={1}>
{/* flexGrow で残り高さを占め、入力欄とフッタを画面最下部へ押し下げる */}
<Box flexDirection="column" marginY={1} flexGrow={1} overflowY="hidden">
{sorted.length === 0 ? (
<Text dimColor>{m.list.emptyHint}</Text>
) : (
Expand Down
Loading
Loading