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
8 changes: 6 additions & 2 deletions .claude/rules/ink-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@
- ビュー切替は `App` の `View` state(`{mode:'list'}` | `{mode:'detail', id}`)。Enter/→ で `onOpen(id)`、
Esc で `onBack`。詳細ビューは単一 `useInput` の state machine(panel = input | actions)で、
タイピング(追加指示)と操作キー(m/d = マージ/破棄)の衝突を防ぐ。
- 詳細ビューは**ステータスヘッダを持たない**。コンテンツ(ログ)+ フッタ(コンポーザ)だけにし、
ログ用の縦幅を最大化する(一覧はヘッダ=Banner + コンテンツ + フッタだが、詳細はヘッダ抜き)。
- ログは末尾ビューポート(`justifyContent="flex-end"` + `overflowY="hidden"`)に描き、`<Static>` は使わない
(全画面では画面外へ消えるため)。PgUp/PgDn のスクロールは純関数 `core/scroll.ts`
(`logWindow`/`scrollUp`/`scrollDown`)に委譲し、移動量は可視ログ高さ(`logViewportRows`)から導く。
(全画面では画面外へ消えるため)。PgUp/PgDn とマウスホイールのスクロールは純関数 `core/scroll.ts`
(`logWindow`/`scrollUp`/`scrollDown`)に委譲し、移動量は可視ログ高さ(`logViewportRows`)/ホイールは
`WHEEL_SCROLL_ROWS` から導く。**マウスホイールのレポート列は `parseSgrMouse` で useInput 先頭で先取り解釈**
する(一覧と同じ)。これをしないとホイールのエスケープ列が生テキストとしてコンポーザへ入力されてしまう。
- 1 SDK セッション 1 ライター。詳細ビューを開いても codiva が唯一のライターであり続ける
(外部 CLI との二重接続はしない)。マージ/破棄は一覧・詳細のどちらからでも可能。

Expand Down
7 changes: 5 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,11 @@ Claude Code の実画面に寄せる: 画面は**端末の縦幅いっぱい**
`composer`(起動時既定。タイピング + 矢印キャレット移動)と `list`(↑↓選択・Enter/→ = 詳細を開く・
m/d = マージ/破棄)の2ゾーンで Tab 切替。選択セッションの `PermissionDialog` は list フォーカス時のみ
アクティブ。マウスクリック(`core/mouse.ts` + `useAbsolutePosition`)で行選択・キャレット移動。
- `SessionDetail`: 詳細画面。SDK セッションに**直結**し、末尾ビューポートにログを描画(`core/scroll.ts` の
`logWindow`/`scrollUp`/`scrollDown` で PgUp/PgDn スクロール)、`streamingText` のタイピング風プレビュー、
- `SessionDetail`: 詳細画面。**ステータスヘッダは持たず**、コンテンツ(末尾ビューポートのログ)+ フッタ
(追加指示コンポーザ)だけの構成。SDK セッションに**直結**し、末尾ビューポートにログを描画(`core/scroll.ts` の
`logWindow`/`scrollUp`/`scrollDown` で PgUp/PgDn とマウスホイール(`WHEEL_SCROLL_ROWS`)スクロール。
ホイールは `parseSgrMouse` で先取り解釈し、レポート列がコンポーザへ文字入力として漏れないようにする)、
`streamingText` のタイピング風プレビュー、
下部の追加指示コンポーザ(`manager.send(id, text)`)を持つ。Tab で入力↔操作パネルを切替し、
操作パネルで m/d = マージ/破棄。`pendingPermission` があれば `PermissionDialog` に委譲。単一 `useInput` の
state machine(panel = input | actions)でタイピングとキー操作の衝突を防ぐ。
Expand Down
9 changes: 5 additions & 4 deletions src/core/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ export function isFullscreenViewport(rows: number): boolean {
}

