Add conversation branching and restore

This commit is contained in:
2026-06-25 11:46:24 -05:00
parent 7c84a54e6a
commit bcbb9dfa1b
14 changed files with 1952 additions and 5 deletions
+8 -1
View File
@@ -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
View File
@@ -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`.
+6
View File
@@ -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.
+8
View File
@@ -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.
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(&current.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(&current) 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, .. })));
}
}
+46
View File
@@ -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>> {
+470
View File
@@ -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, .. }
));
}
}
+2
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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);
}