diff --git a/.claude/rules/ink-components.md b/.claude/rules/ink-components.md
index ce4ec28..68356a0 100644
--- a/.claude/rules/ink-components.md
+++ b/.claude/rules/ink-components.md
@@ -222,6 +222,23 @@
- ビュー切替は `App` の `View` state(`{mode:'list'}` | `{mode:'detail', id}`)。Enter/→ で `onOpen(id)`、
Esc で `onBack`。詳細ビューは単一 `useInput` の state machine(panel = input | actions)で、
タイピング(追加指示)と操作キー(m/d = マージ/破棄)の衝突を防ぐ。
+- **許可/質問ダイアログが出ている間は 2 ゾーン**(`DetailFocus` = `dialog` / `log`。Tab で往復)。
+ 一覧の `ListFocus` と**同じ理由**でゾーンを分ける: ダイアログと ↑↓ を混ぜると
+ 「選択肢の移動」に食われて**会話ログを遡れず、質問の背景が分からないまま回答させられる**
+ (かつては `pending` の間キー全部をダイアログに委譲していたので、詳細ビューには逃げ道が
+ 1 つも無かった)。`dialog` ゾーンは `PermissionDialog` がキーを持ち、view 側は**出口の Tab
+ だけ**を処理する。`log` ゾーンは ↑↓ / PgUp / PgDn がログのスクロールだけを受け、
+ ダイアログは `active={false}` で**表示のまま**にする(回答の途中経過は内部 state なので
+ アンマウントしない = 一覧と同じ)。
+ - **ゾーンは pending が解けたら `dialog` へ畳む**(一覧が composer へ畳むのと同じ)。
+ 宙に浮かせると次の質問が来た瞬間にキーの意味が変わる。
+ - **既定ゾーンは `dialog`**(回答は待たせている用事なので、そこへ辿り着くのに Tab を
+ 踏ませない = 一覧の `zoneForRow` と同じ方針)。`log` ゾーンからは Tab とクリック
+ (`onActivate`)の 2 経路で戻れる。Esc も `log` → `dialog` へ戻す(一覧へ戻るのは
+ `dialog` ゾーンから)。
+ - **ダイアログ内の「表示のみ」ヒントは view から渡す**(`inactiveHint`)。一覧の
+ `permission.inactiveHelp`(「↑↓: セッション切替」)は詳細では嘘になるので、
+ 共有コンポーネントに view の知識を持たせず prop で差し替える。
- **`Ctrl+C` は実行中のターンの中断**(`manager.interrupt(id)`。Claude Code と同じ操作)。Ink は
`exitOnCtrlC: false` なのでアプリ終了ではなくこのハンドラへ届く。破棄ではないので案内文(`detail.cancelHint`)
は「あとで再開できる」ことを伝え、中断後は同じ行が `resume.oneKeyHint` に入れ替わる。対象判定
@@ -350,9 +367,14 @@
- **副作用のある操作は左ボタンだけ**(`MouseEvent.button`)。右クリック(端末の
コンテキストメニューを期待した操作)や中クリック(貼り付け)でブラウザを開かない。
選択・フォーカス移動は無害なのでどのボタンでも受けてよい。
- - **モーダル表示中は詳細ビューもマウスレポートを飲む**(`modelSelect || pending` で
- 早期 return。一覧と同じ)。飲まないと許可待ちのダイアログの上での 1 クリックで
- 背後のログの選択が動き、URL の上ならブラウザまで開く。
+ - **モーダル表示中は詳細ビューもマウスレポートを飲む**(`modelSelect` 等で早期 return。
+ 一覧と同じ)。飲まないと許可待ちのダイアログの上での 1 クリックで背後のログの選択が
+ 動き、URL の上ならブラウザまで開く。
+ - **許可/質問ダイアログは例外を 2 つ持つ**。(1) **ホイールは常に通す**(スクロールは
+ 押下と違って副作用が無く、質問の背景を読むための第一の手段)。(2) `log` ゾーンでは
+ 押下系も通す(範囲選択・URL を開く)— そこはユーザーが Tab で明示的に「ログを触る」と
+ 選んだ状態なので、上の「意図しない副作用」に当たらない。`dialog` ゾーンでは従来どおり
+ 押下系を飲む。
- **当たり判定は行末で丸めない**(`logLinkAt` は `column >= stringWidth(text)` を弾く)。
選択のアンカー(`logCaretAt`)は短い行の右の余白からドラッグを始めたいので丸めてよいが、
リンクを丸めると URL で終わる行の**右の余白をクリックしただけで開く**。
diff --git a/README.md b/README.md
index 098120a..50e38e5 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,22 @@ codiva
- 回答の途中(何問目か・チェック済みの選択肢・書きかけの自由記述)は `Tab` で往復しても、選択肢をクリックして「自分で入力する」に戻っても保持されます。
- 回答し終えるとフォーカスは入力欄へ戻るので、次の質問が来ても**タイピング中のキーを奪われません**(`Tab` 1 回でまた回答できます)。
+### 詳細ビューのフォーカス移動(`Tab`)— 回答する前にログを遡る
+
+セッション詳細で**質問・許可のダイアログが出ている間**も、`Tab` で **ダイアログ ⇄ 会話ログ** を行き来できます。質問だけを見せられて「何の話か分からないまま答える」ことがないように、背景を読み返してから回答するための切り替えです。
+
+| ゾーン | `↑↓` / `PgUp` `PgDn` の動作 | 主なキー |
+|---|---|---|
+| ダイアログ(既定) | ダイアログの選択肢 | `Enter` 決定 ・ `Space` トグル ・ `y`/`n`(ツール許可) |
+| 会話ログ | **ログのスクロール** | `Tab` 回答へ戻る ・ `Esc` ダイアログへ |
+
+- ダイアログが出ると**まずダイアログにフォーカスが当たります**(回答は待たせている用事なので、そこへ辿り着くために `Tab` を押す必要はありません)。ログを遡りたくなったら `Tab` を 1 回押してください。
+- ログゾーンでもダイアログは**表示されたまま**(内容は読めます)で、キーだけを受け取りません。回答へ戻るのは `Tab`・`Esc`・**ダイアログのクリック**のどれでも構いません。
+- 回答の途中(何問目か・チェック済みの選択肢・書きかけの自由記述)は往復しても保持されます。
+- ログゾーンでは**ドラッグでの範囲選択・URL のクリック**も普段どおり使えます(ホイールでのスクロールはどちらのゾーンでも効きます)。
+- 回答し終えるとフォーカスはダイアログ側に戻るので、次の質問もそのまま答えられます。
+- `Ctrl+C`(中断)はどちらのゾーンでも効きます。
+
### ログの読み方・スクロール
セッション詳細のログでは、エージェントの応答が**返ってきたぶんだけ下に伸びていきます**(1 行が書き換わり続けるのではなく、チャットと同じように読み進められます)。
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5a2c9c6..bfdcab2 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -947,6 +947,11 @@ Claude Code の実画面に寄せる: 画面は**端末の縦幅いっぱい**
操作パネルで m/d/x = マージ/破棄/削除(`x` は行ごと消すので成功時は一覧へ戻る)。
`pendingPermission` があれば `PermissionDialog` に委譲。単一 `useInput` の
state machine(panel = input | actions)でタイピングとキー操作の衝突を防ぐ。
+ 許可/質問待ちの間はさらに 2 ゾーン(`DetailFocus` = dialog | log)を Tab で往復し、
+ `log` ゾーンでは ↑↓ / PgUp / PgDn がログのスクロールに戻る(ダイアログは `active={false}`
+ で表示のまま)。**質問の背景(何をしようとしているのか)を読んでから回答するための出口**で、
+ 以前は `pending` の間キーを全部ダイアログへ委譲していたため会話ログを 1 行も遡れなかった。
+ ゾーンを分けるのは一覧の `ListFocus` と同じ理由(↑↓ が指す対象を一意にする)。
- `PromptInput` / `StatusFooter`: presentational。キー処理は view の単一 `useInput` に集約(ロジックは持たない)。`PromptInput` は複数行対応(純粋モデルは `core/text-buffer.ts`、キー対応は `ui/input.ts` の `editText`/`resolveEnter`)。幅を超えたテキストは**折り返す**(truncate しない): 折り返し後の表示行・キャレット位置・クリック逆算・選択範囲はすべて純粋な `core/composer-layout.ts`(`composerLayout`)が算出し、折り返し幅は Box の実測値(`useComposerWidth`)を描画・当たり判定・↑↓ 移動で共有する。IME 対応で実端末カーソルをキャレットに重ねる(`useCursor`)。
- **`useComposer` / `Composer`(`ui/composer.tsx`)— 入力欄は 1 実装**: 入力欄は 4 か所(一覧の新規指示・
詳細の追加指示・`/prompt` のエディタ・質問ダイアログの「自分で入力する」)にあるが、バッファ
diff --git a/src/core/i18n.ts b/src/core/i18n.ts
index db08d55..1e3b91a 100644
--- a/src/core/i18n.ts
+++ b/src/core/i18n.ts
@@ -88,7 +88,19 @@ export interface Messages {
* フッタヒントではなく独立した行に出す(再開の `resume.oneKeyHint` と同じ扱い)。
*/
cancelHint: string;
+ /** 許可/質問ダイアログにフォーカスがあるとき(`dialog` ゾーン)のフッタヒント */
helpPending: string;
+ /**
+ * 許可/質問待ちのまま会話ログを遡っているとき(`log` ゾーン)のフッタヒント。
+ * 質問の背景を読んでから回答するためのゾーンなので、戻り方(Tab / Esc)を必ず出す。
+ */
+ helpLog: string;
+ /**
+ * `log` ゾーンのあいだ「表示だけ」になっているダイアログの中に出す案内。
+ * 一覧の `permission.inactiveHelp`(↑↓ = セッション切替)は詳細では嘘になるので、
+ * 共有コンポーネントへは view からこの文言を渡す。
+ */
+ dialogInactiveHelp: string;
helpActions: string;
helpInput: string;
};
@@ -552,7 +564,9 @@ const ja: Messages = {
discardAction: '破棄(worktree削除)',
removeAction: '削除(一覧から消す)',
cancelHint: 'Ctrl+C: 実行中のターンを中断(あとで再開できます)',
- helpPending: 'Esc: 一覧へ戻る',
+ helpPending: 'ダイアログで回答 ・ Tab: ログを遡る ・ Esc: 一覧へ戻る',
+ helpLog: '↑↓/PgUp/PgDn: ログを遡る ・ Tab: 回答へ戻る ・ Esc: ダイアログへ',
+ dialogInactiveHelp: '↑↓/PgUp/PgDn: ログを遡る ・ クリックで回答へ ・ Tab: 回答へ戻る',
helpActions: 'm/d/x: 操作 ・ ↑↓/PgUp/PgDn: ログ ・ Tab: 入力へ ・ Esc: 戻る',
helpInput:
'Enter: 送信 ・ Shift+Enter: 改行 ・ ↑↓/PgUp/PgDn: ログ ・ Tab: 操作 ・ Esc: 一覧へ ・ Ctrl+U: 全消し',
@@ -849,7 +863,9 @@ const en: Messages = {
discardAction: 'Discard (remove worktree)',
removeAction: 'Remove (drop from the list)',
cancelHint: 'Ctrl+C: interrupt the current turn (you can resume it later)',
- helpPending: 'Esc: back to list',
+ helpPending: 'Answer in the dialog · Tab: scroll back the log · Esc: back to list',
+ helpLog: '↑↓/PgUp/PgDn: scroll the log · Tab: back to the dialog · Esc: dialog',
+ dialogInactiveHelp: '↑↓/PgUp/PgDn: scroll the log · click to answer · Tab: back to the dialog',
helpActions: 'm/d/x: actions · ↑↓/PgUp/PgDn: log · Tab: input · Esc: back',
helpInput:
'Enter: send · Shift+Enter: newline · ↑↓/PgUp/PgDn: log · Tab: actions · Esc: back · Ctrl+U: clear',
diff --git a/src/ui/permission-dialog.tsx b/src/ui/permission-dialog.tsx
index 1ee6401..f088ae5 100644
--- a/src/ui/permission-dialog.tsx
+++ b/src/ui/permission-dialog.tsx
@@ -41,11 +41,17 @@ export const PermissionDialog: FC<{
/** false ならキーを受け取らない(表示のみ)。省略時は true。 */
active?: boolean;
/**
- * ダイアログの中がクリックされた(= ここを操作したい)。一覧ビューは
- * フォーカスゾーンを `dialog` へ移す。詳細ビューは常に active なので渡さない。
+ * ダイアログの中がクリックされた(= ここを操作したい)。一覧ビューはフォーカスゾーンを
+ * `dialog` へ、詳細ビューは `log` ゾーンから `dialog` ゾーンへ戻す。
*/
onActivate?: () => void;
-}> = ({ request, onAnswer, onAllow, onDeny, onCopy, active = true, onActivate }) => {
+ /**
+ * `active={false}` のときに枠内へ出す案内。**ゾーンから抜ける/戻る方法は view 固有**
+ * (一覧は「↑↓ でセッション切替」、詳細は「↑↓ でログを遡る」)なので、共有コンポーネントに
+ * view の知識を持たせず文言を渡してもらう。省略時は一覧向けの既定。
+ */
+ inactiveHint?: string;
+}> = ({ request, onAnswer, onAllow, onDeny, onCopy, active = true, onActivate, inactiveHint }) => {
if (request.kind === 'question') {
return (
);
}
@@ -65,6 +72,7 @@ export const PermissionDialog: FC<{
onDeny={onDeny}
active={active}
onActivate={onActivate}
+ inactiveHint={inactiveHint}
/>
);
};
@@ -75,7 +83,8 @@ const ToolDialog: FC<{
onDeny: (message: string) => void;
active: boolean;
onActivate?: () => void;
-}> = ({ request, onAllow, onDeny, active, onActivate }) => {
+ inactiveHint?: string;
+}> = ({ request, onAllow, onDeny, active, onActivate, inactiveHint }) => {
const m = useMessages();
const { columns } = useWindowSize();
// 枠の位置と高さを実測する(クリックがこのダイアログの中かを判定するため)。
@@ -141,7 +150,7 @@ const ToolDialog: FC<{
{m.permission.deny}
) : (
- {m.permission.inactiveHelp}
+ {inactiveHint ?? m.permission.inactiveHelp}
)}
);
@@ -171,7 +180,8 @@ const QuestionDialog: FC<{
onCopy?: (text: string) => void;
active: boolean;
onActivate?: () => void;
-}> = ({ request, onAnswer, onDeny, onCopy, active, onActivate }) => {
+ inactiveHint?: string;
+}> = ({ request, onAnswer, onDeny, onCopy, active, onActivate, inactiveHint }) => {
const m = useMessages();
const { columns } = useWindowSize();
const questions = request.questions ?? [];
@@ -454,7 +464,7 @@ const QuestionDialog: FC<{
{!active
- ? m.permission.inactiveHelp
+ ? (inactiveHint ?? m.permission.inactiveHelp)
: mode === 'typing'
? m.permission.typingHelp
: m.permission.questionHelp(current.multiSelect ?? false)}
diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx
index 45ec0d2..04a1ed3 100644
--- a/src/ui/session-detail.tsx
+++ b/src/ui/session-detail.tsx
@@ -63,6 +63,19 @@ import { PrSummary } from './pr-cell';
import { StatusFooter } from './status-footer';
import { statusColor, theme } from './theme';
+/**
+ * 許可/質問ダイアログが出ているあいだのフォーカスゾーン(Tab で往復)。
+ *
+ * - `dialog`(既定)… `PermissionDialog` がキーを持つ。view 側は出口の Tab だけを処理する。
+ * - `log` … ↑↓ / PgUp / PgDn が**会話ログのスクロール**に戻る。ダイアログは
+ * `active={false}` で表示のまま残す(回答の途中経過は内部 state なのでアンマウントしない)。
+ *
+ * ゾーンを分ける理由は一覧の `ListFocus` と同じ「↑↓ が指す対象を一意にする」こと。
+ * かつては `pending` のあいだキーを全部ダイアログへ委譲していたため、**質問の背景
+ * (何をしようとしているのか)を読み返せないまま回答させられていた**。
+ */
+type DetailFocus = 'dialog' | 'log';
+
/**
* The in-app detail view: live log of a single session plus a follow-up
* composer. Reconnects to the running SDK session (no external CLI) — send
@@ -70,7 +83,8 @@ import { statusColor, theme } from './theme';
*
* A single `useInput` runs a small state machine (panel = input | actions) so
* typing and command keys never collide (see .claude/rules/ink-components.md).
- * When the session is blocked on a permission/question, the dialog owns the keys.
+ * When the session is blocked on a permission/question, the dialog owns the keys
+ * unless the user tabs over to the log ({@link DetailFocus}).
*/
export const SessionDetail: FC<{
manager: SessionManager;
@@ -141,6 +155,10 @@ export const SessionDetail: FC<{
setAnchor(next);
};
const [panel, setPanel] = useState<'input' | 'actions'>('input');
+ // 許可/質問ダイアログが出ているあいだのゾーン(`pending` のときだけ意味を持つ)。
+ // 既定は `dialog`: 回答は待たせている用事なので、そこへ辿り着くのに Tab を踏ませない
+ // (一覧の `zoneForRow` と同じ方針)。
+ const [dialogFocus, setDialogFocus] = useState('dialog');
// Open when the user runs `/model`; the ModelSelect dialog then owns the keys.
const [modelSelect, setModelSelect] = useState(false);
// Open when the user runs `/agent`; the AgentSelect dialog then owns the keys.
@@ -175,6 +193,8 @@ export const SessionDetail: FC<{
const recovering = recovery.busy;
const pending = session?.pendingPermission;
+ // ダイアログがキーを持っているか。`log` ゾーンでは表示だけになり、↑↓ はログへ戻る。
+ const dialogActive = pending !== undefined && dialogFocus === 'dialog';
const status = session?.status;
const isTerminal = status !== undefined && isTerminalStatus(status);
// このセッションを駆動しているエージェントと、その capability。UI は「持たない機能の
@@ -230,6 +250,15 @@ export const SessionDetail: FC<{
void manager.interrupt(session.id).catch(() => undefined);
};
+ // 決着が付いたら(回答した / ターンが先へ進んだ)ゾーンを既定へ畳む。宙に浮かせたまま
+ // にすると、同じセッションが次に質問した瞬間にキーの意味が変わる(一覧が composer へ
+ // 畳むのと同じ理由)。
+ useEffect(() => {
+ if (!pending && dialogFocus !== 'dialog') {
+ setDialogFocus('dialog');
+ }
+ }, [pending, dialogFocus]);
+
// Fetch the diff summary once the session reaches a terminal state.
useEffect(() => {
if (!isTerminal) {
@@ -501,11 +530,19 @@ export const SessionDetail: FC<{
// editText に流れ込み「スクロールしようとすると文字が入力される」のを防ぐ)。
const mouse = parseSgrMouse(rawInput);
if (mouse) {
- // モーダル表示中はマウスも飲む(一覧の `session-list.tsx` と同じ方針)。
+ // 画面を占有するモーダル表示中はマウスも飲む(一覧の `session-list.tsx` と同じ方針)。
// `parseSgrMouse` で弾くのは自分のハンドラを守るだけで、同じ生入力は兄弟の
// useInput にも届く。飲まないとダイアログ上の 1 クリックで背後のログの選択が
- // 動き、URL の上ならブラウザまで開いてしまう(許可待ちの最中に)。
- if (modelSelect || agentSelect || loginAgent !== null || pending) {
+ // 動き、URL の上ならブラウザまで開いてしまう。
+ if (modelSelect || agentSelect || loginAgent !== null) {
+ return;
+ }
+ // 許可/質問ダイアログにキーがあるあいだ(`dialog` ゾーン)は**押下系だけ**飲む。
+ // ホイールは通す — スクロールは押下と違って副作用が無く、質問の背景を読み返す
+ // 第一の手段なので、ここで奪うと「ログが見えないまま回答」に戻ってしまう。
+ // `log` ゾーンではユーザーが明示的にログを触ると選んだ状態なので、範囲選択・
+ // URL のクリックもそのまま通す。
+ if (dialogActive && mouse.kind !== 'wheel') {
return;
}
if (mouse.kind === 'wheel') {
@@ -586,6 +623,12 @@ export const SessionDetail: FC<{
setConfirm(null);
return;
}
+ // ログを遡っていたなら、まず回答(ダイアログ)へ戻す。一覧の Esc がどのゾーンからも
+ // 既定ゾーンへ戻るのと同じで、待たせている質問を素通りして画面を離れさせない。
+ if (pending && !dialogActive) {
+ setDialogFocus('dialog');
+ return;
+ }
if (panel === 'actions') {
setPanel('input');
return;
@@ -611,7 +654,35 @@ export const SessionDetail: FC<{
return;
}
if (pending) {
- return; // PermissionDialog owns the keys
+ // 許可/質問待ちのあいだは 2 ゾーン(`DetailFocus`)を Tab で往復する。
+ // ここに ↑↓ を両ゾーンで持たせないのが要点 — 一覧と同じで、混ぜるとダイアログの
+ // 選択肢移動に食われてログを 1 行も遡れなくなる(= 質問の背景が読めない)。
+ if (key.tab) {
+ setDialogFocus(dialogActive ? 'log' : 'dialog');
+ return;
+ }
+ if (dialogActive) {
+ return; // PermissionDialog owns the keys(出口の Tab は上で処理した)
+ }
+ // `log` ゾーン: ログのスクロールだけを受ける(回答は Tab / クリックで戻ってから)。
+ if (key.pageUp) {
+ applyAnchor(scrollUp(anchorRef.current, total, logCap));
+ return;
+ }
+ if (key.pageDown) {
+ applyAnchor(scrollDown(anchorRef.current, total, logCap));
+ return;
+ }
+ if (key.upArrow || key.downArrow) {
+ applyAnchor(
+ key.upArrow
+ ? scrollUp(anchorRef.current, total, logCap, ARROW_SCROLL_LINES)
+ : scrollDown(anchorRef.current, total, logCap, ARROW_SCROLL_LINES),
+ );
+ return;
+ }
+ // 印字キーはここでは何もしない(コンポーザはダイアログに場所を譲っていて出ていない)。
+ return;
}
// Log scroll (terminal scrollback is disabled under the alt screen). The
// step is derived from the *visible* log height, not the full terminal, so a
@@ -703,7 +774,9 @@ export const SessionDetail: FC<{
: agentSelect
? m.agent.help
: pending
- ? m.detail.helpPending
+ ? dialogActive
+ ? m.detail.helpPending
+ : m.detail.helpLog
: panel === 'actions'
? m.detail.helpActions
: m.detail.helpInput;
@@ -876,8 +949,16 @@ export const SessionDetail: FC<{
onCancel={() => setModelSelect(false)}
/>
) : pending ? (
+ // `log` ゾーンでも**描いたまま**にする(質問文を読みながらログを遡れるように)。
+ // キーを持つのは `dialog` ゾーンのときだけで、無効化はアンマウントではなく
+ // `active` で行う — 回答の途中経過(何問目か・チェック・書きかけの自由記述)は
+ // 内部 state なので、Tab で往復するたびに捨てられては困る(一覧と同じ)。
setDialogFocus('dialog')}
+ inactiveHint={m.detail.dialogInactiveHelp}
onAnswer={(answers) => manager.answer(session.id, answers)}
onAllow={() => manager.allow(session.id)}
onDeny={(message) => manager.deny(session.id, message)}
diff --git a/tests/app.test.tsx b/tests/app.test.tsx
index 90f8c2b..fb2918a 100644
--- a/tests/app.test.tsx
+++ b/tests/app.test.tsx
@@ -1306,6 +1306,97 @@ describe('App detail view (in-app connection)', () => {
expect(lastFrame()).not.toContain('rm -rf /'); // ダイアログは閉じている
});
+ /**
+ * 許可/質問待ちのあいだ Tab で「ダイアログ ⇄ 会話ログ」を往復できる。かつては `pending` の
+ * あいだキーを全部ダイアログへ委譲していたため、詳細ビューでは**会話ログを 1 行も遡れず**
+ * 「何の話か分からないまま回答する」しかなかった(一覧の `ListFocus` と同じ問題)。
+ * ホイールはゾーンに関係なく効く(スクロールは副作用の無い操作)。
+ */
+ it('許可待ちでも Tab でログを遡れ、Tab で回答へ戻れる', async () => {
+ const out = new AsyncQueue();
+ let captured: Options | undefined;
+ const queryFn = ((params: { options: Options }) => {
+ captured = params.options;
+ const gen = (async function* () {
+ yield* out;
+ })() as unknown as Query & { interrupt: () => Promise };
+ gen.interrupt = async () => {};
+ return gen;
+ }) as unknown as QueryFn;
+ const manager = new SessionManager({ worktrees, queryFn, now: () => 0 });
+ manager.cycleMode(); // confirm モード(ツールが許可待ちへ上がる)
+ // フッタヒントとダイアログの案内が切り詰められない幅で描く。
+ const { app, stdin, lastFrame } = renderFullscreen(, 20, 120);
+ stdin.write('ask me');
+ await settle(lastFrame);
+ stdin.write('\r');
+ await settle(lastFrame);
+ out.push(asMsg({ type: 'system', subtype: 'init', session_id: 'sdk-tab-log' }));
+ // 可視域より多い行を流して「遡れる」状態を作る。
+ for (let i = 0; i < 40; i++) {
+ out.push(
+ asMsg({
+ type: 'assistant',
+ message: { content: [{ type: 'text', text: `log-${String(i).padStart(2, '0')}` }] },
+ }),
+ );
+ }
+ await settle(lastFrame);
+ stdin.write('\t'); // focus the list
+ await settle(lastFrame);
+ stdin.write('\r'); // open the detail view
+ await settle(lastFrame);
+
+ const ctx = { signal: new AbortController().signal } as unknown as Parameters<
+ NonNullable
+ >[2];
+ const decision = captured?.canUseTool?.('Bash', { command: 'rm -rf build' }, ctx);
+ await settle(lastFrame);
+ expect(manager.getSnapshot()[0]?.status).toBe('awaiting_permission');
+
+ const frame = () => stripAnsi(lastFrame());
+ const top = () => [...frame().matchAll(/log-(\d+)/g)].map((mm) => Number(mm[1]))[0] ?? -1;
+ expect(frame()).toContain('rm -rf build'); // ダイアログが出ている
+ expect(frame()).toContain(messages.ja.detail.helpPending);
+ expect(frame()).not.toContain(messages.ja.detail.dialogInactiveHelp);
+
+ // dialog ゾーンでは ↑ はダイアログのもの(ログは動かない)。
+ const before = top();
+ expect(before).toBeGreaterThanOrEqual(0);
+ stdin.write('\x1b[A');
+ await settle(lastFrame);
+ expect(top()).toBe(before);
+ // ただしホイールはゾーンに関係なく効く(押下と違い副作用が無い)。
+ stdin.write('\x1b[<64;10;5M');
+ await settle(lastFrame);
+ expect(top()).toBeLessThan(before);
+ stdin.write('\x1b[<65;10;5M'); // 末尾へ戻す
+ await settle(lastFrame);
+ expect(top()).toBe(before);
+
+ // Tab → log ゾーン。ダイアログは**表示のまま**(質問文を読みながら遡れる)。
+ stdin.write('\t');
+ await settle(lastFrame);
+ expect(frame()).toContain(messages.ja.detail.helpLog);
+ expect(frame()).toContain(messages.ja.detail.dialogInactiveHelp);
+ expect(frame()).toContain('rm -rf build');
+
+ // ↑ でログを遡れる(= 回答の前に背景を読める)。
+ stdin.write('\x1b[A');
+ await settle(lastFrame);
+ expect(top()).toBeLessThan(before);
+ expect(frame()).toContain('過去ログを表示中');
+
+ // Tab で回答へ戻り、そのまま許可できる(往復でダイアログは壊れない)。
+ stdin.write('\t');
+ await settle(lastFrame);
+ expect(frame()).toContain(messages.ja.detail.helpPending);
+ stdin.write('y');
+ await settle(lastFrame);
+ await expect(decision).resolves.toMatchObject({ behavior: 'allow' });
+ app.unmount();
+ }, 30000);
+
// 詳細ビューの `/exit` はアプリ終了ではなく「セッションを閉じて一覧へ戻る」
// (終了は一覧ビューの `/exit` = commands.test.tsx でカバー)。
it('/exit in the detail view returns to the list without quitting the app', async () => {