/**
* 詳細ビューでログ以外に消費される固定の縦幅(ステータスヘッダ + 余白 + 入力欄 +
* フッタ)のおおよその見積り。実測値の下限をやや大きめに取る(過小評価すると
* スクロール1回の移動量が実際の可視ログ高さを超え、未表示の行を飛ばしてしまうため)。
* 詳細ビューでログ以外に消費される固定の縦幅(余白 + スクロールヒント + 入力欄 +
* フッタ)のおおよその見積り。ステータスヘッダを廃したぶん従来より小さい。
* 実測値の下限をやや大きめに取る(過小評価するとスクロール1回の移動量が実際の
* 可視ログ高さを超え、未表示の行を飛ばしてしまうため)。
*/
export const DETAIL_CHROME_ROWS = 10;
export const DETAIL_CHROME_ROWS = 7;

/**
* 詳細ビューで実際にログが見える行数のおおよその見積り。端末全体の rows から
Expand Down
8 changes: 8 additions & 0 deletions src/core/scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export function pageStep(rows: number): number {
return Math.max(1, Math.floor(Math.max(1, rows) / 2));
}

/**
* The `rows` value passed to {@link scrollUp}/{@link scrollDown} for a single
* mouse-wheel tick. `pageStep` halves it, so this yields ~3 lines per tick — a
* fine-grained step (terminals emit several wheel reports per physical scroll),
* distinct from PageUp/PageDown's half-viewport jump.
*/
export const WHEEL_SCROLL_ROWS = 6;

