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
28 changes: 25 additions & 3 deletions .claude/rules/ink-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` に入れ替わる。対象判定
Expand Down Expand Up @@ -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 で終わる行の**右の余白をクリックしただけで開く**。
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 行が書き換わり続けるのではなく、チャットと同じように読み進められます)。
Expand Down
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` のエディタ・質問ダイアログの「自分で入力する」)にあるが、バッファ
Expand Down
20 changes: 18 additions & 2 deletions src/core/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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: 全消し',
Expand Down Expand Up @@ -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',
Expand Down
24 changes: 17 additions & 7 deletions src/ui/permission-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<QuestionDialog
Expand All @@ -55,6 +61,7 @@ export const PermissionDialog: FC<{
onCopy={onCopy}
active={active}
onActivate={onActivate}
inactiveHint={inactiveHint}
/>
);
}
Expand All @@ -65,6 +72,7 @@ export const PermissionDialog: FC<{
onDeny={onDeny}
active={active}
onActivate={onActivate}
inactiveHint={inactiveHint}
/>
);
};
Expand All @@ -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();
// 枠の位置と高さを実測する(クリックがこのダイアログの中かを判定するため)。
Expand Down Expand Up @@ -141,7 +150,7 @@ const ToolDialog: FC<{
{m.permission.deny}
</Text>
) : (
<Text dimColor>{m.permission.inactiveHelp}</Text>
<Text dimColor>{inactiveHint ?? m.permission.inactiveHelp}</Text>
)}
</Box>
);
Expand Down Expand Up @@ -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 ?? [];
Expand Down Expand Up @@ -454,7 +464,7 @@ const QuestionDialog: FC<{
<Box marginTop={1}>
<Text dimColor>
{!active
? m.permission.inactiveHelp
? (inactiveHint ?? m.permission.inactiveHelp)
: mode === 'typing'
? m.permission.typingHelp
: m.permission.questionHelp(current.multiSelect ?? false)}
Expand Down
Loading
Loading