From 20e11ccd395c4785f928175e7344cbdefba18dff Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Sat, 27 Jun 2026 16:40:10 -0500 Subject: [PATCH] Preparing for v0.4.2 --- Cargo.lock | 54 +- Cargo.toml | 2 +- ROADMAP.md | 31 + cassady-desktop/package.json | 2 +- cassady-desktop/src-tauri/Cargo.toml | 2 +- cassady-desktop/src-tauri/src/lib.rs | 9 + cassady-desktop/src-tauri/src/session.rs | 257 +++- cassady-desktop/src-tauri/src/types.rs | 337 +++++ .../src/components/BranchModal.tsx | 193 +++ cassady-desktop/src/components/ChatShell.tsx | 220 ++- cassady-desktop/src/components/Composer.tsx | 127 +- cassady-desktop/src/components/LoginModal.tsx | 198 +++ .../src/components/LogoutModal.tsx | 127 ++ cassady-desktop/src/components/SlashMenu.tsx | 62 + cassady-desktop/src/components/Transcript.tsx | 2 + .../src/components/blocks/StatusBlock.tsx | 6 +- cassady-desktop/src/hooks/useTurn.ts | 32 +- cassady-desktop/src/lib/tauri.ts | 177 +++ docs/commands.md | 9 +- plans/DESKTOP_SLASH_COMMANDS_PLAN.md | 134 ++ src/app.rs | 1264 +++-------------- src/commands.rs | 1154 +++++++++++++++ src/embedding.rs | 81 +- src/lib.rs | 1 + src/ui/autofill.rs | 97 +- 25 files changed, 3365 insertions(+), 1213 deletions(-) create mode 100644 cassady-desktop/src/components/BranchModal.tsx create mode 100644 cassady-desktop/src/components/LoginModal.tsx create mode 100644 cassady-desktop/src/components/LogoutModal.tsx create mode 100644 cassady-desktop/src/components/SlashMenu.tsx create mode 100644 plans/DESKTOP_SLASH_COMMANDS_PLAN.md create mode 100644 src/commands.rs diff --git a/Cargo.lock b/Cargo.lock index a65f13c..27c033a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,9 +99,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -293,12 +293,12 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" dependencies = [ "memchr", - "serde", + "serde_core", ] [[package]] @@ -403,7 +403,7 @@ dependencies = [ [[package]] name = "cassady" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "async-trait", @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "cassady-desktop" -version = "0.4.1" +version = "0.4.2" dependencies = [ "cassady", "serde", @@ -2189,9 +2189,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -3228,9 +3228,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3248,9 +3248,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -3655,9 +3655,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "once_cell", "ring", @@ -5208,9 +5208,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -5221,9 +5221,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5231,9 +5231,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5241,9 +5241,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -5254,9 +5254,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -5289,9 +5289,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index a9c8a20..082cf44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cassady" -version = "0.4.1" +version = "0.4.2" edition = "2021" description = "Cassady/Cass minimal terminal coding agent" license = "MIT" diff --git a/ROADMAP.md b/ROADMAP.md index 4ae1045..48f6a10 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,36 @@ # Cassady (Cass) Roadmap +## v0.4.2 — Desktop Slash Commands + +This release focuses on full slash-command support in the desktop app and on moving the command system out of the CLI into a shared core module so the TUI and the desktop share one registry, parser, autocomplete, and execution path. The desktop composer now surfaces a filterable slash menu with auto-run, and the interactive commands (`/login`, `/logout`, `/branch`) open native modals. See `plans/DESKTOP_SLASH_COMMANDS_PLAN.md`. + +### Shared Command Core + +- [x] **Add the `cassady::commands` module.** Move the command catalog (`COMMANDS`/`CommandSpec`), `LocalCommand`/`FastModeCommand` enums, `parse`, the autocomplete builders (`build_autofill` + `command_autofill`/`model_autofill`/`resume_chat_autofill`), and the pure action functions out of `src/app.rs` into a new public core module. + - Move `AutoFillItem`/`AutoFillMenu` from `ui/autofill.rs` into `commands` (with serde derives); `ui/autofill` re-exports them so the TUI renderer is unchanged. + - Add `CommandOutcome`/`CommandContext`/`execute` as the single entry point both hosts use; interactive commands return `Open*Picker` outcomes so each host renders its own picker. + +- [x] **Refactor the CLI dispatcher onto the shared module.** Replace the inline `match parse_local_command` block and the private helpers in `src/app.rs` with `commands::execute` + an outcome match; delete the duplicated code. CLI user-facing behavior is unchanged. + +- [x] **Expose `embedding::Session` accessors.** Add `config`/`config_mut`/`conversation`/`set_conversation` and refactor `Session::set_model` to delegate to `commands::apply_model_selection`, removing the duplicated resolve/switch logic. + +### Desktop Slash Surface + +- [x] **Add a slash-command popup to the composer.** `SlashMenu` reuses the `ModelSelector` pattern (filter, arrow-key nav, grouped headers, click-outside) and auto-runs complete commands on selection; value-command names insert and keep the menu open for argument selection. Typing a complete command and pressing Enter also runs it. + - Add Tauri commands `list_slash_commands`, `slash_autofill`, `run_slash_command`, plus `create_branch_from_checkpoint`, `apply_provider_login`, `discover_models`, `remove_providers`, and `list_provider_catalog`, with camelCase DTOs and TS mirrors. + +- [x] **Route command outcomes in the desktop shell.** `/fast`, `/status`, and `/model` push status blocks and update the status footer; `/new` and `/resume` install the new conversation and rebuild the transcript (including resume warnings); busy/parse/error outcomes update the status line. + +### Interactive Command Modals + +- [x] **Add a branch/restore modal.** `BranchModal` lists the branch family, supports switching to a branch or branching from a checkpoint (conversation-only or with tracked-file restore), and reports the restore outcome in the transcript. +- [x] **Add a logout modal.** `LogoutModal` multi-selects configured providers, removes them via `remove_providers`, refreshes the model list, and reports the result. +- [x] **Add a login wizard modal.** `LoginModal` picks a provider from the catalog, configures base URL / API-key env / model id, and applies via `apply_provider_login`. + +### Verification + +- [x] **Build, test, and document.** `cargo test --locked --all-targets`, `cargo fmt --check`, `cargo clippy` (no new warnings), the desktop `npm run typecheck` and `npm run build` all pass. Update `docs/commands.md` to note desktop parity. + ## v0.4.0 — Desktop App This release focuses on adding a native desktop app for Cassady that runs the real coding agent via the experimental Rust embedding API, themed to match the `cassady-web/` landing page. The desktop app is a peer of the TUI: same config, providers, tools, safety policy, and JSONL storage, with a windowed chat surface for streaming transcripts, tool calls, approvals, and cancellation. See `plans/V0_4_0_DESKTOP_APP_PLAN.md`. diff --git a/cassady-desktop/package.json b/cassady-desktop/package.json index 098bae1..430ca13 100644 --- a/cassady-desktop/package.json +++ b/cassady-desktop/package.json @@ -1,7 +1,7 @@ { "name": "cassady-desktop-frontend", "private": true, - "version": "0.4.1", + "version": "0.4.2", "type": "module", "scripts": { "dev": "vite", diff --git a/cassady-desktop/src-tauri/Cargo.toml b/cassady-desktop/src-tauri/Cargo.toml index 9313f64..755b0d9 100644 --- a/cassady-desktop/src-tauri/Cargo.toml +++ b/cassady-desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cassady-desktop" -version = "0.4.1" +version = "0.4.2" edition = "2021" description = "Cassady desktop app" license = "MIT" diff --git a/cassady-desktop/src-tauri/src/lib.rs b/cassady-desktop/src-tauri/src/lib.rs index dcef53b..203e888 100644 --- a/cassady-desktop/src-tauri/src/lib.rs +++ b/cassady-desktop/src-tauri/src/lib.rs @@ -18,6 +18,15 @@ pub fn run() { session::session_records, session::list_models_cmd, session::update_session_settings, + session::reload_session_config, + session::list_slash_commands, + session::slash_autofill, + session::run_slash_command, + session::create_branch_from_checkpoint, + session::apply_provider_login, + session::discover_models, + session::remove_providers, + session::list_provider_catalog, turn::start_turn, turn::approve, turn::deny, diff --git a/cassady-desktop/src-tauri/src/session.rs b/cassady-desktop/src-tauri/src/session.rs index c6a246d..e359975 100644 --- a/cassady-desktop/src-tauri/src/session.rs +++ b/cassady-desktop/src-tauri/src/session.rs @@ -1,11 +1,16 @@ use crate::state::DesktopState; use crate::types::{ - ChatSummaryDto, ConversationInfoDto, ListChatsArgs, ModelOptionDto, NewSessionArgs, - ResumeSessionArgs, SessionIdArgs, UpdateSessionSettingsArgs, + ApplyProviderLoginArgs, AutoFillMenuDto, BranchResultDto, ChatSummaryDto, CommandOutcomeDto, + CommandSpecDto, ConversationInfoDto, CreateBranchArgs, DiscoverModelsArgs, ListChatsArgs, + LogoutResultDto, ModelOptionDto, NewSessionArgs, ProviderApplyResultDto, + ProviderLogoutCandidateDto, RemoveProvidersArgs, RestoreReportDto, ResumeSessionArgs, + RunSlashCommandArgs, SessionIdArgs, SlashAutofillArgs, UpdateSessionSettingsArgs, }; +use cassady::commands::{self, CommandContext, CommandOutcome}; use cassady::config::{cass_root, load_or_create_default_model_registry, save_last_used_provider}; use cassady::conversation::list_chats as list_chats_fn; use cassady::embedding::SessionBuilder; +use cassady::setup::SetupSelection; use std::path::PathBuf; use tauri::State; @@ -179,3 +184,251 @@ pub fn update_session_settings( Ok(info.into()) } + +/// Reload the session's resolved config from disk, used after `/login` or +/// `/logout` change the active provider/model outside the session. Returns +/// the updated conversation info so the host can refresh its display. +#[tauri::command] +pub fn reload_session_config( + state: State<'_, DesktopState>, + args: SessionIdArgs, +) -> Result { + let info = { + let mut sessions = state + .sessions + .lock() + .map_err(|e| format!("sessions lock: {e}"))?; + let session = sessions + .get_mut(&args.chat_id) + .ok_or_else(|| format!("session {} not found", args.chat_id))?; + session.reload_config().map_err(|e| e.to_string())?; + session.info() + }; + Ok(info.into()) +} + +// --- Slash commands --------------------------------------------------------- + +#[tauri::command] +pub fn list_slash_commands() -> Result, String> { + Ok(commands::COMMANDS + .iter() + .map(|spec| (*spec).into()) + .collect()) +} + +#[tauri::command] +pub fn list_provider_catalog() -> Result, String> { + Ok(commands::login_catalog() + .into_iter() + .map(crate::types::ProviderCatalogEntryDto::from) + .collect()) +} + +#[tauri::command] +pub fn slash_autofill(args: SlashAutofillArgs) -> Result, String> { + let root = cass_root(); + let config = commands::reload_config(&root).map_err(|e| e.to_string())?; + let cwd = match args.cwd { + Some(cwd) => PathBuf::from(cwd), + None => std::env::current_dir().map_err(|e| e.to_string())?, + }; + let cwd = cwd.canonicalize().map_err(|e| e.to_string())?; + let menu = + commands::build_autofill(&args.input, 0, &config, &cwd).map_err(|e| e.to_string())?; + Ok(menu.map(AutoFillMenuDto::from)) +} + +#[tauri::command] +pub fn run_slash_command( + state: State<'_, DesktopState>, + args: RunSlashCommandArgs, +) -> Result { + let busy = { + let turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?; + turns.values().any(|entry| entry.chat_id == args.chat_id) + }; + + // Clone the session state out so `execute` can mutate a owned config copy + // without holding simultaneous &mut config / &conversation borrows of the + // session. Mutations are written back after the call. + let (mut config, conversation, mut reasoning_effort, cwd, access_mode) = { + let sessions = state + .sessions + .lock() + .map_err(|e| format!("sessions lock: {e}"))?; + let session = sessions + .get(&args.chat_id) + .ok_or_else(|| format!("session {} not found", args.chat_id))?; + ( + session.config().clone(), + session.conversation().clone(), + session.reasoning_effort(), + session.cwd().to_path_buf(), + session.access_mode(), + ) + }; + + let mut ctx = CommandContext { + config: &mut config, + reasoning_effort: &mut reasoning_effort, + conversation: &conversation, + cwd: &cwd, + access_mode, + busy, + status: "", + }; + let outcome = commands::execute(&args.input, &mut ctx); + + // Write back config + reasoning effort mutations (e.g. /fast, /model). + { + let mut sessions = state + .sessions + .lock() + .map_err(|e| format!("sessions lock: {e}"))?; + let session = sessions + .get_mut(&args.chat_id) + .ok_or_else(|| format!("session {} not found", args.chat_id))?; + *session.config_mut() = config; + session.set_reasoning_effort(reasoning_effort); + } + + match outcome { + CommandOutcome::Status { title, content } => Ok(CommandOutcomeDto::Status { + title: title.into(), + content, + }), + CommandOutcome::NewChat { + conversation: new_conv, + status, + } => { + let info = rekey_session(&state, &args.chat_id, new_conv)?; + Ok(CommandOutcomeDto::NewChat { + info: info.into(), + status, + }) + } + CommandOutcome::ResumedChat { + conversation: new_conv, + warning, + status, + } => { + let info = rekey_session(&state, &args.chat_id, new_conv)?; + Ok(CommandOutcomeDto::ResumedChat { + info: info.into(), + warning, + status, + }) + } + CommandOutcome::OpenBranchPicker { family } => Ok(CommandOutcomeDto::OpenBranchPicker { + family: family.into(), + }), + CommandOutcome::OpenLoginWizard => Ok(CommandOutcomeDto::OpenLoginWizard), + CommandOutcome::OpenLogoutPicker { candidates } => { + Ok(CommandOutcomeDto::OpenLogoutPicker { + candidates: candidates + .into_iter() + .map(ProviderLogoutCandidateDto::from) + .collect(), + }) + } + CommandOutcome::Busy(msg) => Ok(CommandOutcomeDto::Busy { message: msg }), + CommandOutcome::ParseError(msg) => Ok(CommandOutcomeDto::ParseError { message: msg }), + CommandOutcome::Error { title, message } => Ok(CommandOutcomeDto::Error { + title: title.into(), + message, + }), + } +} + +/// Replace a session's conversation and re-key it in the sessions map under +/// the new conversation id. Used by `/new` and `/resume`. +fn rekey_session( + state: &State<'_, DesktopState>, + old_chat_id: &str, + new_conversation: cassady::conversation::Conversation, +) -> Result { + let mut sessions = state + .sessions + .lock() + .map_err(|e| format!("sessions lock: {e}"))?; + let mut session = sessions + .remove(old_chat_id) + .ok_or_else(|| format!("session {} not found", old_chat_id))?; + session.set_conversation(new_conversation); + let info = session.info(); + sessions.insert(info.id.clone(), session); + Ok(info) +} + +#[tauri::command] +pub fn create_branch_from_checkpoint( + state: State<'_, DesktopState>, + args: CreateBranchArgs, +) -> Result { + let checkpoint: cassady::branch::Checkpoint = args.checkpoint.into(); + let (info, source_chat_id, status, restore) = { + let mut sessions = state + .sessions + .lock() + .map_err(|e| format!("sessions lock: {e}"))?; + let mut session = sessions + .remove(&args.chat_id) + .ok_or_else(|| format!("session {} not found", args.chat_id))?; + let config = session.config().clone(); + let outcome = + commands::create_branch_from_checkpoint(&config, &checkpoint, args.restore_files) + .map_err(|e| e.to_string())?; + let source_chat_id = outcome.source_chat_id; + let status = outcome.status; + let restore = outcome.restore.map(|r| RestoreReportDto { + summary: r.summary, + applied: r.applied, + skipped: r.skipped, + conflicts: r.conflicts, + }); + session.set_conversation(outcome.conversation); + let info = session.info(); + sessions.insert(info.id.clone(), session); + (info, source_chat_id, status, restore) + }; + Ok(BranchResultDto { + info: info.into(), + source_chat_id, + status, + restore, + }) +} + +#[tauri::command] +pub async fn apply_provider_login( + args: ApplyProviderLoginArgs, +) -> Result { + let root = cass_root(); + let selections: Vec = args + .selections + .into_iter() + .map(SetupSelection::from) + .collect(); + commands::apply_login(&root, &selections, args.active_index).map_err(|e| e.to_string())?; + let config = commands::reload_config(&root).map_err(|e| e.to_string())?; + Ok(ProviderApplyResultDto { + active_provider: config.provider_id, + active_model: config.model, + }) +} + +#[tauri::command] +pub async fn discover_models(args: DiscoverModelsArgs) -> Result, String> { + commands::discover_models(&args.base_url, &args.api_key) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_providers(args: RemoveProvidersArgs) -> Result { + let root = cass_root(); + let result = + commands::remove_providers(&root, &args.provider_ids).map_err(|e| e.to_string())?; + Ok(result.into()) +} diff --git a/cassady-desktop/src-tauri/src/types.rs b/cassady-desktop/src-tauri/src/types.rs index 2ca4feb..9af60e5 100644 --- a/cassady-desktop/src-tauri/src/types.rs +++ b/cassady-desktop/src-tauri/src/types.rs @@ -1,5 +1,8 @@ use cassady::access::AccessMode; +use cassady::branch::{BranchFamily, BranchSummary, Checkpoint, CheckpointKind}; +use cassady::commands::CommandSpec; use cassady::config::{ModelDefinition, ReasoningEffort}; +use cassady::setup::{LogoutResult, ProviderCatalogEntry, ProviderLogoutCandidate, SetupSelection}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -235,6 +238,340 @@ pub struct TurnHandle { pub chat_id: String, } +// --- Slash command DTOs ----------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandSpecDto { + pub name: String, + pub usage: String, + pub description: String, + pub takes_value: bool, +} + +impl From for CommandSpecDto { + fn from(spec: CommandSpec) -> Self { + Self { + name: spec.name.to_string(), + usage: spec.usage.to_string(), + description: spec.description.to_string(), + takes_value: spec.takes_value, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutoFillItemDto { + pub label: String, + pub insert: String, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutoFillMenuDto { + pub title: String, + pub replacement_start: usize, + pub replacement_end: usize, + pub items: Vec, + pub selected: usize, +} + +impl From for AutoFillMenuDto { + fn from(menu: cassady::commands::AutoFillMenu) -> Self { + Self { + title: menu.title, + replacement_start: menu.replacement_start, + replacement_end: menu.replacement_end, + items: menu + .items + .into_iter() + .map(|item| AutoFillItemDto { + label: item.label, + insert: item.insert, + detail: item.detail, + }) + .collect(), + selected: menu.selected, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BranchSummaryDto { + pub id: String, + pub created_at: String, + pub parent_chat_id: Option, + pub branch_label: Option, + pub record_count: usize, + pub current: bool, +} + +impl From for BranchSummaryDto { + fn from(b: BranchSummary) -> Self { + Self { + id: b.id, + created_at: b.created_at, + parent_chat_id: b.parent_chat_id, + branch_label: b.branch_label, + record_count: b.record_count, + current: b.current, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointDto { + pub id: String, + pub chat_id: String, + pub record_index: usize, + pub tool_call_id: Option, + pub kind: CheckpointKind, + pub label: String, + pub detail: String, + pub ts: Option, +} + +impl From for CheckpointDto { + fn from(c: Checkpoint) -> Self { + Self { + id: c.id, + chat_id: c.chat_id, + record_index: c.record_index, + tool_call_id: c.tool_call_id, + kind: c.kind, + label: c.label, + detail: c.detail, + ts: c.ts, + } + } +} + +impl From for Checkpoint { + fn from(c: CheckpointDto) -> Self { + Self { + id: c.id, + chat_id: c.chat_id, + record_index: c.record_index, + tool_call_id: c.tool_call_id, + kind: c.kind, + label: c.label, + detail: c.detail, + ts: c.ts, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BranchFamilyDto { + pub branches: Vec, + pub checkpoints: Vec, +} + +impl From for BranchFamilyDto { + fn from(f: BranchFamily) -> Self { + Self { + branches: f.branches.into_iter().map(BranchSummaryDto::from).collect(), + checkpoints: f.checkpoints.into_iter().map(CheckpointDto::from).collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderLogoutCandidateDto { + pub id: String, + pub name: Option, + pub default_model: Option, + pub model_count: usize, +} + +impl From for ProviderLogoutCandidateDto { + fn from(c: ProviderLogoutCandidate) -> Self { + Self { + id: c.id, + name: c.name, + default_model: c.default_model, + model_count: c.model_count, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LogoutResultDto { + pub removed_provider_ids: Vec, + pub removed_model_count: usize, + pub remaining_provider_count: usize, + pub active_provider: Option, + pub active_model: Option, +} + +impl From for LogoutResultDto { + fn from(r: LogoutResult) -> Self { + Self { + removed_provider_ids: r.removed_provider_ids, + removed_model_count: r.removed_model_count, + remaining_provider_count: r.remaining_provider_count, + active_provider: r.active_provider, + active_model: r.active_model, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderCatalogEntryDto { + pub name: String, + pub id: String, + pub base_url: String, + pub api_key_env: String, +} + +impl From for ProviderCatalogEntryDto { + fn from(e: ProviderCatalogEntry) -> Self { + Self { + name: e.name.to_string(), + id: e.id.to_string(), + base_url: e.base_url.to_string(), + api_key_env: e.api_key_env.to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetupSelectionDto { + pub provider_id: String, + pub provider_name: String, + pub base_url: String, + pub api_key_env: String, + pub model_id: String, + pub supports_tools: bool, + pub supports_reasoning: bool, +} + +impl From for SetupSelection { + fn from(s: SetupSelectionDto) -> Self { + Self { + provider_id: s.provider_id, + provider_name: s.provider_name, + base_url: s.base_url, + api_key_env: s.api_key_env, + model_id: s.model_id, + supports_tools: s.supports_tools, + supports_reasoning: s.supports_reasoning, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreReportDto { + pub summary: String, + pub applied: usize, + pub skipped: usize, + pub conflicts: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BranchResultDto { + pub info: ConversationInfoDto, + pub source_chat_id: String, + pub status: String, + pub restore: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderApplyResultDto { + pub active_provider: String, + pub active_model: String, +} + +/// Tagged result of `run_slash_command`, mirroring `cassady::commands::CommandOutcome`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum CommandOutcomeDto { + Status { + title: String, + content: String, + }, + NewChat { + info: ConversationInfoDto, + status: String, + }, + ResumedChat { + info: ConversationInfoDto, + warning: Option, + status: String, + }, + OpenBranchPicker { + family: BranchFamilyDto, + }, + OpenLoginWizard, + OpenLogoutPicker { + candidates: Vec, + }, + Busy { + message: String, + }, + ParseError { + message: String, + }, + Error { + title: String, + message: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunSlashCommandArgs { + pub chat_id: String, + pub input: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SlashAutofillArgs { + pub input: String, + pub cwd: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateBranchArgs { + pub chat_id: String, + pub checkpoint: CheckpointDto, + pub restore_files: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyProviderLoginArgs { + pub selections: Vec, + pub active_index: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoverModelsArgs { + pub base_url: String, + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveProvidersArgs { + pub provider_ids: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/cassady-desktop/src/components/BranchModal.tsx b/cassady-desktop/src/components/BranchModal.tsx new file mode 100644 index 0000000..e1cbc9c --- /dev/null +++ b/cassady-desktop/src/components/BranchModal.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { BranchFamily, Checkpoint } from "@/lib/tauri"; + +export function BranchModal({ + family, + onSwitch, + onBranch, + onClose, +}: { + family: BranchFamily; + onSwitch: (branchId: string) => void; + onBranch: (checkpoint: Checkpoint, restoreFiles: boolean) => Promise; + onClose: () => void; +}) { + const [selectedCheckpoint, setSelectedCheckpoint] = useState( + null, + ); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape" && !applying) { + if (selectedCheckpoint) setSelectedCheckpoint(null); + else onClose(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [applying, selectedCheckpoint, onClose]); + + const runBranch = async (restoreFiles: boolean) => { + if (!selectedCheckpoint) return; + setApplying(true); + setError(null); + try { + await onBranch(selectedCheckpoint, restoreFiles); + } catch (e) { + setError(String(e)); + setApplying(false); + } + }; + + return ( +
+ + + + {selectedCheckpoint ? "branch from checkpoint" : "branch / restore"} + + + {selectedCheckpoint + ? `${selectedCheckpoint.chatId} · ${selectedCheckpoint.label}` + : "switch to a branch or branch from a checkpoint"} + + + + {!selectedCheckpoint ? ( +
+ {family.branches.length > 0 && ( +
+

+ branches +

+
+ {family.branches.map((b) => ( + + ))} +
+
+ )} + {family.checkpoints.length > 0 && ( +
+

+ checkpoints +

+
+ {family.checkpoints.map((c) => ( + + ))} +
+
+ )} + {family.branches.length === 0 && + family.checkpoints.length === 0 && ( +

+ no branches or checkpoints found. +

+ )} +
+ ) : ( +
+ + + +
+ )} + {error && ( +

+ {error} +

+ )} +
+ + + +
+
+ ); +} diff --git a/cassady-desktop/src/components/ChatShell.tsx b/cassady-desktop/src/components/ChatShell.tsx index afc8edd..6fbf983 100644 --- a/cassady-desktop/src/components/ChatShell.tsx +++ b/cassady-desktop/src/components/ChatShell.tsx @@ -2,12 +2,31 @@ import { useEffect, useMemo, useState } from "react"; import { TopBar, OpenChatList, NoConfigCard, useSessionManager } from "@/components/TopBar"; import { Transcript } from "@/components/Transcript"; import { Composer } from "@/components/Composer"; +import { BranchModal } from "@/components/BranchModal"; +import { LogoutModal } from "@/components/LogoutModal"; +import { LoginModal } from "@/components/LoginModal"; import { ACCESS_MODES, REASONING_EFFORTS } from "@/lib/sessionSettings"; import { StatusFooter } from "@/components/StatusFooter"; import { ApprovalDialog } from "@/components/ApprovalDialog"; import { useTurn } from "@/hooks/useTurn"; -import type { AccessMode, ReasoningEffort } from "@/lib/tauri"; -import { listModels, updateSessionSettings, type ModelOption } from "@/lib/tauri"; +import type { + AccessMode, + BranchFamily, + Checkpoint, + CommandOutcome, + LogoutResult, + ProviderApplyResult, + ProviderLogoutCandidate, + ReasoningEffort, +} from "@/lib/tauri"; +import { + createBranchFromCheckpoint, + listModels, + reloadSessionConfig, + runSlashCommand, + updateSessionSettings, + type ModelOption, +} from "@/lib/tauri"; export function ChatShell({ cwd }: { cwd: string }) { const { @@ -22,6 +41,12 @@ export function ChatShell({ cwd }: { cwd: string }) { handlePick, } = useSessionManager(cwd); const [modelOptions, setModelOptions] = useState([]); + const [branchFamily, setBranchFamily] = useState(null); + const [logoutCandidates, setLogoutCandidates] = useState< + ProviderLogoutCandidate[] + >([]); + const [logoutOpen, setLogoutOpen] = useState(false); + const [loginOpen, setLoginOpen] = useState(false); const turn = useTurn(chat); @@ -94,6 +119,165 @@ export function ChatShell({ cwd }: { cwd: string }) { })(); }; + const refreshModelOptions = async () => { + try { + setModelOptions(await listModels()); + } catch { + setModelOptions([]); + } + }; + + const routeSlashOutcome = async (outcome: CommandOutcome) => { + switch (outcome.kind) { + case "status": { + turn.pushNotice(outcome.content, "status"); + // The CLI sets the status line to the content for /fast and /model, + // and to "status shown" for /status. + turn.setStatusHint( + outcome.title === "status" ? "status shown" : outcome.content, + ); + break; + } + case "newChat": { + turn.queueAfterLoad({ status: outcome.status }); + setChat(outcome.info); + break; + } + case "resumedChat": { + turn.queueAfterLoad({ + status: outcome.status, + notice: outcome.warning + ? { text: outcome.warning, kind: "error" } + : undefined, + }); + setChat(outcome.info); + break; + } + case "openBranchPicker": { + setBranchFamily(outcome.family); + turn.setStatusHint("branch/restore menu"); + break; + } + case "openLoginWizard": { + setLoginOpen(true); + turn.setStatusHint("login"); + break; + } + case "openLogoutPicker": { + setLogoutCandidates(outcome.candidates); + setLogoutOpen(true); + turn.setStatusHint("logout"); + break; + } + case "busy": + turn.setStatusHint(outcome.message); + break; + case "parseError": + turn.setStatusHint(outcome.message); + break; + case "error": + turn.pushNotice(outcome.message, "error"); + turn.setStatusHint(outcome.message); + break; + } + }; + + const onSlashCommand = (input: string) => { + if (!chat) return; + void (async () => { + try { + const outcome = await runSlashCommand(chat.id, input); + await routeSlashOutcome(outcome); + } catch (e) { + turn.setStatusHint(String(e)); + } + })(); + }; + + const onBranchSwitch = (branchId: string) => { + setBranchFamily(null); + onSlashCommand(`/resume ${branchId}`); + }; + + const onBranchFromCheckpoint = async ( + checkpoint: Checkpoint, + restoreFiles: boolean, + ) => { + if (!chat) return; + try { + const result = await createBranchFromCheckpoint( + chat.id, + checkpoint, + restoreFiles, + ); + const notice = result.restore + ? { + text: `${result.restore.summary}\n\nApplied: ${result.restore.applied}\nSkipped: ${result.restore.skipped}\nConflicts: ${result.restore.conflicts}`, + kind: (result.restore.conflicts > 0 ? "error" : "status") as + | "status" + | "error", + } + : undefined; + turn.queueAfterLoad({ status: result.status, notice }); + setChat(result.info); + setBranchFamily(null); + } catch (e) { + turn.setStatusHint(String(e)); + } + }; + + const onLogoutResolved = async (result: LogoutResult) => { + setLogoutOpen(false); + setLogoutCandidates([]); + await refreshModelOptions(); + if (result.removedProviderIds.length === 0) { + turn.pushNotice("logout cancelled", "status"); + turn.setStatusHint("logout cancelled"); + } else if (result.remainingProviderCount === 0) { + turn.pushNotice( + `removed providers: ${result.removedProviderIds.join(", ")}\nremoved model entries: ${result.removedModelCount}\nno providers remain; run /login before sending another message`, + "status", + ); + turn.setStatusHint("no provider configured"); + } else { + // Reload the open session's config so the active provider/model reflect + // the post-logout defaults (repair_active_defaults updated config.json). + if (chat) { + try { + const info = await reloadSessionConfig(chat.id); + setChat(info); + } catch (e) { + turn.setStatusHint(String(e)); + } + } + turn.pushNotice( + `removed providers: ${result.removedProviderIds.join(", ")}\nremoved model entries: ${result.removedModelCount}\nactive provider: ${result.activeProvider ?? "?"}\nactive model: ${result.activeModel ?? "?"}`, + "status", + ); + turn.setStatusHint("logout updated"); + } + }; + + const onLoginResolved = async (result: ProviderApplyResult) => { + setLoginOpen(false); + await refreshModelOptions(); + // Reload the open session's config so it picks up the new active + // provider/model written by apply_provider_login. + if (chat) { + try { + const info = await reloadSessionConfig(chat.id); + setChat(info); + } catch (e) { + turn.setStatusHint(String(e)); + } + } + turn.pushNotice( + `active provider: ${result.activeProvider}\nactive model: ${result.activeModel}`, + "status", + ); + turn.setStatusHint("login updated"); + }; + const onSend = (message: string) => { void turn.send(message); }; @@ -159,6 +343,8 @@ export function ChatShell({ cwd }: { cwd: string }) { )} + {chat && branchFamily && ( + { + setBranchFamily(null); + turn.setStatusHint("branch menu cancelled"); + }} + /> + )} + {chat && logoutOpen && ( + { + setLogoutOpen(false); + turn.setStatusHint("logout cancelled"); + }} + /> + )} + {chat && loginOpen && ( + { + setLoginOpen(false); + turn.setStatusHint("login cancelled"); + }} + /> + )} ); } diff --git a/cassady-desktop/src/components/Composer.tsx b/cassady-desktop/src/components/Composer.tsx index 558966c..77c6255 100644 --- a/cassady-desktop/src/components/Composer.tsx +++ b/cassady-desktop/src/components/Composer.tsx @@ -1,15 +1,24 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { ModelSelector } from "@/components/ModelSelector"; +import { SlashMenu } from "@/components/SlashMenu"; import { ArrowUp, Square } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { AccessMode, ModelOption, ReasoningEffort } from "@/lib/tauri"; +import { + slashAutofill, + type AccessMode, + type AutoFillMenu, + type ModelOption, + type ReasoningEffort, +} from "@/lib/tauri"; export function Composer({ disabled, running, + cwd, onSend, onCancel, + onSlashCommand, accessMode, reasoningEffort, model, @@ -20,8 +29,10 @@ export function Composer({ }: { disabled?: boolean; running?: boolean; + cwd: string; onSend: (message: string) => void; onCancel: () => void; + onSlashCommand: (input: string) => void; accessMode: AccessMode; reasoningEffort: ReasoningEffort; model: string; @@ -31,7 +42,10 @@ export function Composer({ onSelectModel: (model: string) => void; }) { const [value, setValue] = useState(""); + const [slashMenu, setSlashMenu] = useState(null); + const [slashSelected, setSlashSelected] = useState(0); const ref = useRef(null); + const rowRef = useRef(null); useEffect(() => { const el = ref.current; @@ -40,6 +54,39 @@ export function Composer({ el.style.height = `${Math.min(el.scrollHeight, 200)}px`; }, [value]); + useEffect(() => { + if (!value.startsWith("/") || value.includes("\n")) { + setSlashMenu(null); + return; + } + let active = true; + void (async () => { + try { + const menu = await slashAutofill(value, cwd); + if (active) { + setSlashMenu(menu); + setSlashSelected(menu?.selected ?? 0); + } + } catch { + if (active) setSlashMenu(null); + } + })(); + return () => { + active = false; + }; + }, [value, cwd]); + + useEffect(() => { + if (!slashMenu) return; + const handler = (e: MouseEvent) => { + if (rowRef.current && !rowRef.current.contains(e.target as Node)) { + setSlashMenu(null); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [slashMenu]); + const submit = () => { const trimmed = value.trim(); if (!trimmed || running) return; @@ -47,13 +94,64 @@ export function Composer({ setValue(""); }; + const applySlashSelection = () => { + if (!slashMenu) return; + const item = slashMenu.items[slashSelected]; + if (!item) return; + const newInput = + value.slice(0, slashMenu.replacementStart) + + item.insert + + value.slice(slashMenu.replacementEnd); + if (item.insert.endsWith(" ")) { + // Value command name selected (e.g. "/model "); keep the menu open for + // argument selection. + setValue(newInput); + } else { + // Complete command; auto-run and clear the input. + setSlashMenu(null); + setValue(""); + onSlashCommand(newInput.trim()); + } + }; + const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) { - e.preventDefault(); - submit(); - } else if (e.key === "Escape" && running) { - e.preventDefault(); - onCancel(); + if (slashMenu) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSlashSelected((s) => Math.min(s + 1, slashMenu.items.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSlashSelected((s) => Math.max(s - 1, 0)); + return; + } + if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + applySlashSelection(); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setSlashMenu(null); + return; + } + } else { + if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + const trimmed = value.trim(); + if (trimmed.startsWith("/")) { + setValue(""); + onSlashCommand(trimmed); + } else { + submit(); + } + return; + } + if (e.key === "Escape" && running) { + e.preventDefault(); + onCancel(); + } } }; @@ -83,7 +181,18 @@ export function Composer({ onSelect={onSelectModel} /> -
+
+ {slashMenu && ( + { + setSlashSelected(idx); + applySlashSelection(); + }} + onHover={setSlashSelected} + /> + )} ›