Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3c354b8b | ||
|
|
20e11ccd39 | ||
|
|
96b9c9cdba |
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.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -436,7 +436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cassady-desktop"
|
||||
version = "0.4.0"
|
||||
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.0"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
description = "Cassady/Cass minimal terminal coding agent"
|
||||
license = "MIT"
|
||||
|
||||
@@ -86,7 +86,7 @@ cass setup
|
||||
cass update
|
||||
```
|
||||
|
||||
`cass --resume` without an id lists saved chats for the current directory. `cass desktop` launches the desktop app in the background when the `cassady-desktop` binary is installed next to `cass` or on `PATH`; after it starts, you can close the terminal. `cass update` checks official GitHub releases and can update `cass`, `cassady`, and the companion desktop binary when present in the selected prebuilt archive. When Cassady exits a chat, it prints a resume command for that session.
|
||||
`cass --resume` without an id lists saved chats for the current directory. `cass desktop` launches the desktop app in the background when a release-built `cassady-desktop` binary is installed next to `cass` or on `PATH`; after it starts, you can close the terminal. Cassady refuses desktop binaries that do not contain the bundled frontend assets because they open as a blank window. `cass update` checks official GitHub releases and can update `cass`, `cassady`, and the companion desktop binary when present in the selected prebuilt archive. When Cassady exits a chat, it prints a resume command for that session.
|
||||
|
||||
Common in-chat commands:
|
||||
|
||||
|
||||
+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`.
|
||||
|
||||
@@ -37,9 +37,9 @@ From the repository root:
|
||||
```sh
|
||||
cd cassady-desktop
|
||||
npm install
|
||||
cargo tauri build --target aarch64-apple-darwin
|
||||
cargo tauri build --target aarch64-apple-darwin --no-bundle -- --locked
|
||||
```
|
||||
|
||||
`cargo tauri build` runs the frontend build and embeds the generated assets into the standalone binary. A plain `cargo build -p cassady-desktop` is useful for Rust checks, but do not package that output because it may run in Tauri dev-asset mode and open to a blank window when copied elsewhere.
|
||||
`cargo tauri build` runs the frontend build and embeds the generated assets into the standalone binary. Release Cargo builds now fail fast when `cassady-desktop/dist` is missing or incomplete, and `cass desktop` rejects desktop binaries that do not contain the bundled frontend assets. A plain debug `cargo build -p cassady-desktop` is useful for Rust checks, but do not package that output.
|
||||
|
||||
For a GUI dev session, run the Vite dev server and the Tauri app using your local Tauri workflow. Release packaging uses the raw `cassady-desktop` binary, not DMG, app bundle, AppImage, MSI, or other installer artifacts.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cassady-desktop-frontend",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cassady-desktop-frontend",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cassady-desktop-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cassady-desktop"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
description = "Cassady desktop app"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
// Tauri embeds the built frontend into release binaries. `cargo tauri build`
|
||||
// runs the frontend build before Cargo, so make Cargo rerun this build script
|
||||
@@ -11,5 +15,68 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=../vite.config.ts");
|
||||
println!("cargo:rerun-if-changed=../package.json");
|
||||
println!("cargo:rerun-if-changed=../package-lock.json");
|
||||
|
||||
if env::var("PROFILE").as_deref() == Ok("release") {
|
||||
validate_release_build_mode();
|
||||
validate_frontend_dist();
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
fn validate_release_build_mode() {
|
||||
if env::var("DEP_TAURI_DEV").as_deref() == Ok("false") {
|
||||
return;
|
||||
}
|
||||
|
||||
panic!(
|
||||
"Cassady desktop release binaries must be built with `cargo tauri build --no-bundle` so Tauri enables custom-protocol asset embedding. Plain `cargo build --release -p cassady-desktop` builds a dev-url binary that opens as a blank window when the Vite server is not running."
|
||||
);
|
||||
}
|
||||
|
||||
fn validate_frontend_dist() {
|
||||
let index = Path::new("../dist/index.html");
|
||||
let assets_dir = Path::new("../dist/assets");
|
||||
|
||||
if !index.is_file() {
|
||||
panic!(
|
||||
"Cassady desktop release builds require built frontend assets. Run `npm install` and `npm run build` in cassady-desktop, or use `cargo tauri build --no-bundle`, before building the desktop binary."
|
||||
);
|
||||
}
|
||||
|
||||
let html = fs::read_to_string(index).unwrap_or_else(|error| {
|
||||
panic!("failed to read {}: {error}", index.display());
|
||||
});
|
||||
if !html.contains("assets/index-") {
|
||||
panic!(
|
||||
"{} does not reference the Vite-built frontend assets. Run `npm run build` in cassady-desktop before building the release desktop binary.",
|
||||
index.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !assets_dir.is_dir() {
|
||||
panic!(
|
||||
"{} is missing. Run `npm run build` in cassady-desktop before building the release desktop binary.",
|
||||
assets_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
let mut has_js = false;
|
||||
let mut has_css = false;
|
||||
for entry in fs::read_dir(assets_dir).unwrap_or_else(|error| {
|
||||
panic!("failed to read {}: {error}", assets_dir.display());
|
||||
}) {
|
||||
let path = entry
|
||||
.unwrap_or_else(|error| panic!("failed to read asset entry: {error}"))
|
||||
.path();
|
||||
has_js |= path.extension().and_then(|extension| extension.to_str()) == Some("js");
|
||||
has_css |= path.extension().and_then(|extension| extension.to_str()) == Some("css");
|
||||
}
|
||||
|
||||
if !has_js || !has_css {
|
||||
panic!(
|
||||
"{} must contain Vite-built JavaScript and CSS assets. Run `npm run build` in cassady-desktop before building the release desktop binary.",
|
||||
assets_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cassady",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"identifier": "com.cassady.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
+8
-3
@@ -79,7 +79,7 @@ Runs the interactive setup wizard in a terminal. It configures providers, authen
|
||||
|
||||
### `cass desktop`
|
||||
|
||||
Launches the Cassady desktop app and immediately detaches it from the terminal, so the app window can keep running after you close the terminal. The desktop binary must be installed as `cassady-desktop` next to `cass` or somewhere on `PATH`. You can also set `CASSADY_DESKTOP_BIN=/path/to/cassady-desktop`.
|
||||
Launches the Cassady desktop app and immediately detaches it from the terminal, so the app window can keep running after you close the terminal. The desktop binary must be installed as `cassady-desktop` next to `cass` or somewhere on `PATH`, and it must be a release-built binary with the bundled frontend assets. Cassady rejects assetless desktop binaries because they open as a blank window. You can also set `CASSADY_DESKTOP_BIN=/path/to/cassady-desktop`.
|
||||
|
||||
Useful option:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ That directory contains `config.json`, `providers.json`, `models.json`, `global.
|
||||
|
||||
## Release artifacts and updates
|
||||
|
||||
When using release archives, each archive contains both `cass` and `cassady`. macOS and Linux archives also contain `cassady-desktop`; put all extracted binaries somewhere on your `PATH` or keep them in the same directory so `cass desktop` can launch the desktop app.
|
||||
When using release archives, each archive contains both `cass` and `cassady`. macOS and Linux archives also contain a release-built `cassady-desktop` with bundled frontend assets; put all extracted binaries somewhere on your `PATH` or keep them in the same directory so `cass desktop` can launch the desktop app.
|
||||
|
||||
`cass update` can update release-archive installs from official GitHub releases. It supports the same prebuilt targets as the release process:
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
## Cassady v0.4.1
|
||||
|
||||
Cassady v0.4.1 fixes the desktop launch path so `cass desktop` no longer opens a blank white window when an assetless or stale `cassady-desktop` binary is found.
|
||||
|
||||
### Downloads
|
||||
|
||||
- macOS Apple Silicon: `cassady-v0.4.1-aarch64-apple-darwin.tar.gz`
|
||||
- Linux x86_64: `cassady-v0.4.1-x86_64-unknown-linux-gnu.tar.gz`
|
||||
- Linux ARM64: `cassady-v0.4.1-aarch64-unknown-linux-gnu.tar.gz`
|
||||
- Windows x86_64: `cassady-v0.4.1-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
|
||||
|
||||
- Rejects stale or incorrectly built `cassady-desktop` binaries that do not contain the bundled frontend assets, replacing a blank window with an actionable error.
|
||||
- Fails release desktop builds early unless they are built through the Tauri release path with embedded frontend assets.
|
||||
- Updates desktop build and install documentation to make the required release-built companion binary explicit.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
No config migration is required. If `cass desktop` previously opened a blank white window, update both `cass` and the companion `cassady-desktop` binary from the v0.4.1 macOS/Linux archive, or reinstall the npm package once v0.4.1 is published.
|
||||
|
||||
### Install from source
|
||||
|
||||
```sh
|
||||
cargo install --git https://github.com/owenqwenstarsky/cassady --tag v0.4.1
|
||||
```
|
||||
|
||||
### 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,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
@@ -1,10 +1,12 @@
|
||||
use crate::cli::{Cli, DesktopArgs};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
const DESKTOP_BIN_ENV: &str = "CASSADY_DESKTOP_BIN";
|
||||
const DESKTOP_FRONTEND_ASSET_MARKER: &[u8] = b"assets/index-";
|
||||
|
||||
pub fn launch(cli: &Cli, args: &DesktopArgs) -> Result<()> {
|
||||
let cwd = resolve_launch_cwd(cli.cwd.as_deref())?;
|
||||
@@ -40,6 +42,7 @@ fn locate_desktop_binary() -> Result<PathBuf> {
|
||||
if let Some(path) = env::var_os(DESKTOP_BIN_ENV).filter(|value| !value.is_empty()) {
|
||||
let path = PathBuf::from(path);
|
||||
if is_runnable_file(&path) {
|
||||
validate_desktop_binary(&path)?;
|
||||
return Ok(path);
|
||||
}
|
||||
bail!(
|
||||
@@ -53,12 +56,14 @@ fn locate_desktop_binary() -> Result<PathBuf> {
|
||||
if let Some(dir) = current_exe.parent() {
|
||||
let candidate = dir.join(binary_name);
|
||||
if is_runnable_file(&candidate) {
|
||||
validate_desktop_binary(&candidate)?;
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = find_on_path(binary_name) {
|
||||
validate_desktop_binary(&path)?;
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
@@ -82,6 +87,25 @@ fn find_on_path(name: &str) -> Option<PathBuf> {
|
||||
.find(|candidate| is_runnable_file(candidate))
|
||||
}
|
||||
|
||||
fn validate_desktop_binary(path: &Path) -> Result<()> {
|
||||
if has_embedded_frontend_assets(path)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
bail!(
|
||||
"found Cassady desktop binary at {}, but it does not include the built frontend assets and would open a blank window. Rebuild it with `(cd cassady-desktop && cargo tauri build --no-bundle)` or install a macOS/Linux release/npm package that includes `cassady-desktop`.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
fn has_embedded_frontend_assets(path: &Path) -> Result<bool> {
|
||||
let bytes = fs::read(path)
|
||||
.with_context(|| format!("checking embedded desktop assets in {}", path.display()))?;
|
||||
Ok(bytes
|
||||
.windows(DESKTOP_FRONTEND_ASSET_MARKER.len())
|
||||
.any(|window| window == DESKTOP_FRONTEND_ASSET_MARKER))
|
||||
}
|
||||
|
||||
fn is_runnable_file(path: &Path) -> bool {
|
||||
path.is_file() && is_executable(path)
|
||||
}
|
||||
@@ -157,4 +181,18 @@ mod tests {
|
||||
let name = format!("definitely-not-cassady-desktop-{}", std::process::id());
|
||||
assert!(find_on_path(&name).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_frontend_asset_marker_is_detected() {
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(file.path(), b"prefix /assets/index-AbCdEf.js suffix").unwrap();
|
||||
assert!(has_embedded_frontend_assets(file.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_embedded_frontend_asset_marker_is_rejected() {
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(file.path(), b"plain cargo-built desktop binary").unwrap();
|
||||
assert!(!has_embedded_frontend_assets(file.path()).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
+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