/**
* Resolve an anchor into a concrete window over `messages`. At most ~`rows`
* entries are rendered (Ink would otherwise render the whole, possibly huge, log);
Expand Down
71 changes: 26 additions & 45 deletions src/ui/session-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@ import { type FC, useEffect, useRef, useState } from 'react';
import {
type DiffStat,
emptyBuffer,
formatUsd,
type LogEntry,
logViewportRows,
logWindow,
parseSgrMouse,
type ScrollAnchor,
type SessionManager,
scrollDown,
scrollUp,
type TextBuffer,
WHEEL_SCROLL_ROWS,
} from '@/core';
import { useRunMode, useSessions } from './hooks';
import { useMessages } from './i18n-context';
import { editText, resolveEnter } from './input';
import { PermissionDialog } from './permission-dialog';
import { ProgressBadge } from './progress-badge';
import { PromptInput } from './prompt-input';
import { StatusFooter } from './status-footer';
import { glyph, theme } from './theme';
Expand Down Expand Up @@ -130,7 +130,23 @@ export const SessionDetail: FC<{
});
};

const total = session?.messages.length ?? 0;

useInput((input, key) => {
// SGR マウスレポートはキー入力より先に解釈する。これをしないと(マウス有効時に)
// ホイールスクロールのエスケープ列が生テキストとして editText に流れ込み、
// 「スクロールしようとすると文字が入力される」バグになる(一覧の useInput と同じ対策)。
const mouse = parseSgrMouse(input);
if (mouse) {
if (mouse.kind === 'wheel') {
setAnchor((a) =>
mouse.dir === 'up'
? scrollUp(a, total, WHEEL_SCROLL_ROWS)
: scrollDown(a, total, WHEEL_SCROLL_ROWS),
);
}
return; // press/release はログビューでは無視(クリック操作はない)
}
if (key.escape) {
if (confirm) {
setConfirm(null);
Expand All @@ -157,11 +173,11 @@ export const SessionDetail: FC<{
// step is derived from the *visible* log height, not the full terminal, so a
// page never jumps past unseen lines.
if (key.pageUp) {
setAnchor((a) => scrollUp(a, session?.messages.length ?? 0, logViewportRows(rows)));
setAnchor((a) => scrollUp(a, total, logViewportRows(rows)));
return;
}
if (key.pageDown) {
setAnchor((a) => scrollDown(a, session?.messages.length ?? 0, logViewportRows(rows)));
setAnchor((a) => scrollDown(a, total, logViewportRows(rows)));
return;
}
if (confirm) {
Expand Down Expand Up @@ -212,7 +228,6 @@ export const SessionDetail: FC<{
);
}

const activeForm = session.todos.find((t) => t.status === 'in_progress')?.activeForm;
const footerHint = pending
? m.detail.helpPending
: panel === 'actions'
Expand All @@ -223,48 +238,14 @@ export const SessionDetail: FC<{

return (
<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>
<ProgressBadge state={session} />
<Text dimColor>
{' '}
{session.branch}
</Text>
</Box>

{session.progress ? (
<Text dimColor>
{m.detail.progress(session.progress.done, session.progress.total, activeForm)}
</Text>
) : null}

{session.totalCostUsd ? (
<Text dimColor>{m.detail.cost(formatUsd(session.totalCostUsd))}</Text>
) : null}

{session.error ? (
<Text color="red">
{m.detail.errorLabel}: {session.error}
</Text>
) : null}
</Box>

{/*
* メッセージログの末尾ビューポート。flexGrow で残り高さを占め、
* justifyContent="flex-end" + overflowY="hidden" で「最新行が下端、
* 溢れた古い行は上へクリップ」にする。<Static> はスクロールバック側に
* 書くため全画面レイアウトでは画面外に消えてしまい使えない。
* ヘッダは持たない(要件: セッション詳細はコンテンツ + フッタのみ)。
* メッセージログの末尾ビューポートが上端いっぱいまで残り高さを占める。
* flexGrow で残りを占め、justifyContent="flex-end" + overflowY="hidden" で
* 「最新行が下端、溢れた古い行は上へクリップ」にする。<Static> はスクロール
* バック側に書くため全画面レイアウトでは画面外に消えてしまい使えない。
*/}
<Box
flexDirection="column"
flexGrow={1}
marginTop={1}
overflowY="hidden"
justifyContent="flex-end"
>
<Box flexDirection="column" flexGrow={1} overflowY="hidden" justifyContent="flex-end">
{win.entries.map((entry) => (
<LogLine key={entry.seq} entry={entry} />
))}
Expand Down
32 changes: 30 additions & 2 deletions tests/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,15 +517,43 @@ describe('App detail view (in-app connection)', () => {
await flush();
stdin.write('\r'); // Enter → open detail in-app (no external CLI)
await flush();
// Detail chrome: the follow-up composer placeholder + the session title header.
// Detail chrome: the follow-up composer placeholder is shown, and the list
// composer placeholder is gone (no status header — content + footer only).
expect(lastFrame()).toContain('追加の指示を入力');
expect(lastFrame()).toContain('open me');
expect(lastFrame()).not.toContain('実装してほしいこと');

stdin.write('\x1b'); // Esc → back to the list
await flush();
expect(lastFrame()).toContain('実装してほしいこと'); // list composer placeholder
});

it('mouse-wheel reports scroll the log instead of typing into the composer', async () => {
const { manager, out } = drivenManager();
const { stdin, lastFrame } = render(<App manager={manager} />);
stdin.write('scroll me');
await flush();
stdin.write('\r');
await flush();
out.push(asMsg({ type: 'system', subtype: 'init', session_id: 'sdk-w' }));
await flush();

stdin.write('\t'); // focus the list
await flush();
stdin.write('\r'); // open detail
await flush();

// Wheel up/down SGR reports (button 64/65). They must be consumed as scroll
// gestures, never inserted as text — the composer stays empty (placeholder shown).
stdin.write('\x1b[<64;10;3M');
await flush();
stdin.write('\x1b[<65;10;3M');
await flush();

const frame = lastFrame();
expect(frame).toContain('追加の指示を入力'); // empty composer → placeholder still visible
expect(frame).not.toMatch(/64|65/); // no escape-report fragments leaked as text
});

it('sends a follow-up from the detail composer to the live session', async () => {
const { manager, out } = drivenManager();
const { stdin, lastFrame } = render(<App manager={manager} />);
Expand Down
Loading