Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2845529682 | ||
|
|
bcbb9dfa1b |
Generated
+1
-1
@@ -226,7 +226,7 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||
|
||||
[[package]]
|
||||
name = "cassady"
|
||||
version = "0.2.7"
|
||||
version = "0.2.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cassady"
|
||||
version = "0.2.7"
|
||||
version = "0.2.8"
|
||||
edition = "2021"
|
||||
description = "Cassady/Cass minimal terminal coding agent"
|
||||
license = "MIT"
|
||||
|
||||
@@ -74,6 +74,7 @@ cass update
|
||||
|
||||
Common in-chat commands:
|
||||
|
||||
- `/branch` or `/restore`: open the branch/restore menu.
|
||||
- `/model <model>`: switch to a model from `~/.cass/models.json`.
|
||||
- `/new`: create a new chat for the current directory.
|
||||
- `/resume <chat>`: 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`:
|
||||
|
||||
+42
@@ -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`.
|
||||
|
||||
@@ -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 <model>`: switch the model for future turns. Autocomplete lists models from `~/.cass/models.json`.
|
||||
- `/new`: create a new chat for the current directory.
|
||||
- `/resume <chat>`: 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
branch_from: Option<BranchPoint>,
|
||||
}
|
||||
|
||||
struct BranchPoint {
|
||||
chat_id: String,
|
||||
record_index: usize,
|
||||
tool_call_id: Option<String>,
|
||||
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<String>,
|
||||
kind: CheckpointKind,
|
||||
label: String,
|
||||
detail: String,
|
||||
ts: Option<String>,
|
||||
}
|
||||
|
||||
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/<chat_id>.jsonl
|
||||
~/.cass/file-snapshots/<chat_id>/<tool_call_id>/<hash>.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<String>,
|
||||
after_hash: Option<String>,
|
||||
before_snapshot: Option<PathBuf>,
|
||||
after_snapshot: Option<PathBuf>,
|
||||
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.
|
||||
+15
-1
@@ -304,10 +304,19 @@ pub async fn run_turn_with_commands(
|
||||
let (runtime_tx, mut runtime_rx) = mpsc::unbounded_channel::<ToolRuntimeEvent>();
|
||||
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(),
|
||||
|
||||
+443
-1
@@ -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<Instant> = None;
|
||||
let mut last_esc: Option<Instant> = None;
|
||||
let mut branch_menu: Option<BranchMenuState> = None;
|
||||
let mut handle: Option<JoinHandle<Result<Conversation>>> = None;
|
||||
let mut cancel_requested = false;
|
||||
let mut current_turn_start_len: Option<usize> = 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<Self> {
|
||||
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<BranchMenuItem> {
|
||||
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<BranchMenuState>,
|
||||
config: &Config,
|
||||
_cwd: &Path,
|
||||
conversation: &mut Conversation,
|
||||
chat_id: &mut String,
|
||||
transcript: &mut Vec<TranscriptBlock>,
|
||||
active_assistant: &mut Option<usize>,
|
||||
active_reasoning: &mut Option<usize>,
|
||||
active_tools: &mut HashMap<String, usize>,
|
||||
status: &mut String,
|
||||
) -> Result<BranchMenuOutcome> {
|
||||
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<BranchMenuState>,
|
||||
config: &Config,
|
||||
conversation: &mut Conversation,
|
||||
chat_id: &mut String,
|
||||
transcript: &mut Vec<TranscriptBlock>,
|
||||
active_assistant: &mut Option<usize>,
|
||||
active_reasoning: &mut Option<usize>,
|
||||
active_tools: &mut HashMap<String, usize>,
|
||||
status: &mut String,
|
||||
) -> Result<BranchMenuOutcome> {
|
||||
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<BranchMenuState>,
|
||||
config: &Config,
|
||||
checkpoint: &crate::branch::Checkpoint,
|
||||
restore_files: bool,
|
||||
conversation: &mut Conversation,
|
||||
chat_id: &mut String,
|
||||
transcript: &mut Vec<TranscriptBlock>,
|
||||
active_assistant: &mut Option<usize>,
|
||||
active_reasoning: &mut Option<usize>,
|
||||
active_tools: &mut HashMap<String, usize>,
|
||||
status: &mut String,
|
||||
) -> Result<BranchMenuOutcome> {
|
||||
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 <model>",
|
||||
@@ -1511,6 +1947,12 @@ fn parse_local_command(input: &str) -> std::result::Result<LocalCommand, String>
|
||||
};
|
||||
|
||||
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 <model>".into());
|
||||
|
||||
+435
@@ -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<String>,
|
||||
pub kind: CheckpointKind,
|
||||
pub label: String,
|
||||
pub detail: String,
|
||||
pub ts: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BranchSummary {
|
||||
pub id: String,
|
||||
pub created_at: String,
|
||||
pub parent_chat_id: Option<String>,
|
||||
pub branch_label: Option<String>,
|
||||
pub record_count: usize,
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BranchFamily {
|
||||
pub branches: Vec<BranchSummary>,
|
||||
pub checkpoints: Vec<Checkpoint>,
|
||||
}
|
||||
|
||||
pub fn checkpoint_records(records: &[Record], chat_id: &str) -> Vec<Checkpoint> {
|
||||
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<BranchFamily> {
|
||||
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<Conversation> {
|
||||
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<Vec<Record>> {
|
||||
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<Record>) {
|
||||
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<String> = 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<BTreeMap<String, (Option<String>, 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<Option<(Option<String>, 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, (Option<String>, 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<Record> {
|
||||
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, .. })));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
branch_from: Option<BranchPoint>,
|
||||
},
|
||||
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<String>,
|
||||
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<ConversationMeta> {
|
||||
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<String>,
|
||||
pub branch_from: Option<BranchPoint>,
|
||||
}
|
||||
|
||||
pub fn list_chats(conversations_dir: &Path, cwd: &Path) -> Result<Vec<ChatSummary>> {
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub after_hash: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub before_snapshot: Option<PathBuf>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub after_snapshot: Option<PathBuf>,
|
||||
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<u8>, hash: String },
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RestorePlan {
|
||||
pub actions: Vec<RestoreAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RestoreAction {
|
||||
Write {
|
||||
path: PathBuf,
|
||||
snapshot: PathBuf,
|
||||
desired_hash: String,
|
||||
expected_current_hash: Option<String>,
|
||||
conflict: bool,
|
||||
},
|
||||
Delete {
|
||||
path: PathBuf,
|
||||
expected_current_hash: Option<String>,
|
||||
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<PendingFileEditSnapshot> {
|
||||
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<Vec<FileEditJournalEntry>> {
|
||||
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<RestorePlan> {
|
||||
let entries = load_journal(cass_root, chat_id)?;
|
||||
let mut by_path: BTreeMap<PathBuf, Vec<FileEditJournalEntry>> = 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<RestoreOutcome> {
|
||||
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<SnapshotState> {
|
||||
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<String>, Option<PathBuf>)> {
|
||||
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<Option<String>> {
|
||||
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, .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+75
-2
@@ -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<OverlayItem>,
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user