Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3c354b8b | ||
|
|
20e11ccd39 |
Generated
+27
-27
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+31
@@ -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`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cassady-desktop-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cassady-desktop"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
description = "Cassady desktop app"
|
||||
license = "MIT"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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())
|
||||
}
|
||||
|
||||
@@ -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<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)]
|
||||
mod tests {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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<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);
|
||||
|
||||
@@ -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 }) {
|
||||
<Composer
|
||||
onSend={onSend}
|
||||
onCancel={onCancel}
|
||||
onSlashCommand={onSlashCommand}
|
||||
cwd={chat.cwd}
|
||||
running={turn.state === "running" || turn.state === "approval"}
|
||||
accessMode={accessMode}
|
||||
reasoningEffort={reasoningEffort}
|
||||
@@ -180,6 +366,36 @@ export function ChatShell({ cwd }: { cwd: string }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AutoFillMenu | null>(null);
|
||||
const [slashSelected, setSlashSelected] = useState(0);
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
<textarea
|
||||
ref={ref}
|
||||
@@ -92,7 +201,7 @@ export function Composer({
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
placeholder="message cass…"
|
||||
placeholder="message cass… (type / for commands)"
|
||||
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",
|
||||
"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} />;
|
||||
case "status":
|
||||
return <StatusBlock key={b.id} text={b.text} />;
|
||||
case "error":
|
||||
return <StatusBlock key={b.id} text={b.text} marker="✗" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export function StatusBlock({
|
||||
text,
|
||||
marker = "·",
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
marker?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("py-1 font-mono text-xs text-[var(--color-amber)]", className)}>
|
||||
· {text}
|
||||
<div className={cn("whitespace-pre-wrap py-1 font-mono text-xs text-[var(--color-amber)]", className)}>
|
||||
{marker} {text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ export type BlockKind =
|
||||
| "assistant"
|
||||
| "reasoning"
|
||||
| "tool"
|
||||
| "status";
|
||||
| "status"
|
||||
| "error";
|
||||
|
||||
export interface TranscriptBlock {
|
||||
id: string;
|
||||
@@ -130,6 +131,8 @@ export interface UseTurn {
|
||||
resolveApproval: (approved: boolean) => Promise<void>;
|
||||
reset: () => 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 {
|
||||
@@ -145,6 +148,10 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
|
||||
const loadSeqRef = useRef(0);
|
||||
const streamSeqRef = useRef(0);
|
||||
const chatRef = useRef<ConversationInfo | null>(chat);
|
||||
const pendingAfterLoadRef = useRef<{
|
||||
status?: string;
|
||||
notice?: { text: string; kind: "status" | "error" };
|
||||
} | null>(null);
|
||||
chatRef.current = chat;
|
||||
|
||||
const appendBlock = useCallback((block: TranscriptBlock) => {
|
||||
@@ -364,6 +371,17 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
|
||||
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(() => {
|
||||
const chatId = chat?.id;
|
||||
const loadSeq = loadSeqRef.current + 1;
|
||||
@@ -383,6 +401,16 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
|
||||
const records = await sessionRecords(chatId);
|
||||
if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) {
|
||||
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) {
|
||||
if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) {
|
||||
@@ -404,5 +432,7 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
|
||||
resolveApproval,
|
||||
reset,
|
||||
setStatusHint,
|
||||
pushNotice,
|
||||
queueAfterLoad,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +148,12 @@ export async function updateSessionSettings(
|
||||
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> {
|
||||
return invoke<string>("get_cwd");
|
||||
}
|
||||
@@ -185,3 +191,174 @@ export async function cancelTurn(
|
||||
): Promise<ConversationInfo> {
|
||||
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
@@ -115,7 +115,10 @@ The updater does not invoke `sudo` or administrator prompts. If the install dire
|
||||
|
||||
## 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.
|
||||
- `/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.
|
||||
- `/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
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
## Cassady v0.4.2
|
||||
|
||||
Cassady v0.4.2 brings full slash-command support to the desktop app and moves command parsing/execution into a shared core so the terminal UI and desktop app stay in sync.
|
||||
|
||||
### Downloads
|
||||
|
||||
- macOS Apple Silicon: `cassady-v0.4.2-aarch64-apple-darwin.tar.gz`
|
||||
- Linux x86_64: `cassady-v0.4.2-x86_64-unknown-linux-gnu.tar.gz`
|
||||
- Linux ARM64: `cassady-v0.4.2-aarch64-unknown-linux-gnu.tar.gz`
|
||||
- Windows x86_64: `cassady-v0.4.2-x86_64-pc-windows-gnu.zip`
|
||||
|
||||
The macOS and Linux archives contain `cass`, `cassady`, and `cassady-desktop`; launch the desktop app with `cass desktop`. The Windows archive contains `cass` and `cassady`. SHA-256 checksum files are included for every archive.
|
||||
|
||||
### Highlights
|
||||
|
||||
- Adds a filterable desktop slash-command menu with keyboard navigation, grouped autocomplete, click-outside dismissal, and auto-run for complete command selections.
|
||||
- Supports desktop execution for `/fast`, `/status`, `/model`, `/new`, and `/resume`, including status blocks, footer updates, transcript rebuilds, and resume warnings.
|
||||
- Adds native desktop modals for `/login`, `/logout`, and `/branch`/`/restore`, covering provider setup, provider removal, branch switching, checkpoint branching, and optional tracked-file restore.
|
||||
- Moves the command catalog, parser, autocomplete, and pure command actions into the shared `cassady::commands` module so CLI and desktop behavior use the same implementation.
|
||||
- Expands the embedding/session surface used by the desktop app with config/conversation accessors and shared model-selection logic.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
No config migration is required. Desktop users should update both `cass` and the companion `cassady-desktop` binary from the same v0.4.2 macOS/Linux archive so the new slash-command frontend and Tauri commands match.
|
||||
|
||||
### Install from source
|
||||
|
||||
```sh
|
||||
cargo install --git https://github.com/owenqwenstarsky/cassady --tag v0.4.2
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
Built and tested with the GitHub Actions release workflow:
|
||||
|
||||
```sh
|
||||
cargo test --locked --all-targets
|
||||
cargo build -p cassady --release --locked --target aarch64-apple-darwin --bin cass --bin cassady
|
||||
(cd cassady-desktop && cargo tauri build --target aarch64-apple-darwin --no-bundle -- --locked)
|
||||
cargo build -p cassady --release --locked --target x86_64-unknown-linux-gnu --bin cass --bin cassady
|
||||
(cd cassady-desktop && cargo tauri build --target x86_64-unknown-linux-gnu --no-bundle -- --locked)
|
||||
cargo build -p cassady --release --locked --target aarch64-unknown-linux-gnu --bin cass --bin cassady
|
||||
(cd cassady-desktop && cargo tauri build --target aarch64-unknown-linux-gnu --no-bundle -- --locked)
|
||||
cargo zigbuild -p cassady --release --locked --target x86_64-pc-windows-gnu --bin cass --bin cassady
|
||||
```
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1154
File diff suppressed because it is too large
Load Diff
+39
-42
@@ -7,10 +7,7 @@
|
||||
|
||||
use crate::access::AccessMode;
|
||||
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
|
||||
use crate::config::{
|
||||
load_or_create_default_model_registry, load_or_create_default_provider_registry, Config,
|
||||
ConfigOverrides, ReasoningEffort,
|
||||
};
|
||||
use crate::config::{Config, ConfigOverrides, ReasoningEffort};
|
||||
use crate::conversation::{self, Conversation, Record};
|
||||
use crate::prompt;
|
||||
use serde_json::Value;
|
||||
@@ -183,18 +180,6 @@ struct PreparedSession {
|
||||
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)]
|
||||
pub struct Session {
|
||||
config: Config,
|
||||
@@ -230,6 +215,30 @@ impl Session {
|
||||
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) {
|
||||
self.mode = mode;
|
||||
}
|
||||
@@ -240,38 +249,26 @@ impl Session {
|
||||
|
||||
pub fn set_model(&mut self, model: impl Into<String>) -> Result<()> {
|
||||
let model = model.into();
|
||||
let models =
|
||||
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;
|
||||
crate::commands::apply_model_selection(&mut self.config, &model).map_err(Error::config)?;
|
||||
self.config.ensure_provider_auth().map_err(Error::config)?;
|
||||
self.reasoning_effort =
|
||||
ReasoningEffort::default_for_model(self.config.model_metadata.as_ref());
|
||||
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 {
|
||||
&self.conversation.path
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod branch;
|
||||
pub mod check;
|
||||
pub mod cli;
|
||||
pub mod codex_auth;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod conversation;
|
||||
pub mod desktop;
|
||||
|
||||
+7
-90
@@ -1,90 +1,7 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AutoFillItem {
|
||||
pub label: String,
|
||||
pub insert: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
//! Re-exports the shared autocomplete menu types from [`crate::commands`].
|
||||
//!
|
||||
//! The `AutoFillItem`/`AutoFillMenu` value types and their helpers live in
|
||||
//! `crate::commands` so both the CLI and the desktop share one definition.
|
||||
//! This module keeps the historical `crate::ui::autofill` import path working
|
||||
//! for the TUI renderer.
|
||||
pub use crate::commands::{AutoFillItem, AutoFillMenu};
|
||||
|
||||
Reference in New Issue
Block a user