Preparing for v0.4.2

This commit is contained in:
2026-06-27 16:40:10 -05:00
parent 96b9c9cdba
commit 20e11ccd39
25 changed files with 3365 additions and 1213 deletions
Generated
+27 -27
View File
@@ -99,9 +99,9 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "approx" name = "approx"
@@ -293,12 +293,12 @@ dependencies = [
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.12.1" version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde_core",
] ]
[[package]] [[package]]
@@ -403,7 +403,7 @@ dependencies = [
[[package]] [[package]]
name = "cassady" name = "cassady"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -436,7 +436,7 @@ dependencies = [
[[package]] [[package]]
name = "cassady-desktop" name = "cassady-desktop"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"cassady", "cassady",
"serde", "serde",
@@ -2189,9 +2189,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.102" version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -3228,9 +3228,9 @@ dependencies = [
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [ dependencies = [
"bytes", "bytes",
"cfg_aliases", "cfg_aliases",
@@ -3248,9 +3248,9 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.14" version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
@@ -3655,9 +3655,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
@@ -5208,9 +5208,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.125" version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -5221,9 +5221,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.75" version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -5231,9 +5231,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.125" version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -5241,9 +5241,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.125" version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -5254,9 +5254,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.125" version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -5289,9 +5289,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.102" version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cassady" name = "cassady"
version = "0.4.1" version = "0.4.2"
edition = "2021" edition = "2021"
description = "Cassady/Cass minimal terminal coding agent" description = "Cassady/Cass minimal terminal coding agent"
license = "MIT" license = "MIT"
+31
View File
@@ -1,5 +1,36 @@
# Cassady (Cass) Roadmap # 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 ## 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`. 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`.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "cassady-desktop-frontend", "name": "cassady-desktop-frontend",
"private": true, "private": true,
"version": "0.4.1", "version": "0.4.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cassady-desktop" name = "cassady-desktop"
version = "0.4.1" version = "0.4.2"
edition = "2021" edition = "2021"
description = "Cassady desktop app" description = "Cassady desktop app"
license = "MIT" license = "MIT"
+9
View File
@@ -18,6 +18,15 @@ pub fn run() {
session::session_records, session::session_records,
session::list_models_cmd, session::list_models_cmd,
session::update_session_settings, 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::start_turn,
turn::approve, turn::approve,
turn::deny, turn::deny,
+255 -2
View File
@@ -1,11 +1,16 @@
use crate::state::DesktopState; use crate::state::DesktopState;
use crate::types::{ use crate::types::{
ChatSummaryDto, ConversationInfoDto, ListChatsArgs, ModelOptionDto, NewSessionArgs, ApplyProviderLoginArgs, AutoFillMenuDto, BranchResultDto, ChatSummaryDto, CommandOutcomeDto,
ResumeSessionArgs, SessionIdArgs, UpdateSessionSettingsArgs, 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::config::{cass_root, load_or_create_default_model_registry, save_last_used_provider};
use cassady::conversation::list_chats as list_chats_fn; use cassady::conversation::list_chats as list_chats_fn;
use cassady::embedding::SessionBuilder; use cassady::embedding::SessionBuilder;
use cassady::setup::SetupSelection;
use std::path::PathBuf; use std::path::PathBuf;
use tauri::State; use tauri::State;
@@ -179,3 +184,251 @@ pub fn update_session_settings(
Ok(info.into()) 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<ConversationInfoDto, String> {
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<Vec<CommandSpecDto>, String> {
Ok(commands::COMMANDS
.iter()
.map(|spec| (*spec).into())
.collect())
}
#[tauri::command]
pub fn list_provider_catalog() -> Result<Vec<crate::types::ProviderCatalogEntryDto>, String> {
Ok(commands::login_catalog()
.into_iter()
.map(crate::types::ProviderCatalogEntryDto::from)
.collect())
}
#[tauri::command]
pub fn slash_autofill(args: SlashAutofillArgs) -> Result<Option<AutoFillMenuDto>, 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<CommandOutcomeDto, String> {
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<cassady::embedding::ConversationInfo, String> {
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<BranchResultDto, String> {
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<ProviderApplyResultDto, String> {
let root = cass_root();
let selections: Vec<SetupSelection> = 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<Vec<String>, 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<LogoutResultDto, String> {
let root = cass_root();
let result =
commands::remove_providers(&root, &args.provider_ids).map_err(|e| e.to_string())?;
Ok(result.into())
}
+337
View File
@@ -1,5 +1,8 @@
use cassady::access::AccessMode; use cassady::access::AccessMode;
use cassady::branch::{BranchFamily, BranchSummary, Checkpoint, CheckpointKind};
use cassady::commands::CommandSpec;
use cassady::config::{ModelDefinition, ReasoningEffort}; use cassady::config::{ModelDefinition, ReasoningEffort};
use cassady::setup::{LogoutResult, ProviderCatalogEntry, ProviderLogoutCandidate, SetupSelection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -235,6 +238,340 @@ pub struct TurnHandle {
pub chat_id: String, 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<CommandSpec> 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<String>,
}
#[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<AutoFillItemDto>,
pub selected: usize,
}
impl From<cassady::commands::AutoFillMenu> 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<String>,
pub branch_label: Option<String>,
pub record_count: usize,
pub current: bool,
}
impl From<BranchSummary> 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<String>,
pub kind: CheckpointKind,
pub label: String,
pub detail: String,
pub ts: Option<String>,
}
impl From<Checkpoint> 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<CheckpointDto> 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<BranchSummaryDto>,
pub checkpoints: Vec<CheckpointDto>,
}
impl From<BranchFamily> 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<String>,
pub default_model: Option<String>,
pub model_count: usize,
}
impl From<ProviderLogoutCandidate> 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<String>,
pub removed_model_count: usize,
pub remaining_provider_count: usize,
pub active_provider: Option<String>,
pub active_model: Option<String>,
}
impl From<LogoutResult> 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<ProviderCatalogEntry> 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<SetupSelectionDto> 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<RestoreReportDto>,
}
#[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<String>,
status: String,
},
OpenBranchPicker {
family: BranchFamilyDto,
},
OpenLoginWizard,
OpenLogoutPicker {
candidates: Vec<ProviderLogoutCandidateDto>,
},
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<String>,
}
#[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<SetupSelectionDto>,
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<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -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<void>;
onClose: () => void;
}) {
const [selectedCheckpoint, setSelectedCheckpoint] = useState<Checkpoint | null>(
null,
);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="text-[var(--color-amber)]">
{selectedCheckpoint ? "branch from checkpoint" : "branch / restore"}
</CardTitle>
<CardDescription>
{selectedCheckpoint
? `${selectedCheckpoint.chatId} · ${selectedCheckpoint.label}`
: "switch to a branch or branch from a checkpoint"}
</CardDescription>
</CardHeader>
<CardContent>
{!selectedCheckpoint ? (
<div className="flex flex-col gap-3">
{family.branches.length > 0 && (
<div>
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
branches
</p>
<div className="flex flex-col gap-1">
{family.branches.map((b) => (
<button
key={b.id}
type="button"
onClick={() => onSwitch(b.id)}
disabled={applying || b.current}
className={cn(
"flex items-center justify-between border px-3 py-2 text-left transition-colors",
b.current
? "border-[var(--color-line)] text-[var(--color-fg-dim)]"
: "border-[var(--color-line)] hover:border-[var(--color-accent)]/50 hover:text-[var(--color-fg)]",
)}
>
<span className="flex flex-col gap-0.5">
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{b.current ? `current · ${b.id}` : b.id}
</span>
{b.branchLabel && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{b.branchLabel}
</span>
)}
</span>
<span className="shrink-0 font-mono text-[10px] text-[var(--color-fg-dim)]">
{b.recordCount} records
</span>
</button>
))}
</div>
</div>
)}
{family.checkpoints.length > 0 && (
<div>
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
checkpoints
</p>
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{family.checkpoints.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setSelectedCheckpoint(c)}
disabled={applying}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{c.chatId} · {c.label}
</span>
{c.detail && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{c.detail}
</span>
)}
</button>
))}
</div>
</div>
)}
{family.branches.length === 0 &&
family.checkpoints.length === 0 && (
<p className="font-mono text-xs text-[var(--color-fg-dim)]">
no branches or checkpoints found.
</p>
)}
</div>
) : (
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => setSelectedCheckpoint(null)}
disabled={applying}
className="self-start font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
‹ back
</button>
<button
type="button"
onClick={() => void runBranch(false)}
disabled={applying}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
branch conversation only
</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)]">
safe default; leaves files unchanged
</span>
</button>
<button
type="button"
onClick={() => void runBranch(true)}
disabled={applying}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
branch and restore tracked files
</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)]">
applies safe write/edit snapshots; conflicts are skipped
</span>
</button>
</div>
)}
{error && (
<p className="mt-3 whitespace-pre-wrap font-mono text-xs text-[var(--color-amber)]">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-end gap-3">
<Button variant="outline" onClick={onClose} disabled={applying}>
cancel
</Button>
</CardFooter>
</Card>
</div>
);
}
+218 -2
View File
@@ -2,12 +2,31 @@ import { useEffect, useMemo, useState } from "react";
import { TopBar, OpenChatList, NoConfigCard, useSessionManager } from "@/components/TopBar"; import { TopBar, OpenChatList, NoConfigCard, useSessionManager } from "@/components/TopBar";
import { Transcript } from "@/components/Transcript"; import { Transcript } from "@/components/Transcript";
import { Composer } from "@/components/Composer"; 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 { ACCESS_MODES, REASONING_EFFORTS } from "@/lib/sessionSettings";
import { StatusFooter } from "@/components/StatusFooter"; import { StatusFooter } from "@/components/StatusFooter";
import { ApprovalDialog } from "@/components/ApprovalDialog"; import { ApprovalDialog } from "@/components/ApprovalDialog";
import { useTurn } from "@/hooks/useTurn"; import { useTurn } from "@/hooks/useTurn";
import type { AccessMode, ReasoningEffort } from "@/lib/tauri"; import type {
import { listModels, updateSessionSettings, type ModelOption } from "@/lib/tauri"; 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 }) { export function ChatShell({ cwd }: { cwd: string }) {
const { const {
@@ -22,6 +41,12 @@ export function ChatShell({ cwd }: { cwd: string }) {
handlePick, handlePick,
} = useSessionManager(cwd); } = useSessionManager(cwd);
const [modelOptions, setModelOptions] = useState<ModelOption[]>([]); const [modelOptions, setModelOptions] = useState<ModelOption[]>([]);
const [branchFamily, setBranchFamily] = useState<BranchFamily | null>(null);
const [logoutCandidates, setLogoutCandidates] = useState<
ProviderLogoutCandidate[]
>([]);
const [logoutOpen, setLogoutOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const turn = useTurn(chat); 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) => { const onSend = (message: string) => {
void turn.send(message); void turn.send(message);
}; };
@@ -159,6 +343,8 @@ export function ChatShell({ cwd }: { cwd: string }) {
<Composer <Composer
onSend={onSend} onSend={onSend}
onCancel={onCancel} onCancel={onCancel}
onSlashCommand={onSlashCommand}
cwd={chat.cwd}
running={turn.state === "running" || turn.state === "approval"} running={turn.state === "running" || turn.state === "approval"}
accessMode={accessMode} accessMode={accessMode}
reasoningEffort={reasoningEffort} reasoningEffort={reasoningEffort}
@@ -180,6 +366,36 @@ export function ChatShell({ cwd }: { cwd: string }) {
status={turn.status} status={turn.status}
/> />
)} )}
{chat && branchFamily && (
<BranchModal
family={branchFamily}
onSwitch={onBranchSwitch}
onBranch={onBranchFromCheckpoint}
onClose={() => {
setBranchFamily(null);
turn.setStatusHint("branch menu cancelled");
}}
/>
)}
{chat && logoutOpen && (
<LogoutModal
candidates={logoutCandidates}
onResolved={onLogoutResolved}
onClose={() => {
setLogoutOpen(false);
turn.setStatusHint("logout cancelled");
}}
/>
)}
{chat && loginOpen && (
<LoginModal
onResolved={onLoginResolved}
onClose={() => {
setLoginOpen(false);
turn.setStatusHint("login cancelled");
}}
/>
)}
</div> </div>
); );
} }
+118 -9
View File
@@ -1,15 +1,24 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ModelSelector } from "@/components/ModelSelector"; import { ModelSelector } from "@/components/ModelSelector";
import { SlashMenu } from "@/components/SlashMenu";
import { ArrowUp, Square } from "lucide-react"; import { ArrowUp, Square } from "lucide-react";
import { cn } from "@/lib/utils"; 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({ export function Composer({
disabled, disabled,
running, running,
cwd,
onSend, onSend,
onCancel, onCancel,
onSlashCommand,
accessMode, accessMode,
reasoningEffort, reasoningEffort,
model, model,
@@ -20,8 +29,10 @@ export function Composer({
}: { }: {
disabled?: boolean; disabled?: boolean;
running?: boolean; running?: boolean;
cwd: string;
onSend: (message: string) => void; onSend: (message: string) => void;
onCancel: () => void; onCancel: () => void;
onSlashCommand: (input: string) => void;
accessMode: AccessMode; accessMode: AccessMode;
reasoningEffort: ReasoningEffort; reasoningEffort: ReasoningEffort;
model: string; model: string;
@@ -31,7 +42,10 @@ export function Composer({
onSelectModel: (model: string) => void; onSelectModel: (model: string) => void;
}) { }) {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [slashMenu, setSlashMenu] = useState<AutoFillMenu | null>(null);
const [slashSelected, setSlashSelected] = useState(0);
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
@@ -40,6 +54,39 @@ export function Composer({
el.style.height = `${Math.min(el.scrollHeight, 200)}px`; el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}, [value]); }, [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 submit = () => {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed || running) return; if (!trimmed || running) return;
@@ -47,13 +94,64 @@ export function Composer({
setValue(""); 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<HTMLTextAreaElement>) => { const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) { if (slashMenu) {
e.preventDefault(); if (e.key === "ArrowDown") {
submit(); e.preventDefault();
} else if (e.key === "Escape" && running) { setSlashSelected((s) => Math.min(s + 1, slashMenu.items.length - 1));
e.preventDefault(); return;
onCancel(); }
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} onSelect={onSelectModel}
/> />
</div> </div>
<div className="flex items-end gap-2"> <div ref={rowRef} className="relative flex items-end gap-2">
{slashMenu && (
<SlashMenu
menu={slashMenu}
selected={slashSelected}
onSelect={(idx) => {
setSlashSelected(idx);
applySlashSelection();
}}
onHover={setSlashSelected}
/>
)}
<span className="font-mono text-[var(--color-accent)] pb-2">›</span> <span className="font-mono text-[var(--color-accent)] pb-2">›</span>
<textarea <textarea
ref={ref} ref={ref}
@@ -92,7 +201,7 @@ export function Composer({
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
disabled={disabled} disabled={disabled}
rows={1} rows={1}
placeholder="message cass…" placeholder="message cass… (type / for commands)"
className={cn( className={cn(
"flex-1 resize-none bg-transparent font-mono text-sm text-[var(--color-fg)] placeholder:text-[var(--color-fg-dim)] focus:outline-none", "flex-1 resize-none bg-transparent font-mono text-sm text-[var(--color-fg)] placeholder:text-[var(--color-fg-dim)] focus:outline-none",
"py-2", "py-2",
@@ -0,0 +1,198 @@
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 {
applyProviderLogin,
listProviderCatalog,
type ProviderApplyResult,
type ProviderCatalogEntry,
type SetupSelection,
} from "@/lib/tauri";
const CHATGPT_CODEX_ID = "chatgpt-codex";
export function LoginModal({
onResolved,
onClose,
}: {
onResolved: (result: ProviderApplyResult) => void;
onClose: () => void;
}) {
const [catalog, setCatalog] = useState<ProviderCatalogEntry[]>([]);
const [selection, setSelection] = useState<SetupSelection | null>(null);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
setCatalog(await listProviderCatalog());
} catch {
setCatalog([]);
}
})();
}, []);
const isCodex = selection?.providerId === CHATGPT_CODEX_ID;
const apply = async () => {
if (!selection) return;
if (!selection.providerId.trim() || !selection.modelId.trim()) {
setError("provider id and model id are required");
return;
}
if (!isCodex && !selection.apiKeyEnv.trim()) {
setError("API key environment variable is required for this provider");
return;
}
setApplying(true);
setError(null);
try {
const result = await applyProviderLogin([selection], 0);
onResolved(result);
} catch (e) {
setError(String(e));
setApplying(false);
}
};
const pick = (entry: ProviderCatalogEntry) => {
setError(null);
setSelection({
providerId: entry.id,
providerName: entry.name,
baseUrl: entry.baseUrl,
apiKeyEnv: entry.apiKeyEnv,
modelId: "",
supportsTools: true,
supportsReasoning: false,
});
};
const update = (patch: Partial<SetupSelection>) => {
setSelection((prev) => (prev ? { ...prev, ...patch } : prev));
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="text-[var(--color-amber)]">login</CardTitle>
<CardDescription>
{selection ? "configure provider and model" : "choose a provider"}
</CardDescription>
</CardHeader>
<CardContent>
{!selection ? (
<div className="grid max-h-80 grid-cols-1 gap-1 overflow-y-auto">
{catalog.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => pick(entry)}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
{entry.name}
</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)]">
{entry.id} · {entry.baseUrl}
</span>
</button>
))}
{catalog.length === 0 && (
<p className="font-mono text-xs text-[var(--color-fg-dim)]">
provider catalog unavailable.
</p>
)}
</div>
) : (
<div className="flex flex-col gap-3">
<Field label="provider id">
<input
value={selection.providerId}
onChange={(e) => update({ providerId: e.target.value })}
className={inputClass}
/>
</Field>
<Field label="base url">
<input
value={selection.baseUrl}
onChange={(e) => update({ baseUrl: e.target.value })}
className={inputClass}
/>
</Field>
<Field label="api key env">
<input
value={selection.apiKeyEnv}
onChange={(e) => update({ apiKeyEnv: e.target.value })}
placeholder={isCodex ? "uses local codex auth" : "$MY_API_KEY"}
disabled={isCodex}
className={cn(inputClass, isCodex && "opacity-50")}
/>
</Field>
<Field label="model id">
<input
value={selection.modelId}
onChange={(e) => update({ modelId: e.target.value })}
placeholder="e.g. gpt-5.5"
className={inputClass}
/>
</Field>
</div>
)}
{error && (
<p className="mt-3 whitespace-pre-wrap font-mono text-xs text-[var(--color-amber)]">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-end gap-3">
<Button
variant="outline"
onClick={() => {
if (selection) setSelection(null);
else onClose();
}}
disabled={applying}
>
{selection ? "back" : "cancel"}
</Button>
{selection && (
<Button onClick={apply} disabled={applying}>
apply
</Button>
)}
</CardFooter>
</Card>
</div>
);
}
const inputClass =
"w-full border border-[var(--color-line)] bg-[var(--color-bg)] px-2 py-1.5 font-mono text-xs text-[var(--color-fg)] placeholder:text-[var(--color-fg-dim)] focus:border-[var(--color-accent)] focus:outline-none";
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)]">
{label}
</span>
{children}
</label>
);
}
@@ -0,0 +1,127 @@
import { 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 { removeProviders, type LogoutResult, type ProviderLogoutCandidate } from "@/lib/tauri";
export function LogoutModal({
candidates,
onResolved,
onClose,
}: {
candidates: ProviderLogoutCandidate[];
onResolved: (result: LogoutResult) => void;
onClose: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const apply = async () => {
if (selected.size === 0) return;
setApplying(true);
setError(null);
try {
const result = await removeProviders([...selected]);
onResolved(result);
} catch (e) {
setError(String(e));
setApplying(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="text-[var(--color-amber)]">logout</CardTitle>
<CardDescription>
remove saved providers and their models
</CardDescription>
</CardHeader>
<CardContent>
{candidates.length === 0 ? (
<p className="font-mono text-xs text-[var(--color-fg-dim)]">
no providers configured.
</p>
) : (
<div className="flex flex-col gap-1">
{candidates.map((c) => {
const checked = selected.has(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => toggle(c.id)}
className={cn(
"flex items-center gap-2 border px-3 py-2 text-left transition-colors",
checked
? "border-[var(--color-accent)]/60 bg-[var(--color-accent-glow)]"
: "border-[var(--color-line)] hover:border-[var(--color-accent)]/40",
)}
>
<span
className={cn(
"flex h-3 w-3 shrink-0 items-center justify-center border text-[10px]",
checked
? "border-[var(--color-accent)] text-[var(--color-accent)]"
: "border-[var(--color-fg-dim)]",
)}
>
{checked ? "✓" : ""}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{c.id}
</span>
{c.name && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{c.name}
</span>
)}
</span>
<span className="shrink-0 font-mono text-[10px] text-[var(--color-fg-dim)]">
{c.modelCount} models
</span>
</button>
);
})}
</div>
)}
{error && (
<p className="mt-3 whitespace-pre-wrap font-mono text-xs text-[var(--color-amber)]">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-end gap-3">
<Button variant="outline" onClick={onClose} disabled={applying}>
cancel
</Button>
<Button
onClick={apply}
disabled={applying || selected.size === 0}
>
remove {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -0,0 +1,62 @@
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import type { AutoFillMenu } from "@/lib/tauri";
export function SlashMenu({
menu,
selected,
onSelect,
onHover,
}: {
menu: AutoFillMenu;
selected: number;
onSelect: (index: number) => void;
onHover: (index: number) => void;
}) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = listRef.current?.querySelector(`[data-index="${selected}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [selected]);
return (
<div className="absolute bottom-full left-0 z-40 mb-2 w-[min(90vw,30rem)] border border-[var(--color-line)] bg-[var(--color-bg-elev)] shadow-[0_24px_60px_-20px_rgba(0,0,0,0.7)]">
<div className="flex items-center justify-between border-b border-[var(--color-line)] px-3 py-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
{menu.title}
</span>
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)]">
{menu.items.length}
</span>
</div>
<div ref={listRef} className="max-h-60 overflow-y-auto py-1">
{menu.items.map((item, idx) => {
const active = idx === selected;
return (
<button
key={idx}
type="button"
data-index={idx}
onClick={() => onSelect(idx)}
onMouseEnter={() => onHover(idx)}
className={cn(
"flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors",
active && "bg-[var(--color-accent-glow)]",
)}
>
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{item.label}
</span>
{item.detail && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{item.detail}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
@@ -41,6 +41,8 @@ export function Transcript({ blocks }: { blocks: TranscriptBlock[] }) {
return <ToolBlock key={b.id} block={b} />; return <ToolBlock key={b.id} block={b} />;
case "status": case "status":
return <StatusBlock key={b.id} text={b.text} />; return <StatusBlock key={b.id} text={b.text} />;
case "error":
return <StatusBlock key={b.id} text={b.text} marker="✗" />;
default: default:
return null; return null;
} }
@@ -2,14 +2,16 @@ import { cn } from "@/lib/utils";
export function StatusBlock({ export function StatusBlock({
text, text,
marker = "·",
className, className,
}: { }: {
text: string; text: string;
marker?: string;
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("py-1 font-mono text-xs text-[var(--color-amber)]", className)}> <div className={cn("whitespace-pre-wrap py-1 font-mono text-xs text-[var(--color-amber)]", className)}>
· {text} {marker} {text}
</div> </div>
); );
} }
+31 -1
View File
@@ -16,7 +16,8 @@ export type BlockKind =
| "assistant" | "assistant"
| "reasoning" | "reasoning"
| "tool" | "tool"
| "status"; | "status"
| "error";
export interface TranscriptBlock { export interface TranscriptBlock {
id: string; id: string;
@@ -130,6 +131,8 @@ export interface UseTurn {
resolveApproval: (approved: boolean) => Promise<void>; resolveApproval: (approved: boolean) => Promise<void>;
reset: () => void; reset: () => void;
setStatusHint: (text: string) => void; setStatusHint: (text: string) => void;
pushNotice: (text: string, kind: "status" | "error") => void;
queueAfterLoad: (pending: { status?: string; notice?: { text: string; kind: "status" | "error" } }) => void;
} }
export function useTurn(chat: ConversationInfo | null): UseTurn { export function useTurn(chat: ConversationInfo | null): UseTurn {
@@ -145,6 +148,10 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
const loadSeqRef = useRef(0); const loadSeqRef = useRef(0);
const streamSeqRef = useRef(0); const streamSeqRef = useRef(0);
const chatRef = useRef<ConversationInfo | null>(chat); const chatRef = useRef<ConversationInfo | null>(chat);
const pendingAfterLoadRef = useRef<{
status?: string;
notice?: { text: string; kind: "status" | "error" };
} | null>(null);
chatRef.current = chat; chatRef.current = chat;
const appendBlock = useCallback((block: TranscriptBlock) => { const appendBlock = useCallback((block: TranscriptBlock) => {
@@ -364,6 +371,17 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
setStatus(text); setStatus(text);
}, []); }, []);
const pushNotice = useCallback((text: string, kind: "status" | "error") => {
appendBlock({ id: nextBlockId(), kind, text });
}, [appendBlock]);
const queueAfterLoad = useCallback(
(pending: { status?: string; notice?: { text: string; kind: "status" | "error" } }) => {
pendingAfterLoadRef.current = pending;
},
[],
);
useEffect(() => { useEffect(() => {
const chatId = chat?.id; const chatId = chat?.id;
const loadSeq = loadSeqRef.current + 1; const loadSeq = loadSeqRef.current + 1;
@@ -383,6 +401,16 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
const records = await sessionRecords(chatId); const records = await sessionRecords(chatId);
if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) { if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) {
setBlocks(blocksFromRecords(records)); setBlocks(blocksFromRecords(records));
const pending = pendingAfterLoadRef.current;
if (pending) {
pendingAfterLoadRef.current = null;
if (pending.notice) {
appendBlock({ id: nextBlockId(), kind: pending.notice.kind, text: pending.notice.text });
}
if (pending.status) {
setStatus(pending.status);
}
}
} }
} catch (e) { } catch (e) {
if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) { if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) {
@@ -404,5 +432,7 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
resolveApproval, resolveApproval,
reset, reset,
setStatusHint, setStatusHint,
pushNotice,
queueAfterLoad,
}; };
} }
+177
View File
@@ -148,6 +148,12 @@ export async function updateSessionSettings(
return invoke<ConversationInfo>("update_session_settings", { args }); return invoke<ConversationInfo>("update_session_settings", { args });
} }
export async function reloadSessionConfig(
chatId: string,
): Promise<ConversationInfo> {
return invoke<ConversationInfo>("reload_session_config", { args: { chatId } });
}
export async function getCwd(): Promise<string> { export async function getCwd(): Promise<string> {
return invoke<string>("get_cwd"); return invoke<string>("get_cwd");
} }
@@ -185,3 +191,174 @@ export async function cancelTurn(
): Promise<ConversationInfo> { ): Promise<ConversationInfo> {
return invoke<ConversationInfo>("cancel_turn", { args: { turnId } }); return invoke<ConversationInfo>("cancel_turn", { args: { turnId } });
} }
// --- Slash commands ---------------------------------------------------------
export interface CommandSpec {
name: string;
usage: string;
description: string;
takesValue: boolean;
}
export interface ProviderCatalogEntry {
name: string;
id: string;
baseUrl: string;
apiKeyEnv: string;
}
export interface AutoFillItem {
label: string;
insert: string;
detail?: string;
}
export interface AutoFillMenu {
title: string;
replacementStart: number;
replacementEnd: number;
items: AutoFillItem[];
selected: number;
}
export type CheckpointKind = "user" | "assistant" | "tool_call" | "tool_result";
export interface BranchSummary {
id: string;
createdAt: string;
parentChatId?: string | null;
branchLabel?: string | null;
recordCount: number;
current: boolean;
}
export interface Checkpoint {
id: string;
chatId: string;
recordIndex: number;
toolCallId?: string | null;
kind: CheckpointKind;
label: string;
detail: string;
ts?: string | null;
}
export interface BranchFamily {
branches: BranchSummary[];
checkpoints: Checkpoint[];
}
export interface ProviderLogoutCandidate {
id: string;
name?: string | null;
defaultModel?: string | null;
modelCount: number;
}
export interface LogoutResult {
removedProviderIds: string[];
removedModelCount: number;
remainingProviderCount: number;
activeProvider?: string | null;
activeModel?: string | null;
}
export interface RestoreReport {
summary: string;
applied: number;
skipped: number;
conflicts: number;
}
export interface BranchResult {
info: ConversationInfo;
sourceChatId: string;
status: string;
restore?: RestoreReport | null;
}
export interface SetupSelection {
providerId: string;
providerName: string;
baseUrl: string;
apiKeyEnv: string;
modelId: string;
supportsTools: boolean;
supportsReasoning: boolean;
}
export interface ProviderApplyResult {
activeProvider: string;
activeModel: string;
}
export type CommandOutcome =
| { kind: "status"; title: string; content: string }
| { kind: "newChat"; info: ConversationInfo; status: string }
| {
kind: "resumedChat";
info: ConversationInfo;
warning?: string | null;
status: string;
}
| { kind: "openBranchPicker"; family: BranchFamily }
| { kind: "openLoginWizard" }
| { kind: "openLogoutPicker"; candidates: ProviderLogoutCandidate[] }
| { kind: "busy"; message: string }
| { kind: "parseError"; message: string }
| { kind: "error"; title: string; message: string };
export async function listSlashCommands(): Promise<CommandSpec[]> {
return invoke<CommandSpec[]>("list_slash_commands");
}
export async function listProviderCatalog(): Promise<ProviderCatalogEntry[]> {
return invoke<ProviderCatalogEntry[]>("list_provider_catalog");
}
export async function slashAutofill(
input: string,
cwd?: string,
): Promise<AutoFillMenu | null> {
return invoke<AutoFillMenu | null>("slash_autofill", { args: { input, cwd } });
}
export async function runSlashCommand(
chatId: string,
input: string,
): Promise<CommandOutcome> {
return invoke<CommandOutcome>("run_slash_command", { args: { chatId, input } });
}
export async function createBranchFromCheckpoint(
chatId: string,
checkpoint: Checkpoint,
restoreFiles: boolean,
): Promise<BranchResult> {
return invoke<BranchResult>("create_branch_from_checkpoint", {
args: { chatId, checkpoint, restoreFiles },
});
}
export async function applyProviderLogin(
selections: SetupSelection[],
activeIndex: number,
): Promise<ProviderApplyResult> {
return invoke<ProviderApplyResult>("apply_provider_login", {
args: { selections, activeIndex },
});
}
export async function discoverModels(
baseUrl: string,
apiKey: string,
): Promise<string[]> {
return invoke<string[]>("discover_models", { args: { baseUrl, apiKey } });
}
export async function removeProviders(
providerIds: string[],
): Promise<LogoutResult> {
return invoke<LogoutResult>("remove_providers", { args: { providerIds } });
}
+7 -2
View File
@@ -115,7 +115,10 @@ The updater does not invoke `sudo` or administrator prompts. If the install dire
## In-chat commands ## In-chat commands
Type `/` to open command autocomplete. Type `/` to open command autocomplete. These commands work in both the terminal
TUI (`cass`/`cassady`) and the desktop app's composer, with autocomplete and
auto-run on full selection. The command catalog, parser, and execution live in
the shared `cassady::commands` module, so behavior stays in sync across hosts.
- `/branch` or `/restore`: open the branch/restore menu for the current conversation family. - `/branch` or `/restore`: open the branch/restore menu for the current conversation family.
- `/fast`, `/fast on`, `/fast off`, `/fast status`: toggle or inspect a persisted fast-mode preference. Fast mode is active only when the current provider/model supports it; ChatGPT Codex models, including `gpt-5.5`, are treated as fast-capable. - `/fast`, `/fast on`, `/fast off`, `/fast status`: toggle or inspect a persisted fast-mode preference. Fast mode is active only when the current provider/model supports it; ChatGPT Codex models, including `gpt-5.5`, are treated as fast-capable.
@@ -126,7 +129,9 @@ Type `/` to open command autocomplete.
- `/resume <chat>`: resume a saved chat from the current directory. Autocomplete lists matching chats. - `/resume <chat>`: resume a saved chat from the current directory. Autocomplete lists matching chats.
- `/status`: show chat id, state, model, fast-mode state, access mode, cwd, record count, and current status. - `/status`: show chat id, state, model, fast-mode state, access mode, cwd, record count, and current status.
Local commands can be used only when the agent is idle. Local commands can be used only when the agent is idle. In the desktop app,
`/login`, `/logout`, and `/branch` open native modals instead of the terminal
wizards/menus used by the CLI.
## Keys ## Keys
+134
View File
@@ -0,0 +1,134 @@
# Desktop Slash Commands Implementation Plan
## Goal
Full, clean slash-command support in the Cassady desktop app, parity with the CLI's 8 in-chat commands (`/branch` `/fast` `/login` `/logout` `/model` `/new` `/resume` `/status`), with a filterable autocomplete popup in the composer and auto-run on full selection. The command **registry, parser, autocomplete, and execution** move out of `src/app.rs` into a new shared `cassady::commands` core module so the CLI TUI and the Tauri desktop (and any future host) share one command system.
## Scope
### In scope
- New `cassady::commands` module: catalog (`COMMANDS`/`CommandSpec`), `LocalCommand`/`FastModeCommand` enums, `parse`, autocomplete builders (`build_autofill` + `command_autofill`/`model_autofill`/`resume_chat_autofill`), and pure action functions. Move `AutoFillItem`/`AutoFillMenu` here (add `Serialize`/`Deserialize`), out of `ui/`.
- A `CommandOutcome` enum + `commands::execute(input, &mut CommandContext)` single entry point that both hosts use. Interactive commands return `OpenBranchPicker`/`OpenLoginWizard`/`OpenLogoutPicker` so each host renders its own picker and calls back into core for the apply step.
- Refactor `src/app.rs` dispatcher to call `commands::*` instead of its private inline copies; delete the duplicated private functions. CLI user-facing behavior unchanged.
- `embedding::Session`: add `config_mut()`/`set_conversation()` accessors; refactor `Session::set_model` to delegate to `commands::apply_model` (dedup).
- Desktop Tauri commands: `list_slash_commands`, `slash_autofill`, `run_slash_command` (calls `commands::execute`), plus `create_branch_from_checkpoint`, `apply_provider_login`, `discover_models`, `list_logout_candidates`, `remove_providers` for the modal apply steps.
- Frontend `SlashMenu` popup on the `Composer` textarea (reuse the `ModelSelector` pattern). Auto-run complete commands on selection and on manual Enter.
- Frontend modals: `BranchModal` (branch/restore picker), `LoginModal` (provider setup wizard), `LogoutModal` (provider removal). Reuse `ApprovalDialog`/`Card` styling.
- Update `docs/commands.md` and `ROADMAP.md`.
### Out of scope
- Adding new commands beyond the 8 (no `/help`, `/clear`, `/compact`, `/exit`).
- DMG/installer or Tauri auto-update changes.
- Cross-branch file-restore behavior changes (still uses conservative `file_edits` planning).
- Refactoring the CLI setup wizard itself (`setup::run` stays crossterm-based; desktop gets its own React wizard).
## Context or Current State
- **Desktop** (`cassady-desktop/`): React 18 + Vite + Tailwind v4 + Tauri 2, dark terminal aesthetic. `Composer.tsx` is a plain auto-growing textarea (Enter sends, Esc cancels); no autocomplete/slash UI. `ModelSelector.tsx` is the only existing filterable dropdown — a reusable pattern. IPC centralized in `src/lib/tauri.ts`; Tauri commands registered in `src-tauri/src/lib.rs:12-25`. `embedding::Session` is the runtime; `Session.config` is private.
- **CLI** (`src/app.rs`): 8 slash commands, private `LocalCommand`/`CommandSpec`/`COMMANDS`/`parse_local_command`/`build_autofill`/`*_autofill`/`apply_*`/`chat_status` etc. inlined (lines ~1925-2410, dispatcher 702-1064). Autocomplete popup via `ui/autofill.rs` (`AutoFillMenu`/`AutoFillItem` — pure data, no ratatui).
- **Core already public & UI-agnostic** (no extraction needed, just facades in `commands`): `config::save_fast_mode_preference`, `Config::fast_mode_state`, `config::load_or_create_default_*_registry`, `config::save_last_used_provider`, `setup::apply_setups`/`configured_providers`/`remove_providers`/`provider_catalog`/`discover_models`, `conversation::Conversation::{create,load}`/`list_chats`, `branch::load_family`/`create_branch`/`checkpoint_records`/`checkpoint_title`, `file_edits::plan_restore`/`apply_restore_plan`/`summarize_plan`.
- **Gaps to fill in core**: lift the private `apply_fast_mode_command`, `apply_model_selection`, `create_new_conversation`, `chat_status`, `fast_mode_status`, `model_status_message`, and the autocomplete builders into `commands`; add a UI-agnostic branch orchestrator (`create_branch` + optional file restore) and a `CommandOutcome`/`execute` dispatch.
## Design Principles
1. **Single source of truth.** Catalog, parser, autocomplete, and pure actions live in `cassady::commands`; neither `app.rs` nor the desktop duplicates them.
2. **Core does data, host does picker.** Interactive commands (`/login` `/logout` `/branch`) return an "open picker" outcome + candidate data; the host renders its own UI and calls back into core for the apply. Pure commands (`/fast` `/model` `/new` `/resume` `/status`) execute fully in core and return a displayable result.
3. **No behavior change for the CLI.** The TUI refactor is mechanical: replace inline private calls with `commands::*` calls; identical user-facing behavior.
4. **Reuse existing desktop wiring.** `/new`//`/resume`//`/model` reuse the existing `new_session`/`resume_session`/`update_session_settings` Tauri commands and frontend handlers where possible; only `/fast`, `/status`, `/branch`, `/login`, `/logout` need new Tauri commands.
5. **Auto-run on full selection.** Selecting a complete command (no-arg, or a specific model/chat arg) runs it immediately and clears the input; selecting a value-command name inserts it and keeps the menu open for arg selection; manual Enter on a complete typed command also runs.
## Design
### `cassady::commands` module (`src/commands.rs`)
```rust
pub struct CommandSpec { name, usage, description, takes_value } // moved from app.rs
pub const COMMANDS: &[CommandSpec]; // moved
pub enum LocalCommand { Branch, Fast(FastModeCommand), Login, Logout, Model(String), New, Resume(String), Status }
pub enum FastModeCommand { Toggle, On, Off, Status }
pub fn parse(input: &str) -> Result<LocalCommand, String>; // = parse_local_command
pub struct AutoFillItem { label, insert, detail } // moved from ui/autofill.rs + Serialize
pub struct AutoFillMenu { title, replacement_start, replacement_end, items, selected } // + Serialize
pub fn build_autofill(input, selected, &Config, cwd) -> Result<Option<AutoFillMenu>>; // moved
// + command_autofill, model_autofill, resume_chat_autofill, model_matches, model_detail, chat_matches, chat_detail, short_model_name
pub struct CommandContext<'a> { config: &'a mut Config, conversation: &'a Conversation, cwd: &'a Path, busy: bool, status: &'a str }
pub enum CommandOutcome {
Status(String), // /fast, /status, /model success
NewChat { conversation: Conversation, status: String }, // /new
ResumedChat { conversation: Conversation, warning: Option<String>, status: String }, // /resume
OpenBranchPicker { family: BranchFamily }, // /branch
OpenLoginWizard, // /login
OpenLogoutPicker { candidates: Vec<ProviderLogoutCandidate> }, // /logout
Busy(String), Error(String),
}
pub fn execute(input: &str, ctx: &mut CommandContext) -> CommandOutcome;
```
Pure action functions (called by `execute` and reusable directly): `apply_fast_mode(&mut Config, FastModeCommand) -> Result<FastModeOutcome>`, `apply_model(&mut Config, &str) -> Result<ModelOutcome>` (resolve + switch provider + `save_last_used_provider`), `create_new(&Config, &Path) -> Result<Conversation>`, `load_for_resume(&Config, &str) -> Result<(Conversation, Option<String>)>`, `status_summary(...) -> StatusSummary`, `branch_family(&Config, &Conversation) -> Result<BranchFamily>`, `create_branch(&Config, &Conversation, &Checkpoint, restore_files: bool) -> Result<BranchOutcome>` (orchestrates `branch::create_branch` + optional `file_edits::plan_restore`/`apply_restore_plan`), `logout_candidates(root) -> Result<Vec<ProviderLogoutCandidate>>`, `remove_providers(root, &[String]) -> Result<LogoutResult>`, `login_catalog() -> Vec<ProviderCatalogEntry>`, `apply_login(root, &[SetupSelection], usize) -> Result<()>`, `discover_models(base_url, api_key) -> Result<Vec<String>>` (thin facades over `setup::*`).
### `embedding::Session` changes
- `pub fn config_mut(&mut self) -> &mut Config` and `pub fn set_conversation(&mut self, Conversation)`.
- `Session::set_model` refactored to delegate to `commands::apply_model` (removes the duplicated resolve/switch logic in `embedding.rs:241`).
### CLI refactor (`src/app.rs`)
- Delete the private command helpers (lines ~1925-2410) and the inline dispatcher logic (702-1064); replace with `commands::parse` + `commands::execute` + a small `match CommandOutcome { ... }` that maps to TUI state mutations (`transcript.push`, `status =`, `branch_menu = Some(...)`, `run_login_menu_from_tui`/`run_logout_menu_from_tui` for the wizard outcomes, `stick_to_bottom`/`scroll` recompute). Net TUI behavior unchanged.
### Desktop Tauri commands (`src-tauri/src/session.rs` + `lib.rs`)
- `list_slash_commands() -> Vec<CommandSpecDto>` (from `commands::COMMANDS`).
- `slash_autofill(input, cwd) -> Option<AutoFillMenuDto>` (from `commands::build_autofill`).
- `run_slash_command(input) -> CommandOutcomeDto` (builds `CommandContext` from the `Session` via the new accessors, calls `commands::execute`). Maps `NewChat`/`ResumedChat` to `session.set_conversation(...)`; maps `Open*Picker` to DTOs the frontend renders.
- `create_branch_from_checkpoint(chat_id, checkpoint_id, restore_files) -> BranchResultDto`.
- `apply_provider_login(selections, active_index) -> ()` and `discover_models(base_url, api_key) -> Vec<String>`.
- `list_logout_candidates() -> Vec<ProviderLogoutCandidateDto>` and `remove_providers(ids) -> LogoutResultDto`.
- `/fast` handled inside `run_slash_command` via `commands::apply_fast_mode` + `session.config_mut()`; `/status` via `commands::status_summary` + `session_info`.
### Frontend (`cassady-desktop/src/`)
- `lib/tauri.ts`: add wrappers for the new commands + `CommandOutcome`/`CommandSpec`/`AutoFillMenu` TS mirrors.
- `components/SlashMenu.tsx`: popup anchored to the composer textarea, triggered when `value.startsWith('/')` and no newline before cursor. Renders `command_autofill` list (usage + description) or the arg list (models/chats) from `slash_autofill`. Arrow keys + Enter + Escape + click-outside (mirror `ModelSelector`). On select-complete -> call `runSlashCommand` -> route outcome. On select-value-command-name -> insert and keep open.
- `Composer.tsx`: wire `SlashMenu` (passes value, cursor, running); intercept arrow/Enter/Esc when menu open; auto-run complete commands; keep existing Enter-to-send for normal messages.
- `components/BranchModal.tsx`: lists `BranchFamily` (branches + checkpoints), checkpoint select, conversation-only vs +files-restore toggle, preview via `summarize_plan`, confirm -> `create_branch_from_checkpoint`. Styled like `ApprovalDialog`.
- `components/LoginModal.tsx`: multi-step wizard — provider catalog -> base url / api-key-env / model id (with `discover_models` lookup) -> apply. Largest UI piece.
- `components/LogoutModal.tsx`: candidate list with checkboxes + confirm -> `remove_providers`.
- `ChatShell.tsx`: hold modal state (`branchFamily`, `loginOpen`, `logoutCandidates`) driven by `runSlashCommand` outcomes; render the three modals.
- `Transcript`/blocks: reuse `StatusBlock` for `/fast`//`/status`//`/model`//`/new`//`/resume` status messages; an error block for errors; a small status block for `ResumedChat` warning.
## Implementation Steps
1. Create `src/commands.rs`: move `AutoFillItem`/`AutoFillMenu` from `ui/autofill.rs` (add serde derives), move catalog/parser/autocomplete/pure-action functions from `app.rs`, add `CommandOutcome`/`CommandContext`/`execute` + the thin `setup`/`branch`/`file_edits` facades. Register `pub mod commands;` in `lib.rs`.
2. Add `embedding::Session::config_mut`/`set_conversation`; refactor `Session::set_model` to delegate to `commands::apply_model`.
3. Refactor `src/app.rs` dispatcher to use `commands::execute` + outcome match; delete the old private helpers. Run `cargo test` — CLI behavior must be unchanged.
4. Add desktop Tauri commands + DTOs in `src-tauri/src/session.rs` and register in `lib.rs`. Add TS mirrors + wrappers in `lib/tauri.ts`.
5. Build `SlashMenu.tsx`; wire into `Composer.tsx` (trigger, keys, auto-run, manual Enter parse).
6. Wire `/fast`, `/status`, `/model`, `/new`, `/resume` outcomes to existing frontend handlers / status blocks. Verify end-to-end.
7. Build `BranchModal.tsx` + `create_branch_from_checkpoint` wiring.
8. Build `LogoutModal.tsx` + removal wiring.
9. Build `LoginModal.tsx` + `apply_provider_login`/`discover_models` wiring.
10. Update `docs/commands.md` (note desktop support) and `ROADMAP.md`. Run `cargo test --locked --all-targets` and the desktop build (`cd cassady-desktop && npm run build` + `cargo build`).
## Tests
- Unit (Rust): `commands::parse` (all 8 + aliases + usage errors), `apply_fast_mode` (toggle/on/off/status + unsupported model), `apply_model` (same-provider switch + cross-provider switch + unknown model), `status_summary`, `build_autofill`/`command_autofill`/`model_autofill`/`resume_chat_autofill` (matching, suppression on exact match, replacement ranges), `create_branch` orchestration (with/without file restore).
- Integration (Rust): extend `tests/` to cover `commands::execute` outcomes for each `LocalCommand` variant using a temp `~/.cass`.
- Desktop Tauri: smoke tests for `run_slash_command` + `slash_autofill` + `create_branch_from_checkpoint` + `apply_provider_login` + `remove_providers`.
- Manual: each of the 8 commands via the `SlashMenu` (autocomplete + manual typing + auto-run + busy guard); each modal flow; CLI parity check.
## Documentation
- `docs/commands.md`: add a note that all 8 in-chat commands work in the desktop app via the composer slash menu.
- `ROADMAP.md`: add an entry under the next release referencing this plan.
## Acceptance Criteria
- All 8 slash commands work in the desktop app with autocomplete and auto-run, behaviorally parallel to the CLI.
- `cassady::commands` is the single source for the catalog, parser, autocomplete, and pure actions; `src/app.rs` contains no private duplicates; the CLI's user-facing behavior is unchanged.
- `embedding::Session` exposes the new accessors and `set_model` delegates to `commands::apply_model`.
- The three modals (Branch, Login, Logout) render cleanly in the existing dark terminal aesthetic and round-trip through the core facades.
- `cargo test --locked --all-targets` passes; `cd cassady-desktop && npm run build` and `cargo build` pass.
+231 -1033
View File
File diff suppressed because it is too large Load Diff
+1154
View File
File diff suppressed because it is too large Load Diff
+39 -42
View File
@@ -7,10 +7,7 @@
use crate::access::AccessMode; use crate::access::AccessMode;
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings}; use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
use crate::config::{ use crate::config::{Config, ConfigOverrides, ReasoningEffort};
load_or_create_default_model_registry, load_or_create_default_provider_registry, Config,
ConfigOverrides, ReasoningEffort,
};
use crate::conversation::{self, Conversation, Record}; use crate::conversation::{self, Conversation, Record};
use crate::prompt; use crate::prompt;
use serde_json::Value; use serde_json::Value;
@@ -183,18 +180,6 @@ struct PreparedSession {
reasoning_effort: ReasoningEffort, reasoning_effort: ReasoningEffort,
} }
fn find_session_model<'a>(
models: &'a crate::config::ModelsFile,
current_provider_id: &str,
model_id: &str,
) -> Option<&'a crate::config::ModelDefinition> {
models
.models
.iter()
.find(|model| model.provider == current_provider_id && model.id == model_id)
.or_else(|| models.models.iter().find(|model| model.id == model_id))
}
#[derive(Debug)] #[derive(Debug)]
pub struct Session { pub struct Session {
config: Config, config: Config,
@@ -230,6 +215,30 @@ impl Session {
self.reasoning_effort self.reasoning_effort
} }
/// Borrow the resolved config (read-only).
pub fn config(&self) -> &Config {
&self.config
}
/// Mutably borrow the resolved config. Hosts (e.g. the desktop slash
/// command surface) use this to apply `/fast` and `/model` changes through
/// `crate::commands`.
pub fn config_mut(&mut self) -> &mut Config {
&mut self.config
}
/// Borrow the current conversation (read-only).
pub fn conversation(&self) -> &Conversation {
&self.conversation
}
/// Replace the current conversation. Used after `/new` and `/resume` (via
/// `crate::commands`) install a different conversation.
pub fn set_conversation(&mut self, conversation: Conversation) {
self.conversation = conversation;
self.resume_warning = None;
}
pub fn set_access_mode(&mut self, mode: AccessMode) { pub fn set_access_mode(&mut self, mode: AccessMode) {
self.mode = mode; self.mode = mode;
} }
@@ -240,38 +249,26 @@ impl Session {
pub fn set_model(&mut self, model: impl Into<String>) -> Result<()> { pub fn set_model(&mut self, model: impl Into<String>) -> Result<()> {
let model = model.into(); let model = model.into();
let models = crate::commands::apply_model_selection(&mut self.config, &model).map_err(Error::config)?;
load_or_create_default_model_registry(&self.config.root).map_err(Error::config)?;
let metadata = find_session_model(&models, &self.config.provider_id, &model).cloned();
if let Some(metadata) = &metadata {
if metadata.provider != self.config.provider_id {
let providers = load_or_create_default_provider_registry(&self.config.root)
.map_err(Error::config)?;
let provider = providers
.providers
.iter()
.find(|provider| provider.id == metadata.provider)
.ok_or_else(|| {
Error::config(anyhow::anyhow!(
"model `{}` references unknown provider `{}`",
metadata.id,
metadata.provider
))
})?;
self.config.provider_id = provider.id.clone();
self.config.active_provider = provider.to_resolved();
}
}
self.config.model = model;
self.config.model_metadata = metadata;
self.config.ensure_provider_auth().map_err(Error::config)?; self.config.ensure_provider_auth().map_err(Error::config)?;
self.reasoning_effort = self.reasoning_effort =
ReasoningEffort::default_for_model(self.config.model_metadata.as_ref()); ReasoningEffort::default_for_model(self.config.model_metadata.as_ref());
Ok(()) Ok(())
} }
/// Reload the resolved config from disk, picking up provider/model
/// changes made by `/login` or `/logout` outside this session. Preserves
/// the current conversation, cwd, and access mode; updates reasoning
/// effort to the new model's default. Matches the CLI's post-login/logout
/// `Config::load` reload.
pub fn reload_config(&mut self) -> Result<()> {
self.config =
crate::commands::reload_config(&self.config.root).map_err(Error::config)?;
self.reasoning_effort =
ReasoningEffort::default_for_model(self.config.model_metadata.as_ref());
Ok(())
}
pub fn conversation_path(&self) -> &Path { pub fn conversation_path(&self) -> &Path {
&self.conversation.path &self.conversation.path
} }
+1
View File
@@ -5,6 +5,7 @@ pub mod branch;
pub mod check; pub mod check;
pub mod cli; pub mod cli;
pub mod codex_auth; pub mod codex_auth;
pub mod commands;
pub mod config; pub mod config;
pub mod conversation; pub mod conversation;
pub mod desktop; pub mod desktop;
+7 -90
View File
@@ -1,90 +1,7 @@
#[derive(Debug, Clone, PartialEq, Eq)] //! Re-exports the shared autocomplete menu types from [`crate::commands`].
pub struct AutoFillItem { //!
pub label: String, //! The `AutoFillItem`/`AutoFillMenu` value types and their helpers live in
pub insert: String, //! `crate::commands` so both the CLI and the desktop share one definition.
pub detail: Option<String>, //! This module keeps the historical `crate::ui::autofill` import path working
} //! for the TUI renderer.
pub use crate::commands::{AutoFillItem, AutoFillMenu};
impl AutoFillItem {
pub fn new(label: impl Into<String>, insert: impl Into<String>) -> Self {
Self {
label: label.into(),
insert: insert.into(),
detail: None,
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoFillMenu {
pub title: String,
pub replacement_start: usize,
pub replacement_end: usize,
pub items: Vec<AutoFillItem>,
pub selected: usize,
}
impl AutoFillMenu {
pub fn new(
title: impl Into<String>,
replacement_start: usize,
replacement_end: usize,
items: Vec<AutoFillItem>,
) -> Self {
Self {
title: title.into(),
replacement_start,
replacement_end,
items,
selected: 0,
}
}
pub fn with_selected(mut self, selected: usize) -> Self {
self.selected = selected.min(self.items.len().saturating_sub(1));
self
}
pub fn selected_index(&self) -> Option<usize> {
if self.items.is_empty() {
None
} else {
Some(self.selected.min(self.items.len() - 1))
}
}
pub fn previous_index(&self) -> usize {
self.selected_index().unwrap_or(0).saturating_sub(1)
}
pub fn next_index(&self) -> usize {
self.selected_index()
.map(|idx| (idx + 1).min(self.items.len().saturating_sub(1)))
.unwrap_or(0)
}
pub fn apply(&self, input: &str) -> Option<String> {
let item = self.items.get(self.selected_index()?)?;
if self.replacement_start > self.replacement_end || self.replacement_end > input.len() {
return None;
}
if !input.is_char_boundary(self.replacement_start)
|| !input.is_char_boundary(self.replacement_end)
{
return None;
}
let mut out = String::with_capacity(
input.len() - (self.replacement_end - self.replacement_start) + item.insert.len(),
);
out.push_str(&input[..self.replacement_start]);
out.push_str(&item.insert);
out.push_str(&input[self.replacement_end..]);
Some(out)
}
}