From bcbb9dfa1b470d7744a7b56420c95c0fd0e2173b Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Thu, 25 Jun 2026 11:46:24 -0500 Subject: [PATCH] Add conversation branching and restore --- README.md | 9 +- ROADMAP.md | 42 ++ docs/commands.md | 6 + docs/troubleshooting.md | 8 + docs/workflows.md | 10 + ...V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md | 373 ++++++++++++++ src/agent.rs | 16 +- src/app.rs | 444 ++++++++++++++++- src/branch.rs | 435 ++++++++++++++++ src/conversation.rs | 46 ++ src/file_edits.rs | 470 ++++++++++++++++++ src/lib.rs | 2 + src/ui/render.rs | 77 ++- tests/conversation_tests.rs | 19 + 14 files changed, 1952 insertions(+), 5 deletions(-) create mode 100644 plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md create mode 100644 src/branch.rs create mode 100644 src/file_edits.rs diff --git a/README.md b/README.md index f9f4225..6b924de 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ cass update Common in-chat commands: +- `/branch` or `/restore`: open the branch/restore menu. - `/model `: switch to a model from `~/.cass/models.json`. - `/new`: create a new chat for the current directory. - `/resume `: resume a saved chat for the current directory. @@ -88,7 +89,7 @@ Helpful keys: - `Tab`: cycle reasoning effort while idle. - `Ctrl-O`: toggle compact/full tool output display. - `Ctrl-Shift-R` or `Ctrl-R`: toggle reasoning display. -- `Esc`: request turn cancellation while a turn is running. +- `Esc`: request turn cancellation while a turn is running; while idle, press twice within 1.5 seconds to open branch/restore. - `Ctrl-C` twice within 1.5 seconds: exit. ## Safety model @@ -101,6 +102,12 @@ Cassady exposes tools according to the active access mode: Use `--readonly`, `--workspace-edit`, or `--full-access` to choose a mode at launch, or press `Shift-Tab` while idle. +## Branch and restore + +Press `Esc` twice while idle, or type `/branch`, to browse the current conversation's branch family. Selecting an earlier user message, assistant message, tool call, or tool result creates a new branch conversation instead of truncating the original chat. The menu also lets you switch back to related branches later. + +Conversation-only branching is the safe default. If you choose file restore, Cassady restores only file changes it tracked from successful `write` and `edit` tools. Shell commands, manual edits, unsupported files, and hash conflicts are reported or skipped rather than overwritten blindly. + ## Configuration and docs Cassady stores user-editable files in `~/.cass`: diff --git a/ROADMAP.md b/ROADMAP.md index 3cb6809..da32758 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,47 @@ # Cassady (Cass) Roadmap +## v0.2.8 — Conversation Branch and Restore + +This release focuses on making conversation recovery safe and explorable. Pressing `Esc` twice while idle opens a branch/restore menu where users can browse prior user messages, assistant messages, and tool calls, create a new branch from a selected checkpoint, and optionally restore Cassady-tracked file edits without destroying the original conversation. See `plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md`. + +### Branch Navigation + +- [x] **Open a branch/restore menu with double Esc.** Add an idle `Esc`-twice shortcut that mirrors the discoverability of double `Ctrl-C` while preserving current busy-turn cancellation and approval-denial behavior. + - Keep draft input intact when the menu is opened or cancelled. + - Provide clear status text after the first `Esc` so users know a second press opens branch/restore. + +- [x] **Browse checkpoints across the related branch family.** Show user messages, assistant messages, tool-call requests, and tool results from the current chat and related branches. + - Include enough preview text, tool names, paths, timestamps, and branch labels to choose the right point. + - Allow switching back to the original conversation or another existing branch from the same menu. + +### Safe Conversation Branching + +- [x] **Create branches instead of destructive reverts.** Selecting a checkpoint should write a new conversation JSONL with parent/checkpoint metadata, leaving the source conversation unchanged. + - Support repeated branching so users can return to the menu later and branch or switch again. + - Keep older conversations without branch metadata loadable. + +- [x] **Handle tool-call checkpoints cleanly.** Branching at a specific tool call or tool result should preserve a valid provider message history. + - Repair partial multi-tool assistant turns with synthetic cancelled/omitted tool results where needed. + - Add tests for branching at user, assistant, and tool boundaries. + +### File Edit Restoration + +- [x] **Journal Cassady file edits with restorable snapshots.** Record successful `write` and `edit` tool mutations outside the model-visible transcript with before/after hashes and snapshots. + - Keep restore support limited to Cassady-tracked file tools; warn that shell commands and manual edits are not automatically reversible. + - Store enough data to restore both backward and forward between tracked checkpoints. + +- [x] **Offer explicit conversation-only or conversation-plus-files restore actions.** Make conversation-only branching the safe default, and require confirmation before changing workspace files. + - Preview files to update or delete, detect hash conflicts, and refuse unsafe overwrites by default. + - Use atomic writes for restored files and preserve clear status/transcript messages for skipped or conflicted paths. + +### Documentation and Validation + +- [x] **Document branch/restore workflows and limitations.** Update README and bundled docs with the double-`Esc` shortcut, menu controls, branch semantics, and file-restore safety model. + - Include troubleshooting for restore conflicts and unsupported shell/manual filesystem changes. + +- [x] **Test the branch and restore model.** Cover branch metadata, checkpoint extraction, tool-call repair, edit journaling, restore planning, and keybinding behavior where practical. + - Verify `cargo fmt` and `cargo test --locked --all-targets` pass before release. + ## v0.2.7 — Self-Update Command This release focuses on making Cassady easy to keep current after installation. The goal is to let users run one clean command, `cass update`, to check GitHub releases, choose the recommended prebuilt binary or a source-build fallback, verify what will be installed, and update both `cass` and `cassady` safely. See `plans/V0_2_7_SELF_UPDATE_COMMAND_PLAN.md`. diff --git a/docs/commands.md b/docs/commands.md index fd8df24..b947c21 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -92,6 +92,7 @@ The updater does not invoke `sudo` or administrator prompts. If the install dire Type `/` to open command autocomplete. +- `/branch` or `/restore`: open the branch/restore menu for the current conversation family. - `/model `: switch the model for future turns. Autocomplete lists models from `~/.cass/models.json`. - `/new`: create a new chat for the current directory. - `/resume `: resume a saved chat from the current directory. Autocomplete lists matching chats. @@ -113,8 +114,13 @@ Local commands can be used only when the agent is idle. - `y`: approve a pending tool approval prompt. - `n` or `Esc`: deny a pending tool approval prompt. - `Esc`: request cancellation while a turn is running. +- `Esc` twice while idle: open the branch/restore menu without discarding draft input. - `Ctrl-C`: request cancellation while busy; press twice within 1.5 seconds to exit. +## Branch/restore notes + +The branch/restore menu lists related conversations plus checkpoints for user messages, assistant messages, tool-call requests, and tool results. Selecting a checkpoint creates a new branch JSONL conversation and leaves the source conversation unchanged. Conversation-only branching leaves files untouched; branch-plus-file restore applies Cassady's tracked `write`/`edit` snapshots and skips unsafe hash conflicts. + ## Output notes Tool calls are shown compactly by default. Press `Ctrl-O` to expand full tool output. Provider-streamed reasoning is hidden by default unless `show_reasoning` is enabled in config or toggled at runtime. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8280571..c7c3e53 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -143,6 +143,14 @@ Likely cause: Windows CRLF line endings or invisible whitespace differences. Fix: re-read the exact target region and preserve the line endings in `old_text`, or use a smaller unique snippet. +## Branch/restore file conflicts + +Symptom: branch-plus-file restore reports conflicts or skips paths. + +Likely cause: the file changed outside Cassady after the tracked `write`/`edit`, the file is unsupported for snapshots, or the change came from a shell command or manual editor rather than a Cassady file tool. + +Fix: review the restore preview, inspect conflicted files manually, and rerun the menu with conversation-only branching if you only need to revisit the chat. Cassady will not overwrite unknown current content by default. Open the branch/restore menu again with double `Esc` or `/branch` to switch back to the original branch. + ## Update command problems Symptom: `cass update` cannot complete. diff --git a/docs/workflows.md b/docs/workflows.md index 209b9c1..83c6c73 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -103,6 +103,16 @@ Inside the UI: This creates a new chat for the same cwd and model while preserving your current configuration. +## Branch or restore a conversation point + +Press `Esc` twice while idle, or type: + +```text +/branch +``` + +Use the menu to select a related branch or a checkpoint from a user message, assistant message, tool-call request, or tool result. Branching creates a new chat from that point and leaves the original chat available in the same menu. Choose conversation-only branching to leave files untouched, or choose branch-plus-files to restore Cassady-tracked `write`/`edit` snapshots with conflict checks. + ## Check status ```text diff --git a/plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md b/plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md new file mode 100644 index 0000000..1dac225 --- /dev/null +++ b/plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md @@ -0,0 +1,373 @@ +# v0.2.8 Conversation Branch and Restore Implementation Plan + +## Goal + +v0.2.8 adds an in-chat branch and restore menu opened by pressing `Esc` twice while Cassady is idle. Users should be able to browse the current conversation timeline, choose a checkpoint at a user message, assistant message, or tool call, and branch from that point without destroying the original conversation. They can optionally restore Cassady-tracked file edits to match the selected checkpoint, or branch the conversation only. + +Success statement: + +> A user can press `Esc` twice, select an earlier message or tool call, create a new branch from that point, optionally restore tracked file edits, and later open the same menu from either branch to switch or branch again from the related conversation history. + +## Scope + +### In scope + +- Add a double-`Esc` idle shortcut that opens a branch/restore menu, similar in feel to the double-`Ctrl-C` exit affordance. +- Keep the existing busy `Esc` behavior for turn cancellation and approval denial. +- Add a branch-aware conversation model that creates a new conversation file when restoring to a checkpoint instead of truncating or overwriting the original chat. +- Let users browse checkpoints for: + - user messages, + - assistant messages, + - assistant tool-call requests, + - completed tool results. +- Preserve valid model conversation structure when branching at or around tool calls. +- Track file mutations made by Cassady's `write` and `edit` tools with enough before/after data to restore workspace files backward or forward between tracked checkpoints. +- Offer restore actions that clearly separate conversation-only branching from conversation-plus-file restoration. +- Allow users to return to the original conversation or other related branches by opening the menu again. +- Add tests for branch metadata, checkpoint extraction, valid tool-call repair, file-edit journaling, workspace restore planning, conflict detection, and keybinding behavior where practical. +- Update README and bundled docs for the new shortcut, branch semantics, file-restore limitations, and safety prompts. + +### Out of scope + +- Rewriting arbitrary filesystem changes made by shell commands, editors, package managers, test runners, or the user outside Cassady's `write`/`edit` tools. +- Git integration, commits, worktrees, or automatic VCS operations. +- A visual diff editor for every file restore. v0.2.8 should show a concise restore plan and rely on safe conflict checks. +- Merging branches or replaying assistant responses across branches. +- Branching while an agent turn is running. +- Changing provider message semantics beyond the minimum repair needed for valid branched conversations. + +## Context and Current State + +Relevant files and behavior: + +- `src/app.rs` owns the TUI event loop, current conversation, transcript blocks, double-`Ctrl-C` exit behavior, busy `Esc` cancellation, local `/new` and `/resume` commands, and turn spawning. +- `src/conversation.rs` stores conversations as append-only JSONL with `Meta`, `System`, `User`, `Assistant`, and `Tool` records. There is no branch metadata, checkpoint API, or rewrite/create-from-prefix helper yet. +- `src/agent.rs` appends user, assistant, and tool records during a turn. Assistant records can contain multiple tool calls, while each tool result is a separate `Record::Tool`. +- `src/tools/edit.rs` and `src/tools/write.rs` perform atomic writes and return user-visible summaries/diffs, but they do not persist before/after snapshots that can be used for later restore. +- `src/ui/render.rs` renders the main chat. A branch menu should be integrated as an in-TUI modal or state, not by dropping into the setup/update prompt menu in `src/menu.rs`. + +The key design constraint is that restore must not mean destructive truncation. Selecting an old point creates a new branch conversation and leaves the source conversation available. + +## Design Principles + +1. **Branch, do not erase.** Restoring conversation state always creates or switches to a conversation branch; the original JSONL file remains intact. +2. **Make file restore explicit.** Conversation branching is safe and default. File restoration is a separate confirmation because it changes the workspace. +3. **Keep model history valid.** Branches created at tool boundaries must not leave assistant tool calls without corresponding tool records. +4. **Track only what Cassady can prove.** File restore uses durable snapshots from `write`/`edit`; unsupported shell/user changes are detected or warned about, not guessed. +5. **Recoverable navigation.** Every branch keeps parent/checkpoint metadata so the menu can show the related branch family and let users switch or branch again. +6. **Small, testable modules.** Put checkpoint extraction, branch creation, and file restore planning in dedicated modules rather than expanding the TUI loop with business logic. + +## User Experience + +### Shortcut behavior + +- While idle, first `Esc` sets status text: + + ```text + press Esc again within 1.5s to branch or restore + ``` + +- A second `Esc` within the same window opens the branch/restore menu. +- If the input box is non-empty, do not discard it silently. The first `Esc` should keep the input and show the same status; opening the menu should preserve the draft input if the user cancels. +- While an agent turn is running, keep the current behavior: `Esc` cancels the active turn. Do not open the branch menu while busy. +- During approval prompts, keep `Esc` as denial for the approval request. + +### Main branch menu + +The menu should show the current branch family, not only the current JSONL prefix: + +```text +Branch / Restore + +Current chat: 2026-06-25-101533-abcd + +Branches + • current branch + • original chat from before restore + • earlier branch: "try without refactor" + +Timeline + 1. user Add tests for config loading + 2. assistant Proposed plan + 3. tool read src/config.rs ✓ + 4. assistant Found config parser + 5. tool edit src/config.rs ✓ file: src/config.rs + 6. user Make it cleaner +``` + +Keyboard controls should be consistent with the main TUI: up/down or `j`/`k` move, Enter selects, `Esc` cancels, and an optional `/` filter can be deferred unless cheap. + +### Checkpoint actions + +After selecting a checkpoint, show an action menu: + +```text +Branch from checkpoint + +Selected: tool edit src/config.rs at 10:24:11 + + 1. Branch conversation only + 2. Branch conversation and restore tracked file edits + 3. Preview tracked file restore plan + 4. Cancel +``` + +Default should be conversation-only. The branch should get a fresh chat id, copy records through the selected checkpoint, and append branch metadata. The status should make the branch explicit: + +```text +branched 2026-06-25-110212-wxyz from 2026-06-25-101533-abcd at tool edit src/config.rs +``` + +### Switching among related branches + +Opening the menu from a branch should show its ancestors and descendants. Users can switch back to an existing branch without creating another branch: + +```text +Switch to branch + + original 2026-06-25-101533-abcd 18 records + current 2026-06-25-110212-wxyz branched at tool edit src/config.rs +``` + +Switching branch changes the active conversation/transcript only. It should not change files unless the user explicitly chooses a file restore action. + +### File restore safety + +When the user chooses file restoration, show a concise plan before writing: + +```text +Restore tracked file edits to selected checkpoint? + +Will update: + src/config.rs current hash matches Cassady snapshot + src/app.rs current hash differs; requires confirmation or skip + +Will delete: + src/generated.rs created after the checkpoint by Cassady write + +Not tracked: + shell command outputs and manual edits cannot be restored automatically + +Proceed? [y/N] +``` + +Rules: + +- If the current file hash matches the expected tracked hash, restore automatically after confirmation. +- If the file changed outside Cassady since the relevant snapshot, mark it as a conflict and default to skipping or cancelling the whole restore. +- For files that did not exist at the target checkpoint, delete only if the current content hash matches the tracked created-file hash. +- Never overwrite unknown current content without an explicit conflict confirmation. + +## Design + +### Conversation branch metadata + +Extend the conversation metadata in a backward-compatible way. One acceptable shape is adding optional fields to `Record::Meta` with `#[serde(default)]` and `skip_serializing_if`: + +```rust +Record::Meta { + chat_id: String, + created_at: String, + model: String, + cwd: String, + parent_chat_id: Option, + branch_from: Option, +} + +struct BranchPoint { + chat_id: String, + record_index: usize, + tool_call_id: Option, + checkpoint_label: String, +} +``` + +Older conversations load with no parent. Descendants can be discovered by scanning `config.conversations_dir()` for `Meta.parent_chat_id` references. + +Add a `conversation::create_branch(...)` helper that: + +1. loads the source conversation, +2. computes a valid record prefix for the selected checkpoint, +3. writes a new JSONL file with a fresh chat id and branch metadata, +4. preserves the original `System` prompt and source metadata needed for branch navigation, +5. returns the new `Conversation` for the TUI to load immediately. + +Do not truncate or rewrite the source conversation. + +### Checkpoint extraction + +Add a focused module such as `src/branch.rs` or `src/conversation_branch.rs` with types like: + +```rust +struct Checkpoint { + id: String, + chat_id: String, + record_index: usize, + tool_call_id: Option, + kind: CheckpointKind, + label: String, + detail: String, + ts: Option, +} + +enum CheckpointKind { + User, + Assistant, + ToolCall, + ToolResult, +} +``` + +Checkpoint rules: + +- A user checkpoint means the branch includes that user record. +- An assistant checkpoint means the branch includes that assistant record. If the assistant requested tools, the branch helper must repair or omit incomplete tool-call state before the next provider turn. +- A tool-result checkpoint means the branch includes records through that tool result. +- A tool-call checkpoint without a completed result should branch to the state immediately before executing that tool call, represented by an assistant record plus synthetic denied/cancelled tool records for any required missing calls. + +Because OpenAI-compatible providers require every assistant tool call to receive a tool message before the next user message, branch creation must repair partial tool-call groups. Reuse or generalize the existing cancellation repair behavior in `src/app.rs` (`finalize_cancelled_turn` and pending tool-call handling) so branched conversations remain valid. + +### File edit journal + +Add a durable edit journal separate from model-visible conversation records, for example: + +```text +~/.cass/file-edits/.jsonl +~/.cass/file-snapshots///.bin +``` + +Journal entries should be written only for successful `write` and `edit` tool calls: + +```rust +struct FileEditJournalEntry { + chat_id: String, + record_index: usize, + tool_call_id: String, + tool_name: String, // write | edit + path: PathBuf, + existed_before: bool, + existed_after: bool, + before_hash: Option, + after_hash: Option, + before_snapshot: Option, + after_snapshot: Option, + ts: String, +} +``` + +Implementation approach: + +- Add a `file_edits` module that can capture before/after bytes, hash them, store snapshots, append journal entries, and build restore plans. +- Pass chat id / record index / tool call id into tool execution context, or wrap `write`/`edit` execution in `agent.rs` so the agent captures before/after around successful file tools. +- Store full bytes, not just unified diffs, so restore works both backward and forward. +- Limit snapshots to regular files. If a path is a directory, symlink, binary too large, or otherwise unsafe, skip journaling and note that restore will not cover it. + +The first implementation can treat text and binary bytes uniformly for snapshot storage, while still using existing `write`/`edit` tools for text operations. + +### Restore planning + +File restore should compute a target workspace state from the selected checkpoint and branch lineage: + +1. Determine the selected checkpoint's branch lineage back to the root conversation. +2. Load file-edit journal entries along that lineage up to the checkpoint. +3. For every path touched by tracked edits in the relevant branch family, compute the desired state at the checkpoint: + - absent if no tracked edit existed before the checkpoint and the file was created later, + - the last `after_snapshot` at or before the checkpoint, + - the `before_snapshot` for paths whose first tracked edit happened after the checkpoint. +4. Compare the current workspace file hash to the journal's expected current hash when possible. +5. Produce a restore plan with actions: write snapshot, delete file, skip unsupported, conflict. +6. Apply only after explicit confirmation. + +For v0.2.8, if cross-branch target-state computation becomes too large, keep the algorithm conservative: support full restore for the current branch's lineage and show a clear unsupported/conflict message for unrelated sibling states. The branch metadata should still be designed so broader cross-branch restore can be added later without changing saved data. + +### TUI integration + +Add branch-menu state to `run_tui` rather than invoking `src/menu.rs` inside the alternate-screen UI. Suggested approach: + +- Add an enum such as `OverlayState::BranchMenu(BranchMenuState)` in `src/app.rs` or a new `src/ui/branch_menu.rs`. +- Extend `render::RenderState` to include an optional overlay. +- Render a centered modal with title, help text, visible items, selected row, and preview/detail panel. +- Route key events to the overlay first while it is open. +- On confirmed branch/switch/restore, update: + - `conversation`, + - `chat_id`, + - `transcript = transcript_from_loaded(...)`, + - active assistant/tool state, + - scroll/stick-to-bottom/status. + +Keep the TUI loop readable by moving branch operations into functions such as: + +```rust +open_branch_menu(...) +handle_branch_menu_key(...) +apply_branch_action(...) +``` + +### Slash command fallback + +Optionally add a discoverable slash command such as `/branch` or `/restore` that opens the same menu. This is useful for users whose terminals send unusual `Esc` sequences. If added, document it as an alias for the menu rather than a separate workflow. + +## Implementation Steps + +1. **Add branch metadata and helpers.** Extend `Record::Meta` compatibly, add branch point types, implement branch-family scanning and `create_branch` from a record prefix. +2. **Build checkpoint extraction.** Convert conversations into user/assistant/tool checkpoints with labels, previews, timestamps, and valid prefix calculations. +3. **Repair tool-call prefixes.** Generalize pending-tool-call repair so branches created around tool calls are valid for future provider requests. +4. **Add edit journaling.** Capture successful `write`/`edit` before/after snapshots, append a file-edit journal entry, and keep this separate from model-visible JSONL records. +5. **Implement restore planning.** Load journal entries, compute target states, detect conflicts by hash, and apply writes/deletes safely with existing atomic-write behavior. +6. **Add the in-TUI menu.** Implement double-`Esc` idle detection, overlay state, rendering, keyboard navigation, action confirmation, and branch/switch application. +7. **Wire status and recovery messages.** Make every branch, switch, restore, skip, and conflict result visible in the transcript or status line. +8. **Document the feature.** Update README and bundled docs with shortcut behavior, branch semantics, file restore coverage, and limitations around shell/manual edits. +9. **Test and polish.** Add unit/integration tests, run formatting, and verify the TUI manually in a small repository. + +## Tests + +- `conversation` tests: + - old JSONL conversations without branch metadata still load, + - new branch metadata serializes/deserializes, + - `create_branch` leaves the source file unchanged, + - branch-family scanning finds ancestors and descendants. +- Checkpoint tests: + - user, assistant, tool-call, and tool-result checkpoints are extracted with stable labels, + - branching at a tool result keeps valid assistant/tool ordering, + - branching in the middle of multi-tool assistant output repairs missing tool results. +- File journal tests: + - `write` records absent-to-present and present-to-present snapshots, + - `edit` records before/after bytes for successful edits only, + - failed or denied tools do not create journal entries. +- Restore-plan tests: + - restore to an earlier checkpoint rewrites tracked files to prior content, + - restore to a later checkpoint can reapply tracked content from snapshots, + - created files are deleted only when hashes match, + - external modifications are reported as conflicts. +- TUI/key tests where practical: + - first idle `Esc` sets double-press status, + - second idle `Esc` opens the branch menu, + - busy `Esc` still cancels a turn, + - approval `Esc` still denies approval. + +Manual checks: + +- Start a chat, make a `write` edit, branch conversation-only from before the edit, confirm the original remains available. +- Open the menu from the branch and switch back to the original chat. +- Branch with file restore and verify the workspace file content matches the chosen checkpoint. +- Trigger a conflict by manually editing a tracked file before restore and confirm Cassady refuses to overwrite it by default. + +## Documentation + +Update: + +- `README.md`: everyday workflow section for branching/restoring and a short safety note. +- `docs/commands.md` or the relevant TUI guide: double-`Esc`, optional `/branch`, and menu controls. +- `docs/troubleshooting.md`: conflicts, unsupported shell/manual edits, and how to switch back to the original branch. +- Any keyboard shortcut table maintained in bundled docs. + +## Acceptance Criteria + +- Pressing `Esc` twice while idle opens a branch/restore menu. +- Selecting a user, assistant, or tool checkpoint creates a new branch conversation without modifying the source conversation. +- The branch menu can be opened from the new branch to switch back to the original or create another branch. +- Users can choose conversation-only branching or branch-plus-file restore. +- File restore covers successful Cassady `write`/`edit` mutations with before/after snapshots and refuses unsafe overwrites by default. +- Branches created around tool calls produce valid future model requests. +- Existing conversations remain loadable. +- `cargo fmt` and `cargo test --locked --all-targets` pass. diff --git a/src/agent.rs b/src/agent.rs index 3ab5f9a..47dd707 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -304,10 +304,19 @@ pub async fn run_turn_with_commands( let (runtime_tx, mut runtime_rx) = mpsc::unbounded_channel::(); let mut call_tool_ctx = tool_ctx.clone(); call_tool_ctx.runtime_tx = Some(runtime_tx); + let file_edit_snapshot = crate::file_edits::begin_tool_edit( + &settings.config.root, + &conversation.id, + conversation.records.len(), + &call_id, + &call_name, + &call_arguments, + &call_tool_ctx, + ); let output = { let execute = tools::execute_with_approval( &call_name, - call_arguments, + call_arguments.clone(), &call_tool_ctx, approved, ); @@ -325,6 +334,11 @@ pub async fn run_turn_with_commands( } output }; + if output.ok { + if let Some(snapshot) = file_edit_snapshot { + let _ = crate::file_edits::finish_tool_edit(&settings.config.root, snapshot); + } + } let _ = tx.send(AgentEvent::ToolResult { id: call_id.clone(), name: call_name.clone(), diff --git a/src/app.rs b/src/app.rs index ff727f8..4185378 100644 --- a/src/app.rs +++ b/src/app.rs @@ -208,6 +208,8 @@ async fn run_tui( let mut reasoning_effort = config.reasoning_effort; let mut scroll: u16 = 0; let mut last_ctrl_c: Option = None; + let mut last_esc: Option = None; + let mut branch_menu: Option = None; let mut handle: Option>> = None; let mut cancel_requested = false; let mut current_turn_start_len: Option = None; @@ -332,6 +334,7 @@ async fn run_tui( )? }; + let overlay_view = branch_menu.as_ref().map(BranchMenuState::overlay_view); terminal.draw(|f| { render::render( f, @@ -349,7 +352,12 @@ async fn run_tui( show_reasoning, reasoning_effort, scroll, - autofill: autofill.as_ref(), + autofill: if branch_menu.is_some() { + None + } else { + autofill.as_ref() + }, + overlay: overlay_view.as_ref(), }, ) })?; @@ -358,6 +366,43 @@ async fn run_tui( match event { Event::Key(key) if key.kind == KeyEventKind::Press => { let busy = handle.is_some(); + if branch_menu.is_some() { + match handle_branch_menu_key( + key.code, + &mut branch_menu, + &config, + &cwd, + &mut conversation, + &mut chat_id, + &mut transcript, + &mut active_assistant, + &mut active_reasoning, + &mut active_tools, + &mut status, + ) { + Ok(BranchMenuOutcome::None) => {} + Ok(BranchMenuOutcome::Changed) => { + stick_to_bottom = true; + scroll = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + )?; + } + Err(err) => { + branch_menu = None; + status = format!("branch menu failed: {err}"); + transcript.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "branch".into(), + content: err.to_string(), + }); + } + } + continue; + } if busy { if let Some(pending) = pending_approval.clone() { match key.code { @@ -429,11 +474,13 @@ async fn run_tui( } cancel_requested = true; last_ctrl_c = Some(now); + last_esc = None; status = "turn cancellation requested; press Ctrl-C again within 1.5s to exit".into(); } else { input.clear(); autofill_selected = 0; last_ctrl_c = Some(now); + last_esc = None; status = "press Ctrl-C again within 1.5s to exit".into(); } } @@ -443,8 +490,31 @@ async fn run_tui( } cancel_requested = true; last_ctrl_c = None; + last_esc = None; status = "turn cancellation requested".into(); } + (KeyCode::Esc, _) => { + let now = Instant::now(); + if last_esc + .map(|t| now.duration_since(t) <= Duration::from_millis(1500)) + .unwrap_or(false) + { + match BranchMenuState::open(&config, &conversation) { + Ok(menu) => { + branch_menu = Some(menu); + last_esc = None; + status = "branch/restore menu".into(); + } + Err(err) => { + last_esc = None; + status = format!("branch menu failed: {err}"); + } + } + } else { + last_esc = Some(now); + status = "press Esc again within 1.5s to branch or restore".into(); + } + } (KeyCode::BackTab, _) => { if busy { status = "mode can be changed when idle".into(); @@ -580,6 +650,28 @@ async fn run_tui( input.clear(); } else if input.trim_start().starts_with('/') { match parse_local_command(&input) { + Ok(LocalCommand::Branch) => { + if busy { + status = "branch menu can be opened when idle".into(); + } else { + match BranchMenuState::open(&config, &conversation) { + Ok(menu) => { + branch_menu = Some(menu); + input.clear(); + autofill_selected = 0; + status = "branch/restore menu".into(); + } + Err(err) => { + status = format!("branch menu failed: {err}"); + transcript.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "branch".into(), + content: err.to_string(), + }); + } + } + } + } Ok(LocalCommand::Status) => { let content = chat_status( &chat_id, @@ -863,6 +955,12 @@ async fn run_tui( { last_ctrl_c = None; } + if last_esc + .map(|t| t.elapsed() > Duration::from_millis(1500)) + .unwrap_or(false) + { + last_esc = None; + } } } @@ -872,6 +970,337 @@ struct PendingApproval { block_index: usize, } +#[derive(Debug, Clone)] +enum BranchMenuMode { + Main, + Actions(crate::branch::Checkpoint), +} + +#[derive(Debug, Clone)] +struct BranchMenuState { + mode: BranchMenuMode, + selected: usize, + family: crate::branch::BranchFamily, +} + +#[derive(Debug, Clone)] +enum BranchMenuItem { + Switch(String), + Checkpoint(crate::branch::Checkpoint), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BranchMenuOutcome { + None, + Changed, +} + +impl BranchMenuState { + fn open(config: &Config, conversation: &Conversation) -> Result { + let family = crate::branch::load_family(&config.conversations_dir(), conversation)?; + Ok(Self { + mode: BranchMenuMode::Main, + selected: 0, + family, + }) + } + + fn overlay_view(&self) -> render::OverlayView { + match &self.mode { + BranchMenuMode::Main => render::OverlayView { + title: "Branch / Restore".into(), + help: "Enter select · Esc cancel · ↑/↓ move".into(), + selected: self.selected, + items: self + .main_items() + .into_iter() + .map(|item| match item { + BranchMenuItem::Switch(id) => { + let branch = self.family.branches.iter().find(|b| b.id == id); + let mut label = if branch.is_some_and(|b| b.current) { + format!("current branch {id}") + } else { + format!("switch to {id}") + }; + if branch.and_then(|b| b.parent_chat_id.as_ref()).is_none() { + label.push_str(" (root)"); + } + render::OverlayItem { + label, + detail: branch.and_then(|b| b.branch_label.clone()).unwrap_or_else( + || { + branch + .map(|b| format!("{} records", b.record_count)) + .unwrap_or_default() + }, + ), + } + } + BranchMenuItem::Checkpoint(checkpoint) => render::OverlayItem { + label: format!("{} · {}", checkpoint.chat_id, checkpoint.label), + detail: checkpoint.detail, + }, + }) + .collect(), + }, + BranchMenuMode::Actions(checkpoint) => render::OverlayView { + title: "Branch from checkpoint".into(), + help: format!( + "{} · Enter select · Esc back", + crate::branch::checkpoint_title(checkpoint) + ), + selected: self.selected, + items: vec![ + render::OverlayItem { + label: "Branch conversation only".into(), + detail: "safe default; leaves files unchanged".into(), + }, + render::OverlayItem { + label: "Branch conversation and restore tracked files".into(), + detail: "applies safe Cassady write/edit snapshots; conflicts are skipped" + .into(), + }, + render::OverlayItem { + label: "Preview tracked file restore plan".into(), + detail: "show file actions in transcript".into(), + }, + render::OverlayItem { + label: "Cancel".into(), + detail: String::new(), + }, + ], + }, + } + } + + fn main_items(&self) -> Vec { + let mut items = Vec::new(); + for branch in &self.family.branches { + items.push(BranchMenuItem::Switch(branch.id.clone())); + } + for checkpoint in &self.family.checkpoints { + items.push(BranchMenuItem::Checkpoint(checkpoint.clone())); + } + items + } + + fn len(&self) -> usize { + match self.mode { + BranchMenuMode::Main => self.main_items().len(), + BranchMenuMode::Actions(_) => 4, + } + } +} + +#[allow(clippy::too_many_arguments)] +fn handle_branch_menu_key( + code: KeyCode, + menu: &mut Option, + config: &Config, + _cwd: &Path, + conversation: &mut Conversation, + chat_id: &mut String, + transcript: &mut Vec, + active_assistant: &mut Option, + active_reasoning: &mut Option, + active_tools: &mut HashMap, + status: &mut String, +) -> Result { + let Some(state) = menu.as_mut() else { + return Ok(BranchMenuOutcome::None); + }; + match code { + KeyCode::Esc => match state.mode { + BranchMenuMode::Main => { + *menu = None; + *status = "branch menu cancelled".into(); + } + BranchMenuMode::Actions(_) => { + state.mode = BranchMenuMode::Main; + state.selected = 0; + } + }, + KeyCode::Up | KeyCode::Char('k') => { + state.selected = state.selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + let max = state.len().saturating_sub(1); + state.selected = state.selected.saturating_add(1).min(max); + } + KeyCode::Enter => { + return apply_branch_menu_selection( + menu, + config, + conversation, + chat_id, + transcript, + active_assistant, + active_reasoning, + active_tools, + status, + ); + } + _ => {} + } + Ok(BranchMenuOutcome::None) +} + +#[allow(clippy::too_many_arguments)] +fn apply_branch_menu_selection( + menu: &mut Option, + config: &Config, + conversation: &mut Conversation, + chat_id: &mut String, + transcript: &mut Vec, + active_assistant: &mut Option, + active_reasoning: &mut Option, + active_tools: &mut HashMap, + status: &mut String, +) -> Result { + let Some(state) = menu.as_mut() else { + return Ok(BranchMenuOutcome::None); + }; + match &state.mode { + BranchMenuMode::Main => { + let items = state.main_items(); + let Some(item) = items.get(state.selected).cloned() else { + return Ok(BranchMenuOutcome::None); + }; + match item { + BranchMenuItem::Switch(id) => { + let (loaded, warning) = Conversation::load(&config.conversations_dir(), &id)?; + *conversation = loaded; + *chat_id = conversation.id.clone(); + *transcript = transcript_from_loaded(conversation, warning); + *active_assistant = None; + *active_reasoning = None; + active_tools.clear(); + *status = format!("switched to branch {chat_id}"); + *menu = None; + Ok(BranchMenuOutcome::Changed) + } + BranchMenuItem::Checkpoint(checkpoint) => { + state.mode = BranchMenuMode::Actions(checkpoint); + state.selected = 0; + Ok(BranchMenuOutcome::None) + } + } + } + BranchMenuMode::Actions(checkpoint) => { + let selected = state.selected; + let checkpoint = checkpoint.clone(); + match selected { + 0 => branch_from_checkpoint( + menu, + config, + &checkpoint, + false, + conversation, + chat_id, + transcript, + active_assistant, + active_reasoning, + active_tools, + status, + ), + 1 => branch_from_checkpoint( + menu, + config, + &checkpoint, + true, + conversation, + chat_id, + transcript, + active_assistant, + active_reasoning, + active_tools, + status, + ), + 2 => { + let plan = crate::file_edits::plan_restore( + &config.root, + &checkpoint.chat_id, + checkpoint.record_index, + )?; + transcript.push(TranscriptBlock { + kind: TranscriptKind::Status, + title: "restore preview".into(), + content: crate::file_edits::summarize_plan(&plan), + }); + *status = "restore plan previewed".into(); + *menu = None; + Ok(BranchMenuOutcome::Changed) + } + _ => { + state.mode = BranchMenuMode::Main; + state.selected = 0; + Ok(BranchMenuOutcome::None) + } + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn branch_from_checkpoint( + menu: &mut Option, + config: &Config, + checkpoint: &crate::branch::Checkpoint, + restore_files: bool, + conversation: &mut Conversation, + chat_id: &mut String, + transcript: &mut Vec, + active_assistant: &mut Option, + active_reasoning: &mut Option, + active_tools: &mut HashMap, + status: &mut String, +) -> Result { + let (source, _) = Conversation::load(&config.conversations_dir(), &checkpoint.chat_id)?; + let branch = crate::branch::create_branch(&config.conversations_dir(), &source, checkpoint)?; + let old_id = checkpoint.chat_id.clone(); + *conversation = branch; + *chat_id = conversation.id.clone(); + *transcript = transcript_from_loaded(conversation, None); + *active_assistant = None; + *active_reasoning = None; + active_tools.clear(); + + let mut restore_status = String::new(); + if restore_files { + let plan = crate::file_edits::plan_restore( + &config.root, + &checkpoint.chat_id, + checkpoint.record_index, + )?; + let summary = crate::file_edits::summarize_plan(&plan); + let outcome = crate::file_edits::apply_restore_plan(&plan)?; + restore_status = format!( + "; restored files: {} applied, {} skipped, {} conflicts", + outcome.applied, outcome.skipped, outcome.conflicts + ); + transcript.push(TranscriptBlock { + kind: if outcome.conflicts == 0 { + TranscriptKind::Status + } else { + TranscriptKind::Error + }, + title: "file restore".into(), + content: format!( + "{summary}\n\nApplied: {}\nSkipped: {}\nConflicts: {}", + outcome.applied, outcome.skipped, outcome.conflicts + ), + }); + } + + *status = format!( + "branched {chat_id} from {old_id} at {}{}", + crate::branch::checkpoint_title(checkpoint), + restore_status + ); + *menu = None; + Ok(BranchMenuOutcome::Changed) +} + struct AgentEventContext<'a> { terminal: &'a terminal::CassTerminal, input: &'a str, @@ -1247,6 +1676,7 @@ fn assistant_content_matches(a: &str, b: &str) -> bool { #[derive(Debug, Clone, PartialEq, Eq)] enum LocalCommand { + Branch, Model(String), New, Resume(String), @@ -1261,6 +1691,12 @@ struct CommandSpec { } const COMMANDS: &[CommandSpec] = &[ + CommandSpec { + name: "branch", + usage: "/branch", + description: "open branch/restore menu", + takes_value: false, + }, CommandSpec { name: "model", usage: "/model ", @@ -1511,6 +1947,12 @@ fn parse_local_command(input: &str) -> std::result::Result }; match command { + "/branch" | "/restore" => { + if parts.next().is_some() { + return Err("usage: /branch".into()); + } + Ok(LocalCommand::Branch) + } "/model" => { let Some(model) = parts.next() else { return Err("usage: /model ".into()); diff --git a/src/branch.rs b/src/branch.rs new file mode 100644 index 0000000..6b1af18 --- /dev/null +++ b/src/branch.rs @@ -0,0 +1,435 @@ +use crate::conversation::{self, BranchPoint, Conversation, Record, StoredToolCall}; +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; + +const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by user."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CheckpointKind { + User, + Assistant, + ToolCall, + ToolResult, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Checkpoint { + pub id: String, + pub chat_id: String, + pub record_index: usize, + pub tool_call_id: Option, + pub kind: CheckpointKind, + pub label: String, + pub detail: String, + pub ts: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchSummary { + pub id: String, + pub created_at: String, + pub parent_chat_id: Option, + pub branch_label: Option, + pub record_count: usize, + pub current: bool, +} + +#[derive(Debug, Clone)] +pub struct BranchFamily { + pub branches: Vec, + pub checkpoints: Vec, +} + +pub fn checkpoint_records(records: &[Record], chat_id: &str) -> Vec { + let mut checkpoints = Vec::new(); + for (idx, record) in records.iter().enumerate() { + match record { + Record::User { content, ts } => checkpoints.push(Checkpoint { + id: format!("{chat_id}:{idx}:user"), + chat_id: chat_id.to_string(), + record_index: idx, + tool_call_id: None, + kind: CheckpointKind::User, + label: "user".into(), + detail: preview(content), + ts: Some(ts.clone()), + }), + Record::Assistant { + content, + reasoning, + tool_calls, + ts, + .. + } => { + let detail = if content.trim().is_empty() { + preview(reasoning) + } else { + preview(content) + }; + checkpoints.push(Checkpoint { + id: format!("{chat_id}:{idx}:assistant"), + chat_id: chat_id.to_string(), + record_index: idx, + tool_call_id: None, + kind: CheckpointKind::Assistant, + label: "assistant".into(), + detail, + ts: Some(ts.clone()), + }); + for call in tool_calls { + checkpoints.push(Checkpoint { + id: format!("{chat_id}:{idx}:tool_call:{}", call.id), + chat_id: chat_id.to_string(), + record_index: idx, + tool_call_id: Some(call.id.clone()), + kind: CheckpointKind::ToolCall, + label: format!("tool call {}", call.name), + detail: tool_call_detail(call), + ts: Some(ts.clone()), + }); + } + } + Record::Tool { + tool_call_id, + name, + ok, + content, + ts, + } => checkpoints.push(Checkpoint { + id: format!("{chat_id}:{idx}:tool_result:{tool_call_id}"), + chat_id: chat_id.to_string(), + record_index: idx, + tool_call_id: Some(tool_call_id.clone()), + kind: CheckpointKind::ToolResult, + label: format!("tool {name} {}", if *ok { "✓" } else { "✗" }), + detail: preview(content), + ts: Some(ts.clone()), + }), + _ => {} + } + } + checkpoints +} + +pub fn load_family(conversations_dir: &Path, current: &Conversation) -> Result { + let metas = load_all_metas(conversations_dir)?; + let root = root_for(¤t.id, &metas); + let mut ids = BTreeSet::new(); + for id in metas.keys() { + if root_for(id, &metas) == root { + ids.insert(id.clone()); + } + } + ids.insert(current.id.clone()); + + let mut branches = Vec::new(); + let mut checkpoints = Vec::new(); + for id in ids { + let Ok((conversation, _)) = Conversation::load(conversations_dir, &id) else { + continue; + }; + let meta = conversation.meta(); + branches.push(BranchSummary { + id: conversation.id.clone(), + created_at: meta + .as_ref() + .map(|m| m.created_at.clone()) + .unwrap_or_default(), + parent_chat_id: meta.as_ref().and_then(|m| m.parent_chat_id.clone()), + branch_label: meta + .as_ref() + .and_then(|m| m.branch_from.as_ref().map(|p| p.checkpoint_label.clone())), + record_count: conversation.records.len(), + current: conversation.id == current.id, + }); + checkpoints.extend(checkpoint_records(&conversation.records, &conversation.id)); + } + branches.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + checkpoints.sort_by(|a, b| { + a.chat_id + .cmp(&b.chat_id) + .then(a.record_index.cmp(&b.record_index)) + .then(a.id.cmp(&b.id)) + }); + Ok(BranchFamily { + branches, + checkpoints, + }) +} + +pub fn create_branch( + conversations_dir: &Path, + source: &Conversation, + checkpoint: &Checkpoint, +) -> Result { + if source.id != checkpoint.chat_id { + bail!( + "checkpoint {} belongs to {}, not {}", + checkpoint.id, + checkpoint.chat_id, + source.id + ); + } + if checkpoint.record_index >= source.records.len() { + bail!("checkpoint record index is out of range"); + } + + fs::create_dir_all(conversations_dir)?; + let id = conversation::new_chat_id(); + let path = conversations_dir.join(format!("{id}.jsonl")); + let mut records = Vec::new(); + let meta = source + .meta() + .context("source conversation is missing metadata")?; + records.push(Record::Meta { + chat_id: id.clone(), + created_at: conversation::now_ts(), + model: meta.model, + cwd: meta.cwd, + parent_chat_id: Some(source.id.clone()), + branch_from: Some(BranchPoint { + chat_id: source.id.clone(), + record_index: checkpoint.record_index, + tool_call_id: checkpoint.tool_call_id.clone(), + checkpoint_label: checkpoint_title(checkpoint), + }), + }); + + let prefix = valid_prefix(source, checkpoint)?; + records.extend(prefix); + repair_pending_tool_calls(&mut records); + + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .with_context(|| format!("creating branch conversation {}", path.display()))?; + for record in &records { + writeln!(file, "{}", serde_json::to_string(record)?)?; + } + file.flush()?; + + Ok(Conversation { id, path, records }) +} + +fn valid_prefix(source: &Conversation, checkpoint: &Checkpoint) -> Result> { + let mut end = checkpoint.record_index + 1; + if matches!(checkpoint.kind, CheckpointKind::ToolCall) { + end = checkpoint.record_index + 1; + } + let mut prefix = source.records[..end].to_vec(); + + // Drop the source meta; the branch writes its own meta record. + if matches!(prefix.first(), Some(Record::Meta { .. })) { + prefix.remove(0); + } + + // For a tool-call checkpoint, keep the assistant turn but do not copy any + // later tool result. The repair step below writes cancelled tool results so + // the next provider request remains valid. + Ok(prefix) +} + +pub fn repair_pending_tool_calls(records: &mut Vec) { + let mut pending: Vec<(String, String)> = Vec::new(); + for record in records.iter() { + match record { + Record::Assistant { tool_calls, .. } => { + pending = tool_calls + .iter() + .map(|call| (call.id.clone(), call.name.clone())) + .collect(); + } + Record::Tool { tool_call_id, .. } => pending.retain(|(id, _)| id != tool_call_id), + Record::User { .. } => pending.clear(), + _ => {} + } + } + let seen: HashSet = records + .iter() + .filter_map(|record| match record { + Record::Tool { tool_call_id, .. } => Some(tool_call_id.clone()), + _ => None, + }) + .collect(); + for (id, name) in pending { + if seen.contains(&id) { + continue; + } + records.push(Record::Tool { + tool_call_id: id, + name, + ok: false, + content: TOOL_CANCELLED_MESSAGE.to_string(), + ts: conversation::now_ts(), + }); + } +} + +fn load_all_metas(conversations_dir: &Path) -> Result, String)>> { + let mut out = BTreeMap::new(); + if !conversations_dir.exists() { + return Ok(out); + } + for entry in fs::read_dir(conversations_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("jsonl") { + continue; + } + let Some(id) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if let Ok(Some((parent, cwd))) = read_meta_parent_cwd(&path) { + out.insert(id.to_string(), (parent, cwd)); + } + } + Ok(out) +} + +fn read_meta_parent_cwd(path: &Path) -> Result, String)>> { + let file = File::open(path)?; + for line in BufReader::new(file).lines().take(10) { + let line = line?; + if line.trim().is_empty() { + continue; + } + let record: Record = serde_json::from_str(&line)?; + if let Record::Meta { + parent_chat_id, + cwd, + .. + } = record + { + return Ok(Some((parent_chat_id, cwd))); + } + } + Ok(None) +} + +fn root_for(id: &str, metas: &BTreeMap, String)>) -> String { + let mut current = id.to_string(); + let mut seen = HashSet::new(); + while seen.insert(current.clone()) { + let Some((Some(parent), _)) = metas.get(¤t) else { + break; + }; + current = parent.clone(); + } + current +} + +pub fn checkpoint_title(checkpoint: &Checkpoint) -> String { + if checkpoint.detail.is_empty() { + checkpoint.label.clone() + } else { + format!("{}: {}", checkpoint.label, checkpoint.detail) + } +} + +fn tool_call_detail(call: &StoredToolCall) -> String { + let mut detail = String::new(); + if let Some(path) = call.arguments.get("path").and_then(|v| v.as_str()) { + detail = format!("file: {path}"); + } else if let Some(command) = call.arguments.get("command").and_then(|v| v.as_str()) { + detail = command.to_string(); + } + if detail.is_empty() { + preview(&call.arguments.to_string()) + } else { + preview(&detail) + } +} + +fn preview(content: &str) -> String { + content + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or("") + .chars() + .take(96) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::tempdir; + + fn base_records(id: &str) -> Vec { + vec![ + Record::Meta { + chat_id: id.into(), + created_at: "now".into(), + model: "m".into(), + cwd: "/tmp".into(), + parent_chat_id: None, + branch_from: None, + }, + Record::System { + content: "s".into(), + }, + Record::User { + content: "u".into(), + ts: "t".into(), + }, + Record::Assistant { + content: "a".into(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![StoredToolCall { + id: "call1".into(), + name: "read".into(), + arguments: json!({"path":"x"}), + }], + ts: "t".into(), + }, + ] + } + + #[test] + fn checkpoints_include_tool_calls() { + let checkpoints = checkpoint_records(&base_records("c"), "c"); + assert!(checkpoints.iter().any(|c| c.kind == CheckpointKind::User)); + assert!(checkpoints + .iter() + .any(|c| c.kind == CheckpointKind::Assistant)); + assert!(checkpoints + .iter() + .any(|c| c.kind == CheckpointKind::ToolCall)); + } + + #[test] + fn create_branch_does_not_modify_source_and_repairs_pending_tools() { + let dir = tempdir().unwrap(); + let source = Conversation { + id: "source".into(), + path: dir.path().join("source.jsonl"), + records: base_records("source"), + }; + let checkpoint = checkpoint_records(&source.records, &source.id) + .into_iter() + .find(|c| c.kind == CheckpointKind::Assistant) + .unwrap(); + let branch = create_branch(dir.path(), &source, &checkpoint).unwrap(); + assert_ne!(branch.id, source.id); + assert!( + source + .records + .iter() + .filter(|r| matches!(r, Record::Tool { .. })) + .count() + == 0 + ); + assert!(branch + .records + .iter() + .any(|r| matches!(r, Record::Tool { ok: false, .. }))); + } +} diff --git a/src/conversation.rs b/src/conversation.rs index 91ca55e..c9452f1 100644 --- a/src/conversation.rs +++ b/src/conversation.rs @@ -14,6 +14,10 @@ pub enum Record { created_at: String, model: String, cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + parent_chat_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + branch_from: Option, }, System { content: String, @@ -40,6 +44,15 @@ pub enum Record { }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BranchPoint { + pub chat_id: String, + pub record_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + pub checkpoint_label: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct StoredToolCall { pub id: String, @@ -92,6 +105,8 @@ impl Conversation { created_at: now_ts(), model: model.to_string(), cwd: cwd.display().to_string(), + parent_chat_id: None, + branch_from: None, })?; convo.append(Record::System { content: base_system, @@ -161,6 +176,37 @@ impl Conversation { _ => None, }) } + + pub fn meta(&self) -> Option { + self.records.iter().find_map(|r| match r { + Record::Meta { + chat_id, + created_at, + model, + cwd, + parent_chat_id, + branch_from, + } => Some(ConversationMeta { + chat_id: chat_id.clone(), + created_at: created_at.clone(), + model: model.clone(), + cwd: cwd.clone(), + parent_chat_id: parent_chat_id.clone(), + branch_from: branch_from.clone(), + }), + _ => None, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationMeta { + pub chat_id: String, + pub created_at: String, + pub model: String, + pub cwd: String, + pub parent_chat_id: Option, + pub branch_from: Option, } pub fn list_chats(conversations_dir: &Path, cwd: &Path) -> Result> { diff --git a/src/file_edits.rs b/src/file_edits.rs new file mode 100644 index 0000000..c19ac56 --- /dev/null +++ b/src/file_edits.rs @@ -0,0 +1,470 @@ +use crate::tools::{self, ToolContext}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const MAX_SNAPSHOT_BYTES: u64 = 10 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FileEditJournalEntry { + pub chat_id: String, + pub record_index: usize, + pub tool_call_id: String, + pub tool_name: String, + pub path: PathBuf, + pub existed_before: bool, + pub existed_after: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before_snapshot: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after_snapshot: Option, + pub ts: String, +} + +#[derive(Debug, Clone)] +pub struct PendingFileEditSnapshot { + pub chat_id: String, + pub record_index: usize, + pub tool_call_id: String, + pub tool_name: String, + pub path: PathBuf, + before: SnapshotState, +} + +#[derive(Debug, Clone)] +enum SnapshotState { + Missing, + File { bytes: Vec, hash: String }, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestorePlan { + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RestoreAction { + Write { + path: PathBuf, + snapshot: PathBuf, + desired_hash: String, + expected_current_hash: Option, + conflict: bool, + }, + Delete { + path: PathBuf, + expected_current_hash: Option, + conflict: bool, + }, + Skip { + path: PathBuf, + reason: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestoreOutcome { + pub applied: usize, + pub skipped: usize, + pub conflicts: usize, +} + +pub fn begin_tool_edit( + cass_root: &Path, + chat_id: &str, + record_index: usize, + tool_call_id: &str, + tool_name: &str, + args: &Value, + ctx: &ToolContext, +) -> Option { + if !matches!(tool_name, "write" | "edit") { + return None; + } + let path_arg = args.get("path")?.as_str()?; + let path = + tools::path::resolve_for_write(path_arg, &ctx.cwd, ctx.mode, &ctx.blocked_write_roots) + .ok()?; + let before = snapshot_state(&path).unwrap_or(SnapshotState::Unsupported); + // Ensure journal directories are creatable before executing, but do not fail + // the tool if Cassady cannot journal; restore will simply be unavailable. + let _ = fs::create_dir_all(cass_root.join("file-edits")); + let _ = fs::create_dir_all(cass_root.join("file-snapshots")); + Some(PendingFileEditSnapshot { + chat_id: chat_id.to_string(), + record_index, + tool_call_id: tool_call_id.to_string(), + tool_name: tool_name.to_string(), + path, + before, + }) +} + +pub fn finish_tool_edit(cass_root: &Path, pending: PendingFileEditSnapshot) -> Result<()> { + let after = snapshot_state(&pending.path).unwrap_or(SnapshotState::Unsupported); + if matches!(pending.before, SnapshotState::Unsupported) + || matches!(after, SnapshotState::Unsupported) + { + return Ok(()); + } + if same_state(&pending.before, &after) { + return Ok(()); + } + + let (existed_before, before_hash, before_snapshot) = store_snapshot( + cass_root, + &pending.chat_id, + &pending.tool_call_id, + "before", + &pending.before, + )?; + let (existed_after, after_hash, after_snapshot) = store_snapshot( + cass_root, + &pending.chat_id, + &pending.tool_call_id, + "after", + &after, + )?; + + let entry = FileEditJournalEntry { + chat_id: pending.chat_id.clone(), + record_index: pending.record_index, + tool_call_id: pending.tool_call_id, + tool_name: pending.tool_name, + path: pending.path, + existed_before, + existed_after, + before_hash, + after_hash, + before_snapshot, + after_snapshot, + ts: crate::conversation::now_ts(), + }; + append_journal(cass_root, &pending.chat_id, &entry) +} + +pub fn load_journal(cass_root: &Path, chat_id: &str) -> Result> { + let path = journal_path(cass_root, chat_id); + if !path.exists() { + return Ok(Vec::new()); + } + let content = + fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let mut out = Vec::new(); + for (idx, line) in content.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let entry: FileEditJournalEntry = serde_json::from_str(line) + .with_context(|| format!("parsing {} line {}", path.display(), idx + 1))?; + out.push(entry); + } + out.sort_by_key(|entry| entry.record_index); + Ok(out) +} + +pub fn plan_restore( + cass_root: &Path, + chat_id: &str, + target_record_index: usize, +) -> Result { + let entries = load_journal(cass_root, chat_id)?; + let mut by_path: BTreeMap> = BTreeMap::new(); + for entry in entries { + by_path.entry(entry.path.clone()).or_default().push(entry); + } + + let mut actions = Vec::new(); + for (path, mut entries) in by_path { + entries.sort_by_key(|entry| entry.record_index); + let latest = entries.last().cloned(); + let desired = entries + .iter() + .rev() + .find(|entry| entry.record_index <= target_record_index) + .cloned(); + let first_after = entries + .iter() + .find(|entry| entry.record_index > target_record_index) + .cloned(); + + let (want_exists, want_hash, want_snapshot) = if let Some(entry) = desired { + (entry.existed_after, entry.after_hash, entry.after_snapshot) + } else if let Some(entry) = first_after { + ( + entry.existed_before, + entry.before_hash, + entry.before_snapshot, + ) + } else { + continue; + }; + + let expected_current_hash = latest.and_then(|entry| entry.after_hash); + let current_hash = hash_existing_file(&path)?; + let conflict = expected_current_hash.is_some() + && current_hash.is_some() + && expected_current_hash != current_hash; + + if want_exists { + match (want_hash, want_snapshot) { + (Some(desired_hash), Some(snapshot)) => actions.push(RestoreAction::Write { + path, + snapshot, + desired_hash, + expected_current_hash, + conflict, + }), + _ => actions.push(RestoreAction::Skip { + path, + reason: "missing desired snapshot".into(), + }), + } + } else { + let conflict = conflict + || (current_hash.is_some() + && expected_current_hash.is_none() + && current_hash != expected_current_hash); + actions.push(RestoreAction::Delete { + path, + expected_current_hash, + conflict, + }); + } + } + + Ok(RestorePlan { actions }) +} + +pub fn apply_restore_plan(plan: &RestorePlan) -> Result { + let mut outcome = RestoreOutcome { + applied: 0, + skipped: 0, + conflicts: 0, + }; + for action in &plan.actions { + match action { + RestoreAction::Write { + path, + snapshot, + conflict, + .. + } => { + if *conflict { + outcome.conflicts += 1; + continue; + } + let bytes = fs::read(snapshot) + .with_context(|| format!("reading snapshot {}", snapshot.display()))?; + crate::tools::write::atomic_write(path, &bytes) + .with_context(|| format!("restoring {}", path.display()))?; + outcome.applied += 1; + } + RestoreAction::Delete { path, conflict, .. } => { + if *conflict { + outcome.conflicts += 1; + continue; + } + if path.exists() { + fs::remove_file(path) + .with_context(|| format!("deleting {}", path.display()))?; + outcome.applied += 1; + } else { + outcome.skipped += 1; + } + } + RestoreAction::Skip { .. } => outcome.skipped += 1, + } + } + Ok(outcome) +} + +pub fn summarize_plan(plan: &RestorePlan) -> String { + if plan.actions.is_empty() { + return "No tracked file edits need restoration for this checkpoint.".into(); + } + let mut lines = Vec::new(); + for action in &plan.actions { + match action { + RestoreAction::Write { path, conflict, .. } => lines.push(format!( + "{} update {}", + if *conflict { "CONFLICT" } else { "will" }, + path.display() + )), + RestoreAction::Delete { path, conflict, .. } => lines.push(format!( + "{} delete {}", + if *conflict { "CONFLICT" } else { "will" }, + path.display() + )), + RestoreAction::Skip { path, reason } => { + lines.push(format!("skip {}: {reason}", path.display())) + } + } + } + lines.join("\n") +} + +fn snapshot_state(path: &Path) -> Result { + match fs::metadata(path) { + Ok(metadata) => { + if !metadata.is_file() || metadata.len() > MAX_SNAPSHOT_BYTES { + return Ok(SnapshotState::Unsupported); + } + let bytes = fs::read(path)?; + let hash = sha256_hex(&bytes); + Ok(SnapshotState::File { bytes, hash }) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(SnapshotState::Missing), + Err(err) => Err(err.into()), + } +} + +fn same_state(a: &SnapshotState, b: &SnapshotState) -> bool { + match (a, b) { + (SnapshotState::Missing, SnapshotState::Missing) => true, + (SnapshotState::File { hash: a, .. }, SnapshotState::File { hash: b, .. }) => a == b, + _ => false, + } +} + +fn store_snapshot( + cass_root: &Path, + chat_id: &str, + tool_call_id: &str, + side: &str, + state: &SnapshotState, +) -> Result<(bool, Option, Option)> { + match state { + SnapshotState::Missing => Ok((false, None, None)), + SnapshotState::Unsupported => Ok((false, None, None)), + SnapshotState::File { bytes, hash } => { + let dir = cass_root + .join("file-snapshots") + .join(chat_id) + .join(tool_call_id); + fs::create_dir_all(&dir)?; + let path = dir.join(format!("{side}-{hash}.bin")); + if !path.exists() { + fs::write(&path, bytes)?; + } + Ok((true, Some(hash.clone()), Some(path))) + } + } +} + +fn append_journal(cass_root: &Path, chat_id: &str, entry: &FileEditJournalEntry) -> Result<()> { + let path = journal_path(cass_root, chat_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new().create(true).append(true).open(&path)?; + writeln!(file, "{}", serde_json::to_string(entry)?)?; + file.flush()?; + Ok(()) +} + +fn journal_path(cass_root: &Path, chat_id: &str) -> PathBuf { + cass_root + .join("file-edits") + .join(format!("{chat_id}.jsonl")) +} + +fn hash_existing_file(path: &Path) -> Result> { + match fs::metadata(path) { + Ok(metadata) => { + if !metadata.is_file() || metadata.len() > MAX_SNAPSHOT_BYTES { + return Ok(None); + } + Ok(Some(sha256_hex(&fs::read(path)?))) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::access::AccessMode; + use tempfile::tempdir; + + fn tool_ctx(cwd: &Path) -> ToolContext { + ToolContext { + mode: AccessMode::WorkspaceEdit, + cwd: cwd.to_path_buf(), + read_roots: vec![cwd.to_path_buf()], + blocked_write_roots: Vec::new(), + model_result_limit: 1000, + runtime_tx: None, + } + } + + #[test] + fn journal_and_restore_rewinds_write() { + let root = tempdir().unwrap(); + let work = tempdir().unwrap(); + let path = work.path().join("a.txt"); + fs::write(&path, "old").unwrap(); + let ctx = tool_ctx(work.path()); + let pending = begin_tool_edit( + root.path(), + "chat", + 3, + "call", + "write", + &serde_json::json!({"path":"a.txt"}), + &ctx, + ) + .unwrap(); + fs::write(&path, "new").unwrap(); + finish_tool_edit(root.path(), pending).unwrap(); + + let plan = plan_restore(root.path(), "chat", 2).unwrap(); + assert_eq!(plan.actions.len(), 1); + let outcome = apply_restore_plan(&plan).unwrap(); + assert_eq!(outcome.applied, 1); + assert_eq!(fs::read_to_string(&path).unwrap(), "old"); + } + + #[test] + fn restore_detects_external_conflict() { + let root = tempdir().unwrap(); + let work = tempdir().unwrap(); + let path = work.path().join("a.txt"); + fs::write(&path, "old").unwrap(); + let ctx = tool_ctx(work.path()); + let pending = begin_tool_edit( + root.path(), + "chat", + 3, + "call", + "write", + &serde_json::json!({"path":"a.txt"}), + &ctx, + ) + .unwrap(); + fs::write(&path, "new").unwrap(); + finish_tool_edit(root.path(), pending).unwrap(); + fs::write(&path, "manual").unwrap(); + let plan = plan_restore(root.path(), "chat", 2).unwrap(); + assert!(matches!( + &plan.actions[0], + RestoreAction::Write { conflict: true, .. } + )); + } +} diff --git a/src/lib.rs b/src/lib.rs index b7edb08..7e19c4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod access; pub mod agent; pub mod app; +pub mod branch; pub mod check; pub mod cli; pub mod config; @@ -8,6 +9,7 @@ pub mod conversation; pub mod docs; pub mod embedding; pub mod error; +pub mod file_edits; pub mod menu; pub mod prelude; pub mod prompt; diff --git a/src/ui/render.rs b/src/ui/render.rs index f47a5ee..52a3bf7 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -5,7 +5,7 @@ use crate::ui::theme; use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd}; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::prelude::*; -use ratatui::widgets::{Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use std::path::Path; use unicode_width::UnicodeWidthChar; @@ -26,7 +26,20 @@ pub struct TranscriptBlock { pub content: String, } -#[derive(Debug)] +#[derive(Debug, Clone)] +pub struct OverlayView { + pub title: String, + pub help: String, + pub items: Vec, + pub selected: usize, +} + +#[derive(Debug, Clone)] +pub struct OverlayItem { + pub label: String, + pub detail: String, +} + pub struct RenderState<'a> { pub app_name: &'a str, pub chat_id: &'a str, @@ -42,6 +55,7 @@ pub struct RenderState<'a> { pub reasoning_effort: ReasoningEffort, pub scroll: u16, pub autofill: Option<&'a AutoFillMenu>, + pub overlay: Option<&'a OverlayView>, } pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) { @@ -74,6 +88,10 @@ pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) { let footer = truncate_end(&footer_text(state), chunks[3].width as usize); f.render_widget(Paragraph::new(footer).style(theme::footer()), chunks[3]); + + if let Some(overlay) = state.overlay { + render_overlay(f, f.area(), overlay); + } } pub fn transcript_area(area: Rect, input: &str) -> Rect { @@ -116,6 +134,61 @@ fn autofill_height(menu: Option<&AutoFillMenu>) -> u16 { menu.map(|menu| menu.items.len().min(6) as u16).unwrap_or(0) } +fn render_overlay(f: &mut Frame<'_>, area: Rect, overlay: &OverlayView) { + let max_width = area.width.max(1); + let preferred_width = area.width.saturating_mul(4).saturating_div(5).max(40); + let width = preferred_width.min(max_width); + let max_height = area.height.saturating_sub(2).max(1); + let preferred_height = (overlay.items.len() as u16 + 5).max(8); + let height = preferred_height.min(max_height); + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; + let rect = Rect::new(x, y, width, height); + f.render_widget(Clear, rect); + let block = Block::default() + .title(overlay.title.clone()) + .borders(Borders::ALL) + .style(theme::menu()); + let inner = block.inner(rect); + f.render_widget(block, rect); + + let visible = inner.height.saturating_sub(2) as usize; + let selected = overlay.selected.min(overlay.items.len().saturating_sub(1)); + let start = if selected >= visible && visible > 0 { + selected + 1 - visible + } else { + 0 + }; + let end = (start + visible).min(overlay.items.len()); + let mut lines = Vec::new(); + lines.push(Line::styled( + truncate_end(&overlay.help, inner.width as usize), + Style::default().fg(Color::DarkGray), + )); + for idx in start..end { + let item = &overlay.items[idx]; + let marker = if idx == selected { "›" } else { " " }; + let mut text = format!("{marker} {}", item.label); + if !item.detail.is_empty() { + text.push_str(" "); + text.push_str(&item.detail); + } + let style = if idx == selected { + theme::selection() + } else { + theme::menu() + }; + lines.push(Line::styled( + truncate_end(&text, inner.width as usize), + style, + )); + } + f.render_widget( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + inner, + ); +} + fn render_autofill_menu(f: &mut Frame<'_>, area: Rect, menu: &AutoFillMenu) { if area.height == 0 || menu.items.is_empty() { return; diff --git a/tests/conversation_tests.rs b/tests/conversation_tests.rs index 2b0a9e0..86969e8 100644 --- a/tests/conversation_tests.rs +++ b/tests/conversation_tests.rs @@ -23,3 +23,22 @@ fn conversation_appends_loads_and_lists_by_cwd() { assert_eq!(chats[0].id, convo.id); assert_eq!(chats[0].first_user_preview, "hello world"); } + +#[test] +fn legacy_meta_without_branch_fields_still_loads() { + let root = tempdir().unwrap(); + let id = "legacy"; + std::fs::write( + root.path().join(format!("{id}.jsonl")), + r#"{"type":"meta","chat_id":"legacy","created_at":"now","model":"m","cwd":"/tmp"} +{"type":"system","content":"base"} +"#, + ) + .unwrap(); + + let (loaded, warning) = Conversation::load(root.path(), id).unwrap(); + assert!(warning.is_none()); + let meta = loaded.meta().unwrap(); + assert_eq!(meta.parent_chat_id, None); + assert_eq!(meta.branch_from, None); +}