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 () => {