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
11 changes: 10 additions & 1 deletion .claude/rules/ink-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@

- **1画面につき `useInput` は1つ**(view コンポーネントに置く)。`PromptInput` 等は presentational にして、キー処理は view 側の単一ハンドラに集約する(複数 `useInput` の競合を避ける)。
- モーダルな状態(`pendingPermission` あり)では、そのダイアログにキーを委譲し、背後の view はキーを処理しない。
例外は**中断(`Ctrl+C`)だけ**で、詳細ビューは `pending` ガードより前にこれを処理する(ダイアログの
`n` は「そのツール1回を断る」だけなので、作業自体をやめる出口が他に無い)。`editText` は ctrl chord を
無視するので、ダイアログ側の入力とは競合しない。
例外として**モーダル自身は `useInput` を持つ**(`permission-dialog` / `model-select` / `repo-prompt-editor`)。
成立条件は「背後の view がモーダル表示中に全キーを飲む」こと(`pending` / `modelSelect` / `promptEdit` の
ガードが view の `useInput` の先頭にある)。モーダルを増やすときはこのガードを必ず追加する。
Expand All @@ -39,7 +42,8 @@
composer に戻ってそのまま挿入される。選択中セッションの許可/質問ダイアログは
**list フォーカス時のみ**アクティブ(composer のタイピングを乗っ取らない)。
- **フォーカスに依存しない操作は chord にする**。中断セッションの復帰(`Ctrl+R` = 選択中を再開、
`Ctrl+A` = 一括再開)は一覧・詳細のどちらでも、フォーカスゾーン/操作パネルの状態に関係なく効く。
`Ctrl+A` = 一括再開)と実行中ターンの中断(詳細ビューの `Ctrl+C`)は、フォーカスゾーン/操作
パネルの状態に関係なく効く。
印字キー(`r`)だけにすると既定フォーカス(composer / 入力欄)から「Tab → r」の2手になり、
復帰が「ワンプッシュ」にならない。逆に印字キーをフォーカス横断で奪うとタイピングが壊れるので、
**横断させたいものは必ず ctrl 付き**にする(`editText` は ctrl chord を無視するので競合しない)。
Expand Down Expand Up @@ -168,6 +172,11 @@
- ビュー切替は `App` の `View` state(`{mode:'list'}` | `{mode:'detail', id}`)。Enter/→ で `onOpen(id)`、
Esc で `onBack`。詳細ビューは単一 `useInput` の state machine(panel = input | actions)で、
タイピング(追加指示)と操作キー(m/d = マージ/破棄)の衝突を防ぐ。
- **`Ctrl+C` は実行中のターンの中断**(`manager.interrupt(id)`。Claude Code と同じ操作)。Ink は
`exitOnCtrlC: false` なのでアプリ終了ではなくこのハンドラへ届く。破棄ではないので案内文(`detail.cancelHint`)
は「あとで再開できる」ことを伝え、中断後は同じ行が `resume.oneKeyHint` に入れ替わる。対象判定
(連打の吸収)は core 側(`SessionManager.interrupt`)に置く — ここの `status` はスロットルされた
購読値なので「もう中断済み」を同期的に知らない(`resume` と同じ)。
- **スラッシュ無しでもコマンド名と完全一致すればコマンド**(`core/commands.ts` の `toCommandInput`)。
正式名のみ(別名 `?`/`changes` は昇格させない = 1文字の `?` を送れる余地を残す)。判定と実行・
パレット表示は `useCommandRunner` が返す `run`/`preview` を共有して**必ず同じ条件**にする
Expand Down
8 changes: 8 additions & 0 deletions .claude/rules/session-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ UI・永続・通知は**この表を参照**し、独自の集合(`TERMINAL`
| `terminal` | 終端=差分/操作を出す | `isTerminalStatus`(detail/list/manager) |
| `attention` | 一覧の ● 強調 | `needsAttention`(list) |
| `active` | 稼働時間を積算する区間 | `isActiveStatus`(`accrueActive`) |
| `interruptible` | `Ctrl+C` で中断できる(ターンが進行中) | `isInterruptible`(detail / `SessionManager.interrupt`) |
| `resumable` | 再開アクション `r` の可否 | `isResumable`(両 view) |
| `restoreAs` | 保存時に丸める先 | `persistence.restorableStatus` |
| `notifyKey` | デスクトップ通知の文言キー | `core/notify.ts` |
Expand All @@ -92,6 +93,13 @@ UI・永続・通知は**この表を参照**し、独自の集合(`TERMINAL`

- アプリ終了は `stop()`(quiet 停止。状態を変えずサブプロセスだけ落とす)。`abort()` は
`failed` にするので「1件破棄」専用。両者を混同しない。
- **中断(`interrupt()`、詳細ビューの `Ctrl+C`)は3つ目の別物**: 走っているターンだけをやめ、
サブプロセスは生かしたまま `interrupted`(idle & resumable)にする。状態は **SDK の応答を
待たずに先に確定**させる — CLI が返すターン終了 result は `is_error: true` なので、
診断が無いと `failed` に落ちる(sdk-parse は `terminal_reason: 'aborted_streaming'` も
同じ `USER_INTERRUPT_DETAIL` で `interrupted` にするので、二重ログにも `failed` にもならない)。
対象判定(`isInterruptible`)は `SessionManager.interrupt` に置く(`resume` と同じ理由 =
UI の購読はスロットルされていて連打を弾けない)。
- `stop()` / 再開可能状態へ落ちる前に**保留中の許可を deny で解決**する。未応答の `tool_use`
で終わるトランスクリプトは後の resume を壊す。
- 復元セッションは `start()` せず、最初の `send()` で遅延 resume(起動時にサブプロセスを乱立させない)。
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/add-session-status/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ description: codiva の SessionStatus(running / completed / rate_limited な

## 2. 性質を決める — `src/core/status-meta.ts`

`STATUS_META` に1行足す。6フィールドすべてを意識して決める:
`STATUS_META` に1行足す。7フィールドすべてを意識して決める:

| フィールド | 問い |
|---|---|
| `terminal` | 差分・マージ/破棄操作を出す状態か |
| `attention` | 一覧で ● 強調してユーザーの操作を促すか |
| `active` | 稼働時間(`activeMs`)を積算する区間か(=実際に動いているか) |
| `interruptible` | `Ctrl+C`(中断)が意味を持つか(=ターンが進行中か。`active` と違い `awaiting_*` も true) |
| `resumable` | `r`(再開)で SDK 会話を続行できるか |
| `restoreAs` | アプリ終了時に何へ丸めて保存するか(`undefined` = 保存しない) |
| `notifyKey` | デスクトップ通知の文言キー(`Messages['notify']` のキー。不要なら省略) |
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ codiva
> keybind = super+backspace=text:\x15
> ```

### 実行中の作業を中断する(`Ctrl+C`)

セッション詳細ビューで `Ctrl+C` を押すと、**そのセッションが今やっているターンを中断**します(Claude Code の `Ctrl+C` と同じ操作です)。codiva 自体は終了しません。

- 中断したセッションは**失敗ではなく「中断」**として残るので、`Ctrl+R`(または追加指示を送る)で**同じ会話の続き**から再開できます。worktree・ブランチ・書きかけのコードはそのままです。
- **許可待ち / 質問待ちのダイアログが出ている間も効きます**。ダイアログの `n`(拒否)は「そのツール 1 回を断る」だけで作業は続くので、「この作業自体をやめたい」ときは `Ctrl+C` を使ってください。
- 入力欄にフォーカスがあっても効きます(書きかけを消したいだけなら `Ctrl+U`)。実行中は画面下に案内が出ます。
- 中断ではなくセッションを**捨てたい**ときは `d`(破棄)/ `x`(削除)です。一覧ビューでは `Ctrl+C` は何もしません(誤爆を避けるため、中断は詳細ビューだけの操作です)。

### テキストのコピー

入力欄・ヘッダ(ワードマーク / プラン / モデル / ブランチ / cwd)・**セッション詳細のログ**・**`/prompt` のリポジトリ指示エディタ**は、**ドラッグで範囲選択して離すとクリップボードへコピー**されます(OSC 52 なので SSH 越しでも動きます)。ヘッダの cwd 行をドラッグすれば、いま作業しているパスをそのまま貼り付けられます。ヘッダのドラッグは入力中のフォーカスや一覧の選択行を動かしません。
Expand Down
35 changes: 32 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,15 @@ codiva/
needs_login ──(再ログイン後 追加指示 / 再開アクション)──▶ running # 認証が戻れば同じ SDK 会話を resume
needs_login ──(アプリ終了 → 保存)───────────▶ interrupted # 次回起動時には再ログイン済みかもしれない
interrupted ──(追加指示送信 / 再開アクションで resume)───────▶ running # 生存中セッションもその場で再開(consume ループ再起動)
running/awaiting_* ──(ユーザーが Ctrl+C)─────▶ interrupted # 詳細ビューの中断。resumable(後述)
```

`interrupted` は「クリーンに完了していないが resume で続行できる」セッションを表す。発生元は3つ:
`interrupted` は「クリーンに完了していないが resume で続行できる」セッションを表す。発生元は4つ:
(1) **通信断**(`Session.consume` の for-await が throw、または接続断を示すエラー `result`。`core/errors.ts`
の `isConnectionError` で判定し、resume 元となる `sdkSessionId` がある場合のみ。無い=init 前の早期失敗は
`failed`)。(2) **応答途中の API エラー**(後述)。(3) **アプリ終了時の丸め**(`restorableStatus` が実行中/
入力待ちを保存時に `interrupted` にする。`stop()` はメモリ上の状態を変えない)。いずれも `completed` と同じく idle で resumable。追加指示または
入力待ちを保存時に `interrupted` にする。`stop()` はメモリ上の状態を変えない)。(4) **ユーザーによる中断**
(詳細ビューの `Ctrl+C`。後述)。いずれも `completed` と同じく idle で resumable。追加指示または
**再開アクション(一覧/詳細の `r`)** で resume できる — 送信すると `SessionManager.send` → `Session.send`
が(通信断で終了した)consume ループを `resume: sdkSessionId` 付きで**再起動**し、同じ SDK 会話を続行する
(生存中セッションでもその場で再開でき、アプリ再起動を待たなくてよい)。通信断遷移時はデスクトップ通知
Expand Down Expand Up @@ -168,6 +170,33 @@ codiva/
**ストアの現在値**(`send` が同期的に `running` へ進める)で `isResumable` を確かめてから送り、送ったかを
返す。View(`Ctrl+R` / 一覧の `r` / 一括)はすべてこれを経由する。

**ユーザーによる中断(詳細ビューの `Ctrl+C`)**: 走っているターンを止めたいだけで、セッションを捨てたい
わけではない(Claude Code の `Ctrl+C` と同じ操作)。`SessionDetail` → `SessionManager.interrupt(id)` →
`Session.interrupt()` → SDK の `Query.interrupt()` で、状態は **`interrupted`(idle & resumable)** に落ちる。
`stop()`(状態を変えずプロセスだけ落とす)/ `abort()`(`failed` にする)とは別物。

- **状態は SDK の応答を待たずに先に確定させる**。理由は2つ。(a) 体感: interrupt は control request なので
CLI の応答まで待つと押しても数百 ms 反応しない。(b) 分類: CLI は中断されたターンを
`subtype: 'error_during_execution'` + `is_error: true` + **`terminal_reason: 'aborted_streaming'`** の
result で閉じる(実測: `__fixtures__/session-interrupt.jsonl`)ため、診断が無いと `failed` に落ちる。
先に `interrupted` を立てておけば、result 側は**すでに resumable なら診断を維持**するロールアップガード
(`isResumable`)でコストだけを拾う。
- **`sdk-parse` 側も `aborted_streaming` を `interrupted` に分類する**(保険)。中断のあとに assistant
メッセージが 1 通挟まって status が `running` へ戻っても、ターンの終わりは `failed` にならない。ログに
書くのは `USER_INTERRUPT_DETAIL`(= `'interrupted by user'`)で、CLI の内部診断
(`errors: ['[ede_diagnostic] …']`)は出さない。2 経路で**同じ文言**を使うので `toInterrupted` の
重複畳み込みが効き、ログは 1 行だけになる。
- **許可/質問待ちでも中断できる**(`isInterruptible` = `running` / `awaiting_permission` / `awaiting_input`)。
ダイアログの `n`(deny)は「その 1 ツールを断る」だけでターンは続くので、「この作業自体をやめる」出口は
これしかない。`Ctrl+C` は詳細ビューの `useInput` で**`pending` ガードより前**に処理し、`toInterrupted` が
`pendingPermission` を落とすことで `commit()` の既存経路が canUseTool の promise を deny で閉じる
(未応答の `tool_use` で終わる transcript は後の resume を壊す ⇒ `stop()` と同じ理由)。
- **連打の吸収は `SessionManager.interrupt(id)`**(`resume()` と同じ理由で core 側。UI の購読は
~100ms スロットルされていて「もう中断済み」を同期的に知らない)。ストアの現在値で `isInterruptible` を
確かめ、中断を試みたかを返す。
- 中断後は `interrupted` なので**そのまま `Ctrl+R` / 追加指示で続けられる**。案内も `detail.cancelHint`
(実行中)→ `resume.oneKeyHint`(中断後)と同じ 1 行を状態で入れ替える。

**応答途中の API エラー(`API Error: Connection closed mid-response.`)**: ストリーミング中に接続が切れると
CLI は「そこまでの部分応答を確定させて」ターンを終える。ワイヤ上は `error: 'server_error'` を立てた
assistant メッセージ(本文が `API Error: Connection closed mid-response. The response above may be
Expand Down Expand Up @@ -357,7 +386,7 @@ interface SessionState {
- streaming input mode を常用: `query()` の prompt に自前の `AsyncGenerator<SDKUserMessage>` を渡し、内部キュー(push可能な async queue)で管理。`send(text)` でいつでも追加メッセージを投入できる。
- 受信ループ: `for await (const msg of query)` で各 SDK メッセージを `applySdkMessage()`(`core/sdk-parse.ts`)に畳み込む。SDK メッセージ形状の解釈はここに閉じ、純粋 reducer(`reduce(state, CodivaEvent)`)は型付きイベントだけを扱う。UI アクション(追加指示・許可・モデル切替等)は `reduce` へ dispatch。変更のたびに `onChange` を発火。
- `respondToPermission(result)`: 保留中の canUseTool Promise を resolve。
- `interrupt()` / `abort()`: SDK の interrupt / AbortController。
- `interrupt()` / `abort()`: SDK の interrupt / AbortController。**`interrupt()` は「走っているターンだけをやめる」**(詳細ビューの `Ctrl+C`): サブプロセスは生かしたまま `interrupted`(idle & resumable)にし、追加指示 / `Ctrl+R` で同じ SDK 会話を続けられる状態にする。状態は SDK の応答を待たずに**先に**確定させる(体感 + 分類。下記「ユーザーによる中断」を参照)。許可/質問待ちで呼ばれた場合は `commit()` の既存経路が canUseTool の promise を deny で閉じる(未応答の `tool_use` は後の resume を壊す)。`isInterruptible` でない状態では何もしない。
- `SessionOptions`(`model`/`effort`/`permissionMode`/`maxBudgetUsd`/`appendSystemPrompt`/`ignoredFiles`)を DI で受け、`query()` の `options` に反映(設定ファイル由来)。`permissionMode` 未指定時は `acceptEdits`。
- **systemPrompt の組み立ては純関数 `core/system-prompt.ts`(`composeSystemPrompt`)**。要素は「worktree の環境説明」→「リポジトリ追加指示」の順(前提の説明が先、著者の具体的な指示が後)で、どちらも無ければ `undefined`(= `systemPrompt` を渡さない)。`session.ts` は文言も結合順も持たない。
- **worktree の環境説明(共有 symlink の注意書き)**: `ignoredFiles: 'symlink'`(既定)では ignore 済みパスが元リポジトリの実体を指すため、セッションが依存更新やビルドを走らせるとメインチェックアウトと並行セッションに波及する。そこで**このモードのときだけ** `SHARED_IGNORED_FILES_NOTICE` を systemPrompt に載せ、「読むのは安全 / 書く前にそのパスだけリンクを切って独立させる / リンク越しに消さない(`rm -rf <path>/` 禁止)/ 触らない作業では何もしない」を伝える。モードは合成レイヤの `sessionOptionsFrom(config, appendSystemPrompt)`(`bootstrap/build-manager.ts`。config → `SessionOptions` の対応付けだけを持つ純関数で、spec で固定してある)が `resolveIgnoredFilesMode(config)` で解決して `SessionOptions.ignoredFiles` へ渡す。解決箇所は合成レイヤの2つ(`index.tsx` の `WorktreeManager` 生成とここ)だが、どちらも同じ config 由来なので一致する。**既知の制約**: モードは state.json に永続していないので、`symlink` で作った worktree を後から `copy` / `none` 設定で復元すると注意書きが載らない(設定を変えた場合のみ。逆向き=実体があるのに注意書きが載るケースは、手順1の `test -L` 判定で無害化される)。**codiva 側でリンクを張り替えることはしない** — 何が書き込み対象かは指示内容次第で、先回りして全部コピーすると symlink モードの利点(複製コストゼロ)が消えるため、判断はセッションに委ねる。文言は AI 向けなので英語・i18n カタログ対象外(`utils/title.ts` と同じ扱い)。
Expand Down
Loading
Loading