3 Commits
Author SHA1 Message Date
owen 2845529682 Bump version to 0.2.8
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
2026-06-25 11:47:37 -05:00
owen bcbb9dfa1b Add conversation branching and restore 2026-06-25 11:46:24 -05:00
owen 7c84a54e6a Add self-update command
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
2026-06-25 07:50:05 -05:00
21 changed files with 3871 additions and 16 deletions
Generated
+128 -2
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -91,6 +97,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -211,7 +226,7 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "cassady"
version = "0.2.6"
version = "0.2.8"
dependencies = [
"anyhow",
"async-trait",
@@ -219,6 +234,7 @@ dependencies = [
"clap",
"crossterm",
"dirs",
"flate2",
"futures-util",
"ignore",
"include_dir",
@@ -227,13 +243,17 @@ dependencies = [
"ratatui",
"regex",
"reqwest",
"semver",
"serde",
"serde_json",
"sha2",
"tar",
"tempfile",
"thiserror 1.0.69",
"tokio",
"unicode-width 0.1.14",
"wiremock",
"zip",
]
[[package]]
@@ -365,6 +385,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -501,6 +530,17 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -638,6 +678,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -656,6 +706,16 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1376,6 +1436,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.1"
@@ -2231,6 +2301,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -2346,6 +2422,17 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -2976,7 +3063,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3301,6 +3388,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -3404,8 +3501,37 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cassady"
version = "0.2.6"
version = "0.2.8"
edition = "2021"
description = "Cassady/Cass minimal terminal coding agent"
license = "MIT"
@@ -33,11 +33,16 @@ ratatui = { version = "0.30", default-features = false, features = ["crossterm",
regex = "1"
pulldown-cmark = "0.12"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
flate2 = "1"
tar = "0.4"
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "process", "io-util"] }
unicode-width = "0.1"
zip = { version = "2", default-features = false, features = ["deflate"] }
[dev-dependencies]
tempfile = "3"
+12 -3
View File
@@ -11,7 +11,7 @@ The project installs two equivalent commands, `cass` and `cassady`; examples use
- v0.2.6 adds an experimental Rust embedding API for headless sessions; it is useful for early integrations but not yet a stable long-term library contract.
- Config and conversation state live under `~/.cass`.
- Windows binaries are built for releases, but deeper Windows terminal, path, shell, and filesystem polish is planned for a later release.
- Cassady is not an installer, updater, or package manager.
- `cass update` can update release-archive installs from official GitHub releases; external package managers should still be updated through their own tools.
## Install from source
@@ -39,6 +39,7 @@ If Cassady cannot resolve a usable provider, model, or API key, it offers to run
```sh
cass setup
cass check
cass update --check
cass
```
@@ -66,12 +67,14 @@ cass --resume <chat-id>
cass --resume
cass check
cass setup
cass update
```
`cass --resume` without an id lists saved chats for the current directory. When Cassady exits a chat, it prints a resume command for that session.
`cass --resume` without an id lists saved chats for the current directory. `cass update` checks official GitHub releases and can update both `cass` and `cassady` in the current install directory. When Cassady exits a chat, it prints a resume command for that session.
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.
@@ -86,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
@@ -99,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`:
+75
View File
@@ -1,5 +1,80 @@
# 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`.
### Update Command Experience
- [x] **Add a polished `cass update` command.** Check the official Cassady GitHub releases, compare the running version to the selected release, and guide the user through an interactive update flow.
- Keep update independent of provider/model setup so it works even when config is missing or invalid.
- Support script-friendly checks with flags such as `--check`, `--dry-run`, and `--yes`.
- [x] **Select the right update path.** Prefer a matching prebuilt release archive when available, with explicit `--prebuilt` and `--source` modes for users who want to choose.
- Support the same macOS, Linux, and Windows targets used by Cassady releases.
- Offer source builds for unsupported targets or users who prefer building locally.
### Safe Installation
- [x] **Verify and stage prebuilt artifacts before replacing binaries.** Download archives and SHA-256 files from the release, verify checksums, extract safely, and validate staged `cass`/`cassady` binaries.
- Reject checksum mismatches, unsafe archive paths, missing binaries, and unexpected versions.
- Show clear progress and failure messages without dumping raw implementation details.
- [x] **Replace installed binaries cleanly.** Update the current binary and same-directory companion binary when possible, using backups and rollback on failure.
- Do not auto-run `sudo` or administrator prompts.
- Handle Windows executable replacement with a staged helper or documented manual fallback if necessary.
### Source Build Fallback and Documentation
- [x] **Build from release source when requested.** Download the selected release source, validate its version, check for Rust tooling, run a locked release build, and install the resulting local binaries.
- Keep source mode tied to release tags rather than arbitrary branches.
- Do not attempt cross-compilation or Rust toolchain installation in this release.
- [x] **Document and test update behavior.** Update README and bundled docs for `cass update`, platform notes, troubleshooting, and package-manager caveats.
- Add tests for release parsing, target mapping, asset selection, checksum validation, archive extraction safety, install planning, and mocked update flows.
## v0.2.6 — Rust Embedding API ✅ Completed
This release focuses on adding the first intentional Rust library surface for embedding Cassady in other Rust projects. The goal is to provide the bones for programmatic, headless agent sessions: configure a workspace, start or resume an agent session, send turns, stream typed events, and handle approvals without launching the TUI. See `plans/V0_2_6_RUST_EMBEDDING_API_PLAN.md`.
+2 -2
View File
@@ -6,12 +6,12 @@ Cassady tools may list, search, and read this directory. Mutating tools are bloc
## Contents
- [Commands](commands.md): CLI forms, global flags, in-chat commands, and keys.
- [Commands](commands.md): CLI forms, global flags, `cass update`, in-chat commands, and keys.
- [Configuration](configuration.md): `~/.cass` files, setup, precedence, schema examples, and validation.
- [Providers and models](providers.md): built-in OpenAI-compatible providers, custom endpoints, model discovery, and reasoning metadata.
- [Access modes and tool safety](access-modes.md): what tools can read, write, edit, and run in each mode.
- [Experimental Rust embedding API](embedding.md): import Cassady from Rust, start headless sessions, stream events, and handle approvals.
- [Workflows](workflows.md): common ways to inspect code, apply edits, run checks, switch models, and resume chats.
- [Troubleshooting](troubleshooting.md): symptoms, likely causes, fixes, and verification commands.
- [Platform notes](platforms.md): macOS, Linux, and Windows environment/path notes.
- [Platform notes](platforms.md): macOS, Linux, Windows, release artifact, and update notes.
- [Glossary](glossary.md): short definitions for Cassady terms.
+34 -1
View File
@@ -9,10 +9,11 @@ cass [OPTIONS]
cassady [OPTIONS]
cass check [OPTIONS]
cass setup [OPTIONS]
cass update [OPTIONS]
cass --resume [CHAT_ID]
```
Run `cass --help`, `cass check --help`, or `cass setup --help` for the help generated by the current binary.
Run `cass --help`, `cass check --help`, `cass setup --help`, or `cass update --help` for the help generated by the current binary.
## Startup behavior
@@ -61,10 +62,37 @@ Missing API keys for inactive providers are warnings. A missing active API key i
Runs the interactive setup wizard in a terminal. It configures OpenAI-compatible providers, API key environment-variable references, and first models. It updates `config.json`, `providers.json`, and `models.json` while preserving unrelated entries where possible.
### `cass update`
Checks official GitHub releases for Cassady, including Cassady prereleases, and updates the current install directory. The updater runs before provider/model config is loaded, so it can be used even if `~/.cass` is missing or invalid.
By default, Cassady selects the matching prebuilt archive for the current platform, downloads the archive and `.sha256` file, verifies SHA-256, stages both `cass` and `cassady`, and replaces same-directory binaries with rollback backups. If no prebuilt archive is available, it can build from the selected release source when you choose source mode.
Useful options:
- `--check`: check the selected release without installing.
- `--dry-run`: show the selected release, mode, asset, and install plan without downloading or installing.
- `--yes` / `-y`: accept default prompts for non-interactive use.
- `--prebuilt`: require a matching prebuilt archive.
- `--source`: build from release source even when a prebuilt archive exists.
- `--to TAG`: use a specific release tag such as `v0.2.7`.
Examples:
```sh
cass update --check
cass update --dry-run
cass update
cass update --source
```
The updater does not invoke `sudo` or administrator prompts. If the install directory is not writable, rerun the update from an install location you own or update through the same package manager or manual process you originally used.
## In-chat commands
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.
@@ -86,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.
+15 -2
View File
@@ -52,9 +52,22 @@ That directory contains `config.json`, `providers.json`, `models.json`, `global.
## Non-interactive contexts
- `cass check` is suitable for scripts and CI because it prints text and exits non-zero on errors.
- `cass update --check` and `cass update --dry-run` are suitable for scripts that only need release status or an install plan.
- `cass update --yes` accepts default prompts for scripted updates, but still fails instead of escalating privileges when the install directory is not writable.
- `cass setup` requires an interactive terminal.
- `cass` chat is an interactive terminal UI.
## Release artifacts
## Release artifacts and updates
When using release archives, each archive contains both `cass` and `cassady`. Put the extracted binaries somewhere on your `PATH` or run them by explicit path. Cassady itself does not install, update, or manage PATH entries.
When using release archives, each archive contains both `cass` and `cassady`. Put the extracted binaries somewhere on your `PATH` or run them by explicit path.
`cass update` can update release-archive installs from official GitHub releases. It supports the same prebuilt targets as the release process:
- macOS Apple Silicon: `aarch64-apple-darwin`
- Linux x86_64: `x86_64-unknown-linux-gnu`
- Linux ARM64: `aarch64-unknown-linux-gnu`
- Windows x86_64: `x86_64-pc-windows-gnu`
On macOS and Linux, the updater replaces same-directory `cass` and `cassady` binaries with backups and rollback on failure. On Windows, replacing a running `.exe` is more constrained; if automatic replacement is unavailable, Cassady leaves staged files in place and reports manual copy guidance instead of partially modifying the install.
If Cassady is installed through a package manager in the future, prefer that package manager's update command instead of `cass update`.
+31
View File
@@ -143,6 +143,37 @@ 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.
Likely causes and fixes:
- Network or GitHub API failure: retry later or verify proxy/firewall settings.
- No matching prebuilt archive: use `cass update --source` if you have Rust installed, or download the release archive manually for a supported target.
- SHA-256 mismatch: do not install the archive. Retry the update; if it repeats, check the GitHub release page before proceeding.
- Missing Rust toolchain in source mode: install Rust/Cargo yourself, then rerun `cass update --source`. Cassady does not install Rust automatically.
- Non-writable install directory: update through the original install method, move Cassady to a directory you own, or manually replace the binaries. Cassady does not run `sudo` for you.
- PATH conflict: `cass update` updates the current executable directory. Run `which cass` / `which cassady` on macOS/Linux or `Get-Command cass` in PowerShell to confirm which binary your shell starts.
- Windows replacement limitation: if Cassady reports that automatic replacement is unavailable, use the staged file paths it prints and copy them after the running process exits.
Useful checks:
```sh
cass update --check
cass update --dry-run
cass --version
cassady --version
```
## Terminal rendering problems
Symptom: the UI appears garbled or keys do not behave as expected.
+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
+390
View File
@@ -0,0 +1,390 @@
# v0.2.7 Self-Update Command Implementation Plan
## Goal
v0.2.7 adds a polished `cass update` command that can update Cassady from official GitHub releases without requiring users to manually download archives, verify checksums, unpack binaries, or rebuild from source.
Success statement:
> A user can run `cass update`, see the available release, choose the recommended prebuilt binary or a source build fallback, and finish with updated `cass` and `cassady` commands in the same install location.
## Scope
### In scope
- Add a `cass update` / `cassady update` subcommand.
- Query official Cassady GitHub releases from `owenqwenstarsky/cassady`.
- Compare the current binary version with the latest stable release.
- Download and install the matching prebuilt archive when available.
- Verify prebuilt archives with the shipped `.sha256` files before installing.
- Offer a source-build path that downloads release source for the selected tag and builds local binaries.
- Update both shipped binaries, `cass` and `cassady`, when possible.
- Use interactive prompts by default with clear summaries, confirmations, progress, success, and recovery messages.
- Provide non-interactive flags for check-only and yes-to-prompts usage.
- Keep `cass update` independent of model/provider setup so updates work even when `~/.cass` is missing or broken.
- Add tests for release parsing, target detection, asset selection, checksum validation, archive extraction safety, and install planning.
- Update README and bundled docs.
### Out of scope
- Publishing through Homebrew, apt, winget, Scoop, npm, or other package managers.
- Automatic background updates or prompts during normal chat startup.
- Updating Cassady when it was installed by an external package manager that should own the install directory.
- Privilege escalation, `sudo` automation, or administrator prompts.
- Code signing, notarization, or signature verification beyond existing SHA-256 files.
- Downgrading by default. Installing an older tag should require an explicit flag if supported.
- Cross-compiling in source mode. Source builds target the current host platform only.
## Context and Current State
Relevant files:
- `Cargo.toml`: package version and two binaries, `cass` and `cassady`.
- `src/cli.rs`: Clap command definitions currently include `check` and `setup`.
- `src/app.rs`: top-level command dispatch; update should run before setup/config loading.
- `src/main.rs` and `src/bin/cassady.rs`: both call `cassady::run()`.
- `README.md` and `docs/commands.md`: command documentation.
- `docs/platforms.md` and `docs/troubleshooting.md`: platform and recovery guidance.
- `AGENTS.md`: release artifacts use these names:
- `cassady-vX.Y.Z-aarch64-apple-darwin.tar.gz`
- `cassady-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz`
- `cassady-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz`
- `cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip`
Current releases include both `cass` and `cassady` in each archive plus one `.sha256` file per archive. The update command should reuse that release contract instead of inventing a new distribution format.
## Design Principles
1. **Boring and recoverable.** Updating should be explicit, easy to understand, and safe to interrupt before installation starts.
2. **Use official release artifacts first.** Prefer prebuilt archives with SHA-256 verification; fall back to source builds when the user asks or no asset matches.
3. **No surprise setup coupling.** Users should not need a configured provider, model, or API key to update the CLI.
4. **Respect install ownership.** Do not auto-escalate privileges or overwrite package-manager-owned paths without clear user confirmation.
5. **Both command names stay aligned.** If the user has both `cass` and `cassady` in the install directory, update them together.
6. **Interactive by default, scriptable when requested.** The normal path should be friendly; flags should support CI/check scripts.
7. **Fail closed on integrity.** Missing or mismatched checksums for prebuilt artifacts must stop installation.
## User Experience
### Default interactive flow
```text
$ cass update
Cassady update
Current version: v0.2.6
Latest release: v0.2.7
Install path: /usr/local/bin
Recommended: prebuilt aarch64-apple-darwin archive
Update Cassady to v0.2.7? [Y/n]
```
If the user accepts, Cassady should show concise phases:
```text
Downloading cassady-v0.2.7-aarch64-apple-darwin.tar.gz ... 8.4 MB
Downloading cassady-v0.2.7-aarch64-apple-darwin.tar.gz.sha256 ... done
Verifying SHA-256 ... ok
Preparing cass and cassady ... ok
Installing to /usr/local/bin ... ok
Verifying installed version ... cass 0.2.7
Cassady is up to date.
```
If the current version is already latest:
```text
Cassady is already up to date.
Current version: v0.2.7
Latest release: v0.2.7
```
### Prebuilt or source selection
The default `auto` mode should choose the prebuilt release asset when a supported target is detected. If no matching prebuilt exists, prompt for source mode:
```text
No prebuilt archive is available for this platform.
Build Cassady v0.2.7 from source instead? [Y/n]
```
If both paths are available and the user asks for source mode:
```sh
cass update --source
```
Cassady should confirm prerequisites before building:
```text
Source build requires cargo, rustc, and a working C toolchain.
Build Cassady v0.2.7 from release source now? [Y/n]
```
### Useful flags
Add a command shape like:
```sh
cass update [OPTIONS]
```
Suggested options:
- `--check`: check GitHub for the latest release and print status without installing.
- `--yes`: accept default prompts for non-interactive use.
- `--prebuilt`: require a matching prebuilt archive; fail instead of falling back to source.
- `--source`: build from release source even when a prebuilt archive exists.
- `--to TAG`: install a specific release tag such as `v0.2.7`.
- `--dry-run`: resolve the release, target, assets, and install path without downloading or installing.
Optional later flags, only if implementation needs them:
- `--stable-only`: ignore prerelease tags during latest-release selection if Cassady later publishes both stable and prerelease channels.
- `--install-dir PATH`: install into an explicit directory. This should be advanced and carefully documented because it can conflict with PATH order.
Avoid adding a public `--repo` override unless needed for testing; tests can inject a mock client instead.
## Design
### Module layout
Add a focused update module:
```rust
pub mod update;
```
Suggested internal types:
```rust
pub struct UpdateOptions { ... }
pub enum UpdateMode { Auto, Prebuilt, Source }
pub struct ReleaseInfo { ... }
pub struct ReleaseAsset { ... }
pub struct PlatformTarget { ... }
pub struct UpdatePlan { ... }
pub enum InstallAction { Replace, AddCompanion, SkipMissingCompanion }
```
`src/cli.rs` should add an `Update` subcommand with parsed flags. `src/app.rs` should dispatch it before setup/config loading:
```rust
if let Some(Command::Update(args)) = cli.command {
return crate::update::run(args).await;
}
```
This keeps update usable even when `Config::load()` would fail.
### GitHub release discovery
Use the GitHub Releases API with an explicit user agent:
- Latest release: `GET https://api.github.com/repos/owenqwenstarsky/cassady/releases?per_page=30` and choose the highest semver non-draft tag, including prereleases because Cassady's current release process marks releases as prereleases.
- Specific tag: `GET https://api.github.com/repos/owenqwenstarsky/cassady/releases/tags/{tag}`
Parse:
- `tag_name`
- `name`
- `draft`
- `prerelease`
- `assets[].name`
- `assets[].browser_download_url`
- `assets[].size`
- `tarball_url` or `zipball_url` for source mode
Use `semver` to compare `env!("CARGO_PKG_VERSION")` with release tags after stripping a leading `v`. Draft releases should never be selected. Prereleases should be eligible by default while Cassady's official releases are marked as prereleases.
### Platform target mapping
Map the running platform to release asset targets:
| OS | Arch | Target | Archive |
| --- | --- | --- | --- |
| macOS | `aarch64` | `aarch64-apple-darwin` | `.tar.gz` |
| Linux | `x86_64` | `x86_64-unknown-linux-gnu` | `.tar.gz` |
| Linux | `aarch64` | `aarch64-unknown-linux-gnu` | `.tar.gz` |
| Windows | `x86_64` | `x86_64-pc-windows-gnu` | `.zip` |
Unsupported platforms should produce a clean message and offer source mode when possible.
### Prebuilt update path
For tag `vX.Y.Z` and target `TARGET`, find:
```text
cassady-vX.Y.Z-TARGET.tar.gz
cassady-vX.Y.Z-TARGET.tar.gz.sha256
```
or on Windows:
```text
cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip
cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip.sha256
```
Flow:
1. Download archive and checksum into a temporary staging directory.
2. Parse the `.sha256` file and verify that the checksum filename matches the downloaded archive name.
3. Compute SHA-256 of the archive and compare exactly.
4. Extract into staging using path traversal checks.
5. Require the expected binaries:
- Unix: `cass`, `cassady`
- Windows: `cass.exe`, `cassady.exe`
6. Run the staged `cass --version` or `cassady --version` when possible and confirm the expected version.
7. Build an install plan for the current executable directory.
8. Confirm the final plan with the user unless `--yes` was supplied.
9. Replace binaries with backups and rollback on failure.
10. Verify installed version after replacement when possible.
Archive extraction must reject absolute paths, `..` components, symlinks that escape staging, and unexpected top-level layouts.
### Source-build update path
Source mode should still be tied to a GitHub release tag, not an arbitrary branch.
Flow:
1. Resolve the selected release tag.
2. Download release source from `tarball_url` or `zipball_url` into staging.
3. Extract with the same path traversal protections as prebuilt archives.
4. Verify `Cargo.toml` version matches the selected tag.
5. Run:
```sh
cargo build --release --locked --bins
```
from the extracted source tree.
6. Locate built binaries under `target/release/`.
7. Run staged `--version` checks.
8. Install using the same installer path as prebuilt updates.
Before source mode starts, check for `cargo` and `rustc` on PATH and show a clear error if they are missing. Do not attempt to install Rust automatically.
### Install planning and replacement
Determine the current executable path with `std::env::current_exe()`, then derive the install directory. The install plan should include:
- current binary path
- sibling `cass` path
- sibling `cassady` path
- which binaries currently exist
- which binaries are writable
- whether companion binaries will be updated, added, skipped, or blocked
Recommended behavior:
- Always update the currently running binary name.
- If the sibling binary exists in the same directory, update it too.
- If the sibling binary is missing and the directory is writable, ask whether to install it.
- If a target path is not writable, stop with an actionable message. Do not invoke `sudo` or administrator prompts automatically.
- Use backups such as `.cass-update-backup-v0.2.6-<timestamp>` during replacement.
- If any replacement fails, restore backups before returning an error.
Unix can generally replace a running executable via atomic rename. Windows cannot reliably overwrite the running `.exe`; implement one of these approaches during coding:
1. Preferred: stage replacements and spawn a small PowerShell or `cmd` helper that waits for the current process to exit, moves files into place, and writes a log.
2. Fallback: stage replacements and print exact manual copy commands if helper launch is unavailable.
Document any Windows limitation honestly in `docs/platforms.md` and `docs/troubleshooting.md`.
### Output and error style
Keep output concise and user-facing:
- Show current version, target version, install directory, selected mode, and asset/source name before changing files.
- Show clear phase lines for download, verify, build, install, and final verification.
- On failure, say whether anything was changed and where staging/backups are located.
- If update cannot proceed because the install path is not writable, tell the user which path failed and suggest reinstalling through the same method they originally used.
Avoid dumping raw GitHub JSON, backtraces, or Cargo logs unless the source build fails; in that case, preserve the final relevant Cargo output and staging path.
## Dependencies
Likely additions to `Cargo.toml`:
- `semver` for version comparison.
- `sha2` for SHA-256 verification.
- `tar` and `flate2` for `.tar.gz` extraction.
- `zip` for Windows release archives and GitHub source zips if used.
Prefer small, well-maintained crates. Reuse existing `reqwest`, `tokio`, `serde`, and `serde_json`.
## Implementation Steps
1. Add CLI parsing for `cass update` and dispatch it before setup/config loading.
2. Add `src/update.rs` with release API types, version comparison, and target detection.
3. Implement GitHub release fetching with a testable client abstraction or injectable base URL for tests.
4. Implement asset selection for current platform and update mode.
5. Implement download, progress reporting, and checksum verification for prebuilt archives.
6. Implement safe archive extraction and staged binary validation.
7. Implement install planning from `current_exe()` and companion binary detection.
8. Implement Unix replacement with backups and rollback.
9. Implement Windows staged-helper replacement or a clearly documented manual fallback.
10. Implement source mode: source download, version validation, prerequisite checks, `cargo build --release --locked --bins`, and staged binary validation.
11. Polish interactive prompts and `--check`, `--dry-run`, `--yes`, `--prebuilt`, `--source`, and `--to` behavior.
12. Update docs and release notes template expectations if needed.
13. Add tests and run full verification.
## Tests
Add focused unit tests for:
- parsing `vX.Y.Z` tags and comparing against the current version shape
- ignoring drafts and prereleases where applicable
- mapping supported and unsupported platform targets
- matching asset and checksum filenames
- parsing `.sha256` lines generated by the release process
- rejecting checksum filename mismatches and digest mismatches
- rejecting archive path traversal entries
- planning installation when only `cass`, only `cassady`, or both binaries exist
- refusing non-writable install targets in planning or dry-run mode
- source mode validating that `Cargo.toml` version matches the selected tag
Add integration-style tests with a mock HTTP server for:
- already-up-to-date response
- latest prebuilt update plan
- missing prebuilt with source fallback prompt path, where build execution can be mocked
- download checksum mismatch failure
- successful staged install into a temporary directory using fake binaries
Manual checks:
```sh
cargo fmt
cargo test --locked --all-targets
cargo run -- update --check
cargo run -- update --dry-run --to v0.2.7
```
For a real release candidate, test from a temporary install directory before using `cass update` on the developer's normal binary.
## Documentation
Update:
- `README.md`: mention `cass update` in install/update and everyday command sections.
- `docs/commands.md`: full command reference, flags, interactivity, examples, and exit behavior.
- `docs/platforms.md`: platform-specific update support and Windows replacement notes.
- `docs/troubleshooting.md`: network failures, checksum mismatch, no matching prebuilt, missing Rust toolchain, non-writable install directory, PATH conflicts, and rollback recovery.
- `docs/README.md`: add any new update-related links or summaries.
Document that users should prefer the package manager's update mechanism if Cassady was installed through a package manager in the future.
## Acceptance Criteria
- `cass update --check` reports the current/latest release without reading provider config.
- `cass update --dry-run` shows the selected release, mode, asset/source, and install plan without modifying files.
- On supported release targets, `cass update` can download the matching official archive, verify SHA-256, stage both binaries, and update the current install directory.
- `cass update --source` can download release source, build with `cargo build --release --locked --bins`, and install the resulting local binaries.
- Checksum mismatch, missing assets, unsupported platforms, missing Rust toolchain, and non-writable install paths fail with clear messages and no partial install.
- Existing `cass` and `cassady` sibling binaries remain version-aligned after a successful update.
- README and bundled docs explain the command accurately.
- `cargo fmt` and `cargo test --locked --all-targets` pass.
@@ -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(),
+447 -1
View File
@@ -21,6 +21,10 @@ const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by user.";
pub async fn run() -> Result<()> {
let mut cli = cli::parse();
if let Some(Command::Update(args)) = cli.command.clone() {
return crate::update::run(args).await;
}
if matches!(cli.command, Some(Command::Check)) {
let report = crate::check::run(&cli)?;
print!("{}", report.render());
@@ -204,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;
@@ -328,6 +334,7 @@ async fn run_tui(
)?
};
let overlay_view = branch_menu.as_ref().map(BranchMenuState::overlay_view);
terminal.draw(|f| {
render::render(
f,
@@ -345,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(),
},
)
})?;
@@ -354,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 {
@@ -425,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();
}
}
@@ -439,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();
@@ -576,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,
@@ -859,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;
}
}
}
@@ -868,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,
@@ -1243,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),
@@ -1257,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>",
@@ -1507,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, .. })));
}
}
+30 -1
View File
@@ -1,4 +1,4 @@
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Debug, Parser, Clone)]
@@ -46,6 +46,35 @@ pub enum Command {
Check,
/// Configure an OpenAI-compatible provider and first model.
Setup,
/// Update Cassady from official GitHub releases.
Update(UpdateArgs),
}
#[derive(Debug, Args, Clone, PartialEq, Eq)]
pub struct UpdateArgs {
/// Check the latest release without installing.
#[arg(long)]
pub check: bool,
/// Show what would be updated without downloading or installing.
#[arg(long)]
pub dry_run: bool,
/// Accept default prompts for non-interactive use.
#[arg(long, short = 'y')]
pub yes: bool,
/// Require a matching prebuilt archive and do not fall back to source.
#[arg(long, conflicts_with = "source")]
pub prebuilt: bool,
/// Build from release source even when a prebuilt archive exists.
#[arg(long, conflicts_with = "prebuilt")]
pub source: bool,
/// Install a specific release tag, such as v0.2.7.
#[arg(long, value_name = "TAG")]
pub to: Option<String>,
}
pub fn parse() -> Cli {
+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, .. }
));
}
}
+3
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;
@@ -16,6 +18,7 @@ pub mod security;
pub mod setup;
pub mod tools;
pub mod ui;
pub mod update;
pub async fn run() -> anyhow::Result<()> {
app::run().await
+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;
+1255
View File
File diff suppressed because it is too large Load Diff
+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);
}