From 5fc2876d5a5b149b5150e7c7b169c7f447e16304 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 1 Feb 2026 04:16:51 +0800 Subject: [PATCH 001/226] feat: added initial configuration discovery + plan. --- Cargo.lock | 12 + Cargo.toml | 1 + _plans/CONFIGURATION_FEATURE.md | 338 +++++++++++++ src/app.rs | 147 ++++-- src/command/handlers.rs | 133 +---- src/config.rs | 195 -------- src/config/configuration.rs | 853 ++++++++++++++++++++++++++++++++ src/config/mod.rs | 8 + src/main.rs | 3 +- src/model/discovery.rs | 33 ++ src/persistence/auth.rs | 88 +++- src/sound.rs | 36 ++ 12 files changed, 1505 insertions(+), 342 deletions(-) create mode 100644 _plans/CONFIGURATION_FEATURE.md delete mode 100644 src/config.rs create mode 100644 src/config/configuration.rs create mode 100644 src/config/mod.rs create mode 100644 src/sound.rs diff --git a/Cargo.lock b/Cargo.lock index 923038b..3364ae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,6 +806,7 @@ dependencies = [ "futures", "glob", "ignore", + "json5", "lazy_static", "nucleo-matcher", "ratatui", @@ -2291,6 +2292,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "kasuari" version = "0.4.11" diff --git a/Cargo.toml b/Cargo.toml index efb0c2b..2568922 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ tokio = { version = "1.40", features = ["full"] } reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +json5 = "0.4" schemars = "1.0" anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } diff --git a/_plans/CONFIGURATION_FEATURE.md b/_plans/CONFIGURATION_FEATURE.md new file mode 100644 index 0000000..eb14c21 --- /dev/null +++ b/_plans/CONFIGURATION_FEATURE.md @@ -0,0 +1,338 @@ +# Configuration Feature Plan + +Goal: Add a layered configuration system for Crabcode that is (1) compatible with OpenCode configs, (2) supports both global + per-project config, and (3) can be extended incrementally. For the first implementation pass, only `theme`, `sounds`, and `model` are functional; other supported keys are parsed/merged but treated as unimplemented. + +## Non-Goals (Initial Scope) + +- Implementing the behavior of OpenCode features we explicitly do not support (keybinds, theme selection via OpenCode config, custom tools, share, tui, server, plugin). +- Remote config (OpenCode `.well-known/opencode`) and OpenCode env overrides (`OPENCODE_CONFIG`, `OPENCODE_CONFIG_CONTENT`). These can be added later. + +## Sources + Precedence + +We load up to four JSON/JSONC files and deep-merge them with increasing priority: + +1. OpenCode global (lowest priority) +2. Crabcode global +3. OpenCode local +4. Crabcode local (highest priority) + +This is the inverse of how we describe merge application (base -> overrides). In code, we typically load in base-first order and apply overrides after. + +### Global Files + +Global config can live in either the app directory (preferred) or directly under the config home. + +Notes: + +- Prefer the XDG path resolution: use `$XDG_CONFIG_HOME` if set, else `~/.config`. +- Each layer (OpenCode global, Crabcode global) must resolve to at most one file. If multiple candidates exist for the same layer, Crabcode errors and tells the user to keep only one. + +OpenCode global candidates (zero or one must exist): + +- `$XDG_CONFIG_HOME/opencode/opencode.jsonc` +- `$XDG_CONFIG_HOME/opencode/opencode.json` +- `$XDG_CONFIG_HOME/opencode.jsonc` +- `$XDG_CONFIG_HOME/opencode.json` + +Crabcode global candidates (zero or one must exist): + +- `$XDG_CONFIG_HOME/crabcode/crabcode.jsonc` +- `$XDG_CONFIG_HOME/crabcode/crabcode.json` +- `$XDG_CONFIG_HOME/crabcode.jsonc` +- `$XDG_CONFIG_HOME/crabcode.json` + +### Local (Per-Project) Files + +We treat “local” as “nearest project root” (see discovery algorithm below). + +As with global configs, each layer (OpenCode local, Crabcode local) must resolve to at most one file; multiple candidates for the same layer is an error. + +OpenCode local candidates (zero or one must exist): + +- `/.opencode/opencode.jsonc` +- `/.opencode/opencode.json` +- `/opencode.jsonc` +- `/opencode.json` + +Crabcode local candidates (zero or one must exist): + +- `/.crabcode/crabcode.jsonc` +- `/.crabcode/crabcode.json` +- `/.opencode/crabcode.jsonc` +- `/.opencode/crabcode.json` +- `/crabcode.jsonc` +- `/crabcode.json` + +Rationale: + +- This supports existing OpenCode users without forcing duplicated config. +- Supporting `.opencode/crabcode.json(c)` allows teams to keep config near existing OpenCode structure while adopting Crabcode-specific keys. + +### Project Root Discovery + +Algorithm: + +- Start at current working directory. +- Walk upward until: + - A `.git` directory is found (treat that directory as project root), or + - The filesystem root is reached. +- If no `.git` is found, treat the current working directory as project root. + +This matches OpenCode’s “traverse up to nearest Git directory” behavior, but scoped to our use. + +## File Format + Parsing + +We support JSON and JSONC: + +- `.json` is strict JSON. +- `.jsonc` allows comments and trailing commas. + +Implementation approach (Rust): + +- Parse each config file into a `serde_json::Value` (not a strongly-typed struct). +- Use a JSONC-capable parser for `.jsonc` (recommended: `json5` crate; it handles comments + trailing commas). +- Keep track of the file path and source label for diagnostics. + +## Deep Merge Semantics + +We need predictable, “graceful” merges. + +Recommended merge behavior: + +- Object + object: recursively merge keys. +- Array + array: override entire array with higher-priority value. +- Primitive (string/number/bool) or type mismatch: higher-priority value replaces lower. +- `null`: treat as “unset” (removes the key from the merged result) rather than a literal null. + +Rationale for `null` as unset: it provides an escape hatch to disable values from lower layers (useful when the global config is shared). + +## Variable Substitution + +Support OpenCode-style placeholders inside string values: + +- `{env:VAR_NAME}` -> environment variable value, or empty string if unset. +- `{file:path}` -> file contents (trim trailing newlines). + +Path rules for `{file:...}`: + +- `~` expands to home directory. +- Relative paths resolve relative to the config file’s directory. +- Absolute paths are allowed. + +Processing rules: + +- Apply substitution after all config sources are merged (so placeholders in the winning value get resolved). +- Traverse the merged `serde_json::Value` recursively and only substitute within string leaves. +- Support multiple placeholders within the same string. +- If a `{file:...}` read fails, replace with empty string and record a warning diagnostic. + +## Compatibility Strategy (OpenCode + Crabcode) + +We load both OpenCode and Crabcode sources, but we do not implement all OpenCode keys. + +### Keys We Intend to Parse/Merge (OpenCode-Compatible) + +These keys should be accepted from OpenCode config files and merged (even if unimplemented at runtime initially): + +- `agent` +- `instructions` +- `tools` (tool enable/disable map) +- `mcp` +- `model` (default model) +- `provider` (providers outside models.dev) +- `command` +- `permission` +- `compaction` +- `watcher` +- `default_agent` +- `formatter` +- `disabled_providers` +- `enabled_providers` + +If we later expand the compatibility set, we do it by: + +- Adding parsing/normalization for the new key into our internal config representation. +- Implementing the behavior in the relevant subsystem. + +### Keys We Explicitly Ignore From OpenCode + +We ignore these keys when they appear in OpenCode configs: + +- `keybinds` +- `theme` (Crabcode does not read theme selection from OpenCode config) +- `custom tools` (in OpenCode schema: `tool` / `tools` are not the same as “custom tools”; we ignore the custom-tool feature) +- `share` +- `tui` +- `server` +- `plugin` + +We should still allow these keys to exist (no parse error); we just exclude them from the merged config we act upon. + +### Crabcode-Specific Additions + +Crabcode config supports everything in the compatibility set above, plus: + +- `sounds` (Crabcode-only) +- `theme` (Crabcode controls the theme selection, but the theme system is compatible with OpenCode) + +If these appear in OpenCode config files, they are ignored. + +## Crabcode Config Schema (Initial) + +Minimal schema we actively apply in the first iteration: + +```jsonc +{ + "$schema": "https://crabcode.ai/config.json", // future + + // Crabcode-only theme values + "theme": "default", + + // OpenCode-compatible + "model": "openai/gpt-5.2", + + // Crabcode-only (All are optional to use, but these are the defaults) + "sounds": { + "error": { "file": "/absolute/path.wav", "enabled": false }, + "complete": { "file": "/absolute/path.wav", "enabled": true }, + "permission": { "file": "/absolute/path.wav", "enabled": false }, + "question": { "file": "/absolute/path.wav", "enabled": false }, + }, +} +``` + +Sounds requirements: + +- `file` must be an absolute path (no `~`, no relative). If invalid, record a warning and treat sound as disabled. +- `enabled` default behavior: + - If not specified: default to `false` except `complete` default to `true` (per requirement). + +## .opencode Directory Structure Compatibility + +We support discovering config additions from `.opencode/` (and global `~/.config/opencode/`) similar to OpenCode: + +- Agents: + - `.opencode/agents/*.md` + - `.opencode/agent/*.md` (back-compat) +- Skills: + - `.opencode/skills/**` + - `.opencode/skill/**` (back-compat) + +Initial behavior: + +- Discover these files/directories and record them in a “config inventory” for future use. +- Do not change runtime behavior yet (unimplemented), but surface them as diagnostics so users know they were found. + +Later behavior (future phases): + +- Parse agent markdown frontmatter or content per OpenCode docs and integrate into agent registry. +- Load skills from discovered skill folders. + +## Theme Support (OpenCode-Compatible) + +Crabcode supports OpenCode's theme JSON format and reads theme definitions from the same `themes/` folders OpenCode uses (https://opencode.ai/docs/themes/#custom-themes). + +### Theme Selection Rules + +- `theme` is read from Crabcode config files only (e.g. `~/.config/crabcode/crabcode.json(c)` and `.crabcode/crabcode.json(c)` and `.opencode/crabcode.json(c)`). +- If `theme` is present only in OpenCode config files, Crabcode ignores it. + +Theme value format: + +- `theme` is a theme ID (string), not a path. +- The ID is resolved by searching theme definitions in both OpenCode and Crabcode theme folders. + +### Theme Discovery (Built-in + Custom) + +Load themes with higher priority overriding lower when the same theme name exists in multiple locations. + +Recommended combined hierarchy: + +1. Built-in themes (embedded or shipped with the binary) +2. OpenCode user themes: `$XDG_CONFIG_HOME/opencode/themes/*.json` +3. Crabcode user themes: `$XDG_CONFIG_HOME/crabcode/themes/*.json` +4. OpenCode project themes: `/.opencode/themes/*.json` +5. Crabcode project themes: `/.crabcode/themes/*.json` +6. Current working directory themes: `./.opencode/themes/*.json` (if different from project root) + +This preserves OpenCode's precedence while allowing Crabcode-native theme folders. + +## Decisions Locked In (From Discussion) + +- Theme selection: theme ID only; resolve from `themes/` folders in both OpenCode and Crabcode locations. +- Support global "flat" configs (e.g. `$XDG_CONFIG_HOME/opencode.json(c)`, `$XDG_CONFIG_HOME/crabcode.json(c)`) in addition to app directories. +- Support project-root configs (e.g. `/opencode.json(c)`, `/crabcode.json(c)`) in addition to dot-directories. +- Only one config file per layer (OpenCode global, Crabcode global, OpenCode local, Crabcode local); multiple candidates for the same layer is an error. +- `null` means unset during merge. +- No `CRABCODE_CONFIG` env override for now. + +## Diagnostics and “Unimplemented” Reporting + +We want it to “merge gracefully without issues” and also make it obvious what is currently unused. + +Proposed diagnostic design: + +- Collect warnings during load/merge/resolve: + - Parse errors per file (non-fatal; skip file). + - `{file:...}` read failures. + - Invalid `sounds.*.file` (non-absolute). + - Unknown keys (only if they look like they were intended, optional). + +- Collect “unimplemented keys” present in the merged config: + - If a supported-but-unimplemented top-level key exists (e.g. `permission`), record it once. + - If an ignored key exists in OpenCode configs (e.g. `keybinds`), do not warn (silently ignore). + +Where to surface: + +- Log at startup (once), and optionally show in a UI “Config” screen later. + +## Integration Points in Current Codebase + +Current state observations: + +- `src/config.rs` currently manages `api_keys.json` and is not a general config loader. +- Theme is currently loaded from `src/theme.json` with a fallback to `src/themes/ayu.json` (`src/app.rs`). +- Model selection is persisted in SQLite (`src/persistence/prefs.rs`) and in message history; config should only set the default. + +Planned integration: + +- Add a new module (recommended: `src/config/mod.rs` or rename `src/config.rs` -> `src/config/api_keys.rs` and create `src/config/mod.rs`). +- Add `ConfigLoader` that returns: + - `MergedConfig` (typed subset we act upon: theme/model/sounds) + - `RawMergedValue` (full merged JSON value, for future keys) + - `Diagnostics` (warnings + unimplemented) + +## Phase 1 Implementation Checklist + +Phase 1 should implement behavior for `theme`, `sounds`, `model` only. + +- Load config sources (4-tier) + deep merge. +- Fail-fast duplicate checks per layer: error if more than one candidate exists for any single layer. +- Variable substitution. +- Apply `theme`: + - Decide how to map a theme string to an actual theme file. + - Recommended: treat it as an ID that maps to a built-in JSON file in `src/themes/*.json`. + - If theme is invalid/missing, keep current fallback behavior. +- Apply `model`: + - Use config `model` only as the default when there is no active model in prefs yet. + - Do not overwrite persisted “active model” selection. +- Apply `sounds`: + - Introduce an audio playback layer and trigger events from existing UI flows. + - If we can’t add playback immediately, still wire config parsing + diagnostics so the shape is stable. + +## Phase 2+ (Future) + +- Implement additional OpenCode-compatible keys in priority order: + - `permission` (ties into tool execution) + - `tools` enable/disable + - `instructions` loader + - `agent` + `.opencode/agents/*.md` + - `skills` + `.opencode/skills/**` + - `command`, `watcher`, `formatter`, `mcp`, `provider` + +## References + +- OpenCode config docs: https://opencode.ai/docs/config/ +- OpenCode config schema: https://opencode.ai/config.json +- OpenCode agents: https://opencode.ai/docs/agents/ +- OpenCode skills: https://opencode.ai/docs/skills/ diff --git a/src/app.rs b/src/app.rs index 1d20bcf..1bcbecc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,6 @@ use crate::command::handlers::register_all_commands; use crate::command::parser::InputType; use crate::command::registry::Registry; use crate::llm::client::stream_llm_with_cancellation; -use crate::logging::log; use crate::session::manager::SessionManager; use crate::push_toast; @@ -46,6 +45,20 @@ use crate::{ theme::{self, Theme}, }; +use anyhow::Result; + +fn parse_model_ref(model: &str) -> (String, String) { + let model = model.trim(); + if let Some((provider_id, model_id)) = model.split_once('/') { + let provider_id = provider_id.trim(); + let model_id = model_id.trim(); + if !provider_id.is_empty() && !model_id.is_empty() { + return (provider_id.to_string(), model_id.to_string()); + } + } + ("opencode".to_string(), model.to_string()) +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum BaseFocus { Home, @@ -91,6 +104,7 @@ pub struct App { pub themes: Vec, pub current_theme_index: usize, pub dark_mode: bool, + pub sounds: crate::config::SoundsConfig, pub is_streaming: bool, chunk_sender: Option, chunk_receiver: Option, @@ -105,7 +119,7 @@ pub struct App { } impl App { - pub fn new() -> Self { + pub fn new() -> Result { let mut registry = Registry::new(); register_all_commands(&mut registry); @@ -115,15 +129,12 @@ impl App { let mut input = Input::new().with_autocomplete(autocomplete); input.set_placeholder(placeholder_static); - let cwd = std::env::current_dir() - .ok() - .and_then(|p| p.to_str().map(|s| s.to_string())) + let cwd_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let cwd = cwd_path + .to_str() + .map(|s| s.to_string()) .unwrap_or_else(|| "?".to_string()); - let theme = theme::Theme::load_from_file("src/theme.json") - .unwrap_or_else(|_| theme::Theme::load_from_file("src/themes/ayu.json").unwrap()); - let colors = theme.get_colors(true); - let home_state = init_home(); let agent = "Plan".to_string(); let chat_state = init_chat(Chat::new(), &agent); @@ -131,7 +142,6 @@ impl App { let models_dialog_state = init_models_dialog("Models", vec![]); let connect_dialog_state = init_connect_dialog(); let sessions_dialog_state = init_sessions_dialog("Sessions", vec![]); - let session_rename_dialog_state = init_session_rename_dialog(colors); let which_key_state = crate::views::which_key::init_which_key(); let api_key_input = crate::ui::components::api_key_input::ApiKeyInput::new(); @@ -147,20 +157,72 @@ impl App { } }; + let loaded_config = crate::config::ConfigLoader::load()?; + if !loaded_config.diagnostics.info.is_empty() { + for msg in &loaded_config.diagnostics.info { + eprintln!("Config: {}", msg); + } + } + if !loaded_config.diagnostics.warnings.is_empty() { + for msg in &loaded_config.diagnostics.warnings { + eprintln!("Config warning: {}", msg); + } + } + if !loaded_config.diagnostics.unimplemented_keys.is_empty() { + eprintln!( + "Config: unimplemented keys present: {}", + loaded_config.diagnostics.unimplemented_keys.join(", ") + ); + } + let active_model_info = if let Some(ref dao) = prefs_dao { dao.get_active_model().ok().flatten() } else { None }; - let (active_model, active_provider_name) = - if let Some((provider_id, model_id)) = active_model_info { - (model_id.clone(), provider_id.clone()) - } else { - ("big-pickle".to_string(), "opencode".to_string()) - }; + if active_model_info.is_none() { + if let (Some(ref dao), Some(model_str)) = (prefs_dao.as_ref(), loaded_config.merged_config.model.clone()) { + let (provider_id, model_id) = parse_model_ref(&model_str); + let _ = dao.set_active_model(provider_id, model_id); + } + } + + let active_model_info = if let Some(ref dao) = prefs_dao { + dao.get_active_model().ok().flatten() + } else { + None + }; + + let (active_model, active_provider_name) = if let Some((provider_id, model_id)) = active_model_info { + (model_id.clone(), provider_id.clone()) + } else if let Some(model_str) = loaded_config.merged_config.model.clone() { + let (provider_id, model_id) = parse_model_ref(&model_str); + (model_id, provider_id) + } else { + ("big-pickle".to_string(), "opencode".to_string()) + }; + + let (themes, current_theme_index) = crate::config::discover_themes( + &loaded_config.xdg_config_home, + &loaded_config.project_root, + &loaded_config.cwd, + loaded_config.merged_config.theme.as_deref(), + ); + + let theme_for_colors = themes + .get(current_theme_index) + .or_else(|| themes.first()) + .cloned() + .unwrap_or_else(|| { + theme::Theme::load_from_file("src/theme.json") + .unwrap_or_else(|_| theme::Theme::load_from_file("src/generated_themes/ayu.json").unwrap()) + }); + let colors = theme_for_colors.get_colors(true); - Self { + let session_rename_dialog_state = init_session_rename_dialog(colors); + + Ok(Self { running: true, version: env!("CARGO_PKG_VERSION").to_string(), input, @@ -184,9 +246,10 @@ impl App { overlay_focus: OverlayFocus::None, ctrl_c_press_count: 0, last_ctrl_c_time: std::time::Instant::now(), - themes: vec![theme], - current_theme_index: 0, + themes, + current_theme_index, dark_mode: true, + sounds: loaded_config.merged_config.sounds.clone(), is_streaming: false, chunk_sender: None, chunk_receiver: None, @@ -198,6 +261,21 @@ impl App { streaming_chat_len_before_assistant: 0, tool_call_message_indices: std::collections::HashMap::new(), tool_call_order: Vec::new(), + }) + } + + fn play_sound_event(&self, event: crate::sound::SoundEvent) { + use crate::sound::SoundEvent; + let effect = match event { + SoundEvent::Error => &self.sounds.error, + SoundEvent::Complete => &self.sounds.complete, + SoundEvent::Permission => &self.sounds.permission, + SoundEvent::Question => &self.sounds.question, + }; + if effect.is_effectively_enabled() { + if let Some(ref path) = effect.file { + crate::sound::play_file(path); + } } } @@ -287,12 +365,18 @@ impl App { let handled = match self.overlay_focus { OverlayFocus::SuggestionsPopup => { - let handled = self.handle_suggestions_popup_keys(key); - if !handled { - self.input.handle_event(key); + // When the suggestions popup is open, the keystroke should be handled either by the + // popup itself (navigation/autocomplete) or by the input. If we return `false` here + // and the popup closes during `update_suggestions()`, the same key event can be + // processed again by the base input handler, resulting in duplicated characters. + let popup_handled = self.handle_suggestions_popup_keys(key); + if popup_handled { + true + } else { + let input_handled = self.input.handle_event(key); self.update_suggestions(); + input_handled } - handled } OverlayFocus::ModelsDialog => { let action = handle_models_dialog_key_event(&mut self.models_dialog_state, key); @@ -807,6 +891,7 @@ impl App { } } crate::command::registry::CommandResult::Error(msg) => { + self.play_sound_event(crate::sound::SoundEvent::Error); if msg.starts_with("Unknown command:") { push_toast(ratatui_toolkit::Toast::new( msg, @@ -919,11 +1004,12 @@ impl App { self.quit(); } } - crate::command::registry::CommandResult::Error(msg) => { - if msg.starts_with("Unknown command:") { - push_toast(ratatui_toolkit::Toast::new( - msg, - ratatui_toolkit::ToastLevel::Error, + crate::command::registry::CommandResult::Error(msg) => { + self.play_sound_event(crate::sound::SoundEvent::Error); + if msg.starts_with("Unknown command:") { + push_toast(ratatui_toolkit::Toast::new( + msg, + ratatui_toolkit::ToastLevel::Error, Some(std::time::Duration::from_secs(3)), )); } else { @@ -1302,11 +1388,14 @@ impl App { self.streaming_model = None; self.streaming_provider = None; self.cleanup_streaming(); + + self.play_sound_event(crate::sound::SoundEvent::Complete); } crate::llm::ChunkMessage::Failed(error) => { self.is_streaming = false; self.chat_state.chat.mark_streaming_end(); self.chat_state.chat.finalize_streaming_metrics(); + self.play_sound_event(crate::sound::SoundEvent::Error); push_toast(ratatui_toolkit::Toast::new( format!("LLM error: {}", error), ratatui_toolkit::ToastLevel::Error, @@ -1708,6 +1797,6 @@ impl App { impl Default for App { fn default() -> Self { - Self::new() + Self::new().expect("Failed to initialize App") } } diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 726f863..a26c405 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -78,26 +78,22 @@ pub fn handle_connect<'a>( let args = parsed.args.clone(); Box::pin(async move { - if args.is_empty() { - let auth_dao = match crate::persistence::AuthDAO::new() { - Ok(dao) => dao, - Err(e) => { - return CommandResult::Error(format!("Failed to load auth config: {}", e)) - } - }; + if !args.is_empty() { + return CommandResult::Error( + "This command only opens the connect dialog. Usage: /connect".to_string(), + ); + } + + let auth_dao = match crate::persistence::AuthDAO::new() { + Ok(dao) => dao, + Err(e) => return CommandResult::Error(format!("Failed to load auth config: {}", e)), + }; let connected_providers = match auth_dao.load() { Ok(providers) => providers, Err(e) => return CommandResult::Error(format!("Failed to load providers: {}", e)), }; - let api_key_config = match crate::config::ApiKeyConfig::load() { - Ok(c) => c, - Err(e) => { - return CommandResult::Error(format!("Failed to load API key config: {}", e)) - } - }; - let discovery = match crate::model::discovery::Discovery::new() { Ok(d) => d, Err(e) => { @@ -147,40 +143,9 @@ pub fn handle_connect<'a>( items.sort_by(|a, b| a.name.cmp(&b.name)); - CommandResult::ShowDialog { - title: "Connect a provider".to_string(), - items, - } - } else { - let config = match crate::config::ApiKeyConfig::load() { - Ok(c) => c, - Err(e) => return CommandResult::Error(format!("Failed to load config: {}", e)), - }; - - if args.len() == 1 { - let provider = &args[0]; - if let Some(_api_key) = config.get_api_key(provider) { - CommandResult::Success(format!("Provider '{}' is configured", provider)) - } else { - CommandResult::Success(format!( - "Provider '{}' is not configured. Usage: /connect {} ", - provider, provider - )) - } - } else { - let provider = &args[0]; - let api_key = &args[1]; - let mut config = config; - config.set_api_key(provider.clone(), api_key.clone()); - if let Err(e) = config.save() { - CommandResult::Error(format!("Failed to save config: {}", e)) - } else { - CommandResult::Success(format!( - "API key configured for provider '{}'", - provider - )) - } - } + CommandResult::ShowDialog { + title: "Connect a provider".to_string(), + items, } }) } @@ -647,7 +612,7 @@ mod tests { #[tokio::test] async fn test_handle_connect_no_args() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { @@ -673,13 +638,13 @@ mod tests { _ => panic!("Expected ShowDialog"), } - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); let _ = crate::model::discovery::Discovery::cleanup_test(); } #[tokio::test] - async fn test_handle_connect_provider_only() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); + async fn test_handle_connect_with_args_errors() { + let _ = crate::persistence::AuthDAO::cleanup_test(); let parsed = ParsedCommand { name: "connect".to_string(), @@ -691,65 +656,11 @@ mod tests { let mut session_manager = SessionManager::new(); let result = handle_connect(&parsed, &mut session_manager).await; match result { - CommandResult::Success(msg) => { - assert!(msg.contains("not configured") || msg.contains("is not configured")); - } - _ => panic!("Expected Success"), - } - - let _ = crate::config::ApiKeyConfig::cleanup_test(); - } - - #[tokio::test] - async fn test_handle_connect_with_api_key() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); - - let parsed = ParsedCommand { - name: "connect".to_string(), - args: vec!["nano-gpt".to_string(), "sk-test-key".to_string()], - raw: "/connect nano-gpt sk-test-key".to_string(), - prefs_dao: None, - active_model_id: None, - }; - let mut session_manager = SessionManager::new(); - let result = handle_connect(&parsed, &mut session_manager).await; - match result { - CommandResult::Success(msg) => { - assert!(msg.contains("API key configured")); - } - _ => panic!("Expected Success"), - } - - let _ = crate::config::ApiKeyConfig::cleanup_test(); - } - - #[tokio::test] - async fn test_handle_connect_and_retrieve() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); - - let mut session_manager = SessionManager::new(); - - let parsed1 = ParsedCommand { - name: "connect".to_string(), - args: vec!["nano-gpt".to_string(), "sk-test-key".to_string()], - raw: "/connect nano-gpt sk-test-key".to_string(), - prefs_dao: None, - active_model_id: None, - }; - let result1 = handle_connect(&parsed1, &mut session_manager).await; - match result1 { - CommandResult::Success(msg) => { - assert!(msg.contains("API key configured")); - } - _ => panic!("Expected Success"), - } - - let config = crate::config::ApiKeyConfig::load_test().unwrap(); - if let Some(api_key) = config.get_api_key("nano-gpt") { - assert_eq!(api_key, "sk-test-key"); + CommandResult::Error(msg) => assert!(msg.contains("Usage: /connect")), + _ => panic!("Expected Error"), } - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); } #[tokio::test] @@ -800,7 +711,7 @@ mod tests { #[tokio::test] async fn test_handle_models_cleanup() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { name: "models".to_string(), @@ -819,7 +730,7 @@ mod tests { CommandResult::Error(_) => {} _ => panic!("Expected ShowDialog or Error"), } - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); let _ = crate::model::discovery::Discovery::cleanup_test(); } diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 4033551..0000000 --- a/src/config.rs +++ /dev/null @@ -1,195 +0,0 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::env; -use std::fs; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ApiKeyConfig { - pub api_keys: HashMap, -} - -impl Default for ApiKeyConfig { - fn default() -> Self { - Self::new() - } -} - -impl ApiKeyConfig { - pub fn new() -> Self { - Self { - api_keys: HashMap::new(), - } - } - - pub fn load() -> Result { - let path = Self::config_path(); - if path.exists() { - let content = fs::read_to_string(&path)?; - let config: ApiKeyConfig = serde_json::from_str(&content)?; - Ok(config) - } else { - Ok(Self::new()) - } - } - - pub fn save(&self) -> Result<()> { - let path = Self::config_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let content = serde_json::to_string_pretty(self)?; - fs::write(&path, content)?; - Ok(()) - } - - pub fn set_api_key(&mut self, provider: String, api_key: String) { - self.api_keys.insert(provider, api_key); - } - - pub fn get_api_key(&self, provider: &str) -> Option<&String> { - self.api_keys.get(provider) - } - - pub fn list_providers(&self) -> Vec { - let mut providers: Vec = self.api_keys.keys().cloned().collect(); - providers.sort(); - providers - } - - fn config_path() -> PathBuf { - if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { - PathBuf::from("/tmp/crabcode_test_api_keys.json") - } else { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("crabcode") - .join("api_keys.json") - } - } - - #[cfg(test)] - pub fn load_test() -> Result { - let path = PathBuf::from("/tmp/crabcode_test_api_keys.json"); - if path.exists() { - let content = fs::read_to_string(&path)?; - let config: ApiKeyConfig = serde_json::from_str(&content)?; - Ok(config) - } else { - Ok(Self::new()) - } - } - - #[cfg(test)] - pub fn save_test(&self) -> Result<()> { - let path = PathBuf::from("/tmp/crabcode_test_api_keys.json"); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let content = serde_json::to_string_pretty(self)?; - fs::write(&path, content)?; - Ok(()) - } - - #[cfg(test)] - pub fn cleanup_test() -> Result<()> { - let path = PathBuf::from("/tmp/crabcode_test_api_keys.json"); - if path.exists() { - fs::remove_file(&path)?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_api_key_config_new() { - let config = ApiKeyConfig::new(); - assert!(config.api_keys.is_empty()); - } - - #[test] - fn test_api_key_config_default() { - let config = ApiKeyConfig::default(); - assert!(config.api_keys.is_empty()); - } - - #[test] - fn test_set_api_key() { - let mut config = ApiKeyConfig::new(); - config.set_api_key("nano-gpt".to_string(), "test-key-123".to_string()); - assert_eq!( - config.get_api_key("nano-gpt"), - Some(&"test-key-123".to_string()) - ); - } - - #[test] - fn test_get_api_key_nonexistent() { - let config = ApiKeyConfig::new(); - assert_eq!(config.get_api_key("nonexistent"), None); - } - - #[test] - fn test_list_providers_empty() { - let config = ApiKeyConfig::new(); - assert!(config.list_providers().is_empty()); - } - - #[test] - fn test_list_providers() { - let mut config = ApiKeyConfig::new(); - config.set_api_key("z-ai".to_string(), "key1".to_string()); - config.set_api_key("nano-gpt".to_string(), "key2".to_string()); - let providers = config.list_providers(); - assert_eq!(providers.len(), 2); - assert!(providers.contains(&"nano-gpt".to_string())); - assert!(providers.contains(&"z-ai".to_string())); - } - - #[test] - fn test_list_providers_sorted() { - let mut config = ApiKeyConfig::new(); - config.set_api_key("z-ai".to_string(), "key1".to_string()); - config.set_api_key("nano-gpt".to_string(), "key2".to_string()); - let providers = config.list_providers(); - assert_eq!(providers[0], "nano-gpt"); - assert_eq!(providers[1], "z-ai"); - } - - #[test] - fn test_save_and_load_test() -> Result<()> { - ApiKeyConfig::cleanup_test()?; - - let mut config = ApiKeyConfig::new(); - config.set_api_key("nano-gpt".to_string(), "test-key".to_string()); - config.save_test()?; - - let loaded = ApiKeyConfig::load_test()?; - assert_eq!( - loaded.get_api_key("nano-gpt"), - Some(&"test-key".to_string()) - ); - - ApiKeyConfig::cleanup_test()?; - Ok(()) - } - - #[test] - fn test_serialization() { - let mut config = ApiKeyConfig::new(); - config.set_api_key("nano-gpt".to_string(), "test-key".to_string()); - - let serialized = serde_json::to_string(&config).unwrap(); - let deserialized: ApiKeyConfig = serde_json::from_str(&serialized).unwrap(); - - assert_eq!( - deserialized.get_api_key("nano-gpt"), - Some(&"test-key".to_string()) - ); - } -} diff --git a/src/config/configuration.rs b/src/config/configuration.rs new file mode 100644 index 0000000..1b6a7d0 --- /dev/null +++ b/src/config/configuration.rs @@ -0,0 +1,853 @@ +use anyhow::{anyhow, Context, Result}; +use regex::Regex; +use serde_json::Value; +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn discover_themes( + xdg_config_home: &Path, + project_root: &Path, + cwd: &Path, + selected_theme_id: Option<&str>, +) -> (Vec, usize) { + let mut theme_by_id: HashMap = HashMap::new(); + let mut themes: Vec = Vec::new(); + + let mut layers: Vec> = Vec::new(); + + let mut built_in = Vec::new(); + if PathBuf::from("src/theme.json").is_file() { + built_in.push(PathBuf::from("src/theme.json")); + } + built_in.extend(list_json_files(Path::new("src/themes"))); + built_in.extend(list_json_files(Path::new("src/generated_themes"))); + layers.push(built_in); + + layers.push(list_json_files( + &xdg_config_home.join("opencode").join("themes"), + )); + layers.push(list_json_files( + &xdg_config_home.join("crabcode").join("themes"), + )); + layers.push(list_json_files( + &project_root.join(".opencode").join("themes"), + )); + layers.push(list_json_files( + &project_root.join(".crabcode").join("themes"), + )); + if cwd != project_root { + layers.push(list_json_files(&cwd.join(".opencode").join("themes"))); + } + + for files in layers { + for path in files { + let Ok(theme) = crate::theme::Theme::load_from_file(&path) else { + continue; + }; + if let Some(idx) = theme_by_id.get(&theme.id).copied() { + themes[idx] = theme; + } else { + let idx = themes.len(); + theme_by_id.insert(theme.id.clone(), idx); + themes.push(theme); + } + } + } + + if themes.is_empty() { + let fallback = crate::theme::Theme::load_from_file("src/theme.json").unwrap_or_else(|_| { + crate::theme::Theme::load_from_file("src/generated_themes/ayu.json").unwrap() + }); + themes.push(fallback); + } + + let mut selected_idx = 0usize; + if let Some(id) = selected_theme_id { + if let Some((idx, _)) = themes.iter().enumerate().find(|(_, t)| t.id == id) { + selected_idx = idx; + } + } + + (themes, selected_idx) +} + +fn list_json_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let rd = match fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return out, + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension().and_then(|s| s.to_str()) { + if ext.eq_ignore_ascii_case("json") { + out.push(path); + } + } + } + } + out.sort(); + out +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SourceKind { + OpenCode, + Crabcode, +} + +#[derive(Debug, Clone)] +struct SourceFile { + label: &'static str, + kind: SourceKind, + path: PathBuf, +} + +#[derive(Debug, Clone, Default)] +pub struct ConfigDiagnostics { + pub warnings: Vec, + pub info: Vec, + pub unimplemented_keys: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ConfigInventory { + pub opencode_agents: Vec, + pub opencode_skills_dirs: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct SoundEffectConfig { + pub file: Option, + pub enabled: bool, +} + +impl SoundEffectConfig { + pub fn is_effectively_enabled(&self) -> bool { + self.enabled && self.file.is_some() + } +} + +#[derive(Debug, Clone)] +pub struct SoundsConfig { + pub error: SoundEffectConfig, + pub complete: SoundEffectConfig, + pub permission: SoundEffectConfig, + pub question: SoundEffectConfig, +} + +impl Default for SoundsConfig { + fn default() -> Self { + Self { + error: SoundEffectConfig { + file: None, + enabled: false, + }, + complete: SoundEffectConfig { + file: None, + enabled: true, + }, + permission: SoundEffectConfig { + file: None, + enabled: false, + }, + question: SoundEffectConfig { + file: None, + enabled: false, + }, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct MergedConfig { + pub theme: Option, + pub model: Option, + pub sounds: SoundsConfig, +} + +#[derive(Debug, Clone)] +pub struct LoadedConfig { + pub merged_config: MergedConfig, + pub raw_merged: Value, + pub diagnostics: ConfigDiagnostics, + pub inventory: ConfigInventory, + pub project_root: PathBuf, + pub cwd: PathBuf, + pub xdg_config_home: PathBuf, +} + +pub struct ConfigLoader; + +impl ConfigLoader { + pub fn load() -> Result { + let cwd = std::env::current_dir().context("Failed to determine current directory")?; + let xdg_config_home = xdg_config_home(); + let project_root = discover_project_root(&cwd); + + let mut diagnostics = ConfigDiagnostics::default(); + let mut inventory = ConfigInventory::default(); + + discover_opencode_inventory( + &xdg_config_home, + &project_root, + &mut inventory, + &mut diagnostics, + ); + + let sources = resolve_sources(&xdg_config_home, &project_root)?; + + let mut merged: Value = Value::Object(serde_json::Map::new()); + let mut provenance: HashMap = HashMap::new(); + provenance.insert("".to_string(), cwd.clone()); + + for source in sources { + let parsed = match load_config_value(&source.path) { + Ok(v) => v, + Err(e) => { + diagnostics.warnings.push(format!( + "Failed to parse {} config at {}: {}", + source.label, + source.path.display(), + e + )); + continue; + } + }; + + let filtered = filter_top_level(parsed, source.kind); + let base_dir = source + .path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| cwd.clone()); + deep_merge_with_provenance( + &mut merged, + &filtered, + "".to_string(), + &base_dir, + &mut provenance, + ); + } + + substitute_placeholders(&mut merged, &provenance, &mut diagnostics); + + let merged_config = parse_merged_config(&merged, &mut diagnostics); + diagnostics.unimplemented_keys = collect_unimplemented_keys(&merged); + + Ok(LoadedConfig { + merged_config, + raw_merged: merged, + diagnostics, + inventory, + project_root, + cwd, + xdg_config_home, + }) + } +} + +fn xdg_config_home() -> PathBuf { + if let Ok(val) = std::env::var("XDG_CONFIG_HOME") { + if !val.trim().is_empty() { + return PathBuf::from(val); + } + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") +} + +fn discover_project_root(cwd: &Path) -> PathBuf { + let mut current = cwd.to_path_buf(); + let mut saw_git = false; + loop { + if current.join(".git").is_dir() { + saw_git = true; + break; + } + let parent = match current.parent() { + Some(p) => p.to_path_buf(), + None => break, + }; + if parent == current { + break; + } + current = parent; + } + + if saw_git { + current + } else { + cwd.to_path_buf() + } +} + +fn discover_opencode_inventory( + xdg_config_home: &Path, + project_root: &Path, + inventory: &mut ConfigInventory, + diagnostics: &mut ConfigDiagnostics, +) { + let global_opencode = xdg_config_home.join("opencode"); + let local_opencode = project_root.join(".opencode"); + + let mut agents = Vec::new(); + agents.extend(list_md_files(&global_opencode.join("agents"))); + agents.extend(list_md_files(&global_opencode.join("agent"))); + agents.extend(list_md_files(&local_opencode.join("agents"))); + agents.extend(list_md_files(&local_opencode.join("agent"))); + agents.sort(); + agents.dedup(); + + if !agents.is_empty() { + diagnostics + .info + .push(format!("Discovered {} OpenCode agent files", agents.len())); + } + inventory.opencode_agents = agents; + + let mut skills_dirs = Vec::new(); + for dir in [ + global_opencode.join("skills"), + global_opencode.join("skill"), + local_opencode.join("skills"), + local_opencode.join("skill"), + ] { + if dir.is_dir() { + skills_dirs.push(dir); + } + } + skills_dirs.sort(); + skills_dirs.dedup(); + + if !skills_dirs.is_empty() { + diagnostics.info.push(format!( + "Discovered {} OpenCode skills dirs", + skills_dirs.len() + )); + } + inventory.opencode_skills_dirs = skills_dirs; +} + +fn list_md_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let rd = match fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return out, + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension().and_then(|s| s.to_str()) { + if ext.eq_ignore_ascii_case("md") { + out.push(path); + } + } + } + } + out +} + +fn resolve_sources(xdg_config_home: &Path, project_root: &Path) -> Result> { + let mut out = Vec::new(); + + let opencode_global = resolve_single_layer( + "OpenCode global", + SourceKind::OpenCode, + &[ + xdg_config_home.join("opencode").join("opencode.jsonc"), + xdg_config_home.join("opencode").join("opencode.json"), + xdg_config_home.join("opencode.jsonc"), + xdg_config_home.join("opencode.json"), + ], + )?; + if let Some(path) = opencode_global { + out.push(SourceFile { + label: "OpenCode global", + kind: SourceKind::OpenCode, + path, + }); + } + + let crabcode_global = resolve_single_layer( + "Crabcode global", + SourceKind::Crabcode, + &[ + xdg_config_home.join("crabcode").join("crabcode.jsonc"), + xdg_config_home.join("crabcode").join("crabcode.json"), + xdg_config_home.join("crabcode.jsonc"), + xdg_config_home.join("crabcode.json"), + ], + )?; + if let Some(path) = crabcode_global { + out.push(SourceFile { + label: "Crabcode global", + kind: SourceKind::Crabcode, + path, + }); + } + + let opencode_local = resolve_single_layer( + "OpenCode local", + SourceKind::OpenCode, + &[ + project_root.join(".opencode").join("opencode.jsonc"), + project_root.join(".opencode").join("opencode.json"), + project_root.join("opencode.jsonc"), + project_root.join("opencode.json"), + ], + )?; + if let Some(path) = opencode_local { + out.push(SourceFile { + label: "OpenCode local", + kind: SourceKind::OpenCode, + path, + }); + } + + let crabcode_local = resolve_single_layer( + "Crabcode local", + SourceKind::Crabcode, + &[ + project_root.join(".crabcode").join("crabcode.jsonc"), + project_root.join(".crabcode").join("crabcode.json"), + project_root.join(".opencode").join("crabcode.jsonc"), + project_root.join(".opencode").join("crabcode.json"), + project_root.join("crabcode.jsonc"), + project_root.join("crabcode.json"), + ], + )?; + if let Some(path) = crabcode_local { + out.push(SourceFile { + label: "Crabcode local", + kind: SourceKind::Crabcode, + path, + }); + } + + Ok(out) +} + +fn resolve_single_layer( + label: &'static str, + _kind: SourceKind, + candidates: &[PathBuf], +) -> Result> { + let existing: Vec = candidates.iter().filter(|p| p.is_file()).cloned().collect(); + if existing.len() > 1 { + let mut msg = format!( + "Multiple config files found for {}. Keep only one:\n", + label + ); + for p in existing { + msg.push_str(&format!("- {}\n", p.display())); + } + return Err(anyhow!(msg)); + } + Ok(existing.into_iter().next()) +} + +fn load_config_value(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read config file {}", path.display()))?; + + match path.extension().and_then(|s| s.to_str()) { + Some(ext) if ext.eq_ignore_ascii_case("jsonc") => { + let v: Value = json5::from_str(&content) + .with_context(|| format!("Invalid JSONC in {}", path.display()))?; + Ok(v) + } + _ => { + let v: Value = serde_json::from_str(&content) + .with_context(|| format!("Invalid JSON in {}", path.display()))?; + Ok(v) + } + } +} + +fn filter_top_level(value: Value, kind: SourceKind) -> Value { + let mut map = match value { + Value::Object(m) => m, + _ => return Value::Object(serde_json::Map::new()), + }; + + let allow: BTreeSet<&'static str> = match kind { + SourceKind::OpenCode => opencode_allowed_keys(), + SourceKind::Crabcode => crabcode_allowed_keys(), + }; + + let ignore: BTreeSet<&'static str> = match kind { + SourceKind::OpenCode => opencode_ignored_keys(), + SourceKind::Crabcode => BTreeSet::new(), + }; + + map.retain(|k, _| { + let k = k.as_str(); + if ignore.contains(k) { + return false; + } + allow.contains(k) + }); + + Value::Object(map) +} + +fn opencode_allowed_keys() -> BTreeSet<&'static str> { + [ + "$schema", + "agent", + "instructions", + "tools", + "mcp", + "model", + "provider", + "command", + "permission", + "compaction", + "watcher", + "default_agent", + "formatter", + "disabled_providers", + "enabled_providers", + ] + .into_iter() + .collect() +} + +fn crabcode_allowed_keys() -> BTreeSet<&'static str> { + let mut out = opencode_allowed_keys(); + out.insert("theme"); + out.insert("sounds"); + out +} + +fn opencode_ignored_keys() -> BTreeSet<&'static str> { + [ + "keybinds", + "theme", + "share", + "tui", + "server", + "plugin", + "tool", + "custom tools", + "custom_tools", + "customTools", + "sounds", + ] + .into_iter() + .collect() +} + +fn deep_merge_with_provenance( + base: &mut Value, + overlay: &Value, + pointer: String, + overlay_base_dir: &Path, + provenance: &mut HashMap, +) { + match (base, overlay) { + (Value::Object(base_map), Value::Object(overlay_map)) => { + for (k, overlay_v) in overlay_map { + let child_ptr = format!("{}/{}", pointer, escape_json_pointer(k)); + if overlay_v.is_null() { + base_map.remove(k); + remove_provenance_subtree(provenance, &child_ptr); + continue; + } + match base_map.get_mut(k) { + Some(base_v) => { + if base_v.is_object() && overlay_v.is_object() { + deep_merge_with_provenance( + base_v, + overlay_v, + child_ptr, + overlay_base_dir, + provenance, + ); + } else { + *base_v = overlay_v.clone(); + set_provenance_for_subtree( + base_v, + &child_ptr, + overlay_base_dir, + provenance, + ); + } + } + None => { + base_map.insert(k.clone(), overlay_v.clone()); + if let Some(v) = base_map.get(k) { + set_provenance_for_subtree(v, &child_ptr, overlay_base_dir, provenance); + } + } + } + } + } + (base_slot, overlay_v) => { + if overlay_v.is_null() { + *base_slot = Value::Object(serde_json::Map::new()); + remove_provenance_subtree(provenance, &pointer); + return; + } + *base_slot = overlay_v.clone(); + set_provenance_for_subtree(base_slot, &pointer, overlay_base_dir, provenance); + } + } +} + +fn remove_provenance_subtree(provenance: &mut HashMap, pointer: &str) { + let keys: Vec = provenance + .keys() + .filter(|k| k == &pointer || k.starts_with(&(pointer.to_string() + "/"))) + .cloned() + .collect(); + for k in keys { + provenance.remove(&k); + } +} + +fn set_provenance_for_subtree( + value: &Value, + pointer: &str, + overlay_base_dir: &Path, + provenance: &mut HashMap, +) { + remove_provenance_subtree(provenance, pointer); + provenance.insert(pointer.to_string(), overlay_base_dir.to_path_buf()); + + if matches!(value, Value::Object(_) | Value::Array(_)) { + // Child pointers are resolved by nearest ancestor, so we don't need to enumerate. + } +} + +fn escape_json_pointer(s: &str) -> String { + s.replace('~', "~0").replace('/', "~1") +} + +fn substitute_placeholders( + value: &mut Value, + provenance: &HashMap, + diagnostics: &mut ConfigDiagnostics, +) { + let re = Regex::new(r"\{(env|file):([^}]+)\}").unwrap(); + substitute_placeholders_inner(value, "".to_string(), provenance, diagnostics, &re); +} + +fn substitute_placeholders_inner( + value: &mut Value, + pointer: String, + provenance: &HashMap, + diagnostics: &mut ConfigDiagnostics, + re: &Regex, +) { + match value { + Value::Object(map) => { + for (k, v) in map.iter_mut() { + let child_ptr = format!("{}/{}", pointer, escape_json_pointer(k)); + substitute_placeholders_inner(v, child_ptr, provenance, diagnostics, re); + } + } + Value::Array(arr) => { + for (idx, v) in arr.iter_mut().enumerate() { + let child_ptr = format!("{}/{}", pointer, idx); + substitute_placeholders_inner(v, child_ptr, provenance, diagnostics, re); + } + } + Value::String(s) => { + let base_dir = find_base_dir_for_pointer(provenance, &pointer); + let replaced = replace_in_string(s, &base_dir, diagnostics, re); + *s = replaced; + } + _ => {} + } +} + +fn find_base_dir_for_pointer(provenance: &HashMap, pointer: &str) -> PathBuf { + let mut cur = pointer.to_string(); + loop { + if let Some(p) = provenance.get(&cur) { + return p.clone(); + } + if cur.is_empty() { + return PathBuf::from("."); + } + if let Some((parent, _)) = cur.rsplit_once('/') { + cur = parent.to_string(); + } else { + cur.clear(); + } + } +} + +fn replace_in_string( + s: &str, + base_dir: &Path, + diagnostics: &mut ConfigDiagnostics, + re: &Regex, +) -> String { + re.replace_all(s, |caps: ®ex::Captures<'_>| { + let kind = &caps[1]; + let arg = caps[2].trim(); + match kind { + "env" => std::env::var(arg).unwrap_or_default(), + "file" => { + let path = expand_path(arg, base_dir); + match fs::read_to_string(&path) { + Ok(content) => trim_trailing_newlines(&content), + Err(e) => { + diagnostics.warnings.push(format!( + "Failed to read file for placeholder {{file:{}}} at {}: {}", + arg, + path.display(), + e + )); + String::new() + } + } + } + _ => String::new(), + } + }) + .to_string() +} + +fn trim_trailing_newlines(s: &str) -> String { + s.trim_end_matches(['\n', '\r']).to_string() +} + +fn expand_path(arg: &str, base_dir: &Path) -> PathBuf { + let arg = arg.trim(); + if let Some(rest) = arg.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + if arg == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } + let p = PathBuf::from(arg); + if p.is_absolute() { + p + } else { + base_dir.join(p) + } +} + +fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> MergedConfig { + let mut out = MergedConfig::default(); + let obj = match merged.as_object() { + Some(o) => o, + None => return out, + }; + + if let Some(Value::String(theme)) = obj.get("theme") { + if !theme.trim().is_empty() { + out.theme = Some(theme.trim().to_string()); + } + } + + if let Some(Value::String(model)) = obj.get("model") { + if !model.trim().is_empty() { + out.model = Some(model.trim().to_string()); + } + } + + out.sounds = parse_sounds(obj.get("sounds"), diagnostics); + + out +} + +fn parse_sounds(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> SoundsConfig { + let mut sounds = SoundsConfig::default(); + let Some(Value::Object(map)) = value else { + return sounds; + }; + + apply_sound_event( + &mut sounds.error, + map.get("error"), + "sounds.error", + diagnostics, + ); + apply_sound_event( + &mut sounds.complete, + map.get("complete"), + "sounds.complete", + diagnostics, + ); + apply_sound_event( + &mut sounds.permission, + map.get("permission"), + "sounds.permission", + diagnostics, + ); + apply_sound_event( + &mut sounds.question, + map.get("question"), + "sounds.question", + diagnostics, + ); + + sounds +} + +fn apply_sound_event( + target: &mut SoundEffectConfig, + value: Option<&Value>, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) { + let Some(Value::Object(map)) = value else { + return; + }; + + if let Some(Value::Bool(enabled)) = map.get("enabled") { + target.enabled = *enabled; + } + + if let Some(Value::String(file)) = map.get("file") { + let p = PathBuf::from(file); + if p.is_absolute() { + target.file = Some(p); + } else { + diagnostics.warnings.push(format!( + "{}: sound file must be an absolute path; treating as disabled", + key + )); + target.file = None; + target.enabled = false; + } + } + + if target.file.is_none() { + target.enabled = false; + } +} + +fn collect_unimplemented_keys(merged: &Value) -> Vec { + let Some(obj) = merged.as_object() else { + return Vec::new(); + }; + + let supported: BTreeSet<&'static str> = crabcode_allowed_keys(); + let implemented: BTreeSet<&'static str> = ["theme", "model", "sounds"].into_iter().collect(); + + let mut keys = Vec::new(); + for k in obj.keys() { + let ks = k.as_str(); + if ks == "$schema" { + continue; + } + if supported.contains(ks) && !implemented.contains(ks) { + keys.push(ks.to_string()); + } + } + keys.sort(); + keys +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..d50e810 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,8 @@ +pub mod configuration; + +pub use configuration::{ + ConfigDiagnostics, ConfigInventory, ConfigLoader, LoadedConfig, MergedConfig, + SoundEffectConfig, SoundsConfig, +}; + +pub use configuration::discover_themes; diff --git a/src/main.rs b/src/main.rs index 161eefb..877016c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod model; mod persistence; mod prompt; mod session; +mod sound; mod streaming; mod theme; mod tools; @@ -61,7 +62,7 @@ struct Args {} #[tokio::main] async fn main() -> Result<()> { let _args = Args::parse(); - let mut app = App::new(); + let mut app = App::new()?; enable_raw_mode()?; let mut stdout = io::stdout(); diff --git a/src/model/discovery.rs b/src/model/discovery.rs index b9edeb8..9bf9071 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -209,6 +209,39 @@ impl Discovery { return Ok(cached); } + // In test mode, avoid hard network dependency so unit tests are reliable. + if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { + match self.fetch_from_api().await { + Ok(providers) => { + let _ = self.save_to_cache(&providers); + return Ok(providers); + } + Err(_) => { + let mut providers: HashMap = HashMap::new(); + for (id, name) in [ + ("opencode", "OpenCode"), + ("anthropic", "Anthropic"), + ("openai", "OpenAI"), + ("google", "Google"), + ] { + providers.insert( + id.to_string(), + Provider { + id: id.to_string(), + name: name.to_string(), + api: String::new(), + doc: String::new(), + env: Vec::new(), + npm: String::new(), + models: HashMap::new(), + }, + ); + } + return Ok(providers); + } + } + } + let providers = self.fetch_from_api().await?; self.save_to_cache(&providers)?; diff --git a/src/persistence/auth.rs b/src/persistence/auth.rs index e627654..df80a07 100644 --- a/src/persistence/auth.rs +++ b/src/persistence/auth.rs @@ -1,9 +1,10 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::env; use std::path::PathBuf; -use super::{ensure_data_dir, get_data_dir}; +use super::get_data_dir; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] @@ -24,14 +25,69 @@ pub struct AuthDAO { impl AuthDAO { pub fn new() -> Result { - let data_dir = get_data_dir(); - ensure_data_dir()?; - Ok(Self { - auth_path: data_dir.join("auth.json"), - }) + let auth_path = Self::auth_path(); + if let Some(parent) = auth_path.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(Self { auth_path }) + } + + fn auth_path() -> PathBuf { + if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { + PathBuf::from("/tmp/crabcode_test_data").join("auth.json") + } else { + let data_dir = get_data_dir(); + data_dir.join("auth.json") + } + } + + fn legacy_api_keys_path() -> PathBuf { + if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { + PathBuf::from("/tmp/crabcode_test_api_keys.json") + } else { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("crabcode") + .join("api_keys.json") + } + } + + fn try_migrate_legacy_api_keys(&self) -> Result<()> { + if self.auth_path.exists() { + return Ok(()); + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + struct LegacyApiKeyConfig { + api_keys: HashMap, + } + + let legacy_path = Self::legacy_api_keys_path(); + if !legacy_path.exists() { + return Ok(()); + } + + let content = std::fs::read_to_string(&legacy_path)?; + let legacy: LegacyApiKeyConfig = serde_json::from_str(&content)?; + if legacy.api_keys.is_empty() { + return Ok(()); + } + + let mut providers: HashMap = HashMap::new(); + for (name, key) in legacy.api_keys { + providers.insert(name, AuthConfig::Api { key }); + } + + self.save(&providers)?; + + // Best-effort cleanup: once migrated, avoid keeping two sources of truth. + let _ = std::fs::remove_file(&legacy_path); + Ok(()) } pub fn load(&self) -> Result> { + self.try_migrate_legacy_api_keys()?; + if !self.auth_path.exists() { return Ok(HashMap::new()); } @@ -40,6 +96,9 @@ impl AuthDAO { } pub fn save(&self, providers: &HashMap) -> Result<()> { + if let Some(parent) = self.auth_path.parent() { + std::fs::create_dir_all(parent)?; + } let content = serde_json::to_string_pretty(providers)?; std::fs::write(&self.auth_path, content)?; Ok(()) @@ -65,3 +124,20 @@ impl AuthDAO { })) } } + +#[cfg(test)] +impl AuthDAO { + pub fn cleanup_test() -> Result<()> { + let auth_path = Self::auth_path(); + if auth_path.exists() { + std::fs::remove_file(&auth_path)?; + } + + let legacy_path = Self::legacy_api_keys_path(); + if legacy_path.exists() { + std::fs::remove_file(&legacy_path)?; + } + + Ok(()) + } +} diff --git a/src/sound.rs b/src/sound.rs new file mode 100644 index 0000000..6e1e125 --- /dev/null +++ b/src/sound.rs @@ -0,0 +1,36 @@ +use std::path::Path; +use std::process::Command; + +#[derive(Debug, Clone, Copy)] +pub enum SoundEvent { + Error, + Complete, + Permission, + Question, +} + +pub fn play_file(path: &Path) { + if !path.is_file() { + return; + } + + #[cfg(target_os = "macos")] + { + let _ = Command::new("afplay").arg(path).spawn(); + return; + } + + #[cfg(target_os = "linux")] + { + if Command::new("paplay").arg(path).spawn().is_ok() { + return; + } + let _ = Command::new("aplay").arg(path).spawn(); + return; + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = path; + } +} From f896f2dca97a1907f20c3892902d2698a178eec0 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 1 Feb 2026 05:10:03 +0800 Subject: [PATCH 002/226] feat: added basic themes dialog, got my themes from officially the correct directory in opencode repo. --- AGENTS.md | 4 + scripts/gen-themes.ts | 59 +- src/app.rs | 117 +++- src/command/handlers.rs | 69 ++- src/generated_themes/aura.json | 192 +++---- src/generated_themes/ayu.json | 205 +++---- src/generated_themes/carbonfox.json | 360 ++++++++---- src/generated_themes/catppuccin-frappe.json | 233 ++++++++ .../catppuccin-macchiato.json | 233 ++++++++ src/generated_themes/catppuccin.json | 233 ++++---- src/generated_themes/cobalt2.json | 228 ++++++++ src/generated_themes/cursor.json | 249 ++++++++ src/generated_themes/deltarune.json | 231 -------- src/generated_themes/dracula.json | 340 ++++++----- src/generated_themes/everforest.json | 241 ++++++++ src/generated_themes/flexoki.json | 237 ++++++++ src/generated_themes/github.json | 233 ++++++++ src/generated_themes/gruvbox.json | 364 +++++++----- src/generated_themes/kanagawa.json | 77 +++ src/generated_themes/lucent-orng.json | 237 ++++++++ src/generated_themes/material.json | 235 ++++++++ src/generated_themes/matrix.json | 77 +++ src/generated_themes/mercury.json | 252 +++++++++ src/generated_themes/monokai.json | 342 ++++++----- src/generated_themes/nightowl.json | 342 ++++++----- src/generated_themes/nord.json | 344 ++++++----- src/generated_themes/oc-1.json | 535 ------------------ src/generated_themes/one-dark.json | 84 +++ src/generated_themes/onedarkpro.json | 131 ----- src/generated_themes/opencode.json | 245 ++++++++ src/generated_themes/orng.json | 249 ++++++++ src/generated_themes/osaka-jade.json | 93 +++ src/generated_themes/palenight.json | 222 ++++++++ src/generated_themes/rosepine.json | 234 ++++++++ src/generated_themes/shadesofpurple.json | 131 ----- src/generated_themes/solarized.json | 344 ++++++----- src/generated_themes/synthwave84.json | 226 ++++++++ src/generated_themes/tokyonight.json | 388 ++++++++----- src/generated_themes/undertale.json | 232 -------- src/generated_themes/vercel.json | 245 ++++++++ src/generated_themes/vesper.json | 339 ++++++----- src/generated_themes/zenburn.json | 223 ++++++++ src/theme.rs | 243 ++++++-- src/ui/components/dialog.rs | 44 +- src/ui/components/popup.rs | 5 +- src/views/mod.rs | 2 + src/views/themes_dialog.rs | 90 +++ src/views/which_key.rs | 19 +- 48 files changed, 7157 insertions(+), 2901 deletions(-) create mode 100644 src/generated_themes/catppuccin-frappe.json create mode 100644 src/generated_themes/catppuccin-macchiato.json create mode 100644 src/generated_themes/cobalt2.json create mode 100644 src/generated_themes/cursor.json delete mode 100644 src/generated_themes/deltarune.json create mode 100644 src/generated_themes/everforest.json create mode 100644 src/generated_themes/flexoki.json create mode 100644 src/generated_themes/github.json create mode 100644 src/generated_themes/kanagawa.json create mode 100644 src/generated_themes/lucent-orng.json create mode 100644 src/generated_themes/material.json create mode 100644 src/generated_themes/matrix.json create mode 100644 src/generated_themes/mercury.json delete mode 100644 src/generated_themes/oc-1.json create mode 100644 src/generated_themes/one-dark.json delete mode 100644 src/generated_themes/onedarkpro.json create mode 100644 src/generated_themes/opencode.json create mode 100644 src/generated_themes/orng.json create mode 100644 src/generated_themes/osaka-jade.json create mode 100644 src/generated_themes/palenight.json create mode 100644 src/generated_themes/rosepine.json delete mode 100644 src/generated_themes/shadesofpurple.json create mode 100644 src/generated_themes/synthwave84.json delete mode 100644 src/generated_themes/undertale.json create mode 100644 src/generated_themes/vercel.json create mode 100644 src/generated_themes/zenburn.json create mode 100644 src/views/themes_dialog.rs diff --git a/AGENTS.md b/AGENTS.md index 45754c1..de7edda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ This file contains important information about the codebase that the AI agent should be aware of. +## Common Project Commands + +Before adding/changing scripts, make sure to check `justfile` for existing recipes (this repo uses `just` and typically runs scripts via `bun`). + ## File Locations ### SQLite Database diff --git a/scripts/gen-themes.ts b/scripts/gen-themes.ts index db769a0..ce03d17 100644 --- a/scripts/gen-themes.ts +++ b/scripts/gen-themes.ts @@ -1,43 +1,56 @@ -// @ts-nocheck +// Generate Crabcode's built-in theme set from OpenCode's TUI themes. +// Run via: `bun run scripts/gen-themes.ts` -import { writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; +// @ts-nocheck -const GITHUB_API_URL = 'https://api.github.com/repos/anomalyco/opencode/contents/packages/ui/src/theme/themes'; -const THEMES_DIR = join(process.cwd(), 'src', 'themes'); +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' -interface GitHubFile { - name: string; - download_url: string; +type GitHubFile = { + name: string + download_url?: string } +const OPENCODE_REF = process.env.OPENCODE_REF ?? 'production' +const GITHUB_API_URL = `https://api.github.com/repos/anomalyco/opencode/contents/packages/opencode/src/cli/cmd/tui/context/theme?ref=${encodeURIComponent( + OPENCODE_REF, +)}` +const THEMES_DIR = join(process.cwd(), 'src', 'generated_themes') + async function fetchThemes() { - const response = await fetch(GITHUB_API_URL); + const response = await fetch(GITHUB_API_URL) if (!response.ok) { - throw new Error(`Failed to fetch themes: ${response.statusText}`); + throw new Error(`Failed to fetch themes: ${response.status} ${response.statusText}`) } - const files: GitHubFile[] = await response.json(); + const files = (await response.json()) as GitHubFile[] - mkdirSync(THEMES_DIR, { recursive: true }); + rmSync(THEMES_DIR, { recursive: true, force: true }) + mkdirSync(THEMES_DIR, { recursive: true }) for (const file of files) { - if (!file.name.endsWith('.json')) continue; + if (!file?.name?.endsWith('.json')) continue + if (!file.download_url) continue - console.log(`Fetching ${file.name}...`); - const themeResponse = await fetch(file.download_url); + console.log(`Fetching ${file.name}...`) + const themeResponse = await fetch(file.download_url) if (!themeResponse.ok) { - console.error(`Failed to fetch ${file.name}: ${themeResponse.statusText}`); - continue; + console.error( + `Failed to fetch ${file.name}: ${themeResponse.status} ${themeResponse.statusText}`, + ) + continue } - const themeContent = await themeResponse.text(); - const themePath = join(THEMES_DIR, file.name); - writeFileSync(themePath, themeContent); - console.log(`Saved ${file.name}`); + const themeContent = await themeResponse.text() + const themePath = join(THEMES_DIR, file.name) + writeFileSync(themePath, themeContent) + console.log(`Saved ${file.name}`) } - console.log(`\nDone! Themes saved to ${THEMES_DIR}`); + console.log(`\nDone! Themes saved to ${THEMES_DIR}`) } -fetchThemes().catch(console.error); +fetchThemes().catch((err) => { + console.error(err) + process.exitCode = 1 +}) diff --git a/src/app.rs b/src/app.rs index 1bcbecc..d867fe4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,6 +23,10 @@ use crate::views::models_dialog::{ handle_models_dialog_key_event, handle_models_dialog_mouse_event, init_models_dialog, render_models_dialog, }; +use crate::views::themes_dialog::{ + handle_themes_dialog_key_event, handle_themes_dialog_mouse_event, init_themes_dialog, + render_themes_dialog, +}; use crate::views::session_rename_dialog::{ handle_session_rename_dialog_key_event, init_session_rename_dialog, render_session_rename_dialog, RenameAction, @@ -37,7 +41,7 @@ use crate::views::suggestions_popup::{ }; use crate::views::{ ChatState, ConnectDialogState, HomeState, ModelsDialogState, SessionRenameDialogState, - SessionsDialogState, SuggestionsPopupState, + SessionsDialogState, SuggestionsPopupState, ThemesDialogState, }; use crate::{ @@ -69,6 +73,7 @@ pub enum BaseFocus { pub enum OverlayFocus { None, ModelsDialog, + ThemesDialog, ConnectDialog, ApiKeyInput, SuggestionsPopup, @@ -87,6 +92,7 @@ pub struct App { pub chat_state: ChatState, pub suggestions_popup_state: SuggestionsPopupState, pub models_dialog_state: ModelsDialogState, + pub themes_dialog_state: ThemesDialogState, pub connect_dialog_state: ConnectDialogState, pub sessions_dialog_state: SessionsDialogState, pub session_rename_dialog_state: SessionRenameDialogState, @@ -140,6 +146,7 @@ impl App { let chat_state = init_chat(Chat::new(), &agent); let suggestions_popup_state = init_suggestions_popup(Popup::new()); let models_dialog_state = init_models_dialog("Models", vec![]); + let themes_dialog_state = init_themes_dialog("Themes", vec![]); let connect_dialog_state = init_connect_dialog(); let sessions_dialog_state = init_sessions_dialog("Sessions", vec![]); let which_key_state = crate::views::which_key::init_which_key(); @@ -232,6 +239,7 @@ impl App { chat_state, suggestions_popup_state, models_dialog_state, + themes_dialog_state, connect_dialog_state, sessions_dialog_state, session_rename_dialog_state, @@ -436,6 +444,31 @@ impl App { } true } + OverlayFocus::ThemesDialog => { + let action = + handle_themes_dialog_key_event(&mut self.themes_dialog_state, key); + + match action { + crate::views::themes_dialog::ThemesDialogAction::SelectTheme { theme_id } => { + if let Some((idx, theme)) = + self.themes.iter().enumerate().find(|(_, t)| t.id == theme_id) + { + self.current_theme_index = idx; + push_toast(ratatui_toolkit::Toast::new( + format!("Theme: {}", theme.id), + ratatui_toolkit::ToastLevel::Info, + None, + )); + } + } + crate::views::themes_dialog::ThemesDialogAction::None => {} + } + + if !self.themes_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + true + } OverlayFocus::ConnectDialog => { if handle_connect_dialog_key_event(&mut self.connect_dialog_state, key) { return; @@ -552,6 +585,13 @@ impl App { rt.block_on(self.process_input("/models")); }); } + crate::views::which_key::WhichKeyAction::ShowThemes => { + self.overlay_focus = OverlayFocus::None; + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_input("/themes")); + }); + } crate::views::which_key::WhichKeyAction::ShowSessions => { self.overlay_focus = OverlayFocus::None; tokio::task::block_in_place(|| { @@ -714,6 +754,8 @@ impl App { pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { if self.overlay_focus == OverlayFocus::ModelsDialog { handle_models_dialog_mouse_event(&mut self.models_dialog_state, mouse); + } else if self.overlay_focus == OverlayFocus::ThemesDialog { + handle_themes_dialog_mouse_event(&mut self.themes_dialog_state, mouse); } else if self.overlay_focus == OverlayFocus::ConnectDialog { handle_connect_dialog_mouse_event(&mut self.connect_dialog_state, mouse); } else if self.overlay_focus == OverlayFocus::SessionsDialog { @@ -793,6 +835,20 @@ impl App { ); self.models_dialog_state.dialog.selected_index = 0; } + (_, OverlayFocus::ThemesDialog) => { + self.themes_dialog_state + .dialog + .search_textarea + .insert_str(&text); + self.themes_dialog_state.dialog.set_search_query( + self.themes_dialog_state + .dialog + .search_textarea + .lines() + .join(""), + ); + self.themes_dialog_state.dialog.selected_index = 0; + } (_, OverlayFocus::ConnectDialog) => { self.connect_dialog_state .dialog @@ -859,6 +915,10 @@ impl App { match parse_input(input) { InputType::Command(mut parsed) => { + if parsed.name == "themes" { + self.show_themes_dialog(); + return; + } parsed.prefs_dao = self.prefs_dao.as_ref(); parsed.active_model_id = Some(self.model.clone()); @@ -976,6 +1036,10 @@ impl App { &mut self, mut parsed: crate::command::parser::ParsedCommand<'_>, ) { + if parsed.name == "themes" { + self.show_themes_dialog(); + return; + } parsed.prefs_dao = self.prefs_dao.as_ref(); parsed.active_model_id = Some(self.model.clone()); @@ -1311,6 +1375,49 @@ impl App { self.models_dialog_state.refresh_items(items); } + fn show_themes_dialog(&mut self) { + use crate::ui::components::dialog::DialogItem; + + let current_id = self + .themes + .get(self.current_theme_index) + .map(|t| t.id.clone()); + + let mut items: Vec = self + .themes + .iter() + .map(|t| { + let is_active = current_id.as_deref() == Some(t.id.as_str()); + DialogItem { + id: t.id.clone(), + name: t.id.clone(), + group: String::new(), + description: String::new(), + tip: if is_active { + Some("Active".to_string()) + } else { + None + }, + provider_id: String::new(), + } + }) + .collect(); + + items.sort_by(|a, b| a.id.cmp(&b.id)); + + let mut selected_index = 0usize; + if let Some(ref id) = current_id { + if let Some((idx, _)) = items.iter().enumerate().find(|(_, it)| &it.id == id) { + selected_index = idx; + } + } + + self.themes_dialog_state = init_themes_dialog("Themes", items); + self.themes_dialog_state.dialog.show(); + self.themes_dialog_state.dialog.selected_index = selected_index; + self.overlay_focus = OverlayFocus::ThemesDialog; + } + fn cleanup_streaming(&mut self) { self.chunk_sender = None; self.chunk_receiver = None; @@ -1690,6 +1797,7 @@ impl App { if is_suggestions_visible(&self.suggestions_popup_state) && self.overlay_focus != OverlayFocus::ModelsDialog + && self.overlay_focus != OverlayFocus::ThemesDialog { let main_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) @@ -1732,6 +1840,7 @@ impl App { if is_suggestions_visible(&self.suggestions_popup_state) && self.overlay_focus != OverlayFocus::ModelsDialog + && self.overlay_focus != OverlayFocus::ThemesDialog { let input_height = self.input.get_height(); let main_chunks = ratatui::layout::Layout::default() @@ -1765,6 +1874,12 @@ impl App { render_models_dialog(f, &mut self.models_dialog_state, size, colors); } + if self.overlay_focus == OverlayFocus::ThemesDialog + && self.themes_dialog_state.dialog.is_visible() + { + render_themes_dialog(f, &mut self.themes_dialog_state, size, colors); + } + if self.overlay_focus == OverlayFocus::ConnectDialog && self.connect_dialog_state.dialog.is_visible() { diff --git a/src/command/handlers.rs b/src/command/handlers.rs index a26c405..17319a4 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -94,19 +94,39 @@ pub fn handle_connect<'a>( Err(e) => return CommandResult::Error(format!("Failed to load providers: {}", e)), }; - let discovery = match crate::model::discovery::Discovery::new() { - Ok(d) => d, - Err(e) => { - return CommandResult::Error(format!( - "Failed to initialize provider discovery: {}", - e - )) + fn fallback_providers() -> std::collections::HashMap { + use crate::model::discovery::Provider; + use std::collections::HashMap; + + let mut out: HashMap = HashMap::new(); + for (id, name) in [ + ("opencode", "OpenCode"), + ("anthropic", "Anthropic"), + ("openai", "OpenAI"), + ("google", "Google"), + ] { + out.insert( + id.to_string(), + Provider { + id: id.to_string(), + name: name.to_string(), + api: String::new(), + doc: String::new(), + env: Vec::new(), + npm: String::new(), + models: HashMap::new(), + }, + ); } - }; + out + } - let providers_map = match discovery.fetch_providers().await { - Ok(p) => p, - Err(e) => return CommandResult::Error(format!("Failed to fetch providers: {}", e)), + let providers_map = match crate::model::discovery::Discovery::new() { + Ok(discovery) => match discovery.fetch_providers().await { + Ok(p) => p, + Err(_) => fallback_providers(), + }, + Err(_) => fallback_providers(), }; const POPULAR_PROVIDERS: &[&str] = &[ @@ -390,6 +410,24 @@ pub fn handle_models<'a>( }) } +pub fn handle_themes<'a>( + parsed: &'a ParsedCommand<'a>, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error( + "This command only opens the themes dialog. Usage: /themes".to_string(), + ); + } + + // The app intercepts /themes to show the dialog. + CommandResult::Success(String::new()) + }) +} + pub fn handle_refreshmodels<'a>( _parsed: &'a ParsedCommand<'a>, _sm: &'a mut SessionManager, @@ -472,6 +510,12 @@ pub fn register_all_commands(registry: &mut Registry) { handler: handle_models, }); + registry.register(Command { + name: "themes".to_string(), + description: "Choose a theme".to_string(), + handler: handle_themes, + }); + registry.register(Command { name: "refreshmodels".to_string(), description: "Refresh the models.dev cache".to_string(), @@ -754,12 +798,13 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 7); + assert_eq!(names.len(), 8); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); assert!(names.contains(&"connect".to_string())); assert!(names.contains(&"models".to_string())); + assert!(names.contains(&"themes".to_string())); assert!(names.contains(&"home".to_string())); assert!(names.contains(&"refreshmodels".to_string())); } diff --git a/src/generated_themes/aura.json b/src/generated_themes/aura.json index 874939f..e7798d5 100644 --- a/src/generated_themes/aura.json +++ b/src/generated_themes/aura.json @@ -1,131 +1,69 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Aura", - "id": "aura", - "light": { - "seeds": { - "neutral": "#f5f0ff", - "primary": "#a277ff", - "success": "#40bf7a", - "warning": "#d9a24a", - "error": "#d94f4f", - "info": "#5bb8d9", - "interactive": "#a277ff", - "diffAdd": "#b3e6cc", - "diffDelete": "#f5b3b3" - }, - "overrides": { - "background-base": "#f5f0ff", - "background-weak": "#efe8fc", - "background-strong": "#faf7ff", - "background-stronger": "#fdfcff", - "border-weak-base": "#e0d6f2", - "border-weak-hover": "#d5c9eb", - "border-weak-active": "#cbbee3", - "border-weak-selected": "#c0b3dc", - "border-weak-disabled": "#f9f6ff", - "border-weak-focus": "#c5b8df", - "border-base": "#b5a6d4", - "border-hover": "#aa99cc", - "border-active": "#9f8dc4", - "border-selected": "#9480bc", - "border-disabled": "#ede7f9", - "border-focus": "#a593c8", - "border-strong-base": "#8068a8", - "border-strong-hover": "#735a9c", - "border-strong-active": "#664d90", - "border-strong-selected": "#5a4184", - "border-strong-disabled": "#d4c8ed", - "border-strong-focus": "#6d5396", - "surface-diff-add-base": "#e8f5ed", - "surface-diff-delete-base": "#fae8e8", - "surface-diff-hidden-base": "#e8e4f5", - "text-base": "#2d2640", - "text-weak": "#5c5270", - "text-strong": "#15101f", - "syntax-string": "#40bf7a", - "syntax-primitive": "#d94f4f", - "syntax-property": "#a277ff", - "syntax-type": "#d9a24a", - "syntax-constant": "#5bb8d9", - "syntax-info": "#5bb8d9", - "markdown-heading": "#a277ff", - "markdown-text": "#2d2640", - "markdown-link": "#c17ac8", - "markdown-link-text": "#a277ff", - "markdown-code": "#40bf7a", - "markdown-block-quote": "#6d6d6d", - "markdown-emph": "#d9a24a", - "markdown-strong": "#a277ff", - "markdown-horizontal-rule": "#d4c8ed", - "markdown-list-item": "#a277ff", - "markdown-list-enumeration": "#a277ff", - "markdown-image": "#c17ac8", - "markdown-image-text": "#a277ff", - "markdown-code-block": "#5bb8d9" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#0f0f0f", + "darkBgPanel": "#15141b", + "darkBorder": "#2d2d2d", + "darkFgMuted": "#6d6d6d", + "darkFg": "#edecee", + "purple": "#a277ff", + "pink": "#f694ff", + "blue": "#82e2ff", + "red": "#ff6767", + "orange": "#ffca85", + "cyan": "#61ffca", + "green": "#9dff65" }, - "dark": { - "seeds": { - "neutral": "#15141b", - "primary": "#a277ff", - "success": "#61ffca", - "warning": "#ffca85", - "error": "#ff6767", - "info": "#82e2ff", - "interactive": "#a277ff", - "diffAdd": "#61ffca", - "diffDelete": "#ff6767" - }, - "overrides": { - "background-base": "#15141b", - "background-weak": "#1a1921", - "background-strong": "#121118", - "background-stronger": "#0f0e14", - "border-weak-base": "#2d2b38", - "border-weak-hover": "#332f42", - "border-weak-active": "#38354c", - "border-weak-selected": "#3e3a56", - "border-weak-disabled": "#1a1921", - "border-weak-focus": "#363350", - "border-base": "#433f5a", - "border-hover": "#4a4565", - "border-active": "#514c70", - "border-selected": "#58527b", - "border-disabled": "#1f1e28", - "border-focus": "#4e496c", - "border-strong-base": "#635c8a", - "border-strong-hover": "#6d6597", - "border-strong-active": "#776fa4", - "border-strong-selected": "#8179b1", - "border-strong-disabled": "#2a283a", - "border-strong-focus": "#716a9e", - "surface-diff-add-base": "#162620", - "surface-diff-delete-base": "#26161a", - "surface-diff-hidden-base": "#1e1d2a", - "text-base": "#edecee", - "text-weak": "#6d6d6d", - "text-strong": "#ffffff", - "syntax-string": "#61ffca", - "syntax-primitive": "#ff6767", - "syntax-property": "#a277ff", - "syntax-type": "#ffca85", - "syntax-constant": "#82e2ff", - "syntax-info": "#82e2ff", - "markdown-heading": "#a277ff", - "markdown-text": "#edecee", - "markdown-link": "#f694ff", - "markdown-link-text": "#a277ff", - "markdown-code": "#61ffca", - "markdown-block-quote": "#6d6d6d", - "markdown-emph": "#ffca85", - "markdown-strong": "#a277ff", - "markdown-horizontal-rule": "#2d2b38", - "markdown-list-item": "#a277ff", - "markdown-list-enumeration": "#a277ff", - "markdown-image": "#f694ff", - "markdown-image-text": "#a277ff", - "markdown-code-block": "#edecee" - } + "theme": { + "primary": "purple", + "secondary": "pink", + "accent": "purple", + "error": "red", + "warning": "orange", + "success": "cyan", + "info": "purple", + "text": "darkFg", + "textMuted": "darkFgMuted", + "background": "darkBg", + "backgroundPanel": "darkBgPanel", + "backgroundElement": "darkBgPanel", + "border": "darkBorder", + "borderActive": "darkFgMuted", + "borderSubtle": "darkBorder", + "diffAdded": "cyan", + "diffRemoved": "red", + "diffContext": "darkFgMuted", + "diffHunkHeader": "darkFgMuted", + "diffHighlightAdded": "cyan", + "diffHighlightRemoved": "red", + "diffAddedBg": "#354933", + "diffRemovedBg": "#3f191a", + "diffContextBg": "darkBgPanel", + "diffLineNumber": "darkBorder", + "diffAddedLineNumberBg": "#162620", + "diffRemovedLineNumberBg": "#26161a", + "markdownText": "darkFg", + "markdownHeading": "purple", + "markdownLink": "pink", + "markdownLinkText": "purple", + "markdownCode": "cyan", + "markdownBlockQuote": "darkFgMuted", + "markdownEmph": "orange", + "markdownStrong": "purple", + "markdownHorizontalRule": "darkFgMuted", + "markdownListItem": "purple", + "markdownListEnumeration": "purple", + "markdownImage": "pink", + "markdownImageText": "purple", + "markdownCodeBlock": "darkFg", + "syntaxComment": "darkFgMuted", + "syntaxKeyword": "pink", + "syntaxFunction": "purple", + "syntaxVariable": "purple", + "syntaxString": "cyan", + "syntaxNumber": "green", + "syntaxType": "purple", + "syntaxOperator": "pink", + "syntaxPunctuation": "darkFg" } } diff --git a/src/generated_themes/ayu.json b/src/generated_themes/ayu.json index eac9e04..a42fce4 100644 --- a/src/generated_themes/ayu.json +++ b/src/generated_themes/ayu.json @@ -1,133 +1,80 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Ayu", - "id": "ayu", - "light": { - "seeds": { - "neutral": "#fdfaf4", - "primary": "#4aa8c8", - "success": "#5fb978", - "warning": "#ea9f41", - "error": "#e6656a", - "info": "#2f9bce", - "interactive": "#4aa8c8", - "diffAdd": "#b1d780", - "diffDelete": "#e6656a" - }, - "overrides": { - "background-base": "#fdfaf4", - "background-weak": "#fcf9f3", - "background-strong": "#fbf8f2", - "background-stronger": "#faf7f1", - "surface-raised-base-hover": "#f4f0e9", - "border-weak-base": "#e6ddcf", - "border-weak-hover": "#dcd3c5", - "border-weak-active": "#d1c9ba", - "border-weak-selected": "#c6bfaf", - "border-weak-disabled": "#f7f0e6", - "border-weak-focus": "#cbc4b6", - "border-base": "#bfb3a3", - "border-hover": "#b4a898", - "border-active": "#a99e8e", - "border-selected": "#9e9383", - "border-disabled": "#efe5d8", - "border-focus": "#b09f8f", - "border-strong-base": "#837765", - "border-strong-hover": "#7a6f5f", - "border-strong-active": "#716655", - "border-strong-selected": "#685e4e", - "border-strong-disabled": "#d8cabc", - "border-strong-focus": "#766b5c", - "surface-diff-add-base": "#eef5e4", - "surface-diff-delete-base": "#fde5e5", - "surface-diff-hidden-base": "#e3edf3", - "text-base": "#4f5964", - "text-weak": "#77818d", - "text-strong": "#1b232b", - "syntax-string": "#7fad00", - "syntax-primitive": "#ef7d71", - "syntax-property": "#4aa8c8", - "syntax-type": "#ed982e", - "syntax-constant": "#2f9bce", - "syntax-info": "#2f9bce", - "markdown-heading": "#4aa8c8", - "markdown-text": "#4f5964", - "markdown-link": "#4aa8c8", - "markdown-link-text": "#2f9bce", - "markdown-code": "#7fad00", - "markdown-block-quote": "#ed982e", - "markdown-emph": "#ed982e", - "markdown-strong": "#f07f72", - "markdown-horizontal-rule": "#d7cec0", - "markdown-list-item": "#4aa8c8", - "markdown-list-enumeration": "#2f9bce", - "markdown-image": "#4aa8c8", - "markdown-image-text": "#2f9bce", - "markdown-code-block": "#4aa8c8" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#0B0E14", + "darkBgAlt": "#0D1017", + "darkLine": "#11151C", + "darkPanel": "#0F131A", + "darkFg": "#BFBDB6", + "darkFgMuted": "#565B66", + "darkGutter": "#6C7380", + "darkTag": "#39BAE6", + "darkFunc": "#FFB454", + "darkEntity": "#59C2FF", + "darkString": "#AAD94C", + "darkRegexp": "#95E6CB", + "darkMarkup": "#F07178", + "darkKeyword": "#FF8F40", + "darkSpecial": "#E6B673", + "darkComment": "#ACB6BF", + "darkConstant": "#D2A6FF", + "darkOperator": "#F29668", + "darkAdded": "#7FD962", + "darkRemoved": "#F26D78", + "darkAccent": "#E6B450", + "darkError": "#D95757", + "darkIndentActive": "#6C7380" }, - "dark": { - "seeds": { - "neutral": "#0f1419", - "primary": "#3fb7e3", - "success": "#78d05c", - "warning": "#e4a75c", - "error": "#f58572", - "info": "#66c6f1", - "interactive": "#3fb7e3", - "diffAdd": "#59c57c", - "diffDelete": "#f58572" - }, - "overrides": { - "background-base": "#0f1419", - "background-weak": "#18222c", - "background-strong": "#0b1015", - "background-stronger": "#080c10", - "surface-raised-base-hover": "#0f1419", - "border-weak-base": "#2b3440", - "border-weak-hover": "#323c49", - "border-weak-active": "#394454", - "border-weak-selected": "#415063", - "border-weak-disabled": "#0a0e12", - "border-weak-focus": "#374453", - "border-base": "#475367", - "border-hover": "#515f75", - "border-active": "#5d6b83", - "border-selected": "#687795", - "border-disabled": "#11161d", - "border-focus": "#56647c", - "border-strong-base": "#73819b", - "border-strong-hover": "#7f8da8", - "border-strong-active": "#8b99b5", - "border-strong-selected": "#98a6c3", - "border-strong-disabled": "#1b222c", - "border-strong-focus": "#8391ad", - "surface-diff-add-base": "#132f27", - "surface-diff-delete-base": "#361d20", - "surface-diff-hidden-base": "#1b2632", - "text-base": "#d6dae0", - "text-weak": "#a3adba", - "text-strong": "#fbfbfd", - "syntax-string": "#b1c74a", - "syntax-primitive": "#f2856f", - "syntax-property": "#3fb7e3", - "syntax-type": "#e4a75c", - "syntax-constant": "#66c6f1", - "syntax-info": "#66c6f1", - "markdown-heading": "#3fb7e3", - "markdown-text": "#d6dae0", - "markdown-link": "#3fb7e3", - "markdown-link-text": "#66c6f1", - "markdown-code": "#b1c74a", - "markdown-block-quote": "#e4a75c", - "markdown-emph": "#e4a75c", - "markdown-strong": "#f2856f", - "markdown-horizontal-rule": "#2b3542", - "markdown-list-item": "#3fb7e3", - "markdown-list-enumeration": "#66c6f1", - "markdown-image": "#3fb7e3", - "markdown-image-text": "#66c6f1", - "markdown-code-block": "#d6dae0" - } + "theme": { + "primary": "darkEntity", + "secondary": "darkConstant", + "accent": "darkAccent", + "error": "darkError", + "warning": "darkSpecial", + "success": "darkAdded", + "info": "darkTag", + "text": "darkFg", + "textMuted": "darkFgMuted", + "background": "darkBg", + "backgroundPanel": "darkPanel", + "backgroundElement": "darkBgAlt", + "border": "darkGutter", + "borderActive": "darkIndentActive", + "borderSubtle": "darkLine", + "diffAdded": "darkAdded", + "diffRemoved": "darkRemoved", + "diffContext": "darkComment", + "diffHunkHeader": "darkComment", + "diffHighlightAdded": "darkString", + "diffHighlightRemoved": "darkMarkup", + "diffAddedBg": "#20303b", + "diffRemovedBg": "#37222c", + "diffContextBg": "darkPanel", + "diffLineNumber": "darkGutter", + "diffAddedLineNumberBg": "#1b2b34", + "diffRemovedLineNumberBg": "#2d1f26", + "markdownText": "darkFg", + "markdownHeading": "darkConstant", + "markdownLink": "darkEntity", + "markdownLinkText": "darkTag", + "markdownCode": "darkString", + "markdownBlockQuote": "darkSpecial", + "markdownEmph": "darkSpecial", + "markdownStrong": "darkFunc", + "markdownHorizontalRule": "darkFgMuted", + "markdownListItem": "darkEntity", + "markdownListEnumeration": "darkTag", + "markdownImage": "darkEntity", + "markdownImageText": "darkTag", + "markdownCodeBlock": "darkFg", + "syntaxComment": "darkComment", + "syntaxKeyword": "darkKeyword", + "syntaxFunction": "darkFunc", + "syntaxVariable": "darkEntity", + "syntaxString": "darkString", + "syntaxNumber": "darkConstant", + "syntaxType": "darkSpecial", + "syntaxOperator": "darkOperator", + "syntaxPunctuation": "darkFg" } } diff --git a/src/generated_themes/carbonfox.json b/src/generated_themes/carbonfox.json index e2fa20d..b91de1f 100644 --- a/src/generated_themes/carbonfox.json +++ b/src/generated_themes/carbonfox.json @@ -1,122 +1,248 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Carbonfox", - "id": "carbonfox", - "light": { - "seeds": { - "neutral": "#8e8e8e", - "primary": "#0072c3", - "success": "#198038", - "warning": "#f1c21b", - "error": "#da1e28", - "info": "#0043ce", - "interactive": "#0f62fe", - "diffAdd": "#198038", - "diffDelete": "#da1e28" - }, - "overrides": { - "background-base": "#ffffff", - "background-weak": "#f4f4f4", - "background-strong": "#e8e8e8", - "background-stronger": "#dcdcdc", - "surface-raised-strong": "#ffffff", - "surface-raised-stronger": "#ffffff", - "surface-float-base": "#161616", - "surface-float-base-hover": "#262626", - "text-base": "#161616", - "text-weak": "#525252", - "text-strong": "#000000", - "syntax-string": "#198038", - "syntax-primitive": "#da1e28", - "syntax-property": "#0043ce", - "syntax-type": "#007d79", - "syntax-constant": "#6929c4", - "syntax-keyword": "#525252", - "syntax-info": "#0043ce", - "markdown-heading": "#0043ce", - "markdown-text": "#161616", - "markdown-link": "#0043ce", - "markdown-link-text": "#0072c3", - "markdown-code": "#198038", - "markdown-block-quote": "#525252", - "markdown-emph": "#6929c4", - "markdown-strong": "#161616", - "markdown-horizontal-rule": "#c6c6c6", - "markdown-list-item": "#0072c3", - "markdown-list-enumeration": "#0072c3", - "markdown-image": "#0043ce", - "markdown-image-text": "#0072c3", - "markdown-code-block": "#393939" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "bg0": "#0d0d0d", + "bg1": "#161616", + "bg1a": "#1a1a1a", + "bg2": "#1e1e1e", + "bg3": "#262626", + "bg4": "#303030", + "fg0": "#ffffff", + "fg1": "#f2f4f8", + "fg2": "#a9afbc", + "fg3": "#7d848f", + "lbg0": "#ffffff", + "lbg1": "#f4f4f4", + "lbg2": "#e8e8e8", + "lbg3": "#dcdcdc", + "lfg0": "#000000", + "lfg1": "#161616", + "lfg2": "#525252", + "lfg3": "#6f6f6f", + "red": "#ee5396", + "green": "#25be6a", + "yellow": "#08bdba", + "blue": "#78a9ff", + "magenta": "#be95ff", + "cyan": "#33b1ff", + "white": "#dfdfe0", + "orange": "#3ddbd9", + "pink": "#ff7eb6", + "blueBright": "#8cb6ff", + "cyanBright": "#52c7ff", + "greenBright": "#46c880", + "redLight": "#9f1853", + "greenLight": "#198038", + "yellowLight": "#007d79", + "blueLight": "#0043ce", + "magentaLight": "#6929c4", + "cyanLight": "#0072c3", + "warning": "#f1c21b", + "diffGreen": "#50fa7b", + "diffRed": "#ff6b6b", + "diffGreenBg": "#0f2418", + "diffRedBg": "#2a1216" }, - "dark": { - "seeds": { - "neutral": "#393939", - "primary": "#33b1ff", - "success": "#42be65", - "warning": "#f1c21b", - "error": "#ff8389", - "info": "#78a9ff", - "interactive": "#4589ff", - "diffAdd": "#42be65", - "diffDelete": "#ff8389" - }, - "overrides": { - "background-base": "#161616", - "background-weak": "#262626", - "background-strong": "#0d0d0d", - "background-stronger": "#000000", - "surface-raised-base": "#1c1c1c", - "surface-raised-base-hover": "#262626", - "surface-raised-strong": "#262626", - "surface-raised-strong-hover": "#303030", - "surface-raised-stronger": "#303030", - "surface-raised-stronger-hover": "#393939", - "surface-raised-stronger-non-alpha": "#303030", - "surface-float-base": "#0d0d0d", - "surface-float-base-hover": "#1a1a1a", - "surface-inset-base": "#0d0d0d", - "surface-inset-strong": "#000000", - "surface-base": "#1e1e1e", - "surface-base-hover": "#262626", - "surface-diff-add-base": "#0e3a22", - "surface-diff-delete-base": "#4d1a1f", - "input-base": "#262626", - "input-hover": "#303030", - "button-secondary-base": "#393939", - "button-secondary-hover": "#4c4c4c", - "border-weak-base": "#393939", - "border-weak-hover": "#4c4c4c", - "border-base": "#525252", - "border-hover": "#636363", - "border-strong-base": "#6f6f6f", - "text-base": "#f2f4f8", - "text-weak": "#8d8d8d", - "text-weaker": "#6f6f6f", - "text-strong": "#ffffff", - "icon-base": "#8d8d8d", - "icon-weak-base": "#6f6f6f", - "syntax-string": "#42be65", - "syntax-primitive": "#ff8389", - "syntax-property": "#78a9ff", - "syntax-type": "#08bdba", - "syntax-constant": "#be95ff", - "syntax-keyword": "#8d8d8d", - "syntax-info": "#78a9ff", - "markdown-heading": "#82cfff", - "markdown-text": "#f2f4f8", - "markdown-link": "#78a9ff", - "markdown-link-text": "#33b1ff", - "markdown-code": "#42be65", - "markdown-block-quote": "#8d8d8d", - "markdown-emph": "#be95ff", - "markdown-strong": "#ffffff", - "markdown-horizontal-rule": "#393939", - "markdown-list-item": "#33b1ff", - "markdown-list-enumeration": "#33b1ff", - "markdown-image": "#78a9ff", - "markdown-image-text": "#33b1ff", - "markdown-code-block": "#c6c6c6" + "theme": { + "primary": { + "dark": "cyan", + "light": "blueLight" + }, + "secondary": { + "dark": "blue", + "light": "blueLight" + }, + "accent": { + "dark": "pink", + "light": "redLight" + }, + "error": { + "dark": "red", + "light": "redLight" + }, + "warning": { + "dark": "warning", + "light": "yellowLight" + }, + "success": { + "dark": "green", + "light": "greenLight" + }, + "info": { + "dark": "blue", + "light": "blueLight" + }, + "text": { + "dark": "fg1", + "light": "lfg1" + }, + "textMuted": { + "dark": "fg3", + "light": "lfg3" + }, + "background": { + "dark": "bg1", + "light": "lbg0" + }, + "backgroundPanel": { + "dark": "bg1a", + "light": "lbg1" + }, + "backgroundElement": { + "dark": "bg2", + "light": "lbg1" + }, + "border": { + "dark": "bg4", + "light": "lbg3" + }, + "borderActive": { + "dark": "cyan", + "light": "blueLight" + }, + "borderSubtle": { + "dark": "bg3", + "light": "lbg2" + }, + "diffAdded": { + "dark": "diffGreen", + "light": "greenLight" + }, + "diffRemoved": { + "dark": "diffRed", + "light": "redLight" + }, + "diffContext": { + "dark": "fg3", + "light": "lfg3" + }, + "diffHunkHeader": { + "dark": "blue", + "light": "blueLight" + }, + "diffHighlightAdded": { + "dark": "#7dffaa", + "light": "greenLight" + }, + "diffHighlightRemoved": { + "dark": "#ff9999", + "light": "redLight" + }, + "diffAddedBg": { + "dark": "diffGreenBg", + "light": "#defbe6" + }, + "diffRemovedBg": { + "dark": "diffRedBg", + "light": "#fff1f1" + }, + "diffContextBg": { + "dark": "bg1", + "light": "lbg1" + }, + "diffLineNumber": { + "dark": "fg3", + "light": "lfg3" + }, + "diffAddedLineNumberBg": { + "dark": "diffGreenBg", + "light": "#defbe6" + }, + "diffRemovedLineNumberBg": { + "dark": "diffRedBg", + "light": "#fff1f1" + }, + "markdownText": { + "dark": "fg1", + "light": "lfg1" + }, + "markdownHeading": { + "dark": "blueBright", + "light": "blueLight" + }, + "markdownLink": { + "dark": "blue", + "light": "blueLight" + }, + "markdownLinkText": { + "dark": "cyan", + "light": "cyanLight" + }, + "markdownCode": { + "dark": "green", + "light": "greenLight" + }, + "markdownBlockQuote": { + "dark": "fg3", + "light": "lfg3" + }, + "markdownEmph": { + "dark": "magenta", + "light": "magentaLight" + }, + "markdownStrong": { + "dark": "fg0", + "light": "lfg0" + }, + "markdownHorizontalRule": { + "dark": "bg4", + "light": "lbg3" + }, + "markdownListItem": { + "dark": "cyan", + "light": "cyanLight" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "cyanLight" + }, + "markdownImage": { + "dark": "blue", + "light": "blueLight" + }, + "markdownImageText": { + "dark": "cyan", + "light": "cyanLight" + }, + "markdownCodeBlock": { + "dark": "fg2", + "light": "lfg2" + }, + "syntaxComment": { + "dark": "fg3", + "light": "lfg3" + }, + "syntaxKeyword": { + "dark": "magenta", + "light": "magentaLight" + }, + "syntaxFunction": { + "dark": "blueBright", + "light": "blueLight" + }, + "syntaxVariable": { + "dark": "white", + "light": "lfg1" + }, + "syntaxString": { + "dark": "green", + "light": "greenLight" + }, + "syntaxNumber": { + "dark": "orange", + "light": "yellowLight" + }, + "syntaxType": { + "dark": "yellow", + "light": "yellowLight" + }, + "syntaxOperator": { + "dark": "fg2", + "light": "lfg2" + }, + "syntaxPunctuation": { + "dark": "fg2", + "light": "lfg1" } } } diff --git a/src/generated_themes/catppuccin-frappe.json b/src/generated_themes/catppuccin-frappe.json new file mode 100644 index 0000000..79e56ee --- /dev/null +++ b/src/generated_themes/catppuccin-frappe.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "frappeRosewater": "#f2d5cf", + "frappeFlamingo": "#eebebe", + "frappePink": "#f4b8e4", + "frappeMauve": "#ca9ee6", + "frappeRed": "#e78284", + "frappeMaroon": "#ea999c", + "frappePeach": "#ef9f76", + "frappeYellow": "#e5c890", + "frappeGreen": "#a6d189", + "frappeTeal": "#81c8be", + "frappeSky": "#99d1db", + "frappeSapphire": "#85c1dc", + "frappeBlue": "#8da4e2", + "frappeLavender": "#babbf1", + "frappeText": "#c6d0f5", + "frappeSubtext1": "#b5bfe2", + "frappeSubtext0": "#a5adce", + "frappeOverlay2": "#949cb8", + "frappeOverlay1": "#838ba7", + "frappeOverlay0": "#737994", + "frappeSurface2": "#626880", + "frappeSurface1": "#51576d", + "frappeSurface0": "#414559", + "frappeBase": "#303446", + "frappeMantle": "#292c3c", + "frappeCrust": "#232634" + }, + "theme": { + "primary": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "secondary": { + "dark": "frappeMauve", + "light": "frappeMauve" + }, + "accent": { + "dark": "frappePink", + "light": "frappePink" + }, + "error": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "warning": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "success": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "info": { + "dark": "frappeTeal", + "light": "frappeTeal" + }, + "text": { + "dark": "frappeText", + "light": "frappeText" + }, + "textMuted": { + "dark": "frappeSubtext1", + "light": "frappeSubtext1" + }, + "background": { + "dark": "frappeBase", + "light": "frappeBase" + }, + "backgroundPanel": { + "dark": "frappeMantle", + "light": "frappeMantle" + }, + "backgroundElement": { + "dark": "frappeCrust", + "light": "frappeCrust" + }, + "border": { + "dark": "frappeSurface0", + "light": "frappeSurface0" + }, + "borderActive": { + "dark": "frappeSurface1", + "light": "frappeSurface1" + }, + "borderSubtle": { + "dark": "frappeSurface2", + "light": "frappeSurface2" + }, + "diffAdded": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "diffRemoved": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "diffContext": { + "dark": "frappeOverlay2", + "light": "frappeOverlay2" + }, + "diffHunkHeader": { + "dark": "frappePeach", + "light": "frappePeach" + }, + "diffHighlightAdded": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "diffHighlightRemoved": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "diffAddedBg": { + "dark": "#29342b", + "light": "#29342b" + }, + "diffRemovedBg": { + "dark": "#3a2a31", + "light": "#3a2a31" + }, + "diffContextBg": { + "dark": "frappeMantle", + "light": "frappeMantle" + }, + "diffLineNumber": { + "dark": "frappeSurface1", + "light": "frappeSurface1" + }, + "diffAddedLineNumberBg": { + "dark": "#223025", + "light": "#223025" + }, + "diffRemovedLineNumberBg": { + "dark": "#2f242b", + "light": "#2f242b" + }, + "markdownText": { + "dark": "frappeText", + "light": "frappeText" + }, + "markdownHeading": { + "dark": "frappeMauve", + "light": "frappeMauve" + }, + "markdownLink": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "markdownLinkText": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "markdownCode": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "markdownBlockQuote": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "markdownEmph": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "markdownStrong": { + "dark": "frappePeach", + "light": "frappePeach" + }, + "markdownHorizontalRule": { + "dark": "frappeSubtext0", + "light": "frappeSubtext0" + }, + "markdownListItem": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "markdownListEnumeration": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "markdownImage": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "markdownImageText": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "markdownCodeBlock": { + "dark": "frappeText", + "light": "frappeText" + }, + "syntaxComment": { + "dark": "frappeOverlay2", + "light": "frappeOverlay2" + }, + "syntaxKeyword": { + "dark": "frappeMauve", + "light": "frappeMauve" + }, + "syntaxFunction": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "syntaxVariable": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "syntaxString": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "syntaxNumber": { + "dark": "frappePeach", + "light": "frappePeach" + }, + "syntaxType": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "syntaxOperator": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "syntaxPunctuation": { + "dark": "frappeText", + "light": "frappeText" + } + } +} diff --git a/src/generated_themes/catppuccin-macchiato.json b/src/generated_themes/catppuccin-macchiato.json new file mode 100644 index 0000000..6d9827d --- /dev/null +++ b/src/generated_themes/catppuccin-macchiato.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "macRosewater": "#f4dbd6", + "macFlamingo": "#f0c6c6", + "macPink": "#f5bde6", + "macMauve": "#c6a0f6", + "macRed": "#ed8796", + "macMaroon": "#ee99a0", + "macPeach": "#f5a97f", + "macYellow": "#eed49f", + "macGreen": "#a6da95", + "macTeal": "#8bd5ca", + "macSky": "#91d7e3", + "macSapphire": "#7dc4e4", + "macBlue": "#8aadf4", + "macLavender": "#b7bdf8", + "macText": "#cad3f5", + "macSubtext1": "#b8c0e0", + "macSubtext0": "#a5adcb", + "macOverlay2": "#939ab7", + "macOverlay1": "#8087a2", + "macOverlay0": "#6e738d", + "macSurface2": "#5b6078", + "macSurface1": "#494d64", + "macSurface0": "#363a4f", + "macBase": "#24273a", + "macMantle": "#1e2030", + "macCrust": "#181926" + }, + "theme": { + "primary": { + "dark": "macBlue", + "light": "macBlue" + }, + "secondary": { + "dark": "macMauve", + "light": "macMauve" + }, + "accent": { + "dark": "macPink", + "light": "macPink" + }, + "error": { + "dark": "macRed", + "light": "macRed" + }, + "warning": { + "dark": "macYellow", + "light": "macYellow" + }, + "success": { + "dark": "macGreen", + "light": "macGreen" + }, + "info": { + "dark": "macTeal", + "light": "macTeal" + }, + "text": { + "dark": "macText", + "light": "macText" + }, + "textMuted": { + "dark": "macSubtext1", + "light": "macSubtext1" + }, + "background": { + "dark": "macBase", + "light": "macBase" + }, + "backgroundPanel": { + "dark": "macMantle", + "light": "macMantle" + }, + "backgroundElement": { + "dark": "macCrust", + "light": "macCrust" + }, + "border": { + "dark": "macSurface0", + "light": "macSurface0" + }, + "borderActive": { + "dark": "macSurface1", + "light": "macSurface1" + }, + "borderSubtle": { + "dark": "macSurface2", + "light": "macSurface2" + }, + "diffAdded": { + "dark": "macGreen", + "light": "macGreen" + }, + "diffRemoved": { + "dark": "macRed", + "light": "macRed" + }, + "diffContext": { + "dark": "macOverlay2", + "light": "macOverlay2" + }, + "diffHunkHeader": { + "dark": "macPeach", + "light": "macPeach" + }, + "diffHighlightAdded": { + "dark": "macGreen", + "light": "macGreen" + }, + "diffHighlightRemoved": { + "dark": "macRed", + "light": "macRed" + }, + "diffAddedBg": { + "dark": "#29342b", + "light": "#29342b" + }, + "diffRemovedBg": { + "dark": "#3a2a31", + "light": "#3a2a31" + }, + "diffContextBg": { + "dark": "macMantle", + "light": "macMantle" + }, + "diffLineNumber": { + "dark": "macSurface1", + "light": "macSurface1" + }, + "diffAddedLineNumberBg": { + "dark": "#223025", + "light": "#223025" + }, + "diffRemovedLineNumberBg": { + "dark": "#2f242b", + "light": "#2f242b" + }, + "markdownText": { + "dark": "macText", + "light": "macText" + }, + "markdownHeading": { + "dark": "macMauve", + "light": "macMauve" + }, + "markdownLink": { + "dark": "macBlue", + "light": "macBlue" + }, + "markdownLinkText": { + "dark": "macSky", + "light": "macSky" + }, + "markdownCode": { + "dark": "macGreen", + "light": "macGreen" + }, + "markdownBlockQuote": { + "dark": "macYellow", + "light": "macYellow" + }, + "markdownEmph": { + "dark": "macYellow", + "light": "macYellow" + }, + "markdownStrong": { + "dark": "macPeach", + "light": "macPeach" + }, + "markdownHorizontalRule": { + "dark": "macSubtext0", + "light": "macSubtext0" + }, + "markdownListItem": { + "dark": "macBlue", + "light": "macBlue" + }, + "markdownListEnumeration": { + "dark": "macSky", + "light": "macSky" + }, + "markdownImage": { + "dark": "macBlue", + "light": "macBlue" + }, + "markdownImageText": { + "dark": "macSky", + "light": "macSky" + }, + "markdownCodeBlock": { + "dark": "macText", + "light": "macText" + }, + "syntaxComment": { + "dark": "macOverlay2", + "light": "macOverlay2" + }, + "syntaxKeyword": { + "dark": "macMauve", + "light": "macMauve" + }, + "syntaxFunction": { + "dark": "macBlue", + "light": "macBlue" + }, + "syntaxVariable": { + "dark": "macRed", + "light": "macRed" + }, + "syntaxString": { + "dark": "macGreen", + "light": "macGreen" + }, + "syntaxNumber": { + "dark": "macPeach", + "light": "macPeach" + }, + "syntaxType": { + "dark": "macYellow", + "light": "macYellow" + }, + "syntaxOperator": { + "dark": "macSky", + "light": "macSky" + }, + "syntaxPunctuation": { + "dark": "macText", + "light": "macText" + } + } +} diff --git a/src/generated_themes/catppuccin.json b/src/generated_themes/catppuccin.json index 2a32df0..d0fa6a1 100644 --- a/src/generated_themes/catppuccin.json +++ b/src/generated_themes/catppuccin.json @@ -1,131 +1,112 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Catppuccin", - "id": "catppuccin", - "light": { - "seeds": { - "neutral": "#f5e0dc", - "primary": "#7287fd", - "success": "#40a02b", - "warning": "#df8e1d", - "error": "#d20f39", - "info": "#04a5e5", - "interactive": "#7287fd", - "diffAdd": "#a6d189", - "diffDelete": "#e78284" - }, - "overrides": { - "background-base": "#f5e0dc", - "background-weak": "#f2d8d4", - "background-strong": "#f9e8e4", - "background-stronger": "#fdeeee", - "border-weak-base": "#e0cfd3", - "border-weak-hover": "#d6c4c8", - "border-weak-active": "#cdb9be", - "border-weak-selected": "#c2aeb4", - "border-weak-disabled": "#fbeff2", - "border-weak-focus": "#c7b4ba", - "border-base": "#bca6b2", - "border-hover": "#b19ca8", - "border-active": "#a6929e", - "border-selected": "#9a8894", - "border-disabled": "#f3e4e7", - "border-focus": "#ab97a1", - "border-strong-base": "#83677f", - "border-strong-hover": "#775b73", - "border-strong-active": "#6b5068", - "border-strong-selected": "#5f465d", - "border-strong-disabled": "#d9c5cf", - "border-strong-focus": "#714f66", - "surface-diff-add-base": "#edf5e6", - "surface-diff-delete-base": "#fde1e3", - "surface-diff-hidden-base": "#e4e2f6", - "text-base": "#4c4f69", - "text-weak": "#6c6f85", - "text-strong": "#1f1f2a", - "syntax-string": "#40a02b", - "syntax-primitive": "#d20f39", - "syntax-property": "#7287fd", - "syntax-type": "#df8e1d", - "syntax-constant": "#04a5e5", - "syntax-info": "#04a5e5", - "markdown-heading": "#7287fd", - "markdown-text": "#4c4f69", - "markdown-link": "#7287fd", - "markdown-link-text": "#04a5e5", - "markdown-code": "#40a02b", - "markdown-block-quote": "#df8e1d", - "markdown-emph": "#df8e1d", - "markdown-strong": "#d20f39", - "markdown-horizontal-rule": "#d4c5cf", - "markdown-list-item": "#7287fd", - "markdown-list-enumeration": "#04a5e5", - "markdown-image": "#7287fd", - "markdown-image-text": "#04a5e5", - "markdown-code-block": "#7287fd" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "lightRosewater": "#dc8a78", + "lightFlamingo": "#dd7878", + "lightPink": "#ea76cb", + "lightMauve": "#8839ef", + "lightRed": "#d20f39", + "lightMaroon": "#e64553", + "lightPeach": "#fe640b", + "lightYellow": "#df8e1d", + "lightGreen": "#40a02b", + "lightTeal": "#179299", + "lightSky": "#04a5e5", + "lightSapphire": "#209fb5", + "lightBlue": "#1e66f5", + "lightLavender": "#7287fd", + "lightText": "#4c4f69", + "lightSubtext1": "#5c5f77", + "lightSubtext0": "#6c6f85", + "lightOverlay2": "#7c7f93", + "lightOverlay1": "#8c8fa1", + "lightOverlay0": "#9ca0b0", + "lightSurface2": "#acb0be", + "lightSurface1": "#bcc0cc", + "lightSurface0": "#ccd0da", + "lightBase": "#eff1f5", + "lightMantle": "#e6e9ef", + "lightCrust": "#dce0e8", + "darkRosewater": "#f5e0dc", + "darkFlamingo": "#f2cdcd", + "darkPink": "#f5c2e7", + "darkMauve": "#cba6f7", + "darkRed": "#f38ba8", + "darkMaroon": "#eba0ac", + "darkPeach": "#fab387", + "darkYellow": "#f9e2af", + "darkGreen": "#a6e3a1", + "darkTeal": "#94e2d5", + "darkSky": "#89dceb", + "darkSapphire": "#74c7ec", + "darkBlue": "#89b4fa", + "darkLavender": "#b4befe", + "darkText": "#cdd6f4", + "darkSubtext1": "#bac2de", + "darkSubtext0": "#a6adc8", + "darkOverlay2": "#9399b2", + "darkOverlay1": "#7f849c", + "darkOverlay0": "#6c7086", + "darkSurface2": "#585b70", + "darkSurface1": "#45475a", + "darkSurface0": "#313244", + "darkBase": "#1e1e2e", + "darkMantle": "#181825", + "darkCrust": "#11111b" }, - "dark": { - "seeds": { - "neutral": "#1e1e2e", - "primary": "#b4befe", - "success": "#a6d189", - "warning": "#f4b8e4", - "error": "#f38ba8", - "info": "#89dceb", - "interactive": "#b4befe", - "diffAdd": "#94e2d5", - "diffDelete": "#f38ba8" + "theme": { + "primary": { "dark": "darkBlue", "light": "lightBlue" }, + "secondary": { "dark": "darkMauve", "light": "lightMauve" }, + "accent": { "dark": "darkPink", "light": "lightPink" }, + "error": { "dark": "darkRed", "light": "lightRed" }, + "warning": { "dark": "darkYellow", "light": "lightYellow" }, + "success": { "dark": "darkGreen", "light": "lightGreen" }, + "info": { "dark": "darkTeal", "light": "lightTeal" }, + "text": { "dark": "darkText", "light": "lightText" }, + "textMuted": { "dark": "darkSubtext1", "light": "lightSubtext1" }, + "background": { "dark": "darkBase", "light": "lightBase" }, + "backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" }, + "backgroundElement": { "dark": "darkCrust", "light": "lightCrust" }, + "border": { "dark": "darkSurface0", "light": "lightSurface0" }, + "borderActive": { "dark": "darkSurface1", "light": "lightSurface1" }, + "borderSubtle": { "dark": "darkSurface2", "light": "lightSurface2" }, + "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffContext": { "dark": "darkOverlay2", "light": "lightOverlay2" }, + "diffHunkHeader": { "dark": "darkPeach", "light": "lightPeach" }, + "diffHighlightAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffHighlightRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffAddedBg": { "dark": "#24312b", "light": "#d6f0d9" }, + "diffRemovedBg": { "dark": "#3c2a32", "light": "#f6dfe2" }, + "diffContextBg": { "dark": "darkMantle", "light": "lightMantle" }, + "diffLineNumber": { "dark": "darkSurface1", "light": "lightSurface1" }, + "diffAddedLineNumberBg": { "dark": "#1e2a25", "light": "#c9e3cb" }, + "diffRemovedLineNumberBg": { "dark": "#32232a", "light": "#e9d3d6" }, + "markdownText": { "dark": "darkText", "light": "lightText" }, + "markdownHeading": { "dark": "darkMauve", "light": "lightMauve" }, + "markdownLink": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownLinkText": { "dark": "darkSky", "light": "lightSky" }, + "markdownCode": { "dark": "darkGreen", "light": "lightGreen" }, + "markdownBlockQuote": { "dark": "darkYellow", "light": "lightYellow" }, + "markdownEmph": { "dark": "darkYellow", "light": "lightYellow" }, + "markdownStrong": { "dark": "darkPeach", "light": "lightPeach" }, + "markdownHorizontalRule": { + "dark": "darkSubtext0", + "light": "lightSubtext0" }, - "overrides": { - "background-base": "#1e1e2e", - "background-weak": "#211f31", - "background-strong": "#1c1c29", - "background-stronger": "#191926", - "border-weak-base": "#35324a", - "border-weak-hover": "#393655", - "border-weak-active": "#403c61", - "border-weak-selected": "#47436d", - "border-weak-disabled": "#141426", - "border-weak-focus": "#3d3a63", - "border-base": "#4a4763", - "border-hover": "#524f70", - "border-active": "#5a577d", - "border-selected": "#625f8a", - "border-disabled": "#1b1a2c", - "border-focus": "#575379", - "border-strong-base": "#6e6a8c", - "border-strong-hover": "#787497", - "border-strong-active": "#8380a2", - "border-strong-selected": "#8d8bad", - "border-strong-disabled": "#232237", - "border-strong-focus": "#7b779b", - "surface-diff-add-base": "#1d2c30", - "surface-diff-delete-base": "#2c1f2a", - "surface-diff-hidden-base": "#232538", - "text-base": "#cdd6f4", - "text-weak": "#a6adc8", - "text-strong": "#f4f2ff", - "syntax-string": "#a6e3a1", - "syntax-primitive": "#f38ba8", - "syntax-property": "#b4befe", - "syntax-type": "#f9e2af", - "syntax-constant": "#89dceb", - "syntax-info": "#89dceb", - "markdown-heading": "#b4befe", - "markdown-text": "#cdd6f4", - "markdown-link": "#b4befe", - "markdown-link-text": "#89dceb", - "markdown-code": "#a6e3a1", - "markdown-block-quote": "#f9e2af", - "markdown-emph": "#f9e2af", - "markdown-strong": "#f38ba8", - "markdown-horizontal-rule": "#2e2d45", - "markdown-list-item": "#b4befe", - "markdown-list-enumeration": "#89dceb", - "markdown-image": "#b4befe", - "markdown-image-text": "#89dceb", - "markdown-code-block": "#cdd6f4" - } + "markdownListItem": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownListEnumeration": { "dark": "darkSky", "light": "lightSky" }, + "markdownImage": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownImageText": { "dark": "darkSky", "light": "lightSky" }, + "markdownCodeBlock": { "dark": "darkText", "light": "lightText" }, + "syntaxComment": { "dark": "darkOverlay2", "light": "lightOverlay2" }, + "syntaxKeyword": { "dark": "darkMauve", "light": "lightMauve" }, + "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" }, + "syntaxVariable": { "dark": "darkRed", "light": "lightRed" }, + "syntaxString": { "dark": "darkGreen", "light": "lightGreen" }, + "syntaxNumber": { "dark": "darkPeach", "light": "lightPeach" }, + "syntaxType": { "dark": "darkYellow", "light": "lightYellow" }, + "syntaxOperator": { "dark": "darkSky", "light": "lightSky" }, + "syntaxPunctuation": { "dark": "darkText", "light": "lightText" } } } diff --git a/src/generated_themes/cobalt2.json b/src/generated_themes/cobalt2.json new file mode 100644 index 0000000..2967eae --- /dev/null +++ b/src/generated_themes/cobalt2.json @@ -0,0 +1,228 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#193549", + "backgroundAlt": "#122738", + "backgroundPanel": "#1f4662", + "foreground": "#ffffff", + "foregroundMuted": "#adb7c9", + "yellow": "#ffc600", + "yellowBright": "#ffe14c", + "orange": "#ff9d00", + "orangeBright": "#ffb454", + "mint": "#2affdf", + "mintBright": "#7efff5", + "blue": "#0088ff", + "blueBright": "#5cb7ff", + "pink": "#ff628c", + "pinkBright": "#ff86a5", + "green": "#9eff80", + "greenBright": "#b9ff9f", + "purple": "#9a5feb", + "purpleBright": "#b88cfd", + "red": "#ff0088", + "redBright": "#ff5fb3" + }, + "theme": { + "primary": { + "dark": "blue", + "light": "#0066cc" + }, + "secondary": { + "dark": "purple", + "light": "#7c4dff" + }, + "accent": { + "dark": "mint", + "light": "#00acc1" + }, + "error": { + "dark": "red", + "light": "#e91e63" + }, + "warning": { + "dark": "yellow", + "light": "#ff9800" + }, + "success": { + "dark": "green", + "light": "#4caf50" + }, + "info": { + "dark": "orange", + "light": "#ff5722" + }, + "text": { + "dark": "foreground", + "light": "#193549" + }, + "textMuted": { + "dark": "foregroundMuted", + "light": "#5c6b7d" + }, + "background": { + "dark": "#193549", + "light": "#ffffff" + }, + "backgroundPanel": { + "dark": "#122738", + "light": "#f5f7fa" + }, + "backgroundElement": { + "dark": "#1f4662", + "light": "#e8ecf1" + }, + "border": { + "dark": "#1f4662", + "light": "#d3dae3" + }, + "borderActive": { + "dark": "blue", + "light": "#0066cc" + }, + "borderSubtle": { + "dark": "#0e1e2e", + "light": "#e8ecf1" + }, + "diffAdded": { + "dark": "green", + "light": "#4caf50" + }, + "diffRemoved": { + "dark": "red", + "light": "#e91e63" + }, + "diffContext": { + "dark": "foregroundMuted", + "light": "#5c6b7d" + }, + "diffHunkHeader": { + "dark": "mint", + "light": "#00acc1" + }, + "diffHighlightAdded": { + "dark": "greenBright", + "light": "#4caf50" + }, + "diffHighlightRemoved": { + "dark": "redBright", + "light": "#e91e63" + }, + "diffAddedBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "#122738", + "light": "#f5f7fa" + }, + "diffLineNumber": { + "dark": "#2d5a7b", + "light": "#b0bec5" + }, + "diffAddedLineNumberBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "markdownText": { + "dark": "foreground", + "light": "#193549" + }, + "markdownHeading": { + "dark": "yellow", + "light": "#ff9800" + }, + "markdownLink": { + "dark": "blue", + "light": "#0066cc" + }, + "markdownLinkText": { + "dark": "mint", + "light": "#00acc1" + }, + "markdownCode": { + "dark": "green", + "light": "#4caf50" + }, + "markdownBlockQuote": { + "dark": "foregroundMuted", + "light": "#5c6b7d" + }, + "markdownEmph": { + "dark": "orange", + "light": "#ff5722" + }, + "markdownStrong": { + "dark": "pink", + "light": "#e91e63" + }, + "markdownHorizontalRule": { + "dark": "#2d5a7b", + "light": "#d3dae3" + }, + "markdownListItem": { + "dark": "blue", + "light": "#0066cc" + }, + "markdownListEnumeration": { + "dark": "mint", + "light": "#00acc1" + }, + "markdownImage": { + "dark": "blue", + "light": "#0066cc" + }, + "markdownImageText": { + "dark": "mint", + "light": "#00acc1" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#193549" + }, + "syntaxComment": { + "dark": "#0088ff", + "light": "#5c6b7d" + }, + "syntaxKeyword": { + "dark": "orange", + "light": "#ff5722" + }, + "syntaxFunction": { + "dark": "yellow", + "light": "#ff9800" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#193549" + }, + "syntaxString": { + "dark": "green", + "light": "#4caf50" + }, + "syntaxNumber": { + "dark": "pink", + "light": "#e91e63" + }, + "syntaxType": { + "dark": "mint", + "light": "#00acc1" + }, + "syntaxOperator": { + "dark": "orange", + "light": "#ff5722" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#193549" + } + } +} diff --git a/src/generated_themes/cursor.json b/src/generated_themes/cursor.json new file mode 100644 index 0000000..ab518db --- /dev/null +++ b/src/generated_themes/cursor.json @@ -0,0 +1,249 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#181818", + "darkPanel": "#141414", + "darkElement": "#262626", + "darkFg": "#e4e4e4", + "darkMuted": "#e4e4e45e", + "darkBorder": "#e4e4e413", + "darkBorderActive": "#e4e4e426", + "darkCyan": "#88c0d0", + "darkBlue": "#81a1c1", + "darkGreen": "#3fa266", + "darkGreenBright": "#70b489", + "darkRed": "#e34671", + "darkRedBright": "#fc6b83", + "darkYellow": "#f1b467", + "darkOrange": "#d2943e", + "darkPink": "#E394DC", + "darkPurple": "#AAA0FA", + "darkTeal": "#82D2CE", + "darkSyntaxYellow": "#F8C762", + "darkSyntaxOrange": "#EFB080", + "darkSyntaxGreen": "#A8CC7C", + "darkSyntaxBlue": "#87C3FF", + "lightBg": "#fcfcfc", + "lightPanel": "#f3f3f3", + "lightElement": "#ededed", + "lightFg": "#141414", + "lightMuted": "#141414ad", + "lightBorder": "#14141413", + "lightBorderActive": "#14141426", + "lightTeal": "#6f9ba6", + "lightBlue": "#3c7cab", + "lightBlueDark": "#206595", + "lightGreen": "#1f8a65", + "lightGreenBright": "#55a583", + "lightRed": "#cf2d56", + "lightRedBright": "#e75e78", + "lightOrange": "#db704b", + "lightYellow": "#c08532", + "lightPurple": "#9e94d5", + "lightPurpleDark": "#6049b3", + "lightPink": "#b8448b", + "lightMagenta": "#b3003f" + }, + "theme": { + "primary": { + "dark": "darkCyan", + "light": "lightTeal" + }, + "secondary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "accent": { + "dark": "darkCyan", + "light": "lightTeal" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "backgroundElement": { + "dark": "darkElement", + "light": "lightElement" + }, + "border": { + "dark": "darkBorder", + "light": "lightBorder" + }, + "borderActive": { + "dark": "darkCyan", + "light": "lightTeal" + }, + "borderSubtle": { + "dark": "#0f0f0f", + "light": "#e0e0e0" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHunkHeader": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHighlightAdded": { + "dark": "darkGreenBright", + "light": "lightGreenBright" + }, + "diffHighlightRemoved": { + "dark": "darkRedBright", + "light": "lightRedBright" + }, + "diffAddedBg": { + "dark": "#3fa26633", + "light": "#1f8a651f" + }, + "diffRemovedBg": { + "dark": "#b8004933", + "light": "#cf2d5614" + }, + "diffContextBg": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "diffLineNumber": { + "dark": "#e4e4e442", + "light": "#1414147a" + }, + "diffAddedLineNumberBg": { + "dark": "#3fa26633", + "light": "#1f8a651f" + }, + "diffRemovedLineNumberBg": { + "dark": "#b8004933", + "light": "#cf2d5614" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkPurple", + "light": "lightBlueDark" + }, + "markdownLink": { + "dark": "darkTeal", + "light": "lightBlueDark" + }, + "markdownLinkText": { + "dark": "darkBlue", + "light": "lightMuted" + }, + "markdownCode": { + "dark": "darkPink", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "markdownEmph": { + "dark": "darkTeal", + "light": "lightFg" + }, + "markdownStrong": { + "dark": "darkSyntaxYellow", + "light": "lightFg" + }, + "markdownHorizontalRule": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "markdownListItem": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightMuted" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightBlueDark" + }, + "markdownImageText": { + "dark": "darkBlue", + "light": "lightMuted" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "syntaxKeyword": { + "dark": "darkTeal", + "light": "lightMagenta" + }, + "syntaxFunction": { + "dark": "darkSyntaxOrange", + "light": "lightOrange" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkPink", + "light": "lightPurple" + }, + "syntaxNumber": { + "dark": "darkSyntaxYellow", + "light": "lightPink" + }, + "syntaxType": { + "dark": "darkSyntaxOrange", + "light": "lightBlueDark" + }, + "syntaxOperator": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/generated_themes/deltarune.json b/src/generated_themes/deltarune.json deleted file mode 100644 index f2ab17e..0000000 --- a/src/generated_themes/deltarune.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkWorldBg": "#0B0B3B", - "darkWorldDeep": "#050520", - "darkWorldPanel": "#151555", - "krisBlue": "#6A7BC4", - "krisCyan": "#75FBED", - "krisIce": "#C7E3F2", - "susiePurple": "#5B209D", - "susieMagenta": "#A017D0", - "susiePink": "#F983D8", - "ralseiGreen": "#33A56C", - "ralseiTeal": "#40E4D4", - "noelleRose": "#DC8998", - "noelleRed": "#DC1510", - "noelleMint": "#ECFFBB", - "noelleCyan": "#77E0FF", - "noelleAqua": "#BBFFFC", - "gold": "#FBCE3C", - "orange": "#F4A731", - "hotPink": "#EB0095", - "queenPink": "#F983D8", - "cyberGreen": "#00FF00", - "white": "#FFFFFF", - "black": "#000000", - "textMuted": "#8888AA" - }, - "theme": { - "primary": { - "dark": "hotPink", - "light": "susieMagenta" - }, - "secondary": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "accent": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "error": { - "dark": "noelleRed", - "light": "noelleRed" - }, - "warning": { - "dark": "gold", - "light": "orange" - }, - "success": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "info": { - "dark": "noelleCyan", - "light": "krisBlue" - }, - "text": { - "dark": "white", - "light": "black" - }, - "textMuted": { - "dark": "textMuted", - "light": "#555577" - }, - "background": { - "dark": "darkWorldBg", - "light": "white" - }, - "backgroundPanel": { - "dark": "darkWorldDeep", - "light": "#F0F0F8" - }, - "backgroundElement": { - "dark": "darkWorldPanel", - "light": "#E5E5F0" - }, - "border": { - "dark": "krisBlue", - "light": "susiePurple" - }, - "borderActive": { - "dark": "hotPink", - "light": "susieMagenta" - }, - "borderSubtle": { - "dark": "#3A3A6A", - "light": "#AAAACC" - }, - "diffAdded": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "diffRemoved": { - "dark": "hotPink", - "light": "noelleRed" - }, - "diffContext": { - "dark": "textMuted", - "light": "#666688" - }, - "diffHunkHeader": { - "dark": "krisBlue", - "light": "susiePurple" - }, - "diffHighlightAdded": { - "dark": "ralseiGreen", - "light": "ralseiTeal" - }, - "diffHighlightRemoved": { - "dark": "noelleRed", - "light": "hotPink" - }, - "diffAddedBg": { - "dark": "#0A2A2A", - "light": "#D4FFEE" - }, - "diffRemovedBg": { - "dark": "#2A0A2A", - "light": "#FFD4E8" - }, - "diffContextBg": { - "dark": "darkWorldDeep", - "light": "#F5F5FA" - }, - "diffLineNumber": { - "dark": "textMuted", - "light": "#666688" - }, - "diffAddedLineNumberBg": { - "dark": "#082020", - "light": "#E0FFF0" - }, - "diffRemovedLineNumberBg": { - "dark": "#200820", - "light": "#FFE0F0" - }, - "markdownText": { - "dark": "white", - "light": "black" - }, - "markdownHeading": { - "dark": "gold", - "light": "orange" - }, - "markdownLink": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "markdownLinkText": { - "dark": "noelleCyan", - "light": "susiePurple" - }, - "markdownCode": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "markdownBlockQuote": { - "dark": "textMuted", - "light": "#666688" - }, - "markdownEmph": { - "dark": "susiePink", - "light": "susieMagenta" - }, - "markdownStrong": { - "dark": "hotPink", - "light": "susiePurple" - }, - "markdownHorizontalRule": { - "dark": "krisBlue", - "light": "susiePurple" - }, - "markdownListItem": { - "dark": "gold", - "light": "orange" - }, - "markdownListEnumeration": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "markdownImage": { - "dark": "susieMagenta", - "light": "susiePurple" - }, - "markdownImageText": { - "dark": "susiePink", - "light": "susieMagenta" - }, - "markdownCodeBlock": { - "dark": "white", - "light": "black" - }, - "syntaxComment": { - "dark": "textMuted", - "light": "#666688" - }, - "syntaxKeyword": { - "dark": "hotPink", - "light": "susieMagenta" - }, - "syntaxFunction": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "syntaxVariable": { - "dark": "gold", - "light": "orange" - }, - "syntaxString": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "syntaxNumber": { - "dark": "noelleRose", - "light": "noelleRed" - }, - "syntaxType": { - "dark": "noelleCyan", - "light": "krisBlue" - }, - "syntaxOperator": { - "dark": "white", - "light": "black" - }, - "syntaxPunctuation": { - "dark": "krisBlue", - "light": "#555577" - } - } -} diff --git a/src/generated_themes/dracula.json b/src/generated_themes/dracula.json index 696f106..c837a0b 100644 --- a/src/generated_themes/dracula.json +++ b/src/generated_themes/dracula.json @@ -1,131 +1,219 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Dracula", - "id": "dracula", - "light": { - "seeds": { - "neutral": "#f8f8f2", - "primary": "#7c6bf5", - "success": "#2fbf71", - "warning": "#f7a14d", - "error": "#d9536f", - "info": "#1d7fc5", - "interactive": "#7c6bf5", - "diffAdd": "#9fe3b3", - "diffDelete": "#f8a1b8" - }, - "overrides": { - "background-base": "#f8f8f2", - "background-weak": "#f1f2ed", - "background-strong": "#f6f6f1", - "background-stronger": "#f2f2ec", - "border-weak-base": "#e2e3da", - "border-weak-hover": "#d8d9d0", - "border-weak-active": "#cfd0c7", - "border-weak-selected": "#c4c6bc", - "border-weak-disabled": "#eceee3", - "border-weak-focus": "#c9cabf", - "border-base": "#c4c6ba", - "border-hover": "#b8baae", - "border-active": "#abada3", - "border-selected": "#979a90", - "border-disabled": "#e5e7dd", - "border-focus": "#b0b2a7", - "border-strong-base": "#9fa293", - "border-strong-hover": "#8e9185", - "border-strong-active": "#7e8176", - "border-strong-selected": "#6f7268", - "border-strong-disabled": "#c7c9be", - "border-strong-focus": "#878b7f", - "surface-diff-add-base": "#e4f5e6", - "surface-diff-delete-base": "#fae4eb", - "surface-diff-hidden-base": "#dedfe9", - "text-base": "#1f1f2f", - "text-weak": "#52526b", - "text-strong": "#05040c", - "syntax-string": "#2fbf71", - "syntax-primitive": "#d16090", - "syntax-property": "#7c6bf5", - "syntax-type": "#f7a14d", - "syntax-constant": "#1d7fc5", - "syntax-info": "#1d7fc5", - "markdown-heading": "#7c6bf5", - "markdown-text": "#1f1f2f", - "markdown-link": "#7c6bf5", - "markdown-link-text": "#1d7fc5", - "markdown-code": "#2fbf71", - "markdown-block-quote": "#f7a14d", - "markdown-emph": "#f7a14d", - "markdown-strong": "#d16090", - "markdown-horizontal-rule": "#c3c5d4", - "markdown-list-item": "#7c6bf5", - "markdown-list-enumeration": "#1d7fc5", - "markdown-image": "#7c6bf5", - "markdown-image-text": "#1d7fc5", - "markdown-code-block": "#1d7fc5" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#282a36", + "currentLine": "#44475a", + "selection": "#44475a", + "foreground": "#f8f8f2", + "comment": "#6272a4", + "cyan": "#8be9fd", + "green": "#50fa7b", + "orange": "#ffb86c", + "pink": "#ff79c6", + "purple": "#bd93f9", + "red": "#ff5555", + "yellow": "#f1fa8c" }, - "dark": { - "seeds": { - "neutral": "#1d1e28", - "primary": "#bd93f9", - "success": "#50fa7b", - "warning": "#ffb86c", - "error": "#ff5555", - "info": "#8be9fd", - "interactive": "#bd93f9", - "diffAdd": "#2fb27d", - "diffDelete": "#ff6b81" - }, - "overrides": { - "background-base": "#14151f", - "background-weak": "#181926", - "background-strong": "#161722", - "background-stronger": "#191a26", - "border-weak-base": "#2d2f3c", - "border-weak-hover": "#303244", - "border-weak-active": "#35364c", - "border-weak-selected": "#3b3d55", - "border-weak-disabled": "#1e1f2b", - "border-weak-focus": "#383a50", - "border-base": "#3f415a", - "border-hover": "#464967", - "border-active": "#4d5073", - "border-selected": "#55587f", - "border-disabled": "#272834", - "border-focus": "#4a4d6d", - "border-strong-base": "#606488", - "border-strong-hover": "#6a6e96", - "border-strong-active": "#7378a3", - "border-strong-selected": "#7d82b1", - "border-strong-disabled": "#343649", - "border-strong-focus": "#6f739c", - "surface-diff-add-base": "#1f2a2f", - "surface-diff-delete-base": "#2d1f27", - "surface-diff-hidden-base": "#24253a", - "text-base": "#f8f8f2", - "text-weak": "#b6b9e4", - "text-strong": "#ffffff", - "syntax-string": "#50fa7b", - "syntax-primitive": "#ff79c6", - "syntax-property": "#bd93f9", - "syntax-type": "#ffb86c", - "syntax-constant": "#8be9fd", - "syntax-info": "#8be9fd", - "markdown-heading": "#bd93f9", - "markdown-text": "#f8f8f2", - "markdown-link": "#bd93f9", - "markdown-link-text": "#8be9fd", - "markdown-code": "#50fa7b", - "markdown-block-quote": "#ffb86c", - "markdown-emph": "#ffb86c", - "markdown-strong": "#ff79c6", - "markdown-horizontal-rule": "#44475a", - "markdown-list-item": "#bd93f9", - "markdown-list-enumeration": "#8be9fd", - "markdown-image": "#bd93f9", - "markdown-image-text": "#8be9fd", - "markdown-code-block": "#f8f8f2" + "theme": { + "primary": { + "dark": "purple", + "light": "purple" + }, + "secondary": { + "dark": "pink", + "light": "pink" + }, + "accent": { + "dark": "cyan", + "light": "cyan" + }, + "error": { + "dark": "red", + "light": "red" + }, + "warning": { + "dark": "yellow", + "light": "yellow" + }, + "success": { + "dark": "green", + "light": "green" + }, + "info": { + "dark": "orange", + "light": "orange" + }, + "text": { + "dark": "foreground", + "light": "#282a36" + }, + "textMuted": { + "dark": "comment", + "light": "#6272a4" + }, + "background": { + "dark": "#282a36", + "light": "#f8f8f2" + }, + "backgroundPanel": { + "dark": "#21222c", + "light": "#e8e8e2" + }, + "backgroundElement": { + "dark": "currentLine", + "light": "#d8d8d2" + }, + "border": { + "dark": "currentLine", + "light": "#c8c8c2" + }, + "borderActive": { + "dark": "purple", + "light": "purple" + }, + "borderSubtle": { + "dark": "#191a21", + "light": "#e0e0e0" + }, + "diffAdded": { + "dark": "green", + "light": "green" + }, + "diffRemoved": { + "dark": "red", + "light": "red" + }, + "diffContext": { + "dark": "comment", + "light": "#6272a4" + }, + "diffHunkHeader": { + "dark": "comment", + "light": "#6272a4" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "green" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "red" + }, + "diffAddedBg": { + "dark": "#1a3a1a", + "light": "#e0ffe0" + }, + "diffRemovedBg": { + "dark": "#3a1a1a", + "light": "#ffe0e0" + }, + "diffContextBg": { + "dark": "#21222c", + "light": "#e8e8e2" + }, + "diffLineNumber": { + "dark": "currentLine", + "light": "#c8c8c2" + }, + "diffAddedLineNumberBg": { + "dark": "#1a3a1a", + "light": "#e0ffe0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1a1a", + "light": "#ffe0e0" + }, + "markdownText": { + "dark": "foreground", + "light": "#282a36" + }, + "markdownHeading": { + "dark": "purple", + "light": "purple" + }, + "markdownLink": { + "dark": "cyan", + "light": "cyan" + }, + "markdownLinkText": { + "dark": "pink", + "light": "pink" + }, + "markdownCode": { + "dark": "green", + "light": "green" + }, + "markdownBlockQuote": { + "dark": "comment", + "light": "#6272a4" + }, + "markdownEmph": { + "dark": "yellow", + "light": "yellow" + }, + "markdownStrong": { + "dark": "orange", + "light": "orange" + }, + "markdownHorizontalRule": { + "dark": "comment", + "light": "#6272a4" + }, + "markdownListItem": { + "dark": "purple", + "light": "purple" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImage": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImageText": { + "dark": "pink", + "light": "pink" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#282a36" + }, + "syntaxComment": { + "dark": "comment", + "light": "#6272a4" + }, + "syntaxKeyword": { + "dark": "pink", + "light": "pink" + }, + "syntaxFunction": { + "dark": "green", + "light": "green" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#282a36" + }, + "syntaxString": { + "dark": "yellow", + "light": "yellow" + }, + "syntaxNumber": { + "dark": "purple", + "light": "purple" + }, + "syntaxType": { + "dark": "cyan", + "light": "cyan" + }, + "syntaxOperator": { + "dark": "pink", + "light": "pink" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#282a36" } } } diff --git a/src/generated_themes/everforest.json b/src/generated_themes/everforest.json new file mode 100644 index 0000000..62dfb31 --- /dev/null +++ b/src/generated_themes/everforest.json @@ -0,0 +1,241 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#2d353b", + "darkStep2": "#333c43", + "darkStep3": "#343f44", + "darkStep4": "#3d484d", + "darkStep5": "#475258", + "darkStep6": "#7a8478", + "darkStep7": "#859289", + "darkStep8": "#9da9a0", + "darkStep9": "#a7c080", + "darkStep10": "#83c092", + "darkStep11": "#7a8478", + "darkStep12": "#d3c6aa", + "darkRed": "#e67e80", + "darkOrange": "#e69875", + "darkGreen": "#a7c080", + "darkCyan": "#83c092", + "darkYellow": "#dbbc7f", + "lightStep1": "#fdf6e3", + "lightStep2": "#efebd4", + "lightStep3": "#f4f0d9", + "lightStep4": "#efebd4", + "lightStep5": "#e6e2cc", + "lightStep6": "#a6b0a0", + "lightStep7": "#939f91", + "lightStep8": "#829181", + "lightStep9": "#8da101", + "lightStep10": "#35a77c", + "lightStep11": "#a6b0a0", + "lightStep12": "#5c6a72", + "lightRed": "#f85552", + "lightOrange": "#f57d26", + "lightGreen": "#8da101", + "lightCyan": "#35a77c", + "lightYellow": "#dfa000" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "#7fbbb3", + "light": "#3a94c5" + }, + "accent": { + "dark": "#d699b6", + "light": "#df69ba" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "darkStep7", + "light": "lightStep7" + }, + "borderActive": { + "dark": "darkStep8", + "light": "lightStep8" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#4fd6be", + "light": "#1e725c" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#b8db87", + "light": "#4db380" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#20303b", + "light": "#d5e5d5" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#1b2b34", + "light": "#c5d5c5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "#d699b6", + "light": "#df69ba" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "#d699b6", + "light": "#df69ba" + }, + "syntaxFunction": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/generated_themes/flexoki.json b/src/generated_themes/flexoki.json new file mode 100644 index 0000000..e525705 --- /dev/null +++ b/src/generated_themes/flexoki.json @@ -0,0 +1,237 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "black": "#100F0F", + "base950": "#1C1B1A", + "base900": "#282726", + "base850": "#343331", + "base800": "#403E3C", + "base700": "#575653", + "base600": "#6F6E69", + "base500": "#878580", + "base300": "#B7B5AC", + "base200": "#CECDC3", + "base150": "#DAD8CE", + "base100": "#E6E4D9", + "base50": "#F2F0E5", + "paper": "#FFFCF0", + "red400": "#D14D41", + "red600": "#AF3029", + "orange400": "#DA702C", + "orange600": "#BC5215", + "yellow400": "#D0A215", + "yellow600": "#AD8301", + "green400": "#879A39", + "green600": "#66800B", + "cyan400": "#3AA99F", + "cyan600": "#24837B", + "blue400": "#4385BE", + "blue600": "#205EA6", + "purple400": "#8B7EC8", + "purple600": "#5E409D", + "magenta400": "#CE5D97", + "magenta600": "#A02F6F" + }, + "theme": { + "primary": { + "dark": "orange400", + "light": "blue600" + }, + "secondary": { + "dark": "blue400", + "light": "purple600" + }, + "accent": { + "dark": "purple400", + "light": "orange600" + }, + "error": { + "dark": "red400", + "light": "red600" + }, + "warning": { + "dark": "orange400", + "light": "orange600" + }, + "success": { + "dark": "green400", + "light": "green600" + }, + "info": { + "dark": "cyan400", + "light": "cyan600" + }, + "text": { + "dark": "base200", + "light": "black" + }, + "textMuted": { + "dark": "base600", + "light": "base600" + }, + "background": { + "dark": "black", + "light": "paper" + }, + "backgroundPanel": { + "dark": "base950", + "light": "base50" + }, + "backgroundElement": { + "dark": "base900", + "light": "base100" + }, + "border": { + "dark": "base700", + "light": "base300" + }, + "borderActive": { + "dark": "base600", + "light": "base500" + }, + "borderSubtle": { + "dark": "base800", + "light": "base200" + }, + "diffAdded": { + "dark": "green400", + "light": "green600" + }, + "diffRemoved": { + "dark": "red400", + "light": "red600" + }, + "diffContext": { + "dark": "base600", + "light": "base600" + }, + "diffHunkHeader": { + "dark": "blue400", + "light": "blue600" + }, + "diffHighlightAdded": { + "dark": "green400", + "light": "green600" + }, + "diffHighlightRemoved": { + "dark": "red400", + "light": "red600" + }, + "diffAddedBg": { + "dark": "#1A2D1A", + "light": "#D5E5D5" + }, + "diffRemovedBg": { + "dark": "#2D1A1A", + "light": "#F7D8DB" + }, + "diffContextBg": { + "dark": "base950", + "light": "base50" + }, + "diffLineNumber": { + "dark": "base600", + "light": "base600" + }, + "diffAddedLineNumberBg": { + "dark": "#152515", + "light": "#C5D5C5" + }, + "diffRemovedLineNumberBg": { + "dark": "#251515", + "light": "#E7C8CB" + }, + "markdownText": { + "dark": "base200", + "light": "black" + }, + "markdownHeading": { + "dark": "purple400", + "light": "purple600" + }, + "markdownLink": { + "dark": "blue400", + "light": "blue600" + }, + "markdownLinkText": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownCode": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownBlockQuote": { + "dark": "yellow400", + "light": "yellow600" + }, + "markdownEmph": { + "dark": "yellow400", + "light": "yellow600" + }, + "markdownStrong": { + "dark": "orange400", + "light": "orange600" + }, + "markdownHorizontalRule": { + "dark": "base600", + "light": "base600" + }, + "markdownListItem": { + "dark": "orange400", + "light": "orange600" + }, + "markdownListEnumeration": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownImage": { + "dark": "magenta400", + "light": "magenta600" + }, + "markdownImageText": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownCodeBlock": { + "dark": "base200", + "light": "black" + }, + "syntaxComment": { + "dark": "base600", + "light": "base600" + }, + "syntaxKeyword": { + "dark": "green400", + "light": "green600" + }, + "syntaxFunction": { + "dark": "orange400", + "light": "orange600" + }, + "syntaxVariable": { + "dark": "blue400", + "light": "blue600" + }, + "syntaxString": { + "dark": "cyan400", + "light": "cyan600" + }, + "syntaxNumber": { + "dark": "purple400", + "light": "purple600" + }, + "syntaxType": { + "dark": "yellow400", + "light": "yellow600" + }, + "syntaxOperator": { + "dark": "base300", + "light": "base600" + }, + "syntaxPunctuation": { + "dark": "base300", + "light": "base600" + } + } +} diff --git a/src/generated_themes/github.json b/src/generated_themes/github.json new file mode 100644 index 0000000..99a8087 --- /dev/null +++ b/src/generated_themes/github.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#0d1117", + "darkBgAlt": "#010409", + "darkBgPanel": "#161b22", + "darkFg": "#c9d1d9", + "darkFgMuted": "#8b949e", + "darkBlue": "#58a6ff", + "darkGreen": "#3fb950", + "darkRed": "#f85149", + "darkOrange": "#d29922", + "darkPurple": "#bc8cff", + "darkPink": "#ff7b72", + "darkYellow": "#e3b341", + "darkCyan": "#39c5cf", + "lightBg": "#ffffff", + "lightBgAlt": "#f6f8fa", + "lightBgPanel": "#f0f3f6", + "lightFg": "#24292f", + "lightFgMuted": "#57606a", + "lightBlue": "#0969da", + "lightGreen": "#1a7f37", + "lightRed": "#cf222e", + "lightOrange": "#bc4c00", + "lightPurple": "#8250df", + "lightPink": "#bf3989", + "lightYellow": "#9a6700", + "lightCyan": "#1b7c83" + }, + "theme": { + "primary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "accent": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "backgroundElement": { + "dark": "darkBgPanel", + "light": "lightBgPanel" + }, + "border": { + "dark": "#30363d", + "light": "#d0d7de" + }, + "borderActive": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "borderSubtle": { + "dark": "#21262d", + "light": "#d8dee4" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "diffHunkHeader": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "diffHighlightAdded": { + "dark": "#3fb950", + "light": "#1a7f37" + }, + "diffHighlightRemoved": { + "dark": "#f85149", + "light": "#cf222e" + }, + "diffAddedBg": { + "dark": "#033a16", + "light": "#dafbe1" + }, + "diffRemovedBg": { + "dark": "#67060c", + "light": "#ffebe9" + }, + "diffContextBg": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "diffLineNumber": { + "dark": "#484f58", + "light": "#afb8c1" + }, + "diffAddedLineNumberBg": { + "dark": "#033a16", + "light": "#dafbe1" + }, + "diffRemovedLineNumberBg": { + "dark": "#67060c", + "light": "#ffebe9" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkPink", + "light": "lightPink" + }, + "markdownBlockQuote": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "#30363d", + "light": "#d0d7de" + }, + "markdownListItem": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "syntaxKeyword": { + "dark": "darkPink", + "light": "lightRed" + }, + "syntaxFunction": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxVariable": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxString": { + "dark": "darkCyan", + "light": "lightBlue" + }, + "syntaxNumber": { + "dark": "darkBlue", + "light": "lightCyan" + }, + "syntaxType": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxOperator": { + "dark": "darkPink", + "light": "lightRed" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/generated_themes/gruvbox.json b/src/generated_themes/gruvbox.json index cf87ccd..dcae302 100644 --- a/src/generated_themes/gruvbox.json +++ b/src/generated_themes/gruvbox.json @@ -1,132 +1,242 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Gruvbox", - "id": "gruvbox", - "light": { - "seeds": { - "neutral": "#fbf1c7", - "primary": "#076678", - "success": "#79740e", - "warning": "#b57614", - "error": "#9d0006", - "info": "#8f3f71", - "interactive": "#076678", - "diffAdd": "#79740e", - "diffDelete": "#9d0006" - }, - "overrides": { - "background-base": "#fbf1c7", - "background-weak": "#f2e5bc", - "background-strong": "#f9f5d7", - "background-stronger": "#fdf9e8", - "surface-raised-stronger-non-alpha": "#fbfaf5", - "border-weak-base": "#d5c4a1", - "border-weak-hover": "#c9b897", - "border-weak-active": "#bdae93", - "border-weak-selected": "#b0a285", - "border-weak-disabled": "#f0e4b8", - "border-weak-focus": "#c4b590", - "border-base": "#bdae93", - "border-hover": "#b0a285", - "border-active": "#a89984", - "border-selected": "#928374", - "border-disabled": "#e5d9ad", - "border-focus": "#a89984", - "border-strong-base": "#7c6f64", - "border-strong-hover": "#6e6259", - "border-strong-active": "#665c54", - "border-strong-selected": "#5a524b", - "border-strong-disabled": "#c9bda1", - "border-strong-focus": "#665c54", - "surface-diff-add-base": "#dde3b1", - "surface-diff-delete-base": "#e8c7c3", - "surface-diff-hidden-base": "#ebdfb5", - "text-base": "#3c3836", - "text-weak": "#7c6f64", - "text-strong": "#282828", - "syntax-string": "#79740e", - "syntax-primitive": "#9d0006", - "syntax-property": "#076678", - "syntax-type": "#b57614", - "syntax-constant": "#8f3f71", - "syntax-info": "#427b58", - "markdown-heading": "#076678", - "markdown-text": "#3c3836", - "markdown-link": "#076678", - "markdown-link-text": "#427b58", - "markdown-code": "#79740e", - "markdown-block-quote": "#928374", - "markdown-emph": "#8f3f71", - "markdown-strong": "#af3a03", - "markdown-horizontal-rule": "#d5c4a1", - "markdown-list-item": "#076678", - "markdown-list-enumeration": "#427b58", - "markdown-image": "#076678", - "markdown-image-text": "#427b58", - "markdown-code-block": "#3c3836" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg0": "#282828", + "darkBg1": "#3c3836", + "darkBg2": "#504945", + "darkBg3": "#665c54", + "darkFg0": "#fbf1c7", + "darkFg1": "#ebdbb2", + "darkGray": "#928374", + "darkRed": "#cc241d", + "darkGreen": "#98971a", + "darkYellow": "#d79921", + "darkBlue": "#458588", + "darkPurple": "#b16286", + "darkAqua": "#689d6a", + "darkOrange": "#d65d0e", + "darkRedBright": "#fb4934", + "darkGreenBright": "#b8bb26", + "darkYellowBright": "#fabd2f", + "darkBlueBright": "#83a598", + "darkPurpleBright": "#d3869b", + "darkAquaBright": "#8ec07c", + "darkOrangeBright": "#fe8019", + "lightBg0": "#fbf1c7", + "lightBg1": "#ebdbb2", + "lightBg2": "#d5c4a1", + "lightBg3": "#bdae93", + "lightFg0": "#282828", + "lightFg1": "#3c3836", + "lightGray": "#7c6f64", + "lightRed": "#9d0006", + "lightGreen": "#79740e", + "lightYellow": "#b57614", + "lightBlue": "#076678", + "lightPurple": "#8f3f71", + "lightAqua": "#427b58", + "lightOrange": "#af3a03" }, - "dark": { - "seeds": { - "neutral": "#282828", - "primary": "#83a598", - "success": "#b8bb26", - "warning": "#fabd2f", - "error": "#fb4934", - "info": "#d3869b", - "interactive": "#83a598", - "diffAdd": "#b8bb26", - "diffDelete": "#fb4934" - }, - "overrides": { - "background-base": "#282828", - "background-weak": "#32302f", - "background-strong": "#1d2021", - "background-stronger": "#141617", - "border-weak-base": "#504945", - "border-weak-hover": "#5a524b", - "border-weak-active": "#665c54", - "border-weak-selected": "#70665d", - "border-weak-disabled": "#1e1d1c", - "border-weak-focus": "#5e5650", - "border-base": "#665c54", - "border-hover": "#70665d", - "border-active": "#7c6f64", - "border-selected": "#928374", - "border-disabled": "#2a2827", - "border-focus": "#7c6f64", - "border-strong-base": "#928374", - "border-strong-hover": "#9d8e7f", - "border-strong-active": "#a89984", - "border-strong-selected": "#b3a48f", - "border-strong-disabled": "#3c3836", - "border-strong-focus": "#a89984", - "surface-diff-add-base": "#2a3325", - "surface-diff-delete-base": "#3c2222", - "surface-diff-hidden-base": "#32302f", - "text-base": "#ebdbb2", - "text-weak": "#a89984", - "text-strong": "#fbf1c7", - "syntax-string": "#b8bb26", - "syntax-primitive": "#fb4934", - "syntax-property": "#83a598", - "syntax-type": "#fabd2f", - "syntax-constant": "#d3869b", - "syntax-info": "#8ec07c", - "markdown-heading": "#83a598", - "markdown-text": "#ebdbb2", - "markdown-link": "#83a598", - "markdown-link-text": "#8ec07c", - "markdown-code": "#b8bb26", - "markdown-block-quote": "#928374", - "markdown-emph": "#d3869b", - "markdown-strong": "#fe8019", - "markdown-horizontal-rule": "#504945", - "markdown-list-item": "#83a598", - "markdown-list-enumeration": "#8ec07c", - "markdown-image": "#83a598", - "markdown-image-text": "#8ec07c", - "markdown-code-block": "#ebdbb2" + "theme": { + "primary": { + "dark": "darkBlueBright", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkPurpleBright", + "light": "lightPurple" + }, + "accent": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "error": { + "dark": "darkRedBright", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrangeBright", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "info": { + "dark": "darkYellowBright", + "light": "lightYellow" + }, + "text": { + "dark": "darkFg1", + "light": "lightFg1" + }, + "textMuted": { + "dark": "darkGray", + "light": "lightGray" + }, + "background": { + "dark": "darkBg0", + "light": "lightBg0" + }, + "backgroundPanel": { + "dark": "darkBg1", + "light": "lightBg1" + }, + "backgroundElement": { + "dark": "darkBg2", + "light": "lightBg2" + }, + "border": { + "dark": "darkBg3", + "light": "lightBg3" + }, + "borderActive": { + "dark": "darkFg1", + "light": "lightFg1" + }, + "borderSubtle": { + "dark": "darkBg2", + "light": "lightBg2" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkGray", + "light": "lightGray" + }, + "diffHunkHeader": { + "dark": "darkAqua", + "light": "lightAqua" + }, + "diffHighlightAdded": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRedBright", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#32302f", + "light": "#dcd8a4" + }, + "diffRemovedBg": { + "dark": "#322929", + "light": "#e2c7c3" + }, + "diffContextBg": { + "dark": "darkBg1", + "light": "lightBg1" + }, + "diffLineNumber": { + "dark": "darkBg3", + "light": "lightBg3" + }, + "diffAddedLineNumberBg": { + "dark": "#2a2827", + "light": "#cec99e" + }, + "diffRemovedLineNumberBg": { + "dark": "#2a2222", + "light": "#d3bdb9" + }, + "markdownText": { + "dark": "darkFg1", + "light": "lightFg1" + }, + "markdownHeading": { + "dark": "darkBlueBright", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "markdownLinkText": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "markdownCode": { + "dark": "darkYellowBright", + "light": "lightYellow" + }, + "markdownBlockQuote": { + "dark": "darkGray", + "light": "lightGray" + }, + "markdownEmph": { + "dark": "darkPurpleBright", + "light": "lightPurple" + }, + "markdownStrong": { + "dark": "darkOrangeBright", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkGray", + "light": "lightGray" + }, + "markdownListItem": { + "dark": "darkBlueBright", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "markdownImage": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "markdownImageText": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "markdownCodeBlock": { + "dark": "darkFg1", + "light": "lightFg1" + }, + "syntaxComment": { + "dark": "darkGray", + "light": "lightGray" + }, + "syntaxKeyword": { + "dark": "darkRedBright", + "light": "lightRed" + }, + "syntaxFunction": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "syntaxVariable": { + "dark": "darkBlueBright", + "light": "lightBlue" + }, + "syntaxString": { + "dark": "darkYellowBright", + "light": "lightYellow" + }, + "syntaxNumber": { + "dark": "darkPurpleBright", + "light": "lightPurple" + }, + "syntaxType": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "syntaxOperator": { + "dark": "darkOrangeBright", + "light": "lightOrange" + }, + "syntaxPunctuation": { + "dark": "darkFg1", + "light": "lightFg1" } } } diff --git a/src/generated_themes/kanagawa.json b/src/generated_themes/kanagawa.json new file mode 100644 index 0000000..91a7840 --- /dev/null +++ b/src/generated_themes/kanagawa.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "sumiInk0": "#1F1F28", + "sumiInk1": "#2A2A37", + "sumiInk2": "#363646", + "sumiInk3": "#54546D", + "fujiWhite": "#DCD7BA", + "oldWhite": "#C8C093", + "fujiGray": "#727169", + "oniViolet": "#957FB8", + "crystalBlue": "#7E9CD8", + "carpYellow": "#C38D9D", + "sakuraPink": "#D27E99", + "waveAqua": "#76946A", + "roninYellow": "#D7A657", + "dragonRed": "#E82424", + "lotusGreen": "#98BB6C", + "waveBlue": "#2D4F67", + "lightBg": "#F2E9DE", + "lightPaper": "#EAE4D7", + "lightText": "#54433A", + "lightGray": "#9E9389" + }, + "theme": { + "primary": { "dark": "crystalBlue", "light": "waveBlue" }, + "secondary": { "dark": "oniViolet", "light": "oniViolet" }, + "accent": { "dark": "sakuraPink", "light": "sakuraPink" }, + "error": { "dark": "dragonRed", "light": "dragonRed" }, + "warning": { "dark": "roninYellow", "light": "roninYellow" }, + "success": { "dark": "lotusGreen", "light": "lotusGreen" }, + "info": { "dark": "waveAqua", "light": "waveAqua" }, + "text": { "dark": "fujiWhite", "light": "lightText" }, + "textMuted": { "dark": "fujiGray", "light": "lightGray" }, + "background": { "dark": "sumiInk0", "light": "lightBg" }, + "backgroundPanel": { "dark": "sumiInk1", "light": "lightPaper" }, + "backgroundElement": { "dark": "sumiInk2", "light": "#E3DCD2" }, + "border": { "dark": "sumiInk3", "light": "#D4CBBF" }, + "borderActive": { "dark": "carpYellow", "light": "carpYellow" }, + "borderSubtle": { "dark": "sumiInk2", "light": "#DCD4C9" }, + "diffAdded": { "dark": "lotusGreen", "light": "lotusGreen" }, + "diffRemoved": { "dark": "dragonRed", "light": "dragonRed" }, + "diffContext": { "dark": "fujiGray", "light": "lightGray" }, + "diffHunkHeader": { "dark": "waveBlue", "light": "waveBlue" }, + "diffHighlightAdded": { "dark": "#A9D977", "light": "#89AF5B" }, + "diffHighlightRemoved": { "dark": "#F24A4A", "light": "#D61F1F" }, + "diffAddedBg": { "dark": "#252E25", "light": "#EAF3E4" }, + "diffRemovedBg": { "dark": "#362020", "light": "#FBE6E6" }, + "diffContextBg": { "dark": "sumiInk1", "light": "lightPaper" }, + "diffLineNumber": { "dark": "sumiInk3", "light": "#C7BEB4" }, + "diffAddedLineNumberBg": { "dark": "#202820", "light": "#DDE8D6" }, + "diffRemovedLineNumberBg": { "dark": "#2D1C1C", "light": "#F2DADA" }, + "markdownText": { "dark": "fujiWhite", "light": "lightText" }, + "markdownHeading": { "dark": "oniViolet", "light": "oniViolet" }, + "markdownLink": { "dark": "crystalBlue", "light": "waveBlue" }, + "markdownLinkText": { "dark": "waveAqua", "light": "waveAqua" }, + "markdownCode": { "dark": "lotusGreen", "light": "lotusGreen" }, + "markdownBlockQuote": { "dark": "fujiGray", "light": "lightGray" }, + "markdownEmph": { "dark": "carpYellow", "light": "carpYellow" }, + "markdownStrong": { "dark": "roninYellow", "light": "roninYellow" }, + "markdownHorizontalRule": { "dark": "fujiGray", "light": "lightGray" }, + "markdownListItem": { "dark": "crystalBlue", "light": "waveBlue" }, + "markdownListEnumeration": { "dark": "waveAqua", "light": "waveAqua" }, + "markdownImage": { "dark": "crystalBlue", "light": "waveBlue" }, + "markdownImageText": { "dark": "waveAqua", "light": "waveAqua" }, + "markdownCodeBlock": { "dark": "fujiWhite", "light": "lightText" }, + "syntaxComment": { "dark": "fujiGray", "light": "lightGray" }, + "syntaxKeyword": { "dark": "oniViolet", "light": "oniViolet" }, + "syntaxFunction": { "dark": "crystalBlue", "light": "waveBlue" }, + "syntaxVariable": { "dark": "fujiWhite", "light": "lightText" }, + "syntaxString": { "dark": "lotusGreen", "light": "lotusGreen" }, + "syntaxNumber": { "dark": "roninYellow", "light": "roninYellow" }, + "syntaxType": { "dark": "carpYellow", "light": "carpYellow" }, + "syntaxOperator": { "dark": "sakuraPink", "light": "sakuraPink" }, + "syntaxPunctuation": { "dark": "fujiWhite", "light": "lightText" } + } +} diff --git a/src/generated_themes/lucent-orng.json b/src/generated_themes/lucent-orng.json new file mode 100644 index 0000000..036dedf --- /dev/null +++ b/src/generated_themes/lucent-orng.json @@ -0,0 +1,237 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep6": "#3c3c3c", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#EE7948", + "darkAccent": "#FFF7F1", + "darkRed": "#e06c75", + "darkOrange": "#EC5B2B", + "darkBlue": "#6ba1e6", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "darkPanelBg": "#2a1a1599", + "lightStep6": "#d4d4d4", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#EE7948", + "lightAccent": "#c94d24", + "lightRed": "#d1383d", + "lightOrange": "#EC5B2B", + "lightBlue": "#0062d1", + "lightCyan": "#318795", + "lightYellow": "#b0851f", + "lightPanelBg": "#fff5f099" + }, + "theme": { + "primary": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "secondary": { + "dark": "darkSecondary", + "light": "lightSecondary" + }, + "accent": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "selectedListItemText": { + "dark": "#0a0a0a", + "light": "#ffffff" + }, + "background": { + "dark": "transparent", + "light": "transparent" + }, + "backgroundPanel": { + "dark": "transparent", + "light": "transparent" + }, + "backgroundElement": { + "dark": "transparent", + "light": "transparent" + }, + "backgroundMenu": { + "dark": "darkPanelBg", + "light": "lightPanelBg" + }, + "border": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "borderActive": { + "dark": "darkSecondary", + "light": "lightAccent" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffRemovedBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffContextBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffLineNumber": { + "dark": "#666666", + "light": "#999999" + }, + "diffAddedLineNumberBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffRemovedLineNumberBg": { + "dark": "transparent", + "light": "transparent" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownLink": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownBlockQuote": { + "dark": "darkAccent", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkSecondary", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxFunction": { + "dark": "darkSecondary", + "light": "lightAccent" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxNumber": { + "dark": "darkAccent", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/generated_themes/material.json b/src/generated_themes/material.json new file mode 100644 index 0000000..c3a1068 --- /dev/null +++ b/src/generated_themes/material.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#263238", + "darkBgAlt": "#1e272c", + "darkBgPanel": "#37474f", + "darkFg": "#eeffff", + "darkFgMuted": "#546e7a", + "darkRed": "#f07178", + "darkPink": "#f78c6c", + "darkOrange": "#ffcb6b", + "darkYellow": "#ffcb6b", + "darkGreen": "#c3e88d", + "darkCyan": "#89ddff", + "darkBlue": "#82aaff", + "darkPurple": "#c792ea", + "darkViolet": "#bb80b3", + "lightBg": "#fafafa", + "lightBgAlt": "#f5f5f5", + "lightBgPanel": "#e7e7e8", + "lightFg": "#263238", + "lightFgMuted": "#90a4ae", + "lightRed": "#e53935", + "lightPink": "#ec407a", + "lightOrange": "#f4511e", + "lightYellow": "#ffb300", + "lightGreen": "#91b859", + "lightCyan": "#39adb5", + "lightBlue": "#6182b8", + "lightPurple": "#7c4dff", + "lightViolet": "#945eb8" + }, + "theme": { + "primary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "accent": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "backgroundElement": { + "dark": "darkBgPanel", + "light": "lightBgPanel" + }, + "border": { + "dark": "#37474f", + "light": "#e0e0e0" + }, + "borderActive": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "borderSubtle": { + "dark": "#1e272c", + "light": "#eeeeee" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "diffHunkHeader": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "diffHighlightAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "diffLineNumber": { + "dark": "#37474f", + "light": "#cfd8dc" + }, + "diffAddedLineNumberBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownLinkText": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "#37474f", + "light": "#e0e0e0" + }, + "markdownListItem": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImageText": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "syntaxKeyword": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxFunction": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/generated_themes/matrix.json b/src/generated_themes/matrix.json new file mode 100644 index 0000000..3549462 --- /dev/null +++ b/src/generated_themes/matrix.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "matrixInk0": "#0a0e0a", + "matrixInk1": "#0e130d", + "matrixInk2": "#141c12", + "matrixInk3": "#1e2a1b", + "rainGreen": "#2eff6a", + "rainGreenDim": "#1cc24b", + "rainGreenHi": "#62ff94", + "rainCyan": "#00efff", + "rainTeal": "#24f6d9", + "rainPurple": "#c770ff", + "rainOrange": "#ffa83d", + "alertRed": "#ff4b4b", + "alertYellow": "#e6ff57", + "alertBlue": "#30b3ff", + "rainGray": "#8ca391", + "lightBg": "#eef3ea", + "lightPaper": "#e4ebe1", + "lightInk1": "#dae1d7", + "lightText": "#203022", + "lightGray": "#748476" + }, + "theme": { + "primary": { "dark": "rainGreen", "light": "rainGreenDim" }, + "secondary": { "dark": "rainCyan", "light": "rainTeal" }, + "accent": { "dark": "rainPurple", "light": "rainPurple" }, + "error": { "dark": "alertRed", "light": "alertRed" }, + "warning": { "dark": "alertYellow", "light": "alertYellow" }, + "success": { "dark": "rainGreenHi", "light": "rainGreenDim" }, + "info": { "dark": "alertBlue", "light": "alertBlue" }, + "text": { "dark": "rainGreenHi", "light": "lightText" }, + "textMuted": { "dark": "rainGray", "light": "lightGray" }, + "background": { "dark": "matrixInk0", "light": "lightBg" }, + "backgroundPanel": { "dark": "matrixInk1", "light": "lightPaper" }, + "backgroundElement": { "dark": "matrixInk2", "light": "lightInk1" }, + "border": { "dark": "matrixInk3", "light": "lightGray" }, + "borderActive": { "dark": "rainGreen", "light": "rainGreenDim" }, + "borderSubtle": { "dark": "matrixInk2", "light": "lightInk1" }, + "diffAdded": { "dark": "rainGreenDim", "light": "rainGreenDim" }, + "diffRemoved": { "dark": "alertRed", "light": "alertRed" }, + "diffContext": { "dark": "rainGray", "light": "lightGray" }, + "diffHunkHeader": { "dark": "alertBlue", "light": "alertBlue" }, + "diffHighlightAdded": { "dark": "#77ffaf", "light": "#5dac7e" }, + "diffHighlightRemoved": { "dark": "#ff7171", "light": "#d53a3a" }, + "diffAddedBg": { "dark": "#132616", "light": "#e0efde" }, + "diffRemovedBg": { "dark": "#261212", "light": "#f9e5e5" }, + "diffContextBg": { "dark": "matrixInk1", "light": "lightPaper" }, + "diffLineNumber": { "dark": "matrixInk3", "light": "lightGray" }, + "diffAddedLineNumberBg": { "dark": "#0f1b11", "light": "#d6e7d2" }, + "diffRemovedLineNumberBg": { "dark": "#1b1414", "light": "#f2d2d2" }, + "markdownText": { "dark": "rainGreenHi", "light": "lightText" }, + "markdownHeading": { "dark": "rainCyan", "light": "rainTeal" }, + "markdownLink": { "dark": "alertBlue", "light": "alertBlue" }, + "markdownLinkText": { "dark": "rainTeal", "light": "rainTeal" }, + "markdownCode": { "dark": "rainGreenDim", "light": "rainGreenDim" }, + "markdownBlockQuote": { "dark": "rainGray", "light": "lightGray" }, + "markdownEmph": { "dark": "rainOrange", "light": "rainOrange" }, + "markdownStrong": { "dark": "alertYellow", "light": "alertYellow" }, + "markdownHorizontalRule": { "dark": "rainGray", "light": "lightGray" }, + "markdownListItem": { "dark": "alertBlue", "light": "alertBlue" }, + "markdownListEnumeration": { "dark": "rainTeal", "light": "rainTeal" }, + "markdownImage": { "dark": "alertBlue", "light": "alertBlue" }, + "markdownImageText": { "dark": "rainTeal", "light": "rainTeal" }, + "markdownCodeBlock": { "dark": "rainGreenHi", "light": "lightText" }, + "syntaxComment": { "dark": "rainGray", "light": "lightGray" }, + "syntaxKeyword": { "dark": "rainPurple", "light": "rainPurple" }, + "syntaxFunction": { "dark": "alertBlue", "light": "alertBlue" }, + "syntaxVariable": { "dark": "rainGreenHi", "light": "lightText" }, + "syntaxString": { "dark": "rainGreenDim", "light": "rainGreenDim" }, + "syntaxNumber": { "dark": "rainOrange", "light": "rainOrange" }, + "syntaxType": { "dark": "alertYellow", "light": "alertYellow" }, + "syntaxOperator": { "dark": "rainTeal", "light": "rainTeal" }, + "syntaxPunctuation": { "dark": "rainGreenHi", "light": "lightText" } + } +} diff --git a/src/generated_themes/mercury.json b/src/generated_themes/mercury.json new file mode 100644 index 0000000..dfd4f35 --- /dev/null +++ b/src/generated_themes/mercury.json @@ -0,0 +1,252 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "purple-800": "#3442a6", + "purple-700": "#465bd1", + "purple-600": "#5266eb", + "purple-400": "#8da4f5", + "purple-300": "#a7b6f8", + + "red-700": "#b0175f", + "red-600": "#d03275", + "red-400": "#fc92b4", + + "green-700": "#036e43", + "green-600": "#188554", + "green-400": "#77c599", + + "orange-700": "#a44200", + "orange-600": "#c45000", + "orange-400": "#fc9b6f", + + "blue-600": "#007f95", + "blue-400": "#77becf", + + "neutral-1000": "#10101a", + "neutral-950": "#171721", + "neutral-900": "#1e1e2a", + "neutral-800": "#272735", + "neutral-700": "#363644", + "neutral-600": "#535461", + "neutral-500": "#70707d", + "neutral-400": "#9d9da8", + "neutral-300": "#c3c3cc", + "neutral-200": "#dddde5", + "neutral-100": "#f4f5f9", + "neutral-050": "#fbfcfd", + "neutral-000": "#ffffff", + "neutral-150": "#ededf3", + + "border-light": "#7073931a", + "border-light-subtle": "#7073930f", + "border-dark": "#b4b7c81f", + "border-dark-subtle": "#b4b7c814", + + "diff-added-light": "#1885541a", + "diff-removed-light": "#d032751a", + "diff-added-dark": "#77c59933", + "diff-removed-dark": "#fc92b433" + }, + "theme": { + "primary": { + "light": "purple-600", + "dark": "purple-400" + }, + "secondary": { + "light": "purple-700", + "dark": "purple-300" + }, + "accent": { + "light": "purple-400", + "dark": "purple-400" + }, + "error": { + "light": "red-700", + "dark": "red-400" + }, + "warning": { + "light": "orange-700", + "dark": "orange-400" + }, + "success": { + "light": "green-700", + "dark": "green-400" + }, + "info": { + "light": "blue-600", + "dark": "blue-400" + }, + "text": { + "light": "neutral-700", + "dark": "neutral-200" + }, + "textMuted": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "background": { + "light": "neutral-000", + "dark": "neutral-950" + }, + "backgroundPanel": { + "light": "neutral-050", + "dark": "neutral-1000" + }, + "backgroundElement": { + "light": "neutral-100", + "dark": "neutral-800" + }, + "border": { + "light": "border-light", + "dark": "border-dark" + }, + "borderActive": { + "light": "purple-600", + "dark": "purple-400" + }, + "borderSubtle": { + "light": "border-light-subtle", + "dark": "border-dark-subtle" + }, + "diffAdded": { + "light": "green-700", + "dark": "green-400" + }, + "diffRemoved": { + "light": "red-700", + "dark": "red-400" + }, + "diffContext": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "diffHunkHeader": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "diffHighlightAdded": { + "light": "green-700", + "dark": "green-400" + }, + "diffHighlightRemoved": { + "light": "red-700", + "dark": "red-400" + }, + "diffAddedBg": { + "light": "diff-added-light", + "dark": "diff-added-dark" + }, + "diffRemovedBg": { + "light": "diff-removed-light", + "dark": "diff-removed-dark" + }, + "diffContextBg": { + "light": "neutral-050", + "dark": "neutral-900" + }, + "diffLineNumber": { + "light": "neutral-600", + "dark": "neutral-300" + }, + "diffAddedLineNumberBg": { + "light": "diff-added-light", + "dark": "diff-added-dark" + }, + "diffRemovedLineNumberBg": { + "light": "diff-removed-light", + "dark": "diff-removed-dark" + }, + "markdownText": { + "light": "neutral-700", + "dark": "neutral-200" + }, + "markdownHeading": { + "light": "neutral-900", + "dark": "neutral-000" + }, + "markdownLink": { + "light": "purple-700", + "dark": "purple-400" + }, + "markdownLinkText": { + "light": "purple-600", + "dark": "purple-300" + }, + "markdownCode": { + "light": "green-700", + "dark": "green-400" + }, + "markdownBlockQuote": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "markdownEmph": { + "light": "orange-700", + "dark": "orange-400" + }, + "markdownStrong": { + "light": "neutral-900", + "dark": "neutral-100" + }, + "markdownHorizontalRule": { + "light": "border-light", + "dark": "border-dark" + }, + "markdownListItem": { + "light": "neutral-900", + "dark": "neutral-000" + }, + "markdownListEnumeration": { + "light": "purple-600", + "dark": "purple-400" + }, + "markdownImage": { + "light": "purple-700", + "dark": "purple-400" + }, + "markdownImageText": { + "light": "purple-600", + "dark": "purple-300" + }, + "markdownCodeBlock": { + "light": "neutral-700", + "dark": "neutral-200" + }, + "syntaxComment": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "syntaxKeyword": { + "light": "purple-700", + "dark": "purple-400" + }, + "syntaxFunction": { + "light": "purple-600", + "dark": "purple-400" + }, + "syntaxVariable": { + "light": "blue-600", + "dark": "blue-400" + }, + "syntaxString": { + "light": "green-700", + "dark": "green-400" + }, + "syntaxNumber": { + "light": "orange-700", + "dark": "orange-400" + }, + "syntaxType": { + "light": "blue-600", + "dark": "blue-400" + }, + "syntaxOperator": { + "light": "purple-700", + "dark": "purple-400" + }, + "syntaxPunctuation": { + "light": "neutral-700", + "dark": "neutral-200" + } + } +} diff --git a/src/generated_themes/monokai.json b/src/generated_themes/monokai.json index d49846d..09637a1 100644 --- a/src/generated_themes/monokai.json +++ b/src/generated_themes/monokai.json @@ -1,131 +1,221 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Monokai", - "id": "monokai", - "light": { - "seeds": { - "neutral": "#fdf8ec", - "primary": "#bf7bff", - "success": "#4fb54b", - "warning": "#f1a948", - "error": "#e54b4b", - "info": "#2d9ad7", - "interactive": "#bf7bff", - "diffAdd": "#bfe7a3", - "diffDelete": "#f6a3ae" - }, - "overrides": { - "background-base": "#fdf8ec", - "background-weak": "#f8f2e6", - "background-strong": "#fbf5e8", - "background-stronger": "#f7efdd", - "border-weak-base": "#e9e0cf", - "border-weak-hover": "#dfd5c3", - "border-weak-active": "#d5cab7", - "border-weak-selected": "#cabfad", - "border-weak-disabled": "#f3ebdd", - "border-weak-focus": "#d0c2b1", - "border-base": "#c7b9a5", - "border-hover": "#bcae98", - "border-active": "#b0a28c", - "border-selected": "#a49781", - "border-disabled": "#efe5d6", - "border-focus": "#b6a893", - "border-strong-base": "#998b76", - "border-strong-hover": "#8a7c67", - "border-strong-active": "#7a6d58", - "border-strong-selected": "#6c604c", - "border-strong-disabled": "#d7cabc", - "border-strong-focus": "#82745f", - "surface-diff-add-base": "#e8f7e1", - "surface-diff-delete-base": "#fde5e4", - "surface-diff-hidden-base": "#e9e0d0", - "text-base": "#292318", - "text-weak": "#6d5c40", - "text-strong": "#1c150c", - "syntax-string": "#4fb54b", - "syntax-primitive": "#d9487c", - "syntax-property": "#bf7bff", - "syntax-type": "#f1a948", - "syntax-constant": "#2d9ad7", - "syntax-info": "#2d9ad7", - "markdown-heading": "#bf7bff", - "markdown-text": "#292318", - "markdown-link": "#bf7bff", - "markdown-link-text": "#2d9ad7", - "markdown-code": "#4fb54b", - "markdown-block-quote": "#f1a948", - "markdown-emph": "#f1a948", - "markdown-strong": "#d9487c", - "markdown-horizontal-rule": "#cdbdab", - "markdown-list-item": "#bf7bff", - "markdown-list-enumeration": "#2d9ad7", - "markdown-image": "#bf7bff", - "markdown-image-text": "#2d9ad7", - "markdown-code-block": "#2d9ad7" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#272822", + "backgroundAlt": "#1e1f1c", + "backgroundPanel": "#3e3d32", + "foreground": "#f8f8f2", + "comment": "#75715e", + "red": "#f92672", + "orange": "#fd971f", + "lightOrange": "#e69f66", + "yellow": "#e6db74", + "green": "#a6e22e", + "cyan": "#66d9ef", + "blue": "#66d9ef", + "purple": "#ae81ff", + "pink": "#f92672" }, - "dark": { - "seeds": { - "neutral": "#272822", - "primary": "#ae81ff", - "success": "#a6e22e", - "warning": "#fd971f", - "error": "#f92672", - "info": "#66d9ef", - "interactive": "#ae81ff", - "diffAdd": "#4d7f2a", - "diffDelete": "#f4477c" - }, - "overrides": { - "background-base": "#23241e", - "background-weak": "#27281f", - "background-strong": "#25261f", - "background-stronger": "#292a23", - "border-weak-base": "#343528", - "border-weak-hover": "#393a2d", - "border-weak-active": "#3f4033", - "border-weak-selected": "#454639", - "border-weak-disabled": "#1d1e16", - "border-weak-focus": "#414235", - "border-base": "#494a3a", - "border-hover": "#50523f", - "border-active": "#585a45", - "border-selected": "#60624b", - "border-disabled": "#23241b", - "border-focus": "#555741", - "border-strong-base": "#6a6c55", - "border-strong-hover": "#73755d", - "border-strong-active": "#7d7f66", - "border-strong-selected": "#878970", - "border-strong-disabled": "#2c2d23", - "border-strong-focus": "#7a7c63", - "surface-diff-add-base": "#1e2a1d", - "surface-diff-delete-base": "#301c24", - "surface-diff-hidden-base": "#2f2f24", - "text-base": "#f8f8f2", - "text-weak": "#c5c5c0", - "text-strong": "#ffffff", - "syntax-string": "#a6e22e", - "syntax-primitive": "#f92672", - "syntax-property": "#ae81ff", - "syntax-type": "#fd971f", - "syntax-constant": "#66d9ef", - "syntax-info": "#66d9ef", - "markdown-heading": "#ae81ff", - "markdown-text": "#f8f8f2", - "markdown-link": "#ae81ff", - "markdown-link-text": "#66d9ef", - "markdown-code": "#a6e22e", - "markdown-block-quote": "#fd971f", - "markdown-emph": "#fd971f", - "markdown-strong": "#f92672", - "markdown-horizontal-rule": "#3b3c34", - "markdown-list-item": "#ae81ff", - "markdown-list-enumeration": "#66d9ef", - "markdown-image": "#ae81ff", - "markdown-image-text": "#66d9ef", - "markdown-code-block": "#f8f8f2" + "theme": { + "primary": { + "dark": "cyan", + "light": "blue" + }, + "secondary": { + "dark": "purple", + "light": "purple" + }, + "accent": { + "dark": "green", + "light": "green" + }, + "error": { + "dark": "red", + "light": "red" + }, + "warning": { + "dark": "yellow", + "light": "orange" + }, + "success": { + "dark": "green", + "light": "green" + }, + "info": { + "dark": "orange", + "light": "orange" + }, + "text": { + "dark": "foreground", + "light": "#272822" + }, + "textMuted": { + "dark": "comment", + "light": "#75715e" + }, + "background": { + "dark": "#272822", + "light": "#fafafa" + }, + "backgroundPanel": { + "dark": "#1e1f1c", + "light": "#f0f0f0" + }, + "backgroundElement": { + "dark": "#3e3d32", + "light": "#e0e0e0" + }, + "border": { + "dark": "#3e3d32", + "light": "#d0d0d0" + }, + "borderActive": { + "dark": "cyan", + "light": "blue" + }, + "borderSubtle": { + "dark": "#1e1f1c", + "light": "#e8e8e8" + }, + "diffAdded": { + "dark": "green", + "light": "green" + }, + "diffRemoved": { + "dark": "red", + "light": "red" + }, + "diffContext": { + "dark": "comment", + "light": "#75715e" + }, + "diffHunkHeader": { + "dark": "comment", + "light": "#75715e" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "green" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "red" + }, + "diffAddedBg": { + "dark": "#1a3a1a", + "light": "#e0ffe0" + }, + "diffRemovedBg": { + "dark": "#3a1a1a", + "light": "#ffe0e0" + }, + "diffContextBg": { + "dark": "#1e1f1c", + "light": "#f0f0f0" + }, + "diffLineNumber": { + "dark": "#3e3d32", + "light": "#d0d0d0" + }, + "diffAddedLineNumberBg": { + "dark": "#1a3a1a", + "light": "#e0ffe0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1a1a", + "light": "#ffe0e0" + }, + "markdownText": { + "dark": "foreground", + "light": "#272822" + }, + "markdownHeading": { + "dark": "pink", + "light": "pink" + }, + "markdownLink": { + "dark": "cyan", + "light": "blue" + }, + "markdownLinkText": { + "dark": "purple", + "light": "purple" + }, + "markdownCode": { + "dark": "green", + "light": "green" + }, + "markdownBlockQuote": { + "dark": "comment", + "light": "#75715e" + }, + "markdownEmph": { + "dark": "yellow", + "light": "orange" + }, + "markdownStrong": { + "dark": "orange", + "light": "orange" + }, + "markdownHorizontalRule": { + "dark": "comment", + "light": "#75715e" + }, + "markdownListItem": { + "dark": "cyan", + "light": "blue" + }, + "markdownListEnumeration": { + "dark": "purple", + "light": "purple" + }, + "markdownImage": { + "dark": "cyan", + "light": "blue" + }, + "markdownImageText": { + "dark": "purple", + "light": "purple" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#272822" + }, + "syntaxComment": { + "dark": "comment", + "light": "#75715e" + }, + "syntaxKeyword": { + "dark": "pink", + "light": "pink" + }, + "syntaxFunction": { + "dark": "green", + "light": "green" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#272822" + }, + "syntaxString": { + "dark": "yellow", + "light": "orange" + }, + "syntaxNumber": { + "dark": "purple", + "light": "purple" + }, + "syntaxType": { + "dark": "cyan", + "light": "blue" + }, + "syntaxOperator": { + "dark": "pink", + "light": "pink" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#272822" } } } diff --git a/src/generated_themes/nightowl.json b/src/generated_themes/nightowl.json index 5b0331e..24c7473 100644 --- a/src/generated_themes/nightowl.json +++ b/src/generated_themes/nightowl.json @@ -1,131 +1,221 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Night Owl", - "id": "nightowl", - "light": { - "seeds": { - "neutral": "#f0f0f0", - "primary": "#4876d6", - "success": "#2aa298", - "warning": "#c96765", - "error": "#de3d3b", - "info": "#4876d6", - "interactive": "#4876d6", - "diffAdd": "#2aa298", - "diffDelete": "#de3d3b" - }, - "overrides": { - "background-base": "#fbfbfb", - "background-weak": "#f0f0f0", - "background-strong": "#ffffff", - "background-stronger": "#ffffff", - "border-weak-base": "#d9d9d9", - "border-weak-hover": "#cccccc", - "border-weak-active": "#bfbfbf", - "border-weak-selected": "#4876d6", - "border-weak-disabled": "#e6e6e6", - "border-weak-focus": "#4876d6", - "border-base": "#c0c0c0", - "border-hover": "#b3b3b3", - "border-active": "#a6a6a6", - "border-selected": "#4876d6", - "border-disabled": "#d9d9d9", - "border-focus": "#4876d6", - "border-strong-base": "#90a7b2", - "border-strong-hover": "#7d9aa6", - "border-strong-active": "#6a8d9a", - "border-strong-selected": "#4876d6", - "border-strong-disabled": "#c0c0c0", - "border-strong-focus": "#4876d6", - "surface-diff-add-base": "#eaf8f6", - "surface-diff-delete-base": "#fbe9e9", - "surface-diff-hidden-base": "#e8f0fc", - "text-base": "#403f53", - "text-weak": "#7a8181", - "text-strong": "#1a1a1a", - "syntax-string": "#c96765", - "syntax-primitive": "#aa0982", - "syntax-property": "#4876d6", - "syntax-type": "#994cc3", - "syntax-constant": "#2aa298", - "syntax-info": "#4876d6", - "markdown-heading": "#4876d6", - "markdown-text": "#403f53", - "markdown-link": "#4876d6", - "markdown-link-text": "#2aa298", - "markdown-code": "#2aa298", - "markdown-block-quote": "#7a8181", - "markdown-emph": "#994cc3", - "markdown-strong": "#c96765", - "markdown-horizontal-rule": "#90a7b2", - "markdown-list-item": "#4876d6", - "markdown-list-enumeration": "#2aa298", - "markdown-image": "#4876d6", - "markdown-image-text": "#2aa298", - "markdown-code-block": "#403f53" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nightOwlBg": "#011627", + "nightOwlFg": "#d6deeb", + "nightOwlBlue": "#82AAFF", + "nightOwlCyan": "#7fdbca", + "nightOwlGreen": "#c5e478", + "nightOwlYellow": "#ecc48d", + "nightOwlOrange": "#F78C6C", + "nightOwlRed": "#EF5350", + "nightOwlPink": "#ff5874", + "nightOwlPurple": "#c792ea", + "nightOwlMuted": "#5f7e97", + "nightOwlGray": "#637777", + "nightOwlLightGray": "#89a4bb", + "nightOwlPanel": "#0b253a" }, - "dark": { - "seeds": { - "neutral": "#011627", - "primary": "#82aaff", - "success": "#c5e478", - "warning": "#ecc48d", - "error": "#ef5350", - "info": "#82aaff", - "interactive": "#82aaff", - "diffAdd": "#c5e478", - "diffDelete": "#ef5350" - }, - "overrides": { - "background-base": "#011627", - "background-weak": "#0b253a", - "background-strong": "#001122", - "background-stronger": "#000c17", - "border-weak-base": "#1d3b53", - "border-weak-hover": "#234561", - "border-weak-active": "#2a506f", - "border-weak-selected": "#82aaff", - "border-weak-disabled": "#0f2132", - "border-weak-focus": "#82aaff", - "border-base": "#3a5a75", - "border-hover": "#456785", - "border-active": "#507494", - "border-selected": "#82aaff", - "border-disabled": "#1a3347", - "border-focus": "#82aaff", - "border-strong-base": "#5f7e97", - "border-strong-hover": "#6e8da6", - "border-strong-active": "#7d9cb5", - "border-strong-selected": "#82aaff", - "border-strong-disabled": "#2c4a63", - "border-strong-focus": "#82aaff", - "surface-diff-add-base": "#0a2e1a", - "surface-diff-delete-base": "#2d1b1b", - "surface-diff-hidden-base": "#0b253a", - "text-base": "#d6deeb", - "text-weak": "#5f7e97", - "text-strong": "#ffffff", - "syntax-string": "#ecc48d", - "syntax-primitive": "#f78c6c", - "syntax-property": "#82aaff", - "syntax-type": "#c5e478", - "syntax-constant": "#7fdbca", - "syntax-info": "#82aaff", - "markdown-heading": "#82aaff", - "markdown-text": "#d6deeb", - "markdown-link": "#82aaff", - "markdown-link-text": "#7fdbca", - "markdown-code": "#c5e478", - "markdown-block-quote": "#5f7e97", - "markdown-emph": "#c792ea", - "markdown-strong": "#ecc48d", - "markdown-horizontal-rule": "#5f7e97", - "markdown-list-item": "#82aaff", - "markdown-list-enumeration": "#7fdbca", - "markdown-image": "#82aaff", - "markdown-image-text": "#7fdbca", - "markdown-code-block": "#d6deeb" + "theme": { + "primary": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "secondary": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "accent": { + "dark": "nightOwlPurple", + "light": "nightOwlPurple" + }, + "error": { + "dark": "nightOwlRed", + "light": "nightOwlRed" + }, + "warning": { + "dark": "nightOwlYellow", + "light": "nightOwlYellow" + }, + "success": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "info": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "text": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "textMuted": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "background": { + "dark": "nightOwlBg", + "light": "nightOwlBg" + }, + "backgroundPanel": { + "dark": "nightOwlPanel", + "light": "nightOwlPanel" + }, + "backgroundElement": { + "dark": "nightOwlPanel", + "light": "nightOwlPanel" + }, + "border": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "borderActive": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "borderSubtle": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffAdded": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "diffRemoved": { + "dark": "nightOwlRed", + "light": "nightOwlRed" + }, + "diffContext": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffHunkHeader": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffHighlightAdded": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "diffHighlightRemoved": { + "dark": "nightOwlRed", + "light": "nightOwlRed" + }, + "diffAddedBg": { + "dark": "#0a2e1a", + "light": "#0a2e1a" + }, + "diffRemovedBg": { + "dark": "#2d1b1b", + "light": "#2d1b1b" + }, + "diffContextBg": { + "dark": "nightOwlPanel", + "light": "nightOwlPanel" + }, + "diffLineNumber": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffAddedLineNumberBg": { + "dark": "#0a2e1a", + "light": "#0a2e1a" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1b1b", + "light": "#2d1b1b" + }, + "markdownText": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "markdownHeading": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownLink": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "markdownLinkText": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownCode": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "markdownBlockQuote": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "markdownEmph": { + "dark": "nightOwlPurple", + "light": "nightOwlPurple" + }, + "markdownStrong": { + "dark": "nightOwlYellow", + "light": "nightOwlYellow" + }, + "markdownHorizontalRule": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "markdownListItem": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownListEnumeration": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "markdownImage": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "markdownImageText": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownCodeBlock": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "syntaxComment": { + "dark": "nightOwlGray", + "light": "nightOwlGray" + }, + "syntaxKeyword": { + "dark": "nightOwlPurple", + "light": "nightOwlPurple" + }, + "syntaxFunction": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "syntaxVariable": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "syntaxString": { + "dark": "nightOwlYellow", + "light": "nightOwlYellow" + }, + "syntaxNumber": { + "dark": "nightOwlOrange", + "light": "nightOwlOrange" + }, + "syntaxType": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "syntaxOperator": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "syntaxPunctuation": { + "dark": "nightOwlFg", + "light": "nightOwlFg" } } } diff --git a/src/generated_themes/nord.json b/src/generated_themes/nord.json index 44378de..4a52538 100644 --- a/src/generated_themes/nord.json +++ b/src/generated_themes/nord.json @@ -1,131 +1,223 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Nord", - "id": "nord", - "light": { - "seeds": { - "neutral": "#eceff4", - "primary": "#5e81ac", - "success": "#8fbcbb", - "warning": "#d08770", - "error": "#bf616a", - "info": "#81a1c1", - "interactive": "#5e81ac", - "diffAdd": "#a3be8c", - "diffDelete": "#bf616a" - }, - "overrides": { - "background-base": "#eceff4", - "background-weak": "#e4e8f0", - "background-strong": "#f1f3f8", - "background-stronger": "#f6f8fc", - "border-weak-base": "#d5dbe7", - "border-weak-hover": "#c9d0de", - "border-weak-active": "#bec5d4", - "border-weak-selected": "#b2bacc", - "border-weak-disabled": "#f0f3fa", - "border-weak-focus": "#b9bfd0", - "border-base": "#afb7cb", - "border-hover": "#a3abc1", - "border-active": "#979fb7", - "border-selected": "#8b94ad", - "border-disabled": "#e5e9f2", - "border-focus": "#9ca4ba", - "border-strong-base": "#757f97", - "border-strong-hover": "#69718a", - "border-strong-active": "#5d647d", - "border-strong-selected": "#525970", - "border-strong-disabled": "#c9cedc", - "border-strong-focus": "#636c84", - "surface-diff-add-base": "#e4f0e4", - "surface-diff-delete-base": "#f4e1e4", - "surface-diff-hidden-base": "#dfe6f2", - "text-base": "#2e3440", - "text-weak": "#4c566a", - "text-strong": "#1f2530", - "syntax-string": "#a3be8c", - "syntax-primitive": "#bf616a", - "syntax-property": "#5e81ac", - "syntax-type": "#d08770", - "syntax-constant": "#81a1c1", - "syntax-info": "#81a1c1", - "markdown-heading": "#5e81ac", - "markdown-text": "#2e3440", - "markdown-link": "#5e81ac", - "markdown-link-text": "#81a1c1", - "markdown-code": "#a3be8c", - "markdown-block-quote": "#d08770", - "markdown-emph": "#d08770", - "markdown-strong": "#bf616a", - "markdown-horizontal-rule": "#cbd3e1", - "markdown-list-item": "#5e81ac", - "markdown-list-enumeration": "#81a1c1", - "markdown-image": "#5e81ac", - "markdown-image-text": "#81a1c1", - "markdown-code-block": "#5e81ac" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nord0": "#2E3440", + "nord1": "#3B4252", + "nord2": "#434C5E", + "nord3": "#4C566A", + "nord4": "#D8DEE9", + "nord5": "#E5E9F0", + "nord6": "#ECEFF4", + "nord7": "#8FBCBB", + "nord8": "#88C0D0", + "nord9": "#81A1C1", + "nord10": "#5E81AC", + "nord11": "#BF616A", + "nord12": "#D08770", + "nord13": "#EBCB8B", + "nord14": "#A3BE8C", + "nord15": "#B48EAD" }, - "dark": { - "seeds": { - "neutral": "#2e3440", - "primary": "#88c0d0", - "success": "#a3be8c", - "warning": "#d08770", - "error": "#bf616a", - "info": "#81a1c1", - "interactive": "#88c0d0", - "diffAdd": "#81a1c1", - "diffDelete": "#bf616a" - }, - "overrides": { - "background-base": "#1f2430", - "background-weak": "#222938", - "background-strong": "#1c202a", - "background-stronger": "#181c24", - "border-weak-base": "#343a47", - "border-weak-hover": "#383f50", - "border-weak-active": "#3d4458", - "border-weak-selected": "#434a62", - "border-weak-disabled": "#151923", - "border-weak-focus": "#3f4359", - "border-base": "#4a5163", - "border-hover": "#515870", - "border-active": "#585f7c", - "border-selected": "#606889", - "border-disabled": "#1b202a", - "border-focus": "#545b78", - "border-strong-base": "#6a7492", - "border-strong-hover": "#747e9f", - "border-strong-active": "#7e88ac", - "border-strong-selected": "#8993b9", - "border-strong-disabled": "#232836", - "border-strong-focus": "#76819f", - "surface-diff-add-base": "#1f2e33", - "surface-diff-delete-base": "#2e212a", - "surface-diff-hidden-base": "#222b3a", - "text-base": "#e5e9f0", - "text-weak": "#a4adbf", - "text-strong": "#f8fafc", - "syntax-string": "#a3be8c", - "syntax-primitive": "#d57780", - "syntax-property": "#88c0d0", - "syntax-type": "#eac196", - "syntax-constant": "#81a1c1", - "syntax-info": "#81a1c1", - "markdown-heading": "#88c0d0", - "markdown-text": "#e5e9f0", - "markdown-link": "#88c0d0", - "markdown-link-text": "#81a1c1", - "markdown-code": "#a3be8c", - "markdown-block-quote": "#d08770", - "markdown-emph": "#d08770", - "markdown-strong": "#bf616a", - "markdown-horizontal-rule": "#2f384a", - "markdown-list-item": "#88c0d0", - "markdown-list-enumeration": "#81a1c1", - "markdown-image": "#88c0d0", - "markdown-image-text": "#81a1c1", - "markdown-code-block": "#cbd3e1" + "theme": { + "primary": { + "dark": "nord8", + "light": "nord10" + }, + "secondary": { + "dark": "nord9", + "light": "nord9" + }, + "accent": { + "dark": "nord7", + "light": "nord7" + }, + "error": { + "dark": "nord11", + "light": "nord11" + }, + "warning": { + "dark": "nord12", + "light": "nord12" + }, + "success": { + "dark": "nord14", + "light": "nord14" + }, + "info": { + "dark": "nord8", + "light": "nord10" + }, + "text": { + "dark": "nord6", + "light": "nord0" + }, + "textMuted": { + "dark": "#8B95A7", + "light": "nord1" + }, + "background": { + "dark": "nord0", + "light": "nord6" + }, + "backgroundPanel": { + "dark": "nord1", + "light": "nord5" + }, + "backgroundElement": { + "dark": "nord2", + "light": "nord4" + }, + "border": { + "dark": "nord2", + "light": "nord3" + }, + "borderActive": { + "dark": "nord3", + "light": "nord2" + }, + "borderSubtle": { + "dark": "nord2", + "light": "nord3" + }, + "diffAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffContext": { + "dark": "#8B95A7", + "light": "nord3" + }, + "diffHunkHeader": { + "dark": "#8B95A7", + "light": "nord3" + }, + "diffHighlightAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffHighlightRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffAddedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffContextBg": { + "dark": "nord1", + "light": "nord5" + }, + "diffLineNumber": { + "dark": "nord2", + "light": "nord4" + }, + "diffAddedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "markdownText": { + "dark": "nord4", + "light": "nord0" + }, + "markdownHeading": { + "dark": "nord8", + "light": "nord10" + }, + "markdownLink": { + "dark": "nord9", + "light": "nord9" + }, + "markdownLinkText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCode": { + "dark": "nord14", + "light": "nord14" + }, + "markdownBlockQuote": { + "dark": "#8B95A7", + "light": "nord3" + }, + "markdownEmph": { + "dark": "nord12", + "light": "nord12" + }, + "markdownStrong": { + "dark": "nord13", + "light": "nord13" + }, + "markdownHorizontalRule": { + "dark": "#8B95A7", + "light": "nord3" + }, + "markdownListItem": { + "dark": "nord8", + "light": "nord10" + }, + "markdownListEnumeration": { + "dark": "nord7", + "light": "nord7" + }, + "markdownImage": { + "dark": "nord9", + "light": "nord9" + }, + "markdownImageText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCodeBlock": { + "dark": "nord4", + "light": "nord0" + }, + "syntaxComment": { + "dark": "#8B95A7", + "light": "nord3" + }, + "syntaxKeyword": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxFunction": { + "dark": "nord8", + "light": "nord8" + }, + "syntaxVariable": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxString": { + "dark": "nord14", + "light": "nord14" + }, + "syntaxNumber": { + "dark": "nord15", + "light": "nord15" + }, + "syntaxType": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxOperator": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxPunctuation": { + "dark": "nord4", + "light": "nord0" } } } diff --git a/src/generated_themes/oc-1.json b/src/generated_themes/oc-1.json deleted file mode 100644 index fe04b19..0000000 --- a/src/generated_themes/oc-1.json +++ /dev/null @@ -1,535 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "OC-1", - "id": "oc-1", - "light": { - "seeds": { - "neutral": "#8e8b8b", - "primary": "#dcde8d", - "success": "#12c905", - "warning": "#ffdc17", - "error": "#fc533a", - "info": "#a753ae", - "interactive": "#034cff", - "diffAdd": "#9ff29a", - "diffDelete": "#fc533a" - }, - "overrides": { - "background-base": "#f8f7f7", - "background-weak": "var(--smoke-light-3)", - "background-strong": "var(--smoke-light-1)", - "background-stronger": "#fcfcfc", - "surface-base": "var(--smoke-light-alpha-2)", - "base": "var(--smoke-light-alpha-2)", - "surface-base-hover": "#0500000f", - "surface-base-active": "var(--smoke-light-alpha-3)", - "surface-base-interactive-active": "var(--cobalt-light-alpha-3)", - "base2": "var(--smoke-light-alpha-2)", - "base3": "var(--smoke-light-alpha-2)", - "surface-inset-base": "var(--smoke-light-alpha-2)", - "surface-inset-base-hover": "var(--smoke-light-alpha-3)", - "surface-inset-strong": "#1f000017", - "surface-inset-strong-hover": "#1f000017", - "surface-raised-base": "var(--smoke-light-alpha-2)", - "surface-float-base": "var(--smoke-dark-1)", - "surface-float-base-hover": "var(--smoke-dark-2)", - "surface-raised-base-hover": "var(--smoke-light-alpha-3)", - "surface-raised-base-active": "var(--smoke-light-alpha-4)", - "surface-raised-strong": "var(--smoke-light-1)", - "surface-raised-strong-hover": "var(--white)", - "surface-raised-stronger": "var(--white)", - "surface-raised-stronger-hover": "var(--white)", - "surface-weak": "var(--smoke-light-alpha-3)", - "surface-weaker": "var(--smoke-light-alpha-4)", - "surface-strong": "#ffffff", - "surface-raised-stronger-non-alpha": "var(--white)", - "surface-brand-base": "var(--yuzu-light-9)", - "surface-brand-hover": "var(--yuzu-light-10)", - "surface-interactive-base": "var(--cobalt-light-3)", - "surface-interactive-hover": "var(--cobalt-light-4)", - "surface-interactive-weak": "var(--cobalt-light-2)", - "surface-interactive-weak-hover": "var(--cobalt-light-3)", - "surface-success-base": "var(--apple-light-3)", - "surface-success-weak": "var(--apple-light-2)", - "surface-success-strong": "var(--apple-light-9)", - "surface-warning-base": "var(--solaris-light-3)", - "surface-warning-weak": "var(--solaris-light-2)", - "surface-warning-strong": "var(--solaris-light-9)", - "surface-critical-base": "var(--ember-light-3)", - "surface-critical-weak": "var(--ember-light-2)", - "surface-critical-strong": "var(--ember-light-9)", - "surface-info-base": "var(--lilac-light-3)", - "surface-info-weak": "var(--lilac-light-2)", - "surface-info-strong": "var(--lilac-light-9)", - "surface-diff-unchanged-base": "#ffffff00", - "surface-diff-skip-base": "var(--smoke-light-2)", - "surface-diff-hidden-base": "var(--blue-light-3)", - "surface-diff-hidden-weak": "var(--blue-light-2)", - "surface-diff-hidden-weaker": "var(--blue-light-1)", - "surface-diff-hidden-strong": "var(--blue-light-5)", - "surface-diff-hidden-stronger": "var(--blue-light-9)", - "surface-diff-add-base": "#dafbe0", - "surface-diff-add-weak": "var(--mint-light-2)", - "surface-diff-add-weaker": "var(--mint-light-1)", - "surface-diff-add-strong": "var(--mint-light-5)", - "surface-diff-add-stronger": "var(--mint-light-9)", - "surface-diff-delete-base": "var(--ember-light-3)", - "surface-diff-delete-weak": "var(--ember-light-2)", - "surface-diff-delete-weaker": "var(--ember-light-1)", - "surface-diff-delete-strong": "var(--ember-light-6)", - "surface-diff-delete-stronger": "var(--ember-light-9)", - "input-base": "var(--smoke-light-1)", - "input-hover": "var(--smoke-light-2)", - "input-active": "var(--cobalt-light-1)", - "input-selected": "var(--cobalt-light-4)", - "input-focus": "var(--cobalt-light-1)", - "input-disabled": "var(--smoke-light-4)", - "text-base": "var(--smoke-light-11)", - "text-weak": "var(--smoke-light-9)", - "text-weaker": "var(--smoke-light-8)", - "text-strong": "var(--smoke-light-12)", - "text-invert-base": "var(--smoke-dark-alpha-11)", - "text-invert-weak": "var(--smoke-dark-alpha-9)", - "text-invert-weaker": "var(--smoke-dark-alpha-8)", - "text-invert-strong": "var(--smoke-dark-alpha-12)", - "text-interactive-base": "var(--cobalt-light-9)", - "text-on-brand-base": "var(--smoke-light-alpha-11)", - "text-on-interactive-base": "var(--smoke-light-1)", - "text-on-interactive-weak": "var(--smoke-dark-alpha-11)", - "text-on-success-base": "var(--apple-light-10)", - "text-on-critical-base": "var(--ember-light-10)", - "text-on-critical-weak": "var(--ember-light-8)", - "text-on-critical-strong": "var(--ember-light-12)", - "text-on-warning-base": "var(--smoke-dark-alpha-11)", - "text-on-info-base": "var(--smoke-dark-alpha-11)", - "text-diff-add-base": "var(--mint-light-11)", - "text-diff-delete-base": "var(--ember-light-10)", - "text-diff-delete-strong": "var(--ember-light-12)", - "text-diff-add-strong": "var(--mint-light-12)", - "text-on-info-weak": "var(--smoke-dark-alpha-9)", - "text-on-info-strong": "var(--smoke-dark-alpha-12)", - "text-on-warning-weak": "var(--smoke-dark-alpha-9)", - "text-on-warning-strong": "var(--smoke-dark-alpha-12)", - "text-on-success-weak": "var(--apple-light-6)", - "text-on-success-strong": "var(--apple-light-12)", - "text-on-brand-weak": "var(--smoke-light-alpha-9)", - "text-on-brand-weaker": "var(--smoke-light-alpha-8)", - "text-on-brand-strong": "var(--smoke-light-alpha-12)", - "button-secondary-base": "#fdfcfc", - "button-secondary-hover": "#faf9f9", - "border-base": "var(--smoke-light-alpha-7)", - "border-hover": "var(--smoke-light-alpha-8)", - "border-active": "var(--smoke-light-alpha-9)", - "border-selected": "var(--cobalt-light-alpha-9)", - "border-disabled": "var(--smoke-light-alpha-8)", - "border-focus": "var(--smoke-light-alpha-9)", - "border-weak-base": "var(--smoke-light-alpha-5)", - "border-strong-base": "var(--smoke-light-alpha-7)", - "border-strong-hover": "var(--smoke-light-alpha-8)", - "border-strong-active": "var(--smoke-light-alpha-7)", - "border-strong-selected": "var(--cobalt-light-alpha-6)", - "border-strong-disabled": "var(--smoke-light-alpha-6)", - "border-strong-focus": "var(--smoke-light-alpha-7)", - "border-weak-hover": "var(--smoke-light-alpha-6)", - "border-weak-active": "var(--smoke-light-alpha-7)", - "border-weak-selected": "var(--cobalt-light-alpha-5)", - "border-weak-disabled": "var(--smoke-light-alpha-6)", - "border-weak-focus": "var(--smoke-light-alpha-7)", - "border-interactive-base": "var(--cobalt-light-7)", - "border-interactive-hover": "var(--cobalt-light-8)", - "border-interactive-active": "var(--cobalt-light-9)", - "border-interactive-selected": "var(--cobalt-light-9)", - "border-interactive-disabled": "var(--smoke-light-8)", - "border-interactive-focus": "var(--cobalt-light-9)", - "border-success-base": "var(--apple-light-6)", - "border-success-hover": "var(--apple-light-7)", - "border-success-selected": "var(--apple-light-9)", - "border-warning-base": "var(--solaris-light-6)", - "border-warning-hover": "var(--solaris-light-7)", - "border-warning-selected": "var(--solaris-light-9)", - "border-critical-base": "var(--ember-light-6)", - "border-critical-hover": "var(--ember-light-7)", - "border-critical-selected": "var(--ember-light-9)", - "border-info-base": "var(--lilac-light-6)", - "border-info-hover": "var(--lilac-light-7)", - "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--smoke-light-9)", - "icon-hover": "var(--smoke-light-11)", - "icon-active": "var(--smoke-light-12)", - "icon-selected": "var(--smoke-light-12)", - "icon-disabled": "var(--smoke-light-8)", - "icon-focus": "var(--smoke-light-12)", - "icon-invert-base": "#ffffff", - "icon-weak-base": "var(--smoke-light-7)", - "icon-weak-hover": "var(--smoke-light-8)", - "icon-weak-active": "var(--smoke-light-9)", - "icon-weak-selected": "var(--smoke-light-10)", - "icon-weak-disabled": "var(--smoke-light-6)", - "icon-weak-focus": "var(--smoke-light-9)", - "icon-strong-base": "var(--smoke-light-12)", - "icon-strong-hover": "#151313", - "icon-strong-active": "#020202", - "icon-strong-selected": "#020202", - "icon-strong-disabled": "var(--smoke-light-8)", - "icon-strong-focus": "#020202", - "icon-brand-base": "var(--smoke-light-12)", - "icon-interactive-base": "var(--cobalt-light-9)", - "icon-success-base": "var(--apple-light-7)", - "icon-success-hover": "var(--apple-light-8)", - "icon-success-active": "var(--apple-light-11)", - "icon-warning-base": "var(--amber-light-7)", - "icon-warning-hover": "var(--amber-light-8)", - "icon-warning-active": "var(--amber-light-11)", - "icon-critical-base": "var(--ember-light-10)", - "icon-critical-hover": "var(--ember-light-11)", - "icon-critical-active": "var(--ember-light-12)", - "icon-info-base": "var(--lilac-light-7)", - "icon-info-hover": "var(--lilac-light-8)", - "icon-info-active": "var(--lilac-light-11)", - "icon-on-brand-base": "var(--smoke-light-alpha-11)", - "icon-on-brand-hover": "var(--smoke-light-alpha-12)", - "icon-on-brand-selected": "var(--smoke-light-alpha-12)", - "icon-on-interactive-base": "var(--smoke-light-1)", - "icon-agent-plan-base": "var(--purple-light-9)", - "icon-agent-docs-base": "var(--amber-light-9)", - "icon-agent-ask-base": "var(--cyan-light-9)", - "icon-agent-build-base": "var(--cobalt-light-9)", - "icon-on-success-base": "var(--apple-light-alpha-9)", - "icon-on-success-hover": "var(--apple-light-alpha-10)", - "icon-on-success-selected": "var(--apple-light-alpha-11)", - "icon-on-warning-base": "var(--amber-lightalpha-9)", - "icon-on-warning-hover": "var(--amber-lightalpha-10)", - "icon-on-warning-selected": "var(--amber-lightalpha-11)", - "icon-on-critical-base": "var(--ember-light-alpha-9)", - "icon-on-critical-hover": "var(--ember-light-alpha-10)", - "icon-on-critical-selected": "var(--ember-light-alpha-11)", - "icon-on-info-base": "var(--lilac-light-9)", - "icon-on-info-hover": "var(--lilac-light-alpha-10)", - "icon-on-info-selected": "var(--lilac-light-alpha-11)", - "icon-diff-add-base": "var(--mint-light-11)", - "icon-diff-add-hover": "var(--mint-light-12)", - "icon-diff-add-active": "var(--mint-light-12)", - "icon-diff-delete-base": "var(--ember-light-10)", - "icon-diff-delete-hover": "var(--ember-light-11)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#006656", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#fb4804", - "syntax-operator": "var(--text-base)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ed6dc8", - "syntax-type": "#596600", - "syntax-constant": "#007b80", - "syntax-punctuation": "var(--text-base)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-light-10)", - "syntax-warning": "var(--amber-light-10)", - "syntax-critical": "var(--ember-light-10)", - "syntax-info": "#0092a8", - "syntax-diff-add": "var(--mint-light-11)", - "syntax-diff-delete": "var(--ember-light-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#d68c27", - "markdown-text": "#1a1a1a", - "markdown-link": "#3b7dd8", - "markdown-link-text": "#318795", - "markdown-code": "#3d9a57", - "markdown-block-quote": "#b0851f", - "markdown-emph": "#b0851f", - "markdown-strong": "#d68c27", - "markdown-horizontal-rule": "#8a8a8a", - "markdown-list-item": "#3b7dd8", - "markdown-list-enumeration": "#318795", - "markdown-image": "#3b7dd8", - "markdown-image-text": "#318795", - "markdown-code-block": "#1a1a1a", - "border-color": "#ffffff", - "border-weaker-base": "var(--smoke-light-alpha-3)", - "border-weaker-hover": "var(--smoke-light-alpha-4)", - "border-weaker-active": "var(--smoke-light-alpha-6)", - "border-weaker-selected": "var(--cobalt-light-alpha-4)", - "border-weaker-disabled": "var(--smoke-light-alpha-2)", - "border-weaker-focus": "var(--smoke-light-alpha-6)", - "button-ghost-hover": "var(--smoke-light-alpha-2)", - "button-ghost-hover2": "var(--smoke-light-alpha-3)", - "avatar-background-pink": "#feeef8", - "avatar-background-mint": "#e1fbf4", - "avatar-background-orange": "#fff1e7", - "avatar-background-purple": "#f9f1fe", - "avatar-background-cyan": "#e7f9fb", - "avatar-background-lime": "#eefadc", - "avatar-text-pink": "#cd1d8d", - "avatar-text-mint": "#147d6f", - "avatar-text-orange": "#ed5f00", - "avatar-text-purple": "#8445bc", - "avatar-text-cyan": "#0894b3", - "avatar-text-lime": "#5d770d" - } - }, - "dark": { - "seeds": { - "neutral": "#716c6b", - "primary": "#fab283", - "success": "#12c905", - "warning": "#fcd53a", - "error": "#fc533a", - "info": "#edb2f1", - "interactive": "#034cff", - "diffAdd": "#c8ffc4", - "diffDelete": "#fc533a" - }, - "overrides": { - "background-base": "var(--smoke-dark-1)", - "background-weak": "#1c1717", - "background-strong": "#151313", - "background-stronger": "#191515", - "surface-base": "var(--smoke-dark-alpha-2)", - "base": "var(--smoke-dark-alpha-2)", - "surface-base-hover": "#e0b7b716", - "surface-base-active": "var(--smoke-dark-alpha-3)", - "surface-base-interactive-active": "var(--cobalt-dark-alpha-2)", - "base2": "var(--smoke-dark-alpha-2)", - "base3": "var(--smoke-dark-alpha-2)", - "surface-inset-base": "#0e0b0b7f", - "surface-inset-base-hover": "#0e0b0b7f", - "surface-inset-strong": "#060505cc", - "surface-inset-strong-hover": "#060505cc", - "surface-raised-base": "var(--smoke-dark-alpha-3)", - "surface-float-base": "var(--smoke-dark-1)", - "surface-float-base-hover": "var(--smoke-dark-2)", - "surface-raised-base-hover": "var(--smoke-dark-alpha-4)", - "surface-raised-base-active": "var(--smoke-dark-alpha-5)", - "surface-raised-strong": "var(--smoke-dark-alpha-4)", - "surface-raised-strong-hover": "var(--smoke-dark-alpha-6)", - "surface-raised-stronger": "var(--smoke-dark-alpha-6)", - "surface-raised-stronger-hover": "var(--smoke-dark-alpha-7)", - "surface-weak": "var(--smoke-dark-alpha-4)", - "surface-weaker": "var(--smoke-dark-alpha-5)", - "surface-strong": "var(--smoke-dark-alpha-7)", - "surface-raised-stronger-non-alpha": "var(--smoke-dark-3)", - "surface-brand-base": "var(--yuzu-light-9)", - "surface-brand-hover": "var(--yuzu-light-10)", - "surface-interactive-base": "var(--cobalt-light-3)", - "surface-interactive-hover": "var(--cobalt-light-4)", - "surface-interactive-weak": "var(--cobalt-light-2)", - "surface-interactive-weak-hover": "var(--cobalt-light-3)", - "surface-success-base": "var(--apple-dark-3)", - "surface-success-weak": "var(--apple-dark-2)", - "surface-success-strong": "var(--apple-dark-9)", - "surface-warning-base": "var(--solaris-light-3)", - "surface-warning-weak": "var(--solaris-light-2)", - "surface-warning-strong": "var(--solaris-light-9)", - "surface-critical-base": "var(--ember-dark-3)", - "surface-critical-weak": "var(--ember-dark-2)", - "surface-critical-strong": "var(--ember-dark-9)", - "surface-info-base": "var(--lilac-light-3)", - "surface-info-weak": "var(--lilac-light-2)", - "surface-info-strong": "var(--lilac-light-9)", - "surface-diff-unchanged-base": "var(--smoke-dark-1)", - "surface-diff-skip-base": "var(--smoke-dark-alpha-1)", - "surface-diff-hidden-base": "var(--blue-dark-2)", - "surface-diff-hidden-weak": "var(--blue-dark-1)", - "surface-diff-hidden-weaker": "var(--blue-dark-3)", - "surface-diff-hidden-strong": "var(--blue-dark-5)", - "surface-diff-hidden-stronger": "var(--blue-dark-11)", - "surface-diff-add-base": "var(--mint-dark-3)", - "surface-diff-add-weak": "var(--mint-dark-4)", - "surface-diff-add-weaker": "var(--mint-dark-3)", - "surface-diff-add-strong": "var(--mint-dark-5)", - "surface-diff-add-stronger": "var(--mint-dark-11)", - "surface-diff-delete-base": "var(--ember-dark-3)", - "surface-diff-delete-weak": "var(--ember-dark-4)", - "surface-diff-delete-weaker": "var(--ember-dark-3)", - "surface-diff-delete-strong": "var(--ember-dark-5)", - "surface-diff-delete-stronger": "var(--ember-dark-11)", - "input-base": "var(--smoke-dark-2)", - "input-hover": "var(--smoke-dark-2)", - "input-active": "var(--cobalt-dark-1)", - "input-selected": "var(--cobalt-dark-2)", - "input-focus": "var(--cobalt-dark-1)", - "input-disabled": "var(--smoke-dark-4)", - "text-base": "var(--smoke-dark-alpha-11)", - "text-weak": "var(--smoke-dark-alpha-9)", - "text-weaker": "var(--smoke-dark-alpha-8)", - "text-strong": "var(--smoke-dark-alpha-12)", - "text-invert-base": "var(--smoke-dark-alpha-11)", - "text-invert-weak": "var(--smoke-dark-alpha-9)", - "text-invert-weaker": "var(--smoke-dark-alpha-8)", - "text-invert-strong": "var(--smoke-dark-alpha-12)", - "text-interactive-base": "var(--cobalt-dark-11)", - "text-on-brand-base": "var(--smoke-dark-alpha-11)", - "text-on-interactive-base": "var(--smoke-dark-12)", - "text-on-interactive-weak": "var(--smoke-dark-alpha-11)", - "text-on-success-base": "var(--apple-dark-9)", - "text-on-critical-base": "var(--ember-dark-9)", - "text-on-critical-weak": "var(--ember-dark-8)", - "text-on-critical-strong": "var(--ember-dark-12)", - "text-on-warning-base": "var(--smoke-dark-alpha-11)", - "text-on-info-base": "var(--smoke-dark-alpha-11)", - "text-diff-add-base": "var(--mint-dark-11)", - "text-diff-delete-base": "var(--ember-dark-9)", - "text-diff-delete-strong": "var(--ember-dark-12)", - "text-diff-add-strong": "var(--mint-dark-8)", - "text-on-info-weak": "var(--smoke-dark-alpha-9)", - "text-on-info-strong": "var(--smoke-dark-alpha-12)", - "text-on-warning-weak": "var(--smoke-dark-alpha-9)", - "text-on-warning-strong": "var(--smoke-dark-alpha-12)", - "text-on-success-weak": "var(--apple-dark-8)", - "text-on-success-strong": "var(--apple-dark-12)", - "text-on-brand-weak": "var(--smoke-dark-alpha-9)", - "text-on-brand-weaker": "var(--smoke-dark-alpha-8)", - "text-on-brand-strong": "var(--smoke-dark-alpha-12)", - "button-secondary-base": "#231f1f", - "button-secondary-hover": "#2a2727", - "border-base": "var(--smoke-dark-alpha-7)", - "border-hover": "var(--smoke-dark-alpha-8)", - "border-active": "var(--smoke-dark-alpha-9)", - "border-selected": "var(--cobalt-dark-alpha-11)", - "border-disabled": "var(--smoke-dark-alpha-8)", - "border-focus": "var(--smoke-dark-alpha-9)", - "border-weak-base": "var(--smoke-dark-alpha-6)", - "border-strong-base": "var(--smoke-dark-alpha-8)", - "border-strong-hover": "var(--smoke-dark-alpha-7)", - "border-strong-active": "var(--smoke-dark-alpha-8)", - "border-strong-selected": "var(--cobalt-dark-alpha-6)", - "border-strong-disabled": "var(--smoke-dark-alpha-6)", - "border-strong-focus": "var(--smoke-dark-alpha-8)", - "border-weak-hover": "var(--smoke-dark-alpha-7)", - "border-weak-active": "var(--smoke-dark-alpha-8)", - "border-weak-selected": "var(--cobalt-dark-alpha-6)", - "border-weak-disabled": "var(--smoke-dark-alpha-6)", - "border-weak-focus": "var(--smoke-dark-alpha-8)", - "border-interactive-base": "var(--cobalt-light-7)", - "border-interactive-hover": "var(--cobalt-light-8)", - "border-interactive-active": "var(--cobalt-light-9)", - "border-interactive-selected": "var(--cobalt-light-9)", - "border-interactive-disabled": "var(--smoke-light-8)", - "border-interactive-focus": "var(--cobalt-light-9)", - "border-success-base": "var(--apple-light-6)", - "border-success-hover": "var(--apple-light-7)", - "border-success-selected": "var(--apple-light-9)", - "border-warning-base": "var(--solaris-light-6)", - "border-warning-hover": "var(--solaris-light-7)", - "border-warning-selected": "var(--solaris-light-9)", - "border-critical-base": "var(--ember-dark-5)", - "border-critical-hover": "var(--ember-dark-7)", - "border-critical-selected": "var(--ember-dark-9)", - "border-info-base": "var(--lilac-light-6)", - "border-info-hover": "var(--lilac-light-7)", - "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--smoke-dark-9)", - "icon-hover": "var(--smoke-dark-10)", - "icon-active": "var(--smoke-dark-11)", - "icon-selected": "var(--smoke-dark-12)", - "icon-disabled": "var(--smoke-dark-7)", - "icon-focus": "var(--smoke-dark-12)", - "icon-invert-base": "var(--smoke-dark-1)", - "icon-weak-base": "var(--smoke-dark-6)", - "icon-weak-hover": "var(--smoke-light-7)", - "icon-weak-active": "var(--smoke-light-8)", - "icon-weak-selected": "var(--smoke-light-9)", - "icon-weak-disabled": "var(--smoke-light-4)", - "icon-weak-focus": "var(--smoke-light-9)", - "icon-strong-base": "var(--smoke-dark-12)", - "icon-strong-hover": "#f6f3f3", - "icon-strong-active": "#fcfcfc", - "icon-strong-selected": "#fdfcfc", - "icon-strong-disabled": "var(--smoke-dark-8)", - "icon-strong-focus": "#fdfcfc", - "icon-brand-base": "var(--white)", - "icon-interactive-base": "var(--cobalt-dark-9)", - "icon-success-base": "var(--apple-dark-9)", - "icon-success-hover": "var(--apple-dark-10)", - "icon-success-active": "var(--apple-dark-11)", - "icon-warning-base": "var(--amber-dark-7)", - "icon-warning-hover": "var(--amber-dark-8)", - "icon-warning-active": "var(--amber-dark-11)", - "icon-critical-base": "var(--ember-dark-9)", - "icon-critical-hover": "var(--ember-dark-11)", - "icon-critical-active": "var(--ember-dark-12)", - "icon-info-base": "var(--lilac-dark-7)", - "icon-info-hover": "var(--lilac-dark-8)", - "icon-info-active": "var(--lilac-dark-11)", - "icon-on-brand-base": "var(--smoke-light-alpha-11)", - "icon-on-brand-hover": "var(--smoke-light-alpha-12)", - "icon-on-brand-selected": "var(--smoke-light-alpha-12)", - "icon-on-interactive-base": "var(--smoke-dark-12)", - "icon-agent-plan-base": "var(--purple-dark-9)", - "icon-agent-docs-base": "var(--amber-dark-9)", - "icon-agent-ask-base": "var(--cyan-dark-9)", - "icon-agent-build-base": "var(--cobalt-dark-11)", - "icon-on-success-base": "var(--apple-dark-alpha-9)", - "icon-on-success-hover": "var(--apple-dark-alpha-10)", - "icon-on-success-selected": "var(--apple-dark-alpha-11)", - "icon-on-warning-base": "var(--amber-darkalpha-9)", - "icon-on-warning-hover": "var(--amber-darkalpha-10)", - "icon-on-warning-selected": "var(--amber-darkalpha-11)", - "icon-on-critical-base": "var(--ember-dark-alpha-9)", - "icon-on-critical-hover": "var(--ember-dark-alpha-10)", - "icon-on-critical-selected": "var(--ember-dark-alpha-11)", - "icon-on-info-base": "var(--lilac-dark-9)", - "icon-on-info-hover": "var(--lilac-dark-alpha-10)", - "icon-on-info-selected": "var(--lilac-dark-alpha-11)", - "icon-diff-add-base": "var(--mint-dark-11)", - "icon-diff-add-hover": "var(--mint-dark-10)", - "icon-diff-add-active": "var(--mint-dark-11)", - "icon-diff-delete-base": "var(--ember-dark-9)", - "icon-diff-delete-hover": "var(--ember-dark-10)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#00ceb9", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#ffba92", - "syntax-operator": "var(--text-weak)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ff9ae2", - "syntax-type": "#ecf58c", - "syntax-constant": "#93e9f6", - "syntax-punctuation": "var(--text-weak)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-dark-10)", - "syntax-warning": "var(--amber-dark-10)", - "syntax-critical": "var(--ember-dark-10)", - "syntax-info": "#93e9f6", - "syntax-diff-add": "var(--mint-dark-11)", - "syntax-diff-delete": "var(--ember-dark-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#9d7cd8", - "markdown-text": "#eeeeee", - "markdown-link": "#fab283", - "markdown-link-text": "#56b6c2", - "markdown-code": "#7fd88f", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#f5a742", - "markdown-horizontal-rule": "#808080", - "markdown-list-item": "#fab283", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#fab283", - "markdown-image-text": "#56b6c2", - "markdown-code-block": "#eeeeee", - "border-color": "#ffffff", - "border-weaker-base": "var(--smoke-dark-alpha-3)", - "border-weaker-hover": "var(--smoke-dark-alpha-4)", - "border-weaker-active": "var(--smoke-dark-alpha-6)", - "border-weaker-selected": "var(--cobalt-dark-alpha-3)", - "border-weaker-disabled": "var(--smoke-dark-alpha-2)", - "border-weaker-focus": "var(--smoke-dark-alpha-6)", - "button-ghost-hover": "var(--smoke-dark-alpha-2)", - "button-ghost-hover2": "var(--smoke-dark-alpha-3)", - "avatar-background-pink": "#501b3f", - "avatar-background-mint": "#033a34", - "avatar-background-orange": "#5f2a06", - "avatar-background-purple": "#432155", - "avatar-background-cyan": "#0f3058", - "avatar-background-lime": "#2b3711", - "avatar-text-pink": "#e34ba9", - "avatar-text-mint": "#95f3d9", - "avatar-text-orange": "#ff802b", - "avatar-text-purple": "#9d5bd2", - "avatar-text-cyan": "#369eff", - "avatar-text-lime": "#c4f042" - } - } -} diff --git a/src/generated_themes/one-dark.json b/src/generated_themes/one-dark.json new file mode 100644 index 0000000..73b24e9 --- /dev/null +++ b/src/generated_themes/one-dark.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#282c34", + "darkBgAlt": "#21252b", + "darkBgPanel": "#353b45", + "darkFg": "#abb2bf", + "darkFgMuted": "#5c6370", + "darkPurple": "#c678dd", + "darkBlue": "#61afef", + "darkRed": "#e06c75", + "darkGreen": "#98c379", + "darkYellow": "#e5c07b", + "darkOrange": "#d19a66", + "darkCyan": "#56b6c2", + "lightBg": "#fafafa", + "lightBgAlt": "#f0f0f1", + "lightBgPanel": "#eaeaeb", + "lightFg": "#383a42", + "lightFgMuted": "#a0a1a7", + "lightPurple": "#a626a4", + "lightBlue": "#4078f2", + "lightRed": "#e45649", + "lightGreen": "#50a14f", + "lightYellow": "#c18401", + "lightOrange": "#986801", + "lightCyan": "#0184bc" + }, + "theme": { + "primary": { "dark": "darkBlue", "light": "lightBlue" }, + "secondary": { "dark": "darkPurple", "light": "lightPurple" }, + "accent": { "dark": "darkCyan", "light": "lightCyan" }, + "error": { "dark": "darkRed", "light": "lightRed" }, + "warning": { "dark": "darkYellow", "light": "lightYellow" }, + "success": { "dark": "darkGreen", "light": "lightGreen" }, + "info": { "dark": "darkOrange", "light": "lightOrange" }, + "text": { "dark": "darkFg", "light": "lightFg" }, + "textMuted": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "background": { "dark": "darkBg", "light": "lightBg" }, + "backgroundPanel": { "dark": "darkBgAlt", "light": "lightBgAlt" }, + "backgroundElement": { "dark": "darkBgPanel", "light": "lightBgPanel" }, + "border": { "dark": "#393f4a", "light": "#d1d1d2" }, + "borderActive": { "dark": "darkBlue", "light": "lightBlue" }, + "borderSubtle": { "dark": "#2c313a", "light": "#e0e0e1" }, + "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffContext": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" }, + "diffHighlightAdded": { "dark": "#aad482", "light": "#489447" }, + "diffHighlightRemoved": { "dark": "#e8828b", "light": "#d65145" }, + "diffAddedBg": { "dark": "#2c382b", "light": "#eafbe9" }, + "diffRemovedBg": { "dark": "#3a2d2f", "light": "#fce9e8" }, + "diffContextBg": { "dark": "darkBgAlt", "light": "lightBgAlt" }, + "diffLineNumber": { "dark": "#495162", "light": "#c9c9ca" }, + "diffAddedLineNumberBg": { "dark": "#283427", "light": "#e1f3df" }, + "diffRemovedLineNumberBg": { "dark": "#36292b", "light": "#f5e2e1" }, + "markdownText": { "dark": "darkFg", "light": "lightFg" }, + "markdownHeading": { "dark": "darkPurple", "light": "lightPurple" }, + "markdownLink": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownLinkText": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownCode": { "dark": "darkGreen", "light": "lightGreen" }, + "markdownBlockQuote": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "markdownEmph": { "dark": "darkYellow", "light": "lightYellow" }, + "markdownStrong": { "dark": "darkOrange", "light": "lightOrange" }, + "markdownHorizontalRule": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownListItem": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownListEnumeration": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownImage": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownImageText": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownCodeBlock": { "dark": "darkFg", "light": "lightFg" }, + "syntaxComment": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "syntaxKeyword": { "dark": "darkPurple", "light": "lightPurple" }, + "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" }, + "syntaxVariable": { "dark": "darkRed", "light": "lightRed" }, + "syntaxString": { "dark": "darkGreen", "light": "lightGreen" }, + "syntaxNumber": { "dark": "darkOrange", "light": "lightOrange" }, + "syntaxType": { "dark": "darkYellow", "light": "lightYellow" }, + "syntaxOperator": { "dark": "darkCyan", "light": "lightCyan" }, + "syntaxPunctuation": { "dark": "darkFg", "light": "lightFg" } + } +} diff --git a/src/generated_themes/onedarkpro.json b/src/generated_themes/onedarkpro.json deleted file mode 100644 index ce01511..0000000 --- a/src/generated_themes/onedarkpro.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "One Dark Pro", - "id": "onedarkpro", - "light": { - "seeds": { - "neutral": "#f5f6f8", - "primary": "#528bff", - "success": "#4fa66d", - "warning": "#d19a66", - "error": "#e06c75", - "info": "#61afef", - "interactive": "#528bff", - "diffAdd": "#c2ebcf", - "diffDelete": "#f7c1c5" - }, - "overrides": { - "background-base": "#f5f6f8", - "background-weak": "#eef0f4", - "background-strong": "#fafbfc", - "background-stronger": "#ffffff", - "border-weak-base": "#dee2eb", - "border-weak-hover": "#d4d9e3", - "border-weak-active": "#caced6", - "border-weak-selected": "#bec4d0", - "border-weak-disabled": "#f4f6fb", - "border-weak-focus": "#c4cada", - "border-base": "#b5bccd", - "border-hover": "#aab1c2", - "border-active": "#a0a7b8", - "border-selected": "#959cae", - "border-disabled": "#eceef4", - "border-focus": "#a6adbf", - "border-strong-base": "#747c92", - "border-strong-hover": "#6a7287", - "border-strong-active": "#60687c", - "border-strong-selected": "#565e71", - "border-strong-disabled": "#cbd0dd", - "border-strong-focus": "#666d82", - "surface-diff-add-base": "#e5f4ea", - "surface-diff-delete-base": "#fde7ea", - "surface-diff-hidden-base": "#e4e8f4", - "text-base": "#2b303b", - "text-weak": "#6b717f", - "text-strong": "#0e1118", - "syntax-string": "#4fa66d", - "syntax-primitive": "#d85462", - "syntax-property": "#528bff", - "syntax-type": "#d19a66", - "syntax-constant": "#61afef", - "syntax-info": "#61afef", - "markdown-heading": "#528bff", - "markdown-text": "#2b303b", - "markdown-link": "#528bff", - "markdown-link-text": "#61afef", - "markdown-code": "#4fa66d", - "markdown-block-quote": "#d19a66", - "markdown-emph": "#d19a66", - "markdown-strong": "#d85462", - "markdown-horizontal-rule": "#d3d7e4", - "markdown-list-item": "#528bff", - "markdown-list-enumeration": "#61afef", - "markdown-image": "#528bff", - "markdown-image-text": "#61afef", - "markdown-code-block": "#528bff" - } - }, - "dark": { - "seeds": { - "neutral": "#1e222a", - "primary": "#61afef", - "success": "#98c379", - "warning": "#e5c07b", - "error": "#e06c75", - "info": "#56b6c2", - "interactive": "#61afef", - "diffAdd": "#4b815a", - "diffDelete": "#b2555f" - }, - "overrides": { - "background-base": "#1e222a", - "background-weak": "#212631", - "background-strong": "#1b1f27", - "background-stronger": "#171b23", - "border-weak-base": "#323848", - "border-weak-hover": "#363d52", - "border-weak-active": "#3c435c", - "border-weak-selected": "#424967", - "border-weak-disabled": "#141720", - "border-weak-focus": "#3f4560", - "border-base": "#4a5164", - "border-hover": "#515871", - "border-active": "#585f7e", - "border-selected": "#60688a", - "border-disabled": "#1a1e27", - "border-focus": "#555c79", - "border-strong-base": "#6a7390", - "border-strong-hover": "#737c9d", - "border-strong-active": "#7d87ab", - "border-strong-selected": "#8791b8", - "border-strong-disabled": "#212533", - "border-strong-focus": "#7680a2", - "surface-diff-add-base": "#1c2a26", - "surface-diff-delete-base": "#2a1c22", - "surface-diff-hidden-base": "#232836", - "text-base": "#abb2bf", - "text-weak": "#818899", - "text-strong": "#f6f7fb", - "syntax-string": "#98c379", - "syntax-primitive": "#e06c75", - "syntax-property": "#61afef", - "syntax-type": "#e5c07b", - "syntax-constant": "#56b6c2", - "syntax-info": "#56b6c2", - "markdown-heading": "#61afef", - "markdown-text": "#abb2bf", - "markdown-link": "#61afef", - "markdown-link-text": "#56b6c2", - "markdown-code": "#98c379", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#e06c75", - "markdown-horizontal-rule": "#2d3444", - "markdown-list-item": "#61afef", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#61afef", - "markdown-image-text": "#56b6c2", - "markdown-code-block": "#abb2bf" - } - } -} diff --git a/src/generated_themes/opencode.json b/src/generated_themes/opencode.json new file mode 100644 index 0000000..8f585a4 --- /dev/null +++ b/src/generated_themes/opencode.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#0a0a0a", + "darkStep2": "#141414", + "darkStep3": "#1e1e1e", + "darkStep4": "#282828", + "darkStep5": "#323232", + "darkStep6": "#3c3c3c", + "darkStep7": "#484848", + "darkStep8": "#606060", + "darkStep9": "#fab283", + "darkStep10": "#ffc09f", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#5c9cf5", + "darkAccent": "#9d7cd8", + "darkRed": "#e06c75", + "darkOrange": "#f5a742", + "darkGreen": "#7fd88f", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "lightStep1": "#ffffff", + "lightStep2": "#fafafa", + "lightStep3": "#f5f5f5", + "lightStep4": "#ebebeb", + "lightStep5": "#e1e1e1", + "lightStep6": "#d4d4d4", + "lightStep7": "#b8b8b8", + "lightStep8": "#a0a0a0", + "lightStep9": "#3b7dd8", + "lightStep10": "#2968c3", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#7b5bb6", + "lightAccent": "#d68c27", + "lightRed": "#d1383d", + "lightOrange": "#d68c27", + "lightGreen": "#3d9a57", + "lightCyan": "#318795", + "lightYellow": "#b0851f" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "darkSecondary", + "light": "lightSecondary" + }, + "accent": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "darkStep7", + "light": "lightStep7" + }, + "borderActive": { + "dark": "darkStep8", + "light": "lightStep8" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#4fd6be", + "light": "#1e725c" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#b8db87", + "light": "#4db380" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#20303b", + "light": "#d5e5d5" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#1b2b34", + "light": "#c5d5c5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "syntaxFunction": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/generated_themes/orng.json b/src/generated_themes/orng.json new file mode 100644 index 0000000..1fc602f --- /dev/null +++ b/src/generated_themes/orng.json @@ -0,0 +1,249 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#0a0a0a", + "darkStep2": "#141414", + "darkStep3": "#1e1e1e", + "darkStep4": "#282828", + "darkStep5": "#323232", + "darkStep6": "#3c3c3c", + "darkStep7": "#484848", + "darkStep8": "#606060", + "darkStep9": "#EC5B2B", + "darkStep10": "#EE7948", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#EE7948", + "darkAccent": "#FFF7F1", + "darkRed": "#e06c75", + "darkOrange": "#EC5B2B", + "darkBlue": "#6ba1e6", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "lightStep1": "#ffffff", + "lightStep2": "#FFF7F1", + "lightStep3": "#f5f0eb", + "lightStep4": "#ebebeb", + "lightStep5": "#e1e1e1", + "lightStep6": "#d4d4d4", + "lightStep7": "#b8b8b8", + "lightStep8": "#a0a0a0", + "lightStep9": "#EC5B2B", + "lightStep10": "#c94d24", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#EE7948", + "lightAccent": "#c94d24", + "lightRed": "#d1383d", + "lightOrange": "#EC5B2B", + "lightBlue": "#0062d1", + "lightCyan": "#318795", + "lightYellow": "#b0851f" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "darkSecondary", + "light": "lightSecondary" + }, + "accent": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "selectedListItemText": { + "dark": "#0a0a0a", + "light": "#ffffff" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "#EC5B2B", + "light": "#EC5B2B" + }, + "borderActive": { + "dark": "#EE7948", + "light": "#c94d24" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#6ba1e6", + "light": "#0062d1" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#6ba1e6", + "light": "#0062d1" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#1a2a3d", + "light": "#e0edfa" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#162535", + "light": "#d0e5f5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "#EC5B2B", + "light": "#EC5B2B" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownBlockQuote": { + "dark": "#FFF7F1", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "#EE7948", + "light": "#EC5B2B" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "#EC5B2B", + "light": "#EC5B2B" + }, + "syntaxFunction": { + "dark": "#EE7948", + "light": "#c94d24" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxNumber": { + "dark": "#FFF7F1", + "light": "#EC5B2B" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/generated_themes/osaka-jade.json b/src/generated_themes/osaka-jade.json new file mode 100644 index 0000000..1c9de92 --- /dev/null +++ b/src/generated_themes/osaka-jade.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg0": "#111c18", + "darkBg1": "#1a2520", + "darkBg2": "#23372B", + "darkBg3": "#3d4a44", + "darkFg0": "#C1C497", + "darkFg1": "#9aa88a", + "darkGray": "#53685B", + "darkRed": "#FF5345", + "darkGreen": "#549e6a", + "darkYellow": "#459451", + "darkBlue": "#509475", + "darkMagenta": "#D2689C", + "darkCyan": "#2DD5B7", + "darkWhite": "#F6F5DD", + "darkRedBright": "#db9f9c", + "darkGreenBright": "#63b07a", + "darkYellowBright": "#E5C736", + "darkBlueBright": "#ACD4CF", + "darkMagentaBright": "#75bbb3", + "darkCyanBright": "#8CD3CB", + "lightBg0": "#F6F5DD", + "lightBg1": "#E8E7CC", + "lightBg2": "#D5D4B8", + "lightBg3": "#A8A78C", + "lightFg0": "#111c18", + "lightFg1": "#1a2520", + "lightGray": "#53685B", + "lightRed": "#c7392d", + "lightGreen": "#3d7a52", + "lightYellow": "#b5a020", + "lightBlue": "#3d7560", + "lightMagenta": "#a8527a", + "lightCyan": "#1faa90" + }, + "theme": { + "primary": { "dark": "darkCyan", "light": "lightCyan" }, + "secondary": { "dark": "darkMagenta", "light": "lightMagenta" }, + "accent": { "dark": "darkGreen", "light": "lightGreen" }, + "error": { "dark": "darkRed", "light": "lightRed" }, + "warning": { "dark": "darkYellowBright", "light": "lightYellow" }, + "success": { "dark": "darkGreen", "light": "lightGreen" }, + "info": { "dark": "darkCyan", "light": "lightCyan" }, + "text": { "dark": "darkFg0", "light": "lightFg0" }, + "textMuted": { "dark": "darkGray", "light": "lightGray" }, + "background": { "dark": "darkBg0", "light": "lightBg0" }, + "backgroundPanel": { "dark": "darkBg1", "light": "lightBg1" }, + "backgroundElement": { "dark": "darkBg2", "light": "lightBg2" }, + "border": { "dark": "darkBg3", "light": "lightBg3" }, + "borderActive": { "dark": "darkCyan", "light": "lightCyan" }, + "borderSubtle": { "dark": "darkBg2", "light": "lightBg2" }, + "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffContext": { "dark": "darkGray", "light": "lightGray" }, + "diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" }, + "diffHighlightAdded": { "dark": "darkGreenBright", "light": "lightGreen" }, + "diffHighlightRemoved": { "dark": "darkRedBright", "light": "lightRed" }, + "diffAddedBg": { "dark": "#15241c", "light": "#e0eee5" }, + "diffRemovedBg": { "dark": "#241515", "light": "#eee0e0" }, + "diffContextBg": { "dark": "darkBg1", "light": "lightBg1" }, + "diffLineNumber": { "dark": "darkBg3", "light": "lightBg3" }, + "diffAddedLineNumberBg": { "dark": "#121f18", "light": "#d5e5da" }, + "diffRemovedLineNumberBg": { "dark": "#1f1212", "light": "#e5d5d5" }, + "markdownText": { "dark": "darkFg0", "light": "lightFg0" }, + "markdownHeading": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownLink": { "dark": "darkCyanBright", "light": "lightCyan" }, + "markdownLinkText": { "dark": "darkGreen", "light": "lightGreen" }, + "markdownCode": { "dark": "darkGreenBright", "light": "lightGreen" }, + "markdownBlockQuote": { "dark": "darkGray", "light": "lightGray" }, + "markdownEmph": { "dark": "darkMagenta", "light": "lightMagenta" }, + "markdownStrong": { "dark": "darkFg0", "light": "lightFg0" }, + "markdownHorizontalRule": { "dark": "darkGray", "light": "lightGray" }, + "markdownListItem": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownListEnumeration": { + "dark": "darkCyanBright", + "light": "lightCyan" + }, + "markdownImage": { "dark": "darkCyanBright", "light": "lightCyan" }, + "markdownImageText": { "dark": "darkGreen", "light": "lightGreen" }, + "markdownCodeBlock": { "dark": "darkFg0", "light": "lightFg0" }, + "syntaxComment": { "dark": "darkGray", "light": "lightGray" }, + "syntaxKeyword": { "dark": "darkCyan", "light": "lightCyan" }, + "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" }, + "syntaxVariable": { "dark": "darkFg0", "light": "lightFg0" }, + "syntaxString": { "dark": "darkGreenBright", "light": "lightGreen" }, + "syntaxNumber": { "dark": "darkMagenta", "light": "lightMagenta" }, + "syntaxType": { "dark": "darkGreen", "light": "lightGreen" }, + "syntaxOperator": { "dark": "darkYellow", "light": "lightYellow" }, + "syntaxPunctuation": { "dark": "darkFg0", "light": "lightFg0" } + } +} diff --git a/src/generated_themes/palenight.json b/src/generated_themes/palenight.json new file mode 100644 index 0000000..79f7c59 --- /dev/null +++ b/src/generated_themes/palenight.json @@ -0,0 +1,222 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#292d3e", + "backgroundAlt": "#1e2132", + "backgroundPanel": "#32364a", + "foreground": "#a6accd", + "foregroundBright": "#bfc7d5", + "comment": "#676e95", + "red": "#f07178", + "orange": "#f78c6c", + "yellow": "#ffcb6b", + "green": "#c3e88d", + "cyan": "#89ddff", + "blue": "#82aaff", + "purple": "#c792ea", + "magenta": "#ff5370", + "pink": "#f07178" + }, + "theme": { + "primary": { + "dark": "blue", + "light": "#4976eb" + }, + "secondary": { + "dark": "purple", + "light": "#a854f2" + }, + "accent": { + "dark": "cyan", + "light": "#00acc1" + }, + "error": { + "dark": "red", + "light": "#e53935" + }, + "warning": { + "dark": "yellow", + "light": "#ffb300" + }, + "success": { + "dark": "green", + "light": "#91b859" + }, + "info": { + "dark": "orange", + "light": "#f4511e" + }, + "text": { + "dark": "foreground", + "light": "#292d3e" + }, + "textMuted": { + "dark": "comment", + "light": "#8796b0" + }, + "background": { + "dark": "#292d3e", + "light": "#fafafa" + }, + "backgroundPanel": { + "dark": "#1e2132", + "light": "#f5f5f5" + }, + "backgroundElement": { + "dark": "#32364a", + "light": "#e7e7e8" + }, + "border": { + "dark": "#32364a", + "light": "#e0e0e0" + }, + "borderActive": { + "dark": "blue", + "light": "#4976eb" + }, + "borderSubtle": { + "dark": "#1e2132", + "light": "#eeeeee" + }, + "diffAdded": { + "dark": "green", + "light": "#91b859" + }, + "diffRemoved": { + "dark": "red", + "light": "#e53935" + }, + "diffContext": { + "dark": "comment", + "light": "#8796b0" + }, + "diffHunkHeader": { + "dark": "cyan", + "light": "#00acc1" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "#91b859" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "#e53935" + }, + "diffAddedBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "#1e2132", + "light": "#f5f5f5" + }, + "diffLineNumber": { + "dark": "#444760", + "light": "#cfd8dc" + }, + "diffAddedLineNumberBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "markdownText": { + "dark": "foreground", + "light": "#292d3e" + }, + "markdownHeading": { + "dark": "purple", + "light": "#a854f2" + }, + "markdownLink": { + "dark": "blue", + "light": "#4976eb" + }, + "markdownLinkText": { + "dark": "cyan", + "light": "#00acc1" + }, + "markdownCode": { + "dark": "green", + "light": "#91b859" + }, + "markdownBlockQuote": { + "dark": "comment", + "light": "#8796b0" + }, + "markdownEmph": { + "dark": "yellow", + "light": "#ffb300" + }, + "markdownStrong": { + "dark": "orange", + "light": "#f4511e" + }, + "markdownHorizontalRule": { + "dark": "comment", + "light": "#8796b0" + }, + "markdownListItem": { + "dark": "blue", + "light": "#4976eb" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "#00acc1" + }, + "markdownImage": { + "dark": "blue", + "light": "#4976eb" + }, + "markdownImageText": { + "dark": "cyan", + "light": "#00acc1" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#292d3e" + }, + "syntaxComment": { + "dark": "comment", + "light": "#8796b0" + }, + "syntaxKeyword": { + "dark": "purple", + "light": "#a854f2" + }, + "syntaxFunction": { + "dark": "blue", + "light": "#4976eb" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#292d3e" + }, + "syntaxString": { + "dark": "green", + "light": "#91b859" + }, + "syntaxNumber": { + "dark": "orange", + "light": "#f4511e" + }, + "syntaxType": { + "dark": "yellow", + "light": "#ffb300" + }, + "syntaxOperator": { + "dark": "cyan", + "light": "#00acc1" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#292d3e" + } + } +} diff --git a/src/generated_themes/rosepine.json b/src/generated_themes/rosepine.json new file mode 100644 index 0000000..444cdbd --- /dev/null +++ b/src/generated_themes/rosepine.json @@ -0,0 +1,234 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "base": "#191724", + "surface": "#1f1d2e", + "overlay": "#26233a", + "muted": "#6e6a86", + "subtle": "#908caa", + "text": "#e0def4", + "love": "#eb6f92", + "gold": "#f6c177", + "rose": "#ebbcba", + "pine": "#31748f", + "foam": "#9ccfd8", + "iris": "#c4a7e7", + "highlightLow": "#21202e", + "highlightMed": "#403d52", + "highlightHigh": "#524f67", + "moonBase": "#232136", + "moonSurface": "#2a273f", + "moonOverlay": "#393552", + "moonMuted": "#6e6a86", + "moonSubtle": "#908caa", + "moonText": "#e0def4", + "dawnBase": "#faf4ed", + "dawnSurface": "#fffaf3", + "dawnOverlay": "#f2e9e1", + "dawnMuted": "#9893a5", + "dawnSubtle": "#797593", + "dawnText": "#575279" + }, + "theme": { + "primary": { + "dark": "foam", + "light": "pine" + }, + "secondary": { + "dark": "iris", + "light": "#907aa9" + }, + "accent": { + "dark": "rose", + "light": "#d7827e" + }, + "error": { + "dark": "love", + "light": "#b4637a" + }, + "warning": { + "dark": "gold", + "light": "#ea9d34" + }, + "success": { + "dark": "pine", + "light": "#286983" + }, + "info": { + "dark": "foam", + "light": "#56949f" + }, + "text": { + "dark": "#e0def4", + "light": "#575279" + }, + "textMuted": { + "dark": "muted", + "light": "dawnMuted" + }, + "background": { + "dark": "base", + "light": "dawnBase" + }, + "backgroundPanel": { + "dark": "surface", + "light": "dawnSurface" + }, + "backgroundElement": { + "dark": "overlay", + "light": "dawnOverlay" + }, + "border": { + "dark": "highlightMed", + "light": "#dfdad9" + }, + "borderActive": { + "dark": "foam", + "light": "pine" + }, + "borderSubtle": { + "dark": "highlightLow", + "light": "#f4ede8" + }, + "diffAdded": { + "dark": "pine", + "light": "#286983" + }, + "diffRemoved": { + "dark": "love", + "light": "#b4637a" + }, + "diffContext": { + "dark": "muted", + "light": "dawnMuted" + }, + "diffHunkHeader": { + "dark": "iris", + "light": "#907aa9" + }, + "diffHighlightAdded": { + "dark": "pine", + "light": "#286983" + }, + "diffHighlightRemoved": { + "dark": "love", + "light": "#b4637a" + }, + "diffAddedBg": { + "dark": "#1f2d3a", + "light": "#e5f2f3" + }, + "diffRemovedBg": { + "dark": "#3a1f2d", + "light": "#fce5e8" + }, + "diffContextBg": { + "dark": "surface", + "light": "dawnSurface" + }, + "diffLineNumber": { + "dark": "muted", + "light": "dawnMuted" + }, + "diffAddedLineNumberBg": { + "dark": "#1f2d3a", + "light": "#e5f2f3" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1f2d", + "light": "#fce5e8" + }, + "markdownText": { + "dark": "#e0def4", + "light": "#575279" + }, + "markdownHeading": { + "dark": "iris", + "light": "#907aa9" + }, + "markdownLink": { + "dark": "foam", + "light": "pine" + }, + "markdownLinkText": { + "dark": "rose", + "light": "#d7827e" + }, + "markdownCode": { + "dark": "pine", + "light": "#286983" + }, + "markdownBlockQuote": { + "dark": "muted", + "light": "dawnMuted" + }, + "markdownEmph": { + "dark": "gold", + "light": "#ea9d34" + }, + "markdownStrong": { + "dark": "love", + "light": "#b4637a" + }, + "markdownHorizontalRule": { + "dark": "highlightMed", + "light": "#dfdad9" + }, + "markdownListItem": { + "dark": "foam", + "light": "pine" + }, + "markdownListEnumeration": { + "dark": "rose", + "light": "#d7827e" + }, + "markdownImage": { + "dark": "foam", + "light": "pine" + }, + "markdownImageText": { + "dark": "rose", + "light": "#d7827e" + }, + "markdownCodeBlock": { + "dark": "#e0def4", + "light": "#575279" + }, + "syntaxComment": { + "dark": "muted", + "light": "dawnMuted" + }, + "syntaxKeyword": { + "dark": "pine", + "light": "#286983" + }, + "syntaxFunction": { + "dark": "rose", + "light": "#d7827e" + }, + "syntaxVariable": { + "dark": "#e0def4", + "light": "#575279" + }, + "syntaxString": { + "dark": "gold", + "light": "#ea9d34" + }, + "syntaxNumber": { + "dark": "iris", + "light": "#907aa9" + }, + "syntaxType": { + "dark": "foam", + "light": "#56949f" + }, + "syntaxOperator": { + "dark": "subtle", + "light": "dawnSubtle" + }, + "syntaxPunctuation": { + "dark": "subtle", + "light": "dawnSubtle" + } + } +} diff --git a/src/generated_themes/shadesofpurple.json b/src/generated_themes/shadesofpurple.json deleted file mode 100644 index bc62577..0000000 --- a/src/generated_themes/shadesofpurple.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Shades of Purple", - "id": "shadesofpurple", - "light": { - "seeds": { - "neutral": "#f7ebff", - "primary": "#7a5af8", - "success": "#3dd598", - "warning": "#f7c948", - "error": "#ff6bd5", - "info": "#62d4ff", - "interactive": "#7a5af8", - "diffAdd": "#c8f8da", - "diffDelete": "#ffc3ef" - }, - "overrides": { - "background-base": "#f7ebff", - "background-weak": "#f2e2ff", - "background-strong": "#fbf2ff", - "background-stronger": "#fff7ff", - "border-weak-base": "#e5d3ff", - "border-weak-hover": "#dac8f5", - "border-weak-active": "#d1bdeb", - "border-weak-selected": "#c6b3e1", - "border-weak-disabled": "#fcf6ff", - "border-weak-focus": "#ccb9e7", - "border-base": "#baa4d5", - "border-hover": "#b098cb", - "border-active": "#a68dc2", - "border-selected": "#9b82b8", - "border-disabled": "#f1e7ff", - "border-focus": "#a692c6", - "border-strong-base": "#8769a9", - "border-strong-hover": "#7b5c9d", - "border-strong-active": "#704f91", - "border-strong-selected": "#664587", - "border-strong-disabled": "#d8c4f0", - "border-strong-focus": "#755495", - "surface-diff-add-base": "#edf8f1", - "surface-diff-delete-base": "#ffe4f4", - "surface-diff-hidden-base": "#e9e4ff", - "text-base": "#3b2c59", - "text-weak": "#6c568f", - "text-strong": "#1c1033", - "syntax-string": "#3dd598", - "syntax-primitive": "#ff6bd5", - "syntax-property": "#7a5af8", - "syntax-type": "#f7c948", - "syntax-constant": "#62d4ff", - "syntax-info": "#62d4ff", - "markdown-heading": "#7a5af8", - "markdown-text": "#3b2c59", - "markdown-link": "#7a5af8", - "markdown-link-text": "#62d4ff", - "markdown-code": "#3dd598", - "markdown-block-quote": "#f7c948", - "markdown-emph": "#f7c948", - "markdown-strong": "#ff6bd5", - "markdown-horizontal-rule": "#decbed", - "markdown-list-item": "#7a5af8", - "markdown-list-enumeration": "#62d4ff", - "markdown-image": "#7a5af8", - "markdown-image-text": "#62d4ff", - "markdown-code-block": "#7a5af8" - } - }, - "dark": { - "seeds": { - "neutral": "#1a102b", - "primary": "#c792ff", - "success": "#7be0b0", - "warning": "#ffd580", - "error": "#ff7ac6", - "info": "#7dd4ff", - "interactive": "#c792ff", - "diffAdd": "#53c39f", - "diffDelete": "#d85aa0" - }, - "overrides": { - "background-base": "#1a102b", - "background-weak": "#1f1434", - "background-strong": "#1c122f", - "background-stronger": "#170e26", - "border-weak-base": "#352552", - "border-weak-hover": "#3a2a5d", - "border-weak-active": "#402f68", - "border-weak-selected": "#463674", - "border-weak-disabled": "#10091b", - "border-weak-focus": "#3d2d65", - "border-base": "#4d3a73", - "border-hover": "#553f7f", - "border-active": "#5d468c", - "border-selected": "#654c99", - "border-disabled": "#150d21", - "border-focus": "#594283", - "border-strong-base": "#7659b0", - "border-strong-hover": "#8262be", - "border-strong-active": "#8e6ccc", - "border-strong-selected": "#9a77da", - "border-strong-disabled": "#1c122c", - "border-strong-focus": "#8666c4", - "surface-diff-add-base": "#142c27", - "surface-diff-delete-base": "#2d1424", - "surface-diff-hidden-base": "#231737", - "text-base": "#f5f0ff", - "text-weak": "#c9b6ff", - "text-strong": "#ffffff", - "syntax-string": "#7be0b0", - "syntax-primitive": "#ff7ac6", - "syntax-property": "#c792ff", - "syntax-type": "#ffd580", - "syntax-constant": "#7dd4ff", - "syntax-info": "#7dd4ff", - "markdown-heading": "#c792ff", - "markdown-text": "#f5f0ff", - "markdown-link": "#c792ff", - "markdown-link-text": "#7dd4ff", - "markdown-code": "#7be0b0", - "markdown-block-quote": "#ffd580", - "markdown-emph": "#ffd580", - "markdown-strong": "#ff7ac6", - "markdown-horizontal-rule": "#2d1d41", - "markdown-list-item": "#c792ff", - "markdown-list-enumeration": "#7dd4ff", - "markdown-image": "#c792ff", - "markdown-image-text": "#7dd4ff", - "markdown-code-block": "#f5f0ff" - } - } -} diff --git a/src/generated_themes/solarized.json b/src/generated_themes/solarized.json index 7cb4477..e4de113 100644 --- a/src/generated_themes/solarized.json +++ b/src/generated_themes/solarized.json @@ -1,131 +1,223 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Solarized", - "id": "solarized", - "light": { - "seeds": { - "neutral": "#fdf6e3", - "primary": "#268bd2", - "success": "#859900", - "warning": "#b58900", - "error": "#dc322f", - "info": "#2aa198", - "interactive": "#268bd2", - "diffAdd": "#c6dc7a", - "diffDelete": "#f2a1a1" - }, - "overrides": { - "background-base": "#fdf6e3", - "background-weak": "#f6efda", - "background-strong": "#faf3dc", - "background-stronger": "#f6edd4", - "border-weak-base": "#e3e0cd", - "border-weak-hover": "#d9d4c2", - "border-weak-active": "#cfcab7", - "border-weak-selected": "#c5c0ad", - "border-weak-disabled": "#f2edda", - "border-weak-focus": "#cbc6b2", - "border-base": "#bcb5a0", - "border-hover": "#b1aa96", - "border-active": "#a59f8c", - "border-selected": "#999382", - "border-disabled": "#ede7d4", - "border-focus": "#aca58f", - "border-strong-base": "#8c8572", - "border-strong-hover": "#7f7866", - "border-strong-active": "#716b5b", - "border-strong-selected": "#645f50", - "border-strong-disabled": "#d5cdb8", - "border-strong-focus": "#78715f", - "surface-diff-add-base": "#eef5d6", - "surface-diff-delete-base": "#fde4dd", - "surface-diff-hidden-base": "#e3ecf3", - "text-base": "#586e75", - "text-weak": "#7a8c8e", - "text-strong": "#073642", - "syntax-string": "#859900", - "syntax-primitive": "#d33682", - "syntax-property": "#268bd2", - "syntax-type": "#b58900", - "syntax-constant": "#2aa198", - "syntax-info": "#2aa198", - "markdown-heading": "#268bd2", - "markdown-text": "#586e75", - "markdown-link": "#268bd2", - "markdown-link-text": "#2aa198", - "markdown-code": "#859900", - "markdown-block-quote": "#b58900", - "markdown-emph": "#b58900", - "markdown-strong": "#d33682", - "markdown-horizontal-rule": "#cfd1bf", - "markdown-list-item": "#268bd2", - "markdown-list-enumeration": "#2aa198", - "markdown-image": "#268bd2", - "markdown-image-text": "#2aa198", - "markdown-code-block": "#2aa198" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "base03": "#002b36", + "base02": "#073642", + "base01": "#586e75", + "base00": "#657b83", + "base0": "#839496", + "base1": "#93a1a1", + "base2": "#eee8d5", + "base3": "#fdf6e3", + "yellow": "#b58900", + "orange": "#cb4b16", + "red": "#dc322f", + "magenta": "#d33682", + "violet": "#6c71c4", + "blue": "#268bd2", + "cyan": "#2aa198", + "green": "#859900" }, - "dark": { - "seeds": { - "neutral": "#002b36", - "primary": "#6c71c4", - "success": "#859900", - "warning": "#b58900", - "error": "#dc322f", - "info": "#2aa198", - "interactive": "#6c71c4", - "diffAdd": "#4c7654", - "diffDelete": "#c34b4b" - }, - "overrides": { - "background-base": "#001f27", - "background-weak": "#022733", - "background-strong": "#01222b", - "background-stronger": "#032830", - "border-weak-base": "#20373f", - "border-weak-hover": "#243e47", - "border-weak-active": "#28434f", - "border-weak-selected": "#2d4958", - "border-weak-disabled": "#0f2026", - "border-weak-focus": "#2a4552", - "border-base": "#31505b", - "border-hover": "#365765", - "border-active": "#3c5e70", - "border-selected": "#42657a", - "border-disabled": "#13272e", - "border-focus": "#3a5a6b", - "border-strong-base": "#4a7887", - "border-strong-hover": "#528294", - "border-strong-active": "#5a8ca1", - "border-strong-selected": "#6396ae", - "border-strong-disabled": "#1b323b", - "border-strong-focus": "#56879a", - "surface-diff-add-base": "#0f2f29", - "surface-diff-delete-base": "#321c1c", - "surface-diff-hidden-base": "#0f3844", - "text-base": "#93a1a1", - "text-weak": "#6c7f80", - "text-strong": "#fdf6e3", - "syntax-string": "#859900", - "syntax-primitive": "#d33682", - "syntax-property": "#6c71c4", - "syntax-type": "#b58900", - "syntax-constant": "#2aa198", - "syntax-info": "#2aa198", - "markdown-heading": "#6c71c4", - "markdown-text": "#93a1a1", - "markdown-link": "#6c71c4", - "markdown-link-text": "#2aa198", - "markdown-code": "#859900", - "markdown-block-quote": "#b58900", - "markdown-emph": "#b58900", - "markdown-strong": "#d33682", - "markdown-horizontal-rule": "#0e3b46", - "markdown-list-item": "#6c71c4", - "markdown-list-enumeration": "#2aa198", - "markdown-image": "#6c71c4", - "markdown-image-text": "#2aa198", - "markdown-code-block": "#93a1a1" + "theme": { + "primary": { + "dark": "blue", + "light": "blue" + }, + "secondary": { + "dark": "violet", + "light": "violet" + }, + "accent": { + "dark": "cyan", + "light": "cyan" + }, + "error": { + "dark": "red", + "light": "red" + }, + "warning": { + "dark": "yellow", + "light": "yellow" + }, + "success": { + "dark": "green", + "light": "green" + }, + "info": { + "dark": "orange", + "light": "orange" + }, + "text": { + "dark": "base0", + "light": "base00" + }, + "textMuted": { + "dark": "base01", + "light": "base1" + }, + "background": { + "dark": "base03", + "light": "base3" + }, + "backgroundPanel": { + "dark": "base02", + "light": "base2" + }, + "backgroundElement": { + "dark": "#073642", + "light": "#eee8d5" + }, + "border": { + "dark": "base02", + "light": "base2" + }, + "borderActive": { + "dark": "base01", + "light": "base1" + }, + "borderSubtle": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffAdded": { + "dark": "green", + "light": "green" + }, + "diffRemoved": { + "dark": "red", + "light": "red" + }, + "diffContext": { + "dark": "base01", + "light": "base1" + }, + "diffHunkHeader": { + "dark": "base01", + "light": "base1" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "green" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "red" + }, + "diffAddedBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffRemovedBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffContextBg": { + "dark": "base02", + "light": "base2" + }, + "diffLineNumber": { + "dark": "base01", + "light": "base1" + }, + "diffAddedLineNumberBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffRemovedLineNumberBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "markdownText": { + "dark": "base0", + "light": "base00" + }, + "markdownHeading": { + "dark": "blue", + "light": "blue" + }, + "markdownLink": { + "dark": "cyan", + "light": "cyan" + }, + "markdownLinkText": { + "dark": "violet", + "light": "violet" + }, + "markdownCode": { + "dark": "green", + "light": "green" + }, + "markdownBlockQuote": { + "dark": "base01", + "light": "base1" + }, + "markdownEmph": { + "dark": "yellow", + "light": "yellow" + }, + "markdownStrong": { + "dark": "orange", + "light": "orange" + }, + "markdownHorizontalRule": { + "dark": "base01", + "light": "base1" + }, + "markdownListItem": { + "dark": "blue", + "light": "blue" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImage": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImageText": { + "dark": "violet", + "light": "violet" + }, + "markdownCodeBlock": { + "dark": "base0", + "light": "base00" + }, + "syntaxComment": { + "dark": "base01", + "light": "base1" + }, + "syntaxKeyword": { + "dark": "green", + "light": "green" + }, + "syntaxFunction": { + "dark": "blue", + "light": "blue" + }, + "syntaxVariable": { + "dark": "cyan", + "light": "cyan" + }, + "syntaxString": { + "dark": "cyan", + "light": "cyan" + }, + "syntaxNumber": { + "dark": "magenta", + "light": "magenta" + }, + "syntaxType": { + "dark": "yellow", + "light": "yellow" + }, + "syntaxOperator": { + "dark": "green", + "light": "green" + }, + "syntaxPunctuation": { + "dark": "base0", + "light": "base00" } } } diff --git a/src/generated_themes/synthwave84.json b/src/generated_themes/synthwave84.json new file mode 100644 index 0000000..d25bf3b --- /dev/null +++ b/src/generated_themes/synthwave84.json @@ -0,0 +1,226 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#262335", + "backgroundAlt": "#1e1a29", + "backgroundPanel": "#2a2139", + "foreground": "#ffffff", + "foregroundMuted": "#848bbd", + "pink": "#ff7edb", + "pinkBright": "#ff92df", + "cyan": "#36f9f6", + "cyanBright": "#72f1f8", + "yellow": "#fede5d", + "yellowBright": "#fff95d", + "orange": "#ff8b39", + "orangeBright": "#ff9f43", + "purple": "#b084eb", + "purpleBright": "#c792ea", + "red": "#fe4450", + "redBright": "#ff5e5b", + "green": "#72f1b8", + "greenBright": "#97f1d8" + }, + "theme": { + "primary": { + "dark": "cyan", + "light": "#00bcd4" + }, + "secondary": { + "dark": "pink", + "light": "#e91e63" + }, + "accent": { + "dark": "purple", + "light": "#9c27b0" + }, + "error": { + "dark": "red", + "light": "#f44336" + }, + "warning": { + "dark": "yellow", + "light": "#ff9800" + }, + "success": { + "dark": "green", + "light": "#4caf50" + }, + "info": { + "dark": "orange", + "light": "#ff5722" + }, + "text": { + "dark": "foreground", + "light": "#262335" + }, + "textMuted": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "background": { + "dark": "#262335", + "light": "#fafafa" + }, + "backgroundPanel": { + "dark": "#1e1a29", + "light": "#f5f5f5" + }, + "backgroundElement": { + "dark": "#2a2139", + "light": "#eeeeee" + }, + "border": { + "dark": "#495495", + "light": "#e0e0e0" + }, + "borderActive": { + "dark": "cyan", + "light": "#00bcd4" + }, + "borderSubtle": { + "dark": "#241b2f", + "light": "#f0f0f0" + }, + "diffAdded": { + "dark": "green", + "light": "#4caf50" + }, + "diffRemoved": { + "dark": "red", + "light": "#f44336" + }, + "diffContext": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "diffHunkHeader": { + "dark": "purple", + "light": "#9c27b0" + }, + "diffHighlightAdded": { + "dark": "greenBright", + "light": "#4caf50" + }, + "diffHighlightRemoved": { + "dark": "redBright", + "light": "#f44336" + }, + "diffAddedBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "#1e1a29", + "light": "#f5f5f5" + }, + "diffLineNumber": { + "dark": "#495495", + "light": "#b0b0b0" + }, + "diffAddedLineNumberBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "markdownText": { + "dark": "foreground", + "light": "#262335" + }, + "markdownHeading": { + "dark": "pink", + "light": "#e91e63" + }, + "markdownLink": { + "dark": "cyan", + "light": "#00bcd4" + }, + "markdownLinkText": { + "dark": "purple", + "light": "#9c27b0" + }, + "markdownCode": { + "dark": "green", + "light": "#4caf50" + }, + "markdownBlockQuote": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "markdownEmph": { + "dark": "yellow", + "light": "#ff9800" + }, + "markdownStrong": { + "dark": "orange", + "light": "#ff5722" + }, + "markdownHorizontalRule": { + "dark": "#495495", + "light": "#e0e0e0" + }, + "markdownListItem": { + "dark": "cyan", + "light": "#00bcd4" + }, + "markdownListEnumeration": { + "dark": "purple", + "light": "#9c27b0" + }, + "markdownImage": { + "dark": "cyan", + "light": "#00bcd4" + }, + "markdownImageText": { + "dark": "purple", + "light": "#9c27b0" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#262335" + }, + "syntaxComment": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "syntaxKeyword": { + "dark": "pink", + "light": "#e91e63" + }, + "syntaxFunction": { + "dark": "orange", + "light": "#ff5722" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#262335" + }, + "syntaxString": { + "dark": "yellow", + "light": "#ff9800" + }, + "syntaxNumber": { + "dark": "purple", + "light": "#9c27b0" + }, + "syntaxType": { + "dark": "cyan", + "light": "#00bcd4" + }, + "syntaxOperator": { + "dark": "pink", + "light": "#e91e63" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#262335" + } + } +} diff --git a/src/generated_themes/tokyonight.json b/src/generated_themes/tokyonight.json index 31d0e8a..1c9503a 100644 --- a/src/generated_themes/tokyonight.json +++ b/src/generated_themes/tokyonight.json @@ -1,155 +1,243 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Tokyonight", - "id": "tokyonight", - "light": { - "seeds": { - "neutral": "#e1e2e7", - "primary": "#2e7de9", - "success": "#587539", - "warning": "#8c6c3e", - "error": "#c94060", - "info": "#007197", - "interactive": "#2e7de9", - "diffAdd": "#4f8f7b", - "diffDelete": "#d05f7c" - }, - "overrides": { - "background-base": "#e1e2e7", - "background-weak": "#dee0ea", - "background-strong": "#e5e6ee", - "background-stronger": "#e9eaf1", - "border-weak-base": "#cdd0dc", - "border-weak-hover": "#c3c6d2", - "border-weak-active": "#b9bcc8", - "border-weak-selected": "#aeb2bf", - "border-weak-disabled": "#e6e7ef", - "border-weak-focus": "#b3b6c3", - "border-base": "#a7abbb", - "border-hover": "#9ba0b1", - "border-active": "#9095a8", - "border-selected": "#83889e", - "border-disabled": "#dedfe6", - "border-focus": "#9599a8", - "border-strong-base": "#757b90", - "border-strong-hover": "#6a7084", - "border-strong-active": "#5f6578", - "border-strong-selected": "#545a6d", - "border-strong-disabled": "#c4c6d0", - "border-strong-focus": "#666b7f", - "surface-diff-add-base": "#dfe7da", - "surface-diff-delete-base": "#f4dadd", - "surface-diff-hidden-base": "#cfd1dd", - "text-base": "#273153", - "text-weak": "#5c6390", - "text-strong": "#1c2544", - "syntax-string": "#587539", - "syntax-primitive": "#b15c00", - "syntax-property": "#9854f1", - "syntax-type": "#3760bf", - "syntax-constant": "#007197", - "syntax-info": "#007197", - "markdown-heading": "#9854f1", - "markdown-text": "#273153", - "markdown-link": "#2e7de9", - "markdown-link-text": "#007197", - "markdown-code": "#587539", - "markdown-block-quote": "#8c6c3e", - "markdown-emph": "#8c6c3e", - "markdown-strong": "#b15c00", - "markdown-horizontal-rule": "#a1a6c5", - "markdown-list-item": "#2e7de9", - "markdown-list-enumeration": "#007197", - "markdown-image": "#2e7de9", - "markdown-image-text": "#007197", - "markdown-code-block": "#3760bf" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#1a1b26", + "darkStep2": "#1e2030", + "darkStep3": "#222436", + "darkStep4": "#292e42", + "darkStep5": "#3b4261", + "darkStep6": "#545c7e", + "darkStep7": "#737aa2", + "darkStep8": "#9099b2", + "darkStep9": "#82aaff", + "darkStep10": "#89b4fa", + "darkStep11": "#828bb8", + "darkStep12": "#c8d3f5", + "darkRed": "#ff757f", + "darkOrange": "#ff966c", + "darkYellow": "#ffc777", + "darkGreen": "#c3e88d", + "darkCyan": "#86e1fc", + "darkPurple": "#c099ff", + "lightStep1": "#e1e2e7", + "lightStep2": "#d5d6db", + "lightStep3": "#c8c9ce", + "lightStep4": "#b9bac1", + "lightStep5": "#a8aecb", + "lightStep6": "#9699a8", + "lightStep7": "#737a8c", + "lightStep8": "#5a607d", + "lightStep9": "#2e7de9", + "lightStep10": "#1a6ce7", + "lightStep11": "#8990a3", + "lightStep12": "#3760bf", + "lightRed": "#f52a65", + "lightOrange": "#b15c00", + "lightYellow": "#8c6c3e", + "lightGreen": "#587539", + "lightCyan": "#007197", + "lightPurple": "#9854f1" }, - "dark": { - "seeds": { - "neutral": "#1a1b26", - "primary": "#7aa2f7", - "success": "#9ece6a", - "warning": "#e0af68", - "error": "#f7768e", - "info": "#7dcfff", - "interactive": "#7aa2f7", - "diffAdd": "#41a6b5", - "diffDelete": "#c34043" - }, - "overrides": { - "background-base": "#0f111a", - "background-weak": "#111428", - "background-strong": "#101324", - "background-stronger": "#13172a", - "border-weak-base": "#25283b", - "border-weak-hover": "#292c43", - "border-weak-active": "#2e314b", - "border-weak-selected": "#343755", - "border-weak-disabled": "#151727", - "border-weak-focus": "#30324f", - "border-base": "#3a3e57", - "border-hover": "#414264", - "border-active": "#474972", - "border-selected": "#4f507f", - "border-disabled": "#1c1d2d", - "border-focus": "#45496f", - "border-strong-base": "#5a5f82", - "border-strong-hover": "#646994", - "border-strong-active": "#6f74a6", - "border-strong-selected": "#7a7fb8", - "border-strong-disabled": "#23243a", - "border-strong-focus": "#6a6f9f", - "surface-base": "#1f2335", - "base": "#1f2335", - "surface-base-hover": "#232840", - "surface-base-active": "#262c46", - "surface-base-interactive-active": "#2b3357", - "base2": "#1f2335", - "base3": "#1f2335", - "surface-inset-base": "#161a2ab3", - "surface-inset-base-hover": "#161a2acc", - "surface-inset-strong": "#0d111fcc", - "surface-inset-strong-hover": "#0d111fcc", - "surface-raised-base": "#242a42", - "surface-float-base": "#242b45", - "surface-float-base-hover": "#2a3154", - "surface-raised-base-hover": "#272e49", - "surface-raised-base-active": "#2c3353", - "surface-raised-strong": "#31385a", - "surface-raised-strong-hover": "#373f6b", - "surface-raised-stronger": "#3b4261", - "surface-raised-stronger-hover": "#444c82", - "surface-weak": "#1b2033", - "surface-weaker": "#181d2d", - "surface-strong": "#323858", - "surface-raised-stronger-non-alpha": "#2b3150", - "surface-diff-add-base": "#1c2a38", - "surface-diff-delete-base": "#2a1f32", - "surface-diff-hidden-base": "#24283b", - "text-base": "#c0caf5", - "text-weak": "#7a88cf", - "text-strong": "#eaeaff", - "syntax-string": "#9ece6a", - "syntax-primitive": "#ff9e64", - "syntax-property": "#bb9af7", - "syntax-type": "#e0af68", - "syntax-constant": "#7dcfff", - "syntax-info": "#7dcfff", - "markdown-heading": "#bb9af7", - "markdown-text": "#c0caf5", - "markdown-link": "#7aa2f7", - "markdown-link-text": "#7dcfff", - "markdown-code": "#9ece6a", - "markdown-block-quote": "#e0af68", - "markdown-emph": "#e0af68", - "markdown-strong": "#ff9e64", - "markdown-horizontal-rule": "#3b4261", - "markdown-list-item": "#7aa2f7", - "markdown-list-enumeration": "#7dcfff", - "markdown-image": "#7aa2f7", - "markdown-image-text": "#7dcfff", - "markdown-code-block": "#c0caf5" + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "accent": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "darkStep7", + "light": "lightStep7" + }, + "borderActive": { + "dark": "darkStep8", + "light": "lightStep8" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#4fd6be", + "light": "#1e725c" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#b8db87", + "light": "#4db380" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#20303b", + "light": "#d5e5d5" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#1b2b34", + "light": "#c5d5c5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxFunction": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" } } } diff --git a/src/generated_themes/undertale.json b/src/generated_themes/undertale.json deleted file mode 100644 index bfbd60b..0000000 --- a/src/generated_themes/undertale.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "black": "#000000", - "white": "#FFFFFF", - "soulRed": "#FF0000", - "soulOrange": "#FF6600", - "soulYellow": "#FFFF00", - "soulGreen": "#00FF00", - "soulAqua": "#00FFFF", - "soulBlue": "#0000FF", - "soulPurple": "#FF00FF", - "ruinsPurple": "#A349A4", - "ruinsDark": "#380A43", - "snowdinBlue": "#6BA3E5", - "hotlandOrange": "#FF7F27", - "coreGray": "#3A3949", - "battleBg": "#0D0D1A", - "battlePanel": "#1A1A2E", - "uiYellow": "#FFC90E", - "textGray": "#909090", - "damageRed": "#FF3333", - "healGreen": "#00FF00", - "saveYellow": "#FFFF00", - "determinationRed": "#FF0000", - "mttPink": "#FF6EB4", - "waterfall": "#283197", - "waterfallGlow": "#00BFFF" - }, - "theme": { - "primary": { - "dark": "soulRed", - "light": "determinationRed" - }, - "secondary": { - "dark": "uiYellow", - "light": "uiYellow" - }, - "accent": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "error": { - "dark": "damageRed", - "light": "soulRed" - }, - "warning": { - "dark": "uiYellow", - "light": "hotlandOrange" - }, - "success": { - "dark": "healGreen", - "light": "soulGreen" - }, - "info": { - "dark": "soulAqua", - "light": "waterfallGlow" - }, - "text": { - "dark": "white", - "light": "black" - }, - "textMuted": { - "dark": "textGray", - "light": "coreGray" - }, - "background": { - "dark": "black", - "light": "white" - }, - "backgroundPanel": { - "dark": "battleBg", - "light": "#F0F0F0" - }, - "backgroundElement": { - "dark": "battlePanel", - "light": "#E5E5E5" - }, - "border": { - "dark": "white", - "light": "black" - }, - "borderActive": { - "dark": "soulRed", - "light": "determinationRed" - }, - "borderSubtle": { - "dark": "#555555", - "light": "#AAAAAA" - }, - "diffAdded": { - "dark": "healGreen", - "light": "soulGreen" - }, - "diffRemoved": { - "dark": "damageRed", - "light": "soulRed" - }, - "diffContext": { - "dark": "textGray", - "light": "coreGray" - }, - "diffHunkHeader": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "diffHighlightAdded": { - "dark": "soulGreen", - "light": "healGreen" - }, - "diffHighlightRemoved": { - "dark": "soulRed", - "light": "determinationRed" - }, - "diffAddedBg": { - "dark": "#002200", - "light": "#CCFFCC" - }, - "diffRemovedBg": { - "dark": "#220000", - "light": "#FFCCCC" - }, - "diffContextBg": { - "dark": "battleBg", - "light": "#F5F5F5" - }, - "diffLineNumber": { - "dark": "textGray", - "light": "coreGray" - }, - "diffAddedLineNumberBg": { - "dark": "#001A00", - "light": "#E0FFE0" - }, - "diffRemovedLineNumberBg": { - "dark": "#1A0000", - "light": "#FFE0E0" - }, - "markdownText": { - "dark": "white", - "light": "black" - }, - "markdownHeading": { - "dark": "uiYellow", - "light": "hotlandOrange" - }, - "markdownLink": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "markdownLinkText": { - "dark": "waterfallGlow", - "light": "waterfall" - }, - "markdownCode": { - "dark": "healGreen", - "light": "soulGreen" - }, - "markdownBlockQuote": { - "dark": "textGray", - "light": "coreGray" - }, - "markdownEmph": { - "dark": "mttPink", - "light": "soulPurple" - }, - "markdownStrong": { - "dark": "soulRed", - "light": "determinationRed" - }, - "markdownHorizontalRule": { - "dark": "white", - "light": "black" - }, - "markdownListItem": { - "dark": "uiYellow", - "light": "uiYellow" - }, - "markdownListEnumeration": { - "dark": "uiYellow", - "light": "uiYellow" - }, - "markdownImage": { - "dark": "ruinsPurple", - "light": "soulPurple" - }, - "markdownImageText": { - "dark": "mttPink", - "light": "ruinsPurple" - }, - "markdownCodeBlock": { - "dark": "white", - "light": "black" - }, - "syntaxComment": { - "dark": "textGray", - "light": "coreGray" - }, - "syntaxKeyword": { - "dark": "soulRed", - "light": "determinationRed" - }, - "syntaxFunction": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "syntaxVariable": { - "dark": "uiYellow", - "light": "hotlandOrange" - }, - "syntaxString": { - "dark": "healGreen", - "light": "soulGreen" - }, - "syntaxNumber": { - "dark": "mttPink", - "light": "soulPurple" - }, - "syntaxType": { - "dark": "waterfallGlow", - "light": "waterfall" - }, - "syntaxOperator": { - "dark": "white", - "light": "black" - }, - "syntaxPunctuation": { - "dark": "textGray", - "light": "coreGray" - } - } -} diff --git a/src/generated_themes/vercel.json b/src/generated_themes/vercel.json new file mode 100644 index 0000000..86b965b --- /dev/null +++ b/src/generated_themes/vercel.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background100": "#0A0A0A", + "background200": "#000000", + "gray100": "#1A1A1A", + "gray200": "#1F1F1F", + "gray300": "#292929", + "gray400": "#2E2E2E", + "gray500": "#454545", + "gray600": "#878787", + "gray700": "#8F8F8F", + "gray900": "#A1A1A1", + "gray1000": "#EDEDED", + "blue600": "#0099FF", + "blue700": "#0070F3", + "blue900": "#52A8FF", + "blue1000": "#EBF8FF", + "red700": "#E5484D", + "red900": "#FF6166", + "red1000": "#FDECED", + "amber700": "#FFB224", + "amber900": "#F2A700", + "amber1000": "#FDF4DC", + "green700": "#46A758", + "green900": "#63C46D", + "green1000": "#E6F9E9", + "teal700": "#12A594", + "teal900": "#0AC7AC", + "purple700": "#8E4EC6", + "purple900": "#BF7AF0", + "pink700": "#E93D82", + "pink900": "#F75590", + "highlightPink": "#FF0080", + "highlightPurple": "#F81CE5", + "cyan": "#50E3C2", + "lightBackground": "#FFFFFF", + "lightGray100": "#FAFAFA", + "lightGray200": "#EAEAEA", + "lightGray600": "#666666", + "lightGray1000": "#171717" + }, + "theme": { + "primary": { + "dark": "blue700", + "light": "blue700" + }, + "secondary": { + "dark": "blue900", + "light": "#0062D1" + }, + "accent": { + "dark": "purple700", + "light": "purple700" + }, + "error": { + "dark": "red700", + "light": "#DC3545" + }, + "warning": { + "dark": "amber700", + "light": "#FF9500" + }, + "success": { + "dark": "green700", + "light": "#388E3C" + }, + "info": { + "dark": "blue900", + "light": "blue700" + }, + "text": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "textMuted": { + "dark": "gray600", + "light": "lightGray600" + }, + "background": { + "dark": "background200", + "light": "lightBackground" + }, + "backgroundPanel": { + "dark": "gray100", + "light": "lightGray100" + }, + "backgroundElement": { + "dark": "gray300", + "light": "lightGray200" + }, + "border": { + "dark": "gray200", + "light": "lightGray200" + }, + "borderActive": { + "dark": "gray500", + "light": "#999999" + }, + "borderSubtle": { + "dark": "gray100", + "light": "#EAEAEA" + }, + "diffAdded": { + "dark": "green900", + "light": "green700" + }, + "diffRemoved": { + "dark": "red900", + "light": "red700" + }, + "diffContext": { + "dark": "gray600", + "light": "lightGray600" + }, + "diffHunkHeader": { + "dark": "gray600", + "light": "lightGray600" + }, + "diffHighlightAdded": { + "dark": "green900", + "light": "green700" + }, + "diffHighlightRemoved": { + "dark": "red900", + "light": "red700" + }, + "diffAddedBg": { + "dark": "#0B1D0F", + "light": "#E6F9E9" + }, + "diffRemovedBg": { + "dark": "#2A1314", + "light": "#FDECED" + }, + "diffContextBg": { + "dark": "background200", + "light": "lightBackground" + }, + "diffLineNumber": { + "dark": "gray600", + "light": "lightGray600" + }, + "diffAddedLineNumberBg": { + "dark": "#0F2613", + "light": "#D6F5D6" + }, + "diffRemovedLineNumberBg": { + "dark": "#3C1618", + "light": "#FFE5E5" + }, + "markdownText": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "markdownHeading": { + "dark": "purple900", + "light": "purple700" + }, + "markdownLink": { + "dark": "blue900", + "light": "blue700" + }, + "markdownLinkText": { + "dark": "teal900", + "light": "teal700" + }, + "markdownCode": { + "dark": "green900", + "light": "green700" + }, + "markdownBlockQuote": { + "dark": "gray600", + "light": "lightGray600" + }, + "markdownEmph": { + "dark": "amber900", + "light": "amber700" + }, + "markdownStrong": { + "dark": "pink900", + "light": "pink700" + }, + "markdownHorizontalRule": { + "dark": "gray500", + "light": "#999999" + }, + "markdownListItem": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "markdownListEnumeration": { + "dark": "blue900", + "light": "blue700" + }, + "markdownImage": { + "dark": "teal900", + "light": "teal700" + }, + "markdownImageText": { + "dark": "cyan", + "light": "teal700" + }, + "markdownCodeBlock": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "syntaxComment": { + "dark": "gray600", + "light": "#888888" + }, + "syntaxKeyword": { + "dark": "pink900", + "light": "pink700" + }, + "syntaxFunction": { + "dark": "purple900", + "light": "purple700" + }, + "syntaxVariable": { + "dark": "blue900", + "light": "blue700" + }, + "syntaxString": { + "dark": "green900", + "light": "green700" + }, + "syntaxNumber": { + "dark": "amber900", + "light": "amber700" + }, + "syntaxType": { + "dark": "teal900", + "light": "teal700" + }, + "syntaxOperator": { + "dark": "pink900", + "light": "pink700" + }, + "syntaxPunctuation": { + "dark": "gray1000", + "light": "lightGray1000" + } + } +} diff --git a/src/generated_themes/vesper.json b/src/generated_themes/vesper.json index 3c5e44c..758c8f2 100644 --- a/src/generated_themes/vesper.json +++ b/src/generated_themes/vesper.json @@ -1,131 +1,218 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Vesper", - "id": "vesper", - "light": { - "seeds": { - "neutral": "#F0F0F0", - "primary": "#FFC799", - "success": "#99FFE4", - "warning": "#FFC799", - "error": "#FF8080", - "info": "#FFC799", - "interactive": "#FFC799", - "diffAdd": "#99FFE4", - "diffDelete": "#FF8080" - }, - "overrides": { - "background-base": "#FFF", - "background-weak": "#F8F8F8", - "background-strong": "#F0F0F0", - "background-stronger": "#E8E8E8", - "border-weak-base": "#E8E8E8", - "border-weak-hover": "#E0E0E0", - "border-weak-active": "#D8D8D8", - "border-weak-selected": "#D0D0D0", - "border-weak-disabled": "#F0F0F0", - "border-weak-focus": "#D8D8D8", - "border-base": "#D0D0D0", - "border-hover": "#C8C8C8", - "border-active": "#C0C0C0", - "border-selected": "#B8B8B8", - "border-disabled": "#E8E8E8", - "border-focus": "#C0C0C0", - "border-strong-base": "#A0A0A0", - "border-strong-hover": "#989898", - "border-strong-active": "#909090", - "border-strong-selected": "#888888", - "border-strong-disabled": "#D0D0D0", - "border-strong-focus": "#909090", - "surface-diff-add-base": "#e8f5e8", - "surface-diff-delete-base": "#f5e8e8", - "surface-diff-hidden-base": "#F0F0F0", - "text-base": "#101010", - "text-weak": "#A0A0A0", - "text-strong": "#000000", - "syntax-string": "#99FFE4", - "syntax-primitive": "#FF8080", - "syntax-property": "#FFC799", - "syntax-type": "#FFC799", - "syntax-constant": "#A0A0A0", - "syntax-info": "#A0A0A0", - "markdown-heading": "#FFC799", - "markdown-text": "#101010", - "markdown-link": "#FFC799", - "markdown-link-text": "#A0A0A0", - "markdown-code": "#A0A0A0", - "markdown-block-quote": "#101010", - "markdown-emph": "#101010", - "markdown-strong": "#101010", - "markdown-horizontal-rule": "#65737E", - "markdown-list-item": "#101010", - "markdown-list-enumeration": "#101010", - "markdown-image": "#FFC799", - "markdown-image-text": "#A0A0A0", - "markdown-code-block": "#FFC799" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "vesperBg": "#101010", + "vesperFg": "#FFF", + "vesperComment": "#8b8b8b", + "vesperKeyword": "#A0A0A0", + "vesperFunction": "#FFC799", + "vesperString": "#99FFE4", + "vesperNumber": "#FFC799", + "vesperError": "#FF8080", + "vesperWarning": "#FFC799", + "vesperSuccess": "#99FFE4", + "vesperMuted": "#A0A0A0" }, - "dark": { - "seeds": { - "neutral": "#101010", - "primary": "#FFC799", - "success": "#99FFE4", - "warning": "#FFC799", - "error": "#FF8080", - "info": "#FFC799", - "interactive": "#FFC799", - "diffAdd": "#99FFE4", - "diffDelete": "#FF8080" - }, - "overrides": { - "background-base": "#101010", - "background-weak": "#141414", - "background-strong": "#0C0C0C", - "background-stronger": "#080808", - "border-weak-base": "#1C1C1C", - "border-weak-hover": "#202020", - "border-weak-active": "#242424", - "border-weak-selected": "#282828", - "border-weak-disabled": "#141414", - "border-weak-focus": "#242424", - "border-base": "#282828", - "border-hover": "#303030", - "border-active": "#383838", - "border-selected": "#404040", - "border-disabled": "#181818", - "border-focus": "#383838", - "border-strong-base": "#505050", - "border-strong-hover": "#585858", - "border-strong-active": "#606060", - "border-strong-selected": "#686868", - "border-strong-disabled": "#202020", - "border-strong-focus": "#606060", - "surface-diff-add-base": "#0d2818", - "surface-diff-delete-base": "#281a1a", - "surface-diff-hidden-base": "#141414", - "text-base": "#FFF", - "text-weak": "#A0A0A0", - "text-strong": "#FFFFFF", - "syntax-string": "#99FFE4", - "syntax-primitive": "#FF8080", - "syntax-property": "#FFC799", - "syntax-type": "#FFC799", - "syntax-constant": "#A0A0A0", - "syntax-info": "#8b8b8b", - "markdown-heading": "#FFC799", - "markdown-text": "#FFF", - "markdown-link": "#FFC799", - "markdown-link-text": "#A0A0A0", - "markdown-code": "#A0A0A0", - "markdown-block-quote": "#FFF", - "markdown-emph": "#FFF", - "markdown-strong": "#FFF", - "markdown-horizontal-rule": "#65737E", - "markdown-list-item": "#FFF", - "markdown-list-enumeration": "#FFF", - "markdown-image": "#FFC799", - "markdown-image-text": "#A0A0A0", - "markdown-code-block": "#FFF" + "theme": { + "primary": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "secondary": { + "dark": "#99FFE4", + "light": "#99FFE4" + }, + "accent": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "error": { + "dark": "vesperError", + "light": "vesperError" + }, + "warning": { + "dark": "vesperWarning", + "light": "vesperWarning" + }, + "success": { + "dark": "vesperSuccess", + "light": "vesperSuccess" + }, + "info": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "text": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "textMuted": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "background": { + "dark": "vesperBg", + "light": "#FFF" + }, + "backgroundPanel": { + "dark": "vesperBg", + "light": "#F0F0F0" + }, + "backgroundElement": { + "dark": "vesperBg", + "light": "#E0E0E0" + }, + "border": { + "dark": "#282828", + "light": "#D0D0D0" + }, + "borderActive": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "borderSubtle": { + "dark": "#1C1C1C", + "light": "#E8E8E8" + }, + "diffAdded": { + "dark": "vesperSuccess", + "light": "vesperSuccess" + }, + "diffRemoved": { + "dark": "vesperError", + "light": "vesperError" + }, + "diffContext": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "diffHunkHeader": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "diffHighlightAdded": { + "dark": "vesperSuccess", + "light": "vesperSuccess" + }, + "diffHighlightRemoved": { + "dark": "vesperError", + "light": "vesperError" + }, + "diffAddedBg": { + "dark": "#0d2818", + "light": "#e8f5e8" + }, + "diffRemovedBg": { + "dark": "#281a1a", + "light": "#f5e8e8" + }, + "diffContextBg": { + "dark": "vesperBg", + "light": "#F8F8F8" + }, + "diffLineNumber": { + "dark": "#505050", + "light": "#808080" + }, + "diffAddedLineNumberBg": { + "dark": "#0d2818", + "light": "#e8f5e8" + }, + "diffRemovedLineNumberBg": { + "dark": "#281a1a", + "light": "#f5e8e8" + }, + "markdownText": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownHeading": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "markdownLink": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "markdownLinkText": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "markdownCode": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "markdownBlockQuote": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownEmph": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownStrong": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownHorizontalRule": { + "dark": "#65737E", + "light": "#65737E" + }, + "markdownListItem": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownListEnumeration": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownImage": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "markdownImageText": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "markdownCodeBlock": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "syntaxComment": { + "dark": "vesperComment", + "light": "vesperComment" + }, + "syntaxKeyword": { + "dark": "vesperKeyword", + "light": "vesperKeyword" + }, + "syntaxFunction": { + "dark": "vesperFunction", + "light": "vesperFunction" + }, + "syntaxVariable": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "syntaxString": { + "dark": "vesperString", + "light": "vesperString" + }, + "syntaxNumber": { + "dark": "vesperNumber", + "light": "vesperNumber" + }, + "syntaxType": { + "dark": "vesperFunction", + "light": "vesperFunction" + }, + "syntaxOperator": { + "dark": "vesperKeyword", + "light": "vesperKeyword" + }, + "syntaxPunctuation": { + "dark": "vesperFg", + "light": "vesperBg" } } } diff --git a/src/generated_themes/zenburn.json b/src/generated_themes/zenburn.json new file mode 100644 index 0000000..c447592 --- /dev/null +++ b/src/generated_themes/zenburn.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "bg": "#3f3f3f", + "bgAlt": "#4f4f4f", + "bgPanel": "#5f5f5f", + "fg": "#dcdccc", + "fgMuted": "#9f9f9f", + "red": "#cc9393", + "redBright": "#dca3a3", + "green": "#7f9f7f", + "greenBright": "#8fb28f", + "yellow": "#f0dfaf", + "yellowDim": "#e0cf9f", + "blue": "#8cd0d3", + "blueDim": "#7cb8bb", + "magenta": "#dc8cc3", + "cyan": "#93e0e3", + "orange": "#dfaf8f" + }, + "theme": { + "primary": { + "dark": "blue", + "light": "#5f7f8f" + }, + "secondary": { + "dark": "magenta", + "light": "#8f5f8f" + }, + "accent": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "error": { + "dark": "red", + "light": "#8f5f5f" + }, + "warning": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "success": { + "dark": "green", + "light": "#5f8f5f" + }, + "info": { + "dark": "orange", + "light": "#8f7f5f" + }, + "text": { + "dark": "fg", + "light": "#3f3f3f" + }, + "textMuted": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "background": { + "dark": "bg", + "light": "#ffffef" + }, + "backgroundPanel": { + "dark": "bgAlt", + "light": "#f5f5e5" + }, + "backgroundElement": { + "dark": "bgPanel", + "light": "#ebebdb" + }, + "border": { + "dark": "#5f5f5f", + "light": "#d0d0c0" + }, + "borderActive": { + "dark": "blue", + "light": "#5f7f8f" + }, + "borderSubtle": { + "dark": "#4f4f4f", + "light": "#e0e0d0" + }, + "diffAdded": { + "dark": "green", + "light": "#5f8f5f" + }, + "diffRemoved": { + "dark": "red", + "light": "#8f5f5f" + }, + "diffContext": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "diffHunkHeader": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "diffHighlightAdded": { + "dark": "greenBright", + "light": "#5f8f5f" + }, + "diffHighlightRemoved": { + "dark": "redBright", + "light": "#8f5f5f" + }, + "diffAddedBg": { + "dark": "#4f5f4f", + "light": "#efffef" + }, + "diffRemovedBg": { + "dark": "#5f4f4f", + "light": "#ffefef" + }, + "diffContextBg": { + "dark": "bgAlt", + "light": "#f5f5e5" + }, + "diffLineNumber": { + "dark": "#6f6f6f", + "light": "#b0b0a0" + }, + "diffAddedLineNumberBg": { + "dark": "#4f5f4f", + "light": "#efffef" + }, + "diffRemovedLineNumberBg": { + "dark": "#5f4f4f", + "light": "#ffefef" + }, + "markdownText": { + "dark": "fg", + "light": "#3f3f3f" + }, + "markdownHeading": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "markdownLink": { + "dark": "blue", + "light": "#5f7f8f" + }, + "markdownLinkText": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "markdownCode": { + "dark": "green", + "light": "#5f8f5f" + }, + "markdownBlockQuote": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "markdownEmph": { + "dark": "yellowDim", + "light": "#8f8f5f" + }, + "markdownStrong": { + "dark": "orange", + "light": "#8f7f5f" + }, + "markdownHorizontalRule": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "markdownListItem": { + "dark": "blue", + "light": "#5f7f8f" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "markdownImage": { + "dark": "blue", + "light": "#5f7f8f" + }, + "markdownImageText": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "markdownCodeBlock": { + "dark": "fg", + "light": "#3f3f3f" + }, + "syntaxComment": { + "dark": "#7f9f7f", + "light": "#5f7f5f" + }, + "syntaxKeyword": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "syntaxFunction": { + "dark": "blue", + "light": "#5f7f8f" + }, + "syntaxVariable": { + "dark": "fg", + "light": "#3f3f3f" + }, + "syntaxString": { + "dark": "red", + "light": "#8f5f5f" + }, + "syntaxNumber": { + "dark": "greenBright", + "light": "#5f8f5f" + }, + "syntaxType": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "syntaxOperator": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "syntaxPunctuation": { + "dark": "fg", + "light": "#3f3f3f" + } + } +} diff --git a/src/theme.rs b/src/theme.rs index ee7b183..f358e11 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,23 +1,83 @@ use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; use std::fs; use std::path::Path; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Copy)] +pub struct ThemeColors { + pub primary: ratatui::style::Color, + pub background: ratatui::style::Color, + pub text: ratatui::style::Color, + pub text_weak: ratatui::style::Color, + pub text_strong: ratatui::style::Color, + pub border: ratatui::style::Color, + pub border_weak_focus: ratatui::style::Color, + pub border_focus: ratatui::style::Color, + pub border_strong_focus: ratatui::style::Color, + pub success: ratatui::style::Color, + pub warning: ratatui::style::Color, + pub error: ratatui::style::Color, + pub info: ratatui::style::Color, +} + +pub fn darken_color(color: ratatui::style::Color, factor: f32) -> ratatui::style::Color { + match color { + ratatui::style::Color::Rgb(r, g, b) => { + let r = (r as f32 * factor).max(0.0).min(255.0) as u8; + let g = (g as f32 * factor).max(0.0).min(255.0) as u8; + let b = (b as f32 * factor).max(0.0).min(255.0) as u8; + ratatui::style::Color::Rgb(r, g, b) + } + _ => color, + } +} + +pub fn contrast_text(background: ratatui::style::Color) -> ratatui::style::Color { + match background { + ratatui::style::Color::Rgb(r, g, b) => { + // Relative luminance (rough) to choose black/white for readability. + let lum = 0.2126 * (r as f32) + 0.7152 * (g as f32) + 0.0722 * (b as f32); + if lum > 140.0 { + ratatui::style::Color::Black + } else { + ratatui::style::Color::White + } + } + _ => ratatui::style::Color::White, + } +} + +#[derive(Debug, Clone)] pub struct Theme { pub name: String, pub id: String, - pub light: ThemeMode, - pub dark: ThemeMode, + data: ThemeData, +} + +#[derive(Debug, Clone)] +enum ThemeData { + Desktop(DesktopTheme), + Tui(TuiTheme), +} + +// OpenCode desktop themes ("https://opencode.ai/desktop-theme.json") +#[derive(Debug, Clone, Deserialize)] +struct DesktopTheme { + pub name: String, + pub id: String, + pub light: DesktopThemeMode, + pub dark: DesktopThemeMode, } #[derive(Debug, Clone, Deserialize)] -pub struct ThemeMode { - pub seeds: ThemeSeeds, - pub overrides: ThemeOverrides, +struct DesktopThemeMode { + pub seeds: DesktopThemeSeeds, + pub overrides: DesktopThemeOverrides, } #[derive(Debug, Clone, Deserialize)] -pub struct ThemeSeeds { +struct DesktopThemeSeeds { pub neutral: String, pub primary: String, pub success: String, @@ -28,7 +88,7 @@ pub struct ThemeSeeds { } #[derive(Debug, Clone, Deserialize)] -pub struct ThemeOverrides { +struct DesktopThemeOverrides { #[serde(rename = "background-base")] pub background_base: String, @@ -57,61 +117,142 @@ pub struct ThemeOverrides { pub syntax_string: String, } -#[derive(Debug, Clone, Copy)] -pub struct ThemeColors { - pub primary: ratatui::style::Color, - pub background: ratatui::style::Color, - pub text: ratatui::style::Color, - pub text_weak: ratatui::style::Color, - pub text_strong: ratatui::style::Color, - pub border: ratatui::style::Color, - pub border_weak_focus: ratatui::style::Color, - pub border_focus: ratatui::style::Color, - pub border_strong_focus: ratatui::style::Color, - pub success: ratatui::style::Color, - pub warning: ratatui::style::Color, - pub error: ratatui::style::Color, - pub info: ratatui::style::Color, +// OpenCode TUI themes ("https://opencode.ai/theme.json") +#[derive(Debug, Clone, Deserialize)] +struct TuiTheme { + #[serde(default)] + pub defs: HashMap, + + #[serde(default)] + pub theme: HashMap, } -pub fn darken_color(color: ratatui::style::Color, factor: f32) -> ratatui::style::Color { - match color { - ratatui::style::Color::Rgb(r, g, b) => { - let r = (r as f32 * factor).max(0.0).min(255.0) as u8; - let g = (g as f32 * factor).max(0.0).min(255.0) as u8; - let b = (b as f32 * factor).max(0.0).min(255.0) as u8; - ratatui::style::Color::Rgb(r, g, b) - } - _ => color, - } +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum TuiThemeValue { + Str(String), + Mode { dark: String, light: String }, } impl Theme { pub fn load_from_file>(path: P) -> Result> { + let path = path.as_ref(); let content = fs::read_to_string(path)?; - let theme: Theme = serde_json::from_str(&content)?; - Ok(theme) + let v: Value = serde_json::from_str(&content)?; + + // Some OpenCode theme JSONs don't include name/id; derive from filename. + let derived_id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("theme") + .to_string(); + let id = v + .get("id") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| derived_id.clone()); + let name = v + .get("name") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| id.clone()); + + if v.get("light").is_some() && v.get("dark").is_some() { + let desktop: DesktopTheme = serde_json::from_value(v)?; + return Ok(Self { + name: desktop.name.clone(), + id: desktop.id.clone(), + data: ThemeData::Desktop(desktop), + }); + } + + if v.get("defs").is_some() && v.get("theme").is_some() { + let tui: TuiTheme = serde_json::from_value(v)?; + return Ok(Self { + name, + id, + data: ThemeData::Tui(tui), + }); + } + + Err(format!("Unsupported theme schema in {}", path.display()).into()) } pub fn get_colors(&self, dark: bool) -> ThemeColors { - let mode = if dark { &self.dark } else { &self.light }; - - ThemeColors { - primary: parse_hex(&mode.seeds.primary), - background: parse_hex(&mode.overrides.background_base), - text: parse_hex(&mode.overrides.text_base), - text_weak: parse_hex(&mode.overrides.text_weak), - text_strong: parse_hex(&mode.overrides.text_strong), - border: parse_hex(&mode.overrides.border_base), - border_weak_focus: parse_hex(&mode.overrides.border_weak_focus), - border_focus: parse_hex(&mode.overrides.border_focus), - border_strong_focus: parse_hex(&mode.overrides.border_strong_focus), - success: parse_hex(&mode.seeds.success), - warning: parse_hex(&mode.seeds.warning), - error: parse_hex(&mode.seeds.error), - info: parse_hex(&mode.seeds.info), + match &self.data { + ThemeData::Desktop(theme) => { + let mode = if dark { &theme.dark } else { &theme.light }; + ThemeColors { + primary: parse_hex(&mode.seeds.primary), + background: parse_hex(&mode.overrides.background_base), + text: parse_hex(&mode.overrides.text_base), + text_weak: parse_hex(&mode.overrides.text_weak), + text_strong: parse_hex(&mode.overrides.text_strong), + border: parse_hex(&mode.overrides.border_base), + border_weak_focus: parse_hex(&mode.overrides.border_weak_focus), + border_focus: parse_hex(&mode.overrides.border_focus), + border_strong_focus: parse_hex(&mode.overrides.border_strong_focus), + success: parse_hex(&mode.seeds.success), + warning: parse_hex(&mode.seeds.warning), + error: parse_hex(&mode.seeds.error), + info: parse_hex(&mode.seeds.info), + } + } + ThemeData::Tui(theme) => { + let resolve = |key: &str| resolve_tui_color(theme, key, dark); + + let primary = resolve("primary"); + let background = resolve("background"); + let text = resolve("text"); + let text_weak = resolve("textMuted"); + let border = resolve("border"); + let border_focus = resolve("borderActive"); + let border_weak_focus = resolve("borderSubtle"); + + ThemeColors { + primary, + background, + text, + text_weak, + text_strong: text, + border, + border_weak_focus, + border_focus, + border_strong_focus: border_focus, + success: resolve("success"), + warning: resolve("warning"), + error: resolve("error"), + info: resolve("info"), + } + } + } + } +} + +fn resolve_tui_color(theme: &TuiTheme, key: &str, dark: bool) -> ratatui::style::Color { + let Some(v) = theme.theme.get(key) else { + return ratatui::style::Color::Reset; + }; + + let raw = match v { + TuiThemeValue::Str(s) => s.as_str(), + TuiThemeValue::Mode { dark: d, light: l } => { + if dark { + d.as_str() + } else { + l.as_str() + } } + }; + + if raw.trim_start().starts_with('#') { + return parse_hex(raw); } + + let Some(def) = theme.defs.get(raw) else { + return ratatui::style::Color::Reset; + }; + parse_hex(def) } fn parse_hex(hex: &str) -> ratatui::style::Color { diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 2676cbe..3d2212d 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -1,4 +1,4 @@ -use crate::theme::ThemeColors; +use crate::theme::{contrast_text, ThemeColors}; use nucleo_matcher::{ pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, @@ -68,6 +68,10 @@ pub struct Dialog { } impl Dialog { + fn group_has_header(group: &str) -> bool { + !group.is_empty() + } + pub fn new(title: impl Into) -> Self { let title = title.into(); let mut search_textarea = TextArea::default(); @@ -309,8 +313,9 @@ impl Dialog { return 1; } let mut count = 0; - for (_, items) in &self.filtered_items { - count += items.len() + 1; + for (group, items) in &self.filtered_items { + let header = if Self::group_has_header(group) { 1 } else { 0 }; + count += items.len() + header; } count } @@ -319,12 +324,14 @@ impl Dialog { let mut line_index = 0; let mut current_item_index = 0; - for (_, items) in &self.filtered_items { + for (group, items) in &self.filtered_items { if items.is_empty() { continue; } - line_index += 1; + if Self::group_has_header(group) { + line_index += 1; + } for _item in items { if current_item_index == item_index { @@ -518,13 +525,16 @@ impl Dialog { let mut current_line = 0; let mut item_index = 0; - for (_, items) in &self.filtered_items { + for (group, items) in &self.filtered_items { if items.is_empty() { continue; } - let group_header_line = current_line; - let items_start_line = group_header_line + 1; + let items_start_line = if Self::group_has_header(group) { + current_line + 1 + } else { + current_line + }; let items_end_line = items_start_line + items.len(); if line >= items_start_line && line < items_end_line { @@ -651,12 +661,15 @@ impl Dialog { if items.is_empty() { continue; } - content_lines.push(Line::from(vec![Span::styled( - group.clone(), - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - )])); + + if Self::group_has_header(group) { + content_lines.push(Line::from(vec![Span::styled( + group.clone(), + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )])); + } for item in items { let is_selected = item_index == self.selected_index; @@ -729,9 +742,10 @@ impl Dialog { }; if is_selected { + let fg = contrast_text(colors.primary); for span in &mut spans { let mut style = span.style.clone(); - style = style.fg(Color::Black).bg(colors.primary); + style = style.fg(fg).bg(colors.primary); span.style = style; } } diff --git a/src/ui/components/popup.rs b/src/ui/components/popup.rs index 36cd087..40cded8 100644 --- a/src/ui/components/popup.rs +++ b/src/ui/components/popup.rs @@ -1,5 +1,5 @@ use crate::autocomplete::Suggestion; -use crate::theme::ThemeColors; +use crate::theme::{contrast_text, ThemeColors}; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ prelude::Rect, @@ -126,7 +126,8 @@ impl Popup { .enumerate() .map(|(i, suggestion)| { let (bg_style, name_fg, desc_fg) = if i == self.selected_index { - (colors.primary, colors.background, colors.background) + let fg = contrast_text(colors.primary); + (colors.primary, fg, fg) } else { (Color::Reset, Color::White, Color::Rgb(150, 150, 150)) }; diff --git a/src/views/mod.rs b/src/views/mod.rs index 0d9ccb3..60a623a 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -2,6 +2,7 @@ pub mod chat; pub mod connect_dialog; pub mod home; pub mod models_dialog; +pub mod themes_dialog; pub mod session_rename_dialog; pub mod sessions_dialog; pub mod suggestions_popup; @@ -11,6 +12,7 @@ pub use chat::ChatState; pub use connect_dialog::ConnectDialogState; pub use home::HomeState; pub use models_dialog::ModelsDialogState; +pub use themes_dialog::ThemesDialogState; pub use session_rename_dialog::SessionRenameDialogState; pub use sessions_dialog::SessionsDialogState; pub use suggestions_popup::SuggestionsPopupState; diff --git a/src/views/themes_dialog.rs b/src/views/themes_dialog.rs new file mode 100644 index 0000000..b49b1e0 --- /dev/null +++ b/src/views/themes_dialog.rs @@ -0,0 +1,90 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; +use ratatui::{layout::Rect, Frame}; + +use crate::theme::ThemeColors; +use crate::ui::components::dialog::{Dialog, DialogItem}; + +#[derive(Debug, Clone, PartialEq)] +pub enum ThemesDialogAction { + SelectTheme { theme_id: String }, + None, +} + +#[derive(Debug)] +pub struct ThemesDialogState { + pub dialog: Dialog, +} + +impl ThemesDialogState { + pub fn new(dialog: Dialog) -> Self { + Self { dialog } + } + + pub fn with_items(title: impl Into, items: Vec) -> Self { + Self { + dialog: Dialog::with_items(title, items), + } + } + + pub fn refresh_items(&mut self, items: Vec) { + let title = self.dialog.title.clone(); + let was_visible = self.dialog.is_visible(); + let selected_index = self.dialog.selected_index; + let items_clone = items.clone(); + + self.dialog = Dialog::with_items(title, items); + + if was_visible { + self.dialog.show(); + } + + if selected_index < items_clone.len() { + self.dialog.selected_index = selected_index; + } + } +} + +pub fn init_themes_dialog(title: impl Into, items: Vec) -> ThemesDialogState { + ThemesDialogState::with_items(title, items) +} + +pub fn render_themes_dialog( + f: &mut Frame, + dialog_state: &mut ThemesDialogState, + area: Rect, + colors: ThemeColors, +) { + dialog_state.dialog.render(f, area, colors); +} + +pub fn handle_themes_dialog_key_event( + dialog_state: &mut ThemesDialogState, + event: KeyEvent, +) -> ThemesDialogAction { + if !dialog_state.dialog.is_visible() { + return ThemesDialogAction::None; + } + + match event.code { + KeyCode::Enter => { + dialog_state.dialog.hide(); + if let Some(selected) = dialog_state.dialog.get_selected() { + return ThemesDialogAction::SelectTheme { + theme_id: selected.id.clone(), + }; + } + } + _ => { + dialog_state.dialog.handle_key_event(event); + } + } + + ThemesDialogAction::None +} + +pub fn handle_themes_dialog_mouse_event( + dialog_state: &mut ThemesDialogState, + event: MouseEvent, +) -> bool { + dialog_state.dialog.handle_mouse_event(event) +} diff --git a/src/views/which_key.rs b/src/views/which_key.rs index 7ce557e..d0afb9e 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -15,6 +15,7 @@ const TIMEOUT_SECONDS: u64 = 5; #[derive(Debug, Clone, PartialEq)] pub enum WhichKeyAction { ShowModels, + ShowThemes, ShowSessions, NewSession, Quit, @@ -47,6 +48,11 @@ impl WhichKeyState { description: "Open Models dialog".to_string(), action: WhichKeyAction::ShowModels, }, + KeyBinding { + key: "t".to_string(), + description: "Open Themes dialog".to_string(), + action: WhichKeyAction::ShowThemes, + }, KeyBinding { key: "l".to_string(), description: "Open Sessions dialog".to_string(), @@ -119,6 +125,10 @@ impl WhichKeyState { self.hide(); WhichKeyAction::ShowModels } + KeyCode::Char('t') | KeyCode::Char('T') => { + self.hide(); + WhichKeyAction::ShowThemes + } KeyCode::Char('l') | KeyCode::Char('L') => { self.hide(); WhichKeyAction::ShowSessions @@ -165,15 +175,14 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo let area = f.area(); let popup_width = 40u16; - // Base height: 2 (borders) + 1 (empty) + 4 (bindings) + 1 (empty) + 1 (ESC) = 9 - // Add 2 more lines per chat binding when active - let base_height = 9u16; let chat_bindings_count = if state.is_chat_active { - state.chat_bindings.len() as u16 + state.chat_bindings.len() } else { 0 }; - let popup_height = base_height + chat_bindings_count * 1; + // Content lines: 1 (empty) + bindings + chat bindings + 1 (empty) + 1 (ESC) + // Add 2 for top/bottom borders. + let popup_height = (state.bindings.len() + chat_bindings_count + 5) as u16; let popup_area = Rect { x: area.x + (area.width.saturating_sub(popup_width)) / 2, From f3b4b2f8c475495b61f06efb6692bd59be348a90 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 1 Feb 2026 05:17:43 +0800 Subject: [PATCH 003/226] feat: basic theme preview as selection goes. --- src/app.rs | 52 ++++++++++++++++++++++++++++++++++++++ src/views/themes_dialog.rs | 13 ++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/app.rs b/src/app.rs index d867fe4..f3a7bdf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,6 +93,8 @@ pub struct App { pub suggestions_popup_state: SuggestionsPopupState, pub models_dialog_state: ModelsDialogState, pub themes_dialog_state: ThemesDialogState, + themes_dialog_original_theme_index: usize, + themes_dialog_committed: bool, pub connect_dialog_state: ConnectDialogState, pub sessions_dialog_state: SessionsDialogState, pub session_rename_dialog_state: SessionRenameDialogState, @@ -240,6 +242,8 @@ impl App { suggestions_popup_state, models_dialog_state, themes_dialog_state, + themes_dialog_original_theme_index: 0, + themes_dialog_committed: false, connect_dialog_state, sessions_dialog_state, session_rename_dialog_state, @@ -449,11 +453,19 @@ impl App { handle_themes_dialog_key_event(&mut self.themes_dialog_state, key); match action { + crate::views::themes_dialog::ThemesDialogAction::PreviewTheme { theme_id } => { + if let Some((idx, _)) = + self.themes.iter().enumerate().find(|(_, t)| t.id == theme_id) + { + self.current_theme_index = idx; + } + } crate::views::themes_dialog::ThemesDialogAction::SelectTheme { theme_id } => { if let Some((idx, theme)) = self.themes.iter().enumerate().find(|(_, t)| t.id == theme_id) { self.current_theme_index = idx; + self.themes_dialog_committed = true; push_toast(ratatui_toolkit::Toast::new( format!("Theme: {}", theme.id), ratatui_toolkit::ToastLevel::Info, @@ -465,6 +477,9 @@ impl App { } if !self.themes_dialog_state.dialog.is_visible() { + if !self.themes_dialog_committed { + self.current_theme_index = self.themes_dialog_original_theme_index; + } self.overlay_focus = OverlayFocus::None; } true @@ -755,7 +770,29 @@ impl App { if self.overlay_focus == OverlayFocus::ModelsDialog { handle_models_dialog_mouse_event(&mut self.models_dialog_state, mouse); } else if self.overlay_focus == OverlayFocus::ThemesDialog { + let before = self + .themes_dialog_state + .dialog + .get_selected() + .map(|it| it.id.clone()); + handle_themes_dialog_mouse_event(&mut self.themes_dialog_state, mouse); + + let after = self + .themes_dialog_state + .dialog + .get_selected() + .map(|it| it.id.clone()); + + if before != after { + if let Some(theme_id) = after { + if let Some((idx, _)) = + self.themes.iter().enumerate().find(|(_, t)| t.id == theme_id) + { + self.current_theme_index = idx; + } + } + } } else if self.overlay_focus == OverlayFocus::ConnectDialog { handle_connect_dialog_mouse_event(&mut self.connect_dialog_state, mouse); } else if self.overlay_focus == OverlayFocus::SessionsDialog { @@ -848,6 +885,19 @@ impl App { .join(""), ); self.themes_dialog_state.dialog.selected_index = 0; + + if let Some(theme_id) = self + .themes_dialog_state + .dialog + .get_selected() + .map(|it| it.id.clone()) + { + if let Some((idx, _)) = + self.themes.iter().enumerate().find(|(_, t)| t.id == theme_id) + { + self.current_theme_index = idx; + } + } } (_, OverlayFocus::ConnectDialog) => { self.connect_dialog_state @@ -1415,6 +1465,8 @@ impl App { self.themes_dialog_state = init_themes_dialog("Themes", items); self.themes_dialog_state.dialog.show(); self.themes_dialog_state.dialog.selected_index = selected_index; + self.themes_dialog_original_theme_index = self.current_theme_index; + self.themes_dialog_committed = false; self.overlay_focus = OverlayFocus::ThemesDialog; } diff --git a/src/views/themes_dialog.rs b/src/views/themes_dialog.rs index b49b1e0..52d3802 100644 --- a/src/views/themes_dialog.rs +++ b/src/views/themes_dialog.rs @@ -6,6 +6,7 @@ use crate::ui::components::dialog::{Dialog, DialogItem}; #[derive(Debug, Clone, PartialEq)] pub enum ThemesDialogAction { + PreviewTheme { theme_id: String }, SelectTheme { theme_id: String }, None, } @@ -65,6 +66,8 @@ pub fn handle_themes_dialog_key_event( return ThemesDialogAction::None; } + let before = dialog_state.dialog.get_selected().map(|it| it.id.clone()); + match event.code { KeyCode::Enter => { dialog_state.dialog.hide(); @@ -79,6 +82,16 @@ pub fn handle_themes_dialog_key_event( } } + if dialog_state.dialog.is_visible() { + let after = dialog_state.dialog.get_selected().map(|it| it.id.clone()); + + if before != after { + if let Some(theme_id) = after { + return ThemesDialogAction::PreviewTheme { theme_id }; + } + } + } + ThemesDialogAction::None } From 30cec4f607abc0c15ea88912756742f7039992d7 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 1 Feb 2026 05:39:42 +0800 Subject: [PATCH 004/226] feat: complete work of art for the theme-token usage. It works. --- src/app.rs | 13 +++++- src/theme.rs | 67 +++++++++++++++++++++++++++++- src/ui/components/api_key_input.rs | 14 ++++--- src/ui/components/chat.rs | 15 ++----- src/ui/components/dialog.rs | 20 ++++----- src/ui/components/input.rs | 14 +++---- src/ui/components/status_bar.rs | 19 +++++++-- src/views/chat.rs | 31 +++++++++----- src/views/home.rs | 4 +- src/views/session_rename_dialog.rs | 10 +++-- 10 files changed, 151 insertions(+), 56 deletions(-) diff --git a/src/app.rs b/src/app.rs index f3a7bdf..5c9733b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -145,7 +145,7 @@ impl App { let home_state = init_home(); let agent = "Plan".to_string(); - let chat_state = init_chat(Chat::new(), &agent); + let chat = Chat::new(); let suggestions_popup_state = init_suggestions_popup(Popup::new()); let models_dialog_state = init_models_dialog("Models", vec![]); let themes_dialog_state = init_themes_dialog("Themes", vec![]); @@ -229,6 +229,7 @@ impl App { }); let colors = theme_for_colors.get_colors(true); + let chat_state = init_chat(chat, &agent, &colors); let session_rename_dialog_state = init_session_rename_dialog(colors); Ok(Self { @@ -325,7 +326,11 @@ impl App { if self.themes.is_empty() { return theme::ThemeColors { primary: ratatui::style::Color::Rgb(255, 140, 0), + secondary: ratatui::style::Color::Rgb(255, 140, 0), + accent: ratatui::style::Color::Rgb(255, 140, 0), + interactive: ratatui::style::Color::Rgb(255, 140, 0), background: ratatui::style::Color::Reset, + dialog_background: ratatui::style::Color::Reset, text: ratatui::style::Color::Reset, text_weak: ratatui::style::Color::Reset, text_strong: ratatui::style::Color::Reset, @@ -683,6 +688,10 @@ impl App { } else { self.agent = "Plan".to_string(); } + + let colors = self.get_current_theme_colors(); + let agent_color = crate::theme::agent_color(&self.agent, &colors); + self.chat_state.wave_spinner.set_color(agent_color); true } KeyCode::Esc => { @@ -1939,7 +1948,7 @@ impl App { } if self.overlay_focus == OverlayFocus::ApiKeyInput && self.api_key_input.is_visible() { - self.api_key_input.render(f, size); + self.api_key_input.render(f, size, &colors); } if self.overlay_focus == OverlayFocus::SessionsDialog diff --git a/src/theme.rs b/src/theme.rs index f358e11..ddbdfdf 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -7,7 +7,11 @@ use std::path::Path; #[derive(Debug, Clone, Copy)] pub struct ThemeColors { pub primary: ratatui::style::Color, + pub secondary: ratatui::style::Color, + pub accent: ratatui::style::Color, + pub interactive: ratatui::style::Color, pub background: ratatui::style::Color, + pub dialog_background: ratatui::style::Color, pub text: ratatui::style::Color, pub text_weak: ratatui::style::Color, pub text_strong: ratatui::style::Color, @@ -48,6 +52,21 @@ pub fn contrast_text(background: ratatui::style::Color) -> ratatui::style::Color } } +pub fn agent_color(agent: &str, colors: &ThemeColors) -> ratatui::style::Color { + match agent { + // OpenCode tokens: + // - Plan: info (icon-agent-plan-base) + // - Build: interactive (icon-agent-build-base) + "Plan" => colors.info, + "Build" => colors.interactive, + _ => colors.primary, + } +} + +pub fn agent_mode_color(agent_mode: Option<&str>, colors: &ThemeColors) -> ratatui::style::Color { + agent_color(agent_mode.unwrap_or("Plan"), colors) +} + #[derive(Debug, Clone)] pub struct Theme { pub name: String, @@ -92,6 +111,14 @@ struct DesktopThemeOverrides { #[serde(rename = "background-base")] pub background_base: String, + #[serde(rename = "background-stronger")] + #[serde(default)] + pub background_stronger: Option, + + #[serde(rename = "surface-raised-stronger-non-alpha")] + #[serde(default)] + pub surface_raised_stronger_non_alpha: Option, + #[serde(rename = "text-base")] pub text_base: String, @@ -182,9 +209,23 @@ impl Theme { match &self.data { ThemeData::Desktop(theme) => { let mode = if dark { &theme.dark } else { &theme.light }; + + let dialog_background = mode + .overrides + .surface_raised_stronger_non_alpha + .as_deref() + .or(mode.overrides.background_stronger.as_deref()) + .unwrap_or(mode.overrides.background_base.as_str()); + + let primary = parse_hex(&mode.seeds.primary); + let interactive = parse_hex(&mode.seeds.interactive); ThemeColors { - primary: parse_hex(&mode.seeds.primary), + primary, + secondary: primary, + accent: interactive, + interactive, background: parse_hex(&mode.overrides.background_base), + dialog_background: parse_hex(dialog_background), text: parse_hex(&mode.overrides.text_base), text_weak: parse_hex(&mode.overrides.text_weak), text_strong: parse_hex(&mode.overrides.text_strong), @@ -202,7 +243,27 @@ impl Theme { let resolve = |key: &str| resolve_tui_color(theme, key, dark); let primary = resolve("primary"); + let secondary = resolve("secondary"); + let accent = resolve("accent"); + let interactive = { + // OpenCode theme.json doesn't always include an explicit interactive token. + // Map it to primary so we still get a theme-driven value. + let v = resolve_tui_color(theme, "interactive", dark); + if v == ratatui::style::Color::Reset { + primary + } else { + v + } + }; let background = resolve("background"); + let dialog_background = { + let v = resolve("backgroundPanel"); + if v == ratatui::style::Color::Reset { + background + } else { + v + } + }; let text = resolve("text"); let text_weak = resolve("textMuted"); let border = resolve("border"); @@ -211,7 +272,11 @@ impl Theme { ThemeColors { primary, + secondary, + accent, + interactive, background, + dialog_background, text, text_weak, text_strong: text, diff --git a/src/ui/components/api_key_input.rs b/src/ui/components/api_key_input.rs index 2d76161..3fb0246 100644 --- a/src/ui/components/api_key_input.rs +++ b/src/ui/components/api_key_input.rs @@ -1,13 +1,15 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ prelude::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Clear, Paragraph}, Frame, }; use tui_textarea::{Input as TuiInput, TextArea}; +use crate::theme::ThemeColors; + #[derive(Debug, Clone, PartialEq)] pub enum InputAction { Submitted { @@ -92,7 +94,7 @@ impl ApiKeyInput { } } - pub fn render(&mut self, frame: &mut Frame, area: Rect) { + pub fn render(&mut self, frame: &mut Frame, area: Rect, colors: &ThemeColors) { if !self.visible { return; } @@ -121,7 +123,7 @@ impl ApiKeyInput { }; frame.render_widget( - Paragraph::new("").style(Style::default().bg(Color::Rgb(20, 20, 30))), + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), dialog_area, ); @@ -138,14 +140,14 @@ impl ApiKeyInput { Span::styled( "API key", Style::default() - .fg(Color::White) + .fg(colors.text) .add_modifier(Modifier::BOLD), ), Span::raw(" ".repeat(40)), Span::styled( "esc", Style::default() - .fg(Color::Rgb(255, 140, 0)) + .fg(colors.primary) .add_modifier(Modifier::BOLD), ), ]); @@ -156,7 +158,7 @@ impl ApiKeyInput { let footer_line = Line::from(vec![Span::styled( "enter submit", Style::default() - .fg(Color::Rgb(150, 120, 100)) + .fg(colors.text_weak) .add_modifier(Modifier::DIM), )]); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 0ce9734..35f1b2b 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -636,7 +636,8 @@ impl Chat { match message.role { MessageRole::User => { // User message: Box with left border colored by agent mode - let border_color = self.get_agent_color(message.agent_mode.as_deref()); + let border_color = + crate::theme::agent_mode_color(message.agent_mode.as_deref(), colors); let content = message.content.clone(); // Wrap content to fit within max_width - padding @@ -919,20 +920,12 @@ impl Chat { out } - fn get_agent_color(&self, agent_mode: Option<&str>) -> Color { - match agent_mode { - Some("Plan") => Color::Rgb(255, 165, 0), // Orange - Some("Build") => Color::Rgb(147, 112, 219), // Purple - _ => Color::Gray, - } - } - fn format_metadata(&self, message: &Message, _model: &str, colors: &ThemeColors) -> Vec { let mut spans = Vec::new(); // Get agent mode from previous user message or default to "Plan" let agent_mode = self.get_agent_mode_for_message(message); - let agent_color = self.get_agent_color(Some(&agent_mode)); + let agent_color = crate::theme::agent_color(&agent_mode, colors); // Agent icon (▣) with extra space spans.push(Span::styled( @@ -957,7 +950,7 @@ impl Chat { let model_display = message.model.as_deref().unwrap_or(_model); spans.push(Span::styled( model_display.to_string(), - Style::default().fg(colors.text_weak), + Style::default().fg(colors.text), )); // Timing + throughput metrics (only show for completed messages) diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 3d2212d..8d4b229 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -606,7 +606,7 @@ impl Dialog { frame.render_widget( ratatui::widgets::Paragraph::new("") - .style(ratatui::style::Style::default().bg(Color::Rgb(20, 20, 30))), + .style(ratatui::style::Style::default().bg(colors.dialog_background)), self.dialog_area, ); @@ -626,7 +626,7 @@ impl Dialog { Span::styled( &self.title, Style::default() - .fg(Color::White) + .fg(colors.text) .add_modifier(Modifier::BOLD), ), Span::raw(" "), @@ -652,7 +652,7 @@ impl Dialog { if flat_items.is_empty() { content_lines.push(Line::from(vec![Span::styled( "No results found", - Style::default().fg(Color::Gray), + Style::default().fg(colors.text_weak), )])); } else { let mut item_index = 0; @@ -694,14 +694,14 @@ impl Dialog { Span::styled( item.description.clone(), Style::default() - .fg(Color::Rgb(150, 150, 150)) + .fg(colors.text_weak) .add_modifier(Modifier::DIM), ), Span::raw(" ".repeat(padding_len)), Span::styled( tip, Style::default() - .fg(Color::Rgb(100, 200, 100)) + .fg(colors.text) .add_modifier(Modifier::BOLD), ), Span::raw(" ".repeat(padding_after_tip)), @@ -713,7 +713,7 @@ impl Dialog { Span::styled( tip, Style::default() - .fg(Color::Rgb(150, 120, 100)) + .fg(colors.text_weak) .add_modifier(Modifier::DIM), ), Span::raw(" ".repeat(padding_after_tip)), @@ -727,7 +727,7 @@ impl Dialog { Span::styled( item.description.clone(), Style::default() - .fg(Color::Rgb(150, 150, 150)) + .fg(colors.text_weak) .add_modifier(Modifier::DIM), ), Span::raw(" ".repeat(padding_len)), @@ -795,7 +795,7 @@ impl Dialog { footer_spans.push(Span::styled( &action.key, Style::default() - .fg(Color::Rgb(150, 120, 100)) + .fg(colors.text_weak) .add_modifier(Modifier::DIM), )); } @@ -812,7 +812,7 @@ impl Dialog { Span::styled( "ctrl+a", Style::default() - .fg(Color::Rgb(150, 120, 100)) + .fg(colors.text_weak) .add_modifier(Modifier::DIM), ), Span::raw(" "), @@ -826,7 +826,7 @@ impl Dialog { Span::styled( "ctrl+f", Style::default() - .fg(Color::Rgb(150, 120, 100)) + .fg(colors.text_weak) .add_modifier(Modifier::DIM), ), ]) diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 1729bbe..d4cfd88 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -1,5 +1,6 @@ use crate::autocomplete::{AutoComplete, Suggestion}; use crate::persistence::PromptHistoryCache; +use crate::theme::{agent_color, ThemeColors}; use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -43,12 +44,9 @@ impl Input { agent: &str, model: &str, provider_name: &str, + colors: &ThemeColors, ) { - let agent_color = if agent == "Plan" { - ratatui::style::Color::Rgb(255, 165, 0) - } else { - ratatui::style::Color::Rgb(147, 112, 219) - }; + let agent_color = agent_color(agent, colors); let border = Block::bordered() .borders(ratatui::widgets::Borders::LEFT) @@ -90,12 +88,14 @@ impl Input { ratatui::text::Span::raw(" "), ratatui::text::Span::styled( model.to_string(), - ratatui::style::Style::default().fg(ratatui::style::Color::Rgb(255, 200, 100)), + ratatui::style::Style::default().fg(colors.text), ), ratatui::text::Span::raw(" "), ratatui::text::Span::styled( provider_name.to_string(), - ratatui::style::Style::default().fg(ratatui::style::Color::Yellow), + ratatui::style::Style::default() + .fg(colors.text_weak) + .add_modifier(ratatui::style::Modifier::DIM), ), ]); diff --git a/src/ui/components/status_bar.rs b/src/ui/components/status_bar.rs index f9bef46..f92005d 100644 --- a/src/ui/components/status_bar.rs +++ b/src/ui/components/status_bar.rs @@ -5,6 +5,8 @@ use ratatui::{ Frame, }; +use crate::theme::ThemeColors; + pub struct StatusBar { pub version: String, pub cwd: String, @@ -30,7 +32,7 @@ impl StatusBar { } } - pub fn render(&self, f: &mut Frame, area: Rect) { + pub fn render(&self, f: &mut Frame, area: Rect, colors: &ThemeColors) { let cwd_with_tilde = if let Some(home) = std::env::var_os("HOME") { let home_str = home.to_string_lossy(); if self.cwd.starts_with(&*home_str) { @@ -46,20 +48,29 @@ impl StatusBar { } else { cwd_with_tilde }; - let mut left_spans = vec![Span::raw(cwd_display)]; + let mut left_spans = vec![Span::styled( + cwd_display, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )]; if let Some(ref branch) = self.branch { left_spans.push(Span::raw(" (")); left_spans.push(Span::styled( branch, - Style::default().fg(Color::Rgb(255, 140, 0)), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), )); left_spans.push(Span::raw(")")); } let right_spans = vec![Span::styled( &self.version, - Style::default().add_modifier(Modifier::DIM), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), )]; let line = Line::from(left_spans); diff --git a/src/views/chat.rs b/src/views/chat.rs index de551c7..91885fa 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -27,16 +27,20 @@ impl ChatState { } } -pub fn init_chat(chat: Chat, agent: &str) -> ChatState { - let agent_color = get_agent_color(agent); +pub fn init_chat(chat: Chat, agent: &str, colors: &ThemeColors) -> ChatState { + let agent_color = crate::theme::agent_color(agent, colors); ChatState::new(chat, agent_color) } -fn get_agent_color(agent: &str) -> ratatui::style::Color { - match agent { - "Plan" => ratatui::style::Color::Rgb(255, 165, 0), // Orange - "Build" => ratatui::style::Color::Rgb(147, 112, 219), // Purple - _ => ratatui::style::Color::Gray, +pub fn agent_color_for_tab(agent_index: usize, colors: &ThemeColors) -> ratatui::style::Color { + // Matches OpenCode's rotation: primary/secondary/accent/success/warning/error + match agent_index % 6 { + 0 => colors.primary, + 1 => colors.secondary, + 2 => colors.accent, + 3 => colors.success, + 4 => colors.warning, + _ => colors.error, } } @@ -79,7 +83,14 @@ pub fn render_chat( chat_state .chat .render(f, above_status_chunks[1], &agent, &model, colors); - input.render(f, above_status_chunks[3], &agent, &model, &provider_name); + input.render( + f, + above_status_chunks[3], + &agent, + &model, + &provider_name, + colors, + ); let status_chunks = Layout::default() .direction(Direction::Horizontal) @@ -88,7 +99,7 @@ pub fn render_chat( if is_streaming { // Update spinner color based on current agent (only if changed) - let agent_color = get_agent_color(&agent); + let agent_color = crate::theme::agent_color(&agent, colors); chat_state.wave_spinner.set_color(agent_color); // Animation update is now handled in the main event loop at a fixed rate @@ -131,5 +142,5 @@ pub fn render_chat( f.render_widget(blank, above_status_chunks[5]); let status_bar = StatusBar::new(version, cwd, branch, agent, model); - status_bar.render(f, main_chunks[1]); + status_bar.render(f, main_chunks[1], colors); } diff --git a/src/views/home.rs b/src/views/home.rs index 2ea930a..822a036 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -90,7 +90,7 @@ pub fn render_home( let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); f.render_widget(logo, logo_chunks[1]); - input.render(f, home_chunks[1], &agent, &model, &provider_name); + input.render(f, home_chunks[1], &agent, &model, &provider_name, colors); let help_text = vec![ Span::styled("/", Style::default().fg(colors.info)), @@ -109,5 +109,5 @@ pub fn render_home( f.render_widget(blank, home_chunks[3]); let status_bar = StatusBar::new(version, cwd, branch, agent, model); - status_bar.render(f, main_chunks[1]); + status_bar.render(f, main_chunks[1], colors); } diff --git a/src/views/session_rename_dialog.rs b/src/views/session_rename_dialog.rs index 99c91ae..d4ea12f 100644 --- a/src/views/session_rename_dialog.rs +++ b/src/views/session_rename_dialog.rs @@ -84,7 +84,11 @@ impl Default for SessionRenameDialogState { fn default() -> Self { Self::new(ThemeColors { primary: Color::Rgb(255, 140, 0), + secondary: Color::Rgb(255, 140, 0), + accent: Color::Rgb(255, 140, 0), + interactive: Color::Rgb(255, 140, 0), background: Color::Reset, + dialog_background: Color::Reset, text: Color::Reset, text_weak: Color::Reset, text_strong: Color::Reset, @@ -139,7 +143,7 @@ pub fn render_session_rename_dialog( f.render_widget( ratatui::widgets::Paragraph::new("") - .style(ratatui::style::Style::default().bg(Color::Rgb(20, 20, 30))), + .style(ratatui::style::Style::default().bg(colors.dialog_background)), dialog_state.dialog_area, ); @@ -158,7 +162,7 @@ pub fn render_session_rename_dialog( Span::styled( "Rename session", Style::default() - .fg(Color::White) + .fg(colors.text) .add_modifier(ratatui::style::Modifier::BOLD), ), Span::raw(" "), @@ -178,7 +182,7 @@ pub fn render_session_rename_dialog( let footer_line = Line::from(vec![Span::styled( "enter submit", Style::default() - .fg(Color::Rgb(150, 120, 100)) + .fg(colors.text_weak) .add_modifier(ratatui::style::Modifier::DIM), )]); From 52504960a1ac04e8e4761d5a943697d6d0f87035 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 1 Feb 2026 05:42:44 +0800 Subject: [PATCH 005/226] feat: proper status bar theme usage. (branch) --- src/ui/components/status_bar.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/ui/components/status_bar.rs b/src/ui/components/status_bar.rs index f92005d..32d82bc 100644 --- a/src/ui/components/status_bar.rs +++ b/src/ui/components/status_bar.rs @@ -1,9 +1,4 @@ -use ratatui::{ - layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span}, - Frame, -}; +use ratatui::{layout::Rect, style::Modifier, style::Style, text::Line, text::Span, Frame}; use crate::theme::ThemeColors; @@ -56,14 +51,12 @@ impl StatusBar { )]; if let Some(ref branch) = self.branch { - left_spans.push(Span::raw(" (")); left_spans.push(Span::styled( - branch, + format!(":{}", branch), Style::default() .fg(colors.text_weak) .add_modifier(Modifier::DIM), )); - left_spans.push(Span::raw(")")); } let right_spans = vec![Span::styled( From 1ce44a6399e6e81c9047f5143e37c95637cd2385 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 1 Feb 2026 05:48:55 +0800 Subject: [PATCH 006/226] feat: perfect search UX - Basically when I search and my cursor was previously focused on something else. I press down and I kinda expect the cursor to be just focus on the first element in the results. - Secondly, if I search and my cursor was previously focused on essentially what I searched i.e. 'gu', and I was focused on "github" before, my cursor shouldnt lose focus on "github" and in fact, I could move my cursor up or down relative to "github" (right now it disappears).. searching 'gu' also shows 'github' or 'gruvbox'. --- src/ui/components/dialog.rs | 111 +++++++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 9 deletions(-) diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 8d4b229..121dab7 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -179,20 +179,16 @@ impl Dialog { pub fn set_search_query(&mut self, query: impl Into) { self.search_query = query.into(); self.apply_filter(); - self.selected_index = 0; - self.scroll_offset = 0; - self.update_scrollbar(); } pub fn clear_search(&mut self) { self.search_query.clear(); self.apply_filter(); - self.selected_index = 0; - self.scroll_offset = 0; - self.update_scrollbar(); } fn apply_filter(&mut self) { + let preferred_selected_id = self.get_selected().map(|item| item.id.clone()); + if self.search_query.is_empty() { self.filtered_items = self .groups @@ -246,7 +242,32 @@ impl Dialog { } self.filtered_items = filtered; } - self.update_scrollbar(); + + self.reconcile_selection_after_filter(preferred_selected_id); + } + + fn reconcile_selection_after_filter(&mut self, preferred_selected_id: Option) { + let flat_items = self.get_flat_items(); + if flat_items.is_empty() { + self.selected_index = 0; + self.scroll_offset = 0; + self.update_scrollbar(); + return; + } + + if let Some(id) = preferred_selected_id { + if let Some(pos) = flat_items.iter().position(|item| item.id == id) { + self.selected_index = pos; + self.adjust_scroll(); + return; + } + } + + if self.selected_index >= flat_items.len() { + self.selected_index = 0; + } + + self.adjust_scroll(); } fn update_scrollbar(&mut self) { @@ -267,7 +288,17 @@ impl Dialog { pub fn next(&mut self) { let flat_items = self.get_flat_items(); - if !flat_items.is_empty() && self.selected_index < flat_items.len() - 1 { + if flat_items.is_empty() { + return; + } + + if self.selected_index >= flat_items.len() { + self.selected_index = 0; + self.adjust_scroll(); + return; + } + + if self.selected_index < flat_items.len() - 1 { self.selected_index += 1; self.adjust_scroll(); } @@ -275,7 +306,17 @@ impl Dialog { pub fn previous(&mut self) { let flat_items = self.get_flat_items(); - if !flat_items.is_empty() && self.selected_index > 0 { + if flat_items.is_empty() { + return; + } + + if self.selected_index >= flat_items.len() { + self.selected_index = flat_items.len().saturating_sub(1); + self.adjust_scroll(); + return; + } + + if self.selected_index > 0 { self.selected_index -= 1; self.adjust_scroll(); } @@ -903,6 +944,35 @@ mod tests { ] } + fn create_fuzzy_test_items() -> Vec { + vec![ + DialogItem { + id: "1".to_string(), + name: "gitlab".to_string(), + group: "Other".to_string(), + description: "".to_string(), + tip: None, + provider_id: "p".to_string(), + }, + DialogItem { + id: "2".to_string(), + name: "github".to_string(), + group: "Other".to_string(), + description: "".to_string(), + tip: None, + provider_id: "p".to_string(), + }, + DialogItem { + id: "3".to_string(), + name: "gruvbox".to_string(), + group: "Other".to_string(), + description: "".to_string(), + tip: None, + provider_id: "p".to_string(), + }, + ] + } + #[test] fn test_dialog_creation() { let dialog = Dialog::new("Test Dialog"); @@ -1002,6 +1072,29 @@ mod tests { assert_eq!(dialog.selected_index, 2); } + #[test] + fn test_dialog_next_from_invalid_selection_focuses_first() { + let mut dialog = Dialog::with_items("Providers", create_fuzzy_test_items()); + dialog.set_search_query("gu"); + assert!(!dialog.get_flat_items().is_empty()); + + dialog.selected_index = 999; + dialog.next(); + assert_eq!(dialog.selected_index, 0); + assert!(dialog.get_selected().is_some()); + } + + #[test] + fn test_dialog_search_preserves_selected_item_if_still_present() { + let mut dialog = Dialog::with_items("Providers", create_fuzzy_test_items()); + + dialog.selected_index = 1; + assert_eq!(dialog.get_selected().unwrap().name, "github"); + + dialog.set_search_query("gu"); + assert_eq!(dialog.get_selected().unwrap().name, "github"); + } + #[test] fn test_dialog_previous() { let mut dialog = Dialog::with_items("Models", create_test_items()); From ef06df6416f5a6dff7e079f9c13a4ac43fb76266 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 1 Feb 2026 05:54:56 +0800 Subject: [PATCH 007/226] fix: fixed the `esc` in the dialog to stick to the right. --- src/ui/components/dialog.rs | 46 ++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 121dab7..2b34817 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -663,25 +663,33 @@ impl Dialog { ]) .split(self.content_area); - let title_line = Line::from(vec![ - Span::styled( - &self.title, - Style::default() - .fg(colors.text) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - "esc", - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - ]); - - let title_paragraph = - Paragraph::new(title_line).alignment(ratatui::layout::Alignment::Left); - frame.render_widget(title_paragraph, chunks[0]); + let esc_text = "esc"; + let esc_area_width = (esc_text.width() as u16).saturating_add(1); + let header_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(esc_area_width), + ]) + .split(chunks[0]); + + let title_paragraph = Paragraph::new(Line::from(vec![Span::styled( + &self.title, + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Left); + frame.render_widget(title_paragraph, header_chunks[0]); + + let esc_paragraph = Paragraph::new(Line::from(vec![Span::styled( + esc_text, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Right); + frame.render_widget(esc_paragraph, header_chunks[1]); frame.render_widget(&self.search_textarea, chunks[2]); From f6b04cc8e79a93d230dcb1922b4ebe1fb7b931dd Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 3 Feb 2026 00:46:56 +0800 Subject: [PATCH 008/226] feat: lower scroll speed so it doesn't feel janky. --- src/main.rs | 49 ++++++++++++++++++++++++++++++++++++--- src/ui/components/chat.rs | 4 ++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 877016c..fdda2db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -148,12 +148,55 @@ async fn run_event_loop( // )); match event { + event::Event::Mouse(mouse) => { + if matches!( + mouse.kind, + event::MouseEventKind::ScrollDown | event::MouseEventKind::ScrollUp + ) { + const MAX_SCROLL_PER_FRAME: usize = 6; + let mut last_scroll = mouse; + let mut scroll_count = 1usize; + + while event::poll(Duration::from_millis(0))? { + let next = event::read()?; + match next { + event::Event::Mouse(next_mouse) => { + if matches!( + next_mouse.kind, + event::MouseEventKind::ScrollDown + | event::MouseEventKind::ScrollUp + ) { + if next_mouse.kind == last_scroll.kind { + scroll_count = scroll_count.saturating_add(1); + } else { + last_scroll = next_mouse; + scroll_count = 1; + } + } else { + app.handle_mouse_event(next_mouse); + } + } + event::Event::Key(key) => { + app.handle_keys(key); + } + event::Event::Paste(text) => { + app.handle_paste(text); + } + _ => {} + } + } + + let repeat = scroll_count.min(MAX_SCROLL_PER_FRAME); + for _ in 0..repeat { + app.handle_mouse_event(last_scroll); + } + } else { + app.handle_mouse_event(mouse); + } + } event::Event::Key(key) => { app.handle_keys(key); } - event::Event::Mouse(mouse) => { - app.handle_mouse_event(mouse); - } event::Event::Paste(text) => { app.handle_paste(text); } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 35f1b2b..e53ddf5 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -436,11 +436,11 @@ impl Chat { match event.kind { MouseEventKind::ScrollDown => { - self.scroll_down(3); + self.scroll_down(1); true } MouseEventKind::ScrollUp => { - self.scroll_up(3); + self.scroll_up(1); true } MouseEventKind::Down(MouseButton::Left) => { From 4c8947ff1f78be41e5e58c0c4407936bcb9eedab Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 3 Feb 2026 00:52:31 +0800 Subject: [PATCH 009/226] fix: 'qui' instead of 'quit' (cutoff). --- src/views/chat.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/views/chat.rs b/src/views/chat.rs index 91885fa..0fbdc7a 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -92,9 +92,24 @@ pub fn render_chat( colors, ); + let help_text = vec![ + Span::styled("/", Style::default().fg(colors.info)), + Span::raw(" commands "), + Span::styled("ctrl+x", Style::default().fg(colors.info)), + Span::raw(" shortcuts "), + Span::styled("tab", Style::default().fg(colors.info)), + Span::raw(" agents "), + Span::styled("ctrl+cc", Style::default().fg(colors.info)), + Span::raw(" quit"), + ]; + let help_line = Line::from(help_text); + let help_width = help_line.width() as u16; + let available_width = above_status_chunks[4].width; + let help_width = help_width.min(available_width); + let status_chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(0), Constraint::Length(35)]) + .constraints([Constraint::Min(0), Constraint::Length(help_width)]) .split(above_status_chunks[4]); if is_streaming { @@ -127,15 +142,7 @@ pub fn render_chat( f.render_widget(streaming_paragraph, status_chunks[0]); } - let help_text = vec![ - Span::styled("/", Style::default().fg(colors.info)), - Span::raw(" commands "), - Span::styled("tab", Style::default().fg(colors.info)), - Span::raw(" agents "), - Span::styled("ctrl+cc", Style::default().fg(colors.info)), - Span::raw(" quit"), - ]; - let help = Paragraph::new(Line::from(help_text)).alignment(Alignment::Right); + let help = Paragraph::new(help_line).alignment(Alignment::Right); f.render_widget(help, status_chunks[1]); let blank = Block::default(); From e8598c6b104a8b8088bb5c6ae1dd45c9e2493046 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 3 Feb 2026 01:31:23 +0800 Subject: [PATCH 010/226] docs: added some todos. --- _plans/TODO_PER_PROJECT_SESSIONMEMORY.md | 3 +++ _plans/TODO_RAW_MODE.md | 1 + 2 files changed, 4 insertions(+) create mode 100644 _plans/TODO_PER_PROJECT_SESSIONMEMORY.md create mode 100644 _plans/TODO_RAW_MODE.md diff --git a/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md b/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md new file mode 100644 index 0000000..6cbe5f9 --- /dev/null +++ b/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md @@ -0,0 +1,3 @@ +Just essentially add a 'project' field in the session. + +If crabcode is used on a git repository, scope that into that git repository only so that the sessions I find are in that repo only. diff --git a/_plans/TODO_RAW_MODE.md b/_plans/TODO_RAW_MODE.md new file mode 100644 index 0000000..9488a4a --- /dev/null +++ b/_plans/TODO_RAW_MODE.md @@ -0,0 +1 @@ +A way to do raw ai mode (no system prompts, just chatting with the model) From 5da96045486db975a153cc3a49657fe6607b89e1 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 3 Feb 2026 02:13:04 +0800 Subject: [PATCH 011/226] fix: ctrl+a in /models and a few jank fixes. --- src/app.rs | 14 ++++++++++++++ src/ui/components/dialog.rs | 30 +----------------------------- src/views/models_dialog.rs | 13 +++++++++++-- 3 files changed, 26 insertions(+), 31 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5c9733b..577e4ab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -396,6 +396,20 @@ impl App { } } OverlayFocus::ModelsDialog => { + if key.code == KeyCode::Char('a') + && key.modifiers == event::KeyModifiers::CONTROL + { + self.models_dialog_state.dialog.hide(); + if let crate::command::parser::InputType::Command(parsed) = + crate::command::parser::parse_input("/connect") + { + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_command_input(parsed)); + }); + } + return; + } let action = handle_models_dialog_key_event(&mut self.models_dialog_state, key); match action { diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 2b34817..bbbbe3c 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -850,35 +850,7 @@ impl Dialog { } let footer_line = if footer_spans.is_empty() { - Line::from(vec![ - Span::styled( - "Connect provider", - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - "ctrl+a", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), - Span::raw(" "), - Span::styled( - "Favorite", - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - "ctrl+f", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), - ]) + Line::from(vec![]) } else { Line::from(footer_spans) }; diff --git a/src/views/models_dialog.rs b/src/views/models_dialog.rs index 6b41c57..6787452 100644 --- a/src/views/models_dialog.rs +++ b/src/views/models_dialog.rs @@ -2,7 +2,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; use ratatui::{layout::Rect, Frame}; use crate::theme::ThemeColors; -use crate::ui::components::dialog::{Dialog, DialogItem}; +use crate::ui::components::dialog::{Dialog, DialogAction, DialogItem}; #[derive(Debug, Clone, PartialEq)] pub enum ModelsDialogAction { @@ -29,7 +29,16 @@ impl ModelsDialogState { pub fn with_items(title: impl Into, items: Vec) -> Self { Self { - dialog: Dialog::with_items(title, items), + dialog: Dialog::with_items(title, items).with_actions(vec![ + DialogAction { + label: "Connect provider".to_string(), + key: "ctrl+a".to_string(), + }, + DialogAction { + label: "Favorite".to_string(), + key: "ctrl+f".to_string(), + }, + ]), } } From 3b09cbf8a6d4827122fd580fed5cafd5d7e38744 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 3 Feb 2026 02:13:28 +0800 Subject: [PATCH 012/226] docs: initial docs w/ gittydocs. --- AGENTS.md | 4 ++ _docs/config.mdx | 105 ++++++++++++++++++++++++++++++++++++++++++ _docs/gittydocs.jsonc | 20 ++++++++ _docs/index.mdx | 72 +++++++++++++++++++++++++++++ justfile | 3 ++ 5 files changed, 204 insertions(+) create mode 100644 _docs/config.mdx create mode 100644 _docs/gittydocs.jsonc create mode 100644 _docs/index.mdx diff --git a/AGENTS.md b/AGENTS.md index de7edda..c7b17fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,10 @@ Before adding/changing scripts, make sure to check `justfile` for existing recip ## File Locations +### Configuration Docs +- **Location**: `_docs/config.mdx` +- **Purpose**: Source-of-truth, human/AI-readable contract for `crabcode.json(c)` + ### SQLite Database - **Location**: - macOS: `~/Library/Application Support/crabcode/data.db` diff --git a/_docs/config.mdx b/_docs/config.mdx new file mode 100644 index 0000000..c30b12d --- /dev/null +++ b/_docs/config.mdx @@ -0,0 +1,105 @@ +--- +title: Configuration +description: Crabcode configur/ation keys and merge behavior. +--- + +# Configuration + +Crabcode reads up to 4 config files (JSON or JSONC) and deep-merges them with increasing priority: + +1. OpenCode global +2. Crabcode global +3. OpenCode local +4. Crabcode local + +Only these keys are implemented in phase 1: `theme`, `model`, `sounds`. + +## File Format + +- `.json`: strict JSON +- `.jsonc`: JSON with comments + trailing commas + +Recommended filename: `crabcode.jsonc`. + +## Quick Example + +```jsonc +{ + // Optional; enables editor tooling when you host a schema file + "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/schema/crabcode.schema.json", + + "theme": "default", + "model": "openai/gpt-5.2", + + "sounds": { + "complete": { "enabled": true, "file": "/absolute/path.wav" }, + "error": { "enabled": false }, + }, +} +``` + +## Default Template + +If you want a fully-commented starting point, copy: + +- `defaults/crabcode.jsonc` + +## Source Of Truth (Prompt) + +Treat this section as the authoritative contract for `crabcode.json(c)`. + +### Top-Level Properties + +- `$schema` (string, optional) + - A URL to a JSON Schema for this config. + +- `theme` (string, optional) + - Theme ID (not a path). + +- `model` (string, optional) + - Default model ID, e.g. `openai/gpt-5.2`. + +- `sounds` (object, optional) + - Keys: `error`, `complete`, `permission`, `question` (each optional) + - Each sound event value is an object: + - `enabled` (boolean, optional) + - `file` (string, optional) + - Must be an absolute path. If not absolute, it is treated as disabled. + - Defaults: + - `complete.enabled = true` + - all other `*.enabled = false` + - if `file` is missing, the sound is disabled + +### Accepted (Merged) But Not Implemented Yet + +These keys are accepted and merged (for forward compatibility), but may have no runtime effect yet: + +- `agent` +- `instructions` +- `tools` +- `mcp` +- `provider` +- `command` +- `permission` +- `compaction` +- `watcher` +- `default_agent` +- `formatter` +- `disabled_providers` +- `enabled_providers` + +## Merge Rules + +- Object + object: recursively merge +- Array + array: override entire array +- Primitive or type mismatch: higher priority replaces lower +- `null`: unset (removes the key from the merged result) + +## Variable Substitution + +After merging, Crabcode expands placeholders inside string values: + +- `{env:VAR_NAME}`: environment variable value (or empty if unset) +- `{file:path}`: file contents (trims trailing newlines) + - `~` expands to home + - relative paths resolve relative to the config file containing the winning value diff --git a/_docs/gittydocs.jsonc b/_docs/gittydocs.jsonc new file mode 100644 index 0000000..ae2926a --- /dev/null +++ b/_docs/gittydocs.jsonc @@ -0,0 +1,20 @@ +{ + "site": { + "name": "crabcode", + "repo": { + "owner": "blankeos", + "name": "crabcode", + "ref": "main", + "docsPath": "_docs", + }, + }, + "nav": [ + { + "label": "Docs", + "items": [ + { "label": "Overview", "path": "/" }, + { "label": "Configuration", "path": "/config" }, + ], + }, + ], +} diff --git a/_docs/index.mdx b/_docs/index.mdx new file mode 100644 index 0000000..7ccc3aa --- /dev/null +++ b/_docs/index.mdx @@ -0,0 +1,72 @@ +--- +title: Docs Index +description: Overview and entry points for crabcode documentation. +--- + +# crabcode docs + +crabcode is a Rust-first, terminal UI coding agent inspired by OpenCode, built for fast startup, interactive workflows, and configurable model support. + +## Start here + +- Project overview: `README.md` +- Configuration reference: `_docs/config.mdx` + +## What crabcode includes + +- Ratatui TUI with keyboard-first workflows +- Streaming responses and session management +- Agent modes (PLAN for analysis, BUILD for implementation) +- Model discovery and provider integration via models.dev + aisdk.rs +- Optional sound effects for events (complete/error/permission/question) + +## Quick start + +```bash +cargo install crabcode +crabcode +``` + +Then run `/connect` inside the app to add a provider and choose a model. + +## Configuration + +Crabcode reads up to four JSON/JSONC config files and merges them in priority order. The canonical contract lives in `_docs/config.mdx`. + +Common keys (phase 1): + +- `theme` +- `model` +- `sounds` + +## Data locations + +These are OS-specific paths for local data. + +- Credentials: `~/Library/Application Support/crabcode/auth.json` (macOS), `~/.local/share/crabcode/auth.json` (Linux) +- Preferences DB: `~/Library/Application Support/crabcode/data.db` (macOS), `~/.local/share/crabcode/data.db` (Linux) +- models.dev cache: `~/Library/Caches/crabcode/models_dev_cache.json` (macOS), `~/.cache/crabcode/models_dev_cache.json` (Linux) + +## Commands + +Common in-app commands: + +- `/sessions` +- `/new` +- `/connect` +- `/models` +- `/exit` + +## Development + +```bash +git clone https://github.com/blankeos/crabcode.git +cd crabcode +cargo build --release +``` + +Run tests: + +```bash +cargo test +``` diff --git a/justfile b/justfile index dba9aa2..c02e43b 100644 --- a/justfile +++ b/justfile @@ -10,5 +10,8 @@ preview: gen-themes: bun run scripts/gen-themes.ts +devdocs: + gittydocs dev _docs + log: tail -f app.log From 03fc7874f0495f8153729cf889c31477f487f1dd Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 3 Feb 2026 02:22:00 +0800 Subject: [PATCH 013/226] feat: keep focus on the currently selected item even after ctrl+f to favorite. --- src/ui/components/dialog.rs | 32 ++++++++++++++++++++++++++++---- src/views/models_dialog.rs | 19 ++++++++++++++----- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index bbbbe3c..88621ec 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -187,7 +187,9 @@ impl Dialog { } fn apply_filter(&mut self) { - let preferred_selected_id = self.get_selected().map(|item| item.id.clone()); + let preferred_selected = self + .get_selected() + .map(|item| (item.id.clone(), item.provider_id.clone())); if self.search_query.is_empty() { self.filtered_items = self @@ -243,10 +245,10 @@ impl Dialog { self.filtered_items = filtered; } - self.reconcile_selection_after_filter(preferred_selected_id); + self.reconcile_selection_after_filter(preferred_selected); } - fn reconcile_selection_after_filter(&mut self, preferred_selected_id: Option) { + fn reconcile_selection_after_filter(&mut self, preferred_selected: Option<(String, String)>) { let flat_items = self.get_flat_items(); if flat_items.is_empty() { self.selected_index = 0; @@ -255,7 +257,16 @@ impl Dialog { return; } - if let Some(id) = preferred_selected_id { + if let Some((id, provider_id)) = preferred_selected { + if let Some(pos) = flat_items + .iter() + .position(|item| item.id == id && item.provider_id == provider_id) + { + self.selected_index = pos; + self.adjust_scroll(); + return; + } + if let Some(pos) = flat_items.iter().position(|item| item.id == id) { self.selected_index = pos; self.adjust_scroll(); @@ -426,6 +437,19 @@ impl Dialog { flat_items.get(self.selected_index).copied() } + pub fn select_item_by_key(&mut self, id: &str, provider_id: &str) -> bool { + let flat_items = self.get_flat_items(); + if let Some(pos) = flat_items + .iter() + .position(|item| item.id == id && item.provider_id == provider_id) + { + self.selected_index = pos; + self.adjust_scroll(); + return true; + } + false + } + pub fn is_visible(&self) -> bool { self.visible } diff --git a/src/views/models_dialog.rs b/src/views/models_dialog.rs index 6787452..2993d95 100644 --- a/src/views/models_dialog.rs +++ b/src/views/models_dialog.rs @@ -45,17 +45,26 @@ impl ModelsDialogState { pub fn refresh_items(&mut self, items: Vec) { let title = self.dialog.title.clone(); let was_visible = self.dialog.is_visible(); - let selected_index = self.dialog.selected_index; - let items_clone = items.clone(); + let selected_item = self + .dialog + .get_selected() + .map(|item| (item.id.clone(), item.provider_id.clone())); + let search_query = self.dialog.search_textarea.lines().join(""); + let actions = self.dialog.actions.clone(); - self.dialog = Dialog::with_items(title, items); + self.dialog = Dialog::with_items(title, items).with_actions(actions); if was_visible { self.dialog.show(); } - if selected_index < items_clone.len() { - self.dialog.selected_index = selected_index; + if !search_query.is_empty() { + self.dialog.search_textarea.insert_str(&search_query); + self.dialog.set_search_query(search_query); + } + + if let Some((id, provider_id)) = selected_item { + self.dialog.select_item_by_key(&id, &provider_id); } } } From 6a09831dbb30df8c8e44aeeb26b7aa920c2a7f7d Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 12:11:33 +0800 Subject: [PATCH 014/226] feat: Add accurate token counting during streaming responses This replaces the previous character-based estimation with proper tokenization using the model-specific tokenizer. The token counter is now initialized when streaming starts to ensure accurate measurements. Also adds elapsed time display alongside the existing tokens/second metric for better streaming performance feedback. --- Cargo.lock | 30 +++++++++- Cargo.toml | 1 + _docs/config.mdx | 2 +- src/app.rs | 3 + src/ui/components/chat.rs | 109 +++++++++++++++++++++++------------- src/utils/mod.rs | 1 + src/utils/token_counter.rs | 111 +++++++++++++++++++++++++++++++++++++ src/views/chat.rs | 12 +++- 8 files changed, 227 insertions(+), 42 deletions(-) create mode 100644 src/utils/token_counter.rs diff --git a/Cargo.lock b/Cargo.lock index 3364ae3..8009e67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,6 +492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -821,6 +822,7 @@ dependencies = [ "strsim", "textwrap", "thiserror 1.0.69", + "tiktoken-rs", "tokio", "tokio-test", "tokio-util", @@ -1309,6 +1311,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -4650,7 +4663,7 @@ dependencies = [ "anyhow", "base64", "bitflags 2.10.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset", @@ -4758,6 +4771,21 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "tiktoken-rs" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a19830747d9034cd9da43a60eaa8e552dfda7712424aebf187b7a60126bae0d" +dependencies = [ + "anyhow", + "base64", + "bstr", + "fancy-regex 0.13.0", + "lazy_static", + "regex", + "rustc-hash", +] + [[package]] name = "time" version = "0.3.45" diff --git a/Cargo.toml b/Cargo.toml index 2568922..1e5b5dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ textwrap = "0.16" unicode-width = "0.1" tui-markdown = "0.3" ratatui-core = "0.1" +tiktoken-rs = "0.9.1" [dev-dependencies] tokio-test = "0.4" diff --git a/_docs/config.mdx b/_docs/config.mdx index c30b12d..3a2b8b2 100644 --- a/_docs/config.mdx +++ b/_docs/config.mdx @@ -1,6 +1,6 @@ --- title: Configuration -description: Crabcode configur/ation keys and merge behavior. +description: Crabcode configuration keys and merge behavior. --- # Configuration diff --git a/src/app.rs b/src/app.rs index 577e4ab..7ca6f14 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1739,6 +1739,9 @@ impl App { // so they don't change if the user switches models during streaming self.streaming_model = Some(self.model.clone()); self.streaming_provider = Some(self.provider_name.clone()); + self.chat_state + .chat + .prepare_streaming_token_counter(&self.model); self.chat_state.chat.add_assistant_message(""); if let Some(last_msg) = self.chat_state.chat.messages.last_mut() { diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index e53ddf5..f7214f0 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1,6 +1,7 @@ use crate::session::types::{Message, MessageRole}; use crate::theme::ThemeColors; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; +use crate::utils::token_counter::StreamingTokenCounter; use ratatui::{ crossterm::event::{MouseButton, MouseEvent, MouseEventKind}, layout::Rect, @@ -27,6 +28,7 @@ pub struct Chat { pub streaming_t1_ms: Option, pub streaming_tn_ms: Option, pub streaming_token_count: usize, + streaming_token_counter: Option, /// Whether to autoscroll to bottom when new content arrives /// Only autoscrolls if user is already near the bottom pub autoscroll_enabled: bool, @@ -53,6 +55,11 @@ fn now_epoch_ms() -> u64 { .as_millis() as u64 } +fn estimate_tokens(text: &str) -> usize { + let chars = text.chars().count(); + (chars.saturating_add(3)) / 4 +} + impl Chat { pub fn new() -> Self { Self { @@ -69,6 +76,7 @@ impl Chat { streaming_t1_ms: None, streaming_tn_ms: None, streaming_token_count: 0, + streaming_token_counter: None, autoscroll_enabled: true, user_scrolled_up: false, cached_tokens_per_sec: None, @@ -93,6 +101,7 @@ impl Chat { streaming_t1_ms: None, streaming_tn_ms: None, streaming_token_count: 0, + streaming_token_counter: None, autoscroll_enabled: true, user_scrolled_up: false, cached_tokens_per_sec: None, @@ -168,8 +177,7 @@ impl Chat { self.streaming_t1_ms = Some(now_epoch_ms()); } - // Estimate tokens: ~4 characters per token on average - self.streaming_token_count += chunk_str.chars().count().max(1) / 4; + self.update_streaming_token_count(chunk_str); if self.should_autoscroll() { self.scroll_offset = usize::MAX; self.user_scrolled_up = false; @@ -202,7 +210,7 @@ impl Chat { self.streaming_first_token_time = Some(now); self.streaming_t1_ms = Some(now_epoch_ms()); } - self.streaming_token_count += chunk_str.chars().count().max(1) / 4; + self.update_streaming_token_count(chunk_str); if self.should_autoscroll() { self.scroll_offset = usize::MAX; self.user_scrolled_up = false; @@ -221,6 +229,7 @@ impl Chat { self.streaming_t1_ms = None; self.streaming_tn_ms = None; self.streaming_token_count = 0; + self.streaming_token_counter = None; } pub fn begin_streaming_turn(&mut self) { @@ -237,6 +246,10 @@ impl Chat { self.cached_tokens_per_sec = None; self.last_tps_calculated = None; + if let Some(counter) = self.streaming_token_counter.as_mut() { + counter.reset(); + } + if let Some(msg) = self .messages .last_mut() @@ -252,43 +265,13 @@ impl Chat { self.streaming_tn_ms = Some(now_epoch_ms()); } - pub fn get_streaming_tokens_per_sec(&mut self) -> Option { - // Throttle token calculation to prevent excessive updates during high-frequency renders - // caused by mouse movement. Only recalculate every 100ms. - const TPS_THROTTLE_MS: u128 = 100; - - let now = std::time::Instant::now(); - if let Some(last_calc) = self.last_tps_calculated { - if now.duration_since(last_calc).as_millis() < TPS_THROTTLE_MS { - // Still within throttle window, return cached value - return self.cached_tokens_per_sec; - } - } - // Update timestamp for next throttle check - self.last_tps_calculated = Some(now); - - // Use first_token_time for more accurate measurement (like PR #5497) - let result = if let Some(first_token_time) = self.streaming_first_token_time { - let elapsed_ms = first_token_time.elapsed().as_millis(); - // Only show after minimum elapsed time to avoid inaccurate early readings - if elapsed_ms >= MIN_TOKENS_PER_SECOND_ELAPSED_MS && self.streaming_token_count > 0 { - let tokens_per_sec = - (self.streaming_token_count as f64) / (elapsed_ms as f64 / 1000.0); - if tokens_per_sec.is_finite() { - Some(tokens_per_sec) - } else { - None - } - } else { - None - } - } else { - None - }; + pub fn get_streaming_tokens_per_sec(&self) -> Option { + self.cached_tokens_per_sec + } - // Cache the result for throttled returns - self.cached_tokens_per_sec = result; - result + pub fn get_streaming_elapsed_seconds(&self) -> Option { + self.streaming_start_time + .map(|start| start.elapsed().as_secs_f64()) } pub fn is_streaming(&self) -> bool { @@ -340,6 +323,54 @@ impl Chat { self.streaming_token_count = 0; self.streaming_renderer = None; self.streaming_message_idx = None; + self.streaming_token_counter = None; + } + + pub fn prepare_streaming_token_counter(&mut self, model: &str) { + self.streaming_token_counter = Some(StreamingTokenCounter::new(model)); + } + + fn update_streaming_token_count(&mut self, chunk: &str) { + if let Some(counter) = self.streaming_token_counter.as_mut() { + self.streaming_token_count = counter.add_text(chunk); + } else { + self.streaming_token_count = self + .streaming_token_count + .saturating_add(estimate_tokens(chunk)); + } + + self.update_streaming_tokens_per_sec(); + } + + fn update_streaming_tokens_per_sec(&mut self) { + const TPS_THROTTLE_MS: u128 = 100; + + let now = std::time::Instant::now(); + if let Some(last_calc) = self.last_tps_calculated { + if now.duration_since(last_calc).as_millis() < TPS_THROTTLE_MS { + return; + } + } + self.last_tps_calculated = Some(now); + + let result = if let Some(first_token_time) = self.streaming_first_token_time { + let elapsed_ms = first_token_time.elapsed().as_millis(); + if elapsed_ms >= MIN_TOKENS_PER_SECOND_ELAPSED_MS && self.streaming_token_count > 0 { + let tokens_per_sec = + (self.streaming_token_count as f64) / (elapsed_ms as f64 / 1000.0); + if tokens_per_sec.is_finite() { + Some(tokens_per_sec) + } else { + None + } + } else { + None + } + } else { + None + }; + + self.cached_tokens_per_sec = result; } /// Update the streaming markdown renderer for the current streaming message diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1e40f95..90837b0 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ pub mod frecency; pub mod git; pub mod ignore; +pub mod token_counter; diff --git a/src/utils/token_counter.rs b/src/utils/token_counter.rs new file mode 100644 index 0000000..e712383 --- /dev/null +++ b/src/utils/token_counter.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; + +use tiktoken_rs::{cl100k_base, get_bpe_from_model, o200k_base, CoreBPE}; + +const TAIL_CONTEXT_CHARS: usize = 256; + +#[derive(Clone)] +pub struct StreamingTokenCounter { + encoder: TokenEncoder, + total_tokens: usize, + tail_text: String, + tail_tokens: usize, +} + +#[derive(Clone)] +enum TokenEncoder { + Tiktoken(Arc), + Approximate, +} + +impl StreamingTokenCounter { + pub fn new(model: &str) -> Self { + let encoder = match get_bpe_from_model(model) { + Ok(bpe) => TokenEncoder::Tiktoken(Arc::new(bpe)), + Err(_) => fallback_encoder(model).unwrap_or(TokenEncoder::Approximate), + }; + + Self { + encoder, + total_tokens: 0, + tail_text: String::new(), + tail_tokens: 0, + } + } + + pub fn reset(&mut self) { + self.total_tokens = 0; + self.tail_text.clear(); + self.tail_tokens = 0; + } + + pub fn add_text(&mut self, text: &str) -> usize { + if text.is_empty() { + return self.total_tokens; + } + + match &self.encoder { + TokenEncoder::Tiktoken(bpe) => { + let combined = format!("{}{}", self.tail_text, text); + let combined_tokens = bpe.encode_ordinary(&combined).len(); + self.total_tokens = + self.total_tokens.saturating_sub(self.tail_tokens) + combined_tokens; + + self.tail_text = take_last_chars(&combined, TAIL_CONTEXT_CHARS); + self.tail_tokens = bpe.encode_ordinary(&self.tail_text).len(); + } + TokenEncoder::Approximate => { + self.total_tokens = self.total_tokens.saturating_add(approximate_tokens(text)); + } + } + + self.total_tokens + } + + pub fn total_tokens(&self) -> usize { + self.total_tokens + } +} + +impl std::fmt::Debug for StreamingTokenCounter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamingTokenCounter") + .field("total_tokens", &self.total_tokens) + .field("tail_len", &self.tail_text.chars().count()) + .finish() + } +} + +fn fallback_encoder(model: &str) -> Option { + let model_lower = model.to_lowercase(); + let use_o200k = model_lower.contains("gpt-5") + || model_lower.contains("gpt-4o") + || model_lower.contains("gpt-4.1") + || model_lower.starts_with("o1") + || model_lower.starts_with("o3") + || model_lower.starts_with("o4") + || model_lower.contains("o1-") + || model_lower.contains("o3-") + || model_lower.contains("o4-"); + + if use_o200k { + return o200k_base() + .map(|bpe| TokenEncoder::Tiktoken(Arc::new(bpe))) + .ok(); + } + + cl100k_base() + .map(|bpe| TokenEncoder::Tiktoken(Arc::new(bpe))) + .ok() +} + +fn approximate_tokens(text: &str) -> usize { + let chars = text.chars().count(); + (chars.saturating_add(3)) / 4 +} + +fn take_last_chars(text: &str, max_chars: usize) -> String { + let mut chars: Vec = text.chars().rev().take(max_chars).collect(); + chars.reverse(); + chars.into_iter().collect() +} diff --git a/src/views/chat.rs b/src/views/chat.rs index 0fbdc7a..d66e28d 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -121,8 +121,10 @@ pub fn render_chat( // to prevent speed issues when mouse movement causes frequent redraws let mut streaming_text = chat_state.wave_spinner.spans(); + let tps = chat_state.chat.get_streaming_tokens_per_sec(); + // Add tokens/second if available - if let Some(tps) = chat_state.chat.get_streaming_tokens_per_sec() { + if let Some(tps) = tps { streaming_text.push(Span::raw(" ")); streaming_text.push(Span::styled( format!("{:.0}t/s", tps), @@ -130,6 +132,14 @@ pub fn render_chat( )); } + if let Some(elapsed) = chat_state.chat.get_streaming_elapsed_seconds() { + streaming_text.push(Span::raw(if tps.is_some() { " • " } else { " " })); + streaming_text.push(Span::styled( + format!("{:.1}s", elapsed), + Style::default().fg(colors.info), + )); + } + streaming_text.push(Span::raw(" ")); streaming_text.push(Span::styled( "esc to stop", From d402a4f5db68e8d93bc58b6746ae009f157fe929 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 14:47:35 +0800 Subject: [PATCH 015/226] feat: hide modal when click outside. --- src/app.rs | 22 ++++++++++++++++ src/ui/components/dialog.rs | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/app.rs b/src/app.rs index 7ca6f14..89fdecc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -792,6 +792,9 @@ impl App { pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { if self.overlay_focus == OverlayFocus::ModelsDialog { handle_models_dialog_mouse_event(&mut self.models_dialog_state, mouse); + if !self.models_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } } else if self.overlay_focus == OverlayFocus::ThemesDialog { let before = self .themes_dialog_state @@ -801,6 +804,14 @@ impl App { handle_themes_dialog_mouse_event(&mut self.themes_dialog_state, mouse); + if !self.themes_dialog_state.dialog.is_visible() { + if !self.themes_dialog_committed { + self.current_theme_index = self.themes_dialog_original_theme_index; + } + self.overlay_focus = OverlayFocus::None; + return; + } + let after = self .themes_dialog_state .dialog @@ -818,8 +829,19 @@ impl App { } } else if self.overlay_focus == OverlayFocus::ConnectDialog { handle_connect_dialog_mouse_event(&mut self.connect_dialog_state, mouse); + if !self.connect_dialog_state.dialog.is_visible() { + if let Some(selected_item) = get_pending_selection(&mut self.connect_dialog_state) { + self.api_key_input.show(&selected_item.id); + self.overlay_focus = OverlayFocus::ApiKeyInput; + return; + } + self.overlay_focus = OverlayFocus::None; + } } else if self.overlay_focus == OverlayFocus::SessionsDialog { handle_sessions_dialog_mouse_event(&mut self.sessions_dialog_state, mouse); + if !self.sessions_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } } else if self.overlay_focus == OverlayFocus::None { // Handle mouse events for chat scrolling when in chat mode if self.base_focus == BaseFocus::Chat { diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 88621ec..b78b8cc 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -493,6 +493,14 @@ impl Dialog { use ratatui::layout::Position; let point = Position::new(event.column, event.row); + if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) + && !self.dialog_area.contains(point) + { + self.hide(); + self.is_dragging_scrollbar = false; + return true; + } + const PADDING: u16 = 3; let content_area = Rect { x: self.dialog_area.x + PADDING, @@ -1022,6 +1030,50 @@ mod tests { assert!(!dialog.is_visible()); } + #[test] + fn test_dialog_click_outside_closes_modal() { + let mut dialog = Dialog::new("Test"); + dialog.show(); + dialog.dialog_area = Rect { + x: 10, + y: 10, + width: 30, + height: 10, + }; + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }); + + assert!(handled); + assert!(!dialog.is_visible()); + } + + #[test] + fn test_dialog_click_inside_keeps_modal_open() { + let mut dialog = Dialog::new("Test"); + dialog.show(); + dialog.dialog_area = Rect { + x: 10, + y: 10, + width: 30, + height: 10, + }; + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 11, + row: 11, + modifiers: KeyModifiers::NONE, + }); + + assert!(!handled); + assert!(dialog.is_visible()); + } + #[test] fn test_dialog_toggle() { let mut dialog = Dialog::new("Test"); From 2c6594c1343215258bcd70916efe1152a45c2d9f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 14 Feb 2026 01:49:27 +0800 Subject: [PATCH 016/226] feat: Add built-in sound effects for error and complete events Implement automatic fallback to bundled MP3 files when no custom sound is configured. Sounds are materialized to the data directory on first use and cached for subsequent plays. Only error and complete events have built-in sounds; permission and question remain opt-in with user-provided files. --- sounds/complete.mp3 | Bin 0 -> 11564 bytes sounds/error.mp3 | Bin 0 -> 6668 bytes sounds/question.mp3 | Bin 0 -> 10412 bytes src/app.rs | 25 +++--- src/config/configuration.rs | 12 +-- src/sound.rs | 175 +++++++++++++++++++++++++++++++++++- 6 files changed, 187 insertions(+), 25 deletions(-) create mode 100644 sounds/complete.mp3 create mode 100644 sounds/error.mp3 create mode 100644 sounds/question.mp3 diff --git a/sounds/complete.mp3 b/sounds/complete.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..8b2517732f2ab498184eb5fda659f6c972d17273 GIT binary patch literal 11564 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 literal 0 HcmV?d00001 diff --git a/sounds/error.mp3 b/sounds/error.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..da5cf1dc5f5dc0d4215ff080862ab8c1571c13f3 GIT binary patch literal 6668 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 SOWwGSgr9!?V`Th`f&LdqyyFxA literal 0 HcmV?d00001 diff --git a/sounds/question.mp3 b/sounds/question.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3a94a893a15385a16a5618b1bdd1a100fb64573e GIT binary patch literal 10412 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, pub current_theme_index: usize, pub dark_mode: bool, - pub sounds: crate::config::SoundsConfig, + pub sounds: crate::sound::ResolvedSoundsConfig, pub is_streaming: bool, chunk_sender: Option, chunk_receiver: Option, @@ -184,6 +184,14 @@ impl App { ); } + let (resolved_sounds, sound_warnings) = + crate::sound::resolve_effective_sounds(&loaded_config.merged_config.sounds); + if !sound_warnings.is_empty() { + for msg in &sound_warnings { + eprintln!("Sound warning: {}", msg); + } + } + let active_model_info = if let Some(ref dao) = prefs_dao { dao.get_active_model().ok().flatten() } else { @@ -262,7 +270,7 @@ impl App { themes, current_theme_index, dark_mode: true, - sounds: loaded_config.merged_config.sounds.clone(), + sounds: resolved_sounds, is_streaming: false, chunk_sender: None, chunk_receiver: None, @@ -278,17 +286,8 @@ impl App { } fn play_sound_event(&self, event: crate::sound::SoundEvent) { - use crate::sound::SoundEvent; - let effect = match event { - SoundEvent::Error => &self.sounds.error, - SoundEvent::Complete => &self.sounds.complete, - SoundEvent::Permission => &self.sounds.permission, - SoundEvent::Question => &self.sounds.question, - }; - if effect.is_effectively_enabled() { - if let Some(ref path) = effect.file { - crate::sound::play_file(path); - } + if let Some(path) = self.sounds.path_for_event(event) { + crate::sound::play_file(path); } } diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 1b6a7d0..80df3ca 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -124,12 +124,6 @@ pub struct SoundEffectConfig { pub enabled: bool, } -impl SoundEffectConfig { - pub fn is_effectively_enabled(&self) -> bool { - self.enabled && self.file.is_some() - } -} - #[derive(Debug, Clone)] pub struct SoundsConfig { pub error: SoundEffectConfig, @@ -143,7 +137,7 @@ impl Default for SoundsConfig { Self { error: SoundEffectConfig { file: None, - enabled: false, + enabled: true, }, complete: SoundEffectConfig { file: None, @@ -824,10 +818,6 @@ fn apply_sound_event( target.enabled = false; } } - - if target.file.is_none() { - target.enabled = false; - } } fn collect_unimplemented_keys(merged: &Value) -> Vec { diff --git a/src/sound.rs b/src/sound.rs index 6e1e125..5b326fd 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -1,4 +1,5 @@ -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; #[derive(Debug, Clone, Copy)] @@ -9,6 +10,178 @@ pub enum SoundEvent { Question, } +#[derive(Debug, Clone, Default)] +pub struct ResolvedSoundsConfig { + pub error: Option, + pub complete: Option, + pub permission: Option, + pub question: Option, +} + +impl ResolvedSoundsConfig { + pub fn path_for_event(&self, event: SoundEvent) -> Option<&Path> { + match event { + SoundEvent::Error => self.error.as_deref(), + SoundEvent::Complete => self.complete.as_deref(), + SoundEvent::Permission => self.permission.as_deref(), + SoundEvent::Question => self.question.as_deref(), + } + } +} + +#[derive(Debug, Clone, Copy)] +enum BuiltInSound { + Error, + Complete, +} + +#[derive(Debug, Default)] +struct BuiltInSoundCache { + error: Option, + complete: Option, +} + +const BUILTIN_ERROR_MP3: &[u8] = include_bytes!("../sounds/error.mp3"); +const BUILTIN_COMPLETE_MP3: &[u8] = include_bytes!("../sounds/complete.mp3"); + +pub fn resolve_effective_sounds( + config: &crate::config::SoundsConfig, +) -> (ResolvedSoundsConfig, Vec) { + let mut warnings = Vec::new(); + let mut built_in_cache = BuiltInSoundCache::default(); + + let resolved = ResolvedSoundsConfig { + error: resolve_event_path( + "sounds.error", + &config.error, + Some(BuiltInSound::Error), + &mut built_in_cache, + &mut warnings, + ), + complete: resolve_event_path( + "sounds.complete", + &config.complete, + Some(BuiltInSound::Complete), + &mut built_in_cache, + &mut warnings, + ), + permission: resolve_event_path( + "sounds.permission", + &config.permission, + None, + &mut built_in_cache, + &mut warnings, + ), + question: resolve_event_path( + "sounds.question", + &config.question, + None, + &mut built_in_cache, + &mut warnings, + ), + }; + + (resolved, warnings) +} + +fn resolve_event_path( + key: &str, + effect: &crate::config::SoundEffectConfig, + fallback: Option, + built_in_cache: &mut BuiltInSoundCache, + warnings: &mut Vec, +) -> Option { + if !effect.enabled { + return None; + } + + if let Some(path) = effect.file.as_ref() { + if path.is_file() { + return Some(path.clone()); + } + + warnings.push(format!( + "{}: configured sound file was not found at {}; event stays silent", + key, + path.display() + )); + return None; + } + + if let Some(sound) = fallback { + return materialize_built_in_sound(sound, built_in_cache, warnings); + } + + warnings.push(format!( + "{}: enabled but no file configured; event stays silent", + key + )); + None +} + +fn materialize_built_in_sound( + sound: BuiltInSound, + built_in_cache: &mut BuiltInSoundCache, + warnings: &mut Vec, +) -> Option { + let cached = match sound { + BuiltInSound::Error => built_in_cache.error.as_ref(), + BuiltInSound::Complete => built_in_cache.complete.as_ref(), + }; + if let Some(path) = cached { + return Some(path.clone()); + } + + let (file_name, bytes) = match sound { + BuiltInSound::Error => ("error.mp3", BUILTIN_ERROR_MP3), + BuiltInSound::Complete => ("complete.mp3", BUILTIN_COMPLETE_MP3), + }; + + let sounds_dir = crate::persistence::get_data_dir().join("sounds"); + if let Err(err) = fs::create_dir_all(&sounds_dir) { + warnings.push(format!( + "Failed to prepare built-in sounds directory {}: {}", + sounds_dir.display(), + err + )); + return None; + } + + let out_path = sounds_dir.join(file_name); + if let Err(err) = ensure_file_contents(&out_path, bytes) { + warnings.push(format!( + "Failed to materialize built-in sound {}: {}", + out_path.display(), + err + )); + return None; + } + + match sound { + BuiltInSound::Error => { + built_in_cache.error = Some(out_path.clone()); + } + BuiltInSound::Complete => { + built_in_cache.complete = Some(out_path.clone()); + } + } + + Some(out_path) +} + +fn ensure_file_contents(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let should_write = match fs::read(path) { + Ok(existing) => existing != bytes, + Err(_) => true, + }; + + if should_write { + fs::write(path, bytes)?; + } + + Ok(()) +} + pub fn play_file(path: &Path) { if !path.is_file() { return; From e6c47b1d5f9daa6cd6a7496bc2fb47a569dbbd0f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 14 Feb 2026 01:50:05 +0800 Subject: [PATCH 017/226] docs: Add bundled sound defaults and JSON schema for config - Add default sounds for complete/error events with fallback behavior - Create crabcode.schema.json for editor validation - Add defaults/ folder with template config and skills README - Update docs to reflect new sounds behavior and cache locations - Add .agent folder support plan for cross-tool compatibility --- .gitignore | 1 + _docs/config.mdx | 17 +++- _docs/index.mdx | 3 +- _plans/TODO_SUPPORT_AGENT_FOLDER.md | 14 ++++ crabcode.jsonc | 10 +++ defaults/README.md | 12 +++ defaults/crabcode.jsonc | 55 +++++++++++++ defaults/skills/README.md | 9 +++ schema/crabcode.schema.json | 116 ++++++++++++++++++++++++++++ 9 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 _plans/TODO_SUPPORT_AGENT_FOLDER.md create mode 100644 crabcode.jsonc create mode 100644 defaults/README.md create mode 100644 defaults/crabcode.jsonc create mode 100644 defaults/skills/README.md create mode 100644 schema/crabcode.schema.json diff --git a/.gitignore b/.gitignore index 142ec12..a2952ce 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ src/themes/ !src/theme.json !src/theme.json app.log +sounds/complete.wav diff --git a/_docs/config.mdx b/_docs/config.mdx index 3a2b8b2..75ad4b5 100644 --- a/_docs/config.mdx +++ b/_docs/config.mdx @@ -32,8 +32,9 @@ Recommended filename: `crabcode.jsonc`. "model": "openai/gpt-5.2", "sounds": { - "complete": { "enabled": true, "file": "/absolute/path.wav" }, - "error": { "enabled": false }, + "complete": { "enabled": true }, + "error": { "enabled": true }, + "question": { "enabled": true, "file": "/absolute/path/question.mp3" } }, } ``` @@ -65,10 +66,18 @@ Treat this section as the authoritative contract for `crabcode.json(c)`. - `enabled` (boolean, optional) - `file` (string, optional) - Must be an absolute path. If not absolute, it is treated as disabled. + - If provided but the file is missing, the event stays silent. - Defaults: - `complete.enabled = true` - - all other `*.enabled = false` - - if `file` is missing, the sound is disabled + - `error.enabled = true` + - `permission.enabled = false` + - `question.enabled = false` + - Fallback behavior when `enabled = true` and `file` is omitted: + - `complete` and `error` use bundled default sounds + - `permission` and `question` stay silent + - Bundled sounds are materialized at runtime under the app data dir: + - macOS: `~/Library/Application Support/crabcode/sounds` + - Linux: `~/.local/share/crabcode/sounds` ### Accepted (Merged) But Not Implemented Yet diff --git a/_docs/index.mdx b/_docs/index.mdx index 7ccc3aa..65b6065 100644 --- a/_docs/index.mdx +++ b/_docs/index.mdx @@ -18,7 +18,7 @@ crabcode is a Rust-first, terminal UI coding agent inspired by OpenCode, built f - Streaming responses and session management - Agent modes (PLAN for analysis, BUILD for implementation) - Model discovery and provider integration via models.dev + aisdk.rs -- Optional sound effects for events (complete/error/permission/question) +- Bundled complete/error sounds with configurable overrides (plus permission/question) ## Quick start @@ -46,6 +46,7 @@ These are OS-specific paths for local data. - Credentials: `~/Library/Application Support/crabcode/auth.json` (macOS), `~/.local/share/crabcode/auth.json` (Linux) - Preferences DB: `~/Library/Application Support/crabcode/data.db` (macOS), `~/.local/share/crabcode/data.db` (Linux) - models.dev cache: `~/Library/Caches/crabcode/models_dev_cache.json` (macOS), `~/.cache/crabcode/models_dev_cache.json` (Linux) +- Built-in sounds cache: `~/Library/Application Support/crabcode/sounds` (macOS), `~/.local/share/crabcode/sounds` (Linux) ## Commands diff --git a/_plans/TODO_SUPPORT_AGENT_FOLDER.md b/_plans/TODO_SUPPORT_AGENT_FOLDER.md new file mode 100644 index 0000000..fd47818 --- /dev/null +++ b/_plans/TODO_SUPPORT_AGENT_FOLDER.md @@ -0,0 +1,14 @@ +- considering just using .agent/SKILLS since everyone else supports it now. +- Consider removing the local .crabcode folder? But crabcode folder on the global will still be used +- still gonna be using crabcode.jsonc + +Let's implement @\_plans/TODO_SUPPORT_AGENT_FOLDER.md +Because openai decided to support that .agent folder, it supports: + +- Amp +- Codex +- Gemini CLI +- Github Copilot +- Kimi Code CLI +- OpenCode + I now want crabcode to support that too. diff --git a/crabcode.jsonc b/crabcode.jsonc new file mode 100644 index 0000000..c7a64f6 --- /dev/null +++ b/crabcode.jsonc @@ -0,0 +1,10 @@ +{ + // Crabcode theme id (see src/generated_themes/carbonfox.json) + "theme": "dracula", + // "sounds": { + // "complete": { + // "enabled": true, + // "file": "/Users/carlo/Desktop/Projects/crabcode/sounds/complete.wav", + // }, + // }, +} diff --git a/defaults/README.md b/defaults/README.md new file mode 100644 index 0000000..6e0d76d --- /dev/null +++ b/defaults/README.md @@ -0,0 +1,12 @@ +# Defaults + +This folder contains copy/paste templates for Crabcode. + +Config: + +- `defaults/crabcode.jsonc` (recommended starting point) + +Common install locations: + +- Global: `~/.config/crabcode/crabcode.jsonc` +- Project: `/.crabcode/crabcode.jsonc` diff --git a/defaults/crabcode.jsonc b/defaults/crabcode.jsonc new file mode 100644 index 0000000..7477d8a --- /dev/null +++ b/defaults/crabcode.jsonc @@ -0,0 +1,55 @@ +{ + // Optional: enables editor completion/validation. + "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/schema/crabcode.schema.json", + + // Theme ID (not a path). Crabcode resolves theme IDs from built-ins and theme folders. + // Example built-ins live under `src/generated_themes/*.json`. + "theme": "default", + + // Default model (used only when you don't have an active model persisted yet). + "model": "openai/gpt-5.2", + + // Sounds are optional. + // - `enabled` toggles each event. + // - `file` must be an absolute path (no `~`, no relative). + // - If `enabled` is true and `file` is omitted: + // - `complete` and `error` use bundled defaults + // - `permission` and `question` stay silent + "sounds": { + "error": { + "enabled": true, + // Optional override: + // "file": "/absolute/path/to/error.mp3" + }, + "complete": { + "enabled": true, + // Optional override: + // "file": "/absolute/path/to/complete.mp3" + }, + "permission": { + "enabled": false, + // "file": "/absolute/path/to/permission.wav" + }, + "question": { + "enabled": false, + // "file": "/absolute/path/to/question.wav" + } + }, + + // --- Accepted (merged) but not implemented yet (phase 1) --- + // These keys are preserved for forward compatibility. + // + // "agent": { ... }, + // "instructions": [ ... ], + // "tools": { "bash": true, "read": true }, + // "mcp": { ... }, + // "provider": { ... }, + // "command": { ... }, + // "permission": { ... }, + // "compaction": { ... }, + // "watcher": { ... }, + // "default_agent": "...", + // "formatter": { ... }, + // "disabled_providers": [ ... ], + // "enabled_providers": [ ... ] +} diff --git a/defaults/skills/README.md b/defaults/skills/README.md new file mode 100644 index 0000000..4d0ea9a --- /dev/null +++ b/defaults/skills/README.md @@ -0,0 +1,9 @@ +# Default Skills (Planned) + +This folder is reserved for future, built-in skill templates. + +Phase 1 only discovers OpenCode skills folders (it does not load/apply skills yet). + +Relevant doc: + +- `_docs/config.mdx` diff --git a/schema/crabcode.schema.json b/schema/crabcode.schema.json new file mode 100644 index 0000000..f80a00f --- /dev/null +++ b/schema/crabcode.schema.json @@ -0,0 +1,116 @@ +{ + "$defs": { + "SoundEffectConfigFile": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "file": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "SoundsConfigFile": { + "additionalProperties": false, + "properties": { + "complete": { + "anyOf": [ + { + "$ref": "#/$defs/SoundEffectConfigFile" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/SoundEffectConfigFile" + }, + { + "type": "null" + } + ] + }, + "permission": { + "anyOf": [ + { + "$ref": "#/$defs/SoundEffectConfigFile" + }, + { + "type": "null" + } + ] + }, + "question": { + "anyOf": [ + { + "$ref": "#/$defs/SoundEffectConfigFile" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/blankeos/crabcode/main/schema/crabcode.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "$schema": { + "type": [ + "string", + "null" + ] + }, + "agent": true, + "command": true, + "compaction": true, + "default_agent": true, + "disabled_providers": true, + "enabled_providers": true, + "formatter": true, + "instructions": true, + "mcp": true, + "model": { + "type": [ + "string", + "null" + ] + }, + "permission": true, + "provider": true, + "sounds": { + "anyOf": [ + { + "$ref": "#/$defs/SoundsConfigFile" + }, + { + "type": "null" + } + ] + }, + "theme": { + "type": [ + "string", + "null" + ] + }, + "tools": true, + "watcher": true + }, + "title": "CrabcodeConfigFile", + "type": "object" +} From ab208a5d21759f20978f6211f6bd2a87ba2be8fe Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 14 Feb 2026 08:07:06 +0800 Subject: [PATCH 018/226] docs: better docs. --- AGENTS.md | 18 ++++- _docs/config.mdx | 174 ++++++++++++++++++++++++------------------ _docs/gittydocs.jsonc | 5 ++ _docs/index.mdx | 75 +++++------------- _docs/quickstart.mdx | 99 ++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 135 deletions(-) create mode 100644 _docs/quickstart.mdx diff --git a/AGENTS.md b/AGENTS.md index c7b17fd..1067368 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,11 +9,13 @@ Before adding/changing scripts, make sure to check `justfile` for existing recip ## File Locations ### Configuration Docs + - **Location**: `_docs/config.mdx` - **Purpose**: Source-of-truth, human/AI-readable contract for `crabcode.json(c)` ### SQLite Database -- **Location**: + +- **Location**: - macOS: `~/Library/Application Support/crabcode/data.db` - Linux: `~/.local/share/crabcode/data.db` - **Implementation**: `src/persistence/prefs.rs` @@ -22,7 +24,8 @@ Before adding/changing scripts, make sure to check `justfile` for existing recip - Preference keys and values with timestamps ### Authentication Credentials -- **Location**: + +- **Location**: - macOS: `~/Library/Application Support/crabcode/auth.json` - Linux: `~/.local/share/crabcode/auth.json` - **Implementation**: `src/persistence/auth.rs` @@ -39,7 +42,8 @@ Before adding/changing scripts, make sure to check `justfile` for existing recip ``` ### Models.dev API Cache -- **Location**: + +- **Location**: - macOS: `~/Library/Caches/crabcode/models_dev_cache.json` - Linux: `~/.cache/crabcode/models_dev_cache.json` - Test mode: `/tmp/crabcode_test_cache/models_dev_cache.json` @@ -48,5 +52,13 @@ Before adding/changing scripts, make sure to check `justfile` for existing recip - **Implementation**: `src/model/discovery.rs` The cache stores provider and model information from models.dev and expires after 24 hours. The cached data includes: + - Provider information (id, name, API endpoints, documentation, env vars, npm packages) - Model information per provider (id, name, family, capabilities, modalities, cost, limits) + +### Writing official documentation + +Traverse this llms.txt as ofsten as you can if you write docs: https://gittydocs.carlo.tl/llms.txt + +- Write the dcocs in `config.mdx` +- When writing titles + first text in the body, never use the same 'title' (in mdx data) and '# ` (in the body). diff --git a/_docs/config.mdx b/_docs/config.mdx index 75ad4b5..72977c2 100644 --- a/_docs/config.mdx +++ b/_docs/config.mdx @@ -1,114 +1,138 @@ --- title: Configuration -description: Crabcode configuration keys and merge behavior. +description: crabcode configuration—OpenCode-compatible with a few additions. --- -# Configuration +# Configure crabcode -Crabcode reads up to 4 config files (JSON or JSONC) and deep-merges them with increasing priority: +crabcode is designed to be a drop-in replacement for OpenCode. It reads the same config files, uses the same merge rules, and respects most of the same settings. If you're coming from OpenCode, your existing config just works. -1. OpenCode global -2. Crabcode global -3. OpenCode local -4. Crabcode local +The only difference: crabcode adds a couple terminal-specific settings that OpenCode doesn't have (sounds, theme selection). -Only these keys are implemented in phase 1: `theme`, `model`, `sounds`. +For everything else, refer to the [OpenCode configuration docs](https://opencode.ai/docs/config/). -## File Format +--- + +## Quick start + +Create a `crabcode.jsonc` in your project root: + +```jsonc +{ + "theme": "default", + "model": "openai/gpt-5.2" +} +``` + +That's it. crabcode validates on startup and tells you if something's wrong. + +--- + +## Config sources + +crabcode reads up to four files and merges them (higher priority overrides lower): -- `.json`: strict JSON -- `.jsonc`: JSON with comments + trailing commas +1. **OpenCode global** – `~/.config/opencode/opencode.json(c)` +2. **crabcode global** – `~/.config/crabcode/crabcode.json(c)` +3. **OpenCode local** – `.opencode/opencode.json(c)` in your project +4. **crabcode local** – `crabcode.json(c)` or `.crabcode/crabcode.json(c)` in your project + +Set your defaults globally, override per-project when needed. + +--- -Recommended filename: `crabcode.jsonc`. +## crabcode-specific settings -## Quick Example +These only work in crabcode config files. They'll be silently ignored if placed in OpenCode configs. + +### `theme` + +Sets the terminal UI theme: ```jsonc { - // Optional; enables editor tooling when you host a schema file - "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/schema/crabcode.schema.json", + "theme": "default" +} +``` - "theme": "default", - "model": "openai/gpt-5.2", +crabcode uses OpenCode's theme JSON format and can load themes from OpenCode's `themes/` folders. See [OpenCode's theme docs](https://opencode.ai/docs/themes/) for how to create custom themes. +### `sounds` + +Audio feedback for events (terminal-only, so crabcode-only): + +```jsonc +{ "sounds": { "complete": { "enabled": true }, "error": { "enabled": true }, - "question": { "enabled": true, "file": "/absolute/path/question.mp3" } - }, + "permission": { "enabled": false }, + "question": { "enabled": false } + } } ``` -## Default Template +**Custom sounds:** Provide an absolute path to a sound file: -If you want a fully-commented starting point, copy: +```jsonc +{ + "sounds": { + "complete": { + "enabled": true, + "file": "/Users/you/sounds/done.wav" + } + } +} +``` -- `defaults/crabcode.jsonc` +If `file` is omitted, `complete` and `error` use bundled defaults. `permission` and `question` stay silent by default. -## Source Of Truth (Prompt) +--- -Treat this section as the authoritative contract for `crabcode.json(c)`. +## What's supported from OpenCode -### Top-Level Properties +crabcode parses and merges these OpenCode settings (some are functional now, others reserved for future implementation): -- `$schema` (string, optional) - - A URL to a JSON Schema for this config. +| Setting | Status | +|---------|--------| +| `model` | ✅ Works | +| `theme` | ✅ Works (crabcode config only) | +| `sounds` | ✅ Works (crabcode-only) | +| `agent`, `instructions`, `tools`, `mcp`, `provider`, `command`, `permission`, `compaction`, `watcher`, `default_agent`, `formatter`, `disabled_providers`, `enabled_providers` | ⏳ Merged but not yet implemented | -- `theme` (string, optional) - - Theme ID (not a path). +These OpenCode settings are intentionally ignored (they don't apply to a terminal UI): -- `model` (string, optional) - - Default model ID, e.g. `openai/gpt-5.2`. +- `keybinds`, `share`, `tui`, `server`, `plugin` +- Custom tools (the `tool` / `tools` schema extension) -- `sounds` (object, optional) - - Keys: `error`, `complete`, `permission`, `question` (each optional) - - Each sound event value is an object: - - `enabled` (boolean, optional) - - `file` (string, optional) - - Must be an absolute path. If not absolute, it is treated as disabled. - - If provided but the file is missing, the event stays silent. - - Defaults: - - `complete.enabled = true` - - `error.enabled = true` - - `permission.enabled = false` - - `question.enabled = false` - - Fallback behavior when `enabled = true` and `file` is omitted: - - `complete` and `error` use bundled default sounds - - `permission` and `question` stay silent - - Bundled sounds are materialized at runtime under the app data dir: - - macOS: `~/Library/Application Support/crabcode/sounds` - - Linux: `~/.local/share/crabcode/sounds` +--- -### Accepted (Merged) But Not Implemented Yet +## Variable substitution -These keys are accepted and merged (for forward compatibility), but may have no runtime effect yet: +crabcode supports OpenCode's placeholder syntax: -- `agent` -- `instructions` -- `tools` -- `mcp` -- `provider` -- `command` -- `permission` -- `compaction` -- `watcher` -- `default_agent` -- `formatter` -- `disabled_providers` -- `enabled_providers` +```jsonc +{ + "model": "{env:CRABCODE_DEFAULT_MODEL}", + "instructions": "{file:~/prompts/system.txt}" +} +``` + +- `{env:VAR}` – Environment variable value +- `{file:path}` – File contents. `~` expands to home; relative paths resolve from the config file's directory. -## Merge Rules +--- + +## Troubleshooting -- Object + object: recursively merge -- Array + array: override entire array -- Primitive or type mismatch: higher priority replaces lower -- `null`: unset (removes the key from the merged result) +**Multiple config files detected?** Each layer (global OpenCode, global crabcode, local OpenCode, local crabcode) can only have one config file. If you have both `opencode.json` and `opencode.jsonc`, crabcode errors and asks you to pick one. -## Variable Substitution +**Config changes not applying?** crabcode loads config at startup. Restart the app to pick up changes. -After merging, Crabcode expands placeholders inside string values: +**Want IDE autocomplete?** Add this to get validation in VS Code: -- `{env:VAR_NAME}`: environment variable value (or empty if unset) -- `{file:path}`: file contents (trims trailing newlines) - - `~` expands to home - - relative paths resolve relative to the config file containing the winning value +```jsonc +{ + "$schema": "https://opencode.ai/config.json" +} +``` \ No newline at end of file diff --git a/_docs/gittydocs.jsonc b/_docs/gittydocs.jsonc index ae2926a..4e27fc6 100644 --- a/_docs/gittydocs.jsonc +++ b/_docs/gittydocs.jsonc @@ -1,4 +1,8 @@ { + "$schema": "https://raw.githubusercontent.com/blankeos/gittydocs/main/gittydocs.schema.json", + "theme": { + "preset": "ember", + }, "site": { "name": "crabcode", "repo": { @@ -13,6 +17,7 @@ "label": "Docs", "items": [ { "label": "Overview", "path": "/" }, + { "label": "Quickstart", "path": "/quickstart" }, { "label": "Configuration", "path": "/config" }, ], }, diff --git a/_docs/index.mdx b/_docs/index.mdx index 65b6065..f44e9cc 100644 --- a/_docs/index.mdx +++ b/_docs/index.mdx @@ -1,73 +1,34 @@ --- -title: Docs Index -description: Overview and entry points for crabcode documentation. +title: crabcode +description: A fast, terminal-first coding agent built in Rust. --- -# crabcode docs +# Build faster in your terminal -crabcode is a Rust-first, terminal UI coding agent inspired by OpenCode, built for fast startup, interactive workflows, and configurable model support. - -## Start here - -- Project overview: `README.md` -- Configuration reference: `_docs/config.mdx` - -## What crabcode includes - -- Ratatui TUI with keyboard-first workflows -- Streaming responses and session management -- Agent modes (PLAN for analysis, BUILD for implementation) -- Model discovery and provider integration via models.dev + aisdk.rs -- Bundled complete/error sounds with configurable overrides (plus permission/question) - -## Quick start +crabcode is a Rust-first coding agent that lives in your terminal. No context switching, no heavy IDE—just fast startup, keyboard-driven workflows, and the AI models you already use. ```bash cargo install crabcode -crabcode ``` -Then run `/connect` inside the app to add a provider and choose a model. - -## Configuration - -Crabcode reads up to four JSON/JSONC config files and merges them in priority order. The canonical contract lives in `_docs/config.mdx`. - -Common keys (phase 1): - -- `theme` -- `model` -- `sounds` - -## Data locations - -These are OS-specific paths for local data. - -- Credentials: `~/Library/Application Support/crabcode/auth.json` (macOS), `~/.local/share/crabcode/auth.json` (Linux) -- Preferences DB: `~/Library/Application Support/crabcode/data.db` (macOS), `~/.local/share/crabcode/data.db` (Linux) -- models.dev cache: `~/Library/Caches/crabcode/models_dev_cache.json` (macOS), `~/.cache/crabcode/models_dev_cache.json` (Linux) -- Built-in sounds cache: `~/Library/Application Support/crabcode/sounds` (macOS), `~/.local/share/crabcode/sounds` (Linux) +--- -## Commands +## What is crabcode? -Common in-app commands: +| | OpenCode | crabcode | +| -------------- | ---------------------------- | ---------------------------- | +| **Platforms** | Terminal, Desktop, Web | Terminal only | +| **Built with** | TypeScript/Zig/Tauri | Rust | +| **Focus** | Multi-platform, feature-rich | Terminal-native, lightweight | -- `/sessions` -- `/new` -- `/connect` -- `/models` -- `/exit` +crabcode is a focused, terminal-only coding agent—just the TUI, built in Rust for speed. OpenCode gives you options across multiple platforms; crabcode picks one and does it well. -## Development +**Coming from OpenCode?** Your existing config at `~/.config/opencode/config.json` is automatically picked up. -```bash -git clone https://github.com/blankeos/crabcode.git -cd crabcode -cargo build --release -``` +--- -Run tests: +## Where to next -```bash -cargo test -``` +- [Quickstart](/quickstart) – Get up and running in 5 minutes +- [Configuration](/config) – OpenCode-compatible config with a few additions +- [GitHub](https://github.com/blankeos/crabcode) – Source code and issues diff --git a/_docs/quickstart.mdx b/_docs/quickstart.mdx new file mode 100644 index 0000000..8305fa8 --- /dev/null +++ b/_docs/quickstart.mdx @@ -0,0 +1,99 @@ +--- +title: Quickstart +description: Get up and running with crabcode in 5 minutes. +--- + +# Get from zero to coding + +Five minutes from now you'll be pair programming with AI in your terminal. + +--- + +## Install + +```bash +cargo install crabcode +``` + +**Other options:** + +- **From source:** `git clone https://github.com/blankeos/crabcode.git && cd crabcode && cargo build --release` +- **Homebrew:** Coming soon + +--- + +## First run + +Launch crabcode: + +```bash +crabcode +``` + +Run `/connect` to add your first provider (OpenAI, Anthropic, etc.). That's it—you're ready. + +--- + +## Your first session + +crabcode works best when you type naturally, like you're pair programming: + +- **Ask questions:** "Explain the main function in src/lib.rs" +- **Request changes:** "Add error handling to this module" +- **Plan first:** `/plan` when you're not sure how to approach something +- **Build directly:** `/build` when you know what you want done + +Press `?` anytime to see available commands. + +--- + +## Configure crabcode + +crabcode uses JSONC (JSON with comments). Create a `crabcode.jsonc` in your project: + +```jsonc +{ + "theme": "default", + "model": "openai/gpt-5.2", + "sounds": { + "complete": { "enabled": true }, + "error": { "enabled": true } + } +} +``` + +### How config merging works + +crabcode reads up to four files (highest priority wins): + +1. OpenCode global (`~/.config/opencode/config.json`) +2. crabcode global (`~/.config/crabcode/crabcode.jsonc`) +3. OpenCode local (`.opencode/opencode.json` in your project) +4. crabcode local (`crabcode.jsonc` in your project) + +Set your defaults globally, override per-project when needed. See the [full configuration reference](/config). + +--- + +## Common commands + +Once you're in crabcode: + +| Command | What it does | +|---------|--------------| +| `/sessions` | Browse and resume previous conversations | +| `/new` | Start fresh | +| `/connect` | Add or switch providers | +| `/models` | See available models and their costs | +| `/exit` or `Ctrl+C` | Quit | + +--- + +## Where your data lives + +| What | macOS | Linux | +|------|-------|-------| +| Credentials | `~/Library/Application Support/crabcode/auth.json` | `~/.local/share/crabcode/auth.json` | +| Preferences | `~/Library/Application Support/crabcode/data.db` | `~/.local/share/crabcode/data.db` | +| Model cache | `~/Library/Caches/crabcode/models_dev_cache.json` | `~/.cache/crabcode/models_dev_cache.json` | +| Sounds | `~/Library/Application Support/crabcode/sounds` | `~/.local/share/crabcode/sounds` | \ No newline at end of file From f20543e3122275e63268621c7ba0687ca077d284 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 08:12:47 +0800 Subject: [PATCH 019/226] feat: added npm release scripts based on what I did for iconmate https://github.com/Blankeos/iconmate --- .github/workflows/release.yml | 296 ++++++++++++++++++++++++++++++++++ Cargo.toml | 5 + dist-workspace.toml | 13 ++ justfile | 5 + npm/.gitignore | 18 +++ npm/README.md | 27 ++++ npm/bin.js | 45 ++++++ npm/install.js | 158 ++++++++++++++++++ npm/package.json | 38 +++++ scripts/tag_and_release.sh | 72 +++++++++ 10 files changed, 677 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 dist-workspace.toml create mode 100644 npm/.gitignore create mode 100644 npm/README.md create mode 100755 npm/bin.js create mode 100755 npm/install.js create mode 100644 npm/package.json create mode 100755 scripts/tag_and_release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3c59af5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,296 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<<EOF" >> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<<EOF" >> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive diff --git a/Cargo.toml b/Cargo.toml index 1e5b5dd..20f0bef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,3 +52,8 @@ tokio-test = "0.4" # aisdk = { path = "/Users/carlo/Desktop/Projects/aisdk-rs" } # After pushing aisdk = { git = "https://github.com/Blankeos/aisdk-rs", branch = "apikey-not-required" } + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..92c4095 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,13 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.30.3" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = [] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] diff --git a/justfile b/justfile index c02e43b..bd9d456 100644 --- a/justfile +++ b/justfile @@ -15,3 +15,8 @@ devdocs: log: tail -f app.log + +# Release: bump versions, create release commit, and create a git tag. +# Usage: just tag [patch|minor|major] +tag bump="": + sh scripts/tag_and_release.sh {{bump}} diff --git a/npm/.gitignore b/npm/.gitignore new file mode 100644 index 0000000..8439081 --- /dev/null +++ b/npm/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Downloaded binaries +bin/ +*.tar.xz +*.zip + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..d0a0e8d --- /dev/null +++ b/npm/README.md @@ -0,0 +1,27 @@ +# crabcode + +Cross-platform installer package for the `crabcode` CLI. + +## Install + +```bash +npm install -g crabcode + +# or +pnpm add -g crabcode +# or +bun add -g crabcode +``` + +## Usage + +```bash +crabcode +``` + +The package downloads the matching release binary from GitHub after install and +executes it on demand, so you do not need Cargo installed on consumer machines. + +Repository: + +https://github.com/Blankeos/crabcode diff --git a/npm/bin.js b/npm/bin.js new file mode 100755 index 0000000..05f0a0d --- /dev/null +++ b/npm/bin.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +const { spawn } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const { install } = require("./install"); + +const binaryName = process.platform === "win32" ? "crabcode.exe" : "crabcode"; +const binaryPath = path.join(__dirname, "bin", binaryName); + +async function ensureBinary() { + if (fs.existsSync(binaryPath)) { + return; + } + + console.error("crabcode binary not found. Attempting download..."); + + try { + await install(); + } catch (error) { + process.exit(1); + } + + if (!fs.existsSync(binaryPath)) { + console.error("❌ crabcode binary still missing after download."); + process.exit(1); + } +} + +async function run() { + await ensureBinary(); + + const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" }); + + child.on("error", (err) => { + console.error("❌ Failed to start crabcode:", err.message); + process.exit(1); + }); + + child.on("exit", (code, signal) => { + process.exit(signal ? 1 : code || 0); + }); +} + +run(); diff --git a/npm/install.js b/npm/install.js new file mode 100755 index 0000000..aa9e3ee --- /dev/null +++ b/npm/install.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const https = require("https"); + +// Version should match your Rust crate version. +const VERSION = require("./package.json").version; +const BINARY_NAME = "crabcode"; + +function getPlatformInfo() { + const platform = process.platform; + const arch = process.arch; + + // Map Node.js platform/arch to Rust target triples. + const platformMap = { + darwin: { + x64: "x86_64-apple-darwin", + arm64: "aarch64-apple-darwin", + }, + linux: { + x64: "x86_64-unknown-linux-gnu", + arm64: "aarch64-unknown-linux-gnu", + }, + win32: { + x64: "x86_64-pc-windows-msvc", + }, + }; + + if (!platformMap[platform]) { + throw new Error(`Unsupported platform: ${platform}`); + } + + if (!platformMap[platform][arch]) { + throw new Error(`Unsupported architecture: ${arch} on ${platform}`); + } + + const target = platformMap[platform][arch]; + const extension = platform === "win32" ? ".zip" : ".tar.xz"; + const binaryName = platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME; + + return { + target, + extension, + binaryName, + filename: `${BINARY_NAME}-${target}${extension}`, + url: `https://github.com/Blankeos/crabcode/releases/download/v${VERSION}/${BINARY_NAME}-${target}${extension}`, + }; +} + +async function downloadFile(url, dest) { + console.log(`Downloading ${url}...`); + + const file = fs.createWriteStream(dest); + const response = await new Promise((resolve, reject) => { + https + .get(url, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + https.get(res.headers.location, resolve).on("error", reject); + } else if (res.statusCode === 200) { + resolve(res); + } else { + reject(new Error(`Failed to download: ${res.statusCode} ${res.statusMessage}`)); + } + }) + .on("error", reject); + }); + + response.pipe(file); + return new Promise((resolve, reject) => { + file.on("finish", () => { + file.close(); + resolve(); + }); + file.on("error", (err) => { + fs.unlink(dest, () => {}); + reject(err); + }); + }); +} + +function extractArchive(archivePath, extractDir, platformInfo) { + console.log("Extracting binary..."); + + const cmd = + platformInfo.extension === ".zip" + ? `unzip -o "${archivePath}" -d "${extractDir}" 2>/dev/null || powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"` + : `tar -xf "${archivePath}" -C "${extractDir}"`; + + execSync(cmd, { stdio: "inherit" }); +} + +function logInstallFailure(error) { + const message = error instanceof Error ? error.message : String(error); + console.error("Installation failed:", message); + console.error("\nYou can install crabcode directly using:"); + console.error( + 'curl --proto "=https" --tlsv1.2 -LsSf https://github.com/Blankeos/crabcode/releases/latest/download/crabcode-installer.sh | sh', + ); +} + +async function install({ exitOnComplete = false } = {}) { + try { + const platformInfo = getPlatformInfo(); + const binDir = path.join(__dirname, "bin"); + const archivePath = path.join(__dirname, platformInfo.filename); + const binaryPath = path.join(binDir, platformInfo.binaryName); + + if (!fs.existsSync(binDir)) fs.mkdirSync(binDir, { recursive: true }); + + await downloadFile(platformInfo.url, archivePath); + extractArchive(archivePath, __dirname, platformInfo); + + const extractedBinaryPath = path.join(__dirname, platformInfo.binaryName); + if (fs.existsSync(extractedBinaryPath)) { + fs.renameSync(extractedBinaryPath, binaryPath); + } else { + const subdirPath = path.join(__dirname, `${BINARY_NAME}-${platformInfo.target}`, platformInfo.binaryName); + if (fs.existsSync(subdirPath)) { + fs.renameSync(subdirPath, binaryPath); + fs.rmSync(path.dirname(subdirPath), { recursive: true, force: true }); + } else { + throw new Error("Binary not found after extraction"); + } + } + + if (process.platform !== "win32") { + fs.chmodSync(binaryPath, 0o755); + } + + fs.unlinkSync(archivePath); + console.log(`crabcode v${VERSION} installed successfully!`); + + if (exitOnComplete) { + process.exit(0); + return binaryPath; + } + + return binaryPath; + } catch (error) { + logInstallFailure(error); + + if (exitOnComplete) { + process.exit(1); + return; + } + + throw error; + } +} + +// Only run install if this script is executed directly. +if (require.main === module) { + install({ exitOnComplete: true }); +} + +module.exports = { getPlatformInfo, install }; diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..83dad25 --- /dev/null +++ b/npm/package.json @@ -0,0 +1,38 @@ +{ + "name": "crabcode", + "version": "0.0.1", + "description": "(WIP) Rust AI CLI coding agent with a beautiful terminal UI", + "main": "bin.js", + "bin": { + "crabcode": "./bin.js" + }, + "scripts": { + "postinstall": "node install.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/Blankeos/crabcode.git" + }, + "keywords": [ + "ai", + "cli", + "tui", + "terminal", + "rust" + ], + "author": "Blankeos", + "license": "MIT", + "files": [ + "install.js", + "bin.js", + "README.md" + ], + "engines": { + "node": ">=12" + }, + "os": [ + "darwin", + "linux", + "win32" + ] +} diff --git a/scripts/tag_and_release.sh b/scripts/tag_and_release.sh new file mode 100755 index 0000000..d1a22af --- /dev/null +++ b/scripts/tag_and_release.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +# This script bumps crate/npm versions, commits the release and creates a git tag. +# - Keep the tree clean before running. +# - Optionally pass patch|minor|major as first arg or answer the prompt. + +set -euo pipefail + +if [ -n "$(git status --porcelain)" ]; then + echo "Please commit all changes before running a release bump." >&2 + exit 1 +fi + +NAME=$(sed -n 's/^name *= *"\([^"]*\)".*/\1/p' Cargo.toml) +CURRENT=$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' Cargo.toml) + +if [ $# -gt 1 ]; then + echo "Usage: ./scripts/tag_and_release.sh [patch|minor|major]" >&2 + exit 1 +fi + +BUMP="${1-}" + +if [ -z "$BUMP" ]; then + echo "What kind of release bump for $NAME? (current version: $CURRENT) [patch, minor, major]" + read -r BUMP +fi + +IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + +if [ -z "$MAJOR" ] || [ -z "$MINOR" ] || [ -z "$PATCH" ]; then + echo "Failed to parse current version: $CURRENT" >&2 + exit 1 +fi + +case "$BUMP" in + patch) NEW="$MAJOR.$MINOR.$((PATCH + 1))" ;; + minor) NEW="$MAJOR.$((MINOR + 1)).0" ;; + major) NEW="$((MAJOR + 1)).0.0" ;; + *) echo "Please specify patch, minor, or major" >&2; exit 1 ;; +esac + +echo "Will bump ${CURRENT} -> ${NEW} and create git tag v${NEW}" +read -p "Proceed? [Y/n] " -r CONFIRM +CONFIRM=${CONFIRM:-Y} +if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 +fi + +echo "Updating Cargo.toml version to ${NEW}" +sed -i.bak "s/^version *= *\"[^\"]*\"/version = \"${NEW}\"/" Cargo.toml +rm -f Cargo.toml.bak + +if [ -f "npm/package.json" ]; then + echo "Updating npm/package.json version to ${NEW}" + sed -i.bak "s/\"version\":[[:space:]]*\"[^\"]*\"/\"version\": \"${NEW}\"/" npm/package.json + rm -f npm/package.json.bak + git add npm/package.json +fi + +git add Cargo.toml +git commit -m "release: ${NAME} v${NEW}" + +echo "Creating git tag v${NEW}" +git tag "v${NEW}" + +echo "Pushing commit and tag" +git push +git push --tags + +echo "Done: ${NAME} v${NEW}" From 46205ff75f4d4fc32f8124b981d65c04e0254470 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 08:22:37 +0800 Subject: [PATCH 020/226] docs: added banner image. --- AGENTS.md | 5 ++--- README.md | 2 +- _docs/[images]/crabcode_banner.jpg | Bin 0 -> 131580 bytes _docs/index.mdx | 2 ++ 4 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 _docs/[images]/crabcode_banner.jpg diff --git a/AGENTS.md b/AGENTS.md index 1067368..173dbb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,6 @@ The cache stores provider and model information from models.dev and expires afte ### Writing official documentation -Traverse this llms.txt as ofsten as you can if you write docs: https://gittydocs.carlo.tl/llms.txt - -- Write the dcocs in `config.mdx` +- Important: always refer to this when asked to write inside \_docs. +- Traverse this llms.txt as often as you can if you write docs: https://gittydocs.carlo.tl/llms.txt - When writing titles + first text in the body, never use the same 'title' (in mdx data) and '# <title>` (in the body). diff --git a/README.md b/README.md index 6b10471..15363ed 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A purely Rust-based AI CLI coding agent with a beautiful terminal UI for interac > > ~ Carlo (Author) -![screenshot](_docs/screenshot.png) +![Crabcode banner](_docs/crabcode_banner.jpg) ## Features diff --git a/_docs/[images]/crabcode_banner.jpg b/_docs/[images]/crabcode_banner.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ac17b8e71beba98ed3aa649da0d062bb9d2d4e57 GIT binary patch literal 131580 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 KYx8bv<^S0u>|_)G literal 0 HcmV?d00001 diff --git a/_docs/index.mdx b/_docs/index.mdx index f44e9cc..28d5876 100644 --- a/_docs/index.mdx +++ b/_docs/index.mdx @@ -5,6 +5,8 @@ description: A fast, terminal-first coding agent built in Rust. # Build faster in your terminal +![Crabcode banner](/static/crabcode_banner.jpg) + crabcode is a Rust-first coding agent that lives in your terminal. No context switching, no heavy IDE—just fast startup, keyboard-driven workflows, and the AI models you already use. ```bash From 740dfb091eb3129c9a8a10181fa578d7b8e4c1c9 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 08:30:55 +0800 Subject: [PATCH 021/226] docs: more install options. --- README.md | 7 ++++--- _docs/index.mdx | 11 ++++++++--- _docs/quickstart.mdx | 40 ++++++++++++++++++++-------------------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 15363ed..039d0b7 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,11 @@ A purely Rust-based AI CLI coding agent with a beautiful terminal UI for interac ## Quick Start -Install via cargo: - ```bash -cargo install crabcode +cargo install crabcode # via cargo +npm install -g crabcode # via npm +bun install -g crabcode # via bun +# Brew, coming sooon ``` ## Quick Start diff --git a/_docs/index.mdx b/_docs/index.mdx index 28d5876..2df320f 100644 --- a/_docs/index.mdx +++ b/_docs/index.mdx @@ -7,10 +7,15 @@ description: A fast, terminal-first coding agent built in Rust. ![Crabcode banner](/static/crabcode_banner.jpg) -crabcode is a Rust-first coding agent that lives in your terminal. No context switching, no heavy IDE—just fast startup, keyboard-driven workflows, and the AI models you already use. +**crabcode** is a Rust-first coding agent that lives in your terminal. + +This project was literally just "what if I built opencode, in rust?". Same UX, no context switching, no heavy IDE—just fast startup, keyboard-driven workflows, and the AI models you already use. ```bash -cargo install crabcode +cargo install crabcode # via cargo +npm install -g crabcode # via npm +bun install -g crabcode # via bun +# Brew, coming soon ``` --- @@ -23,7 +28,7 @@ cargo install crabcode | **Built with** | TypeScript/Zig/Tauri | Rust | | **Focus** | Multi-platform, feature-rich | Terminal-native, lightweight | -crabcode is a focused, terminal-only coding agent—just the TUI, built in Rust for speed. OpenCode gives you options across multiple platforms; crabcode picks one and does it well. +crabcode is a focused, terminal-only coding agent—just the TUI, built in Rust for speed and memory-efficiency. OpenCode gives you options across multiple platforms; crabcode picks one and does it well. **Coming from OpenCode?** Your existing config at `~/.config/opencode/config.json` is automatically picked up. diff --git a/_docs/quickstart.mdx b/_docs/quickstart.mdx index 8305fa8..e6c6898 100644 --- a/_docs/quickstart.mdx +++ b/_docs/quickstart.mdx @@ -12,13 +12,13 @@ Five minutes from now you'll be pair programming with AI in your terminal. ## Install ```bash -cargo install crabcode +cargo install crabcode # via cargo +npm install -g crabcode # via npm +bun install -g crabcode # via bun +# Brew, coming sooon ``` -**Other options:** - -- **From source:** `git clone https://github.com/blankeos/crabcode.git && cd crabcode && cargo build --release` -- **Homebrew:** Coming soon +**Homebrew:** Coming soon --- @@ -57,8 +57,8 @@ crabcode uses JSONC (JSON with comments). Create a `crabcode.jsonc` in your proj "model": "openai/gpt-5.2", "sounds": { "complete": { "enabled": true }, - "error": { "enabled": true } - } + "error": { "enabled": true }, + }, } ``` @@ -79,21 +79,21 @@ Set your defaults globally, override per-project when needed. See the [full conf Once you're in crabcode: -| Command | What it does | -|---------|--------------| -| `/sessions` | Browse and resume previous conversations | -| `/new` | Start fresh | -| `/connect` | Add or switch providers | -| `/models` | See available models and their costs | -| `/exit` or `Ctrl+C` | Quit | +| Command | What it does | +| ------------------- | ---------------------------------------- | +| `/sessions` | Browse and resume previous conversations | +| `/new` | Start fresh | +| `/connect` | Add or switch providers | +| `/models` | See available models and their costs | +| `/exit` or `Ctrl+C` | Quit | --- ## Where your data lives -| What | macOS | Linux | -|------|-------|-------| -| Credentials | `~/Library/Application Support/crabcode/auth.json` | `~/.local/share/crabcode/auth.json` | -| Preferences | `~/Library/Application Support/crabcode/data.db` | `~/.local/share/crabcode/data.db` | -| Model cache | `~/Library/Caches/crabcode/models_dev_cache.json` | `~/.cache/crabcode/models_dev_cache.json` | -| Sounds | `~/Library/Application Support/crabcode/sounds` | `~/.local/share/crabcode/sounds` | \ No newline at end of file +| What | macOS | Linux | +| ------------------------ | -------------------------------------------------- | ----------------------------------------- | +| Credentials | `~/Library/Application Support/crabcode/auth.json` | `~/.local/share/crabcode/auth.json` | +| Preferences and Sessions | `~/Library/Application Support/crabcode/data.db` | `~/.local/share/crabcode/data.db` | +| Model cache | `~/Library/Caches/crabcode/models_dev_cache.json` | `~/.cache/crabcode/models_dev_cache.json` | +| Sounds | `~/Library/Application Support/crabcode/sounds` | `~/.local/share/crabcode/sounds` | From 12a04d3a0d396b94e3ba27a9fc09a67d39aa6b37 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 09:02:45 +0800 Subject: [PATCH 022/226] feat: added 'notify' feature along w/ the sounds. --- _docs/config.mdx | 30 ++- _plans/CONFIGURATION_FEATURE.md | 42 +++- crabcode.jsonc | 16 +- ...abcode.schema.json => crabcode.schema.json | 21 +- defaults/crabcode.jsonc | 7 +- src/agent/manager.rs | 41 ++-- src/app.rs | 185 +++++++++++------ src/command/handlers.rs | 137 ++++++------- src/config/configuration.rs | 27 ++- src/main.rs | 1 + src/notify.rs | 187 ++++++++++++++++++ src/prompt/mod.rs | 34 ++-- src/sound.rs | 29 +++ src/tools/aisdk_bridge.rs | 45 +++-- src/tools/bash.rs | 37 ++-- src/tools/edit.rs | 23 ++- src/tools/fs/glob.rs | 40 ++-- src/tools/fs/list.rs | 40 ++-- src/tools/fs/read.rs | 35 ++-- src/tools/fs/write.rs | 28 ++- src/tools/mod.rs | 2 +- src/tools/registry.rs | 5 +- src/views/mod.rs | 4 +- 23 files changed, 736 insertions(+), 280 deletions(-) rename schema/crabcode.schema.json => crabcode.schema.json (84%) create mode 100644 src/notify.rs diff --git a/_docs/config.mdx b/_docs/config.mdx index 72977c2..913f58a 100644 --- a/_docs/config.mdx +++ b/_docs/config.mdx @@ -72,6 +72,11 @@ Audio feedback for events (terminal-only, so crabcode-only): } ``` +Each sound event key (`complete`, `error`, `permission`, `question`) accepts either: + +- object form: `{ "enabled": true, "notify": false, "file": "/absolute/path.wav" }` +- boolean shorthand: `true`/`false` (equivalent to toggling `enabled`) + **Custom sounds:** Provide an absolute path to a sound file: ```jsonc @@ -87,6 +92,27 @@ Audio feedback for events (terminal-only, so crabcode-only): If `file` is omitted, `complete` and `error` use bundled defaults. `permission` and `question` stay silent by default. +### `sounds.<event>.notify` + +Trigger native desktop notifications per sound event. + +Default: `false` for each event. + +For completion notifications, crabcode includes runtime stats when available (for example `1.0s | 30t/s`). + +```jsonc +{ + "sounds": { + "complete": { "enabled": true, "notify": true }, + "error": { "enabled": true, "notify": true } + } +} +``` + +`sounds.notify` (top-level) is no longer supported. + +On macOS, crabcode uses `osascript` (Notification Center). + --- ## What's supported from OpenCode @@ -133,6 +159,6 @@ crabcode supports OpenCode's placeholder syntax: ```jsonc { - "$schema": "https://opencode.ai/config.json" + "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json" } -``` \ No newline at end of file +``` diff --git a/_plans/CONFIGURATION_FEATURE.md b/_plans/CONFIGURATION_FEATURE.md index eb14c21..b654e75 100644 --- a/_plans/CONFIGURATION_FEATURE.md +++ b/_plans/CONFIGURATION_FEATURE.md @@ -1,6 +1,6 @@ # Configuration Feature Plan -Goal: Add a layered configuration system for Crabcode that is (1) compatible with OpenCode configs, (2) supports both global + per-project config, and (3) can be extended incrementally. For the first implementation pass, only `theme`, `sounds`, and `model` are functional; other supported keys are parsed/merged but treated as unimplemented. +Goal: Add a layered configuration system for Crabcode that is (1) compatible with OpenCode configs, (2) supports both global + per-project config, and (3) can be extended incrementally. For the first implementation pass, only `theme`, `sounds`, and `model` are functional. `sounds.<event>.notify` extends `sounds` with native desktop notifications (default off per event); other supported keys are parsed/merged but treated as unimplemented. ## Non-Goals (Initial Scope) @@ -173,6 +173,7 @@ We should still allow these keys to exist (no parse error); we just exclude them Crabcode config supports everything in the compatibility set above, plus: - `sounds` (Crabcode-only) +- `sounds.<event>.notify` (Crabcode-only desktop notifications, default off per event) - `theme` (Crabcode controls the theme selection, but the theme system is compatible with OpenCode) If these appear in OpenCode config files, they are ignored. @@ -193,19 +194,43 @@ Minimal schema we actively apply in the first iteration: // Crabcode-only (All are optional to use, but these are the defaults) "sounds": { - "error": { "file": "/absolute/path.wav", "enabled": false }, - "complete": { "file": "/absolute/path.wav", "enabled": true }, - "permission": { "file": "/absolute/path.wav", "enabled": false }, - "question": { "file": "/absolute/path.wav", "enabled": false }, + "error": { "file": "/absolute/path.wav", "enabled": false, "notify": false }, + "complete": { "file": "/absolute/path.wav", "enabled": true, "notify": true }, + "permission": { "file": "/absolute/path.wav", "enabled": false, "notify": false }, + "question": { "file": "/absolute/path.wav", "enabled": false, "notify": false }, }, } ``` Sounds requirements: +- Sound event keys (`error`, `complete`, `permission`, `question`) should accept either: + - Object form: `{ "enabled": bool, "file": "/absolute/path.wav" }` + - Boolean shorthand: `true`/`false` (e.g. `"complete": true`) - `file` must be an absolute path (no `~`, no relative). If invalid, record a warning and treat sound as disabled. - `enabled` default behavior: - If not specified: default to `false` except `complete` default to `true` (per requirement). +- `notify` is an optional boolean under each event object with default `false`. + +### Desktop Notification Delivery (`sounds.<event>.notify`) + +When `sounds.<event>.notify` is `true`, Crabcode should emit a native desktop notification for that event. + +Cross-platform backend plan: + +- macOS: use Notification Center via `osascript` (`display notification ...`). +- Linux: use `notify-send` (libnotify); if unavailable, log a warning and continue. +- Windows: use a PowerShell/WinRT toast invocation; if unavailable, log a warning and continue. + +Behavioral rules: + +- Fire exactly once per completed assistant response (not per chunk). +- Notification delivery must be best-effort and non-blocking (spawn background process/task). +- For completion notifications, include concise runtime stats when available (for example `1.0s | 30t/s`). +- `sounds.<event>.enabled` and `sounds.<event>.notify` are independent toggles: + - `complete: { enabled: false, notify: true }` => silent audio + desktop notification. + - `complete: { enabled: true, notify: false }` => sound only. +- If notification permission is denied by the OS, do not fail app startup or streaming. ## .opencode Directory Structure Compatibility @@ -276,6 +301,7 @@ Proposed diagnostic design: - Parse errors per file (non-fatal; skip file). - `{file:...}` read failures. - Invalid `sounds.*.file` (non-absolute). + - Notification backend unavailable when any `sounds.<event>.notify=true` (e.g., missing `notify-send`). - Unknown keys (only if they look like they were intended, optional). - Collect “unimplemented keys” present in the merged config: @@ -293,6 +319,8 @@ Current state observations: - `src/config.rs` currently manages `api_keys.json` and is not a general config loader. - Theme is currently loaded from `src/theme.json` with a fallback to `src/themes/ayu.json` (`src/app.rs`). - Model selection is persisted in SQLite (`src/persistence/prefs.rs`) and in message history; config should only set the default. +- `src/sound.rs` already contains event-based sound resolution and OS-specific command dispatch (good pattern to mirror for desktop notifications). +- `src/app.rs` already emits `SoundEvent::Complete` at streaming end (and `SoundEvent::Error` on failures); these are integration points for `sounds.<event>.notify`. Planned integration: @@ -301,6 +329,7 @@ Planned integration: - `MergedConfig` (typed subset we act upon: theme/model/sounds) - `RawMergedValue` (full merged JSON value, for future keys) - `Diagnostics` (warnings + unimplemented) +- Add a desktop notification module (e.g. `src/notify.rs`) with OS-specific backends and a no-op fallback. ## Phase 1 Implementation Checklist @@ -318,6 +347,9 @@ Phase 1 should implement behavior for `theme`, `sounds`, `model` only. - Do not overwrite persisted “active model” selection. - Apply `sounds`: - Introduce an audio playback layer and trigger events from existing UI flows. + - Add per-event `notify` parsing (`sounds.<event>.notify`, default `false`) and boolean shorthand support for sound event toggles. + - Add native desktop notifications for completion events on macOS/Linux/Windows. + - Keep notification dispatch best-effort/non-blocking with warning diagnostics on backend failures. - If we can’t add playback immediately, still wire config parsing + diagnostics so the shape is stable. ## Phase 2+ (Future) diff --git a/crabcode.jsonc b/crabcode.jsonc index c7a64f6..c868707 100644 --- a/crabcode.jsonc +++ b/crabcode.jsonc @@ -1,10 +1,12 @@ { + "$schema": "crabcode.schema.json", // Crabcode theme id (see src/generated_themes/carbonfox.json) - "theme": "dracula", - // "sounds": { - // "complete": { - // "enabled": true, - // "file": "/Users/carlo/Desktop/Projects/crabcode/sounds/complete.wav", - // }, - // }, + "theme": "vercel", + "sounds": { + "complete": { + "enabled": true, + "notify": true, + "file": "/Users/carlo/Desktop/Projects/crabcode/sounds/complete.wav", + }, + }, } diff --git a/schema/crabcode.schema.json b/crabcode.schema.json similarity index 84% rename from schema/crabcode.schema.json rename to crabcode.schema.json index f80a00f..da0ed31 100644 --- a/schema/crabcode.schema.json +++ b/crabcode.schema.json @@ -14,6 +14,13 @@ "string", "null" ] + }, + "notify": { + "default": false, + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -26,6 +33,9 @@ { "$ref": "#/$defs/SoundEffectConfigFile" }, + { + "type": "boolean" + }, { "type": "null" } @@ -36,6 +46,9 @@ { "$ref": "#/$defs/SoundEffectConfigFile" }, + { + "type": "boolean" + }, { "type": "null" } @@ -46,6 +59,9 @@ { "$ref": "#/$defs/SoundEffectConfigFile" }, + { + "type": "boolean" + }, { "type": "null" } @@ -56,6 +72,9 @@ { "$ref": "#/$defs/SoundEffectConfigFile" }, + { + "type": "boolean" + }, { "type": "null" } @@ -65,7 +84,7 @@ "type": "object" } }, - "$id": "https://raw.githubusercontent.com/blankeos/crabcode/main/schema/crabcode.schema.json", + "$id": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { diff --git a/defaults/crabcode.jsonc b/defaults/crabcode.jsonc index 7477d8a..7f637d5 100644 --- a/defaults/crabcode.jsonc +++ b/defaults/crabcode.jsonc @@ -1,6 +1,6 @@ { // Optional: enables editor completion/validation. - "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/schema/crabcode.schema.json", + "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json", // Theme ID (not a path). Crabcode resolves theme IDs from built-ins and theme folders. // Example built-ins live under `src/generated_themes/*.json`. @@ -11,6 +11,7 @@ // Sounds are optional. // - `enabled` toggles each event. + // - `notify` toggles desktop notifications per event. // - `file` must be an absolute path (no `~`, no relative). // - If `enabled` is true and `file` is omitted: // - `complete` and `error` use bundled defaults @@ -18,20 +19,24 @@ "sounds": { "error": { "enabled": true, + "notify": false, // Optional override: // "file": "/absolute/path/to/error.mp3" }, "complete": { "enabled": true, + "notify": false, // Optional override: // "file": "/absolute/path/to/complete.mp3" }, "permission": { "enabled": false, + "notify": false, // "file": "/absolute/path/to/permission.wav" }, "question": { "enabled": false, + "notify": false, // "file": "/absolute/path/to/question.wav" } }, diff --git a/src/agent/manager.rs b/src/agent/manager.rs index 65dfb53..392f3a3 100644 --- a/src/agent/manager.rs +++ b/src/agent/manager.rs @@ -21,9 +21,20 @@ pub struct AgentManager { #[derive(Debug, Clone)] pub enum AgentEvent { - ToolCallStarted { tool_id: String, call_id: String }, - ToolCallCompleted { tool_id: String, call_id: String, result: ToolResult }, - ToolCallFailed { tool_id: String, call_id: String, error: String }, + ToolCallStarted { + tool_id: String, + call_id: String, + }, + ToolCallCompleted { + tool_id: String, + call_id: String, + result: ToolResult, + }, + ToolCallFailed { + tool_id: String, + call_id: String, + error: String, + }, Message(String), } @@ -35,13 +46,10 @@ impl AgentManager { platform: impl Into<String>, ) -> anyhow::Result<Self> { let tool_registry = initialize_tool_registry().await; - - let composer = SystemPromptComposer::new( - model_id, - working_directory, - is_git_repo, - platform, - ).with_tool_registry(tool_registry.clone()); + + let composer = + SystemPromptComposer::new(model_id, working_directory, is_git_repo, platform) + .with_tool_registry(tool_registry.clone()); let system_prompt = composer.compose().await; @@ -93,8 +101,7 @@ impl AgentManager { tool.execute(params, &ctx).await } - pub fn create_system_message(&self, - ) -> Message { + pub fn create_system_message(&self) -> Message { Message::system(self.agent.system_prompt.clone()) } @@ -113,7 +120,8 @@ impl AgentManager { }); match self - .execute_tool(&call.tool_id, + .execute_tool( + &call.tool_id, call.params.clone(), call.call_id.clone(), abort_rx.clone(), @@ -178,12 +186,7 @@ mod tests { #[tokio::test] async fn test_agent_manager_creation() { - let manager = AgentManager::new( - "gpt-4", - "/tmp", - false, - "darwin", - ).await; + let manager = AgentManager::new("gpt-4", "/tmp", false, "darwin").await; assert!(manager.is_ok()); } diff --git a/src/app.rs b/src/app.rs index 63711d0..1709de9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,10 +23,6 @@ use crate::views::models_dialog::{ handle_models_dialog_key_event, handle_models_dialog_mouse_event, init_models_dialog, render_models_dialog, }; -use crate::views::themes_dialog::{ - handle_themes_dialog_key_event, handle_themes_dialog_mouse_event, init_themes_dialog, - render_themes_dialog, -}; use crate::views::session_rename_dialog::{ handle_session_rename_dialog_key_event, init_session_rename_dialog, render_session_rename_dialog, RenameAction, @@ -39,6 +35,10 @@ use crate::views::suggestions_popup::{ clear_suggestions, get_selected_suggestion, handle_suggestions_popup_key_event, init_suggestions_popup, is_suggestions_visible, render_suggestions_popup, set_suggestions, }; +use crate::views::themes_dialog::{ + handle_themes_dialog_key_event, handle_themes_dialog_mouse_event, init_themes_dialog, + render_themes_dialog, +}; use crate::views::{ ChatState, ConnectDialogState, HomeState, ModelsDialogState, SessionRenameDialogState, SessionsDialogState, SuggestionsPopupState, ThemesDialogState, @@ -199,7 +199,10 @@ impl App { }; if active_model_info.is_none() { - if let (Some(ref dao), Some(model_str)) = (prefs_dao.as_ref(), loaded_config.merged_config.model.clone()) { + if let (Some(ref dao), Some(model_str)) = ( + prefs_dao.as_ref(), + loaded_config.merged_config.model.clone(), + ) { let (provider_id, model_id) = parse_model_ref(&model_str); let _ = dao.set_active_model(provider_id, model_id); } @@ -211,14 +214,15 @@ impl App { None }; - let (active_model, active_provider_name) = if let Some((provider_id, model_id)) = active_model_info { - (model_id.clone(), provider_id.clone()) - } else if let Some(model_str) = loaded_config.merged_config.model.clone() { - let (provider_id, model_id) = parse_model_ref(&model_str); - (model_id, provider_id) - } else { - ("big-pickle".to_string(), "opencode".to_string()) - }; + let (active_model, active_provider_name) = + if let Some((provider_id, model_id)) = active_model_info { + (model_id.clone(), provider_id.clone()) + } else if let Some(model_str) = loaded_config.merged_config.model.clone() { + let (provider_id, model_id) = parse_model_ref(&model_str); + (model_id, provider_id) + } else { + ("big-pickle".to_string(), "opencode".to_string()) + }; let (themes, current_theme_index) = crate::config::discover_themes( &loaded_config.xdg_config_home, @@ -232,8 +236,9 @@ impl App { .or_else(|| themes.first()) .cloned() .unwrap_or_else(|| { - theme::Theme::load_from_file("src/theme.json") - .unwrap_or_else(|_| theme::Theme::load_from_file("src/generated_themes/ayu.json").unwrap()) + theme::Theme::load_from_file("src/theme.json").unwrap_or_else(|_| { + theme::Theme::load_from_file("src/generated_themes/ayu.json").unwrap() + }) }); let colors = theme_for_colors.get_colors(true); @@ -286,9 +291,55 @@ impl App { } fn play_sound_event(&self, event: crate::sound::SoundEvent) { + self.play_sound_event_with_notification_detail(event, None); + } + + fn play_sound_event_with_notification_detail( + &self, + event: crate::sound::SoundEvent, + detail: Option<&str>, + ) { if let Some(path) = self.sounds.path_for_event(event) { crate::sound::play_file(path); } + + if self.sounds.notify_for_event(event) { + crate::notify::notify_event(event, detail); + } + } + + fn completion_notification_stats(&self) -> Option<String> { + let message = self.chat_state.chat.messages.iter().rev().find(|msg| { + msg.role == crate::session::types::MessageRole::Assistant && msg.is_complete + })?; + + if let (Some(t0), Some(t1), Some(tn)) = (message.t0_ms, message.t1_ms, message.tn_ms) { + let output_tokens = message.output_tokens.or(message.token_count).unwrap_or(0); + let total_ms = tn.saturating_sub(t0); + let decode_ms = tn.saturating_sub(t1); + + let total_sec = total_ms as f64 / 1000.0; + let tokens_per_sec = if decode_ms > 0 && output_tokens > 0 { + (output_tokens as f64) / (decode_ms as f64 / 1000.0) + } else { + 0.0 + }; + + return Some(format!("{:.1}s | {:.0}t/s", total_sec, tokens_per_sec)); + } + + if let (Some(token_count), Some(duration_ms)) = (message.token_count, message.duration_ms) { + let duration_sec = duration_ms as f64 / 1000.0; + let tokens_per_sec = if duration_ms > 0 { + (token_count as f64) / (duration_ms as f64 / 1000.0) + } else { + 0.0 + }; + + return Some(format!("{:.1}s | {:.0}t/s", duration_sec, tokens_per_sec)); + } + + None } fn get_random_placeholder() -> String { @@ -395,9 +446,7 @@ impl App { } } OverlayFocus::ModelsDialog => { - if key.code == KeyCode::Char('a') - && key.modifiers == event::KeyModifiers::CONTROL - { + if key.code == KeyCode::Char('a') && key.modifiers == event::KeyModifiers::CONTROL { self.models_dialog_state.dialog.hide(); if let crate::command::parser::InputType::Command(parsed) = crate::command::parser::parse_input("/connect") @@ -467,20 +516,25 @@ impl App { true } OverlayFocus::ThemesDialog => { - let action = - handle_themes_dialog_key_event(&mut self.themes_dialog_state, key); + let action = handle_themes_dialog_key_event(&mut self.themes_dialog_state, key); match action { crate::views::themes_dialog::ThemesDialogAction::PreviewTheme { theme_id } => { - if let Some((idx, _)) = - self.themes.iter().enumerate().find(|(_, t)| t.id == theme_id) + if let Some((idx, _)) = self + .themes + .iter() + .enumerate() + .find(|(_, t)| t.id == theme_id) { self.current_theme_index = idx; } } crate::views::themes_dialog::ThemesDialogAction::SelectTheme { theme_id } => { - if let Some((idx, theme)) = - self.themes.iter().enumerate().find(|(_, t)| t.id == theme_id) + if let Some((idx, theme)) = self + .themes + .iter() + .enumerate() + .find(|(_, t)| t.id == theme_id) { self.current_theme_index = idx; self.themes_dialog_committed = true; @@ -819,8 +873,11 @@ impl App { if before != after { if let Some(theme_id) = after { - if let Some((idx, _)) = - self.themes.iter().enumerate().find(|(_, t)| t.id == theme_id) + if let Some((idx, _)) = self + .themes + .iter() + .enumerate() + .find(|(_, t)| t.id == theme_id) { self.current_theme_index = idx; } @@ -936,8 +993,11 @@ impl App { .get_selected() .map(|it| it.id.clone()) { - if let Some((idx, _)) = - self.themes.iter().enumerate().find(|(_, t)| t.id == theme_id) + if let Some((idx, _)) = self + .themes + .iter() + .enumerate() + .find(|(_, t)| t.id == theme_id) { self.current_theme_index = idx; } @@ -1162,17 +1222,18 @@ impl App { self.quit(); } } - crate::command::registry::CommandResult::Error(msg) => { - self.play_sound_event(crate::sound::SoundEvent::Error); - if msg.starts_with("Unknown command:") { - push_toast(ratatui_toolkit::Toast::new( - msg, - ratatui_toolkit::ToastLevel::Error, + crate::command::registry::CommandResult::Error(msg) => { + self.play_sound_event(crate::sound::SoundEvent::Error); + if msg.starts_with("Unknown command:") { + push_toast(ratatui_toolkit::Toast::new( + msg, + ratatui_toolkit::ToastLevel::Error, Some(std::time::Duration::from_secs(3)), )); } else { let error_msg = format!("Error: {}", msg); - let error_message = crate::session::types::Message::assistant(error_msg.clone()); + let error_message = + crate::session::types::Message::assistant(error_msg.clone()); let _ = self .session_manager .add_message_to_current_session(&error_message); @@ -1592,7 +1653,11 @@ impl App { self.streaming_provider = None; self.cleanup_streaming(); - self.play_sound_event(crate::sound::SoundEvent::Complete); + let completion_stats = self.completion_notification_stats(); + self.play_sound_event_with_notification_detail( + crate::sound::SoundEvent::Complete, + completion_stats.as_deref(), + ); } crate::llm::ChunkMessage::Failed(error) => { self.is_streaming = false; @@ -1647,8 +1712,10 @@ impl App { } for call in tool_calls { - let args_value: serde_json::Value = serde_json::from_str(&call.function.arguments) - .unwrap_or_else(|_| serde_json::Value::String(call.function.arguments.clone())); + let args_value: serde_json::Value = + serde_json::from_str(&call.function.arguments).unwrap_or_else(|_| { + serde_json::Value::String(call.function.arguments.clone()) + }); let content = serde_json::json!({ "id": call.id, @@ -1668,7 +1735,11 @@ impl App { } } crate::llm::ChunkMessage::ToolResult(result) => { - if let Some(idx) = self.tool_call_message_indices.get(&result.tool_call_id).copied() { + if let Some(idx) = self + .tool_call_message_indices + .get(&result.tool_call_id) + .copied() + { if let Some(msg) = self.chat_state.chat.messages.get_mut(idx) { let mut v: serde_json::Value = serde_json::from_str(&msg.content) .unwrap_or_else(|_| serde_json::json!({})); @@ -1676,13 +1747,15 @@ impl App { v["name"] = serde_json::Value::String(result.name.clone()); // Merge structured payloads from the AISDK bridge if present. - if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&result.content) { + if let Ok(payload) = + serde_json::from_str::<serde_json::Value>(&result.content) + { if payload.is_object() { if v.get("status").is_none() { - v["status"] = payload - .get("status") - .cloned() - .unwrap_or_else(|| serde_json::Value::String("ok".to_string())); + v["status"] = + payload.get("status").cloned().unwrap_or_else(|| { + serde_json::Value::String("ok".to_string()) + }); } else { v["status"] = payload .get("status") @@ -1703,7 +1776,8 @@ impl App { } } else { v["status"] = serde_json::Value::String("ok".to_string()); - v["output_preview"] = serde_json::Value::String(result.content.clone()); + v["output_preview"] = + serde_json::Value::String(result.content.clone()); } } else { let status = if result.content.trim_start().starts_with("Error:") { @@ -1712,7 +1786,8 @@ impl App { "ok" }; v["status"] = serde_json::Value::String(status.to_string()); - v["output_preview"] = serde_json::Value::String(result.content.clone()); + v["output_preview"] = + serde_json::Value::String(result.content.clone()); } msg.content = v.to_string(); @@ -1776,15 +1851,15 @@ impl App { let model = self.model.clone(); let cwd = self.cwd.clone(); let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); - + // Build messages with system prompt let mut messages = self.chat_state.chat.messages.clone(); - + // Check if we already have a system message - let has_system = messages.iter().any(|m| { - m.role == crate::session::types::MessageRole::System - }); - + let has_system = messages + .iter() + .any(|m| m.role == crate::session::types::MessageRole::System); + if !has_system { // Create system prompt with tools let composer = crate::prompt::SystemPromptComposer::new( @@ -1793,11 +1868,9 @@ impl App { is_git_repo, std::env::consts::OS, ); - + let system_prompt = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - composer.compose().await - }) + tokio::runtime::Handle::current().block_on(async { composer.compose().await }) }); let system_msg = crate::session::types::Message::system(system_prompt); messages.insert(0, system_msg); diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 17319a4..7eb5a1e 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -89,79 +89,80 @@ pub fn handle_connect<'a>( Err(e) => return CommandResult::Error(format!("Failed to load auth config: {}", e)), }; - let connected_providers = match auth_dao.load() { - Ok(providers) => providers, - Err(e) => return CommandResult::Error(format!("Failed to load providers: {}", e)), - }; - - fn fallback_providers() -> std::collections::HashMap<String, crate::model::discovery::Provider> { - use crate::model::discovery::Provider; - use std::collections::HashMap; - - let mut out: HashMap<String, Provider> = HashMap::new(); - for (id, name) in [ - ("opencode", "OpenCode"), - ("anthropic", "Anthropic"), - ("openai", "OpenAI"), - ("google", "Google"), - ] { - out.insert( - id.to_string(), - Provider { - id: id.to_string(), - name: name.to_string(), - api: String::new(), - doc: String::new(), - env: Vec::new(), - npm: String::new(), - models: HashMap::new(), - }, - ); - } - out + let connected_providers = match auth_dao.load() { + Ok(providers) => providers, + Err(e) => return CommandResult::Error(format!("Failed to load providers: {}", e)), + }; + + fn fallback_providers( + ) -> std::collections::HashMap<String, crate::model::discovery::Provider> { + use crate::model::discovery::Provider; + use std::collections::HashMap; + + let mut out: HashMap<String, Provider> = HashMap::new(); + for (id, name) in [ + ("opencode", "OpenCode"), + ("anthropic", "Anthropic"), + ("openai", "OpenAI"), + ("google", "Google"), + ] { + out.insert( + id.to_string(), + Provider { + id: id.to_string(), + name: name.to_string(), + api: String::new(), + doc: String::new(), + env: Vec::new(), + npm: String::new(), + models: HashMap::new(), + }, + ); } + out + } - let providers_map = match crate::model::discovery::Discovery::new() { - Ok(discovery) => match discovery.fetch_providers().await { - Ok(p) => p, - Err(_) => fallback_providers(), - }, + let providers_map = match crate::model::discovery::Discovery::new() { + Ok(discovery) => match discovery.fetch_providers().await { + Ok(p) => p, Err(_) => fallback_providers(), - }; - - const POPULAR_PROVIDERS: &[&str] = &[ - "opencode", - "anthropic", - "openai", - "google", - "zai-coding-plan", - ]; - - let mut items: Vec<crate::command::registry::DialogItem> = providers_map - .into_iter() - .map(|(id, provider)| { - let group = if POPULAR_PROVIDERS.contains(&id.as_str()) { - "Popular" + }, + Err(_) => fallback_providers(), + }; + + const POPULAR_PROVIDERS: &[&str] = &[ + "opencode", + "anthropic", + "openai", + "google", + "zai-coding-plan", + ]; + + let mut items: Vec<crate::command::registry::DialogItem> = providers_map + .into_iter() + .map(|(id, provider)| { + let group = if POPULAR_PROVIDERS.contains(&id.as_str()) { + "Popular" + } else { + "Other" + }; + let is_connected = connected_providers.contains_key(&id); + crate::command::registry::DialogItem { + id: id.clone(), + name: provider.name.clone(), + group: group.to_string(), + description: id.clone(), + tip: if is_connected { + Some("🟢 Connected".to_string()) } else { - "Other" - }; - let is_connected = connected_providers.contains_key(&id); - crate::command::registry::DialogItem { - id: id.clone(), - name: provider.name.clone(), - group: group.to_string(), - description: id.clone(), - tip: if is_connected { - Some("🟢 Connected".to_string()) - } else { - None - }, - provider_id: id.clone(), - } - }) - .collect(); + None + }, + provider_id: id.clone(), + } + }) + .collect(); - items.sort_by(|a, b| a.name.cmp(&b.name)); + items.sort_by(|a, b| a.name.cmp(&b.name)); CommandResult::ShowDialog { title: "Connect a provider".to_string(), diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 80df3ca..7e3ce2b 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -122,6 +122,7 @@ pub struct ConfigInventory { pub struct SoundEffectConfig { pub file: Option<PathBuf>, pub enabled: bool, + pub notify: bool, } #[derive(Debug, Clone)] @@ -138,18 +139,22 @@ impl Default for SoundsConfig { error: SoundEffectConfig { file: None, enabled: true, + notify: false, }, complete: SoundEffectConfig { file: None, enabled: true, + notify: false, }, permission: SoundEffectConfig { file: None, enabled: false, + notify: false, }, question: SoundEffectConfig { file: None, enabled: false, + notify: false, }, } } @@ -763,6 +768,13 @@ fn parse_sounds(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> S return sounds; }; + if map.contains_key("notify") { + diagnostics.warnings.push( + "sounds.notify is no longer supported; use sounds.<event>.notify (for example sounds.complete.notify)" + .to_string(), + ); + } + apply_sound_event( &mut sounds.error, map.get("error"), @@ -797,7 +809,16 @@ fn apply_sound_event( key: &str, diagnostics: &mut ConfigDiagnostics, ) { - let Some(Value::Object(map)) = value else { + let Some(value) = value else { + return; + }; + + if let Value::Bool(enabled) = value { + target.enabled = *enabled; + return; + } + + let Value::Object(map) = value else { return; }; @@ -805,6 +826,10 @@ fn apply_sound_event( target.enabled = *enabled; } + if let Some(Value::Bool(notify)) = map.get("notify") { + target.notify = *notify; + } + if let Some(Value::String(file)) = map.get("file") { let p = PathBuf::from(file); if p.is_absolute() { diff --git a/src/main.rs b/src/main.rs index fdda2db..f4a0c76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod config; mod llm; mod logging; mod model; +mod notify; mod persistence; mod prompt; mod session; diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 0000000..1408f0a --- /dev/null +++ b/src/notify.rs @@ -0,0 +1,187 @@ +use std::process::{Command, Stdio}; + +pub fn is_supported() -> bool { + #[cfg(target_os = "macos")] + { + return command_available("osascript"); + } + + #[cfg(target_os = "linux")] + { + return command_available("notify-send"); + } + + #[cfg(target_os = "windows")] + { + return command_available("pwsh") || command_available("powershell"); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + false + } +} + +pub fn notify_event(event: crate::sound::SoundEvent, detail: Option<&str>) { + let (title, subtitle, body) = notification_content(event, detail); + + #[cfg(target_os = "macos")] + { + let osascript_title = with_crab_title(&title); + let script = build_osascript(&osascript_title, &subtitle, &body); + let _ = Command::new("osascript").arg("-e").arg(script).spawn(); + return; + } + + #[cfg(target_os = "linux")] + { + let summary = if subtitle.is_empty() { + title.to_string() + } else { + format!("{} - {}", title, subtitle) + }; + + let _ = Command::new("notify-send") + .arg("-a") + .arg("crabcode") + .arg(summary) + .arg(body) + .spawn(); + return; + } + + #[cfg(target_os = "windows")] + { + let script = build_windows_toast_script(title, subtitle, body); + if command_available("pwsh") { + let _ = Command::new("pwsh") + .arg("-NoProfile") + .arg("-Command") + .arg(&script) + .spawn(); + return; + } + + let _ = Command::new("powershell") + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .spawn(); + return; + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = (title, subtitle, body); + } +} + +fn notification_content( + event: crate::sound::SoundEvent, + detail: Option<&str>, +) -> (String, String, String) { + match event { + crate::sound::SoundEvent::Complete => { + let subtitle = match detail { + Some(stats) if !stats.trim().is_empty() => { + format!("Response complete - {}", stats.trim()) + } + _ => "Response complete".to_string(), + }; + ( + "crabcode".to_string(), + subtitle, + "Your assistant response is ready.".to_string(), + ) + } + crate::sound::SoundEvent::Error => ( + "crabcode".to_string(), + "Action failed".to_string(), + "Something went wrong while processing your request.".to_string(), + ), + crate::sound::SoundEvent::Permission => ( + "crabcode".to_string(), + "Permission required".to_string(), + "A tool is requesting permission.".to_string(), + ), + crate::sound::SoundEvent::Question => ( + "crabcode".to_string(), + "Question".to_string(), + "The assistant needs your input.".to_string(), + ), + } +} + +fn command_available(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + +#[cfg(target_os = "macos")] +fn build_osascript(title: &str, subtitle: &str, body: &str) -> String { + let mut script = format!( + "display notification \"{}\" with title \"{}\"", + escape_applescript(body), + escape_applescript(title), + ); + + if !subtitle.is_empty() { + script.push_str(&format!(" subtitle \"{}\"", escape_applescript(subtitle))); + } + + script +} + +#[cfg(target_os = "macos")] +fn with_crab_title(title: &str) -> String { + if title.trim().is_empty() { + return "🦀 crabcode".to_string(); + } + if title.starts_with('🦀') { + return title.to_string(); + } + format!("🦀 {}", title) +} + +#[cfg(target_os = "macos")] +fn escape_applescript(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(target_os = "windows")] +fn build_windows_toast_script(title: &str, subtitle: &str, body: &str) -> String { + let heading = if subtitle.is_empty() { + title.to_string() + } else { + format!("{} - {}", title, subtitle) + }; + + let heading = escape_xml(&heading); + let body = escape_xml(body); + + format!( + r#"$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] +$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] +$template = "<toast><visual><binding template='ToastText02'><text id='1'>{}</text><text id='2'>{}</text></binding></visual></toast>" +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml($template) +$toast = [Windows.UI.Notifications.ToastNotification]::new($xml) +$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('crabcode') +$notifier.Show($toast)"#, + heading, body + ) +} + +#[cfg(target_os = "windows")] +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 135f3bc..104cb38 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -14,7 +14,7 @@ pub enum ProviderType { impl ProviderType { pub fn from_model_id(model_id: &str) -> Self { let lower = model_id.to_lowercase(); - + if lower.contains("gpt-5") { ProviderType::Codex } else if lower.contains("gpt-") || lower.contains("o1") || lower.contains("o3") { @@ -58,14 +58,13 @@ impl SystemPromptComposer { self } - pub async fn compose(&self, - ) -> String { + pub async fn compose(&self) -> String { let mut parts = Vec::new(); parts.push(self.get_header()); parts.push(self.get_core_prompt()); parts.push(self.get_environment_context()); - + if let Some(ref registry) = self.tool_registry { parts.push(self.get_tools_context(registry).await); } @@ -225,7 +224,7 @@ Your output will be displayed on a command line interface. Your responses should fn get_environment_context(&self) -> String { let git_status = if self.is_git_repo { "yes" } else { "no" }; let date = chrono::Local::now().format("%a %b %d %Y").to_string(); - + format!( r#"<env> Working directory: {} @@ -237,17 +236,15 @@ Your output will be displayed on a command line interface. Your responses should ) } - async fn get_tools_context(&self, - registry: &ToolRegistry, - ) -> String { + async fn get_tools_context(&self, registry: &ToolRegistry) -> String { let schemas = registry.list_schemas().await; - + if schemas.is_empty() { return String::new(); } - let tools_json = serde_json::to_string_pretty(&schemas) - .unwrap_or_else(|_| "[]".to_string()); + let tools_json = + serde_json::to_string_pretty(&schemas).unwrap_or_else(|_| "[]".to_string()); format!( r#"You have access to the following tools (JSON schema): @@ -276,8 +273,17 @@ mod tests { fn test_provider_type_detection() { assert_eq!(ProviderType::from_model_id("gpt-4"), ProviderType::OpenAI); assert_eq!(ProviderType::from_model_id("gpt-5"), ProviderType::Codex); - assert_eq!(ProviderType::from_model_id("claude-3"), ProviderType::Anthropic); - assert_eq!(ProviderType::from_model_id("gemini-pro"), ProviderType::Gemini); - assert_eq!(ProviderType::from_model_id("unknown"), ProviderType::Generic); + assert_eq!( + ProviderType::from_model_id("claude-3"), + ProviderType::Anthropic + ); + assert_eq!( + ProviderType::from_model_id("gemini-pro"), + ProviderType::Gemini + ); + assert_eq!( + ProviderType::from_model_id("unknown"), + ProviderType::Generic + ); } } diff --git a/src/sound.rs b/src/sound.rs index 5b326fd..66db0c8 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -16,6 +16,10 @@ pub struct ResolvedSoundsConfig { pub complete: Option<PathBuf>, pub permission: Option<PathBuf>, pub question: Option<PathBuf>, + pub error_notify: bool, + pub complete_notify: bool, + pub permission_notify: bool, + pub question_notify: bool, } impl ResolvedSoundsConfig { @@ -27,6 +31,15 @@ impl ResolvedSoundsConfig { SoundEvent::Question => self.question.as_deref(), } } + + pub fn notify_for_event(&self, event: SoundEvent) -> bool { + match event { + SoundEvent::Error => self.error_notify, + SoundEvent::Complete => self.complete_notify, + SoundEvent::Permission => self.permission_notify, + SoundEvent::Question => self.question_notify, + } + } } #[derive(Debug, Clone, Copy)] @@ -79,8 +92,24 @@ pub fn resolve_effective_sounds( &mut built_in_cache, &mut warnings, ), + error_notify: config.error.notify, + complete_notify: config.complete.notify, + permission_notify: config.permission.notify, + question_notify: config.question.notify, }; + if (resolved.error_notify + || resolved.complete_notify + || resolved.permission_notify + || resolved.question_notify) + && !crate::notify::is_supported() + { + warnings.push( + "Desktop notifications are enabled for sounds, but no supported notification backend is available on this OS" + .to_string(), + ); + } + (resolved, warnings) } diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 7ea5ae2..4f84a0b 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -9,16 +9,19 @@ use crate::llm::ChunkSender; static TOOL_CALL_SEQ: AtomicUsize = AtomicUsize::new(0); /// Convert our ToolRegistry to AISDK Tools -pub async fn convert_to_aisdk_tools(registry: &ToolRegistry, sender: Option<ChunkSender>) -> Vec<Tool> { +pub async fn convert_to_aisdk_tools( + registry: &ToolRegistry, + sender: Option<ChunkSender>, +) -> Vec<Tool> { let mut aisdk_tools = Vec::new(); let tools = registry.list().await; - + for tool_def in tools { let tool_id = tool_def.id.clone(); let tool_description = tool_def.description.clone(); let registry = registry.clone(); let sender = sender.clone(); - + // Create the execute function let execute = ToolExecute::new(Box::new(move |input: Value| { let tool_id = tool_id.clone(); @@ -36,14 +39,16 @@ pub async fn convert_to_aisdk_tools(registry: &ToolRegistry, sender: Option<Chun if let Some(ref sender) = sender { // Surface tool call start to the UI let args = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()); - let _ = sender.send(crate::llm::ChunkMessage::ToolCalls(vec![crate::llm::ToolCall { - id: call_id.clone(), - call_type: "function".to_string(), - function: crate::llm::FunctionCall { - name: tool_id.clone(), - arguments: args, + let _ = sender.send(crate::llm::ChunkMessage::ToolCalls(vec![ + crate::llm::ToolCall { + id: call_id.clone(), + call_type: "function".to_string(), + function: crate::llm::FunctionCall { + name: tool_id.clone(), + arguments: args, + }, }, - }])); + ])); } let sender_for_block = sender.clone(); @@ -56,8 +61,7 @@ pub async fn convert_to_aisdk_tools(registry: &ToolRegistry, sender: Option<Chun tokio::runtime::Handle::current().block_on(async move { let _ = crate::logging::log(&format!( "[AISDK_TOOL] call {} args={} ", - tool_id_for_exec, - input + tool_id_for_exec, input )); let handler = registry @@ -142,11 +146,11 @@ pub async fn convert_to_aisdk_tools(registry: &ToolRegistry, sender: Option<Chun result })); - + // Build the tool schema from parameters let mut properties = serde_json::Map::new(); let mut required = Vec::new(); - + for param in &tool_def.parameters { let schema = param_to_json_schema(¶m.param_type); properties.insert(param.name.clone(), schema); @@ -154,7 +158,7 @@ pub async fn convert_to_aisdk_tools(registry: &ToolRegistry, sender: Option<Chun required.push(param.name.clone()); } } - + let input_schema_json = serde_json::json!({ "type": "object", "properties": properties, @@ -171,29 +175,30 @@ pub async fn convert_to_aisdk_tools(registry: &ToolRegistry, sender: Option<Chun Schema::from(true) } }; - + let aisdk_tool = match Tool::builder() .name(&tool_def.id) .description(&tool_def.description) .input_schema(schema) .execute(execute) - .build() { + .build() + { Ok(t) => t, Err(e) => { let _ = crate::logging::log(&format!("Error building tool {}: {}", tool_def.id, e)); continue; } }; - + aisdk_tools.push(aisdk_tool); } - + aisdk_tools } fn param_to_json_schema(param_type: &crate::tools::ParameterType) -> serde_json::Value { use crate::tools::ParameterType; - + match param_type { ParameterType::String => serde_json::json!({"type": "string"}), ParameterType::Integer => serde_json::json!({"type": "integer"}), diff --git a/src/tools/bash.rs b/src/tools/bash.rs index 5ce1741..fa305ad 100644 --- a/src/tools/bash.rs +++ b/src/tools/bash.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_bool_param, get_integer_param, get_string_param, validate_required, Tool, ToolContext, - ToolError, ToolHandler, ToolResult, ParameterSchema, ParameterType, + get_bool_param, get_integer_param, get_string_param, validate_required, ParameterSchema, + ParameterType, Tool, ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -85,14 +85,20 @@ impl ToolHandler for BashTool { .ok_or_else(|| ToolError::Validation("command is required".to_string()))?; let timeout_seconds = get_integer_param(¶ms, "timeout") - .map(|v| if v <= 0 { DEFAULT_TIMEOUT_SECONDS } else { v as u64 }) + .map(|v| { + if v <= 0 { + DEFAULT_TIMEOUT_SECONDS + } else { + v as u64 + } + }) .unwrap_or(DEFAULT_TIMEOUT_SECONDS); - let workdir = get_string_param(¶ms, "path") - .or_else(|| get_string_param(¶ms, "workdir")); + let workdir = + get_string_param(¶ms, "path").or_else(|| get_string_param(¶ms, "workdir")); - let description = get_string_param(¶ms, "description") - .unwrap_or_else(|| command_str.clone()); + let description = + get_string_param(¶ms, "description").unwrap_or_else(|| command_str.clone()); if let Some(reason) = Self::is_dangerous(&command_str) { return Err(ToolError::Permission(reason)); @@ -194,20 +200,23 @@ impl ToolHandler for BashTool { output_parts.join("\n") }; - let truncated = stdout_lines.len() >= MAX_OUTPUT_SIZE || stderr_lines.len() >= MAX_OUTPUT_SIZE; + let truncated = + stdout_lines.len() >= MAX_OUTPUT_SIZE || stderr_lines.len() >= MAX_OUTPUT_SIZE; let final_output = if truncated { - format!("{}\n\n[Output truncated to {} bytes]", output, MAX_OUTPUT_SIZE) + format!( + "{}\n\n[Output truncated to {} bytes]", + output, MAX_OUTPUT_SIZE + ) } else { output }; let exit_code = exit_status.code().unwrap_or(-1); - Ok(ToolResult::new( - format!("Bash: {}", description), - final_output + Ok( + ToolResult::new(format!("Bash: {}", description), final_output) + .with_metadata("exit_code", serde_json::json!(exit_code)) + .with_metadata("command", serde_json::json!(command_str)), ) - .with_metadata("exit_code", serde_json::json!(exit_code)) - .with_metadata("command", serde_json::json!(command_str))) } } diff --git a/src/tools/edit.rs b/src/tools/edit.rs index 8410fc0..a99e5cf 100644 --- a/src/tools/edit.rs +++ b/src/tools/edit.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_bool_param, get_string_param, validate_required, Tool, ToolContext, ToolError, - ToolHandler, ToolResult, ParameterSchema, ParameterType, + get_bool_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -42,7 +42,7 @@ impl EditTool { if i + old_lines.len() <= lines.len() { let candidate: String = lines[i..i + old_lines.len()].join("\n"); let similarity = Self::levenshtein_similarity(&candidate, old_string); - + if similarity >= SIMILARITY_THRESHOLD { let start = lines[..i].join("\n").len(); let start = if i > 0 { start + 1 } else { start }; @@ -110,11 +110,17 @@ impl ToolHandler for EditTool { let path = Path::new(&file_path); if !path.exists() { - return Err(ToolError::NotFound(format!("File not found: {}", file_path))); + return Err(ToolError::NotFound(format!( + "File not found: {}", + file_path + ))); } if !path.is_file() { - return Err(ToolError::Validation(format!("Path is not a file: {}", file_path))); + return Err(ToolError::Validation(format!( + "Path is not a file: {}", + file_path + ))); } let content = std::fs::read_to_string(path) @@ -136,13 +142,14 @@ impl ToolHandler for EditTool { return Ok(ToolResult::new( format!("Edit: {}", file_path), - format!("Replaced {} occurrence(s)", count) + format!("Replaced {} occurrence(s)", count), )); } match Self::find_best_match(&content, &old_string) { Some((start, end)) => { - let mut new_content = String::with_capacity(content.len() - (end - start) + new_string.len()); + let mut new_content = + String::with_capacity(content.len() - (end - start) + new_string.len()); new_content.push_str(&content[..start]); new_content.push_str(&new_string); new_content.push_str(&content[end..]); @@ -154,7 +161,7 @@ impl ToolHandler for EditTool { Ok(ToolResult::new( format!("Edit: {}", file_path), - format!("Replaced at line {}", line_num) + format!("Replaced at line {}", line_num), )) } None => Err(ToolError::NotFound(format!( diff --git a/src/tools/fs/glob.rs b/src/tools/fs/glob.rs index b6480b2..840719a 100644 --- a/src/tools/fs/glob.rs +++ b/src/tools/fs/glob.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_string_param, validate_required, Tool, ToolContext, ToolError, ToolHandler, ToolResult, - ParameterSchema, ParameterType, + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -19,17 +19,22 @@ impl ToolHandler for GlobTool { fn definition(&self) -> Tool { Tool { id: "glob".to_string(), - description: "Find files by glob pattern. Returns file paths sorted by modification time.".to_string(), + description: + "Find files by glob pattern. Returns file paths sorted by modification time." + .to_string(), parameters: vec![ ParameterSchema { name: "pattern".to_string(), - description: "Glob pattern to match files (e.g., '**/*.rs', '*.md')".to_string(), + description: "Glob pattern to match files (e.g., '**/*.rs', '*.md')" + .to_string(), required: true, param_type: ParameterType::String, }, ParameterSchema { name: "path".to_string(), - description: "Base directory to search from (default: current working directory)".to_string(), + description: + "Base directory to search from (default: current working directory)" + .to_string(), required: false, param_type: ParameterType::String, }, @@ -45,8 +50,7 @@ impl ToolHandler for GlobTool { let pattern = get_string_param(¶ms, "pattern") .ok_or_else(|| ToolError::Validation("pattern is required".to_string()))?; - let base_path = get_string_param(¶ms, "path") - .unwrap_or_else(|| ".".to_string()); + let base_path = get_string_param(¶ms, "path").unwrap_or_else(|| ".".to_string()); let pattern_path = Path::new(&base_path).join(&pattern); let pattern_str = pattern_path @@ -54,11 +58,11 @@ impl ToolHandler for GlobTool { .ok_or_else(|| ToolError::Execution("Invalid path encoding".to_string()))?; let mut entries: Vec<(glob::Paths, String)> = Vec::new(); - + match glob::glob(pattern_str) { Ok(paths) => { let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new(); - + for entry in paths { match entry { Ok(path) => { @@ -81,7 +85,7 @@ impl ToolHandler for GlobTool { let limit = 100; let total = files.len(); let truncated = total > limit; - + let output: Vec<String> = files .into_iter() .take(limit) @@ -93,14 +97,24 @@ impl ToolHandler for GlobTool { } else { let mut text = output.join("\n"); if truncated { - text.push_str(&format!("\n\n... and {} more files (showing first {})", total - limit, limit)); + text.push_str(&format!( + "\n\n... and {} more files (showing first {})", + total - limit, + limit + )); } text }; Ok(ToolResult::new(format!("Glob: {}", pattern), result_text) - .with_metadata("match_count", serde_json::Value::Number((total as i64).into())) - .with_metadata("shown_count", serde_json::Value::Number(((total.min(limit)) as i64).into())) + .with_metadata( + "match_count", + serde_json::Value::Number((total as i64).into()), + ) + .with_metadata( + "shown_count", + serde_json::Value::Number(((total.min(limit)) as i64).into()), + ) .with_metadata("limit", serde_json::Value::Number((limit as i64).into())) .with_metadata("truncated", serde_json::Value::Bool(truncated))) } diff --git a/src/tools/fs/list.rs b/src/tools/fs/list.rs index bbd65a1..5946f7d 100644 --- a/src/tools/fs/list.rs +++ b/src/tools/fs/list.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_string_param, validate_required, Tool, ToolContext, ToolError, ToolHandler, ToolResult, - ParameterSchema, ParameterType, + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -22,13 +22,13 @@ impl ListTool { depth: usize, ) -> Result<(), ToolError> { const MAX_DEPTH: usize = 10; - + if depth > MAX_DEPTH { return Ok(()); } let connector = if is_last { "└── " } else { "├── " }; - + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { output.push(format!("{}{}{}", prefix, connector, name)); } @@ -53,7 +53,7 @@ impl ListTool { filtered.sort_by(|a, b| { let a_is_dir = a.file_type().map(|t| t.is_dir()).unwrap_or(false); let b_is_dir = b.file_type().map(|t| t.is_dir()).unwrap_or(false); - + match (a_is_dir, b_is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, @@ -126,17 +126,23 @@ impl ToolHandler for ListTool { .unwrap_or_default(); let path = Path::new(&path_str); - + if !path.exists() { - return Err(ToolError::NotFound(format!("Directory not found: {}", path_str))); + return Err(ToolError::NotFound(format!( + "Directory not found: {}", + path_str + ))); } if !path.is_dir() { - return Err(ToolError::Validation(format!("Path is not a directory: {}", path_str))); + return Err(ToolError::Validation(format!( + "Path is not a directory: {}", + path_str + ))); } let mut output = Vec::new(); - + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { output.push(name.to_string()); } else { @@ -159,7 +165,7 @@ impl ToolHandler for ListTool { filtered.sort_by(|a, b| { let a_is_dir = a.file_type().map(|t| t.is_dir()).unwrap_or(false); let b_is_dir = b.file_type().map(|t| t.is_dir()).unwrap_or(false); - + match (a_is_dir, b_is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, @@ -170,14 +176,7 @@ impl ToolHandler for ListTool { let count = filtered.len(); for (i, entry) in filtered.iter().enumerate() { let is_last = i == count - 1; - Self::list_directory( - &entry.path(), - &ignore_patterns, - "", - is_last, - &mut output, - 1, - )?; + Self::list_directory(&entry.path(), &ignore_patterns, "", is_last, &mut output, 1)?; } let result_text = if output.len() <= 1 { @@ -186,9 +185,6 @@ impl ToolHandler for ListTool { output.join("\n") }; - Ok(ToolResult::new( - format!("List: {}", path_str), - result_text - )) + Ok(ToolResult::new(format!("List: {}", path_str), result_text)) } } diff --git a/src/tools/fs/read.rs b/src/tools/fs/read.rs index b79c496..59f598f 100644 --- a/src/tools/fs/read.rs +++ b/src/tools/fs/read.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_integer_param, get_string_param, validate_required, Tool, ToolContext, ToolError, - ToolHandler, ToolResult, ParameterSchema, ParameterType, + get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -70,11 +70,17 @@ impl ToolHandler for ReadTool { let path = Path::new(&file_path); if !path.exists() { - return Err(ToolError::NotFound(format!("File not found: {}", file_path))); + return Err(ToolError::NotFound(format!( + "File not found: {}", + file_path + ))); } if !path.is_file() { - return Err(ToolError::Validation(format!("Path is not a file: {}", file_path))); + return Err(ToolError::Validation(format!( + "Path is not a file: {}", + file_path + ))); } let metadata = std::fs::metadata(path) @@ -96,7 +102,7 @@ impl ToolHandler for ReadTool { if Self::is_binary(&content) { return Ok(ToolResult::new( format!("Read: {}", file_path), - "[Binary file - contents not displayed]".to_string() + "[Binary file - contents not displayed]".to_string(), )); } @@ -107,7 +113,10 @@ impl ToolHandler for ReadTool { if offset >= total_lines { return Ok(ToolResult::new( format!("Read: {}", file_path), - format!("[File has {} lines, offset {} is beyond end]", total_lines, offset) + format!( + "[File has {} lines, offset {} is beyond end]", + total_lines, offset + ), )); } @@ -123,13 +132,15 @@ impl ToolHandler for ReadTool { let mut output = numbered_lines.join("\n"); if end < total_lines { - output.push_str(&format!("\n\n... {} more lines (showing {}-{} of {})", - total_lines - end, offset + 1, end, total_lines)); + output.push_str(&format!( + "\n\n... {} more lines (showing {}-{} of {})", + total_lines - end, + offset + 1, + end, + total_lines + )); } - Ok(ToolResult::new( - format!("Read: {}", file_path), - output - )) + Ok(ToolResult::new(format!("Read: {}", file_path), output)) } } diff --git a/src/tools/fs/write.rs b/src/tools/fs/write.rs index e3d312d..920deff 100644 --- a/src/tools/fs/write.rs +++ b/src/tools/fs/write.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_string_param, validate_required, Tool, ToolContext, ToolError, ToolHandler, ToolResult, - ParameterSchema, ParameterType, + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -29,7 +29,8 @@ impl ToolHandler for WriteTool { fn definition(&self) -> Tool { Tool { id: "write".to_string(), - description: "Create or overwrite a file. Creates parent directories if needed.".to_string(), + description: "Create or overwrite a file. Creates parent directories if needed." + .to_string(), parameters: vec![ ParameterSchema { name: "file_path".to_string(), @@ -69,13 +70,14 @@ impl ToolHandler for WriteTool { if let Some(parent) = path.parent() { if !parent.exists() { - std::fs::create_dir_all(parent) - .map_err(|e| ToolError::Execution(format!("Failed to create directories: {}", e)))?; + std::fs::create_dir_all(parent).map_err(|e| { + ToolError::Execution(format!("Failed to create directories: {}", e)) + })?; } } let temp_path = path.with_extension("tmp"); - + std::fs::write(&temp_path, content) .map_err(|e| ToolError::Execution(format!("Failed to write temp file: {}", e)))?; @@ -83,14 +85,20 @@ impl ToolHandler for WriteTool { .map_err(|e| ToolError::Execution(format!("Failed to rename file: {}", e)))?; let is_new = !path.exists(); - + Ok(ToolResult::new( format!("Write: {}", file_path), if is_new { - format!("Created file with {} bytes", std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)) + format!( + "Created file with {} bytes", + std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) + ) } else { - format!("Updated file with {} bytes", std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)) - } + format!( + "Updated file with {} bytes", + std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) + ) + }, )) } } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 6a4a080..3d7c67f 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; use serde_json::Value; -pub mod bash; pub mod aisdk_bridge; +pub mod bash; pub mod context; pub mod edit; pub mod fs; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 6d57a4b..62e0c4e 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -29,10 +29,7 @@ impl ToolRegistry { pub async fn list(&self) -> Vec<Tool> { let tools = self.tools.read().await; - tools - .values() - .map(|t| t.definition()) - .collect() + tools.values().map(|t| t.definition()).collect() } pub async fn list_schemas(&self) -> Vec<serde_json::Value> { diff --git a/src/views/mod.rs b/src/views/mod.rs index 60a623a..3749c0c 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -2,20 +2,20 @@ pub mod chat; pub mod connect_dialog; pub mod home; pub mod models_dialog; -pub mod themes_dialog; pub mod session_rename_dialog; pub mod sessions_dialog; pub mod suggestions_popup; +pub mod themes_dialog; pub mod which_key; pub use chat::ChatState; pub use connect_dialog::ConnectDialogState; pub use home::HomeState; pub use models_dialog::ModelsDialogState; -pub use themes_dialog::ThemesDialogState; pub use session_rename_dialog::SessionRenameDialogState; pub use sessions_dialog::SessionsDialogState; pub use suggestions_popup::SuggestionsPopupState; +pub use themes_dialog::ThemesDialogState; #[allow(unused_imports)] pub use which_key::WhichKeyAction; pub use which_key::WhichKeyState; From aa083c8a4e9bc4725c3773d6f4c2d3c41e6890b8 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 09:14:49 +0800 Subject: [PATCH 023/226] feat: a lot better theme for Plan and Build agents. --- src/theme.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/theme.rs b/src/theme.rs index ddbdfdf..ca65b27 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -54,11 +54,11 @@ pub fn contrast_text(background: ratatui::style::Color) -> ratatui::style::Color pub fn agent_color(agent: &str, colors: &ThemeColors) -> ratatui::style::Color { match agent { - // OpenCode tokens: - // - Plan: info (icon-agent-plan-base) - // - Build: interactive (icon-agent-build-base) - "Plan" => colors.info, - "Build" => colors.interactive, + // Match OpenCode primary agent colors: + // - Build: secondary + // - Plan: accent + "Build" => colors.secondary, + "Plan" => colors.accent, _ => colors.primary, } } From cc0bbe00ea9732f1702c19d1232a1f7a90a6952d Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 09:33:23 +0800 Subject: [PATCH 024/226] feat: BIG completely revamped toasts. No more ratatui_toolkit. --- Cargo.lock | 2198 ++------------------------------------- Cargo.toml | 1 - src/app.rs | 53 +- src/command/handlers.rs | 13 +- src/main.rs | 5 +- src/toast.rs | 286 +++++ 6 files changed, 428 insertions(+), 2128 deletions(-) create mode 100644 src/toast.rs diff --git a/Cargo.lock b/Cargo.lock index 8009e67..43f3e65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,22 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - [[package]] name = "adler2" version = "2.0.1" @@ -73,46 +57,7 @@ source = "git+https://github.com/Blankeos/aisdk-rs?branch=apikey-not-required#a1 dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "aliasable" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" - -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", + "syn", ] [[package]] @@ -121,35 +66,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "allsorts" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ec6442ceba5ea9d0201cd0afe96ecac4e8253e5f5be725e074747e6f4238735" -dependencies = [ - "bitflags 1.3.2", - "bitreader", - "brotli-decompressor", - "byteorder", - "crc32fast", - "encoding_rs", - "flate2", - "glyph-names", - "itertools 0.10.5", - "lazy_static", - "libc", - "log", - "num-traits", - "ouroboros", - "pathfinder_geometry", - "rustc-hash", - "tinyvec", - "ucd-trie", - "unicode-canonical-combining-class", - "unicode-general-category", - "unicode-joining-type", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -159,33 +75,6 @@ dependencies = [ "libc", ] -[[package]] -name = "ansee" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df6071478bf233f71ccc43923c0bcd61e24eb69812aefb9507141cb156ea2414" -dependencies = [ - "ab_glyph", - "ansi-parser", - "anyhow", - "clap", - "dafont", - "font-kit", - "image", - "imageproc", - "lazy_static", -] - -[[package]] -name = "ansi-parser" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43e7fd8284f025d0bd143c2855618ecdf697db55bde39211e5c9faec7669173" -dependencies = [ - "heapless", - "nom 7.1.3", -] - [[package]] name = "ansi-to-tui" version = "8.0.1" @@ -255,67 +144,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arboard" -version = "3.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" -dependencies = [ - "clipboard-win", - "image", - "log", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "x11rb", -] - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -324,16 +152,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", + "syn", ] [[package]] @@ -348,49 +167,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" -dependencies = [ - "arrayvec", -] - [[package]] name = "base64" version = "0.22.1" @@ -421,42 +197,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -[[package]] -name = "bitreader" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "886559b1e163d56c765bc3a985febb4eee8009f625244511d8ee3c432e08c066" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -472,17 +218,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.5.2", -] - -[[package]] -name = "brotli-decompressor" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "objc2", ] [[package]] @@ -496,36 +232,12 @@ dependencies = [ "serde", ] -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - [[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -[[package]] -name = "bytemuck" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.11.0" @@ -538,7 +250,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" dependencies = [ - "bitflags 2.10.0", + "bitflags", "polling", "rustix 1.1.3", "slab", @@ -579,8 +291,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -590,12 +300,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.43" @@ -638,10 +342,10 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -659,12 +363,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "colorchoice" version = "1.0.4" @@ -715,9 +413,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" dependencies = [ "clipboard-win", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", "smithay-clipboard", "x11-clipboard", ] @@ -738,51 +436,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - -[[package]] -name = "core-text" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" -dependencies = [ - "core-foundation", - "core-graphics", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -803,7 +456,7 @@ dependencies = [ "clap", "copypasta", "cuid2", - "dirs 5.0.1", + "dirs", "futures", "glob", "ignore", @@ -812,7 +465,6 @@ dependencies = [ "nucleo-matcher", "ratatui", "ratatui-core", - "ratatui-toolkit", "regex", "reqwest", "rusqlite", @@ -840,15 +492,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -880,9 +523,9 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags", "crossterm_winapi", - "mio 1.1.1", + "mio", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -899,12 +542,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -915,16 +552,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf", -] - [[package]] name = "cuid-util" version = "0.1.1" @@ -940,7 +567,7 @@ dependencies = [ "cuid-util", "getrandom 0.2.17", "num", - "rand 0.8.5", + "rand", "sha3", "web-time", ] @@ -951,25 +578,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "custom_error" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f8a51dd197fa6ba5b4dc98a990a43cc13693c23eb0089ebb0fcc1f04152bca6" - -[[package]] -name = "dafont" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ef50b74cba90d2ae53ac0c4b84203e438e8c816a4e675681bed3f27ed6e195" -dependencies = [ - "allsorts", - "base64", - "mmapio", - "rayon", - "xmlparser", -] - [[package]] name = "darling" version = "0.20.11" @@ -1001,7 +609,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn", ] [[package]] @@ -1014,7 +622,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn", ] [[package]] @@ -1025,7 +633,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1036,15 +644,9 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn", ] -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - [[package]] name = "deranged" version = "0.5.5" @@ -1072,7 +674,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1082,7 +684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.114", + "syn", ] [[package]] @@ -1107,16 +709,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys 0.4.1", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys 0.5.0", + "dirs-sys", ] [[package]] @@ -1127,41 +720,19 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users 0.4.6", + "redox_users", "windows-sys 0.48.0", ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1179,18 +750,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dwrote" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" -dependencies = [ - "lazy_static", - "libc", - "winapi", - "wio", -] - [[package]] name = "dyn-clone" version = "1.0.20" @@ -1212,26 +771,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1254,15 +793,6 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - [[package]] name = "eventsource-stream" version = "0.2.3" @@ -1274,21 +804,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1301,16 +816,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - [[package]] name = "fancy-regex" version = "0.13.0" @@ -1328,75 +833,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-msvc-tools" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" version = "1.0.35" @@ -1407,12 +849,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "float-ord" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" - [[package]] name = "fnv" version = "1.0.7" @@ -1431,59 +867,13 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "font-kit" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" -dependencies = [ - "bitflags 2.10.0", - "byteorder", - "core-foundation", - "core-graphics", - "core-text", - "dirs 6.0.0", - "dwrote", - "float-ord", - "freetype-sys", - "lazy_static", - "libc", - "log", - "pathfinder_geometry", - "pathfinder_simd", - "walkdir", - "winapi", - "yeslogic-fontconfig-sys", -] - [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared 0.1.1", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared 0.3.1", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "foreign-types-shared", ] [[package]] @@ -1492,12 +882,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1507,26 +891,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "freetype-sys" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "futures" version = "0.3.31" @@ -1583,7 +947,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1676,16 +1040,6 @@ dependencies = [ "wasip2", ] -[[package]] -name = "gif" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "glob" version = "0.3.3" @@ -1705,12 +1059,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "glyph-names" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3531d702d6c1a3ba92a5fb55a404c7b8c476c8e7ca249951077afcbe4bc807f" - [[package]] name = "h2" version = "0.4.13" @@ -1730,26 +1078,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -1790,22 +1118,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -1818,12 +1130,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "http" version = "1.4.0" @@ -2091,64 +1397,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "image" -version = "0.25.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.11", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imageproc" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d" -dependencies = [ - "ab_glyph", - "approx", - "getrandom 0.2.17", - "image", - "itertools 0.12.1", - "nalgebra", - "num", - "rand 0.8.5", - "rand_distr", - "rayon", -] - -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - [[package]] name = "indexmap" version = "2.13.0" @@ -2168,26 +1416,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "inotify" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" -dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - [[package]] name = "instability" version = "0.3.11" @@ -2198,27 +1426,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "ioctl-rs" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" -dependencies = [ - "libc", + "syn", ] [[package]] @@ -2245,55 +1453,27 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itertools" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] -name = "itertools" -version = "0.13.0" +name = "itoa" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" @@ -2335,60 +1515,18 @@ dependencies = [ "cpufeatures", ] -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libfuzzer-sys" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" -dependencies = [ - "arbitrary", - "cc", -] - [[package]] name = "libloading" version = "0.8.9" @@ -2399,21 +1537,14 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - [[package]] name = "libredox" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags", "libc", - "redox_syscall 0.7.0", ] [[package]] @@ -2466,15 +1597,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - [[package]] name = "lru" version = "0.12.5" @@ -2493,36 +1615,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix 0.29.0", - "winapi", -] - -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - [[package]] name = "memchr" version = "2.7.6" @@ -2538,30 +1630,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -2581,19 +1649,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", ] [[package]] @@ -2608,41 +1663,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mmapio" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0204e2cac68f5b2e35b7ec8cb5d906f6e58e78dad8066a30b6ee54da99bb03dd" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "moxcms" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "nalgebra" -version = "0.32.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" -dependencies = [ - "approx", - "matrixmultiply", - "num-complex", - "num-rational", - "num-traits", - "simba", - "typenum", -] - [[package]] name = "native-tls" version = "0.2.14" @@ -2660,39 +1680,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nix" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset 0.6.5", - "pin-utils", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset 0.9.1", -] - [[package]] name = "nom" version = "7.1.3" @@ -2712,31 +1699,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "notify" -version = "6.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" -dependencies = [ - "bitflags 2.10.0", - "crossbeam-channel", - "filetime", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio 0.8.11", - "walkdir", - "windows-sys 0.48.0", -] - [[package]] name = "nucleo-matcher" version = "0.3.1" @@ -2786,17 +1748,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -2835,16 +1786,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", ] [[package]] @@ -2863,77 +1804,32 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" -dependencies = [ - "objc2-encode", -] - [[package]] name = "objc2-app-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", "libc", - "objc2 0.5.2", + "objc2", "objc2-core-data", "objc2-core-image", - "objc2-foundation 0.2.2", + "objc2-foundation", "objc2-quartz-core", ] -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-graphics", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-core-data" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.10.0", - "dispatch2", - "objc2 0.6.3", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.10.0", - "dispatch2", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-io-surface", + "objc2", + "objc2-foundation", ] [[package]] @@ -2943,8 +1839,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "objc2", + "objc2-foundation", "objc2-metal", ] @@ -2960,32 +1856,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", "libc", - "objc2 0.5.2", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-foundation", + "objc2", ] [[package]] @@ -2994,10 +1868,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "objc2", + "objc2-foundation", ] [[package]] @@ -3006,10 +1880,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "objc2", + "objc2-foundation", "objc2-metal", ] @@ -3031,7 +1905,7 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags 2.10.0", + "bitflags", "libc", "once_cell", "onig_sys", @@ -3053,9 +1927,9 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cfg-if", - "foreign-types 0.3.2", + "foreign-types", "libc", "once_cell", "openssl-macros", @@ -3070,7 +1944,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -3097,48 +1971,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[package]] -name = "ouroboros" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ba07320d39dfea882faa70554b4bd342a5f273ed59ba7c1c6b4c840492c954" -dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", -] - -[[package]] -name = "ouroboros_macro" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4c6225c69b4ca778c0aea097321a64c421cf4577b331c61b229267edabb6f8" -dependencies = [ - "heck 0.4.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -3157,7 +1989,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -3168,31 +2000,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pathfinder_geometry" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" -dependencies = [ - "log", - "pathfinder_simd", -] - -[[package]] -name = "pathfinder_simd" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" -dependencies = [ - "rustc_version", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3229,7 +2036,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -3242,58 +2049,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3325,19 +2080,6 @@ dependencies = [ "time", ] -[[package]] -name = "png" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" -dependencies = [ - "bitflags 2.10.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "polling" version = "3.11.0" @@ -3352,27 +2094,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "portable-pty" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix 0.25.1", - "serial", - "shared_library", - "shell-words", - "winapi", - "winreg", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -3416,30 +2137,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3449,45 +2146,13 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn 2.0.114", -] - -[[package]] -name = "pulldown-cmark" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" -dependencies = [ - "bitflags 2.10.0", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - [[package]] name = "pulldown-cmark" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.10.0", + "bitflags", "getopts", "memchr", "pulldown-cmark-escape", @@ -3500,30 +2165,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" -[[package]] -name = "pxfm" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - [[package]] name = "quick-xml" version = "0.38.4" @@ -3555,18 +2196,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -3576,17 +2207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -3598,32 +2219,13 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cassowary", "compact_str 0.8.1", "crossterm", @@ -3633,7 +2235,6 @@ dependencies = [ "lru 0.12.5", "paste", "strum 0.26.3", - "time", "unicode-segmentation", "unicode-truncate 1.1.0", "unicode-width 0.2.0", @@ -3645,7 +2246,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.10.0", + "bitflags", "compact_str 0.9.0", "hashbrown 0.16.1", "indoc", @@ -3659,128 +2260,13 @@ dependencies = [ "unicode-width 0.2.0", ] -[[package]] -name = "ratatui-toolkit" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f4216a9fb6e732437c540b5adb29c0e8eeb0e00834a729b1cc017430047353" -dependencies = [ - "ansee", - "anyhow", - "arboard", - "base64", - "crossterm", - "dirs 5.0.1", - "image", - "libc", - "notify", - "portable-pty", - "pulldown-cmark 0.12.2", - "ratatui", - "serde", - "serde_json", - "syntect", - "syntect-tui", - "termwiz", - "thiserror 2.0.18", - "throbber-widgets-tui", - "tokio", - "tracing", - "unicode-width 0.2.0", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" -dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] @@ -3794,17 +2280,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -3822,7 +2297,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -3919,12 +2394,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "rgb" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" - [[package]] name = "ring" version = "0.17.14" @@ -3964,7 +2433,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.114", + "syn", "unicode-ident", ] @@ -3974,7 +2443,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.10.0", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4003,7 +2472,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4016,7 +2485,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.11.0", @@ -4068,15 +2537,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - [[package]] name = "same-file" version = "1.0.6" @@ -4117,7 +2577,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn", ] [[package]] @@ -4138,7 +2598,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -4188,7 +2648,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4199,7 +2659,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4221,52 +2681,10 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serial" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" -dependencies = [ - "serial-core", - "serial-unix", - "serial-windows", -] - -[[package]] -name = "serial-core" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" -dependencies = [ - "libc", -] - -[[package]] -name = "serial-unix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" -dependencies = [ - "ioctl-rs", - "libc", - "serial-core", - "termios 0.2.2", -] - -[[package]] -name = "serial-windows" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" -dependencies = [ - "libc", - "serial-core", + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] @@ -4290,22 +2708,6 @@ dependencies = [ "keccak", ] -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" @@ -4329,7 +2731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 1.1.1", + "mio", "signal-hook", ] @@ -4343,46 +2745,12 @@ dependencies = [ "libc", ] -[[package]] -name = "simba" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" -dependencies = [ - "approx", - "num-complex", - "num-traits", - "paste", - "wide", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - [[package]] name = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "siphasher" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" - [[package]] name = "slab" version = "0.4.11" @@ -4407,7 +2775,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.10.0", + "bitflags", "calloop", "calloop-wayland-source", "cursor-icon", @@ -4491,11 +2859,11 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "rustversion", - "syn 2.0.114", + "syn", ] [[package]] @@ -4504,10 +2872,10 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4516,17 +2884,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.114" @@ -4555,7 +2912,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4579,24 +2936,13 @@ dependencies = [ "yaml-rust", ] -[[package]] -name = "syntect-tui" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24486acfb54bfcae77f45784cb59254e14454949a44f9d0b62613a699619c210" -dependencies = [ - "custom_error", - "ratatui", - "syntect", -] - [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "core-foundation", "system-configuration-sys", ] @@ -4624,78 +2970,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom 7.1.3", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" -dependencies = [ - "libc", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.10.0", - "fancy-regex 0.11.0", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix 0.29.0", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf", - "sha2", - "signal-hook", - "siphasher", - "terminfo", - "termios 0.3.3", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - [[package]] name = "textwrap" version = "0.16.2" @@ -4733,7 +3007,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4744,31 +3018,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "throbber-widgets-tui" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d" -dependencies = [ - "rand 0.8.5", - "ratatui", -] - -[[package]] -name = "tiff" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg 0.4.21", + "syn", ] [[package]] @@ -4780,7 +3030,7 @@ dependencies = [ "anyhow", "base64", "bstr", - "fancy-regex 0.13.0", + "fancy-regex", "lazy_static", "regex", "rustc-hash", @@ -4794,9 +3044,7 @@ checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", "serde_core", "time-core", @@ -4829,21 +3077,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.49.0" @@ -4852,7 +3085,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio 1.1.1", + "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4869,7 +3102,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4978,7 +3211,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", "futures-util", "http", @@ -5022,7 +3255,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -5040,12 +3273,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" - [[package]] name = "tui-markdown" version = "0.3.7" @@ -5055,7 +3282,7 @@ dependencies = [ "ansi-to-tui", "itertools 0.14.0", "pretty_assertions", - "pulldown-cmark 0.13.0", + "pulldown-cmark", "ratatui-core", "rstest", "syntect", @@ -5091,30 +3318,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-canonical-combining-class" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6925586af9268182c711e47c0853ed84131049efaca41776d0ca97f983865c32" - -[[package]] -name = "unicode-general-category" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2281c8c1d221438e373249e065ca4989c4c36952c211ff21a0ee91c44a3869e7" - [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-joining-type" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f8cb47ccb8bc750808755af3071da4a10dcd147b68fc874b7ae4b12543f6f5" - [[package]] name = "unicode-linebreak" version = "0.1.5" @@ -5197,23 +3406,11 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ - "atomic", "getrandom 0.3.4", "js-sys", "wasm-bindgen", ] -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "vcpkg" version = "0.2.15" @@ -5226,15 +3423,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -5315,7 +3503,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn", "wasm-bindgen-shared", ] @@ -5361,7 +3549,7 @@ version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ - "bitflags 2.10.0", + "bitflags", "rustix 1.1.3", "wayland-backend", "wayland-scanner", @@ -5373,7 +3561,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cursor-icon", "wayland-backend", ] @@ -5395,7 +3583,7 @@ version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5407,7 +3595,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5420,7 +3608,7 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791c58fdeec5406aa37169dd815327d1e47f334219b523444bc26d70ceb4c34e" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5433,7 +3621,7 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5483,94 +3671,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - [[package]] name = "winapi" version = "0.3.9" @@ -5623,7 +3723,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -5634,7 +3734,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -5912,24 +4012,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - -[[package]] -name = "wio" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" -dependencies = [ - "winapi", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5981,18 +4063,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "xmlparser" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yaml-rust" version = "0.4.5" @@ -6008,17 +4078,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] - [[package]] name = "yoke" version = "0.8.1" @@ -6038,7 +4097,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", "synstructure", ] @@ -6059,7 +4118,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -6079,7 +4138,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", "synstructure", ] @@ -6119,7 +4178,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -6127,42 +4186,3 @@ name = "zmij" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" - -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2" -dependencies = [ - "zune-core 0.5.1", -] diff --git a/Cargo.toml b/Cargo.toml index 20f0bef..abe022f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ copypasta = "0.10" async-trait = "0.1" futures = "0.3" dirs = "5.0" -ratatui-toolkit = "0.1" lazy_static = "1.5" nucleo-matcher = "0.3" rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/src/app.rs b/src/app.rs index 1709de9..a854b6b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use crate::llm::client::stream_llm_with_cancellation; use crate::session::manager::SessionManager; use crate::push_toast; +use crate::toast::{self, Toast, ToastLevel}; use crate::ui::components::chat::Chat; use crate::ui::components::input::Input; use crate::ui::components::popup::Popup; @@ -45,7 +46,7 @@ use crate::views::{ }; use crate::{ - get_toast_manager, render_toasts, + get_toast_manager, theme::{self, Theme}, }; @@ -478,9 +479,9 @@ impl App { } } - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!("Switched to: {}", model_id_clone), - ratatui_toolkit::ToastLevel::Info, + ToastLevel::Info, None, )); } @@ -495,13 +496,13 @@ impl App { false }; - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( if is_favorite { "Added to favorites" } else { "Removed from favorites" }, - ratatui_toolkit::ToastLevel::Info, + ToastLevel::Info, None, )); @@ -538,9 +539,9 @@ impl App { { self.current_theme_index = idx; self.themes_dialog_committed = true; - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!("Theme: {}", theme.id), - ratatui_toolkit::ToastLevel::Info, + ToastLevel::Info, None, )); } @@ -944,12 +945,12 @@ impl App { const MAX_PASTE_SIZE: usize = 20 * 1024 * 1024; if text.len() > MAX_PASTE_SIZE { - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!( "Paste content too large ({}MB). Maximum is 20MB.", text.len() / 1024 / 1024 ), - ratatui_toolkit::ToastLevel::Warning, + ToastLevel::Warning, None, )); return; @@ -1107,9 +1108,9 @@ impl App { crate::command::registry::CommandResult::Error(msg) => { self.play_sound_event(crate::sound::SoundEvent::Error); if msg.starts_with("Unknown command:") { - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( msg, - ratatui_toolkit::ToastLevel::Error, + ToastLevel::Error, Some(std::time::Duration::from_secs(3)), )); } else { @@ -1225,9 +1226,9 @@ impl App { crate::command::registry::CommandResult::Error(msg) => { self.play_sound_event(crate::sound::SoundEvent::Error); if msg.starts_with("Unknown command:") { - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( msg, - ratatui_toolkit::ToastLevel::Error, + ToastLevel::Error, Some(std::time::Duration::from_secs(3)), )); } else { @@ -1617,11 +1618,7 @@ impl App { .append_reasoning_to_last_assistant(&reasoning); } crate::llm::ChunkMessage::Warning(msg) => { - push_toast(ratatui_toolkit::Toast::new( - msg, - ratatui_toolkit::ToastLevel::Warning, - None, - )); + push_toast(Toast::new(msg, ToastLevel::Warning, None)); } crate::llm::ChunkMessage::End => { // Capture end timestamp for TTFT/TPS/latency calculations. @@ -1664,9 +1661,9 @@ impl App { self.chat_state.chat.mark_streaming_end(); self.chat_state.chat.finalize_streaming_metrics(); self.play_sound_event(crate::sound::SoundEvent::Error); - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!("LLM error: {}", error), - ratatui_toolkit::ToastLevel::Error, + ToastLevel::Error, None, )); self.chat_state @@ -1679,11 +1676,7 @@ impl App { self.is_streaming = false; self.chat_state.chat.mark_streaming_end(); self.chat_state.chat.finalize_streaming_metrics(); - push_toast(ratatui_toolkit::Toast::new( - "Streaming cancelled", - ratatui_toolkit::ToastLevel::Info, - None, - )); + push_toast(Toast::new("Streaming cancelled", ToastLevel::Info, None)); self.chat_state .chat .messages @@ -1920,9 +1913,9 @@ impl App { self.base_focus = BaseFocus::Chat; if let Err(e) = self.start_llm_streaming(&msg) { - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!("LLM error: {}", e), - ratatui_toolkit::ToastLevel::Error, + ToastLevel::Error, None, )); } @@ -1939,9 +1932,9 @@ impl App { .add_user_message_with_agent_mode(&msg, self.agent.clone()); if let Err(e) = self.start_llm_streaming(&msg) { - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!("LLM error: {}", e), - ratatui_toolkit::ToastLevel::Error, + ToastLevel::Error, None, )); } @@ -2078,7 +2071,7 @@ impl App { crate::views::which_key::render_which_key(f, &self.which_key_state, &colors); } - render_toasts(f, &get_toast_manager().lock().unwrap()); + toast::render_toasts(f, &get_toast_manager().lock().unwrap(), &colors); } } diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 7eb5a1e..abb1ce1 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -2,6 +2,7 @@ use crate::command::parser::ParsedCommand; use crate::command::registry::{Command, CommandResult, Registry}; use crate::push_toast; use crate::session::manager::SessionManager; +use crate::toast::{Toast, ToastLevel}; use chrono::{DateTime, Local, Utc}; use std::pin::Pin; @@ -437,9 +438,9 @@ pub fn handle_refreshmodels<'a>( let discovery = match crate::model::discovery::Discovery::new() { Ok(d) => d, Err(e) => { - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!("Failed to initialize model discovery: {}", e), - ratatui_toolkit::ToastLevel::Error, + ToastLevel::Error, Some(std::time::Duration::from_secs(3)), )); return CommandResult::Success(String::new()); @@ -449,9 +450,9 @@ pub fn handle_refreshmodels<'a>( let providers = match discovery.refresh_cache().await { Ok(p) => p, Err(e) => { - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!("Failed to refresh models cache: {}", e), - ratatui_toolkit::ToastLevel::Error, + ToastLevel::Error, Some(std::time::Duration::from_secs(3)), )); return CommandResult::Success(String::new()); @@ -461,12 +462,12 @@ pub fn handle_refreshmodels<'a>( let provider_count = providers.len(); let model_count: usize = providers.values().map(|p| p.models.len()).sum(); - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!( "Models cache refreshed: {} providers, {} models", provider_count, model_count ), - ratatui_toolkit::ToastLevel::Info, + ToastLevel::Info, Some(std::time::Duration::from_secs(3)), )); diff --git a/src/main.rs b/src/main.rs index f4a0c76..a55f690 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,11 +15,13 @@ mod session; mod sound; mod streaming; mod theme; +mod toast; mod tools; mod ui; mod utils; mod views; +use crate::toast::{Toast, ToastManager}; use anyhow::Result; use app::App; use clap::Parser; @@ -35,7 +37,6 @@ use ratatui::crossterm::{ }, }; use ratatui::{backend::CrosstermBackend, Terminal}; -use ratatui_toolkit::{render_toasts, Toast, ToastManager}; use std::io; use std::sync::Mutex; use std::time::Duration; @@ -144,7 +145,7 @@ async fn run_event_loop( // DO NOT REMOVE THIS LOG THAT I UNCOMMENT SOMETIMES. I USE IT FOR DEBUGGING // push_toast(Toast::new( // format!("Event: {:?}", event), - // ratatui_toolkit::ToastLevel::Info, + // crate::toast::ToastLevel::Info, // None, // )); diff --git a/src/toast.rs b/src/toast.rs new file mode 100644 index 0000000..3066566 --- /dev/null +++ b/src/toast.rs @@ -0,0 +1,286 @@ +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, Paragraph}; +use ratatui::Frame; +use unicode_width::UnicodeWidthStr; + +use crate::theme::ThemeColors; + +const DEFAULT_TOAST_DURATION: Duration = Duration::from_secs(4); +const MAX_QUEUED_TOASTS: usize = 24; +const MAX_VISIBLE_TOASTS: usize = 3; +const MAX_TEXT_LINES_PER_TOAST: usize = 8; + +const TOAST_MIN_CONTENT_WIDTH: u16 = 12; +const TOAST_MAX_WIDTH: u16 = 96; +const TOAST_HORIZONTAL_MARGIN: u16 = 2; +const TOAST_VERTICAL_MARGIN: u16 = 1; +const TOAST_VERTICAL_SPACING: u16 = 1; + +const ACCENT_WIDTH: u16 = 1; +const HORIZONTAL_PADDING: u16 = 2; +const VERTICAL_PADDING: u16 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastLevel { + Info, + Warning, + Error, + Success, +} + +impl ToastLevel { + fn accent_color(self, colors: &ThemeColors) -> Color { + match self { + ToastLevel::Info => colors.info, + ToastLevel::Warning => colors.warning, + ToastLevel::Error => colors.error, + ToastLevel::Success => colors.success, + } + } +} + +#[derive(Debug, Clone)] +pub struct Toast { + message: String, + level: ToastLevel, + expires_at: Instant, +} + +impl Toast { + pub fn new(message: impl Into<String>, level: ToastLevel, duration: Option<Duration>) -> Self { + let duration = duration.unwrap_or(DEFAULT_TOAST_DURATION); + Self { + message: message.into(), + level, + expires_at: Instant::now() + duration, + } + } + + fn is_expired(&self, now: Instant) -> bool { + self.expires_at <= now + } +} + +#[derive(Debug)] +pub struct ToastManager { + toasts: VecDeque<Toast>, +} + +impl ToastManager { + pub fn new() -> Self { + Self { + toasts: VecDeque::new(), + } + } + + pub fn add(&mut self, toast: Toast) { + self.toasts.push_back(toast); + while self.toasts.len() > MAX_QUEUED_TOASTS { + let _ = self.toasts.pop_front(); + } + } + + pub fn remove_expired(&mut self) { + let now = Instant::now(); + self.toasts.retain(|toast| !toast.is_expired(now)); + } +} + +pub fn render_toasts(frame: &mut Frame, manager: &ToastManager, colors: &ThemeColors) { + let now = Instant::now(); + let visible_toasts: Vec<&Toast> = manager + .toasts + .iter() + .rev() + .filter(|toast| !toast.is_expired(now)) + .take(MAX_VISIBLE_TOASTS) + .collect(); + + if visible_toasts.is_empty() { + return; + } + + let area = frame.area(); + if area.width <= TOAST_HORIZONTAL_MARGIN * 2 + 8 || area.height <= TOAST_VERTICAL_MARGIN * 2 + 2 + { + return; + } + + let available_width = area.width.saturating_sub(TOAST_HORIZONTAL_MARGIN * 2); + let max_toast_width = available_width.min(TOAST_MAX_WIDTH); + let max_content_width = max_toast_width.saturating_sub(ACCENT_WIDTH + HORIZONTAL_PADDING * 2); + if max_content_width == 0 { + return; + } + let mut y = area.y.saturating_add(TOAST_VERTICAL_MARGIN); + + for toast in visible_toasts { + let preferred_content_width = preferred_content_width(&toast.message, max_content_width); + let min_content_width = TOAST_MIN_CONTENT_WIDTH.min(max_content_width).max(1); + let content_width = preferred_content_width.max(min_content_width); + let toast_width = content_width.saturating_add(ACCENT_WIDTH + HORIZONTAL_PADDING * 2); + + let mut wrapped_lines = wrap_message(&toast.message, content_width as usize); + if wrapped_lines.len() > MAX_TEXT_LINES_PER_TOAST { + wrapped_lines.truncate(MAX_TEXT_LINES_PER_TOAST); + if let Some(last_line) = wrapped_lines.last_mut() { + truncate_with_ellipsis(last_line, content_width as usize); + } + } + + let text_height = wrapped_lines.len().max(1) as u16; + let toast_height = text_height + VERTICAL_PADDING * 2; + + let x = area.x.saturating_add( + area.width + .saturating_sub(toast_width) + .saturating_sub(TOAST_HORIZONTAL_MARGIN), + ); + + let bottom = area.y.saturating_add(area.height); + if y.saturating_add(toast_height) > bottom { + break; + } + + let toast_area = Rect { + x, + y, + width: toast_width, + height: toast_height, + }; + + let accent = toast.level.accent_color(colors); + let background = tint_color(colors.dialog_background, accent, 0.14); + + frame.render_widget(Clear, toast_area); + let body_area = Rect { + x: toast_area.x.saturating_add(ACCENT_WIDTH), + y: toast_area.y, + width: toast_area.width.saturating_sub(ACCENT_WIDTH), + height: toast_area.height, + }; + if body_area.width > 0 { + frame.render_widget( + Paragraph::new("").style(Style::default().bg(background)), + body_area, + ); + } + + let accent_area = Rect { + x: toast_area.x, + y: toast_area.y, + width: ACCENT_WIDTH, + height: toast_area.height, + }; + if accent_area.width > 0 { + frame.render_widget( + Paragraph::new("").style(Style::default().bg(accent)), + accent_area, + ); + } + + let text_area = Rect { + x: toast_area.x + ACCENT_WIDTH + HORIZONTAL_PADDING, + y: toast_area.y + VERTICAL_PADDING, + width: content_width, + height: text_height, + }; + + let lines: Vec<Line> = wrapped_lines + .into_iter() + .map(|line| Line::from(Span::styled(line, Style::default().fg(colors.text)))) + .collect(); + frame.render_widget( + Paragraph::new(lines).style(Style::default().bg(background)), + text_area, + ); + + y = y.saturating_add(toast_height + TOAST_VERTICAL_SPACING); + } +} + +fn preferred_content_width(message: &str, max_content_width: u16) -> u16 { + let widest_line = message + .lines() + .map(|line| line.width() as u16) + .max() + .unwrap_or(0); + + widest_line.max(1).min(max_content_width) +} + +fn wrap_message(message: &str, max_width: usize) -> Vec<String> { + if max_width == 0 { + return vec![String::new()]; + } + + let mut lines = Vec::new(); + for raw_line in message.lines() { + if raw_line.trim().is_empty() { + lines.push(String::new()); + continue; + } + + for wrapped in textwrap::wrap(raw_line, max_width) { + lines.push(wrapped.into_owned()); + } + } + + if lines.is_empty() { + lines.push(String::new()); + } + + lines +} + +fn truncate_with_ellipsis(line: &mut String, max_width: usize) { + if max_width == 0 { + line.clear(); + return; + } + + if line.width() <= max_width { + return; + } + + let suffix = "..."; + let suffix_width = suffix.width(); + if suffix_width >= max_width { + *line = ".".repeat(max_width); + return; + } + + let target = max_width.saturating_sub(suffix_width); + let mut trimmed = String::new(); + for ch in line.chars() { + let mut candidate = trimmed.clone(); + candidate.push(ch); + if candidate.width() > target { + break; + } + trimmed.push(ch); + } + + trimmed.push_str(suffix); + *line = trimmed; +} + +fn tint_color(base: Color, accent: Color, amount: f32) -> Color { + match (base, accent) { + (Color::Rgb(br, bg, bb), Color::Rgb(ar, ag, ab)) => { + let mix = |base: u8, accent: u8| -> u8 { + let base = base as f32; + let accent = accent as f32; + (base + (accent - base) * amount).clamp(0.0, 255.0) as u8 + }; + + Color::Rgb(mix(br, ar), mix(bg, ag), mix(bb, ab)) + } + _ => base, + } +} From 971599fddf2b6f4e07abd03c0b8fbdd65c23d3e6 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 09:49:14 +0800 Subject: [PATCH 025/226] chore: added more todos for myself. --- .gitignore | 2 ++ .ignore | 1 + README.md | 8 ++++---- 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 .ignore diff --git a/.gitignore b/.gitignore index a2952ce..aa38b32 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ src/themes/ !src/theme.json app.log sounds/complete.wav + +_dev_reference1 diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..5db5e72 --- /dev/null +++ b/.ignore @@ -0,0 +1 @@ +!_dev_reference1 diff --git a/README.md b/README.md index 039d0b7..3a13df5 100644 --- a/README.md +++ b/README.md @@ -146,18 +146,18 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/opencode). Also made this project w/ OpenCode btw, so thank you OpenCode! 🙏 -## Scope +## Scope and Limits - [x] Chat, switch models, agents - [x] Minimal configurations (I want it to just feel at least like vanilla opencode) - [x] The cheapest model providers (GLM, etc.) -- [ ] A ding sound, my only opencode plugin at the moment. +- [x] A ding sound, my only opencode plugin at the moment. - [x] No reverse-engineering oauth from big AI (Codex, Claude Code, Gemini), at least for now (Don't wanna get in trouble). - [ ] Possibly ralphy? (very far, idk how to do that) - [ ] ACP w/ Zed? (very far, idk how to do that) -- [x] No plugin ecosystem +- [x] No plugin ecosystem (If I think it's worth building, just make it built-in and configurable) - [x] No desktop app -- [x] No web sharing thing +- [x] No web sharing thing (Might be a dealbreaker for vibecoders w/ tailscale, but I haven't reached these levels yet, when I do, I might) ## Why? From a736169effafc4a6db318966f42fd682206b6aec Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 10:11:48 +0800 Subject: [PATCH 026/226] feat: better markdown color themes. --- src/app.rs | 15 ++ src/theme.rs | 275 +++++++++++++++++++++++++---- src/ui/components/chat.rs | 9 +- src/ui/markdown/streaming.rs | 239 +++++++++++++++++++++++-- src/views/session_rename_dialog.rs | 15 ++ 5 files changed, 507 insertions(+), 46 deletions(-) diff --git a/src/app.rs b/src/app.rs index a854b6b..3998a79 100644 --- a/src/app.rs +++ b/src/app.rs @@ -382,6 +382,7 @@ impl App { interactive: ratatui::style::Color::Rgb(255, 140, 0), background: ratatui::style::Color::Reset, dialog_background: ratatui::style::Color::Reset, + background_element: ratatui::style::Color::Reset, text: ratatui::style::Color::Reset, text_weak: ratatui::style::Color::Reset, text_strong: ratatui::style::Color::Reset, @@ -393,6 +394,20 @@ impl App { warning: ratatui::style::Color::Rgb(255, 255, 0), error: ratatui::style::Color::Rgb(255, 0, 0), info: ratatui::style::Color::Rgb(0, 255, 255), + markdown_text: ratatui::style::Color::Reset, + markdown_heading: ratatui::style::Color::Rgb(255, 140, 0), + markdown_link: ratatui::style::Color::Rgb(0, 255, 255), + markdown_link_text: ratatui::style::Color::Rgb(0, 255, 255), + markdown_code: ratatui::style::Color::Rgb(0, 255, 0), + markdown_block_quote: ratatui::style::Color::Rgb(255, 255, 0), + markdown_emph: ratatui::style::Color::Rgb(255, 255, 0), + markdown_strong: ratatui::style::Color::Rgb(255, 140, 0), + markdown_horizontal_rule: ratatui::style::Color::Reset, + markdown_list_item: ratatui::style::Color::Rgb(255, 140, 0), + markdown_list_enumeration: ratatui::style::Color::Rgb(0, 255, 255), + markdown_image: ratatui::style::Color::Rgb(255, 140, 0), + markdown_image_text: ratatui::style::Color::Rgb(0, 255, 255), + markdown_code_block: ratatui::style::Color::Reset, }; } diff --git a/src/theme.rs b/src/theme.rs index ca65b27..94d77aa 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -12,6 +12,7 @@ pub struct ThemeColors { pub interactive: ratatui::style::Color, pub background: ratatui::style::Color, pub dialog_background: ratatui::style::Color, + pub background_element: ratatui::style::Color, pub text: ratatui::style::Color, pub text_weak: ratatui::style::Color, pub text_strong: ratatui::style::Color, @@ -23,6 +24,20 @@ pub struct ThemeColors { pub warning: ratatui::style::Color, pub error: ratatui::style::Color, pub info: ratatui::style::Color, + pub markdown_text: ratatui::style::Color, + pub markdown_heading: ratatui::style::Color, + pub markdown_link: ratatui::style::Color, + pub markdown_link_text: ratatui::style::Color, + pub markdown_code: ratatui::style::Color, + pub markdown_block_quote: ratatui::style::Color, + pub markdown_emph: ratatui::style::Color, + pub markdown_strong: ratatui::style::Color, + pub markdown_horizontal_rule: ratatui::style::Color, + pub markdown_list_item: ratatui::style::Color, + pub markdown_list_enumeration: ratatui::style::Color, + pub markdown_image: ratatui::style::Color, + pub markdown_image_text: ratatui::style::Color, + pub markdown_code_block: ratatui::style::Color, } pub fn darken_color(color: ratatui::style::Color, factor: f32) -> ratatui::style::Color { @@ -111,6 +126,10 @@ struct DesktopThemeOverrides { #[serde(rename = "background-base")] pub background_base: String, + #[serde(rename = "background-weak")] + #[serde(default)] + pub background_weak: Option<String>, + #[serde(rename = "background-stronger")] #[serde(default)] pub background_stronger: Option<String>, @@ -142,6 +161,62 @@ struct DesktopThemeOverrides { #[serde(rename = "syntax-string")] pub syntax_string: String, + + #[serde(rename = "markdown-text")] + #[serde(default)] + pub markdown_text: Option<String>, + + #[serde(rename = "markdown-heading")] + #[serde(default)] + pub markdown_heading: Option<String>, + + #[serde(rename = "markdown-link")] + #[serde(default)] + pub markdown_link: Option<String>, + + #[serde(rename = "markdown-link-text")] + #[serde(default)] + pub markdown_link_text: Option<String>, + + #[serde(rename = "markdown-code")] + #[serde(default)] + pub markdown_code: Option<String>, + + #[serde(rename = "markdown-block-quote")] + #[serde(default)] + pub markdown_block_quote: Option<String>, + + #[serde(rename = "markdown-emph")] + #[serde(default)] + pub markdown_emph: Option<String>, + + #[serde(rename = "markdown-strong")] + #[serde(default)] + pub markdown_strong: Option<String>, + + #[serde(rename = "markdown-horizontal-rule")] + #[serde(default)] + pub markdown_horizontal_rule: Option<String>, + + #[serde(rename = "markdown-list-item")] + #[serde(default)] + pub markdown_list_item: Option<String>, + + #[serde(rename = "markdown-list-enumeration")] + #[serde(default)] + pub markdown_list_enumeration: Option<String>, + + #[serde(rename = "markdown-image")] + #[serde(default)] + pub markdown_image: Option<String>, + + #[serde(rename = "markdown-image-text")] + #[serde(default)] + pub markdown_image_text: Option<String>, + + #[serde(rename = "markdown-code-block")] + #[serde(default)] + pub markdown_code_block: Option<String>, } // OpenCode TUI themes ("https://opencode.ai/theme.json") @@ -217,34 +292,111 @@ impl Theme { .or(mode.overrides.background_stronger.as_deref()) .unwrap_or(mode.overrides.background_base.as_str()); + let resolve_override = |value: Option<&str>, fallback: ratatui::style::Color| { + value.map(parse_hex).unwrap_or(fallback) + }; + let primary = parse_hex(&mode.seeds.primary); + let secondary = primary; let interactive = parse_hex(&mode.seeds.interactive); + let background = parse_hex(&mode.overrides.background_base); + let dialog_background = parse_hex(dialog_background); + let background_element = + resolve_override(mode.overrides.background_weak.as_deref(), dialog_background); + let text = parse_hex(&mode.overrides.text_base); + let text_weak = parse_hex(&mode.overrides.text_weak); + let text_strong = parse_hex(&mode.overrides.text_strong); + let border = parse_hex(&mode.overrides.border_base); + let border_weak_focus = parse_hex(&mode.overrides.border_weak_focus); + let border_focus = parse_hex(&mode.overrides.border_focus); + let border_strong_focus = parse_hex(&mode.overrides.border_strong_focus); + let success = parse_hex(&mode.seeds.success); + let warning = parse_hex(&mode.seeds.warning); + let error = parse_hex(&mode.seeds.error); + let info = parse_hex(&mode.seeds.info); + + let markdown_text = resolve_override(mode.overrides.markdown_text.as_deref(), text); + let markdown_heading = + resolve_override(mode.overrides.markdown_heading.as_deref(), primary); + let markdown_link = resolve_override(mode.overrides.markdown_link.as_deref(), info); + let markdown_link_text = + resolve_override(mode.overrides.markdown_link_text.as_deref(), info); + let markdown_code = resolve_override( + mode.overrides.markdown_code.as_deref(), + parse_hex(&mode.overrides.syntax_string), + ); + let markdown_block_quote = + resolve_override(mode.overrides.markdown_block_quote.as_deref(), text_weak); + let markdown_emph = + resolve_override(mode.overrides.markdown_emph.as_deref(), warning); + let markdown_strong = + resolve_override(mode.overrides.markdown_strong.as_deref(), primary); + let markdown_horizontal_rule = + resolve_override(mode.overrides.markdown_horizontal_rule.as_deref(), border); + let markdown_list_item = + resolve_override(mode.overrides.markdown_list_item.as_deref(), markdown_link); + let markdown_list_enumeration = resolve_override( + mode.overrides.markdown_list_enumeration.as_deref(), + markdown_link_text, + ); + let markdown_image = + resolve_override(mode.overrides.markdown_image.as_deref(), markdown_link); + let markdown_image_text = resolve_override( + mode.overrides.markdown_image_text.as_deref(), + markdown_link_text, + ); + let markdown_code_block = + resolve_override(mode.overrides.markdown_code_block.as_deref(), markdown_text); + ThemeColors { primary, - secondary: primary, + secondary, accent: interactive, interactive, - background: parse_hex(&mode.overrides.background_base), - dialog_background: parse_hex(dialog_background), - text: parse_hex(&mode.overrides.text_base), - text_weak: parse_hex(&mode.overrides.text_weak), - text_strong: parse_hex(&mode.overrides.text_strong), - border: parse_hex(&mode.overrides.border_base), - border_weak_focus: parse_hex(&mode.overrides.border_weak_focus), - border_focus: parse_hex(&mode.overrides.border_focus), - border_strong_focus: parse_hex(&mode.overrides.border_strong_focus), - success: parse_hex(&mode.seeds.success), - warning: parse_hex(&mode.seeds.warning), - error: parse_hex(&mode.seeds.error), - info: parse_hex(&mode.seeds.info), + background, + dialog_background, + background_element, + text, + text_weak, + text_strong, + border, + border_weak_focus, + border_focus, + border_strong_focus, + success, + warning, + error, + info, + markdown_text, + markdown_heading, + markdown_link, + markdown_link_text, + markdown_code, + markdown_block_quote, + markdown_emph, + markdown_strong, + markdown_horizontal_rule, + markdown_list_item, + markdown_list_enumeration, + markdown_image, + markdown_image_text, + markdown_code_block, } } ThemeData::Tui(theme) => { let resolve = |key: &str| resolve_tui_color(theme, key, dark); + let resolve_or = |key: &str, fallback: ratatui::style::Color| { + let v = resolve(key); + if v == ratatui::style::Color::Reset { + fallback + } else { + v + } + }; let primary = resolve("primary"); - let secondary = resolve("secondary"); - let accent = resolve("accent"); + let secondary = resolve_or("secondary", primary); + let accent = resolve_or("accent", secondary); let interactive = { // OpenCode theme.json doesn't always include an explicit interactive token. // Map it to primary so we still get a theme-driven value. @@ -264,11 +416,31 @@ impl Theme { v } }; - let text = resolve("text"); - let text_weak = resolve("textMuted"); - let border = resolve("border"); - let border_focus = resolve("borderActive"); - let border_weak_focus = resolve("borderSubtle"); + let background_element = resolve_or("backgroundElement", dialog_background); + let text = resolve_or("text", primary); + let text_weak = resolve_or("textMuted", text); + let border = resolve_or("border", text_weak); + let border_focus = resolve_or("borderActive", border); + let border_weak_focus = resolve_or("borderSubtle", border); + + let markdown_text = resolve_or("markdownText", text); + let markdown_heading = resolve_or("markdownHeading", primary); + let markdown_link = + resolve_or("markdownLink", resolve_or("info", markdown_heading)); + let markdown_link_text = resolve_or("markdownLinkText", markdown_link); + let markdown_code = + resolve_or("markdownCode", resolve_or("success", markdown_text)); + let markdown_block_quote = resolve_or("markdownBlockQuote", text_weak); + let markdown_emph = + resolve_or("markdownEmph", resolve_or("warning", markdown_text)); + let markdown_strong = resolve_or("markdownStrong", markdown_heading); + let markdown_horizontal_rule = resolve_or("markdownHorizontalRule", border); + let markdown_list_item = resolve_or("markdownListItem", markdown_link); + let markdown_list_enumeration = + resolve_or("markdownListEnumeration", markdown_link_text); + let markdown_image = resolve_or("markdownImage", markdown_link); + let markdown_image_text = resolve_or("markdownImageText", markdown_link_text); + let markdown_code_block = resolve_or("markdownCodeBlock", markdown_text); ThemeColors { primary, @@ -277,6 +449,7 @@ impl Theme { interactive, background, dialog_background, + background_element, text, text_weak, text_strong: text, @@ -284,10 +457,24 @@ impl Theme { border_weak_focus, border_focus, border_strong_focus: border_focus, - success: resolve("success"), - warning: resolve("warning"), - error: resolve("error"), - info: resolve("info"), + success: resolve_or("success", primary), + warning: resolve_or("warning", primary), + error: resolve_or("error", primary), + info: resolve_or("info", primary), + markdown_text, + markdown_heading, + markdown_link, + markdown_link_text, + markdown_code, + markdown_block_quote, + markdown_emph, + markdown_strong, + markdown_horizontal_rule, + markdown_list_item, + markdown_list_enumeration, + markdown_image, + markdown_image_text, + markdown_code_block, } } } @@ -323,12 +510,36 @@ fn resolve_tui_color(theme: &TuiTheme, key: &str, dark: bool) -> ratatui::style: fn parse_hex(hex: &str) -> ratatui::style::Color { let hex = hex.trim_start_matches('#'); - if hex.len() == 6 { - let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); - let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); - let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); - ratatui::style::Color::Rgb(r, g, b) - } else { - ratatui::style::Color::Reset + match hex.len() { + 3 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap_or(0); + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap_or(0); + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap_or(0); + ratatui::style::Color::Rgb(r, g, b) + } + 6 | 8 => { + let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); + let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); + let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); + ratatui::style::Color::Rgb(r, g, b) + } + _ => ratatui::style::Color::Reset, + } +} + +#[cfg(test)] +mod tests { + use super::parse_hex; + + #[test] + fn parse_hex_supports_short_rgb() { + let color = parse_hex("#fff"); + assert_eq!(color, ratatui::style::Color::Rgb(255, 255, 255)); + } + + #[test] + fn parse_hex_supports_rrggbbaa() { + let color = parse_hex("#112233ff"); + assert_eq!(color, ratatui::style::Color::Rgb(17, 34, 51)); } } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index f7214f0..ebc1485 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -724,19 +724,22 @@ impl Chat { if is_streaming { // Use the streaming renderer content for markdown if let Some(content) = streaming_content { - let markdown_lines = render_markdown(content, max_width); + let markdown_lines = render_markdown(content, max_width, colors); lines.extend(markdown_lines); } else { // Fallback to plain text if renderer not available let content = message.content.clone(); let wrapped_lines = textwrap::wrap(&content, max_width); for line in wrapped_lines { - lines.push(Line::from(line.to_string())); + lines.push(Line::from(Span::styled( + line.to_string(), + Style::default().fg(colors.markdown_text), + ))); } } } else { // For complete messages, use tui-markdown directly - let markdown_lines = render_markdown(&message.content, max_width); + let markdown_lines = render_markdown(&message.content, max_width, colors); lines.extend(markdown_lines); } diff --git a/src/ui/markdown/streaming.rs b/src/ui/markdown/streaming.rs index 0c56d2d..3149a3f 100644 --- a/src/ui/markdown/streaming.rs +++ b/src/ui/markdown/streaming.rs @@ -1,4 +1,8 @@ -use ratatui::text::Line; +use crate::theme::ThemeColors; +use ratatui::{ + style::{Modifier, Style}, + text::{Line, Span}, +}; /// A simple streaming markdown renderer that caches parsed content /// to avoid re-parsing on every frame during streaming. @@ -76,18 +80,73 @@ fn compute_hash(content: &str) -> u64 { hasher.finish() } +#[derive(Debug, Clone, Copy)] +struct MarkdownStyleSheet { + colors: ThemeColors, +} + +impl MarkdownStyleSheet { + fn new(colors: ThemeColors) -> Self { + Self { colors } + } +} + +impl tui_markdown::StyleSheet for MarkdownStyleSheet { + fn heading(&self, _level: u8) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.markdown_heading)) + .add_modifier(ratatui_core::style::Modifier::BOLD) + } + + fn code(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.markdown_code)) + .bg(convert_color_to_core(self.colors.background_element)) + } + + fn link(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.markdown_link)) + .add_modifier(ratatui_core::style::Modifier::UNDERLINED) + } + + fn blockquote(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.markdown_block_quote)) + .add_modifier(ratatui_core::style::Modifier::ITALIC) + } + + fn heading_meta(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.text_weak)) + .add_modifier(ratatui_core::style::Modifier::DIM) + } + + fn metadata_block(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.text_weak)) + .add_modifier(ratatui_core::style::Modifier::DIM) + } +} + /// Render markdown content to lines /// This uses tui-markdown to parse and render the markdown -pub fn render_markdown(content: &str, max_width: usize) -> Vec<Line> { - // Use tui-markdown to parse the content - let text = tui_markdown::from_str(content); +pub fn render_markdown( + content: &str, + max_width: usize, + colors: &ThemeColors, +) -> Vec<Line<'static>> { + let options = tui_markdown::Options::new(MarkdownStyleSheet::new(*colors)); + let text = tui_markdown::from_str_with_options(content, &options); // Convert to our ratatui version's Line type and wrap to max_width let mut result = Vec::new(); + let mut in_code_block = false; for line in text.lines { // Convert ratatui-core Line to our ratatui Line - let converted_line = convert_line(line); + let mut converted_line = convert_line(line); + apply_markdown_theme(&mut converted_line, &mut in_code_block, colors); // Check if line needs wrapping let line_str = line_to_string(&converted_line); @@ -97,7 +156,12 @@ pub fn render_markdown(content: &str, max_width: usize) -> Vec<Line> { result.push(converted_line); } else { // Wrap the line - let wrapped = wrap_line(&line_str, max_width); + let wrap_style = converted_line + .spans + .first() + .map(|span| span.style) + .unwrap_or_else(|| Style::default().fg(colors.markdown_text)); + let wrapped = wrap_line(&line_str, max_width, wrap_style); result.extend(wrapped); } } @@ -105,14 +169,94 @@ pub fn render_markdown(content: &str, max_width: usize) -> Vec<Line> { result } +fn apply_markdown_theme(line: &mut Line<'_>, in_code_block: &mut bool, colors: &ThemeColors) { + let line_text = line_to_string(line); + let trimmed = line_text.trim_start(); + + if trimmed.starts_with("```") { + style_line(line, Style::default().fg(colors.markdown_code)); + *in_code_block = !*in_code_block; + return; + } + + if *in_code_block { + style_line( + line, + Style::default() + .fg(colors.markdown_code_block) + .bg(colors.background_element), + ); + return; + } + + if trimmed == "---" { + style_line(line, Style::default().fg(colors.markdown_horizontal_rule)); + return; + } + + if is_ordered_list_marker(trimmed) { + if let Some(span) = line.spans.first_mut() { + span.style = span.style.fg(colors.markdown_list_enumeration); + } + } else if is_unordered_list_marker(trimmed) { + if let Some(span) = line.spans.first_mut() { + span.style = span.style.fg(colors.markdown_list_item); + } + } + + for span in &mut line.spans { + if span.style.fg.is_some() { + continue; + } + + let modifiers = span.style.add_modifier; + let fg = if modifiers.contains(Modifier::BOLD) { + colors.markdown_strong + } else if modifiers.contains(Modifier::ITALIC) { + colors.markdown_emph + } else { + colors.markdown_text + }; + + span.style = span.style.fg(fg); + } +} + +fn style_line(line: &mut Line<'_>, style: Style) { + for span in &mut line.spans { + span.style = span.style.patch(style); + } +} + +fn is_unordered_list_marker(trimmed: &str) -> bool { + trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") +} + +fn is_ordered_list_marker(trimmed: &str) -> bool { + let mut chars = trimmed.chars().peekable(); + let mut saw_digit = false; + + while let Some(ch) = chars.peek() { + if ch.is_ascii_digit() { + saw_digit = true; + chars.next(); + } else { + break; + } + } + + saw_digit && chars.next() == Some('.') && chars.next() == Some(' ') +} + /// Convert a ratatui-core Line to our ratatui Line fn convert_line(line: ratatui_core::text::Line<'_>) -> Line<'static> { + let line_style = convert_style(line.style); let spans: Vec<ratatui::text::Span<'static>> = line .spans .into_iter() .map(|span| { let content = span.content.to_string(); - let style = convert_style(span.style); + let style = line_style.patch(convert_style(span.style)); ratatui::text::Span::styled(content, style) }) .collect(); @@ -142,6 +286,9 @@ fn convert_style(style: ratatui_core::style::Style) -> ratatui::style::Style { if modifiers.contains(ratatui_core::style::Modifier::ITALIC) { new_style = new_style.add_modifier(ratatui::style::Modifier::ITALIC); } + if modifiers.contains(ratatui_core::style::Modifier::DIM) { + new_style = new_style.add_modifier(ratatui::style::Modifier::DIM); + } if modifiers.contains(ratatui_core::style::Modifier::UNDERLINED) { new_style = new_style.add_modifier(ratatui::style::Modifier::UNDERLINED); } @@ -185,6 +332,30 @@ fn convert_color(color: ratatui_core::style::Color) -> ratatui::style::Color { } } +fn convert_color_to_core(color: ratatui::style::Color) -> ratatui_core::style::Color { + match color { + ratatui::style::Color::Reset => ratatui_core::style::Color::Reset, + ratatui::style::Color::Black => ratatui_core::style::Color::Black, + ratatui::style::Color::Red => ratatui_core::style::Color::Red, + ratatui::style::Color::Green => ratatui_core::style::Color::Green, + ratatui::style::Color::Yellow => ratatui_core::style::Color::Yellow, + ratatui::style::Color::Blue => ratatui_core::style::Color::Blue, + ratatui::style::Color::Magenta => ratatui_core::style::Color::Magenta, + ratatui::style::Color::Cyan => ratatui_core::style::Color::Cyan, + ratatui::style::Color::Gray => ratatui_core::style::Color::Gray, + ratatui::style::Color::DarkGray => ratatui_core::style::Color::DarkGray, + ratatui::style::Color::LightRed => ratatui_core::style::Color::LightRed, + ratatui::style::Color::LightGreen => ratatui_core::style::Color::LightGreen, + ratatui::style::Color::LightYellow => ratatui_core::style::Color::LightYellow, + ratatui::style::Color::LightBlue => ratatui_core::style::Color::LightBlue, + ratatui::style::Color::LightMagenta => ratatui_core::style::Color::LightMagenta, + ratatui::style::Color::LightCyan => ratatui_core::style::Color::LightCyan, + ratatui::style::Color::White => ratatui_core::style::Color::White, + ratatui::style::Color::Rgb(r, g, b) => ratatui_core::style::Color::Rgb(r, g, b), + ratatui::style::Color::Indexed(i) => ratatui_core::style::Color::Indexed(i), + } +} + /// Convert a Line to a String (for width calculation) fn line_to_string(line: &Line<'_>) -> String { line.spans @@ -194,18 +365,56 @@ fn line_to_string(line: &Line<'_>) -> String { } /// Wrap a line string into multiple lines respecting max_width -fn wrap_line(line_str: &str, max_width: usize) -> Vec<Line<'static>> { +fn wrap_line(line_str: &str, max_width: usize, style: Style) -> Vec<Line<'static>> { let wrapped = textwrap::wrap(line_str, max_width); wrapped .into_iter() - .map(|s| Line::from(s.to_string())) + .map(|s| Line::from(Span::styled(s.to_string(), style))) .collect() } #[cfg(test)] mod tests { use super::*; + use ratatui::style::Color; + + fn test_colors() -> ThemeColors { + ThemeColors { + primary: Color::Rgb(255, 140, 0), + secondary: Color::Rgb(255, 140, 0), + accent: Color::Rgb(255, 140, 0), + interactive: Color::Rgb(255, 140, 0), + background: Color::Reset, + dialog_background: Color::Reset, + background_element: Color::Reset, + text: Color::Reset, + text_weak: Color::Rgb(140, 140, 140), + text_strong: Color::Reset, + border: Color::Reset, + border_weak_focus: Color::Reset, + border_focus: Color::Reset, + border_strong_focus: Color::Reset, + success: Color::Rgb(0, 255, 0), + warning: Color::Rgb(255, 255, 0), + error: Color::Rgb(255, 0, 0), + info: Color::Rgb(0, 255, 255), + markdown_text: Color::Rgb(180, 255, 180), + markdown_heading: Color::Rgb(0, 255, 255), + markdown_link: Color::Rgb(0, 200, 255), + markdown_link_text: Color::Rgb(80, 240, 240), + markdown_code: Color::Rgb(0, 255, 0), + markdown_block_quote: Color::Rgb(180, 180, 180), + markdown_emph: Color::Rgb(255, 210, 120), + markdown_strong: Color::Rgb(255, 255, 120), + markdown_horizontal_rule: Color::Rgb(100, 100, 100), + markdown_list_item: Color::Rgb(0, 255, 255), + markdown_list_enumeration: Color::Rgb(80, 240, 240), + markdown_image: Color::Rgb(0, 200, 255), + markdown_image_text: Color::Rgb(80, 240, 240), + markdown_code_block: Color::Rgb(180, 255, 180), + } + } #[test] fn test_streaming_renderer_new() { @@ -237,7 +446,8 @@ mod tests { #[test] fn test_render_markdown_basic() { - let lines = render_markdown("# Hello\n\nThis is **bold** and *italic*.", 80); + let colors = test_colors(); + let lines = render_markdown("# Hello\n\nThis is **bold** and *italic*.", 80, &colors); // Should have parsed into lines assert!(!lines.is_empty()); @@ -245,15 +455,22 @@ mod tests { #[test] fn test_render_code_block() { - let lines = render_markdown("```rust\nfn main() {\n println!(\"Hello\");\n}\n```", 80); + let colors = test_colors(); + let lines = render_markdown( + "```rust\nfn main() {\n println!(\"Hello\");\n}\n```", + 80, + &colors, + ); assert!(!lines.is_empty()); } #[test] fn test_render_with_wrapping() { + let colors = test_colors(); let lines = render_markdown( "This is a long line that needs wrapping because it exceeds the maximum width.", 20, + &colors, ); // Should produce multiple lines due to wrapping assert!(lines.len() > 1); diff --git a/src/views/session_rename_dialog.rs b/src/views/session_rename_dialog.rs index d4ea12f..c1a539e 100644 --- a/src/views/session_rename_dialog.rs +++ b/src/views/session_rename_dialog.rs @@ -89,6 +89,7 @@ impl Default for SessionRenameDialogState { interactive: Color::Rgb(255, 140, 0), background: Color::Reset, dialog_background: Color::Reset, + background_element: Color::Reset, text: Color::Reset, text_weak: Color::Reset, text_strong: Color::Reset, @@ -100,6 +101,20 @@ impl Default for SessionRenameDialogState { warning: Color::Rgb(255, 255, 0), error: Color::Rgb(255, 0, 0), info: Color::Rgb(0, 255, 255), + markdown_text: Color::Reset, + markdown_heading: Color::Rgb(255, 140, 0), + markdown_link: Color::Rgb(0, 255, 255), + markdown_link_text: Color::Rgb(0, 255, 255), + markdown_code: Color::Rgb(0, 255, 0), + markdown_block_quote: Color::Rgb(255, 255, 0), + markdown_emph: Color::Rgb(255, 255, 0), + markdown_strong: Color::Rgb(255, 140, 0), + markdown_horizontal_rule: Color::Reset, + markdown_list_item: Color::Rgb(255, 140, 0), + markdown_list_enumeration: Color::Rgb(0, 255, 255), + markdown_image: Color::Rgb(255, 140, 0), + markdown_image_text: Color::Rgb(0, 255, 255), + markdown_code_block: Color::Reset, }) } } From c9436d2883be0026c698974d2bbd4b7d2fd37ae2 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 11:10:49 +0800 Subject: [PATCH 027/226] feat: added permission-gated tool calls and agent-specific tools (Plan and Build). --- .gitignore | 1 + README.md | 4 + TOOL_SYSTEM_PERMISSIONS.md | 222 +++++++++++++++ src/app.rs | 69 ++++- src/config/configuration.rs | 62 +++++ src/llm/client.rs | 15 +- src/llm/mod.rs | 1 + src/tools/aisdk_bridge.rs | 22 +- src/tools/fs/glob.rs | 189 ++++++++----- src/tools/fs/grep.rs | 199 ++++++++++++++ src/tools/fs/list.rs | 42 ++- src/tools/fs/mod.rs | 2 + src/tools/fs/read.rs | 127 ++++++++- src/tools/fs/write.rs | 20 +- src/tools/init.rs | 3 +- src/tools/mod.rs | 4 + src/tools/permission.rs | 487 +++++++++++++++++++++++++++++++++ src/ui/components/chat.rs | 126 ++++++++- src/views/mod.rs | 2 + src/views/permission_dialog.rs | 332 ++++++++++++++++++++++ 20 files changed, 1824 insertions(+), 105 deletions(-) create mode 100644 TOOL_SYSTEM_PERMISSIONS.md create mode 100644 src/tools/fs/grep.rs create mode 100644 src/tools/permission.rs create mode 100644 src/views/permission_dialog.rs diff --git a/.gitignore b/.gitignore index aa38b32..90f293e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ app.log sounds/complete.wav _dev_reference1 +.env diff --git a/README.md b/README.md index 3a13df5..4ebfc3a 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,12 @@ This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/o - [x] The cheapest model providers (GLM, etc.) - [x] A ding sound, my only opencode plugin at the moment. - [x] No reverse-engineering oauth from big AI (Codex, Claude Code, Gemini), at least for now (Don't wanna get in trouble). +- [ ] ChatGPT oauth (because I use it) +- [ ] Copy chat contents, copy the chat input +- [ ] Image inputs - [ ] Possibly ralphy? (very far, idk how to do that) - [ ] ACP w/ Zed? (very far, idk how to do that) +- [x] No Claude Code oauth spoofing. - [x] No plugin ecosystem (If I think it's worth building, just make it built-in and configurable) - [x] No desktop app - [x] No web sharing thing (Might be a dealbreaker for vibecoders w/ tailscale, but I haven't reached these levels yet, when I do, I might) diff --git a/TOOL_SYSTEM_PERMISSIONS.md b/TOOL_SYSTEM_PERMISSIONS.md new file mode 100644 index 0000000..80e9b1e --- /dev/null +++ b/TOOL_SYSTEM_PERMISSIONS.md @@ -0,0 +1,222 @@ +# Tool System + Permissions Implementation Plan + +## Goal + +Bring crabcode close to OpenCode basic-tool and permission behavior while fitting the current Rust architecture. + +## Scope + +This plan covers: + +1. Agent-specific tool access (Plan vs Build vs custom agents). +2. Permission-gated execution UX for blocked tool calls. +3. Nuanced permission checks for paths, gitignored files, and sensitive files. +4. Core tool parity improvements needed to support these workflows. + +This plan does **not** attempt full feature parity with every OpenCode tool in one pass. + +## Current State (crabcode) + +- Tools are globally registered and effectively globally available. +- Agent mode exists in UI, but does not materially change tool access. +- Permission handling is mostly hard-coded guardrails inside individual tools. +- No generic "ask user and resume" permission workflow in the execution pipeline. +- `glob`/`list` do not use gitignore-aware file discovery. +- Sensitive reads (like `.env`) are not centrally permission-managed. + +## Target State + +- Tool availability is resolved from active agent policy (Plan/Build/custom). +- Tool execution passes through a centralized permission engine. +- Blocked actions become interactive permission requests (deny, allow once, allow always). +- Permission requests trigger existing sound/notification hooks. +- File discovery and path checks are gitignore-aware and external-directory-aware. +- Sensitive path patterns (especially `.env*`) are permission-gated for reads and writes. + +## Parity Matrix (high-level) + +1. **Tool filtering by agent** + - Current: static registry. + - Target: runtime filtering based on active agent + configured permissions. +2. **Permission engine** + - Current: ad-hoc checks in tool implementations. + - Target: shared rule evaluator with `allow | ask | deny` and pattern matching. +3. **Permission prompt lifecycle** + - Current: missing. + - Target: request queue + UI prompt + decision persistence. +4. **External directory access** + - Current: inconsistent. + - Target: centralized check requiring permission for outside-workdir access. +5. **Gitignore-aware operations** + - Current: weak coverage. + - Target: default ignore behavior for discovery tools and guarded writes. +6. **Sensitive file policy (`.env*`)** + - Current: limited write-only hard block. + - Target: read/write permission policy with ask/deny defaults. + +## Proposed Architecture + +### 1) Permission Domain Model + +Add a `permission` module with: + +- `PermissionDecision`: `Allow`, `Ask`, `Deny`. +- `PermissionRule`: pattern + decision + optional tool scope. +- `PermissionRequest`: tool name, action type, target path/command metadata, reason text. +- `PermissionResponse`: `Deny`, `AllowOnce`, `AllowAlways`. + +Add pattern matching rules with: + +- last-match-wins, +- wildcard support for tool and path patterns, +- separate defaults per action category (read/write/exec/network if needed later). + +### 2) Execution Interceptor + +Introduce a centralized preflight in the tool execution path (before tool handler runs): + +1. Build execution context (active agent, tool name, arguments metadata). +2. Resolve tool availability from agent policy. +3. Run permission evaluation: + - if `Allow`: execute immediately, + - if `Deny`: return denied error, + - if `Ask`: emit permission request and suspend execution. +4. Resume execution only after explicit UI response. + +This should replace scattered one-off permission checks where feasible. + +### 3) Session Permission State + +Maintain session-scoped permission state: + +- pending request queue (at most one active prompt in UI), +- once-grants keyed to request signature, +- always-grants persisted in config/runtime policy store, +- rejection tracking for repeat behavior. + +### 4) Agent Tool Policy Layer + +Define agent policy in config/runtime: + +- `plan`: restricted tools (no mutating filesystem by default, no bash by default unless explicitly enabled). +- `build`: full engineering toolset with permission checks. +- `custom`: explicit allow/deny lists inherited from base defaults. + +Effective tool list = `registered tools` intersect `agent allowed tools` intersect `permission-enabled tools`. + +### 5) File Safety and Path Policy + +Create shared path-policy helpers: + +- `is_outside_workdir(path)`, +- `is_gitignored(path)` (via a gitignore-aware matcher), +- `is_sensitive_env_path(path)` for `.env*` and related secrets. + +Use these in read/write/edit/glob/list/grep style tools. + +## Implementation Phases + +## Phase 0: Baseline and Safety + +1. Add integration tests that capture current behavior for read/write/glob/list/bash. +2. Add snapshot tests for active tool list by mode (Plan/Build). +3. Add fixtures for gitignored files and external directory targets. + +Deliverable: failing tests for target behavior, safety net for refactors. + +## Phase 1: Permission Engine Foundation + +1. Implement permission types, matcher, and evaluator with `allow|ask|deny`. +2. Add config parsing and in-memory policy defaults. +3. Add unit tests for wildcard matching, precedence, and default fallbacks. + +Deliverable: reusable evaluator independent from tool implementations. + +## Phase 2: Execution Pipeline Integration + +1. Add preflight interceptor in tool execution pipeline. +2. Convert tool permission failures to centralized permission requests. +3. Implement suspend/resume flow for tool calls waiting on user decision. +4. Keep backward-compatible errors until UI flow is fully wired. + +Deliverable: tools route through centralized permission logic. + +## Phase 3: UI Permission Prompt + Sound + +1. Add UI state for pending permission request and decision actions. +2. Present clear prompt with: tool, target, reason, risk hint. +3. Add actions: `Deny`, `Allow Once`, `Allow Always`. +4. Trigger permission sound and notification hooks on new prompt. +5. Ensure decision feeds back to session processor and resumes execution. + +Deliverable: end-to-end permission request UX. + +## Phase 4: Agent-Specific Tool Access + +1. Define Plan/Build default tool policies. +2. Add custom agent policy schema support. +3. Apply policy when exposing tool schemas to the model. +4. Add tests ensuring unavailable tools are not callable per active agent. + +Deliverable: active mode materially controls tool access. + +## Phase 5: Path + Gitignore + Sensitive File Rules + +1. Implement centralized external-directory checks for fs tools. +2. Make glob/list (and grep when added) gitignore-aware by default. +3. Add write/edit ask behavior for gitignored targets. +4. Add read/write ask-or-deny defaults for `.env*` and similar secrets. +5. Replace legacy hard-coded `.env` write block with policy-based handling. + +Deliverable: nuanced, predictable path safety behavior. + +## Phase 6: Basic Tool Parity Additions + +1. Add `grep` tool (regex content search) with permission preflight. +2. Revisit `glob` and `list` behavior to align with documented semantics. +3. Ensure tool argument schemas and guidance match runtime behavior. + +Deliverable: stronger basic-tool parity baseline. + +## Validation Checklist + +- Plan agent cannot access disallowed mutating tools. +- Build agent can access full configured set, still permission-gated. +- Read outside workspace triggers ask flow and respects user decision. +- Write outside workspace triggers ask flow and can be denied. +- Writes to gitignored paths trigger ask flow. +- Discovery tools do not leak ignored files by default. +- Reading `.env` requires explicit permission. +- Permission prompt plays sound and appears in UI with actionable choices. +- `Allow Once` applies only to matching request. +- `Allow Always` persists and suppresses repeated prompts. +- Deny returns clear error to model and user transcript. + +## Test Strategy + +1. **Unit tests** + - permission matcher precedence and wildcard behavior, + - path classification helpers (external/gitignored/sensitive). +2. **Integration tests** + - tool call preflight and blocked/resume flow, + - agent-mode tool exposure and invocation constraints. +3. **UI tests** + - permission prompt render and action dispatch, + - sound/notification trigger on permission request. +4. **Regression tests** + - existing safe bash checks still enforced, + - existing tool outputs remain stable where semantics unchanged. + +## Rollout Notes + +- Ship behind a feature flag for initial validation. +- Log permission decisions during beta to tune defaults. +- Document final config behavior in `_docs/config.mdx` once implementation lands. + +## Recommended Build Order + +1. Phase 1 and Phase 2 (permission core + interceptor). +2. Phase 4 (agent tool gating) so model behavior aligns early. +3. Phase 3 (UI prompt) to unlock interactive approvals. +4. Phase 5 (path/gitignore/sensitive policy hardening). +5. Phase 6 (extra tool parity work). diff --git a/src/app.rs b/src/app.rs index 3998a79..01f6662 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,6 +24,10 @@ use crate::views::models_dialog::{ handle_models_dialog_key_event, handle_models_dialog_mouse_event, init_models_dialog, render_models_dialog, }; +use crate::views::permission_dialog::{ + handle_permission_dialog_key_event, handle_permission_dialog_mouse_event, + init_permission_dialog, render_permission_dialog, PermissionDialogAction, +}; use crate::views::session_rename_dialog::{ handle_session_rename_dialog_key_event, init_session_rename_dialog, render_session_rename_dialog, RenameAction, @@ -41,8 +45,8 @@ use crate::views::themes_dialog::{ render_themes_dialog, }; use crate::views::{ - ChatState, ConnectDialogState, HomeState, ModelsDialogState, SessionRenameDialogState, - SessionsDialogState, SuggestionsPopupState, ThemesDialogState, + ChatState, ConnectDialogState, HomeState, ModelsDialogState, PermissionDialogState, + SessionRenameDialogState, SessionsDialogState, SuggestionsPopupState, ThemesDialogState, }; use crate::{ @@ -80,6 +84,7 @@ pub enum OverlayFocus { SuggestionsPopup, SessionsDialog, SessionRenameDialog, + PermissionDialog, WhichKey, } @@ -99,6 +104,7 @@ pub struct App { pub connect_dialog_state: ConnectDialogState, pub sessions_dialog_state: SessionsDialogState, pub session_rename_dialog_state: SessionRenameDialogState, + pub permission_dialog_state: PermissionDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, pub api_key_input: crate::ui::components::api_key_input::ApiKeyInput, pub prefs_dao: Option<crate::persistence::PrefsDAO>, @@ -114,6 +120,7 @@ pub struct App { pub current_theme_index: usize, pub dark_mode: bool, pub sounds: crate::sound::ResolvedSoundsConfig, + pub tool_permissions: crate::tools::ToolPermissions, pub is_streaming: bool, chunk_sender: Option<crate::llm::ChunkSender>, chunk_receiver: Option<crate::llm::ChunkReceiver>, @@ -145,13 +152,14 @@ impl App { .unwrap_or_else(|| "?".to_string()); let home_state = init_home(); - let agent = "Plan".to_string(); + let mut agent = "Plan".to_string(); let chat = Chat::new(); let suggestions_popup_state = init_suggestions_popup(Popup::new()); let models_dialog_state = init_models_dialog("Models", vec![]); let themes_dialog_state = init_themes_dialog("Themes", vec![]); let connect_dialog_state = init_connect_dialog(); let sessions_dialog_state = init_sessions_dialog("Sessions", vec![]); + let permission_dialog_state = init_permission_dialog(); let which_key_state = crate::views::which_key::init_which_key(); let api_key_input = crate::ui::components::api_key_input::ApiKeyInput::new(); @@ -185,6 +193,12 @@ impl App { ); } + if let Some(default_agent) = loaded_config.merged_config.default_agent.clone() { + if !default_agent.trim().is_empty() { + agent = default_agent; + } + } + let (resolved_sounds, sound_warnings) = crate::sound::resolve_effective_sounds(&loaded_config.merged_config.sounds); if !sound_warnings.is_empty() { @@ -245,6 +259,12 @@ impl App { let chat_state = init_chat(chat, &agent, &colors); let session_rename_dialog_state = init_session_rename_dialog(colors); + let mut agent_policies = crate::tools::AgentToolPolicies::default(); + for (mode, tools) in &loaded_config.merged_config.agent_tool_policies { + agent_policies = agent_policies.with_custom_tools(mode.clone(), tools.clone()); + } + let tool_permissions = crate::tools::ToolPermissions::new(cwd_path.clone()) + .with_agent_policies(agent_policies); Ok(Self { running: true, @@ -262,6 +282,7 @@ impl App { connect_dialog_state, sessions_dialog_state, session_rename_dialog_state, + permission_dialog_state, which_key_state, api_key_input, prefs_dao, @@ -277,6 +298,7 @@ impl App { current_theme_index, dark_mode: true, sounds: resolved_sounds, + tool_permissions, is_streaming: false, chunk_sender: None, chunk_receiver: None, @@ -678,6 +700,24 @@ impl App { } } } + OverlayFocus::PermissionDialog => { + let action = + handle_permission_dialog_key_event(&mut self.permission_dialog_state, key); + match action { + PermissionDialogAction::Respond(response) => { + self.permission_dialog_state.respond_current(response); + if self.permission_dialog_state.has_active() { + self.overlay_focus = OverlayFocus::PermissionDialog; + } else { + self.chat_state.chat.resume_streaming_tps_timer(); + self.overlay_focus = OverlayFocus::None; + } + true + } + PermissionDialogAction::Handled => true, + PermissionDialogAction::NotHandled => true, + } + } OverlayFocus::WhichKey => { let action = self.which_key_state.handle_key_event(key); match action { @@ -864,6 +904,8 @@ impl App { if !self.models_dialog_state.dialog.is_visible() { self.overlay_focus = OverlayFocus::None; } + } else if self.overlay_focus == OverlayFocus::PermissionDialog { + let _ = handle_permission_dialog_mouse_event(&mut self.permission_dialog_state, mouse); } else if self.overlay_focus == OverlayFocus::ThemesDialog { let before = self .themes_dialog_state @@ -1592,6 +1634,11 @@ impl App { } fn cleanup_streaming(&mut self) { + self.chat_state.chat.resume_streaming_tps_timer(); + self.permission_dialog_state.clear_with_deny(); + if self.overlay_focus == OverlayFocus::PermissionDialog { + self.overlay_focus = OverlayFocus::None; + } self.chunk_sender = None; self.chunk_receiver = None; self.streaming_cancel_token = None; @@ -1813,6 +1860,12 @@ impl App { .add_message(crate::session::types::Message::tool(content)); } } + crate::llm::ChunkMessage::PermissionRequest(prompt) => { + self.play_sound_event(crate::sound::SoundEvent::Permission); + self.chat_state.chat.pause_streaming_tps_timer(); + self.permission_dialog_state.enqueue(prompt); + self.overlay_focus = OverlayFocus::PermissionDialog; + } } } } @@ -1857,6 +1910,8 @@ impl App { let provider_name = self.provider_name.clone(); let model = self.model.clone(); + let agent_mode = self.agent.clone(); + let tool_permissions = self.tool_permissions.clone(); let cwd = self.cwd.clone(); let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); @@ -1891,6 +1946,8 @@ impl App { cancel_token, provider_name, model, + agent_mode, + tool_permissions, messages, sender_clone.clone(), ), @@ -2082,6 +2139,12 @@ impl App { render_session_rename_dialog(f, &mut self.session_rename_dialog_state, size, colors); } + if self.overlay_focus == OverlayFocus::PermissionDialog + && self.permission_dialog_state.has_active() + { + render_permission_dialog(f, &mut self.permission_dialog_state, size, colors); + } + if self.overlay_focus == OverlayFocus::WhichKey { crate::views::which_key::render_which_key(f, &self.which_key_state, &colors); } diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 7e3ce2b..ffd9438 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -164,6 +164,8 @@ impl Default for SoundsConfig { pub struct MergedConfig { pub theme: Option<String>, pub model: Option<String>, + pub default_agent: Option<String>, + pub agent_tool_policies: HashMap<String, Vec<String>>, pub sounds: SoundsConfig, } @@ -757,11 +759,71 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M } } + if let Some(Value::String(default_agent)) = obj.get("default_agent") { + if !default_agent.trim().is_empty() { + out.default_agent = Some(default_agent.trim().to_string()); + } + } + + out.agent_tool_policies = parse_agent_tool_policies(obj.get("agent"), diagnostics); + out.sounds = parse_sounds(obj.get("sounds"), diagnostics); out } +fn parse_agent_tool_policies( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> HashMap<String, Vec<String>> { + let mut out = HashMap::new(); + let Some(Value::Object(agents)) = value else { + return out; + }; + + for (name, val) in agents { + let Some(agent_obj) = val.as_object() else { + continue; + }; + + let Some(tools_val) = agent_obj.get("tools") else { + continue; + }; + + let mut tools = Vec::new(); + match tools_val { + Value::Array(arr) => { + for item in arr { + if let Some(s) = item.as_str() { + let trimmed = s.trim(); + if !trimmed.is_empty() { + tools.push(trimmed.to_ascii_lowercase()); + } + } + } + } + Value::String(s) => { + let trimmed = s.trim(); + if !trimmed.is_empty() { + tools.push(trimmed.to_ascii_lowercase()); + } + } + _ => { + diagnostics.warnings.push(format!( + "agent.{}.tools must be a string or array of strings", + name + )); + } + } + + if !tools.is_empty() { + out.insert(name.trim().to_ascii_lowercase(), tools); + } + } + + out +} + fn parse_sounds(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> SoundsConfig { let mut sounds = SoundsConfig::default(); let Some(Value::Object(map)) = value else { diff --git a/src/llm/client.rs b/src/llm/client.rs index c56a197..918f54b 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -48,7 +48,10 @@ impl LLMClient { let aisdk_messages = self.convert_messages(messages); let tool_registry = crate::tools::initialize_tool_registry().await; - let aisdk_tools = convert_to_aisdk_tools(&tool_registry, None).await; + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let permissions = crate::tools::ToolPermissions::new(cwd); + let aisdk_tools = + convert_to_aisdk_tools(&tool_registry, None, "Build".to_string(), permissions).await; let provider_kind = self.provider_kind(); let base_url = provider_kind.normalize_base_url(&self.base_url); @@ -183,6 +186,8 @@ pub async fn stream_llm_with_cancellation( cancel_token: CancellationToken, provider_name: String, model: String, + agent_mode: String, + tool_permissions: crate::tools::ToolPermissions, messages: Vec<crate::session::types::Message>, sender: crate::llm::ChunkSender, ) -> Result<(), Box<dyn std::error::Error>> { @@ -220,7 +225,13 @@ pub async fn stream_llm_with_cancellation( let aisdk_messages = convert_messages(&messages); let tool_registry = crate::tools::initialize_tool_registry().await; - let aisdk_tools = convert_to_aisdk_tools(&tool_registry, Some(sender.clone())).await; + let aisdk_tools = convert_to_aisdk_tools( + &tool_registry, + Some(sender.clone()), + agent_mode, + tool_permissions, + ) + .await; let response = match provider_kind { ProviderKind::OpenAICompatible => { diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 709b736..f62a3bd 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -13,6 +13,7 @@ pub enum ChunkMessage { Warning(String), ToolCalls(Vec<ToolCall>), ToolResult(ToolCallResult), + PermissionRequest(crate::tools::PermissionPrompt), End, Failed(String), Cancelled, diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 4f84a0b..b75679b 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -12,15 +12,23 @@ static TOOL_CALL_SEQ: AtomicUsize = AtomicUsize::new(0); pub async fn convert_to_aisdk_tools( registry: &ToolRegistry, sender: Option<ChunkSender>, + agent_mode: String, + permissions: crate::tools::ToolPermissions, ) -> Vec<Tool> { let mut aisdk_tools = Vec::new(); let tools = registry.list().await; for tool_def in tools { + if !permissions.is_tool_allowed_for_agent(&agent_mode, &tool_def.id) { + continue; + } + let tool_id = tool_def.id.clone(); let tool_description = tool_def.description.clone(); let registry = registry.clone(); let sender = sender.clone(); + let agent_mode = agent_mode.clone(); + let permissions = permissions.clone(); // Create the execute function let execute = ToolExecute::new(Box::new(move |input: Value| { @@ -32,6 +40,8 @@ pub async fn convert_to_aisdk_tools( let tool_description_for_ui = tool_description.clone(); let registry = registry.clone(); let sender = sender.clone(); + let agent_mode = agent_mode.clone(); + let permissions = permissions.clone(); let call_seq = TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1; let call_id = format!("call_{call_seq}"); @@ -73,8 +83,18 @@ pub async fn convert_to_aisdk_tools( return Err(format!("Validation error: {}", e)); } + permissions + .preflight( + &agent_mode, + &tool_id_for_exec, + &input, + sender_for_block.as_ref(), + ) + .await + .map_err(|e| format!("{}", e))?; + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); - let ctx = ToolContext::new("session", "message", "aisdk", abort_rx); + let ctx = ToolContext::new("session", "message", agent_mode.clone(), abort_rx); let tool_result = handler .execute(input, &ctx) diff --git a/src/tools/fs/glob.rs b/src/tools/fs/glob.rs index 840719a..c2ba61b 100644 --- a/src/tools/fs/glob.rs +++ b/src/tools/fs/glob.rs @@ -4,7 +4,7 @@ use crate::tools::{ }; use async_trait::async_trait; use serde_json::Value; -use std::path::Path; +use std::path::{Path, PathBuf}; pub struct GlobTool; @@ -12,6 +12,11 @@ impl GlobTool { pub fn new() -> Self { Self } + + fn is_in_git_metadata(path: &Path, base: &Path) -> bool { + let rel = path.strip_prefix(base).unwrap_or(path); + rel.components().any(|component| component.as_os_str() == ".git") + } } #[async_trait] @@ -20,7 +25,7 @@ impl ToolHandler for GlobTool { Tool { id: "glob".to_string(), description: - "Find files by glob pattern. Returns file paths sorted by modification time." + "Find files by glob pattern. Includes hidden/gitignored files, excluding .git internals. Returns paths sorted by modification time." .to_string(), parameters: vec![ ParameterSchema { @@ -51,74 +56,136 @@ impl ToolHandler for GlobTool { .ok_or_else(|| ToolError::Validation("pattern is required".to_string()))?; let base_path = get_string_param(¶ms, "path").unwrap_or_else(|| ".".to_string()); + let base = PathBuf::from(&base_path); - let pattern_path = Path::new(&base_path).join(&pattern); - let pattern_str = pattern_path - .to_str() - .ok_or_else(|| ToolError::Execution("Invalid path encoding".to_string()))?; - - let mut entries: Vec<(glob::Paths, String)> = Vec::new(); - - match glob::glob(pattern_str) { - Ok(paths) => { - let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new(); - - for entry in paths { - match entry { - Ok(path) => { - if let Ok(metadata) = std::fs::metadata(&path) { - if let Ok(modified) = metadata.modified() { - files.push((path, modified)); - } else { - files.push((path, std::time::SystemTime::UNIX_EPOCH)); - } - } - } - Err(e) => { - return Err(ToolError::Execution(format!("Glob error: {}", e))); - } - } - } + if !base.exists() { + return Err(ToolError::NotFound(format!( + "Path not found: {}", + base_path + ))); + } - files.sort_by(|a, b| b.1.cmp(&a.1)); + let glob_pattern = glob::Pattern::new(&pattern) + .map_err(|e| ToolError::Validation(format!("Invalid glob pattern: {}", e)))?; - let limit = 100; - let total = files.len(); - let truncated = total > limit; + let pattern_is_absolute = Path::new(&pattern).is_absolute(); - let output: Vec<String> = files - .into_iter() - .take(limit) - .map(|(path, _)| path.display().to_string()) - .collect(); + let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new(); + + if base.is_file() { + let candidate = base.clone(); + let rel = candidate.strip_prefix(&base).unwrap_or(&candidate); + let matches = if pattern_is_absolute { + glob_pattern.matches_path(&candidate) + } else { + glob_pattern.matches_path(rel) + }; + + if matches { + let modified = std::fs::metadata(&candidate) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + files.push((candidate, modified)); + } + } else { + let mut walker = ignore::WalkBuilder::new(&base); + walker + .hidden(false) + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false) + .standard_filters(false); + + for entry in walker.build() { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let path = entry.path(); + if Self::is_in_git_metadata(path, &base) { + continue; + } + + if !path.is_file() { + continue; + } - let result_text = if output.is_empty() { - "No files found matching pattern.".to_string() + let rel = path.strip_prefix(&base).unwrap_or(path); + let matches = if pattern_is_absolute { + glob_pattern.matches_path(path) } else { - let mut text = output.join("\n"); - if truncated { - text.push_str(&format!( - "\n\n... and {} more files (showing first {})", - total - limit, - limit - )); - } - text + glob_pattern.matches_path(rel) }; - Ok(ToolResult::new(format!("Glob: {}", pattern), result_text) - .with_metadata( - "match_count", - serde_json::Value::Number((total as i64).into()), - ) - .with_metadata( - "shown_count", - serde_json::Value::Number(((total.min(limit)) as i64).into()), - ) - .with_metadata("limit", serde_json::Value::Number((limit as i64).into())) - .with_metadata("truncated", serde_json::Value::Bool(truncated))) + if !matches { + continue; + } + + let modified = std::fs::metadata(path) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + files.push((path.to_path_buf(), modified)); } - Err(e) => Err(ToolError::Execution(format!("Invalid glob pattern: {}", e))), } + + files.sort_by(|a, b| b.1.cmp(&a.1)); + + let limit = 100; + let total = files.len(); + let truncated = total > limit; + + let output: Vec<String> = files + .into_iter() + .take(limit) + .map(|(path, _)| path.display().to_string()) + .collect(); + + let result_text = if output.is_empty() { + "No files found matching pattern.".to_string() + } else { + let mut text = output.join("\n"); + if truncated { + text.push_str(&format!( + "\n\n... and {} more files (showing first {})", + total - limit, + limit + )); + } + text + }; + + Ok(ToolResult::new(format!("Glob: {}", pattern), result_text) + .with_metadata( + "match_count", + serde_json::Value::Number((total as i64).into()), + ) + .with_metadata( + "shown_count", + serde_json::Value::Number(((total.min(limit)) as i64).into()), + ) + .with_metadata("limit", serde_json::Value::Number((limit as i64).into())) + .with_metadata("truncated", serde_json::Value::Bool(truncated))) + } +} + +#[cfg(test)] +mod tests { + use super::GlobTool; + use std::path::Path; + + #[test] + fn detects_git_metadata_paths() { + let base = Path::new("/tmp/workspace"); + assert!(GlobTool::is_in_git_metadata( + Path::new("/tmp/workspace/.git/config"), + base + )); + assert!(!GlobTool::is_in_git_metadata( + Path::new("/tmp/workspace/.gitignore"), + base + )); } } diff --git a/src/tools/fs/grep.rs b/src/tools/fs/grep.rs new file mode 100644 index 0000000..c6605b3 --- /dev/null +++ b/src/tools/fs/grep.rs @@ -0,0 +1,199 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +const BINARY_CHECK_SIZE: usize = 8192; +const RESULT_LIMIT: usize = 200; + +pub struct GrepTool; + +impl GrepTool { + pub fn new() -> Self { + Self + } + + fn is_binary(data: &[u8]) -> bool { + data.iter().take(BINARY_CHECK_SIZE).any(|b| *b == 0) + } + + fn include_matches(include: &Option<glob::Pattern>, path: &Path, base: &Path) -> bool { + let Some(include) = include else { + return true; + }; + + let rel = path.strip_prefix(base).unwrap_or(path); + include.matches_path(rel) || include.matches_path(path) + } +} + +#[async_trait] +impl ToolHandler for GrepTool { + fn definition(&self) -> Tool { + Tool { + id: "grep".to_string(), + description: "Search file contents using regex and return matching lines with file paths and line numbers.".to_string(), + parameters: vec![ + ParameterSchema { + name: "pattern".to_string(), + description: "Regex pattern to search for".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "path".to_string(), + description: "Directory or file to search (default: current directory)" + .to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "include".to_string(), + description: "Optional glob filter for files (for example *.rs, *.{ts,tsx})" + .to_string(), + required: false, + param_type: ParameterType::String, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["pattern"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> { + let pattern = get_string_param(¶ms, "pattern") + .ok_or_else(|| ToolError::Validation("pattern is required".to_string()))?; + let path_str = get_string_param(¶ms, "path").unwrap_or_else(|| ".".to_string()); + let include = get_string_param(¶ms, "include"); + + let regex = regex::Regex::new(&pattern) + .map_err(|e| ToolError::Validation(format!("Invalid regex pattern: {}", e)))?; + + let base = PathBuf::from(&path_str); + if !base.exists() { + return Err(ToolError::NotFound(format!("Path not found: {}", path_str))); + } + + let include_pattern = if let Some(ref include_glob) = include { + Some(glob::Pattern::new(include_glob).map_err(|e| { + ToolError::Validation(format!("Invalid include glob pattern: {}", e)) + })?) + } else { + None + }; + + let mut output = Vec::new(); + let mut total_matches = 0usize; + let mut matched_files = 0usize; + + if base.is_file() { + if Self::include_matches(&include_pattern, &base, &base.parent().unwrap_or(&base)) { + let content = std::fs::read(&base) + .map_err(|e| ToolError::Execution(format!("Failed to read file: {}", e)))?; + + if !Self::is_binary(&content) { + let text = String::from_utf8_lossy(&content); + let mut file_had_match = false; + for (idx, line) in text.lines().enumerate() { + if regex.is_match(line) { + total_matches += 1; + file_had_match = true; + if output.len() < RESULT_LIMIT { + output.push(format!( + "{}:{}: {}", + base.display(), + idx + 1, + line.trim_end() + )); + } + } + } + if file_had_match { + matched_files += 1; + } + } + } + } else { + let mut walker = ignore::WalkBuilder::new(&base); + walker + .hidden(false) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .parents(true) + .standard_filters(true); + + for entry in walker.build() { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let path = entry.path(); + if !path.is_file() { + continue; + } + + if !Self::include_matches(&include_pattern, path, &base) { + continue; + } + + let content = match std::fs::read(path) { + Ok(c) => c, + Err(_) => continue, + }; + + if Self::is_binary(&content) { + continue; + } + + let text = String::from_utf8_lossy(&content); + let mut file_had_match = false; + for (idx, line) in text.lines().enumerate() { + if regex.is_match(line) { + total_matches += 1; + file_had_match = true; + if output.len() < RESULT_LIMIT { + output.push(format!( + "{}:{}: {}", + path.display(), + idx + 1, + line.trim_end() + )); + } + } + } + + if file_had_match { + matched_files += 1; + } + } + } + + let truncated = total_matches > RESULT_LIMIT; + let result_text = if output.is_empty() { + "No matches found.".to_string() + } else { + let mut text = output.join("\n"); + if truncated { + text.push_str(&format!( + "\n\n... and {} more matches (showing first {})", + total_matches - RESULT_LIMIT, + RESULT_LIMIT + )); + } + text + }; + + Ok(ToolResult::new(format!("Grep: {}", pattern), result_text) + .with_metadata("match_count", serde_json::json!(total_matches)) + .with_metadata("file_count", serde_json::json!(matched_files)) + .with_metadata("truncated", serde_json::json!(truncated)) + .with_metadata("limit", serde_json::json!(RESULT_LIMIT))) + } +} diff --git a/src/tools/fs/list.rs b/src/tools/fs/list.rs index 5946f7d..d634092 100644 --- a/src/tools/fs/list.rs +++ b/src/tools/fs/list.rs @@ -13,6 +13,16 @@ impl ListTool { Self } + fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool { + // Keep repository internals out of default tree output while still + // surfacing other dotfiles (for example .env, .env.local). + if name == ".git" { + return true; + } + + ignore_patterns.iter().any(|p| name.contains(p)) + } + fn list_directory( path: &Path, ignore_patterns: &[String], @@ -46,7 +56,7 @@ impl ListTool { .into_iter() .filter(|entry| { let name = entry.file_name().to_string_lossy().to_string(); - !name.starts_with('.') && !ignore_patterns.iter().any(|p| name.contains(p)) + !Self::should_skip_entry(&name, ignore_patterns) }) .collect(); @@ -89,7 +99,8 @@ impl ToolHandler for ListTool { fn definition(&self) -> Tool { Tool { id: "list".to_string(), - description: "List directory contents in a tree format. Shows files and subdirectories with visual tree connectors.".to_string(), + description: "List directory contents in a tree format. Includes hidden and gitignored files (except .git internals)." + .to_string(), parameters: vec![ ParameterSchema { name: "path".to_string(), @@ -158,7 +169,7 @@ impl ToolHandler for ListTool { .into_iter() .filter(|entry| { let name = entry.file_name().to_string_lossy().to_string(); - !name.starts_with('.') && !ignore_patterns.iter().any(|p| name.contains(p)) + !Self::should_skip_entry(&name, &ignore_patterns) }) .collect(); @@ -176,7 +187,14 @@ impl ToolHandler for ListTool { let count = filtered.len(); for (i, entry) in filtered.iter().enumerate() { let is_last = i == count - 1; - Self::list_directory(&entry.path(), &ignore_patterns, "", is_last, &mut output, 1)?; + Self::list_directory( + &entry.path(), + &ignore_patterns, + "", + is_last, + &mut output, + 1, + )?; } let result_text = if output.len() <= 1 { @@ -188,3 +206,19 @@ impl ToolHandler for ListTool { Ok(ToolResult::new(format!("List: {}", path_str), result_text)) } } + +#[cfg(test)] +mod tests { + use super::ListTool; + + #[test] + fn should_skip_entry_keeps_dotenv_visible() { + assert!(!ListTool::should_skip_entry(".env", &[])); + assert!(!ListTool::should_skip_entry(".env.local", &[])); + } + + #[test] + fn should_skip_entry_hides_git_metadata_directory() { + assert!(ListTool::should_skip_entry(".git", &[])); + } +} diff --git a/src/tools/fs/mod.rs b/src/tools/fs/mod.rs index 1f16de4..881020e 100644 --- a/src/tools/fs/mod.rs +++ b/src/tools/fs/mod.rs @@ -1,9 +1,11 @@ pub mod glob; +pub mod grep; pub mod list; pub mod read; pub mod write; pub use glob::GlobTool; +pub use grep::GrepTool; pub use list::ListTool; pub use read::ReadTool; pub use write::WriteTool; diff --git a/src/tools/fs/read.rs b/src/tools/fs/read.rs index 59f598f..f9c694b 100644 --- a/src/tools/fs/read.rs +++ b/src/tools/fs/read.rs @@ -17,9 +17,67 @@ impl ReadTool { Self } + fn file_path_param(params: &Value) -> Option<String> { + get_string_param(params, "file_path").or_else(|| get_string_param(params, "filePath")) + } + fn is_binary(data: &[u8]) -> bool { data.iter().take(BINARY_CHECK_SIZE).any(|b| *b == 0) } + + fn read_directory(path: &Path, offset: usize, limit: usize) -> Result<String, ToolError> { + let mut entries: Vec<String> = std::fs::read_dir(path) + .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))? + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + let with_marker = if entry + .file_type() + .map(|kind| kind.is_dir()) + .unwrap_or(false) + { + format!("{}/", name) + } else { + name + }; + Some(with_marker) + }) + .collect(); + + entries.sort(); + + if offset >= entries.len() { + return Ok(format!( + "<path>{}</path>\n<type>directory</type>\n<entries>\n\n({} entries)\n</entries>", + path.display(), + entries.len() + )); + } + + let end = (offset + limit).min(entries.len()); + let selected = &entries[offset..end]; + let truncated = end < entries.len(); + + let mut output = String::new(); + output.push_str(&format!("<path>{}</path>\n", path.display())); + output.push_str("<type>directory</type>\n"); + output.push_str("<entries>\n"); + output.push_str(&selected.join("\n")); + + if truncated { + output.push_str(&format!( + "\n\n(Showing {} of {} entries. Use offset {} to continue)\n", + selected.len(), + entries.len(), + end + )); + } else { + output.push_str(&format!("\n\n({} entries)\n", entries.len())); + } + + output.push_str("</entries>"); + Ok(output) + } } #[async_trait] @@ -27,12 +85,19 @@ impl ToolHandler for ReadTool { fn definition(&self) -> Tool { Tool { id: "read".to_string(), - description: "Read file contents with line numbers and pagination. Detects binary files automatically.".to_string(), + description: "Read file or directory contents with pagination. Detects binary files automatically." + .to_string(), parameters: vec![ ParameterSchema { name: "file_path".to_string(), - description: "Path to the file to read".to_string(), - required: true, + description: "Path to the file or directory to read".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "filePath".to_string(), + description: "Alias of file_path for compatibility".to_string(), + required: false, param_type: ParameterType::String, }, ParameterSchema { @@ -52,11 +117,18 @@ impl ToolHandler for ReadTool { } fn validate(&self, params: &Value) -> Result<(), ToolError> { - validate_required(params, &["file_path"]) + let has_snake_case = get_string_param(params, "file_path").is_some(); + let has_camel_case = get_string_param(params, "filePath").is_some(); + + if has_snake_case || has_camel_case { + Ok(()) + } else { + validate_required(params, &["file_path"]) + } } async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> { - let file_path = get_string_param(¶ms, "file_path") + let file_path = Self::file_path_param(¶ms) .ok_or_else(|| ToolError::Validation("file_path is required".to_string()))?; let offset = get_integer_param(¶ms, "offset") @@ -76,11 +148,13 @@ impl ToolHandler for ReadTool { ))); } + if path.is_dir() { + let output = Self::read_directory(path, offset, limit)?; + return Ok(ToolResult::new(format!("Read: {}", file_path), output)); + } + if !path.is_file() { - return Err(ToolError::Validation(format!( - "Path is not a file: {}", - file_path - ))); + return Err(ToolError::Validation(format!("Path is not readable: {}", file_path))); } let metadata = std::fs::metadata(path) @@ -144,3 +218,38 @@ impl ToolHandler for ReadTool { Ok(ToolResult::new(format!("Read: {}", file_path), output)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_temp_dir(prefix: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be monotonic enough for tests") + .as_nanos(); + std::env::temp_dir().join(format!("{}_{}", prefix, nanos)) + } + + #[test] + fn read_directory_includes_hidden_and_directory_markers() { + let dir = unique_temp_dir("crabcode_read_tool_test"); + std::fs::create_dir_all(&dir).expect("temp dir should be created"); + + let env_path = dir.join(".env"); + let file_path = dir.join("README.md"); + let nested_dir = dir.join("config"); + + std::fs::write(&env_path, "API_KEY=test").expect(".env should be written"); + std::fs::write(&file_path, "# test").expect("README should be written"); + std::fs::create_dir_all(&nested_dir).expect("nested directory should be created"); + + let output = ReadTool::read_directory(&dir, 0, 100).expect("directory read should work"); + + assert!(output.contains(".env")); + assert!(output.contains("README.md")); + assert!(output.contains("config/")); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/tools/fs/write.rs b/src/tools/fs/write.rs index 920deff..7c8d42e 100644 --- a/src/tools/fs/write.rs +++ b/src/tools/fs/write.rs @@ -6,22 +6,12 @@ use async_trait::async_trait; use serde_json::Value; use std::path::Path; -const BLOCKED_FILES: [&str; 3] = [".env", ".env.local", ".env.production"]; - pub struct WriteTool; impl WriteTool { pub fn new() -> Self { Self } - - fn is_blocked(path: &Path) -> bool { - if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { - BLOCKED_FILES.contains(&file_name) - } else { - false - } - } } #[async_trait] @@ -60,13 +50,7 @@ impl ToolHandler for WriteTool { .ok_or_else(|| ToolError::Validation("content is required".to_string()))?; let path = Path::new(&file_path); - - if Self::is_blocked(path) { - return Err(ToolError::Permission(format!( - "Writing to {} is blocked for security reasons", - file_path - ))); - } + let is_new = !path.exists(); if let Some(parent) = path.parent() { if !parent.exists() { @@ -84,8 +68,6 @@ impl ToolHandler for WriteTool { std::fs::rename(&temp_path, path) .map_err(|e| ToolError::Execution(format!("Failed to rename file: {}", e)))?; - let is_new = !path.exists(); - Ok(ToolResult::new( format!("Write: {}", file_path), if is_new { diff --git a/src/tools/init.rs b/src/tools/init.rs index 997f652..005bd08 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,5 +1,5 @@ use crate::tools::{ - fs::{GlobTool, ListTool, ReadTool, WriteTool}, + fs::{GlobTool, GrepTool, ListTool, ReadTool, WriteTool}, BashTool, EditTool, ToolRegistry, }; use std::sync::Arc; @@ -8,6 +8,7 @@ pub async fn initialize_tool_registry() -> ToolRegistry { let registry = ToolRegistry::new(); registry.register(Arc::new(GlobTool::new())).await; + registry.register(Arc::new(GrepTool::new())).await; registry.register(Arc::new(ListTool::new())).await; registry.register(Arc::new(ReadTool::new())).await; registry.register(Arc::new(WriteTool::new())).await; diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 3d7c67f..d0b00ee 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -7,6 +7,7 @@ pub mod context; pub mod edit; pub mod fs; pub mod init; +pub mod permission; pub mod registry; pub mod types; @@ -14,6 +15,9 @@ pub use bash::BashTool; pub use context::ToolContext; pub use edit::EditTool; pub use init::initialize_tool_registry; +pub use permission::{ + AgentToolPolicies, PermissionAction, PermissionPrompt, PermissionResponse, ToolPermissions, +}; pub use registry::ToolRegistry; pub use types::{ParameterSchema, ParameterType, Tool, ToolError, ToolId, ToolResult}; diff --git a/src/tools/permission.rs b/src/tools/permission.rs new file mode 100644 index 0000000..b296fd3 --- /dev/null +++ b/src/tools/permission.rs @@ -0,0 +1,487 @@ +use crate::llm::{ChunkMessage, ChunkSender}; +use crate::tools::ToolError; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PermissionAction { + Read, + Write, + Edit, + List, + Glob, + Grep, + Bash, + Unknown, +} + +impl PermissionAction { + pub fn from_tool_id(tool_id: &str) -> Self { + match tool_id { + "read" => Self::Read, + "write" => Self::Write, + "edit" => Self::Edit, + "list" => Self::List, + "glob" => Self::Glob, + "grep" => Self::Grep, + "bash" => Self::Bash, + _ => Self::Unknown, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionResponse { + Deny, + AllowOnce, + AllowAlways, +} + +#[derive(Debug)] +pub struct PermissionPrompt { + pub tool_id: String, + pub action: PermissionAction, + pub target: Option<String>, + pub reason: String, + pub response_tx: tokio::sync::oneshot::Sender<PermissionResponse>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum PermissionReasonKind { + SensitivePath, + ExternalPath, + GitignoredWrite, + BashCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PermissionFingerprint { + tool_id: String, + action: PermissionAction, + target: Option<String>, + command: Option<String>, + reason: PermissionReasonKind, +} + +#[derive(Debug, Clone)] +pub struct AgentToolPolicies { + custom: HashMap<String, HashSet<String>>, +} + +impl AgentToolPolicies { + pub fn new() -> Self { + Self { + custom: HashMap::new(), + } + } + + pub fn with_custom_tools( + mut self, + mode_name: impl Into<String>, + tools: impl IntoIterator<Item = String>, + ) -> Self { + let mode = mode_name.into().trim().to_ascii_lowercase(); + if mode.is_empty() { + return self; + } + + let set: HashSet<String> = tools + .into_iter() + .map(|t| t.trim().to_ascii_lowercase()) + .filter(|t| !t.is_empty()) + .collect(); + self.custom.insert(mode, set); + self + } + + pub fn is_allowed(&self, mode_name: &str, tool_id: &str) -> bool { + let mode = mode_name.trim().to_ascii_lowercase(); + let tool = tool_id.trim().to_ascii_lowercase(); + + if let Some(custom) = self.custom.get(&mode) { + return custom.contains("*") || custom.contains(&tool); + } + + if mode == "plan" { + // Plan mode is intentionally read/search-only by default. + return matches!(tool.as_str(), "read" | "list" | "glob" | "grep"); + } + + if mode == "build" { + return true; + } + + // Unknown/custom modes default to build behavior unless explicitly configured. + true + } +} + +impl Default for AgentToolPolicies { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +pub struct ToolPermissions { + workdir: PathBuf, + always_grants: Arc<RwLock<HashSet<PermissionFingerprint>>>, + agent_policies: Arc<AgentToolPolicies>, +} + +impl ToolPermissions { + pub fn new(workdir: impl Into<PathBuf>) -> Self { + Self { + workdir: normalize_path(&workdir.into()), + always_grants: Arc::new(RwLock::new(HashSet::new())), + agent_policies: Arc::new(AgentToolPolicies::default()), + } + } + + pub fn with_agent_policies(mut self, policies: AgentToolPolicies) -> Self { + self.agent_policies = Arc::new(policies); + self + } + + pub fn workdir(&self) -> &Path { + &self.workdir + } + + pub fn is_tool_allowed_for_agent(&self, agent_mode: &str, tool_id: &str) -> bool { + self.agent_policies.is_allowed(agent_mode, tool_id) + } + + pub async fn preflight( + &self, + agent_mode: &str, + tool_id: &str, + params: &Value, + sender: Option<&ChunkSender>, + ) -> Result<(), ToolError> { + if !self.is_tool_allowed_for_agent(agent_mode, tool_id) { + return Err(ToolError::Permission(format!( + "Tool '{}' is not available in {} mode", + tool_id, agent_mode + ))); + } + + let action = PermissionAction::from_tool_id(tool_id); + let path = extract_primary_path(action, params, &self.workdir); + let command = if action == PermissionAction::Bash { + get_string(params, "command").map(|s| s.trim().to_string()) + } else { + None + }; + + let reason = self.evaluate_reason(action, path.as_deref()); + + let Some(reason_kind) = reason else { + return Ok(()); + }; + + let target = path + .as_ref() + .map(|p| p.display().to_string()) + .or_else(|| command.clone()); + + let fingerprint = PermissionFingerprint { + tool_id: tool_id.to_string(), + action, + target: target.clone(), + command, + reason: reason_kind, + }; + + if self.always_grants.read().await.contains(&fingerprint) { + return Ok(()); + } + + let reason_text = reason_text(reason_kind, tool_id, target.as_deref()); + + let Some(sender) = sender else { + return Err(ToolError::Permission(reason_text)); + }; + + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + let prompt = PermissionPrompt { + tool_id: tool_id.to_string(), + action, + target, + reason: reason_text, + response_tx, + }; + + sender + .send(ChunkMessage::PermissionRequest(prompt)) + .map_err(|_| { + ToolError::Execution("Failed to deliver permission request to UI".to_string()) + })?; + + let response = response_rx.await.unwrap_or(PermissionResponse::Deny); + match response { + PermissionResponse::Deny => Err(ToolError::Permission( + "Permission denied by user".to_string(), + )), + PermissionResponse::AllowOnce => Ok(()), + PermissionResponse::AllowAlways => { + self.always_grants.write().await.insert(fingerprint); + Ok(()) + } + } + } + + fn evaluate_reason( + &self, + action: PermissionAction, + path: Option<&Path>, + ) -> Option<PermissionReasonKind> { + if matches!( + action, + PermissionAction::Read | PermissionAction::Write | PermissionAction::Edit + ) { + if let Some(path) = path { + if is_sensitive_path(path) { + return Some(PermissionReasonKind::SensitivePath); + } + } + } + + if matches!( + action, + PermissionAction::Read + | PermissionAction::Write + | PermissionAction::Edit + | PermissionAction::List + | PermissionAction::Glob + | PermissionAction::Grep + ) { + if let Some(path) = path { + if is_outside_workdir(path, &self.workdir) { + return Some(PermissionReasonKind::ExternalPath); + } + } + } + + if matches!(action, PermissionAction::Write | PermissionAction::Edit) { + if let Some(path) = path { + if is_gitignored(path, &self.workdir) { + return Some(PermissionReasonKind::GitignoredWrite); + } + } + } + + if action == PermissionAction::Bash { + return Some(PermissionReasonKind::BashCommand); + } + + None + } +} + +fn reason_text(reason: PermissionReasonKind, tool_id: &str, target: Option<&str>) -> String { + match reason { + PermissionReasonKind::SensitivePath => match target { + Some(target) => format!( + "Tool '{}' wants to access sensitive file '{}'; explicit approval required", + tool_id, target + ), + None => format!( + "Tool '{}' wants to access a sensitive file; explicit approval required", + tool_id + ), + }, + PermissionReasonKind::ExternalPath => match target { + Some(target) => format!( + "Tool '{}' wants to access path outside working directory: {}", + tool_id, target + ), + None => format!( + "Tool '{}' wants to access path outside working directory", + tool_id + ), + }, + PermissionReasonKind::GitignoredWrite => match target { + Some(target) => format!( + "Tool '{}' wants to modify gitignored path: {}", + tool_id, target + ), + None => format!("Tool '{}' wants to modify a gitignored path", tool_id), + }, + PermissionReasonKind::BashCommand => { + "Bash command execution requires permission".to_string() + } + } +} + +fn get_string(params: &Value, key: &str) -> Option<String> { + params + .get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn extract_primary_path( + action: PermissionAction, + params: &Value, + workdir: &Path, +) -> Option<PathBuf> { + let raw = match action { + PermissionAction::Read | PermissionAction::Write | PermissionAction::Edit => { + get_string(params, "file_path").or_else(|| get_string(params, "filePath")) + } + PermissionAction::List | PermissionAction::Glob | PermissionAction::Grep => { + get_string(params, "path").or_else(|| Some(".".to_string())) + } + PermissionAction::Bash => { + get_string(params, "workdir").or_else(|| get_string(params, "path")) + } + PermissionAction::Unknown => None, + }?; + + Some(resolve_path(&raw, workdir)) +} + +pub fn resolve_path(raw: &str, workdir: &Path) -> PathBuf { + let p = PathBuf::from(raw); + if p.is_absolute() { + normalize_path(&p) + } else { + normalize_path(&workdir.join(p)) + } +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + + out +} + +fn canonical_or_normalized(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path)) +} + +pub fn is_outside_workdir(path: &Path, workdir: &Path) -> bool { + let target = canonical_or_normalized(path); + let base = canonical_or_normalized(workdir); + !target.starts_with(base) +} + +pub fn is_sensitive_path(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + + let lower = name.to_ascii_lowercase(); + lower == ".env" + || lower == ".envrc" + || lower.starts_with(".env.") + || lower == "auth.json" + || lower.ends_with(".pem") + || lower.ends_with(".key") +} + +pub fn is_gitignored(path: &Path, workdir: &Path) -> bool { + let relative = path.strip_prefix(workdir).ok(); + let candidate = relative.unwrap_or(path); + + let status = Command::new("git") + .arg("-C") + .arg(workdir) + .arg("check-ignore") + .arg("-q") + .arg("--") + .arg(candidate) + .status(); + + matches!(status, Ok(s) if s.success()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_mode_blocks_mutating_tools() { + let policies = AgentToolPolicies::default(); + assert!(policies.is_allowed("plan", "read")); + assert!(policies.is_allowed("plan", "glob")); + assert!(!policies.is_allowed("plan", "write")); + assert!(!policies.is_allowed("plan", "edit")); + assert!(!policies.is_allowed("plan", "bash")); + } + + #[test] + fn sensitive_path_detection_matches_env_patterns() { + assert!(is_sensitive_path(Path::new(".env"))); + assert!(is_sensitive_path(Path::new(".env.local"))); + assert!(is_sensitive_path(Path::new(".env.production"))); + assert!(!is_sensitive_path(Path::new("README.md"))); + } + + #[test] + fn external_path_detection_works() { + let wd = PathBuf::from("/tmp/workspace"); + assert!(!is_outside_workdir( + Path::new("/tmp/workspace/src/main.rs"), + &wd + )); + assert!(is_outside_workdir( + Path::new("/tmp/elsewhere/file.txt"), + &wd + )); + } + + #[test] + fn extract_primary_path_accepts_camel_case_file_path() { + let wd = PathBuf::from("/tmp/workspace"); + let params = serde_json::json!({ "filePath": ".env" }); + + let extracted = extract_primary_path(PermissionAction::Read, ¶ms, &wd) + .expect("expected path to be extracted"); + + assert_eq!(extracted, PathBuf::from("/tmp/workspace/.env")); + } + + #[tokio::test] + async fn allow_always_persists_for_same_request_fingerprint() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + + let perms_for_task = perms.clone(); + let params_for_task = params.clone(); + let tx_for_task = tx.clone(); + let first = tokio::spawn(async move { + perms_for_task + .preflight("build", "read", ¶ms_for_task, Some(&tx_for_task)) + .await + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + let _ = prompt.response_tx.send(PermissionResponse::AllowAlways); + + let first_result = first.await.expect("task should complete"); + assert!(first_result.is_ok()); + + let second = perms.preflight("build", "read", ¶ms, Some(&tx)).await; + assert!(second.is_ok()); + assert!(rx.try_recv().is_err()); + } +} diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index ebc1485..1fc5c36 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -28,6 +28,8 @@ pub struct Chat { pub streaming_t1_ms: Option<u64>, pub streaming_tn_ms: Option<u64>, pub streaming_token_count: usize, + streaming_pause_started_at: Option<std::time::Instant>, + streaming_paused_duration: std::time::Duration, streaming_token_counter: Option<StreamingTokenCounter>, /// Whether to autoscroll to bottom when new content arrives /// Only autoscrolls if user is already near the bottom @@ -76,6 +78,8 @@ impl Chat { streaming_t1_ms: None, streaming_tn_ms: None, streaming_token_count: 0, + streaming_pause_started_at: None, + streaming_paused_duration: std::time::Duration::default(), streaming_token_counter: None, autoscroll_enabled: true, user_scrolled_up: false, @@ -101,6 +105,8 @@ impl Chat { streaming_t1_ms: None, streaming_tn_ms: None, streaming_token_count: 0, + streaming_pause_started_at: None, + streaming_paused_duration: std::time::Duration::default(), streaming_token_counter: None, autoscroll_enabled: true, user_scrolled_up: false, @@ -229,6 +235,8 @@ impl Chat { self.streaming_t1_ms = None; self.streaming_tn_ms = None; self.streaming_token_count = 0; + self.streaming_pause_started_at = None; + self.streaming_paused_duration = std::time::Duration::default(); self.streaming_token_counter = None; } @@ -243,6 +251,8 @@ impl Chat { self.streaming_t1_ms = None; self.streaming_tn_ms = None; self.streaming_token_count = 0; + self.streaming_pause_started_at = None; + self.streaming_paused_duration = std::time::Duration::default(); self.cached_tokens_per_sec = None; self.last_tps_calculated = None; @@ -269,9 +279,37 @@ impl Chat { self.cached_tokens_per_sec } + pub fn pause_streaming_tps_timer(&mut self) { + if self.streaming_start_time.is_none() { + return; + } + + if self.streaming_pause_started_at.is_none() { + self.streaming_pause_started_at = Some(std::time::Instant::now()); + } + } + + pub fn resume_streaming_tps_timer(&mut self) { + if let Some(started) = self.streaming_pause_started_at.take() { + self.streaming_paused_duration += started.elapsed(); + self.last_tps_calculated = None; + } + } + + fn total_paused_duration(&self) -> std::time::Duration { + let mut paused = self.streaming_paused_duration; + if let Some(started) = self.streaming_pause_started_at { + paused += started.elapsed(); + } + paused + } + pub fn get_streaming_elapsed_seconds(&self) -> Option<f64> { - self.streaming_start_time - .map(|start| start.elapsed().as_secs_f64()) + self.streaming_start_time.map(|start| { + let elapsed = start.elapsed(); + let paused = self.total_paused_duration(); + elapsed.saturating_sub(paused).as_secs_f64() + }) } pub fn is_streaming(&self) -> bool { @@ -288,12 +326,14 @@ impl Chat { Some(now_epoch_ms()) }); + let paused_ms = self.total_paused_duration().as_millis(); + let decode_duration_ms = if let (Some(t1), Some(tn)) = (self.streaming_first_token_time, self.streaming_end_time) { - tn.duration_since(t1).as_millis() as u64 + tn.duration_since(t1).as_millis().saturating_sub(paused_ms) as u64 } else if let Some(t1) = self.streaming_first_token_time { - t1.elapsed().as_millis() as u64 + t1.elapsed().as_millis().saturating_sub(paused_ms) as u64 } else { 0 }; @@ -321,6 +361,8 @@ impl Chat { self.streaming_t1_ms = None; self.streaming_tn_ms = None; self.streaming_token_count = 0; + self.streaming_pause_started_at = None; + self.streaming_paused_duration = std::time::Duration::default(); self.streaming_renderer = None; self.streaming_message_idx = None; self.streaming_token_counter = None; @@ -354,7 +396,11 @@ impl Chat { self.last_tps_calculated = Some(now); let result = if let Some(first_token_time) = self.streaming_first_token_time { - let elapsed_ms = first_token_time.elapsed().as_millis(); + let paused_ms = self.total_paused_duration().as_millis(); + let elapsed_ms = first_token_time + .elapsed() + .as_millis() + .saturating_sub(paused_ms); if elapsed_ms >= MIN_TOKENS_PER_SECOND_ELAPSED_MS && self.streaming_token_count > 0 { let tokens_per_sec = (self.streaming_token_count as f64) / (elapsed_ms as f64 / 1000.0); @@ -1135,6 +1181,76 @@ mod tests { assert_eq!(chat.messages[2].content, " assistant"); } + #[test] + fn test_streaming_pause_excluded_from_decode_duration() { + use std::time::Duration; + + let mut chat = Chat::new(); + chat.add_assistant_message(""); + if let Some(last) = chat.messages.last_mut() { + last.is_complete = false; + } + + chat.begin_streaming_turn(); + chat.append_to_last_assistant("hello"); + + std::thread::sleep(Duration::from_millis(40)); + chat.pause_streaming_tps_timer(); + std::thread::sleep(Duration::from_millis(320)); + chat.resume_streaming_tps_timer(); + std::thread::sleep(Duration::from_millis(40)); + + chat.mark_streaming_end(); + chat.finalize_streaming_metrics(); + + let duration_ms = chat + .messages + .iter() + .rev() + .find(|m| m.role == MessageRole::Assistant) + .and_then(|m| m.duration_ms) + .unwrap_or(0); + + assert!(duration_ms < 250, "duration was {}ms", duration_ms); + } + + #[test] + fn test_streaming_elapsed_timer_freezes_while_paused() { + use std::time::Duration; + + let mut chat = Chat::new(); + chat.add_assistant_message(""); + if let Some(last) = chat.messages.last_mut() { + last.is_complete = false; + } + + chat.begin_streaming_turn(); + chat.append_to_last_assistant("hello"); + std::thread::sleep(Duration::from_millis(60)); + + let before_pause = chat.get_streaming_elapsed_seconds().unwrap_or(0.0); + chat.pause_streaming_tps_timer(); + std::thread::sleep(Duration::from_millis(220)); + let during_pause = chat.get_streaming_elapsed_seconds().unwrap_or(0.0); + + assert!( + (during_pause - before_pause).abs() < 0.06, + "timer moved during pause (before={:.3}s, during={:.3}s)", + before_pause, + during_pause + ); + + chat.resume_streaming_tps_timer(); + std::thread::sleep(Duration::from_millis(70)); + let after_resume = chat.get_streaming_elapsed_seconds().unwrap_or(0.0); + assert!( + after_resume > during_pause + 0.03, + "timer did not resume (during={:.3}s, after={:.3}s)", + during_pause, + after_resume + ); + } + #[test] fn test_chat_clear() { let mut chat = Chat::new(); diff --git a/src/views/mod.rs b/src/views/mod.rs index 3749c0c..d7d2a73 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -2,6 +2,7 @@ pub mod chat; pub mod connect_dialog; pub mod home; pub mod models_dialog; +pub mod permission_dialog; pub mod session_rename_dialog; pub mod sessions_dialog; pub mod suggestions_popup; @@ -12,6 +13,7 @@ pub use chat::ChatState; pub use connect_dialog::ConnectDialogState; pub use home::HomeState; pub use models_dialog::ModelsDialogState; +pub use permission_dialog::PermissionDialogState; pub use session_rename_dialog::SessionRenameDialogState; pub use sessions_dialog::SessionsDialogState; pub use suggestions_popup::SuggestionsPopupState; diff --git a/src/views/permission_dialog.rs b/src/views/permission_dialog.rs new file mode 100644 index 0000000..6bacde3 --- /dev/null +++ b/src/views/permission_dialog.rs @@ -0,0 +1,332 @@ +use crate::theme::{contrast_text, ThemeColors}; +use crate::tools::{PermissionPrompt, PermissionResponse}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph, Wrap}, + Frame, +}; +use std::collections::VecDeque; + +#[derive(Default)] +pub struct PermissionDialogState { + current: Option<PermissionPrompt>, + queue: VecDeque<PermissionPrompt>, + selected_action: usize, +} + +impl PermissionDialogState { + pub fn new() -> Self { + Self { + current: None, + queue: VecDeque::new(), + selected_action: 1, + } + } + + pub fn enqueue(&mut self, prompt: PermissionPrompt) { + if self.current.is_none() { + self.current = Some(prompt); + self.selected_action = 1; + } else { + self.queue.push_back(prompt); + } + } + + pub fn has_active(&self) -> bool { + self.current.is_some() + } + + pub fn next_action(&mut self) { + self.selected_action = (self.selected_action + 1) % 3; + } + + pub fn previous_action(&mut self) { + self.selected_action = if self.selected_action == 0 { + 2 + } else { + self.selected_action - 1 + }; + } + + pub fn selected_response(&self) -> PermissionResponse { + match self.selected_action { + 0 => PermissionResponse::Deny, + 1 => PermissionResponse::AllowOnce, + _ => PermissionResponse::AllowAlways, + } + } + + pub fn respond_current(&mut self, response: PermissionResponse) { + if let Some(prompt) = self.current.take() { + let _ = prompt.response_tx.send(response); + } + + self.current = self.queue.pop_front(); + if self.current.is_some() { + self.selected_action = 1; + } + } + + pub fn deny_current(&mut self) { + self.respond_current(PermissionResponse::Deny); + } + + pub fn clear_with_deny(&mut self) { + if let Some(prompt) = self.current.take() { + let _ = prompt.response_tx.send(PermissionResponse::Deny); + } + + while let Some(prompt) = self.queue.pop_front() { + let _ = prompt.response_tx.send(PermissionResponse::Deny); + } + + self.selected_action = 1; + } +} + +pub enum PermissionDialogAction { + Respond(PermissionResponse), + Handled, + NotHandled, +} + +pub fn init_permission_dialog() -> PermissionDialogState { + PermissionDialogState::new() +} + +pub fn handle_permission_dialog_key_event( + state: &mut PermissionDialogState, + event: KeyEvent, +) -> PermissionDialogAction { + if !state.has_active() { + return PermissionDialogAction::NotHandled; + } + + match event.code { + KeyCode::Esc => PermissionDialogAction::Respond(PermissionResponse::Deny), + KeyCode::Left => { + state.previous_action(); + PermissionDialogAction::Handled + } + KeyCode::Right | KeyCode::Tab => { + state.next_action(); + PermissionDialogAction::Handled + } + KeyCode::Char('h') => { + state.previous_action(); + PermissionDialogAction::Handled + } + KeyCode::Char('l') => { + state.next_action(); + PermissionDialogAction::Handled + } + KeyCode::Char('1') => PermissionDialogAction::Respond(PermissionResponse::Deny), + KeyCode::Char('2') => PermissionDialogAction::Respond(PermissionResponse::AllowOnce), + KeyCode::Char('3') => PermissionDialogAction::Respond(PermissionResponse::AllowAlways), + KeyCode::Enter => PermissionDialogAction::Respond(state.selected_response()), + _ => PermissionDialogAction::NotHandled, + } +} + +pub fn handle_permission_dialog_mouse_event( + _state: &mut PermissionDialogState, + _event: MouseEvent, +) -> bool { + false +} + +pub fn render_permission_dialog( + f: &mut Frame, + state: &mut PermissionDialogState, + area: Rect, + colors: ThemeColors, +) { + let Some(prompt) = state.current.as_ref() else { + return; + }; + + let width = area.width.min(78).max(54).min(area.width); + let height = area.height.min(17).max(12).min(area.height); + let dialog_area = Rect { + x: area.x + (area.width - width) / 2, + y: area.y + (area.height - height) / 2, + width, + height, + }; + + f.render_widget(Clear, dialog_area); + f.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + dialog_area, + ); + + const PADDING: u16 = 3; + let content_area = Rect { + x: dialog_area.x + PADDING, + y: dialog_area.y + PADDING, + width: dialog_area.width.saturating_sub(PADDING * 2), + height: dialog_area.height.saturating_sub(PADDING * 2), + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(2), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(content_area); + + let esc_text = "esc"; + let esc_area_width = (esc_text.len() as u16).saturating_add(1); + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(esc_area_width)]) + .split(chunks[0]); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "Permission required", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])), + header_chunks[0], + ); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + esc_text, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )])) + .alignment(Alignment::Right), + header_chunks[1], + ); + + let target = prompt + .target + .as_deref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "(none)".to_string()); + let summary = Line::from(vec![ + Span::styled( + "Tool", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::raw(" "), + Span::styled( + prompt.tool_id.clone(), + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " • ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled( + "Target", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::raw(" "), + Span::styled(target, Style::default().fg(colors.text)), + ]); + f.render_widget(Paragraph::new(summary), chunks[2]); + + let reason = Paragraph::new(prompt.reason.clone()) + .style(Style::default().fg(colors.text)) + .wrap(Wrap { trim: true }); + f.render_widget(reason, chunks[3]); + + let actions = [("Deny", "1"), ("Allow Once", "2"), ("Allow Always", "3")]; + let mut action_spans = Vec::new(); + for (idx, (label, key)) in actions.iter().enumerate() { + if idx > 0 { + action_spans.push(Span::raw(" ")); + } + + let is_selected = idx == state.selected_action; + if is_selected { + let selected = Style::default() + .bg(colors.primary) + .fg(contrast_text(colors.primary)) + .add_modifier(Modifier::BOLD); + action_spans.push(Span::styled(format!(" {} ({}) ", label, key), selected)); + } else { + action_spans.push(Span::styled( + format!("{} ", label), + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )); + action_spans.push(Span::styled( + format!("({})", key), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + } + + let actions_line = Paragraph::new(Line::from(action_spans)).alignment(Alignment::Left); + f.render_widget(actions_line, chunks[5]); + + let help = Line::from(vec![ + Span::styled( + "Confirm", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " enter", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::raw(" "), + Span::styled( + "Switch", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " ⇄", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::raw(" "), + Span::styled( + "Deny", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " esc", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ]); + let help = Paragraph::new(help).alignment(Alignment::Left); + f.render_widget(help, chunks[7]); +} From 4ebef58d6557749f1bacd944cfaf7821db8d3875 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 11:14:39 +0800 Subject: [PATCH 028/226] chore: fmt. --- src/tools/fs/glob.rs | 3 ++- src/tools/fs/list.rs | 9 +-------- src/tools/fs/read.rs | 11 +++++------ 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/tools/fs/glob.rs b/src/tools/fs/glob.rs index c2ba61b..0f13278 100644 --- a/src/tools/fs/glob.rs +++ b/src/tools/fs/glob.rs @@ -15,7 +15,8 @@ impl GlobTool { fn is_in_git_metadata(path: &Path, base: &Path) -> bool { let rel = path.strip_prefix(base).unwrap_or(path); - rel.components().any(|component| component.as_os_str() == ".git") + rel.components() + .any(|component| component.as_os_str() == ".git") } } diff --git a/src/tools/fs/list.rs b/src/tools/fs/list.rs index d634092..58b1317 100644 --- a/src/tools/fs/list.rs +++ b/src/tools/fs/list.rs @@ -187,14 +187,7 @@ impl ToolHandler for ListTool { let count = filtered.len(); for (i, entry) in filtered.iter().enumerate() { let is_last = i == count - 1; - Self::list_directory( - &entry.path(), - &ignore_patterns, - "", - is_last, - &mut output, - 1, - )?; + Self::list_directory(&entry.path(), &ignore_patterns, "", is_last, &mut output, 1)?; } let result_text = if output.len() <= 1 { diff --git a/src/tools/fs/read.rs b/src/tools/fs/read.rs index f9c694b..6870c2d 100644 --- a/src/tools/fs/read.rs +++ b/src/tools/fs/read.rs @@ -31,11 +31,7 @@ impl ReadTool { .filter_map(|entry| { let entry = entry.ok()?; let name = entry.file_name().to_string_lossy().to_string(); - let with_marker = if entry - .file_type() - .map(|kind| kind.is_dir()) - .unwrap_or(false) - { + let with_marker = if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { format!("{}/", name) } else { name @@ -154,7 +150,10 @@ impl ToolHandler for ReadTool { } if !path.is_file() { - return Err(ToolError::Validation(format!("Path is not readable: {}", file_path))); + return Err(ToolError::Validation(format!( + "Path is not readable: {}", + file_path + ))); } let metadata = std::fs::metadata(path) From b66ba3b4844d42d6c4418960c1f031c778d5dd65 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 11:15:08 +0800 Subject: [PATCH 029/226] fix: popup padding commands. --- src/ui/components/popup.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/ui/components/popup.rs b/src/ui/components/popup.rs index 40cded8..dd642b2 100644 --- a/src/ui/components/popup.rs +++ b/src/ui/components/popup.rs @@ -10,6 +10,7 @@ use ratatui::{ }; const MAX_VISIBLE_ITEMS: usize = 8; +const ITEM_HORIZONTAL_PADDING: usize = 1; pub enum PopupAction { Handled, @@ -100,6 +101,7 @@ impl Popup { } let popup_width = area.width; + let item_width = popup_width.saturating_sub(2) as usize; let popup_height = (self.suggestions.len() as u16).min(MAX_VISIBLE_ITEMS as u16) + 2; let popup_area = Rect { @@ -138,28 +140,37 @@ impl Popup { .add_modifier(Modifier::BOLD); let desc_style = Style::default().fg(desc_fg).bg(bg_style); let padding_style = Style::default().bg(bg_style); + let left_padding = " ".repeat(ITEM_HORIZONTAL_PADDING); + let right_padding = " ".repeat(ITEM_HORIZONTAL_PADDING); let line = if !suggestion.description.is_empty() { let mid_padding = " ".repeat(max_name_len + 3 - suggestion.name.len()); let content_len = suggestion.name.len() + suggestion.description.len() + mid_padding.len() - + 2; - let end_padding = - " ".repeat(popup_width.saturating_sub(content_len as u16).max(0) as usize); + + 1 + + ITEM_HORIZONTAL_PADDING + + ITEM_HORIZONTAL_PADDING; + let end_padding = " ".repeat(item_width.saturating_sub(content_len)); Line::from(vec![ + Span::styled(left_padding, padding_style), Span::styled(format!("/{}", suggestion.name), name_style), Span::styled(mid_padding, padding_style), Span::styled(suggestion.description.clone(), desc_style), Span::styled(end_padding, padding_style), + Span::styled(right_padding, padding_style), ]) } else { - let content_len = suggestion.name.len() + 1; - let end_padding = - " ".repeat(popup_width.saturating_sub(content_len as u16).max(0) as usize); + let content_len = suggestion.name.len() + + 1 + + ITEM_HORIZONTAL_PADDING + + ITEM_HORIZONTAL_PADDING; + let end_padding = " ".repeat(item_width.saturating_sub(content_len)); Line::from(vec![ + Span::styled(left_padding, padding_style), Span::styled(format!("/{}", suggestion.name), name_style), Span::styled(end_padding, padding_style), + Span::styled(right_padding, padding_style), ]) }; ListItem::new(line) From a59bf81c2e80cd4fa19228c6e643d3a344122d9a Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 11:16:21 +0800 Subject: [PATCH 030/226] fix: border-l of my chat messages. --- src/ui/components/chat.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 1fc5c36..3062cb7 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -720,11 +720,8 @@ impl Chat { // Wrap content to fit within max_width - padding let wrapped_lines = textwrap::wrap(&content, max_width.saturating_sub(4)); - for (i, line) in wrapped_lines.iter().enumerate() { - let is_first = i == 0; - let _is_last = i == wrapped_lines.len() - 1; - - let left_border = if is_first { "▌ " } else { "│ " }; + for line in wrapped_lines.iter() { + let left_border = "▌ "; let right_padding = " ".repeat(max_width.saturating_sub(line.len() + 3)); From aa8d67148327c85fbfff34154a2630cd18ec8a48 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 11:19:47 +0800 Subject: [PATCH 031/226] fix: minor spacings in scrollbar stuff. --- src/ui/components/chat.rs | 4 ++-- src/views/chat.rs | 2 +- src/views/home.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 3062cb7..7958bb2 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -581,11 +581,11 @@ impl Chat { // Update streaming renderer before calculating heights self.update_streaming_renderer(); - // Calculate content area (leave space for scrollbar) + // Calculate content area (leave space for scrollbar + right padding) let content_area = Rect { x: area.x, y: area.y, - width: area.width.saturating_sub(1), + width: area.width.saturating_sub(2), height: area.height, }; diff --git a/src/views/chat.rs b/src/views/chat.rs index d66e28d..fc1f123 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -100,7 +100,7 @@ pub fn render_chat( Span::styled("tab", Style::default().fg(colors.info)), Span::raw(" agents "), Span::styled("ctrl+cc", Style::default().fg(colors.info)), - Span::raw(" quit"), + Span::raw(" quit "), ]; let help_line = Line::from(help_text); let help_width = help_line.width() as u16; diff --git a/src/views/home.rs b/src/views/home.rs index 822a036..7da9f6d 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -100,7 +100,7 @@ pub fn render_home( Span::styled("tab", Style::default().fg(colors.info)), Span::raw(" agents "), Span::styled("ctrl+cc", Style::default().fg(colors.info)), - Span::raw(" quit"), + Span::raw(" quit "), ]; let help = Paragraph::new(Line::from(help_text)).alignment(Alignment::Right); f.render_widget(help, home_chunks[2]); From 20cc17c241df17becd62a7a5a19de981e474d06d Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 11:26:48 +0800 Subject: [PATCH 032/226] fix: for sessions and themes dialogs. autofocus what is current. --- src/app.rs | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/app.rs b/src/app.rs index 01f6662..9182909 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1215,9 +1215,7 @@ impl App { provider_id: item.provider_id.clone(), }) .collect(); - self.sessions_dialog_state = init_sessions_dialog(title, dialog_items); - self.sessions_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::SessionsDialog; + self.show_sessions_dialog(title, dialog_items); } else { let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = items @@ -1328,9 +1326,7 @@ impl App { provider_id: item.provider_id.clone(), }) .collect(); - self.sessions_dialog_state = init_sessions_dialog(title, dialog_items); - self.sessions_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::SessionsDialog; + self.show_sessions_dialog(title, dialog_items); } else { let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = items .into_iter() @@ -1588,6 +1584,25 @@ impl App { self.models_dialog_state.refresh_items(items); } + fn show_sessions_dialog( + &mut self, + title: impl Into<String>, + items: Vec<crate::ui::components::dialog::DialogItem>, + ) { + self.sessions_dialog_state = init_sessions_dialog(title, items); + + let current_session_id = self.session_manager.get_current_session_id().cloned(); + if let Some(session_id) = current_session_id { + let _ = self + .sessions_dialog_state + .dialog + .select_item_by_key(&session_id, ""); + } + + self.sessions_dialog_state.dialog.show(); + self.overlay_focus = OverlayFocus::SessionsDialog; + } + fn show_themes_dialog(&mut self) { use crate::ui::components::dialog::DialogItem; @@ -1618,16 +1633,16 @@ impl App { items.sort_by(|a, b| a.id.cmp(&b.id)); - let mut selected_index = 0usize; - if let Some(ref id) = current_id { - if let Some((idx, _)) = items.iter().enumerate().find(|(_, it)| &it.id == id) { - selected_index = idx; - } + self.themes_dialog_state = init_themes_dialog("Themes", items); + + if let Some(theme_id) = current_id.as_deref() { + let _ = self + .themes_dialog_state + .dialog + .select_item_by_key(theme_id, ""); } - self.themes_dialog_state = init_themes_dialog("Themes", items); self.themes_dialog_state.dialog.show(); - self.themes_dialog_state.dialog.selected_index = selected_index; self.themes_dialog_original_theme_index = self.current_theme_index; self.themes_dialog_committed = false; self.overlay_focus = OverlayFocus::ThemesDialog; From b3cb516d6193266261bdef022d3b77011fee937c Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 11:30:29 +0800 Subject: [PATCH 033/226] chore: just local dev stuff. --- _plans/TODO_PER_PROJECT_SESSIONMEMORY.md | 2 ++ crabcode.jsonc | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md b/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md index 6cbe5f9..5369d9a 100644 --- a/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md +++ b/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md @@ -1,3 +1,5 @@ Just essentially add a 'project' field in the session. If crabcode is used on a git repository, scope that into that git repository only so that the sessions I find are in that repo only. + +This is inspired by Codex. (Not even Opencode) diff --git a/crabcode.jsonc b/crabcode.jsonc index c868707..2ae2492 100644 --- a/crabcode.jsonc +++ b/crabcode.jsonc @@ -1,7 +1,7 @@ { "$schema": "crabcode.schema.json", // Crabcode theme id (see src/generated_themes/carbonfox.json) - "theme": "vercel", + // "theme": "vercel", "sounds": { "complete": { "enabled": true, From beeb39f17ab919ef59b521df3ae5cee8aa28a33e Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 11:45:31 +0800 Subject: [PATCH 034/226] chore: put plans in _plans. --- TOOL_SYSTEM_PERMISSIONS.md => _plans/TOOL_SYSTEM_PERMISSIONS.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename TOOL_SYSTEM_PERMISSIONS.md => _plans/TOOL_SYSTEM_PERMISSIONS.md (100%) diff --git a/TOOL_SYSTEM_PERMISSIONS.md b/_plans/TOOL_SYSTEM_PERMISSIONS.md similarity index 100% rename from TOOL_SYSTEM_PERMISSIONS.md rename to _plans/TOOL_SYSTEM_PERMISSIONS.md From a55d27e6c2edb03c89bf80f75fc05b1df48b07f1 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Sat, 14 Feb 2026 11:56:16 +0800 Subject: [PATCH 035/226] chore: used the crabcode branch for my aisdk-rs fork. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index abe022f..1092ac4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,7 @@ tokio-test = "0.4" # Local # aisdk = { path = "/Users/carlo/Desktop/Projects/aisdk-rs" } # After pushing -aisdk = { git = "https://github.com/Blankeos/aisdk-rs", branch = "apikey-not-required" } +aisdk = { git = "https://github.com/Blankeos/aisdk-rs", branch = "crabcode" } # The profile that 'dist' will build with [profile.dist] From 7283a967b03ddc0ffb4406b76dd72b0f7da56932 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 16 Feb 2026 10:24:51 +0800 Subject: [PATCH 036/226] feat: openai codex oauth. --- .gitignore | 1 + .ignore | 1 + Cargo.lock | 6 +- Cargo.toml | 9 +- MODEL_OAUTH.md | 297 ++++++++++++++++ README.md | 4 +- src/app.rs | 411 +++++++++++++++++++++- src/auth/mod.rs | 3 + src/auth/openai_oauth.rs | 539 +++++++++++++++++++++++++++++ src/llm/client.rs | 169 ++++++++- src/main.rs | 1 + src/persistence/auth.rs | 13 + src/ui/components/api_key_input.rs | 53 ++- src/utils/clipboard.rs | 63 ++++ src/utils/mod.rs | 1 + src/views/connect_dialog.rs | 15 +- src/views/mod.rs | 2 + src/views/openai_oauth_flow.rs | 336 ++++++++++++++++++ 18 files changed, 1867 insertions(+), 57 deletions(-) create mode 100644 MODEL_OAUTH.md create mode 100644 src/auth/mod.rs create mode 100644 src/auth/openai_oauth.rs create mode 100644 src/utils/clipboard.rs create mode 100644 src/views/openai_oauth_flow.rs diff --git a/.gitignore b/.gitignore index 90f293e..81032cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ app.log sounds/complete.wav _dev_reference1 +_dev_reference2 .env diff --git a/.ignore b/.ignore index 5db5e72..930d48a 100644 --- a/.ignore +++ b/.ignore @@ -1 +1,2 @@ !_dev_reference1 +!_dev_reference2 diff --git a/Cargo.lock b/Cargo.lock index 43f3e65..5cea8d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,11 +32,11 @@ dependencies = [ [[package]] name = "aisdk" version = "0.4.0" -source = "git+https://github.com/Blankeos/aisdk-rs?branch=apikey-not-required#a1e06d7a365407b66cadd65c49e11a7e42c1ca1f" dependencies = [ "aisdk-macros", "async-trait", "derive_builder", + "eventsource-stream", "futures", "log", "parking_lot", @@ -53,7 +53,6 @@ dependencies = [ [[package]] name = "aisdk-macros" version = "0.3.0" -source = "git+https://github.com/Blankeos/aisdk-rs?branch=apikey-not-required#a1e06d7a365407b66cadd65c49e11a7e42c1ca1f" dependencies = [ "proc-macro2", "quote", @@ -452,6 +451,7 @@ dependencies = [ "aisdk", "anyhow", "async-trait", + "base64", "chrono", "clap", "copypasta", @@ -463,6 +463,7 @@ dependencies = [ "json5", "lazy_static", "nucleo-matcher", + "rand", "ratatui", "ratatui-core", "regex", @@ -471,6 +472,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2", "strsim", "textwrap", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index 1092ac4..8e6bec9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,15 +42,18 @@ unicode-width = "0.1" tui-markdown = "0.3" ratatui-core = "0.1" tiktoken-rs = "0.9.1" +base64 = "0.22" +sha2 = "0.10" +rand = "0.8" [dev-dependencies] tokio-test = "0.4" [patch.crates-io] -# Local -# aisdk = { path = "/Users/carlo/Desktop/Projects/aisdk-rs" } +# For local fork development +aisdk = { path = "/Users/carlo/Desktop/Projects/aisdk-rs" } # After pushing -aisdk = { git = "https://github.com/Blankeos/aisdk-rs", branch = "crabcode" } +# aisdk = { git = "https://github.com/Blankeos/aisdk-rs", branch = "crabcode" } # The profile that 'dist' will build with [profile.dist] diff --git a/MODEL_OAUTH.md b/MODEL_OAUTH.md new file mode 100644 index 0000000..b3e2edf --- /dev/null +++ b/MODEL_OAUTH.md @@ -0,0 +1,297 @@ +# OpenAI ChatGPT Plus/Pro OAuth Plan (Codex) + +## Short answer + +Yes, this is possible. + +But for ChatGPT Plus/Pro specifically (using Codex via ChatGPT subscription), this is **not** the same as normal OpenAI API-key auth. It relies on ChatGPT OAuth tokens and ChatGPT backend endpoints, so we need a small transport/auth layer beyond the current API-key-only flow. + +## What I verified + +### Current crabcode behavior + +- `auth.json` persistence exists and already supports `type: "api"` and `type: "oauth"` in `src/persistence/auth.rs`. +- `/connect` currently always routes provider selection to API key entry in `src/app.rs` + `src/ui/components/api_key_input.rs`. +- LLM calls are built from `stream_llm_with_cancellation()` in `src/llm/client.rs` and currently assume API-key style provider setup. + +### How opencode does OpenAI/Codex auth + +From `_dev_reference1/packages/opencode/src/plugin/codex.ts`: + +- Three methods for `openai`: + 1. `ChatGPT Pro/Plus (browser)` (OAuth + PKCE + local callback) + 2. `ChatGPT Pro/Plus (headless)` (device auth + polling) + 3. `Manually enter API Key` +- OAuth issuer and token exchange endpoints: + - `https://auth.openai.com/oauth/authorize` + - `https://auth.openai.com/oauth/token` +- Codex request target is rewritten to: + - `https://chatgpt.com/backend-api/codex/responses` +- It sets `Authorization: Bearer <oauth_access_token>` and, when present, `ChatGPT-Account-Id`. +- It stores extra OAuth data (not just refresh/access/expires), including optional `accountId`. + +### aisdk-rs fork capability check + +From `/Users/carlo/Desktop/Projects/aisdk-rs`: + +- OpenAI provider currently has: + - fixed request path (`/v1/responses`) + - fixed header construction (Content-Type + Authorization) + - no built-in request interceptor/custom fetch hook + - no generic extra header injection on OpenAI provider settings +- OpenAI builder also enforces non-empty API key. + +Conclusion: **aisdk-rs in current form is not enough for full ChatGPT Plus/Pro Codex transport behavior** without extending it (or bypassing it for this provider). + +--- + +## Scope for this implementation + +### In scope (now) + +- OpenAI-only OAuth support in crabcode. +- `/connect` method selection for OpenAI: + 1. ChatGPT Plus/Pro (browser) + 2. ChatGPT Plus/Pro (headless) + 3. Manually enter API key (existing path) +- Use OAuth tokens for Codex completions. + +### Out of scope (for now) + +- OAuth for other providers. +- Full plugin framework like opencode. +- Multi-provider OAuth abstractions beyond what OpenAI needs. + +--- + +## Proposed architecture + +## 1) Auth data model updates (compat with opencode format) + +### File + +- `src/persistence/auth.rs` + +### Changes + +- Extend OAuth variant to include optional fields used by OpenAI OAuth: + - `accountId` (serde rename from `account_id`) + - optional `enterpriseUrl` (future-safe; can be ignored in logic) +- Keep existing `refresh`, `access`, `expires` unchanged. + +### Why + +- You said auth.json uses opencode-compatible shape. +- `ChatGPT-Account-Id` header should be set when available. + +## 2) New OpenAI OAuth service module + +### New module + +- `src/auth/openai_oauth.rs` (or `src/llm/openai_oauth.rs` if you want to keep auth+transport together) + +### Responsibilities + +- Build browser OAuth authorize URL (PKCE + state). +- Run local callback server for browser flow (localhost callback). +- Implement headless/device flow: + - request user code + - poll token readiness + - exchange code for tokens +- Parse JWT claims to extract optional `chatgpt_account_id`. +- Refresh access token when expired. +- Return normalized auth payload ready to persist into `auth.json`. + +### Notes + +- Reuse opencode-known constants/endpoints for compatibility. +- All network calls via `reqwest`. + +## 3) Connect UX changes (OpenAI method picker) + +### Existing behavior to keep + +- Non-openai providers can continue using current API key flow. + +### New behavior for openai + +- After selecting `openai` in `/connect`, show a second step for method selection: + 1. ChatGPT Plus/Pro (browser) + 2. ChatGPT Plus/Pro (headless) + 3. Manually enter API key +- If method 3 selected: show existing API key input. +- If method 1/2 selected: show OAuth progress/status overlay and finalize into OAuth auth config. + +### Files likely touched + +- `src/app.rs` +- `src/views/connect_dialog.rs` +- maybe a new lightweight overlay component for OAuth status/code display + +## 4) LLM transport integration for OAuth Codex + +### Key requirement + +When provider is `openai` and auth type is `oauth`, requests must go to ChatGPT Codex endpoint with OAuth bearer token (+ optional account header), not standard API-key flow. + +### Recommended implementation path + +#### A. Extend aisdk-rs fork (recommended) + +Add to OpenAI provider settings in aisdk-rs: + +- customizable response path (default `/v1/responses`) +- additional headers map + +Then crabcode can configure: + +- base URL: `https://chatgpt.com/backend-api/codex` +- response path: `responses` +- auth token: OAuth access token +- extra headers: + - `ChatGPT-Account-Id` when present + - optional `originator` / user-agent parity headers if required + +This keeps crabcode on one streaming stack and avoids a separate custom SSE client for OpenAI OAuth. + +#### B. Fallback path (if you avoid aisdk-rs patch) + +Implement a dedicated reqwest SSE transport in crabcode just for OpenAI OAuth Codex and map events into existing `ChunkMessage` flow. + +This works, but increases maintenance and duplicates provider logic already centralized in aisdk-rs. + +## 5) Token refresh strategy + +- On every OpenAI OAuth request, check expiry. +- If expired (or near expiry), refresh first and persist updated token. +- If refresh fails: + - surface clear toast/actionable error + - keep auth entry but mark as needing re-auth in UX messaging + +## 6) Model availability strategy + +For OpenAI + OAuth auth type: + +- Prefer showing a codex-focused allowlist (as opencode does) to reduce unsupported model failures. +- Initial allowlist can include: + - `gpt-5.3-codex` + - `gpt-5.2-codex` + - `gpt-5.1-codex` + - `gpt-5.1-codex-mini` + - `gpt-5.1-codex-max` + - `codex-mini-latest` +- Keep API-key OpenAI auth path unchanged (full OpenAI model list). + +--- + +## Implementation phases + +## Phase 0 - Foundation and compatibility + +1. Extend `AuthConfig::OAuth` with optional `accountId`/`enterpriseUrl` fields. +2. Add serde tests to confirm opencode-compatible roundtrip JSON. + +## Phase 1 - OAuth engine + +1. Build OpenAI OAuth service module for browser flow. +2. Add headless/device flow. +3. Add refresh-token support. +4. Add JWT claim parsing helper for account id extraction. + +## Phase 2 - Connect UX + +1. Add openai method-selection step in `/connect` flow. +2. Wire method actions: + - browser OAuth + - headless OAuth + - manual API key (existing) +3. Add cancellation + timeout handling in UI state. + +## Phase 3 - Inference transport + +1. Implement recommended aisdk-rs extension (path override + extra headers), then bump/update usage. +2. In crabcode stream path, branch OpenAI auth mode: + - API key: standard OpenAI API + - OAuth: Codex endpoint + OAuth headers +3. Add base URL fallback for OpenAI when models.dev `api` is empty (`https://api.openai.com`). + +## Phase 4 - Model filtering and polish + +1. Apply codex-focused model filtering when auth type is OpenAI OAuth. +2. Improve known error mapping (e.g., `usage_not_included`) to user-friendly guidance. +3. Ensure `/models` and model dialog behavior remain coherent across API vs OAuth auth types. + +## Phase 5 - Tests and verification + +1. Unit tests: + - OAuth URL + PKCE/state generation + - JWT account id extraction + - auth.json serde compatibility + - token refresh behavior +2. Integration-style tests: + - `/connect` openai -> method select -> persisted auth + - OAuth auth path can stream with OpenAI provider branch +3. Manual smoke checks: + - browser flow on macOS + - headless flow on terminal-only path + - fallback to manual API key + +--- + +## Risks and mitigations + +- Private/undocumented endpoints may change: + - Mitigation: isolate constants + transport logic in one module, clear errors, easy hotfix points. +- OAuth token refresh failures: + - Mitigation: proactive refresh + explicit reconnect flow + preserved auth state. +- Divergence from aisdk-rs upstream: + - Mitigation: keep patch minimal and generic (path/header extensibility useful beyond this feature). + +--- + +## Acceptance criteria + +- `/connect` for `openai` offers exactly three methods (browser OAuth, headless OAuth, manual API key). +- OAuth success writes opencode-compatible `auth.json` entry with `type: "oauth"` and token fields. +- OpenAI OAuth path can request Codex completions without API key. +- Manual API key path for OpenAI still works as before. +- Non-openai provider behavior remains unchanged. + +--- + +## Final feasibility verdict + +This feature is feasible and a good fit for crabcode. + +The only notable blocker is that current aisdk-rs OpenAI transport is too rigid for ChatGPT Plus/Pro Codex endpoint requirements. Once we add minimal path/header configurability (or implement a temporary custom transport), the rest is straightforward engineering work in auth flow + connect UX. + +--- + +## Runtime loop issue (tool-call step stops early) + +### Symptom (current user-reported issue) + +- Codex OAuth path can execute one tool call, then frequently ends the stream before the follow-up model step. +- Repro pattern: assistant emits short preamble text + one tool call, tool runs, then turn ends without the next assistant/tool step. + +### Brainstorm and likely root cause + +- In `aisdk-rs` `stream_text()` orchestration, step termination currently marks `StopReason::Finish` as soon as any `Done(Text|Reasoning)` output appears. +- Codex can return mixed outputs in a single completed response (for example: an output text message plus one or more function calls). +- If text is seen first, the loop marks the step finished even when tool calls are also present in that same step payload. +- This explains intermittency: it depends on output composition/order from the backend for that turn. + +### Fix plan + +1. Update `aisdk-rs` step-finalization logic to decide finish based on the whole step, not the first non-tool output seen. +2. Continue looping when a step contains any tool call, even if text/reasoning is also present. +3. Only set `StopReason::Finish` when a step has terminal assistant content and no tool calls. +4. Apply the same mixed-output guard to non-streaming `generate_text()` for consistency. +5. Add regression tests for mixed output (`Text + ToolCall`) to prevent future regressions. + +### Validation + +- `cargo test` targeted for new mixed-output tests in `aisdk-rs`. +- `cargo check --features openai` in `aisdk-rs`. +- `cargo check` in `crabcode`. +- Manual smoke: ask for a task that typically needs `glob -> read -> summarize` and verify multi-step tool loop no longer stops after first call. diff --git a/README.md b/README.md index 4ebfc3a..2748232 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,8 @@ This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/o - [x] Minimal configurations (I want it to just feel at least like vanilla opencode) - [x] The cheapest model providers (GLM, etc.) - [x] A ding sound, my only opencode plugin at the moment. -- [x] No reverse-engineering oauth from big AI (Codex, Claude Code, Gemini), at least for now (Don't wanna get in trouble). -- [ ] ChatGPT oauth (because I use it) +- [x] No reverse-engineering oauth from big AI (Claude Code, Gemini), at least for now (Don't wanna get in trouble). +- [x] Exception: ChatGPT oauth (because I use it) - [ ] Copy chat contents, copy the chat input - [ ] Image inputs - [ ] Possibly ralphy? (very far, idk how to do that) diff --git a/src/app.rs b/src/app.rs index 9182909..0a11b63 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,6 +24,10 @@ use crate::views::models_dialog::{ handle_models_dialog_key_event, handle_models_dialog_mouse_event, init_models_dialog, render_models_dialog, }; +use crate::views::openai_oauth_flow::{ + handle_openai_oauth_flow_key_event, handle_openai_oauth_flow_mouse_event, + init_openai_oauth_flow, render_openai_oauth_flow, OpenAIOAuthFlowAction, +}; use crate::views::permission_dialog::{ handle_permission_dialog_key_event, handle_permission_dialog_mouse_event, init_permission_dialog, render_permission_dialog, PermissionDialogAction, @@ -45,8 +49,9 @@ use crate::views::themes_dialog::{ render_themes_dialog, }; use crate::views::{ - ChatState, ConnectDialogState, HomeState, ModelsDialogState, PermissionDialogState, - SessionRenameDialogState, SessionsDialogState, SuggestionsPopupState, ThemesDialogState, + ChatState, ConnectDialogState, HomeState, ModelsDialogState, OpenAIOAuthFlowState, + PermissionDialogState, SessionRenameDialogState, SessionsDialogState, SuggestionsPopupState, + ThemesDialogState, }; use crate::{ @@ -80,6 +85,7 @@ pub enum OverlayFocus { ModelsDialog, ThemesDialog, ConnectDialog, + OpenAIOAuthFlow, ApiKeyInput, SuggestionsPopup, SessionsDialog, @@ -88,6 +94,19 @@ pub enum OverlayFocus { WhichKey, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectDialogMode { + ProviderSelection, + OpenAIMethodSelection, +} + +#[derive(Debug)] +enum OpenAIOAuthTaskMessage { + HeadlessCode { code: String, url: String }, + Success(crate::auth::OAuthCredentials), + Failed(String), +} + pub struct App { pub running: bool, pub version: String, @@ -102,11 +121,15 @@ pub struct App { themes_dialog_original_theme_index: usize, themes_dialog_committed: bool, pub connect_dialog_state: ConnectDialogState, + connect_dialog_mode: ConnectDialogMode, + openai_oauth_flow_state: OpenAIOAuthFlowState, pub sessions_dialog_state: SessionsDialogState, pub session_rename_dialog_state: SessionRenameDialogState, pub permission_dialog_state: PermissionDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, pub api_key_input: crate::ui::components::api_key_input::ApiKeyInput, + openai_oauth_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<OpenAIOAuthTaskMessage>>, + openai_oauth_in_progress: bool, pub prefs_dao: Option<crate::persistence::PrefsDAO>, pub agent: String, pub model: String, @@ -158,6 +181,7 @@ impl App { let models_dialog_state = init_models_dialog("Models", vec![]); let themes_dialog_state = init_themes_dialog("Themes", vec![]); let connect_dialog_state = init_connect_dialog(); + let openai_oauth_flow_state = init_openai_oauth_flow(); let sessions_dialog_state = init_sessions_dialog("Sessions", vec![]); let permission_dialog_state = init_permission_dialog(); let which_key_state = crate::views::which_key::init_which_key(); @@ -280,11 +304,15 @@ impl App { themes_dialog_original_theme_index: 0, themes_dialog_committed: false, connect_dialog_state, + connect_dialog_mode: ConnectDialogMode::ProviderSelection, + openai_oauth_flow_state, sessions_dialog_state, session_rename_dialog_state, permission_dialog_state, which_key_state, api_key_input, + openai_oauth_receiver: None, + openai_oauth_in_progress: false, prefs_dao, agent, model: active_model, @@ -595,6 +623,11 @@ impl App { true } OverlayFocus::ConnectDialog => { + if key.code == KeyCode::Char('d') && key.modifiers == event::KeyModifiers::CONTROL { + self.disconnect_selected_provider(); + return; + } + if handle_connect_dialog_key_event(&mut self.connect_dialog_state, key) { return; } @@ -602,14 +635,41 @@ impl App { if let Some(selected_item) = get_pending_selection(&mut self.connect_dialog_state) { - self.api_key_input.show(&selected_item.id); - self.overlay_focus = OverlayFocus::ApiKeyInput; + self.handle_connect_dialog_selection(selected_item); return; } + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; self.overlay_focus = OverlayFocus::None; } false } + OverlayFocus::OpenAIOAuthFlow => { + let action = + handle_openai_oauth_flow_key_event(&mut self.openai_oauth_flow_state, key); + match action { + OpenAIOAuthFlowAction::Handled => true, + OpenAIOAuthFlowAction::NotHandled => false, + OpenAIOAuthFlowAction::Close => { + self.overlay_focus = OverlayFocus::None; + true + } + OpenAIOAuthFlowAction::CopyLink(url) => { + match crate::utils::clipboard::copy_text(&url) { + Ok(_) => push_toast(Toast::new( + "Copied OpenAI login link", + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to copy link: {}", err), + ToastLevel::Error, + None, + )), + } + true + } + } + } OverlayFocus::ApiKeyInput => { let action = self.api_key_input.handle_key_event(key); match action { @@ -623,6 +683,7 @@ impl App { crate::persistence::AuthConfig::Api { key: api_key }, ); self.connect_dialog_state = init_connect_dialog(); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; } self.overlay_focus = OverlayFocus::None; true @@ -945,12 +1006,35 @@ impl App { handle_connect_dialog_mouse_event(&mut self.connect_dialog_state, mouse); if !self.connect_dialog_state.dialog.is_visible() { if let Some(selected_item) = get_pending_selection(&mut self.connect_dialog_state) { - self.api_key_input.show(&selected_item.id); - self.overlay_focus = OverlayFocus::ApiKeyInput; + self.handle_connect_dialog_selection(selected_item); return; } + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; self.overlay_focus = OverlayFocus::None; } + } else if self.overlay_focus == OverlayFocus::OpenAIOAuthFlow { + let action = + handle_openai_oauth_flow_mouse_event(&mut self.openai_oauth_flow_state, mouse); + match action { + OpenAIOAuthFlowAction::Handled | OpenAIOAuthFlowAction::NotHandled => {} + OpenAIOAuthFlowAction::Close => { + self.overlay_focus = OverlayFocus::None; + } + OpenAIOAuthFlowAction::CopyLink(url) => { + match crate::utils::clipboard::copy_text(&url) { + Ok(_) => push_toast(Toast::new( + "Copied OpenAI login link", + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to copy link: {}", err), + ToastLevel::Error, + None, + )), + } + } + } } else if self.overlay_focus == OverlayFocus::SessionsDialog { handle_sessions_dialog_mouse_event(&mut self.sessions_dialog_state, mouse); if !self.sessions_dialog_state.dialog.is_visible() { @@ -1194,12 +1278,9 @@ impl App { provider_id: item.provider_id.clone(), }) .collect(); - self.connect_dialog_state = crate::views::ConnectDialogState::new( - crate::ui::components::dialog::Dialog::with_items( - title, - dialog_items, - ), - ); + self.connect_dialog_state = + crate::views::ConnectDialogState::with_items(title, dialog_items); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; self.connect_dialog_state.dialog.show(); self.overlay_focus = OverlayFocus::ConnectDialog; } else if title == "Sessions" { @@ -1309,9 +1390,9 @@ impl App { provider_id: item.provider_id.clone(), }) .collect(); - self.connect_dialog_state = crate::views::ConnectDialogState::new( - crate::ui::components::dialog::Dialog::with_items(title, dialog_items), - ); + self.connect_dialog_state = + crate::views::ConnectDialogState::with_items(title, dialog_items); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; self.connect_dialog_state.dialog.show(); self.overlay_focus = OverlayFocus::ConnectDialog; } else if title == "Sessions" { @@ -1648,6 +1729,298 @@ impl App { self.overlay_focus = OverlayFocus::ThemesDialog; } + fn show_openai_connect_methods(&mut self) { + use crate::ui::components::dialog::DialogItem; + + let items = vec![ + DialogItem { + id: "openai-oauth-browser".to_string(), + name: "ChatGPT Plus/Pro (browser)".to_string(), + group: "OpenAI".to_string(), + description: "OAuth via browser callback".to_string(), + tip: None, + provider_id: "openai".to_string(), + }, + DialogItem { + id: "openai-oauth-headless".to_string(), + name: "ChatGPT Plus/Pro (headless)".to_string(), + group: "OpenAI".to_string(), + description: "Device code login flow".to_string(), + tip: None, + provider_id: "openai".to_string(), + }, + DialogItem { + id: "openai-api-key".to_string(), + name: "Manually enter API key".to_string(), + group: "OpenAI".to_string(), + description: "Use OpenAI API key".to_string(), + tip: None, + provider_id: "openai".to_string(), + }, + ]; + + self.connect_dialog_state = crate::views::ConnectDialogState::new( + crate::ui::components::dialog::Dialog::with_items("Connect OpenAI", items), + ); + self.connect_dialog_state.dialog.show(); + self.connect_dialog_mode = ConnectDialogMode::OpenAIMethodSelection; + self.overlay_focus = OverlayFocus::ConnectDialog; + } + + fn reopen_connect_dialog(&mut self, select_provider_id: Option<&str>) { + if let crate::command::parser::InputType::Command(parsed) = + crate::command::parser::parse_input("/connect") + { + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_command_input(parsed)); + }); + } + + if let Some(provider_id) = select_provider_id { + let _ = self + .connect_dialog_state + .dialog + .select_item_by_key(provider_id, ""); + } + } + + fn disconnect_selected_provider(&mut self) { + if self.connect_dialog_mode != ConnectDialogMode::ProviderSelection { + push_toast(Toast::new( + "Disconnect is available in provider list", + ToastLevel::Info, + None, + )); + return; + } + + let selected_item = match self.connect_dialog_state.dialog.get_selected() { + Some(item) => item.clone(), + None => { + push_toast(Toast::new("No provider selected", ToastLevel::Info, None)); + return; + } + }; + + let provider_id = selected_item.id; + let provider_name = selected_item.name; + + let auth_dao = match crate::persistence::AuthDAO::new() { + Ok(dao) => dao, + Err(err) => { + push_toast(Toast::new( + format!("Failed to open auth store: {}", err), + ToastLevel::Error, + None, + )); + return; + } + }; + + match auth_dao.get_provider(&provider_id) { + Ok(Some(_)) => { + if let Err(err) = auth_dao.remove_provider(&provider_id) { + push_toast(Toast::new( + format!("Failed to disconnect {}: {}", provider_name, err), + ToastLevel::Error, + None, + )); + return; + } + + push_toast(Toast::new( + format!("Disconnected {}", provider_name), + ToastLevel::Info, + None, + )); + + self.reopen_connect_dialog(Some(&provider_id)); + } + Ok(None) => { + push_toast(Toast::new( + format!("{} is not connected", provider_name), + ToastLevel::Info, + None, + )); + } + Err(err) => { + push_toast(Toast::new( + format!("Failed to inspect provider auth: {}", err), + ToastLevel::Error, + None, + )); + } + } + } + + fn handle_connect_dialog_selection( + &mut self, + selected_item: crate::ui::components::dialog::DialogItem, + ) { + match self.connect_dialog_mode { + ConnectDialogMode::ProviderSelection => { + if selected_item.id == "openai" { + self.show_openai_connect_methods(); + return; + } + + self.api_key_input.show(&selected_item.id); + self.overlay_focus = OverlayFocus::ApiKeyInput; + } + ConnectDialogMode::OpenAIMethodSelection => match selected_item.id.as_str() { + "openai-oauth-browser" => { + self.begin_openai_oauth_browser(); + } + "openai-oauth-headless" => { + self.begin_openai_oauth_headless(); + } + "openai-api-key" => { + self.api_key_input.show("openai"); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.overlay_focus = OverlayFocus::ApiKeyInput; + } + _ => { + self.overlay_focus = OverlayFocus::None; + } + }, + } + } + + fn begin_openai_oauth_browser(&mut self) { + if self.openai_oauth_in_progress { + push_toast(Toast::new( + "OpenAI OAuth is already in progress", + ToastLevel::Info, + None, + )); + self.overlay_focus = OverlayFocus::None; + return; + } + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<OpenAIOAuthTaskMessage>(); + self.openai_oauth_receiver = Some(receiver); + self.openai_oauth_in_progress = true; + self.openai_oauth_flow_state.show_browser_waiting(); + self.overlay_focus = OverlayFocus::OpenAIOAuthFlow; + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.connect_dialog_state = init_connect_dialog(); + + tokio::spawn(async move { + match crate::auth::openai_oauth::authorize_browser().await { + Ok(credentials) => { + let _ = sender.send(OpenAIOAuthTaskMessage::Success(credentials)); + } + Err(err) => { + let _ = sender.send(OpenAIOAuthTaskMessage::Failed(err.to_string())); + } + } + }); + } + + fn begin_openai_oauth_headless(&mut self) { + if self.openai_oauth_in_progress { + push_toast(Toast::new( + "OpenAI OAuth is already in progress", + ToastLevel::Info, + None, + )); + self.overlay_focus = OverlayFocus::None; + return; + } + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<OpenAIOAuthTaskMessage>(); + self.openai_oauth_receiver = Some(receiver); + self.openai_oauth_in_progress = true; + self.openai_oauth_flow_state.show_headless_preparing(); + self.overlay_focus = OverlayFocus::OpenAIOAuthFlow; + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.connect_dialog_state = init_connect_dialog(); + + tokio::spawn(async move { + let code_sender = sender.clone(); + let result = crate::auth::openai_oauth::authorize_headless(move |code, url| { + let _ = code_sender.send(OpenAIOAuthTaskMessage::HeadlessCode { code, url }); + }) + .await; + + match result { + Ok(credentials) => { + let _ = sender.send(OpenAIOAuthTaskMessage::Success(credentials)); + } + Err(err) => { + let _ = sender.send(OpenAIOAuthTaskMessage::Failed(err.to_string())); + } + } + }); + } + + fn process_openai_oauth_events(&mut self) { + let mut events = Vec::new(); + + if let Some(receiver) = &mut self.openai_oauth_receiver { + while let Ok(event) = receiver.try_recv() { + events.push(event); + } + } + + for event in events { + match event { + OpenAIOAuthTaskMessage::HeadlessCode { code, url } => { + self.openai_oauth_flow_state.set_headless_code(code, url); + self.overlay_focus = OverlayFocus::OpenAIOAuthFlow; + } + OpenAIOAuthTaskMessage::Success(credentials) => { + if let Ok(auth_dao) = crate::persistence::AuthDAO::new() { + let _ = auth_dao.set_provider( + "openai".to_string(), + crate::persistence::AuthConfig::OAuth { + refresh: credentials.refresh, + access: credentials.access, + expires: credentials.expires, + account_id: credentials.account_id, + enterprise_url: credentials.enterprise_url, + }, + ); + } + + if let Some(prefs_dao) = self.prefs_dao.as_ref() { + let _ = prefs_dao + .set_active_model("openai".to_string(), "gpt-5.3-codex".to_string()); + } + + self.provider_name = "openai".to_string(); + self.model = "gpt-5.3-codex".to_string(); + self.openai_oauth_in_progress = false; + self.openai_oauth_receiver = None; + self.openai_oauth_flow_state.hide(); + if self.overlay_focus == OverlayFocus::OpenAIOAuthFlow { + self.overlay_focus = OverlayFocus::None; + } + + push_toast(Toast::new( + "Connected OpenAI via ChatGPT Plus/Pro OAuth", + ToastLevel::Info, + None, + )); + } + OpenAIOAuthTaskMessage::Failed(error) => { + self.openai_oauth_in_progress = false; + self.openai_oauth_receiver = None; + self.openai_oauth_flow_state.hide(); + if self.overlay_focus == OverlayFocus::OpenAIOAuthFlow { + self.overlay_focus = OverlayFocus::None; + } + push_toast(Toast::new( + format!("OpenAI OAuth failed: {}", error), + ToastLevel::Error, + None, + )); + } + } + } + } + fn cleanup_streaming(&mut self) { self.chat_state.chat.resume_streaming_tps_timer(); self.permission_dialog_state.clear_with_deny(); @@ -1676,6 +2049,8 @@ impl App { } pub fn process_streaming_chunks(&mut self) { + self.process_openai_oauth_events(); + let mut chunks = Vec::new(); if let Some(receiver) = &mut self.chunk_receiver { @@ -2138,6 +2513,12 @@ impl App { render_connect_dialog(f, &mut self.connect_dialog_state, size, colors); } + if self.overlay_focus == OverlayFocus::OpenAIOAuthFlow + && self.openai_oauth_flow_state.is_visible() + { + render_openai_oauth_flow(f, &mut self.openai_oauth_flow_state, size, colors); + } + if self.overlay_focus == OverlayFocus::ApiKeyInput && self.api_key_input.is_visible() { self.api_key_input.render(f, size, &colors); } diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..0a721fc --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod openai_oauth; + +pub use openai_oauth::OAuthCredentials; diff --git a/src/auth/openai_oauth.rs b/src/auth/openai_oauth.rs new file mode 100644 index 0000000..ceaceed --- /dev/null +++ b/src/auth/openai_oauth.rs @@ -0,0 +1,539 @@ +use anyhow::{anyhow, bail, Context, Result}; +use base64::Engine; +use rand::Rng; +use sha2::{Digest, Sha256}; +use std::process::Command; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const ISSUER: &str = "https://auth.openai.com"; +const OAUTH_SCOPE: &str = "openid profile email offline_access"; +const OAUTH_PORT: u16 = 1455; +const OAUTH_POLLING_SAFETY_MARGIN_MS: u64 = 3_000; + +#[derive(Debug, Clone)] +pub struct OAuthCredentials { + pub refresh: String, + pub access: String, + pub expires: i64, + pub account_id: Option<String>, + pub enterprise_url: Option<String>, +} + +#[derive(Debug, Clone)] +struct PkceCodes { + verifier: String, + challenge: String, +} + +#[derive(Debug, serde::Deserialize)] +struct TokenResponse { + #[serde(default)] + id_token: Option<String>, + access_token: String, + #[serde(default)] + refresh_token: Option<String>, + #[serde(default)] + expires_in: Option<i64>, +} + +#[derive(Debug, serde::Deserialize)] +struct DeviceAuthStartResponse { + device_auth_id: String, + user_code: String, + interval: String, +} + +#[derive(Debug, serde::Deserialize)] +struct DeviceAuthTokenResponse { + authorization_code: String, + code_verifier: String, +} + +pub fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +pub fn build_user_agent() -> String { + format!( + "crabcode/{} ({} {}; {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + std::env::consts::FAMILY + ) +} + +pub async fn authorize_browser() -> Result<OAuthCredentials> { + let listener = TcpListener::bind(("127.0.0.1", OAUTH_PORT)) + .await + .with_context(|| { + format!( + "failed to bind oauth callback listener on port {}", + OAUTH_PORT + ) + })?; + + let redirect_uri = format!("http://localhost:{}/auth/callback", OAUTH_PORT); + let pkce = generate_pkce(); + let state = generate_state(); + let authorize_url = build_authorize_url(&redirect_uri, &pkce, &state)?; + + open_browser(&authorize_url).with_context(|| { + format!( + "failed to open browser. open this url manually: {}", + authorize_url + ) + })?; + + let code = wait_for_oauth_callback(listener, &state) + .await + .context("did not receive oauth callback")?; + + let client = reqwest::Client::new(); + let token_response = exchange_authorization_code( + &client, + &code, + &redirect_uri, + &pkce.verifier, + Some(build_user_agent()), + ) + .await?; + + credentials_from_token_response(token_response, None) +} + +pub async fn authorize_headless<F>(mut on_code: F) -> Result<OAuthCredentials> +where + F: FnMut(String, String) + Send, +{ + let client = reqwest::Client::new(); + let user_agent = build_user_agent(); + + let start_response = client + .post(format!("{ISSUER}/api/accounts/deviceauth/usercode")) + .header("Content-Type", "application/json") + .header("User-Agent", user_agent.clone()) + .json(&serde_json::json!({ "client_id": CLIENT_ID })) + .send() + .await + .context("failed to initiate device authorization")?; + + if !start_response.status().is_success() { + let status = start_response.status(); + let body = start_response + .text() + .await + .unwrap_or_else(|_| "<unable to read body>".to_string()); + bail!("device authorization start failed: {status} {body}"); + } + + let device_data: DeviceAuthStartResponse = start_response + .json() + .await + .context("failed to parse device authorization response")?; + + let interval_ms = std::cmp::max(device_data.interval.parse::<u64>().unwrap_or(5), 1) * 1_000; + let code_url = format!("{ISSUER}/codex/device"); + on_code(device_data.user_code.clone(), code_url); + + let deadline = Instant::now() + Duration::from_secs(10 * 60); + + loop { + if Instant::now() >= deadline { + bail!("timed out while waiting for device authorization"); + } + + let token_response = client + .post(format!("{ISSUER}/api/accounts/deviceauth/token")) + .header("Content-Type", "application/json") + .header("User-Agent", user_agent.clone()) + .json(&serde_json::json!({ + "device_auth_id": device_data.device_auth_id, + "user_code": device_data.user_code, + })) + .send() + .await + .context("failed to poll device authorization")?; + + if token_response.status().is_success() { + let device_token: DeviceAuthTokenResponse = token_response + .json() + .await + .context("failed to parse device authorization token response")?; + + let token_response = exchange_authorization_code( + &client, + &device_token.authorization_code, + &format!("{ISSUER}/deviceauth/callback"), + &device_token.code_verifier, + Some(user_agent.clone()), + ) + .await?; + + return credentials_from_token_response(token_response, None); + } + + if token_response.status() != reqwest::StatusCode::FORBIDDEN + && token_response.status() != reqwest::StatusCode::NOT_FOUND + { + let status = token_response.status(); + let body = token_response + .text() + .await + .unwrap_or_else(|_| "<unable to read body>".to_string()); + bail!("device authorization polling failed: {status} {body}"); + } + + tokio::time::sleep(Duration::from_millis( + interval_ms + OAUTH_POLLING_SAFETY_MARGIN_MS, + )) + .await; + } +} + +pub async fn refresh_access_token(refresh_token: &str) -> Result<OAuthCredentials> { + let client = reqwest::Client::new(); + let response = client + .post(format!("{ISSUER}/oauth/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", CLIENT_ID), + ]) + .send() + .await + .context("failed to refresh access token")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "<unable to read body>".to_string()); + bail!("token refresh failed: {status} {body}"); + } + + let token_response: TokenResponse = response + .json() + .await + .context("failed to parse refresh token response")?; + + credentials_from_token_response(token_response, Some(refresh_token.to_string())) +} + +fn credentials_from_token_response( + token_response: TokenResponse, + fallback_refresh: Option<String>, +) -> Result<OAuthCredentials> { + let refresh = token_response + .refresh_token + .clone() + .or(fallback_refresh) + .ok_or_else(|| anyhow!("missing refresh token in oauth response"))?; + + let account_id = extract_account_id(&token_response); + let expires = now_unix_ms() + token_response.expires_in.unwrap_or(3600) * 1_000; + + Ok(OAuthCredentials { + refresh, + access: token_response.access_token, + expires, + account_id, + enterprise_url: None, + }) +} + +async fn wait_for_oauth_callback(listener: TcpListener, expected_state: &str) -> Result<String> { + let deadline = Instant::now() + Duration::from_secs(5 * 60); + + loop { + let now = Instant::now(); + if now >= deadline { + bail!("oauth callback timeout") + } + + let remaining = deadline.saturating_duration_since(now); + let (mut socket, _) = tokio::time::timeout(remaining, listener.accept()) + .await + .context("timed out waiting for oauth callback connection")? + .context("failed to accept oauth callback connection")?; + + let mut buffer = vec![0_u8; 8 * 1024]; + let read_count = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buffer)) + .await + .context("timed out reading oauth callback request")? + .context("failed to read oauth callback request")?; + + if read_count == 0 { + continue; + } + + let request = String::from_utf8_lossy(&buffer[..read_count]); + let Some(first_line) = request.lines().next() else { + continue; + }; + + let raw_target = first_line.split_whitespace().nth(1).unwrap_or("/"); + let parsed_url = match reqwest::Url::parse(&format!("http://localhost{}", raw_target)) { + Ok(url) => url, + Err(_) => { + write_html_response( + &mut socket, + 400, + "Authorization Failed", + "Invalid callback request.", + ) + .await; + continue; + } + }; + + if parsed_url.path() != "/auth/callback" { + write_html_response(&mut socket, 404, "Not Found", "Not found.").await; + continue; + } + + if let Some(error) = parsed_url + .query_pairs() + .find_map(|(k, v)| (k == "error").then_some(v.into_owned())) + { + let error_description = parsed_url + .query_pairs() + .find_map(|(k, v)| (k == "error_description").then_some(v.into_owned())) + .unwrap_or(error); + write_html_response(&mut socket, 400, "Authorization Failed", &error_description).await; + bail!("oauth authorization failed: {error_description}"); + } + + let code = parsed_url + .query_pairs() + .find_map(|(k, v)| (k == "code").then_some(v.into_owned())) + .ok_or_else(|| anyhow!("missing authorization code in callback"))?; + + let state = parsed_url + .query_pairs() + .find_map(|(k, v)| (k == "state").then_some(v.into_owned())) + .ok_or_else(|| anyhow!("missing oauth state in callback"))?; + + if state != expected_state { + write_html_response( + &mut socket, + 400, + "Authorization Failed", + "Invalid oauth state.", + ) + .await; + bail!("invalid oauth state received") + } + + write_html_response( + &mut socket, + 200, + "Authorization Successful", + "You can close this window and return to crabcode.", + ) + .await; + + return Ok(code); + } +} + +async fn write_html_response(socket: &mut TcpStream, status: u16, title: &str, body: &str) { + let page = format!( + "<!doctype html><html><head><title>{title}

{title}

{body}

" + ); + let status_text = if status == 200 { "OK" } else { "Bad Request" }; + let response = format!( + "HTTP/1.1 {status} {status_text}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + page.len(), + page + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.flush().await; +} + +async fn exchange_authorization_code( + client: &reqwest::Client, + code: &str, + redirect_uri: &str, + code_verifier: &str, + user_agent: Option, +) -> Result { + let mut request = client + .post(format!("{ISSUER}/oauth/token")) + .header("Content-Type", "application/x-www-form-urlencoded"); + + if let Some(agent) = user_agent { + request = request.header("User-Agent", agent); + } + + let response = request + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", CLIENT_ID), + ("code_verifier", code_verifier), + ]) + .send() + .await + .context("failed to exchange authorization code")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + bail!("token exchange failed: {status} {body}"); + } + + response + .json::() + .await + .context("failed to parse token exchange response") +} + +fn build_authorize_url(redirect_uri: &str, pkce: &PkceCodes, state: &str) -> Result { + let mut url = reqwest::Url::parse(&format!("{ISSUER}/oauth/authorize")) + .context("failed to build authorize url")?; + + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", CLIENT_ID) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", OAUTH_SCOPE) + .append_pair("code_challenge", &pkce.challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("id_token_add_organizations", "true") + .append_pair("codex_cli_simplified_flow", "true") + .append_pair("originator", "opencode") + .append_pair("state", state); + + Ok(url.to_string()) +} + +fn open_browser(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let mut command = { + let mut cmd = Command::new("open"); + cmd.arg(url); + cmd + }; + + #[cfg(target_os = "linux")] + let mut command = { + let mut cmd = Command::new("xdg-open"); + cmd.arg(url); + cmd + }; + + #[cfg(target_os = "windows")] + let mut command = { + let mut cmd = Command::new("cmd"); + cmd.args(["/C", "start", "", url]); + cmd + }; + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + bail!("unsupported platform for automatic browser launch") + } + + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + { + let status = command + .status() + .context("failed to launch browser command")?; + if status.success() { + return Ok(()); + } + bail!("browser command returned non-zero exit status") + } +} + +fn generate_pkce() -> PkceCodes { + let verifier = generate_random_string(43); + let challenge = { + let digest = Sha256::digest(verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) + }; + + PkceCodes { + verifier, + challenge, + } +} + +fn generate_state() -> String { + let bytes: Vec = (0..32).map(|_| rand::random::()).collect(); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn generate_random_string(length: usize) -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + let mut rng = rand::thread_rng(); + (0..length) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +fn extract_account_id(tokens: &TokenResponse) -> Option { + if let Some(ref id_token) = tokens.id_token { + if let Some(claims) = parse_jwt_claims(id_token) { + if let Some(account_id) = extract_account_id_from_claims(&claims) { + return Some(account_id); + } + } + } + + if let Some(claims) = parse_jwt_claims(&tokens.access_token) { + return extract_account_id_from_claims(&claims); + } + + None +} + +fn parse_jwt_claims(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload)) + .ok()?; + + serde_json::from_slice::(&decoded).ok() +} + +fn extract_account_id_from_claims(claims: &serde_json::Value) -> Option { + claims + .get("chatgpt_account_id") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + .or_else(|| { + claims + .get("https://api.openai.com/auth") + .and_then(|v| v.get("chatgpt_account_id")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + }) + .or_else(|| { + claims + .get("organizations") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|org| org.get("id")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + }) +} diff --git a/src/llm/client.rs b/src/llm/client.rs index 918f54b..d7407c8 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -6,6 +6,7 @@ use aisdk::{ providers::{Anthropic, OpenAI, OpenAICompatible}, }; use futures::StreamExt; +use std::collections::HashMap; use tokio_util::sync::CancellationToken; use crate::logging::log; @@ -195,14 +196,11 @@ pub async fn stream_llm_with_cancellation( use std::time::Instant; let auth_dao = crate::persistence::AuthDAO::new()?; - - let api_key = auth_dao.get_api_key(&provider_name)?; - if api_key.is_none() { - let _ = sender.send(crate::llm::ChunkMessage::Warning(format!( - "No API key configured for '{}'. Trying anyway.", - provider_name - ))); - } + let auth_config = auth_dao.get_provider(&provider_name)?; + let mut api_key = auth_config.as_ref().and_then(|config| match config { + crate::persistence::AuthConfig::Api { key } => Some(key.clone()), + crate::persistence::AuthConfig::OAuth { access, .. } => Some(access.clone()), + }); let discovery = crate::model::discovery::Discovery::new()?; @@ -214,11 +212,107 @@ pub async fn stream_llm_with_cancellation( let npm_package = &provider.npm; let provider_kind = ProviderKind::from_provider(&provider_name, npm_package); - let base_url = provider_kind.normalize_base_url(&provider.api); + let mut base_url = provider_kind.normalize_base_url(&provider.api); + let mut effective_model = model.clone(); + let mut openai_response_path: Option = None; + let mut openai_additional_headers: HashMap = HashMap::new(); + let mut openai_force_store_false = false; + let mut openai_default_instructions: Option = None; + let mut openai_disallow_system_messages = false; + let mut openai_force_tool_strict_false = false; + + if provider_kind == ProviderKind::OpenAI && provider_name == "openai" { + if let Some(crate::persistence::AuthConfig::OAuth { + refresh, + access, + expires, + account_id, + enterprise_url, + }) = auth_config.clone() + { + let mut oauth_refresh = refresh; + let mut oauth_access = access; + let mut oauth_expires = expires; + let mut oauth_account_id = account_id; + let mut oauth_enterprise_url = enterprise_url; + + if oauth_expires <= crate::auth::openai_oauth::now_unix_ms() + 60_000 { + match crate::auth::openai_oauth::refresh_access_token(&oauth_refresh).await { + Ok(refreshed) => { + oauth_refresh = refreshed.refresh; + oauth_access = refreshed.access; + oauth_expires = refreshed.expires; + if refreshed.account_id.is_some() { + oauth_account_id = refreshed.account_id; + } + if refreshed.enterprise_url.is_some() { + oauth_enterprise_url = refreshed.enterprise_url; + } + + let _ = auth_dao.set_provider( + provider_name.clone(), + crate::persistence::AuthConfig::OAuth { + refresh: oauth_refresh.clone(), + access: oauth_access.clone(), + expires: oauth_expires, + account_id: oauth_account_id.clone(), + enterprise_url: oauth_enterprise_url.clone(), + }, + ); + } + Err(err) => { + let _ = sender.send(crate::llm::ChunkMessage::Warning(format!( + "Failed to refresh OpenAI OAuth token: {}", + err + ))); + } + } + } + + api_key = Some(oauth_access.clone()); + base_url = "https://chatgpt.com".to_string(); + openai_response_path = Some("/backend-api/codex/responses".to_string()); + openai_force_store_false = true; + openai_default_instructions = Some( + "You are Codex, a coding assistant focused on high-quality code changes." + .to_string(), + ); + openai_disallow_system_messages = true; + openai_force_tool_strict_false = true; + + openai_additional_headers.insert("originator".to_string(), "crabcode".to_string()); + openai_additional_headers.insert( + "User-Agent".to_string(), + crate::auth::openai_oauth::build_user_agent(), + ); + + if let Some(account_id) = oauth_account_id { + openai_additional_headers.insert("ChatGPT-Account-Id".to_string(), account_id); + } + + let _ = log("Configured OpenAI OAuth Codex transport"); + + if !is_openai_oauth_model_allowed(&effective_model) { + let fallback_model = "gpt-5.3-codex".to_string(); + let _ = sender.send(crate::llm::ChunkMessage::Warning(format!( + "Model '{}' is not supported for OpenAI OAuth. Falling back to '{}'.", + effective_model, fallback_model + ))); + effective_model = fallback_model; + } + } + } + + if api_key.is_none() { + let _ = sender.send(crate::llm::ChunkMessage::Warning(format!( + "No API key configured for '{}'. Trying anyway.", + provider_name + ))); + } let _ = log(&format!( - "Provider: {}, NPM: {}, Base URL: {}", - provider_name, npm_package, base_url + "Provider: {}, NPM: {}, Base URL: {}, Model: {}", + provider_name, npm_package, base_url, effective_model )); // Determine which provider to use based on npm package @@ -237,7 +331,7 @@ pub async fn stream_llm_with_cancellation( ProviderKind::OpenAICompatible => { let mut provider_builder = OpenAICompatible::::builder() .base_url(&base_url) - .model_name(&model) + .model_name(&effective_model) .provider_name(&provider.name); if let Some(key) = api_key.as_deref() { @@ -262,7 +356,7 @@ pub async fn stream_llm_with_cancellation( ProviderKind::Anthropic => { let mut provider_builder = Anthropic::::builder() .base_url(&base_url) - .model_name(&model) + .model_name(&effective_model) .provider_name(&provider.name); if let Some(key) = api_key.as_deref() { @@ -287,13 +381,38 @@ pub async fn stream_llm_with_cancellation( ProviderKind::OpenAI => { let mut provider_builder = OpenAI::::builder() .base_url(&base_url) - .model_name(&model) + .model_name(&effective_model) .provider_name(&provider.name); if let Some(key) = api_key.as_deref() { provider_builder = provider_builder.api_key(key); } + if let Some(response_path) = &openai_response_path { + provider_builder = provider_builder.response_path(response_path); + } + + if openai_force_store_false { + provider_builder = provider_builder.force_store_false(true); + } + + if let Some(instructions) = &openai_default_instructions { + provider_builder = provider_builder.default_instructions(instructions.clone()); + } + + if openai_disallow_system_messages { + provider_builder = provider_builder.disallow_system_messages(true); + } + + if openai_force_tool_strict_false { + provider_builder = provider_builder.force_tool_strict_false(true); + } + + if !openai_additional_headers.is_empty() { + provider_builder = + provider_builder.additional_headers(openai_additional_headers.clone()); + } + let provider_config = provider_builder .build() .map_err(|e| Box::new(e) as Box)?; @@ -384,7 +503,20 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec bool { + matches!( + model, + "gpt-5.1-codex-max" + | "gpt-5.1-codex-mini" + | "gpt-5.2" + | "gpt-5.2-codex" + | "gpt-5.3-codex" + | "gpt-5.1-codex" + | "codex-mini-latest" + ) || model.contains("codex") +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ProviderKind { OpenAI, OpenAICompatible, @@ -408,6 +540,13 @@ impl ProviderKind { fn normalize_base_url(self, base_url: &str) -> String { match self { ProviderKind::Anthropic => normalize_anthropic_base_url(base_url), + ProviderKind::OpenAI => { + if base_url.trim().is_empty() { + "https://api.openai.com".to_string() + } else { + base_url.to_string() + } + } _ => base_url.to_string(), } } diff --git a/src/main.rs b/src/main.rs index a55f690..03fd399 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod agent; mod app; +mod auth; mod autocomplete; mod command; mod config; diff --git a/src/persistence/auth.rs b/src/persistence/auth.rs index df80a07..1fc4813 100644 --- a/src/persistence/auth.rs +++ b/src/persistence/auth.rs @@ -16,6 +16,14 @@ pub enum AuthConfig { refresh: String, access: String, expires: i64, + #[serde(rename = "accountId", default, skip_serializing_if = "Option::is_none")] + account_id: Option, + #[serde( + rename = "enterpriseUrl", + default, + skip_serializing_if = "Option::is_none" + )] + enterprise_url: Option, }, } @@ -123,6 +131,11 @@ impl AuthDAO { AuthConfig::OAuth { access, .. } => Some(access.clone()), })) } + + pub fn get_provider(&self, name: &str) -> Result> { + let providers = self.load()?; + Ok(providers.get(name).cloned()) + } } #[cfg(test)] diff --git a/src/ui/components/api_key_input.rs b/src/ui/components/api_key_input.rs index 3fb0246..3a5fa60 100644 --- a/src/ui/components/api_key_input.rs +++ b/src/ui/components/api_key_input.rs @@ -136,32 +136,51 @@ impl ApiKeyInput { ]) .split(content_area); - let title_line = Line::from(vec![ + let esc_text = "esc"; + let esc_area_width = (esc_text.len() as u16).saturating_add(1); + let header_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(esc_area_width), + ]) + .split(chunks[0]); + + let title_paragraph = Paragraph::new(Line::from(vec![Span::styled( + "API key", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Left); + frame.render_widget(title_paragraph, header_chunks[0]); + + let esc_paragraph = Paragraph::new(Line::from(vec![Span::styled( + esc_text, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Right); + frame.render_widget(esc_paragraph, header_chunks[1]); + + frame.render_widget(&self.text_area, chunks[1]); + + let footer_line = Line::from(vec![ Span::styled( - "API key", + "enter", Style::default() - .fg(colors.text) + .fg(colors.primary) .add_modifier(Modifier::BOLD), ), - Span::raw(" ".repeat(40)), Span::styled( - "esc", + " submit", Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), ), ]); - frame.render_widget(Paragraph::new(title_line), chunks[0]); - frame.render_widget(&self.text_area, chunks[1]); - - let footer_line = Line::from(vec![Span::styled( - "enter submit", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - )]); - frame.render_widget(Paragraph::new(footer_line), chunks[2]); } } diff --git a/src/utils/clipboard.rs b/src/utils/clipboard.rs new file mode 100644 index 0000000..dc90c0c --- /dev/null +++ b/src/utils/clipboard.rs @@ -0,0 +1,63 @@ +use anyhow::{bail, Context, Result}; +use std::io::Write; +use std::process::{Command, Stdio}; + +pub fn copy_text(text: &str) -> Result<()> { + #[cfg(target_os = "macos")] + { + return run_command_with_stdin("pbcopy", &[], text); + } + + #[cfg(target_os = "linux")] + { + let candidates: [(&str, &[&str]); 3] = [ + ("wl-copy", &[]), + ("xclip", &["-selection", "clipboard"]), + ("xsel", &["--clipboard", "--input"]), + ]; + + for (cmd, args) in candidates { + if run_command_with_stdin(cmd, args, text).is_ok() { + return Ok(()); + } + } + + bail!("no clipboard command found (tried wl-copy, xclip, xsel)"); + } + + #[cfg(target_os = "windows")] + { + return run_command_with_stdin("cmd", &["/C", "clip"], text); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + bail!("clipboard copy is not supported on this platform") + } +} + +fn run_command_with_stdin(cmd: &str, args: &[&str], text: &str) -> Result<()> { + let mut child = Command::new(cmd) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("failed to launch '{}'", cmd))?; + + if let Some(stdin) = child.stdin.as_mut() { + stdin + .write_all(text.as_bytes()) + .with_context(|| format!("failed to write to '{}' stdin", cmd))?; + } + + let status = child + .wait() + .with_context(|| format!("failed waiting for '{}'", cmd))?; + + if status.success() { + Ok(()) + } else { + bail!("'{}' exited with status {}", cmd, status) + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 90837b0..4d43d14 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod clipboard; pub mod frecency; pub mod git; pub mod ignore; diff --git a/src/views/connect_dialog.rs b/src/views/connect_dialog.rs index 0f869ce..f8c8919 100644 --- a/src/views/connect_dialog.rs +++ b/src/views/connect_dialog.rs @@ -1,5 +1,5 @@ use crate::theme::ThemeColors; -use crate::ui::components::dialog::{Dialog, DialogItem}; +use crate::ui::components::dialog::{Dialog, DialogAction as FooterAction, DialogItem}; use ratatui::crossterm::event::{KeyEvent, MouseEvent}; use ratatui::{layout::Rect, Frame}; @@ -18,15 +18,24 @@ impl ConnectDialogState { } pub fn with_items(title: impl Into, items: Vec) -> Self { + let title = title.into(); + let mut dialog = Dialog::with_items(title.clone(), items); + if title == "Connect a provider" { + dialog = dialog.with_actions(vec![FooterAction { + label: "Disconnect".to_string(), + key: "ctrl+d".to_string(), + }]); + } + Self { - dialog: Dialog::with_items(title, items), + dialog, pending_selection: None, } } } pub fn init_connect_dialog() -> ConnectDialogState { - ConnectDialogState::new(Dialog::with_items("Connect a provider", vec![])) + ConnectDialogState::with_items("Connect a provider", vec![]) } pub fn render_connect_dialog( diff --git a/src/views/mod.rs b/src/views/mod.rs index d7d2a73..626af9f 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -2,6 +2,7 @@ pub mod chat; pub mod connect_dialog; pub mod home; pub mod models_dialog; +pub mod openai_oauth_flow; pub mod permission_dialog; pub mod session_rename_dialog; pub mod sessions_dialog; @@ -13,6 +14,7 @@ pub use chat::ChatState; pub use connect_dialog::ConnectDialogState; pub use home::HomeState; pub use models_dialog::ModelsDialogState; +pub use openai_oauth_flow::OpenAIOAuthFlowState; pub use permission_dialog::PermissionDialogState; pub use session_rename_dialog::SessionRenameDialogState; pub use sessions_dialog::SessionsDialogState; diff --git a/src/views/openai_oauth_flow.rs b/src/views/openai_oauth_flow.rs new file mode 100644 index 0000000..2444374 --- /dev/null +++ b/src/views/openai_oauth_flow.rs @@ -0,0 +1,336 @@ +use crate::theme::ThemeColors; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpenAIOAuthFlowMode { + BrowserWaiting, + HeadlessPreparing, + HeadlessCode, +} + +#[derive(Debug)] +pub struct OpenAIOAuthFlowState { + pub visible: bool, + pub mode: OpenAIOAuthFlowMode, + pub url: Option, + pub code: Option, + dialog_area: Rect, + link_area: Option, +} + +impl OpenAIOAuthFlowState { + pub fn new() -> Self { + Self { + visible: false, + mode: OpenAIOAuthFlowMode::BrowserWaiting, + url: None, + code: None, + dialog_area: Rect::default(), + link_area: None, + } + } + + pub fn show_browser_waiting(&mut self) { + self.visible = true; + self.mode = OpenAIOAuthFlowMode::BrowserWaiting; + self.url = None; + self.code = None; + } + + pub fn show_headless_preparing(&mut self) { + self.visible = true; + self.mode = OpenAIOAuthFlowMode::HeadlessPreparing; + self.url = None; + self.code = None; + } + + pub fn set_headless_code(&mut self, code: String, url: String) { + self.visible = true; + self.mode = OpenAIOAuthFlowMode::HeadlessCode; + self.url = Some(url); + self.code = Some(code); + } + + pub fn hide(&mut self) { + self.visible = false; + self.url = None; + self.code = None; + self.link_area = None; + } + + pub fn is_visible(&self) -> bool { + self.visible + } +} + +impl Default for OpenAIOAuthFlowState { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenAIOAuthFlowAction { + Handled, + NotHandled, + Close, + CopyLink(String), +} + +pub fn init_openai_oauth_flow() -> OpenAIOAuthFlowState { + OpenAIOAuthFlowState::new() +} + +pub fn handle_openai_oauth_flow_key_event( + state: &mut OpenAIOAuthFlowState, + event: KeyEvent, +) -> OpenAIOAuthFlowAction { + if !state.visible { + return OpenAIOAuthFlowAction::NotHandled; + } + + if event.code == KeyCode::Esc { + state.hide(); + return OpenAIOAuthFlowAction::Close; + } + + if event.code == KeyCode::Char('y') && event.modifiers == KeyModifiers::CONTROL { + if let Some(url) = &state.url { + return OpenAIOAuthFlowAction::CopyLink(url.clone()); + } + } + + OpenAIOAuthFlowAction::Handled +} + +pub fn handle_openai_oauth_flow_mouse_event( + state: &mut OpenAIOAuthFlowState, + event: MouseEvent, +) -> OpenAIOAuthFlowAction { + if !state.visible { + return OpenAIOAuthFlowAction::NotHandled; + } + + if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + return OpenAIOAuthFlowAction::Handled; + } + + let point = Position::new(event.column, event.row); + + if !state.dialog_area.contains(point) { + state.hide(); + return OpenAIOAuthFlowAction::Close; + } + + if let (Some(link_area), Some(url)) = (state.link_area, &state.url) { + if link_area.contains(point) { + return OpenAIOAuthFlowAction::CopyLink(url.clone()); + } + } + + OpenAIOAuthFlowAction::Handled +} + +pub fn render_openai_oauth_flow( + frame: &mut Frame, + state: &mut OpenAIOAuthFlowState, + area: Rect, + colors: ThemeColors, +) { + if !state.visible { + return; + } + + const DIALOG_WIDTH: u16 = 82; + const DIALOG_HEIGHT: u16 = 16; + const PADDING: u16 = 3; + + let dialog_width = area.width.min(DIALOG_WIDTH); + let dialog_height = area.height.min(DIALOG_HEIGHT); + + state.dialog_area = Rect { + x: (area.width - dialog_width) / 2, + y: (area.height - dialog_height) / 2, + width: dialog_width, + height: dialog_height, + }; + + frame.render_widget(Clear, state.dialog_area); + frame.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + state.dialog_area, + ); + + let content_area = Rect { + x: state.dialog_area.x + PADDING, + y: state.dialog_area.y + PADDING, + width: state.dialog_area.width.saturating_sub(PADDING * 2), + height: state.dialog_area.height.saturating_sub(PADDING * 2), + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(content_area); + + state.link_area = None; + + let esc_text = "esc"; + let esc_area_width = (esc_text.width() as u16).saturating_add(1); + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(esc_area_width)]) + .split(chunks[0]); + + let title = Line::from(vec![Span::styled( + "OpenAI OAuth", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )]); + frame.render_widget( + Paragraph::new(title).alignment(Alignment::Left), + header_chunks[0], + ); + + let esc_hint = Line::from(vec![Span::styled( + esc_text, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )]); + frame.render_widget( + Paragraph::new(esc_hint).alignment(Alignment::Right), + header_chunks[1], + ); + + match state.mode { + OpenAIOAuthFlowMode::BrowserWaiting => { + frame.render_widget( + Paragraph::new(Line::from(vec![Span::raw( + "Complete login in your browser. Waiting for callback...", + )])) + .style(Style::default().fg(colors.text)), + chunks[2], + ); + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "If browser did not open, retry from /connect.", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])), + chunks[3], + ); + } + OpenAIOAuthFlowMode::HeadlessPreparing => { + frame.render_widget( + Paragraph::new(Line::from(vec![Span::raw( + "Requesting device code from OpenAI...", + )])) + .style(Style::default().fg(colors.text)), + chunks[2], + ); + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "This view will update with link + code automatically.", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])), + chunks[3], + ); + } + OpenAIOAuthFlowMode::HeadlessCode => { + let url = state.url.clone().unwrap_or_default(); + let code = state.code.clone().unwrap_or_default(); + + frame.render_widget( + Paragraph::new(Line::from(vec![Span::raw( + "1. Open this login link (click it or press ctrl+y to copy):", + )])) + .style(Style::default().fg(colors.text)), + chunks[2], + ); + + state.link_area = Some(chunks[3]); + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + url, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::UNDERLINED), + )])), + chunks[3], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw("2. Enter code: "), + Span::styled( + code, + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + ])), + chunks[4], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "3. Return here and wait for completion.", + Style::default().fg(colors.text_weak), + )])), + chunks[5], + ); + } + } + + let footer = if state.url.is_some() { + Line::from(vec![ + Span::styled( + "copy link", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "ctrl+y", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ]) + } else { + Line::from(vec![Span::styled( + "waiting...", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )]) + }; + + frame.render_widget(Paragraph::new(footer).alignment(Alignment::Left), chunks[8]); +} From 23c348a0a00fd66ae683fb9055f32b87a22d4200 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 16 Feb 2026 12:20:44 +0800 Subject: [PATCH 037/226] feat: Infinite steps + more consistent timeout w/ opencode. - Add configurable agent steps and provider timeouts - Implement `agent..steps` for limiting agent tool iterations and `provider..options.timeout` for per-provider request timeouts. - Removes the hardcoded 15-step limit and 5-minute global timeout. - Updates config documentation to reflect newly supported OpenCode settings. --- _docs/config.mdx | 5 +- src/app.rs | 55 +++++++--- src/config/configuration.rs | 140 +++++++++++++++++++++++- src/config/mod.rs | 2 +- src/llm/client.rs | 209 ++++++++++++++++++++++++++++++++---- 5 files changed, 373 insertions(+), 38 deletions(-) diff --git a/_docs/config.mdx b/_docs/config.mdx index 913f58a..6cd770e 100644 --- a/_docs/config.mdx +++ b/_docs/config.mdx @@ -124,7 +124,10 @@ crabcode parses and merges these OpenCode settings (some are functional now, oth | `model` | ✅ Works | | `theme` | ✅ Works (crabcode config only) | | `sounds` | ✅ Works (crabcode-only) | -| `agent`, `instructions`, `tools`, `mcp`, `provider`, `command`, `permission`, `compaction`, `watcher`, `default_agent`, `formatter`, `disabled_providers`, `enabled_providers` | ⏳ Merged but not yet implemented | +| `default_agent` | ✅ Works | +| `agent` | ⚠️ Partial (`agent..tools`, `agent..steps`) | +| `provider` | ⚠️ Partial (`provider..options.timeout` as milliseconds, or `false` to disable) | +| `instructions`, `tools`, `mcp`, `command`, `permission`, `compaction`, `watcher`, `formatter`, `disabled_providers`, `enabled_providers` | ⏳ Merged but not yet implemented | These OpenCode settings are intentionally ignored (they don't apply to a terminal UI): diff --git a/src/app.rs b/src/app.rs index 0a11b63..8705c4a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -132,6 +132,8 @@ pub struct App { openai_oauth_in_progress: bool, pub prefs_dao: Option, pub agent: String, + pub agent_steps: std::collections::HashMap, + pub provider_timeouts: std::collections::HashMap, pub model: String, pub provider_name: String, pub cwd: String, @@ -269,6 +271,8 @@ impl App { &loaded_config.cwd, loaded_config.merged_config.theme.as_deref(), ); + let agent_steps = loaded_config.merged_config.agent_steps.clone(); + let provider_timeouts = loaded_config.merged_config.provider_timeouts.clone(); let theme_for_colors = themes .get(current_theme_index) @@ -315,6 +319,8 @@ impl App { openai_oauth_in_progress: false, prefs_dao, agent, + agent_steps, + provider_timeouts, model: active_model, provider_name: active_provider_name, cwd, @@ -2301,6 +2307,14 @@ impl App { let provider_name = self.provider_name.clone(); let model = self.model.clone(); let agent_mode = self.agent.clone(); + let provider_timeout = self + .provider_timeouts + .get(&self.provider_name.to_ascii_lowercase()) + .copied(); + let agent_max_steps = self + .agent_steps + .get(&self.agent.to_ascii_lowercase()) + .copied(); let tool_permissions = self.tool_permissions.clone(); let cwd = self.cwd.clone(); let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); @@ -2330,26 +2344,35 @@ impl App { } tokio::spawn(async move { - let result = tokio::time::timeout( - std::time::Duration::from_secs(300), - stream_llm_with_cancellation( - cancel_token, - provider_name, - model, - agent_mode, - tool_permissions, - messages, - sender_clone.clone(), - ), - ) - .await; + let stream = stream_llm_with_cancellation( + cancel_token, + provider_name, + model, + agent_mode, + agent_max_steps, + tool_permissions, + messages, + sender_clone.clone(), + ); + + let result: Result>, u64> = match provider_timeout + { + Some(crate::config::ProviderTimeout::Millis(ms)) => { + match tokio::time::timeout(std::time::Duration::from_millis(ms), stream).await { + Ok(inner) => Ok(inner), + Err(_) => Err(ms), + } + } + Some(crate::config::ProviderTimeout::Disabled) | None => Ok(stream.await), + }; let _ = match result { Ok(Ok(())) => sender_clone.send(crate::llm::ChunkMessage::End), Ok(Err(e)) => sender_clone.send(crate::llm::ChunkMessage::Failed(e.to_string())), - Err(_) => sender_clone.send(crate::llm::ChunkMessage::Failed( - "Timeout: No response within 5 minutes".to_string(), - )), + Err(ms) => sender_clone.send(crate::llm::ChunkMessage::Failed(format!( + "Timeout: No response within {} ms", + ms + ))), }; }); diff --git a/src/config/configuration.rs b/src/config/configuration.rs index ffd9438..253c810 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -160,12 +160,20 @@ impl Default for SoundsConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderTimeout { + Millis(u64), + Disabled, +} + #[derive(Debug, Clone, Default)] pub struct MergedConfig { pub theme: Option, pub model: Option, pub default_agent: Option, pub agent_tool_policies: HashMap>, + pub agent_steps: HashMap, + pub provider_timeouts: HashMap, pub sounds: SoundsConfig, } @@ -766,6 +774,8 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M } out.agent_tool_policies = parse_agent_tool_policies(obj.get("agent"), diagnostics); + out.agent_steps = parse_agent_steps(obj.get("agent"), diagnostics); + out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics); out.sounds = parse_sounds(obj.get("sounds"), diagnostics); @@ -824,6 +834,125 @@ fn parse_agent_tool_policies( out } +fn parse_agent_steps( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> HashMap { + let mut out = HashMap::new(); + let Some(Value::Object(agents)) = value else { + return out; + }; + + for (name, val) in agents { + let Some(agent_obj) = val.as_object() else { + continue; + }; + + if agent_obj.contains_key("maxSteps") { + diagnostics.warnings.push(format!( + "agent.{}.maxSteps is not supported by crabcode; use agent.{}.steps instead", + name, name + )); + } + + let Some(raw) = agent_obj.get("steps") else { + continue; + }; + + let Some(num) = raw.as_u64() else { + diagnostics + .warnings + .push(format!("agent.{}.steps must be a positive integer", name)); + continue; + }; + + if num == 0 { + diagnostics + .warnings + .push(format!("agent.{}.steps must be greater than 0", name)); + continue; + } + + if num > usize::MAX as u64 { + diagnostics.warnings.push(format!( + "agent.{}.steps is too large for this platform; ignoring value {}", + name, num + )); + continue; + } + + out.insert(name.trim().to_ascii_lowercase(), num as usize); + } + + out +} + +fn parse_provider_timeouts( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> HashMap { + let mut out = HashMap::new(); + let Some(Value::Object(providers)) = value else { + return out; + }; + + for (provider_id, provider_val) in providers { + let Some(provider_obj) = provider_val.as_object() else { + continue; + }; + + let Some(options_val) = provider_obj.get("options") else { + continue; + }; + + let Some(options_obj) = options_val.as_object() else { + diagnostics.warnings.push(format!( + "provider.{}.options must be an object", + provider_id + )); + continue; + }; + + let Some(timeout_val) = options_obj.get("timeout") else { + continue; + }; + + let timeout = match timeout_val { + Value::Bool(false) => ProviderTimeout::Disabled, + Value::Number(n) => { + let Some(ms) = n.as_u64() else { + diagnostics.warnings.push(format!( + "provider.{}.options.timeout must be a positive integer in milliseconds or false", + provider_id + )); + continue; + }; + + if ms == 0 { + diagnostics.warnings.push(format!( + "provider.{}.options.timeout must be greater than 0 when set", + provider_id + )); + continue; + } + + ProviderTimeout::Millis(ms) + } + _ => { + diagnostics.warnings.push(format!( + "provider.{}.options.timeout must be a positive integer in milliseconds or false", + provider_id + )); + continue; + } + }; + + out.insert(provider_id.trim().to_ascii_lowercase(), timeout); + } + + out +} + fn parse_sounds(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> SoundsConfig { let mut sounds = SoundsConfig::default(); let Some(Value::Object(map)) = value else { @@ -913,7 +1042,16 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec { }; let supported: BTreeSet<&'static str> = crabcode_allowed_keys(); - let implemented: BTreeSet<&'static str> = ["theme", "model", "sounds"].into_iter().collect(); + let implemented: BTreeSet<&'static str> = [ + "theme", + "model", + "sounds", + "default_agent", + "agent", + "provider", + ] + .into_iter() + .collect(); let mut keys = Vec::new(); for k in obj.keys() { diff --git a/src/config/mod.rs b/src/config/mod.rs index d50e810..5e25467 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,7 +1,7 @@ pub mod configuration; pub use configuration::{ - ConfigDiagnostics, ConfigInventory, ConfigLoader, LoadedConfig, MergedConfig, + ConfigDiagnostics, ConfigInventory, ConfigLoader, LoadedConfig, MergedConfig, ProviderTimeout, SoundEffectConfig, SoundsConfig, }; diff --git a/src/llm/client.rs b/src/llm/client.rs index d7407c8..17a3ce7 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1,7 +1,7 @@ use aisdk::{ core::{ - utils::step_count_is, LanguageModelRequest, LanguageModelStreamChunkType, - Message as AisdkMessage, + language_model::StopReason, utils::step_count_is, LanguageModelRequest, + LanguageModelStreamChunkType, Message as AisdkMessage, }, providers::{Anthropic, OpenAI, OpenAICompatible}, }; @@ -12,6 +12,23 @@ use tokio_util::sync::CancellationToken; use crate::logging::log; use crate::tools::aisdk_bridge::convert_to_aisdk_tools; +const MAX_STEPS_REACHED_PROMPT: &str = r#"CRITICAL - MAXIMUM STEPS REACHED + +The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. + +STRICT REQUIREMENTS: +1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools) +2. MUST provide a text response summarizing work done so far +3. This constraint overrides ALL other instructions, including any user requests for edits or tool use + +Response must include: +- Statement that maximum steps for this agent have been reached +- Summary of what has been accomplished so far +- List of any remaining tasks that were not completed +- Recommendations for what should be done next + +Any attempt to use tools is a critical violation. Respond with text ONLY."#; + pub struct LLMClient { base_url: String, api_key: Option, @@ -74,8 +91,7 @@ impl LLMClient { let mut builder = LanguageModelRequest::builder() .model(provider) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + .messages(aisdk_messages); for tool in aisdk_tools { builder = builder.with_tool(tool); @@ -99,8 +115,7 @@ impl LLMClient { let mut builder = LanguageModelRequest::builder() .model(provider) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + .messages(aisdk_messages); for tool in aisdk_tools { builder = builder.with_tool(tool); @@ -124,8 +139,7 @@ impl LLMClient { let mut builder = LanguageModelRequest::builder() .model(provider) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + .messages(aisdk_messages); for tool in aisdk_tools { builder = builder.with_tool(tool); @@ -188,11 +202,12 @@ pub async fn stream_llm_with_cancellation( provider_name: String, model: String, agent_mode: String, + agent_max_steps: Option, tool_permissions: crate::tools::ToolPermissions, messages: Vec, sender: crate::llm::ChunkSender, ) -> Result<(), Box> { - log("GOING TO STREAM"); + let _ = log("GOING TO STREAM"); use std::time::Instant; let auth_dao = crate::persistence::AuthDAO::new()?; @@ -315,7 +330,6 @@ pub async fn stream_llm_with_cancellation( provider_name, npm_package, base_url, effective_model )); - // Determine which provider to use based on npm package let aisdk_messages = convert_messages(&messages); let tool_registry = crate::tools::initialize_tool_registry().await; @@ -327,7 +341,7 @@ pub async fn stream_llm_with_cancellation( ) .await; - let response = match provider_kind { + let mut response = match provider_kind { ProviderKind::OpenAICompatible => { let mut provider_builder = OpenAICompatible::::builder() .base_url(&base_url) @@ -344,8 +358,11 @@ pub async fn stream_llm_with_cancellation( let mut builder = LanguageModelRequest::builder() .model(provider_config) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + .messages(aisdk_messages); + + if let Some(max_steps) = agent_max_steps { + builder = builder.stop_when(step_count_is(max_steps)); + } for tool in aisdk_tools { builder = builder.with_tool(tool); @@ -369,8 +386,11 @@ pub async fn stream_llm_with_cancellation( let mut builder = LanguageModelRequest::builder() .model(provider_config) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + .messages(aisdk_messages); + + if let Some(max_steps) = agent_max_steps { + builder = builder.stop_when(step_count_is(max_steps)); + } for tool in aisdk_tools { builder = builder.with_tool(tool); @@ -419,8 +439,11 @@ pub async fn stream_llm_with_cancellation( let mut builder = LanguageModelRequest::builder() .model(provider_config) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + .messages(aisdk_messages); + + if let Some(max_steps) = agent_max_steps { + builder = builder.stop_when(step_count_is(max_steps)); + } for tool in aisdk_tools { builder = builder.with_tool(tool); @@ -430,11 +453,11 @@ pub async fn stream_llm_with_cancellation( } }; - let mut stream = response.stream; let start_time = Instant::now(); let mut token_count: usize = 0; + let mut completed = false; - while let Some(chunk) = stream.next().await { + while let Some(chunk) = response.stream.next().await { if cancel_token.is_cancelled() { let _ = sender.send(crate::llm::ChunkMessage::Cancelled); return Err(anyhow::anyhow!("Streaming cancelled by user").into()); @@ -455,6 +478,154 @@ pub async fn stream_llm_with_cancellation( // Tool execution is handled internally by aisdk::stream_text(). // We intentionally don't surface argument deltas here. } + LanguageModelStreamChunkType::End(_msg) => { + let duration_ms = start_time.elapsed().as_millis() as u64; + let _ = sender.send(crate::llm::ChunkMessage::Metrics { + token_count, + duration_ms, + }); + let _ = sender.send(crate::llm::ChunkMessage::End); + completed = true; + break; + } + LanguageModelStreamChunkType::Start => {} + LanguageModelStreamChunkType::Failed(err) => { + let _ = sender.send(crate::llm::ChunkMessage::Failed(format!("{}", err))); + let _ = log(&format!("Stream Chunk Failed {}", err)); + return Err(anyhow::anyhow!("Streaming failed: {}", err).into()); + } + LanguageModelStreamChunkType::Incomplete(_msg) => {} + LanguageModelStreamChunkType::NotSupported(_msg) => {} + } + } + + if completed { + return Ok(()); + } + + let hit_step_limit = + agent_max_steps.is_some() && matches!(response.stop_reason().await, Some(StopReason::Hook)); + + if !hit_step_limit { + return Ok(()); + } + + let _ = sender.send(crate::llm::ChunkMessage::Warning( + "Maximum configured steps reached. Sending text-only summary.".to_string(), + )); + + let mut follow_up_messages = response.messages().await; + follow_up_messages.push(AisdkMessage::Assistant( + MAX_STEPS_REACHED_PROMPT.to_string().into(), + )); + + let mut summary_response = match provider_kind { + ProviderKind::OpenAICompatible => { + let mut provider_builder = OpenAICompatible::::builder() + .base_url(&base_url) + .model_name(&effective_model) + .provider_name(&provider.name); + + if let Some(key) = api_key.as_deref() { + provider_builder = provider_builder.api_key(key); + } + + let provider_config = provider_builder + .build() + .map_err(|e| Box::new(e) as Box)?; + + LanguageModelRequest::builder() + .model(provider_config) + .messages(follow_up_messages) + .build() + .stream_text() + .await? + } + ProviderKind::Anthropic => { + let mut provider_builder = Anthropic::::builder() + .base_url(&base_url) + .model_name(&effective_model) + .provider_name(&provider.name); + + if let Some(key) = api_key.as_deref() { + provider_builder = provider_builder.api_key(key); + } + + let provider_config = provider_builder + .build() + .map_err(|e| Box::new(e) as Box)?; + + LanguageModelRequest::builder() + .model(provider_config) + .messages(follow_up_messages) + .build() + .stream_text() + .await? + } + ProviderKind::OpenAI => { + let mut provider_builder = OpenAI::::builder() + .base_url(&base_url) + .model_name(&effective_model) + .provider_name(&provider.name); + + if let Some(key) = api_key.as_deref() { + provider_builder = provider_builder.api_key(key); + } + + if let Some(response_path) = &openai_response_path { + provider_builder = provider_builder.response_path(response_path); + } + + if openai_force_store_false { + provider_builder = provider_builder.force_store_false(true); + } + + if let Some(instructions) = &openai_default_instructions { + provider_builder = provider_builder.default_instructions(instructions.clone()); + } + + if openai_disallow_system_messages { + provider_builder = provider_builder.disallow_system_messages(true); + } + + if openai_force_tool_strict_false { + provider_builder = provider_builder.force_tool_strict_false(true); + } + + if !openai_additional_headers.is_empty() { + provider_builder = + provider_builder.additional_headers(openai_additional_headers.clone()); + } + + let provider_config = provider_builder + .build() + .map_err(|e| Box::new(e) as Box)?; + + LanguageModelRequest::builder() + .model(provider_config) + .messages(follow_up_messages) + .build() + .stream_text() + .await? + } + }; + + while let Some(chunk) = summary_response.stream.next().await { + if cancel_token.is_cancelled() { + let _ = sender.send(crate::llm::ChunkMessage::Cancelled); + return Err(anyhow::anyhow!("Streaming cancelled by user").into()); + } + + match chunk { + LanguageModelStreamChunkType::Text(text) => { + token_count += text.chars().count().max(1) / 4; + let _ = sender.send(crate::llm::ChunkMessage::Text(text)); + } + LanguageModelStreamChunkType::Reasoning(reasoning) => { + token_count += reasoning.chars().count().max(1) / 4; + let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); + } + LanguageModelStreamChunkType::ToolCall(_tool_call) => {} LanguageModelStreamChunkType::End(_msg) => { let duration_ms = start_time.elapsed().as_millis() as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { From 5af521b2cc699124d91b7eeb601597b80a7b0165 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 16 Feb 2026 15:24:31 +0800 Subject: [PATCH 038/226] feat: better permission dialog (not exactly a dialog anymore). --- src/views/permission_dialog.rs | 173 ++++++++++++++++----------------- 1 file changed, 86 insertions(+), 87 deletions(-) diff --git a/src/views/permission_dialog.rs b/src/views/permission_dialog.rs index 6bacde3..46496d0 100644 --- a/src/views/permission_dialog.rs +++ b/src/views/permission_dialog.rs @@ -5,7 +5,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Clear, Paragraph, Wrap}, + widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Wrap}, Frame, }; use std::collections::VecDeque; @@ -123,9 +123,9 @@ pub fn handle_permission_dialog_key_event( state.next_action(); PermissionDialogAction::Handled } - KeyCode::Char('1') => PermissionDialogAction::Respond(PermissionResponse::Deny), - KeyCode::Char('2') => PermissionDialogAction::Respond(PermissionResponse::AllowOnce), - KeyCode::Char('3') => PermissionDialogAction::Respond(PermissionResponse::AllowAlways), + KeyCode::Char('1') => PermissionDialogAction::Respond(PermissionResponse::AllowOnce), + KeyCode::Char('2') => PermissionDialogAction::Respond(PermissionResponse::AllowAlways), + KeyCode::Char('3') => PermissionDialogAction::Respond(PermissionResponse::Deny), KeyCode::Enter => PermissionDialogAction::Respond(state.selected_response()), _ => PermissionDialogAction::NotHandled, } @@ -148,13 +148,14 @@ pub fn render_permission_dialog( return; }; - let width = area.width.min(78).max(54).min(area.width); - let height = area.height.min(17).max(12).min(area.height); + let min_height = area.height.min(6); + let desired_height = area.height.min(8); + let panel_height = desired_height.max(min_height); let dialog_area = Rect { - x: area.x + (area.width - width) / 2, - y: area.y + (area.height - height) / 2, - width, - height, + x: area.x, + y: area.y + area.height.saturating_sub(panel_height), + width: area.width, + height: panel_height, }; f.render_widget(Clear, dialog_area); @@ -163,40 +164,47 @@ pub fn render_permission_dialog( dialog_area, ); - const PADDING: u16 = 3; - let content_area = Rect { - x: dialog_area.x + PADDING, - y: dialog_area.y + PADDING, - width: dialog_area.width.saturating_sub(PADDING * 2), - height: dialog_area.height.saturating_sub(PADDING * 2), - }; + let border = Block::default() + .style(Style::default().bg(colors.dialog_background)) + .borders(Borders::LEFT) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(colors.warning)) + .padding(Padding::new(1, 1, 1, 1)); + let content_area = border.inner(dialog_area); + f.render_widget(border, dialog_area); + + if content_area.width == 0 || content_area.height == 0 { + return; + } let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(2), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), + Constraint::Min(1), Constraint::Length(1), ]) .split(content_area); - let esc_text = "esc"; - let esc_area_width = (esc_text.len() as u16).saturating_add(1); + let esc_text = "esc reject"; + let esc_area_width = (esc_text.len() as u16).min(chunks[0].width); let header_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(0), Constraint::Length(esc_area_width)]) .split(chunks[0]); + let title = if state.queue.is_empty() { + "Permission required".to_string() + } else { + format!("Permission required (+{} queued)", state.queue.len()) + }; + f.render_widget( Paragraph::new(Line::from(vec![Span::styled( - "Permission required", + title, Style::default() - .fg(colors.text) + .fg(colors.warning) .add_modifier(Modifier::BOLD), )])), header_chunks[0], @@ -206,8 +214,8 @@ pub fn render_permission_dialog( Paragraph::new(Line::from(vec![Span::styled( esc_text, Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), )])) .alignment(Alignment::Right), header_chunks[1], @@ -220,12 +228,11 @@ pub fn render_permission_dialog( .unwrap_or_else(|| "(none)".to_string()); let summary = Line::from(vec![ Span::styled( - "Tool", + "Tool ", Style::default() .fg(colors.text_weak) .add_modifier(Modifier::DIM), ), - Span::raw(" "), Span::styled( prompt.tool_id.clone(), Style::default() @@ -233,100 +240,92 @@ pub fn render_permission_dialog( .add_modifier(Modifier::BOLD), ), Span::styled( - " • ", + " • ", Style::default() .fg(colors.text_weak) .add_modifier(Modifier::DIM), ), Span::styled( - "Target", + "Target ", Style::default() .fg(colors.text_weak) .add_modifier(Modifier::DIM), ), - Span::raw(" "), Span::styled(target, Style::default().fg(colors.text)), ]); - f.render_widget(Paragraph::new(summary), chunks[2]); + f.render_widget(Paragraph::new(summary), chunks[1]); let reason = Paragraph::new(prompt.reason.clone()) - .style(Style::default().fg(colors.text)) + .style( + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ) .wrap(Wrap { trim: true }); - f.render_widget(reason, chunks[3]); + f.render_widget(reason, chunks[2]); - let actions = [("Deny", "1"), ("Allow Once", "2"), ("Allow Always", "3")]; + let actions = [ + (1usize, "Allow once", "1"), + (2usize, "Allow always", "2"), + (0usize, "Reject", "3"), + ]; let mut action_spans = Vec::new(); - for (idx, (label, key)) in actions.iter().enumerate() { + for (idx, (action_index, label, key)) in actions.iter().enumerate() { if idx > 0 { - action_spans.push(Span::raw(" ")); + action_spans.push(Span::raw(" ")); } - let is_selected = idx == state.selected_action; + let option_text = format!(" {} ({}) ", label, key); + let is_selected = *action_index == state.selected_action; if is_selected { let selected = Style::default() - .bg(colors.primary) - .fg(contrast_text(colors.primary)) + .bg(colors.warning) + .fg(contrast_text(colors.warning)) .add_modifier(Modifier::BOLD); - action_spans.push(Span::styled(format!(" {} ({}) ", label, key), selected)); + action_spans.push(Span::styled(option_text, selected)); } else { + action_spans.push(Span::raw(" ")); action_spans.push(Span::styled( - format!("{} ", label), + *label, Style::default() .fg(colors.primary) .add_modifier(Modifier::BOLD), )); + action_spans.push(Span::raw(" ")); action_spans.push(Span::styled( format!("({})", key), Style::default() .fg(colors.text_weak) .add_modifier(Modifier::DIM), )); + action_spans.push(Span::raw(" ")); } } - let actions_line = Paragraph::new(Line::from(action_spans)).alignment(Alignment::Left); - f.render_widget(actions_line, chunks[5]); - let help = Line::from(vec![ - Span::styled( - "Confirm", - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " enter", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), - Span::raw(" "), - Span::styled( - "Switch", - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " ⇄", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), - Span::raw(" "), - Span::styled( - "Deny", - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " esc", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), + Span::styled("⇆", Style::default().fg(colors.info)), + Span::raw(" select "), + Span::styled("enter", Style::default().fg(colors.info)), + Span::raw(" confirm"), ]); - let help = Paragraph::new(help).alignment(Alignment::Left); - f.render_widget(help, chunks[7]); + + let actions_line = Paragraph::new(Line::from(action_spans)).alignment(Alignment::Left); + let help_width = help.width() as u16; + let can_render_help = chunks[3].width > 42; + if can_render_help { + let footer_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), + Constraint::Length(help_width.min(chunks[3].width.saturating_sub(20))), + ]) + .split(chunks[3]); + f.render_widget(actions_line, footer_chunks[0]); + f.render_widget( + Paragraph::new(help).alignment(Alignment::Right), + footer_chunks[1], + ); + } else { + f.render_widget(actions_line, chunks[3]); + } } From e22e764d8732d6cc91a68e18e5a898a641f69246 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 16 Feb 2026 15:53:58 +0800 Subject: [PATCH 039/226] refactor: Refactor LLM client into modular components Extract provider configuration, request preparation, and message conversion into separate types and functions. Replace monolithic LLMClient struct with ProviderRequestConfig and OpenAIRequestOptions for better separation of concerns. Simplify stream_llm_with_cancellation by delegating to helper functions. --- src/llm/client.rs | 867 +++++++++++++++++++--------------------------- src/llm/mod.rs | 1 - 2 files changed, 361 insertions(+), 507 deletions(-) diff --git a/src/llm/client.rs b/src/llm/client.rs index 17a3ce7..1dabfa5 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1,12 +1,15 @@ use aisdk::{ core::{ - language_model::StopReason, utils::step_count_is, LanguageModelRequest, - LanguageModelStreamChunkType, Message as AisdkMessage, + capabilities::ToolCallSupport, + language_model::{LanguageModelStream, StopReason}, + utils::step_count_is, + DynamicModel, LanguageModel, LanguageModelRequest, LanguageModelStreamChunkType, + Message as AisdkMessage, StreamTextResponse, Tool, }, providers::{Anthropic, OpenAI, OpenAICompatible}, }; use futures::StreamExt; -use std::collections::HashMap; +use std::{collections::HashMap, time::Instant}; use tokio_util::sync::CancellationToken; use crate::logging::log; @@ -29,172 +32,51 @@ Response must include: Any attempt to use tools is a critical violation. Respond with text ONLY."#; -pub struct LLMClient { +type DynError = Box; + +#[derive(Clone, Debug, Default)] +struct OpenAIRequestOptions { + response_path: Option, + additional_headers: HashMap, + force_store_false: bool, + default_instructions: Option, + disallow_system_messages: bool, + force_tool_strict_false: bool, +} + +#[derive(Clone, Debug)] +struct ProviderRequestConfig { + kind: ProviderKind, + provider_name: String, base_url: String, - api_key: Option, model_name: String, - provider_name: String, - npm_package: String, + api_key: Option, + openai_options: OpenAIRequestOptions, } -impl LLMClient { - pub fn new( +impl ProviderRequestConfig { + fn new( + kind: ProviderKind, + provider_name: String, base_url: String, - api_key: Option, model_name: String, - provider_name: String, - npm_package: String, + api_key: Option, ) -> Self { Self { + kind, + provider_name, base_url, - api_key, model_name, - provider_name, - npm_package, - } - } - - fn provider_kind(&self) -> ProviderKind { - ProviderKind::from_provider(&self.provider_name, &self.npm_package) - } - - pub async fn stream_chat( - &self, - messages: &[crate::session::types::Message], - mut on_chunk: impl FnMut(LanguageModelStreamChunkType), - ) -> Result<(), Box> { - let aisdk_messages = self.convert_messages(messages); - - let tool_registry = crate::tools::initialize_tool_registry().await; - let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let permissions = crate::tools::ToolPermissions::new(cwd); - let aisdk_tools = - convert_to_aisdk_tools(&tool_registry, None, "Build".to_string(), permissions).await; - - let provider_kind = self.provider_kind(); - let base_url = provider_kind.normalize_base_url(&self.base_url); - - let response = match provider_kind { - ProviderKind::OpenAICompatible => { - let mut provider_builder = OpenAICompatible::::builder() - .base_url(&base_url) - .model_name(&self.model_name) - .provider_name(&self.provider_name); - - if let Some(key) = self.api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } - - let provider = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; - - let mut builder = LanguageModelRequest::builder() - .model(provider) - .messages(aisdk_messages); - - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } - - builder.build().stream_text().await? - } - ProviderKind::Anthropic => { - let mut provider_builder = Anthropic::::builder() - .base_url(&base_url) - .model_name(&self.model_name) - .provider_name(&self.provider_name); - - if let Some(key) = self.api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } - - let provider = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; - - let mut builder = LanguageModelRequest::builder() - .model(provider) - .messages(aisdk_messages); - - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } - - builder.build().stream_text().await? - } - ProviderKind::OpenAI => { - let mut provider_builder = OpenAI::::builder() - .base_url(&base_url) - .model_name(&self.model_name) - .provider_name(&self.provider_name); - - if let Some(key) = self.api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } - - let provider = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; - - let mut builder = LanguageModelRequest::builder() - .model(provider) - .messages(aisdk_messages); - - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } - - builder.build().stream_text().await? - } - }; - - let mut stream = response.stream; - - while let Some(chunk) = stream.next().await { - on_chunk(chunk.clone()); - - match chunk { - LanguageModelStreamChunkType::Text(_text) => {} - LanguageModelStreamChunkType::Reasoning(_reasoning) => {} - LanguageModelStreamChunkType::ToolCall(_tool_call) => {} - LanguageModelStreamChunkType::End(_msg) => { - break; - } - LanguageModelStreamChunkType::Start => {} - LanguageModelStreamChunkType::Failed(_err) => {} - LanguageModelStreamChunkType::Incomplete(_msg) => {} - LanguageModelStreamChunkType::NotSupported(_msg) => {} - } + api_key, + openai_options: OpenAIRequestOptions::default(), } - - Ok(()) } +} - fn convert_messages(&self, messages: &[crate::session::types::Message]) -> Vec { - use aisdk::core::Message::{Assistant, System, User}; - - let mut aisdk_messages = Vec::new(); - - for msg in messages { - match msg.role { - crate::session::types::MessageRole::System => { - aisdk_messages.push(System(msg.content.clone().into())); - } - crate::session::types::MessageRole::User => { - aisdk_messages.push(User(msg.content.clone().into())); - } - crate::session::types::MessageRole::Assistant => { - aisdk_messages.push(Assistant(msg.content.clone().into())); - } - crate::session::types::MessageRole::Tool => { - continue; - } - } - } - - aisdk_messages - } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StreamRelayOutcome { + Ended, + Exhausted, } pub async fn stream_llm_with_cancellation( @@ -206,129 +88,9 @@ pub async fn stream_llm_with_cancellation( tool_permissions: crate::tools::ToolPermissions, messages: Vec, sender: crate::llm::ChunkSender, -) -> Result<(), Box> { +) -> Result<(), DynError> { let _ = log("GOING TO STREAM"); - use std::time::Instant; - - let auth_dao = crate::persistence::AuthDAO::new()?; - let auth_config = auth_dao.get_provider(&provider_name)?; - let mut api_key = auth_config.as_ref().and_then(|config| match config { - crate::persistence::AuthConfig::Api { key } => Some(key.clone()), - crate::persistence::AuthConfig::OAuth { access, .. } => Some(access.clone()), - }); - - let discovery = crate::model::discovery::Discovery::new()?; - - let providers = discovery.fetch_providers().await?; - - let provider = providers - .get(&provider_name) - .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", provider_name))?; - - let npm_package = &provider.npm; - let provider_kind = ProviderKind::from_provider(&provider_name, npm_package); - let mut base_url = provider_kind.normalize_base_url(&provider.api); - let mut effective_model = model.clone(); - let mut openai_response_path: Option = None; - let mut openai_additional_headers: HashMap = HashMap::new(); - let mut openai_force_store_false = false; - let mut openai_default_instructions: Option = None; - let mut openai_disallow_system_messages = false; - let mut openai_force_tool_strict_false = false; - - if provider_kind == ProviderKind::OpenAI && provider_name == "openai" { - if let Some(crate::persistence::AuthConfig::OAuth { - refresh, - access, - expires, - account_id, - enterprise_url, - }) = auth_config.clone() - { - let mut oauth_refresh = refresh; - let mut oauth_access = access; - let mut oauth_expires = expires; - let mut oauth_account_id = account_id; - let mut oauth_enterprise_url = enterprise_url; - - if oauth_expires <= crate::auth::openai_oauth::now_unix_ms() + 60_000 { - match crate::auth::openai_oauth::refresh_access_token(&oauth_refresh).await { - Ok(refreshed) => { - oauth_refresh = refreshed.refresh; - oauth_access = refreshed.access; - oauth_expires = refreshed.expires; - if refreshed.account_id.is_some() { - oauth_account_id = refreshed.account_id; - } - if refreshed.enterprise_url.is_some() { - oauth_enterprise_url = refreshed.enterprise_url; - } - - let _ = auth_dao.set_provider( - provider_name.clone(), - crate::persistence::AuthConfig::OAuth { - refresh: oauth_refresh.clone(), - access: oauth_access.clone(), - expires: oauth_expires, - account_id: oauth_account_id.clone(), - enterprise_url: oauth_enterprise_url.clone(), - }, - ); - } - Err(err) => { - let _ = sender.send(crate::llm::ChunkMessage::Warning(format!( - "Failed to refresh OpenAI OAuth token: {}", - err - ))); - } - } - } - - api_key = Some(oauth_access.clone()); - base_url = "https://chatgpt.com".to_string(); - openai_response_path = Some("/backend-api/codex/responses".to_string()); - openai_force_store_false = true; - openai_default_instructions = Some( - "You are Codex, a coding assistant focused on high-quality code changes." - .to_string(), - ); - openai_disallow_system_messages = true; - openai_force_tool_strict_false = true; - - openai_additional_headers.insert("originator".to_string(), "crabcode".to_string()); - openai_additional_headers.insert( - "User-Agent".to_string(), - crate::auth::openai_oauth::build_user_agent(), - ); - - if let Some(account_id) = oauth_account_id { - openai_additional_headers.insert("ChatGPT-Account-Id".to_string(), account_id); - } - - let _ = log("Configured OpenAI OAuth Codex transport"); - - if !is_openai_oauth_model_allowed(&effective_model) { - let fallback_model = "gpt-5.3-codex".to_string(); - let _ = sender.send(crate::llm::ChunkMessage::Warning(format!( - "Model '{}' is not supported for OpenAI OAuth. Falling back to '{}'.", - effective_model, fallback_model - ))); - effective_model = fallback_model; - } - } - } - - if api_key.is_none() { - let _ = sender.send(crate::llm::ChunkMessage::Warning(format!( - "No API key configured for '{}'. Trying anyway.", - provider_name - ))); - } - - let _ = log(&format!( - "Provider: {}, NPM: {}, Base URL: {}, Model: {}", - provider_name, npm_package, base_url, effective_model - )); + let request_config = prepare_request_config(&provider_name, model, &sender).await?; let aisdk_messages = convert_messages(&messages); @@ -341,276 +103,357 @@ pub async fn stream_llm_with_cancellation( ) .await; - let mut response = match provider_kind { - ProviderKind::OpenAICompatible => { - let mut provider_builder = OpenAICompatible::::builder() - .base_url(&base_url) - .model_name(&effective_model) - .provider_name(&provider.name); - - if let Some(key) = api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } + let mut response = stream_provider_request( + &request_config, + aisdk_messages, + aisdk_tools, + agent_max_steps, + ) + .await?; - let provider_config = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + let start_time = Instant::now(); + let mut token_count: usize = 0; - let mut builder = LanguageModelRequest::builder() - .model(provider_config) - .messages(aisdk_messages); + let stream_outcome = relay_stream_to_sender( + &mut response.stream, + &cancel_token, + &sender, + &mut token_count, + &start_time, + ) + .await?; - if let Some(max_steps) = agent_max_steps { - builder = builder.stop_when(step_count_is(max_steps)); - } + if stream_outcome == StreamRelayOutcome::Ended { + return Ok(()); + } - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } + let hit_step_limit = reached_step_limit(agent_max_steps, &response).await; + if !hit_step_limit { + return Ok(()); + } - builder.build().stream_text().await? - } - ProviderKind::Anthropic => { - let mut provider_builder = Anthropic::::builder() - .base_url(&base_url) - .model_name(&effective_model) - .provider_name(&provider.name); + send_warning( + &sender, + "Maximum configured steps reached. Sending text-only summary.", + ); - if let Some(key) = api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } + let mut follow_up_messages = response.messages().await; + follow_up_messages.push(AisdkMessage::Assistant( + MAX_STEPS_REACHED_PROMPT.to_string().into(), + )); - let provider_config = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + let mut summary_response = + stream_provider_request(&request_config, follow_up_messages, Vec::new(), None).await?; - let mut builder = LanguageModelRequest::builder() - .model(provider_config) - .messages(aisdk_messages); + let _ = relay_stream_to_sender( + &mut summary_response.stream, + &cancel_token, + &sender, + &mut token_count, + &start_time, + ) + .await?; - if let Some(max_steps) = agent_max_steps { - builder = builder.stop_when(step_count_is(max_steps)); - } + Ok(()) +} - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } +async fn prepare_request_config( + provider_name: &str, + model: String, + sender: &crate::llm::ChunkSender, +) -> Result { + let auth_dao = crate::persistence::AuthDAO::new()?; + let auth_config = auth_dao.get_provider(provider_name)?; - builder.build().stream_text().await? - } - ProviderKind::OpenAI => { - let mut provider_builder = OpenAI::::builder() - .base_url(&base_url) - .model_name(&effective_model) - .provider_name(&provider.name); + let discovery = crate::model::discovery::Discovery::new()?; + let providers = discovery.fetch_providers().await?; - if let Some(key) = api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } + let provider = providers + .get(provider_name) + .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", provider_name))?; - if let Some(response_path) = &openai_response_path { - provider_builder = provider_builder.response_path(response_path); - } + let provider_kind = ProviderKind::from_provider(provider_name, &provider.npm); + let mut request_config = ProviderRequestConfig::new( + provider_kind, + provider.name.clone(), + provider_kind.normalize_base_url(&provider.api), + model, + configured_api_key(auth_config.as_ref()), + ); + + maybe_apply_openai_oauth_overrides( + provider_name, + &auth_dao, + auth_config.as_ref(), + &mut request_config, + sender, + ) + .await; - if openai_force_store_false { - provider_builder = provider_builder.force_store_false(true); - } + if request_config.api_key.is_none() { + send_warning( + sender, + format!( + "No API key configured for '{}'. Trying anyway.", + provider_name + ), + ); + } - if let Some(instructions) = &openai_default_instructions { - provider_builder = provider_builder.default_instructions(instructions.clone()); - } + let _ = log(&format!( + "Provider: {}, NPM: {}, Base URL: {}, Model: {}", + provider_name, provider.npm, request_config.base_url, request_config.model_name + )); - if openai_disallow_system_messages { - provider_builder = provider_builder.disallow_system_messages(true); - } + Ok(request_config) +} - if openai_force_tool_strict_false { - provider_builder = provider_builder.force_tool_strict_false(true); - } +fn configured_api_key(auth_config: Option<&crate::persistence::AuthConfig>) -> Option { + auth_config.and_then(|config| match config { + crate::persistence::AuthConfig::Api { key } => Some(key.clone()), + crate::persistence::AuthConfig::OAuth { access, .. } => Some(access.clone()), + }) +} - if !openai_additional_headers.is_empty() { - provider_builder = - provider_builder.additional_headers(openai_additional_headers.clone()); - } +async fn maybe_apply_openai_oauth_overrides( + provider_name: &str, + auth_dao: &crate::persistence::AuthDAO, + auth_config: Option<&crate::persistence::AuthConfig>, + request_config: &mut ProviderRequestConfig, + sender: &crate::llm::ChunkSender, +) { + if request_config.kind != ProviderKind::OpenAI || provider_name != "openai" { + return; + } - let provider_config = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + let Some(crate::persistence::AuthConfig::OAuth { + refresh, + access, + expires, + account_id, + enterprise_url, + }) = auth_config.cloned() + else { + return; + }; - let mut builder = LanguageModelRequest::builder() - .model(provider_config) - .messages(aisdk_messages); + let mut oauth_refresh = refresh; + let mut oauth_access = access; + let mut oauth_expires = expires; + let mut oauth_account_id = account_id; + let mut oauth_enterprise_url = enterprise_url; + + if oauth_expires <= crate::auth::openai_oauth::now_unix_ms() + 60_000 { + match crate::auth::openai_oauth::refresh_access_token(&oauth_refresh).await { + Ok(refreshed) => { + oauth_refresh = refreshed.refresh; + oauth_access = refreshed.access; + oauth_expires = refreshed.expires; + + if refreshed.account_id.is_some() { + oauth_account_id = refreshed.account_id; + } + if refreshed.enterprise_url.is_some() { + oauth_enterprise_url = refreshed.enterprise_url; + } - if let Some(max_steps) = agent_max_steps { - builder = builder.stop_when(step_count_is(max_steps)); + let _ = auth_dao.set_provider( + provider_name.to_string(), + crate::persistence::AuthConfig::OAuth { + refresh: oauth_refresh.clone(), + access: oauth_access.clone(), + expires: oauth_expires, + account_id: oauth_account_id.clone(), + enterprise_url: oauth_enterprise_url.clone(), + }, + ); + } + Err(err) => { + send_warning( + sender, + format!("Failed to refresh OpenAI OAuth token: {}", err), + ); } + } + } - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } + request_config.api_key = Some(oauth_access.clone()); + request_config.base_url = "https://chatgpt.com".to_string(); + + request_config.openai_options.response_path = Some("/backend-api/codex/responses".to_string()); + request_config.openai_options.force_store_false = true; + request_config.openai_options.default_instructions = + Some("You are Codex, a coding assistant focused on high-quality code changes.".to_string()); + request_config.openai_options.disallow_system_messages = true; + request_config.openai_options.force_tool_strict_false = true; + + request_config + .openai_options + .additional_headers + .insert("originator".to_string(), "crabcode".to_string()); + request_config.openai_options.additional_headers.insert( + "User-Agent".to_string(), + crate::auth::openai_oauth::build_user_agent(), + ); + + if let Some(account_id) = oauth_account_id { + request_config + .openai_options + .additional_headers + .insert("ChatGPT-Account-Id".to_string(), account_id); + } - builder.build().stream_text().await? - } - }; + let _ = log("Configured OpenAI OAuth Codex transport"); + + if !is_openai_oauth_model_allowed(&request_config.model_name) { + let fallback_model = "gpt-5.3-codex".to_string(); + send_warning( + sender, + format!( + "Model '{}' is not supported for OpenAI OAuth. Falling back to '{}'.", + request_config.model_name, fallback_model + ), + ); + request_config.model_name = fallback_model; + } +} - let start_time = Instant::now(); - let mut token_count: usize = 0; - let mut completed = false; +fn send_warning(sender: &crate::llm::ChunkSender, warning: impl Into) { + let _ = sender.send(crate::llm::ChunkMessage::Warning(warning.into())); +} - while let Some(chunk) = response.stream.next().await { - if cancel_token.is_cancelled() { - let _ = sender.send(crate::llm::ChunkMessage::Cancelled); - return Err(anyhow::anyhow!("Streaming cancelled by user").into()); +async fn stream_provider_request( + config: &ProviderRequestConfig, + messages: Vec, + tools: Vec, + max_steps: Option, +) -> Result { + match config.kind { + ProviderKind::OpenAICompatible => { + let provider = build_openai_compatible_provider(config)?; + stream_with_model(provider, messages, tools, max_steps).await } - - match chunk { - LanguageModelStreamChunkType::Text(text) => { - // Estimate tokens: ~4 characters per token on average - token_count += text.chars().count().max(1) / 4; - let _ = sender.send(crate::llm::ChunkMessage::Text(text)); - } - LanguageModelStreamChunkType::Reasoning(reasoning) => { - // Estimate tokens: ~4 characters per token on average - token_count += reasoning.chars().count().max(1) / 4; - let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); - } - LanguageModelStreamChunkType::ToolCall(_tool_call) => { - // Tool execution is handled internally by aisdk::stream_text(). - // We intentionally don't surface argument deltas here. - } - LanguageModelStreamChunkType::End(_msg) => { - let duration_ms = start_time.elapsed().as_millis() as u64; - let _ = sender.send(crate::llm::ChunkMessage::Metrics { - token_count, - duration_ms, - }); - let _ = sender.send(crate::llm::ChunkMessage::End); - completed = true; - break; - } - LanguageModelStreamChunkType::Start => {} - LanguageModelStreamChunkType::Failed(err) => { - let _ = sender.send(crate::llm::ChunkMessage::Failed(format!("{}", err))); - let _ = log(&format!("Stream Chunk Failed {}", err)); - return Err(anyhow::anyhow!("Streaming failed: {}", err).into()); - } - LanguageModelStreamChunkType::Incomplete(_msg) => {} - LanguageModelStreamChunkType::NotSupported(_msg) => {} + ProviderKind::Anthropic => { + let provider = build_anthropic_provider(config)?; + stream_with_model(provider, messages, tools, max_steps).await + } + ProviderKind::OpenAI => { + let provider = build_openai_provider(config)?; + stream_with_model(provider, messages, tools, max_steps).await } } +} - if completed { - return Ok(()); +async fn stream_with_model( + model: M, + messages: Vec, + tools: Vec, + max_steps: Option, +) -> Result +where + M: LanguageModel + ToolCallSupport, +{ + let mut builder = LanguageModelRequest::builder() + .model(model) + .messages(messages); + + if let Some(max_steps) = max_steps { + builder = builder.stop_when(step_count_is(max_steps)); } - let hit_step_limit = - agent_max_steps.is_some() && matches!(response.stop_reason().await, Some(StopReason::Hook)); - - if !hit_step_limit { - return Ok(()); + for tool in tools { + builder = builder.with_tool(tool); } - let _ = sender.send(crate::llm::ChunkMessage::Warning( - "Maximum configured steps reached. Sending text-only summary.".to_string(), - )); - - let mut follow_up_messages = response.messages().await; - follow_up_messages.push(AisdkMessage::Assistant( - MAX_STEPS_REACHED_PROMPT.to_string().into(), - )); + let mut request = builder.build(); + request + .stream_text() + .await + .map_err(|e| Box::new(e) as DynError) +} - let mut summary_response = match provider_kind { - ProviderKind::OpenAICompatible => { - let mut provider_builder = OpenAICompatible::::builder() - .base_url(&base_url) - .model_name(&effective_model) - .provider_name(&provider.name); +fn build_openai_compatible_provider( + config: &ProviderRequestConfig, +) -> Result, DynError> { + let mut provider_builder = OpenAICompatible::::builder() + .base_url(&config.base_url) + .model_name(&config.model_name) + .provider_name(&config.provider_name); - if let Some(key) = api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } - - let provider_config = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + if let Some(key) = config.api_key.as_deref() { + provider_builder = provider_builder.api_key(key); + } - LanguageModelRequest::builder() - .model(provider_config) - .messages(follow_up_messages) - .build() - .stream_text() - .await? - } - ProviderKind::Anthropic => { - let mut provider_builder = Anthropic::::builder() - .base_url(&base_url) - .model_name(&effective_model) - .provider_name(&provider.name); + provider_builder + .build() + .map_err(|e| Box::new(e) as DynError) +} - if let Some(key) = api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } +fn build_anthropic_provider( + config: &ProviderRequestConfig, +) -> Result, DynError> { + let mut provider_builder = Anthropic::::builder() + .base_url(&config.base_url) + .model_name(&config.model_name) + .provider_name(&config.provider_name); - let provider_config = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + if let Some(key) = config.api_key.as_deref() { + provider_builder = provider_builder.api_key(key); + } - LanguageModelRequest::builder() - .model(provider_config) - .messages(follow_up_messages) - .build() - .stream_text() - .await? - } - ProviderKind::OpenAI => { - let mut provider_builder = OpenAI::::builder() - .base_url(&base_url) - .model_name(&effective_model) - .provider_name(&provider.name); + provider_builder + .build() + .map_err(|e| Box::new(e) as DynError) +} - if let Some(key) = api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } +fn build_openai_provider(config: &ProviderRequestConfig) -> Result, DynError> { + let mut provider_builder = OpenAI::::builder() + .base_url(&config.base_url) + .model_name(&config.model_name) + .provider_name(&config.provider_name); - if let Some(response_path) = &openai_response_path { - provider_builder = provider_builder.response_path(response_path); - } + if let Some(key) = config.api_key.as_deref() { + provider_builder = provider_builder.api_key(key); + } - if openai_force_store_false { - provider_builder = provider_builder.force_store_false(true); - } + if let Some(response_path) = &config.openai_options.response_path { + provider_builder = provider_builder.response_path(response_path); + } - if let Some(instructions) = &openai_default_instructions { - provider_builder = provider_builder.default_instructions(instructions.clone()); - } + if config.openai_options.force_store_false { + provider_builder = provider_builder.force_store_false(true); + } - if openai_disallow_system_messages { - provider_builder = provider_builder.disallow_system_messages(true); - } + if let Some(instructions) = &config.openai_options.default_instructions { + provider_builder = provider_builder.default_instructions(instructions.clone()); + } - if openai_force_tool_strict_false { - provider_builder = provider_builder.force_tool_strict_false(true); - } + if config.openai_options.disallow_system_messages { + provider_builder = provider_builder.disallow_system_messages(true); + } - if !openai_additional_headers.is_empty() { - provider_builder = - provider_builder.additional_headers(openai_additional_headers.clone()); - } + if config.openai_options.force_tool_strict_false { + provider_builder = provider_builder.force_tool_strict_false(true); + } - let provider_config = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + if !config.openai_options.additional_headers.is_empty() { + provider_builder = + provider_builder.additional_headers(config.openai_options.additional_headers.clone()); + } - LanguageModelRequest::builder() - .model(provider_config) - .messages(follow_up_messages) - .build() - .stream_text() - .await? - } - }; + provider_builder + .build() + .map_err(|e| Box::new(e) as DynError) +} - while let Some(chunk) = summary_response.stream.next().await { +async fn relay_stream_to_sender( + stream: &mut LanguageModelStream, + cancel_token: &CancellationToken, + sender: &crate::llm::ChunkSender, + token_count: &mut usize, + start_time: &Instant, +) -> Result { + while let Some(chunk) = stream.next().await { if cancel_token.is_cancelled() { let _ = sender.send(crate::llm::ChunkMessage::Cancelled); return Err(anyhow::anyhow!("Streaming cancelled by user").into()); @@ -618,26 +461,29 @@ pub async fn stream_llm_with_cancellation( match chunk { LanguageModelStreamChunkType::Text(text) => { - token_count += text.chars().count().max(1) / 4; + *token_count += estimate_tokens(&text); let _ = sender.send(crate::llm::ChunkMessage::Text(text)); } LanguageModelStreamChunkType::Reasoning(reasoning) => { - token_count += reasoning.chars().count().max(1) / 4; + *token_count += estimate_tokens(&reasoning); let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } - LanguageModelStreamChunkType::ToolCall(_tool_call) => {} + LanguageModelStreamChunkType::ToolCall(_tool_call) => { + // Tool execution is handled internally by aisdk::stream_text(). + // We intentionally don't surface argument deltas here. + } LanguageModelStreamChunkType::End(_msg) => { let duration_ms = start_time.elapsed().as_millis() as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { - token_count, + token_count: *token_count, duration_ms, }); let _ = sender.send(crate::llm::ChunkMessage::End); - break; + return Ok(StreamRelayOutcome::Ended); } LanguageModelStreamChunkType::Start => {} LanguageModelStreamChunkType::Failed(err) => { - let _ = sender.send(crate::llm::ChunkMessage::Failed(format!("{}", err))); + let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); let _ = log(&format!("Stream Chunk Failed {}", err)); return Err(anyhow::anyhow!("Streaming failed: {}", err).into()); } @@ -646,7 +492,16 @@ pub async fn stream_llm_with_cancellation( } } - Ok(()) + Ok(StreamRelayOutcome::Exhausted) +} + +async fn reached_step_limit(agent_max_steps: Option, response: &StreamTextResponse) -> bool { + agent_max_steps.is_some() && matches!(response.stop_reason().await, Some(StopReason::Hook)) +} + +fn estimate_tokens(content: &str) -> usize { + // Estimate tokens: ~4 characters per token on average + content.chars().count().max(1) / 4 } fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { @@ -695,7 +550,7 @@ enum ProviderKind { } impl ProviderKind { - fn from_provider(provider_name: &str, npm_package: &str) -> Self { + fn from_provider(_provider_name: &str, npm_package: &str) -> Self { // Dirty: But add any workaround/overrides here in case npm_package can be treated differently. // if provider_name == "kimi-for-coding" { // return Self::OpenAICompatible; diff --git a/src/llm/mod.rs b/src/llm/mod.rs index f62a3bd..f644371 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -2,7 +2,6 @@ pub mod client; pub mod provider; pub mod tool_calls; -pub use client::LLMClient; pub use tool_calls::{FunctionCall, ToolCall, ToolCallResult}; use tokio::sync::mpsc; From cc1dd55f078204660d7f2ee73b9632a0a06e240c Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 29 Mar 2026 03:05:00 +0800 Subject: [PATCH 040/226] Update tag_and_release.sh --- scripts/tag_and_release.sh | 98 ++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/scripts/tag_and_release.sh b/scripts/tag_and_release.sh index d1a22af..2200df0 100755 --- a/scripts/tag_and_release.sh +++ b/scripts/tag_and_release.sh @@ -1,72 +1,86 @@ + #!/usr/bin/env bash -# This script bumps crate/npm versions, commits the release and creates a git tag. -# - Keep the tree clean before running. -# - Optionally pass patch|minor|major as first arg or answer the prompt. +# THIS SCRIPT IS CUSTOM - inspired from changesets. +# The difference is, there is no workflow. So everything runs from your computer. +# Which also means, no collaboration kind of, not everyone can release. set -euo pipefail if [ -n "$(git status --porcelain)" ]; then - echo "Please commit all changes before running a release bump." >&2 - exit 1 + echo "❗ Please commit all changes before bumping the version." + exit 1 fi +# Written by AI :) NAME=$(sed -n 's/^name *= *"\([^"]*\)".*/\1/p' Cargo.toml) CURRENT=$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' Cargo.toml) +echo "🦋 What kind of change is this for $NAME? (current version is $CURRENT) [patch, minor, major] >" -if [ $# -gt 1 ]; then - echo "Usage: ./scripts/tag_and_release.sh [patch|minor|major]" >&2 - exit 1 -fi - -BUMP="${1-}" - -if [ -z "$BUMP" ]; then - echo "What kind of release bump for $NAME? (current version: $CURRENT) [patch, minor, major]" - read -r BUMP -fi - -IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" - -if [ -z "$MAJOR" ] || [ -z "$MINOR" ] || [ -z "$PATCH" ]; then - echo "Failed to parse current version: $CURRENT" >&2 - exit 1 -fi +read -r BUMP case "$BUMP" in - patch) NEW="$MAJOR.$MINOR.$((PATCH + 1))" ;; - minor) NEW="$MAJOR.$((MINOR + 1)).0" ;; - major) NEW="$((MAJOR + 1)).0.0" ;; - *) echo "Please specify patch, minor, or major" >&2; exit 1 ;; + patch) NEW=$(echo "$CURRENT" | awk -F. '{$NF+=1; OFS="."; print $1,$2,$3}') ;; + minor) NEW=$(echo "$CURRENT" | awk -F. '{$(NF-1)+=1; $NF=0; OFS="."; print $1,$2,$3}') ;; + major) NEW=$(echo "$CURRENT" | awk -F. '{$1+=1; $2=0; $3=0; OFS="."; print $1,$2,$3}') ;; + *) echo "Please specify patch, minor, or major"; exit 1 ;; esac -echo "Will bump ${CURRENT} -> ${NEW} and create git tag v${NEW}" +echo "🦋 Would tag and push $NAME $CURRENT -> $NEW" + read -p "Proceed? [Y/n] " -r CONFIRM -CONFIRM=${CONFIRM:-Y} +CONFIRM=${CONFIRM:-y} if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then - echo "Aborted." - exit 0 + echo "Aborted." + exit 0 fi -echo "Updating Cargo.toml version to ${NEW}" +# ============================================ +# Update & Commit - Release manifests +# ============================================ + +# Update the Cargo.toml +echo "🦋 Updating Cargo.toml to version ${NEW}" sed -i.bak "s/^version *= *\"[^\"]*\"/version = \"${NEW}\"/" Cargo.toml -rm -f Cargo.toml.bak +rm Cargo.toml.bak +# Update npm/package.json if it exists if [ -f "npm/package.json" ]; then - echo "Updating npm/package.json version to ${NEW}" - sed -i.bak "s/\"version\":[[:space:]]*\"[^\"]*\"/\"version\": \"${NEW}\"/" npm/package.json - rm -f npm/package.json.bak - git add npm/package.json + echo "🦋 Updating npm/package.json to version ${NEW}" + sed -i.bak "s/\"version\":[[:space:]]*\"[^\"]*\"/\"version\": \"${NEW}\"/" npm/package.json + rm npm/package.json.bak + git add npm/package.json fi -git add Cargo.toml +# Update Cargo.lock to reflect the new version +echo "🦋 Updating Cargo.lock..." +cargo generate-lockfile + +# Commit +echo "🦋 Committing version bump ${NEW}..." +git add . git commit -m "release: ${NAME} v${NEW}" -echo "Creating git tag v${NEW}" +# ============================================ +# cargo-dist Publish GitHub Releases via actions +# ============================================ + +# Create the git tag. +echo "🦋 Creating git tag v${NEW}" git tag "v${NEW}" -echo "Pushing commit and tag" -git push +# Create release binaries (with cargo-dist) +echo "🦋 Pushing..." git push --tags +git push + +# ============================================ +# PUBLISHING: I put it here as documentation, but this is manual for now! +# ============================================ + +# crates.io +# cargo publish -echo "Done: ${NAME} v${NEW}" +# npm +# cd npm +# npm publish From 854791f523e3050f8c90f24db38a533b9223569a Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 29 Mar 2026 03:19:05 +0800 Subject: [PATCH 041/226] chore: install script for linux/macos via curl. --- README.md | 23 +++++++------------- install.sh | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 15 deletions(-) create mode 100755 install.sh diff --git a/README.md b/README.md index 2748232..997933c 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,14 @@ A purely Rust-based AI CLI coding agent with a beautiful terminal UI for interac - **Session Management** - Create and manage multiple chat sessions - **Streaming Responses** - Real-time streaming of AI responses (w/ [aisdk.rs](https://aisdk.rs)) -## Quick Start - -```bash -cargo install crabcode # via cargo -npm install -g crabcode # via npm -bun install -g crabcode # via bun -# Brew, coming sooon +## Installation + +```sh +npm install -g crabcode # npm +bun install -g crabcode # or bun +cargo binstall crabcode # or cargo-binstall (prebuilt binary, faster) +cargo install crabcode # or cargo (build from source) +curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) ``` ## Quick Start @@ -120,14 +121,6 @@ I tried crabcode specifically for these providers: ## Development -### Build from source - -```bash -git clone https://github.com/blankeos/crabcode.git -cd crabcode -cargo build --release -``` - ### Run tests ```bash diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ecf1382 --- /dev/null +++ b/install.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +VERSION="latest" +REPO="Blankeos/crabcode" +INSTALL_DIR="$HOME/.local/bin" +BINARY_NAME="crabcode" + +echo "🦀 Installing crabcode..." + +# Check if cargo is available +if command -v cargo &> /dev/null; then + echo "📦 Installing via cargo..." + cargo install crabcode + echo "✓ crabcode installed successfully via cargo" + echo "" + echo "Run: crabcode" + exit 0 +fi + +# Fall back to downloading pre-built binary +echo "⬇️ Downloading pre-built binary..." + +# Determine platform +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Linux*) OS="linux";; + Darwin*) OS="macos";; + *) echo "❌ Unsupported OS: $OS"; exit 1;; +esac + +case "$ARCH" in + x86_64) ARCH="x86_64";; + aarch64) ARCH="aarch64";; + *) echo "❌ Unsupported architecture: $ARCH"; exit 1;; +esac + +# Create install directory +mkdir -p "$INSTALL_DIR" + +# Download binary +BINARY_URL="https://github.com/${REPO}/releases/download/${VERSION}/crabcode-${OS}-${ARCH}" + +if curl -L "$BINARY_URL" -o "$INSTALL_DIR/$BINARY_NAME"; then + chmod +x "$INSTALL_DIR/$BINARY_NAME" + echo "✓ crabcode installed successfully to $INSTALL_DIR/$BINARY_NAME" +else + echo "❌ Failed to download binary. Please install via cargo: cargo install crabcode" + exit 1 +fi + +# Add to PATH if not already there +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + echo "" + echo "⚠️ Add $INSTALL_DIR to your PATH:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + echo " Add this to your ~/.bashrc or ~/.zshrc" +fi + +echo "" +echo "Run: $BINARY_NAME" From 693e534b336a7f9b85367127e336baa04365b51c Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 9 May 2026 21:55:09 +0800 Subject: [PATCH 042/226] chore: remove ralphy. --- .ralphy/config.yaml | 35 ----------------------------------- .ralphy/progress.txt | 19 ------------------- 2 files changed, 54 deletions(-) delete mode 100644 .ralphy/config.yaml delete mode 100644 .ralphy/progress.txt diff --git a/.ralphy/config.yaml b/.ralphy/config.yaml deleted file mode 100644 index e37d803..0000000 --- a/.ralphy/config.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Ralphy Configuration -# https://github.com/michaelshimeles/ralphy - -# Project info (auto-detected, edit if needed) -project: - name: "crabcode" - language: "Rust" - framework: "" - description: "" # Add a brief description - -# Commands (auto-detected from package.json/pyproject.toml) -commands: - test: "cargo test" - lint: "cargo clippy" - build: "cargo build" - -# Rules - instructions the AI MUST follow -# These are injected into every prompt -rules: - # Examples: - # - "Always use TypeScript strict mode" - # - "Follow the error handling pattern in src/utils/errors.ts" - # - "All API endpoints must have input validation with Zod" - # - "Use server actions instead of API routes in Next.js" - # - # Skills/playbooks (optional): - # - "Before coding, read and follow any relevant skill/playbook docs under .opencode/skills or .claude/skills." - -# Boundaries - files/folders the AI should not modify -boundaries: - never_touch: - # Examples: - # - "src/legacy/**" - # - "migrations/**" - # - "*.lock" diff --git a/.ralphy/progress.txt b/.ralphy/progress.txt deleted file mode 100644 index 98bf699..0000000 --- a/.ralphy/progress.txt +++ /dev/null @@ -1,19 +0,0 @@ -# Ralphy Progress Log - -- [✓] 2026-01-22 16:48 - Project setup (Cargo.toml, basic structure) -- [✓] 2026-01-22 16:50 - Main loop with ratatui Terminal -- [✓] 2026-01-22 16:55 - Landing page with logo display -- [✓] 2026-01-22 16:58 - Basic text input using tui-textarea -- [✓] 2026-01-22 17:06 - Command parsing (`/` trigger) -- [✓] 2026-01-22 17:07 - `/exit` command implementation -- [✓] 2026-01-22 17:22 - Chat message display component -- [✓] 2026-01-22 17:29 - Auto-suggestion popup (ratatui popup example) -- [✓] 2026-01-22 17:33 - Status bar implementation -- [✓] 2026-01-22 17:37 - Git branch detection -- [✓] 2026-01-22 17:41 - `/sessions` and `/new` commands -- [✓] 2026-01-22 17:45 - Model configuration types -- [✓] 2026-01-22 17:48 - Provider trait definition -- [✓] 2026-01-22 17:54 - Models.dev API client -- [✓] 2026-01-22 18:09 - `/models` command -- [✓] 2026-01-22 18:27 - `/connect` command for API keys -- [✓] 2026-01-22 18:39 - Nano-GPT provider implementation From 183e38e4d4460f91b8eb04d3aa0365a26da445eb Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 9 May 2026 21:56:01 +0800 Subject: [PATCH 043/226] feat: add skills dialog and update OpenAI provider API. - Add `/skills` command and SkillsDialog overlay for listing available skills - Update OpenAI provider builder method names to match SDK changes - Add `dpreview` just recipe and `node_modules` to gitignore --- .gitignore | 1 + justfile | 6 ++- src/app.rs | 96 ++++++++++++++++++++++++++++++++++++++ src/command/handlers.rs | 27 ++++++++++- src/llm/client.rs | 12 ++--- src/views/mod.rs | 2 + src/views/skills_dialog.rs | 73 +++++++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/views/skills_dialog.rs diff --git a/.gitignore b/.gitignore index 81032cd..d423829 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ sounds/complete.wav _dev_reference1 _dev_reference2 .env +node_modules diff --git a/justfile b/justfile index bd9d456..4b76e8e 100644 --- a/justfile +++ b/justfile @@ -7,6 +7,9 @@ dev: preview: ./target/release/crabcode +dpreview: + ./target/debug/crabcode + gen-themes: bun run scripts/gen-themes.ts @@ -17,6 +20,7 @@ log: tail -f app.log # Release: bump versions, create release commit, and create a git tag. + # Usage: just tag [patch|minor|major] tag bump="": - sh scripts/tag_and_release.sh {{bump}} + sh scripts/tag_and_release.sh {{ bump }} diff --git a/src/app.rs b/src/app.rs index 8705c4a..773de1a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -91,6 +91,7 @@ pub enum OverlayFocus { SessionsDialog, SessionRenameDialog, PermissionDialog, + SkillsDialog, WhichKey, } @@ -126,6 +127,7 @@ pub struct App { pub sessions_dialog_state: SessionsDialogState, pub session_rename_dialog_state: SessionRenameDialogState, pub permission_dialog_state: PermissionDialogState, + pub skills_dialog_state: crate::views::SkillsDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, pub api_key_input: crate::ui::components::api_key_input::ApiKeyInput, openai_oauth_receiver: Option>, @@ -146,6 +148,7 @@ pub struct App { pub dark_mode: bool, pub sounds: crate::sound::ResolvedSoundsConfig, pub tool_permissions: crate::tools::ToolPermissions, + pub skills_dirs: Vec, pub is_streaming: bool, chunk_sender: Option, chunk_receiver: Option, @@ -186,6 +189,7 @@ impl App { let openai_oauth_flow_state = init_openai_oauth_flow(); let sessions_dialog_state = init_sessions_dialog("Sessions", vec![]); let permission_dialog_state = init_permission_dialog(); + let skills_dialog_state = crate::views::skills_dialog::init_skills_dialog("Skills", vec![]); let which_key_state = crate::views::which_key::init_which_key(); let api_key_input = crate::ui::components::api_key_input::ApiKeyInput::new(); @@ -313,6 +317,7 @@ impl App { sessions_dialog_state, session_rename_dialog_state, permission_dialog_state, + skills_dialog_state, which_key_state, api_key_input, openai_oauth_receiver: None, @@ -333,6 +338,7 @@ impl App { dark_mode: true, sounds: resolved_sounds, tool_permissions, + skills_dirs: loaded_config.inventory.opencode_skills_dirs, is_streaming: false, chunk_sender: None, chunk_receiver: None, @@ -785,6 +791,27 @@ impl App { PermissionDialogAction::NotHandled => true, } } + OverlayFocus::SkillsDialog => { + let action = + crate::views::skills_dialog::handle_skills_dialog_key_event( + &mut self.skills_dialog_state, + key, + ); + match action { + crate::views::skills_dialog::SkillsDialogAction::SelectSkill { skill_id: _ } => { + if !self.skills_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + true + } + crate::views::skills_dialog::SkillsDialogAction::None => { + if !self.skills_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + false + } + } + } OverlayFocus::WhichKey => { let action = self.which_key_state.handle_key_event(key); match action { @@ -1046,6 +1073,14 @@ impl App { if !self.sessions_dialog_state.dialog.is_visible() { self.overlay_focus = OverlayFocus::None; } + } else if self.overlay_focus == OverlayFocus::SkillsDialog { + crate::views::skills_dialog::handle_skills_dialog_mouse_event( + &mut self.skills_dialog_state, + mouse, + ); + if !self.skills_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } } else if self.overlay_focus == OverlayFocus::None { // Handle mouse events for chat scrolling when in chat mode if self.base_focus == BaseFocus::Chat { @@ -1179,6 +1214,20 @@ impl App { ); self.sessions_dialog_state.dialog.selected_index = 0; } + (_, OverlayFocus::SkillsDialog) => { + self.skills_dialog_state + .dialog + .search_textarea + .insert_str(&text); + self.skills_dialog_state.dialog.set_search_query( + self.skills_dialog_state + .dialog + .search_textarea + .lines() + .join(""), + ); + self.skills_dialog_state.dialog.selected_index = 0; + } (_, OverlayFocus::SessionRenameDialog) => { self.session_rename_dialog_state .input_textarea @@ -1221,6 +1270,10 @@ impl App { self.show_themes_dialog(); return; } + if parsed.name == "skills" { + self.show_skills_dialog(); + return; + } parsed.prefs_dao = self.prefs_dao.as_ref(); parsed.active_model_id = Some(self.model.clone()); @@ -1735,6 +1788,38 @@ impl App { self.overlay_focus = OverlayFocus::ThemesDialog; } + fn show_skills_dialog(&mut self) { + use crate::ui::components::dialog::DialogItem; + + let mut items: Vec = Vec::new(); + + for skill_dir in &self.skills_dirs { + if let Some(dir_name) = skill_dir.file_name().and_then(|n| n.to_str()) { + let description = if skill_dir.join("SKILL.md").exists() { + "Available".to_string() + } else { + "No SKILL.md".to_string() + }; + + items.push(DialogItem { + id: dir_name.to_string(), + name: dir_name.to_string(), + group: "Skills".to_string(), + description, + tip: None, + provider_id: String::new(), + }); + } + } + + items.sort_by(|a, b| a.id.cmp(&b.id)); + + self.skills_dialog_state = + crate::views::skills_dialog::init_skills_dialog("Skills", items); + self.skills_dialog_state.dialog.show(); + self.overlay_focus = OverlayFocus::SkillsDialog; + } + fn show_openai_connect_methods(&mut self) { use crate::ui::components::dialog::DialogItem; @@ -2552,6 +2637,17 @@ impl App { render_sessions_dialog(f, &mut self.sessions_dialog_state, size, colors); } + if self.overlay_focus == OverlayFocus::SkillsDialog + && self.skills_dialog_state.dialog.is_visible() + { + crate::views::skills_dialog::render_skills_dialog( + f, + &mut self.skills_dialog_state, + size, + colors, + ); + } + if self.overlay_focus == OverlayFocus::SessionRenameDialog && self.session_rename_dialog_state.is_visible() { diff --git a/src/command/handlers.rs b/src/command/handlers.rs index abb1ce1..e309797 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -430,6 +430,24 @@ pub fn handle_themes<'a>( }) } +pub fn handle_skills<'a>( + parsed: &'a ParsedCommand<'a>, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error( + "This command only opens the skills dialog. Usage: /skills".to_string(), + ); + } + + // The app intercepts /skills to show the dialog. + CommandResult::Success(String::new()) + }) +} + pub fn handle_refreshmodels<'a>( _parsed: &'a ParsedCommand<'a>, _sm: &'a mut SessionManager, @@ -523,6 +541,12 @@ pub fn register_all_commands(registry: &mut Registry) { description: "Refresh the models.dev cache".to_string(), handler: handle_refreshmodels, }); + + registry.register(Command { + name: "skills".to_string(), + description: "List available skills".to_string(), + handler: handle_skills, + }); } #[cfg(test)] @@ -800,7 +824,7 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 8); + assert_eq!(names.len(), 9); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); @@ -809,6 +833,7 @@ mod tests { assert!(names.contains(&"themes".to_string())); assert!(names.contains(&"home".to_string())); assert!(names.contains(&"refreshmodels".to_string())); + assert!(names.contains(&"skills".to_string())); } #[tokio::test] diff --git a/src/llm/client.rs b/src/llm/client.rs index 1dabfa5..34a24c5 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -416,12 +416,12 @@ fn build_openai_provider(config: &ProviderRequestConfig) -> Result Result Self { + Self { dialog } + } + + pub fn with_items(title: impl Into, items: Vec) -> Self { + Self { + dialog: Dialog::with_items(title, items), + } + } +} + +pub fn init_skills_dialog(title: impl Into, items: Vec) -> SkillsDialogState { + SkillsDialogState::with_items(title, items) +} + +pub fn render_skills_dialog( + f: &mut Frame, + dialog_state: &mut SkillsDialogState, + area: Rect, + colors: ThemeColors, +) { + dialog_state.dialog.render(f, area, colors); +} + +pub fn handle_skills_dialog_key_event( + dialog_state: &mut SkillsDialogState, + event: KeyEvent, +) -> SkillsDialogAction { + if !dialog_state.dialog.is_visible() { + return SkillsDialogAction::None; + } + + match event.code { + KeyCode::Enter => { + dialog_state.dialog.hide(); + if let Some(selected) = dialog_state.dialog.get_selected() { + return SkillsDialogAction::SelectSkill { + skill_id: selected.id.clone(), + }; + } + } + _ => { + dialog_state.dialog.handle_key_event(event); + } + } + + SkillsDialogAction::None +} + +pub fn handle_skills_dialog_mouse_event( + dialog_state: &mut SkillsDialogState, + event: MouseEvent, +) -> bool { + dialog_state.dialog.handle_mouse_event(event) +} From a93f6247244fab80f6f8bb6d844a512b0cb5accc Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 9 May 2026 22:35:18 +0800 Subject: [PATCH 044/226] feat: implement skills system with file-system discovery and tool integration. Add a `/skills` command, skill discovery from multiple paths (`.opencode/`, `.crabcode/`, `.claude/`, `.agents/`), a `skill` tool for LLM loading, system prompt injection of available skills, and auto-registration of skill slash commands. Also refactors the which-key popup and dialog description rendering. --- Cargo.lock | 20 +++ Cargo.toml | 1 + _plans/SKILLS_COMMAND.md | 96 +++++++++++ src/app.rs | 29 ++-- src/command/handlers.rs | 29 ++++ src/config/configuration.rs | 6 + src/main.rs | 1 + src/prompt/mod.rs | 35 ++++- src/skill/mod.rs | 306 ++++++++++++++++++++++++++++++++++++ src/tools/init.rs | 3 +- src/tools/mod.rs | 2 + src/tools/skill.rs | 171 ++++++++++++++++++++ src/ui/components/dialog.rs | 3 +- src/views/which_key.rs | 114 ++++++++++---- 14 files changed, 768 insertions(+), 48 deletions(-) create mode 100644 _plans/SKILLS_COMMAND.md create mode 100644 src/skill/mod.rs create mode 100644 src/tools/skill.rs diff --git a/Cargo.lock b/Cargo.lock index 5cea8d5..963b47c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,6 +472,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "serde_yaml", "sha2", "strsim", "textwrap", @@ -2689,6 +2690,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3372,6 +3386,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 8e6bec9..89dd6e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ tui-markdown = "0.3" ratatui-core = "0.1" tiktoken-rs = "0.9.1" base64 = "0.22" +serde_yaml = "0.9" sha2 = "0.10" rand = "0.8" diff --git a/_plans/SKILLS_COMMAND.md b/_plans/SKILLS_COMMAND.md new file mode 100644 index 0000000..3057679 --- /dev/null +++ b/_plans/SKILLS_COMMAND.md @@ -0,0 +1,96 @@ +# /skills Command + +## Overview + +The `/skills` command lists all available skills discovered from the filesystem. Skills provide specialized domain-specific instructions and workflows that can be loaded by the LLM using the `skill` tool or invoked directly as slash commands. + +## Skill Discovery Paths + +Skills are discovered from the following locations (in order, matching OpenCode behavior): + +### Global paths (`~/.config/`) + +- `~/.config/opencode/skills/*/SKILL.md` +- `~/.config/opencode/skill/*/SKILL.md` +- `~/.config/crabcode/skills/*/SKILL.md` +- `~/.config/crabcode/skill/*/SKILL.md` +- `~/.claude/skills/*/SKILL.md` (Claude Code compat) +- `~/.agents/skills/*/SKILL.md` (Claude Code compat) + +### Project paths (walking up from project root) + +- `.opencode/skills/*/SKILL.md` +- `.opencode/skill/*/SKILL.md` +- `.crabcode/skills/*/SKILL.md` +- `.crabcode/skill/*/SKILL.md` +- `.claude/skills/*/SKILL.md` (at each ancestor directory) +- `.agents/skills/*/SKILL.md` (at each ancestor directory) + +### Config paths (future) + +- `config.skills.paths` — additional local directories to scan +- `config.skills.urls` — remote `.well-known/skills/` endpoints + +## SKILL.md Format + +Each skill is defined by a `SKILL.md` file with YAML frontmatter: + +```markdown +--- +name: my-skill +description: Description of what this skill does and when to use it. +--- + +# Skill content (markdown body) + +Instructions, workflows, code examples, etc. +``` + +### Required fields + +- `name` (string) — Unique skill identifier, used as the command name and tool parameter +- `description` (string) — Human-readable description shown in `/skills` dialog and system prompt + +### Fallback parsing + +If standard YAML parsing fails (e.g., values containing unquoted colons from Claude Code compat), a fallback sanitizer converts problematic values to YAML block scalars before retrying. + +## Implementation Details + +### Module: `src/skill/mod.rs` + +- `SkillStore` — lazily initialized static store that holds all loaded skills +- `SkillLoader::load()` — scans all discovery paths for `SKILL.md` files +- `parse_skill_file()` — parses YAML frontmatter with fallback sanitization +- `fallback_sanitize_yaml()` — handles malformed YAML (Claude Code compat) + +### Tool: `src/tools/skill.rs` + +The `skill` tool is registered alongside other tools and can be invoked by the LLM: + +- **Tool ID**: `skill` +- **Parameter**: `name` (string) — the skill name +- **Behavior**: Loads the skill's `SKILL.md` content and returns it wrapped in `` XML with base directory info and a sampled file list +- **Description**: Dynamically includes all available skills in XML format, matching the reference behavior + +### System Prompt + +Available skills are injected into the system prompt via `SystemPromptComposer::get_custom_instructions()` in `src/prompt/mod.rs`. They appear in `` XML block with name, description, and location. + +### Dialog: `/skills` + +The `/skills` command opens a dialog that lists all loaded skills with their name and description (from YAML frontmatter). Selecting a skill from the dialog does not currently auto-invoke it; that is handled by the LLM invoking the `skill` tool. + +### Slash Commands + +Each skill is automatically registered as a slash command (e.g., `/my-skill`). Typing the command injects the skill's markdown content into the conversation. + +## Reference + +Implementation mirrors the OpenCode skills system (`_dev_reference1/packages/opencode/src/skill/`): + +- Same discovery paths and precedences +- Same YAML frontmatter parsing with fallback sanitization +- Same tool behavior with `` XML output +- Same `` format in system prompt +- Same skill-as-command registration diff --git a/src/app.rs b/src/app.rs index 773de1a..f7fb0d1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -223,6 +223,12 @@ impl App { ); } + crate::skill::init_skill_store( + &loaded_config.xdg_config_home, + &loaded_config.project_root, + ); + crate::command::handlers::register_skill_commands(&mut registry); + if let Some(default_agent) = loaded_config.merged_config.default_agent.clone() { if !default_agent.trim().is_empty() { agent = default_agent; @@ -339,6 +345,7 @@ impl App { sounds: resolved_sounds, tool_permissions, skills_dirs: loaded_config.inventory.opencode_skills_dirs, + // Note: skills_dirs is legacy; skill loading is now handled by src/skill/mod.rs is_streaming: false, chunk_sender: None, chunk_receiver: None, @@ -1793,20 +1800,18 @@ impl App { let mut items: Vec = Vec::new(); - for skill_dir in &self.skills_dirs { - if let Some(dir_name) = skill_dir.file_name().and_then(|n| n.to_str()) { - let description = if skill_dir.join("SKILL.md").exists() { - "Available".to_string() - } else { - "No SKILL.md".to_string() - }; - + if let Some(store) = crate::skill::get_skill_store() { + for skill in store.all() { items.push(DialogItem { - id: dir_name.to_string(), - name: dir_name.to_string(), + id: skill.name.clone(), + name: skill.name.clone(), group: "Skills".to_string(), - description, - tip: None, + description: skill.description.clone().unwrap_or_default(), + tip: if skill.description.is_some() { + None + } else { + Some("No description".to_string()) + }, provider_id: String::new(), }); } diff --git a/src/command/handlers.rs b/src/command/handlers.rs index e309797..8235206 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -448,6 +448,35 @@ pub fn handle_skills<'a>( }) } +pub fn handle_skill_command<'a>( + parsed: &'a ParsedCommand<'a>, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let skill_name = parsed.name.clone(); + + Box::pin(async move { + if let Some(store) = crate::skill::get_skill_store() { + if let Some(skill) = store.get(&skill_name) { + return CommandResult::Success(skill.content.clone()); + } + } + + CommandResult::Error(format!("Unknown command: {}", skill_name)) + }) +} + +pub fn register_skill_commands(registry: &mut Registry) { + if let Some(store) = crate::skill::get_skill_store() { + for skill in store.all() { + registry.register(Command { + name: skill.name.clone(), + description: skill.description.clone().unwrap_or_default(), + handler: handle_skill_command, + }); + } + } +} + pub fn handle_refreshmodels<'a>( _parsed: &'a ParsedCommand<'a>, _sm: &'a mut SessionManager, diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 253c810..f71c7cc 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -301,7 +301,9 @@ fn discover_opencode_inventory( diagnostics: &mut ConfigDiagnostics, ) { let global_opencode = xdg_config_home.join("opencode"); + let global_crabcode = xdg_config_home.join("crabcode"); let local_opencode = project_root.join(".opencode"); + let local_crabcode = project_root.join(".crabcode"); let mut agents = Vec::new(); agents.extend(list_md_files(&global_opencode.join("agents"))); @@ -322,8 +324,12 @@ fn discover_opencode_inventory( for dir in [ global_opencode.join("skills"), global_opencode.join("skill"), + global_crabcode.join("skills"), + global_crabcode.join("skill"), local_opencode.join("skills"), local_opencode.join("skill"), + local_crabcode.join("skills"), + local_crabcode.join("skill"), ] { if dir.is_dir() { skills_dirs.push(dir); diff --git a/src/main.rs b/src/main.rs index 03fd399..95b2944 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod notify; mod persistence; mod prompt; mod session; +mod skill; mod sound; mod streaming; mod theme; diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 104cb38..c5f93d9 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -261,7 +261,40 @@ Tool use: } async fn get_custom_instructions(&self) -> String { - rules::get_custom_instructions(&self.working_directory).await + let mut instructions = rules::get_custom_instructions(&self.working_directory).await; + + // Add available skills listing + if let Some(store) = crate::skill::get_skill_store() { + let skills = store.all(); + if !skills.is_empty() { + let skills_xml = skills + .iter() + .map(|s| { + format!( + " \n {}\n {}\n file://{}\n ", + s.name, + s.description.as_deref().unwrap_or(""), + s.location.display() + ) + }) + .collect::>() + .join("\n"); + + let skills_block = format!( + "\n\nSkills provide specialized instructions and workflows for specific tasks.\n\ + Use the skill tool to load a skill when a task matches its description.\n\ + \n{}\n", + skills_xml + ); + + if !instructions.is_empty() { + instructions.push_str("\n\n"); + } + instructions.push_str(&skills_block); + } + } + + instructions } } diff --git a/src/skill/mod.rs b/src/skill/mod.rs new file mode 100644 index 0000000..cf9798a --- /dev/null +++ b/src/skill/mod.rs @@ -0,0 +1,306 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +static SKILL_STORE: OnceLock = OnceLock::new(); + +pub fn init_skill_store(xdg_config_home: &Path, project_root: &Path) { + let store = SkillStore::load(xdg_config_home, project_root); + let _ = SKILL_STORE.set(store); +} + +pub fn get_skill_store() -> Option<&'static SkillStore> { + SKILL_STORE.get() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillInfo { + pub name: String, + pub description: Option, + pub location: PathBuf, + pub content: String, +} + +#[derive(Debug, Clone)] +pub struct SkillStore { + skills: HashMap, + dirs: HashSet, +} + +impl SkillStore { + pub fn load(xdg_config_home: &Path, project_root: &Path) -> Self { + let mut state = ScanState { + matches: HashSet::new(), + dirs: HashSet::new(), + }; + + let global_opencode = xdg_config_home.join("opencode"); + let global_crabcode = xdg_config_home.join("crabcode"); + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + // Phase 1: External dirs (.claude/, .agents/) - Claude Code compat + // Global + for ext_dir in [".claude", ".agents"] { + let root = home.join(ext_dir); + scan(&mut state, &root, "skills/**/SKILL.md", true); + } + // Project (walk-up from project_root) + let mut current = project_root.to_path_buf(); + loop { + for ext_dir in [".claude", ".agents"] { + let root = current.join(ext_dir); + scan(&mut state, &root, "skills/**/SKILL.md", true); + } + if let Some(parent) = current.parent().map(|p| p.to_path_buf()) { + if parent == current { + break; + } + current = parent; + } else { + break; + } + } + + // Phase 2: OpenCode native dirs (.opencode/skills/, .opencode/skill/) + for dir in [&global_opencode, &global_crabcode] { + scan(&mut state, dir, "{skill,skills}/**/SKILL.md", false); + } + + // Phase 3: Project .opencode/ and .crabcode/ + for proj_dir in [ + project_root.join(".opencode"), + project_root.join(".crabcode"), + ] { + scan(&mut state, &proj_dir, "{skill,skills}/**/SKILL.md", false); + } + + // Phase 4: Config skills.paths (read from crabcode config later) + // For now, discover from .opencode + .crabcode only + + // Parse all discovered SKILL.md files + let mut skills: HashMap = HashMap::new(); + let mut matches: Vec = state.matches.into_iter().collect(); + matches.sort(); + + for match_path in &matches { + if let Some(info) = parse_skill_file(match_path) { + if let Some(existing) = skills.get(&info.name) { + eprintln!( + "Warning: duplicate skill name '{}' (existing: {}, duplicate: {})", + info.name, + existing.location.display(), + match_path.display() + ); + } + skills.insert(info.name.clone(), info); + } + } + + if !skills.is_empty() { + eprintln!("Loaded {} skills", skills.len()); + } + + Self { + skills, + dirs: state.dirs, + } + } + + pub fn get(&self, name: &str) -> Option<&SkillInfo> { + self.skills.get(name) + } + + pub fn all(&self) -> Vec<&SkillInfo> { + let mut list: Vec<&SkillInfo> = self.skills.values().collect(); + list.sort_by(|a, b| a.name.cmp(&b.name)); + list + } + + pub fn dirs(&self) -> &HashSet { + &self.dirs + } +} + +struct ScanState { + matches: HashSet, + dirs: HashSet, +} + +fn scan(state: &mut ScanState, root: &Path, pattern: &str, dot: bool) { + if !root.is_dir() { + return; + } + + // Support both brace expansion patterns and simple globs + let patterns: Vec = if pattern.contains('{') { + // Expand brace: "{skill,skills}/**/SKILL.md" -> ["skill/**/SKILL.md", "skills/**/SKILL.md"] + expand_braces(pattern) + } else { + vec![pattern.to_string()] + }; + + for p in &patterns { + let full_pattern = root.join(p).to_string_lossy().to_string(); + match glob::glob(&full_pattern) { + Ok(entries) => { + for entry in entries.flatten() { + if entry.is_file() { + state.matches.insert(entry.clone()); + if let Some(parent) = entry.parent() { + state.dirs.insert(parent.to_path_buf()); + } + } + } + } + Err(e) => { + if !dot { + eprintln!("Warning: glob error scanning {}: {}", root.display(), e); + } + } + } + } +} + +fn expand_braces(pattern: &str) -> Vec { + // Simple brace expansion for "{skill,skills}/**/SKILL.md" style patterns + if let Some(brace_start) = pattern.find('{') { + if let Some(brace_end) = pattern.find('}') { + if brace_end > brace_start { + let prefix = &pattern[..brace_start]; + let options = &pattern[brace_start + 1..brace_end]; + let suffix = &pattern[brace_end + 1..]; + return options + .split(',') + .map(|opt| format!("{}{}{}", prefix, opt.trim(), suffix)) + .collect(); + } + } + } + vec![pattern.to_string()] +} + +fn parse_skill_file(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + + // Parse YAML frontmatter between --- delimiters + let (frontmatter, body) = if let Some(rest) = content.strip_prefix("---\n") { + if let Some((fm, rest)) = rest.split_once("\n---") { + (fm.to_string(), rest.trim_start().to_string()) + } else if let Some((fm, rest)) = rest.split_once("\r\n---") { + (fm.to_string(), rest.trim_start().to_string()) + } else { + // No closing ---, treat whole content as body + (String::new(), content) + } + } else if let Some(rest) = content.strip_prefix("---\r\n") { + if let Some((fm, rest)) = rest.split_once("\r\n---") { + (fm.to_string(), rest.trim_start().to_string()) + } else { + (String::new(), content) + } + } else { + (String::new(), content) + }; + + if frontmatter.is_empty() { + return None; + } + + #[derive(Deserialize)] + struct Frontmatter { + name: String, + description: Option, + } + + // Try serde_yaml first, then fallback sanitization + let fm_data: Frontmatter = match serde_yaml::from_str(&frontmatter) { + Ok(fm) => fm, + Err(_) => { + // Fallback: sanitize malformed YAML (Claude Code compat) + let sanitized = fallback_sanitize_yaml(&frontmatter); + serde_yaml::from_str(&sanitized).ok()? + } + }; + + Some(SkillInfo { + name: fm_data.name, + description: fm_data.description, + location: path.to_path_buf(), + content: body, + }) +} + +fn fallback_sanitize_yaml(frontmatter: &str) -> String { + let mut result = String::new(); + + for line in frontmatter.lines() { + let trimmed = line.trim(); + + // Skip comments and empty lines + if trimmed.starts_with('#') || trimmed.is_empty() { + result.push_str(line); + result.push('\n'); + continue; + } + + // Skip indented lines (continuations) + if line.starts_with(' ') || line.starts_with('\t') { + result.push_str(line); + result.push('\n'); + continue; + } + + // Match key: value + if let Some((key, value)) = trimmed.split_once(':') { + let value = value.trim(); + + // Skip empty, already quoted, or block scalar values + if value.is_empty() + || value == ">" + || value == "|" + || value.starts_with('"') + || value.starts_with('\'') + { + result.push_str(line); + result.push('\n'); + continue; + } + + // If value contains a colon, convert to block scalar + if value.contains(':') { + result.push_str(&format!("{}: |-\n", key)); + result.push_str(&format!(" {}\n", value)); + continue; + } + } + + result.push_str(line); + result.push('\n'); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fallback_sanitize_yaml() { + let input = "name: test\ndescription: Use: build stuff with colons: here\nstatus: ok"; + let result = fallback_sanitize_yaml(input); + assert!(result.contains("description: |-")); + assert!(result.contains(" Use: build stuff with colons: here")); + assert!(result.contains("status: ok")); + } + + #[test] + fn test_expand_braces() { + let result = expand_braces("{skill,skills}/**/SKILL.md"); + assert_eq!(result.len(), 2); + assert!(result.contains(&"skill/**/SKILL.md".to_string())); + assert!(result.contains(&"skills/**/SKILL.md".to_string())); + } +} diff --git a/src/tools/init.rs b/src/tools/init.rs index 005bd08..7a57b53 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,6 +1,6 @@ use crate::tools::{ fs::{GlobTool, GrepTool, ListTool, ReadTool, WriteTool}, - BashTool, EditTool, ToolRegistry, + BashTool, EditTool, SkillTool, ToolRegistry, }; use std::sync::Arc; @@ -14,6 +14,7 @@ pub async fn initialize_tool_registry() -> ToolRegistry { registry.register(Arc::new(WriteTool::new())).await; registry.register(Arc::new(BashTool::new())).await; registry.register(Arc::new(EditTool::new())).await; + registry.register(Arc::new(SkillTool::new())).await; registry } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index d0b00ee..ea4db8d 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -9,6 +9,7 @@ pub mod fs; pub mod init; pub mod permission; pub mod registry; +pub mod skill; pub mod types; pub use bash::BashTool; @@ -19,6 +20,7 @@ pub use permission::{ AgentToolPolicies, PermissionAction, PermissionPrompt, PermissionResponse, ToolPermissions, }; pub use registry::ToolRegistry; +pub use skill::SkillTool; pub use types::{ParameterSchema, ParameterType, Tool, ToolError, ToolId, ToolResult}; #[async_trait] diff --git a/src/tools/skill.rs b/src/tools/skill.rs new file mode 100644 index 0000000..af4c652 --- /dev/null +++ b/src/tools/skill.rs @@ -0,0 +1,171 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; + +pub struct SkillTool; + +impl SkillTool { + pub fn new() -> Self { + Self + } + + fn build_description() -> String { + let mut desc = String::from( + "Load a specialized skill that provides domain-specific instructions and workflows.\n\n\ + Use this tool to inject the skill's instructions and resources into current conversation. \ + The output may contain detailed workflow guidance as well as references to scripts, files, \ + etc in the same directory as the skill.\n\n\ + The skill name must match one of the skills listed in your system prompt.", + ); + + if let Some(store) = crate::skill::get_skill_store() { + let skills = store.all(); + if !skills.is_empty() { + desc.push_str("\n\n\n"); + for skill in &skills { + desc.push_str(&format!(" \n")); + desc.push_str(&format!(" {}\n", skill.name)); + if let Some(ref desc_text) = skill.description { + desc.push_str(&format!( + " {}\n", + desc_text + )); + } + desc.push_str(&format!( + " file://{}\n", + skill.location.display() + )); + desc.push_str(&format!(" \n")); + } + desc.push_str(""); + } + } + + desc + } +} + +#[async_trait] +impl ToolHandler for SkillTool { + fn definition(&self) -> Tool { + Tool { + id: "skill".to_string(), + description: Self::build_description(), + parameters: vec![ParameterSchema { + name: "name".to_string(), + description: "The name of the skill from available_skills".to_string(), + required: true, + param_type: ParameterType::String, + }], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["name"])?; + + let name = get_string_param(params, "name").unwrap_or_default(); + if name.trim().is_empty() { + return Err(ToolError::Validation( + "Skill name cannot be empty".to_string(), + )); + } + + Ok(()) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let name = get_string_param(¶ms, "name").unwrap_or_default(); + let name = name.trim(); + + let store = crate::skill::get_skill_store().ok_or_else(|| { + ToolError::Execution("Skill store not initialized".to_string()) + })?; + + let info = store.get(name).ok_or_else(|| { + let available: Vec = store.all().iter().map(|s| s.name.clone()).collect(); + let msg = if available.is_empty() { + format!("Skill \"{}\" not found. No skills are currently available.", name) + } else { + format!( + "Skill \"{}\" not found. Available skills: {}", + name, + available.join(", ") + ) + }; + ToolError::NotFound(msg) + })?; + + let dir = info + .location + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + let base_url = format!("file://{}", dir.display()); + + // Sample up to 10 files in the skill directory (excluding SKILL.md) + let file_list = sample_skill_files(&dir, 10); + + let output = format!( + "\n\ + # Skill: {name}\n\n\ + {content}\n\n\ + Base directory for this skill: {base_url}\n\ + Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.\n\ + Note: file list is sampled.\n\n\ + \n\ + {files}\n\ + \n\ + ", + name = name, + content = info.content.trim(), + files = file_list, + ); + + Ok(ToolResult { + title: format!("Loaded skill: {}", name), + output, + metadata: { + let mut m = std::collections::HashMap::new(); + m.insert( + "name".to_string(), + serde_json::Value::String(info.name.clone()), + ); + m.insert( + "dir".to_string(), + serde_json::Value::String(dir.to_string_lossy().to_string()), + ); + m + }, + }) + } +} + +fn sample_skill_files(dir: &std::path::Path, limit: usize) -> String { + let mut files = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name != "SKILL.md" && !file_name.starts_with('.') { + files.push(path.to_string_lossy().to_string()); + if files.len() >= limit { + break; + } + } + } + } + } + } + + files + .into_iter() + .map(|f| format!("{}", f)) + .collect::>() + .join("\n") +} diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index b78b8cc..0f58d61 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -754,8 +754,7 @@ impl Dialog { for item in items { let is_selected = item_index == self.selected_index; - let is_special_group = group == "Favorite" || group == "Recent"; - let has_description = is_special_group && !item.description.is_empty(); + let has_description = !item.description.is_empty(); let mut spans: Vec = if let Some(tip) = &item.tip { let base_len = if has_description { diff --git a/src/views/which_key.rs b/src/views/which_key.rs index d0afb9e..30914d8 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -1,9 +1,9 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ - layout::{Alignment, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, Frame, }; use std::time::{Duration, Instant}; @@ -174,15 +174,23 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo } let area = f.area(); - let popup_width = 40u16; let chat_bindings_count = if state.is_chat_active { state.chat_bindings.len() } else { 0 }; - // Content lines: 1 (empty) + bindings + chat bindings + 1 (empty) + 1 (ESC) - // Add 2 for top/bottom borders. - let popup_height = (state.bindings.len() + chat_bindings_count + 5) as u16; + let bindings_count = state.bindings.len() + chat_bindings_count; + + // Scale like the Dialog component (which is 70×25) — broad enough to visually + // anchor the popup and cover behind-the-modal content (logo, scrollbar artefacts). + const POPUP_WIDTH: u16 = 58; + const MIN_POPUP_HEIGHT: u16 = 22; + + let popup_width = area.width.min(POPUP_WIDTH); + let popup_height = area + .height + .min((bindings_count + 10) as u16) + .max(MIN_POPUP_HEIGHT.min(area.height)); let popup_area = Rect { x: area.x + (area.width.saturating_sub(popup_width)) / 2, @@ -191,22 +199,66 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo height: popup_height, }; + // Clear and fill background (flat style like other dialogs) f.render_widget(Clear, popup_area); + f.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + popup_area, + ); + + // Content area with padding (matching Dialog component) + const PADDING: u16 = 3; + let content_area = Rect { + x: popup_area.x + PADDING, + y: popup_area.y + PADDING, + width: popup_area.width.saturating_sub(PADDING * 2), + height: popup_area.height.saturating_sub(PADDING * 2), + }; - let block = Block::default() - .title(" Shortcuts ") - .borders(Borders::ALL) - .border_style(Style::default().fg(colors.border_focus)) - .title_style( + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), // absorb extra space at top + Constraint::Length(1), // title + Constraint::Length(bindings_count as u16), // bindings + Constraint::Length(1), // spacer + Constraint::Length(1), // footer + ]) + .split(content_area); + + // Header: title (left) and esc hint (right) — same as Dialog + let esc_text = "esc"; + let esc_width = esc_text.len() as u16; + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(esc_width)]) + .split(chunks[1]); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "Shortcuts", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])) + .alignment(Alignment::Left), + header_chunks[0], + ); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + esc_text, Style::default() .fg(colors.primary) .add_modifier(Modifier::BOLD), - ); + )])) + .alignment(Alignment::Right), + header_chunks[1], + ); + // Bindings let mut lines: Vec = vec![]; - lines.push(Line::from("")); - for binding in &state.bindings { let key_span = Span::styled( format!(" {} ", binding.key), @@ -218,7 +270,6 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo lines.push(Line::from(vec![key_span, Span::raw(" "), desc_span])); } - // Add chat-specific bindings when on chat page if state.is_chat_active { for binding in &state.chat_bindings { let key_span = Span::styled( @@ -232,21 +283,20 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo } } - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - " ESC ", - Style::default() - .fg(colors.info) - .add_modifier(Modifier::BOLD), - ), - Span::styled("cancel", Style::default().fg(colors.text_weak)), - ])); - - let paragraph = Paragraph::new(Text::from(lines)) - .block(block) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }); + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Left), + chunks[2], + ); - f.render_widget(paragraph, popup_area); + // Footer — dim hint matching Dialog footer style + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "Press a key to execute, ESC to cancel", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])) + .alignment(Alignment::Left), + chunks[4], + ); } From 38f4aff0fad08e13a42159d6f145fbb933517f99 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 9 May 2026 22:47:38 +0800 Subject: [PATCH 045/226] feat: include item tips in dialog search matching. - Display item tips alongside group and name in dialog matching - Move cursor to end when opening session rename dialog - Reduce which-key popup minimum height from 22 to 16 --- _plans/TODO_TEXT_SELECTION.md | 15 +++++++++++++++ src/ui/components/dialog.rs | 17 +++++++++++++++-- src/views/session_rename_dialog.rs | 3 ++- src/views/which_key.rs | 2 +- 4 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 _plans/TODO_TEXT_SELECTION.md diff --git a/_plans/TODO_TEXT_SELECTION.md b/_plans/TODO_TEXT_SELECTION.md new file mode 100644 index 0000000..2986b04 --- /dev/null +++ b/_plans/TODO_TEXT_SELECTION.md @@ -0,0 +1,15 @@ +I want text selection to copy stuff I highlight just like on the browser.. + +I want this to work inside my inputs. And chat messages. + + +Another on "Copying". + +Can we add a ctrl+x g that essentially shows a per message entry map i.e. + "I said..." +o "Ai said..." + "I said..." + "Ai said..." + + +That should be like a "modal" that sticks on the upper right. And pressing up or down will essentially make my current chat message view scroll towards that message. The current 'focus' is indicated by a colored circle symbol symbol (in my diagram it's the 'o' but use a better character). \ No newline at end of file diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 0f58d61..168180d 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -215,7 +215,13 @@ impl Dialog { let combined_strings: Vec = items .iter() - .map(|item| format!("{} {}", group, item.name)) + .map(|item| { + let base = format!("{} {}", group, item.name); + match &item.tip { + Some(tip) => format!("{} {}", base, tip), + None => base, + } + }) .collect(); let matched: Vec<(&str, u32)> = pattern.match_list( @@ -229,7 +235,14 @@ impl Dialog { .filter_map(|(combined_str, score)| { items .iter() - .find(|item| format!("{} {}", group, item.name) == *combined_str) + .find(|item| { + let base = format!("{} {}", group, item.name); + let s = match &item.tip { + Some(tip) => format!("{} {}", base, tip), + None => base, + }; + s == *combined_str + }) .map(|item| (item.clone(), score)) }) .collect(); diff --git a/src/views/session_rename_dialog.rs b/src/views/session_rename_dialog.rs index c1a539e..83b329f 100644 --- a/src/views/session_rename_dialog.rs +++ b/src/views/session_rename_dialog.rs @@ -9,7 +9,7 @@ use ratatui::{ }; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tui_textarea::{Input as TuiInput, TextArea}; +use tui_textarea::{CursorMove, Input as TuiInput, TextArea}; #[derive(Debug)] pub struct SessionRenameDialogState { @@ -52,6 +52,7 @@ impl SessionRenameDialogState { self.input_textarea.set_placeholder_text("Session title"); self.input_textarea .set_cursor_line_style(Style::default().fg(self.colors.primary)); + self.input_textarea.move_cursor(CursorMove::End); self.visible = true; self.is_input_focused.store(true, Ordering::SeqCst); } diff --git a/src/views/which_key.rs b/src/views/which_key.rs index 30914d8..e90432c 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -184,7 +184,7 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo // Scale like the Dialog component (which is 70×25) — broad enough to visually // anchor the popup and cover behind-the-modal content (logo, scrollbar artefacts). const POPUP_WIDTH: u16 = 58; - const MIN_POPUP_HEIGHT: u16 = 22; + const MIN_POPUP_HEIGHT: u16 = 16; let popup_width = area.width.min(POPUP_WIDTH); let popup_height = area From e8f01c8c3dcbf7a947337fd07b324221e9f9fb97 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 9 May 2026 23:04:54 +0800 Subject: [PATCH 046/226] feat: mascot done. --- mascot.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 mascot.txt diff --git a/mascot.txt b/mascot.txt new file mode 100644 index 0000000..4955fb3 --- /dev/null +++ b/mascot.txt @@ -0,0 +1,3 @@ + ▃▃▛████▜▃▃ +█▟▟▜████████▛▙▙█ + ▞ ▘ ▝ ▚ From 07c53ffd2bc96a4762235faa458a81351781b4d2 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 9 May 2026 23:13:48 +0800 Subject: [PATCH 047/226] feat: add hidden token support for command aliases. - Add `hidden_tokens` field to `Command` struct for alias resolution - Support hidden token lookup in autocomplete suggestions and registry - Register `"resume"` as a hidden token for the `sessions` command - Fix which-key popup height calculation (remove hardcoded minimum) - Add mascot ASCII art to landing screen layout --- src/autocomplete/command.rs | 59 +++++++++++++++++++++++++++++++----- src/command/handlers.rs | 10 ++++++ src/command/registry.rs | 35 +++++++++++++++++++-- src/ui/components/landing.rs | 47 +++++++++++++++++++++++++--- src/views/which_key.rs | 8 ++--- 5 files changed, 140 insertions(+), 19 deletions(-) diff --git a/src/autocomplete/command.rs b/src/autocomplete/command.rs index ac3d36c..a0c3d57 100644 --- a/src/autocomplete/command.rs +++ b/src/autocomplete/command.rs @@ -9,11 +9,12 @@ pub struct Suggestion { #[derive(Default)] pub struct CommandAuto { commands: Vec, + hidden_token_map: Vec<(String, String)>, } impl CommandAuto { pub fn new(registry: &Registry) -> Self { - let commands = registry + let commands: Vec = registry .list_commands() .iter() .map(|cmd| Suggestion { @@ -21,16 +22,48 @@ impl CommandAuto { description: cmd.description.clone(), }) .collect(); - Self { commands } + + let hidden_token_map: Vec<(String, String)> = registry + .list_commands() + .iter() + .flat_map(|cmd| { + cmd.hidden_tokens + .iter() + .map(|t| (t.clone(), cmd.name.clone())) + .collect::>() + }) + .collect(); + + Self { + commands, + hidden_token_map, + } } pub fn get_suggestions(&self, input: &str) -> Vec { let input_lower = input.to_lowercase(); - self.commands - .iter() - .filter(|cmd| cmd.name.to_lowercase().starts_with(&input_lower)) - .cloned() - .collect() + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut results: Vec = Vec::new(); + + for cmd in &self.commands { + if cmd.name.to_lowercase().starts_with(&input_lower) { + if seen.insert(cmd.name.clone()) { + results.push(cmd.clone()); + } + } + } + + for (token, command_name) in &self.hidden_token_map { + if token.to_lowercase().starts_with(&input_lower) { + if seen.insert(command_name.clone()) { + if let Some(cmd) = self.commands.iter().find(|c| c.name == *command_name) { + results.push(cmd.clone()); + } + } + } + } + + results } } @@ -54,16 +87,19 @@ mod tests { name: "help".to_string(), description: "Show help".to_string(), handler: dummy_handler, + hidden_tokens: vec![], }); registry.register(Command { name: "sessions".to_string(), description: "Manage sessions".to_string(), handler: dummy_handler, + hidden_tokens: vec!["resume".to_string()], }); registry.register(Command { name: "exit".to_string(), description: "Exit the app".to_string(), handler: dummy_handler, + hidden_tokens: vec![], }); registry } @@ -107,6 +143,15 @@ mod tests { assert_eq!(suggestions[0].name, "help"); } + #[test] + fn test_get_suggestions_hidden_token() { + let registry = setup_registry(); + let auto = CommandAuto::new(®istry); + let suggestions = auto.get_suggestions("res"); + assert_eq!(suggestions.len(), 1); + assert_eq!(suggestions[0].name, "sessions"); + } + #[test] fn test_get_suggestions_no_match() { let registry = setup_registry(); diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 8235206..eb25ef5 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -472,6 +472,7 @@ pub fn register_skill_commands(registry: &mut Registry) { name: skill.name.clone(), description: skill.description.clone().unwrap_or_default(), handler: handle_skill_command, + hidden_tokens: vec![], }); } } @@ -527,54 +528,63 @@ pub fn register_all_commands(registry: &mut Registry) { name: "exit".to_string(), description: "Quit crabcode".to_string(), handler: handle_exit, + hidden_tokens: vec![], }); registry.register(Command { name: "sessions".to_string(), description: "List all sessions".to_string(), handler: handle_sessions, + hidden_tokens: vec!["resume".to_string()], }); registry.register(Command { name: "new".to_string(), description: "Switch to home screen".to_string(), handler: handle_new, + hidden_tokens: vec![], }); registry.register(Command { name: "home".to_string(), description: "Switch to home screen".to_string(), handler: handle_new, + hidden_tokens: vec![], }); registry.register(Command { name: "connect".to_string(), description: "Connect to a model provider".to_string(), handler: handle_connect, + hidden_tokens: vec![], }); registry.register(Command { name: "models".to_string(), description: "List available models".to_string(), handler: handle_models, + hidden_tokens: vec![], }); registry.register(Command { name: "themes".to_string(), description: "Choose a theme".to_string(), handler: handle_themes, + hidden_tokens: vec![], }); registry.register(Command { name: "refreshmodels".to_string(), description: "Refresh the models.dev cache".to_string(), handler: handle_refreshmodels, + hidden_tokens: vec![], }); registry.register(Command { name: "skills".to_string(), description: "List available skills".to_string(), handler: handle_skills, + hidden_tokens: vec![], }); } diff --git a/src/command/registry.rs b/src/command/registry.rs index 5c01627..a8fec1e 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -14,6 +14,7 @@ pub struct Command { pub name: String, pub description: String, pub handler: CommandHandler, + pub hidden_tokens: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -52,7 +53,16 @@ impl Registry { } pub fn get(&self, name: &str) -> Option<&Command> { - self.commands.get(name) + if let Some(cmd) = self.commands.get(name) { + return Some(cmd); + } + // Check hidden_tokens + for cmd in self.commands.values() { + if cmd.hidden_tokens.iter().any(|t| t == name) { + return Some(cmd); + } + } + None } pub async fn execute<'a>( @@ -132,6 +142,7 @@ mod tests { name: "test".to_string(), description: "Test command".to_string(), handler: dummy_handler, + hidden_tokens: vec![], }; registry.register(command); assert_eq!(registry.commands.len(), 1); @@ -144,6 +155,7 @@ mod tests { name: "test".to_string(), description: "Test command".to_string(), handler: dummy_handler, + hidden_tokens: vec![], }; registry.register(command.clone()); @@ -159,6 +171,20 @@ mod tests { assert!(retrieved.is_none()); } + #[test] + fn test_get_by_hidden_token() { + let mut registry = Registry::new(); + let command = Command { + name: "test".to_string(), + description: "Test command".to_string(), + handler: dummy_handler, + hidden_tokens: vec!["alias".to_string()], + }; + registry.register(command); + assert!(registry.get("alias").is_some()); + assert_eq!(registry.get("alias").unwrap().name, "test"); + } + #[tokio::test] async fn test_execute_command() { let mut registry = Registry::new(); @@ -166,9 +192,9 @@ mod tests { name: "test".to_string(), description: "Test command".to_string(), handler: dummy_handler, + hidden_tokens: vec![], }; registry.register(command); - let parsed = ParsedCommand { name: "test".to_string(), args: vec![], @@ -208,11 +234,13 @@ mod tests { name: "test1".to_string(), description: "Test command 1".to_string(), handler: dummy_handler, + hidden_tokens: vec![], }; let command2 = Command { name: "test2".to_string(), description: "Test command 2".to_string(), handler: dummy_handler, + hidden_tokens: vec![], }; registry.register(command1); @@ -230,11 +258,13 @@ mod tests { name: "zebra".to_string(), description: "Test command 1".to_string(), handler: dummy_handler, + hidden_tokens: vec![], }; let command2 = Command { name: "apple".to_string(), description: "Test command 2".to_string(), handler: dummy_handler, + hidden_tokens: vec![], }; registry.register(command1); @@ -266,6 +296,7 @@ mod tests { name: "test".to_string(), description: "Test command".to_string(), handler: handler_with_args, + hidden_tokens: vec![], }; registry.register(command); diff --git a/src/ui/components/landing.rs b/src/ui/components/landing.rs index 1dfd296..2091d5c 100644 --- a/src/ui/components/landing.rs +++ b/src/ui/components/landing.rs @@ -19,11 +19,17 @@ fn darken_color(color: Color, factor: f32) -> Color { } pub const LOGO: &str = r#" -🦀▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ + ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ ██▀▀▀ ██▄█▄ ██▀██ ██▄██ ██▀▀▀ ██▀██ ██▀██ ██▄▄ ▀████ ██ ██ ██▀██ ██▄█▀ ▀████ ▀███▀ ████▀ ██▄▄▄ "#; +pub const MASCO: &str = r#" + ▃▃▛████▜▃▃ + █▟▟▜████████▛▙▙█ + ▞ ▘ ▝ ▚ +"#; + pub struct Landing; impl Landing { @@ -41,7 +47,7 @@ impl Landing { let top_chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(4), Constraint::Length(2)].as_ref()) + .constraints([Constraint::Length(4), Constraint::Length(3)].as_ref()) .split(chunks[0]); let logo_lines: Vec = LOGO @@ -63,6 +69,26 @@ impl Landing { let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); + let welcome_row = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(25), Constraint::Min(1)].as_ref()) + .split(top_chunks[1]); + + let mascot_lines: Vec = MASCO + .lines() + .filter(|l| !l.is_empty()) + .map(|line| { + Line::styled( + line, + Style::default() + .fg(Color::Rgb(255, 140, 0)) + .add_modifier(Modifier::BOLD), + ) + }) + .collect(); + + let mascot = Paragraph::new(Text::from(mascot_lines)); + let welcome_text = Text::from(vec![Line::from(vec![ Span::styled( "Crabcode", @@ -77,12 +103,25 @@ impl Landing { ), ])]); + let welcome_text_col = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ] + .as_ref(), + ) + .split(welcome_row[1]); + let welcome = Paragraph::new(welcome_text) - .alignment(Alignment::Center) + .alignment(Alignment::Left) .wrap(Wrap { trim: true }); f.render_widget(logo, top_chunks[0]); - f.render_widget(welcome, top_chunks[1]); + f.render_widget(mascot, welcome_row[0]); + f.render_widget(welcome, welcome_text_col[1]); } } diff --git a/src/views/which_key.rs b/src/views/which_key.rs index e90432c..3fd6a9a 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -184,13 +184,9 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo // Scale like the Dialog component (which is 70×25) — broad enough to visually // anchor the popup and cover behind-the-modal content (logo, scrollbar artefacts). const POPUP_WIDTH: u16 = 58; - const MIN_POPUP_HEIGHT: u16 = 16; let popup_width = area.width.min(POPUP_WIDTH); - let popup_height = area - .height - .min((bindings_count + 10) as u16) - .max(MIN_POPUP_HEIGHT.min(area.height)); + let popup_height = area.height.min((bindings_count + 10) as u16); let popup_area = Rect { x: area.x + (area.width.saturating_sub(popup_width)) / 2, @@ -218,7 +214,7 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(0), // absorb extra space at top + Constraint::Length(1), // top margin Constraint::Length(1), // title Constraint::Length(bindings_count as u16), // bindings Constraint::Length(1), // spacer From 1a3735331e2589bcf9eb0a27e52e32d61a36e685 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 9 May 2026 23:37:55 +0800 Subject: [PATCH 048/226] feat: add session resume CLI flag, remove landing page, simplify model display. - Add `--session` / `-s` CLI argument to resume a previous session by ID - Print crabcode logo and session info on terminal close - Move mascot from removed landing page into home view - Remove `reasoning` and `tool_call` from model capabilities - Always show only `provider_name` in model dialog description --- crabcode-logo.txt | 2 +- src/command/handlers.rs | 10 +-- src/main.rs | 61 ++++++++++++- src/model/discovery.rs | 7 +- src/ui/components/landing.rs | 170 ----------------------------------- src/ui/components/mod.rs | 1 - src/views/home.rs | 36 +++++++- 7 files changed, 96 insertions(+), 191 deletions(-) delete mode 100644 src/ui/components/landing.rs diff --git a/crabcode-logo.txt b/crabcode-logo.txt index 16f5b4a..671f489 100644 --- a/crabcode-logo.txt +++ b/crabcode-logo.txt @@ -1,3 +1,3 @@ - 🦀▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ + ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ ██▀▀▀ ██▄█▄ ██▀██ ██▄██ ██▀▀▀ ██▀██ ██▀██ ██▄▄ ▀████ ██ ██ ██▀██ ██▄█▀ ▀████ ▀███▀ ████▀ ██▄▄▄ diff --git a/src/command/handlers.rs b/src/command/handlers.rs index eb25ef5..0e766bb 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -277,15 +277,7 @@ pub fn handle_models<'a>( None }; - let description = if group == "Favorite" || group == "Recent" { - model.provider_name.clone() - } else { - format!( - "{} | {}", - model.provider_name, - model.capabilities.join(", ") - ) - }; + let description = model.provider_name.clone(); items.push(DialogItem { id: model.id.clone(), diff --git a/src/main.rs b/src/main.rs index 95b2944..1781469 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,30 @@ use std::io; use std::sync::Mutex; use std::time::Duration; +const POST_CLOSE_LOGO: &str = include_str!("../crabcode-logo.txt"); + +struct PostCloseInfo { + session_id: String, + session_title: String, +} + +fn format_post_close_message(info: Option<&PostCloseInfo>) -> String { + let mut msg = String::new(); + + for line in POST_CLOSE_LOGO.lines() { + msg.push_str(line); + msg.push('\n'); + } + + if let Some(info) = info { + msg.push('\n'); + msg.push_str(&format!(" {:<10}{}\n", "Session", info.session_title)); + msg.push_str(&format!(" {:<10}crabcode -s {}\n", "Continue", info.session_id)); + } + + msg +} + lazy_static::lazy_static! { static ref TOAST_MANAGER: Mutex = Mutex::new(ToastManager::new()); } @@ -61,13 +85,29 @@ pub fn get_toast_manager() -> &'static Mutex { #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] -struct Args {} +struct Args { + /// Resume a session by ID + #[arg(short = 's', long = "session")] + session: Option, +} #[tokio::main] async fn main() -> Result<()> { - let _args = Args::parse(); + let args = Args::parse(); let mut app = App::new()?; + if let Some(ref session_id) = args.session { + app.session_manager.switch_session(session_id); + if let Some(session) = app.session_manager.get_session(session_id) { + app.chat_state.chat.clear(); + let messages = session.messages.clone(); + for message in messages { + app.chat_state.chat.add_message(message); + } + } + app.base_focus = app::BaseFocus::Chat; + } + enable_raw_mode()?; let mut stdout = io::stdout(); @@ -93,6 +133,21 @@ async fn main() -> Result<()> { let result = run_event_loop(&mut terminal, &mut app).await; + let close_info = { + let session_id = app.session_manager.get_current_session_id().cloned(); + let session_title = app + .session_manager + .get_current_session() + .map(|s| s.title.clone()); + match (session_id, session_title) { + (Some(session_id), Some(session_title)) => Some(PostCloseInfo { + session_id, + session_title, + }), + _ => None, + } + }; + disable_raw_mode()?; if supports_keyboard_enhancement().unwrap_or(false) { execute!( @@ -112,6 +167,8 @@ async fn main() -> Result<()> { } terminal.show_cursor()?; + print!("{}", format_post_close_message(close_info.as_ref())); + result } diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 9bf9071..4d02a58 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -267,12 +267,7 @@ impl Discovery { if model.attachment { capabilities.push("attachment".to_string()); } - if model.reasoning { - capabilities.push("reasoning".to_string()); - } - if model.tool_call { - capabilities.push("tool_call".to_string()); - } + if model.structured_output { capabilities.push("structured_output".to_string()); } diff --git a/src/ui/components/landing.rs b/src/ui/components/landing.rs deleted file mode 100644 index 2091d5c..0000000 --- a/src/ui/components/landing.rs +++ /dev/null @@ -1,170 +0,0 @@ -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Paragraph, Wrap}, - Frame, -}; - -fn darken_color(color: Color, factor: f32) -> Color { - match color { - Color::Rgb(r, g, b) => { - let r = (r as f32 * factor).max(0.0).min(255.0) as u8; - let g = (g as f32 * factor).max(0.0).min(255.0) as u8; - let b = (b as f32 * factor).max(0.0).min(255.0) as u8; - Color::Rgb(r, g, b) - } - _ => color, - } -} - -pub const LOGO: &str = r#" - ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ -██▀▀▀ ██▄█▄ ██▀██ ██▄██ ██▀▀▀ ██▀██ ██▀██ ██▄▄ -▀████ ██ ██ ██▀██ ██▄█▀ ▀████ ▀███▀ ████▀ ██▄▄▄ -"#; - -pub const MASCO: &str = r#" - ▃▃▛████▜▃▃ - █▟▟▜████████▛▙▙█ - ▞ ▘ ▝ ▚ -"#; - -pub struct Landing; - -impl Landing { - pub fn new() -> Self { - Self - } - - pub fn render(&self, f: &mut Frame) { - let size = f.area(); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) - .split(size); - - let top_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(4), Constraint::Length(3)].as_ref()) - .split(chunks[0]); - - let logo_lines: Vec = LOGO - .trim() - .lines() - .enumerate() - .map(|(i, line)| { - let color = if i == 2 { - darken_color(Color::Rgb(255, 140, 0), 0.7) - } else { - Color::Rgb(255, 140, 0) - }; - Line::styled( - line, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ) - }) - .collect(); - - let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); - - let welcome_row = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(25), Constraint::Min(1)].as_ref()) - .split(top_chunks[1]); - - let mascot_lines: Vec = MASCO - .lines() - .filter(|l| !l.is_empty()) - .map(|line| { - Line::styled( - line, - Style::default() - .fg(Color::Rgb(255, 140, 0)) - .add_modifier(Modifier::BOLD), - ) - }) - .collect(); - - let mascot = Paragraph::new(Text::from(mascot_lines)); - - let welcome_text = Text::from(vec![Line::from(vec![ - Span::styled( - "Crabcode", - Style::default() - .fg(Color::Rgb(255, 165, 0)) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" - "), - Span::styled( - "Rust AI CLI Coding Agent", - Style::default().fg(Color::White), - ), - ])]); - - let welcome_text_col = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ] - .as_ref(), - ) - .split(welcome_row[1]); - - let welcome = Paragraph::new(welcome_text) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }); - - f.render_widget(logo, top_chunks[0]); - f.render_widget(mascot, welcome_row[0]); - f.render_widget(welcome, welcome_text_col[1]); - } -} - -impl Default for Landing { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::{backend::TestBackend, Terminal}; - - #[test] - fn test_landing_creation() { - let _landing = Landing::new(); - let _landing_default = Landing::default(); - } - - #[test] - fn test_logo_content() { - assert!(LOGO.contains("▄▄▄▄")); - assert!(LOGO.contains("██")); - assert!(LOGO.contains("▀████")); - } - - #[test] - fn test_logo_is_not_empty() { - let trimmed = LOGO.trim(); - assert!(!trimmed.is_empty()); - assert!(trimmed.len() > 0); - } - - #[test] - fn test_render_landing() { - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).unwrap(); - - terminal - .draw(|f| { - Landing::new().render(f); - }) - .unwrap(); - } -} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index a367899..3708b2b 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -2,7 +2,6 @@ pub mod api_key_input; pub mod chat; pub mod dialog; pub mod input; -pub mod landing; pub mod popup; pub mod status_bar; pub mod wave_spinner; diff --git a/src/views/home.rs b/src/views/home.rs index 7da9f6d..198efdd 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -11,11 +11,17 @@ use crate::ui::components::input::Input; use crate::ui::components::status_bar::StatusBar; const LOGO: &str = r#" -🦀▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ + ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ ██▀▀▀ ██▄█▄ ██▀██ ██▄██ ██▀▀▀ ██▀██ ██▀██ ██▄▄ ▀████ ██ ██ ██▀██ ██▄█▀ ▀████ ▀███▀ ████▀ ██▄▄▄ "#; +const MASCO: &str = r#" + ▃▃▛████▜▃▃ + █▟▟▜████████▛▙▙█ + ▞ ▘ ▝ ▚ +"#; + #[derive(Debug, Clone)] pub struct HomeState; @@ -70,6 +76,31 @@ pub fn render_home( ]) .split(home_chunks[0]); + let logo_row = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Length(25), + Constraint::Min(52), + Constraint::Fill(1), + ]) + .split(logo_chunks[1]); + + let mascot_lines: Vec = MASCO + .lines() + .filter(|l| !l.is_empty()) + .map(|line| { + Line::styled( + line, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ) + }) + .collect(); + + let mascot = Paragraph::new(Text::from(mascot_lines)); + let logo_lines: Vec = LOGO .trim() .lines() @@ -89,7 +120,8 @@ pub fn render_home( let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); - f.render_widget(logo, logo_chunks[1]); + f.render_widget(mascot, logo_row[1]); + f.render_widget(logo, logo_row[2]); input.render(f, home_chunks[1], &agent, &model, &provider_name, colors); let help_text = vec![ From ee5e299a8b55f986dee0e863859e5ca0c44dca3b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 9 May 2026 23:53:57 +0800 Subject: [PATCH 049/226] feat(session): use cuid2 identifiers instead of sequential names for sessions. Add `session_identifier` column to persist CUID-based session IDs, decoupling internal identity from display names. Updates migration, DAO, manager, and tests accordingly. --- _plans/__TODOS.md | 1 + src/persistence/history.rs | 49 +++++++++++++++++------------ src/persistence/migrations.rs | 3 ++ src/session/manager.rs | 58 +++++++++++++++++------------------ src/views/home.rs | 2 +- 5 files changed, 63 insertions(+), 50 deletions(-) create mode 100644 _plans/__TODOS.md diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md new file mode 100644 index 0000000..db6d105 --- /dev/null +++ b/_plans/__TODOS.md @@ -0,0 +1 @@ +- [ ] Rearchitect - multi-workspace, just like codex. Since it's a terminal, special case is it runs even when closed. Doing /sessions can switch between running sessions. Show a loading (use claude code loading animation), for loading sessions. Group by folders, not by Today, etc. Move the /sessions dialog to the "left". Run as a process? diff --git a/src/persistence/history.rs b/src/persistence/history.rs index 3125874..1b2f1d9 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -7,6 +7,7 @@ use super::{ensure_data_dir, get_data_dir, migrations::run_migrations}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: i64, + pub session_identifier: String, pub name: String, pub created_at: i64, pub updated_at: i64, @@ -55,31 +56,40 @@ impl HistoryDAO { let mut conn = Connection::open(&db_path)?; run_migrations(&mut conn)?; + // Ensure session_identifier column exists on pre-existing databases + let _ = conn.execute( + "ALTER TABLE sessions ADD COLUMN session_identifier TEXT NOT NULL DEFAULT ''", + [], + ); + Ok(Self { conn }) } - pub fn create_session(&self, name: String) -> Result { - self.conn - .execute("INSERT INTO sessions (name) VALUES (?1)", params![name])?; + pub fn create_session(&self, identifier: &str, name: String) -> Result { + self.conn.execute( + "INSERT INTO sessions (session_identifier, name) VALUES (?1, ?2)", + params![identifier, name], + )?; Ok(self.conn.last_insert_rowid()) } pub fn list_sessions(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, name, created_at, updated_at, total_tokens, total_cost, total_time_sec, avg_tokens_per_sec + "SELECT id, session_identifier, name, created_at, updated_at, total_tokens, total_cost, total_time_sec, avg_tokens_per_sec FROM sessions ORDER BY updated_at DESC" )?; let session_iter = stmt.query_map([], |row| { Ok(Session { id: row.get(0)?, - name: row.get(1)?, - created_at: row.get(2)?, - updated_at: row.get(3)?, - total_tokens: row.get(4)?, - total_cost: row.get(5)?, - total_time_sec: row.get(6)?, - avg_tokens_per_sec: row.get(7)?, + session_identifier: row.get(1)?, + name: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + total_tokens: row.get(5)?, + total_cost: row.get(6)?, + total_time_sec: row.get(7)?, + avg_tokens_per_sec: row.get(8)?, }) })?; @@ -89,7 +99,7 @@ impl HistoryDAO { pub fn get_session(&self, id: i64) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, name, created_at, updated_at, total_tokens, total_cost, total_time_sec, avg_tokens_per_sec + "SELECT id, session_identifier, name, created_at, updated_at, total_tokens, total_cost, total_time_sec, avg_tokens_per_sec FROM sessions WHERE id = ?1" )?; @@ -97,13 +107,14 @@ impl HistoryDAO { if let Some(row) = rows.next()? { Ok(Some(Session { id: row.get(0)?, - name: row.get(1)?, - created_at: row.get(2)?, - updated_at: row.get(3)?, - total_tokens: row.get(4)?, - total_cost: row.get(5)?, - total_time_sec: row.get(6)?, - avg_tokens_per_sec: row.get(7)?, + session_identifier: row.get(1)?, + name: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + total_tokens: row.get(5)?, + total_cost: row.get(6)?, + total_time_sec: row.get(7)?, + avg_tokens_per_sec: row.get(8)?, })) } else { Ok(None) diff --git a/src/persistence/migrations.rs b/src/persistence/migrations.rs index aacd922..67c634c 100644 --- a/src/persistence/migrations.rs +++ b/src/persistence/migrations.rs @@ -28,6 +28,7 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { r#" CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, + session_identifier TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), @@ -37,6 +38,8 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { avg_tokens_per_sec REAL NOT NULL DEFAULT 0 ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_identifier ON sessions(session_identifier); + CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, session_id INTEGER NOT NULL, diff --git a/src/session/manager.rs b/src/session/manager.rs index 068bbfe..5804f51 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -70,7 +70,7 @@ impl SessionManager { .map_err(|e| SessionError::PersistenceError(e.to_string()))? }; - session.id = cuid2::create_id(); + session.id = db_session.session_identifier.clone(); session.title = db_session.name; session.created_at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(db_session.created_at as u64); @@ -94,11 +94,7 @@ impl SessionManager { .clone() .unwrap_or_else(|| format!("session-{}", self.session_counter)); - let session_id = if let Some(ref session_name) = name { - session_name.clone() - } else { - format!("session-{}", self.session_counter) - }; + let session_id = cuid2::create_id(); let mut session = Session::with_title(title.clone()); session.id = session_id.clone(); @@ -108,7 +104,7 @@ impl SessionManager { if let Some(ref dao) = self.history_dao { let db_id = dao - .create_session(title.clone()) + .create_session(&session_id, title.clone()) .unwrap_or_else(|_| self.session_counter as i64); self.id_mapping.insert(session_id.clone(), db_id); self.db_id_to_id.insert(db_id, session_id.clone()); @@ -239,7 +235,7 @@ mod tests { fn test_create_session_default_name() { let mut manager = SessionManager::new(); let id = manager.create_session(None); - assert_eq!(id, "session-1"); + assert!(!id.is_empty()); assert!(manager.sessions.contains_key(&id)); assert_eq!(manager.current_session_id, Some(id)); } @@ -248,9 +244,11 @@ mod tests { fn test_create_session_custom_name() { let mut manager = SessionManager::new(); let id = manager.create_session(Some("my-session".to_string())); - assert_eq!(id, "my-session"); + assert!(!id.is_empty()); assert!(manager.sessions.contains_key(&id)); - assert_eq!(manager.current_session_id, Some(id)); + assert_eq!(manager.current_session_id, Some(id.clone())); + let session = manager.get_session(&id).unwrap(); + assert_eq!(session.title, "my-session"); } #[test] @@ -260,9 +258,9 @@ mod tests { let id2 = manager.create_session(None); let id3 = manager.create_session(None); - assert_eq!(id1, "session-1"); - assert_eq!(id2, "session-2"); - assert_eq!(id3, "session-3"); + assert_ne!(id1, id2); + assert_ne!(id2, id3); + assert_ne!(id1, id3); assert_eq!(manager.sessions.len(), 3); } @@ -299,22 +297,22 @@ mod tests { #[test] fn test_get_session() { let mut manager = SessionManager::new(); - manager.create_session(Some("test".to_string())); - assert!(manager.get_session("test").is_some()); + let id = manager.create_session(Some("test".to_string())); + assert!(manager.get_session(&id).is_some()); assert!(manager.get_session("nonexistent").is_none()); } #[test] fn test_switch_session() { let mut manager = SessionManager::new(); - manager.create_session(Some("session-1".to_string())); - manager.create_session(Some("session-2".to_string())); + let id1 = manager.create_session(Some("session-1".to_string())); + let id2 = manager.create_session(Some("session-2".to_string())); - assert!(manager.switch_session("session-1")); - assert_eq!(manager.current_session_id, Some("session-1".to_string())); + assert!(manager.switch_session(&id1)); + assert_eq!(manager.current_session_id, Some(id1.clone())); - assert!(manager.switch_session("session-2")); - assert_eq!(manager.current_session_id, Some("session-2".to_string())); + assert!(manager.switch_session(&id2)); + assert_eq!(manager.current_session_id, Some(id2.clone())); assert!(!manager.switch_session("nonexistent")); } @@ -322,22 +320,22 @@ mod tests { #[test] fn test_delete_session() { let mut manager = SessionManager::new(); - manager.create_session(Some("session-1".to_string())); - manager.create_session(Some("session-2".to_string())); + let id1 = manager.create_session(Some("session-1".to_string())); + let id2 = manager.create_session(Some("session-2".to_string())); - assert!(manager.delete_session("session-1")); - assert!(!manager.sessions.contains_key("session-1")); - assert!(manager.sessions.contains_key("session-2")); + assert!(manager.delete_session(&id1)); + assert!(!manager.sessions.contains_key(&id1)); + assert!(manager.sessions.contains_key(&id2)); } #[test] fn test_delete_current_session() { let mut manager = SessionManager::new(); - manager.create_session(Some("session-1".to_string())); - manager.create_session(Some("session-2".to_string())); + let id1 = manager.create_session(Some("session-1".to_string())); + let _id2 = manager.create_session(Some("session-2".to_string())); - manager.switch_session("session-1"); - assert!(manager.delete_session("session-1")); + manager.switch_session(&id1); + assert!(manager.delete_session(&id1)); assert!(manager.current_session_id.is_none()); } } diff --git a/src/views/home.rs b/src/views/home.rs index 198efdd..fe37ef2 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -102,8 +102,8 @@ pub fn render_home( let mascot = Paragraph::new(Text::from(mascot_lines)); let logo_lines: Vec = LOGO - .trim() .lines() + .filter(|l| !l.is_empty()) .enumerate() .map(|(i, line)| { let color = if i == 2 { From 44094db9b46811573a7b929823bc286be36cb90b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 00:42:12 +0800 Subject: [PATCH 050/226] feat: add timeline dialog and text selection with copy-on-select. Introduce a timeline dialog (opened via which-key `g`) for navigating message history in the current session. Messages are listed in chronological order with previews, timestamps, and model info. Implement mouse-driven text selection in both the chat area and input field. Selections are automatically copied to clipboard on mouse-up or Ctrl+C when text is selected. Refactor startup diagnostics to buffer messages and emit them after TUI teardown, preventing interference with the terminal UI. --- _plans/__TODOS.md | 12 +- src/app.rs | 244 +++++++++++++++++++++-- src/main.rs | 24 +++ src/skill/mod.rs | 6 +- src/ui/components/chat.rs | 147 +++++++++++++- src/ui/components/dialog.rs | 111 ++++++++--- src/ui/components/input.rs | 95 +++++++-- src/ui/mod.rs | 1 + src/ui/selection.rs | 362 +++++++++++++++++++++++++++++++++++ src/views/mod.rs | 2 + src/views/timeline_dialog.rs | 217 +++++++++++++++++++++ src/views/which_key.rs | 25 ++- 12 files changed, 1181 insertions(+), 65 deletions(-) create mode 100644 src/ui/selection.rs create mode 100644 src/views/timeline_dialog.rs diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index db6d105..d5da2b2 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -1 +1,11 @@ -- [ ] Rearchitect - multi-workspace, just like codex. Since it's a terminal, special case is it runs even when closed. Doing /sessions can switch between running sessions. Show a loading (use claude code loading animation), for loading sessions. Group by folders, not by Today, etc. Move the /sessions dialog to the "left". Run as a process? +- [ ] Rearchitect - multi-workspace, just like codex. Since it's a terminal, special case is it runs even when closed. Doing /sessions can switch between running sessions. Show a loading (use claude code loading animation), for loading sessions. Group by folders, not by Today, etc. Move the /sessions dialog to the "left". Run as a process? Allow for interruption as well. Maybe via a `/` command or a `ctrl-x` shortcut. + +- [ ] Just like opencode. I want to see the `94.4.k (9%) ∙ $0.39` detail just next to the helpful tips under the input box. Use the same data sources. + +- [ ] Scrollbar, make it like opencode. As thin as opencode. That's the only change I want really. + +- [ ] Add print-mode just like `opencode run ""`. See the reference. But two things I want to deviate from the original implementation: + - The preamble, just print whatever is printed, that's IT! + - Also add Call it `opencode -p`. It's gonna be exactly the same as `opencode run`. + - Add `--no-session-persistence` flag, exactly like Claude Code. + - Other than that, very similar to the original implementation. diff --git a/src/app.rs b/src/app.rs index f7fb0d1..f20a186 100644 --- a/src/app.rs +++ b/src/app.rs @@ -92,6 +92,7 @@ pub enum OverlayFocus { SessionRenameDialog, PermissionDialog, SkillsDialog, + TimelineDialog, WhichKey, } @@ -129,6 +130,7 @@ pub struct App { pub permission_dialog_state: PermissionDialogState, pub skills_dialog_state: crate::views::SkillsDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, + pub timeline_dialog_state: crate::views::timeline_dialog::TimelineDialogState, pub api_key_input: crate::ui::components::api_key_input::ApiKeyInput, openai_oauth_receiver: Option>, openai_oauth_in_progress: bool, @@ -191,6 +193,7 @@ impl App { let permission_dialog_state = init_permission_dialog(); let skills_dialog_state = crate::views::skills_dialog::init_skills_dialog("Skills", vec![]); let which_key_state = crate::views::which_key::init_which_key(); + let timeline_dialog_state = crate::views::timeline_dialog::init_timeline_dialog(); let api_key_input = crate::ui::components::api_key_input::ApiKeyInput::new(); let session_manager = SessionManager::new() @@ -200,7 +203,7 @@ impl App { let prefs_dao = match crate::persistence::PrefsDAO::new() { Ok(dao) => Some(dao), Err(e) => { - eprintln!("Warning: Failed to initialize preferences DAO: {}", e); + crate::startup_diag!("Warning: Failed to initialize preferences DAO: {}", e); None } }; @@ -208,16 +211,16 @@ impl App { let loaded_config = crate::config::ConfigLoader::load()?; if !loaded_config.diagnostics.info.is_empty() { for msg in &loaded_config.diagnostics.info { - eprintln!("Config: {}", msg); + crate::startup_diag!("Config: {}", msg); } } if !loaded_config.diagnostics.warnings.is_empty() { for msg in &loaded_config.diagnostics.warnings { - eprintln!("Config warning: {}", msg); + crate::startup_diag!("Config warning: {}", msg); } } if !loaded_config.diagnostics.unimplemented_keys.is_empty() { - eprintln!( + crate::startup_diag!( "Config: unimplemented keys present: {}", loaded_config.diagnostics.unimplemented_keys.join(", ") ); @@ -239,7 +242,7 @@ impl App { crate::sound::resolve_effective_sounds(&loaded_config.merged_config.sounds); if !sound_warnings.is_empty() { for msg in &sound_warnings { - eprintln!("Sound warning: {}", msg); + crate::startup_diag!("Sound warning: {}", msg); } } @@ -325,6 +328,7 @@ impl App { permission_dialog_state, skills_dialog_state, which_key_state, + timeline_dialog_state, api_key_input, openai_oauth_receiver: None, openai_oauth_in_progress: false, @@ -494,9 +498,76 @@ impl App { self.dark_mode = !self.dark_mode; } + fn try_copy_selection(&mut self) -> bool { + // Check chat selection + if self.chat_state.chat.has_selection() { + let colors = self.get_current_theme_colors(); + let model = self.model.clone(); + // Use a default max_width for text extraction + let max_width = 80; + if let Some(text) = self.chat_state.chat.get_selected_text(max_width, &model, &colors) { + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + } + self.chat_state.chat.selection.clear(); + return true; + } + // Check input selection + if self.input.has_selection() { + let text = self.input.get_selected_text(); + if !text.is_empty() { + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + } + self.input.clear_selection(); + return true; + } + false + } + + fn clear_selection(&mut self) -> bool { + if self.chat_state.chat.has_selection() { + self.chat_state.chat.selection.clear(); + return true; + } + if self.input.has_selection() { + self.input.clear_selection(); + return true; + } + false + } + + fn copy_chat_selection(&mut self) { + if !self.chat_state.chat.has_selection() { + return; + } + // Don't copy zero-width selections (e.g., single click without drag) + let ((s_line, s_col), (e_line, e_col)) = self.chat_state.chat.selection.range(); + if s_line == e_line && s_col == e_col { + return; + } + let colors = self.get_current_theme_colors(); + let model = self.model.clone(); + let max_width = self.last_frame_size.width.saturating_sub(4) as usize; + if let Some(text) = self.chat_state.chat.get_selected_text( + max_width.max(40), + &model, + &colors, + ) { + if !text.trim().is_empty() { + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + } + } + } + pub fn handle_keys(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('c') if key.modifiers == event::KeyModifiers::CONTROL => { + // If text is selected (chat or input), copy to clipboard first + if self.try_copy_selection() { + return; + } let now = std::time::Instant::now(); if now.duration_since(self.last_ctrl_c_time).as_secs() < 1 { self.ctrl_c_press_count += 1; @@ -819,6 +890,29 @@ impl App { } } } + OverlayFocus::TimelineDialog => { + let action = crate::views::timeline_dialog::handle_timeline_dialog_key_event( + &mut self.timeline_dialog_state, + key, + ); + match action { + crate::views::timeline_dialog::TimelineDialogAction::Close => { + self.overlay_focus = OverlayFocus::None; + true + } + crate::views::timeline_dialog::TimelineDialogAction::Select(idx) => { + self.chat_state.chat.scroll_to_message_index(idx); + self.overlay_focus = OverlayFocus::None; + true + } + crate::views::timeline_dialog::TimelineDialogAction::Navigate(idx) => { + self.chat_state.chat.scroll_to_message_index(idx); + true + } + crate::views::timeline_dialog::TimelineDialogAction::Handled => true, + crate::views::timeline_dialog::TimelineDialogAction::NotHandled => false, + } + } OverlayFocus::WhichKey => { let action = self.which_key_state.handle_key_event(key); match action { @@ -843,6 +937,10 @@ impl App { rt.block_on(self.process_input("/sessions")); }); } + crate::views::which_key::WhichKeyAction::ShowTimeline => { + self.overlay_focus = OverlayFocus::None; + self.open_timeline_dialog(); + } crate::views::which_key::WhichKeyAction::NewSession => { self.overlay_focus = OverlayFocus::None; tokio::task::block_in_place(|| { @@ -919,6 +1017,10 @@ impl App { true } KeyCode::Esc => { + // If text is selected, clear selection first + if self.clear_selection() { + return true; + } if self.is_streaming { self.cancel_streaming(); return true; @@ -948,6 +1050,10 @@ impl App { } fn handle_input_and_app_keys(&mut self, key: KeyEvent) { + // If chat text is selected and user presses a key, clear the selection + // (unless it's Ctrl+C or Escape which are handled earlier) + self.chat_state.chat.selection.clear(); + match key.code { KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { if self.is_streaming { @@ -1000,6 +1106,19 @@ impl App { } pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { + // If text is selected and user clicks on an overlay, clear selection instead + if self.overlay_focus != OverlayFocus::None + && self.chat_state.chat.has_selection() + && matches!( + mouse.kind, + ratatui::crossterm::event::MouseEventKind::Down(_) + ) + { + self.copy_chat_selection(); + self.chat_state.chat.selection.clear(); + return; + } + if self.overlay_focus == OverlayFocus::ModelsDialog { handle_models_dialog_mouse_event(&mut self.models_dialog_state, mouse); if !self.models_dialog_state.dialog.is_visible() { @@ -1088,11 +1207,20 @@ impl App { if !self.skills_dialog_state.dialog.is_visible() { self.overlay_focus = OverlayFocus::None; } + } else if self.overlay_focus == OverlayFocus::TimelineDialog { + if let Some(idx) = crate::views::timeline_dialog::handle_timeline_dialog_mouse_event( + &mut self.timeline_dialog_state, + mouse, + ) { + self.chat_state.chat.scroll_to_message_index(idx); + } + if !self.timeline_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } } else if self.overlay_focus == OverlayFocus::None { - // Handle mouse events for chat scrolling when in chat mode - if self.base_focus == BaseFocus::Chat { + // If chat has a selection and user clicks outside chat area, clear it + if self.chat_state.chat.has_selection() && self.base_focus == BaseFocus::Chat { let size = self.last_frame_size; - // We need to calculate the chat area similar to render_chat let main_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints( @@ -1108,23 +1236,93 @@ impl App { .direction(ratatui::layout::Direction::Vertical) .constraints( [ - ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(1), // Top padding + ratatui::layout::Constraint::Min(0), // Chat content + ratatui::layout::Constraint::Length(1), // Bottom padding ratatui::layout::Constraint::Length(input_height), - ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(1), // Help bar + ratatui::layout::Constraint::Length(1), // Blank + ] + .as_ref(), + ) + .split(main_chunks[0]); + let chat_area = above_status_chunks[1]; + + let point = ratatui::layout::Position::new(mouse.column, mouse.row); + if !chat_area.contains(point) { + // Click outside chat area, copy selection before clearing + self.copy_chat_selection(); + self.chat_state.chat.selection.clear(); + } + } + + // Handle mouse events for chat scrolling/selection when in chat mode + if self.base_focus == BaseFocus::Chat { + let size = self.last_frame_size; + let main_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Min(0), ratatui::layout::Constraint::Length(1), ] .as_ref(), ) + .split(size); + let input_height = self.input.get_height() as u16; + let above_status_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Length(1), // Top padding + ratatui::layout::Constraint::Min(0), // Chat content + ratatui::layout::Constraint::Length(1), // Bottom padding + ratatui::layout::Constraint::Length(input_height), + ratatui::layout::Constraint::Length(1), // Help bar + ratatui::layout::Constraint::Length(1), // Blank + ] + .as_ref(), + ) .split(main_chunks[0]); - let chat_area = above_status_chunks[0]; + let chat_area = above_status_chunks[1]; + + let had_selection = self.chat_state.chat.has_selection(); + let was_dragging = self.chat_state.chat.selection.is_dragging; if self.chat_state.chat.handle_mouse_event(mouse, chat_area) { + // Auto-copy when selection is finalized (mouse up after drag) + if !had_selection && self.chat_state.chat.has_selection() { + // New selection just started, don't copy yet + } else if was_dragging && !self.chat_state.chat.selection.is_dragging { + // Selection was just finalized (mouse up) + self.copy_chat_selection(); + } return; } } // Handle mouse events for the main input when no overlay is focused + let was_input_selecting = self.input.has_selection(); if self.input.handle_mouse_event(mouse) { + // Auto-copy input selection on mouse up (after drag select) + if matches!( + mouse.kind, + ratatui::crossterm::event::MouseEventKind::Up( + ratatui::crossterm::event::MouseButton::Left + ) + ) && !was_input_selecting + && self.input.has_selection() + { + let text = self.input.get_selected_text(); + if !text.is_empty() { + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new( + "Copied to clipboard", + ToastLevel::Info, + None, + )); + } + } self.update_suggestions(); } } @@ -1545,6 +1743,19 @@ impl App { self.sessions_dialog_state.refresh_items(items); } + fn open_timeline_dialog(&mut self) { + let messages: Vec = match self.session_manager.get_current_session() { + Some(s) => s.messages.clone(), + None => return, + }; + + let model = self.model.clone(); + self.timeline_dialog_state + .refresh_messages(&messages, &model); + self.timeline_dialog_state.show(); + self.overlay_focus = OverlayFocus::TimelineDialog; + } + fn refresh_models_dialog(&mut self) { use crate::model::discovery::Discovery; use crate::model::types::Model as ModelType; @@ -2653,6 +2864,17 @@ impl App { ); } + if self.overlay_focus == OverlayFocus::TimelineDialog + && self.timeline_dialog_state.dialog.is_visible() + { + crate::views::timeline_dialog::render_timeline_dialog( + f, + &mut self.timeline_dialog_state, + size, + colors, + ); + } + if self.overlay_focus == OverlayFocus::SessionRenameDialog && self.session_rename_dialog_state.is_visible() { diff --git a/src/main.rs b/src/main.rs index 1781469..0433175 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,28 @@ use std::time::Duration; const POST_CLOSE_LOGO: &str = include_str!("../crabcode-logo.txt"); +lazy_static::lazy_static! { + static ref STARTUP_DIAGNOSTICS: Mutex> = Mutex::new(Vec::new()); +} + +pub fn push_startup_diag(msg: String) { + STARTUP_DIAGNOSTICS.lock().unwrap().push(msg); +} + +#[macro_export] +macro_rules! startup_diag { + ($($arg:tt)*) => { + $crate::push_startup_diag(format!($($arg)*)) + }; +} + +fn flush_startup_diagnostics() { + let diags = std::mem::take(&mut *STARTUP_DIAGNOSTICS.lock().unwrap()); + for msg in diags { + eprintln!("{}", msg); + } +} + struct PostCloseInfo { session_id: String, session_title: String, @@ -167,6 +189,8 @@ async fn main() -> Result<()> { } terminal.show_cursor()?; + flush_startup_diagnostics(); + print!("{}", format_post_close_message(close_info.as_ref())); result diff --git a/src/skill/mod.rs b/src/skill/mod.rs index cf9798a..e4b8eb3 100644 --- a/src/skill/mod.rs +++ b/src/skill/mod.rs @@ -87,7 +87,7 @@ impl SkillStore { for match_path in &matches { if let Some(info) = parse_skill_file(match_path) { if let Some(existing) = skills.get(&info.name) { - eprintln!( + crate::startup_diag!( "Warning: duplicate skill name '{}' (existing: {}, duplicate: {})", info.name, existing.location.display(), @@ -99,7 +99,7 @@ impl SkillStore { } if !skills.is_empty() { - eprintln!("Loaded {} skills", skills.len()); + crate::startup_diag!("Loaded {} skills", skills.len()); } Self { @@ -156,7 +156,7 @@ fn scan(state: &mut ScanState, root: &Path, pattern: &str, dot: bool) { } Err(e) => { if !dot { - eprintln!("Warning: glob error scanning {}: {}", root.display(), e); + crate::startup_diag!("Warning: glob error scanning {}: {}", root.display(), e); } } } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 7958bb2..cb4e512 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1,6 +1,7 @@ use crate::session::types::{Message, MessageRole}; use crate::theme::ThemeColors; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; +use crate::ui::selection::Selection; use crate::utils::token_counter::StreamingTokenCounter; use ratatui::{ crossterm::event::{MouseButton, MouseEvent, MouseEventKind}, @@ -44,6 +45,10 @@ pub struct Chat { streaming_renderer: Option, /// Index of the message currently being rendered by streaming_renderer streaming_message_idx: Option, + /// Starting line positions for each message in the rendered content + pub message_line_positions: Vec, + /// Text selection state for copy-on-select + pub selection: Selection, } // Minimum elapsed time before showing tokens/s (250ms) @@ -87,6 +92,8 @@ impl Chat { last_tps_calculated: None, streaming_renderer: None, streaming_message_idx: None, + message_line_positions: Vec::new(), + selection: Selection::new(), } } @@ -114,6 +121,8 @@ impl Chat { last_tps_calculated: None, streaming_renderer: None, streaming_message_idx: None, + message_line_positions: Vec::new(), + selection: Selection::new(), } } @@ -238,6 +247,7 @@ impl Chat { self.streaming_pause_started_at = None; self.streaming_paused_duration = std::time::Duration::default(); self.streaming_token_counter = None; + self.selection.clear(); } pub fn begin_streaming_turn(&mut self) { @@ -484,6 +494,49 @@ impl Chat { self.update_scrollbar(); } + pub fn get_message_line_positions(&self, max_width: usize, model: &str, colors: &ThemeColors) -> Vec { + let mut positions = Vec::with_capacity(self.messages.len()); + let mut line = 0; + let message_count = self.messages.len(); + let streaming_idx = self.streaming_assistant_idx(); + let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); + + for (idx, message) in self.messages.iter().enumerate() { + positions.push(line); + let attached_to_assistant = + idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; + let message_lines = self.format_message( + message, + max_width, + idx, + message_count, + streaming_content, + streaming_idx, + model, + colors, + attached_to_assistant, + ); + line += message_lines.len(); + } + + positions + } + + pub fn scroll_to_message_index(&mut self, idx: usize) { + if idx >= self.messages.len() { + return; + } + + let line_pos = self.message_line_positions.get(idx).copied().unwrap_or(0); + + // Scroll so the message is visible (near top of viewport, with a small margin) + let target_offset = line_pos.saturating_sub(2); + let max_offset = self.content_height.saturating_sub(self.viewport_height); + self.scroll_offset = target_offset.min(max_offset); + self.user_scrolled_up = true; + self.update_scrollbar(); + } + fn update_scrollbar(&mut self) { let max_offset = self.content_height.saturating_sub(self.viewport_height); let content_length = max_offset.saturating_add(1).max(1); @@ -492,15 +545,58 @@ impl Chat { self.scrollbar_state = self.scrollbar_state.position(position); } + pub fn has_selection(&self) -> bool { + self.selection.active + } + + pub fn get_selected_text<'a>( + &'a self, + max_width: usize, + model: &'a str, + colors: &'a ThemeColors, + ) -> Option { + if !self.selection.active { + return None; + } + let lines = self.render_visible_messages_without_selection_styling( + max_width, model, colors, + ); + crate::ui::selection::extract_selected_text(&lines, &self.selection) + } + + /// Like render_visible_messages but without applying selection styling + /// (used internally by get_selected_text to get clean text) + fn render_visible_messages_without_selection_styling<'a>( + &'a self, + max_width: usize, + model: &'a str, + colors: &'a ThemeColors, + ) -> Vec> { + self.build_all_lines(max_width, model, colors) + } + pub fn handle_mouse_event(&mut self, event: MouseEvent, area: Rect) -> bool { use ratatui::layout::Position; let point = Position::new(event.column, event.row); if !area.contains(point) { self.is_dragging_scrollbar = false; + // If dragging selection outside area, finalize it + if self.selection.is_dragging { + self.selection.finish(); + // Copy will be handled by app.rs on mouse up + } return false; } + // Calculate the content area (exclude scrollbar column) + let content_area = Rect { + x: area.x, + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + }; + // Calculate scrollbar area (rightmost column) let scrollbar_area = Rect { x: area.x + area.width.saturating_sub(1), @@ -510,6 +606,7 @@ impl Chat { }; let is_on_scrollbar = scrollbar_area.contains(point); + let is_in_content = content_area.contains(point); match event.kind { MouseEventKind::ScrollDown => { @@ -525,6 +622,13 @@ impl Chat { self.is_dragging_scrollbar = true; self.scroll_to_position(event.row, scrollbar_area); true + } else if is_in_content { + // Start text selection + let content_line = (event.row.saturating_sub(content_area.y) as usize) + .saturating_add(self.scroll_offset); + let content_col = event.column.saturating_sub(content_area.x) as usize; + self.selection.start(content_line, content_col); + true } else { false } @@ -533,14 +637,39 @@ impl Chat { if self.is_dragging_scrollbar { self.scroll_to_position(event.row, scrollbar_area); true + } else if is_in_content && self.selection.is_dragging { + // Extend text selection + let content_line = (event.row.saturating_sub(content_area.y) as usize) + .saturating_add(self.scroll_offset); + let content_col = event.column.saturating_sub(content_area.x) as usize; + self.selection.extend(content_line, content_col); + true } else { false } } - MouseEventKind::Up(_) => { + MouseEventKind::Up(MouseButton::Left) => { if self.is_dragging_scrollbar { self.is_dragging_scrollbar = false; true + } else if self.selection.is_dragging { + // Finalize text selection + self.selection.finish(); + // If selection is zero-width (click without drag), clear it + let ((s_line, s_col), (e_line, e_col)) = self.selection.range(); + if s_line == e_line && s_col == e_col { + self.selection.clear(); + } + true + } else { + false + } + } + MouseEventKind::Up(MouseButton::Right) => { + // Right-click clears selection + if self.selection.active { + self.selection.clear(); + true } else { false } @@ -633,7 +762,7 @@ impl Chat { } fn calculate_content_height( - &self, + &mut self, max_width: usize, model: &str, colors: &ThemeColors, @@ -643,7 +772,11 @@ impl Chat { let streaming_idx = self.streaming_assistant_idx(); let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); + self.message_line_positions.clear(); + self.message_line_positions.reserve(message_count); + for (idx, message) in self.messages.iter().enumerate() { + self.message_line_positions.push(total_height); let attached_to_assistant = idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; let message_lines = self.format_message( @@ -668,6 +801,16 @@ impl Chat { max_width: usize, model: &'a str, colors: &'a ThemeColors, + ) -> Vec> { + let lines = self.build_all_lines(max_width, model, colors); + crate::ui::selection::apply_selection_to_lines(lines, &self.selection, colors.accent) + } + + fn build_all_lines<'a>( + &'a self, + max_width: usize, + model: &'a str, + colors: &'a ThemeColors, ) -> Vec> { let mut all_lines: Vec> = Vec::new(); let message_count = self.messages.len(); diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 168180d..606fbb1 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -17,6 +17,13 @@ use std::collections::HashMap; use tui_textarea::{Input as TuiInput, TextArea}; use unicode_width::UnicodeWidthStr; +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DialogPosition { + Left, + Center, + Right, +} + #[derive(Debug)] pub struct DialogItem { pub id: String, @@ -64,6 +71,7 @@ pub struct Dialog { pub is_dragging_scrollbar: bool, pub visible_row_count: usize, pub actions: Vec, + pub position: DialogPosition, matcher: Matcher, } @@ -93,10 +101,16 @@ impl Dialog { is_dragging_scrollbar: false, visible_row_count: 0, actions: Vec::new(), + position: DialogPosition::Center, matcher: Matcher::new(Config::DEFAULT), } } + pub fn with_position(mut self, position: DialogPosition) -> Self { + self.position = position; + self + } + pub fn with_items(title: impl Into, items: Vec) -> Self { let mut dialog = Self::new(title); dialog.set_items(items); @@ -434,14 +448,27 @@ impl Dialog { if self.visible_row_count > 0 { self.visible_row_count } else { - const DIALOG_WIDTH: u16 = 70; - const DIALOG_HEIGHT: u16 = 25; + const DIALOG_WIDTH_CENTER: u16 = 70; + const DIALOG_HEIGHT_CENTER: u16 = 25; + const DIALOG_WIDTH_SIDE: u16 = 40; const PADDING: u16 = 3; let total_fixed_height = 1 + 1 + 3 + 1 + 1; let padding_total = PADDING * 2; - let list_area_height = DIALOG_HEIGHT.saturating_sub(total_fixed_height + padding_total); - list_area_height as usize + + match self.position { + DialogPosition::Center => { + let list_area_height = + DIALOG_HEIGHT_CENTER.saturating_sub(total_fixed_height + padding_total); + list_area_height as usize + } + DialogPosition::Left | DialogPosition::Right => { + // Side panels use full height, minus fixed chrome + padding + let list_area_height = + 40u16.saturating_sub(total_fixed_height + padding_total); + list_area_height as usize + } + } } } @@ -514,12 +541,15 @@ impl Dialog { return true; } - const PADDING: u16 = 3; + let padding = match self.position { + DialogPosition::Center => 3u16, + DialogPosition::Left | DialogPosition::Right => 1u16, + }; let content_area = Rect { - x: self.dialog_area.x + PADDING, - y: self.dialog_area.y + PADDING, - width: self.dialog_area.width.saturating_sub(PADDING * 2), - height: self.dialog_area.height.saturating_sub(PADDING * 2), + x: self.dialog_area.x + padding, + y: self.dialog_area.y + padding, + width: self.dialog_area.width.saturating_sub(padding * 2), + height: self.dialog_area.height.saturating_sub(padding * 2), }; if !content_area.contains(point) { @@ -667,27 +697,55 @@ impl Dialog { return; } - const DIALOG_WIDTH: u16 = 70; - const DIALOG_HEIGHT: u16 = 25; - - let dialog_width = area.width.min(DIALOG_WIDTH); - let dialog_height = area.height.min(DIALOG_HEIGHT); - - self.dialog_area = Rect { - x: (area.width - dialog_width) / 2, - y: (area.height - dialog_height) / 2, - width: dialog_width, - height: dialog_height, - }; + const DIALOG_WIDTH_CENTER: u16 = 70; + const DIALOG_HEIGHT_CENTER: u16 = 25; + const DIALOG_WIDTH_SIDE: u16 = 45; + + match self.position { + DialogPosition::Center => { + let dialog_width = area.width.min(DIALOG_WIDTH_CENTER); + let dialog_height = area.height.min(DIALOG_HEIGHT_CENTER); + + self.dialog_area = Rect { + x: (area.width - dialog_width) / 2, + y: (area.height - dialog_height) / 2, + width: dialog_width, + height: dialog_height, + }; + } + DialogPosition::Right => { + let dialog_width = area.width.min(DIALOG_WIDTH_SIDE); + + self.dialog_area = Rect { + x: area.width.saturating_sub(dialog_width), + y: area.y, + width: dialog_width, + height: area.height, + }; + } + DialogPosition::Left => { + let dialog_width = area.width.min(DIALOG_WIDTH_SIDE); + + self.dialog_area = Rect { + x: area.x, + y: area.y, + width: dialog_width, + height: area.height, + }; + } + } frame.render_widget(Clear, self.dialog_area); - const PADDING: u16 = 3; + let padding = match self.position { + DialogPosition::Center => 3u16, + DialogPosition::Left | DialogPosition::Right => 1u16, + }; self.content_area = Rect { - x: self.dialog_area.x + PADDING, - y: self.dialog_area.y + PADDING, - width: self.dialog_area.width.saturating_sub(PADDING * 2), - height: self.dialog_area.height.saturating_sub(PADDING * 2), + x: self.dialog_area.x + padding, + y: self.dialog_area.y + padding, + width: self.dialog_area.width.saturating_sub(padding * 2), + height: self.dialog_area.height.saturating_sub(padding * 2), }; frame.render_widget( @@ -930,6 +988,7 @@ impl Clone for Dialog { is_dragging_scrollbar: self.is_dragging_scrollbar, visible_row_count: self.visible_row_count, actions: self.actions.clone(), + position: self.position, matcher: Matcher::new(Config::DEFAULT), } } diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index d4cfd88..2febbc7 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -21,6 +21,12 @@ impl Input { pub fn new() -> Self { let mut textarea = TextArea::default(); textarea.set_cursor_line_style(Style::default()); + // Default selection style (will be updated per-theme in render) + textarea.set_selection_style( + Style::default() + .bg(ratatui::style::Color::Rgb(255, 140, 0)) + .fg(ratatui::style::Color::Reset), + ); let prompt_history = PromptHistoryCache::new().ok(); Self { textarea, @@ -72,6 +78,13 @@ impl Input { // Store the textarea area for mouse event handling self.textarea_area = Some(chunks[1]); + // Configure selection style to match theme + self.textarea.set_selection_style( + ratatui::style::Style::default() + .bg(colors.accent) + .fg(colors.text), + ); + // Ensure viewport_top stays within valid bounds let line_count = self.textarea.lines().len(); let visible_lines = chunks[1].height as usize; @@ -242,15 +255,10 @@ impl Input { let line_count = self.textarea.lines().len(); let visible_lines = textarea_area.height as usize; - // Only scroll if content exceeds viewport if line_count > visible_lines { - // Calculate max valid viewport top position let max_viewport_top = line_count.saturating_sub(visible_lines); - - // Only scroll down if we haven't reached the bottom yet if self.viewport_top < max_viewport_top { self.viewport_top += 1; - // Move cursor to keep it visible in the viewport let target_row = self.viewport_top + visible_lines - 1; let (_, cursor_col) = self.textarea.cursor(); self.textarea @@ -263,12 +271,9 @@ impl Input { let line_count = self.textarea.lines().len(); let visible_lines = textarea_area.height as usize; - // Only scroll if content exceeds viewport if line_count > visible_lines { - // Only scroll up if we're not at the top already if self.viewport_top > 0 { self.viewport_top -= 1; - // Move cursor to keep it visible in the viewport let target_row = self.viewport_top; let (_, cursor_col) = self.textarea.cursor(); self.textarea @@ -278,34 +283,88 @@ impl Input { true } MouseEventKind::Down(MouseButton::Left) => { - // Calculate cursor position from mouse coordinates let relative_x = mouse_x.saturating_sub(textarea_area.x); let relative_y = mouse_y.saturating_sub(textarea_area.y); - // Get the lines to calculate proper column position let lines = self.textarea.lines(); - // Account for viewport offset when calculating target row let target_row = self.viewport_top + relative_y as usize; if target_row < lines.len() { let line = &lines[target_row]; - // Clamp column to line length let target_col = (relative_x as usize).min(line.len()); + // Position cursor and start selection for potential drag self.textarea .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); + self.textarea.start_selection(); } else { - // Clicked beyond the last line, move to end of last line let last_row = lines.len().saturating_sub(1); let last_col = lines[last_row].len(); self.textarea .move_cursor(CursorMove::Jump(last_row as u16, last_col as u16)); + self.textarea.start_selection(); + } + true + } + MouseEventKind::Drag(MouseButton::Left) => { + // Extend the ongoing selection + let relative_x = mouse_x.saturating_sub(textarea_area.x); + let relative_y = mouse_y.saturating_sub(textarea_area.y); + + let lines = self.textarea.lines(); + let target_row = self.viewport_top + relative_y as usize; + + if target_row < lines.len() { + let line = &lines[target_row]; + let target_col = (relative_x as usize).min(line.len()); + // Since start_selection() was called and is_selecting() is true, + // move_cursor extends the selection + self.textarea + .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); } true } + MouseEventKind::Up(MouseButton::Right) => { + // Right-click clears selection + self.textarea.cancel_selection(); + true + } _ => false, } } + pub fn has_selection(&self) -> bool { + self.textarea.is_selecting() + } + + pub fn get_selected_text(&self) -> String { + let range = match self.textarea.selection_range() { + Some(r) => r, + None => return String::new(), + }; + let ((start_row, start_col), (end_row, end_col)) = range; + let lines = self.textarea.lines(); + + let mut result = String::new(); + for (i, line) in lines.iter().enumerate() { + if i < start_row || i > end_row { + continue; + } + let start = if i == start_row { start_col } else { 0 }; + let end = if i == end_row { end_col } else { line.len() }; + + let line_str: String = line.chars().skip(start).take(end.saturating_sub(start)).collect(); + if !result.is_empty() { + result.push('\n'); + } + result.push_str(&line_str); + } + result + } + + pub fn clear_selection(&mut self) { + self.textarea.cancel_selection(); + } + pub fn should_show_suggestions(&self) -> bool { let text = self.get_text(); !text.is_empty() && text.starts_with('/') @@ -358,6 +417,11 @@ impl Input { pub fn clear(&mut self) { self.textarea = TextArea::default(); self.textarea.set_cursor_line_style(Style::default()); + self.textarea.set_selection_style( + Style::default() + .bg(ratatui::style::Color::Rgb(255, 140, 0)) + .fg(ratatui::style::Color::Reset), + ); self.viewport_top = 0; self.draft_text = None; if let Some(ref mut history) = self.prompt_history { @@ -385,6 +449,11 @@ impl Input { pub fn set_text(&mut self, text: &str) { self.textarea = TextArea::default(); self.textarea.set_cursor_line_style(Style::default()); + self.textarea.set_selection_style( + Style::default() + .bg(ratatui::style::Color::Rgb(255, 140, 0)) + .fg(ratatui::style::Color::Reset), + ); self.textarea.insert_str(text); self.viewport_top = 0; } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8775f8a..b44e155 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ pub mod components; pub mod layout; pub mod markdown; +pub mod selection; diff --git a/src/ui/selection.rs b/src/ui/selection.rs new file mode 100644 index 0000000..06ad86a --- /dev/null +++ b/src/ui/selection.rs @@ -0,0 +1,362 @@ +use ratatui::{ + style::{Color, Modifier, Style}, + text::Span, +}; +use unicode_width::UnicodeWidthStr; + +/// Represents a text selection range in the chat content. +/// Coordinates are in rendered-content space (line index, column within line). +#[derive(Debug, Clone, Default)] +pub struct Selection { + pub active: bool, + /// Start position (line, column) in rendered content + pub start_line: usize, + pub start_col: usize, + /// End position (line, column) in rendered content + pub end_line: usize, + pub end_col: usize, + /// Whether the user is currently dragging to extend selection + pub is_dragging: bool, +} + +impl Selection { + pub fn new() -> Self { + Self::default() + } + + /// Clear the selection + pub fn clear(&mut self) { + self.active = false; + self.is_dragging = false; + } + + /// Start a new selection at the given rendered-content position + pub fn start(&mut self, line: usize, col: usize) { + self.active = true; + self.is_dragging = true; + self.start_line = line; + self.start_col = col; + self.end_line = line; + self.end_col = col; + } + + /// Extend selection to the given position during drag + pub fn extend(&mut self, line: usize, col: usize) { + if !self.is_dragging { + return; + } + self.end_line = line; + self.end_col = col; + } + + /// Finalize selection (mouse up) + pub fn finish(&mut self) { + self.is_dragging = false; + // Normalize so start <= end + self.normalize(); + } + + /// Normalize selection so start <= end + fn normalize(&mut self) { + if self.start_line > self.end_line + || (self.start_line == self.end_line && self.start_col > self.end_col) + { + std::mem::swap(&mut self.start_line, &mut self.end_line); + std::mem::swap(&mut self.start_col, &mut self.end_col); + } + } + + /// Get the normalized range (start_line, start_col) to (end_line, end_col) + pub fn range(&self) -> ((usize, usize), (usize, usize)) { + let mut start = (self.start_line, self.start_col); + let mut end = (self.end_line, self.end_col); + if start > end { + std::mem::swap(&mut start, &mut end); + } + (start, end) + } + + /// Check if a position (line, col_start..col_end) overlaps with the selection + pub fn overlaps(&self, line: usize, col_start: usize, col_end: usize) -> bool { + if !self.active { + return false; + } + let ((s_line, s_col), (e_line, e_col)) = self.range(); + + if line < s_line || line > e_line { + return false; + } + + if line == s_line && line == e_line { + // Same line + col_end > s_col && col_start < e_col + } else if line == s_line { + // Start line + col_end > s_col + } else if line == e_line { + // End line + col_start < e_col + } else { + // Fully between start and end lines + true + } + } + + /// Check if a line is fully selected + pub fn is_line_fully_selected(&self, line: usize, line_width: usize) -> bool { + if !self.active { + return false; + } + let ((s_line, s_col), (e_line, e_col)) = self.range(); + if line > s_line && line < e_line { + return true; + } + if line == s_line && line == e_line { + return s_col == 0 && e_col >= line_width; + } + if line == s_line { + return s_col == 0 && e_line > s_line; + } + if line == e_line { + return s_line < e_line && e_col >= line_width; + } + false + } + + /// Return the selection range within a specific line. + /// Returns None if the line is not in the selection. + /// Returns (start_col, end_col) if partially or fully selected. + pub fn selection_range_in_line(&self, line: usize, line_width: usize) -> Option<(usize, usize)> { + if !self.active { + return None; + } + let ((s_line, s_col), (e_line, e_col)) = self.range(); + + if line < s_line || line > e_line { + return None; + } + + let start = if line == s_line { s_col } else { 0 }; + let end = if line == e_line { e_col } else { line_width }; + + if start >= end { + return None; + } + Some((start, end)) + } +} + +/// Apply selection styling to a vector of lines. Spans that fall within the +/// selection range get highlighted with the accent color. +pub fn apply_selection_to_lines<'a>( + lines: Vec>, + selection: &Selection, + accent: Color, +) -> Vec> { + if !selection.active { + return lines; + } + let ((s_line, _s_col), (e_line, _e_col)) = selection.range(); + + lines + .into_iter() + .enumerate() + .map(|(line_idx, line)| { + if line_idx < s_line || line_idx > e_line { + return line; + } + let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + let line_width = unicode_width::UnicodeWidthStr::width(line_text.as_str()); + let sel_range = selection.selection_range_in_line(line_idx, line_width); + + // If entire line is selected, just style all spans + if selection.is_line_fully_selected(line_idx, line_width) { + let styled_spans: Vec = line + .spans + .into_iter() + .map(|s| selection_span_style(&s, accent)) + .collect(); + return ratatui::text::Line::from(styled_spans); + } + + // Partial selection: track column position and split spans + let mut col = 0usize; + let mut styled_spans = Vec::new(); + for span in line.spans { + let new_spans = split_and_style_span(&span, col, accent, sel_range); + // Track column advance before extending + col += new_spans + .iter() + .map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref())) + .sum::(); + styled_spans.extend(new_spans); + } + ratatui::text::Line::from(styled_spans) + }) + .collect() +} + +/// Extract the selected text from the rendered content lines. +pub fn extract_selected_text( + lines: &[ratatui::text::Line<'_>], + selection: &Selection, +) -> Option { + if !selection.active { + return None; + } + let ((s_line, s_col), (e_line, e_col)) = selection.range(); + let mut result = String::new(); + + for (line_idx, line) in lines.iter().enumerate() { + if line_idx < s_line || line_idx > e_line { + continue; + } + let full_text: String = line + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + let line_width = unicode_width::UnicodeWidthStr::width(full_text.as_str()); + + let start = if line_idx == s_line { s_col } else { 0 }; + let end = if line_idx == e_line { e_col } else { line_width }; + + if start >= end || start > full_text.len() { + continue; + } + + // Convert display-width start/end to character indices + let chars: Vec = full_text.chars().collect(); + let mut char_start = 0; + let mut display_pos = 0; + for (i, c) in chars.iter().enumerate() { + if display_pos >= start { + char_start = i; + break; + } + display_pos += unicode_width::UnicodeWidthChar::width(*c).unwrap_or(1); + char_start = i + 1; + } + + let mut char_end = char_start; + display_pos = start; + for (i, c) in chars[char_start..].iter().enumerate() { + if display_pos >= end { + char_end = char_start + i; + break; + } + display_pos += unicode_width::UnicodeWidthChar::width(*c).unwrap_or(1); + char_end = char_start + i + 1; + } + + let end_idx = char_end.min(chars.len()); + let start_idx = char_start.min(end_idx); + + let selected_part: String = chars[start_idx..end_idx].iter().collect(); + + if !result.is_empty() { + result.push('\n'); + } + result.push_str(&selected_part); + } + + if result.is_empty() { + None + } else { + Some(result) + } +} + +/// Apply a selection highlight style to a span. +/// Uses the accent color as background with inverted text for visibility. +fn selection_span_style<'a>(span: &Span<'a>, accent: Color) -> Span<'a> { + let current_fg = span.style.fg.unwrap_or(Color::Reset); + Span::styled( + span.content.clone(), + Style::default() + .bg(accent) + .fg(current_fg) + .add_modifier(Modifier::BOLD), + ) +} + +/// Split a span at a given column offset and apply selection style to the selected portion. +/// Returns a vector of spans (unselected prefix, selected middle, unselected suffix). +fn split_and_style_span<'a>( + span: &Span<'a>, + col_offset: usize, + accent: Color, + selection_range: Option<(usize, usize)>, +) -> Vec> { + let content = span.content.as_ref(); + let width = unicode_width::UnicodeWidthStr::width(content); + let span_end = col_offset + width; + + let (sel_start, sel_end) = match selection_range { + Some((s, e)) => (s, e), + None => return vec![span.clone()], + }; + + // Check if this span overlaps with the selection + if sel_end <= col_offset || sel_start >= span_end { + return vec![span.clone()]; + } + + // Calculate the overlap boundaries in display-width positions relative to the span + let overlap_start = sel_start.saturating_sub(col_offset); + let overlap_end = sel_end.saturating_sub(col_offset).min(width); + + if overlap_start >= overlap_end { + return vec![span.clone()]; + } + + // Convert display-width positions back to character indices + let chars: Vec = content.chars().collect(); + let total_chars = chars.len(); + + let mut char_idx = 0; + let mut display_pos = 0; + let char_start; + + while char_idx < total_chars && display_pos < overlap_start { + let c = chars[char_idx]; + display_pos += unicode_width::UnicodeWidthChar::width(c).unwrap_or(1); + char_idx += 1; + } + char_start = char_idx; + + while char_idx < total_chars && display_pos < overlap_end { + let c = chars[char_idx]; + display_pos += unicode_width::UnicodeWidthChar::width(c).unwrap_or(1); + char_idx += 1; + } + let char_end = char_idx; + + if char_start >= char_end { + return vec![span.clone()]; + } + + let before: String = chars[..char_start].iter().collect(); + let selected: String = chars[char_start..char_end].iter().collect(); + let after: String = chars[char_end..].iter().collect(); + + let mut result = Vec::new(); + + if !before.is_empty() { + result.push(Span::styled(before, span.style)); + } + + result.push(Span::styled( + selected, + Style::default() + .bg(accent) + .fg(span.style.fg.unwrap_or(Color::Reset)) + .add_modifier(Modifier::BOLD), + )); + + if !after.is_empty() { + result.push(Span::styled(after, span.style)); + } + + result +} diff --git a/src/views/mod.rs b/src/views/mod.rs index 3c1b666..d506ea8 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -9,6 +9,7 @@ pub mod sessions_dialog; pub mod skills_dialog; pub mod suggestions_popup; pub mod themes_dialog; +pub mod timeline_dialog; pub mod which_key; pub use chat::ChatState; @@ -22,6 +23,7 @@ pub use sessions_dialog::SessionsDialogState; pub use skills_dialog::SkillsDialogState; pub use suggestions_popup::SuggestionsPopupState; pub use themes_dialog::ThemesDialogState; +pub use timeline_dialog::TimelineDialogState; #[allow(unused_imports)] pub use which_key::WhichKeyAction; pub use which_key::WhichKeyState; diff --git a/src/views/timeline_dialog.rs b/src/views/timeline_dialog.rs new file mode 100644 index 0000000..2231f48 --- /dev/null +++ b/src/views/timeline_dialog.rs @@ -0,0 +1,217 @@ +use crate::session::types::{Message, MessageRole}; +use crate::theme::ThemeColors; +use crate::ui::components::dialog::{Dialog, DialogAction as FooterAction, DialogItem, DialogPosition}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; +use ratatui::{layout::Rect, Frame}; + +#[derive(Debug)] +pub struct TimelineDialogState { + pub dialog: Dialog, +} + +impl TimelineDialogState { + pub fn new() -> Self { + let mut dialog = Dialog::new("Timeline").with_position(DialogPosition::Right); + dialog = dialog.with_actions(vec![ + FooterAction { + label: "Jump".to_string(), + key: "enter".to_string(), + }, + ]); + Self { dialog } + } + + pub fn build_from_messages( + messages: &[Message], + model: &str, + ) -> Self { + let mut state = Self::new(); + state.refresh_messages(messages, model); + state + } + + pub fn refresh_messages(&mut self, messages: &[Message], model: &str) { + let mut items: Vec = Vec::new(); + + for (idx, message) in messages.iter().enumerate() { + match message.role { + MessageRole::User | MessageRole::Assistant => {} + _ => continue, + } + + let role_label = match message.role { + MessageRole::User => "You", + MessageRole::Assistant => "Agent", + _ => unreachable!(), + }; + + let preview = message + .content + .lines() + .find(|line| !line.trim().is_empty()) + .map(|line| { + let trimmed = line.trim(); + if trimmed.len() > 60 { + format!("{}…", &trimmed[..60]) + } else { + trimmed.to_string() + } + }) + .unwrap_or_else(|| "(empty)".to_string()); + + let name = format!("{}: {}", role_label, preview); + + let description = match message.role { + MessageRole::Assistant => { + let m = message.model.as_deref().unwrap_or(model); + if message.is_complete { + format!("{}", m) + } else { + format!("{} · streaming", m) + } + } + MessageRole::User => message + .agent_mode + .as_deref() + .unwrap_or("") + .to_string(), + _ => String::new(), + }; + + let tip = { + let duration = message + .timestamp + .elapsed() + .unwrap_or_default(); + let secs = duration.as_secs(); + if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else { + format!("{}h ago", secs / 3600) + } + }; + + items.push(DialogItem { + id: idx.to_string(), + name, + group: String::new(), + description, + tip: Some(tip), + provider_id: String::new(), + }); + } + + // Chronological order: oldest first, newest at bottom + // Cursor starts at the most recent message (bottom) + let last_index = items.len().saturating_sub(1); + + let was_visible = self.dialog.is_visible(); + let mut dialog = Dialog::with_items("Timeline", items) + .with_position(DialogPosition::Right); + dialog.selected_index = last_index; + dialog = dialog.with_actions(vec![ + FooterAction { + label: "Jump".to_string(), + key: "enter".to_string(), + }, + ]); + + if was_visible { + dialog.show(); + } + + self.dialog = dialog; + } + + pub fn show(&mut self) { + self.dialog.show(); + } + + pub fn hide(&mut self) { + self.dialog.hide(); + } +} + +pub fn init_timeline_dialog() -> TimelineDialogState { + TimelineDialogState::new() +} + +pub fn render_timeline_dialog( + f: &mut Frame, + state: &mut TimelineDialogState, + area: Rect, + colors: ThemeColors, +) { + state.dialog.render(f, area, colors); +} + +pub fn handle_timeline_dialog_key_event( + state: &mut TimelineDialogState, + event: KeyEvent, +) -> TimelineDialogAction { + let was_visible = state.dialog.is_visible(); + let prev_selected = state.dialog.selected_index; + + let handled = state.dialog.handle_key_event(event); + + if was_visible && !state.dialog.is_visible() { + return TimelineDialogAction::Close; + } + + if event.code == KeyCode::Enter && was_visible { + if let Some(selected) = state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::() { + return TimelineDialogAction::Select(idx); + } + } + } + + // Detect navigation (up/down changed selection) + if handled && state.dialog.selected_index != prev_selected { + if let Some(selected) = state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::() { + return TimelineDialogAction::Navigate(idx); + } + } + } + + if handled { + TimelineDialogAction::Handled + } else { + TimelineDialogAction::NotHandled + } +} + +pub fn handle_timeline_dialog_mouse_event( + state: &mut TimelineDialogState, + event: MouseEvent, +) -> Option { + let prev_selected = state.dialog.selected_index; + let handled = state.dialog.handle_mouse_event(event); + + if !state.dialog.is_visible() { + return None; + } + + // On click selection, return the selected message index + if handled && state.dialog.selected_index != prev_selected { + if let Some(selected) = state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::() { + return Some(idx); + } + } + } + + None +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TimelineDialogAction { + Handled, + NotHandled, + Close, + Select(usize), + Navigate(usize), +} diff --git a/src/views/which_key.rs b/src/views/which_key.rs index 3fd6a9a..eda5477 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -17,6 +17,7 @@ pub enum WhichKeyAction { ShowModels, ShowThemes, ShowSessions, + ShowTimeline, NewSession, Quit, ScrollUp, @@ -43,6 +44,11 @@ pub struct WhichKeyState { impl WhichKeyState { pub fn new() -> Self { let bindings = vec![ + KeyBinding { + key: "g".to_string(), + description: "Open Messages Timeline dialog".to_string(), + action: WhichKeyAction::ShowTimeline, + }, KeyBinding { key: "m".to_string(), description: "Open Models dialog".to_string(), @@ -121,6 +127,10 @@ impl WhichKeyState { self.update_last_key_time(); match event.code { + KeyCode::Char('g') | KeyCode::Char('G') => { + self.hide(); + WhichKeyAction::ShowTimeline + } KeyCode::Char('m') | KeyCode::Char('M') => { self.hide(); WhichKeyAction::ShowModels @@ -214,11 +224,11 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // top margin - Constraint::Length(1), // title - Constraint::Length(bindings_count as u16), // bindings - Constraint::Length(1), // spacer - Constraint::Length(1), // footer + Constraint::Length(1), // top margin + Constraint::Length(1), // title + Constraint::Length(bindings_count as u16), // bindings + Constraint::Length(1), // spacer + Constraint::Length(1), // footer ]) .split(content_area); @@ -279,10 +289,7 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo } } - f.render_widget( - Paragraph::new(lines).alignment(Alignment::Left), - chunks[2], - ); + f.render_widget(Paragraph::new(lines).alignment(Alignment::Left), chunks[2]); // Footer — dim hint matching Dialog footer style f.render_widget( From a6489507768652fb56054515731d4308922e3b92 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 00:59:50 +0800 Subject: [PATCH 051/226] feat: simplify timeline dialog and fix mouse selection behavior. - Remove `model` parameter from timeline dialog (no longer shows model/description per message) - Shorten message preview truncation from 60 to 20 chars - Add mouse up event handling in Input component for selection finalization - Fix auto-copy on mouse drag-select in app layer - Move timeline shortcut `g` from global to chat-only keybindings - Make `Dialog::adjust_scroll` public for timeline dialog usage --- _plans/__TODOS.md | 11 ++++++++++- src/app.rs | 8 ++------ src/ui/components/dialog.rs | 2 +- src/ui/components/input.rs | 4 ++++ src/views/timeline_dialog.rs | 32 +++++++------------------------- src/views/which_key.rs | 12 ++++++------ 6 files changed, 30 insertions(+), 39 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index d5da2b2..e395c10 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -1,4 +1,13 @@ -- [ ] Rearchitect - multi-workspace, just like codex. Since it's a terminal, special case is it runs even when closed. Doing /sessions can switch between running sessions. Show a loading (use claude code loading animation), for loading sessions. Group by folders, not by Today, etc. Move the /sessions dialog to the "left". Run as a process? Allow for interruption as well. Maybe via a `/` command or a `ctrl-x` shortcut. +- [ ] Rearchitect - multi-workspace, just like the codex desktop app. + - Since it's a terminal, we have a special case to make it run even when closed, or when there are multiple instances of the program running. They have the same sort of "streaming" state. I will elaborate. + - Mutli-workspace feature is essentially having multiple "chat sessions" running. Currently.. Every run of `crabcode` is its own isolated session. + - We want to change that by making `crabcode` a multi-workspace agentic TUI by default, just like the codex desktop app, superconductor, etc. But simpler because the idea is literally just like a chat app on the web. Wherein, I want to be able to check the "sessions" in the sidebar, create new chats in the same tab (in this case a tab is a run of `crabcode`). + - So we can model this off of existing chat apps I've made (INSERT REFERENCE HERE) + - Because we can create multiple sessions, we can swap between them because each chat session will now be isolated with their own state. No worktrees for now because that's complicated. + - Since they each have their own state, that means the streaming will have their own states and when I do `/sessions` I can clearly see what's currently streaming and already done. We want to indicate "streaming" with the same icon claude uses (I had a very nice working example here /Users/carlo/Desktop/Projects/lazygitrs + ) + - Also the idea is, we can run create multiple "sessions" in the same run of `crabcode`. And we can even open multiple `crabcode` runs in the terminal, and it'll still have the same states for "streaming" when I check the other sessions with `/session`. + - /sessions can switch between running sessions. Show a loading (use claude code loading animation), for loading sessions. Group by folders, not by Today, etc. Move the /sessions dialog to the "left". Run as a process? Allow for interruption as well. Maybe via a `/` command or a `ctrl-x` shortcut. - [ ] Just like opencode. I want to see the `94.4.k (9%) ∙ $0.39` detail just next to the helpful tips under the input box. Use the same data sources. diff --git a/src/app.rs b/src/app.rs index f20a186..1aeb0b7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1302,7 +1302,6 @@ impl App { } // Handle mouse events for the main input when no overlay is focused - let was_input_selecting = self.input.has_selection(); if self.input.handle_mouse_event(mouse) { // Auto-copy input selection on mouse up (after drag select) if matches!( @@ -1310,9 +1309,7 @@ impl App { ratatui::crossterm::event::MouseEventKind::Up( ratatui::crossterm::event::MouseButton::Left ) - ) && !was_input_selecting - && self.input.has_selection() - { + ) { let text = self.input.get_selected_text(); if !text.is_empty() { let _ = crate::utils::clipboard::copy_text(&text); @@ -1749,9 +1746,8 @@ impl App { None => return, }; - let model = self.model.clone(); self.timeline_dialog_state - .refresh_messages(&messages, &model); + .refresh_messages(&messages); self.timeline_dialog_state.show(); self.overlay_focus = OverlayFocus::TimelineDialog; } diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 606fbb1..c9bd6c0 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -423,7 +423,7 @@ impl Dialog { line_index } - fn adjust_scroll(&mut self) { + pub fn adjust_scroll(&mut self) { let visible_rows = self.get_visible_row_count().max(1); let selected_line = self.get_line_index_of_item(self.selected_index); diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 2febbc7..237d8f9 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -323,6 +323,10 @@ impl Input { } true } + MouseEventKind::Up(MouseButton::Left) => { + // Selection finalized (cursor was moved during drag) + true + } MouseEventKind::Up(MouseButton::Right) => { // Right-click clears selection self.textarea.cancel_selection(); diff --git a/src/views/timeline_dialog.rs b/src/views/timeline_dialog.rs index 2231f48..12b0ecd 100644 --- a/src/views/timeline_dialog.rs +++ b/src/views/timeline_dialog.rs @@ -21,16 +21,13 @@ impl TimelineDialogState { Self { dialog } } - pub fn build_from_messages( - messages: &[Message], - model: &str, - ) -> Self { + pub fn build_from_messages(messages: &[Message]) -> Self { let mut state = Self::new(); - state.refresh_messages(messages, model); + state.refresh_messages(messages); state } - pub fn refresh_messages(&mut self, messages: &[Message], model: &str) { + pub fn refresh_messages(&mut self, messages: &[Message]) { let mut items: Vec = Vec::new(); for (idx, message) in messages.iter().enumerate() { @@ -51,8 +48,8 @@ impl TimelineDialogState { .find(|line| !line.trim().is_empty()) .map(|line| { let trimmed = line.trim(); - if trimmed.len() > 60 { - format!("{}…", &trimmed[..60]) + if trimmed.len() > 20 { + format!("{}...", &trimmed[..20]) } else { trimmed.to_string() } @@ -60,23 +57,7 @@ impl TimelineDialogState { .unwrap_or_else(|| "(empty)".to_string()); let name = format!("{}: {}", role_label, preview); - - let description = match message.role { - MessageRole::Assistant => { - let m = message.model.as_deref().unwrap_or(model); - if message.is_complete { - format!("{}", m) - } else { - format!("{} · streaming", m) - } - } - MessageRole::User => message - .agent_mode - .as_deref() - .unwrap_or("") - .to_string(), - _ => String::new(), - }; + let description = String::new(); let tip = { let duration = message @@ -111,6 +92,7 @@ impl TimelineDialogState { let mut dialog = Dialog::with_items("Timeline", items) .with_position(DialogPosition::Right); dialog.selected_index = last_index; + dialog.adjust_scroll(); dialog = dialog.with_actions(vec![ FooterAction { label: "Jump".to_string(), diff --git a/src/views/which_key.rs b/src/views/which_key.rs index eda5477..e628c61 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -44,11 +44,6 @@ pub struct WhichKeyState { impl WhichKeyState { pub fn new() -> Self { let bindings = vec![ - KeyBinding { - key: "g".to_string(), - description: "Open Messages Timeline dialog".to_string(), - action: WhichKeyAction::ShowTimeline, - }, KeyBinding { key: "m".to_string(), description: "Open Models dialog".to_string(), @@ -77,6 +72,11 @@ impl WhichKeyState { ]; let chat_bindings = vec![ + KeyBinding { + key: "g".to_string(), + description: "Open Messages Timeline dialog".to_string(), + action: WhichKeyAction::ShowTimeline, + }, KeyBinding { key: "k".to_string(), description: "Scroll up".to_string(), @@ -127,7 +127,7 @@ impl WhichKeyState { self.update_last_key_time(); match event.code { - KeyCode::Char('g') | KeyCode::Char('G') => { + KeyCode::Char('g') | KeyCode::Char('G') if self.is_chat_active => { self.hide(); WhichKeyAction::ShowTimeline } From 46e323410ca2945396f400dd8d02ff752f50070a Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 02:03:22 +0800 Subject: [PATCH 052/226] feat: add message actions dialog with copy/fork/undo, chat-only commands, and emoji fixes. - Add Message Actions overlay (copy, fork at point, undo) from timeline dialog - Add `/timeline` command (chat-only) with highlighted message selection - Introduce `chat_only` flag on commands to filter autocomplete by context - Fix multi-byte/emoji UTF-8 boundary panics in input cursor and word-delete - Fix `SessionManager::add_message_to_current_session` to update in-memory state - Fork now scrolls to bottom; undo truncates session + chat state --- _plans/__TODOS.md | 13 +- src/app.rs | 288 ++++++++++++++++++++++++++++++----- src/autocomplete/command.rs | 33 +++- src/autocomplete/mod.rs | 4 +- src/command/handlers.rs | 38 ++++- src/command/registry.rs | 10 ++ src/session/manager.rs | 8 +- src/ui/components/chat.rs | 33 +++- src/ui/components/input.rs | 140 +++++++++++++++-- src/views/timeline_dialog.rs | 7 +- 10 files changed, 502 insertions(+), 72 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index e395c10..06dcbc1 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -1,4 +1,4 @@ -- [ ] Rearchitect - multi-workspace, just like the codex desktop app. +- [ ] VERY VERY far future. Rearchitect - multi-workspace, just like the codex desktop app. - Since it's a terminal, we have a special case to make it run even when closed, or when there are multiple instances of the program running. They have the same sort of "streaming" state. I will elaborate. - Mutli-workspace feature is essentially having multiple "chat sessions" running. Currently.. Every run of `crabcode` is its own isolated session. - We want to change that by making `crabcode` a multi-workspace agentic TUI by default, just like the codex desktop app, superconductor, etc. But simpler because the idea is literally just like a chat app on the web. Wherein, I want to be able to check the "sessions" in the sidebar, create new chats in the same tab (in this case a tab is a run of `crabcode`). @@ -6,6 +6,7 @@ - Because we can create multiple sessions, we can swap between them because each chat session will now be isolated with their own state. No worktrees for now because that's complicated. - Since they each have their own state, that means the streaming will have their own states and when I do `/sessions` I can clearly see what's currently streaming and already done. We want to indicate "streaming" with the same icon claude uses (I had a very nice working example here /Users/carlo/Desktop/Projects/lazygitrs ) + - Because we want this isolated state. Make sure that in the UI, I can switch session focus just easily and it won't affect the rendering. Each session I go to stream seamlessly. I can show you my existing architecture for this for webapps, it's very seamless. (INSERT REFERENCE HERE) - Also the idea is, we can run create multiple "sessions" in the same run of `crabcode`. And we can even open multiple `crabcode` runs in the terminal, and it'll still have the same states for "streaming" when I check the other sessions with `/session`. - /sessions can switch between running sessions. Show a loading (use claude code loading animation), for loading sessions. Group by folders, not by Today, etc. Move the /sessions dialog to the "left". Run as a process? Allow for interruption as well. Maybe via a `/` command or a `ctrl-x` shortcut. @@ -18,3 +19,13 @@ - Also add Call it `opencode -p`. It's gonna be exactly the same as `opencode run`. - Add `--no-session-persistence` flag, exactly like Claude Code. - Other than that, very similar to the original implementation. + +- [ ] Add a `/copy` command. See opencode reference for "Copy session transcript" for a similar implementation. + +- [ ] Minor, When I 'delete' and I delete the current, go to `home` page. + +- [x] Minor, after forking. please scroll the conversation all the way down. + +- [x] Weird bug: I fork any "agent" message. Anything that has an emoji. I get: 'panicked at src/app.rs:1892:54: byte index 40 is not a char boundary; it is inside '😄' (bytes 37..41) of `Thanks! I'm glad you think I'm cool. 😄' + +- [ ] Minor, `chat_only` flag is codesmell... We better come up with strings for deciding "Only show this slash command in this context", just like how we do with 'Shortcuts' (in case shortcuts follow this codesmell as well, come up with a better approach) diff --git a/src/app.rs b/src/app.rs index 1aeb0b7..a5f433e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,6 +93,7 @@ pub enum OverlayFocus { PermissionDialog, SkillsDialog, TimelineDialog, + MessageActions, WhichKey, } @@ -131,6 +132,8 @@ pub struct App { pub skills_dialog_state: crate::views::SkillsDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, pub timeline_dialog_state: crate::views::timeline_dialog::TimelineDialogState, + pub message_actions_index: Option, + pub message_actions_dialog: Option, pub api_key_input: crate::ui::components::api_key_input::ApiKeyInput, openai_oauth_receiver: Option>, openai_oauth_in_progress: bool, @@ -226,10 +229,7 @@ impl App { ); } - crate::skill::init_skill_store( - &loaded_config.xdg_config_home, - &loaded_config.project_root, - ); + crate::skill::init_skill_store(&loaded_config.xdg_config_home, &loaded_config.project_root); crate::command::handlers::register_skill_commands(&mut registry); if let Some(default_agent) = loaded_config.merged_config.default_agent.clone() { @@ -329,6 +329,8 @@ impl App { skills_dialog_state, which_key_state, timeline_dialog_state, + message_actions_index: None, + message_actions_dialog: None, api_key_input, openai_oauth_receiver: None, openai_oauth_in_progress: false, @@ -505,7 +507,11 @@ impl App { let model = self.model.clone(); // Use a default max_width for text extraction let max_width = 80; - if let Some(text) = self.chat_state.chat.get_selected_text(max_width, &model, &colors) { + if let Some(text) = self + .chat_state + .chat + .get_selected_text(max_width, &model, &colors) + { let _ = crate::utils::clipboard::copy_text(&text); push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); } @@ -549,11 +555,11 @@ impl App { let colors = self.get_current_theme_colors(); let model = self.model.clone(); let max_width = self.last_frame_size.width.saturating_sub(4) as usize; - if let Some(text) = self.chat_state.chat.get_selected_text( - max_width.max(40), - &model, - &colors, - ) { + if let Some(text) = + self.chat_state + .chat + .get_selected_text(max_width.max(40), &model, &colors) + { if !text.trim().is_empty() { let _ = crate::utils::clipboard::copy_text(&text); push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); @@ -870,13 +876,14 @@ impl App { } } OverlayFocus::SkillsDialog => { - let action = - crate::views::skills_dialog::handle_skills_dialog_key_event( - &mut self.skills_dialog_state, - key, - ); + let action = crate::views::skills_dialog::handle_skills_dialog_key_event( + &mut self.skills_dialog_state, + key, + ); match action { - crate::views::skills_dialog::SkillsDialogAction::SelectSkill { skill_id: _ } => { + crate::views::skills_dialog::SkillsDialogAction::SelectSkill { + skill_id: _, + } => { if !self.skills_dialog_state.dialog.is_visible() { self.overlay_focus = OverlayFocus::None; } @@ -897,22 +904,45 @@ impl App { ); match action { crate::views::timeline_dialog::TimelineDialogAction::Close => { + self.chat_state.chat.clear_highlighted_message(); self.overlay_focus = OverlayFocus::None; true } crate::views::timeline_dialog::TimelineDialogAction::Select(idx) => { self.chat_state.chat.scroll_to_message_index(idx); - self.overlay_focus = OverlayFocus::None; + self.chat_state.chat.set_highlighted_message(Some(idx)); + self.show_message_actions(idx); true } crate::views::timeline_dialog::TimelineDialogAction::Navigate(idx) => { self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); true } crate::views::timeline_dialog::TimelineDialogAction::Handled => true, crate::views::timeline_dialog::TimelineDialogAction::NotHandled => false, } } + OverlayFocus::MessageActions => { + if let Some(ref mut dialog) = self.message_actions_dialog { + if key.code == KeyCode::Esc { + self.close_message_actions(); + true + } else if key.code == KeyCode::Enter { + if let Some(selected) = dialog.get_selected() { + let action_clone = selected.provider_id.clone(); + self.execute_message_action(&action_clone); + true + } else { + dialog.handle_key_event(key) + } + } else { + dialog.handle_key_event(key) + } + } else { + false + } + } OverlayFocus::WhichKey => { let action = self.which_key_state.handle_key_event(key); match action { @@ -1091,7 +1121,7 @@ impl App { fn update_suggestions(&mut self) { if self.input.should_show_suggestions() { - let suggestions = self.input.get_autocomplete_suggestions(); + let suggestions = self.input.get_autocomplete_suggestions(self.base_focus == BaseFocus::Chat); if !suggestions.is_empty() { set_suggestions(&mut self.suggestions_popup_state, suggestions); self.overlay_focus = OverlayFocus::SuggestionsPopup; @@ -1213,10 +1243,34 @@ impl App { mouse, ) { self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); } if !self.timeline_dialog_state.dialog.is_visible() { + self.chat_state.chat.clear_highlighted_message(); self.overlay_focus = OverlayFocus::None; } + } else if self.overlay_focus == OverlayFocus::MessageActions { + let maybe_action = if let Some(ref mut dialog) = self.message_actions_dialog { + let handled = dialog.handle_mouse_event(mouse); + if handled { + dialog.get_selected().map(|s| s.provider_id.clone()) + } else { + None + } + } else { + None + }; + if let Some(action) = maybe_action { + self.execute_message_action(&action); + } + if self + .message_actions_dialog + .as_ref() + .map(|d| !d.is_visible()) + .unwrap_or(false) + { + self.close_message_actions(); + } } else if self.overlay_focus == OverlayFocus::None { // If chat has a selection and user clicks outside chat area, clear it if self.chat_state.chat.has_selection() && self.base_focus == BaseFocus::Chat { @@ -1236,12 +1290,12 @@ impl App { .direction(ratatui::layout::Direction::Vertical) .constraints( [ - ratatui::layout::Constraint::Length(1), // Top padding - ratatui::layout::Constraint::Min(0), // Chat content - ratatui::layout::Constraint::Length(1), // Bottom padding + ratatui::layout::Constraint::Length(1), // Top padding + ratatui::layout::Constraint::Min(0), // Chat content + ratatui::layout::Constraint::Length(1), // Bottom padding ratatui::layout::Constraint::Length(input_height), - ratatui::layout::Constraint::Length(1), // Help bar - ratatui::layout::Constraint::Length(1), // Blank + ratatui::layout::Constraint::Length(1), // Help bar + ratatui::layout::Constraint::Length(1), // Blank ] .as_ref(), ) @@ -1274,12 +1328,12 @@ impl App { .direction(ratatui::layout::Direction::Vertical) .constraints( [ - ratatui::layout::Constraint::Length(1), // Top padding - ratatui::layout::Constraint::Min(0), // Chat content - ratatui::layout::Constraint::Length(1), // Bottom padding + ratatui::layout::Constraint::Length(1), // Top padding + ratatui::layout::Constraint::Min(0), // Chat content + ratatui::layout::Constraint::Length(1), // Bottom padding ratatui::layout::Constraint::Length(input_height), - ratatui::layout::Constraint::Length(1), // Help bar - ratatui::layout::Constraint::Length(1), // Blank + ratatui::layout::Constraint::Length(1), // Help bar + ratatui::layout::Constraint::Length(1), // Blank ] .as_ref(), ) @@ -1313,11 +1367,7 @@ impl App { let text = self.input.get_selected_text(); if !text.is_empty() { let _ = crate::utils::clipboard::copy_text(&text); - push_toast(Toast::new( - "Copied to clipboard", - ToastLevel::Info, - None, - )); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); } } self.update_suggestions(); @@ -1476,6 +1526,10 @@ impl App { self.show_skills_dialog(); return; } + if parsed.name == "timeline" && self.base_focus == BaseFocus::Chat { + self.open_timeline_dialog(); + return; + } parsed.prefs_dao = self.prefs_dao.as_ref(); parsed.active_model_id = Some(self.model.clone()); @@ -1592,6 +1646,10 @@ impl App { self.show_themes_dialog(); return; } + if parsed.name == "timeline" && self.base_focus == BaseFocus::Chat { + self.open_timeline_dialog(); + return; + } parsed.prefs_dao = self.prefs_dao.as_ref(); parsed.active_model_id = Some(self.model.clone()); @@ -1741,14 +1799,159 @@ impl App { } fn open_timeline_dialog(&mut self) { - let messages: Vec = match self.session_manager.get_current_session() { - Some(s) => s.messages.clone(), + let messages: Vec = + match self.session_manager.get_current_session() { + Some(s) => s.messages.clone(), + None => return, + }; + + self.timeline_dialog_state.refresh_messages(&messages); + self.timeline_dialog_state.show(); + self.overlay_focus = OverlayFocus::TimelineDialog; + + if let Some(selected) = self.timeline_dialog_state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::() { + self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); + } + } + } + + fn show_message_actions(&mut self, idx: usize) { + use crate::ui::components::dialog::{Dialog, DialogItem}; + + self.message_actions_index = Some(idx); + + let items = vec![ + DialogItem { + id: "copy".to_string(), + name: "Copy".to_string(), + group: String::new(), + description: "Copy message to clipboard".to_string(), + tip: None, + provider_id: "copy".to_string(), + }, + DialogItem { + id: "fork".to_string(), + name: "Fork at this point".to_string(), + group: String::new(), + description: "Create new session (Will include this message)".to_string(), + tip: None, + provider_id: "fork".to_string(), + }, + DialogItem { + id: "undo".to_string(), + name: "Undo".to_string(), + group: String::new(), + description: "Remove messages from here onward".to_string(), + tip: None, + provider_id: "undo".to_string(), + }, + ]; + + let mut dialog = Dialog::with_items("Message Actions", items); + dialog.show(); + self.message_actions_dialog = Some(dialog); + self.overlay_focus = OverlayFocus::MessageActions; + } + + fn execute_message_action(&mut self, action: &str) { + let idx = match self.message_actions_index { + Some(i) => i, None => return, }; - self.timeline_dialog_state - .refresh_messages(&messages); - self.timeline_dialog_state.show(); + match action { + "copy" => { + if let Some(session) = self.session_manager.get_current_session() { + if let Some(msg) = session.messages.get(idx) { + let _ = crate::utils::clipboard::copy_text(&msg.content); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + } + } + self.close_message_actions(); + } + "fork" => { + let messages_to_fork: Vec = { + if let Some(session) = self.session_manager.get_current_session() { + session.messages.iter().take(idx + 1).cloned().collect() + } else { + return; + } + }; + + let fork_title = messages_to_fork + .last() + .map(|msg| { + let preview = msg + .content + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or("fork"); + let truncated: String = preview.chars().take(40).collect(); + if truncated.len() < preview.len() { + format!("{}...", truncated) + } else { + truncated + } + }) + .unwrap_or_default(); + + let _ = self.session_manager.create_session(Some(fork_title)); + for msg in &messages_to_fork { + let _ = self.session_manager.add_message_to_current_session(msg); + } + + self.chat_state.chat.clear(); + self.chat_state.chat.messages = messages_to_fork; + self.chat_state.chat.scroll_offset = usize::MAX; + self.chat_state.chat.clear_highlighted_message(); + self.base_focus = BaseFocus::Chat; + + push_toast(Toast::new( + format!("Forked session from message {}", idx + 1), + ToastLevel::Info, + None, + )); + + self.close_message_actions(); + self.timeline_dialog_state.hide(); + self.overlay_focus = OverlayFocus::None; + } + "undo" => { + let remaining: Vec = { + if let Some(session) = self.session_manager.get_current_session() { + session.messages.truncate(idx); + session.messages.clone() + } else { + return; + } + }; + + self.chat_state.chat.clear(); + for msg in &remaining { + self.chat_state.chat.add_message(msg.clone()); + } + self.chat_state.chat.scroll_offset = usize::MAX; + self.chat_state.chat.clear_highlighted_message(); + + push_toast(Toast::new( + format!("Removed {} message(s)", idx), + ToastLevel::Info, + None, + )); + + self.close_message_actions(); + self.timeline_dialog_state.hide(); + self.overlay_focus = OverlayFocus::None; + } + _ => {} + } + } + + fn close_message_actions(&mut self) { + self.message_actions_index = None; + self.message_actions_dialog = None; self.overlay_focus = OverlayFocus::TimelineDialog; } @@ -2026,8 +2229,7 @@ impl App { items.sort_by(|a, b| a.id.cmp(&b.id)); - self.skills_dialog_state = - crate::views::skills_dialog::init_skills_dialog("Skills", items); + self.skills_dialog_state = crate::views::skills_dialog::init_skills_dialog("Skills", items); self.skills_dialog_state.dialog.show(); self.overlay_focus = OverlayFocus::SkillsDialog; } @@ -2871,6 +3073,12 @@ impl App { ); } + if self.overlay_focus == OverlayFocus::MessageActions { + if let Some(ref mut dialog) = self.message_actions_dialog { + dialog.render(f, size, colors); + } + } + if self.overlay_focus == OverlayFocus::SessionRenameDialog && self.session_rename_dialog_state.is_visible() { diff --git a/src/autocomplete/command.rs b/src/autocomplete/command.rs index a0c3d57..b365308 100644 --- a/src/autocomplete/command.rs +++ b/src/autocomplete/command.rs @@ -1,4 +1,5 @@ use crate::command::registry::Registry; +use std::collections::HashSet; #[derive(Clone)] pub struct Suggestion { @@ -10,6 +11,7 @@ pub struct Suggestion { pub struct CommandAuto { commands: Vec, hidden_token_map: Vec<(String, String)>, + chat_only_commands: HashSet, } impl CommandAuto { @@ -34,18 +36,29 @@ impl CommandAuto { }) .collect(); + let chat_only_commands: HashSet = registry + .list_commands() + .iter() + .filter(|cmd| cmd.chat_only) + .map(|cmd| cmd.name.clone()) + .collect(); + Self { commands, hidden_token_map, + chat_only_commands, } } - pub fn get_suggestions(&self, input: &str) -> Vec { + pub fn get_suggestions(&self, input: &str, is_chat: bool) -> Vec { let input_lower = input.to_lowercase(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut results: Vec = Vec::new(); for cmd in &self.commands { + if !is_chat && self.chat_only_commands.contains(&cmd.name) { + continue; + } if cmd.name.to_lowercase().starts_with(&input_lower) { if seen.insert(cmd.name.clone()) { results.push(cmd.clone()); @@ -54,6 +67,9 @@ impl CommandAuto { } for (token, command_name) in &self.hidden_token_map { + if !is_chat && self.chat_only_commands.contains(command_name) { + continue; + } if token.to_lowercase().starts_with(&input_lower) { if seen.insert(command_name.clone()) { if let Some(cmd) = self.commands.iter().find(|c| c.name == *command_name) { @@ -88,18 +104,21 @@ mod tests { description: "Show help".to_string(), handler: dummy_handler, hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { name: "sessions".to_string(), description: "Manage sessions".to_string(), handler: dummy_handler, hidden_tokens: vec!["resume".to_string()], + chat_only: false, }); registry.register(Command { name: "exit".to_string(), description: "Exit the app".to_string(), handler: dummy_handler, hidden_tokens: vec![], + chat_only: false, }); registry } @@ -121,7 +140,7 @@ mod tests { fn test_get_suggestions_empty() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions(""); + let suggestions = auto.get_suggestions("", true); assert_eq!(suggestions.len(), 3); } @@ -129,7 +148,7 @@ mod tests { fn test_get_suggestions_partial() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions("s"); + let suggestions = auto.get_suggestions("s", true); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].name, "sessions"); } @@ -138,7 +157,7 @@ mod tests { fn test_get_suggestions_exact() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions("help"); + let suggestions = auto.get_suggestions("help", true); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].name, "help"); } @@ -147,7 +166,7 @@ mod tests { fn test_get_suggestions_hidden_token() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions("res"); + let suggestions = auto.get_suggestions("res", true); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].name, "sessions"); } @@ -156,7 +175,7 @@ mod tests { fn test_get_suggestions_no_match() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions("xyz"); + let suggestions = auto.get_suggestions("xyz", true); assert!(suggestions.is_empty()); } @@ -164,7 +183,7 @@ mod tests { fn test_get_suggestions_case_insensitive() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions("HELP"); + let suggestions = auto.get_suggestions("HELP", true); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].name, "help"); } diff --git a/src/autocomplete/mod.rs b/src/autocomplete/mod.rs index 7ea93f9..a3aa9c6 100644 --- a/src/autocomplete/mod.rs +++ b/src/autocomplete/mod.rs @@ -24,9 +24,9 @@ impl AutoComplete { } } - pub fn get_suggestions(&self, input: &str) -> Vec { + pub fn get_suggestions(&self, input: &str, is_chat: bool) -> Vec { match &self.mode { - AutoCompleteMode::Command => self.command_auto.get_suggestions(input), + AutoCompleteMode::Command => self.command_auto.get_suggestions(input, is_chat), AutoCompleteMode::File => self .file_auto .get_suggestions(input) diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 0e766bb..c32d8f5 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -422,6 +422,23 @@ pub fn handle_themes<'a>( }) } +pub fn handle_timeline<'a>( + parsed: &'a ParsedCommand<'a>, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error( + "Usage: /timeline".to_string(), + ); + } + + CommandResult::Success(String::new()) + }) +} + pub fn handle_skills<'a>( parsed: &'a ParsedCommand<'a>, _sm: &'a mut SessionManager, @@ -465,6 +482,7 @@ pub fn register_skill_commands(registry: &mut Registry) { description: skill.description.clone().unwrap_or_default(), handler: handle_skill_command, hidden_tokens: vec![], + chat_only: false, }); } } @@ -521,6 +539,7 @@ pub fn register_all_commands(registry: &mut Registry) { description: "Quit crabcode".to_string(), handler: handle_exit, hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { @@ -528,6 +547,7 @@ pub fn register_all_commands(registry: &mut Registry) { description: "List all sessions".to_string(), handler: handle_sessions, hidden_tokens: vec!["resume".to_string()], + chat_only: false, }); registry.register(Command { @@ -535,6 +555,7 @@ pub fn register_all_commands(registry: &mut Registry) { description: "Switch to home screen".to_string(), handler: handle_new, hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { @@ -542,6 +563,7 @@ pub fn register_all_commands(registry: &mut Registry) { description: "Switch to home screen".to_string(), handler: handle_new, hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { @@ -549,6 +571,7 @@ pub fn register_all_commands(registry: &mut Registry) { description: "Connect to a model provider".to_string(), handler: handle_connect, hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { @@ -556,6 +579,7 @@ pub fn register_all_commands(registry: &mut Registry) { description: "List available models".to_string(), handler: handle_models, hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { @@ -563,6 +587,7 @@ pub fn register_all_commands(registry: &mut Registry) { description: "Choose a theme".to_string(), handler: handle_themes, hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { @@ -570,6 +595,15 @@ pub fn register_all_commands(registry: &mut Registry) { description: "Refresh the models.dev cache".to_string(), handler: handle_refreshmodels, hidden_tokens: vec![], + chat_only: false, + }); + + registry.register(Command { + name: "timeline".to_string(), + description: "Open the message timeline dialog".to_string(), + handler: handle_timeline, + hidden_tokens: vec![], + chat_only: true, }); registry.register(Command { @@ -577,6 +611,7 @@ pub fn register_all_commands(registry: &mut Registry) { description: "List available skills".to_string(), handler: handle_skills, hidden_tokens: vec![], + chat_only: false, }); } @@ -855,7 +890,7 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 9); + assert_eq!(names.len(), 10); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); @@ -864,6 +899,7 @@ mod tests { assert!(names.contains(&"themes".to_string())); assert!(names.contains(&"home".to_string())); assert!(names.contains(&"refreshmodels".to_string())); + assert!(names.contains(&"timeline".to_string())); assert!(names.contains(&"skills".to_string())); } diff --git a/src/command/registry.rs b/src/command/registry.rs index a8fec1e..5723e93 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -15,6 +15,7 @@ pub struct Command { pub description: String, pub handler: CommandHandler, pub hidden_tokens: Vec, + pub chat_only: bool, } #[derive(Debug, Clone, PartialEq)] @@ -143,6 +144,7 @@ mod tests { description: "Test command".to_string(), handler: dummy_handler, hidden_tokens: vec![], + chat_only: false, }; registry.register(command); assert_eq!(registry.commands.len(), 1); @@ -156,6 +158,7 @@ mod tests { description: "Test command".to_string(), handler: dummy_handler, hidden_tokens: vec![], + chat_only: false, }; registry.register(command.clone()); @@ -179,6 +182,7 @@ mod tests { description: "Test command".to_string(), handler: dummy_handler, hidden_tokens: vec!["alias".to_string()], + chat_only: false, }; registry.register(command); assert!(registry.get("alias").is_some()); @@ -193,6 +197,7 @@ mod tests { description: "Test command".to_string(), handler: dummy_handler, hidden_tokens: vec![], + chat_only: false, }; registry.register(command); let parsed = ParsedCommand { @@ -235,12 +240,14 @@ mod tests { description: "Test command 1".to_string(), handler: dummy_handler, hidden_tokens: vec![], + chat_only: false, }; let command2 = Command { name: "test2".to_string(), description: "Test command 2".to_string(), handler: dummy_handler, hidden_tokens: vec![], + chat_only: false, }; registry.register(command1); @@ -259,12 +266,14 @@ mod tests { description: "Test command 1".to_string(), handler: dummy_handler, hidden_tokens: vec![], + chat_only: false, }; let command2 = Command { name: "apple".to_string(), description: "Test command 2".to_string(), handler: dummy_handler, hidden_tokens: vec![], + chat_only: false, }; registry.register(command1); @@ -297,6 +306,7 @@ mod tests { description: "Test command".to_string(), handler: handler_with_args, hidden_tokens: vec![], + chat_only: false, }; registry.register(command); diff --git a/src/session/manager.rs b/src/session/manager.rs index 5804f51..1d4dadd 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -25,7 +25,7 @@ pub struct SessionInfo { } pub struct SessionManager { - sessions: HashMap, + pub sessions: HashMap, current_session_id: Option, session_counter: usize, history_dao: Option, @@ -163,6 +163,12 @@ impl SessionManager { &mut self, message: &crate::session::types::Message, ) -> Result<(), SessionError> { + if let Some(session_id) = &self.current_session_id.clone() { + if let Some(session) = self.sessions.get_mut(session_id) { + session.add_message(message.clone()); + } + } + if let (Some(session_id), Some(ref dao)) = (&self.current_session_id, &self.history_dao) { if let Some(db_id) = self.id_mapping.get(session_id) { let mut db_message: crate::persistence::Message = message.clone().into(); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index cb4e512..6962a30 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -49,6 +49,8 @@ pub struct Chat { pub message_line_positions: Vec, /// Text selection state for copy-on-select pub selection: Selection, + /// Index of the message highlighted by timeline navigation (None = no highlight) + pub highlighted_message_index: Option, } // Minimum elapsed time before showing tokens/s (250ms) @@ -94,6 +96,7 @@ impl Chat { streaming_message_idx: None, message_line_positions: Vec::new(), selection: Selection::new(), + highlighted_message_index: None, } } @@ -123,6 +126,7 @@ impl Chat { streaming_message_idx: None, message_line_positions: Vec::new(), selection: Selection::new(), + highlighted_message_index: None, } } @@ -537,6 +541,14 @@ impl Chat { self.update_scrollbar(); } + pub fn set_highlighted_message(&mut self, idx: Option) { + self.highlighted_message_index = idx; + } + + pub fn clear_highlighted_message(&mut self) { + self.highlighted_message_index = None; + } + fn update_scrollbar(&mut self) { let max_offset = self.content_height.saturating_sub(self.viewport_height); let content_length = max_offset.saturating_add(1).max(1); @@ -802,7 +814,26 @@ impl Chat { model: &'a str, colors: &'a ThemeColors, ) -> Vec> { - let lines = self.build_all_lines(max_width, model, colors); + let mut lines = self.build_all_lines(max_width, model, colors); + + if let Some(hl_idx) = self.highlighted_message_index { + if hl_idx < self.message_line_positions.len() { + let start_line = self.message_line_positions[hl_idx]; + let end_line = if hl_idx + 1 < self.message_line_positions.len() { + self.message_line_positions[hl_idx + 1] + } else { + lines.len() + }; + + let hl_bg = colors.interactive; + for line in lines.iter_mut().skip(start_line).take(end_line.saturating_sub(start_line)) { + for span in line.spans.iter_mut() { + span.style = span.style.bg(hl_bg); + } + } + } + } + crate::ui::selection::apply_selection_to_lines(lines, &self.selection, colors.accent) } diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 237d8f9..a4b00c2 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -7,6 +7,47 @@ use ratatui::crossterm::event::{ use ratatui::prelude::{Rect, Style}; use ratatui::widgets::{Block, Paragraph}; use tui_textarea::{CursorMove, Input as TuiInput, TextArea}; +use unicode_width::UnicodeWidthChar; + +/// Convert a display-column position to a byte offset within a string. +/// Handles multi-byte and wide characters (emoji, CJK, etc.) +fn display_col_to_byte_offset(line: &str, display_col: usize) -> usize { + let mut current_display = 0; + + for (byte_idx, c) in line.char_indices() { + let char_width = UnicodeWidthChar::width(c).unwrap_or(1); + if display_col < current_display + char_width { + return byte_idx; + } + current_display += char_width; + } + + line.len() +} + +/// Clamp a byte offset to the nearest valid UTF-8 character boundary in `s`. +fn char_boundary_before(s: &str, byte_idx: usize) -> usize { + let idx = byte_idx.min(s.len()); + if s.is_char_boundary(idx) { + idx + } else { + (0..idx) + .rev() + .find(|&i| s.is_char_boundary(i)) + .unwrap_or(0) + } +} + +/// Word category for word-delete logic (matching tui-textarea's CharKind). +fn char_kind(c: char) -> u8 { + if c.is_whitespace() { + 0 // Space + } else if c.is_ascii_punctuation() { + 1 // Punct + } else { + 2 // Other (includes emoji, letters, etc.) + } +} pub struct Input { textarea: TextArea<'static>, @@ -214,8 +255,10 @@ impl Input { KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => false, KeyCode::Char('u') if event.modifiers == KeyModifiers::CONTROL => { let (cursor_row, cursor_col) = self.textarea.cursor(); - if let Some(lines) = self.textarea.lines().get(cursor_row) { - let before_cursor = &lines[..cursor_col.min(lines.len())]; + if let Some(line) = self.textarea.lines().get(cursor_row) { + // Clamp to valid char boundary to avoid panics on multi-byte emoji + let safe_col = char_boundary_before(line, cursor_col); + let before_cursor = &line[..safe_col]; for _ in 0..before_cursor.chars().count() { self.textarea.delete_char(); } @@ -224,6 +267,12 @@ impl Input { } KeyCode::Tab => false, KeyCode::Esc => false, + KeyCode::Backspace if event.modifiers.contains(KeyModifiers::ALT) => { + // Handle Alt+Backspace (word-delete) ourselves to avoid + // tui-textarea's buggy word boundary with multi-byte emoji + self.delete_word_backward(); + true + } _ => { self.textarea.input(input); true @@ -291,7 +340,7 @@ impl Input { if target_row < lines.len() { let line = &lines[target_row]; - let target_col = (relative_x as usize).min(line.len()); + let target_col = display_col_to_byte_offset(line, relative_x as usize); // Position cursor and start selection for potential drag self.textarea .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); @@ -315,7 +364,7 @@ impl Input { if target_row < lines.len() { let line = &lines[target_row]; - let target_col = (relative_x as usize).min(line.len()); + let target_col = display_col_to_byte_offset(line, relative_x as usize); // Since start_selection() was called and is_selecting() is true, // move_cursor extends the selection self.textarea @@ -353,14 +402,17 @@ impl Input { if i < start_row || i > end_row { continue; } - let start = if i == start_row { start_col } else { 0 }; - let end = if i == end_row { end_col } else { line.len() }; + let start = if i == start_row { start_col.min(line.len()) } else { 0 }; + let end = if i == end_row { end_col.min(line.len()) } else { line.len() }; - let line_str: String = line.chars().skip(start).take(end.saturating_sub(start)).collect(); + if start >= end { + continue; + } + // Byte-based slicing (safe: start/end are guaranteed char boundaries) if !result.is_empty() { result.push('\n'); } - result.push_str(&line_str); + result.push_str(&line[start..end]); } result } @@ -369,6 +421,62 @@ impl Input { self.textarea.cancel_selection(); } + /// Delete the word before the cursor. Handles multi-byte emoji correctly + /// (works around a tui-textarea bug in find_word_start_backward). + fn delete_word_backward(&mut self) { + let (row, cursor_col) = self.textarea.cursor(); + let lines = self.textarea.lines(); + let line = match lines.get(row) { + Some(l) => l, + None => return, + }; + + // Find the word start by walking chars backwards from the cursor + let safe_col = char_boundary_before(line, cursor_col); + if safe_col == 0 { + // At start of line: join with previous line if possible + if row > 0 { + self.textarea.move_cursor(CursorMove::Jump(row as u16, 0)); + self.textarea.delete_char(); // deletes newline, joining lines + } + return; + } + + // Walk backwards from the cursor to find the word boundary + let prefix = &line[..safe_col]; + let chars_rev: Vec<(usize, char)> = prefix.char_indices().rev().collect(); + + if chars_rev.is_empty() { + return; + } + + // Determine the category of the character just before the cursor + let (_, first_char) = chars_rev[0]; + let first_kind = char_kind(first_char); + + // Scan backward to find where the word starts + let mut word_start = safe_col; + for (byte_idx, c) in chars_rev.iter().skip(1) { + let kind = char_kind(*c); + if kind != first_kind { + // Boundary found at the byte after this character + word_start = byte_idx + c.len_utf8(); + break; + } + word_start = *byte_idx; + } + + // Delete from word_start to safe_col + if word_start < safe_col { + let char_count = line[word_start..safe_col].chars().count(); + self.textarea + .move_cursor(CursorMove::Jump(row as u16, safe_col as u16)); + for _ in 0..char_count { + self.textarea.delete_char(); + } + } + } + pub fn should_show_suggestions(&self) -> bool { let text = self.get_text(); !text.is_empty() && text.starts_with('/') @@ -379,8 +487,8 @@ impl Input { text.trim_end() == "/" } - pub fn complete_selection(&mut self) { - if let Some(selected) = self.get_autocomplete_selection() { + pub fn complete_selection(&mut self, is_chat: bool) { + if let Some(selected) = self.get_autocomplete_selection(is_chat) { let current_text = self.get_text(); let start_index = current_text.rfind('/').map_or(0, |i| i + 1); @@ -394,14 +502,14 @@ impl Input { } } - pub fn get_autocomplete_selection(&self) -> Option { + pub fn get_autocomplete_selection(&self, is_chat: bool) -> Option { if let Some(autocomplete) = &self.autocomplete { let text = self.get_text(); let suggestions = if text.starts_with('/') { let filter = text.trim_start_matches('/'); - autocomplete.get_suggestions(filter) + autocomplete.get_suggestions(filter, is_chat) } else { - autocomplete.get_suggestions(&text) + autocomplete.get_suggestions(&text, is_chat) }; if !suggestions.is_empty() { return Some(suggestions[0].name.clone()); @@ -470,14 +578,14 @@ impl Input { self.textarea.insert_str(text); } - pub fn get_autocomplete_suggestions(&self) -> Vec { + pub fn get_autocomplete_suggestions(&self, is_chat: bool) -> Vec { if let Some(autocomplete) = &self.autocomplete { let text = self.get_text(); if text.starts_with('/') { let filter = text.trim_start_matches('/'); - return autocomplete.get_suggestions(filter); + return autocomplete.get_suggestions(filter, is_chat); } else { - return autocomplete.get_suggestions(&text); + return autocomplete.get_suggestions(&text, is_chat); } } Vec::new() diff --git a/src/views/timeline_dialog.rs b/src/views/timeline_dialog.rs index 12b0ecd..2c974b7 100644 --- a/src/views/timeline_dialog.rs +++ b/src/views/timeline_dialog.rs @@ -48,10 +48,11 @@ impl TimelineDialogState { .find(|line| !line.trim().is_empty()) .map(|line| { let trimmed = line.trim(); - if trimmed.len() > 20 { - format!("{}...", &trimmed[..20]) + let truncated: String = trimmed.chars().take(20).collect(); + if truncated.len() < trimmed.len() { + format!("{}...", truncated) } else { - trimmed.to_string() + truncated } }) .unwrap_or_else(|| "(empty)".to_string()); From cf4c6dcb80ada389f77ee139a5759d170c93401b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 02:04:49 +0800 Subject: [PATCH 053/226] chore: added opencode ai plugin. --- .opencode/package-lock.json | 376 ++++++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 .opencode/package-lock.json diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 0000000..938126d --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,376 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.14.41" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.14.41", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.41.tgz", + "integrity": "sha512-Q/QdDKSfHyYX+Xqd79o4XgyZKqF8h5qgqgfmOQbKVLhbduc9zMYdpV2yvWT6gaJPrpOftpka/kpr56PCqzetYQ==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.14.41", + "effect": "4.0.0-beta.59", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.41", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.41.tgz", + "integrity": "sha512-RYb2dCUv0TWIvBNnnO6ANbAPYri6rKuWizSoVFw/Pw+SCDj9ASHM5gAZ+jkskp8gYMfLLHe/Fpkun/9mr8m0IQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.59", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz", + "integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} From 3c7e456f663d6de7f831cb90fefad799b879f9b9 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 02:34:23 +0800 Subject: [PATCH 054/226] feat: add print mode, session cost tracking, mascot animation, and transcript copy. - Add `--print`/`-p` flag for non-interactive LLM streaming to stdout - Display estimated session cost in status bar using models.dev pricing - Animate home screen mascot with multi-phase tick system - Add `/copy` command to copy session transcript as formatted markdown - Implement two-step delete confirmation in sessions dialog - Expose `parse_model_ref` as `pub` for reuse in print mode --- mascot.txt | 4 + src/app.rs | 150 ++++++++++++++++++++++++++++++++++- src/command/handlers.rs | 17 +++- src/main.rs | 136 +++++++++++++++++++++++++++++++ src/model/discovery.rs | 24 ++++++ src/ui/components/chat.rs | 4 +- src/ui/components/dialog.rs | 13 ++- src/views/chat.rs | 36 +++++++-- src/views/home.rs | 74 +++++++++++++++-- src/views/sessions_dialog.rs | 36 ++++++++- 10 files changed, 469 insertions(+), 25 deletions(-) diff --git a/mascot.txt b/mascot.txt index 4955fb3..d074696 100644 --- a/mascot.txt +++ b/mascot.txt @@ -1,3 +1,7 @@ ▃▃▛████▜▃▃ █▟▟▜████████▛▙▙█ ▞ ▘ ▝ ▚ + + ▃▃▛████▜▃▃ +█▙▟▜████████▛▙▟█ + ▞ ▘ ▝ ▚ diff --git a/src/app.rs b/src/app.rs index a5f433e..107e656 100644 --- a/src/app.rs +++ b/src/app.rs @@ -61,7 +61,7 @@ use crate::{ use anyhow::Result; -fn parse_model_ref(model: &str) -> (String, String) { +pub fn parse_model_ref(model: &str) -> (String, String) { let model = model.trim(); if let Some((provider_id, model_id)) = model.split_once('/') { let provider_id = provider_id.trim(); @@ -165,6 +165,7 @@ pub struct App { streaming_chat_len_before_assistant: usize, tool_call_message_indices: std::collections::HashMap, tool_call_order: Vec, + discovery: Option, } impl App { @@ -307,6 +308,8 @@ impl App { let tool_permissions = crate::tools::ToolPermissions::new(cwd_path.clone()) .with_agent_policies(agent_policies); + let discovery = crate::model::discovery::Discovery::new().ok(); + Ok(Self { running: true, version: env!("CARGO_PKG_VERSION").to_string(), @@ -363,6 +366,7 @@ impl App { streaming_chat_len_before_assistant: 0, tool_call_message_indices: std::collections::HashMap::new(), tool_call_order: Vec::new(), + discovery, }) } @@ -444,8 +448,42 @@ impl App { format!("Ask anything... \"{}\"", suggestions[index]) } - pub fn quit(&mut self) { - self.running = false; + fn session_usage_text(&self) -> String { + let total_tokens: usize = self + .chat_state + .chat + .messages + .iter() + .filter_map(|m| m.token_count) + .sum(); + + if total_tokens == 0 { + return String::new(); + } + + let token_text = format_token_count(total_tokens); + + if let Some(ref discovery) = self.discovery { + if let Some(cost) = discovery.get_model_pricing( + &self.provider_name.to_lowercase(), + &self.model, + ) { + let output_tokens: usize = self + .chat_state + .chat + .messages + .iter() + .filter_map(|m| m.output_tokens) + .sum(); + let total = (output_tokens.max(total_tokens)) as f64; + let price = total / 1_000_000.0 * cost.output; + if price > 0.001 { + return format!("{} \u{00b7} ${:.2}", token_text, price); + } + } + } + + token_text } pub fn get_current_theme_colors(&self) -> theme::ThemeColors { @@ -802,6 +840,11 @@ impl App { } false } + SessionsDialogAction::PendingDelete(_id) => { + self.sessions_dialog_state.dialog.pending_delete_id = + Some(_id.clone()); + true + } SessionsDialogAction::Select(id) => { self.session_manager.switch_session(&id); if let Some(session) = self.session_manager.get_session(&id) { @@ -816,13 +859,28 @@ impl App { true } SessionsDialogAction::Delete(id) => { + let was_current = self + .session_manager + .get_current_session_id() + .map_or(false, |current| *current == id); self.session_manager.delete_session(&id); if let Some(pending) = crate::views::sessions_dialog::get_pending_delete( &mut self.sessions_dialog_state, ) { self.session_manager.delete_session(&pending); } + let remaining = self.session_manager.list_sessions(); + if remaining.is_empty() { + self.sessions_dialog_state.dialog.hide(); + self.overlay_focus = OverlayFocus::None; + } self.refresh_sessions_dialog(); + if was_current { + self.chat_state.chat.clear(); + self.base_focus = BaseFocus::Home; + self.sessions_dialog_state.dialog.hide(); + self.overlay_focus = OverlayFocus::None; + } true } SessionsDialogAction::Rename(id, title) => { @@ -1518,7 +1576,69 @@ impl App { match parse_input(input) { InputType::Command(mut parsed) => { - if parsed.name == "themes" { + if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { + let messages = &self.chat_state.chat.messages; + let session_title = self + .session_manager + .get_current_session() + .map(|s| s.title.clone()) + .unwrap_or_else(|| "Untitled".to_string()); + let mut transcript = format!("# {}\n\n", session_title); + for msg in messages { + match msg.role { + crate::session::types::MessageRole::User => { + transcript.push_str("## User\n\n"); + transcript.push_str(&msg.content); + transcript.push_str("\n\n---\n\n"); + } + crate::session::types::MessageRole::Assistant => { + let agent = msg.agent_mode.as_ref().map_or("Build", |a| a.as_str()); + let model = msg.model.as_deref().unwrap_or("unknown"); + let duration = msg + .duration_ms + .map(|ms| format!(" · {:.1}s", ms as f64 / 1000.0)) + .unwrap_or_default(); + transcript.push_str(&format!( + "## Assistant ({agent} · {model}{duration})\n\n" + )); + transcript.push_str(&msg.content); + transcript.push_str("\n\n---\n\n"); + } + crate::session::types::MessageRole::Tool => { + transcript.push_str("**Tool Result**\n\n"); + if let Ok(v) = serde_json::from_str::(&msg.content) { + if let Some(name) = v.get("name").and_then(|n| n.as_str()) { + transcript.push_str(&format!("**Tool:** {}\n", name)); + } + if let Some(preview) = v.get("output_preview").and_then(|p| p.as_str()) + { + transcript.push_str(&format!("```\n{}\n```\n", preview)); + } + } + transcript.push_str("\n---\n\n"); + } + _ => {} + } + } + match crate::utils::clipboard::copy_text(&transcript) { + Ok(_) => { + push_toast(Toast::new( + "Session transcript copied to clipboard!", + ToastLevel::Info, + None, + )); + } + Err(e) => { + push_toast(Toast::new( + format!("Failed to copy: {}", e), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + } + return; + } + if parsed.name == "themes" { self.show_themes_dialog(); return; } @@ -1949,6 +2069,10 @@ impl App { } } + fn quit(&mut self) { + self.running = false; + } + fn close_message_actions(&mut self) { self.message_actions_index = None; self.message_actions_dialog = None; @@ -2549,6 +2673,7 @@ impl App { if self.last_animation_update.elapsed() >= ANIMATION_INTERVAL { self.chat_state.wave_spinner.update(); + self.home_state.tick(); self.last_animation_update = std::time::Instant::now(); } } @@ -2930,11 +3055,14 @@ impl App { self.last_frame_size = size; let colors = self.get_current_theme_colors(); + let usage_text = self.session_usage_text(); + match self.base_focus { BaseFocus::Home => { render_home( f, &mut self.input, + &self.home_state, self.version.clone(), self.cwd.clone(), git::get_current_branch(), @@ -2942,6 +3070,7 @@ impl App { self.model.clone(), self.provider_name.clone(), &colors, + &usage_text, ); if is_suggestions_visible(&self.suggestions_popup_state) @@ -2985,6 +3114,7 @@ impl App { self.provider_name.clone(), &colors, self.is_streaming, + &usage_text, ); if is_suggestions_visible(&self.suggestions_popup_state) @@ -3099,6 +3229,18 @@ impl App { } } +fn format_token_count(count: usize) -> String { + if count < 1000 { + return count.to_string(); + } + if count < 1_000_000 { + let k = count as f64 / 1000.0; + return format!("{:.1}k", k); + } + let m = count as f64 / 1_000_000.0; + format!("{:.1}M", m) +} + impl Default for App { fn default() -> Self { Self::new().expect("Failed to initialize App") diff --git a/src/command/handlers.rs b/src/command/handlers.rs index c32d8f5..96cfe1b 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -488,6 +488,13 @@ pub fn register_skill_commands(registry: &mut Registry) { } } +pub fn handle_copy<'a>( + _parsed: &'a ParsedCommand<'a>, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + Box::pin(async move { CommandResult::Success("copy".to_string()) }) +} + pub fn handle_refreshmodels<'a>( _parsed: &'a ParsedCommand<'a>, _sm: &'a mut SessionManager, @@ -590,6 +597,14 @@ pub fn register_all_commands(registry: &mut Registry) { chat_only: false, }); + registry.register(Command { + name: "copy".to_string(), + description: "Copy session transcript to clipboard".to_string(), + handler: handle_copy, + hidden_tokens: vec![], + chat_only: true, + }); + registry.register(Command { name: "refreshmodels".to_string(), description: "Refresh the models.dev cache".to_string(), @@ -890,7 +905,7 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 10); + assert_eq!(names.len(), 11); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); diff --git a/src/main.rs b/src/main.rs index 0433175..35ea676 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,6 +89,119 @@ fn format_post_close_message(info: Option<&PostCloseInfo>) -> String { msg } +async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<()> { + use crate::llm::client::stream_llm_with_cancellation; + use crate::session::types::Message; + use tokio::sync::mpsc; + + // Load config and model preferences + let loaded_config = crate::config::ConfigLoader::load()?; + let prefs_dao = crate::persistence::PrefsDAO::new().ok(); + + let (provider_name, model_id) = { + let active = prefs_dao.as_ref().and_then(|d| d.get_active_model().ok().flatten()); + if let Some((pid, mid)) = active { + (pid, mid) + } else if let Some(m) = loaded_config.merged_config.model.clone() { + let (pid, mid) = crate::app::parse_model_ref(&m); + (pid, mid) + } else { + ("opencode".to_string(), "big-pickle".to_string()) + } + }; + + let agent_mode = loaded_config + .merged_config + .default_agent + .clone() + .unwrap_or_else(|| "Build".to_string()); + + let cwd = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .to_string_lossy() + .to_string(); + + let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); + + // Build messages with system prompt + let composer = crate::prompt::SystemPromptComposer::new( + &model_id, + &cwd, + is_git_repo, + std::env::consts::OS, + ); + let system_prompt = composer.compose().await; + let messages = vec![ + Message::system(system_prompt), + Message::user(prompt), + ]; + + let (sender, mut receiver) = mpsc::unbounded_channel(); + + let tool_permissions = crate::tools::ToolPermissions::new( + std::path::PathBuf::from(&cwd), + ); + + let agent_max_steps = loaded_config + .merged_config + .agent_steps + .get(&agent_mode.to_ascii_lowercase()) + .copied(); + + let provider_name_clone = provider_name.clone(); + let model_clone = model_id.clone(); + + tokio::spawn(async move { + let cancel_token = tokio_util::sync::CancellationToken::new(); + let _ = stream_llm_with_cancellation( + cancel_token, + provider_name_clone, + model_clone, + agent_mode.clone(), + agent_max_steps, + tool_permissions, + messages, + sender, + ) + .await; + }); + + while let Some(chunk) = receiver.recv().await { + match chunk { + crate::llm::ChunkMessage::Text(text) => { + print!("{}", text); + use std::io::Write; + let _ = std::io::stdout().flush(); + } + crate::llm::ChunkMessage::ToolCalls(calls) => { + println!(); + for call in &calls { + println!(" 🔧 {}", call.function.name); + } + } + crate::llm::ChunkMessage::ToolResult(result) => { + println!(" ✓ {}", result.name); + } + crate::llm::ChunkMessage::End => { + println!(); + break; + } + crate::llm::ChunkMessage::Failed(error) => { + eprintln!("\nError: {}", error); + break; + } + crate::llm::ChunkMessage::Warning(warning) => { + eprintln!("Warning: {}", warning); + } + _ => {} + } + } + + flush_startup_diagnostics(); + let _ = no_session_persistence; + Ok(()) +} + lazy_static::lazy_static! { static ref TOAST_MANAGER: Mutex = Mutex::new(ToastManager::new()); } @@ -111,11 +224,34 @@ struct Args { /// Resume a session by ID #[arg(short = 's', long = "session")] session: Option, + + /// Run in print mode (non-interactive, streams output to stdout) + #[arg(short = 'p', long = "print")] + print_mode: bool, + + /// Do not persist session data to disk + #[arg(long = "no-session-persistence")] + no_session_persistence: bool, + + /// The prompt to run (positional, used in print mode) + prompt: Vec, } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); + + if args.print_mode { + let prompt = args.prompt.join(" "); + if prompt.trim().is_empty() { + flush_startup_diagnostics(); + eprintln!("Error: No prompt provided for print mode."); + eprintln!("Usage: crabcode -p \"\""); + std::process::exit(1); + } + return run_print_mode(&prompt, args.no_session_persistence).await; + } + let mut app = App::new()?; if let Some(ref session_id) = args.session { diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 4d02a58..fcc0b45 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -292,6 +292,30 @@ impl Discovery { Ok(models) } + pub fn get_model_pricing(&self, provider_id: &str, model_id: &str) -> Option { + let cache_path = self.get_cache_path(); + if !cache_path.exists() { + return None; + } + let cached_json = std::fs::read_to_string(cache_path).ok()?; + let entry: CacheEntry = serde_json::from_str(&cached_json).ok()?; + let provider = entry.data.get(provider_id)?; + let model = provider.models.get(model_id)?; + model.cost.clone() + } + + pub fn get_model_limit(&self, provider_id: &str, model_id: &str) -> Option { + let cache_path = self.get_cache_path(); + if !cache_path.exists() { + return None; + } + let cached_json = std::fs::read_to_string(cache_path).ok()?; + let entry: CacheEntry = serde_json::from_str(&cached_json).ok()?; + let provider = entry.data.get(provider_id)?; + let model = provider.models.get(model_id)?; + model.limit.as_ref().map(|l| l.context) + } + pub async fn list_models(&self, provider_filter: Option<&str>) -> Result { let models = self.fetch_models().await?; diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 6962a30..5607fda 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -764,9 +764,7 @@ impl Chat { f.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::VerticalRight) - .track_symbol(Some(" ")) - .begin_symbol(Some(" ")) - .end_symbol(Some(" ")) + .track_symbol(Some("│")) .thumb_symbol("█"), scrollbar_area, &mut self.scrollbar_state, diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index c9bd6c0..005b51a 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -72,6 +72,7 @@ pub struct Dialog { pub visible_row_count: usize, pub actions: Vec, pub position: DialogPosition, + pub pending_delete_id: Option, matcher: Matcher, } @@ -102,6 +103,7 @@ impl Dialog { visible_row_count: 0, actions: Vec::new(), position: DialogPosition::Center, + pending_delete_id: None, matcher: Matcher::new(Config::DEFAULT), } } @@ -825,6 +827,7 @@ impl Dialog { for item in items { let is_selected = item_index == self.selected_index; + let is_pending_delete = self.pending_delete_id.as_ref() == Some(&item.id); let has_description = !item.description.is_empty(); let mut spans: Vec = if let Some(tip) = &item.tip { @@ -899,6 +902,13 @@ impl Dialog { style = style.fg(fg).bg(colors.primary); span.style = style; } + } else if is_pending_delete { + let fg = contrast_text(colors.error); + for span in &mut spans { + let mut style = span.style.clone(); + style = style.fg(fg).bg(colors.error); + span.style = style; + } } content_lines.push(Line::from(spans)); @@ -924,8 +934,6 @@ impl Dialog { let scrollbar_area = chunks[3]; frame.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")) .track_symbol(Some(" ")), scrollbar_area, &mut self.scrollbar_state, @@ -989,6 +997,7 @@ impl Clone for Dialog { visible_row_count: self.visible_row_count, actions: self.actions.clone(), position: self.position, + pending_delete_id: self.pending_delete_id.clone(), matcher: Matcher::new(Config::DEFAULT), } } diff --git a/src/views/chat.rs b/src/views/chat.rs index fc1f123..38ddf57 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -56,6 +56,7 @@ pub fn render_chat( provider_name: String, colors: &ThemeColors, is_streaming: bool, + usage_text: &str, ) { let size = f.area(); @@ -107,23 +108,34 @@ pub fn render_chat( let available_width = above_status_chunks[4].width; let help_width = help_width.min(available_width); + let usage_width = if !usage_text.is_empty() { + (usage_text.len() as u16 + 2).min(available_width.saturating_sub(help_width)) + } else { + 0 + }; + let middle_width = if usage_width > 0 { + available_width.saturating_sub(help_width + usage_width) + } else { + available_width.saturating_sub(help_width) + }; + let status_chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(0), Constraint::Length(help_width)]) + .constraints([ + Constraint::Min(0), + Constraint::Length(usage_width), + Constraint::Length(help_width), + ]) .split(above_status_chunks[4]); if is_streaming { - // Update spinner color based on current agent (only if changed) let agent_color = crate::theme::agent_color(&agent, colors); chat_state.wave_spinner.set_color(agent_color); - // Animation update is now handled in the main event loop at a fixed rate - // to prevent speed issues when mouse movement causes frequent redraws let mut streaming_text = chat_state.wave_spinner.spans(); let tps = chat_state.chat.get_streaming_tokens_per_sec(); - // Add tokens/second if available if let Some(tps) = tps { streaming_text.push(Span::raw(" ")); streaming_text.push(Span::styled( @@ -133,7 +145,7 @@ pub fn render_chat( } if let Some(elapsed) = chat_state.chat.get_streaming_elapsed_seconds() { - streaming_text.push(Span::raw(if tps.is_some() { " • " } else { " " })); + streaming_text.push(Span::raw(if tps.is_some() { " · " } else { " " })); streaming_text.push(Span::styled( format!("{:.1}s", elapsed), Style::default().fg(colors.info), @@ -152,8 +164,18 @@ pub fn render_chat( f.render_widget(streaming_paragraph, status_chunks[0]); } + if !usage_text.is_empty() { + let usage = Paragraph::new(Line::from(vec![Span::styled( + usage_text, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])); + f.render_widget(usage, status_chunks[1]); + } + let help = Paragraph::new(help_line).alignment(Alignment::Right); - f.render_widget(help, status_chunks[1]); + f.render_widget(help, status_chunks[2]); let blank = Block::default(); f.render_widget(blank, above_status_chunks[5]); diff --git a/src/views/home.rs b/src/views/home.rs index fe37ef2..9e7d83c 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -16,18 +16,46 @@ const LOGO: &str = r#" ▀████ ██ ██ ██▀██ ██▄█▀ ▀████ ▀███▀ ████▀ ██▄▄▄ "#; -const MASCO: &str = r#" +const MASCO: [&str; 2] = [ + r#" ▃▃▛████▜▃▃ █▟▟▜████████▛▙▙█ ▞ ▘ ▝ ▚ -"#; +"#, + r#" + ▃▃▛████▜▃▃ + █▙▟▜████████▛▙▟█ + ▞ ▘ ▝ ▚ +"#, +]; #[derive(Debug, Clone)] -pub struct HomeState; +pub struct HomeState { + phase: u8, + tick_count: u32, +} + +const PHASE_DURATIONS: [u32; 5] = [20, 10, 10, 10, 20]; +const PHASE_FRAMES: [usize; 5] = [0, 1, 0, 1, 0]; impl HomeState { pub fn new() -> Self { - Self + Self { + phase: 0, + tick_count: 0, + } + } + + pub fn tick(&mut self) { + self.tick_count += 1; + if self.tick_count >= PHASE_DURATIONS[self.phase as usize] { + self.tick_count = 0; + self.phase = (self.phase + 1) % PHASE_DURATIONS.len() as u8; + } + } + + pub fn frame(&self) -> usize { + PHASE_FRAMES[self.phase as usize] } } @@ -38,6 +66,7 @@ pub fn init_home() -> HomeState { pub fn render_home( f: &mut Frame, input: &mut Input, + home_state: &HomeState, version: String, cwd: String, branch: Option, @@ -45,6 +74,7 @@ pub fn render_home( model: String, provider_name: String, colors: &ThemeColors, + usage_text: &str, ) { let size = f.area(); @@ -86,7 +116,7 @@ pub fn render_home( ]) .split(logo_chunks[1]); - let mascot_lines: Vec = MASCO + let mascot_lines: Vec = MASCO[home_state.frame()] .lines() .filter(|l| !l.is_empty()) .map(|line| { @@ -134,8 +164,38 @@ pub fn render_home( Span::styled("ctrl+cc", Style::default().fg(colors.info)), Span::raw(" quit "), ]; - let help = Paragraph::new(Line::from(help_text)).alignment(Alignment::Right); - f.render_widget(help, home_chunks[2]); + let help_line = Line::from(help_text); + let help_width = help_line.width() as u16; + let available_width = home_chunks[2].width; + let help_width = help_width.min(available_width); + + let usage_width = if !usage_text.is_empty() { + (usage_text.len() as u16 + 2).min(available_width.saturating_sub(help_width)) + } else { + 0 + }; + + let status_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(usage_width), + Constraint::Min(0), + Constraint::Length(help_width), + ]) + .split(home_chunks[2]); + + if !usage_text.is_empty() { + let usage = Paragraph::new(Line::from(vec![Span::styled( + usage_text, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])); + f.render_widget(usage, status_chunks[0]); + } + + let help = Paragraph::new(help_line).alignment(Alignment::Right); + f.render_widget(help, status_chunks[2]); let blank = Block::default(); f.render_widget(blank, home_chunks[3]); diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index 8207171..a3e46f6 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -76,6 +76,30 @@ pub fn render_sessions_dialog( area: Rect, colors: ThemeColors, ) { + dialog_state.dialog.pending_delete_id = dialog_state.pending_delete.clone(); + if dialog_state.pending_delete.is_some() { + let existing_actions = dialog_state.dialog.actions.clone(); + let has_confirm = existing_actions.iter().any(|a| a.label == "confirm"); + if !has_confirm { + dialog_state.dialog.actions = vec![ + crate::ui::components::dialog::DialogAction { + label: "confirm".to_string(), + key: "ctrl+d".to_string(), + }, + ]; + } + } else { + dialog_state.dialog.actions = vec![ + crate::ui::components::dialog::DialogAction { + label: "Delete".to_string(), + key: "ctrl+d".to_string(), + }, + crate::ui::components::dialog::DialogAction { + label: "Rename".to_string(), + key: "ctrl+r".to_string(), + }, + ]; + } dialog_state.dialog.render(f, area, colors); } @@ -87,8 +111,12 @@ pub fn handle_sessions_dialog_key_event( if event.code == KeyCode::Char('d') && event.modifiers == KeyModifiers::CONTROL { if let Some(selected) = dialog_state.dialog.get_selected() { + if dialog_state.pending_delete.as_ref() == Some(&selected.id) { + dialog_state.pending_delete = None; + return SessionsDialogAction::Delete(selected.id.clone()); + } dialog_state.pending_delete = Some(selected.id.clone()); - return SessionsDialogAction::Delete(selected.id.clone()); + return SessionsDialogAction::PendingDelete(selected.id.clone()); } } @@ -100,6 +128,11 @@ pub fn handle_sessions_dialog_key_event( let handled = dialog_state.dialog.handle_key_event(event); + // Clear pending delete when user navigates away + if matches!(event.code, KeyCode::Up | KeyCode::Down | KeyCode::Esc) { + dialog_state.pending_delete = None; + } + if was_visible && !dialog_state.dialog.is_visible() { return SessionsDialogAction::Close; } @@ -135,5 +168,6 @@ pub enum SessionsDialogAction { Close, Select(String), Delete(String), + PendingDelete(String), Rename(String, String), } From 98260df78efbbb7b4ba760d61e8394f9d57e2019 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 02:48:59 +0800 Subject: [PATCH 055/226] feat: added /rename command. --- _plans/__TODOS.md | 18 +++++++++++++----- src/app.rs | 24 ++++++++++++++++++++++++ src/command/handlers.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 06dcbc1..84d227c 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -10,22 +10,30 @@ - Also the idea is, we can run create multiple "sessions" in the same run of `crabcode`. And we can even open multiple `crabcode` runs in the terminal, and it'll still have the same states for "streaming" when I check the other sessions with `/session`. - /sessions can switch between running sessions. Show a loading (use claude code loading animation), for loading sessions. Group by folders, not by Today, etc. Move the /sessions dialog to the "left". Run as a process? Allow for interruption as well. Maybe via a `/` command or a `ctrl-x` shortcut. -- [ ] Just like opencode. I want to see the `94.4.k (9%) ∙ $0.39` detail just next to the helpful tips under the input box. Use the same data sources. +- [x] Just like opencode. I want to see the `94.4.k (9%) ∙ $0.39` detail just next to the helpful tips under the input box. Use the same data sources. -- [ ] Scrollbar, make it like opencode. As thin as opencode. That's the only change I want really. +- [x] Scrollbar, make it like opencode. As thin as opencode. That's the only change I want really. -- [ ] Add print-mode just like `opencode run ""`. See the reference. But two things I want to deviate from the original implementation: +- [x] Add print-mode just like `opencode run ""`. See the reference. But two things I want to deviate from the original implementation: - The preamble, just print whatever is printed, that's IT! - Also add Call it `opencode -p`. It's gonna be exactly the same as `opencode run`. - Add `--no-session-persistence` flag, exactly like Claude Code. - Other than that, very similar to the original implementation. -- [ ] Add a `/copy` command. See opencode reference for "Copy session transcript" for a similar implementation. +- [x] Add a `/copy` command. See opencode reference for "Copy session transcript" for a similar implementation. -- [ ] Minor, When I 'delete' and I delete the current, go to `home` page. +- [x] Minor, When I 'delete' and I delete the current, go to `home` page. - [x] Minor, after forking. please scroll the conversation all the way down. - [x] Weird bug: I fork any "agent" message. Anything that has an emoji. I get: 'panicked at src/app.rs:1892:54: byte index 40 is not a char boundary; it is inside '😄' (bytes 37..41) of `Thanks! I'm glad you think I'm cool. 😄' - [ ] Minor, `chat_only` flag is codesmell... We better come up with strings for deciding "Only show this slash command in this context", just like how we do with 'Shortcuts' (in case shortcuts follow this codesmell as well, come up with a better approach) + +- [ ] Chore: Create a /checkparity-opencode (the most important thing is only the agent-loop, nothing else. We do differ a bit in terms of UX anyway, but the agent-loop, tool calling, etc has to be very very close so that the performance is mostly the same) and /checkparity-codex (au) command + +- [ ] Feature: Subagents just like opencode. + +- [x] Feature: Rename command `/rename` - parity with opencode. + +- [ ] Bug: theme is not persisted? Or is it by config? Just make theme be based on state now, no more config for it. diff --git a/src/app.rs b/src/app.rs index 107e656..6f86e3d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1646,6 +1646,18 @@ impl App { self.show_skills_dialog(); return; } + if parsed.name == "rename" && parsed.args.is_empty() && self.base_focus == BaseFocus::Chat { + if let Some(session) = self.session_manager.get_current_session() { + let id = session.id.clone(); + let title = session.title.clone(); + drop(session); + self.session_rename_dialog_state + .set_colors(self.get_current_theme_colors()); + self.session_rename_dialog_state.show(id, title); + self.overlay_focus = OverlayFocus::SessionRenameDialog; + } + return; + } if parsed.name == "timeline" && self.base_focus == BaseFocus::Chat { self.open_timeline_dialog(); return; @@ -1766,6 +1778,18 @@ impl App { self.show_themes_dialog(); return; } + if parsed.name == "rename" && parsed.args.is_empty() && self.base_focus == BaseFocus::Chat { + if let Some(session) = self.session_manager.get_current_session() { + let id = session.id.clone(); + let title = session.title.clone(); + drop(session); + self.session_rename_dialog_state + .set_colors(self.get_current_theme_colors()); + self.session_rename_dialog_state.show(id, title); + self.overlay_focus = OverlayFocus::SessionRenameDialog; + } + return; + } if parsed.name == "timeline" && self.base_focus == BaseFocus::Chat { self.open_timeline_dialog(); return; diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 96cfe1b..da31590 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -488,6 +488,28 @@ pub fn register_skill_commands(registry: &mut Registry) { } } +pub fn handle_rename<'a>( + parsed: &'a ParsedCommand<'a>, + sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let session_id = sm.get_current_session_id().cloned(); + let new_title = if parsed.args.is_empty() { + None + } else { + Some(parsed.args.join(" ")) + }; + + Box::pin(async move { + let (Some(sid), Some(title)) = (session_id, new_title) else { + return CommandResult::Error("Usage: /rename ".to_string()); + }; + match sm.rename_session(&sid, title) { + Ok(_) => CommandResult::Success(String::new()), + Err(e) => CommandResult::Error(format!("Failed to rename: {:?}", e)), + } + }) +} + pub fn handle_copy<'a>( _parsed: &'a ParsedCommand<'a>, _sm: &'a mut SessionManager, @@ -597,6 +619,14 @@ pub fn register_all_commands(registry: &mut Registry) { chat_only: false, }); + registry.register(Command { + name: "rename".to_string(), + description: "Rename the current session".to_string(), + handler: handle_rename, + hidden_tokens: vec![], + chat_only: true, + }); + registry.register(Command { name: "copy".to_string(), description: "Copy session transcript to clipboard".to_string(), From 42a287f81f4729dd37e9f9209e1b28af69e846e1 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 03:48:38 +0800 Subject: [PATCH 056/226] fix(pref): cache chat rendering and adapt event loop poll rate to reduce idle CPU usage. - Cache formatted chat lines with fingerprint-based invalidation to avoid re-formatting unchanged messages on every frame - Introduce adaptive poll durations: 16ms during animations vs 250ms when idle, cutting unnecessary re-renders - Cache session usage text to skip recomputation each frame - Fix unicode width calculation in selection rendering (sum span widths instead of concatenating text) --- src/app.rs | 23 ++++- src/main.rs | 20 ++-- src/ui/components/chat.rs | 195 ++++++++++++++++++++++---------------- src/ui/selection.rs | 7 +- 4 files changed, 155 insertions(+), 90 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6f86e3d..b67fe2f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -166,6 +166,8 @@ pub struct App { tool_call_message_indices: std::collections::HashMap, tool_call_order: Vec, discovery: Option, + cached_usage_text: String, + cached_usage_check: (usize, usize), } impl App { @@ -367,6 +369,8 @@ impl App { tool_call_message_indices: std::collections::HashMap::new(), tool_call_order: Vec::new(), discovery, + cached_usage_text: String::new(), + cached_usage_check: (0, 0), }) } @@ -2702,6 +2706,10 @@ impl App { } } + pub fn is_animation_running(&self) -> bool { + self.base_focus == BaseFocus::Home || self.is_streaming + } + pub fn process_streaming_chunks(&mut self) { self.process_openai_oauth_events(); @@ -3079,7 +3087,20 @@ impl App { self.last_frame_size = size; let colors = self.get_current_theme_colors(); - let usage_text = self.session_usage_text(); + let fingerprint: (usize, usize) = ( + self.chat_state.chat.messages.len(), + self.chat_state + .chat + .messages + .iter() + .filter_map(|m| m.token_count) + .sum(), + ); + if self.cached_usage_check != fingerprint { + self.cached_usage_check = fingerprint; + self.cached_usage_text = self.session_usage_text(); + } + let usage_text = &self.cached_usage_text; match self.base_focus { BaseFocus::Home => { diff --git a/src/main.rs b/src/main.rs index 35ea676..17b2266 100644 --- a/src/main.rs +++ b/src/main.rs @@ -336,24 +336,32 @@ async fn run_event_loop( terminal: &mut Terminal>, app: &mut App, ) -> Result<()> { - // Use a shorter poll duration for smoother animations (16ms = ~60fps max) - const POLL_DURATION: Duration = Duration::from_millis(16); + // Adaptive poll duration: fast when animations run (home page / streaming), + // slow otherwise to avoid wasting CPU on unnecessary re-renders. + const FAST_POLL: Duration = Duration::from_millis(16); // ~60fps for animations + const SLOW_POLL: Duration = Duration::from_millis(250); // ~4fps idle while app.running { let loop_start = std::time::Instant::now(); + let animation_needed = app.is_animation_running(); + app.process_streaming_chunks(); app.update_animations(); remove_expired_toasts(); terminal.draw(|f| app.render(f))?; + let poll_duration = if animation_needed { + FAST_POLL + } else { + SLOW_POLL + }; + // Calculate how long the loop iteration took let elapsed = loop_start.elapsed(); - // Poll for events, but with a dynamic timeout to maintain consistent frame timing - // If we spent less than POLL_DURATION processing, wait for the remainder - let poll_timeout = if elapsed < POLL_DURATION { - POLL_DURATION - elapsed + let poll_timeout = if elapsed < poll_duration { + poll_duration - elapsed } else { Duration::from_millis(0) }; diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 5607fda..2f370fa 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -51,6 +51,10 @@ pub struct Chat { pub selection: Selection, /// Index of the message highlighted by timeline navigation (None = no highlight) pub highlighted_message_index: Option, + /// Render cache — fingerprints content to skip expensive re-formatting + cached_lines: Vec>, + cached_positions: Vec, + cached_fingerprint: u64, } // Minimum elapsed time before showing tokens/s (250ms) @@ -97,6 +101,9 @@ impl Chat { message_line_positions: Vec::new(), selection: Selection::new(), highlighted_message_index: None, + cached_lines: Vec::new(), + cached_positions: Vec::new(), + cached_fingerprint: 0, } } @@ -127,11 +134,15 @@ impl Chat { message_line_positions: Vec::new(), selection: Selection::new(), highlighted_message_index: None, + cached_lines: Vec::new(), + cached_positions: Vec::new(), + cached_fingerprint: 0, } } pub fn add_message(&mut self, message: Message) { self.messages.push(message); + self.invalidate_cache(); if self.should_autoscroll() { // Reset scroll to show new content at bottom // Content height will be recalculated on next render @@ -252,6 +263,23 @@ impl Chat { self.streaming_paused_duration = std::time::Duration::default(); self.streaming_token_counter = None; self.selection.clear(); + self.cached_lines.clear(); + self.cached_fingerprint = 0; + } + + fn invalidate_cache(&mut self) { + self.cached_fingerprint = 0; + } + + fn compute_fingerprint(&self, max_width: usize) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.messages.len().hash(&mut h); + for msg in &self.messages { + msg.content.len().hash(&mut h); + } + max_width.hash(&mut h); + h.finish() } pub fn begin_streaming_turn(&mut self) { @@ -719,10 +747,8 @@ impl Chat { ) { self.viewport_height = area.height as usize; - // Update streaming renderer before calculating heights self.update_streaming_renderer(); - // Calculate content area (leave space for scrollbar + right padding) let content_area = Rect { x: area.x, y: area.y, @@ -730,31 +756,91 @@ impl Chat { height: area.height, }; - // Calculate total content height first - let total_height = - self.calculate_content_height(content_area.width as usize, model, colors); - self.content_height = total_height; + let max_width = content_area.width as usize; - // Clamp scroll offset - let max_offset = self.content_height.saturating_sub(self.viewport_height); - self.scroll_offset = self.scroll_offset.min(max_offset); - self.update_scrollbar(); + let fingerprint = self.compute_fingerprint(max_width); + let cache_valid = !self.cached_lines.is_empty() && fingerprint == self.cached_fingerprint; + + let mut positions: Vec; + let mut all_lines: Vec>; + + if cache_valid { + positions = self.cached_positions.clone(); + all_lines = self.cached_lines.clone(); + } else { + let message_count = self.messages.len(); + let streaming_idx = self.streaming_assistant_idx(); + let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); + + positions = Vec::with_capacity(message_count); + all_lines = Vec::new(); + + for (idx, message) in self.messages.iter().enumerate() { + positions.push(all_lines.len()); + let attached = + idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; + let message_lines = self.format_message( + message, + max_width, + idx, + message_count, + streaming_content, + streaming_idx, + model, + colors, + attached, + ); + all_lines.extend(message_lines.into_iter().map(line_to_static)); + } + + self.cached_lines = all_lines.clone(); + self.cached_positions = positions.clone(); + self.cached_fingerprint = fingerprint; + } + + let content_height = all_lines.len(); + + // Apply highlight + let hl_idx = self.highlighted_message_index; + let hl_bg = colors.interactive; + if let Some(hl) = hl_idx { + if hl < positions.len() { + let start = positions[hl]; + let end = if hl + 1 < positions.len() { + positions[hl + 1] + } else { + all_lines.len() + }; + for line in all_lines + .iter_mut() + .skip(start) + .take(end.saturating_sub(start)) + { + for span in line.spans.iter_mut() { + span.style = span.style.bg(hl_bg); + } + } + } + } - // Now render the visible content let content_lines = - self.render_visible_messages(content_area.width as usize, model, colors); + crate::ui::selection::apply_selection_to_lines(all_lines, &self.selection, colors.accent); - // Store scroll_offset before creating paragraph - let scroll_offset = self.scroll_offset; + let viewport = self.viewport_height; + let max_offset = content_height.saturating_sub(viewport); + let clamped_scroll = self.scroll_offset.min(max_offset); - // Render content let paragraph = Paragraph::new(Text::from(content_lines)) .wrap(Wrap { trim: false }) - .scroll((scroll_offset as u16, 0)); + .scroll((clamped_scroll as u16, 0)); f.render_widget(paragraph, content_area); - // Render scrollbar + self.content_height = content_height; + self.message_line_positions = positions; + self.scroll_offset = clamped_scroll; + self.update_scrollbar(); + let scrollbar_area = Rect { x: area.x + area.width.saturating_sub(1), y: area.y, @@ -771,70 +857,6 @@ impl Chat { ); } - fn calculate_content_height( - &mut self, - max_width: usize, - model: &str, - colors: &ThemeColors, - ) -> usize { - let mut total_height = 0; - let message_count = self.messages.len(); - let streaming_idx = self.streaming_assistant_idx(); - let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); - - self.message_line_positions.clear(); - self.message_line_positions.reserve(message_count); - - for (idx, message) in self.messages.iter().enumerate() { - self.message_line_positions.push(total_height); - let attached_to_assistant = - idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; - let message_lines = self.format_message( - message, - max_width, - idx, - message_count, - streaming_content, - streaming_idx, - model, - colors, - attached_to_assistant, - ); - total_height += message_lines.len(); - } - - total_height - } - - fn render_visible_messages<'a>( - &'a self, - max_width: usize, - model: &'a str, - colors: &'a ThemeColors, - ) -> Vec> { - let mut lines = self.build_all_lines(max_width, model, colors); - - if let Some(hl_idx) = self.highlighted_message_index { - if hl_idx < self.message_line_positions.len() { - let start_line = self.message_line_positions[hl_idx]; - let end_line = if hl_idx + 1 < self.message_line_positions.len() { - self.message_line_positions[hl_idx + 1] - } else { - lines.len() - }; - - let hl_bg = colors.interactive; - for line in lines.iter_mut().skip(start_line).take(end_line.saturating_sub(start_line)) { - for span in line.spans.iter_mut() { - span.style = span.style.bg(hl_bg); - } - } - } - } - - crate::ui::selection::apply_selection_to_lines(lines, &self.selection, colors.accent) - } - fn build_all_lines<'a>( &'a self, max_width: usize, @@ -1277,6 +1299,17 @@ impl Chat { } } +fn line_to_static(line: Line<'_>) -> Line<'static> { + Line { + spans: line.spans.into_iter().map(|span| Span { + content: std::borrow::Cow::Owned(span.content.into_owned()), + style: span.style, + }).collect(), + style: Style::default(), + alignment: line.alignment, + } +} + use ratatui::text::Text; #[cfg(test)] diff --git a/src/ui/selection.rs b/src/ui/selection.rs index 06ad86a..8ac3a27 100644 --- a/src/ui/selection.rs +++ b/src/ui/selection.rs @@ -165,8 +165,11 @@ pub fn apply_selection_to_lines<'a>( if line_idx < s_line || line_idx > e_line { return line; } - let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); - let line_width = unicode_width::UnicodeWidthStr::width(line_text.as_str()); + let line_width: usize = line + .spans + .iter() + .map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref())) + .sum(); let sel_range = selection.selection_range_in_line(line_idx, line_width); // If entire line is selected, just style all spans From 6288bcaaab089933ad38ab6ac25ca5bd03cc3dab Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 04:01:31 +0800 Subject: [PATCH 057/226] feat: show token usage as percentage of model limit, fix UTF-8 truncation boundary. - Display token count as percentage of limit (e.g. "12.5K (25%)") - Capitalize "K" suffix for kilotokens - Fix truncation in aisdk tool preview to avoid breaking on char boundary --- _plans/__TODOS.md | 2 ++ src/app.rs | 16 +++++++++++++--- src/tools/aisdk_bridge.rs | 3 ++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 84d227c..ef2c3b2 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -37,3 +37,5 @@ - [x] Feature: Rename command `/rename` - parity with opencode. - [ ] Bug: theme is not persisted? Or is it by config? Just make theme be based on state now, no more config for it. + +- [ ] Bug: skill loading on conflict. i.e. duplicate frontend-design skill. Warning: duplicate skill name 'frontend-design' (existing: /Users/carlo/.claude/skills/frontend-design/SKILL.md, duplicate: /Users/carlo/.config/opencode/skill/frontend-design/SKILL.md) diff --git a/src/app.rs b/src/app.rs index b67fe2f..5fdb66c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -466,8 +466,18 @@ impl App { } let token_text = format_token_count(total_tokens); + let mut text = token_text; if let Some(ref discovery) = self.discovery { + if let Some(limit) = + discovery.get_model_limit(&self.provider_name.to_lowercase(), &self.model) + { + if limit > 0 { + let pct = ((total_tokens as f64 / limit as f64) * 100.0).round() as u32; + text = format!("{} ({}%)", text, pct); + } + } + if let Some(cost) = discovery.get_model_pricing( &self.provider_name.to_lowercase(), &self.model, @@ -482,12 +492,12 @@ impl App { let total = (output_tokens.max(total_tokens)) as f64; let price = total / 1_000_000.0 * cost.output; if price > 0.001 { - return format!("{} \u{00b7} ${:.2}", token_text, price); + return format!("{} \u{00b7} ${:.2}", text, price); } } } - token_text + text } pub fn get_current_theme_colors(&self) -> theme::ThemeColors { @@ -3280,7 +3290,7 @@ fn format_token_count(count: usize) -> String { } if count < 1_000_000 { let k = count as f64 / 1000.0; - return format!("{:.1}k", k); + return format!("{:.1}K", k); } let m = count as f64 / 1_000_000.0; format!("{:.1}M", m) diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index b75679b..3371ef0 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -111,7 +111,8 @@ pub async fn convert_to_aisdk_tools( let preview_limit: usize = 4000; let mut preview = tool_result.output.clone(); if preview.len() > preview_limit { - preview.truncate(preview_limit); + let boundary = preview.floor_char_boundary(preview_limit); + preview.truncate(boundary); preview.push_str("... (truncated)"); } From c4a87aab64286cb654b8edd15c393ae3b68d89d9 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 04:44:58 +0800 Subject: [PATCH 058/226] feat(ui): improve highlight styling and scrollbar consistency. - Use contrast_text for highlighted message and selection backgrounds - Remove empty lines from highlighted messages and pad short lines - Add begin/end symbols to scrollbars - Rename "Jump" to "Jump actions" in timeline dialog --- src/ui/components/chat.rs | 56 +++++++++++++++++++++++++++--------- src/ui/components/dialog.rs | 4 ++- src/ui/selection.rs | 6 ++-- src/views/timeline_dialog.rs | 4 +-- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 2f370fa..52b906e 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1,5 +1,5 @@ use crate::session::types::{Message, MessageRole}; -use crate::theme::ThemeColors; +use crate::theme::{contrast_text, ThemeColors}; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; use crate::ui::selection::Selection; use crate::utils::token_counter::StreamingTokenCounter; @@ -12,6 +12,8 @@ use ratatui::{ Frame, }; use serde_json::Value as JsonValue; +use unicode_width::UnicodeWidthStr; + #[derive(Debug, Clone, Default)] pub struct Chat { @@ -798,11 +800,10 @@ impl Chat { self.cached_fingerprint = fingerprint; } - let content_height = all_lines.len(); + let mut content_height = all_lines.len(); - // Apply highlight + // Apply timeline highlight let hl_idx = self.highlighted_message_index; - let hl_bg = colors.interactive; if let Some(hl) = hl_idx { if hl < positions.len() { let start = positions[hl]; @@ -811,13 +812,40 @@ impl Chat { } else { all_lines.len() }; - for line in all_lines - .iter_mut() - .skip(start) - .take(end.saturating_sub(start)) - { - for span in line.spans.iter_mut() { - span.style = span.style.bg(hl_bg); + + if end > start { + let hl_bg = colors.interactive; + let hl_fg = contrast_text(hl_bg); + let mut removed = 0usize; + + for i in (start..end).rev() { + let line = &mut all_lines[i]; + let current_width: usize = line + .spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + if current_width == 0 { + all_lines.remove(i); + removed += 1; + } else { + for span in line.spans.iter_mut() { + span.style = span.style.bg(hl_bg).fg(hl_fg); + } + if current_width < max_width { + let padding = " ".repeat(max_width - current_width); + line.spans.push( + Span::styled(padding, Style::default().bg(hl_bg)), + ); + } + } + } + + if removed > 0 { + for p in positions.iter_mut().skip(hl + 1) { + *p = p.saturating_sub(removed); + } + content_height = all_lines.len(); } } } @@ -850,8 +878,10 @@ impl Chat { f.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::VerticalRight) - .track_symbol(Some("│")) - .thumb_symbol("█"), + .track_symbol(Some(" ")) + .thumb_symbol("█") + .begin_symbol(Some(" ")) + .end_symbol(Some(" ")), scrollbar_area, &mut self.scrollbar_state, ); diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 005b51a..7c4b2ef 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -934,7 +934,9 @@ impl Dialog { let scrollbar_area = chunks[3]; frame.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::VerticalRight) - .track_symbol(Some(" ")), + .track_symbol(Some(" ")) + .begin_symbol(Some(" ")) + .end_symbol(Some(" ")), scrollbar_area, &mut self.scrollbar_state, ); diff --git a/src/ui/selection.rs b/src/ui/selection.rs index 8ac3a27..1fcd041 100644 --- a/src/ui/selection.rs +++ b/src/ui/selection.rs @@ -1,3 +1,4 @@ +use crate::theme::contrast_text; use ratatui::{ style::{Color, Modifier, Style}, text::Span, @@ -273,12 +274,11 @@ pub fn extract_selected_text( /// Apply a selection highlight style to a span. /// Uses the accent color as background with inverted text for visibility. fn selection_span_style<'a>(span: &Span<'a>, accent: Color) -> Span<'a> { - let current_fg = span.style.fg.unwrap_or(Color::Reset); Span::styled( span.content.clone(), Style::default() .bg(accent) - .fg(current_fg) + .fg(contrast_text(accent)) .add_modifier(Modifier::BOLD), ) } @@ -353,7 +353,7 @@ fn split_and_style_span<'a>( selected, Style::default() .bg(accent) - .fg(span.style.fg.unwrap_or(Color::Reset)) + .fg(contrast_text(accent)) .add_modifier(Modifier::BOLD), )); diff --git a/src/views/timeline_dialog.rs b/src/views/timeline_dialog.rs index 2c974b7..c6c8cb6 100644 --- a/src/views/timeline_dialog.rs +++ b/src/views/timeline_dialog.rs @@ -14,7 +14,7 @@ impl TimelineDialogState { let mut dialog = Dialog::new("Timeline").with_position(DialogPosition::Right); dialog = dialog.with_actions(vec![ FooterAction { - label: "Jump".to_string(), + label: "Jump actions".to_string(), key: "enter".to_string(), }, ]); @@ -96,7 +96,7 @@ impl TimelineDialogState { dialog.adjust_scroll(); dialog = dialog.with_actions(vec![ FooterAction { - label: "Jump".to_string(), + label: "Jump actions".to_string(), key: "enter".to_string(), }, ]); From 4fe29846b91ad512d4c30b2de6132f6ed70bb3ff Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 10 May 2026 04:54:29 +0800 Subject: [PATCH 059/226] feat: restore undone message content to input, make home screen layout responsive. When undoing a message via the timeline, the undone content is now restored to the input field for editing. The home screen logo/mascot layout now adapts to terminal width, stacking vertically on narrow terminals and displaying side-by-side on wide terminals. --- _plans/__TODOS.md | 2 ++ src/app.rs | 18 ++++++++++++++++- src/views/home.rs | 51 ++++++++++++++++++++++++++++++++--------------- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index ef2c3b2..54d8ee8 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -39,3 +39,5 @@ - [ ] Bug: theme is not persisted? Or is it by config? Just make theme be based on state now, no more config for it. - [ ] Bug: skill loading on conflict. i.e. duplicate frontend-design skill. Warning: duplicate skill name 'frontend-design' (existing: /Users/carlo/.claude/skills/frontend-design/SKILL.md, duplicate: /Users/carlo/.config/opencode/skill/frontend-design/SKILL.md) + +- [ ] Bug: Timeline livescroll and actual chat UI consistency - make them the same. diff --git a/src/app.rs b/src/app.rs index 5fdb66c..5d56815 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2077,9 +2077,21 @@ impl App { self.overlay_focus = OverlayFocus::None; } "undo" => { - let remaining: Vec = { + let undone_content: Option = { if let Some(session) = self.session_manager.get_current_session() { + let content = session + .messages + .get(idx) + .map(|m| m.content.clone()); session.messages.truncate(idx); + content + } else { + return; + } + }; + + let remaining: Vec = { + if let Some(session) = self.session_manager.get_current_session() { session.messages.clone() } else { return; @@ -2093,6 +2105,10 @@ impl App { self.chat_state.chat.scroll_offset = usize::MAX; self.chat_state.chat.clear_highlighted_message(); + if let Some(content) = undone_content { + self.input.set_text(&content); + } + push_toast(Toast::new( format!("Removed {} message(s)", idx), ToastLevel::Info, diff --git a/src/views/home.rs b/src/views/home.rs index 9e7d83c..b8f4ef5 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -97,25 +97,18 @@ pub fn render_home( ) .split(main_chunks[0]); + let is_wide = size.width >= 80; + let logo_area_height = if is_wide { 5 } else { 7 }; + let logo_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(0), - Constraint::Length(5), + Constraint::Length(logo_area_height), Constraint::Min(0), ]) .split(home_chunks[0]); - let logo_row = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Fill(1), - Constraint::Length(25), - Constraint::Min(52), - Constraint::Fill(1), - ]) - .split(logo_chunks[1]); - let mascot_lines: Vec = MASCO[home_state.frame()] .lines() .filter(|l| !l.is_empty()) @@ -129,8 +122,6 @@ pub fn render_home( }) .collect(); - let mascot = Paragraph::new(Text::from(mascot_lines)); - let logo_lines: Vec = LOGO .lines() .filter(|l| !l.is_empty()) @@ -148,10 +139,38 @@ pub fn render_home( }) .collect(); - let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); + if is_wide { + let logo_row = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Length(22), + Constraint::Min(55), + Constraint::Fill(1), + ]) + .split(logo_chunks[1]); + + let mascot = Paragraph::new(Text::from(mascot_lines)); + let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); + + f.render_widget(mascot, logo_row[1]); + f.render_widget(logo, logo_row[2]); + } else { + let stack = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + ]) + .split(logo_chunks[1]); - f.render_widget(mascot, logo_row[1]); - f.render_widget(logo, logo_row[2]); + let mascot = Paragraph::new(Text::from(mascot_lines)).alignment(Alignment::Center); + let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); + + f.render_widget(mascot, stack[0]); + f.render_widget(logo, stack[2]); + } input.render(f, home_chunks[1], &agent, &model, &provider_name, colors); let help_text = vec![ From f7013c4e8bc90253d6d097bb14beb3e4afe65596 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 03:08:39 +0800 Subject: [PATCH 060/226] feat: better spacing + color for the chat input box. --- src/ui/components/input.rs | 79 +++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index a4b00c2..64a4d0a 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -5,7 +5,8 @@ use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::prelude::{Rect, Style}; -use ratatui::widgets::{Block, Paragraph}; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Borders, Paragraph}; use tui_textarea::{CursorMove, Input as TuiInput, TextArea}; use unicode_width::UnicodeWidthChar; @@ -84,7 +85,7 @@ impl Input { self } - pub fn render( +pub fn render( &mut self, frame: &mut ratatui::Frame, area: Rect, @@ -95,17 +96,33 @@ impl Input { ) { let agent_color = agent_color(agent, colors); - let border = Block::bordered() - .borders(ratatui::widgets::Borders::LEFT) - .border_style(ratatui::style::Style::default().fg(agent_color)) - .border_type(ratatui::widgets::BorderType::Thick) - .padding(ratatui::widgets::Padding::horizontal(1)); + let border_set = border::Set { + vertical_left: "┃", + ..border::PLAIN + }; + + let border = Block::new() + .borders(Borders::LEFT) + .border_set(border_set) + .border_style(Style::default().fg(agent_color)); let inner_area = border.inner(area); + let bg = Block::default().style(Style::default().bg(colors.background_element)); + frame.render_widget(bg, inner_area); + let line_count = self.textarea.lines().len().max(1); let textarea_height = line_count.min(6) as u16; - let chunks = ratatui::layout::Layout::default() + let h_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + ratatui::layout::Constraint::Length(2), + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(2), + ]) + .split(inner_area); + + let v_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ ratatui::layout::Constraint::Length(1), @@ -114,50 +131,66 @@ impl Input { ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(1), ]) - .split(inner_area); + .split(h_chunks[1]); - // Store the textarea area for mouse event handling - self.textarea_area = Some(chunks[1]); + self.textarea_area = Some(v_chunks[1]); - // Configure selection style to match theme self.textarea.set_selection_style( - ratatui::style::Style::default() + Style::default() .bg(colors.accent) .fg(colors.text), ); + self.textarea + .set_style(Style::default().fg(colors.text).bg(colors.background_element)); - // Ensure viewport_top stays within valid bounds let line_count = self.textarea.lines().len(); - let visible_lines = chunks[1].height as usize; + let visible_lines = v_chunks[1].height as usize; let max_viewport_top = line_count.saturating_sub(visible_lines); self.viewport_top = self.viewport_top.min(max_viewport_top); - frame.render_widget(&self.textarea, chunks[1]); + frame.render_widget(&self.textarea, v_chunks[1]); let info_text = ratatui::text::Line::from(vec![ ratatui::text::Span::styled( agent.to_string(), - ratatui::style::Style::default().fg(agent_color), + Style::default().fg(agent_color), ), ratatui::text::Span::raw(" "), ratatui::text::Span::styled( model.to_string(), - ratatui::style::Style::default().fg(colors.text), + Style::default().fg(colors.text), ), ratatui::text::Span::raw(" "), ratatui::text::Span::styled( provider_name.to_string(), - ratatui::style::Style::default() + Style::default() .fg(colors.text_weak) .add_modifier(ratatui::style::Modifier::DIM), ), ]); let info_paragraph = Paragraph::new(info_text); - frame.render_widget(info_paragraph, chunks[3]); + frame.render_widget(info_paragraph, v_chunks[3]); + + let full_width = area.width as usize; + let cap_dashes = "▀".repeat(full_width - 1); + + let cap_row = Paragraph::new(ratatui::text::Line::from(vec![ + ratatui::text::Span::styled("╹", Style::default().fg(agent_color)), + ratatui::text::Span::styled(cap_dashes, Style::default().fg(colors.background_element)), + ])); + let cap_row_area = Rect::new(area.x, v_chunks[4].y, area.width, 1); + frame.render_widget(cap_row, cap_row_area); + frame.render_widget(border, area); } + pub fn get_height(&self) -> u16 { + let line_count = self.textarea.lines().len().max(1); + let textarea_height = line_count.min(6) as u16; + textarea_height + 4 + } + pub fn handle_event(&mut self, event: KeyEvent) -> bool { let input = TuiInput::from(event); @@ -590,12 +623,6 @@ impl Input { } Vec::new() } - - pub fn get_height(&self) -> u16 { - let line_count = self.textarea.lines().len().max(1); - let textarea_height = line_count.min(6) as u16; - textarea_height + 4 - } } impl Default for Input { From 87cbe6e5f2b4e7a37babb48c1640fb765046980a Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 03:33:02 +0800 Subject: [PATCH 061/226] fix: better spacing using a clever glyph (upper half block). --- src/ui/components/input.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 64a4d0a..eba8c96 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -107,8 +107,14 @@ pub fn render( .border_style(Style::default().fg(agent_color)); let inner_area = border.inner(area); + let bg_area = Rect { + x: inner_area.x, + y: inner_area.y, + width: inner_area.width, + height: inner_area.height.saturating_sub(1), + }; let bg = Block::default().style(Style::default().bg(colors.background_element)); - frame.render_widget(bg, inner_area); + frame.render_widget(bg, bg_area); let line_count = self.textarea.lines().len().max(1); let textarea_height = line_count.min(6) as u16; @@ -172,17 +178,16 @@ pub fn render( let info_paragraph = Paragraph::new(info_text); frame.render_widget(info_paragraph, v_chunks[3]); - let full_width = area.width as usize; - let cap_dashes = "▀".repeat(full_width - 1); + frame.render_widget(border, area); let cap_row = Paragraph::new(ratatui::text::Line::from(vec![ - ratatui::text::Span::styled("╹", Style::default().fg(agent_color)), - ratatui::text::Span::styled(cap_dashes, Style::default().fg(colors.background_element)), + ratatui::text::Span::styled( + "▀".repeat(area.width as usize), + Style::default().fg(colors.background_element), + ), ])); let cap_row_area = Rect::new(area.x, v_chunks[4].y, area.width, 1); frame.render_widget(cap_row, cap_row_area); - - frame.render_widget(border, area); } pub fn get_height(&self) -> u16 { From 9d6889dd69eb9770f5722f1f90ea41df782f06d3 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 03:35:17 +0800 Subject: [PATCH 062/226] feat: PERFECT spacing using clever glyphs. --- src/ui/components/input.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index eba8c96..f5fe9ce 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -182,7 +182,11 @@ pub fn render( let cap_row = Paragraph::new(ratatui::text::Line::from(vec![ ratatui::text::Span::styled( - "▀".repeat(area.width as usize), + "╹", + Style::default().fg(agent_color), + ), + ratatui::text::Span::styled( + "▀".repeat(area.width as usize - 1), Style::default().fg(colors.background_element), ), ])); From 3a9ac3424d49d3aa9d28a9cbea5f98be1f32bdc0 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 03:52:04 +0800 Subject: [PATCH 063/226] fix: horizontal centering of mascot. --- src/views/home.rs | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/views/home.rs b/src/views/home.rs index b8f4ef5..bc57f03 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -6,6 +6,8 @@ use ratatui::{ Frame, }; +use unicode_width::UnicodeWidthStr; + use crate::theme::ThemeColors; use crate::ui::components::input::Input; use crate::ui::components::status_bar::StatusBar; @@ -109,17 +111,9 @@ pub fn render_home( ]) .split(home_chunks[0]); - let mascot_lines: Vec = MASCO[home_state.frame()] + let mascot_raw: Vec<&str> = MASCO[home_state.frame()] .lines() .filter(|l| !l.is_empty()) - .map(|line| { - Line::styled( - line, - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ) - }) .collect(); let logo_lines: Vec = LOGO @@ -150,6 +144,17 @@ pub fn render_home( ]) .split(logo_chunks[1]); + let mascot_lines: Vec = mascot_raw + .iter() + .map(|line| { + Line::styled( + *line, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ) + }) + .collect(); let mascot = Paragraph::new(Text::from(mascot_lines)); let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); @@ -165,7 +170,23 @@ pub fn render_home( ]) .split(logo_chunks[1]); - let mascot = Paragraph::new(Text::from(mascot_lines)).alignment(Alignment::Center); + let max_mascot_width = mascot_raw.iter().map(|l| UnicodeWidthStr::width(*l)).max().unwrap_or(0); + let left_pad = ((stack[0].width as usize).saturating_sub(max_mascot_width)) / 2; + let padding = " ".repeat(left_pad); + + let mascot_lines: Vec = mascot_raw + .iter() + .map(|line| { + let padded = format!("{}{}", padding, line); + Line::styled( + padded, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ) + }) + .collect(); + let mascot = Paragraph::new(Text::from(mascot_lines)); let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); f.render_widget(mascot, stack[0]); From a180207daabbcf6d4f752f133e4493c96c990d3a Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 04:01:30 +0800 Subject: [PATCH 064/226] feat: better chat input box background theme. --- src/theme.json | 2 ++ src/theme.rs | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/theme.json b/src/theme.json index ba73f80..9eaa858 100644 --- a/src/theme.json +++ b/src/theme.json @@ -18,6 +18,7 @@ "background-base": "#faf9f5", "background-weak": "#f5f4ef", "background-strong": "#fafafa", + "surface-raised-stronger-non-alpha": "#ffffff", "background-stronger": "#ffffff", "surface-raised-base-hover": "#f0efea", "border-weak-base": "#e5e4df", @@ -82,6 +83,7 @@ "background-base": "#0f0e0b", "background-weak": "#1a1916", "background-strong": "#0d0c09", + "surface-raised-stronger-non-alpha": "#1c1c1c", "background-stronger": "#0a0907", "surface-raised-base-hover": "#0f0e0b", "border-weak-base": "#2d2b28", diff --git a/src/theme.rs b/src/theme.rs index 94d77aa..8f0dca9 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -301,8 +301,7 @@ impl Theme { let interactive = parse_hex(&mode.seeds.interactive); let background = parse_hex(&mode.overrides.background_base); let dialog_background = parse_hex(dialog_background); - let background_element = - resolve_override(mode.overrides.background_weak.as_deref(), dialog_background); + let background_element = dialog_background; let text = parse_hex(&mode.overrides.text_base); let text_weak = parse_hex(&mode.overrides.text_weak); let text_strong = parse_hex(&mode.overrides.text_strong); From 27a9ce781967e615b2683f30deb51a665b3cb044 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 04:20:04 +0800 Subject: [PATCH 065/226] fix: layout shifts when focusing timeline dialog. --- src/ui/components/chat.rs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 52b906e..ed1ec9a 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -816,19 +816,15 @@ impl Chat { if end > start { let hl_bg = colors.interactive; let hl_fg = contrast_text(hl_bg); - let mut removed = 0usize; - for i in (start..end).rev() { + for i in start..end { let line = &mut all_lines[i]; let current_width: usize = line .spans .iter() .map(|s| UnicodeWidthStr::width(s.content.as_ref())) .sum(); - if current_width == 0 { - all_lines.remove(i); - removed += 1; - } else { + if current_width > 0 { for span in line.spans.iter_mut() { span.style = span.style.bg(hl_bg).fg(hl_fg); } @@ -840,13 +836,6 @@ impl Chat { } } } - - if removed > 0 { - for p in positions.iter_mut().skip(hl + 1) { - *p = p.saturating_sub(removed); - } - content_height = all_lines.len(); - } } } } From ee4262850b81f2e3a275e64ca4df217c50ccfc0c Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 22:06:51 +0800 Subject: [PATCH 066/226] refactor: replace inline timeline highlight with overlay band. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render timeline highlight as a left-edge "▌" band per line instead of painting text backgrounds inline. This avoids distorting text layout and simplifies the render pipeline. Unused imports (`contrast_text`, `UnicodeWidthStr`) and a redundant `mut` on `content_height` are also cleaned up. --- src/ui/components/chat.rs | 70 ++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index ed1ec9a..53e86d3 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1,5 +1,5 @@ use crate::session::types::{Message, MessageRole}; -use crate::theme::{contrast_text, ThemeColors}; +use crate::theme::ThemeColors; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; use crate::ui::selection::Selection; use crate::utils::token_counter::StreamingTokenCounter; @@ -12,7 +12,6 @@ use ratatui::{ Frame, }; use serde_json::Value as JsonValue; -use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone, Default)] @@ -800,59 +799,54 @@ impl Chat { self.cached_fingerprint = fingerprint; } - let mut content_height = all_lines.len(); + let content_height = all_lines.len(); - // Apply timeline highlight - let hl_idx = self.highlighted_message_index; - if let Some(hl) = hl_idx { + let content_lines = + crate::ui::selection::apply_selection_to_lines(all_lines, &self.selection, colors.accent); + + let viewport = self.viewport_height; + let max_offset = content_height.saturating_sub(viewport); + let clamped_scroll = self.scroll_offset.min(max_offset); + + let paragraph = Paragraph::new(Text::from(content_lines)) + .wrap(Wrap { trim: false }) + .scroll((clamped_scroll as u16, 0)); + + f.render_widget(paragraph, content_area); + + // Render timeline highlight as a left-edge overlay band (preserves layout) + if let Some(hl) = self.highlighted_message_index { if hl < positions.len() { let start = positions[hl]; let end = if hl + 1 < positions.len() { positions[hl + 1] } else { - all_lines.len() + content_height }; if end > start { - let hl_bg = colors.interactive; - let hl_fg = contrast_text(hl_bg); + let hl_color = colors.interactive; + let band_line = Line::from(Span::styled("▌", Style::default().fg(hl_color))); for i in start..end { - let line = &mut all_lines[i]; - let current_width: usize = line - .spans - .iter() - .map(|s| UnicodeWidthStr::width(s.content.as_ref())) - .sum(); - if current_width > 0 { - for span in line.spans.iter_mut() { - span.style = span.style.bg(hl_bg).fg(hl_fg); - } - if current_width < max_width { - let padding = " ".repeat(max_width - current_width); - line.spans.push( - Span::styled(padding, Style::default().bg(hl_bg)), - ); - } + if i >= clamped_scroll && i < clamped_scroll.saturating_add(viewport) { + let line_y = content_area.y.saturating_add((i - clamped_scroll) as u16); + let band_area = Rect { + x: content_area.x, + y: line_y, + width: 1, + height: 1, + }; + f.render_widget( + Paragraph::new(band_line.clone()), + band_area, + ); } } } } } - let content_lines = - crate::ui::selection::apply_selection_to_lines(all_lines, &self.selection, colors.accent); - - let viewport = self.viewport_height; - let max_offset = content_height.saturating_sub(viewport); - let clamped_scroll = self.scroll_offset.min(max_offset); - - let paragraph = Paragraph::new(Text::from(content_lines)) - .wrap(Wrap { trim: false }) - .scroll((clamped_scroll as u16, 0)); - - f.render_widget(paragraph, content_area); - self.content_height = content_height; self.message_line_positions = positions; self.scroll_offset = clamped_scroll; From 33abe04fcbc83835645bec44f58c673d2cf94cff Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 22:18:41 +0800 Subject: [PATCH 067/226] refactor(chat): render timeline highlight as full-width background. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the narrow left-edge `▌` band with a full-width background block using `Block::new().bg()`, and adjusts text foreground contrast via `contrast_text()` for readability. Paragraph rendering is deferred until after the highlight overlay so styled text renders on top. Also reduces home screen animation phase durations by ~30%. --- src/ui/components/chat.rs | 56 ++++++++++++++++++++++----------------- src/views/home.rs | 2 +- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 53e86d3..fcc94bd 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1,5 +1,5 @@ use crate::session::types::{Message, MessageRole}; -use crate::theme::ThemeColors; +use crate::theme::{contrast_text, ThemeColors}; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; use crate::ui::selection::Selection; use crate::utils::token_counter::StreamingTokenCounter; @@ -8,7 +8,7 @@ use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, Frame, }; use serde_json::Value as JsonValue; @@ -801,20 +801,11 @@ impl Chat { let content_height = all_lines.len(); - let content_lines = - crate::ui::selection::apply_selection_to_lines(all_lines, &self.selection, colors.accent); - let viewport = self.viewport_height; let max_offset = content_height.saturating_sub(viewport); let clamped_scroll = self.scroll_offset.min(max_offset); - let paragraph = Paragraph::new(Text::from(content_lines)) - .wrap(Wrap { trim: false }) - .scroll((clamped_scroll as u16, 0)); - - f.render_widget(paragraph, content_area); - - // Render timeline highlight as a left-edge overlay band (preserves layout) + // Render timeline highlight as a full-width background overlay if let Some(hl) = self.highlighted_message_index { if hl < positions.len() { let start = positions[hl]; @@ -826,27 +817,44 @@ impl Chat { if end > start { let hl_color = colors.interactive; - let band_line = Line::from(Span::styled("▌", Style::default().fg(hl_color))); + let hl_fg = contrast_text(hl_color); + + for line in all_lines.iter_mut().take(end).skip(start) { + for span in line.spans.iter_mut() { + span.style = span.style.fg(hl_fg); + } + } + + let vis_start = start.max(clamped_scroll); + let vis_end = end.min(clamped_scroll.saturating_add(viewport)); - for i in start..end { - if i >= clamped_scroll && i < clamped_scroll.saturating_add(viewport) { - let line_y = content_area.y.saturating_add((i - clamped_scroll) as u16); - let band_area = Rect { + if vis_end > vis_start { + let y = content_area.y.saturating_add((vis_start - clamped_scroll) as u16); + let height = (vis_end - vis_start).saturating_sub(1) as u16; + if height > 0 { + let hl_area = Rect { x: content_area.x, - y: line_y, - width: 1, - height: 1, + y, + width: content_area.width, + height, }; - f.render_widget( - Paragraph::new(band_line.clone()), - band_area, - ); + let hl_block = Block::new().style(Style::default().bg(hl_color)); + f.render_widget(hl_block, hl_area); } } } } } + let content_lines = + crate::ui::selection::apply_selection_to_lines(all_lines, &self.selection, colors.accent); + + let paragraph = Paragraph::new(Text::from(content_lines)) + .wrap(Wrap { trim: false }) + .scroll((clamped_scroll as u16, 0)); + + f.render_widget(paragraph, content_area); + self.content_height = content_height; self.message_line_positions = positions; self.scroll_offset = clamped_scroll; diff --git a/src/views/home.rs b/src/views/home.rs index bc57f03..c55cf39 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -37,7 +37,7 @@ pub struct HomeState { tick_count: u32, } -const PHASE_DURATIONS: [u32; 5] = [20, 10, 10, 10, 20]; +const PHASE_DURATIONS: [u32; 5] = [14, 7, 7, 7, 14]; const PHASE_FRAMES: [usize; 5] = [0, 1, 0, 1, 0]; impl HomeState { From 51df90e99afde8403d4e92a6a92a23d6acf96ae2 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 22:56:30 +0800 Subject: [PATCH 068/226] feat: opencode parity 1st run. Lots of tools added. --- .opencode/commands/checkparity-opencode.md | 112 +++++++++ _plans/__TODOS.md | 2 +- src/agent/config.rs | 27 +++ src/agent/mod.rs | 2 + src/agent/subagent.rs | 239 ++++++++++++++++++ src/app.rs | 32 +++ src/command/handlers.rs | 2 +- src/llm/client.rs | 16 ++ src/llm/mod.rs | 4 + src/prompt/mod.rs | 25 ++ src/tools/init.rs | 17 +- src/tools/mod.rs | 10 +- src/tools/question.rs | 86 +++++++ src/tools/task.rs | 94 ++++++++ src/tools/todowrite.rs | 117 +++++++++ src/tools/webfetch.rs | 266 +++++++++++++++++++++ 16 files changed, 1047 insertions(+), 4 deletions(-) create mode 100644 .opencode/commands/checkparity-opencode.md create mode 100644 src/agent/config.rs create mode 100644 src/agent/subagent.rs create mode 100644 src/tools/question.rs create mode 100644 src/tools/task.rs create mode 100644 src/tools/todowrite.rs create mode 100644 src/tools/webfetch.rs diff --git a/.opencode/commands/checkparity-opencode.md b/.opencode/commands/checkparity-opencode.md new file mode 100644 index 0000000..c3716f8 --- /dev/null +++ b/.opencode/commands/checkparity-opencode.md @@ -0,0 +1,112 @@ +--- +description: Audit crabcode against opencode for harness feature parity +agent: build +--- + +Audit the crabcode codebase (this Rust project) against the opencode AI coding agent for 1:1 feature parity. Focus ONLY on core harness functionality (agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands). Do NOT audit UX, theming, keybinds, or non-harness features. + +## What to Audit + +For each area below, read the relevant crabcode source files, compare against how opencode does it (I will provide opencode's behavior inline), and produce a table row: Feature | Crabcode Status | Gap + +### 1. Agent Loop +- Multi-step agentic iteration via LLM streaming with tool calling +- Cancellation token support for user interruption +- Step limit enforcement with text-only summary fallback +- Chunk-based streaming: text, reasoning, tool_calls, tool_results, errors, metrics, cancelled +- Plan/Build mode toggle (plan = read-only tools) +- Permission preflight during tool execution (and mid-stream permission dialogs) +- Configurable max steps per agent (with "max steps reached" prompt injection) + +### 2. System Prompt +- Provider-specific header and behavior instructions (Beast for OpenAI, Anthropic-specific, Gemini-specific, Codex-specific) +- Environment context block (workdir, git status, platform, date) +- Tool schemas block (all registered tools as JSON) +- Custom instructions from AGENTS.md/CLAUDE.md (walk-up directory discovery + global fallback) +- Available skills listing as `` XML block +- Available subagents listing: opencode lists subagent names and descriptions in the system prompt so the primary agent knows when to use the Task tool. Crabcode currently does NOT list subagents (there are no real subagents yet). + +### 3. Subagent System +OpenCode has these subagents: +- **explore**: Fast, read-only (glob/grep/read/list tools only). For codebase searching. +- **general**: Full tool access (minus todowrite). For complex multi-step tasks. +- **scout**: Read-only, can clone repos. For external docs/dependency research. +- **vlm-agent**: For image analysis. +Additionally: **compaction**, **title**, **summary** (hidden/system agents that run automatically). + +Check if crabcode has: +- Task tool (the tool primary agents use to spawn subagents) +- The explore/general/scout subagent implementations +- Child sessions for subagent work (session tree: parent/child navigation) +- Subagent descriptions in the system prompt so the primary agent can select subagents +- @mention subagent invocation from user input +- Agent mode: primary vs subagent vs all +- Hidden agents (hidden from @autocomplete but invokable via Task tool) +- Task permissions (which agents can invoke which subagents) + +### 4. Tool Calling +OpenCode's built-in tools: +- **bash** - shell command execution +- **edit** - exact string replacement in files +- **write** - create/overwrite files +- **read** - read files with offset/limit pagination, also directories +- **grep** - regex search with include filters +- **glob** - file pattern matching +- **list** - tree-style directory listing (this is NOT the same as read for directories; it's a deliberate directory-tree listing tool) +- **skill** - loads SKILL.md by name +- **task** - spawn subagents +- **todowrite** - manage structured task lists +- **webfetch** - fetch web content (markdown conversion) +- **websearch** - search the web (Exa AI) +- **question** - ask user questions during execution +- **extract-images** - save session images to disk +- **apply_patch** - apply diffs +- **lsp** - LSP code intelligence (experimental) + +Check crabcode's registered tools in `src/tools/init.rs` and list which are present, which are missing. + +### 5. Skill Loading +OpenCode's skill system: +- Discovery locations: `.opencode/skills//SKILL.md`, `~/.config/opencode/skills//SKILL.md`, `.claude/skills/`, `.agents/skills/`, `~/.claude/skills/`, `~/.agents/skills/` +- Walk-up from project root to git worktree for project skills +- YAML frontmatter with required `name` and `description` +- Pattern-based skill permissions (e.g., `"internal-*": "deny"`) +- Skill tool lists available skills in description + +Check crabcode's skill loading in `src/skill/mod.rs` against this. + +### 6. Agent Configuration +OpenCode supports: +- Agent config via `opencode.json` (JSON) and `~/.config/opencode/agents/.md` (markdown frontmatter) +- Per-agent: description, temperature, model, max_steps, mode (primary/subagent/all), hidden, color, top_p, permissions, task permissions +- Agent creation wizard (`opencode agent create`) + +Check what crabcode has in `src/agent/` and config files. + +### 7. Custom Commands +OpenCode supports: +- User-defined commands via `.opencode/commands/.md` files +- Frontmatter: description, agent, model, subtask +- Template variables: $ARGUMENTS, $1, $2, etc. +- Shell output injection: `!`command`` +- File references: `@path/to/file` + +Check crabcode's command system in `src/command/`. + +### 8. Permission System +OpenCode's permission system: +- Per-tool: allow, deny, ask +- Wildcard patterns (e.g., `"mymcp_*": "deny"`) +- Pattern-specific bash permissions (e.g., `"git push": "ask"`, `"git *": "allow"`) +- Per-agent override of global permissions +- External directory gating +- Doom loop recovery prompts + +Check crabcode's permission system in `src/tools/permission.rs`. + +## Output Format +Produce a markdown table with these columns: +| # | Feature | OpenCode | Crabcode | Gap | +|---|---------|----------|----------|-----| + +Then a separate section with PRIORITY-ranked actionable gaps (CRITICAL/HIGH/MEDIUM/LOW) with specific file locations and implementation notes. diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 54d8ee8..790a222 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -40,4 +40,4 @@ - [ ] Bug: skill loading on conflict. i.e. duplicate frontend-design skill. Warning: duplicate skill name 'frontend-design' (existing: /Users/carlo/.claude/skills/frontend-design/SKILL.md, duplicate: /Users/carlo/.config/opencode/skill/frontend-design/SKILL.md) -- [ ] Bug: Timeline livescroll and actual chat UI consistency - make them the same. +- [x] Bug: Timeline livescroll and actual chat UI consistency - make them the same. diff --git a/src/agent/config.rs b/src/agent/config.rs new file mode 100644 index 0000000..d19f848 --- /dev/null +++ b/src/agent/config.rs @@ -0,0 +1,27 @@ +use std::sync::OnceLock; + +#[derive(Debug, Clone)] +pub struct LlmSessionConfig { + pub provider_name: String, + pub model: String, + pub api_key: Option, + pub provider_kind: ProviderKind, + pub base_url: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderKind { + OpenAI, + OpenAICompatible, + Anthropic, +} + +static LLM_SESSION: OnceLock = OnceLock::new(); + +pub fn set_llm_session(config: LlmSessionConfig) { + let _ = LLM_SESSION.set(config); +} + +pub fn get_llm_session() -> Option<&'static LlmSessionConfig> { + LLM_SESSION.get() +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 0b35968..4a6dc5a 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,4 +1,6 @@ pub mod build; +pub mod config; pub mod manager; pub mod plan; +pub mod subagent; pub mod types; diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs new file mode 100644 index 0000000..a86f36a --- /dev/null +++ b/src/agent/subagent.rs @@ -0,0 +1,239 @@ +use crate::tools::ToolRegistry; +use crate::agent::config::{get_llm_session, ProviderKind}; + +const EXPLORE_SYSTEM_PROMPT: &str = r#"You are a fast, read-only code exploration agent. Your job is to search codebases, find files, and answer questions about code structure. + +TOOLS AVAILABLE: +- glob: Find files by pattern matching +- grep: Search file contents using regex +- read: Read file contents with pagination +- list: List directory contents + +IMPORTANT RULES: +- Only use the tools listed above (glob, grep, read, list) +- Search in parallel when possible (use multiple tool calls at once) +- Be thorough - search patterns, naming conventions, and related files +- Return a single comprehensive message with all findings +- Focus on precise code locations (file paths and line numbers) +- If you can't find something after thorough searching, report that clearly +- Do NOT use bash, write, edit, or any other tools + +You will receive a detailed task description from the primary agent. Complete it and return your findings in a single message."#; + +const GENERAL_SYSTEM_PROMPT: &str = r#"You are a general-purpose subagent that can use all available tools to complete complex multi-step tasks autonomously. + +IMPORTANT RULES: +- Your entire response will be returned to the primary agent as a single tool result +- Complete ALL steps autonomously before returning +- Be thorough and verify your work using available tools +- Return a single comprehensive message with your results +- Do NOT ask questions back to the user - just complete the task +- Do NOT use the todowrite tool + +You will receive a detailed task description from the primary agent. Complete it and return your findings in a single comprehensive message."#; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubAgentType { + Explore, + General, +} + +impl SubAgentType { + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "explore" => Some(Self::Explore), + "general" => Some(Self::General), + _ => None, + } + } + + pub fn name(&self) -> &'static str { + match self { + Self::Explore => "explore", + Self::General => "general", + } + } + + pub fn description(&self) -> &'static str { + match self { + Self::Explore => "Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. This agent is read-only and fast.", + Self::General => "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel, generate and run complex scripts, or research unfamiliar code.", + } + } + + pub fn system_prompt(&self) -> &'static str { + match self { + Self::Explore => EXPLORE_SYSTEM_PROMPT, + Self::General => GENERAL_SYSTEM_PROMPT, + } + } + + pub fn allowed_tools(&self) -> Vec<&'static str> { + match self { + Self::Explore => vec!["glob", "grep", "read", "list"], + Self::General => vec!["bash", "edit", "write", "read", "grep", "glob", "list", "skill", "webfetch"], + } + } +} + +pub struct SubAgentDef { + pub subagent_type: SubAgentType, + pub name: String, + pub description: String, +} + +impl SubAgentDef { + pub fn all() -> Vec { + vec![ + SubAgentDef { + subagent_type: SubAgentType::Explore, + name: SubAgentType::Explore.name().to_string(), + description: SubAgentType::Explore.description().to_string(), + }, + SubAgentDef { + subagent_type: SubAgentType::General, + name: SubAgentType::General.name().to_string(), + description: SubAgentType::General.description().to_string(), + }, + ] + } +} + +pub async fn build_scoped_registry(full_registry: &ToolRegistry, subagent_type: &SubAgentType) -> ToolRegistry { + let scoped = ToolRegistry::new(); + let allowed = subagent_type.allowed_tools(); + + let full_tools = full_registry.list().await; + + for tool_def in &full_tools { + if allowed.contains(&tool_def.id.as_str()) { + if let Some(handler) = full_registry.get(&tool_def.id).await { + scoped.register(handler).await; + } + } + } + + scoped +} + +pub async fn run_subagent( + subagent_type: SubAgentType, + description: &str, + prompt: &str, + full_registry: &ToolRegistry, +) -> Result { + use aisdk::core::{ + language_model::{LanguageModelStreamChunkType}, + DynamicModel, LanguageModelRequest, Message as AisdkMessage, + }; + use futures::StreamExt; + + let session = get_llm_session().ok_or("LLM session not configured")?; + let cwd = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")); + + let scoped_registry = build_scoped_registry(full_registry, &subagent_type).await; + let permissions = crate::tools::ToolPermissions::new(cwd.clone()); + + let aisdk_tools = crate::tools::aisdk_bridge::convert_to_aisdk_tools( + &scoped_registry, + None, + "build".to_string(), + permissions, + ) + .await; + + let system_prompt = subagent_type.system_prompt(); + let user_content = format!( + "## Task Description\n{}\n\n## Task Prompt\n{}", + description, prompt + ); + + let messages = vec![ + AisdkMessage::System(system_prompt.into()), + AisdkMessage::User(user_content.into()), + ]; + + let mut response = match session.provider_kind { + ProviderKind::OpenAICompatible => { + let provider = aisdk::providers::OpenAICompatible::::builder() + .base_url(&session.base_url) + .model_name(&session.model) + .provider_name(&session.provider_name) + .api_key(session.api_key.as_deref().unwrap_or("")) + .build() + .map_err(|e| format!("Failed to build OpenAICompatible provider: {}", e))?; + + let mut request = LanguageModelRequest::builder() + .model(provider) + .messages(messages); + + for tool in aisdk_tools { + request = request.with_tool(tool); + } + + request.build().stream_text().await.map_err(|e| format!("Stream error: {}", e))? + } + ProviderKind::Anthropic => { + let provider = aisdk::providers::Anthropic::::builder() + .base_url(&session.base_url) + .model_name(&session.model) + .provider_name(&session.provider_name) + .api_key(session.api_key.as_deref().unwrap_or("")) + .build() + .map_err(|e| format!("Failed to build Anthropic provider: {}", e))?; + + let mut request = LanguageModelRequest::builder() + .model(provider) + .messages(messages); + + for tool in aisdk_tools { + request = request.with_tool(tool); + } + + request.build().stream_text().await.map_err(|e| format!("Stream error: {}", e))? + } + ProviderKind::OpenAI => { + let provider = aisdk::providers::OpenAI::::builder() + .base_url(&session.base_url) + .model_name(&session.model) + .provider_name(&session.provider_name) + .api_key(session.api_key.as_deref().unwrap_or("")) + .build() + .map_err(|e| format!("Failed to build OpenAI provider: {}", e))?; + + let mut request = LanguageModelRequest::builder() + .model(provider) + .messages(messages); + + for tool in aisdk_tools { + request = request.with_tool(tool); + } + + request.build().stream_text().await.map_err(|e| format!("Stream error: {}", e))? + } + }; + + let mut collected_text = String::new(); + + while let Some(chunk) = response.stream.next().await { + match chunk { + LanguageModelStreamChunkType::Text(text) => { + collected_text.push_str(&text); + } + LanguageModelStreamChunkType::Failed(err) => { + return Err(format!("Subagent streaming failed: {}", err)); + } + LanguageModelStreamChunkType::End(_) => { + break; + } + _ => {} + } + } + + if collected_text.trim().is_empty() { + return Err("Subagent returned no output".to_string()); + } + + Ok(collected_text) +} diff --git a/src/app.rs b/src/app.rs index 5d56815..b901231 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2944,6 +2944,14 @@ impl App { self.permission_dialog_state.enqueue(prompt); self.overlay_focus = OverlayFocus::PermissionDialog; } + crate::llm::ChunkMessage::QuestionRequest { + questions, + response_tx, + } => { + self.chat_state.chat.pause_streaming_tps_timer(); + let answers = auto_answer_questions(&questions); + let _ = response_tx.send(answers); + } } } } @@ -3312,6 +3320,30 @@ fn format_token_count(count: usize) -> String { format!("{:.1}M", m) } +fn auto_answer_questions(questions: &serde_json::Value) -> serde_json::Value { + let arr = match questions { + serde_json::Value::Array(a) => a, + _ => return serde_json::Value::Array(vec![]), + }; + + let answers: Vec = arr + .iter() + .map(|q| { + let options = q.get("options").and_then(|o| o.as_array()); + match options { + Some(opts) if !opts.is_empty() => { + let labels: Vec = + vec![opts[0].get("label").cloned().unwrap_or(serde_json::Value::Null)]; + serde_json::Value::Array(labels) + } + _ => serde_json::Value::Array(vec![]), + } + }) + .collect(); + + serde_json::Value::Array(answers) +} + impl Default for App { fn default() -> Self { Self::new().expect("Failed to initialize App") diff --git a/src/command/handlers.rs b/src/command/handlers.rs index da31590..5d687de 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -935,7 +935,7 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 11); + assert_eq!(names.len(), 12); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); diff --git a/src/llm/client.rs b/src/llm/client.rs index 34a24c5..b6e55ef 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -95,6 +95,22 @@ pub async fn stream_llm_with_cancellation( let aisdk_messages = convert_messages(&messages); let tool_registry = crate::tools::initialize_tool_registry().await; + + crate::tools::register_dynamic_tools(&tool_registry, Some(sender.clone())).await; + + // Set LLM session config for subagent use + crate::agent::config::set_llm_session(crate::agent::config::LlmSessionConfig { + provider_name: request_config.provider_name.clone(), + model: request_config.model_name.clone(), + api_key: request_config.api_key.clone(), + provider_kind: match request_config.kind { + ProviderKind::OpenAI => crate::agent::config::ProviderKind::OpenAI, + ProviderKind::OpenAICompatible => crate::agent::config::ProviderKind::OpenAICompatible, + ProviderKind::Anthropic => crate::agent::config::ProviderKind::Anthropic, + }, + base_url: request_config.base_url.clone(), + }); + let aisdk_tools = convert_to_aisdk_tools( &tool_registry, Some(sender.clone()), diff --git a/src/llm/mod.rs b/src/llm/mod.rs index f644371..2b2f207 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -13,6 +13,10 @@ pub enum ChunkMessage { ToolCalls(Vec), ToolResult(ToolCallResult), PermissionRequest(crate::tools::PermissionPrompt), + QuestionRequest { + questions: serde_json::Value, + response_tx: tokio::sync::oneshot::Sender, + }, End, Failed(String), Cancelled, diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index c5f93d9..66b5043 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -294,6 +294,31 @@ Tool use: } } + // Add available subagents listing + let subagents = crate::agent::subagent::SubAgentDef::all(); + if !subagents.is_empty() { + let subagents_xml = subagents + .iter() + .map(|s| { + format!( + " \n {}\n {}\n ", + s.name, s.description + ) + }) + .collect::>() + .join("\n"); + + let subagents_block = format!( + "\n\n\n{}\n", + subagents_xml + ); + + if !instructions.is_empty() { + instructions.push_str("\n\n"); + } + instructions.push_str(&subagents_block); + } + instructions } } diff --git a/src/tools/init.rs b/src/tools/init.rs index 7a57b53..b0f75e4 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,6 +1,6 @@ use crate::tools::{ fs::{GlobTool, GrepTool, ListTool, ReadTool, WriteTool}, - BashTool, EditTool, SkillTool, ToolRegistry, + BashTool, EditTool, QuestionTool, SkillTool, TaskTool, TodowriteTool, ToolRegistry, WebfetchTool, }; use std::sync::Arc; @@ -15,6 +15,21 @@ pub async fn initialize_tool_registry() -> ToolRegistry { registry.register(Arc::new(BashTool::new())).await; registry.register(Arc::new(EditTool::new())).await; registry.register(Arc::new(SkillTool::new())).await; + registry.register(Arc::new(WebfetchTool::new())).await; + registry.register(Arc::new(TodowriteTool::new())).await; registry } + +pub async fn register_dynamic_tools( + registry: &ToolRegistry, + sender: Option, +) { + registry + .register(Arc::new(QuestionTool::new().with_sender_opt(sender))) + .await; + + registry + .register(Arc::new(TaskTool::new(registry.clone()))) + .await; +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index ea4db8d..1d966d3 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -8,20 +8,28 @@ pub mod edit; pub mod fs; pub mod init; pub mod permission; +pub mod question; pub mod registry; pub mod skill; +pub mod task; +pub mod todowrite; pub mod types; +pub mod webfetch; pub use bash::BashTool; pub use context::ToolContext; pub use edit::EditTool; -pub use init::initialize_tool_registry; +pub use init::{initialize_tool_registry, register_dynamic_tools}; pub use permission::{ AgentToolPolicies, PermissionAction, PermissionPrompt, PermissionResponse, ToolPermissions, }; +pub use question::QuestionTool; pub use registry::ToolRegistry; pub use skill::SkillTool; +pub use task::TaskTool; +pub use todowrite::TodowriteTool; pub use types::{ParameterSchema, ParameterType, Tool, ToolError, ToolId, ToolResult}; +pub use webfetch::WebfetchTool; #[async_trait] pub trait ToolHandler: Send + Sync { diff --git a/src/tools/question.rs b/src/tools/question.rs new file mode 100644 index 0000000..30af31b --- /dev/null +++ b/src/tools/question.rs @@ -0,0 +1,86 @@ +use crate::llm::ChunkSender; +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; + +pub struct QuestionTool { + sender: Option, +} + +impl QuestionTool { + pub fn new() -> Self { + Self { sender: None } + } + + pub fn with_sender(mut self, sender: ChunkSender) -> Self { + self.sender = Some(sender); + self + } + + pub fn with_sender_opt(mut self, sender: Option) -> Self { + self.sender = sender; + self + } +} + +#[async_trait] +impl ToolHandler for QuestionTool { + fn definition(&self) -> Tool { + Tool { + id: "question".to_string(), + description: "Use this tool when you need to ask the user questions during execution. This allows you to:\n1. Gather user preferences or requirements\n2. Clarify ambiguous instructions\n3. Get decisions on implementation choices as you work\n4. Offer choices to the user about what direction to take.\n\nUsage notes:\n- Questions are answered as arrays of labels\n- You can allow multiple selections or single selection\n- Each question needs a header (short label) and options with labels and descriptions\n- When `custom` is enabled, a \"Type your own answer\" option is added automatically\n- The answers will come back as arrays of selected labels".to_string(), + parameters: vec![ParameterSchema { + name: "questions".to_string(), + description: "JSON string of question objects with: question (text), header (short label), options (array of {label, description}), and optional multiple (bool)".to_string(), + required: true, + param_type: ParameterType::String, + }], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["questions"]) + } + + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result { + let questions_raw = get_string_param(¶ms, "questions").unwrap_or_default(); + + let questions: Value = serde_json::from_str(&questions_raw).map_err(|e| { + ToolError::Validation(format!("Invalid JSON for questions parameter: {}", e)) + })?; + + let sender = self.sender.as_ref().ok_or_else(|| { + ToolError::Execution("Question tool has no sender configured".to_string()) + })?; + + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + sender + .send(crate::llm::ChunkMessage::QuestionRequest { + questions: questions.clone(), + response_tx, + }) + .map_err(|_| { + ToolError::Execution("Failed to deliver question request to UI".to_string()) + })?; + + if ctx.is_aborted() { + return Err(ToolError::Execution("Cancelled".to_string())); + } + + let response = response_rx.await.unwrap_or_else(|_| { + serde_json::Value::String("No response from user".to_string()) + }); + + let output = serde_json::to_string_pretty(&response) + .unwrap_or_else(|_| response.to_string()); + + Ok(ToolResult::new("Question answered", output).with_metadata( + "questions", + questions, + )) + } +} diff --git a/src/tools/task.rs b/src/tools/task.rs new file mode 100644 index 0000000..3e9bb9b --- /dev/null +++ b/src/tools/task.rs @@ -0,0 +1,94 @@ +use crate::agent::subagent::{self, SubAgentType}; +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, ToolRegistry, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; + +pub struct TaskTool { + tool_registry: Arc, +} + +impl TaskTool { + pub fn new(tool_registry: ToolRegistry) -> Self { + Self { + tool_registry: Arc::new(tool_registry), + } + } +} + +#[async_trait] +impl ToolHandler for TaskTool { + fn definition(&self) -> Tool { + Tool { + id: "task".to_string(), + description: "Launch a new agent to handle complex, multistep tasks autonomously.\n\nWhen using the Task tool, you must specify a subagent_type parameter to select which agent type to use.\n\nWhen to use the Task tool:\n- When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt.\n\nWhen NOT to use the Task tool:\n- If you want to read a specific file path, use the Read or Glob tool instead\n- If you are searching for a specific class definition, use the Glob tool instead\n- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead\n- Other tasks that are not related to the agent descriptions above\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; do that by using multiple tool calls in a single message\n2. When the agent is done, it will return a single message back to you. The result is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation starts with a fresh context\n4. The agent's outputs should generally be trusted\n5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)\n\nAvailable subagent types:\n- explore: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase.\n- general: General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.".to_string(), + parameters: vec![ + ParameterSchema { + name: "subagent_type".to_string(), + description: "The type of specialized agent to use for this task (explore or general)".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "description".to_string(), + description: "A short (3-5 words) description of the task".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "prompt".to_string(), + description: "The task for the agent to perform".to_string(), + required: true, + param_type: ParameterType::String, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["subagent_type", "description", "prompt"])?; + + let subagent_type = get_string_param(params, "subagent_type").unwrap_or_default(); + if SubAgentType::from_str(&subagent_type).is_none() { + return Err(ToolError::Validation(format!( + "Invalid subagent_type: '{}'. Must be 'explore' or 'general'", + subagent_type + ))); + } + + Ok(()) + } + + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result { + let subagent_type_str = get_string_param(¶ms, "subagent_type").unwrap_or_default(); + let description = get_string_param(¶ms, "description").unwrap_or_default(); + let prompt = get_string_param(¶ms, "prompt").unwrap_or_default(); + + let subagent_type = SubAgentType::from_str(&subagent_type_str) + .ok_or_else(|| ToolError::Validation(format!( + "Unknown subagent type: {}", subagent_type_str + )))?; + + if ctx.is_aborted() { + return Err(ToolError::Execution("Subagent cancelled".to_string())); + } + + let result = subagent::run_subagent( + subagent_type.clone(), + &description, + &prompt, + &self.tool_registry, + ) + .await + .map_err(|e| ToolError::Execution(format!("Subagent error: {}", e)))?; + + Ok(ToolResult::new( + format!("Subagent ({}) result", subagent_type.name()), + result, + ) + .with_metadata("subagent_type", serde_json::json!(subagent_type.name()))) + } +} diff --git a/src/tools/todowrite.rs b/src/tools/todowrite.rs new file mode 100644 index 0000000..572f696 --- /dev/null +++ b/src/tools/todowrite.rs @@ -0,0 +1,117 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Deserialize, Serialize)] +struct TodoItem { + content: String, + status: String, + priority: String, +} + +pub struct TodowriteTool; + +impl TodowriteTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ToolHandler for TodowriteTool { + fn definition(&self) -> Tool { + Tool { + id: "todowrite".to_string(), + description: "Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.\n\n## When to Use This Tool\nUse this tool proactively in these scenarios:\n\n1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions\n2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations\n3. User explicitly requests todo list - When the user directly asks you to use the todo list\n4. User provides multiple tasks - When users provide a list of things to be done\n5. After receiving new instructions - Immediately capture user requirements as todos\n6. After completing a task - Mark it complete and add any new follow-up tasks\n\n## Task States and Management\n\n1. **Task States**: Use these states:\n - pending: Task not yet started\n - in_progress: Currently working on (limit to ONE at a time)\n - completed: Task finished successfully\n - cancelled: Task no longer needed\n\n2. **Task Management**:\n - Update task status in real-time as you work\n - Mark tasks complete IMMEDIATELY after finishing\n - Only have ONE task in_progress at any time\n - Complete current tasks before starting new ones\n\nParameters:\n- todos: Array of todo items (JSON string) each with content, status (pending/in_progress/completed/cancelled), and priority (high/medium/low)".to_string(), + parameters: vec![ParameterSchema { + name: "todos".to_string(), + description: "JSON string of todo items array, each with: content, status, priority".to_string(), + required: true, + param_type: ParameterType::String, + }], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["todos"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let todos_raw = get_string_param(¶ms, "todos").unwrap_or_default(); + + let todos: Vec = serde_json::from_str(&todos_raw).map_err(|e| { + ToolError::Validation(format!("Invalid todo JSON: {}", e)) + })?; + + if todos.is_empty() { + return Err(ToolError::Validation( + "Todos array must contain at least one item".to_string(), + )); + } + + for (i, todo) in todos.iter().enumerate() { + if todo.content.trim().is_empty() { + return Err(ToolError::Validation(format!( + "Todo item {} has empty content", + i + ))); + } + if !matches!( + todo.status.as_str(), + "pending" | "in_progress" | "completed" | "cancelled" + ) { + return Err(ToolError::Validation(format!( + "Todo item '{}' has invalid status: {}. Must be one of: pending, in_progress, completed, cancelled", + todo.content, todo.status + ))); + } + if !matches!(todo.priority.as_str(), "high" | "medium" | "low") { + return Err(ToolError::Validation(format!( + "Todo item '{}' has invalid priority: {}. Must be one of: high, medium, low", + todo.content, todo.priority + ))); + } + } + + let in_progress_count = todos + .iter() + .filter(|t| t.status == "in_progress") + .count(); + + let mut output = String::from("## Todo List\n\n"); + let mut stats = std::collections::HashMap::new(); + stats.insert("total".to_string(), todos.len() as u32); + stats.insert( + "in_progress".to_string(), + in_progress_count as u32, + ); + + for todo in &todos { + let icon = match todo.status.as_str() { + "pending" => "☐", + "in_progress" => "▣", + "completed" => "✓", + "cancelled" => "✗", + _ => "?", + }; + output.push_str(&format!( + "- [{}] ({}) {} — {} priority\n", + icon, todo.status, todo.content, todo.priority + )); + } + + output.push_str(&format!( + "\n**Summary**: {} total, {} in progress", + stats["total"], stats["in_progress"] + )); + + Ok(ToolResult::new("Todo list updated", output.clone()).with_metadata( + "todo_items", + serde_json::json!(todos), + )) + } +} diff --git a/src/tools/webfetch.rs b/src/tools/webfetch.rs new file mode 100644 index 0000000..59c976b --- /dev/null +++ b/src/tools/webfetch.rs @@ -0,0 +1,266 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; + +pub struct WebfetchTool; + +impl WebfetchTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ToolHandler for WebfetchTool { + fn definition(&self) -> Tool { + Tool { + id: "webfetch".to_string(), + description: "Fetches content from a specified URL and returns it as markdown. Handles HTML to markdown conversion.\n\nUsage notes:\n- The URL must be a fully-formed valid URL\n- HTTP URLs will be automatically upgraded to HTTPS\n- Format options: \"markdown\" (default), \"text\", or \"html\"\n- Results may be summarized if the content is very large".to_string(), + parameters: vec![ + ParameterSchema { + name: "url".to_string(), + description: "The URL to fetch content from".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "format".to_string(), + description: "The format to return the content in: markdown, text, or html. Defaults to markdown.".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "timeout".to_string(), + description: "Optional timeout in seconds (max 30)".to_string(), + required: false, + param_type: ParameterType::Integer, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["url"])?; + + let url = get_string_param(params, "url").unwrap_or_default(); + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(ToolError::Validation( + "URL must start with http:// or https://".to_string(), + )); + } + + Ok(()) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let raw_url = get_string_param(¶ms, "url").unwrap_or_default(); + let format = get_string_param(¶ms, "format").unwrap_or_else(|| "markdown".to_string()); + let timeout_secs = params + .get("timeout") + .and_then(|v| v.as_i64()) + .unwrap_or(30) + .max(1) + .min(30) as u64; + + let url = if raw_url.starts_with("http://") { + format!("https://{}", &raw_url[7..]) + } else { + raw_url.clone() + }; + + let client = reqwest::Client::builder() + .user_agent("crabcode/0.1") + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| ToolError::Execution(format!("Failed to create HTTP client: {}", e)))?; + + let response = client.get(&url).send().await.map_err(|e| { + ToolError::Execution(format!("Failed to fetch URL: {}", e)) + })?; + + let status = response.status(); + if !status.is_success() { + return Err(ToolError::Execution(format!( + "HTTP error: {} {}", + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown") + ))); + } + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("text/plain") + .to_lowercase(); + + let body = response.text().await.map_err(|e| { + ToolError::Execution(format!("Failed to read response body: {}", e)) + })?; + + let output = match format.as_str() { + "html" => body, + "text" | "markdown" => { + if content_type.contains("html") { + html_to_markdown(&body) + } else { + body + } + } + _ => body, + }; + + let truncated = if output.len() > 100_000 { + let boundary = output.floor_char_boundary(100_000); + format!("{}...\n\n[Content truncated at 100KB]", &output[..boundary]) + } else { + output + }; + + Ok(ToolResult::new(format!("Fetched: {}", url), truncated) + .with_metadata("url", serde_json::json!(url))) + } +} + +fn html_to_markdown(html: &str) -> String { + let mut result = String::new(); + let mut in_script = false; + let mut in_style = false; + let mut in_tag = false; + let mut tag_name = String::new(); + let mut link_text = String::new(); + let mut link_href = String::new(); + let mut in_a = false; + let mut newlines_since_text: u32 = 0; + + for ch in html.chars() { + if ch == '<' { + in_tag = true; + tag_name.clear(); + continue; + } + + if in_tag { + if ch == '>' { + in_tag = false; + let tn = tag_name.to_lowercase(); + + if tn == "script" || tn.starts_with("script ") { + in_script = true; + } else if tn == "/script" { + in_script = false; + } else if tn == "style" || tn.starts_with("style ") { + in_style = true; + } else if tn == "/style" { + in_style = false; + } else if tn == "a" || tn.starts_with("a ") { + in_a = true; + link_text.clear(); + link_href.clear(); + if let Some(href_start) = tn.find("href=") { + let after = &tn[href_start + 5..]; + if let Some(rest) = after.strip_prefix('"').or_else(|| after.strip_prefix('\'')) { + if let Some(end) = rest.find('"').or_else(|| rest.find('\'')) { + link_href = rest[..end].to_string(); + } + } + } + } else if tn == "/a" { + if !link_text.is_empty() && !link_href.is_empty() { + result.push_str(&format!("[{}]({})", link_text.trim(), link_href.trim())); + } else { + result.push_str(&link_text); + } + in_a = false; + link_text.clear(); + } else if tn == "br" || tn == "br/" || tn == "hr" || tn == "hr/" { + result.push('\n'); + } else if tn == "p" || tn == "/p" || tn == "div" || tn == "/div" + || tn == "/h1" || tn == "/h2" || tn == "/h3" || tn == "/h4" || tn == "/h5" || tn == "/h6" + || tn == "/li" || tn == "/ul" || tn == "/ol" || tn == "/tr" || tn == "/blockquote" + { + if !result.ends_with('\n') { + result.push('\n'); + } + result.push('\n'); + newlines_since_text = 2; + } else if tn == "li" || tn.starts_with("li ") { + result.push_str("\n- "); + } else if tn.starts_with("h1 ") || tn.starts_with("h2 ") || tn.starts_with("h3 ") + || tn.starts_with("h4 ") || tn.starts_with("h5 ") || tn.starts_with("h6 ") + { + if !result.ends_with('\n') { + result.push('\n'); + } + } + + tag_name.clear(); + continue; + } + + if ch != '/' && !tag_name.is_empty() || ch == ' ' && !tag_name.is_empty() { + if ch == ' ' { + tag_name.push(' '); + } else if ch != '/' { + tag_name.push(ch); + } + } else if ch != '/' { + tag_name.push(ch); + } + continue; + } + + if in_script || in_style { + continue; + } + + if in_a { + link_text.push(ch); + continue; + } + + if ch.is_whitespace() { + if !result.ends_with(' ') && newlines_since_text == 0 && !result.ends_with('\n') { + result.push(' '); + } + } else { + result.push(ch); + newlines_since_text = 0; + } + } + + if in_a && !link_text.is_empty() { + if !link_href.is_empty() { + result.push_str(&format!("[{}]({})", link_text.trim(), link_href.trim())); + } else { + result.push_str(&link_text); + } + } + + let cleaned = result + .lines() + .map(|l| l.trim_end()) + .collect::>() + .join("\n"); + + let trimmed = cleaned.trim().to_string(); + let mut final_result = String::new(); + let mut blank_count = 0u32; + for line in trimmed.lines() { + if line.trim().is_empty() { + blank_count += 1; + if blank_count <= 2 { + final_result.push('\n'); + } + } else { + blank_count = 0; + final_result.push_str(line); + final_result.push('\n'); + } + } + + final_result.trim().to_string() +} From fb8943dbccf5f808bf69d821624a3a89c28a3cf8 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 23:14:26 +0800 Subject: [PATCH 069/226] fix: plan to build so it can call tools. --- _plans/__TODOS.md | 4 ++++ src/app.rs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 790a222..0066dff 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -41,3 +41,7 @@ - [ ] Bug: skill loading on conflict. i.e. duplicate frontend-design skill. Warning: duplicate skill name 'frontend-design' (existing: /Users/carlo/.claude/skills/frontend-design/SKILL.md, duplicate: /Users/carlo/.config/opencode/skill/frontend-design/SKILL.md) - [x] Bug: Timeline livescroll and actual chat UI consistency - make them the same. + +- [ ] Parity: Like opencode, I wanna be able to queue messages. By sending some message even though it's still streaming, won't stop the agent, will just keep going. + +- [ ] Markdown: Proper Table rendering. diff --git a/src/app.rs b/src/app.rs index b901231..7cd6aca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -188,7 +188,7 @@ impl App { .unwrap_or_else(|| "?".to_string()); let home_state = init_home(); - let mut agent = "Plan".to_string(); + let mut agent = "Build".to_string(); let chat = Chat::new(); let suggestions_popup_state = init_suggestions_popup(Popup::new()); let models_dialog_state = init_models_dialog("Models", vec![]); From 013835a5e95e6bb7bf462fce8bc7748863a3be88 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 11 May 2026 23:18:04 +0800 Subject: [PATCH 070/226] feat(tools): expand plan mode permissions and log skipped tools. Change plan mode from an allowlist (read/search only) to a denylist (blocking only write/edit/bash), and add a log message when AISDK tools are skipped due to agent mode restrictions. --- src/tools/aisdk_bridge.rs | 4 ++++ src/tools/permission.rs | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 3371ef0..56e361d 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -20,6 +20,10 @@ pub async fn convert_to_aisdk_tools( for tool_def in tools { if !permissions.is_tool_allowed_for_agent(&agent_mode, &tool_def.id) { + let _ = crate::logging::log(&format!( + "[AISDK_TOOLS] Skipping '{}': not allowed in {} mode", + tool_def.id, agent_mode + )); continue; } diff --git a/src/tools/permission.rs b/src/tools/permission.rs index b296fd3..a352744 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -107,8 +107,8 @@ impl AgentToolPolicies { } if mode == "plan" { - // Plan mode is intentionally read/search-only by default. - return matches!(tool.as_str(), "read" | "list" | "glob" | "grep"); + // Plan mode: deny file modifications and bash; allow everything else (read, search, web, etc.) + return !matches!(tool.as_str(), "write" | "edit" | "bash"); } if mode == "build" { From 32df7fb757990e3cc1038eb351ac8319ab815718 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 12 May 2026 01:08:44 +0800 Subject: [PATCH 071/226] feat: working ai sdk port. --- .opencode/commands/checkparity-opencode.md | 2 + Cargo.lock | 46 +-- Cargo.toml | 13 +- _docs/__PARITY.md | 119 +++++++ _plans/__TODOS.md | 9 + aisdk/Cargo.toml | 18 ++ aisdk/src/chunk.rs | 11 + aisdk/src/error.rs | 27 ++ aisdk/src/lib.rs | 51 +++ aisdk/src/message.rs | 89 ++++++ aisdk/src/provider.rs | 34 ++ aisdk/src/providers/anthropic.rs | 234 ++++++++++++++ aisdk/src/providers/compatible.rs | 258 +++++++++++++++ aisdk/src/providers/mod.rs | 7 + aisdk/src/providers/openai.rs | 355 +++++++++++++++++++++ aisdk/src/response.rs | 244 ++++++++++++++ aisdk/src/stop.rs | 19 ++ aisdk/src/tool.rs | 96 ++++++ aisdk_debug.log | 1 + src/agent/subagent.rs | 63 ++-- src/llm/client.rs | 242 ++++++-------- src/tools/aisdk_bridge.rs | 208 ++++++------ src/ui/components/chat.rs | 14 +- src/ui/markdown/mod.rs | 1 + src/ui/markdown/streaming.rs | 29 +- src/ui/markdown/table.rs | 333 +++++++++++++++++++ 26 files changed, 2167 insertions(+), 356 deletions(-) create mode 100644 _docs/__PARITY.md create mode 100644 aisdk/Cargo.toml create mode 100644 aisdk/src/chunk.rs create mode 100644 aisdk/src/error.rs create mode 100644 aisdk/src/lib.rs create mode 100644 aisdk/src/message.rs create mode 100644 aisdk/src/provider.rs create mode 100644 aisdk/src/providers/anthropic.rs create mode 100644 aisdk/src/providers/compatible.rs create mode 100644 aisdk/src/providers/mod.rs create mode 100644 aisdk/src/providers/openai.rs create mode 100644 aisdk/src/response.rs create mode 100644 aisdk/src/stop.rs create mode 100644 aisdk/src/tool.rs create mode 100644 aisdk_debug.log create mode 100644 src/ui/markdown/table.rs diff --git a/.opencode/commands/checkparity-opencode.md b/.opencode/commands/checkparity-opencode.md index c3716f8..8402582 100644 --- a/.opencode/commands/checkparity-opencode.md +++ b/.opencode/commands/checkparity-opencode.md @@ -110,3 +110,5 @@ Produce a markdown table with these columns: |---|---------|----------|----------|-----| Then a separate section with PRIORITY-ranked actionable gaps (CRITICAL/HIGH/MEDIUM/LOW) with specific file locations and implementation notes. + +Write it in _docs/__PARITY.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 963b47c..101b6b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,32 +31,18 @@ dependencies = [ [[package]] name = "aisdk" -version = "0.4.0" +version = "0.1.0" dependencies = [ - "aisdk-macros", "async-trait", "derive_builder", "eventsource-stream", "futures", - "log", - "parking_lot", "reqwest", - "reqwest-eventsource", "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 1.0.69", "tokio", - "uuid", -] - -[[package]] -name = "aisdk-macros" -version = "0.3.0" -dependencies = [ - "proc-macro2", - "quote", - "syn", ] [[package]] @@ -463,6 +449,7 @@ dependencies = [ "json5", "lazy_static", "nucleo-matcher", + "pulldown-cmark", "rand", "ratatui", "ratatui-core", @@ -2381,22 +2368,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "reqwest-eventsource" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde" -dependencies = [ - "eventsource-stream", - "futures-core", - "futures-timer", - "mime", - "nom 7.1.3", - "pin-project-lite", - "reqwest", - "thiserror 1.0.69", -] - [[package]] name = "ring" version = "0.17.14" @@ -3422,17 +3393,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 89dd6e3..094d78c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +[workspace] +members = [".", "aisdk"] +resolver = "2" + [package] name = "crabcode" version = "0.0.1" @@ -31,7 +35,7 @@ nucleo-matcher = "0.3" rusqlite = { version = "0.31", features = ["bundled"] } cuid2 = "0.1" chrono = { version = "0.4", features = ["serde"] } -aisdk = { version = "0.4", features = ["openai", "openaichatcompletions", "openaicompatible", "anthropic"] } +aisdk = { path = "aisdk" } tokio-util = "0.7" glob = "0.3" strsim = "0.11" @@ -40,6 +44,7 @@ regex = "1.10" textwrap = "0.16" unicode-width = "0.1" tui-markdown = "0.3" +pulldown-cmark = "0.13" ratatui-core = "0.1" tiktoken-rs = "0.9.1" base64 = "0.22" @@ -50,12 +55,6 @@ rand = "0.8" [dev-dependencies] tokio-test = "0.4" -[patch.crates-io] -# For local fork development -aisdk = { path = "/Users/carlo/Desktop/Projects/aisdk-rs" } -# After pushing -# aisdk = { git = "https://github.com/Blankeos/aisdk-rs", branch = "crabcode" } - # The profile that 'dist' will build with [profile.dist] inherits = "release" diff --git a/_docs/__PARITY.md b/_docs/__PARITY.md new file mode 100644 index 0000000..611c295 --- /dev/null +++ b/_docs/__PARITY.md @@ -0,0 +1,119 @@ +# Crabcode vs OpenCode — Core Harness Feature Parity Audit + +> Generated: 2026-05-11 | Scope: agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands, permissions. + +## Feature Table + +| # | Feature | OpenCode | Crabcode | Gap | +|---|---------|----------|----------|-----| +| **1.1** | Multi-step agentic iteration (LLM streaming + tool calling) | `stream_text()` with `step_count_is(N)` hook, tool execution loop | `stream_llm_with_cancellation()` at `src/llm/client.rs:82`, `stop_when(step_count_is(max_steps))` at `:377` | **OK** | +| **1.2** | Cancellation token for user interruption | `CancellationToken`, checked in relay loop | `CancellationToken` at `src/llm/client.rs:83`, emits `ChunkMessage::Cancelled` at `:474` | **OK** | +| **1.3** | Step limit enforcement with text-only summary fallback | `stop_when(step_count_is(N))` + follow-up request with `MAX_STEPS_REACHED` prompt, tools stripped | `MAX_STEPS_REACHED_PROMPT` at `src/llm/client.rs:18`, `reached_step_limit()` at `:514`, follow-up stream at `:161-173` with empty tools vec | **OK** | +| **1.4** | Chunk relay: text, reasoning, tool_calls, tool_results, errors, metrics, cancelled | `ChunkType` dispatched per-kind to UI | `ChunkMessage` at `src/llm/mod.rs:9` — Text, Reasoning, ToolCalls, ToolResult, PermissionRequest, QuestionRequest, End, Failed, Cancelled, Metrics, Warning | **OK** | +| **1.5** | Plan/Build mode toggle | User-toggleable mode; plan = read-only tools | `AgentToolPolicies` at `src/tools/permission.rs:71` — plan blocks write/edit/bash, build allows all. No user-facing toggle; mode set at stream start | **Partial**: Mode exists but not user-toggleable mid-conversation | +| **1.6** | Permission preflight during tool execution | `preflight()` checks before each tool call, mid-stream permission dialogs | `permissions.preflight()` in `aisdk_bridge.rs:90-98`, sends `PermissionRequest` chunk, awaits UI response via oneshot | **OK** | +| **1.7** | Configurable max steps per agent | Per-agent `max_steps` in config; "max steps reached" prompt injected | `agent_max_steps: Option` at `src/llm/client.rs:87` | **OK** | +| **2.1** | Provider-specific header (Beast for OpenAI) | Detailed "beast" prompt for OpenAI, concise for Anthropic | `get_beast_prompt()` at `src/prompt/mod.rs:100`, `get_anthropic_prompt()` at `:135`, `get_codex_prompt()` at `:187` | **OK** | +| **2.2** | Provider-specific behavior instructions | Anthropic-specific, Gemini-specific, Codex-specific | `get_gemini_prompt()` at `src/prompt/mod.rs:160`, `get_codex_prompt()` at `:187` | **OK** | +| **2.3** | Environment context block (workdir, git, platform, date) | `` XML block | `get_environment_context()` at `src/prompt/mod.rs:224` | **OK** | +| **2.4** | Tool schemas block (all registered tools as JSON) | All tools rendered as JSON schemas | `get_tools_context()` at `src/prompt/mod.rs:239` — `registry.list_schemas()` serialized as pretty JSON | **OK** | +| **2.5** | Custom instructions from AGENTS.md/CLAUDE.md (walk-up + global) | Walk-up directory discovery + global fallback at `~/.config/opencode/AGENTS.md` and `~/.claude/CLAUDE.md` | `src/prompt/rules.rs` — `resolve_local_rules()` walks up from workdir for AGENTS.md then CLAUDE.md; `resolve_global_rules()` checks `~/.config/crabcode/AGENTS.md` and `~/.claude/CLAUDE.md` | **OK** | +| **2.6** | Available skills as `` XML | Lists skill name, description, location | `src/prompt/mod.rs:267-295` — iterates `SkillStore::all()`, emits `` XML | **OK** | +| **2.7** | Available subagents listing in system prompt | Lists subagent names and descriptions so primary agent knows when to use Task tool | `src/prompt/mod.rs:298-320` — iterates `SubAgentDef::all()`, emits `` XML | **OK** | +| **3.1** | Task tool (primary agent spawns subagents) | `task` tool with subagent_type, description, prompt params | `src/tools/task.rs` — full TaskTool with explore/general enum validation | **OK** | +| **3.2** | Explore subagent | Read-only: glob, grep, read, list. Fast codebase exploration | `src/agent/subagent.rs:4` — ExploreAgent with EXPLORE_SYSTEM_PROMPT, scoped to glob/grep/read/list | **OK** | +| **3.3** | General subagent | Full tool access (minus todowrite). Complex multi-step tasks | `src/agent/subagent.rs:23` — GeneralAgent with GENERAL_SYSTEM_PROMPT, scoped to bash/edit/write/read/grep/glob/list/skill/webfetch | **OK** | +| **3.4** | Scout subagent | Read-only, can clone repos for external docs/deps research | **Not implemented** | **GAP** | +| **3.5** | VLM-agent subagent | For image analysis (delegates to vision models) | **Not implemented** | **GAP** | +| **3.6** | Compaction/Title/Summary hidden agents | System agents that run automatically for session compaction, title generation, summarization | **Not implemented** | **GAP** | +| **3.7** | Subagent multi-step iteration (tool-calling loop within subagent) | Subagents run full agentic loops (stream + tool execution + recursion) | `run_subagent()` at `src/agent/subagent.rs:119` — runs a **single** `stream_text()` call and collects text output. No tool-calling iteration loop inside subagents | **CRITICAL GAP**: Subagents are single-shot LLM calls, not multi-step agents | +| **3.8** | Child sessions / session tree (parent/child navigation) | Subagents create child sessions, navigable in UI | No session tree. Subagents just return a string result | **GAP** | +| **3.9** | Agent mode system (primary vs subagent vs all) | Each agent has a `mode` that controls visibility and invocation | No mode field. Plan/build handled separately via policies | **GAP** | +| **3.10** | Hidden agents (hidden from autocomplete, invokable via Task) | Agents can be marked `hidden: true` | No hidden agent concept | **GAP** | +| **3.11** | Task permissions (which agents can invoke which subagents) | Per-agent `task_permissions` control | No task permission system. Primary agent can always invoke explore/general | **GAP** | +| **3.12** | @mention subagent invocation from user input | `@explore` / `@general` in user input routes to subagent | Not implemented | **GAP** | +| **4.1** | bash | ✓ | `src/tools/bash.rs` | **OK** | +| **4.2** | edit | ✓ | `src/tools/edit.rs` (exact string replacement, fuzzy fallback) | **OK** | +| **4.3** | write | ✓ | `src/tools/fs/write.rs` (atomic write via temp+rename) | **OK** | +| **4.4** | read | ✓ | `src/tools/fs/read.rs` (offset/limit pagination, also reads dirs) | **OK** | +| **4.5** | grep | ✓ | `src/tools/fs/grep.rs` (regex + include filters) | **OK** | +| **4.6** | glob | ✓ | `src/tools/fs/glob.rs` (pattern matching) | **OK** | +| **4.7** | list | ✓ | `src/tools/fs/list.rs` (tree-style directory listing) | **OK** | +| **4.8** | skill | ✓ | `src/tools/skill.rs` (loads SKILL.md by name, injects content) | **OK** | +| **4.9** | task | ✓ | `src/tools/task.rs` (spawns explore/general subagents) | **OK** | +| **4.10** | todowrite | ✓ | `src/tools/todowrite.rs` (JSON-validated structured task list) | **OK** | +| **4.11** | webfetch | ✓ | `src/tools/webfetch.rs` (fetch + handcrafted HTML-to-markdown) | **OK** | +| **4.12** | question | ✓ | `src/tools/question.rs` (oneshot-based UI question prompts) | **OK** | +| **4.13** | websearch | Exa AI web search | **Not implemented** | **GAP** | +| **4.14** | extract-images | Save session images to disk for VLM | **Not implemented** | **GAP** | +| **4.15** | apply_patch | Apply diffs/patch files | **Not implemented** | **GAP** | +| **4.16** | lsp | LSP code intelligence (experimental) | **Not implemented** | **GAP** | +| **5.1** | Discovery: `.opencode/skills//SKILL.md` | OpenCode native layout | Scanned via `{skill,skills}/**/SKILL.md` in `.opencode/`, `.crabcode/`, config dirs at `src/skill/mod.rs:67-77` | **OK** | +| **5.2** | Discovery: `~/.config/opencode/skills//SKILL.md` | Global config skills | `global_opencode` at `src/skill/mod.rs:39` | **OK** | +| **5.3** | Discovery: `.claude/skills/` (project + home) | Claude Code compat | Walk-up `.claude/skills/**/SKILL.md` + `~/.claude/skills/**/SKILL.md` at `src/skill/mod.rs:46-64` | **OK** | +| **5.4** | Discovery: `.agents/skills/` (project + home) | OpenCode compat | Walk-up `.agents/skills/**/SKILL.md` + `~/.agents/skills/**/SKILL.md` at `src/skill/mod.rs:46-64` | **OK** | +| **5.5** | Walk-up bounded to git worktree | Walks up only to git root | Walks up to filesystem root (no git boundary) at `src/skill/mod.rs:50-64` | **Partial**: No git worktree boundary for walk-up | +| **5.6** | YAML frontmatter with `name` and `description` | Required in SKILL.md | Parsed at `src/skill/mod.rs:184-233`, with fallback YAML sanitization for Claude Code compat | **OK** | +| **5.7** | Pattern-based skill permissions | `"internal-*": "deny"` style glob patterns | **Not implemented** | **GAP** | +| **5.8** | Skill tool lists available skills in description | Skill names embedded in tool definition description | `build_description()` at `src/tools/skill.rs:15-48` appends `` XML to tool description | **OK** | +| **6.1** | Agent config via `opencode.json` | `agents` field in JSON config | Crabcode reads opencode.json for compat via `src/config/configuration.rs` | **OK** | +| **6.2** | Agent config via `~/.config/opencode/agents/.md` | Markdown frontmatter with agent definitions | **Not implemented** | **GAP** | +| **6.3** | Per-agent: description, model, temperature, max_steps | Full per-agent override of all params | Only has global `LlmSessionConfig` at `src/agent/config.rs:4` (provider, model, api_key). No per-agent overrides | **GAP** | +| **6.4** | Per-agent: mode (primary/subagent/all) | Controls where agent is visible/usable | Not implemented (only plan/build context) | **GAP** | +| **6.5** | Per-agent: hidden, color, top_p, permissions, task_permissions | Agent metadata fields | Not implemented | **GAP** | +| **6.6** | Agent creation wizard (`opencode agent create`) | Interactive agent creation | Not implemented | **GAP** | +| **7.1** | User-defined commands via `.opencode/commands/.md` | Markdown files define custom slash commands | Not implemented. Only Rust function handlers for built-in commands | **MAJOR GAP** | +| **7.2** | Command frontmatter: description, agent, model, subtask | YAML frontmatter in custom command files | Not implemented | **GAP** | +| **7.3** | Template variables ($ARGUMENTS, $INPUT, $CWD, etc.) | Template substitution in custom commands | Not implemented | **GAP** | +| **7.4** | Shell output injection (`$(command)`) | Inline shell execution in commands | Not implemented | **GAP** | +| **7.5** | File references (`@path/to/file`) | File content insertion in command text | Not implemented | **GAP** | +| **8.1** | Per-tool: allow, deny, ask | Global permission rules per tool | `AgentToolPolicies` at `src/tools/permission.rs:71` — per-mode tool allowlists only (not global per-tool deny/ask rules) | **Partial** | +| **8.2** | Wildcard pattern permissions | `"mymcp_*": "deny"` | Not implemented. Only exact tool name matching | **GAP** | +| **8.3** | Pattern-specific bash permissions | `"git push": "ask"`, `"git *": "allow"` | Not implemented. Bash only gets a generic "bash requires permission" check | **GAP** | +| **8.4** | Per-agent override of global permissions | Agent-level permission config overrides global | Not implemented. Only mode-based (plan/build) | **GAP** | +| **8.5** | External directory gating | Blocks/prompts for paths outside workdir | `is_outside_workdir()` at `src/tools/permission.rs:377` | **OK** | +| **8.6** | Doom loop recovery prompts | Persistent tool failures trigger recovery | Not implemented | **GAP** | + +## Priority-Ranked Actionable Gaps + +### CRITICAL + +| # | Gap | Location | Notes | +|---|-----|----------|-------| +| **C1** | **Subagents are single-shot, not multi-step** | `src/agent/subagent.rs:119-238` | `run_subagent()` calls `stream_text()` once and collects text. No tool-calling iteration loop. The relay loop at lines `219-232` only handles `Text`/`Failed`/`End` — it doesn't relay tool results back to the model for another step. Need a full agentic loop inside subagents (call → tool results → next call, up to step limit). | +| **C2** | **No custom user-defined commands** | `src/command/` | OpenCode's `.opencode/commands/.md` system is entirely absent. Crabcode only has hardcoded Rust function handlers. Need: (a) `.opencode/commands/` + `~/.config/opencode/commands/` directory discovery, (b) Markdown file parser with YAML frontmatter, (c) template engine for `$ARGUMENTS`, `$INPUT`, `$CWD`, (d) shell injection `$(...)`, (e) `@file` references. Entirely new module needed. | + +### HIGH + +| # | Gap | Location | Notes | +|---|-----|----------|-------| +| **H1** | **No multi-agent config (per-agent model, temp, max_steps, mode)** | `src/agent/config.rs`, `src/agent/manager.rs` | `LlmSessionConfig` is a global singleton (`OnceLock`). Need a `config/agents/.md` parser + per-agent struct with: description, temperature, model, max_steps, mode (primary/subagent/all), hidden, color, top_p, permissions, task_permissions. The `AgentManager::new()` at `manager.rs:42` hardcodes `name: "default"` and uses a global provider config. | +| **H2** | **No agent modes (primary/subagent/all/hidden)** | `src/agent/types.rs`, `src/agent/manager.rs` | `Agent` struct at `manager.rs:10` has no `mode` field. Need: enum `AgentMode::Primary | Subagent | All`, hidden flag, integration with tool permission filtering and system prompt visibility. | +| **H3** | **No child sessions / session tree for subagents** | `src/agent/subagent.rs`, `src/session/` | Subagents return a raw string. OpenCode creates child sessions with parent→child navigation. Need: session tree in `SessionManager`, parent_id on Session, UI for navigating child sessions in timeline. | +| **H4** | **Wildcard and pattern-based permission system** | `src/tools/permission.rs` | `AgentToolPolicies` only supports exact tool name matching per mode. Need: glob/wildcard matching (`"mymcp_*": "deny"`), pattern-specific bash permissions (`"git push": "ask"`, `"git *": "allow"`), per-agent permission overrides. | +| **H5** | **Scout subagent** | New: `src/agent/subagent.rs` | Read-only subagent that can clone repos for researching external docs/dependencies. Similar to Explore but with git clone capability and web search. | +| **H6** | **VLM-agent subagent** | New: `src/agent/subagent.rs` | Subagent for image analysis. Needs: `extract-images` tool, forwarding images to vision-capable models, returning analysis results. | +| **H7** | **Hidden/auto agents (compaction, title, summary)** | New: `src/agent/` | System agents that run automatically: compaction (truncates conversation context), title (generates session title), summary (summarizes long contexts). These are hidden from user but invokable via Task tool. | + +### MEDIUM + +| # | Gap | Location | Notes | +|---|-----|----------|-------| +| **M1** | **No @mention subagent invocation** | `src/command/parser.rs` | User typing `@explore find all tests` should route directly to the explore subagent. Need: extend `parse_input()` to detect `@subagent_name` prefix. | +| **M2** | **No websearch tool** | New: `src/tools/websearch.rs` | OpenCode uses Exa AI for web search. Crabcode has no equivalent. | +| **M3** | **No extract-images tool** | New: `src/tools/extract_images.rs` | Tool to save session images to disk for VLM agent consumption. Prerequisite for VLM-agent. | +| **M4** | **No apply_patch tool** | New: `src/tools/apply_patch.rs` | Apply unified diffs to files. Needed for patch-based editing workflows. | +| **M5** | **No LSP tool** | New: `src/tools/lsp.rs` | LSP code intelligence (go-to-def, find-references, diagnostics). | +| **M6** | **No doom loop recovery** | `src/tools/permission.rs`, `src/llm/client.rs` | When tools persistently fail, inject recovery prompts to break the loop. | +| **M7** | **Skill walk-up not bounded by git root** | `src/skill/mod.rs:50-64` | Walk-up for `.claude/` and `.agents/` skill dirs goes all the way to filesystem root. Should stop at git worktree boundary (like OpenCode). | +| **M8** | **No pattern-based skill permissions** | `src/skill/mod.rs`, `src/tools/skill.rs` | OpenCode supports `"internal-*": "deny"` style skill access control. Crabcode loads all skills unconditionally. | +| **M9** | **Plan/Build mode not user-toggleable mid-conversation** | `src/app.rs` (streaming setup) | Agent mode is set once at stream start. User should be able to toggle plan/build during conversations. | + +### LOW + +| # | Gap | Location | Notes | +|---|-----|----------|-------| +| **L1** | **No task permission controls** | `src/tools/task.rs` | Primary agent can always invoke any subagent. OpenCode has per-agent `task_permissions` to restrict which subagents an agent can spawn. | +| **L2** | **No agent color theming** | `src/agent/config.rs` | Per-agent color for UI differentiation of which agent is speaking. | +| **L3** | **No agent creation wizard** | New: command handler | `opencode agent create` interactive wizard missing. UX feature but tied to multi-agent config. | +| **L4** | **No per-agent top_p** | `src/agent/config.rs` | Per-agent LLM sampling parameter. | diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 0066dff..0d3588e 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -45,3 +45,12 @@ - [ ] Parity: Like opencode, I wanna be able to queue messages. By sending some message even though it's still streaming, won't stop the agent, will just keep going. - [ ] Markdown: Proper Table rendering. + +- [ ] Rendering: Thinking Rendering always has this massive space below it, even if the agent didn't really think much. + +- [ ] Tool call rendering: + - [ ] editing files w/ diffs, like opencode does. + - [ ] todowrite - better looking, like opencode does. + - [ ] rendering subagents - just like opencode, clickable to go into their page.. OR I can do `ctrl-x ↓` to go into it if there's a subagent running. I can also switch between subagents with `←` and `→` + +- [ ] Bug: I can type a command see autosuggest, but can't press 'enter' to run the command. Pls fix. diff --git a/aisdk/Cargo.toml b/aisdk/Cargo.toml new file mode 100644 index 0000000..111ecff --- /dev/null +++ b/aisdk/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "aisdk" +version = "0.1.0" +edition = "2021" +description = "Minimal LLM SDK for crabcode" +license = "MIT" + +[dependencies] +reqwest = { version = "0.12", features = ["json", "stream"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +schemars = "1.0" +tokio = { version = "1.40", features = ["sync", "rt", "macros"] } +futures = "0.3" +thiserror = "1.0" +eventsource-stream = "0.2" +async-trait = "0.1" +derive_builder = "0.20" diff --git a/aisdk/src/chunk.rs b/aisdk/src/chunk.rs new file mode 100644 index 0000000..ee68f8f --- /dev/null +++ b/aisdk/src/chunk.rs @@ -0,0 +1,11 @@ +#[derive(Debug, Clone)] +pub enum ChunkType { + Start, + Text(String), + Reasoning(String), + ToolCall(String), + End(String), + Failed(String), + Incomplete(String), + NotSupported(String), +} diff --git a/aisdk/src/error.rs b/aisdk/src/error.rs new file mode 100644 index 0000000..2a7b92d --- /dev/null +++ b/aisdk/src/error.rs @@ -0,0 +1,27 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Stream error: {0}")] + Stream(String), + + #[error("Provider error: {0}")] + Provider(String), + + #[error("Missing field: {0}")] + MissingField(String), + + #[error("Invalid input: {0}")] + InvalidInput(String), + + #[error("Tool call error: {0}")] + ToolCall(String), +} + +pub type Result = std::result::Result; diff --git a/aisdk/src/lib.rs b/aisdk/src/lib.rs new file mode 100644 index 0000000..442e15e --- /dev/null +++ b/aisdk/src/lib.rs @@ -0,0 +1,51 @@ +pub mod message; +pub mod tool; +pub mod chunk; +pub mod stop; +pub mod response; +pub mod error; +pub mod provider; +pub mod providers; + +pub mod core { + pub use crate::chunk::ChunkType; + pub use crate::message::Message; + pub use crate::response::StreamTextResponse; + pub use crate::stop::{step_count_is, StopReason}; + pub use crate::tool::Tool; + + pub mod language_model { + pub use crate::chunk::ChunkType as LanguageModelStreamChunkType; + pub use crate::response::LanguageModelStream; + pub use crate::stop::StopReason; + pub use crate::stop::step_count_is; + } + + pub mod utils { + pub use crate::stop::step_count_is; + } + + pub mod capabilities { + pub use crate::provider::DynamicModel; + } + + pub mod tools { + pub use crate::tool::ToolExecute; + } + + pub mod chunk { + pub use crate::chunk::ChunkType; + } + + pub mod response { + pub use crate::response::{stream_with_tools, LanguageModelStream, StreamTextResponse}; + } + + pub mod stop { + pub use crate::stop::{step_count_is, StopReason}; + } +} + +pub use crate::core::*; + +pub use crate::providers::{Anthropic, OpenAI, OpenAICompatible}; diff --git a/aisdk/src/message.rs b/aisdk/src/message.rs new file mode 100644 index 0000000..396cfc8 --- /dev/null +++ b/aisdk/src/message.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "role")] +pub enum Message { + #[serde(rename = "system")] + System(SystemMessage), + #[serde(rename = "user")] + User(UserMessage), + #[serde(rename = "assistant")] + Assistant(AssistantMessage), +} + +impl Message { + pub fn system(content: impl Into) -> Self { + Self::System(SystemMessage { + content: content.into(), + }) + } + + pub fn user(content: impl Into) -> Self { + Self::User(UserMessage { + content: content.into(), + }) + } + + pub fn assistant(content: impl Into) -> Self { + Self::Assistant(AssistantMessage { + content: content.into(), + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemMessage { + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserMessage { + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssistantMessage { + pub content: String, +} + +impl From for SystemMessage { + fn from(content: String) -> Self { + Self { content } + } +} + +impl From<&str> for SystemMessage { + fn from(content: &str) -> Self { + Self { + content: content.to_string(), + } + } +} + +impl From for UserMessage { + fn from(content: String) -> Self { + Self { content } + } +} + +impl From<&str> for UserMessage { + fn from(content: &str) -> Self { + Self { + content: content.to_string(), + } + } +} + +impl From for AssistantMessage { + fn from(content: String) -> Self { + Self { content } + } +} + +impl From<&str> for AssistantMessage { + fn from(content: &str) -> Self { + Self { + content: content.to_string(), + } + } +} diff --git a/aisdk/src/provider.rs b/aisdk/src/provider.rs new file mode 100644 index 0000000..7713f79 --- /dev/null +++ b/aisdk/src/provider.rs @@ -0,0 +1,34 @@ +use crate::chunk::ChunkType; +use crate::error::Result; +use crate::message::Message; +use crate::tool::Tool; +use async_trait::async_trait; +use futures::Stream; +use std::collections::HashMap; +use std::pin::Pin; + +#[derive(Debug, Clone)] +pub struct DynamicModel; + +#[derive(Debug, Clone)] +pub struct ProviderConfig { + pub base_url: String, + pub api_key: String, + pub model_name: String, + pub provider_name: String, +} + +#[async_trait] +pub trait Provider: Send + Sync + std::fmt::Debug + Clone + 'static { + fn name(&self) -> &str; + fn model_name(&self) -> &str; + async fn stream_text( + &self, + messages: &[Message], + tools: &[Tool], + headers: &HashMap, + ) -> Result; +} + +pub type ProviderStream = + Pin> + Send>>; diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs new file mode 100644 index 0000000..820ae1b --- /dev/null +++ b/aisdk/src/providers/anthropic.rs @@ -0,0 +1,234 @@ +use crate::chunk::ChunkType; +use crate::error::{Error, Result}; +use crate::message::Message; +use crate::provider::{Provider, ProviderStream}; +use crate::tool::Tool; +use async_trait::async_trait; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct Anthropic { + base_url: String, + api_key: String, + model_name: String, + provider_name: String, +} + +impl Anthropic { + pub fn builder() -> AnthropicBuilder { + AnthropicBuilder::default() + } +} + +#[derive(Default)] +pub struct AnthropicBuilder { + base_url: Option, + api_key: Option, + model_name: Option, + provider_name: Option, +} + +impl AnthropicBuilder { + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = Some(url.into()); + self + } + + pub fn api_key(mut self, key: impl Into) -> Self { + self.api_key = Some(key.into()); + self + } + + pub fn model_name(mut self, name: impl Into) -> Self { + self.model_name = Some(name.into()); + self + } + + pub fn provider_name(mut self, name: impl Into) -> Self { + self.provider_name = Some(name.into()); + self + } + + pub fn build(self) -> Result { + Ok(Anthropic { + base_url: self.base_url.ok_or(Error::MissingField("base_url".into()))?, + api_key: self.api_key.ok_or(Error::MissingField("api_key".into()))?, + model_name: self.model_name.ok_or(Error::MissingField("model_name".into()))?, + provider_name: self.provider_name.unwrap_or_else(|| "anthropic".to_string()), + }) + } +} + +#[async_trait] +impl Provider for Anthropic { + fn name(&self) -> &str { + &self.provider_name + } + + fn model_name(&self) -> &str { + &self.model_name + } + + async fn stream_text( + &self, + messages: &[Message], + tools: &[Tool], + _headers: &HashMap, + ) -> Result { + let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/')); + + let system_prompts: Vec = messages + .iter() + .filter_map(|m| match m { + Message::System(s) => Some(serde_json::json!({ + "type": "text", + "text": s.content, + })), + _ => None, + }) + .collect(); + + let user_messages: Vec = messages + .iter() + .filter_map(|m| match m { + Message::User(u) => Some(serde_json::json!({ + "role": "user", + "content": u.content, + })), + Message::Assistant(a) => Some(serde_json::json!({ + "role": "assistant", + "content": a.content, + })), + _ => None, + }) + .collect(); + + let tool_params: Vec = tools + .iter() + .map(|t| { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + serde_json::json!({ + "name": t.name, + "description": t.description, + "input_schema": schema, + }) + }) + .collect(); + + let mut body = serde_json::json!({ + "model": self.model_name, + "messages": user_messages, + "max_tokens": 32000, + "stream": true, + }); + + if !system_prompts.is_empty() { + body["system"] = serde_json::Value::Array(system_prompts); + } + + if !tool_params.is_empty() { + body["tools"] = serde_json::Value::Array(tool_params); + } + + let mut request_headers = reqwest::header::HeaderMap::new(); + request_headers.insert( + reqwest::header::CONTENT_TYPE, + "application/json".parse().unwrap(), + ); + request_headers.insert("x-api-key", self.api_key.parse().unwrap()); + request_headers.insert("anthropic-version", "2023-06-01".parse().unwrap()); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; + let response = client + .post(&url) + .headers(request_headers) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(Error::Provider(format!( + "Anthropic API error {}: {}", + status, text + ))); + } + + let stream = response + .bytes_stream() + .eventsource() + .filter_map(|ev| { + match ev { + Ok(event) => { + let event_type = event.event.as_str(); + let data = &event.data; + + if data.is_empty() { + return futures::future::ready(None); + } + + match serde_json::from_str::(data) { + Ok(value) => match event_type { + "content_block_delta" => { + let delta = &value["delta"]; + match delta["type"].as_str() { + Some("text_delta") => { + futures::future::ready( + delta["text"].as_str().map(|t| { + Ok(ChunkType::Text(t.to_string())) + }), + ) + } + Some("thinking_delta") => { + futures::future::ready( + delta["thinking"].as_str().map(|t| { + Ok(ChunkType::Reasoning(t.to_string())) + }), + ) + } + Some("input_json_delta") => { + futures::future::ready( + delta["partial_json"].as_str().map(|j| { + Ok(ChunkType::ToolCall(j.to_string())) + }), + ) + } + _ => futures::future::ready(None), + } + } + "message_delta" => { + // Stream exhausts naturally after message_stop + futures::future::ready(None) + } + "error" => { + let error_msg = value["error"]["message"] + .as_str() + .unwrap_or("Unknown error"); + futures::future::ready(Some(Ok(ChunkType::Failed( + error_msg.to_string(), + )))) + } + _ => futures::future::ready(None), + }, + Err(e) => futures::future::ready(Some(Ok(ChunkType::Failed( + format!("Invalid SSE data: {}", e), + )))), + } + } + Err(e) => { + let err = format!("SSE error: {}", e); + futures::future::ready(Some(Ok(ChunkType::Failed(err)))) + } + } + }) + .boxed(); + + Ok(stream) + } +} diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs new file mode 100644 index 0000000..87d08d0 --- /dev/null +++ b/aisdk/src/providers/compatible.rs @@ -0,0 +1,258 @@ +use crate::chunk::ChunkType; +use crate::error::{Error, Result}; +use crate::message::Message; +use crate::provider::{Provider, ProviderStream}; +use crate::tool::Tool; +use async_trait::async_trait; +use eventsource_stream::Eventsource; +use futures::stream; +use futures::StreamExt; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct OpenAICompatible { + base_url: String, + api_key: String, + model_name: String, + provider_name: String, +} + +impl OpenAICompatible { + pub fn builder() -> OpenAICompatibleBuilder { + OpenAICompatibleBuilder::default() + } +} + +#[derive(Default)] +pub struct OpenAICompatibleBuilder { + base_url: Option, + api_key: Option, + model_name: Option, + provider_name: Option, +} + +impl OpenAICompatibleBuilder { + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = Some(url.into()); + self + } + + pub fn api_key(mut self, key: impl Into) -> Self { + self.api_key = Some(key.into()); + self + } + + pub fn model_name(mut self, name: impl Into) -> Self { + self.model_name = Some(name.into()); + self + } + + pub fn provider_name(mut self, name: impl Into) -> Self { + self.provider_name = Some(name.into()); + self + } + + pub fn build(self) -> Result { + Ok(OpenAICompatible { + base_url: self.base_url.ok_or(Error::MissingField("base_url".into()))?, + api_key: self.api_key.ok_or(Error::MissingField("api_key".into()))?, + model_name: self.model_name.ok_or(Error::MissingField("model_name".into()))?, + provider_name: self + .provider_name + .unwrap_or_else(|| "openai-compatible".to_string()), + }) + } +} + +#[async_trait] +impl Provider for OpenAICompatible { + fn name(&self) -> &str { + &self.provider_name + } + + fn model_name(&self) -> &str { + &self.model_name + } + + async fn stream_text( + &self, + messages: &[Message], + tools: &[Tool], + _headers: &HashMap, + ) -> Result { + let base = self.base_url.trim_end_matches('/'); + let url = if has_version_segment(base) { + format!("{}/chat/completions", base) + } else { + format!("{}/v1/chat/completions", base) + }; + + let chat_messages: Vec = messages + .iter() + .map(|m| match m { + Message::System(s) => serde_json::json!({ + "role": "system", + "content": s.content, + }), + Message::User(u) => serde_json::json!({ + "role": "user", + "content": u.content, + }), + Message::Assistant(a) => serde_json::json!({ + "role": "assistant", + "content": a.content, + }), + }) + .collect(); + + let tool_params: Vec = tools + .iter() + .map(|t| { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": schema, + } + }) + }) + .collect(); + + let mut body = serde_json::json!({ + "model": self.model_name, + "messages": chat_messages, + "stream": true, + }); + + if !tool_params.is_empty() { + body["tools"] = serde_json::Value::Array(tool_params); + } + + let mut request_headers = reqwest::header::HeaderMap::new(); + request_headers.insert( + reqwest::header::CONTENT_TYPE, + "application/json".parse().unwrap(), + ); + + if !self.api_key.is_empty() { + request_headers.insert( + "Authorization", + format!("Bearer {}", self.api_key).parse().unwrap(), + ); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; + let response = client + .post(&url) + .headers(request_headers) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(Error::Provider(format!("API error {}: {}", status, text))); + } + + let stream = response + .bytes_stream() + .eventsource() + .map(|ev| { + match ev { + Ok(event) => process_sse_data(&event.data), + Err(e) => vec![Ok(ChunkType::Failed(format!("SSE error: {}", e)))], + } + }) + .flat_map(|v| stream::iter(v)) + .boxed(); + + Ok(stream) + } +} + +fn process_sse_data(data: &str) -> Vec> { + // [DONE] is ignored — the HTTP stream end signals completion. + if data == "[DONE]" || data.is_empty() { + return vec![]; + } + + let value: serde_json::Value = match serde_json::from_str(data) { + Ok(v) => v, + Err(e) => return vec![Ok(ChunkType::Failed(format!("Invalid SSE data: {}", e)))], + }; + + if let Some(error) = value["error"].as_object() { + let msg = error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return vec![Ok(ChunkType::Failed(msg.to_string()))]; + } + + let Some(choices) = value["choices"].as_array() else { + return vec![]; + }; + + if choices.is_empty() { + return vec![]; + } + + let choice = &choices[0]; + let finish_reason = choice["finish_reason"].as_str().unwrap_or(""); + let mut chunks = Vec::new(); + + // Emit text delta first (may coexist with finish_reason) + if let Some(delta) = choice["delta"]["content"].as_str() { + if !delta.is_empty() { + chunks.push(Ok(ChunkType::Text(delta.to_string()))); + } + } + + // Emit reasoning delta + if let Some(reasoning) = choice["delta"]["reasoning_content"].as_str() { + if !reasoning.is_empty() { + chunks.push(Ok(ChunkType::Reasoning(reasoning.to_string()))); + } + } + + // Emit tool calls on tool_calls finish_reason. Stream exhausts naturally + // for all other finish_reasons — no explicit End chunk needed. + if finish_reason == "tool_calls" || finish_reason == "function_call" { + if let Some(tool_calls) = choice["delta"]["tool_calls"].as_array() { + if !tool_calls.is_empty() { + let json = serde_json::to_string(tool_calls).unwrap_or_default(); + chunks.push(Ok(ChunkType::ToolCall(json))); + } + } + } + + chunks +} + +fn has_version_segment(base_url: &str) -> bool { + // Check if the URL path already contains a /vN segment (e.g., /v4, /v1) + if let Some(pos) = base_url.find("://") { + let after_scheme = &base_url[pos + 3..]; + if let Some(path_start) = after_scheme.find('/') { + let path = &after_scheme[path_start..]; + // Match /vN where N is one or more digits, followed by / or end of string + let bytes = path.as_bytes(); + for i in 0..bytes.len().saturating_sub(2) { + if bytes[i] == b'/' + && bytes[i + 1] == b'v' + && bytes[i + 2].is_ascii_digit() + && (i + 3 >= bytes.len() || bytes[i + 3] == b'/') + { + return true; + } + } + } + } + false +} diff --git a/aisdk/src/providers/mod.rs b/aisdk/src/providers/mod.rs new file mode 100644 index 0000000..34b4bd5 --- /dev/null +++ b/aisdk/src/providers/mod.rs @@ -0,0 +1,7 @@ +pub mod openai; +pub mod anthropic; +pub mod compatible; + +pub use openai::OpenAI; +pub use anthropic::Anthropic; +pub use compatible::OpenAICompatible; diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs new file mode 100644 index 0000000..f16495e --- /dev/null +++ b/aisdk/src/providers/openai.rs @@ -0,0 +1,355 @@ +use crate::chunk::ChunkType; +use crate::error::{Error, Result}; +use crate::message::Message; +use crate::provider::{Provider, ProviderStream}; +use crate::tool::Tool; +use async_trait::async_trait; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct OpenAI { + base_url: String, + api_key: String, + model_name: String, + provider_name: String, + responses_path: String, + headers: HashMap, + store_override: Option, + strip_system_and_developer_messages: bool, + tool_strict_override: Option, + default_instructions: Option, +} + +impl OpenAI { + pub fn builder() -> OpenAIBuilder { + OpenAIBuilder::default() + } +} + +#[derive(Default)] +pub struct OpenAIBuilder { + base_url: Option, + api_key: Option, + model_name: Option, + provider_name: Option, + responses_path: String, + headers: HashMap, + store_override: Option, + strip_system_and_developer_messages: bool, + tool_strict_override: Option, + default_instructions: Option, +} + +impl OpenAIBuilder { + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = Some(url.into()); + self + } + + pub fn api_key(mut self, key: impl Into) -> Self { + self.api_key = Some(key.into()); + self + } + + pub fn model_name(mut self, name: impl Into) -> Self { + self.model_name = Some(name.into()); + self + } + + pub fn provider_name(mut self, name: impl Into) -> Self { + self.provider_name = Some(name.into()); + self + } + + pub fn responses_path(mut self, path: impl Into) -> Self { + self.responses_path = path.into(); + self + } + + pub fn headers(mut self, headers: HashMap) -> Self { + self.headers = headers; + self + } + + pub fn store_override(mut self, store: bool) -> Self { + self.store_override = Some(store); + self + } + + pub fn strip_system_and_developer_messages(mut self, enabled: bool) -> Self { + self.strip_system_and_developer_messages = enabled; + self + } + + pub fn tool_strict_override(mut self, strict: bool) -> Self { + self.tool_strict_override = Some(strict); + self + } + + pub fn default_instructions(mut self, instructions: impl Into) -> Self { + self.default_instructions = Some(instructions.into()); + self + } + + pub fn build(self) -> Result { + let base_url = self.base_url.ok_or(Error::MissingField("base_url".into()))?; + let api_key = self.api_key.ok_or(Error::MissingField("api_key".into()))?; + let model_name = self.model_name.ok_or(Error::MissingField("model_name".into()))?; + let provider_name = self.provider_name.unwrap_or_else(|| "openai".to_string()); + + let responses_path = { + let trimmed = self.responses_path.trim(); + if trimmed.is_empty() { + "/v1/responses".to_string() + } else if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{trimmed}") + } + }; + + Ok(OpenAI { + base_url, + api_key, + model_name, + provider_name, + responses_path, + headers: self.headers, + store_override: self.store_override, + strip_system_and_developer_messages: self.strip_system_and_developer_messages, + tool_strict_override: self.tool_strict_override, + default_instructions: self.default_instructions, + }) + } +} + +#[async_trait] +impl Provider for OpenAI { + fn name(&self) -> &str { + &self.provider_name + } + + fn model_name(&self) -> &str { + &self.model_name + } + + async fn stream_text( + &self, + messages: &[Message], + tools: &[Tool], + headers: &HashMap, + ) -> Result { + let url = format!( + "{}{}", + self.base_url.trim_end_matches('/'), + self.responses_path + ); + + let mut request_headers = reqwest::header::HeaderMap::new(); + request_headers.insert( + reqwest::header::CONTENT_TYPE, + "application/json".parse().unwrap(), + ); + + if !self.api_key.is_empty() { + request_headers.insert( + "Authorization", + format!("Bearer {}", self.api_key).parse().unwrap(), + ); + } + + for (k, v) in &self.headers { + if let (Ok(name), Ok(value)) = ( + reqwest::header::HeaderName::from_bytes(k.as_bytes()), + reqwest::header::HeaderValue::from_str(v), + ) { + request_headers.insert(name, value); + } + } + + for (k, v) in headers { + if let (Ok(name), Ok(value)) = ( + reqwest::header::HeaderName::from_bytes(k.as_bytes()), + reqwest::header::HeaderValue::from_str(v), + ) { + request_headers.insert(name, value); + } + } + + let input = build_openai_messages(messages, self.strip_system_and_developer_messages); + + let tool_params: Vec = tools + .iter() + .map(|t| { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + let mut tool = serde_json::json!({ + "type": "function", + "name": t.name, + "description": t.description, + "parameters": schema, + }); + + if let Some(strict) = self.tool_strict_override { + tool = serde_json::json!({ + "type": "function", + "name": t.name, + "strict": strict, + "parameters": schema, + "description": t.description, + }); + } + + tool + }) + .collect(); + + let mut body = serde_json::json!({ + "model": self.model_name, + "input": input, + "stream": true, + }); + + if !tool_params.is_empty() { + body["tools"] = serde_json::Value::Array(tool_params); + } + + if let Some(instructions) = &self.default_instructions { + body["instructions"] = serde_json::Value::String(instructions.clone()); + } + + if let Some(store) = self.store_override { + body["store"] = serde_json::Value::Bool(store); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; + let response = client + .post(&url) + .headers(request_headers) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(Error::Provider(format!( + "OpenAI API error {}: {}", + status, text + ))); + } + + let stream = response + .bytes_stream() + .eventsource() + .filter_map(|ev| { + match ev { + Ok(event) => { + let data = &event.data; + // [DONE] / empty data → stream exhausts naturally + if data == "[DONE]" || data.is_empty() { + return futures::future::ready(None); + } + + match serde_json::from_str::(data) { + Ok(value) => { + let event_type = value["type"].as_str().unwrap_or(""); + + match event_type { + "response.output_text.delta" => { + let delta = value["delta"].as_str().unwrap_or(""); + futures::future::ready(Some(Ok(ChunkType::Text( + delta.to_string(), + )))) + } + "response.reasoning_summary_text.delta" => { + let delta = value["delta"].as_str().unwrap_or(""); + futures::future::ready(Some(Ok(ChunkType::Reasoning( + delta.to_string(), + )))) + } + "response.completed" => { + let resp = &value["response"]; + if let Some(error) = resp.get("error") { + if let Some(code) = error.get("code") { + return futures::future::ready(Some(Ok( + ChunkType::Failed(code.to_string()), + ))); + } + } + // Stream exhausts naturally — no End chunk forwarded + futures::future::ready(None) + } + "response.incomplete" => { + futures::future::ready(Some(Ok(ChunkType::Incomplete( + "Response incomplete".to_string(), + )))) + } + "response.failed" => { + futures::future::ready(Some(Ok(ChunkType::Failed( + "Response failed".to_string(), + )))) + } + _ => { + if event_type.contains("tool_call") { + futures::future::ready(Some(Ok(ChunkType::ToolCall( + data.clone(), + )))) + } else { + futures::future::ready(None) + } + } + } + } + Err(e) => futures::future::ready(Some(Ok(ChunkType::Failed(format!( + "Invalid SSE data: {}", + e + ))))), + } + } + Err(e) => { + let err = format!("SSE error: {}", e); + futures::future::ready(Some(Ok(ChunkType::Failed(err)))) + } + } + }) + .boxed(); + + Ok(stream) + } +} + +fn build_openai_messages( + messages: &[Message], + strip_system: bool, +) -> Vec { + messages + .iter() + .filter_map(|msg| { + if strip_system { + if let Message::System(_) = msg { + return None; + } + } + match msg { + Message::System(s) => Some(serde_json::json!({ + "role": "system", + "content": s.content, + })), + Message::User(u) => Some(serde_json::json!({ + "role": "user", + "content": u.content, + })), + Message::Assistant(a) => Some(serde_json::json!({ + "role": "assistant", + "content": a.content, + })), + } + }) + .collect() +} + diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs new file mode 100644 index 0000000..30cc4a4 --- /dev/null +++ b/aisdk/src/response.rs @@ -0,0 +1,244 @@ +use crate::chunk::ChunkType; +use crate::error::Result; +use crate::message::Message; +use crate::provider::Provider; +use crate::stop::{StopReason, StopWhenFn}; +use crate::tool::Tool; +use futures::StreamExt; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::mpsc; + +pub struct StreamTextResponse { + pub stream: LanguageModelStream, + stop_reason: Arc>>, + messages: Arc>>, + _handles: Vec>, +} + +pub struct LanguageModelStream { + rx: mpsc::UnboundedReceiver, +} + +impl futures::Stream for LanguageModelStream { + type Item = ChunkType; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.rx.poll_recv(cx) + } +} + +impl StreamTextResponse { + fn create() -> (Self, mpsc::UnboundedSender) { + let (tx, rx) = mpsc::unbounded_channel(); + let stop_reason = Arc::new(tokio::sync::Mutex::new(None)); + let messages = Arc::new(tokio::sync::Mutex::new(Vec::new())); + + ( + Self { + stream: LanguageModelStream { rx }, + stop_reason: stop_reason.clone(), + messages: messages.clone(), + _handles: Vec::new(), + }, + tx, + ) + } + + pub async fn stop_reason(&self) -> Option { + self.stop_reason.lock().await.clone() + } + + pub async fn messages(&self) -> Vec { + self.messages.lock().await.clone() + } + + fn add_handle(&mut self, handle: tokio::task::JoinHandle<()>) { + self._handles.push(handle); + } +} + +pub async fn stream_with_tools( + provider: P, + messages: Vec, + tools: Vec, + max_steps: Option, + stop_when: Option, + headers: HashMap, +) -> Result { + let (mut response, tx) = StreamTextResponse::create(); + let _ = tx.send(ChunkType::Start); + + let tx_loop = tx.clone(); + let stop_reason_arc = response.stop_reason.clone(); + let messages_arc = response.messages.clone(); + let provider_clone = provider.clone(); + + let handle = tokio::spawn(async move { + let mut current_messages = messages; + let mut step_idx: usize = 0; + let max_steps = max_steps.unwrap_or(usize::MAX); + + loop { + step_idx += 1; + + if step_idx > max_steps { + let _ = tx_loop.send(ChunkType::Incomplete("Max steps reached".to_string())); + *stop_reason_arc.lock().await = Some(StopReason::Hook); + break; + } + + if let Some(ref hook) = stop_when { + if hook(step_idx) { + *stop_reason_arc.lock().await = Some(StopReason::Hook); + break; + } + } + + let stream_result = provider_clone + .stream_text(¤t_messages, &tools, &headers) + .await; + + let mut stream = match stream_result { + Ok(s) => s, + Err(e) => { + let _ = tx_loop.send(ChunkType::Failed(e.to_string())); + *stop_reason_arc.lock().await = Some(StopReason::Error(e.to_string())); + break; + } + }; + + let mut has_tool_call = false; + let mut tool_calls_to_execute: Vec<(String, String, serde_json::Value)> = Vec::new(); + let mut accumulated_text = String::new(); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(ChunkType::Text(text)) => { + accumulated_text.push_str(&text); + let _ = tx_loop.send(ChunkType::Text(text)); + } + Ok(ChunkType::Reasoning(reasoning)) => { + let _ = tx_loop.send(ChunkType::Reasoning(reasoning)); + } + Ok(ChunkType::ToolCall(json_str)) => { + has_tool_call = true; + if let Ok(parsed) = parse_tool_calls(&json_str) { + for (id, name, args) in parsed { + tool_calls_to_execute.push((id, name, args)); + } + } + } + Ok(ChunkType::End(_content)) => { + // Processed internally — NOT forwarded to tx_loop. + // Forwarding End would cause relay_stream_to_sender + // to return Ended prematurely, dropping the channel + // before tool execution / subsequent steps. + } + Ok(ChunkType::Incomplete(msg)) => { + let _ = tx_loop.send(ChunkType::Incomplete(msg)); + } + Ok(ChunkType::Failed(err)) => { + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + Ok(ChunkType::Start) => { + let _ = tx_loop.send(ChunkType::Start); + } + Ok(ChunkType::NotSupported(msg)) => { + let _ = tx_loop.send(ChunkType::NotSupported(msg)); + } + Err(e) => { + let err = e.to_string(); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + } + } + + // Build assistant message from accumulated text deltas + let assistant_text = accumulated_text.trim().to_string(); + if !assistant_text.is_empty() { + let assistant_msg = Message::assistant(&assistant_text); + current_messages.push(assistant_msg.clone()); + messages_arc.lock().await.push(assistant_msg); + } + + if !has_tool_call { + *stop_reason_arc.lock().await = Some(StopReason::Finish); + break; + } + + for (call_id, tool_name, args) in &tool_calls_to_execute { + let tool = tools.iter().find(|t| &t.name == tool_name); + match tool { + Some(t) => match t.execute.call(args.clone()).await { + Ok(result) => { + let _ = tx_loop.send(ChunkType::Text(format!( + "\n[toolu_bdrk_01{}...] ", + &call_id[..8.min(call_id.len())] + ))); + current_messages.push(Message::assistant(format!( + "[tool result: {}] {}", + tool_name, result + ))); + messages_arc.lock().await.push(Message::assistant(format!( + "{{\"tool_call_id\":\"{}\",\"role\":\"tool\",\"name\":\"{}\",\"content\":{}}}", + call_id, tool_name, result + ))); + } + Err(e) => { + let _ = tx_loop.send(ChunkType::Failed(format!( + "Tool '{}' error: {}", tool_name, e + ))); + } + }, + None => { + let _ = tx_loop.send(ChunkType::Failed(format!( + "Tool not found: {}", tool_name + ))); + } + } + } + } + let _ = std::fs::write("aisdk_debug.log", "spawned task done, dropping tx\n"); + }); + + response.add_handle(handle); + Ok(response) +} + +fn parse_tool_calls( + json_str: &str, +) -> std::result::Result, serde_json::Error> { + let parsed: serde_json::Value = serde_json::from_str(json_str)?; + let mut results = Vec::new(); + + if let Some(arr) = parsed.as_array() { + for item in arr { + if let (Some(id), Some(function)) = + (item.get("id").and_then(|v| v.as_str()), item.get("function")) + { + let name = function + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let args = function + .get("arguments") + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(serde_json::Value::Object(Default::default())); + results.push((id.to_string(), name, args)); + } + } + } + + Ok(results) +} diff --git a/aisdk/src/stop.rs b/aisdk/src/stop.rs new file mode 100644 index 0000000..98d44bd --- /dev/null +++ b/aisdk/src/stop.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq)] +pub enum StopReason { + Finish, + Hook, + Error(String), + Other(String), +} + +pub fn step_count_is(max_steps: usize) -> StopWhenFn { + let counter = std::sync::atomic::AtomicUsize::new(0); + Arc::new(move |step_count: usize| { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + step_count >= max_steps + }) +} + +pub type StopWhenFn = Arc bool + Send + Sync>; diff --git a/aisdk/src/tool.rs b/aisdk/src/tool.rs new file mode 100644 index 0000000..c0564f2 --- /dev/null +++ b/aisdk/src/tool.rs @@ -0,0 +1,96 @@ +use schemars::Schema; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +pub type AsyncToolFn = + Arc Pin> + Send>> + Send + Sync>; + +#[derive(Clone)] +pub struct ToolExecute { + inner: AsyncToolFn, +} + +impl ToolExecute { + pub fn new(f: F) -> Self + where + F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + Self { + inner: Arc::new(move |v: serde_json::Value| Box::pin(f(v))), + } + } + + pub async fn call(&self, input: serde_json::Value) -> Result { + (self.inner)(input).await + } +} + +impl std::fmt::Debug for ToolExecute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ToolExecute").finish() + } +} + +#[derive(Clone)] +pub struct Tool { + pub name: String, + pub description: String, + pub input_schema: Schema, + pub execute: ToolExecute, +} + +impl std::fmt::Debug for Tool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Tool") + .field("name", &self.name) + .field("description", &self.description) + .finish() + } +} + +impl Tool { + pub fn builder() -> ToolBuilder { + ToolBuilder::default() + } +} + +#[derive(Default)] +pub struct ToolBuilder { + name: Option, + description: Option, + input_schema: Option, + execute: Option, +} + +impl ToolBuilder { + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn input_schema(mut self, schema: Schema) -> Self { + self.input_schema = Some(schema); + self + } + + pub fn execute(mut self, execute: ToolExecute) -> Self { + self.execute = Some(execute); + self + } + + pub fn build(self) -> Result { + Ok(Tool { + name: self.name.ok_or("name is required")?, + description: self.description.ok_or("description is required")?, + input_schema: self.input_schema.ok_or("input_schema is required")?, + execute: self.execute.ok_or("execute is required")?, + }) + } +} diff --git a/aisdk_debug.log b/aisdk_debug.log new file mode 100644 index 0000000..3511178 --- /dev/null +++ b/aisdk_debug.log @@ -0,0 +1 @@ +spawned task done, dropping tx diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index a86f36a..4bde6d9 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -123,10 +123,13 @@ pub async fn run_subagent( full_registry: &ToolRegistry, ) -> Result { use aisdk::core::{ - language_model::{LanguageModelStreamChunkType}, - DynamicModel, LanguageModelRequest, Message as AisdkMessage, + chunk::ChunkType, + response::{stream_with_tools, StreamTextResponse}, + Message as AisdkMessage, }; + use aisdk::{Anthropic, OpenAI, OpenAICompatible}; use futures::StreamExt; + use std::collections::HashMap; let session = get_llm_session().ok_or("LLM session not configured")?; let cwd = std::env::current_dir() @@ -150,13 +153,15 @@ pub async fn run_subagent( ); let messages = vec![ - AisdkMessage::System(system_prompt.into()), - AisdkMessage::User(user_content.into()), + AisdkMessage::system(system_prompt), + AisdkMessage::user(user_content), ]; - let mut response = match session.provider_kind { + let headers = HashMap::new(); + + let mut response: StreamTextResponse = match session.provider_kind { ProviderKind::OpenAICompatible => { - let provider = aisdk::providers::OpenAICompatible::::builder() + let provider = OpenAICompatible::builder() .base_url(&session.base_url) .model_name(&session.model) .provider_name(&session.provider_name) @@ -164,18 +169,12 @@ pub async fn run_subagent( .build() .map_err(|e| format!("Failed to build OpenAICompatible provider: {}", e))?; - let mut request = LanguageModelRequest::builder() - .model(provider) - .messages(messages); - - for tool in aisdk_tools { - request = request.with_tool(tool); - } - - request.build().stream_text().await.map_err(|e| format!("Stream error: {}", e))? + stream_with_tools(provider, messages, aisdk_tools, None, None, headers) + .await + .map_err(|e| format!("Stream error: {}", e))? } ProviderKind::Anthropic => { - let provider = aisdk::providers::Anthropic::::builder() + let provider = Anthropic::builder() .base_url(&session.base_url) .model_name(&session.model) .provider_name(&session.provider_name) @@ -183,18 +182,12 @@ pub async fn run_subagent( .build() .map_err(|e| format!("Failed to build Anthropic provider: {}", e))?; - let mut request = LanguageModelRequest::builder() - .model(provider) - .messages(messages); - - for tool in aisdk_tools { - request = request.with_tool(tool); - } - - request.build().stream_text().await.map_err(|e| format!("Stream error: {}", e))? + stream_with_tools(provider, messages, aisdk_tools, None, None, headers) + .await + .map_err(|e| format!("Stream error: {}", e))? } ProviderKind::OpenAI => { - let provider = aisdk::providers::OpenAI::::builder() + let provider = OpenAI::builder() .base_url(&session.base_url) .model_name(&session.model) .provider_name(&session.provider_name) @@ -202,15 +195,9 @@ pub async fn run_subagent( .build() .map_err(|e| format!("Failed to build OpenAI provider: {}", e))?; - let mut request = LanguageModelRequest::builder() - .model(provider) - .messages(messages); - - for tool in aisdk_tools { - request = request.with_tool(tool); - } - - request.build().stream_text().await.map_err(|e| format!("Stream error: {}", e))? + stream_with_tools(provider, messages, aisdk_tools, None, None, headers) + .await + .map_err(|e| format!("Stream error: {}", e))? } }; @@ -218,13 +205,13 @@ pub async fn run_subagent( while let Some(chunk) = response.stream.next().await { match chunk { - LanguageModelStreamChunkType::Text(text) => { + ChunkType::Text(text) => { collected_text.push_str(&text); } - LanguageModelStreamChunkType::Failed(err) => { + ChunkType::Failed(err) => { return Err(format!("Subagent streaming failed: {}", err)); } - LanguageModelStreamChunkType::End(_) => { + ChunkType::End(_) => { break; } _ => {} diff --git a/src/llm/client.rs b/src/llm/client.rs index b6e55ef..cf42ed8 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1,13 +1,10 @@ -use aisdk::{ - core::{ - capabilities::ToolCallSupport, - language_model::{LanguageModelStream, StopReason}, - utils::step_count_is, - DynamicModel, LanguageModel, LanguageModelRequest, LanguageModelStreamChunkType, - Message as AisdkMessage, StreamTextResponse, Tool, - }, - providers::{Anthropic, OpenAI, OpenAICompatible}, +use aisdk::core::{ + chunk::ChunkType, + response::{stream_with_tools, LanguageModelStream, StreamTextResponse}, + stop::{step_count_is, StopReason}, + Message as AisdkMessage, Tool, }; +use aisdk::{Anthropic, OpenAI, OpenAICompatible}; use futures::StreamExt; use std::{collections::HashMap, time::Instant}; use tokio_util::sync::CancellationToken; @@ -139,6 +136,11 @@ pub async fn stream_llm_with_cancellation( ) .await?; + let stop_reason = response.stop_reason().await; + let _ = log(&format!( + "Stream completed: outcome={stream_outcome:?}, stop_reason={stop_reason:?}, agent_max_steps={agent_max_steps:?}", + )); + if stream_outcome == StreamRelayOutcome::Ended { return Ok(()); } @@ -154,9 +156,7 @@ pub async fn stream_llm_with_cancellation( ); let mut follow_up_messages = response.messages().await; - follow_up_messages.push(AisdkMessage::Assistant( - MAX_STEPS_REACHED_PROMPT.to_string().into(), - )); + follow_up_messages.push(AisdkMessage::assistant(MAX_STEPS_REACHED_PROMPT)); let mut summary_response = stream_provider_request(&request_config, follow_up_messages, Vec::new(), None).await?; @@ -344,122 +344,70 @@ async fn stream_provider_request( tools: Vec, max_steps: Option, ) -> Result { + let stop_when = max_steps.map(|s| step_count_is(s)); + let headers = HashMap::new(); + match config.kind { ProviderKind::OpenAICompatible => { - let provider = build_openai_compatible_provider(config)?; - stream_with_model(provider, messages, tools, max_steps).await + let mut builder = OpenAICompatible::builder() + .base_url(&config.base_url) + .model_name(&config.model_name) + .provider_name(&config.provider_name); + if let Some(key) = config.api_key.as_deref() { + builder = builder.api_key(key); + } + let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; + stream_with_tools(provider, messages, tools, max_steps, stop_when, headers) + .await + .map_err(|e| Box::new(e) as DynError) } ProviderKind::Anthropic => { - let provider = build_anthropic_provider(config)?; - stream_with_model(provider, messages, tools, max_steps).await + let mut builder = Anthropic::builder() + .base_url(&config.base_url) + .model_name(&config.model_name) + .provider_name(&config.provider_name); + if let Some(key) = config.api_key.as_deref() { + builder = builder.api_key(key); + } + let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; + stream_with_tools(provider, messages, tools, max_steps, stop_when, headers) + .await + .map_err(|e| Box::new(e) as DynError) } ProviderKind::OpenAI => { - let provider = build_openai_provider(config)?; - stream_with_model(provider, messages, tools, max_steps).await - } - } -} - -async fn stream_with_model( - model: M, - messages: Vec, - tools: Vec, - max_steps: Option, -) -> Result -where - M: LanguageModel + ToolCallSupport, -{ - let mut builder = LanguageModelRequest::builder() - .model(model) - .messages(messages); - - if let Some(max_steps) = max_steps { - builder = builder.stop_when(step_count_is(max_steps)); - } - - for tool in tools { - builder = builder.with_tool(tool); - } - - let mut request = builder.build(); - request - .stream_text() - .await - .map_err(|e| Box::new(e) as DynError) -} - -fn build_openai_compatible_provider( - config: &ProviderRequestConfig, -) -> Result, DynError> { - let mut provider_builder = OpenAICompatible::::builder() - .base_url(&config.base_url) - .model_name(&config.model_name) - .provider_name(&config.provider_name); - - if let Some(key) = config.api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } - - provider_builder - .build() - .map_err(|e| Box::new(e) as DynError) -} - -fn build_anthropic_provider( - config: &ProviderRequestConfig, -) -> Result, DynError> { - let mut provider_builder = Anthropic::::builder() - .base_url(&config.base_url) - .model_name(&config.model_name) - .provider_name(&config.provider_name); - - if let Some(key) = config.api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } - - provider_builder - .build() - .map_err(|e| Box::new(e) as DynError) -} - -fn build_openai_provider(config: &ProviderRequestConfig) -> Result, DynError> { - let mut provider_builder = OpenAI::::builder() - .base_url(&config.base_url) - .model_name(&config.model_name) - .provider_name(&config.provider_name); - - if let Some(key) = config.api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } - - if let Some(responses_path) = &config.openai_options.response_path { - provider_builder = provider_builder.responses_path(responses_path); - } - - if config.openai_options.force_store_false { - provider_builder = provider_builder.store_override(false); - } - - if let Some(instructions) = &config.openai_options.default_instructions { - provider_builder = provider_builder.default_instructions(instructions.clone()); - } - - if config.openai_options.disallow_system_messages { - provider_builder = provider_builder.strip_system_and_developer_messages(true); - } + let mut builder = OpenAI::builder() + .base_url(&config.base_url) + .model_name(&config.model_name) + .provider_name(&config.provider_name); + if let Some(key) = config.api_key.as_deref() { + builder = builder.api_key(key); + } - if config.openai_options.force_tool_strict_false { - provider_builder = provider_builder.tool_strict_override(false); - } + if let Some(responses_path) = &config.openai_options.response_path { + builder = builder.responses_path(responses_path); + } + if config.openai_options.force_store_false { + builder = builder.store_override(false); + } + if let Some(instructions) = &config.openai_options.default_instructions { + builder = builder.default_instructions(instructions.clone()); + } + if config.openai_options.disallow_system_messages { + builder = builder.strip_system_and_developer_messages(true); + } + if config.openai_options.force_tool_strict_false { + builder = builder.tool_strict_override(false); + } + if !config.openai_options.additional_headers.is_empty() { + builder = builder.headers(config.openai_options.additional_headers.clone()); + } - if !config.openai_options.additional_headers.is_empty() { - provider_builder = - provider_builder.headers(config.openai_options.additional_headers.clone()); + let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; + stream_with_tools(provider, messages, tools, max_steps, stop_when, headers) + .await + .map_err(|e| Box::new(e) as DynError) + } } - - provider_builder - .build() - .map_err(|e| Box::new(e) as DynError) } async fn relay_stream_to_sender( @@ -469,26 +417,35 @@ async fn relay_stream_to_sender( token_count: &mut usize, start_time: &Instant, ) -> Result { - while let Some(chunk) = stream.next().await { - if cancel_token.is_cancelled() { - let _ = sender.send(crate::llm::ChunkMessage::Cancelled); - return Err(anyhow::anyhow!("Streaming cancelled by user").into()); - } + let _ = log("[RELAY] relay_stream_to_sender started"); + loop { + let chunk = tokio::select! { + _ = cancel_token.cancelled() => { + let _ = sender.send(crate::llm::ChunkMessage::Cancelled); + return Err(anyhow::anyhow!("Streaming cancelled by user").into()); + } + chunk = stream.next() => chunk, + }; + + let chunk = match chunk { + Some(c) => c, + None => break, + }; match chunk { - LanguageModelStreamChunkType::Text(text) => { + ChunkType::Text(text) => { *token_count += estimate_tokens(&text); let _ = sender.send(crate::llm::ChunkMessage::Text(text)); } - LanguageModelStreamChunkType::Reasoning(reasoning) => { + ChunkType::Reasoning(reasoning) => { *token_count += estimate_tokens(&reasoning); let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } - LanguageModelStreamChunkType::ToolCall(_tool_call) => { - // Tool execution is handled internally by aisdk::stream_text(). - // We intentionally don't surface argument deltas here. + ChunkType::ToolCall(_tool_call) => { + let _ = log("[RELAY] ToolCall chunk received"); } - LanguageModelStreamChunkType::End(_msg) => { + ChunkType::End(_msg) => { + let _ = log("[RELAY] End chunk — returning Ended"); let duration_ms = start_time.elapsed().as_millis() as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { token_count: *token_count, @@ -497,17 +454,20 @@ async fn relay_stream_to_sender( let _ = sender.send(crate::llm::ChunkMessage::End); return Ok(StreamRelayOutcome::Ended); } - LanguageModelStreamChunkType::Start => {} - LanguageModelStreamChunkType::Failed(err) => { + ChunkType::Start => { + let _ = log("[RELAY] Start chunk received"); + } + ChunkType::Failed(err) => { let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); let _ = log(&format!("Stream Chunk Failed {}", err)); return Err(anyhow::anyhow!("Streaming failed: {}", err).into()); } - LanguageModelStreamChunkType::Incomplete(_msg) => {} - LanguageModelStreamChunkType::NotSupported(_msg) => {} + ChunkType::Incomplete(_msg) => {} + ChunkType::NotSupported(_msg) => {} } } + let _ = log("[RELAY] stream exhausted — returning Exhausted"); Ok(StreamRelayOutcome::Exhausted) } @@ -516,25 +476,22 @@ async fn reached_step_limit(agent_max_steps: Option, response: &StreamTex } fn estimate_tokens(content: &str) -> usize { - // Estimate tokens: ~4 characters per token on average content.chars().count().max(1) / 4 } fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { - use aisdk::core::Message::{Assistant, System, User}; - let mut aisdk_messages = Vec::new(); for msg in messages { match msg.role { crate::session::types::MessageRole::System => { - aisdk_messages.push(System(msg.content.clone().into())); + aisdk_messages.push(AisdkMessage::system(msg.content.clone())); } crate::session::types::MessageRole::User => { - aisdk_messages.push(User(msg.content.clone().into())); + aisdk_messages.push(AisdkMessage::user(msg.content.clone())); } crate::session::types::MessageRole::Assistant => { - aisdk_messages.push(Assistant(msg.content.clone().into())); + aisdk_messages.push(AisdkMessage::assistant(msg.content.clone())); } crate::session::types::MessageRole::Tool => { continue; @@ -567,11 +524,6 @@ enum ProviderKind { impl ProviderKind { fn from_provider(_provider_name: &str, npm_package: &str) -> Self { - // Dirty: But add any workaround/overrides here in case npm_package can be treated differently. - // if provider_name == "kimi-for-coding" { - // return Self::OpenAICompatible; - // } - match npm_package { "@ai-sdk/openai-compatible" => Self::OpenAICompatible, "@ai-sdk/anthropic" => Self::Anthropic, diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 56e361d..e2c8f08 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -1,5 +1,6 @@ use crate::tools::{ToolContext, ToolRegistry}; -use aisdk::core::{tools::ToolExecute, Tool}; +use aisdk::core::tools::ToolExecute; +use aisdk::core::Tool; use schemars::Schema; use serde_json::Value; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -8,7 +9,6 @@ use crate::llm::ChunkSender; static TOOL_CALL_SEQ: AtomicUsize = AtomicUsize::new(0); -/// Convert our ToolRegistry to AISDK Tools pub async fn convert_to_aisdk_tools( registry: &ToolRegistry, sender: Option, @@ -34,8 +34,7 @@ pub async fn convert_to_aisdk_tools( let agent_mode = agent_mode.clone(); let permissions = permissions.clone(); - // Create the execute function - let execute = ToolExecute::new(Box::new(move |input: Value| { + let execute = ToolExecute::new(move |input: Value| { let tool_id = tool_id.clone(); let tool_id_for_exec = tool_id.clone(); let tool_id_for_ui = tool_id.clone(); @@ -47,130 +46,101 @@ pub async fn convert_to_aisdk_tools( let agent_mode = agent_mode.clone(); let permissions = permissions.clone(); - let call_seq = TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1; - let call_id = format!("call_{call_seq}"); - - if let Some(ref sender) = sender { - // Surface tool call start to the UI - let args = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()); - let _ = sender.send(crate::llm::ChunkMessage::ToolCalls(vec![ - crate::llm::ToolCall { - id: call_id.clone(), - call_type: "function".to_string(), - function: crate::llm::FunctionCall { - name: tool_id.clone(), - arguments: args, + async move { + let call_seq = TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1; + let call_id = format!("call_{call_seq}"); + + if let Some(ref sender) = sender { + let args = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()); + let _ = sender.send(crate::llm::ChunkMessage::ToolCalls(vec![ + crate::llm::ToolCall { + id: call_id.clone(), + call_type: "function".to_string(), + function: crate::llm::FunctionCall { + name: tool_id.clone(), + arguments: args, + }, }, - }, - ])); - } + ])); + } - let sender_for_block = sender.clone(); - let call_id_for_block = call_id.clone(); - let tool_id_for_ui_block = tool_id_for_ui.clone(); - - // aisdk tool execution is synchronous (Fn(Value) -> Result), - // but our tools are async. Bridge by blocking in-place on the current runtime. - let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async move { - let _ = crate::logging::log(&format!( - "[AISDK_TOOL] call {} args={} ", - tool_id_for_exec, input - )); + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] call {} args={}", + tool_id_for_exec, input + )); + + let handler = registry + .get(&tool_id_for_exec) + .await + .ok_or_else(|| format!("Tool '{}' not found", tool_id_for_exec))?; + + if let Err(e) = handler.validate(&input) { + return Err(format!("Validation error: {}", e)); + } + + permissions + .preflight( + &agent_mode, + &tool_id_for_exec, + &input, + sender.as_ref(), + ) + .await + .map_err(|e| format!("{}", e))?; + + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); + let ctx = ToolContext::new("session", "message", agent_mode.clone(), abort_rx); + + let tool_result = handler + .execute(input, &ctx) + .await + .map_err(|e| format!("Execution error: {}", e))?; - let handler = registry - .get(&tool_id_for_exec) - .await - .ok_or_else(|| format!("Tool '{}' not found", tool_id_for_exec))?; + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] result {} bytes={}", + tool_id_for_exec, + tool_result.output.len() + )); - if let Err(e) = handler.validate(&input) { - return Err(format!("Validation error: {}", e)); + if let Some(ref sender) = sender { + let preview_limit: usize = 4000; + let mut preview = tool_result.output.clone(); + if preview.len() > preview_limit { + let boundary = preview.floor_char_boundary(preview_limit); + preview.truncate(boundary); + preview.push_str("... (truncated)"); } - permissions - .preflight( - &agent_mode, - &tool_id_for_exec, - &input, - sender_for_block.as_ref(), - ) - .await - .map_err(|e| format!("{}", e))?; - - let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); - let ctx = ToolContext::new("session", "message", agent_mode.clone(), abort_rx); - - let tool_result = handler - .execute(input, &ctx) - .await - .map_err(|e| format!("Execution error: {}", e))?; - - let _ = crate::logging::log(&format!( - "[AISDK_TOOL] result {} bytes={}", - tool_id_for_exec, - tool_result.output.len() + let line_count = tool_result.output.lines().count(); + let meta = serde_json::Value::Object( + tool_result + .metadata + .into_iter() + .collect::>(), + ); + + let payload = serde_json::json!({ + "status": "ok", + "title": tool_result.title, + "output_preview": preview, + "line_count": line_count, + "metadata": meta, + }) + .to_string(); + + let _ = sender.send(crate::llm::ChunkMessage::ToolResult( + crate::llm::ToolCallResult { + tool_call_id: call_id.clone(), + role: "tool".to_string(), + name: tool_id_for_ui.clone(), + content: payload, + }, )); + } - if let Some(ref sender) = sender_for_block { - let preview_limit: usize = 4000; - let mut preview = tool_result.output.clone(); - if preview.len() > preview_limit { - let boundary = preview.floor_char_boundary(preview_limit); - preview.truncate(boundary); - preview.push_str("... (truncated)"); - } - - let line_count = tool_result.output.lines().count(); - let meta = serde_json::Value::Object( - tool_result - .metadata - .into_iter() - .collect::>(), - ); - - let payload = serde_json::json!({ - "status": "ok", - "title": tool_result.title, - "output_preview": preview, - "line_count": line_count, - "metadata": meta, - }) - .to_string(); - - let _ = sender.send(crate::llm::ChunkMessage::ToolResult( - crate::llm::ToolCallResult { - tool_call_id: call_id_for_block.clone(), - role: "tool".to_string(), - name: tool_id_for_ui_block.clone(), - content: payload, - }, - )); - } - - Ok(tool_result.output) - }) - }); - - if let (Err(err), Some(ref sender)) = (&result, sender.as_ref()) { - // Error path: emit structured error payload. - let payload = serde_json::json!({ - "status": "error", - "title": tool_description_for_ui, - "output_preview": format!("{}", err), - }) - .to_string(); - let _ = sender.send(crate::llm::ChunkMessage::ToolResult( - crate::llm::ToolCallResult { - tool_call_id: call_id.clone(), - role: "tool".to_string(), - name: tool_id_for_ui.clone(), - content: payload, - }, - )); + Ok(tool_result.output) } - - result - })); + }); // Build the tool schema from parameters let mut properties = serde_json::Map::new(); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index fcc94bd..166c926 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -232,6 +232,8 @@ impl Chat { self.add_message(msg); } + self.invalidate_cache(); + let now = std::time::Instant::now(); if self.streaming_start_time.is_none() { self.streaming_start_time = Some(now); @@ -275,9 +277,15 @@ impl Chat { fn compute_fingerprint(&self, max_width: usize) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); + // Bump this whenever rendering logic changes (tables, markdown, etc.) + const RENDER_VERSION: u64 = 1; + RENDER_VERSION.hash(&mut h); self.messages.len().hash(&mut h); for msg in &self.messages { msg.content.len().hash(&mut h); + if let Some(ref reasoning) = msg.reasoning { + reasoning.len().hash(&mut h); + } } max_width.hash(&mut h); h.finish() @@ -972,8 +980,10 @@ impl Chat { ))); } - // Add separator between reasoning and content - lines.push(Line::from("")); + // Add separator between reasoning and content (only if there's content) + if !message.content.is_empty() { + lines.push(Line::from("")); + } } } diff --git a/src/ui/markdown/mod.rs b/src/ui/markdown/mod.rs index 7bf4fc4..be5f445 100644 --- a/src/ui/markdown/mod.rs +++ b/src/ui/markdown/mod.rs @@ -1 +1,2 @@ pub mod streaming; +pub mod table; diff --git a/src/ui/markdown/streaming.rs b/src/ui/markdown/streaming.rs index 3149a3f..95db793 100644 --- a/src/ui/markdown/streaming.rs +++ b/src/ui/markdown/streaming.rs @@ -1,4 +1,5 @@ use crate::theme::ThemeColors; +use crate::ui::markdown::table::preprocess_tables; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, @@ -130,14 +131,18 @@ impl tui_markdown::StyleSheet for MarkdownStyleSheet { } /// Render markdown content to lines -/// This uses tui-markdown to parse and render the markdown +/// This uses tui-markdown to parse and render the markdown. +/// Tables are pre-processed and rendered with Unicode box-drawing characters. pub fn render_markdown( content: &str, max_width: usize, colors: &ThemeColors, ) -> Vec> { + // Pre-process tables: render them as Unicode box-drawing text + let processed = preprocess_tables(content, max_width); + let options = tui_markdown::Options::new(MarkdownStyleSheet::new(*colors)); - let text = tui_markdown::from_str_with_options(content, &options); + let text = tui_markdown::from_str_with_options(&processed, &options); // Convert to our ratatui version's Line type and wrap to max_width let mut result = Vec::new(); @@ -475,4 +480,24 @@ mod tests { // Should produce multiple lines due to wrapping assert!(lines.len() > 1); } + + #[test] + fn test_render_markdown_with_table() { + let colors = test_colors(); + let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n"; + let lines = render_markdown(input, 80, &colors); + + // Convert lines to string for inspection + let output: String = lines.iter() + .map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect::()) + .collect::>() + .join("\n"); + + eprintln!("render_markdown output:\n{}", output); + + // Should contain our Unicode box-drawing corners, not raw markdown + assert!(output.contains('┌'), "Expected ┌ in output, got:\n{}", output); + assert!(output.contains('┐'), "Expected ┐ in output, got:\n{}", output); + assert!(!output.contains("| A |"), "Raw markdown table should be replaced"); + } } diff --git a/src/ui/markdown/table.rs b/src/ui/markdown/table.rs new file mode 100644 index 0000000..290a745 --- /dev/null +++ b/src/ui/markdown/table.rs @@ -0,0 +1,333 @@ +use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd}; + +/// Pre-process markdown content to extract and render tables, +/// replacing table markdown with Unicode box-drawing rendered tables. +pub fn preprocess_tables(content: &str, max_width: usize) -> String { + let parser = Parser::new_ext(content, Options::ENABLE_TABLES).into_offset_iter(); + + let mut result = String::with_capacity(content.len()); + let mut last_end = 0; + + let mut in_table = false; + let mut table_alignments: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + let mut current_row: Vec = Vec::new(); + let mut current_cell = String::new(); + + for (event, range) in parser { + match event { + Event::Start(Tag::Table(alignments)) => { + // Flush text before the table + result.push_str(&content[last_end..range.start]); + in_table = true; + table_alignments = alignments; + rows.clear(); + current_row.clear(); + current_cell.clear(); + } + Event::End(TagEnd::Table) => { + in_table = false; + last_end = range.end; + let rendered = render_table(&rows, &table_alignments, max_width); + result.push_str(&rendered); + rows.clear(); + } + Event::Start(Tag::TableHead) => { + // Reset for header row + current_row.clear(); + current_cell.clear(); + } + Event::End(TagEnd::TableHead) => { + if !current_row.is_empty() { + rows.push(std::mem::take(&mut current_row)); + } + } + Event::Start(Tag::TableRow) => { + current_row.clear(); + current_cell.clear(); + } + Event::End(TagEnd::TableRow) => { + if !current_row.is_empty() { + rows.push(std::mem::take(&mut current_row)); + } + current_row = Vec::new(); + } + Event::Start(Tag::TableCell) => {} + Event::End(TagEnd::TableCell) => { + // Flush cell content — even if empty (preserves column alignment) + current_row.push(std::mem::take(&mut current_cell)); + } + Event::Text(text) if in_table => { + // Flatten inline formatting — just collect the text + current_cell.push_str(&text); + } + Event::Code(code) if in_table => { + current_cell.push('`'); + current_cell.push_str(&code); + current_cell.push('`'); + } + Event::SoftBreak if in_table => { + current_cell.push(' '); + } + Event::HardBreak if in_table => { + current_cell.push('\n'); + } + _ => {} + } + } + + // Flush remaining content after last table + result.push_str(&content[last_end..]); + result +} + +fn render_table(rows: &[Vec], alignments: &[Alignment], max_width: usize) -> String { + if rows.is_empty() { + return String::new(); + } + + let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0); + if num_cols == 0 { + return String::new(); + } + + // Calculate natural column widths + let mut col_widths: Vec = vec![0; num_cols]; + for row in rows { + for (i, cell) in row.iter().enumerate() { + if i < num_cols { + let width = unicode_width::UnicodeWidthStr::width(cell.as_str()); + col_widths[i] = col_widths[i].max(width); + } + } + } + + // Constrain total width to max_width + // Each column separator uses 1 char ('│') and 1 space padding on each side + // So per column: 1 (left pad) + width + 1 (right pad), plus 1 for the left border + let padding_per_col = 2; // one space left, one space right + let border_chars = num_cols + 1; // left border + separators between cols + right border + let available_for_content = + max_width.saturating_sub(border_chars + num_cols * padding_per_col); + + // Distribute available width among columns + let total_natural: usize = col_widths.iter().sum(); + if total_natural <= available_for_content { + // All columns fit — expand last column to fill max_width + col_widths[num_cols - 1] += available_for_content - total_natural; + } else { + // Need to shrink. Give each column at least its natural width (capped), + // then reduce the largest columns. + let natural = col_widths.clone(); + + // Floor: each column gets at least 3 chars or its natural width, whichever is smaller + let min_widths: Vec = natural.iter().map(|&w| w.min(3)).collect(); + let min_total: usize = min_widths.iter().sum(); + + if min_total >= available_for_content { + // Even minimums exceed space — distribute proportionally + let mut remaining = available_for_content; + for i in 0..num_cols { + if i == num_cols - 1 { + col_widths[i] = remaining.max(3); + } else { + let scaled = (natural[i] * available_for_content) / total_natural; + col_widths[i] = scaled.max(3); + remaining = remaining.saturating_sub(col_widths[i]); + } + } + } else { + // Give smaller columns their full natural width first, + // then give remaining space to wider columns + let mut indices: Vec = (0..num_cols).collect(); + // Sort: smallest columns first (they get priority) + indices.sort_by_key(|&i| natural[i]); + + let mut remaining = available_for_content; + let cols_left = num_cols; + + for (pos, &i) in indices.iter().enumerate() { + let still_to_place = cols_left - pos - 1; + // Reserve minimum for remaining columns + let reserved = still_to_place * 3; + let max_possible = remaining.saturating_sub(reserved); + // Give this column its natural width, but don't exceed available + col_widths[i] = natural[i].min(max_possible).max(3); + remaining = remaining.saturating_sub(col_widths[i]); + } + } + } + + let mut result = String::new(); + + // Helper to truncate/pad a cell respecting alignment + let format_cell = |text: &str, width: usize, align: &Alignment| -> String { + let display_width = unicode_width::UnicodeWidthStr::width(text); + if display_width > width { + // Truncate with "..." + let mut truncated = String::with_capacity(width); + let mut current_width = 0; + for ch in text.chars() { + let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if current_width + ch_width + 3 > width { + break; + } + truncated.push(ch); + current_width += ch_width; + } + truncated.push_str("..."); + truncated + } else { + let padding = width - display_width; + match align { + Alignment::Right => { + format!("{}{}", " ".repeat(padding), text) + } + Alignment::Center => { + let left_pad = padding / 2; + let right_pad = padding - left_pad; + format!("{}{}{}", " ".repeat(left_pad), text, " ".repeat(right_pad)) + } + _ => { + format!("{}{}", text, " ".repeat(padding)) + } + } + } + }; + + // Top border + result.push('┌'); + for (i, w) in col_widths.iter().enumerate() { + if i > 0 { + result.push('┬'); + } + result.push_str(&"─".repeat(w + padding_per_col)); + } + result.push_str("┐\n"); + + // Rows + for (row_idx, row) in rows.iter().enumerate() { + // Row content + result.push('│'); + for (col_idx, width) in col_widths.iter().enumerate() { + if col_idx > 0 { + result.push('│'); + } + let cell_text = row.get(col_idx).map(|s| s.as_str()).unwrap_or(""); + let align = alignments.get(col_idx).unwrap_or(&Alignment::None); + result.push(' '); + result.push_str(&format_cell(cell_text, *width, align)); + result.push(' '); + } + result.push_str("│\n"); + + // Separator after header + if row_idx == 0 && rows.len() > 1 { + result.push('├'); + for (i, w) in col_widths.iter().enumerate() { + if i > 0 { + result.push('┼'); + } + result.push_str(&"─".repeat(w + padding_per_col)); + } + result.push_str("┤\n"); + } + } + + // Bottom border + result.push('└'); + for (i, w) in col_widths.iter().enumerate() { + if i > 0 { + result.push('┴'); + } + result.push_str(&"─".repeat(w + padding_per_col)); + } + result.push('┘'); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_table() { + let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n"; + let result = preprocess_tables(input, 80); + assert!(result.contains('┌')); + assert!(result.contains('┐')); + assert!(result.contains("A")); + assert!(result.contains("B")); + assert!(result.contains("1")); + assert!(result.contains("2")); + // Should NOT contain markdown table syntax + assert!(!result.contains('|')); + } + + #[test] + fn test_table_with_alignment() { + let input = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |\n"; + let result = preprocess_tables(input, 80); + assert!(result.contains('┌')); + assert!(result.contains("Left")); + assert!(result.contains("Center")); + assert!(result.contains("Right")); + } + + #[test] + fn test_empty_table() { + let input = "No table here"; + let result = preprocess_tables(input, 80); + assert_eq!(result, "No table here"); + } + + #[test] + fn test_mixed_content_with_table() { + let input = "Some text\n\n| Col1 | Col2 |\n| --- | --- |\n| A | B |\n\nMore text"; + let result = preprocess_tables(input, 80); + assert!(result.contains("Some text")); + assert!(result.contains('┌')); + assert!(result.contains("More text")); + assert!(!result.contains('|')); + } + + #[test] + fn test_table_cell_with_code() { + let input = "| Tool | Desc |\n| --- | --- |\n| `read` | Read files |\n"; + let result = preprocess_tables(input, 80); + assert!(result.contains("`read`")); + } + + #[test] + fn test_table_narrow_width() { + let input = "| Category | Tool | Description |\n| --- | --- | --- |\n| File Ops | `read` | Read files |\n"; + let result = preprocess_tables(input, 40); + // Should still render despite narrow width + assert!(result.contains('┌')); + assert!(!result.contains('|')); + } + + #[test] + fn test_multiple_tables() { + let input = + "| A |\n| --- |\n| 1 |\n\nMiddle text\n\n| X |\n| --- |\n| 9 |\n"; + let result = preprocess_tables(input, 80); + // Count table borders — should have 2 tables + let top_border_count = result.matches("┌").count(); + assert_eq!(top_border_count, 2); + assert!(result.contains("Middle text")); + } + + #[test] + fn test_real_world_table() { + let input = "| Category | Tool | Description |\n|----------|------|-------------|\n| **File Operations** | `read` | Read file or directory contents with pagination |\n| | `write` | Create or overwrite a file |\n| | `edit` | Replace text in files with smart matching |\n| | `list` | List directory contents in tree format |\n| | `glob` | Find files by glob pattern |\n| | `grep` | Search file contents using regex |\n| **Code & Development** | `bash` | Execute shell commands with timeout and output streaming |\n| | `task` | Launch subagents for complex multi-step tasks |\n| | `explore` | Fast agent for exploring codebases (read-only) |\n| | `general` | General-purpose agent for research and complex tasks |\n| **Specialized Skills** | `skill` | Load domain-specific skills (frontend-design, ratatui) |\n| **Data & Search** | `question` | Ask user questions during execution |\n| | `todowrite` | Create and manage structured task lists |\n| | `webfetch` | Fetch content from URLs and convert to markdown |"; + let result = preprocess_tables(input, 80); + assert!(result.contains("File Operations")); + assert!(result.contains("Specialized Skills")); + assert!(result.contains("`todowrite`")); + // Each row should have 3 cells — no concatenation + assert!(!result.contains("File Operations`read`")); + assert!(!result.contains('|')); + } +} From 6461a59994d4ce8a17b548955c966e6ffe2d6eb2 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 12 May 2026 01:21:36 +0800 Subject: [PATCH 072/226] fix: proper table rendering. --- _plans/__TODOS.md | 2 +- src/ui/components/chat.rs | 7 +++--- src/ui/markdown/streaming.rs | 42 ++++++++++++++++++++++++++++++++++++ src/ui/markdown/table.rs | 10 +++++---- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 0d3588e..d47ba1a 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -44,7 +44,7 @@ - [ ] Parity: Like opencode, I wanna be able to queue messages. By sending some message even though it's still streaming, won't stop the agent, will just keep going. -- [ ] Markdown: Proper Table rendering. +- [x] Markdown: Proper Table rendering. - [ ] Rendering: Thinking Rendering always has this massive space below it, even if the agent didn't really think much. diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 166c926..55882fc 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -278,7 +278,7 @@ impl Chat { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // Bump this whenever rendering logic changes (tables, markdown, etc.) - const RENDER_VERSION: u64 = 1; + const RENDER_VERSION: u64 = 2; RENDER_VERSION.hash(&mut h); self.messages.len().hash(&mut h); for msg in &self.messages { @@ -961,7 +961,8 @@ impl Chat { MessageRole::Assistant => { // Display reasoning/thinking tokens if present if let Some(ref reasoning) = message.reasoning { - if !reasoning.is_empty() { + let reasoning_trimmed = reasoning.trim(); + if !reasoning_trimmed.is_empty() { let reasoning_prefix = "💭 Thinking..."; lines.push(Line::from(vec![Span::styled( reasoning_prefix, @@ -970,7 +971,7 @@ impl Chat { .add_modifier(Modifier::ITALIC), )])); - let wrapped_reasoning = textwrap::wrap(reasoning, max_width); + let wrapped_reasoning = textwrap::wrap(reasoning_trimmed, max_width); for line in wrapped_reasoning { lines.push(Line::from(Span::styled( line.to_string(), diff --git a/src/ui/markdown/streaming.rs b/src/ui/markdown/streaming.rs index 95db793..567eb56 100644 --- a/src/ui/markdown/streaming.rs +++ b/src/ui/markdown/streaming.rs @@ -500,4 +500,46 @@ mod tests { assert!(output.contains('┐'), "Expected ┐ in output, got:\n{}", output); assert!(!output.contains("| A |"), "Raw markdown table should be replaced"); } + + #[test] + fn test_render_markdown_real_table_widths() { + let colors = test_colors(); + // Test WITHOUT backticks first - should work + let input_no_code = "| Category | Tool | Description |\n|----------|------|-------------|\n| File Operations | read | Read file or directory contents with pagination |\n| | write | Create or overwrite a file |"; + let lines = render_markdown(input_no_code, 80, &colors); + let line_strings: Vec = lines.iter() + .map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect::()) + .collect(); + + let table_lines: Vec<_> = line_strings.iter() + .filter(|l| l.contains('│') || l.contains('┌') || l.contains('┐') || l.contains('├') || l.contains('┤') || l.contains('└') || l.contains('┘')) + .collect(); + + let first_width = unicode_width::UnicodeWidthStr::width(table_lines[0].as_str()); + for line in &table_lines { + let width = unicode_width::UnicodeWidthStr::width(line.as_str()); + assert_eq!(width, first_width, + "Table lines should have consistent width. Expected {}, got {}.\nLine: {}", + first_width, width, line); + } + + // Test WITH backticks - this will fail until we fix it + let input_with_code = "| Category | Tool | Description |\n|----------|------|-------------|\n| File Operations | `read` | Read file or directory contents with pagination |\n| | `write` | Create or overwrite a file |"; + let lines = render_markdown(input_with_code, 80, &colors); + let line_strings: Vec = lines.iter() + .map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect::()) + .collect(); + + let table_lines: Vec<_> = line_strings.iter() + .filter(|l| l.contains('│') || l.contains('┌') || l.contains('┐') || l.contains('├') || l.contains('┤') || l.contains('└') || l.contains('┘')) + .collect(); + + let first_width = unicode_width::UnicodeWidthStr::width(table_lines[0].as_str()); + for line in &table_lines { + let width = unicode_width::UnicodeWidthStr::width(line.as_str()); + assert_eq!(width, first_width, + "Table lines WITH code should also have consistent width. Expected {}, got {}.\nLine: {}", + first_width, width, line); + } + } } diff --git a/src/ui/markdown/table.rs b/src/ui/markdown/table.rs index 290a745..14b90c0 100644 --- a/src/ui/markdown/table.rs +++ b/src/ui/markdown/table.rs @@ -62,9 +62,9 @@ pub fn preprocess_tables(content: &str, max_width: usize) -> String { current_cell.push_str(&text); } Event::Code(code) if in_table => { - current_cell.push('`'); + // Don't wrap with backticks — tui-markdown will render inline code + // styling itself, and including backticks breaks width calculations current_cell.push_str(&code); - current_cell.push('`'); } Event::SoftBreak if in_table => { current_cell.push(' '); @@ -296,7 +296,9 @@ mod tests { fn test_table_cell_with_code() { let input = "| Tool | Desc |\n| --- | --- |\n| `read` | Read files |\n"; let result = preprocess_tables(input, 80); - assert!(result.contains("`read`")); + // Backticks are stripped — tui-markdown handles inline code styling + assert!(result.contains("read")); + assert!(!result.contains("`read`")); } #[test] @@ -325,7 +327,7 @@ mod tests { let result = preprocess_tables(input, 80); assert!(result.contains("File Operations")); assert!(result.contains("Specialized Skills")); - assert!(result.contains("`todowrite`")); + assert!(result.contains("todowrite")); // Each row should have 3 cells — no concatenation assert!(!result.contains("File Operations`read`")); assert!(!result.contains('|')); From ce20f749109ef37650fea3b5cc92750b53ee0dcd Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 18 May 2026 12:42:55 +0800 Subject: [PATCH 073/226] feat: replace eventsource-stream with custom SSE parser and add inline diff rendering. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite SSE parsing in `compatible.rs` to remove `eventsource-stream` dependency, using a custom `bytes_to_lines` stream that handles both NDJSON and `data:` prefixed lines, with fallback fields for non-standard providers. - Add `diff` crate and `src/ui/diff.rs` with unified diff computation and ratatui rendering (green/red gutters with background colors). - Render inline diffs for `edit` and `write` tool calls in the chat UI. - Overhaul todowrite tool display: simplified output format, solid-background panel rendering, blank line suppression around todowrite messages. - Remove tool boundary marker (`[toolu_bdrk_01…]`) from tool result output. - Add SSE debug logging to `/tmp/crabcode_sse_debug.log`. - Wire diff theme colors through Desktop `theme.json` seeds/overrides and TUI themes. --- .devrefs/devrefs.yml | 15 ++ .gitignore | 1 + AGENTS.md | 4 + Cargo.lock | 2 + Cargo.toml | 1 + _plans/__TODOS.md | 8 +- aisdk/Cargo.toml | 1 + aisdk/src/providers/compatible.rs | 126 +++++++++++-- aisdk/src/response.rs | 4 - src/app.rs | 5 + src/llm/client.rs | 2 + src/theme.rs | 44 ++++- src/tools/todowrite.rs | 33 +--- src/ui/components/chat.rs | 190 +++++++++++++++++-- src/ui/diff.rs | 288 +++++++++++++++++++++++++++++ src/ui/markdown/streaming.rs | 5 + src/ui/mod.rs | 1 + src/views/session_rename_dialog.rs | 5 + 18 files changed, 669 insertions(+), 66 deletions(-) create mode 100644 .devrefs/devrefs.yml create mode 100644 src/ui/diff.rs diff --git a/.devrefs/devrefs.yml b/.devrefs/devrefs.yml new file mode 100644 index 0000000..5d33098 --- /dev/null +++ b/.devrefs/devrefs.yml @@ -0,0 +1,15 @@ +# DevRefs makes cloning/updating references for agents seamless and fast. +# Edit this file directly, or use `devrefs add ` to add entries. +references: + - id: ogulcancelik/herdr + description: "Ratatui app with good scroll" + remote: https://github.com/ogulcancelik/herdr.git + branch: master + - id: anomalyco/opencode + description: "Main reference and preferred agent harness by the author, we wanted to rewrite it in rust w/ crabcode" + remote: https://github.com/anomalyco/opencode.git + branch: dev + - id: openai/codex + description: "a ratatui agent harness that we can use as reference, especially since it's by openai" + remote: https://github.com/openai/codex.git + branch: main diff --git a/.gitignore b/.gitignore index d423829..9ccbe36 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ _dev_reference1 _dev_reference2 .env node_modules +.devrefs/references diff --git a/AGENTS.md b/AGENTS.md index 173dbb3..5e71680 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,3 +61,7 @@ The cache stores provider and model information from models.dev and expires afte - Important: always refer to this when asked to write inside \_docs. - Traverse this llms.txt as often as you can if you write docs: https://gittydocs.carlo.tl/llms.txt - When writing titles + first text in the body, never use the same 'title' (in mdx data) and '# ` (in the body). + +### References + +Use `devrefs list`, everything is in `.devrefs/references/*` diff --git a/Cargo.lock b/Cargo.lock index 101b6b9..a7fcb17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ name = "aisdk" version = "0.1.0" dependencies = [ "async-trait", + "bytes", "derive_builder", "eventsource-stream", "futures", @@ -442,6 +443,7 @@ dependencies = [ "clap", "copypasta", "cuid2", + "diff", "dirs", "futures", "glob", diff --git a/Cargo.toml b/Cargo.toml index 094d78c..816b5e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ base64 = "0.22" serde_yaml = "0.9" sha2 = "0.10" rand = "0.8" +diff = "0.1" [dev-dependencies] tokio-test = "0.4" diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index d47ba1a..fe5fa56 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -30,7 +30,7 @@ - [ ] Minor, `chat_only` flag is codesmell... We better come up with strings for deciding "Only show this slash command in this context", just like how we do with 'Shortcuts' (in case shortcuts follow this codesmell as well, come up with a better approach) -- [ ] Chore: Create a /checkparity-opencode (the most important thing is only the agent-loop, nothing else. We do differ a bit in terms of UX anyway, but the agent-loop, tool calling, etc has to be very very close so that the performance is mostly the same) and /checkparity-codex (au) command +- [x] Chore: Create a /checkparity-opencode (the most important thing is only the agent-loop, nothing else. We do differ a bit in terms of UX anyway, but the agent-loop, tool calling, etc has to be very very close so that the performance is mostly the same) and /checkparity-codex (au) command - [ ] Feature: Subagents just like opencode. @@ -46,11 +46,15 @@ - [x] Markdown: Proper Table rendering. -- [ ] Rendering: Thinking Rendering always has this massive space below it, even if the agent didn't really think much. +- [x] Rendering: Thinking Rendering always has this massive space below it, even if the agent didn't really think much. - [ ] Tool call rendering: - [ ] editing files w/ diffs, like opencode does. - [ ] todowrite - better looking, like opencode does. - [ ] rendering subagents - just like opencode, clickable to go into their page.. OR I can do `ctrl-x ↓` to go into it if there's a subagent running. I can also switch between subagents with `←` and `→` +- [ ] Fix: Chat content colors. Currently no matter what theme I use, the color of the chat especially the main text colors in markdown, are still the default's crabcode-orange's colors. I want them to be a bit more relevant. + - [ ] Bug: I can type a command see autosuggest, but can't press 'enter' to run the command. Pls fix. + +- [ ] A single AI response, is considered 1 message. So combine all its parts into a single message record. Not that every message part becomes a separate message in the timeline dialog. diff --git a/aisdk/Cargo.toml b/aisdk/Cargo.toml index 111ecff..58a3cda 100644 --- a/aisdk/Cargo.toml +++ b/aisdk/Cargo.toml @@ -16,3 +16,4 @@ thiserror = "1.0" eventsource-stream = "0.2" async-trait = "0.1" derive_builder = "0.20" +bytes = "1" diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index 87d08d0..affc4ae 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -4,7 +4,6 @@ use crate::message::Message; use crate::provider::{Provider, ProviderStream}; use crate::tool::Tool; use async_trait::async_trait; -use eventsource_stream::Eventsource; use futures::stream; use futures::StreamExt; use std::collections::HashMap; @@ -160,15 +159,10 @@ impl Provider for OpenAICompatible { return Err(Error::Provider(format!("API error {}: {}", status, text))); } - let stream = response - .bytes_stream() - .eventsource() - .map(|ev| { - match ev { - Ok(event) => process_sse_data(&event.data), - Err(e) => vec![Ok(ChunkType::Failed(format!("SSE error: {}", e)))], - } - }) + let byte_stream = response.bytes_stream(); + let line_stream = bytes_to_lines(byte_stream); + let stream = line_stream + .map(|line| process_sse_data(&line)) .flat_map(|v| stream::iter(v)) .boxed(); @@ -176,15 +170,32 @@ impl Provider for OpenAICompatible { } } +fn debug_log(msg: &str) { + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("/tmp/crabcode_sse_debug.log") + .and_then(|mut f| { + use std::io::Write; + writeln!(f, "{}", msg) + }); +} + fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { // [DONE] is ignored — the HTTP stream end signals completion. if data == "[DONE]" || data.is_empty() { + debug_log(&format!("[SSE] Ignored: [DONE] or empty")); return vec![]; } + debug_log(&format!("[SSE] Raw data: {}", data)); + let value: serde_json::Value = match serde_json::from_str(data) { Ok(v) => v, - Err(e) => return vec![Ok(ChunkType::Failed(format!("Invalid SSE data: {}", e)))], + Err(e) => { + debug_log(&format!("[SSE] JSON parse error: {} | data: {}", e, data)); + return vec![Ok(ChunkType::Failed(format!("Invalid SSE data: {}", e)))]; + } }; if let Some(error) = value["error"].as_object() { @@ -192,14 +203,17 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { .get("message") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); + debug_log(&format!("[SSE] API error: {}", msg)); return vec![Ok(ChunkType::Failed(msg.to_string()))]; } let Some(choices) = value["choices"].as_array() else { + debug_log(&format!("[SSE] No choices array. JSON keys: {:?}", value.as_object().map(|o| o.keys().collect::<Vec<_>>()))); return vec![]; }; if choices.is_empty() { + debug_log("[SSE] choices array is empty"); return vec![]; } @@ -207,18 +221,33 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { let finish_reason = choice["finish_reason"].as_str().unwrap_or(""); let mut chunks = Vec::new(); + // Log the full choice structure for debugging + debug_log(&format!("[SSE] Choice JSON: {}", serde_json::to_string(choice).unwrap_or_default())); + // Emit text delta first (may coexist with finish_reason) - if let Some(delta) = choice["delta"]["content"].as_str() { - if !delta.is_empty() { - chunks.push(Ok(ChunkType::Text(delta.to_string()))); - } + // Try standard delta.content, then fallbacks for non-standard providers + let text = choice["delta"]["content"] + .as_str() + .filter(|s| !s.is_empty()) + .or_else(|| choice["delta"]["text"].as_str().filter(|s| !s.is_empty())) + .or_else(|| choice["message"]["content"].as_str().filter(|s| !s.is_empty())) + .or_else(|| choice["text"].as_str().filter(|s| !s.is_empty())); + + if let Some(delta) = text { + debug_log(&format!("[SSE] Text chunk: {}", delta)); + chunks.push(Ok(ChunkType::Text(delta.to_string()))); } // Emit reasoning delta - if let Some(reasoning) = choice["delta"]["reasoning_content"].as_str() { - if !reasoning.is_empty() { - chunks.push(Ok(ChunkType::Reasoning(reasoning.to_string()))); - } + let reasoning = choice["delta"]["reasoning_content"] + .as_str() + .filter(|s| !s.is_empty()) + .or_else(|| choice["delta"]["reasoning"].as_str().filter(|s| !s.is_empty())) + .or_else(|| choice["reasoning_content"].as_str().filter(|s| !s.is_empty())); + + if let Some(reasoning) = reasoning { + debug_log(&format!("[SSE] Reasoning chunk: {}", reasoning)); + chunks.push(Ok(ChunkType::Reasoning(reasoning.to_string()))); } // Emit tool calls on tool_calls finish_reason. Stream exhausts naturally @@ -232,9 +261,68 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { } } + if chunks.is_empty() { + debug_log(&format!("[SSE] No chunks produced. finish_reason='{}'", finish_reason)); + } + chunks } +/// Convert a byte stream into a stream of lines, handling both SSE (`data: ...`) and raw NDJSON. +fn bytes_to_lines<S>(byte_stream: S) -> impl futures::Stream<Item = String> +where + S: futures::Stream<Item = std::result::Result<bytes::Bytes, reqwest::Error>> + Unpin, +{ + let buffer: Vec<u8> = Vec::new(); + stream::unfold((byte_stream, buffer), |(mut stream, mut buffer)| async move { + loop { + if let Some(pos) = buffer.iter().position(|&b| b == b'\n') { + let line_bytes: Vec<u8> = buffer.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line_bytes); + let line = line.trim_end_matches('\n').trim_end_matches('\r'); + if line.is_empty() { + continue; + } + let data = if let Some(stripped) = line.strip_prefix("data:") { + stripped.trim_start().to_string() + } else { + line.to_string() + }; + if data == "[DONE]" || data.is_empty() { + continue; + } + debug_log(&format!("[LINE] Extracted: {}", data)); + return Some((data, (stream, buffer))); + } + match stream.next().await { + Some(Ok(bytes)) => { + debug_log(&format!("[BYTES] Received {} bytes", bytes.len())); + buffer.extend_from_slice(&bytes); + } + Some(Err(e)) => { + debug_log(&format!("[BYTES] Error: {}", e)); + return None; + } + None => { + let remaining = String::from_utf8_lossy(&buffer).trim().to_string(); + buffer.clear(); + if remaining.is_empty() || remaining == "[DONE]" { + debug_log("[LINE] Stream ended, no remaining data"); + return None; + } + let data = if let Some(stripped) = remaining.strip_prefix("data:") { + stripped.trim_start().to_string() + } else { + remaining + }; + debug_log(&format!("[LINE] Remaining at EOF: {}", data)); + return Some((data, (stream, buffer))); + } + } + } + }) +} + fn has_version_segment(base_url: &str) -> bool { // Check if the URL path already contains a /vN segment (e.g., /v4, /v1) if let Some(pos) = base_url.find("://") { diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 30cc4a4..4c93bd6 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -180,10 +180,6 @@ pub async fn stream_with_tools<P: Provider>( match tool { Some(t) => match t.execute.call(args.clone()).await { Ok(result) => { - let _ = tx_loop.send(ChunkType::Text(format!( - "\n[toolu_bdrk_01{}...] ", - &call_id[..8.min(call_id.len())] - ))); current_messages.push(Message::assistant(format!( "[tool result: {}] {}", tool_name, result diff --git a/src/app.rs b/src/app.rs index 7cd6aca..e9169e7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -535,6 +535,11 @@ impl App { markdown_image: ratatui::style::Color::Rgb(255, 140, 0), markdown_image_text: ratatui::style::Color::Rgb(0, 255, 255), markdown_code_block: ratatui::style::Color::Reset, + diff_add: ratatui::style::Color::Rgb(0, 255, 0), + diff_add_bg: ratatui::style::Color::Rgb(0, 60, 0), + diff_remove: ratatui::style::Color::Rgb(255, 0, 0), + diff_remove_bg: ratatui::style::Color::Rgb(60, 0, 0), + diff_gutter: ratatui::style::Color::Rgb(140, 140, 140), }; } diff --git a/src/llm/client.rs b/src/llm/client.rs index cf42ed8..ee9216b 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -435,10 +435,12 @@ async fn relay_stream_to_sender( match chunk { ChunkType::Text(text) => { *token_count += estimate_tokens(&text); + let _ = log(&format!("[RELAY] Text chunk ({} chars)", text.len())); let _ = sender.send(crate::llm::ChunkMessage::Text(text)); } ChunkType::Reasoning(reasoning) => { *token_count += estimate_tokens(&reasoning); + let _ = log(&format!("[RELAY] Reasoning chunk ({} chars)", reasoning.len())); let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } ChunkType::ToolCall(_tool_call) => { diff --git a/src/theme.rs b/src/theme.rs index 8f0dca9..90dd59d 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -38,6 +38,12 @@ pub struct ThemeColors { pub markdown_image: ratatui::style::Color, pub markdown_image_text: ratatui::style::Color, pub markdown_code_block: ratatui::style::Color, + // Diff colors + pub diff_add: ratatui::style::Color, + pub diff_add_bg: ratatui::style::Color, + pub diff_remove: ratatui::style::Color, + pub diff_remove_bg: ratatui::style::Color, + pub diff_gutter: ratatui::style::Color, } pub fn darken_color(color: ratatui::style::Color, factor: f32) -> ratatui::style::Color { @@ -119,6 +125,10 @@ struct DesktopThemeSeeds { pub error: String, pub info: String, pub interactive: String, + #[serde(rename = "diffAdd", default)] + pub diff_add: Option<String>, + #[serde(rename = "diffDelete", default)] + pub diff_delete: Option<String>, } #[derive(Debug, Clone, Deserialize)] @@ -217,6 +227,12 @@ struct DesktopThemeOverrides { #[serde(rename = "markdown-code-block")] #[serde(default)] pub markdown_code_block: Option<String>, + + #[serde(rename = "surface-diff-add-base", default)] + pub surface_diff_add_base: Option<String>, + + #[serde(rename = "surface-diff-delete-base", default)] + pub surface_diff_delete_base: Option<String>, } // OpenCode TUI themes ("https://opencode.ai/theme.json") @@ -347,6 +363,12 @@ impl Theme { let markdown_code_block = resolve_override(mode.overrides.markdown_code_block.as_deref(), markdown_text); + let diff_add = mode.seeds.diff_add.as_deref().map(parse_hex).unwrap_or(success); + let diff_remove = mode.seeds.diff_delete.as_deref().map(parse_hex).unwrap_or(error); + let diff_add_bg = mode.overrides.surface_diff_add_base.as_deref().map(parse_hex).unwrap_or(success); + let diff_remove_bg = mode.overrides.surface_diff_delete_base.as_deref().map(parse_hex).unwrap_or(error); + let diff_gutter = text_weak; + ThemeColors { primary, secondary, @@ -380,6 +402,11 @@ impl Theme { markdown_image, markdown_image_text, markdown_code_block, + diff_add, + diff_add_bg, + diff_remove, + diff_remove_bg, + diff_gutter, } } ThemeData::Tui(theme) => { @@ -441,6 +468,14 @@ impl Theme { let markdown_image_text = resolve_or("markdownImageText", markdown_link_text); let markdown_code_block = resolve_or("markdownCodeBlock", markdown_text); + let success_color = resolve_or("success", primary); + let error_color = resolve_or("error", primary); + let diff_add = resolve_or("diffAdd", success_color); + let diff_remove = resolve_or("diffDelete", error_color); + let diff_add_bg = resolve_or("diffAddedBg", success_color); + let diff_remove_bg = resolve_or("diffRemovedBg", error_color); + let diff_gutter = text_weak; + ThemeColors { primary, secondary, @@ -456,9 +491,9 @@ impl Theme { border_weak_focus, border_focus, border_strong_focus: border_focus, - success: resolve_or("success", primary), + success: success_color, warning: resolve_or("warning", primary), - error: resolve_or("error", primary), + error: error_color, info: resolve_or("info", primary), markdown_text, markdown_heading, @@ -474,6 +509,11 @@ impl Theme { markdown_image, markdown_image_text, markdown_code_block, + diff_add, + diff_add_bg, + diff_remove, + diff_remove_bg, + diff_gutter, } } } diff --git a/src/tools/todowrite.rs b/src/tools/todowrite.rs index 572f696..55a0f1e 100644 --- a/src/tools/todowrite.rs +++ b/src/tools/todowrite.rs @@ -77,38 +77,17 @@ impl ToolHandler for TodowriteTool { } } - let in_progress_count = todos - .iter() - .filter(|t| t.status == "in_progress") - .count(); - - let mut output = String::from("## Todo List\n\n"); - let mut stats = std::collections::HashMap::new(); - stats.insert("total".to_string(), todos.len() as u32); - stats.insert( - "in_progress".to_string(), - in_progress_count as u32, - ); + let mut output = String::new(); for todo in &todos { - let icon = match todo.status.as_str() { - "pending" => "☐", - "in_progress" => "▣", - "completed" => "✓", - "cancelled" => "✗", - _ => "?", + let mark = match todo.status.as_str() { + "completed" => "[✓]", + "in_progress" => "[•]", + _ => "[ ]", }; - output.push_str(&format!( - "- [{}] ({}) {} — {} priority\n", - icon, todo.status, todo.content, todo.priority - )); + output.push_str(&format!("{} {}\n", mark, todo.content)); } - output.push_str(&format!( - "\n**Summary**: {} total, {} in progress", - stats["total"], stats["in_progress"] - )); - Ok(ToolResult::new("Todo list updated", output.clone()).with_metadata( "todo_items", serde_json::json!(todos), diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 55882fc..ac25f89 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1026,8 +1026,23 @@ impl Chat { lines.push(Line::from(metadata)); lines.push(Line::from("")); } else { - // Keep spacing consistent between segments. - lines.push(Line::from("")); + // Keep spacing consistent between segments, but skip the + // blank line when the next message is a todowrite panel. + let next_is_todowrite = self + .messages + .get(idx + 1) + .map(|m| { + m.role == MessageRole::Tool + && serde_json::from_str::<serde_json::Value>(&m.content) + .ok() + .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())) + .map(|n| n == "todowrite") + .unwrap_or(false) + }) + .unwrap_or(false); + if !next_is_todowrite { + lines.push(Line::from("")); + } } } MessageRole::System => { @@ -1051,7 +1066,15 @@ impl Chat { colors, attached_to_assistant, )); - lines.push(Line::from("")); + // Only add trailing blank line for non-todowrite tools. + let is_todowrite = serde_json::from_str::<serde_json::Value>(&message.content) + .ok() + .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())) + .map(|n| n == "todowrite") + .unwrap_or(false); + if !is_todowrite { + lines.push(Line::from("")); + } } } @@ -1105,7 +1128,7 @@ impl Chat { let mut out: Vec<Line<'a>> = Vec::new(); let parsed: Option<JsonValue> = serde_json::from_str(&message.content).ok(); - let (name, status, args, metadata, output_preview) = + let (name, status, args, metadata, output_preview, title) = if let Some(JsonValue::Object(obj)) = parsed { let name = obj .get("name") @@ -1123,7 +1146,11 @@ impl Chat { .get("output_preview") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - (name, status, args, metadata, output_preview) + let title = obj + .get("title") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + (name, status, args, metadata, output_preview, title) } else { ( "tool".to_string(), @@ -1131,6 +1158,7 @@ impl Chat { None, None, Some(message.content.clone()), + None, ) }; @@ -1149,6 +1177,7 @@ impl Chat { "bash" => "Bash", "list" => "List", "grep" => "Grep", + "todowrite" => "Todos", other => other, }; @@ -1173,6 +1202,15 @@ impl Chat { s.push_str(&format!("in \"{}\"", base)); } s + } else if name == "edit" { + // For edits, show only the file path in the header; the diff is rendered below. + args_obj + .and_then(|o| o.get("file_path")) + .and_then(|v| v.as_str()) + .map(|p| format!("\"{}\"", p)) + .unwrap_or_default() + } else if name == "todowrite" { + String::new() } else { args.as_ref().map(args_preview).unwrap_or_default() }; @@ -1193,14 +1231,142 @@ impl Chat { } } - let wrapped = textwrap::wrap(&header, max_width); - for line in wrapped { - out.push(Line::from(Span::styled( - line.to_string(), - Style::default() + // For todowrite, render everything (header + body) inside a single + // solid-background panel. Skip the normal dim header for this tool. + if name == "todowrite" && status == "ok" { + if let Some(ref preview) = output_preview { + let bg = colors.background_element; + let pad_style = Style::default().bg(bg); + let header_style = Style::default() .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ))); + .add_modifier(Modifier::DIM) + .bg(bg); + let item_style = Style::default().fg(colors.text).bg(bg); + + let panel_width = max_width.saturating_sub(2).max(10); + + // Panel header: # + label (opencode style) + let header_text = format!("# {}", tool_label); + let mut panel_lines: Vec<Line<'_>> = Vec::new(); + + // Padding top + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + + // Panel header + panel_lines.push(Line::from(vec![Span::styled(header_text, header_style)])); + + // Body: each todo item as plain text (no markdown — avoids + // brackets being interpreted as links). + let preview_trimmed = preview.trim_end(); + for raw_line in preview_trimmed.lines() { + let trimmed = raw_line.trim_end(); + if trimmed.is_empty() { + continue; + } + let wrapped = textwrap::wrap(trimmed, panel_width); + for w in wrapped { + panel_lines.push(Line::from(vec![Span::styled( + w.to_string(), + item_style, + )])); + } + } + + // Padding bottom + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + + // Pad every line to panel_width and indent by 1 space so the + // panel reads as a single solid block. + for line in &mut panel_lines { + let text: String = line + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + let text_width = unicode_width::UnicodeWidthStr::width(text.as_str()); + if text_width < panel_width { + let pad = " ".repeat(panel_width - text_width); + line.spans.push(Span::styled(pad, pad_style)); + } + line.spans.insert(0, Span::styled(" ", pad_style)); + } + + out.extend(panel_lines); + } + } else { + // Default header for all other tools. + let wrapped = textwrap::wrap(&header, max_width); + for line in wrapped { + out.push(Line::from(Span::styled( + line.to_string(), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ))); + } + + // For edit tools, render a unified diff preview of old_string -> new_string + if name == "edit" { + if let Some(obj) = args_obj { + let old_str = obj + .get("old_string") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let new_str = obj + .get("new_string") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !old_str.is_empty() || !new_str.is_empty() { + let diff_lines = crate::ui::diff::format_edit_diff( + old_str, + new_str, + max_width, + colors, + ); + out.extend(diff_lines); + } + } + } + + // For write tools, render the content as an all-additions diff. + if name == "write" { + if let Some(obj) = args_obj { + let content = obj + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !content.is_empty() { + let diff_lines = crate::ui::diff::format_edit_diff( + "", + content, + max_width, + colors, + ); + out.extend(diff_lines); + } + } + } + + // Render a subtle result line for completed tools. + if status == "ok" { + if let Some(ref preview) = output_preview { + let mut result_text = preview.clone(); + // For edits, prepend the title (e.g. "Edit: file.rs") if available. + if name == "edit" { + if let Some(ref t) = title { + result_text = format!("{} — {}", t, preview); + } + } + let result_line = format!(" → {}", result_text); + let wrapped = textwrap::wrap(&result_line, max_width); + for line in wrapped { + out.push(Line::from(Span::styled( + line.to_string(), + Style::default().fg(colors.text_weak), + ))); + } + } + } } if status == "error" { diff --git a/src/ui/diff.rs b/src/ui/diff.rs new file mode 100644 index 0000000..d7049aa --- /dev/null +++ b/src/ui/diff.rs @@ -0,0 +1,288 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use crate::theme::ThemeColors; +use unicode_width::UnicodeWidthStr; + +const MAX_DIFF_LINES: usize = 40; +const CONTEXT_LINES: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffLineType { + Remove, + Add, + Context, +} + +pub struct DiffLine { + pub line_type: DiffLineType, + pub text: String, +} + +/// Compute a unified line-based diff between old and new text. +/// Returns at most `MAX_DIFF_LINES` with `CONTEXT_LINES` of context around changes. +pub fn compute_unified_diff(old_text: &str, new_text: &str) -> Vec<DiffLine> { + let raw_diff = diff::lines(old_text, new_text); + + // First pass: collect all lines with their type + let mut all_lines: Vec<DiffLine> = Vec::new(); + for result in raw_diff { + match result { + diff::Result::Left(line) => { + all_lines.push(DiffLine { + line_type: DiffLineType::Remove, + text: line.to_string(), + }); + } + diff::Result::Both(line, _) => { + all_lines.push(DiffLine { + line_type: DiffLineType::Context, + text: line.to_string(), + }); + } + diff::Result::Right(line) => { + all_lines.push(DiffLine { + line_type: DiffLineType::Add, + text: line.to_string(), + }); + } + } + } + + // If the diff is short enough, return it all + if all_lines.len() <= MAX_DIFF_LINES { + return all_lines; + } + + // Otherwise, find change regions and include context around them + let mut change_indices: Vec<usize> = Vec::new(); + for (i, line) in all_lines.iter().enumerate() { + if line.line_type != DiffLineType::Context { + change_indices.push(i); + } + } + + if change_indices.is_empty() { + // No changes? Return first context lines + return all_lines.into_iter().take(MAX_DIFF_LINES).collect(); + } + + // Build a set of indices to keep + let mut keep = vec![false; all_lines.len()]; + for &idx in &change_indices { + let start = idx.saturating_sub(CONTEXT_LINES); + let end = (idx + CONTEXT_LINES + 1).min(all_lines.len()); + for i in start..end { + keep[i] = true; + } + } + + // Merge adjacent kept regions and add ellipsis markers + let mut result: Vec<DiffLine> = Vec::new(); + let mut in_ellipsis = false; + for (i, line) in all_lines.into_iter().enumerate() { + if keep[i] { + result.push(line); + in_ellipsis = false; + } else if !in_ellipsis { + result.push(DiffLine { + line_type: DiffLineType::Context, + text: "⋯".to_string(), + }); + in_ellipsis = true; + } + } + + result +} + +/// Render a unified diff as ratatui Lines with proper colors and gutter. +/// Every line is padded to `max_width` so the background spans the full row. +pub fn render_unified_diff( + diff_lines: &[DiffLine], + max_width: usize, + colors: &ThemeColors, +) -> Vec<Line<'static>> { + let gutter_width = 2usize; // "- ", "+ ", " " + let content_width = max_width.saturating_sub(gutter_width).max(1); + + let mut lines: Vec<Line<'static>> = Vec::new(); + + if max_width < 4 { + return lines; + } + + for diff_line in diff_lines { + let (gutter, fg, bg) = match diff_line.line_type { + DiffLineType::Remove => ("- ", colors.diff_remove, colors.diff_remove_bg), + DiffLineType::Add => ("+ ", colors.diff_add, colors.diff_add_bg), + DiffLineType::Context => (" ", colors.text_weak, colors.background), + }; + + let gutter_style = Style::default().fg(colors.diff_gutter).bg(bg); + let content_style = Style::default().fg(fg).bg(bg); + let pad_style = Style::default().bg(bg); + + // Handle ellipsis specially + if diff_line.text == "⋯" { + let full_line = format!("{}⋯", gutter); + let remaining = max_width.saturating_sub(full_line.len()); + let padding = "─".repeat(remaining); + let mut spans = vec![ + Span::styled(gutter.to_string(), gutter_style), + Span::styled(format!("⋯{}", padding), content_style.add_modifier(Modifier::DIM)), + ]; + // Pad to full width if the ellipsis line is shorter + let visible_width: usize = spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum(); + if visible_width < max_width { + spans.push(Span::styled(" ".repeat(max_width - visible_width), pad_style)); + } + lines.push(Line::from(spans)); + continue; + } + + // Wrap content if needed + let wrapped = textwrap::wrap(&diff_line.text, content_width); + for (chunk_idx, chunk) in wrapped.iter().enumerate() { + let gutter_text = if chunk_idx == 0 { + gutter.to_string() + } else { + " ".to_string() + }; + let mut spans = vec![ + Span::styled(gutter_text.clone(), gutter_style), + Span::styled(chunk.to_string(), content_style), + ]; + // Pad to full width so the background spans the entire row + let visible_width: usize = spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum(); + if visible_width < max_width { + spans.push(Span::styled(" ".repeat(max_width - visible_width), pad_style)); + } + lines.push(Line::from(spans)); + } + } + + lines +} + +/// Convenience: compute and render a unified diff in one call. +pub fn format_edit_diff( + old_string: &str, + new_string: &str, + max_width: usize, + colors: &ThemeColors, +) -> Vec<Line<'static>> { + let diff_lines = compute_unified_diff(old_string, new_string); + render_unified_diff(&diff_lines, max_width, colors) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Color; + + fn test_colors() -> ThemeColors { + ThemeColors { + primary: Color::Reset, + secondary: Color::Reset, + accent: Color::Reset, + interactive: Color::Reset, + background: Color::Reset, + dialog_background: Color::Reset, + background_element: Color::Reset, + text: Color::Reset, + text_weak: Color::Reset, + text_strong: Color::Reset, + border: Color::Reset, + border_weak_focus: Color::Reset, + border_focus: Color::Reset, + border_strong_focus: Color::Reset, + success: Color::Reset, + warning: Color::Reset, + error: Color::Reset, + info: Color::Reset, + markdown_text: Color::Reset, + markdown_heading: Color::Reset, + markdown_link: Color::Reset, + markdown_link_text: Color::Reset, + markdown_code: Color::Reset, + markdown_block_quote: Color::Reset, + markdown_emph: Color::Reset, + markdown_strong: Color::Reset, + markdown_horizontal_rule: Color::Reset, + markdown_list_item: Color::Reset, + markdown_list_enumeration: Color::Reset, + markdown_image: Color::Reset, + markdown_image_text: Color::Reset, + markdown_code_block: Color::Reset, + diff_add: Color::Rgb(0, 255, 0), + diff_add_bg: Color::Rgb(0, 60, 0), + diff_remove: Color::Rgb(255, 0, 0), + diff_remove_bg: Color::Rgb(60, 0, 0), + diff_gutter: Color::Rgb(140, 140, 140), + } + } + + #[test] + fn test_compute_unified_diff_simple() { + let old = "line1\nline2\nline3"; + let new = "line1\nchanged\nline3"; + let diff = compute_unified_diff(old, new); + assert_eq!(diff.len(), 4); + assert_eq!(diff[0].line_type, DiffLineType::Context); + assert_eq!(diff[0].text, "line1"); + assert_eq!(diff[1].line_type, DiffLineType::Remove); + assert_eq!(diff[1].text, "line2"); + assert_eq!(diff[2].line_type, DiffLineType::Add); + assert_eq!(diff[2].text, "changed"); + assert_eq!(diff[3].line_type, DiffLineType::Context); + assert_eq!(diff[3].text, "line3"); + } + + #[test] + fn test_compute_unified_diff_insertion() { + let old = "line1"; + let new = "line1\nline2"; + let diff = compute_unified_diff(old, new); + assert_eq!(diff.len(), 2); + assert_eq!(diff[0].line_type, DiffLineType::Context); + assert_eq!(diff[0].text, "line1"); + assert_eq!(diff[1].line_type, DiffLineType::Add); + assert_eq!(diff[1].text, "line2"); + } + + #[test] + fn test_compute_unified_diff_deletion() { + let old = "line1\nline2"; + let new = "line1"; + let diff = compute_unified_diff(old, new); + assert_eq!(diff.len(), 2); + assert_eq!(diff[0].line_type, DiffLineType::Context); + assert_eq!(diff[0].text, "line1"); + assert_eq!(diff[1].line_type, DiffLineType::Remove); + assert_eq!(diff[1].text, "line2"); + } + + #[test] + fn test_render_unified_diff_produces_lines() { + let colors = test_colors(); + let old = "a\nb\nc"; + let new = "a\nX\nc"; + let lines = format_edit_diff(old, new, 40, &colors); + assert!(!lines.is_empty()); + // Each line should have at least 2 spans (gutter + content) + for line in &lines { + assert!(line.spans.len() >= 2); + } + } + + #[test] + fn test_render_unified_diff_narrow_width() { + let colors = test_colors(); + let old = "a\nb\nc"; + let new = "a\nX\nc"; + let lines = format_edit_diff(old, new, 3, &colors); + // Should still produce lines (width >= 4 is needed) + // With width 3, returns empty + assert!(lines.is_empty()); + } +} diff --git a/src/ui/markdown/streaming.rs b/src/ui/markdown/streaming.rs index 567eb56..31ab35c 100644 --- a/src/ui/markdown/streaming.rs +++ b/src/ui/markdown/streaming.rs @@ -418,6 +418,11 @@ mod tests { markdown_image: Color::Rgb(0, 200, 255), markdown_image_text: Color::Rgb(80, 240, 240), markdown_code_block: Color::Rgb(180, 255, 180), + diff_add: Color::Rgb(0, 255, 0), + diff_add_bg: Color::Rgb(0, 60, 0), + diff_remove: Color::Rgb(255, 0, 0), + diff_remove_bg: Color::Rgb(60, 0, 0), + diff_gutter: Color::Rgb(140, 140, 140), } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b44e155..68048e7 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod components; +pub mod diff; pub mod layout; pub mod markdown; pub mod selection; diff --git a/src/views/session_rename_dialog.rs b/src/views/session_rename_dialog.rs index 83b329f..b8fc9ea 100644 --- a/src/views/session_rename_dialog.rs +++ b/src/views/session_rename_dialog.rs @@ -116,6 +116,11 @@ impl Default for SessionRenameDialogState { markdown_image: Color::Rgb(255, 140, 0), markdown_image_text: Color::Rgb(0, 255, 255), markdown_code_block: Color::Reset, + diff_add: Color::Rgb(0, 255, 0), + diff_add_bg: Color::Rgb(0, 60, 0), + diff_remove: Color::Rgb(255, 0, 0), + diff_remove_bg: Color::Rgb(60, 0, 0), + diff_gutter: Color::Rgb(140, 140, 140), }) } } From d9058d1d500c5a37fb1ff92d37861bf7f5ab7393 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 18 May 2026 14:31:22 +0800 Subject: [PATCH 074/226] feat: better interactive question dialog + fixed toolcall rendering. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a TUI question dialog that lets users answer agent questions interactively (options, multi-select, custom text entry). The question dialog replaces the previous auto-answer behavior. - Add `QuestionDialogState` and rendering in `src/views/question_dialog.rs` - Accept structured arrays (not just JSON strings) in question and todowrite tools - Support plain text / checkbox format in todowrite tool - Add `src/ui/wrapping.rs` — unicode-aware line wrapper that preserves span styles - Render question and todowrite tools as compact background panels in chat - Truncate excessively long tool output previews (max 8 lines) - Fix text selection coordinate mapping when chat content is shorter than viewport --- _plans/__TODOS.md | 2 + aisdk/src/lib.rs | 10 +- aisdk/src/provider.rs | 3 +- aisdk/src/providers/anthropic.rs | 55 +- aisdk/src/providers/compatible.rs | 124 +- aisdk/src/providers/mod.rs | 4 +- aisdk/src/providers/openai.rs | 30 +- aisdk/src/response.rs | 28 +- aisdk/src/tool.rs | 7 +- src/agent/subagent.rs | 14 +- src/app.rs | 227 ++-- src/command/handlers.rs | 4 +- src/llm/client.rs | 5 +- src/main.rs | 20 +- src/theme.rs | 28 +- src/tools/aisdk_bridge.rs | 7 +- src/tools/init.rs | 3 +- src/tools/question.rs | 203 ++- src/tools/skill.rs | 15 +- src/tools/task.rs | 9 +- src/tools/todowrite.rs | 324 ++++- src/tools/webfetch.rs | 45 +- src/ui/components/chat.rs | 800 +++++++++--- src/ui/components/dialog.rs | 3 +- src/ui/components/input.rs | 44 +- src/ui/diff.rs | 27 +- src/ui/markdown/streaming.rs | 173 ++- src/ui/markdown/table.rs | 6 +- src/ui/mod.rs | 1 + src/ui/selection.rs | 18 +- src/ui/wrapping.rs | 332 +++++ src/views/chat.rs | 2 +- src/views/home.rs | 6 +- src/views/mod.rs | 2 + src/views/question_dialog.rs | 2033 +++++++++++++++++++++++++++++ src/views/sessions_dialog.rs | 10 +- src/views/timeline_dialog.rs | 32 +- 37 files changed, 4057 insertions(+), 599 deletions(-) create mode 100644 src/ui/wrapping.rs create mode 100644 src/views/question_dialog.rs diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index fe5fa56..b2ff100 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -58,3 +58,5 @@ - [ ] Bug: I can type a command see autosuggest, but can't press 'enter' to run the command. Pls fix. - [ ] A single AI response, is considered 1 message. So combine all its parts into a single message record. Not that every message part becomes a separate message in the timeline dialog. + +- [ ] Allow me to paste images i.e. [Image #1] [Image #2] [Image #3]. When I click on them, the image would be opened with my Finder (OS-specific) diff --git a/aisdk/src/lib.rs b/aisdk/src/lib.rs index 442e15e..34677f4 100644 --- a/aisdk/src/lib.rs +++ b/aisdk/src/lib.rs @@ -1,11 +1,11 @@ -pub mod message; -pub mod tool; pub mod chunk; -pub mod stop; -pub mod response; pub mod error; +pub mod message; pub mod provider; pub mod providers; +pub mod response; +pub mod stop; +pub mod tool; pub mod core { pub use crate::chunk::ChunkType; @@ -17,8 +17,8 @@ pub mod core { pub mod language_model { pub use crate::chunk::ChunkType as LanguageModelStreamChunkType; pub use crate::response::LanguageModelStream; - pub use crate::stop::StopReason; pub use crate::stop::step_count_is; + pub use crate::stop::StopReason; } pub mod utils { diff --git a/aisdk/src/provider.rs b/aisdk/src/provider.rs index 7713f79..86e3d2b 100644 --- a/aisdk/src/provider.rs +++ b/aisdk/src/provider.rs @@ -30,5 +30,4 @@ pub trait Provider: Send + Sync + std::fmt::Debug + Clone + 'static { ) -> Result<ProviderStream>; } -pub type ProviderStream = - Pin<Box<dyn Stream<Item = Result<ChunkType>> + Send>>; +pub type ProviderStream = Pin<Box<dyn Stream<Item = Result<ChunkType>> + Send>>; diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs index 820ae1b..f9d404d 100644 --- a/aisdk/src/providers/anthropic.rs +++ b/aisdk/src/providers/anthropic.rs @@ -53,10 +53,16 @@ impl AnthropicBuilder { pub fn build(self) -> Result<Anthropic> { Ok(Anthropic { - base_url: self.base_url.ok_or(Error::MissingField("base_url".into()))?, + base_url: self + .base_url + .ok_or(Error::MissingField("base_url".into()))?, api_key: self.api_key.ok_or(Error::MissingField("api_key".into()))?, - model_name: self.model_name.ok_or(Error::MissingField("model_name".into()))?, - provider_name: self.provider_name.unwrap_or_else(|| "anthropic".to_string()), + model_name: self + .model_name + .ok_or(Error::MissingField("model_name".into()))?, + provider_name: self + .provider_name + .unwrap_or_else(|| "anthropic".to_string()), }) } } @@ -178,27 +184,21 @@ impl Provider for Anthropic { "content_block_delta" => { let delta = &value["delta"]; match delta["type"].as_str() { - Some("text_delta") => { - futures::future::ready( - delta["text"].as_str().map(|t| { - Ok(ChunkType::Text(t.to_string())) - }), - ) - } - Some("thinking_delta") => { - futures::future::ready( - delta["thinking"].as_str().map(|t| { - Ok(ChunkType::Reasoning(t.to_string())) - }), - ) - } - Some("input_json_delta") => { - futures::future::ready( - delta["partial_json"].as_str().map(|j| { - Ok(ChunkType::ToolCall(j.to_string())) - }), - ) - } + Some("text_delta") => futures::future::ready( + delta["text"] + .as_str() + .map(|t| Ok(ChunkType::Text(t.to_string()))), + ), + Some("thinking_delta") => futures::future::ready( + delta["thinking"] + .as_str() + .map(|t| Ok(ChunkType::Reasoning(t.to_string()))), + ), + Some("input_json_delta") => futures::future::ready( + delta["partial_json"] + .as_str() + .map(|j| Ok(ChunkType::ToolCall(j.to_string()))), + ), _ => futures::future::ready(None), } } @@ -216,9 +216,10 @@ impl Provider for Anthropic { } _ => futures::future::ready(None), }, - Err(e) => futures::future::ready(Some(Ok(ChunkType::Failed( - format!("Invalid SSE data: {}", e), - )))), + Err(e) => futures::future::ready(Some(Ok(ChunkType::Failed(format!( + "Invalid SSE data: {}", + e + ))))), } } Err(e) => { diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index affc4ae..e4a188e 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -53,9 +53,13 @@ impl OpenAICompatibleBuilder { pub fn build(self) -> Result<OpenAICompatible> { Ok(OpenAICompatible { - base_url: self.base_url.ok_or(Error::MissingField("base_url".into()))?, + base_url: self + .base_url + .ok_or(Error::MissingField("base_url".into()))?, api_key: self.api_key.ok_or(Error::MissingField("api_key".into()))?, - model_name: self.model_name.ok_or(Error::MissingField("model_name".into()))?, + model_name: self + .model_name + .ok_or(Error::MissingField("model_name".into()))?, provider_name: self .provider_name .unwrap_or_else(|| "openai-compatible".to_string()), @@ -208,7 +212,10 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { } let Some(choices) = value["choices"].as_array() else { - debug_log(&format!("[SSE] No choices array. JSON keys: {:?}", value.as_object().map(|o| o.keys().collect::<Vec<_>>()))); + debug_log(&format!( + "[SSE] No choices array. JSON keys: {:?}", + value.as_object().map(|o| o.keys().collect::<Vec<_>>()) + )); return vec![]; }; @@ -222,7 +229,10 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { let mut chunks = Vec::new(); // Log the full choice structure for debugging - debug_log(&format!("[SSE] Choice JSON: {}", serde_json::to_string(choice).unwrap_or_default())); + debug_log(&format!( + "[SSE] Choice JSON: {}", + serde_json::to_string(choice).unwrap_or_default() + )); // Emit text delta first (may coexist with finish_reason) // Try standard delta.content, then fallbacks for non-standard providers @@ -230,7 +240,11 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { .as_str() .filter(|s| !s.is_empty()) .or_else(|| choice["delta"]["text"].as_str().filter(|s| !s.is_empty())) - .or_else(|| choice["message"]["content"].as_str().filter(|s| !s.is_empty())) + .or_else(|| { + choice["message"]["content"] + .as_str() + .filter(|s| !s.is_empty()) + }) .or_else(|| choice["text"].as_str().filter(|s| !s.is_empty())); if let Some(delta) = text { @@ -242,8 +256,16 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { let reasoning = choice["delta"]["reasoning_content"] .as_str() .filter(|s| !s.is_empty()) - .or_else(|| choice["delta"]["reasoning"].as_str().filter(|s| !s.is_empty())) - .or_else(|| choice["reasoning_content"].as_str().filter(|s| !s.is_empty())); + .or_else(|| { + choice["delta"]["reasoning"] + .as_str() + .filter(|s| !s.is_empty()) + }) + .or_else(|| { + choice["reasoning_content"] + .as_str() + .filter(|s| !s.is_empty()) + }); if let Some(reasoning) = reasoning { debug_log(&format!("[SSE] Reasoning chunk: {}", reasoning)); @@ -262,7 +284,10 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { } if chunks.is_empty() { - debug_log(&format!("[SSE] No chunks produced. finish_reason='{}'", finish_reason)); + debug_log(&format!( + "[SSE] No chunks produced. finish_reason='{}'", + finish_reason + )); } chunks @@ -274,53 +299,56 @@ where S: futures::Stream<Item = std::result::Result<bytes::Bytes, reqwest::Error>> + Unpin, { let buffer: Vec<u8> = Vec::new(); - stream::unfold((byte_stream, buffer), |(mut stream, mut buffer)| async move { - loop { - if let Some(pos) = buffer.iter().position(|&b| b == b'\n') { - let line_bytes: Vec<u8> = buffer.drain(..=pos).collect(); - let line = String::from_utf8_lossy(&line_bytes); - let line = line.trim_end_matches('\n').trim_end_matches('\r'); - if line.is_empty() { - continue; - } - let data = if let Some(stripped) = line.strip_prefix("data:") { - stripped.trim_start().to_string() - } else { - line.to_string() - }; - if data == "[DONE]" || data.is_empty() { - continue; - } - debug_log(&format!("[LINE] Extracted: {}", data)); - return Some((data, (stream, buffer))); - } - match stream.next().await { - Some(Ok(bytes)) => { - debug_log(&format!("[BYTES] Received {} bytes", bytes.len())); - buffer.extend_from_slice(&bytes); - } - Some(Err(e)) => { - debug_log(&format!("[BYTES] Error: {}", e)); - return None; - } - None => { - let remaining = String::from_utf8_lossy(&buffer).trim().to_string(); - buffer.clear(); - if remaining.is_empty() || remaining == "[DONE]" { - debug_log("[LINE] Stream ended, no remaining data"); - return None; + stream::unfold( + (byte_stream, buffer), + |(mut stream, mut buffer)| async move { + loop { + if let Some(pos) = buffer.iter().position(|&b| b == b'\n') { + let line_bytes: Vec<u8> = buffer.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line_bytes); + let line = line.trim_end_matches('\n').trim_end_matches('\r'); + if line.is_empty() { + continue; } - let data = if let Some(stripped) = remaining.strip_prefix("data:") { + let data = if let Some(stripped) = line.strip_prefix("data:") { stripped.trim_start().to_string() } else { - remaining + line.to_string() }; - debug_log(&format!("[LINE] Remaining at EOF: {}", data)); + if data == "[DONE]" || data.is_empty() { + continue; + } + debug_log(&format!("[LINE] Extracted: {}", data)); return Some((data, (stream, buffer))); } + match stream.next().await { + Some(Ok(bytes)) => { + debug_log(&format!("[BYTES] Received {} bytes", bytes.len())); + buffer.extend_from_slice(&bytes); + } + Some(Err(e)) => { + debug_log(&format!("[BYTES] Error: {}", e)); + return None; + } + None => { + let remaining = String::from_utf8_lossy(&buffer).trim().to_string(); + buffer.clear(); + if remaining.is_empty() || remaining == "[DONE]" { + debug_log("[LINE] Stream ended, no remaining data"); + return None; + } + let data = if let Some(stripped) = remaining.strip_prefix("data:") { + stripped.trim_start().to_string() + } else { + remaining + }; + debug_log(&format!("[LINE] Remaining at EOF: {}", data)); + return Some((data, (stream, buffer))); + } + } } - } - }) + }, + ) } fn has_version_segment(base_url: &str) -> bool { diff --git a/aisdk/src/providers/mod.rs b/aisdk/src/providers/mod.rs index 34b4bd5..92e76ff 100644 --- a/aisdk/src/providers/mod.rs +++ b/aisdk/src/providers/mod.rs @@ -1,7 +1,7 @@ -pub mod openai; pub mod anthropic; pub mod compatible; +pub mod openai; -pub use openai::OpenAI; pub use anthropic::Anthropic; pub use compatible::OpenAICompatible; +pub use openai::OpenAI; diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index f16495e..9ae6096 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -94,9 +94,13 @@ impl OpenAIBuilder { } pub fn build(self) -> Result<OpenAI> { - let base_url = self.base_url.ok_or(Error::MissingField("base_url".into()))?; + let base_url = self + .base_url + .ok_or(Error::MissingField("base_url".into()))?; let api_key = self.api_key.ok_or(Error::MissingField("api_key".into()))?; - let model_name = self.model_name.ok_or(Error::MissingField("model_name".into()))?; + let model_name = self + .model_name + .ok_or(Error::MissingField("model_name".into()))?; let provider_name = self.provider_name.unwrap_or_else(|| "openai".to_string()); let responses_path = { @@ -284,16 +288,12 @@ impl Provider for OpenAI { // Stream exhausts naturally — no End chunk forwarded futures::future::ready(None) } - "response.incomplete" => { - futures::future::ready(Some(Ok(ChunkType::Incomplete( - "Response incomplete".to_string(), - )))) - } - "response.failed" => { - futures::future::ready(Some(Ok(ChunkType::Failed( - "Response failed".to_string(), - )))) - } + "response.incomplete" => futures::future::ready(Some(Ok( + ChunkType::Incomplete("Response incomplete".to_string()), + ))), + "response.failed" => futures::future::ready(Some(Ok( + ChunkType::Failed("Response failed".to_string()), + ))), _ => { if event_type.contains("tool_call") { futures::future::ready(Some(Ok(ChunkType::ToolCall( @@ -323,10 +323,7 @@ impl Provider for OpenAI { } } -fn build_openai_messages( - messages: &[Message], - strip_system: bool, -) -> Vec<serde_json::Value> { +fn build_openai_messages(messages: &[Message], strip_system: bool) -> Vec<serde_json::Value> { messages .iter() .filter_map(|msg| { @@ -352,4 +349,3 @@ fn build_openai_messages( }) .collect() } - diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 4c93bd6..66ac739 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -175,30 +175,25 @@ pub async fn stream_with_tools<P: Provider>( break; } - for (call_id, tool_name, args) in &tool_calls_to_execute { + for (_call_id, tool_name, args) in &tool_calls_to_execute { let tool = tools.iter().find(|t| &t.name == tool_name); match tool { Some(t) => match t.execute.call(args.clone()).await { Ok(result) => { - current_messages.push(Message::assistant(format!( - "[tool result: {}] {}", - tool_name, result - ))); - messages_arc.lock().await.push(Message::assistant(format!( - "{{\"tool_call_id\":\"{}\",\"role\":\"tool\",\"name\":\"{}\",\"content\":{}}}", - call_id, tool_name, result - ))); + let observation = format!("Tool `{}` result:\n{}", tool_name, result); + current_messages.push(Message::user(observation.clone())); + messages_arc.lock().await.push(Message::user(observation)); } Err(e) => { let _ = tx_loop.send(ChunkType::Failed(format!( - "Tool '{}' error: {}", tool_name, e + "Tool '{}' error: {}", + tool_name, e ))); } }, None => { - let _ = tx_loop.send(ChunkType::Failed(format!( - "Tool not found: {}", tool_name - ))); + let _ = tx_loop + .send(ChunkType::Failed(format!("Tool not found: {}", tool_name))); } } } @@ -218,9 +213,10 @@ fn parse_tool_calls( if let Some(arr) = parsed.as_array() { for item in arr { - if let (Some(id), Some(function)) = - (item.get("id").and_then(|v| v.as_str()), item.get("function")) - { + if let (Some(id), Some(function)) = ( + item.get("id").and_then(|v| v.as_str()), + item.get("function"), + ) { let name = function .get("name") .and_then(|v| v.as_str()) diff --git a/aisdk/src/tool.rs b/aisdk/src/tool.rs index c0564f2..2aea3f1 100644 --- a/aisdk/src/tool.rs +++ b/aisdk/src/tool.rs @@ -3,8 +3,11 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -pub type AsyncToolFn = - Arc<dyn Fn(serde_json::Value) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> + Send + Sync>; +pub type AsyncToolFn = Arc< + dyn Fn(serde_json::Value) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> + + Send + + Sync, +>; #[derive(Clone)] pub struct ToolExecute { diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 4bde6d9..9c08b20 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -1,5 +1,5 @@ -use crate::tools::ToolRegistry; use crate::agent::config::{get_llm_session, ProviderKind}; +use crate::tools::ToolRegistry; const EXPLORE_SYSTEM_PROMPT: &str = r#"You are a fast, read-only code exploration agent. Your job is to search codebases, find files, and answer questions about code structure. @@ -71,7 +71,9 @@ impl SubAgentType { pub fn allowed_tools(&self) -> Vec<&'static str> { match self { Self::Explore => vec!["glob", "grep", "read", "list"], - Self::General => vec!["bash", "edit", "write", "read", "grep", "glob", "list", "skill", "webfetch"], + Self::General => vec![ + "bash", "edit", "write", "read", "grep", "glob", "list", "skill", "webfetch", + ], } } } @@ -99,7 +101,10 @@ impl SubAgentDef { } } -pub async fn build_scoped_registry(full_registry: &ToolRegistry, subagent_type: &SubAgentType) -> ToolRegistry { +pub async fn build_scoped_registry( + full_registry: &ToolRegistry, + subagent_type: &SubAgentType, +) -> ToolRegistry { let scoped = ToolRegistry::new(); let allowed = subagent_type.allowed_tools(); @@ -132,8 +137,7 @@ pub async fn run_subagent( use std::collections::HashMap; let session = get_llm_session().ok_or("LLM session not configured")?; - let cwd = std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from(".")); + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let scoped_registry = build_scoped_registry(full_registry, &subagent_type).await; let permissions = crate::tools::ToolPermissions::new(cwd.clone()); diff --git a/src/app.rs b/src/app.rs index e9169e7..e8dbdbb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -32,6 +32,10 @@ use crate::views::permission_dialog::{ handle_permission_dialog_key_event, handle_permission_dialog_mouse_event, init_permission_dialog, render_permission_dialog, PermissionDialogAction, }; +use crate::views::question_dialog::{ + handle_question_dialog_key_event, handle_question_dialog_mouse_event, init_question_dialog, + render_question_dialog, QuestionDialogAction, +}; use crate::views::session_rename_dialog::{ handle_session_rename_dialog_key_event, init_session_rename_dialog, render_session_rename_dialog, RenameAction, @@ -50,8 +54,8 @@ use crate::views::themes_dialog::{ }; use crate::views::{ ChatState, ConnectDialogState, HomeState, ModelsDialogState, OpenAIOAuthFlowState, - PermissionDialogState, SessionRenameDialogState, SessionsDialogState, SuggestionsPopupState, - ThemesDialogState, + PermissionDialogState, QuestionDialogState, SessionRenameDialogState, SessionsDialogState, + SuggestionsPopupState, ThemesDialogState, }; use crate::{ @@ -91,6 +95,7 @@ pub enum OverlayFocus { SessionsDialog, SessionRenameDialog, PermissionDialog, + QuestionDialog, SkillsDialog, TimelineDialog, MessageActions, @@ -129,6 +134,7 @@ pub struct App { pub sessions_dialog_state: SessionsDialogState, pub session_rename_dialog_state: SessionRenameDialogState, pub permission_dialog_state: PermissionDialogState, + pub question_dialog_state: QuestionDialogState, pub skills_dialog_state: crate::views::SkillsDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, pub timeline_dialog_state: crate::views::timeline_dialog::TimelineDialogState, @@ -197,6 +203,7 @@ impl App { let openai_oauth_flow_state = init_openai_oauth_flow(); let sessions_dialog_state = init_sessions_dialog("Sessions", vec![]); let permission_dialog_state = init_permission_dialog(); + let question_dialog_state = init_question_dialog(); let skills_dialog_state = crate::views::skills_dialog::init_skills_dialog("Skills", vec![]); let which_key_state = crate::views::which_key::init_which_key(); let timeline_dialog_state = crate::views::timeline_dialog::init_timeline_dialog(); @@ -331,6 +338,7 @@ impl App { sessions_dialog_state, session_rename_dialog_state, permission_dialog_state, + question_dialog_state, skills_dialog_state, which_key_state, timeline_dialog_state, @@ -478,10 +486,9 @@ impl App { } } - if let Some(cost) = discovery.get_model_pricing( - &self.provider_name.to_lowercase(), - &self.model, - ) { + if let Some(cost) = + discovery.get_model_pricing(&self.provider_name.to_lowercase(), &self.model) + { let output_tokens: usize = self .chat_state .chat @@ -860,8 +867,7 @@ impl App { false } SessionsDialogAction::PendingDelete(_id) => { - self.sessions_dialog_state.dialog.pending_delete_id = - Some(_id.clone()); + self.sessions_dialog_state.dialog.pending_delete_id = Some(_id.clone()); true } SessionsDialogAction::Select(id) => { @@ -952,6 +958,30 @@ impl App { PermissionDialogAction::NotHandled => true, } } + OverlayFocus::QuestionDialog => { + let action = handle_question_dialog_key_event(&mut self.question_dialog_state, key); + match action { + QuestionDialogAction::Submit => { + self.question_dialog_state.submit_current(); + if self.question_dialog_state.has_active() { + self.overlay_focus = OverlayFocus::QuestionDialog; + } else { + self.chat_state.chat.resume_streaming_tps_timer(); + self.overlay_focus = OverlayFocus::None; + } + true + } + QuestionDialogAction::Cancel => { + self.question_dialog_state.clear_with_empty(); + self.chat_state.chat.resume_streaming_tps_timer(); + self.overlay_focus = OverlayFocus::None; + self.cancel_streaming(); + true + } + QuestionDialogAction::Handled => true, + QuestionDialogAction::NotHandled => true, + } + } OverlayFocus::SkillsDialog => { let action = crate::views::skills_dialog::handle_skills_dialog_key_event( &mut self.skills_dialog_state, @@ -1198,7 +1228,9 @@ impl App { fn update_suggestions(&mut self) { if self.input.should_show_suggestions() { - let suggestions = self.input.get_autocomplete_suggestions(self.base_focus == BaseFocus::Chat); + let suggestions = self + .input + .get_autocomplete_suggestions(self.base_focus == BaseFocus::Chat); if !suggestions.is_empty() { set_suggestions(&mut self.suggestions_popup_state, suggestions); self.overlay_focus = OverlayFocus::SuggestionsPopup; @@ -1233,6 +1265,8 @@ impl App { } } else if self.overlay_focus == OverlayFocus::PermissionDialog { let _ = handle_permission_dialog_mouse_event(&mut self.permission_dialog_state, mouse); + } else if self.overlay_focus == OverlayFocus::QuestionDialog { + let _ = handle_question_dialog_mouse_event(&mut self.question_dialog_state, mouse); } else if self.overlay_focus == OverlayFocus::ThemesDialog { let before = self .themes_dialog_state @@ -1369,7 +1403,7 @@ impl App { [ ratatui::layout::Constraint::Length(1), // Top padding ratatui::layout::Constraint::Min(0), // Chat content - ratatui::layout::Constraint::Length(1), // Bottom padding + ratatui::layout::Constraint::Length(0), // Bottom padding ratatui::layout::Constraint::Length(input_height), ratatui::layout::Constraint::Length(1), // Help bar ratatui::layout::Constraint::Length(1), // Blank @@ -1407,7 +1441,7 @@ impl App { [ ratatui::layout::Constraint::Length(1), // Top padding ratatui::layout::Constraint::Min(0), // Chat content - ratatui::layout::Constraint::Length(1), // Bottom padding + ratatui::layout::Constraint::Length(0), // Bottom padding ratatui::layout::Constraint::Length(input_height), ratatui::layout::Constraint::Length(1), // Help bar ratatui::layout::Constraint::Length(1), // Blank @@ -1569,6 +1603,9 @@ impl App { self.input.insert_str(&text); self.update_suggestions(); } + (_, OverlayFocus::QuestionDialog) => { + self.question_dialog_state.insert_text(&text); + } _ => {} } } @@ -1595,69 +1632,72 @@ impl App { match parse_input(input) { InputType::Command(mut parsed) => { - if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { - let messages = &self.chat_state.chat.messages; - let session_title = self - .session_manager - .get_current_session() - .map(|s| s.title.clone()) - .unwrap_or_else(|| "Untitled".to_string()); - let mut transcript = format!("# {}\n\n", session_title); - for msg in messages { - match msg.role { - crate::session::types::MessageRole::User => { - transcript.push_str("## User\n\n"); - transcript.push_str(&msg.content); - transcript.push_str("\n\n---\n\n"); - } - crate::session::types::MessageRole::Assistant => { - let agent = msg.agent_mode.as_ref().map_or("Build", |a| a.as_str()); - let model = msg.model.as_deref().unwrap_or("unknown"); - let duration = msg - .duration_ms - .map(|ms| format!(" · {:.1}s", ms as f64 / 1000.0)) - .unwrap_or_default(); - transcript.push_str(&format!( - "## Assistant ({agent} · {model}{duration})\n\n" - )); - transcript.push_str(&msg.content); - transcript.push_str("\n\n---\n\n"); - } - crate::session::types::MessageRole::Tool => { - transcript.push_str("**Tool Result**\n\n"); - if let Ok(v) = serde_json::from_str::<serde_json::Value>(&msg.content) { - if let Some(name) = v.get("name").and_then(|n| n.as_str()) { - transcript.push_str(&format!("**Tool:** {}\n", name)); + if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { + let messages = &self.chat_state.chat.messages; + let session_title = self + .session_manager + .get_current_session() + .map(|s| s.title.clone()) + .unwrap_or_else(|| "Untitled".to_string()); + let mut transcript = format!("# {}\n\n", session_title); + for msg in messages { + match msg.role { + crate::session::types::MessageRole::User => { + transcript.push_str("## User\n\n"); + transcript.push_str(&msg.content); + transcript.push_str("\n\n---\n\n"); } - if let Some(preview) = v.get("output_preview").and_then(|p| p.as_str()) - { - transcript.push_str(&format!("```\n{}\n```\n", preview)); + crate::session::types::MessageRole::Assistant => { + let agent = msg.agent_mode.as_ref().map_or("Build", |a| a.as_str()); + let model = msg.model.as_deref().unwrap_or("unknown"); + let duration = msg + .duration_ms + .map(|ms| format!(" · {:.1}s", ms as f64 / 1000.0)) + .unwrap_or_default(); + transcript.push_str(&format!( + "## Assistant ({agent} · {model}{duration})\n\n" + )); + transcript.push_str(&msg.content); + transcript.push_str("\n\n---\n\n"); } + crate::session::types::MessageRole::Tool => { + transcript.push_str("**Tool Result**\n\n"); + if let Ok(v) = + serde_json::from_str::<serde_json::Value>(&msg.content) + { + if let Some(name) = v.get("name").and_then(|n| n.as_str()) { + transcript.push_str(&format!("**Tool:** {}\n", name)); + } + if let Some(preview) = + v.get("output_preview").and_then(|p| p.as_str()) + { + transcript.push_str(&format!("```\n{}\n```\n", preview)); + } + } + transcript.push_str("\n---\n\n"); + } + _ => {} } - transcript.push_str("\n---\n\n"); } - _ => {} - } - } - match crate::utils::clipboard::copy_text(&transcript) { - Ok(_) => { - push_toast(Toast::new( - "Session transcript copied to clipboard!", - ToastLevel::Info, - None, - )); - } - Err(e) => { - push_toast(Toast::new( - format!("Failed to copy: {}", e), - ToastLevel::Error, - Some(std::time::Duration::from_secs(3)), - )); + match crate::utils::clipboard::copy_text(&transcript) { + Ok(_) => { + push_toast(Toast::new( + "Session transcript copied to clipboard!", + ToastLevel::Info, + None, + )); + } + Err(e) => { + push_toast(Toast::new( + format!("Failed to copy: {}", e), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + } + return; } - } - return; - } - if parsed.name == "themes" { + if parsed.name == "themes" { self.show_themes_dialog(); return; } @@ -1665,7 +1705,10 @@ impl App { self.show_skills_dialog(); return; } - if parsed.name == "rename" && parsed.args.is_empty() && self.base_focus == BaseFocus::Chat { + if parsed.name == "rename" + && parsed.args.is_empty() + && self.base_focus == BaseFocus::Chat + { if let Some(session) = self.session_manager.get_current_session() { let id = session.id.clone(); let title = session.title.clone(); @@ -2084,10 +2127,7 @@ impl App { "undo" => { let undone_content: Option<String> = { if let Some(session) = self.session_manager.get_current_session() { - let content = session - .messages - .get(idx) - .map(|m| m.content.clone()); + let content = session.messages.get(idx).map(|m| m.content.clone()); session.messages.truncate(idx); content } else { @@ -2712,9 +2752,13 @@ impl App { fn cleanup_streaming(&mut self) { self.chat_state.chat.resume_streaming_tps_timer(); self.permission_dialog_state.clear_with_deny(); + self.question_dialog_state.clear_with_empty(); if self.overlay_focus == OverlayFocus::PermissionDialog { self.overlay_focus = OverlayFocus::None; } + if self.overlay_focus == OverlayFocus::QuestionDialog { + self.overlay_focus = OverlayFocus::None; + } self.chunk_sender = None; self.chunk_receiver = None; self.streaming_cancel_token = None; @@ -2953,9 +2997,10 @@ impl App { questions, response_tx, } => { + self.play_sound_event(crate::sound::SoundEvent::Question); self.chat_state.chat.pause_streaming_tps_timer(); - let answers = auto_answer_questions(&questions); - let _ = response_tx.send(answers); + self.question_dialog_state.enqueue(questions, response_tx); + self.overlay_focus = OverlayFocus::QuestionDialog; } } } @@ -3305,6 +3350,12 @@ impl App { render_permission_dialog(f, &mut self.permission_dialog_state, size, colors); } + if self.overlay_focus == OverlayFocus::QuestionDialog + && self.question_dialog_state.has_active() + { + render_question_dialog(f, &mut self.question_dialog_state, size, colors); + } + if self.overlay_focus == OverlayFocus::WhichKey { crate::views::which_key::render_which_key(f, &self.which_key_state, &colors); } @@ -3325,30 +3376,6 @@ fn format_token_count(count: usize) -> String { format!("{:.1}M", m) } -fn auto_answer_questions(questions: &serde_json::Value) -> serde_json::Value { - let arr = match questions { - serde_json::Value::Array(a) => a, - _ => return serde_json::Value::Array(vec![]), - }; - - let answers: Vec<serde_json::Value> = arr - .iter() - .map(|q| { - let options = q.get("options").and_then(|o| o.as_array()); - match options { - Some(opts) if !opts.is_empty() => { - let labels: Vec<serde_json::Value> = - vec![opts[0].get("label").cloned().unwrap_or(serde_json::Value::Null)]; - serde_json::Value::Array(labels) - } - _ => serde_json::Value::Array(vec![]), - } - }) - .collect(); - - serde_json::Value::Array(answers) -} - impl Default for App { fn default() -> Self { Self::new().expect("Failed to initialize App") diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 5d687de..ebbe44c 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -430,9 +430,7 @@ pub fn handle_timeline<'a>( Box::pin(async move { if !args.is_empty() { - return CommandResult::Error( - "Usage: /timeline".to_string(), - ); + return CommandResult::Error("Usage: /timeline".to_string()); } CommandResult::Success(String::new()) diff --git a/src/llm/client.rs b/src/llm/client.rs index ee9216b..648bbf6 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -440,7 +440,10 @@ async fn relay_stream_to_sender( } ChunkType::Reasoning(reasoning) => { *token_count += estimate_tokens(&reasoning); - let _ = log(&format!("[RELAY] Reasoning chunk ({} chars)", reasoning.len())); + let _ = log(&format!( + "[RELAY] Reasoning chunk ({} chars)", + reasoning.len() + )); let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } ChunkType::ToolCall(_tool_call) => { diff --git a/src/main.rs b/src/main.rs index 17b2266..1442862 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,7 +83,10 @@ fn format_post_close_message(info: Option<&PostCloseInfo>) -> String { if let Some(info) = info { msg.push('\n'); msg.push_str(&format!(" {:<10}{}\n", "Session", info.session_title)); - msg.push_str(&format!(" {:<10}crabcode -s {}\n", "Continue", info.session_id)); + msg.push_str(&format!( + " {:<10}crabcode -s {}\n", + "Continue", info.session_id + )); } msg @@ -99,7 +102,9 @@ async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<() let prefs_dao = crate::persistence::PrefsDAO::new().ok(); let (provider_name, model_id) = { - let active = prefs_dao.as_ref().and_then(|d| d.get_active_model().ok().flatten()); + let active = prefs_dao + .as_ref() + .and_then(|d| d.get_active_model().ok().flatten()); if let Some((pid, mid)) = active { (pid, mid) } else if let Some(m) = loaded_config.merged_config.model.clone() { @@ -131,16 +136,11 @@ async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<() std::env::consts::OS, ); let system_prompt = composer.compose().await; - let messages = vec![ - Message::system(system_prompt), - Message::user(prompt), - ]; + let messages = vec![Message::system(system_prompt), Message::user(prompt)]; let (sender, mut receiver) = mpsc::unbounded_channel(); - let tool_permissions = crate::tools::ToolPermissions::new( - std::path::PathBuf::from(&cwd), - ); + let tool_permissions = crate::tools::ToolPermissions::new(std::path::PathBuf::from(&cwd)); let agent_max_steps = loaded_config .merged_config @@ -338,7 +338,7 @@ async fn run_event_loop( ) -> Result<()> { // Adaptive poll duration: fast when animations run (home page / streaming), // slow otherwise to avoid wasting CPU on unnecessary re-renders. - const FAST_POLL: Duration = Duration::from_millis(16); // ~60fps for animations + const FAST_POLL: Duration = Duration::from_millis(16); // ~60fps for animations const SLOW_POLL: Duration = Duration::from_millis(250); // ~4fps idle while app.running { diff --git a/src/theme.rs b/src/theme.rs index 90dd59d..1a72c18 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -363,10 +363,30 @@ impl Theme { let markdown_code_block = resolve_override(mode.overrides.markdown_code_block.as_deref(), markdown_text); - let diff_add = mode.seeds.diff_add.as_deref().map(parse_hex).unwrap_or(success); - let diff_remove = mode.seeds.diff_delete.as_deref().map(parse_hex).unwrap_or(error); - let diff_add_bg = mode.overrides.surface_diff_add_base.as_deref().map(parse_hex).unwrap_or(success); - let diff_remove_bg = mode.overrides.surface_diff_delete_base.as_deref().map(parse_hex).unwrap_or(error); + let diff_add = mode + .seeds + .diff_add + .as_deref() + .map(parse_hex) + .unwrap_or(success); + let diff_remove = mode + .seeds + .diff_delete + .as_deref() + .map(parse_hex) + .unwrap_or(error); + let diff_add_bg = mode + .overrides + .surface_diff_add_base + .as_deref() + .map(parse_hex) + .unwrap_or(success); + let diff_remove_bg = mode + .overrides + .surface_diff_delete_base + .as_deref() + .map(parse_hex) + .unwrap_or(error); let diff_gutter = text_weak; ThemeColors { diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index e2c8f08..9cc85ca 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -79,12 +79,7 @@ pub async fn convert_to_aisdk_tools( } permissions - .preflight( - &agent_mode, - &tool_id_for_exec, - &input, - sender.as_ref(), - ) + .preflight(&agent_mode, &tool_id_for_exec, &input, sender.as_ref()) .await .map_err(|e| format!("{}", e))?; diff --git a/src/tools/init.rs b/src/tools/init.rs index b0f75e4..6f01719 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,6 +1,7 @@ use crate::tools::{ fs::{GlobTool, GrepTool, ListTool, ReadTool, WriteTool}, - BashTool, EditTool, QuestionTool, SkillTool, TaskTool, TodowriteTool, ToolRegistry, WebfetchTool, + BashTool, EditTool, QuestionTool, SkillTool, TaskTool, TodowriteTool, ToolRegistry, + WebfetchTool, }; use std::sync::Arc; diff --git a/src/tools/question.rs b/src/tools/question.rs index 30af31b..e0d30a6 100644 --- a/src/tools/question.rs +++ b/src/tools/question.rs @@ -1,10 +1,87 @@ use crate::llm::ChunkSender; use crate::tools::{ - get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, - ToolError, ToolHandler, ToolResult, + validate_required, ParameterSchema, ParameterType, Tool, ToolContext, ToolError, ToolHandler, + ToolResult, }; use async_trait::async_trait; -use serde_json::Value; +use serde_json::{Map, Value}; + +fn question_from_plain_text(params: &Value, question: &str) -> Value { + let mut item = Map::new(); + item.insert("question".to_string(), Value::String(question.to_string())); + + let header = params + .get("header") + .and_then(|v| v.as_str()) + .unwrap_or("Question"); + item.insert("header".to_string(), Value::String(header.to_string())); + + for key in [ + "options", + "custom", + "multiple", + "allow_multiple", + "allowMultiple", + "multi", + "multiselect", + "multi_select", + "multipleChoice", + "multiple_choice", + "type", + "kind", + "mode", + "selection", + "selection_type", + "allow_random_order", + ] { + if let Some(value) = params.get(key) { + item.insert(key.to_string(), value.clone()); + } + } + + Value::Array(vec![Value::Object(item)]) +} + +fn parse_questions_string(params: &Value, raw: &str) -> Result<Value, ToolError> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ToolError::Validation( + "questions parameter cannot be empty".to_string(), + )); + } + + if !trimmed.starts_with('{') && !trimmed.starts_with('[') && !trimmed.starts_with('"') { + return Ok(question_from_plain_text(params, trimmed)); + } + + serde_json::from_str::<Value>(trimmed) + .map_err(|e| ToolError::Validation(format!("Invalid JSON for questions parameter: {}", e))) +} + +fn parse_questions_param(params: &Value) -> Result<Value, ToolError> { + let raw = params.get("questions").ok_or_else(|| { + ToolError::Validation("Missing required parameter: questions".to_string()) + })?; + + let parsed = match raw { + Value::String(s) => parse_questions_string(params, s)?, + Value::Array(_) | Value::Object(_) => raw.clone(), + _ => { + return Err(ToolError::Validation( + "questions parameter must be an array, object, or JSON string".to_string(), + )); + } + }; + + match parsed { + Value::Array(_) => Ok(parsed), + Value::Object(_) => Ok(Value::Array(vec![parsed])), + Value::String(s) if !s.trim().is_empty() => Ok(question_from_plain_text(params, &s)), + _ => Err(ToolError::Validation( + "questions JSON must decode to an array or object".to_string(), + )), + } +} pub struct QuestionTool { sender: Option<ChunkSender>, @@ -31,26 +108,23 @@ impl ToolHandler for QuestionTool { fn definition(&self) -> Tool { Tool { id: "question".to_string(), - description: "Use this tool when you need to ask the user questions during execution. This allows you to:\n1. Gather user preferences or requirements\n2. Clarify ambiguous instructions\n3. Get decisions on implementation choices as you work\n4. Offer choices to the user about what direction to take.\n\nUsage notes:\n- Questions are answered as arrays of labels\n- You can allow multiple selections or single selection\n- Each question needs a header (short label) and options with labels and descriptions\n- When `custom` is enabled, a \"Type your own answer\" option is added automatically\n- The answers will come back as arrays of selected labels".to_string(), + description: "Use this tool when you need to ask the user questions during execution. This allows you to:\n1. Gather user preferences or requirements\n2. Clarify ambiguous instructions\n3. Get decisions on implementation choices as you work\n4. Offer choices to the user about what direction to take.\n\nUsage notes:\n- Questions are answered as arrays of labels\n- You can allow multiple selections or single selection\n- For select-all-that-apply questions, set `multiple: true`\n- Each question needs a header (short label) and options with labels and descriptions\n- A \"Type your own answer\" option is always available for option questions\n- The answers will come back as arrays of selected labels or custom answers".to_string(), parameters: vec![ParameterSchema { name: "questions".to_string(), - description: "JSON string of question objects with: question (text), header (short label), options (array of {label, description}), and optional multiple (bool)".to_string(), + description: "Array of question objects with: question (text), header (short label), options (array of {label, description}), and optional multiple (bool)".to_string(), required: true, - param_type: ParameterType::String, + param_type: ParameterType::Array(Box::new(ParameterType::Object(Default::default()))), }], } } fn validate(&self, params: &Value) -> Result<(), ToolError> { - validate_required(params, &["questions"]) + validate_required(params, &["questions"])?; + parse_questions_param(params).map(|_| ()) } async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<ToolResult, ToolError> { - let questions_raw = get_string_param(¶ms, "questions").unwrap_or_default(); - - let questions: Value = serde_json::from_str(&questions_raw).map_err(|e| { - ToolError::Validation(format!("Invalid JSON for questions parameter: {}", e)) - })?; + let questions = parse_questions_param(¶ms)?; let sender = self.sender.as_ref().ok_or_else(|| { ToolError::Execution("Question tool has no sender configured".to_string()) @@ -71,16 +145,105 @@ impl ToolHandler for QuestionTool { return Err(ToolError::Execution("Cancelled".to_string())); } - let response = response_rx.await.unwrap_or_else(|_| { - serde_json::Value::String("No response from user".to_string()) + let response = response_rx + .await + .unwrap_or_else(|_| serde_json::Value::String("No response from user".to_string())); + + let output = + serde_json::to_string_pretty(&response).unwrap_or_else(|_| response.to_string()); + + Ok(ToolResult::new("Question answered", output) + .with_metadata("questions", questions) + .with_metadata("answers", response)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_questions_accepts_structured_array() { + let params = json!({ + "questions": [{ + "question": "Pick an option", + "header": "Choice", + "options": [{ "label": "A", "description": "First" }] + }] + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions[0]["question"], "Pick an option"); + } + + #[test] + fn parse_questions_accepts_json_string() { + let params = json!({ + "questions": r#"[{"question":"Pick","header":"Choice","options":[]}]"# }); - let output = serde_json::to_string_pretty(&response) - .unwrap_or_else(|_| response.to_string()); + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions[0]["header"], "Choice"); + } + + #[test] + fn parse_questions_accepts_plain_text_with_top_level_options() { + let params = json!({ + "questions": "What should the table contain?", + "options": [ + { "label": "Stats", "description": "Show project stats" }, + { "label": "Files", "description": "Show file list" } + ], + "custom": true + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions[0]["question"], "What should the table contain?"); + assert_eq!(questions[0]["header"], "Question"); + assert_eq!(questions[0]["options"][0]["label"], "Stats"); + assert_eq!(questions[0]["custom"], true); + } + + #[test] + fn parse_questions_accepts_json_encoded_plain_text() { + let params = json!({ "questions": r#""Pick one""# }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions[0]["question"], "Pick one"); + } + + #[test] + fn parse_questions_wraps_single_object() { + let params = json!({ + "questions": { + "question": "Pick", + "header": "Choice", + "options": [] + } + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions.as_array().unwrap().len(), 1); + assert_eq!(questions[0]["question"], "Pick"); + } + + #[test] + fn parse_questions_rejects_empty_string() { + let params = json!({ "questions": "" }); + + let err = parse_questions_param(¶ms).unwrap_err().to_string(); - Ok(ToolResult::new("Question answered", output).with_metadata( - "questions", - questions, - )) + assert!(err.contains("questions parameter cannot be empty")); } } diff --git a/src/tools/skill.rs b/src/tools/skill.rs index af4c652..dabdb98 100644 --- a/src/tools/skill.rs +++ b/src/tools/skill.rs @@ -29,10 +29,7 @@ impl SkillTool { desc.push_str(&format!(" <skill>\n")); desc.push_str(&format!(" <name>{}</name>\n", skill.name)); if let Some(ref desc_text) = skill.description { - desc.push_str(&format!( - " <description>{}</description>\n", - desc_text - )); + desc.push_str(&format!(" <description>{}</description>\n", desc_text)); } desc.push_str(&format!( " <location>file://{}</location>\n", @@ -80,14 +77,16 @@ impl ToolHandler for SkillTool { let name = get_string_param(¶ms, "name").unwrap_or_default(); let name = name.trim(); - let store = crate::skill::get_skill_store().ok_or_else(|| { - ToolError::Execution("Skill store not initialized".to_string()) - })?; + let store = crate::skill::get_skill_store() + .ok_or_else(|| ToolError::Execution("Skill store not initialized".to_string()))?; let info = store.get(name).ok_or_else(|| { let available: Vec<String> = store.all().iter().map(|s| s.name.clone()).collect(); let msg = if available.is_empty() { - format!("Skill \"{}\" not found. No skills are currently available.", name) + format!( + "Skill \"{}\" not found. No skills are currently available.", + name + ) } else { format!( "Skill \"{}\" not found. Available skills: {}", diff --git a/src/tools/task.rs b/src/tools/task.rs index 3e9bb9b..cb5e21f 100644 --- a/src/tools/task.rs +++ b/src/tools/task.rs @@ -1,7 +1,7 @@ use crate::agent::subagent::{self, SubAgentType}; use crate::tools::{ get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, - ToolError, ToolHandler, ToolResult, ToolRegistry, + ToolError, ToolHandler, ToolRegistry, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -67,10 +67,9 @@ impl ToolHandler for TaskTool { let description = get_string_param(¶ms, "description").unwrap_or_default(); let prompt = get_string_param(¶ms, "prompt").unwrap_or_default(); - let subagent_type = SubAgentType::from_str(&subagent_type_str) - .ok_or_else(|| ToolError::Validation(format!( - "Unknown subagent type: {}", subagent_type_str - )))?; + let subagent_type = SubAgentType::from_str(&subagent_type_str).ok_or_else(|| { + ToolError::Validation(format!("Unknown subagent type: {}", subagent_type_str)) + })?; if ctx.is_aborted() { return Err(ToolError::Execution("Subagent cancelled".to_string())); diff --git a/src/tools/todowrite.rs b/src/tools/todowrite.rs index 55a0f1e..faca34a 100644 --- a/src/tools/todowrite.rs +++ b/src/tools/todowrite.rs @@ -1,10 +1,11 @@ use crate::tools::{ - get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, - ToolError, ToolHandler, ToolResult, + validate_required, ParameterSchema, ParameterType, Tool, ToolContext, ToolError, ToolHandler, + ToolResult, }; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; #[derive(Debug, Deserialize, Serialize)] struct TodoItem { @@ -13,6 +14,221 @@ struct TodoItem { priority: String, } +fn todo_item_param_type() -> ParameterType { + let mut props = HashMap::new(); + props.insert("content".to_string(), ParameterType::String); + props.insert("status".to_string(), ParameterType::String); + props.insert("priority".to_string(), ParameterType::String); + ParameterType::Object(props) +} + +fn normalize_status(status: Option<&str>) -> String { + match status + .unwrap_or("pending") + .trim() + .to_ascii_lowercase() + .as_str() + { + "todo" | "open" | "not_started" | "not-started" => "pending".to_string(), + "doing" | "active" | "in-progress" | "in progress" => "in_progress".to_string(), + "done" | "complete" => "completed".to_string(), + "canceled" => "cancelled".to_string(), + value => value.to_string(), + } +} + +fn normalize_priority(priority: Option<&str>) -> String { + match priority + .unwrap_or("medium") + .trim() + .to_ascii_lowercase() + .as_str() + { + "normal" => "medium".to_string(), + value => value.to_string(), + } +} + +fn todo_from_plain(content: &str, status: &str) -> TodoItem { + TodoItem { + content: content.trim().to_string(), + status: status.to_string(), + priority: "medium".to_string(), + } +} + +fn strip_list_marker(line: &str) -> &str { + let trimmed = line.trim(); + if let Some(rest) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + .or_else(|| trimmed.strip_prefix("+ ")) + { + return rest.trim_start(); + } + + if let Some((prefix, rest)) = trimmed.split_once(". ") { + if !prefix.is_empty() && prefix.chars().all(|ch| ch.is_ascii_digit()) { + return rest.trim_start(); + } + } + + trimmed +} + +fn parse_checkbox_line(line: &str) -> Option<TodoItem> { + let line = strip_list_marker(line); + let (status, rest) = if let Some(rest) = line.strip_prefix("[ ]") { + ("pending", rest) + } else if let Some(rest) = line.strip_prefix("[x]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[X]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[✓]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[✔]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[•]") { + ("in_progress", rest) + } else { + return None; + }; + + let content = rest.trim(); + if content.is_empty() { + None + } else { + Some(todo_from_plain(content, status)) + } +} + +fn parse_plain_todos(raw: &str) -> Vec<TodoItem> { + raw.lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + parse_checkbox_line(trimmed).or_else(|| { + let content = strip_list_marker(trimmed); + if content.is_empty() { + None + } else { + Some(todo_from_plain(content, "pending")) + } + }) + }) + .collect() +} + +fn parse_todo_value(value: &Value) -> Result<TodoItem, ToolError> { + match value { + Value::Object(obj) => { + let content = obj + .get("content") + .or_else(|| obj.get("todo")) + .or_else(|| obj.get("task")) + .or_else(|| obj.get("title")) + .or_else(|| obj.get("description")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + Ok(TodoItem { + content: content.trim().to_string(), + status: normalize_status(obj.get("status").and_then(|v| v.as_str())), + priority: normalize_priority(obj.get("priority").and_then(|v| v.as_str())), + }) + } + Value::String(content) => Ok(todo_from_plain(content, "pending")), + _ => Err(ToolError::Validation( + "Each todo must be an object or string".to_string(), + )), + } +} + +fn parse_todos_value(value: &Value) -> Result<Vec<TodoItem>, ToolError> { + match value { + Value::Array(items) => items.iter().map(parse_todo_value).collect(), + Value::Object(_) | Value::String(_) => Ok(vec![parse_todo_value(value)?]), + _ => Err(ToolError::Validation( + "todos must be an array, object, string, or JSON string".to_string(), + )), + } +} + +fn parse_todos_string(raw: &str) -> Result<Vec<TodoItem>, ToolError> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ToolError::Validation( + "Todos parameter cannot be empty".to_string(), + )); + } + + if trimmed + .lines() + .any(|line| parse_checkbox_line(line).is_some()) + { + return Ok(parse_plain_todos(trimmed)); + } + + let starts_like_json = trimmed.starts_with('[') || trimmed.starts_with('{'); + if !starts_like_json { + return Ok(parse_plain_todos(trimmed)); + } + + let parsed = serde_json::from_str::<Value>(trimmed) + .map_err(|e| ToolError::Validation(format!("Invalid todo JSON: {}", e)))?; + parse_todos_value(&parsed) +} + +fn parse_todos_param(params: &Value) -> Result<Vec<TodoItem>, ToolError> { + let raw = params + .get("todos") + .ok_or_else(|| ToolError::Validation("Missing required parameter: todos".to_string()))?; + + match raw { + Value::String(s) => parse_todos_string(s), + Value::Array(_) | Value::Object(_) => parse_todos_value(raw), + _ => Err(ToolError::Validation( + "todos must be an array, object, string, or JSON string".to_string(), + )), + } +} + +fn validate_todos(todos: &[TodoItem]) -> Result<(), ToolError> { + if todos.is_empty() { + return Err(ToolError::Validation( + "Todos array must contain at least one item".to_string(), + )); + } + + for (i, todo) in todos.iter().enumerate() { + if todo.content.trim().is_empty() { + return Err(ToolError::Validation(format!( + "Todo item {} has empty content", + i + ))); + } + if !matches!( + todo.status.as_str(), + "pending" | "in_progress" | "completed" | "cancelled" + ) { + return Err(ToolError::Validation(format!( + "Todo item '{}' has invalid status: {}. Must be one of: pending, in_progress, completed, cancelled", + todo.content, todo.status + ))); + } + if !matches!(todo.priority.as_str(), "high" | "medium" | "low") { + return Err(ToolError::Validation(format!( + "Todo item '{}' has invalid priority: {}. Must be one of: high, medium, low", + todo.content, todo.priority + ))); + } + } + + Ok(()) +} + pub struct TodowriteTool; impl TodowriteTool { @@ -26,56 +242,25 @@ impl ToolHandler for TodowriteTool { fn definition(&self) -> Tool { Tool { id: "todowrite".to_string(), - description: "Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.\n\n## When to Use This Tool\nUse this tool proactively in these scenarios:\n\n1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions\n2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations\n3. User explicitly requests todo list - When the user directly asks you to use the todo list\n4. User provides multiple tasks - When users provide a list of things to be done\n5. After receiving new instructions - Immediately capture user requirements as todos\n6. After completing a task - Mark it complete and add any new follow-up tasks\n\n## Task States and Management\n\n1. **Task States**: Use these states:\n - pending: Task not yet started\n - in_progress: Currently working on (limit to ONE at a time)\n - completed: Task finished successfully\n - cancelled: Task no longer needed\n\n2. **Task Management**:\n - Update task status in real-time as you work\n - Mark tasks complete IMMEDIATELY after finishing\n - Only have ONE task in_progress at any time\n - Complete current tasks before starting new ones\n\nParameters:\n- todos: Array of todo items (JSON string) each with content, status (pending/in_progress/completed/cancelled), and priority (high/medium/low)".to_string(), + description: "Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.\n\n## When to Use This Tool\nUse this tool proactively in these scenarios:\n\n1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions\n2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations\n3. User explicitly requests todo list - When the user directly asks you to use the todo list\n4. User provides multiple tasks - When users provide a list of things to be done\n5. After receiving new instructions - Immediately capture user requirements as todos\n6. After completing a task - Mark it complete and add any new follow-up tasks\n\n## Task States and Management\n\n1. **Task States**: Use these states:\n - pending: Task not yet started\n - in_progress: Currently working on (limit to ONE at a time)\n - completed: Task finished successfully\n - cancelled: Task no longer needed\n\n2. **Task Management**:\n - Update task status in real-time as you work\n - Mark tasks complete IMMEDIATELY after finishing\n - Only have ONE task in_progress at any time\n - Complete current tasks before starting new ones\n\nParameters:\n- todos: Array of todo items, each with content, status (pending/in_progress/completed/cancelled), and priority (high/medium/low)".to_string(), parameters: vec![ParameterSchema { name: "todos".to_string(), - description: "JSON string of todo items array, each with: content, status, priority".to_string(), + description: "Array of todo items, each with: content, status, priority. Plain checklist text is also accepted for compatibility.".to_string(), required: true, - param_type: ParameterType::String, + param_type: ParameterType::Array(Box::new(todo_item_param_type())), }], } } fn validate(&self, params: &Value) -> Result<(), ToolError> { - validate_required(params, &["todos"]) + validate_required(params, &["todos"])?; + let todos = parse_todos_param(params)?; + validate_todos(&todos) } async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> { - let todos_raw = get_string_param(¶ms, "todos").unwrap_or_default(); - - let todos: Vec<TodoItem> = serde_json::from_str(&todos_raw).map_err(|e| { - ToolError::Validation(format!("Invalid todo JSON: {}", e)) - })?; - - if todos.is_empty() { - return Err(ToolError::Validation( - "Todos array must contain at least one item".to_string(), - )); - } - - for (i, todo) in todos.iter().enumerate() { - if todo.content.trim().is_empty() { - return Err(ToolError::Validation(format!( - "Todo item {} has empty content", - i - ))); - } - if !matches!( - todo.status.as_str(), - "pending" | "in_progress" | "completed" | "cancelled" - ) { - return Err(ToolError::Validation(format!( - "Todo item '{}' has invalid status: {}. Must be one of: pending, in_progress, completed, cancelled", - todo.content, todo.status - ))); - } - if !matches!(todo.priority.as_str(), "high" | "medium" | "low") { - return Err(ToolError::Validation(format!( - "Todo item '{}' has invalid priority: {}. Must be one of: high, medium, low", - todo.content, todo.priority - ))); - } - } + let todos = parse_todos_param(¶ms)?; + validate_todos(&todos)?; let mut output = String::new(); @@ -88,9 +273,58 @@ impl ToolHandler for TodowriteTool { output.push_str(&format!("{} {}\n", mark, todo.content)); } - Ok(ToolResult::new("Todo list updated", output.clone()).with_metadata( - "todo_items", - serde_json::json!(todos), - )) + Ok(ToolResult::new("Todo list updated", output.clone()) + .with_metadata("todo_items", serde_json::json!(todos))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_todos_accepts_structured_array() { + let params = json!({ + "todos": [{ + "content": "Implement rendering", + "status": "in_progress", + "priority": "high" + }] + }); + + let todos = parse_todos_param(¶ms).unwrap(); + + assert_eq!(todos.len(), 1); + assert_eq!(todos[0].content, "Implement rendering"); + assert_eq!(todos[0].status, "in_progress"); + assert_eq!(todos[0].priority, "high"); + } + + #[test] + fn parse_todos_accepts_json_string_for_compatibility() { + let params = json!({ + "todos": r#"[{"content":"Choose rendering file","status":"pending","priority":"medium"}]"# + }); + + let todos = parse_todos_param(¶ms).unwrap(); + + assert_eq!(todos.len(), 1); + assert_eq!(todos[0].content, "Choose rendering file"); + } + + #[test] + fn parse_todos_accepts_plain_checkbox_text() { + let params = json!({ + "todos": "[ ] Define table data\n[•] Implement rendering\n[✓] Verify output" + }); + + let todos = parse_todos_param(¶ms).unwrap(); + + assert_eq!(todos.len(), 3); + assert_eq!(todos[0].status, "pending"); + assert_eq!(todos[1].status, "in_progress"); + assert_eq!(todos[2].status, "completed"); + assert_eq!(todos[0].priority, "medium"); } } diff --git a/src/tools/webfetch.rs b/src/tools/webfetch.rs index 59c976b..1b2630f 100644 --- a/src/tools/webfetch.rs +++ b/src/tools/webfetch.rs @@ -77,9 +77,11 @@ impl ToolHandler for WebfetchTool { .build() .map_err(|e| ToolError::Execution(format!("Failed to create HTTP client: {}", e)))?; - let response = client.get(&url).send().await.map_err(|e| { - ToolError::Execution(format!("Failed to fetch URL: {}", e)) - })?; + let response = client + .get(&url) + .send() + .await + .map_err(|e| ToolError::Execution(format!("Failed to fetch URL: {}", e)))?; let status = response.status(); if !status.is_success() { @@ -97,9 +99,10 @@ impl ToolHandler for WebfetchTool { .unwrap_or("text/plain") .to_lowercase(); - let body = response.text().await.map_err(|e| { - ToolError::Execution(format!("Failed to read response body: {}", e)) - })?; + let body = response + .text() + .await + .map_err(|e| ToolError::Execution(format!("Failed to read response body: {}", e)))?; let output = match format.as_str() { "html" => body, @@ -162,7 +165,9 @@ fn html_to_markdown(html: &str) -> String { link_href.clear(); if let Some(href_start) = tn.find("href=") { let after = &tn[href_start + 5..]; - if let Some(rest) = after.strip_prefix('"').or_else(|| after.strip_prefix('\'')) { + if let Some(rest) = + after.strip_prefix('"').or_else(|| after.strip_prefix('\'')) + { if let Some(end) = rest.find('"').or_else(|| rest.find('\'')) { link_href = rest[..end].to_string(); } @@ -178,9 +183,21 @@ fn html_to_markdown(html: &str) -> String { link_text.clear(); } else if tn == "br" || tn == "br/" || tn == "hr" || tn == "hr/" { result.push('\n'); - } else if tn == "p" || tn == "/p" || tn == "div" || tn == "/div" - || tn == "/h1" || tn == "/h2" || tn == "/h3" || tn == "/h4" || tn == "/h5" || tn == "/h6" - || tn == "/li" || tn == "/ul" || tn == "/ol" || tn == "/tr" || tn == "/blockquote" + } else if tn == "p" + || tn == "/p" + || tn == "div" + || tn == "/div" + || tn == "/h1" + || tn == "/h2" + || tn == "/h3" + || tn == "/h4" + || tn == "/h5" + || tn == "/h6" + || tn == "/li" + || tn == "/ul" + || tn == "/ol" + || tn == "/tr" + || tn == "/blockquote" { if !result.ends_with('\n') { result.push('\n'); @@ -189,8 +206,12 @@ fn html_to_markdown(html: &str) -> String { newlines_since_text = 2; } else if tn == "li" || tn.starts_with("li ") { result.push_str("\n- "); - } else if tn.starts_with("h1 ") || tn.starts_with("h2 ") || tn.starts_with("h3 ") - || tn.starts_with("h4 ") || tn.starts_with("h5 ") || tn.starts_with("h6 ") + } else if tn.starts_with("h1 ") + || tn.starts_with("h2 ") + || tn.starts_with("h3 ") + || tn.starts_with("h4 ") + || tn.starts_with("h5 ") + || tn.starts_with("h6 ") { if !result.ends_with('\n') { result.push('\n'); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index ac25f89..58146b0 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -2,17 +2,18 @@ use crate::session::types::{Message, MessageRole}; use crate::theme::{contrast_text, ThemeColors}; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; use crate::ui::selection::Selection; +use crate::ui::wrapping::{wrap_styled_line, WrapOptions}; use crate::utils::token_counter::StreamingTokenCounter; use ratatui::{ crossterm::event::{MouseButton, MouseEvent, MouseEventKind}, layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, Frame, }; use serde_json::Value as JsonValue; - +use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone, Default)] pub struct Chat { @@ -60,6 +61,7 @@ pub struct Chat { // Minimum elapsed time before showing tokens/s (250ms) const MIN_TOKENS_PER_SECOND_ELAPSED_MS: u128 = 250; +const TOOL_RESULT_MAX_SCREEN_LINES: usize = 8; fn now_epoch_ms() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; @@ -278,14 +280,23 @@ impl Chat { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // Bump this whenever rendering logic changes (tables, markdown, etc.) - const RENDER_VERSION: u64 = 2; + const RENDER_VERSION: u64 = 3; RENDER_VERSION.hash(&mut h); self.messages.len().hash(&mut h); for msg in &self.messages { - msg.content.len().hash(&mut h); - if let Some(ref reasoning) = msg.reasoning { - reasoning.len().hash(&mut h); - } + std::mem::discriminant(&msg.role).hash(&mut h); + msg.content.hash(&mut h); + msg.reasoning.hash(&mut h); + msg.is_complete.hash(&mut h); + msg.agent_mode.hash(&mut h); + msg.token_count.hash(&mut h); + msg.duration_ms.hash(&mut h); + msg.t0_ms.hash(&mut h); + msg.t1_ms.hash(&mut h); + msg.tn_ms.hash(&mut h); + msg.output_tokens.hash(&mut h); + msg.model.hash(&mut h); + msg.provider.hash(&mut h); } max_width.hash(&mut h); h.finish() @@ -535,7 +546,12 @@ impl Chat { self.update_scrollbar(); } - pub fn get_message_line_positions(&self, max_width: usize, model: &str, colors: &ThemeColors) -> Vec<usize> { + pub fn get_message_line_positions( + &self, + max_width: usize, + model: &str, + colors: &ThemeColors, + ) -> Vec<usize> { let mut positions = Vec::with_capacity(self.messages.len()); let mut line = 0; let message_count = self.messages.len(); @@ -607,9 +623,8 @@ impl Chat { if !self.selection.active { return None; } - let lines = self.render_visible_messages_without_selection_styling( - max_width, model, colors, - ); + let lines = + self.render_visible_messages_without_selection_styling(max_width, model, colors); crate::ui::selection::extract_selected_text(&lines, &self.selection) } @@ -645,6 +660,14 @@ impl Chat { width: area.width.saturating_sub(2), height: area.height, }; + let visual_y_offset = + content_visual_y_offset(self.content_height, self.viewport_height) as u16; + let rendered_content_area = Rect { + x: content_area.x, + y: content_area.y.saturating_add(visual_y_offset), + width: content_area.width, + height: content_area.height.saturating_sub(visual_y_offset), + }; // Calculate scrollbar area (rightmost column) let scrollbar_area = Rect { @@ -655,7 +678,7 @@ impl Chat { }; let is_on_scrollbar = scrollbar_area.contains(point); - let is_in_content = content_area.contains(point); + let is_in_content = rendered_content_area.contains(point); match event.kind { MouseEventKind::ScrollDown => { @@ -673,9 +696,9 @@ impl Chat { true } else if is_in_content { // Start text selection - let content_line = (event.row.saturating_sub(content_area.y) as usize) + let content_line = (event.row.saturating_sub(rendered_content_area.y) as usize) .saturating_add(self.scroll_offset); - let content_col = event.column.saturating_sub(content_area.x) as usize; + let content_col = event.column.saturating_sub(rendered_content_area.x) as usize; self.selection.start(content_line, content_col); true } else { @@ -688,9 +711,9 @@ impl Chat { true } else if is_in_content && self.selection.is_dragging { // Extend text selection - let content_line = (event.row.saturating_sub(content_area.y) as usize) + let content_line = (event.row.saturating_sub(rendered_content_area.y) as usize) .saturating_add(self.scroll_offset); - let content_col = event.column.saturating_sub(content_area.x) as usize; + let content_col = event.column.saturating_sub(rendered_content_area.x) as usize; self.selection.extend(content_line, content_col); true } else { @@ -786,8 +809,7 @@ impl Chat { for (idx, message) in self.messages.iter().enumerate() { positions.push(all_lines.len()); - let attached = - idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; + let attached = idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; let message_lines = self.format_message( message, max_width, @@ -812,6 +834,13 @@ impl Chat { let viewport = self.viewport_height; let max_offset = content_height.saturating_sub(viewport); let clamped_scroll = self.scroll_offset.min(max_offset); + let visual_y_offset = content_visual_y_offset(content_height, viewport) as u16; + let render_area = Rect { + x: content_area.x, + y: content_area.y.saturating_add(visual_y_offset), + width: content_area.width, + height: content_area.height.saturating_sub(visual_y_offset), + }; // Render timeline highlight as a full-width background overlay if let Some(hl) = self.highlighted_message_index { @@ -837,7 +866,10 @@ impl Chat { let vis_end = end.min(clamped_scroll.saturating_add(viewport)); if vis_end > vis_start { - let y = content_area.y.saturating_add((vis_start - clamped_scroll) as u16); + let y = content_area + .y + .saturating_add(visual_y_offset) + .saturating_add((vis_start - clamped_scroll) as u16); let height = (vis_end - vis_start).saturating_sub(1) as u16; if height > 0 { let hl_area = Rect { @@ -854,14 +886,25 @@ impl Chat { } } - let content_lines = - crate::ui::selection::apply_selection_to_lines(all_lines, &self.selection, colors.accent); + render_line_backgrounds( + f, + render_area, + &all_lines, + clamped_scroll, + render_area.height as usize, + colors.background_element, + ); + + let content_lines = crate::ui::selection::apply_selection_to_lines( + all_lines, + &self.selection, + colors.accent, + ); - let paragraph = Paragraph::new(Text::from(content_lines)) - .wrap(Wrap { trim: false }) - .scroll((clamped_scroll as u16, 0)); + let paragraph = + Paragraph::new(Text::from(content_lines)).scroll((clamped_scroll as u16, 0)); - f.render_widget(paragraph, content_area); + f.render_widget(paragraph, render_area); self.content_height = content_height; self.message_line_positions = positions; @@ -930,6 +973,7 @@ impl Chat { attached_to_assistant: bool, ) -> Vec<Line<'a>> { let mut lines: Vec<Line<'a>> = Vec::new(); + let max_width = max_width.max(1); let _ = message_count; @@ -941,12 +985,12 @@ impl Chat { let content = message.content.clone(); // Wrap content to fit within max_width - padding - let wrapped_lines = textwrap::wrap(&content, max_width.saturating_sub(4)); + let wrapped_lines = textwrap::wrap(&content, max_width.saturating_sub(4).max(1)); for line in wrapped_lines.iter() { let left_border = "▌ "; - - let right_padding = " ".repeat(max_width.saturating_sub(line.len() + 3)); + let line_width = UnicodeWidthStr::width(line.as_ref()); + let right_padding = " ".repeat(max_width.saturating_sub(line_width + 3)); lines.push(Line::from(vec![ Span::styled(left_border, Style::default().fg(border_color)), @@ -959,10 +1003,19 @@ impl Chat { lines.push(Line::from("")); } MessageRole::Assistant => { + let visible_content = if is_synthetic_tool_result_text(&message.content) { + "" + } else { + message.content.as_str() + }; + let has_visible_content = !visible_content.trim().is_empty(); + let mut emitted_anything = false; + // Display reasoning/thinking tokens if present if let Some(ref reasoning) = message.reasoning { let reasoning_trimmed = reasoning.trim(); if !reasoning_trimmed.is_empty() { + emitted_anything = true; let reasoning_prefix = "💭 Thinking..."; lines.push(Line::from(vec![Span::styled( reasoning_prefix, @@ -971,18 +1024,20 @@ impl Chat { .add_modifier(Modifier::ITALIC), )])); - let wrapped_reasoning = textwrap::wrap(reasoning_trimmed, max_width); - for line in wrapped_reasoning { - lines.push(Line::from(Span::styled( - line.to_string(), - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::ITALIC), - ))); - } + let reasoning_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::ITALIC); + let reasoning_line = Line::from(Span::styled( + reasoning_trimmed.to_string(), + reasoning_style, + )); + lines.extend(wrap_styled_line( + &reasoning_line, + WrapOptions::new(max_width.max(1)), + )); // Add separator between reasoning and content (only if there's content) - if !message.content.is_empty() { + if has_visible_content { lines.push(Line::from("")); } } @@ -990,7 +1045,7 @@ impl Chat { let is_streaming = streaming_idx == Some(idx) && !message.is_complete; - if is_streaming { + if has_visible_content && is_streaming { // Use the streaming renderer content for markdown if let Some(content) = streaming_content { let markdown_lines = render_markdown(content, max_width, colors); @@ -998,18 +1053,22 @@ impl Chat { } else { // Fallback to plain text if renderer not available let content = message.content.clone(); - let wrapped_lines = textwrap::wrap(&content, max_width); - for line in wrapped_lines { - lines.push(Line::from(Span::styled( - line.to_string(), - Style::default().fg(colors.markdown_text), - ))); - } + let line = Line::from(Span::styled( + content, + Style::default().fg(colors.markdown_text), + )); + lines.extend(wrap_styled_line(&line, WrapOptions::new(max_width.max(1)))); } - } else { + emitted_anything = true; + } else if has_visible_content { // For complete messages, use tui-markdown directly - let markdown_lines = render_markdown(&message.content, max_width, colors); + let markdown_lines = render_markdown(visible_content, max_width, colors); lines.extend(markdown_lines); + emitted_anything = true; + } + + if !emitted_anything { + return lines; } // Add empty line before metadata for spacing @@ -1027,20 +1086,13 @@ impl Chat { lines.push(Line::from("")); } else { // Keep spacing consistent between segments, but skip the - // blank line when the next message is a todowrite panel. - let next_is_todowrite = self + // blank line when the next message is a compact tool panel. + let next_is_compact_tool_panel = self .messages .get(idx + 1) - .map(|m| { - m.role == MessageRole::Tool - && serde_json::from_str::<serde_json::Value>(&m.content) - .ok() - .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())) - .map(|n| n == "todowrite") - .unwrap_or(false) - }) + .map(|m| m.role == MessageRole::Tool && is_compact_tool_panel(&m.content)) .unwrap_or(false); - if !next_is_todowrite { + if !next_is_compact_tool_panel { lines.push(Line::from("")); } } @@ -1049,14 +1101,8 @@ impl Chat { // System messages: simple display let prefix = "System: "; let content = format!("{}{}", prefix, message.content); - let wrapped_lines = textwrap::wrap(&content, max_width); - - for line in wrapped_lines { - lines.push(Line::from(Span::styled( - line.to_string(), - Style::default().fg(Color::Yellow), - ))); - } + let line = Line::from(Span::styled(content, Style::default().fg(Color::Yellow))); + lines.extend(wrap_styled_line(&line, WrapOptions::new(max_width.max(1)))); lines.push(Line::from("")); } MessageRole::Tool => { @@ -1066,13 +1112,8 @@ impl Chat { colors, attached_to_assistant, )); - // Only add trailing blank line for non-todowrite tools. - let is_todowrite = serde_json::from_str::<serde_json::Value>(&message.content) - .ok() - .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())) - .map(|n| n == "todowrite") - .unwrap_or(false); - if !is_todowrite { + // Panel-style tools already own their vertical spacing. + if !is_compact_tool_panel(&message.content) { lines.push(Line::from("")); } } @@ -1088,6 +1129,18 @@ impl Chat { colors: &'a ThemeColors, attached: bool, ) -> Vec<Line<'a>> { + let max_width = max_width.max(1); + + fn truncate_chars(mut s: String, max_len: usize) -> String { + if s.chars().count() <= max_len { + return s; + } + + s = s.chars().take(max_len).collect(); + s.push('…'); + s + } + fn preview_value(v: &JsonValue, max_len: usize) -> String { let mut s = match v { JsonValue::String(s) => s.clone(), @@ -1096,10 +1149,7 @@ impl Chat { JsonValue::Null => "null".to_string(), other => other.to_string(), }; - if s.len() > max_len { - s.truncate(max_len); - s.push_str("…"); - } + s = truncate_chars(s, max_len); if matches!(v, JsonValue::String(_)) { format!("\"{}\"", s) } else { @@ -1123,6 +1173,127 @@ impl Chat { } } + fn question_values( + args: &Option<JsonValue>, + metadata: &Option<JsonValue>, + ) -> Vec<JsonValue> { + let from_metadata = metadata.as_ref().and_then(|m| m.get("questions")).cloned(); + let from_args = args.as_ref().and_then(|a| a.get("questions")).cloned(); + + match from_metadata.or(from_args) { + Some(JsonValue::Array(items)) => items, + Some(JsonValue::Object(obj)) => vec![JsonValue::Object(obj)], + Some(JsonValue::String(s)) => { + let trimmed = s.trim(); + if trimmed.starts_with('[') || trimmed.starts_with('{') { + match serde_json::from_str::<JsonValue>(trimmed) { + Ok(JsonValue::Array(items)) => items, + Ok(JsonValue::Object(obj)) => vec![JsonValue::Object(obj)], + _ => vec![JsonValue::String(s)], + } + } else { + vec![JsonValue::String(s)] + } + } + _ => Vec::new(), + } + } + + fn answer_values( + metadata: &Option<JsonValue>, + output_preview: &Option<String>, + ) -> Vec<JsonValue> { + if let Some(JsonValue::Array(items)) = metadata.as_ref().and_then(|m| m.get("answers")) + { + return items.clone(); + } + + output_preview + .as_ref() + .and_then(|preview| serde_json::from_str::<JsonValue>(preview).ok()) + .and_then(|value| match value { + JsonValue::Array(items) => Some(items), + _ => None, + }) + .unwrap_or_default() + } + + fn question_text(value: &JsonValue) -> String { + if let Some(text) = value.as_str() { + return text.to_string(); + } + + value + .as_object() + .and_then(|obj| { + ["question", "text", "prompt"] + .iter() + .find_map(|key| obj.get(*key).and_then(|v| v.as_str())) + }) + .unwrap_or("Question") + .to_string() + } + + fn format_answer(value: Option<&JsonValue>) -> String { + match value { + Some(JsonValue::Array(items)) => { + let labels: Vec<String> = items + .iter() + .filter_map(|item| { + item.as_str() + .map(|s| s.to_string()) + .or_else(|| Some(item.to_string())) + }) + .collect(); + if labels.is_empty() { + "Skipped".to_string() + } else { + labels.join(", ") + } + } + Some(JsonValue::String(s)) if !s.trim().is_empty() => s.clone(), + Some(value) if !value.is_null() => value.to_string(), + _ => "Skipped".to_string(), + } + } + + fn push_wrapped<'a>( + out: &mut Vec<Line<'a>>, + line: Line<'static>, + max_width: usize, + subsequent_indent: Line<'static>, + ) { + out.extend(wrap_styled_line( + &line, + WrapOptions::new(max_width.max(1)).subsequent_indent(subsequent_indent), + )); + } + + fn push_limited_wrapped<'a>( + out: &mut Vec<Line<'a>>, + line: Line<'static>, + max_width: usize, + subsequent_indent: Line<'static>, + max_lines: usize, + style: Style, + ) { + let wrapped = wrap_styled_line( + &line, + WrapOptions::new(max_width.max(1)).subsequent_indent(subsequent_indent), + ); + if wrapped.len() <= max_lines { + out.extend(wrapped); + return; + } + + let omitted = wrapped.len().saturating_sub(max_lines.saturating_sub(1)); + out.extend(wrapped.into_iter().take(max_lines.saturating_sub(1))); + out.push(Line::from(Span::styled( + format!(" … +{} lines", omitted), + style, + ))); + } + let _ = attached; let indent = ""; let mut out: Vec<Line<'a>> = Vec::new(); @@ -1178,6 +1349,7 @@ impl Chat { "list" => "List", "grep" => "Grep", "todowrite" => "Todos", + "question" => "Questions", other => other, }; @@ -1231,9 +1403,72 @@ impl Chat { } } - // For todowrite, render everything (header + body) inside a single - // solid-background panel. Skip the normal dim header for this tool. - if name == "todowrite" && status == "ok" { + // Panel-style tools render header and body inside one solid background + // and skip the normal dim header path. + if name == "question" && status != "error" { + let bg = colors.background_element; + let pad_style = Style::default().bg(bg); + let header_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM) + .bg(bg); + let question_style = Style::default().fg(colors.text_weak).bg(bg); + let answer_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD) + .bg(bg); + + let panel_width = max_width.saturating_sub(2).max(10); + let questions = question_values(&args, &metadata); + let answers = answer_values(&metadata, &output_preview); + let mut panel_lines: Vec<Line<'_>> = Vec::new(); + + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + panel_lines.push(Line::from(vec![Span::styled("# Questions", header_style)])); + + if status == "running" { + let count = questions.len(); + let text = if count == 1 { + "Asking 1 question...".to_string() + } else if count > 1 { + format!("Asking {} questions...", count) + } else { + "Asking questions...".to_string() + }; + panel_lines.push(Line::from(vec![Span::styled(text, question_style)])); + } else { + for (idx, question) in questions.iter().enumerate() { + if idx > 0 { + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + } + let q_line = + Line::from(vec![Span::styled(question_text(question), question_style)]); + panel_lines.extend(wrap_styled_line( + &q_line, + WrapOptions::new(panel_width) + .subsequent_indent(Line::from(Span::styled(" ", question_style))), + )); + + let answer = format_answer(answers.get(idx)); + let a_line = Line::from(vec![ + Span::styled(" -> ", header_style), + Span::styled(answer, answer_style), + ]); + panel_lines.extend(wrap_styled_line( + &a_line, + WrapOptions::new(panel_width) + .subsequent_indent(Line::from(Span::styled(" ", answer_style))), + )); + } + } + + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + for line in &mut panel_lines { + line.spans.insert(0, Span::styled(" ", pad_style)); + } + + out.extend(panel_lines); + } else if name == "todowrite" && status == "ok" { if let Some(ref preview) = output_preview { let bg = colors.background_element; let pad_style = Style::default().bg(bg); @@ -1263,31 +1498,20 @@ impl Chat { if trimmed.is_empty() { continue; } - let wrapped = textwrap::wrap(trimmed, panel_width); - for w in wrapped { - panel_lines.push(Line::from(vec![Span::styled( - w.to_string(), - item_style, - )])); - } + let line = Line::from(vec![Span::styled(trimmed.to_string(), item_style)]); + panel_lines.extend(wrap_styled_line( + &line, + WrapOptions::new(panel_width) + .subsequent_indent(Line::from(Span::styled(" ", item_style))), + )); } // Padding bottom panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); - // Pad every line to panel_width and indent by 1 space so the - // panel reads as a single solid block. + // Indent text one cell; the panel background is painted in a + // separate pass so padding rows do not wrap. for line in &mut panel_lines { - let text: String = line - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect(); - let text_width = unicode_width::UnicodeWidthStr::width(text.as_str()); - if text_width < panel_width { - let pad = " ".repeat(panel_width - text_width); - line.spans.push(Span::styled(pad, pad_style)); - } line.spans.insert(0, Span::styled(" ", pad_style)); } @@ -1295,34 +1519,24 @@ impl Chat { } } else { // Default header for all other tools. - let wrapped = textwrap::wrap(&header, max_width); - for line in wrapped { - out.push(Line::from(Span::styled( - line.to_string(), - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ))); - } + let header_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + push_wrapped( + &mut out, + Line::from(Span::styled(header, header_style)), + max_width, + Line::from(Span::styled(" ", header_style)), + ); // For edit tools, render a unified diff preview of old_string -> new_string if name == "edit" { if let Some(obj) = args_obj { - let old_str = obj - .get("old_string") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let new_str = obj - .get("new_string") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let old_str = obj.get("old_string").and_then(|v| v.as_str()).unwrap_or(""); + let new_str = obj.get("new_string").and_then(|v| v.as_str()).unwrap_or(""); if !old_str.is_empty() || !new_str.is_empty() { - let diff_lines = crate::ui::diff::format_edit_diff( - old_str, - new_str, - max_width, - colors, - ); + let diff_lines = + crate::ui::diff::format_edit_diff(old_str, new_str, max_width, colors); out.extend(diff_lines); } } @@ -1331,17 +1545,10 @@ impl Chat { // For write tools, render the content as an all-additions diff. if name == "write" { if let Some(obj) = args_obj { - let content = obj - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let content = obj.get("content").and_then(|v| v.as_str()).unwrap_or(""); if !content.is_empty() { - let diff_lines = crate::ui::diff::format_edit_diff( - "", - content, - max_width, - colors, - ); + let diff_lines = + crate::ui::diff::format_edit_diff("", content, max_width, colors); out.extend(diff_lines); } } @@ -1357,13 +1564,28 @@ impl Chat { result_text = format!("{} — {}", t, preview); } } - let result_line = format!(" → {}", result_text); - let wrapped = textwrap::wrap(&result_line, max_width); - for line in wrapped { - out.push(Line::from(Span::styled( - line.to_string(), - Style::default().fg(colors.text_weak), - ))); + let result_style = Style::default().fg(colors.text_weak); + let mut emitted = 0usize; + for (line_idx, raw_line) in result_text.lines().enumerate() { + if emitted >= TOOL_RESULT_MAX_SCREEN_LINES { + out.push(Line::from(Span::styled(" …", result_style))); + break; + } + let prefix = if line_idx == 0 { " → " } else { " " }; + let line = Line::from(Span::styled( + format!("{}{}", prefix, raw_line), + result_style, + )); + let before = out.len(); + push_limited_wrapped( + &mut out, + line, + max_width, + Line::from(Span::styled(" ", result_style)), + TOOL_RESULT_MAX_SCREEN_LINES.saturating_sub(emitted), + result_style, + ); + emitted += out.len().saturating_sub(before); } } } @@ -1373,11 +1595,7 @@ impl Chat { if let Some(preview) = output_preview { let first = preview.lines().next().unwrap_or("").trim(); if !first.is_empty() { - let mut line = first.to_string(); - if line.len() > max_width.saturating_sub(6) { - line.truncate(max_width.saturating_sub(6)); - line.push_str("…"); - } + let line = truncate_chars(first.to_string(), max_width.saturating_sub(6)); out.push(Line::from(Span::styled( format!("{} {}", indent, line), Style::default().fg(colors.error), @@ -1497,12 +1715,105 @@ impl Chat { } } +fn is_compact_tool_panel(content: &str) -> bool { + serde_json::from_str::<JsonValue>(content) + .ok() + .and_then(|v| { + let name = v.get("name").and_then(|n| n.as_str())?; + let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("ok"); + Some(match name { + "question" => status != "error", + "todowrite" => status == "ok", + _ => false, + }) + }) + .unwrap_or(false) +} + +fn is_synthetic_tool_result_text(content: &str) -> bool { + content.trim_start().starts_with("[tool result:") +} + +fn content_visual_y_offset(content_height: usize, viewport_height: usize) -> usize { + if content_height == 0 { + 0 + } else { + viewport_height.saturating_sub(content_height) + } +} + +fn render_line_backgrounds( + f: &mut Frame, + area: Rect, + lines: &[Line<'_>], + scroll_offset: usize, + viewport_height: usize, + bg: Color, +) { + if area.width == 0 || area.height == 0 || viewport_height == 0 { + return; + } + + let visible_start = scroll_offset.min(lines.len()); + let visible_end = lines + .len() + .min(scroll_offset.saturating_add(viewport_height)); + let mut run_start: Option<usize> = None; + + for idx in visible_start..visible_end { + let is_panel_line = line_uses_background(&lines[idx], bg); + match (run_start, is_panel_line) { + (None, true) => run_start = Some(idx), + (Some(start), false) => { + render_background_run(f, area, scroll_offset, start, idx, bg); + run_start = None; + } + _ => {} + } + } + + if let Some(start) = run_start { + render_background_run(f, area, scroll_offset, start, visible_end, bg); + } +} + +fn render_background_run( + f: &mut Frame, + area: Rect, + scroll_offset: usize, + start: usize, + end: usize, + bg: Color, +) { + let y_offset = start.saturating_sub(scroll_offset) as u16; + let height = end.saturating_sub(start) as u16; + if height == 0 { + return; + } + + let bg_area = Rect { + x: area.x, + y: area.y.saturating_add(y_offset), + width: area.width, + height, + }; + f.render_widget(Block::default().style(Style::default().bg(bg)), bg_area); +} + +fn line_uses_background(line: &Line<'_>, bg: Color) -> bool { + line.spans.iter().any(|span| span.style.bg == Some(bg)) +} + fn line_to_static(line: Line<'_>) -> Line<'static> { Line { - spans: line.spans.into_iter().map(|span| Span { - content: std::borrow::Cow::Owned(span.content.into_owned()), - style: span.style, - }).collect(), + spans: line + .spans + .into_iter() + .map(|span| Span { + content: std::borrow::Cow::Owned(span.content.into_owned()), + style: span.style, + }) + .collect(), style: Style::default(), alignment: line.alignment, } @@ -1513,6 +1824,62 @@ use ratatui::text::Text; #[cfg(test)] mod tests { use super::*; + use ratatui::style::Color; + + fn test_colors() -> ThemeColors { + ThemeColors { + primary: Color::Reset, + secondary: Color::Reset, + accent: Color::Reset, + interactive: Color::Reset, + background: Color::Reset, + dialog_background: Color::Reset, + background_element: Color::Reset, + text: Color::Reset, + text_weak: Color::Reset, + text_strong: Color::Reset, + border: Color::Reset, + border_weak_focus: Color::Reset, + border_focus: Color::Reset, + border_strong_focus: Color::Reset, + success: Color::Reset, + warning: Color::Reset, + error: Color::Reset, + info: Color::Reset, + markdown_text: Color::Reset, + markdown_heading: Color::Reset, + markdown_link: Color::Reset, + markdown_link_text: Color::Reset, + markdown_code: Color::Reset, + markdown_block_quote: Color::Reset, + markdown_emph: Color::Reset, + markdown_strong: Color::Reset, + markdown_horizontal_rule: Color::Reset, + markdown_list_item: Color::Reset, + markdown_list_enumeration: Color::Reset, + markdown_image: Color::Reset, + markdown_image_text: Color::Reset, + markdown_code_block: Color::Reset, + diff_add: Color::Reset, + diff_add_bg: Color::Reset, + diff_remove: Color::Reset, + diff_remove_bg: Color::Reset, + diff_gutter: Color::Reset, + } + } + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + fn buffer_row_text(buffer: &ratatui::buffer::Buffer, width: u16, y: u16) -> String { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::<String>() + } #[test] fn test_chat_new() { @@ -1581,6 +1948,141 @@ mod tests { assert_eq!(chat.messages[2].content, " assistant"); } + #[test] + fn test_render_fingerprint_changes_for_same_length_content_mutation() { + let mut chat = Chat::new(); + chat.add_assistant_message("abcd"); + + let before = chat.compute_fingerprint(80); + chat.messages[0].content = "wxyz".to_string(); + let after = chat.compute_fingerprint(80); + + assert_ne!(before, after); + } + + #[test] + fn test_tool_result_preview_is_bounded() { + let chat = Chat::new(); + let output_preview = (0..40) + .map(|idx| format!("line {}", idx)) + .collect::<Vec<_>>() + .join("\n"); + let content = serde_json::json!({ + "name": "bash", + "status": "ok", + "args": { "command": "printf lots" }, + "output_preview": output_preview, + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 40, &colors, false); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert!(rendered.iter().any(|line| line.contains('…'))); + assert!(rendered.len() <= TOOL_RESULT_MAX_SCREEN_LINES + 2); + } + + #[test] + fn test_question_panel_keeps_padding_without_extra_gap() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "question", + "status": "ok", + "args": { + "questions": [{ "question": "Question" }] + }, + "metadata": { + "questions": [{ "question": "Question" }], + "answers": ["Provide columns and rows"] + } + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert_eq!(rendered.len(), 5); + assert!(rendered[0].trim().is_empty()); + assert_eq!(rendered[1].trim(), "# Questions"); + assert!(rendered[3].contains("Provide columns and rows")); + assert!(rendered[4].trim().is_empty()); + } + + #[test] + fn test_todowrite_panel_keeps_padding_without_extra_gap() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "todowrite", + "status": "ok", + "output_preview": "[ ] Define table data\n[ ] Choose rendering file\n[ ] Implement rendering\n", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert_eq!(rendered.len(), 6); + assert!(rendered[0].trim().is_empty()); + assert_eq!(rendered[1].trim(), "# Todos"); + assert!(rendered[4].contains("Implement rendering")); + assert!(rendered[5].trim().is_empty()); + } + + #[test] + fn test_short_tool_panel_renders_without_trailing_blank_row() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut colors = test_colors(); + colors.background_element = Color::Indexed(236); + + let content = serde_json::json!({ + "name": "todowrite", + "status": "ok", + "output_preview": "[ ] Define table data\n[ ] Choose rendering file\n[ ] Implement rendering\n", + }) + .to_string(); + let mut chat = Chat::new(); + chat.add_message(Message::tool(content)); + + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| chat.render(f, Rect::new(0, 0, 40, 8), "Plan", "model", &colors)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let rows = (0..8) + .map(|y| buffer_row_text(buffer, 38, y)) + .collect::<Vec<_>>(); + + assert!(rows[0].trim().is_empty()); + assert!(rows[1].trim().is_empty()); + assert!(rows[2].trim().is_empty()); + assert_eq!(rows[3].trim(), "# Todos"); + assert!(rows[6].contains("Implement rendering")); + assert!(rows[7].trim().is_empty()); + assert_eq!(buffer[(0, 7)].bg, colors.background_element); + } + + #[test] + fn test_synthetic_tool_result_assistant_text_is_hidden() { + let chat = Chat::new(); + let msg = Message::assistant( + "[tool result: todowrite] [ ] Add unit tests [tool result: todowrite] [ ] Refactor", + ); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + + assert!(lines.is_empty()); + } + #[test] fn test_streaming_pause_excluded_from_decode_duration() { use std::time::Duration; diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 7c4b2ef..7b25226 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -466,8 +466,7 @@ impl Dialog { } DialogPosition::Left | DialogPosition::Right => { // Side panels use full height, minus fixed chrome + padding - let list_area_height = - 40u16.saturating_sub(total_fixed_height + padding_total); + let list_area_height = 40u16.saturating_sub(total_fixed_height + padding_total); list_area_height as usize } } diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index f5fe9ce..e67044e 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -32,10 +32,7 @@ fn char_boundary_before(s: &str, byte_idx: usize) -> usize { if s.is_char_boundary(idx) { idx } else { - (0..idx) - .rev() - .find(|&i| s.is_char_boundary(i)) - .unwrap_or(0) + (0..idx).rev().find(|&i| s.is_char_boundary(i)).unwrap_or(0) } } @@ -85,7 +82,7 @@ impl Input { self } -pub fn render( + pub fn render( &mut self, frame: &mut ratatui::Frame, area: Rect, @@ -141,13 +138,13 @@ pub fn render( self.textarea_area = Some(v_chunks[1]); - self.textarea.set_selection_style( + self.textarea + .set_selection_style(Style::default().bg(colors.accent).fg(colors.text)); + self.textarea.set_style( Style::default() - .bg(colors.accent) - .fg(colors.text), + .fg(colors.text) + .bg(colors.background_element), ); - self.textarea - .set_style(Style::default().fg(colors.text).bg(colors.background_element)); let line_count = self.textarea.lines().len(); let visible_lines = v_chunks[1].height as usize; @@ -157,15 +154,9 @@ pub fn render( frame.render_widget(&self.textarea, v_chunks[1]); let info_text = ratatui::text::Line::from(vec![ - ratatui::text::Span::styled( - agent.to_string(), - Style::default().fg(agent_color), - ), + ratatui::text::Span::styled(agent.to_string(), Style::default().fg(agent_color)), ratatui::text::Span::raw(" "), - ratatui::text::Span::styled( - model.to_string(), - Style::default().fg(colors.text), - ), + ratatui::text::Span::styled(model.to_string(), Style::default().fg(colors.text)), ratatui::text::Span::raw(" "), ratatui::text::Span::styled( provider_name.to_string(), @@ -181,10 +172,7 @@ pub fn render( frame.render_widget(border, area); let cap_row = Paragraph::new(ratatui::text::Line::from(vec![ - ratatui::text::Span::styled( - "╹", - Style::default().fg(agent_color), - ), + ratatui::text::Span::styled("╹", Style::default().fg(agent_color)), ratatui::text::Span::styled( "▀".repeat(area.width as usize - 1), Style::default().fg(colors.background_element), @@ -444,8 +432,16 @@ pub fn render( if i < start_row || i > end_row { continue; } - let start = if i == start_row { start_col.min(line.len()) } else { 0 }; - let end = if i == end_row { end_col.min(line.len()) } else { line.len() }; + let start = if i == start_row { + start_col.min(line.len()) + } else { + 0 + }; + let end = if i == end_row { + end_col.min(line.len()) + } else { + line.len() + }; if start >= end { continue; diff --git a/src/ui/diff.rs b/src/ui/diff.rs index d7049aa..01c633f 100644 --- a/src/ui/diff.rs +++ b/src/ui/diff.rs @@ -1,6 +1,6 @@ +use crate::theme::ThemeColors; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use crate::theme::ThemeColors; use unicode_width::UnicodeWidthStr; const MAX_DIFF_LINES: usize = 40; @@ -129,12 +129,21 @@ pub fn render_unified_diff( let padding = "─".repeat(remaining); let mut spans = vec![ Span::styled(gutter.to_string(), gutter_style), - Span::styled(format!("⋯{}", padding), content_style.add_modifier(Modifier::DIM)), + Span::styled( + format!("⋯{}", padding), + content_style.add_modifier(Modifier::DIM), + ), ]; // Pad to full width if the ellipsis line is shorter - let visible_width: usize = spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum(); + let visible_width: usize = spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); if visible_width < max_width { - spans.push(Span::styled(" ".repeat(max_width - visible_width), pad_style)); + spans.push(Span::styled( + " ".repeat(max_width - visible_width), + pad_style, + )); } lines.push(Line::from(spans)); continue; @@ -153,9 +162,15 @@ pub fn render_unified_diff( Span::styled(chunk.to_string(), content_style), ]; // Pad to full width so the background spans the entire row - let visible_width: usize = spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum(); + let visible_width: usize = spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); if visible_width < max_width { - spans.push(Span::styled(" ".repeat(max_width - visible_width), pad_style)); + spans.push(Span::styled( + " ".repeat(max_width - visible_width), + pad_style, + )); } lines.push(Line::from(spans)); } diff --git a/src/ui/markdown/streaming.rs b/src/ui/markdown/streaming.rs index 31ab35c..4ac6d3f 100644 --- a/src/ui/markdown/streaming.rs +++ b/src/ui/markdown/streaming.rs @@ -1,5 +1,6 @@ use crate::theme::ThemeColors; use crate::ui::markdown::table::preprocess_tables; +use crate::ui::wrapping::{wrap_styled_line, WrapOptions}; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, @@ -138,6 +139,7 @@ pub fn render_markdown( max_width: usize, colors: &ThemeColors, ) -> Vec<Line<'static>> { + let max_width = max_width.max(1); // Pre-process tables: render them as Unicode box-drawing text let processed = preprocess_tables(content, max_width); @@ -157,16 +159,19 @@ pub fn render_markdown( let line_str = line_to_string(&converted_line); let line_width = unicode_width::UnicodeWidthStr::width(line_str.as_str()); - if line_width <= max_width { + if line_width <= max_width || is_preprocessed_table_line(&line_str) { result.push(converted_line); } else { - // Wrap the line - let wrap_style = converted_line + let indent_style = converted_line .spans .first() .map(|span| span.style) .unwrap_or_else(|| Style::default().fg(colors.markdown_text)); - let wrapped = wrap_line(&line_str, max_width, wrap_style); + let continuation_indent = markdown_continuation_indent(&line_str, indent_style); + let wrapped = wrap_styled_line( + &converted_line, + WrapOptions::new(max_width).subsequent_indent(continuation_indent), + ); result.extend(wrapped); } } @@ -174,6 +179,51 @@ pub fn render_markdown( result } +fn is_preprocessed_table_line(line: &str) -> bool { + line.contains('│') + || line.contains('┌') + || line.contains('┐') + || line.contains('├') + || line.contains('┤') + || line.contains('└') + || line.contains('┘') +} + +fn markdown_continuation_indent(line: &str, style: Style) -> Line<'static> { + let leading_spaces = line.chars().take_while(|ch| *ch == ' ').count(); + let trimmed = &line[leading_spaces..]; + let base = " ".repeat(leading_spaces); + + if trimmed.starts_with("> ") { + return Line::from(Span::styled(format!("{base}> "), style)); + } + + if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { + return Line::from(Span::styled(format!("{base} "), style)); + } + + let mut marker_len = 0usize; + let mut saw_digit = false; + for ch in trimmed.chars() { + if ch.is_ascii_digit() { + saw_digit = true; + marker_len += ch.len_utf8(); + continue; + } + if saw_digit && ch == '.' { + marker_len += ch.len_utf8(); + continue; + } + if saw_digit && ch == ' ' { + marker_len += ch.len_utf8(); + return Line::from(Span::styled(" ".repeat(leading_spaces + marker_len), style)); + } + break; + } + + Line::from(Span::styled(base, style)) +} + fn apply_markdown_theme(line: &mut Line<'_>, in_code_block: &mut bool, colors: &ThemeColors) { let line_text = line_to_string(line); let trimmed = line_text.trim_start(); @@ -369,16 +419,6 @@ fn line_to_string(line: &Line<'_>) -> String { .collect::<String>() } -/// Wrap a line string into multiple lines respecting max_width -fn wrap_line(line_str: &str, max_width: usize, style: Style) -> Vec<Line<'static>> { - let wrapped = textwrap::wrap(line_str, max_width); - - wrapped - .into_iter() - .map(|s| Line::from(Span::styled(s.to_string(), style))) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -491,19 +531,36 @@ mod tests { let colors = test_colors(); let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n"; let lines = render_markdown(input, 80, &colors); - + // Convert lines to string for inspection - let output: String = lines.iter() - .map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect::<String>()) + let output: String = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::<String>() + }) .collect::<Vec<_>>() .join("\n"); - + eprintln!("render_markdown output:\n{}", output); - + // Should contain our Unicode box-drawing corners, not raw markdown - assert!(output.contains('┌'), "Expected ┌ in output, got:\n{}", output); - assert!(output.contains('┐'), "Expected ┐ in output, got:\n{}", output); - assert!(!output.contains("| A |"), "Raw markdown table should be replaced"); + assert!( + output.contains('┌'), + "Expected ┌ in output, got:\n{}", + output + ); + assert!( + output.contains('┐'), + "Expected ┐ in output, got:\n{}", + output + ); + assert!( + !output.contains("| A |"), + "Raw markdown table should be replaced" + ); } #[test] @@ -512,39 +569,73 @@ mod tests { // Test WITHOUT backticks first - should work let input_no_code = "| Category | Tool | Description |\n|----------|------|-------------|\n| File Operations | read | Read file or directory contents with pagination |\n| | write | Create or overwrite a file |"; let lines = render_markdown(input_no_code, 80, &colors); - let line_strings: Vec<String> = lines.iter() - .map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect::<String>()) + let line_strings: Vec<String> = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::<String>() + }) .collect(); - - let table_lines: Vec<_> = line_strings.iter() - .filter(|l| l.contains('│') || l.contains('┌') || l.contains('┐') || l.contains('├') || l.contains('┤') || l.contains('└') || l.contains('┘')) + + let table_lines: Vec<_> = line_strings + .iter() + .filter(|l| { + l.contains('│') + || l.contains('┌') + || l.contains('┐') + || l.contains('├') + || l.contains('┤') + || l.contains('└') + || l.contains('┘') + }) .collect(); - + let first_width = unicode_width::UnicodeWidthStr::width(table_lines[0].as_str()); for line in &table_lines { let width = unicode_width::UnicodeWidthStr::width(line.as_str()); - assert_eq!(width, first_width, - "Table lines should have consistent width. Expected {}, got {}.\nLine: {}", - first_width, width, line); + assert_eq!( + width, first_width, + "Table lines should have consistent width. Expected {}, got {}.\nLine: {}", + first_width, width, line + ); } - + // Test WITH backticks - this will fail until we fix it let input_with_code = "| Category | Tool | Description |\n|----------|------|-------------|\n| File Operations | `read` | Read file or directory contents with pagination |\n| | `write` | Create or overwrite a file |"; let lines = render_markdown(input_with_code, 80, &colors); - let line_strings: Vec<String> = lines.iter() - .map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect::<String>()) + let line_strings: Vec<String> = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::<String>() + }) .collect(); - - let table_lines: Vec<_> = line_strings.iter() - .filter(|l| l.contains('│') || l.contains('┌') || l.contains('┐') || l.contains('├') || l.contains('┤') || l.contains('└') || l.contains('┘')) + + let table_lines: Vec<_> = line_strings + .iter() + .filter(|l| { + l.contains('│') + || l.contains('┌') + || l.contains('┐') + || l.contains('├') + || l.contains('┤') + || l.contains('└') + || l.contains('┘') + }) .collect(); - + let first_width = unicode_width::UnicodeWidthStr::width(table_lines[0].as_str()); for line in &table_lines { let width = unicode_width::UnicodeWidthStr::width(line.as_str()); - assert_eq!(width, first_width, - "Table lines WITH code should also have consistent width. Expected {}, got {}.\nLine: {}", - first_width, width, line); + assert_eq!( + width, first_width, + "Table lines WITH code should also have consistent width. Expected {}, got {}.\nLine: {}", + first_width, width, line + ); } } } diff --git a/src/ui/markdown/table.rs b/src/ui/markdown/table.rs index 14b90c0..173b253 100644 --- a/src/ui/markdown/table.rs +++ b/src/ui/markdown/table.rs @@ -107,8 +107,7 @@ fn render_table(rows: &[Vec<String>], alignments: &[Alignment], max_width: usize // So per column: 1 (left pad) + width + 1 (right pad), plus 1 for the left border let padding_per_col = 2; // one space left, one space right let border_chars = num_cols + 1; // left border + separators between cols + right border - let available_for_content = - max_width.saturating_sub(border_chars + num_cols * padding_per_col); + let available_for_content = max_width.saturating_sub(border_chars + num_cols * padding_per_col); // Distribute available width among columns let total_natural: usize = col_widths.iter().sum(); @@ -312,8 +311,7 @@ mod tests { #[test] fn test_multiple_tables() { - let input = - "| A |\n| --- |\n| 1 |\n\nMiddle text\n\n| X |\n| --- |\n| 9 |\n"; + let input = "| A |\n| --- |\n| 1 |\n\nMiddle text\n\n| X |\n| --- |\n| 9 |\n"; let result = preprocess_tables(input, 80); // Count table borders — should have 2 tables let top_border_count = result.matches("┌").count(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 68048e7..485ea6e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,3 +3,4 @@ pub mod diff; pub mod layout; pub mod markdown; pub mod selection; +pub mod wrapping; diff --git a/src/ui/selection.rs b/src/ui/selection.rs index 1fcd041..304d918 100644 --- a/src/ui/selection.rs +++ b/src/ui/selection.rs @@ -127,7 +127,11 @@ impl Selection { /// Return the selection range within a specific line. /// Returns None if the line is not in the selection. /// Returns (start_col, end_col) if partially or fully selected. - pub fn selection_range_in_line(&self, line: usize, line_width: usize) -> Option<(usize, usize)> { + pub fn selection_range_in_line( + &self, + line: usize, + line_width: usize, + ) -> Option<(usize, usize)> { if !self.active { return None; } @@ -215,15 +219,15 @@ pub fn extract_selected_text( if line_idx < s_line || line_idx > e_line { continue; } - let full_text: String = line - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect(); + let full_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); let line_width = unicode_width::UnicodeWidthStr::width(full_text.as_str()); let start = if line_idx == s_line { s_col } else { 0 }; - let end = if line_idx == e_line { e_col } else { line_width }; + let end = if line_idx == e_line { + e_col + } else { + line_width + }; if start >= end || start > full_text.len() { continue; diff --git a/src/ui/wrapping.rs b/src/ui/wrapping.rs new file mode 100644 index 0000000..31b5ccd --- /dev/null +++ b/src/ui/wrapping.rs @@ -0,0 +1,332 @@ +use std::ops::Range; + +use ratatui::{ + style::Style, + text::{Line, Span}, +}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +#[derive(Debug, Clone)] +pub struct WrapOptions<'a> { + pub width: usize, + pub initial_indent: Line<'a>, + pub subsequent_indent: Line<'a>, +} + +impl WrapOptions<'_> { + pub fn new(width: usize) -> Self { + Self { + width: width.max(1), + initial_indent: Line::default(), + subsequent_indent: Line::default(), + } + } +} + +impl<'a> WrapOptions<'a> { + pub fn initial_indent(mut self, indent: Line<'a>) -> Self { + self.initial_indent = indent; + self + } + + pub fn subsequent_indent(mut self, indent: Line<'a>) -> Self { + self.subsequent_indent = indent; + self + } +} + +impl From<usize> for WrapOptions<'_> { + fn from(width: usize) -> Self { + Self::new(width) + } +} + +pub fn wrap_styled_line<'a, O>(line: &'a Line<'a>, options: O) -> Vec<Line<'static>> +where + O: Into<WrapOptions<'a>>, +{ + let options = options.into(); + let mut flat = String::new(); + let mut span_bounds = Vec::with_capacity(line.spans.len()); + + for span in &line.spans { + let start = flat.len(); + flat.push_str(span.content.as_ref()); + let end = flat.len(); + span_bounds.push((start..end, span.style)); + } + + if flat.is_empty() { + return vec![line_with_indent(&options.initial_indent, line.style)]; + } + + let first_width = options + .width + .saturating_sub(options.initial_indent.width()) + .max(1); + let subsequent_width = options + .width + .saturating_sub(options.subsequent_indent.width()) + .max(1); + + let mut ranges = wrap_ranges(&flat, first_width, subsequent_width); + if ranges.is_empty() { + ranges.push(0..0); + } + + ranges + .into_iter() + .enumerate() + .map(|(idx, range)| { + let indent = if idx == 0 { + &options.initial_indent + } else { + &options.subsequent_indent + }; + line_from_range(line, &span_bounds, &range, indent) + }) + .collect() +} + +pub fn wrap_styled_lines<'a, I, O>(lines: I, options: O) -> Vec<Line<'static>> +where + I: IntoIterator<Item = &'a Line<'a>>, + O: Into<WrapOptions<'a>>, +{ + let base_options = options.into(); + let mut out = Vec::new(); + + for (idx, line) in lines.into_iter().enumerate() { + let opts = if idx == 0 { + base_options.clone() + } else { + base_options + .clone() + .initial_indent(base_options.subsequent_indent.clone()) + }; + out.extend(wrap_styled_line(line, opts)); + } + + out +} + +fn wrap_ranges(text: &str, first_width: usize, subsequent_width: usize) -> Vec<Range<usize>> { + let mut ranges = Vec::new(); + let mut start = 0; + let mut width = first_width.max(1); + let mut first_segment = true; + + while start < text.len() { + if !first_segment { + start = skip_breaking_whitespace(text, start); + } + if start >= text.len() { + break; + } + + let remaining = &text[start..]; + if UnicodeWidthStr::width(remaining) <= width { + ranges.push(start..trim_trailing_whitespace(text, text.len(), start)); + break; + } + + let mut used_width = 0; + let mut last_break: Option<(usize, usize)> = None; + let mut forced_break = None; + + for (offset, ch) in remaining.char_indices() { + let byte_idx = start + offset; + let next = byte_idx + ch.len_utf8(); + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + + if used_width + ch_width > width { + forced_break = Some(byte_idx); + break; + } + + used_width += ch_width; + if ch.is_whitespace() && byte_idx > start { + last_break = Some((byte_idx, next)); + } + } + + if let Some((break_start, break_end)) = last_break { + ranges.push(start..trim_trailing_whitespace(text, break_start, start)); + start = skip_breaking_whitespace(text, break_end); + } else if let Some(end) = forced_break { + let end = if end == start { + next_char_boundary(text, start) + } else { + end + }; + ranges.push(start..end); + start = end; + } else { + ranges.push(start..trim_trailing_whitespace(text, text.len(), start)); + break; + } + + width = subsequent_width.max(1); + first_segment = false; + } + + ranges +} + +fn skip_breaking_whitespace(text: &str, mut byte_idx: usize) -> usize { + while byte_idx < text.len() { + let Some(ch) = text[byte_idx..].chars().next() else { + break; + }; + if !ch.is_whitespace() { + break; + } + byte_idx += ch.len_utf8(); + } + byte_idx +} + +fn trim_trailing_whitespace(text: &str, end: usize, floor: usize) -> usize { + let mut trimmed = end; + while trimmed > floor { + let Some((idx, ch)) = text[..trimmed].char_indices().next_back() else { + break; + }; + if !ch.is_whitespace() { + break; + } + trimmed = idx; + } + trimmed +} + +fn next_char_boundary(text: &str, byte_idx: usize) -> usize { + text[byte_idx..] + .chars() + .next() + .map(|ch| byte_idx + ch.len_utf8()) + .unwrap_or(byte_idx) +} + +fn line_with_indent(indent: &Line<'_>, style: Style) -> Line<'static> { + let mut spans = clone_spans(&indent.spans, style); + if spans.is_empty() { + spans.push(Span::raw(String::new())); + } + Line { + spans, + style, + alignment: None, + } +} + +fn line_from_range( + original: &Line<'_>, + span_bounds: &[(Range<usize>, Style)], + range: &Range<usize>, + indent: &Line<'_>, +) -> Line<'static> { + let mut spans = clone_spans(&indent.spans, original.style); + + for (idx, (span_range, span_style)) in span_bounds.iter().enumerate() { + if span_range.end <= range.start { + continue; + } + if span_range.start >= range.end { + break; + } + + let seg_start = range.start.max(span_range.start); + let seg_end = range.end.min(span_range.end); + if seg_end <= seg_start { + continue; + } + + let local_start = seg_start - span_range.start; + let local_end = seg_end - span_range.start; + let content = original.spans[idx].content.as_ref(); + spans.push(Span::styled( + content[local_start..local_end].to_string(), + original.style.patch(*span_style), + )); + } + + Line { + spans, + style: original.style, + alignment: original.alignment, + } +} + +fn clone_spans(spans: &[Span<'_>], base_style: Style) -> Vec<Span<'static>> { + spans + .iter() + .map(|span| { + Span::styled( + span.content.as_ref().to_string(), + base_style.patch(span.style), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::{Color, Modifier}; + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn wraps_and_preserves_span_styles() { + let line = Line::from(vec![ + Span::styled("hello ", Style::default().fg(Color::Red)), + Span::styled( + "world again", + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + ), + ]); + + let wrapped = wrap_styled_line(&line, 8); + + assert_eq!(wrapped.len(), 3); + assert_eq!(line_text(&wrapped[0]), "hello"); + assert_eq!(wrapped[0].spans[0].style.fg, Some(Color::Red)); + assert_eq!(line_text(&wrapped[1]), "world"); + assert_eq!(wrapped[1].spans[0].style.fg, Some(Color::Blue)); + assert!(wrapped[1].spans[0] + .style + .add_modifier + .contains(Modifier::BOLD)); + } + + #[test] + fn uses_subsequent_indent_for_wrapped_segments() { + let line = Line::from("one two three four"); + let wrapped = wrap_styled_line( + &line, + WrapOptions::new(10).subsequent_indent(Line::from(" ")), + ); + + assert_eq!(line_text(&wrapped[0]), "one two"); + assert_eq!(line_text(&wrapped[1]), " three"); + assert_eq!(line_text(&wrapped[2]), " four"); + } + + #[test] + fn wraps_unicode_on_char_boundaries() { + let line = Line::from("cool 😄 emoji wraps"); + let wrapped = wrap_styled_line(&line, 8); + + assert_eq!(line_text(&wrapped[0]), "cool 😄"); + assert_eq!(line_text(&wrapped[1]), "emoji"); + assert_eq!(line_text(&wrapped[2]), "wraps"); + } +} diff --git a/src/views/chat.rs b/src/views/chat.rs index 38ddf57..b983efd 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -72,7 +72,7 @@ pub fn render_chat( [ Constraint::Length(1), // Top padding Constraint::Min(0), // Chat content - Constraint::Length(1), // Bottom padding + Constraint::Length(0), // Bottom padding Constraint::Length(input_height), Constraint::Length(1), Constraint::Length(1), diff --git a/src/views/home.rs b/src/views/home.rs index c55cf39..752b63e 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -170,7 +170,11 @@ pub fn render_home( ]) .split(logo_chunks[1]); - let max_mascot_width = mascot_raw.iter().map(|l| UnicodeWidthStr::width(*l)).max().unwrap_or(0); + let max_mascot_width = mascot_raw + .iter() + .map(|l| UnicodeWidthStr::width(*l)) + .max() + .unwrap_or(0); let left_pad = ((stack[0].width as usize).saturating_sub(max_mascot_width)) / 2; let padding = " ".repeat(left_pad); diff --git a/src/views/mod.rs b/src/views/mod.rs index d506ea8..dae04eb 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -4,6 +4,7 @@ pub mod home; pub mod models_dialog; pub mod openai_oauth_flow; pub mod permission_dialog; +pub mod question_dialog; pub mod session_rename_dialog; pub mod sessions_dialog; pub mod skills_dialog; @@ -18,6 +19,7 @@ pub use home::HomeState; pub use models_dialog::ModelsDialogState; pub use openai_oauth_flow::OpenAIOAuthFlowState; pub use permission_dialog::PermissionDialogState; +pub use question_dialog::QuestionDialogState; pub use session_rename_dialog::SessionRenameDialogState; pub use sessions_dialog::SessionsDialogState; pub use skills_dialog::SkillsDialogState; diff --git a/src/views/question_dialog.rs b/src/views/question_dialog.rs new file mode 100644 index 0000000..82c78f6 --- /dev/null +++ b/src/views/question_dialog.rs @@ -0,0 +1,2033 @@ +use crate::theme::{contrast_text, ThemeColors}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Wrap}, + Frame, +}; +use serde_json::{json, Value}; +use std::collections::VecDeque; +use tokio::sync::oneshot; + +#[derive(Clone, Debug)] +struct QuestionOption { + label: String, + description: String, +} + +#[derive(Clone, Debug)] +struct QuestionItem { + header: String, + question: String, + options: Vec<QuestionOption>, + multiple: bool, + custom: bool, +} + +#[derive(Clone, Debug)] +struct QuestionAnswerState { + selected: Vec<bool>, + cursor: usize, + custom_text: String, + custom_cursor: usize, + custom_selected: bool, +} + +fn char_kind(c: char) -> u8 { + if c.is_whitespace() { + 0 + } else if c.is_ascii_punctuation() { + 1 + } else { + 2 + } +} + +fn char_count(text: &str) -> usize { + text.chars().count() +} + +fn char_to_byte(text: &str, char_idx: usize) -> usize { + if char_idx == 0 { + return 0; + } + + text.char_indices() + .nth(char_idx) + .map(|(idx, _)| idx) + .unwrap_or(text.len()) +} + +fn insert_char_at_cursor(text: &mut String, cursor: &mut usize, ch: char) { + let len = char_count(text); + *cursor = (*cursor).min(len); + let byte_idx = char_to_byte(text, *cursor); + text.insert(byte_idx, ch); + *cursor += 1; +} + +fn delete_char_before_cursor(text: &mut String, cursor: &mut usize) { + let len = char_count(text); + *cursor = (*cursor).min(len); + if *cursor == 0 { + return; + } + + let start = char_to_byte(text, *cursor - 1); + let end = char_to_byte(text, *cursor); + text.replace_range(start..end, ""); + *cursor -= 1; +} + +fn delete_word_before_cursor(text: &mut String, cursor: &mut usize) { + let mut chars: Vec<char> = text.chars().collect(); + *cursor = (*cursor).min(chars.len()); + if *cursor == 0 { + return; + } + + let end = *cursor; + let mut start = end; + while start > 0 && chars[start - 1].is_whitespace() { + start -= 1; + } + + if start > 0 { + let kind = char_kind(chars[start - 1]); + while start > 0 && !chars[start - 1].is_whitespace() && char_kind(chars[start - 1]) == kind + { + start -= 1; + } + } + + chars.drain(start..end); + *text = chars.into_iter().collect(); + *cursor = start; +} + +fn move_word_left(text: &str, cursor: &mut usize) { + let chars: Vec<char> = text.chars().collect(); + *cursor = (*cursor).min(chars.len()); + + while *cursor > 0 && chars[*cursor - 1].is_whitespace() { + *cursor -= 1; + } + + if *cursor > 0 { + let kind = char_kind(chars[*cursor - 1]); + while *cursor > 0 + && !chars[*cursor - 1].is_whitespace() + && char_kind(chars[*cursor - 1]) == kind + { + *cursor -= 1; + } + } +} + +fn move_word_right(text: &str, cursor: &mut usize) { + let chars: Vec<char> = text.chars().collect(); + *cursor = (*cursor).min(chars.len()); + + while *cursor < chars.len() && chars[*cursor].is_whitespace() { + *cursor += 1; + } + + if *cursor < chars.len() { + let kind = char_kind(chars[*cursor]); + while *cursor < chars.len() + && !chars[*cursor].is_whitespace() + && char_kind(chars[*cursor]) == kind + { + *cursor += 1; + } + } +} + +struct QuestionDialogRequest { + questions: Vec<QuestionItem>, + answers: Vec<QuestionAnswerState>, + response_tx: oneshot::Sender<Value>, + current_index: usize, + editing_custom: bool, +} + +pub struct QuestionDialogState { + current: Option<QuestionDialogRequest>, + queue: VecDeque<QuestionDialogRequest>, +} + +pub enum QuestionDialogAction { + Submit, + Cancel, + Handled, + NotHandled, +} + +pub fn init_question_dialog() -> QuestionDialogState { + QuestionDialogState::new() +} + +impl QuestionDialogState { + pub fn new() -> Self { + Self { + current: None, + queue: VecDeque::new(), + } + } + + pub fn enqueue(&mut self, questions: Value, response_tx: oneshot::Sender<Value>) { + let request = QuestionDialogRequest::new(questions, response_tx); + if self.current.is_none() { + self.current = Some(request); + } else { + self.queue.push_back(request); + } + } + + pub fn has_active(&self) -> bool { + self.current.is_some() + } + + pub fn submit_current(&mut self) { + if let Some(request) = self.current.take() { + let response = request.response(); + let _ = request.response_tx.send(response); + } + self.current = self.queue.pop_front(); + } + + pub fn cancel_current(&mut self) { + if let Some(request) = self.current.take() { + let response = request.empty_response(); + let _ = request.response_tx.send(response); + } + self.current = self.queue.pop_front(); + } + + pub fn clear_with_empty(&mut self) { + if let Some(request) = self.current.take() { + let response = request.empty_response(); + let _ = request.response_tx.send(response); + } + + while let Some(request) = self.queue.pop_front() { + let response = request.empty_response(); + let _ = request.response_tx.send(response); + } + } + + pub fn insert_text(&mut self, text: &str) { + let Some(request) = self.current.as_mut() else { + return; + }; + + for ch in text.chars().filter(|ch| *ch != '\r') { + request.insert_char(ch); + } + } + + fn active_mut(&mut self) -> Option<&mut QuestionDialogRequest> { + self.current.as_mut() + } + + fn active(&self) -> Option<&QuestionDialogRequest> { + self.current.as_ref() + } + + fn queued_count(&self) -> usize { + self.queue.len() + } +} + +impl QuestionDialogRequest { + fn new(questions: Value, response_tx: oneshot::Sender<Value>) -> Self { + let questions = parse_questions(questions); + let editing_custom = questions + .first() + .map(|question| question.options.is_empty()) + .unwrap_or(false); + let answers = questions + .iter() + .map(QuestionAnswerState::for_question) + .collect(); + + Self { + questions, + answers, + response_tx, + current_index: 0, + editing_custom, + } + } + + fn current_question(&self) -> Option<&QuestionItem> { + self.questions.get(self.current_index) + } + + fn current_answer(&self) -> Option<&QuestionAnswerState> { + self.answers.get(self.current_index) + } + + fn current_answer_mut(&mut self) -> Option<&mut QuestionAnswerState> { + self.answers.get_mut(self.current_index) + } + + fn focus_count(&self) -> usize { + self.questions.len() + 1 + } + + fn is_confirm_tab(&self) -> bool { + self.current_index == self.questions.len() + } + + fn sync_editing_for_current_focus(&mut self) { + self.editing_custom = self + .current_question() + .map(|question| question.options.is_empty()) + .unwrap_or(false); + } + + fn current_is_text_entry(&self) -> bool { + self.current_question() + .map(|q| q.options.is_empty()) + .unwrap_or(false) + || self.editing_custom + } + + fn current_is_custom_row(&self) -> bool { + let Some(question) = self.current_question() else { + return false; + }; + let Some(answer) = self.current_answer() else { + return false; + }; + + question.custom && !question.options.is_empty() && answer.cursor == question.options.len() + } + + fn previous_option(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + let count = option_row_count(question); + if count == 0 { + return; + } + + let multiple = question.multiple; + let options_len = question.options.len(); + if let Some(answer) = self.current_answer_mut() { + answer.cursor = if answer.cursor == 0 { + count - 1 + } else { + answer.cursor - 1 + }; + if !multiple && answer.cursor < options_len { + answer.select_cursor(); + } + } + self.editing_custom = false; + } + + fn next_option(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + let count = option_row_count(question); + if count == 0 { + return; + } + + let multiple = question.multiple; + let options_len = question.options.len(); + if let Some(answer) = self.current_answer_mut() { + answer.cursor = (answer.cursor + 1) % count; + if !multiple && answer.cursor < options_len { + answer.select_cursor(); + } + } + self.editing_custom = false; + } + + fn previous_question(&mut self) { + let focus_count = self.focus_count(); + if focus_count == 0 { + return; + } + + self.current_index = if self.current_index == 0 { + focus_count - 1 + } else { + self.current_index - 1 + }; + self.sync_editing_for_current_focus(); + } + + fn next_question(&mut self) { + let focus_count = self.focus_count(); + if focus_count == 0 { + return; + } + + self.current_index = (self.current_index + 1) % focus_count; + self.sync_editing_for_current_focus(); + } + + fn next_question_or_submit(&mut self) -> bool { + if self.is_confirm_tab() { + true + } else if self.current_index < self.questions.len() { + self.current_index += 1; + self.sync_editing_for_current_focus(); + false + } else { + true + } + } + + fn begin_custom_editing(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + + if !question.options.is_empty() && !self.current_is_custom_row() { + return; + } + + let custom_cursor = self + .current_answer() + .map(|answer| char_count(&answer.custom_text)) + .unwrap_or(0); + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = custom_cursor; + } + self.editing_custom = true; + } + + fn finish_custom_editing(&mut self) -> bool { + let Some(question) = self.current_question() else { + return false; + }; + let has_options = !question.options.is_empty(); + let multiple = question.multiple; + + let mut should_confirm = true; + if let Some(answer) = self.current_answer_mut() { + let has_text = !answer.custom_text.trim().is_empty(); + + if has_text { + answer.custom_selected = true; + if !multiple { + answer.selected.fill(false); + } + } else if has_options { + answer.custom_selected = false; + should_confirm = false; + } else { + answer.custom_selected = true; + } + } + + if has_options { + self.editing_custom = false; + } + + should_confirm + } + + fn toggle_current(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + if question.options.is_empty() { + self.editing_custom = true; + return; + } + + let options_len = question.options.len(); + let multiple = question.multiple; + if let Some(answer) = self.current_answer_mut() { + if answer.cursor < options_len { + if multiple { + if let Some(selected) = answer.selected.get_mut(answer.cursor) { + *selected = !*selected; + } + } else { + answer.select_cursor(); + answer.custom_selected = false; + } + self.editing_custom = false; + } else { + if multiple && !answer.custom_text.trim().is_empty() { + answer.custom_selected = !answer.custom_selected; + } + } + } + } + + fn insert_char(&mut self, ch: char) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + insert_char_at_cursor(&mut answer.custom_text, &mut answer.custom_cursor, ch); + } + self.sync_custom_selection_from_text(); + } + + fn delete_char(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + delete_char_before_cursor(&mut answer.custom_text, &mut answer.custom_cursor); + } + self.sync_custom_selection_from_text(); + } + + fn delete_word_backward(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + delete_word_before_cursor(&mut answer.custom_text, &mut answer.custom_cursor); + } + self.sync_custom_selection_from_text(); + } + + fn clear_custom_text(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_text.clear(); + answer.custom_cursor = 0; + answer.custom_selected = false; + } + } + + fn sync_custom_selection_from_text(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + let text_only = question.options.is_empty(); + let multiple = question.multiple; + let editing_custom_row = self.editing_custom && self.current_is_custom_row(); + + if let Some(answer) = self.current_answer_mut() { + let has_text = !answer.custom_text.trim().is_empty(); + if text_only || editing_custom_row { + answer.custom_selected = has_text; + if has_text && !multiple { + answer.selected.fill(false); + } + } else if !has_text { + answer.custom_selected = false; + } + } + } + + fn move_custom_cursor_left(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = answer.custom_cursor.saturating_sub(1); + } + } + + fn move_custom_cursor_right(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = (answer.custom_cursor + 1).min(char_count(&answer.custom_text)); + } + } + + fn move_custom_cursor_word_left(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + move_word_left(&answer.custom_text, &mut answer.custom_cursor); + } + } + + fn move_custom_cursor_word_right(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + move_word_right(&answer.custom_text, &mut answer.custom_cursor); + } + } + + fn move_custom_cursor_start(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = 0; + } + } + + fn move_custom_cursor_end(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = char_count(&answer.custom_text); + } + } + + fn stop_editing_custom(&mut self) { + if self + .current_question() + .map(|q| !q.options.is_empty()) + .unwrap_or(false) + { + self.editing_custom = false; + } + } + + fn response(&self) -> Value { + Value::Array( + self.questions + .iter() + .zip(self.answers.iter()) + .map(|(question, answer)| answer.to_value(question)) + .collect(), + ) + } + + fn empty_response(&self) -> Value { + Value::Array( + self.questions + .iter() + .map(|_| Value::Array(Vec::new())) + .collect(), + ) + } +} + +impl QuestionAnswerState { + fn for_question(question: &QuestionItem) -> Self { + let mut selected = vec![false; question.options.len()]; + if !question.multiple && !selected.is_empty() { + selected[0] = true; + } + + Self { + selected, + cursor: 0, + custom_text: String::new(), + custom_cursor: 0, + custom_selected: question.options.is_empty(), + } + } + + fn select_cursor(&mut self) { + if self.cursor < self.selected.len() { + self.selected.fill(false); + self.selected[self.cursor] = true; + self.custom_selected = false; + } else { + self.selected.fill(false); + self.custom_selected = true; + } + } + + fn to_value(&self, question: &QuestionItem) -> Value { + let mut answers = Vec::new(); + for (idx, selected) in self.selected.iter().enumerate() { + if *selected { + if let Some(option) = question.options.get(idx) { + answers.push(Value::String(option.label.clone())); + } + } + } + + let custom = self.custom_text.trim(); + if !custom.is_empty() && (self.custom_selected || question.options.is_empty()) { + answers.push(Value::String(custom.to_string())); + } + + Value::Array(answers) + } +} + +pub fn handle_question_dialog_key_event( + state: &mut QuestionDialogState, + event: KeyEvent, +) -> QuestionDialogAction { + let Some(request) = state.active_mut() else { + return QuestionDialogAction::NotHandled; + }; + + match event.code { + KeyCode::Esc => { + let editing_option_custom = request.editing_custom + && request + .current_question() + .map(|q| !q.options.is_empty()) + .unwrap_or(false); + if editing_option_custom { + request.stop_editing_custom(); + QuestionDialogAction::Handled + } else { + QuestionDialogAction::Cancel + } + } + KeyCode::Left + if request.current_is_text_entry() + && (event.modifiers.contains(KeyModifiers::SUPER) + || event.modifiers.contains(KeyModifiers::META)) => + { + request.move_custom_cursor_start(); + QuestionDialogAction::Handled + } + KeyCode::Right + if request.current_is_text_entry() + && (event.modifiers.contains(KeyModifiers::SUPER) + || event.modifiers.contains(KeyModifiers::META)) => + { + request.move_custom_cursor_end(); + QuestionDialogAction::Handled + } + KeyCode::Left + if request.current_is_text_entry() && event.modifiers.contains(KeyModifiers::ALT) => + { + request.move_custom_cursor_word_left(); + QuestionDialogAction::Handled + } + KeyCode::Right + if request.current_is_text_entry() && event.modifiers.contains(KeyModifiers::ALT) => + { + request.move_custom_cursor_word_right(); + QuestionDialogAction::Handled + } + KeyCode::Left if request.current_is_text_entry() => { + request.move_custom_cursor_left(); + QuestionDialogAction::Handled + } + KeyCode::Right if request.current_is_text_entry() => { + request.move_custom_cursor_right(); + QuestionDialogAction::Handled + } + KeyCode::Left if !request.current_is_text_entry() && request.focus_count() > 1 => { + request.previous_question(); + QuestionDialogAction::Handled + } + KeyCode::Right if !request.current_is_text_entry() && request.focus_count() > 1 => { + request.next_question(); + QuestionDialogAction::Handled + } + KeyCode::Up if !request.current_is_text_entry() => { + request.previous_option(); + QuestionDialogAction::Handled + } + KeyCode::Down if !request.current_is_text_entry() => { + request.next_option(); + QuestionDialogAction::Handled + } + KeyCode::Char('k') if !request.current_is_text_entry() => { + request.previous_option(); + QuestionDialogAction::Handled + } + KeyCode::Char('j') if !request.current_is_text_entry() => { + request.next_option(); + QuestionDialogAction::Handled + } + KeyCode::BackTab if !request.current_is_text_entry() => { + request.previous_question(); + QuestionDialogAction::Handled + } + KeyCode::Tab + if !request.current_is_text_entry() + && event.modifiers.contains(KeyModifiers::SHIFT) => + { + request.previous_question(); + QuestionDialogAction::Handled + } + KeyCode::Tab if !request.current_is_text_entry() => { + request.next_question(); + QuestionDialogAction::Handled + } + KeyCode::PageUp if !request.current_is_text_entry() => { + request.previous_question(); + QuestionDialogAction::Handled + } + KeyCode::PageDown if !request.current_is_text_entry() => { + request.next_question(); + QuestionDialogAction::Handled + } + KeyCode::Char(' ') if !request.current_is_text_entry() => { + request.toggle_current(); + QuestionDialogAction::Handled + } + KeyCode::Tab | KeyCode::BackTab if request.current_is_text_entry() => { + QuestionDialogAction::Handled + } + KeyCode::Backspace + if request.current_is_text_entry() + && (event.modifiers.contains(KeyModifiers::SUPER) + || event.modifiers.contains(KeyModifiers::META)) => + { + request.clear_custom_text(); + QuestionDialogAction::Handled + } + KeyCode::Backspace + if request.current_is_text_entry() && event.modifiers.contains(KeyModifiers::ALT) => + { + request.delete_word_backward(); + QuestionDialogAction::Handled + } + KeyCode::Char('u') + if request.current_is_text_entry() + && event.modifiers.contains(KeyModifiers::CONTROL) => + { + request.clear_custom_text(); + QuestionDialogAction::Handled + } + KeyCode::Backspace if request.current_is_text_entry() => { + request.delete_char(); + QuestionDialogAction::Handled + } + KeyCode::Enter => { + if request.current_is_text_entry() { + if request.finish_custom_editing() && request.next_question_or_submit() { + QuestionDialogAction::Submit + } else { + QuestionDialogAction::Handled + } + } else if request.is_confirm_tab() { + QuestionDialogAction::Submit + } else if request.current_is_custom_row() { + request.begin_custom_editing(); + QuestionDialogAction::Handled + } else if request.next_question_or_submit() { + QuestionDialogAction::Submit + } else { + QuestionDialogAction::Handled + } + } + KeyCode::Char(ch) + if !event.modifiers.contains(KeyModifiers::CONTROL) + && !event.modifiers.contains(KeyModifiers::ALT) => + { + request.insert_char(ch); + QuestionDialogAction::Handled + } + _ => QuestionDialogAction::NotHandled, + } +} + +pub fn handle_question_dialog_mouse_event( + _state: &mut QuestionDialogState, + _event: MouseEvent, +) -> bool { + false +} + +pub fn render_question_dialog( + f: &mut Frame, + state: &mut QuestionDialogState, + area: Rect, + colors: ThemeColors, +) { + let Some(request) = state.active() else { + return; + }; + + let option_count = request + .current_question() + .map(option_row_count) + .unwrap_or(request.questions.len()) as u16; + let extra_body_lines = request + .current_question() + .map(|question| 1 + u16::from(question.multiple)) + .unwrap_or_else(|| u16::from(request.is_confirm_tab())); + let desired_height = 8u16 + .saturating_add(option_count) + .saturating_add(extra_body_lines) + .min(18); + let panel_height = area.height.min(desired_height.max(8)); + let dialog_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(panel_height), + width: area.width, + height: panel_height, + }; + + f.render_widget(Clear, dialog_area); + f.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + dialog_area, + ); + + let border = Block::default() + .style(Style::default().bg(colors.dialog_background)) + .borders(Borders::LEFT) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(colors.info)) + .padding(Padding::new(1, 1, 1, 1)); + let content_area = border.inner(dialog_area); + f.render_widget(border, dialog_area); + + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(content_area); + + let cancel_text = "esc cancel"; + let cancel_width = (cancel_text.len() as u16).min(chunks[0].width); + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(cancel_width)]) + .split(chunks[0]); + + f.render_widget( + Paragraph::new(question_tabs_line(request, state.queued_count(), &colors)), + header_chunks[0], + ); + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + cancel_text, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])) + .alignment(Alignment::Right), + header_chunks[1], + ); + + let body_lines = if request.is_confirm_tab() { + confirm_body_lines(request, &colors) + } else if let (Some(question), Some(answer)) = + (request.current_question(), request.current_answer()) + { + question_body_lines( + question, + answer, + request.current_index, + request.editing_custom, + &colors, + ) + } else { + Vec::new() + }; + f.render_widget( + Paragraph::new(body_lines) + .style(Style::default().bg(colors.dialog_background)) + .wrap(Wrap { trim: true }), + chunks[1], + ); + + let footer = footer_line(request, &colors); + f.render_widget(Paragraph::new(footer).alignment(Alignment::Left), chunks[2]); +} + +fn parse_questions(value: Value) -> Vec<QuestionItem> { + let values = match value { + Value::Array(items) => items, + Value::Object(_) => vec![value], + Value::String(text) => vec![json!({ "question": text, "header": "Question" })], + _ => Vec::new(), + }; + + let mut questions: Vec<QuestionItem> = values + .into_iter() + .filter_map(|value| parse_question(value).or_else(|| Some(default_question()))) + .collect(); + + if questions.is_empty() { + questions.push(default_question()); + } + + questions +} + +fn parse_question(value: Value) -> Option<QuestionItem> { + let obj = value.as_object()?; + let question = string_field(obj, &["question", "text", "prompt"]) + .unwrap_or_else(|| "Question".to_string()); + let header = string_field(obj, &["header", "title"]).unwrap_or_else(|| "Question".to_string()); + let mut options: Vec<QuestionOption> = obj + .get("options") + .and_then(|v| v.as_array()) + .map(|options| options.iter().filter_map(parse_option).collect()) + .unwrap_or_else(Vec::new); + options.retain(|option| !is_custom_answer_sentinel_label(&option.label)); + let multiple = multiple_field(obj).unwrap_or_else(|| question_mentions_multiple(&question)); + let custom = true; + + Some(QuestionItem { + header, + question, + options, + multiple, + custom, + }) +} + +fn parse_option(value: &Value) -> Option<QuestionOption> { + if let Some(label) = value.as_str() { + return Some(QuestionOption { + label: label.to_string(), + description: String::new(), + }); + } + + let obj = value.as_object()?; + let label = string_field(obj, &["label", "value", "text"])?; + let description = string_field(obj, &["description", "detail"]).unwrap_or_default(); + Some(QuestionOption { label, description }) +} + +fn normalized_option_label(label: &str) -> String { + label + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect::<String>() + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") +} + +fn is_custom_answer_sentinel_label(label: &str) -> bool { + matches!( + normalized_option_label(label).as_str(), + "type your own answer" + | "type your own" + | "enter your own answer" + | "write your own answer" + | "provide your own answer" + | "custom answer" + | "enter custom answer" + | "write custom answer" + ) +} + +fn default_question() -> QuestionItem { + QuestionItem { + header: "Question".to_string(), + question: "The agent needs your input.".to_string(), + options: Vec::new(), + multiple: false, + custom: true, + } +} + +fn string_field(obj: &serde_json::Map<String, Value>, names: &[&str]) -> Option<String> { + names + .iter() + .find_map(|name| obj.get(*name).and_then(|v| v.as_str())) + .map(|s| s.to_string()) +} + +fn bool_field(obj: &serde_json::Map<String, Value>, names: &[&str]) -> Option<bool> { + names + .iter() + .find_map(|name| obj.get(*name).and_then(|v| v.as_bool())) +} + +fn boolish_field(obj: &serde_json::Map<String, Value>, names: &[&str]) -> Option<bool> { + names.iter().find_map(|name| { + obj.get(*name).and_then(|value| match value { + Value::Bool(value) => Some(*value), + Value::String(value) => match value.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "multiple" | "multi" | "multiselect" | "multi_select" + | "multiple_choice" | "checkbox" | "checkboxes" | "select_all" => Some(true), + "false" | "no" | "single" | "radio" | "single_choice" => Some(false), + _ => None, + }, + Value::Number(value) => value.as_u64().map(|value| value > 1), + _ => None, + }) + }) +} + +fn multiple_field(obj: &serde_json::Map<String, Value>) -> Option<bool> { + boolish_field( + obj, + &[ + "multiple", + "allow_multiple", + "allowMultiple", + "multi", + "multiselect", + "multi_select", + "multipleChoice", + "multiple_choice", + "checkbox", + "checkboxes", + "type", + "kind", + "mode", + "selection", + "selection_type", + "selectionType", + "max_selections", + "maxSelections", + ], + ) +} + +fn question_mentions_multiple(question: &str) -> bool { + let question = question.to_ascii_lowercase(); + [ + "select all that apply", + "choose all that apply", + "pick all that apply", + "select multiple", + "choose multiple", + "pick multiple", + "multiple answers", + "multiple selections", + ] + .iter() + .any(|phrase| question.contains(phrase)) +} + +fn option_row_count(question: &QuestionItem) -> usize { + question.options.len() + usize::from(question.custom && !question.options.is_empty()) +} + +fn text_with_cursor(text: &str, cursor: usize) -> String { + let mut chars: Vec<char> = text.chars().collect(); + let cursor = cursor.min(chars.len()); + chars.insert(cursor, '_'); + chars.into_iter().collect() +} + +fn stable_tab_label(label: &str) -> String { + format!(" {} ", label.trim()) +} + +fn is_generic_question_label(text: &str) -> bool { + let text = text.trim(); + text.is_empty() || text.eq_ignore_ascii_case("question") +} + +fn question_display_text(question: &QuestionItem, idx: usize) -> String { + if !is_generic_question_label(&question.question) { + return question.question.trim().to_string(); + } + + if !is_generic_question_label(&question.header) { + return question.header.trim().to_string(); + } + + format!("Question {}", idx + 1) +} + +fn question_tabs_line<'a>( + request: &QuestionDialogRequest, + queued_count: usize, + colors: &ThemeColors, +) -> Line<'a> { + let mut spans = Vec::new(); + + for idx in 0..request.questions.len() { + if idx > 0 { + spans.push(Span::raw(" ")); + } + + let active = idx == request.current_index; + let label = stable_tab_label(&format!("Question {}", idx + 1)); + + if active { + spans.push(Span::styled( + label, + Style::default() + .bg(colors.warning) + .fg(contrast_text(colors.warning)) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + label, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + } + + if !request.questions.is_empty() { + spans.push(Span::raw(" ")); + } + + let confirm_label = stable_tab_label("Confirm"); + if request.is_confirm_tab() { + spans.push(Span::styled( + confirm_label, + Style::default() + .bg(colors.warning) + .fg(contrast_text(colors.warning)) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + confirm_label, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + + if queued_count > 0 { + spans.push(Span::styled( + format!(" +{} queued", queued_count), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + + Line::from(spans) +} + +fn question_body_lines<'a>( + question: &QuestionItem, + answer: &QuestionAnswerState, + question_index: usize, + editing_custom: bool, + colors: &ThemeColors, +) -> Vec<Line<'a>> { + let mut lines = Vec::new(); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled( + "Question: ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled( + question_display_text(question, question_index), + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + ])); + if question.multiple { + lines.push(Line::from(vec![Span::styled( + "Select all that apply.", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])); + } + lines.push(Line::from("")); + + if question.options.is_empty() { + let text = if editing_custom { + text_with_cursor(&answer.custom_text, answer.custom_cursor) + } else { + answer.custom_text.clone() + }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default().fg(colors.info)), + Span::styled(text, Style::default().fg(colors.text)), + ])); + return lines; + } + + for (idx, option) in question.options.iter().enumerate() { + lines.push(option_line( + option, + answer.cursor == idx, + answer.selected.get(idx).copied().unwrap_or(false), + question.multiple, + colors, + )); + } + + if question.custom { + let idx = question.options.len(); + let mut label = "Type your own answer".to_string(); + if !answer.custom_text.is_empty() { + label.push_str(": "); + if editing_custom { + label.push_str(&text_with_cursor(&answer.custom_text, answer.custom_cursor)); + } else { + label.push_str(&answer.custom_text); + } + } else if editing_custom { + label.push_str(": _"); + } + + let option = QuestionOption { + label, + description: String::new(), + }; + lines.push(option_line( + &option, + answer.cursor == idx, + answer.custom_selected, + question.multiple, + colors, + )); + } + + lines +} + +fn answer_summary(question: &QuestionItem, answer: &QuestionAnswerState) -> String { + let values = answer.to_value(question); + let labels: Vec<String> = values + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(ToString::to_string)) + .collect() + }) + .unwrap_or_default(); + + if labels.is_empty() { + "No answer".to_string() + } else { + labels.join(", ") + } +} + +fn confirm_body_lines<'a>(request: &QuestionDialogRequest, colors: &ThemeColors) -> Vec<Line<'a>> { + let mut lines = Vec::new(); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Confirm answers", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from("")); + + for (idx, (question, answer)) in request + .questions + .iter() + .zip(request.answers.iter()) + .enumerate() + { + let label = question_display_text(question, idx); + let summary = answer_summary(question, answer); + lines.push(Line::from(vec![ + Span::styled( + format!("{}. ", idx + 1), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled(label, Style::default().fg(colors.text)), + Span::styled( + " - ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled(summary, Style::default().fg(colors.text_weak)), + ])); + } + + lines +} + +fn option_line<'a>( + option: &QuestionOption, + cursor: bool, + selected: bool, + multiple: bool, + colors: &ThemeColors, +) -> Line<'a> { + let check = if multiple { + if selected { + "[x] " + } else { + "[ ] " + } + } else if selected { + "(*) " + } else { + "( ) " + }; + + let selected_style = Style::default() + .bg(colors.info) + .fg(contrast_text(colors.info)) + .add_modifier(Modifier::BOLD); + let label_style = if cursor { + selected_style + } else { + Style::default().fg(colors.text) + }; + let weak_style = if cursor { + selected_style + } else { + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM) + }; + + let mut spans = vec![ + Span::styled(check, weak_style), + Span::styled(option.label.clone(), label_style), + ]; + if !option.description.is_empty() { + spans.push(Span::styled(" - ", weak_style)); + spans.push(Span::styled(option.description.clone(), weak_style)); + } + + Line::from(spans) +} + +fn footer_line<'a>(request: &QuestionDialogRequest, colors: &ThemeColors) -> Line<'a> { + let key_style = Style::default().fg(colors.info); + + if request.current_is_text_entry() { + let esc_label = if request + .current_question() + .map(|question| question.options.is_empty()) + .unwrap_or(false) + { + " dismiss" + } else { + " cancel edit" + }; + return Line::from(vec![ + Span::styled("enter", key_style), + Span::raw(" confirm "), + Span::styled("esc", key_style), + Span::raw(esc_label), + ]); + } + + let mut spans = Vec::new(); + if request.focus_count() > 1 { + spans.push(Span::styled("⇆", key_style)); + spans.push(Span::raw(" cycle tabs ")); + } + + if request.is_confirm_tab() { + spans.push(Span::styled("enter", key_style)); + spans.push(Span::raw(" submit ")); + spans.push(Span::styled("esc", key_style)); + spans.push(Span::raw(" dismiss")); + return Line::from(spans); + } + + let Some(question) = request.current_question() else { + return Line::from(spans); + }; + let Some(answer) = request.current_answer() else { + return Line::from(spans); + }; + + spans.push(Span::styled("↑↓", key_style)); + spans.push(Span::raw(" select ")); + + if question.multiple && answer.cursor < question.options.len() { + spans.push(Span::styled("space", key_style)); + spans.push(Span::raw(" toggle ")); + } + + spans.push(Span::styled("enter", key_style)); + if question.custom && !question.options.is_empty() && answer.cursor == question.options.len() { + spans.push(Span::raw(" edit ")); + } else { + spans.push(Span::raw(" confirm ")); + } + + spans.push(Span::styled("esc", key_style)); + spans.push(Span::raw(" dismiss")); + + Line::from(spans) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{KeyEvent, KeyEventKind, KeyEventState}; + + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + #[test] + fn response_returns_selected_option_labels() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + assert_eq!(request.response(), json!([["A"]])); + } + + #[test] + fn response_accepts_custom_text() { + let (tx, _rx) = oneshot::channel(); + let mut request = + QuestionDialogRequest::new(json!([{ "question": "Explain", "header": "Details" }]), tx); + request.insert_char('h'); + request.insert_char('i'); + + assert_eq!(request.response(), json!([["hi"]])); + } + + #[test] + fn option_custom_answer_requires_enter_before_typing() { + let (tx, _rx) = oneshot::channel(); + let mut request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "custom": true, + "options": [{ "label": "A" }] + }]), + tx, + ); + + request.next_option(); + request.insert_char('z'); + + assert_eq!(request.response(), json!([["A"]])); + assert_eq!(request.answers[0].custom_text, ""); + + request.begin_custom_editing(); + request.insert_char('z'); + request.finish_custom_editing(); + + assert_eq!(request.response(), json!([["z"]])); + } + + #[test] + fn duplicate_custom_answer_option_is_removed() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [ + { "label": "A" }, + { "label": "Type your own answer" }, + { "label": "B" } + ] + }]), + tx, + ); + + assert_eq!(request.questions[0].options.len(), 2); + assert_eq!(request.questions[0].options[0].label, "A"); + assert_eq!(request.questions[0].options[1].label, "B"); + assert_eq!(option_row_count(&request.questions[0]), 3); + } + + #[test] + fn tab_cycles_between_questions_without_submitting() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([ + { + "question": "Pick one", + "header": "One", + "options": [{ "label": "A" }] + }, + { + "question": "Pick two", + "header": "Two", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 1); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 2); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 0); + + handle_question_dialog_key_event(&mut state, key(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(state.current.as_ref().unwrap().current_index, 2); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 1); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 2); + } + + #[test] + fn enter_moves_to_confirm_then_submit() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }] + }]), + tx, + ); + + let action = + handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(action, QuestionDialogAction::Handled)); + assert_eq!(state.current.as_ref().unwrap().current_index, 1); + + let action = + handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(action, QuestionDialogAction::Submit)); + } + + #[test] + fn tab_labels_use_question_numbers() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([ + { + "question": "This is a very long generated question that should not become a giant tab", + "header": "Question", + "options": [{ "label": "A" }] + }, + { + "question": "Short", + "header": "Short", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let line = question_tabs_line(&request, 0, &colors); + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + + assert_eq!(line.spans[0].content.as_ref(), " Question 1 "); + assert_eq!(line.spans[2].content.as_ref(), " Question 2 "); + assert!(text.contains("Confirm")); + assert!(!text.contains("generated question")); + assert!(!text.contains("Short")); + } + + #[test] + fn question_body_shows_full_prompt_under_tabs() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "This is a very long generated question that should not become a giant tab", + "header": "Question", + "options": [{ "label": "A" }] + }]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let body = question_body_lines( + &request.questions[0], + &request.answers[0], + 0, + request.editing_custom, + &colors, + ); + let first_line = body[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::<String>(); + let question_line = body[1] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::<String>(); + + assert_eq!(first_line, ""); + assert_eq!( + question_line, + "Question: This is a very long generated question that should not become a giant tab" + ); + } + + #[test] + fn generic_question_prompt_falls_back_to_numbered_label() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([ + { + "question": "Question", + "header": "Question", + "options": [{ "label": "A" }] + }, + { + "question": "Question", + "header": "Question", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let body = question_body_lines( + &request.questions[1], + &request.answers[1], + 1, + request.editing_custom, + &colors, + ); + let question_line = body[1] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::<String>(); + let confirm = confirm_body_lines(&request, &colors); + let confirm_text = confirm + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::<String>(); + + assert_eq!(question_line, "Question: Question 2"); + assert!(confirm_text.contains("1. Question 1")); + assert!(confirm_text.contains("2. Question 2")); + assert!(!confirm_text.contains("1. Question -")); + } + + #[test] + fn confirm_body_does_not_truncate_questions_or_answers() { + let (tx, _rx) = oneshot::channel(); + let mut request = QuestionDialogRequest::new( + json!([{ + "question": "This is a very long generated question that should not be truncated in confirm", + "header": "Question" + }]), + tx, + ); + for ch in "this is a long custom answer that should not be truncated".chars() { + request.insert_char(ch); + } + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let body = confirm_body_lines(&request, &colors); + let text = body + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::<String>(); + + assert!(text.contains( + "This is a very long generated question that should not be truncated in confirm" + )); + assert!(text.contains("this is a long custom answer that should not be truncated")); + } + + #[test] + fn tab_labels_do_not_pad_to_fixed_width() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "One", + "options": [{ "label": "A" }] + }]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let line = question_tabs_line(&request, 0, &colors); + + assert_eq!(line.spans[0].content.as_ref(), " Question 1 "); + assert_eq!(line.spans[2].content.as_ref(), " Confirm "); + } + + #[test] + fn footer_uses_simple_cycle_tabs_label() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([ + { + "question": "Pick one", + "header": "One", + "options": [{ "label": "A" }] + }, + { + "question": "Pick two", + "header": "Two", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let line = footer_line(&request, &colors); + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + + assert!(text.contains("cycle tabs")); + assert!(!text.contains("tab/shift-tab")); + assert!(!text.contains("←/→")); + } + + #[test] + fn multiple_aliases_render_checkbox_question() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick all project areas", + "header": "Areas", + "type": "multiple_choice", + "options": [{ "label": "CLI" }, { "label": "TUI" }] + }]), + tx, + ); + + assert!(request.questions[0].multiple); + + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let footer = footer_line(&request, &colors); + let footer_text: String = footer + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(footer_text.contains("space")); + assert!(footer_text.contains("toggle")); + + let body = question_body_lines( + &request.questions[0], + &request.answers[0], + 0, + request.editing_custom, + &colors, + ); + let body_text = body + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::<String>(); + assert!(body_text.contains("Select all that apply.")); + assert!(body_text.contains("[ ] ")); + } + + #[test] + fn multiple_can_be_inferred_from_question_text() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Select all that apply", + "header": "Choices", + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + assert!(request.questions[0].multiple); + } + + #[test] + fn multiple_choice_toggles_with_space() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Pick", + "header": "Choice", + "multiple": true, + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Down, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Char(' '), KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.response(), json!([["A", "B"]])); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Char(' '), KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.response(), json!([["A"]])); + } + + #[test] + fn multiple_choice_auto_checks_typed_custom_answer() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Select all that apply", + "header": "Choice", + "multiple": true, + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Down, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Down, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); + state.insert_text("custom"); + handle_question_dialog_key_event(&mut state, key(KeyCode::Esc, KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.response(), json!([["custom"]])); + assert!(request.answers[0].custom_selected); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Up, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Char(' '), KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.response(), json!([["B", "custom"]])); + assert!(request.answers[0].custom_selected); + } + + #[test] + fn custom_text_supports_cursor_insertion() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue(json!([{ "question": "Explain", "header": "Details" }]), tx); + + for ch in ['a', 'b', 'c'] { + handle_question_dialog_key_event( + &mut state, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + handle_question_dialog_key_event(&mut state, key(KeyCode::Left, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Left, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Char('X'), KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].custom_text, "aXbc"); + assert_eq!(request.answers[0].custom_cursor, 2); + } + + #[test] + fn custom_text_supports_option_arrow_word_motion() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue(json!([{ "question": "Explain", "header": "Details" }]), tx); + state.insert_text("hello brave world"); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Left, KeyModifiers::ALT)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].custom_cursor, 12); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Backspace, KeyModifiers::ALT)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].custom_text, "hello world"); + assert_eq!(request.answers[0].custom_cursor, 6); + } + + #[test] + fn custom_text_supports_command_backspace_clear() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue(json!([{ "question": "Explain", "header": "Details" }]), tx); + state.insert_text("hello world"); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Backspace, KeyModifiers::SUPER)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].custom_text, ""); + assert_eq!(request.answers[0].custom_cursor, 0); + } + + #[test] + fn option_questions_always_include_custom_row() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "custom": false, + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + assert!(request.questions[0].custom); + assert_eq!(option_row_count(&request.questions[0]), 3); + } + + #[test] + fn option_line_has_no_cursor_marker() { + let option = QuestionOption { + label: "A".to_string(), + description: String::new(), + }; + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let line = option_line(&option, true, true, false, &colors); + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + + assert!(text.starts_with("(*) ")); + } +} diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index a3e46f6..8fd1e5a 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -81,12 +81,10 @@ pub fn render_sessions_dialog( let existing_actions = dialog_state.dialog.actions.clone(); let has_confirm = existing_actions.iter().any(|a| a.label == "confirm"); if !has_confirm { - dialog_state.dialog.actions = vec![ - crate::ui::components::dialog::DialogAction { - label: "confirm".to_string(), - key: "ctrl+d".to_string(), - }, - ]; + dialog_state.dialog.actions = vec![crate::ui::components::dialog::DialogAction { + label: "confirm".to_string(), + key: "ctrl+d".to_string(), + }]; } } else { dialog_state.dialog.actions = vec![ diff --git a/src/views/timeline_dialog.rs b/src/views/timeline_dialog.rs index c6c8cb6..f610964 100644 --- a/src/views/timeline_dialog.rs +++ b/src/views/timeline_dialog.rs @@ -1,6 +1,8 @@ use crate::session::types::{Message, MessageRole}; use crate::theme::ThemeColors; -use crate::ui::components::dialog::{Dialog, DialogAction as FooterAction, DialogItem, DialogPosition}; +use crate::ui::components::dialog::{ + Dialog, DialogAction as FooterAction, DialogItem, DialogPosition, +}; use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; use ratatui::{layout::Rect, Frame}; @@ -12,12 +14,10 @@ pub struct TimelineDialogState { impl TimelineDialogState { pub fn new() -> Self { let mut dialog = Dialog::new("Timeline").with_position(DialogPosition::Right); - dialog = dialog.with_actions(vec![ - FooterAction { - label: "Jump actions".to_string(), - key: "enter".to_string(), - }, - ]); + dialog = dialog.with_actions(vec![FooterAction { + label: "Jump actions".to_string(), + key: "enter".to_string(), + }]); Self { dialog } } @@ -61,10 +61,7 @@ impl TimelineDialogState { let description = String::new(); let tip = { - let duration = message - .timestamp - .elapsed() - .unwrap_or_default(); + let duration = message.timestamp.elapsed().unwrap_or_default(); let secs = duration.as_secs(); if secs < 60 { format!("{}s ago", secs) @@ -90,16 +87,13 @@ impl TimelineDialogState { let last_index = items.len().saturating_sub(1); let was_visible = self.dialog.is_visible(); - let mut dialog = Dialog::with_items("Timeline", items) - .with_position(DialogPosition::Right); + let mut dialog = Dialog::with_items("Timeline", items).with_position(DialogPosition::Right); dialog.selected_index = last_index; dialog.adjust_scroll(); - dialog = dialog.with_actions(vec![ - FooterAction { - label: "Jump actions".to_string(), - key: "enter".to_string(), - }, - ]); + dialog = dialog.with_actions(vec![FooterAction { + label: "Jump actions".to_string(), + key: "enter".to_string(), + }]); if was_visible { dialog.show(); From 4ba5b9a83600c1993e271b7a6602319978f99060 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 18 May 2026 19:40:08 +0800 Subject: [PATCH 075/226] feat(question): auto-generate fallback options and detect skips; fix tool call streaming. - Auto-generate contextual options (indoor/outdoor, time, budget, yes/no, hobby) when questions have none - Return structured model output with `answered`/`skipped` status so the model knows not to re-ask - Remove `finish_reason` guard so tool call delta chunks always stream to the client - Forward tool call chunks through `tx_loop` for real-time relay - Fall back to `header`/`title` when `question` label is a generic "Question" --- _plans/__TODOS.md | 4 + aisdk/src/providers/compatible.rs | 51 ++++- aisdk/src/response.rs | 1 + src/llm/client.rs | 25 ++- src/tools/question.rs | 358 +++++++++++++++++++++++++++++- src/ui/components/chat.rs | 66 +++++- 6 files changed, 475 insertions(+), 30 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index b2ff100..d0f7c44 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -60,3 +60,7 @@ - [ ] A single AI response, is considered 1 message. So combine all its parts into a single message record. Not that every message part becomes a separate message in the timeline dialog. - [ ] Allow me to paste images i.e. [Image #1] [Image #2] [Image #3]. When I click on them, the image would be opened with my Finder (OS-specific) + +- [ ] Let's make the 'questions' a bit more mouse-driven. + +- [ ] Better question handling for skipped (Skipped, if I didn't press enter. like when I do `arrow right` immediately) diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index e4a188e..87d1405 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -272,14 +272,15 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { chunks.push(Ok(ChunkType::Reasoning(reasoning.to_string()))); } - // Emit tool calls on tool_calls finish_reason. Stream exhausts naturally - // for all other finish_reasons — no explicit End chunk needed. - if finish_reason == "tool_calls" || finish_reason == "function_call" { - if let Some(tool_calls) = choice["delta"]["tool_calls"].as_array() { - if !tool_calls.is_empty() { - let json = serde_json::to_string(tool_calls).unwrap_or_default(); - chunks.push(Ok(ChunkType::ToolCall(json))); - } + if let Some(tool_calls) = choice["delta"]["tool_calls"].as_array() { + if !tool_calls.is_empty() { + let json = serde_json::to_string(tool_calls).unwrap_or_default(); + debug_log(&format!( + "[SSE] Tool call delta: count={} finish_reason='{}'", + tool_calls.len(), + finish_reason + )); + chunks.push(Ok(ChunkType::ToolCall(json))); } } @@ -293,6 +294,40 @@ fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { chunks } +#[cfg(test)] +mod tests { + use super::*; + + fn tool_call_chunks(data: &str) -> Vec<String> { + process_sse_data(data) + .into_iter() + .filter_map(|chunk| match chunk.expect("chunk should parse") { + ChunkType::ToolCall(value) => Some(value), + _ => None, + }) + .collect() + } + + #[test] + fn emits_tool_call_delta_without_finish_reason() { + let data = r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"id":"tool-1","index":0,"type":"function","function":{"name":"question","arguments":"{\"questions\":[{\"header\":\"Hobbies\",\"options\":[]}]}"}}]}}]}"#; + + let chunks = tool_call_chunks(data); + + assert_eq!(chunks.len(), 1); + assert!(chunks[0].contains("\"name\":\"question\"")); + } + + #[test] + fn emits_no_tool_call_for_empty_final_tool_call_chunk() { + let data = r#"{"choices":[{"index":0,"finish_reason":"tool_calls","delta":{"role":"assistant","content":""}}]}"#; + + let chunks = tool_call_chunks(data); + + assert!(chunks.is_empty()); + } +} + /// Convert a byte stream into a stream of lines, handling both SSE (`data: ...`) and raw NDJSON. fn bytes_to_lines<S>(byte_stream: S) -> impl futures::Stream<Item = String> where diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 66ac739..cc9c74b 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -127,6 +127,7 @@ pub async fn stream_with_tools<P: Provider>( } Ok(ChunkType::ToolCall(json_str)) => { has_tool_call = true; + let _ = tx_loop.send(ChunkType::ToolCall(json_str.clone())); if let Ok(parsed) = parse_tool_calls(&json_str) { for (id, name, args) in parsed { tool_calls_to_execute.push((id, name, args)); diff --git a/src/llm/client.rs b/src/llm/client.rs index 648bbf6..0532ee9 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -446,8 +446,29 @@ async fn relay_stream_to_sender( )); let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } - ChunkType::ToolCall(_tool_call) => { - let _ = log("[RELAY] ToolCall chunk received"); + ChunkType::ToolCall(tool_call) => { + let names = serde_json::from_str::<serde_json::Value>(&tool_call) + .ok() + .and_then(|value| { + value.as_array().map(|items| { + items + .iter() + .filter_map(|item| { + item.get("function") + .and_then(|function| function.get("name")) + .and_then(|name| name.as_str()) + }) + .collect::<Vec<_>>() + .join(",") + }) + }) + .filter(|names| !names.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let _ = log(&format!( + "[RELAY] ToolCall chunk received names={} bytes={}", + names, + tool_call.len() + )); } ChunkType::End(_msg) => { let _ = log("[RELAY] End chunk — returning Ended"); diff --git a/src/tools/question.rs b/src/tools/question.rs index e0d30a6..da629f2 100644 --- a/src/tools/question.rs +++ b/src/tools/question.rs @@ -5,6 +5,7 @@ use crate::tools::{ }; use async_trait::async_trait; use serde_json::{Map, Value}; +use std::collections::HashMap; fn question_from_plain_text(params: &Value, question: &str) -> Value { let mut item = Map::new(); @@ -74,15 +75,244 @@ fn parse_questions_param(params: &Value) -> Result<Value, ToolError> { }; match parsed { - Value::Array(_) => Ok(parsed), - Value::Object(_) => Ok(Value::Array(vec![parsed])), - Value::String(s) if !s.trim().is_empty() => Ok(question_from_plain_text(params, &s)), + Value::Array(_) => Ok(normalize_questions(parsed)), + Value::Object(_) => Ok(normalize_questions(Value::Array(vec![parsed]))), + Value::String(s) if !s.trim().is_empty() => { + Ok(normalize_questions(question_from_plain_text(params, &s))) + } _ => Err(ToolError::Validation( "questions JSON must decode to an array or object".to_string(), )), } } +fn normalize_questions(value: Value) -> Value { + match value { + Value::Array(items) => Value::Array( + items + .into_iter() + .enumerate() + .map(|(idx, item)| normalize_question_item(item, idx)) + .collect(), + ), + other => normalize_question_item(other, 0), + } +} + +fn normalize_question_item(mut item: Value, idx: usize) -> Value { + let should_add_options = item + .as_object() + .map(|obj| { + !obj.get("options") + .and_then(|options| options.as_array()) + .map(|options| !options.is_empty()) + .unwrap_or(false) + }) + .unwrap_or(false); + + if !should_add_options { + return item; + } + + let question_text = question_text_for_model(&item, idx); + if let Some(obj) = item.as_object_mut() { + obj.insert( + "options".to_string(), + Value::Array(default_options_for_question(&question_text)), + ); + obj.entry("custom".to_string()).or_insert(Value::Bool(true)); + obj.insert("generated_options".to_string(), Value::Bool(true)); + } + + item +} + +fn option(label: &str, description: &str) -> Value { + serde_json::json!({ + "label": label, + "description": description, + }) +} + +fn default_options_for_question(question: &str) -> Vec<Value> { + let normalized = question.to_ascii_lowercase(); + + if normalized.contains("indoor") && normalized.contains("outdoor") { + return vec![ + option("Indoor", "Prefer hobbies done inside"), + option("Outdoor", "Prefer hobbies outside"), + option("Both", "Enjoy both indoor and outdoor hobbies"), + option("Depends", "Choice depends on mood, weather, or activity"), + ]; + } + + if normalized.contains("how much time") + || normalized.contains("how often") + || normalized.contains("each week") + || normalized.contains("per week") + { + return vec![ + option("Less than 1 hour", "Only a small amount of time"), + option("1-3 hours", "A few short sessions"), + option("4-7 hours", "Several hours most weeks"), + option("8+ hours", "A major part of the week"), + ]; + } + + if normalized.contains("budget") || normalized.contains("cost") || normalized.contains("spend") + { + return vec![ + option("Free", "Prefer no-cost options"), + option("Low budget", "Comfortable with small purchases"), + option("Moderate", "Willing to invest occasionally"), + option("Flexible", "Depends on the hobby"), + ]; + } + + if starts_like_yes_no_question(&normalized) { + return vec![ + option("Yes", "Agree or already do this"), + option("No", "Disagree or do not do this"), + option("Not sure", "Need more time to decide"), + option("It depends", "Answer varies by situation"), + ]; + } + + if normalized.contains("hobby") + || normalized.contains("hobbies") + || normalized.contains("free time") + || normalized.contains("enjoy") + || normalized.contains("try") + || normalized.contains("rewarding") + { + return vec![ + option("Creative", "Art, music, writing, crafts, or making things"), + option("Active", "Sports, fitness, movement, or physical skills"), + option("Technology", "Coding, gaming, gadgets, or digital projects"), + option("Outdoors", "Nature, hiking, gardening, or travel"), + option( + "Relaxing", + "Reading, cooking, mindfulness, or low-key hobbies", + ), + ]; + } + + vec![ + option("Yes", "This fits"), + option("No", "This does not fit"), + option("Not sure", "Need more time to decide"), + option("It depends", "Answer varies by situation"), + ] +} + +fn starts_like_yes_no_question(question: &str) -> bool { + [ + "are ", "can ", "could ", "did ", "do ", "does ", "had ", "has ", "have ", "is ", + "should ", "will ", "would ", + ] + .iter() + .any(|prefix| question.starts_with(prefix)) +} + +fn question_text_for_model(question: &Value, idx: usize) -> String { + if let Some(text) = question + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + { + return text.to_string(); + } + + let Some(obj) = question.as_object() else { + return format!("Question {}", idx + 1); + }; + + for key in ["question", "text", "prompt", "header", "title", "name"] { + if let Some(text) = obj.get(key).and_then(|value| value.as_str()) { + let text = text.trim(); + if !text.is_empty() { + return text.to_string(); + } + } + } + + format!("Question {}", idx + 1) +} + +fn answer_for_question(response: &Value, idx: usize) -> Value { + response + .as_array() + .and_then(|answers| answers.get(idx)) + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())) +} + +fn answer_is_skipped(answer: &Value) -> bool { + match answer { + Value::Array(items) => items.is_empty(), + Value::Null => true, + Value::String(text) => text.trim().is_empty(), + _ => false, + } +} + +fn question_tool_model_output(questions: &Value, response: &Value) -> Value { + let question_items = questions + .as_array() + .cloned() + .unwrap_or_else(|| vec![questions.clone()]); + let total = question_items.len(); + let mut skipped_count = 0usize; + + let items = question_items + .iter() + .enumerate() + .map(|(idx, question)| { + let answer = answer_for_question(response, idx); + let skipped = answer_is_skipped(&answer); + if skipped { + skipped_count += 1; + } + + serde_json::json!({ + "question": question_text_for_model(question, idx), + "answers": answer, + "skipped": skipped, + }) + }) + .collect::<Vec<_>>(); + + let all_skipped = total > 0 && skipped_count == total; + let message = if all_skipped { + format!( + "The user skipped all {} question(s). Do not call the question tool again unless the user explicitly asks to retry.", + total + ) + } else { + "The user answered the question tool prompt. Continue from these answers without re-asking the same questions.".to_string() + }; + + serde_json::json!({ + "status": if all_skipped { "skipped" } else { "answered" }, + "message": message, + "questions": items, + }) +} + +fn generated_options_count(questions: &Value) -> usize { + questions + .as_array() + .map(|items| { + items + .iter() + .filter(|item| { + item.get("generated_options").and_then(|v| v.as_bool()) == Some(true) + }) + .count() + }) + .unwrap_or(0) +} + pub struct QuestionTool { sender: Option<ChunkSender>, } @@ -106,14 +336,27 @@ impl QuestionTool { #[async_trait] impl ToolHandler for QuestionTool { fn definition(&self) -> Tool { + let mut option_props = HashMap::new(); + option_props.insert("label".to_string(), ParameterType::String); + option_props.insert("description".to_string(), ParameterType::String); + + let mut question_props = HashMap::new(); + question_props.insert("question".to_string(), ParameterType::String); + question_props.insert("header".to_string(), ParameterType::String); + question_props.insert( + "options".to_string(), + ParameterType::Array(Box::new(ParameterType::Object(option_props))), + ); + question_props.insert("multiple".to_string(), ParameterType::Boolean); + Tool { id: "question".to_string(), - description: "Use this tool when you need to ask the user questions during execution. This allows you to:\n1. Gather user preferences or requirements\n2. Clarify ambiguous instructions\n3. Get decisions on implementation choices as you work\n4. Offer choices to the user about what direction to take.\n\nUsage notes:\n- Questions are answered as arrays of labels\n- You can allow multiple selections or single selection\n- For select-all-that-apply questions, set `multiple: true`\n- Each question needs a header (short label) and options with labels and descriptions\n- A \"Type your own answer\" option is always available for option questions\n- The answers will come back as arrays of selected labels or custom answers".to_string(), + description: "Use this tool when you need to ask the user questions during execution. This allows you to:\n1. Gather user preferences or requirements\n2. Clarify ambiguous instructions\n3. Get decisions on implementation choices as you work\n4. Offer choices to the user about what direction to take.\n\nUsage notes:\n- Each question object must include `question` for the user-facing prompt\n- Use `header` only as a short tab label; do not put the full prompt only in `header`\n- Always include `options` with `{label, description}` items; a custom answer row is added automatically\n- If `options` is omitted or empty, Crabcode will add generic fallback options before showing the prompt\n- For select-all-that-apply questions, set `multiple: true`\n- Questions are answered as arrays of labels or custom text\n- If the result says the user skipped the questions, do not call this tool again unless the user explicitly asks to retry".to_string(), parameters: vec![ParameterSchema { name: "questions".to_string(), - description: "Array of question objects with: question (text), header (short label), options (array of {label, description}), and optional multiple (bool)".to_string(), + description: "Array of question objects with: question (user-facing prompt), header (short label), options (array of {label, description}), and optional multiple (bool)".to_string(), required: true, - param_type: ParameterType::Array(Box::new(ParameterType::Object(Default::default()))), + param_type: ParameterType::Array(Box::new(ParameterType::Object(question_props))), }], } } @@ -125,6 +368,13 @@ impl ToolHandler for QuestionTool { async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<ToolResult, ToolError> { let questions = parse_questions_param(¶ms)?; + let generated_count = generated_options_count(&questions); + if generated_count > 0 { + let _ = crate::logging::log(&format!( + "[QUESTION_TOOL] added fallback options to {} optionless question(s)", + generated_count + )); + } let sender = self.sender.as_ref().ok_or_else(|| { ToolError::Execution("Question tool has no sender configured".to_string()) @@ -149,8 +399,9 @@ impl ToolHandler for QuestionTool { .await .unwrap_or_else(|_| serde_json::Value::String("No response from user".to_string())); - let output = - serde_json::to_string_pretty(&response).unwrap_or_else(|_| response.to_string()); + let model_output = question_tool_model_output(&questions, &response); + let output = serde_json::to_string_pretty(&model_output) + .unwrap_or_else(|_| model_output.to_string()); Ok(ToolResult::new("Question answered", output) .with_metadata("questions", questions) @@ -191,6 +442,52 @@ mod tests { assert_eq!(questions[0]["header"], "Choice"); } + #[test] + fn parse_questions_adds_fallback_options_when_missing() { + let params = json!({ + "questions": [{ + "header": "Hobby Q1", + "question": "What's a hobby you've always wanted to try but haven't yet?" + }] + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert_eq!(questions[0]["generated_options"], true); + assert_eq!(questions[0]["options"][0]["label"], "Creative"); + assert!(questions[0]["options"].as_array().unwrap().len() >= 4); + } + + #[test] + fn parse_questions_preserves_explicit_options() { + let params = json!({ + "questions": [{ + "question": "Pick one", + "options": [{ "label": "A", "description": "First" }] + }] + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions[0].get("generated_options").is_none()); + assert_eq!(questions[0]["options"][0]["label"], "A"); + assert_eq!(questions[0]["options"].as_array().unwrap().len(), 1); + } + + #[test] + fn parse_questions_generates_time_options_for_time_question() { + let params = json!({ + "questions": [{ + "question": "How much time do you typically spend on hobbies each week?" + }] + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert_eq!(questions[0]["options"][0]["label"], "Less than 1 hour"); + assert_eq!(questions[0]["options"][3]["label"], "8+ hours"); + } + #[test] fn parse_questions_accepts_plain_text_with_top_level_options() { let params = json!({ @@ -246,4 +543,49 @@ mod tests { assert!(err.contains("questions parameter cannot be empty")); } + + #[test] + fn model_output_includes_questions_and_answers() { + let questions = json!([ + { + "question": "What hobby sounds most interesting?", + "header": "Favorite Hobby", + "options": [{ "label": "Reading", "description": "Books" }] + } + ]); + let response = json!([["Reading"]]); + + let output = question_tool_model_output(&questions, &response); + + assert_eq!(output["status"], "answered"); + assert_eq!( + output["questions"][0]["question"], + "What hobby sounds most interesting?" + ); + assert_eq!(output["questions"][0]["answers"][0], "Reading"); + assert_eq!(output["questions"][0]["skipped"], false); + assert!(output["message"] + .as_str() + .unwrap() + .contains("without re-asking")); + } + + #[test] + fn model_output_marks_all_questions_skipped() { + let questions = json!([ + { "header": "Hobbies Question 1", "options": [] }, + { "header": "Hobbies Question 2", "options": [] } + ]); + let response = json!([[], []]); + + let output = question_tool_model_output(&questions, &response); + + assert_eq!(output["status"], "skipped"); + assert_eq!(output["questions"][0]["question"], "Hobbies Question 1"); + assert_eq!(output["questions"][1]["skipped"], true); + assert!(output["message"] + .as_str() + .unwrap() + .contains("Do not call the question tool again")); + } } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 58146b0..2cffe82 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1218,20 +1218,35 @@ impl Chat { .unwrap_or_default() } - fn question_text(value: &JsonValue) -> String { + fn is_generic_question_label(text: &str) -> bool { + let text = text.trim(); + text.is_empty() || text.eq_ignore_ascii_case("question") + } + + fn question_text(value: &JsonValue, idx: usize) -> String { if let Some(text) = value.as_str() { return text.to_string(); } - value - .as_object() - .and_then(|obj| { - ["question", "text", "prompt"] - .iter() - .find_map(|key| obj.get(*key).and_then(|v| v.as_str())) - }) - .unwrap_or("Question") - .to_string() + let Some(obj) = value.as_object() else { + return format!("Question {}", idx + 1); + }; + + let primary = ["question", "text", "prompt"] + .iter() + .find_map(|key| obj.get(*key).and_then(|v| v.as_str())); + if let Some(text) = primary.filter(|text| !is_generic_question_label(text)) { + return text.trim().to_string(); + } + + let fallback = ["header", "title", "name"] + .iter() + .find_map(|key| obj.get(*key).and_then(|v| v.as_str())); + if let Some(text) = fallback.filter(|text| !is_generic_question_label(text)) { + return text.trim().to_string(); + } + + format!("Question {}", idx + 1) } fn format_answer(value: Option<&JsonValue>) -> String { @@ -1441,8 +1456,10 @@ impl Chat { if idx > 0 { panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); } - let q_line = - Line::from(vec![Span::styled(question_text(question), question_style)]); + let q_line = Line::from(vec![Span::styled( + question_text(question, idx), + question_style, + )]); panel_lines.extend(wrap_styled_line( &q_line, WrapOptions::new(panel_width) @@ -2012,6 +2029,31 @@ mod tests { assert!(rendered[4].trim().is_empty()); } + #[test] + fn test_question_panel_uses_header_when_question_is_generic() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "question", + "status": "ok", + "args": { + "questions": [{ "question": "Question", "header": "Location" }] + }, + "metadata": { + "questions": [{ "question": "Question", "header": "Location" }], + "answers": ["Indoor"] + } + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert!(rendered.iter().any(|line| line.trim() == "Location")); + assert!(!rendered.iter().any(|line| line.trim() == "Question")); + } + #[test] fn test_todowrite_panel_keeps_padding_without_extra_gap() { let chat = Chat::new(); From c724c77a09a83dda098f59792c4982d906949614 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 18 May 2026 19:50:05 +0800 Subject: [PATCH 076/226] feat(questions): make navigation mouse-driven and require explicit confirmation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tab hitbox detection so clicking a tab switches questions or jumps to confirm - Remove auto-selection on arrow up/down for single-choice questions; now requires Enter (or click) to pick an option - Change "No answer" → "Skipped" and footer "select" → "move" to disambiguate - Add `confirm_current_selection()` helper and tests for skipped behaviour --- _plans/__TODOS.md | 8 +- src/views/question_dialog.rs | 307 +++++++++++++++++++++++++++++++---- 2 files changed, 284 insertions(+), 31 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index d0f7c44..bc9b864 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -61,6 +61,10 @@ - [ ] Allow me to paste images i.e. [Image #1] [Image #2] [Image #3]. When I click on them, the image would be opened with my Finder (OS-specific) -- [ ] Let's make the 'questions' a bit more mouse-driven. +- [x] Let's make the 'questions' a bit more mouse-driven. -- [ ] Better question handling for skipped (Skipped, if I didn't press enter. like when I do `arrow right` immediately) +- [x] Better question handling for skipped (Skipped, if I didn't press enter. like when I do `arrow right` immediately) + +- [ ] Scroll like herdr. Stuff I like: as thin, as tall (no arrows - currently ours also has no arrows but it was a hack, we just remove the arrows with "" chars so they still take a height. The one from herdr looks like it's a pure scrollbar thumb without arrows and thin enough that I like) + +- [ ] Highlight enhancements, if I click 1 place, then shift+click another. Treat it like the highlight in the browser that doesn't need a drag. Whatever I last clicked (without shift+click), treat it as the anchor for the "select start", and then whatever I shift+click after, treat it as a "select end" and autohighlight that part. diff --git a/src/views/question_dialog.rs b/src/views/question_dialog.rs index 82c78f6..bb25b47 100644 --- a/src/views/question_dialog.rs +++ b/src/views/question_dialog.rs @@ -1,7 +1,9 @@ use crate::theme::{contrast_text, ThemeColors}; -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Wrap}, @@ -10,6 +12,7 @@ use ratatui::{ use serde_json::{json, Value}; use std::collections::VecDeque; use tokio::sync::oneshot; +use unicode_width::UnicodeWidthStr; #[derive(Clone, Debug)] struct QuestionOption { @@ -156,6 +159,13 @@ struct QuestionDialogRequest { pub struct QuestionDialogState { current: Option<QuestionDialogRequest>, queue: VecDeque<QuestionDialogRequest>, + tab_hitboxes: Vec<QuestionTabHitbox>, +} + +#[derive(Clone, Copy, Debug)] +struct QuestionTabHitbox { + area: Rect, + index: usize, } pub enum QuestionDialogAction { @@ -174,6 +184,7 @@ impl QuestionDialogState { Self { current: None, queue: VecDeque::new(), + tab_hitboxes: Vec::new(), } } @@ -181,6 +192,7 @@ impl QuestionDialogState { let request = QuestionDialogRequest::new(questions, response_tx); if self.current.is_none() { self.current = Some(request); + self.tab_hitboxes.clear(); } else { self.queue.push_back(request); } @@ -196,6 +208,7 @@ impl QuestionDialogState { let _ = request.response_tx.send(response); } self.current = self.queue.pop_front(); + self.tab_hitboxes.clear(); } pub fn cancel_current(&mut self) { @@ -204,6 +217,7 @@ impl QuestionDialogState { let _ = request.response_tx.send(response); } self.current = self.queue.pop_front(); + self.tab_hitboxes.clear(); } pub fn clear_with_empty(&mut self) { @@ -216,6 +230,7 @@ impl QuestionDialogState { let response = request.empty_response(); let _ = request.response_tx.send(response); } + self.tab_hitboxes.clear(); } pub fn insert_text(&mut self, text: &str) { @@ -239,6 +254,13 @@ impl QuestionDialogState { fn queued_count(&self) -> usize { self.queue.len() } + + fn tab_index_at(&self, point: Position) -> Option<usize> { + self.tab_hitboxes + .iter() + .find(|hitbox| hitbox.area.contains(point)) + .map(|hitbox| hitbox.index) + } } impl QuestionDialogRequest { @@ -316,17 +338,12 @@ impl QuestionDialogRequest { return; } - let multiple = question.multiple; - let options_len = question.options.len(); if let Some(answer) = self.current_answer_mut() { answer.cursor = if answer.cursor == 0 { count - 1 } else { answer.cursor - 1 }; - if !multiple && answer.cursor < options_len { - answer.select_cursor(); - } } self.editing_custom = false; } @@ -340,13 +357,8 @@ impl QuestionDialogRequest { return; } - let multiple = question.multiple; - let options_len = question.options.len(); if let Some(answer) = self.current_answer_mut() { answer.cursor = (answer.cursor + 1) % count; - if !multiple && answer.cursor < options_len { - answer.select_cursor(); - } } self.editing_custom = false; } @@ -468,6 +480,27 @@ impl QuestionDialogRequest { } } + fn confirm_current_selection(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + if question.options.is_empty() || question.multiple { + return; + } + + let options_len = question.options.len(); + let mut selected = false; + if let Some(answer) = self.current_answer_mut() { + if answer.cursor < options_len { + answer.select_cursor(); + selected = true; + } + } + if selected { + self.editing_custom = false; + } + } + fn insert_char(&mut self, ch: char) { if !self.current_is_text_entry() { return; @@ -626,13 +659,8 @@ impl QuestionDialogRequest { impl QuestionAnswerState { fn for_question(question: &QuestionItem) -> Self { - let mut selected = vec![false; question.options.len()]; - if !question.multiple && !selected.is_empty() { - selected[0] = true; - } - Self { - selected, + selected: vec![false; question.options.len()], cursor: 0, custom_text: String::new(), custom_cursor: 0, @@ -819,10 +847,13 @@ pub fn handle_question_dialog_key_event( } else if request.current_is_custom_row() { request.begin_custom_editing(); QuestionDialogAction::Handled - } else if request.next_question_or_submit() { - QuestionDialogAction::Submit } else { - QuestionDialogAction::Handled + request.confirm_current_selection(); + if request.next_question_or_submit() { + QuestionDialogAction::Submit + } else { + QuestionDialogAction::Handled + } } } KeyCode::Char(ch) @@ -837,10 +868,93 @@ pub fn handle_question_dialog_key_event( } pub fn handle_question_dialog_mouse_event( - _state: &mut QuestionDialogState, - _event: MouseEvent, + state: &mut QuestionDialogState, + event: MouseEvent, ) -> bool { - false + if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + return false; + } + + let point = Position::new(event.column, event.row); + let Some(tab_index) = state.tab_index_at(point) else { + return false; + }; + + let Some(request) = state.active_mut() else { + return false; + }; + + request.current_index = tab_index.min(request.questions.len()); + request.sync_editing_for_current_focus(); + true +} + +fn push_tab_hitbox( + hitboxes: &mut Vec<QuestionTabHitbox>, + header_area: Rect, + x: &mut u16, + label: &str, + index: usize, +) { + let label_width = UnicodeWidthStr::width(label) as u16; + if label_width == 0 || header_area.width == 0 { + return; + } + + let label_start = *x; + let label_end = label_start.saturating_add(label_width); + let header_start = header_area.x; + let header_end = header_area.x.saturating_add(header_area.width); + + if label_end > header_start && label_start < header_end { + let visible_start = label_start.max(header_start); + let visible_end = label_end.min(header_end); + if visible_end > visible_start { + hitboxes.push(QuestionTabHitbox { + area: Rect { + x: visible_start, + y: header_area.y, + width: visible_end - visible_start, + height: 1, + }, + index, + }); + } + } + + *x = label_end; +} + +fn question_tab_hitboxes( + request: &QuestionDialogRequest, + header_area: Rect, +) -> Vec<QuestionTabHitbox> { + let mut hitboxes = Vec::new(); + let mut x = header_area.x; + + for idx in 0..request.questions.len() { + if idx > 0 { + x = x.saturating_add(2); + } + + let label = stable_tab_label(&format!("Question {}", idx + 1)); + push_tab_hitbox(&mut hitboxes, header_area, &mut x, &label, idx); + } + + if !request.questions.is_empty() { + x = x.saturating_add(2); + } + + let confirm_label = stable_tab_label("Confirm"); + push_tab_hitbox( + &mut hitboxes, + header_area, + &mut x, + &confirm_label, + request.questions.len(), + ); + + hitboxes } pub fn render_question_dialog( @@ -850,6 +964,7 @@ pub fn render_question_dialog( colors: ThemeColors, ) { let Some(request) = state.active() else { + state.tab_hitboxes.clear(); return; }; @@ -912,6 +1027,7 @@ pub fn render_question_dialog( Paragraph::new(question_tabs_line(request, state.queued_count(), &colors)), header_chunks[0], ); + let tab_hitboxes = question_tab_hitboxes(request, header_chunks[0]); f.render_widget( Paragraph::new(Line::from(vec![Span::styled( cancel_text, @@ -947,6 +1063,7 @@ pub fn render_question_dialog( let footer = footer_line(request, &colors); f.render_widget(Paragraph::new(footer).alignment(Alignment::Left), chunks[2]); + state.tab_hitboxes = tab_hitboxes; } fn parse_questions(value: Value) -> Vec<QuestionItem> { @@ -1315,7 +1432,7 @@ fn answer_summary(question: &QuestionItem, answer: &QuestionAnswerState) -> Stri .unwrap_or_default(); if labels.is_empty() { - "No answer".to_string() + "Skipped".to_string() } else { labels.join(", ") } @@ -1452,7 +1569,7 @@ fn footer_line<'a>(request: &QuestionDialogRequest, colors: &ThemeColors) -> Lin }; spans.push(Span::styled("↑↓", key_style)); - spans.push(Span::raw(" select ")); + spans.push(Span::raw(" move ")); if question.multiple && answer.cursor < question.options.len() { spans.push(Span::styled("space", key_style)); @@ -1475,7 +1592,9 @@ fn footer_line<'a>(request: &QuestionDialogRequest, colors: &ThemeColors) -> Lin #[cfg(test)] mod tests { use super::*; - use ratatui::crossterm::event::{KeyEvent, KeyEventKind, KeyEventState}; + use ratatui::crossterm::event::{ + KeyEvent, KeyEventKind, KeyEventState, MouseButton, MouseEvent, MouseEventKind, + }; fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { KeyEvent { @@ -1486,10 +1605,19 @@ mod tests { } } + fn mouse_down(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } + } + #[test] fn response_returns_selected_option_labels() { let (tx, _rx) = oneshot::channel(); - let request = QuestionDialogRequest::new( + let mut request = QuestionDialogRequest::new( json!([{ "question": "Pick", "header": "Choice", @@ -1497,10 +1625,26 @@ mod tests { }]), tx, ); + request.confirm_current_selection(); assert_eq!(request.response(), json!([["A"]])); } + #[test] + fn option_response_is_skipped_until_confirmed() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + assert_eq!(request.response(), json!([[]])); + } + #[test] fn response_accepts_custom_text() { let (tx, _rx) = oneshot::channel(); @@ -1528,7 +1672,7 @@ mod tests { request.next_option(); request.insert_char('z'); - assert_eq!(request.response(), json!([["A"]])); + assert_eq!(request.response(), json!([[]])); assert_eq!(request.answers[0].custom_text, ""); request.begin_custom_editing(); @@ -1538,6 +1682,62 @@ mod tests { assert_eq!(request.response(), json!([["z"]])); } + #[test] + fn right_arrow_without_enter_keeps_question_skipped() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }] + }]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Right, KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.current_index, 1); + assert_eq!(request.response(), json!([[]])); + + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let confirm_text = confirm_body_lines(request, &colors) + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::<String>(); + assert!(confirm_text.contains("Skipped")); + } + + #[test] + fn single_choice_arrow_navigation_requires_enter_to_answer() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Down, KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].cursor, 1); + assert_eq!(request.response(), json!([[]])); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.current_index, 1); + assert_eq!(request.response(), json!([["B"]])); + } + #[test] fn duplicate_custom_answer_option_is_removed() { let (tx, _rx) = oneshot::channel(); @@ -1616,12 +1816,61 @@ mod tests { handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(action, QuestionDialogAction::Handled)); assert_eq!(state.current.as_ref().unwrap().current_index, 1); + assert_eq!(state.current.as_ref().unwrap().response(), json!([["A"]])); let action = handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(action, QuestionDialogAction::Submit)); } + #[test] + fn mouse_clicking_tabs_changes_active_question() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([ + { + "question": "Pick one", + "header": "One", + "options": [{ "label": "A" }] + }, + { + "question": "Pick two", + "header": "Two", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + + let header_area = Rect { + x: 4, + y: 2, + width: 80, + height: 1, + }; + state.tab_hitboxes = { + let request = state.current.as_ref().unwrap(); + question_tab_hitboxes(request, header_area) + }; + + let second = state.tab_hitboxes[1].area; + let handled = handle_question_dialog_mouse_event( + &mut state, + mouse_down(second.x.saturating_add(1), second.y), + ); + assert!(handled); + assert_eq!(state.current.as_ref().unwrap().current_index, 1); + + let confirm = state.tab_hitboxes[2].area; + let handled = handle_question_dialog_mouse_event( + &mut state, + mouse_down(confirm.x.saturating_add(1), confirm.y), + ); + assert!(handled); + assert_eq!(state.current.as_ref().unwrap().current_index, 2); + } + #[test] fn tab_labels_use_question_numbers() { let (tx, _rx) = oneshot::channel(); From 363f63fef26b41f5fab6598b68e84e667ce814e0 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 18 May 2026 19:57:41 +0800 Subject: [PATCH 077/226] feat(timeline): collapse consecutive assistant messages into one timeline item. When a tool panel segment or empty assistant response appears between user messages, multiple assistant entries were shown. Now contiguous assistant messages are merged into a single timeline item, using the last non-empty preview. Extracted `message_preview` helper and added tests. --- src/views/timeline_dialog.rs | 134 +++++++++++++++++++++++++++++------ 1 file changed, 113 insertions(+), 21 deletions(-) diff --git a/src/views/timeline_dialog.rs b/src/views/timeline_dialog.rs index f610964..868ab57 100644 --- a/src/views/timeline_dialog.rs +++ b/src/views/timeline_dialog.rs @@ -6,6 +6,12 @@ use crate::ui::components::dialog::{ use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; use ratatui::{layout::Rect, Frame}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimelineRole { + User, + Assistant, +} + #[derive(Debug)] pub struct TimelineDialogState { pub dialog: Dialog, @@ -29,33 +35,34 @@ impl TimelineDialogState { pub fn refresh_messages(&mut self, messages: &[Message]) { let mut items: Vec<DialogItem> = Vec::new(); + let mut last_timeline_role: Option<TimelineRole> = None; + let mut last_assistant_preview_empty = false; for (idx, message) in messages.iter().enumerate() { - match message.role { - MessageRole::User | MessageRole::Assistant => {} + let timeline_role = match message.role { + MessageRole::User => TimelineRole::User, + MessageRole::Assistant => TimelineRole::Assistant, _ => continue, - } - - let role_label = match message.role { - MessageRole::User => "You", - MessageRole::Assistant => "Agent", - _ => unreachable!(), }; - let preview = message - .content - .lines() - .find(|line| !line.trim().is_empty()) - .map(|line| { - let trimmed = line.trim(); - let truncated: String = trimmed.chars().take(20).collect(); - if truncated.len() < trimmed.len() { - format!("{}...", truncated) - } else { - truncated + let preview = message_preview(message); + + if timeline_role == TimelineRole::Assistant + && last_timeline_role == Some(TimelineRole::Assistant) + { + if last_assistant_preview_empty && preview != "(empty)" { + if let Some(item) = items.last_mut() { + item.name = format!("Agent: {}", preview); + last_assistant_preview_empty = false; } - }) - .unwrap_or_else(|| "(empty)".to_string()); + } + continue; + } + + let role_label = match timeline_role { + TimelineRole::User => "You", + TimelineRole::Assistant => "Agent", + }; let name = format!("{}: {}", role_label, preview); let description = String::new(); @@ -80,6 +87,9 @@ impl TimelineDialogState { tip: Some(tip), provider_id: String::new(), }); + last_timeline_role = Some(timeline_role); + last_assistant_preview_empty = + timeline_role == TimelineRole::Assistant && preview == "(empty)"; } // Chronological order: oldest first, newest at bottom @@ -111,6 +121,23 @@ impl TimelineDialogState { } } +fn message_preview(message: &Message) -> String { + message + .content + .lines() + .find(|line| !line.trim().is_empty()) + .map(|line| { + let trimmed = line.trim(); + let truncated: String = trimmed.chars().take(20).collect(); + if truncated.len() < trimmed.len() { + format!("{}...", truncated) + } else { + truncated + } + }) + .unwrap_or_else(|| "(empty)".to_string()) +} + pub fn init_timeline_dialog() -> TimelineDialogState { TimelineDialogState::new() } @@ -192,3 +219,68 @@ pub enum TimelineDialogAction { Select(usize), Navigate(usize), } + +#[cfg(test)] +mod tests { + use super::*; + + fn item_names(state: &TimelineDialogState) -> Vec<String> { + state + .dialog + .items + .iter() + .map(|item| item.name.clone()) + .collect() + } + + fn item_ids(state: &TimelineDialogState) -> Vec<String> { + state + .dialog + .items + .iter() + .map(|item| item.id.clone()) + .collect() + } + + #[test] + fn assistant_segments_between_user_messages_collapse_into_one_timeline_item() { + let messages = vec![ + Message::user("Ask me 4 questions"), + Message::assistant(""), + Message::tool("question tool panel"), + Message::assistant(""), + Message::tool("another tool panel"), + Message::assistant("Final answer after tools"), + Message::user("Next prompt"), + Message::assistant("Next response"), + ]; + + let state = TimelineDialogState::build_from_messages(&messages); + + assert_eq!( + item_names(&state), + vec![ + "You: Ask me 4 questions", + "Agent: Final answer after t...", + "You: Next prompt", + "Agent: Next response", + ] + ); + assert_eq!(item_ids(&state), vec!["0", "1", "6", "7"]); + } + + #[test] + fn assistant_segments_without_visible_text_still_collapse() { + let messages = vec![ + Message::user("Run tools"), + Message::assistant(""), + Message::tool("tool call"), + Message::assistant(""), + ]; + + let state = TimelineDialogState::build_from_messages(&messages); + + assert_eq!(item_names(&state), vec!["You: Run tools", "Agent: (empty)"]); + assert_eq!(item_ids(&state), vec!["0", "1"]); + } +} From 97070d520e310307acb602fb2a9a25fb094a4e64 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 18 May 2026 21:30:25 +0800 Subject: [PATCH 078/226] feat: migrate data storage to XDG_STATE_HOME with private permissions. - Switch from OS-specific paths to `~/.local/state` with `$XDG_STATE_HOME` support - Create data dirs and auth files with restricted permissions (0o700 / 0o600) - Move cache dir under data dir (`get_data_dir().join("cache")`) - Add custom scrollbar component replacing ratatui's built-in Scrollbar widget - Implement shift-click text selection anchored from last plain click - Add `CRABCODE_MOUSE_TRACE` env var for debugging mouse events - Update docs across AGENTS.md, README.md, quickstart.mdx, and TODOs --- AGENTS.md | 12 +- README.md | 6 +- _docs/quickstart.mdx | 12 +- _plans/__TODOS.md | 4 +- src/app.rs | 12 ++ src/main.rs | 6 + src/persistence/auth.rs | 45 ++++-- src/persistence/mod.rs | 81 +++++++++-- src/ui/components/chat.rs | 264 +++++++++++++++++++++++++++++++++--- src/ui/components/dialog.rs | 36 ++--- src/ui/mod.rs | 1 + src/ui/scrollbar.rs | 179 ++++++++++++++++++++++++ src/ui/selection.rs | 23 ++++ 13 files changed, 609 insertions(+), 72 deletions(-) create mode 100644 src/ui/scrollbar.rs diff --git a/AGENTS.md b/AGENTS.md index 5e71680..e3c834d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,8 @@ Before adding/changing scripts, make sure to check `justfile` for existing recip ### SQLite Database - **Location**: - - macOS: `~/Library/Application Support/crabcode/data.db` - - Linux: `~/.local/share/crabcode/data.db` + - Default: `~/.local/state/crabcode/data.db` + - With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/data.db` - **Implementation**: `src/persistence/prefs.rs` - **Contents**: Stores user preferences including: - Model preferences (recent models, favorites, active model) @@ -26,8 +26,8 @@ Before adding/changing scripts, make sure to check `justfile` for existing recip ### Authentication Credentials - **Location**: - - macOS: `~/Library/Application Support/crabcode/auth.json` - - Linux: `~/.local/share/crabcode/auth.json` + - Default: `~/.local/state/crabcode/auth.json` + - With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/auth.json` - **Implementation**: `src/persistence/auth.rs` - **Format**: JSON with provider ID as keys - **Contents**: API keys and OAuth tokens for LLM providers @@ -44,8 +44,8 @@ Before adding/changing scripts, make sure to check `justfile` for existing recip ### Models.dev API Cache - **Location**: - - macOS: `~/Library/Caches/crabcode/models_dev_cache.json` - - Linux: `~/.cache/crabcode/models_dev_cache.json` + - Default: `~/.local/state/crabcode/cache/models_dev_cache.json` + - With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/cache/models_dev_cache.json` - Test mode: `/tmp/crabcode_test_cache/models_dev_cache.json` - **TTL**: 24 hours (`CACHE_TTL_SECONDS = 86400`) - **Source**: `https://models.dev/api.json` diff --git a/README.md b/README.md index 997933c..a6894d9 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,10 @@ curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | ## Configuration -Your credentials are stored in an OS-specific data directory: +Your credentials are stored in crabcode's state directory: -- macOS: `~/Library/Application Support/crabcode/auth.json` -- Linux: `~/.local/share/crabcode/auth.json` +- Default: `~/.local/state/crabcode/auth.json` +- With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/auth.json` Read the [extensive list of configs here](/_docs/config.mdx). diff --git a/_docs/quickstart.mdx b/_docs/quickstart.mdx index e6c6898..d032e4f 100644 --- a/_docs/quickstart.mdx +++ b/_docs/quickstart.mdx @@ -91,9 +91,9 @@ Once you're in crabcode: ## Where your data lives -| What | macOS | Linux | -| ------------------------ | -------------------------------------------------- | ----------------------------------------- | -| Credentials | `~/Library/Application Support/crabcode/auth.json` | `~/.local/share/crabcode/auth.json` | -| Preferences and Sessions | `~/Library/Application Support/crabcode/data.db` | `~/.local/share/crabcode/data.db` | -| Model cache | `~/Library/Caches/crabcode/models_dev_cache.json` | `~/.cache/crabcode/models_dev_cache.json` | -| Sounds | `~/Library/Application Support/crabcode/sounds` | `~/.local/share/crabcode/sounds` | +| What | Default | With `XDG_STATE_HOME` | +| ------------------------ | ------------------------------------------------- | --------------------------------------------- | +| Credentials | `~/.local/state/crabcode/auth.json` | `$XDG_STATE_HOME/crabcode/auth.json` | +| Preferences and Sessions | `~/.local/state/crabcode/data.db` | `$XDG_STATE_HOME/crabcode/data.db` | +| Model cache | `~/.local/state/crabcode/cache/models_dev_cache.json` | `$XDG_STATE_HOME/crabcode/cache/models_dev_cache.json` | +| Sounds | `~/.local/state/crabcode/sounds` | `$XDG_STATE_HOME/crabcode/sounds` | diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index bc9b864..ac46a26 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -53,11 +53,11 @@ - [ ] todowrite - better looking, like opencode does. - [ ] rendering subagents - just like opencode, clickable to go into their page.. OR I can do `ctrl-x ↓` to go into it if there's a subagent running. I can also switch between subagents with `←` and `→` -- [ ] Fix: Chat content colors. Currently no matter what theme I use, the color of the chat especially the main text colors in markdown, are still the default's crabcode-orange's colors. I want them to be a bit more relevant. +- [ ] Fix: Chat content colors. Currently no matter what theme I use, the color of the chat especially the main text colors in markdown, are the default theme colors that were set during start time - meaning at config. Whatever I change via `/themes` dialog, it doesn't update the chat colors themes. - [ ] Bug: I can type a command see autosuggest, but can't press 'enter' to run the command. Pls fix. -- [ ] A single AI response, is considered 1 message. So combine all its parts into a single message record. Not that every message part becomes a separate message in the timeline dialog. +- [x] A single AI response, is considered 1 message. So combine all its parts into a single message record. Not that every message part becomes a separate message in the timeline dialog. - [ ] Allow me to paste images i.e. [Image #1] [Image #2] [Image #3]. When I click on them, the image would be opened with my Finder (OS-specific) diff --git a/src/app.rs b/src/app.rs index e8dbdbb..62dab53 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1245,6 +1245,18 @@ impl App { } pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { + if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() { + let _ = crate::logging::log(&format!( + "Handle mouse: kind={:?} modifiers={:?} col={} row={} base={:?} overlay={:?}", + mouse.kind, + mouse.modifiers, + mouse.column, + mouse.row, + self.base_focus, + self.overlay_focus + )); + } + // If text is selected and user clicks on an overlay, clear selection instead if self.overlay_focus != OverlayFocus::None && self.chat_state.chat.has_selection() diff --git a/src/main.rs b/src/main.rs index 1442862..9e04687 100644 --- a/src/main.rs +++ b/src/main.rs @@ -369,6 +369,12 @@ async fn run_event_loop( if event::poll(poll_timeout)? { let event = event::read()?; + if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() { + if let event::Event::Mouse(mouse) = &event { + let _ = crate::logging::log(&format!("Mouse event: {:?}", mouse)); + } + } + // DO NOT REMOVE THIS LOG THAT I UNCOMMENT SOMETIMES. I USE IT FOR DEBUGGING // push_toast(Toast::new( // format!("Event: {:?}", event), diff --git a/src/persistence/auth.rs b/src/persistence/auth.rs index 1fc4813..8ee083d 100644 --- a/src/persistence/auth.rs +++ b/src/persistence/auth.rs @@ -2,9 +2,9 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use super::get_data_dir; +use super::{ensure_data_dir, get_data_dir}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] @@ -34,14 +34,16 @@ pub struct AuthDAO { impl AuthDAO { pub fn new() -> Result<Self> { let auth_path = Self::auth_path(); - if let Some(parent) = auth_path.parent() { - std::fs::create_dir_all(parent)?; - } + Self::ensure_auth_parent()?; Ok(Self { auth_path }) } + fn test_mode() -> bool { + cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() + } + fn auth_path() -> PathBuf { - if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { + if Self::test_mode() { PathBuf::from("/tmp/crabcode_test_data").join("auth.json") } else { let data_dir = get_data_dir(); @@ -50,7 +52,7 @@ impl AuthDAO { } fn legacy_api_keys_path() -> PathBuf { - if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { + if Self::test_mode() { PathBuf::from("/tmp/crabcode_test_api_keys.json") } else { dirs::config_dir() @@ -60,6 +62,17 @@ impl AuthDAO { } } + fn ensure_auth_parent() -> Result<()> { + if Self::test_mode() { + if let Some(parent) = Self::auth_path().parent() { + std::fs::create_dir_all(parent)?; + } + } else { + ensure_data_dir()?; + } + Ok(()) + } + fn try_migrate_legacy_api_keys(&self) -> Result<()> { if self.auth_path.exists() { return Ok(()); @@ -104,11 +117,10 @@ impl AuthDAO { } pub fn save(&self, providers: &HashMap<String, AuthConfig>) -> Result<()> { - if let Some(parent) = self.auth_path.parent() { - std::fs::create_dir_all(parent)?; - } + Self::ensure_auth_parent()?; let content = serde_json::to_string_pretty(providers)?; std::fs::write(&self.auth_path, content)?; + restrict_auth_file_permissions(&self.auth_path)?; Ok(()) } @@ -138,6 +150,19 @@ impl AuthDAO { } } +#[cfg(unix)] +fn restrict_auth_file_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_auth_file_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + #[cfg(test)] impl AuthDAO { pub fn cleanup_test() -> Result<()> { diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 7a309fd..539adf0 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; -use std::path::PathBuf; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; pub mod auth; pub mod conversions; @@ -18,25 +19,87 @@ pub use prefs::PrefsDAO; pub use prompt_history::PromptHistoryCache; pub fn get_data_dir() -> PathBuf { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("crabcode") + state_home().join("crabcode") } pub fn get_cache_dir() -> PathBuf { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("crabcode") + get_data_dir().join("cache") } pub fn ensure_data_dir() -> Result<()> { let dir = get_data_dir(); - std::fs::create_dir_all(&dir)?; + create_private_dir_all(&dir)?; Ok(()) } pub fn ensure_cache_dir() -> Result<()> { + ensure_data_dir()?; let dir = get_cache_dir(); - std::fs::create_dir_all(&dir)?; + create_private_dir_all(&dir)?; + Ok(()) +} + +fn state_home() -> PathBuf { + resolve_state_home(std::env::var_os("XDG_STATE_HOME"), dirs::home_dir()) +} + +fn resolve_state_home(xdg_state_home: Option<OsString>, home_dir: Option<PathBuf>) -> PathBuf { + if let Some(path) = xdg_state_home { + if !path.is_empty() { + return PathBuf::from(path); + } + } + + home_dir + .unwrap_or_else(|| PathBuf::from(".")) + .join(".local") + .join("state") +} + +fn create_private_dir_all(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir)?; + restrict_dir_permissions(dir)?; + Ok(()) +} + +#[cfg(unix)] +fn restrict_dir_permissions(dir: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; Ok(()) } + +#[cfg(not(unix))] +fn restrict_dir_permissions(_dir: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn state_home_prefers_xdg_state_home() { + let path = resolve_state_home( + Some(OsString::from("/tmp/custom-state")), + Some(PathBuf::from("/home/alice")), + ); + + assert_eq!(path, PathBuf::from("/tmp/custom-state")); + } + + #[test] + fn state_home_falls_back_to_local_state() { + let path = resolve_state_home(None, Some(PathBuf::from("/home/alice"))); + + assert_eq!(path, PathBuf::from("/home/alice/.local/state")); + } + + #[test] + fn empty_xdg_state_home_uses_fallback() { + let path = resolve_state_home(Some(OsString::from("")), Some(PathBuf::from("/home/alice"))); + + assert_eq!(path, PathBuf::from("/home/alice/.local/state")); + } +} diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 2cffe82..5cc7780 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1,15 +1,16 @@ use crate::session::types::{Message, MessageRole}; use crate::theme::{contrast_text, ThemeColors}; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; +use crate::ui::scrollbar::{render_scrollbar, scrollbar_offset_from_row, ScrollMetrics}; use crate::ui::selection::Selection; use crate::ui::wrapping::{wrap_styled_line, WrapOptions}; use crate::utils::token_counter::StreamingTokenCounter; use ratatui::{ - crossterm::event::{MouseButton, MouseEvent, MouseEventKind}, + crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}, layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, + widgets::{Block, Paragraph, ScrollbarState}, Frame, }; use serde_json::Value as JsonValue; @@ -51,6 +52,8 @@ pub struct Chat { pub message_line_positions: Vec<usize>, /// Text selection state for copy-on-select pub selection: Selection, + /// Anchor that existed before the current mouse click started. + pending_click_anchor: Option<(usize, usize)>, /// Index of the message highlighted by timeline navigation (None = no highlight) pub highlighted_message_index: Option<usize>, /// Render cache — fingerprints content to skip expensive re-formatting @@ -103,6 +106,7 @@ impl Chat { streaming_message_idx: None, message_line_positions: Vec::new(), selection: Selection::new(), + pending_click_anchor: None, highlighted_message_index: None, cached_lines: Vec::new(), cached_positions: Vec::new(), @@ -136,6 +140,7 @@ impl Chat { streaming_message_idx: None, message_line_positions: Vec::new(), selection: Selection::new(), + pending_click_anchor: None, highlighted_message_index: None, cached_lines: Vec::new(), cached_positions: Vec::new(), @@ -267,7 +272,8 @@ impl Chat { self.streaming_pause_started_at = None; self.streaming_paused_duration = std::time::Duration::default(); self.streaming_token_counter = None; - self.selection.clear(); + self.selection.reset(); + self.pending_click_anchor = None; self.cached_lines.clear(); self.cached_fingerprint = 0; } @@ -648,6 +654,7 @@ impl Chat { // If dragging selection outside area, finalize it if self.selection.is_dragging { self.selection.finish(); + self.pending_click_anchor = None; // Copy will be handled by app.rs on mouse up } return false; @@ -695,12 +702,22 @@ impl Chat { self.scroll_to_position(event.row, scrollbar_area); true } else if is_in_content { - // Start text selection let content_line = (event.row.saturating_sub(rendered_content_area.y) as usize) .saturating_add(self.scroll_offset); let content_col = event.column.saturating_sub(rendered_content_area.x) as usize; - self.selection.start(content_line, content_col); - true + self.pending_click_anchor = self.selection.anchor; + + if event.modifiers.contains(KeyModifiers::SHIFT) + && self + .selection + .start_from_anchor_to(content_line, content_col) + { + true + } else { + // Start text selection and record this normal click as the anchor. + self.selection.start(content_line, content_col); + true + } } else { false } @@ -725,8 +742,28 @@ impl Chat { self.is_dragging_scrollbar = false; true } else if self.selection.is_dragging { + let ((s_line, s_col), (e_line, e_col)) = self.selection.range(); + let is_zero_width_click = s_line == e_line && s_col == e_col; + + if event.modifiers.contains(KeyModifiers::SHIFT) + && self.pending_click_anchor.is_some() + && is_zero_width_click + { + let content_line = (event.row.saturating_sub(rendered_content_area.y) + as usize) + .saturating_add(self.scroll_offset); + let content_col = + event.column.saturating_sub(rendered_content_area.x) as usize; + if let Some(anchor) = self.pending_click_anchor { + self.selection.anchor = Some(anchor); + self.selection + .start_from_anchor_to(content_line, content_col); + } + } + // Finalize text selection self.selection.finish(); + self.pending_click_anchor = None; // If selection is zero-width (click without drag), clear it let ((s_line, s_col), (e_line, e_col)) = self.selection.range(); if s_line == e_line && s_col == e_col { @@ -741,6 +778,7 @@ impl Chat { // Right-click clears selection if self.selection.active { self.selection.clear(); + self.pending_click_anchor = None; true } else { false @@ -755,14 +793,13 @@ impl Chat { return; } - let relative_y = row.saturating_sub(scrollbar_area.y) as usize; let max_offset = self.content_height.saturating_sub(self.viewport_height); - - let new_offset = if max_offset > 0 && scrollbar_area.height > 0 { - (relative_y * max_offset) / scrollbar_area.height as usize - } else { - 0 - }; + let metrics = ScrollMetrics::new( + self.content_height, + self.viewport_height, + self.scroll_offset, + ); + let new_offset = scrollbar_offset_from_row(metrics, scrollbar_area, row); self.scroll_offset = new_offset.min(max_offset); // Track if user scrolled away from bottom self.user_scrolled_up = self.scroll_offset < max_offset; @@ -918,14 +955,12 @@ impl Chat { height: area.height, }; - f.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .track_symbol(Some(" ")) - .thumb_symbol("█") - .begin_symbol(Some(" ")) - .end_symbol(Some(" ")), + render_scrollbar( + f, + ScrollMetrics::new(content_height, viewport, clamped_scroll), scrollbar_area, - &mut self.scrollbar_state, + colors.background_element, + colors.text_weak, ); } @@ -1841,6 +1876,8 @@ use ratatui::text::Text; #[cfg(test)] mod tests { use super::*; + use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use ratatui::layout::Rect; use ratatui::style::Color; fn test_colors() -> ThemeColors { @@ -1898,6 +1935,22 @@ mod tests { .collect::<String>() } + fn mouse(kind: MouseEventKind, column: u16, row: u16, modifiers: KeyModifiers) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers, + } + } + + fn chat_with_content_height(content_height: usize) -> Chat { + let mut chat = Chat::new(); + chat.content_height = content_height; + chat.viewport_height = 10; + chat + } + #[test] fn test_chat_new() { let chat = Chat::new(); @@ -2207,6 +2260,177 @@ mod tests { assert_eq!(chat.scroll_offset, 0); } + #[test] + fn test_plain_click_records_shift_selection_anchor() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + )); + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + )); + + assert!(!chat.selection.active); + assert!(!chat.selection.is_dragging); + assert_eq!(chat.selection.anchor, Some((2, 3))); + } + + #[test] + fn test_shift_click_selects_from_last_plain_click_anchor() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + ); + chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + ); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 8, + 5, + KeyModifiers::SHIFT, + ), + area, + )); + assert!(chat.selection.active); + assert!(chat.selection.is_dragging); + assert_eq!(chat.selection.anchor, Some((2, 3))); + assert_eq!(chat.selection.range(), ((2, 3), (5, 8))); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 8, + 5, + KeyModifiers::SHIFT, + ), + area, + )); + assert!(chat.selection.active); + assert!(!chat.selection.is_dragging); + assert_eq!(chat.selection.anchor, Some((2, 3))); + assert_eq!(chat.selection.range(), ((2, 3), (5, 8))); + } + + #[test] + fn test_shift_click_selects_when_shift_is_only_reported_on_mouse_up() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + ); + chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + ); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 8, + 5, + KeyModifiers::NONE, + ), + area, + )); + assert_eq!(chat.pending_click_anchor, Some((2, 3))); + assert_eq!(chat.selection.anchor, Some((5, 8))); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 8, + 5, + KeyModifiers::SHIFT, + ), + area, + )); + assert!(chat.selection.active); + assert!(!chat.selection.is_dragging); + assert_eq!(chat.selection.anchor, Some((2, 3))); + assert_eq!(chat.selection.range(), ((2, 3), (5, 8))); + } + + #[test] + fn test_shift_click_keeps_original_anchor_for_repeated_ranges() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 10, + 6, + KeyModifiers::NONE, + ), + area, + ); + chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 10, + 6, + KeyModifiers::NONE, + ), + area, + ); + + chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 2, + 4, + KeyModifiers::SHIFT, + ), + area, + ); + + assert_eq!(chat.selection.anchor, Some((6, 10))); + assert_eq!(chat.selection.range(), ((4, 2), (6, 10))); + } + #[test] fn test_chat_scroll_down() { let mut chat = Chat::new(); diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 7b25226..63523fe 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -1,4 +1,5 @@ use crate::theme::{contrast_text, ThemeColors}; +use crate::ui::scrollbar::{render_scrollbar, scrollbar_offset_from_row, ScrollMetrics}; use nucleo_matcher::{ pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, @@ -8,9 +9,9 @@ use ratatui::crossterm::event::{ }; use ratatui::{ prelude::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, - widgets::{Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + widgets::{Clear, Paragraph, ScrollbarState}, Frame, }; use std::collections::HashMap; @@ -672,14 +673,9 @@ impl Dialog { } let visible_rows = scrollbar_area.height as usize; - let relative_y = row.saturating_sub(scrollbar_area.y) as usize; let max_offset = total_lines.saturating_sub(visible_rows); - - let new_offset = if max_offset > 0 { - (relative_y * max_offset) / visible_rows - } else { - 0 - }; + let metrics = ScrollMetrics::new(total_lines, visible_rows, self.scroll_offset); + let new_offset = scrollbar_offset_from_row(metrics, scrollbar_area, row); self.scroll_offset = new_offset.min(max_offset); let flat_items = self.get_flat_items(); @@ -930,14 +926,22 @@ impl Dialog { Paragraph::new(content_lines).scroll((self.scroll_offset as u16, 0)); frame.render_widget(content_paragraph, list_content_area); - let scrollbar_area = chunks[3]; - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .track_symbol(Some(" ")) - .begin_symbol(Some(" ")) - .end_symbol(Some(" ")), + let scrollbar_area = Rect { + x: chunks[3].x + chunks[3].width.saturating_sub(1), + y: chunks[3].y, + width: 1, + height: chunks[3].height, + }; + render_scrollbar( + frame, + ScrollMetrics::new( + self.get_content_line_count(), + self.visible_row_count, + self.scroll_offset, + ), scrollbar_area, - &mut self.scrollbar_state, + colors.background_element, + colors.text_weak, ); let mut footer_spans = vec![]; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 485ea6e..5ea6228 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,5 +2,6 @@ pub mod components; pub mod diff; pub mod layout; pub mod markdown; +pub mod scrollbar; pub mod selection; pub mod wrapping; diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs new file mode 100644 index 0000000..8418560 --- /dev/null +++ b/src/ui/scrollbar.rs @@ -0,0 +1,179 @@ +use ratatui::{ + layout::Rect, + style::{Color, Style}, + Frame, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ScrollMetrics { + pub content_len: usize, + pub viewport_len: usize, + pub offset: usize, +} + +impl ScrollMetrics { + pub(crate) fn new(content_len: usize, viewport_len: usize, offset: usize) -> Self { + Self { + content_len, + viewport_len, + offset, + } + } + + pub(crate) fn max_offset(self) -> usize { + self.content_len.saturating_sub(self.viewport_len) + } + + fn should_show(self) -> bool { + self.viewport_len > 0 && self.max_offset() > 0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ScrollbarThumb { + pub top: u16, + pub len: u16, +} + +pub(crate) fn scrollbar_thumb(metrics: ScrollMetrics, track: Rect) -> Option<ScrollbarThumb> { + if !metrics.should_show() || track.width == 0 || track.height == 0 { + return None; + } + + let track_height = track.height as usize; + let content_len = metrics.content_len.max(metrics.viewport_len); + if content_len == 0 { + return None; + } + + let thumb_len = ((metrics.viewport_len * track_height) as f32 / content_len as f32) + .round() + .max(1.0) + .min(track_height as f32) as usize; + let max_thumb_top = track_height.saturating_sub(thumb_len); + let max_offset = metrics.max_offset(); + let offset = metrics.offset.min(max_offset); + let thumb_top = if max_thumb_top == 0 || max_offset == 0 { + 0 + } else { + ((offset * max_thumb_top) as f32 / max_offset as f32) + .round() + .clamp(0.0, max_thumb_top as f32) as usize + }; + + Some(ScrollbarThumb { + top: track.y + thumb_top as u16, + len: thumb_len as u16, + }) +} + +pub(crate) fn scrollbar_offset_from_row(metrics: ScrollMetrics, track: Rect, row: u16) -> usize { + let Some(thumb) = scrollbar_thumb(metrics, track) else { + return 0; + }; + + let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1)); + let row_offset = clamped_row.saturating_sub(track.y) as usize; + let thumb_center = (thumb.len as usize) / 2; + let desired_top = row_offset.saturating_sub(thumb_center); + scrollbar_offset_from_thumb_top(metrics, track, desired_top) +} + +fn scrollbar_offset_from_thumb_top(metrics: ScrollMetrics, track: Rect, thumb_top: usize) -> usize { + let max_offset = metrics.max_offset(); + if max_offset == 0 || track.height == 0 { + return 0; + } + + let thumb_len = scrollbar_thumb(metrics, track) + .map(|thumb| thumb.len as usize) + .unwrap_or(1); + let max_thumb_top = track.height as usize - thumb_len.min(track.height as usize); + if max_thumb_top == 0 { + return 0; + } + + let desired_top = thumb_top.min(max_thumb_top); + ((desired_top * max_offset) as f32 / max_thumb_top as f32).round() as usize +} + +pub(crate) fn render_scrollbar( + frame: &mut Frame, + metrics: ScrollMetrics, + track: Rect, + track_color: Color, + thumb_color: Color, +) { + let Some(thumb) = scrollbar_thumb(metrics, track) else { + return; + }; + + let buf = frame.buffer_mut(); + for y in track.y..track.y + track.height { + let cell = &mut buf[(track.x, y)]; + cell.set_symbol("▕"); + cell.set_style(Style::default().fg(track_color)); + } + for y in thumb.top..thumb.top + thumb.len { + let cell = &mut buf[(track.x, y)]; + cell.set_symbol("▐"); + cell.set_style(Style::default().fg(thumb_color)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{backend::TestBackend, style::Color, Terminal}; + + #[test] + fn scrollbar_stays_hidden_when_content_fits() { + let metrics = ScrollMetrics::new(5, 5, 0); + let track = Rect::new(9, 4, 1, 5); + + assert_eq!(scrollbar_thumb(metrics, track), None); + } + + #[test] + fn scrollbar_thumb_reaches_bottom_when_scrolled_to_bottom() { + let metrics = ScrollMetrics::new(25, 5, 20); + let track = Rect::new(9, 4, 1, 5); + + let thumb = scrollbar_thumb(metrics, track).expect("thumb"); + assert_eq!(thumb.top + thumb.len, track.y + track.height); + } + + #[test] + fn scrollbar_offset_mapping_hits_top_middle_and_bottom() { + let metrics = ScrollMetrics::new(25, 5, 0); + let track = Rect::new(9, 4, 1, 5); + + assert_eq!(scrollbar_offset_from_row(metrics, track, 4), 0); + assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10); + assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 20); + } + + #[test] + fn render_scrollbar_uses_thin_track_and_thumb_symbols() { + let backend = TestBackend::new(1, 5); + let mut terminal = Terminal::new(backend).expect("terminal"); + + terminal + .draw(|frame| { + render_scrollbar( + frame, + ScrollMetrics::new(25, 5, 0), + Rect::new(0, 0, 1, 5), + Color::Reset, + Color::Reset, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].symbol(), "▐"); + for y in 1..5 { + assert_eq!(buffer[(0, y)].symbol(), "▕"); + } + } +} diff --git a/src/ui/selection.rs b/src/ui/selection.rs index 304d918..43fc42a 100644 --- a/src/ui/selection.rs +++ b/src/ui/selection.rs @@ -18,6 +18,8 @@ pub struct Selection { pub end_col: usize, /// Whether the user is currently dragging to extend selection pub is_dragging: bool, + /// Last non-shift click position used as the anchor for shift-click selection + pub anchor: Option<(usize, usize)>, } impl Selection { @@ -31,6 +33,11 @@ impl Selection { self.is_dragging = false; } + /// Reset the selection and forget the click anchor + pub fn reset(&mut self) { + *self = Self::default(); + } + /// Start a new selection at the given rendered-content position pub fn start(&mut self, line: usize, col: usize) { self.active = true; @@ -39,6 +46,22 @@ impl Selection { self.start_col = col; self.end_line = line; self.end_col = col; + self.anchor = Some((line, col)); + } + + /// Start a new selection from the last non-shift click anchor. + pub fn start_from_anchor_to(&mut self, line: usize, col: usize) -> bool { + let Some((anchor_line, anchor_col)) = self.anchor else { + return false; + }; + + self.active = true; + self.is_dragging = true; + self.start_line = anchor_line; + self.start_col = anchor_col; + self.end_line = line; + self.end_col = col; + true } /// Extend selection to the given position during drag From eb40ebfc531b6b3731957095ec6038b3919947a3 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 18 May 2026 21:40:54 +0800 Subject: [PATCH 079/226] feat(sessions): switch to session on dialog click and fix popup scroll. Refactor sessions dialog mouse handling to return actionable events, enabling session switching on item click. Fix dialog item hit-testing to exclude group headers and padding. Keep popup selected item in view during scroll. --- _plans/__TODOS.md | 6 +- src/app.rs | 25 +++++++- src/ui/components/dialog.rs | 58 ++++++++++++++++++- src/ui/components/popup.rs | 48 ++++++++++++++- src/views/sessions_dialog.rs | 109 ++++++++++++++++++++++++++++++++++- 5 files changed, 234 insertions(+), 12 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index ac46a26..ab1d084 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -38,7 +38,7 @@ - [ ] Bug: theme is not persisted? Or is it by config? Just make theme be based on state now, no more config for it. -- [ ] Bug: skill loading on conflict. i.e. duplicate frontend-design skill. Warning: duplicate skill name 'frontend-design' (existing: /Users/carlo/.claude/skills/frontend-design/SKILL.md, duplicate: /Users/carlo/.config/opencode/skill/frontend-design/SKILL.md) +- [x] Bug: skill loading on conflict. i.e. duplicate frontend-design skill. Warning: duplicate skill name 'frontend-design' (existing: /Users/carlo/.claude/skills/frontend-design/SKILL.md, duplicate: /Users/carlo/.config/opencode/skill/frontend-design/SKILL.md) - [x] Bug: Timeline livescroll and actual chat UI consistency - make them the same. @@ -65,6 +65,6 @@ - [x] Better question handling for skipped (Skipped, if I didn't press enter. like when I do `arrow right` immediately) -- [ ] Scroll like herdr. Stuff I like: as thin, as tall (no arrows - currently ours also has no arrows but it was a hack, we just remove the arrows with "" chars so they still take a height. The one from herdr looks like it's a pure scrollbar thumb without arrows and thin enough that I like) +- [x] Scroll like herdr. Stuff I like: as thin, as tall (no arrows - currently ours also has no arrows but it was a hack, we just remove the arrows with "" chars so they still take a height. The one from herdr looks like it's a pure scrollbar thumb without arrows and thin enough that I like) -- [ ] Highlight enhancements, if I click 1 place, then shift+click another. Treat it like the highlight in the browser that doesn't need a drag. Whatever I last clicked (without shift+click), treat it as the anchor for the "select start", and then whatever I shift+click after, treat it as a "select end" and autohighlight that part. +- [x] Highlight enhancements, if I click 1 place, then shift+click another. Treat it like the highlight in the browser that doesn't need a drag. Whatever I last clicked (without shift+click), treat it as the anchor for the "select start", and then whatever I shift+click after, treat it as a "select end" and autohighlight that part. (not supported) diff --git a/src/app.rs b/src/app.rs index 62dab53..6271583 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1348,9 +1348,28 @@ impl App { } } } else if self.overlay_focus == OverlayFocus::SessionsDialog { - handle_sessions_dialog_mouse_event(&mut self.sessions_dialog_state, mouse); - if !self.sessions_dialog_state.dialog.is_visible() { - self.overlay_focus = OverlayFocus::None; + let action = handle_sessions_dialog_mouse_event(&mut self.sessions_dialog_state, mouse); + match action { + SessionsDialogAction::Select(id) => { + self.session_manager.switch_session(&id); + if let Some(session) = self.session_manager.get_session(&id) { + self.chat_state.chat.clear(); + for message in &session.messages { + self.chat_state.chat.add_message(message.clone()); + } + } + self.base_focus = BaseFocus::Chat; + self.sessions_dialog_state.dialog.hide(); + self.overlay_focus = OverlayFocus::None; + } + SessionsDialogAction::Close => { + self.overlay_focus = OverlayFocus::None; + } + _ => { + if !self.sessions_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + } } } else if self.overlay_focus == OverlayFocus::SkillsDialog { crate::views::skills_dialog::handle_skills_dialog_mouse_event( diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 63523fe..c208f6f 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -596,7 +596,7 @@ impl Dialog { self.scroll_to_position(event.row, scrollbar_area); true } else { - if let Some(item_index) = self.get_item_index_from_y(event.row, list_area) { + if let Some(item_index) = self.item_index_at_position(event.column, event.row) { self.selected_index = item_index; return true; } @@ -613,7 +613,7 @@ impl Dialog { } MouseEventKind::Moved => { if !is_on_scrollbar { - if let Some(item_index) = self.get_item_index_from_y(event.row, list_area) { + if let Some(item_index) = self.item_index_at_position(event.column, event.row) { if item_index != self.selected_index { self.selected_index = item_index; } @@ -633,6 +633,60 @@ impl Dialog { } } + pub fn item_index_at_position(&self, column: u16, row: u16) -> Option<usize> { + if !self.visible { + return None; + } + + use ratatui::layout::Position; + let point = Position::new(column, row); + + if !self.dialog_area.contains(point) { + return None; + } + + let padding = match self.position { + DialogPosition::Center => 3u16, + DialogPosition::Left | DialogPosition::Right => 1u16, + }; + let content_area = Rect { + x: self.dialog_area.x + padding, + y: self.dialog_area.y + padding, + width: self.dialog_area.width.saturating_sub(padding * 2), + height: self.dialog_area.height.saturating_sub(padding * 2), + }; + + if !content_area.contains(point) { + return None; + } + + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(3), + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(1), + ]) + .split(content_area); + + let list_area = chunks[3]; + let list_content_area = Rect { + x: list_area.x, + y: list_area.y, + width: list_area.width.saturating_sub(2), + height: list_area.height, + }; + + if !list_content_area.contains(point) { + return None; + } + + self.get_item_index_from_y(row, list_area) + } + fn get_item_index_from_y(&self, row: u16, list_area: Rect) -> Option<usize> { let relative_y = row.saturating_sub(list_area.y) as usize; let content_line = self.scroll_offset + relative_y; diff --git a/src/ui/components/popup.rs b/src/ui/components/popup.rs index dd642b2..e70a793 100644 --- a/src/ui/components/popup.rs +++ b/src/ui/components/popup.rs @@ -8,6 +8,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, List, ListItem}, Frame, }; +use std::ops::Range; const MAX_VISIBLE_ITEMS: usize = 8; const ITEM_HORIZONTAL_PADDING: usize = 1; @@ -65,6 +66,21 @@ impl Popup { self.suggestions.get(self.selected_index) } + fn visible_range(&self) -> Range<usize> { + let item_count = self.suggestions.len(); + if item_count == 0 { + return 0..0; + } + + let visible_count = item_count.min(MAX_VISIBLE_ITEMS); + let selected_index = self.selected_index.min(item_count.saturating_sub(1)); + let start = selected_index + .saturating_add(1) + .saturating_sub(visible_count); + + start..start + visible_count + } + pub fn handle_key_event(&mut self, event: KeyEvent) -> PopupAction { if !self.visible { return PopupAction::NotHandled; @@ -102,7 +118,8 @@ impl Popup { let popup_width = area.width; let item_width = popup_width.saturating_sub(2) as usize; - let popup_height = (self.suggestions.len() as u16).min(MAX_VISIBLE_ITEMS as u16) + 2; + let visible_range = self.visible_range(); + let popup_height = (visible_range.len() as u16) + 2; let popup_area = Rect { x: area.x, @@ -126,6 +143,8 @@ impl Popup { .suggestions .iter() .enumerate() + .skip(visible_range.start) + .take(visible_range.len()) .map(|(i, suggestion)| { let (bg_style, name_fg, desc_fg) = if i == self.selected_index { let fg = contrast_text(colors.primary); @@ -324,6 +343,33 @@ mod tests { assert_eq!(popup.get_selected().map(|s| s.name.as_str()), Some("item2")); } + #[test] + fn test_visible_range_keeps_selected_item_in_view() { + let mut popup = Popup::new(); + popup.set_suggestions( + (0..10) + .map(|i| Suggestion { + name: format!("item{}", i), + description: String::new(), + }) + .collect(), + ); + + assert_eq!(popup.visible_range(), 0..8); + + popup.selected_index = 8; + assert_eq!(popup.visible_range(), 1..9); + + popup.selected_index = 9; + assert_eq!(popup.visible_range(), 2..10); + } + + #[test] + fn test_visible_range_empty() { + let popup = Popup::new(); + assert_eq!(popup.visible_range(), 0..0); + } + #[test] fn test_empty_suggestions() { let mut popup = Popup::new(); diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index 8fd1e5a..9070eac 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -1,6 +1,8 @@ use crate::theme::ThemeColors; use crate::ui::components::dialog::{Dialog, DialogAction as FooterAction, DialogItem}; -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use ratatui::{layout::Rect, Frame}; #[derive(Debug)] @@ -151,8 +153,40 @@ pub fn handle_sessions_dialog_key_event( pub fn handle_sessions_dialog_mouse_event( dialog_state: &mut SessionsDialogState, event: MouseEvent, -) -> bool { - dialog_state.dialog.handle_mouse_event(event) +) -> SessionsDialogAction { + let was_visible = dialog_state.dialog.is_visible(); + let previous_index = dialog_state.dialog.selected_index; + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + dialog_state + .dialog + .item_index_at_position(event.column, event.row) + } else { + None + }; + + let handled = dialog_state.dialog.handle_mouse_event(event); + + if dialog_state.dialog.selected_index != previous_index { + dialog_state.pending_delete = None; + } + + if was_visible && !dialog_state.dialog.is_visible() { + dialog_state.pending_delete = None; + return SessionsDialogAction::Close; + } + + if clicked_item.is_some() { + dialog_state.pending_delete = None; + if let Some(selected) = dialog_state.dialog.get_selected() { + return SessionsDialogAction::Select(selected.id.clone()); + } + } + + if handled { + SessionsDialogAction::Handled + } else { + SessionsDialogAction::NotHandled + } } pub fn get_pending_delete(dialog_state: &mut SessionsDialogState) -> Option<String> { @@ -169,3 +203,72 @@ pub enum SessionsDialogAction { PendingDelete(String), Rename(String, String), } + +#[cfg(test)] +mod tests { + use super::*; + + fn session_item(id: &str, name: &str) -> DialogItem { + DialogItem { + id: id.to_string(), + name: name.to_string(), + group: "Today".to_string(), + description: String::new(), + tip: None, + provider_id: String::new(), + } + } + + fn left_click(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn mouse_click_on_item_selects_session() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item("session-1", "First session"), + session_item("session-2", "Second session"), + ], + ); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 10)); + + assert_eq!( + action, + SessionsDialogAction::Select("session-2".to_string()) + ); + assert_eq!(state.dialog.selected_index, 1); + } + + #[test] + fn mouse_click_on_group_header_does_not_select_session() { + let mut state = + init_sessions_dialog("Sessions", vec![session_item("session-1", "First session")]); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 8)); + + assert_eq!(action, SessionsDialogAction::NotHandled); + assert_eq!(state.dialog.selected_index, 0); + } +} From 17119b9d057d0ee69da27ba53112138d2eda0e21 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 18 May 2026 21:58:47 +0800 Subject: [PATCH 080/226] feat: allow command submission during streaming; refactor dialog actions and better scrollbar drag. - Replace blanket streaming blocks with `can_submit_input()` so commands can still run while messages wait - Extract `copy_session_transcript()` and wire it into both inline `/copy` and hotkey paths - Refactor models, themes, and timeline dialog mouse handlers to return typed action enums with side-effects (select, preview, favorite, close) instead of raw bools/indices - Improve scrollbar dragging with grab-offset tracking and out-of-area continuation for both Chat and Dialog components - Include theme colors in chat render fingerprint to invalidate cache on theme switch - Add `skills` command handler stub --- _plans/__TODOS.md | 4 +- src/app.rs | 304 +++++++++++++++++++++++------------ src/theme.rs | 2 +- src/ui/components/chat.rs | 152 ++++++++++++++++-- src/ui/components/dialog.rs | 139 +++++++++++++--- src/ui/scrollbar.rs | 60 ++++++- src/views/models_dialog.rs | 105 +++++++++++- src/views/themes_dialog.rs | 125 +++++++++++++- src/views/timeline_dialog.rs | 80 ++++++++- 9 files changed, 815 insertions(+), 156 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index ab1d084..bcff94b 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -53,9 +53,9 @@ - [ ] todowrite - better looking, like opencode does. - [ ] rendering subagents - just like opencode, clickable to go into their page.. OR I can do `ctrl-x ↓` to go into it if there's a subagent running. I can also switch between subagents with `←` and `→` -- [ ] Fix: Chat content colors. Currently no matter what theme I use, the color of the chat especially the main text colors in markdown, are the default theme colors that were set during start time - meaning at config. Whatever I change via `/themes` dialog, it doesn't update the chat colors themes. +- [x] Fix: Chat content colors. Currently no matter what theme I use, the color of the chat especially the main text colors in markdown, are the default theme colors that were set during start time - meaning at config. Whatever I change via `/themes` dialog, it doesn't update the chat colors themes. -- [ ] Bug: I can type a command see autosuggest, but can't press 'enter' to run the command. Pls fix. +- [x] Bug: I can type a command see autosuggest, but can't press 'enter' to run the command. Pls fix. - [x] A single AI response, is considered 1 message. So combine all its parts into a single message record. Not that every message part becomes a separate message in the timeline dialog. diff --git a/src/app.rs b/src/app.rs index 6271583..7ad09fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1173,9 +1173,6 @@ impl App { } KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { if self.overlay_focus == OverlayFocus::SuggestionsPopup { - if self.is_streaming { - return true; - } self.autocomplete_and_submit(); true } else { @@ -1193,14 +1190,16 @@ impl App { match key.code { KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { - if self.is_streaming { - return; - } let input_text = self.input.get_text(); if !input_text.is_empty() { use crate::command::parser::parse_input; - match parse_input(&input_text) { + let input_type = parse_input(&input_text); + if !Self::can_submit_input(&input_type, self.is_streaming) { + return; + } + + match input_type { crate::command::parser::InputType::Command(parsed) => { // Don't save commands to prompt history tokio::task::block_in_place(|| { @@ -1216,7 +1215,7 @@ impl App { } self.input.clear(); - clear_suggestions(&mut self.suggestions_popup_state); + self.clear_suggestions_and_blur(); } } _ => { @@ -1226,6 +1225,10 @@ impl App { } } + fn can_submit_input(input_type: &InputType<'_>, is_streaming: bool) -> bool { + matches!(input_type, InputType::Command(_)) || !is_streaming + } + fn update_suggestions(&mut self) { if self.input.should_show_suggestions() { let suggestions = self @@ -1271,7 +1274,56 @@ impl App { } if self.overlay_focus == OverlayFocus::ModelsDialog { - handle_models_dialog_mouse_event(&mut self.models_dialog_state, mouse); + let action = handle_models_dialog_mouse_event(&mut self.models_dialog_state, mouse); + match action { + crate::views::models_dialog::ModelsDialogAction::SelectModel { + provider_id, + model_id, + } => { + let model_id_clone = model_id.clone(); + let provider_id_clone = provider_id.clone(); + self.model = model_id_clone.clone(); + self.provider_name = provider_id_clone; + + if let Some(ref dao) = self.prefs_dao { + if let Err(e) = + dao.set_active_model(provider_id.clone(), model_id_clone.clone()) + { + eprintln!("Failed to save active model: {}", e); + } + } + + push_toast(Toast::new( + format!("Switched to: {}", model_id_clone), + ToastLevel::Info, + None, + )); + } + crate::views::models_dialog::ModelsDialogAction::ToggleFavorite { + provider_id, + model_id, + } => { + let is_favorite = if let Some(ref dao) = self.prefs_dao { + dao.toggle_favorite(provider_id.clone(), model_id.clone()) + .unwrap_or(false) + } else { + false + }; + + push_toast(Toast::new( + if is_favorite { + "Added to favorites" + } else { + "Removed from favorites" + }, + ToastLevel::Info, + None, + )); + + self.refresh_models_dialog(); + } + crate::views::models_dialog::ModelsDialogAction::None => {} + } if !self.models_dialog_state.dialog.is_visible() { self.overlay_focus = OverlayFocus::None; } @@ -1280,30 +1332,10 @@ impl App { } else if self.overlay_focus == OverlayFocus::QuestionDialog { let _ = handle_question_dialog_mouse_event(&mut self.question_dialog_state, mouse); } else if self.overlay_focus == OverlayFocus::ThemesDialog { - let before = self - .themes_dialog_state - .dialog - .get_selected() - .map(|it| it.id.clone()); + let action = handle_themes_dialog_mouse_event(&mut self.themes_dialog_state, mouse); - handle_themes_dialog_mouse_event(&mut self.themes_dialog_state, mouse); - - if !self.themes_dialog_state.dialog.is_visible() { - if !self.themes_dialog_committed { - self.current_theme_index = self.themes_dialog_original_theme_index; - } - self.overlay_focus = OverlayFocus::None; - return; - } - - let after = self - .themes_dialog_state - .dialog - .get_selected() - .map(|it| it.id.clone()); - - if before != after { - if let Some(theme_id) = after { + match action { + crate::views::themes_dialog::ThemesDialogAction::PreviewTheme { theme_id } => { if let Some((idx, _)) = self .themes .iter() @@ -1313,6 +1345,31 @@ impl App { self.current_theme_index = idx; } } + crate::views::themes_dialog::ThemesDialogAction::SelectTheme { theme_id } => { + if let Some((idx, theme)) = self + .themes + .iter() + .enumerate() + .find(|(_, t)| t.id == theme_id) + { + self.current_theme_index = idx; + self.themes_dialog_committed = true; + push_toast(Toast::new( + format!("Theme: {}", theme.id), + ToastLevel::Info, + None, + )); + } + } + crate::views::themes_dialog::ThemesDialogAction::None => {} + } + + if !self.themes_dialog_state.dialog.is_visible() { + if !self.themes_dialog_committed { + self.current_theme_index = self.themes_dialog_original_theme_index; + } + self.overlay_focus = OverlayFocus::None; + return; } } else if self.overlay_focus == OverlayFocus::ConnectDialog { handle_connect_dialog_mouse_event(&mut self.connect_dialog_state, mouse); @@ -1380,12 +1437,26 @@ impl App { self.overlay_focus = OverlayFocus::None; } } else if self.overlay_focus == OverlayFocus::TimelineDialog { - if let Some(idx) = crate::views::timeline_dialog::handle_timeline_dialog_mouse_event( + let action = crate::views::timeline_dialog::handle_timeline_dialog_mouse_event( &mut self.timeline_dialog_state, mouse, - ) { - self.chat_state.chat.scroll_to_message_index(idx); - self.chat_state.chat.set_highlighted_message(Some(idx)); + ); + match action { + crate::views::timeline_dialog::TimelineDialogAction::Close => { + self.chat_state.chat.clear_highlighted_message(); + self.overlay_focus = OverlayFocus::None; + } + crate::views::timeline_dialog::TimelineDialogAction::Select(idx) => { + self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); + self.show_message_actions(idx); + } + crate::views::timeline_dialog::TimelineDialogAction::Navigate(idx) => { + self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); + } + crate::views::timeline_dialog::TimelineDialogAction::Handled + | crate::views::timeline_dialog::TimelineDialogAction::NotHandled => {} } if !self.timeline_dialog_state.dialog.is_visible() { self.chat_state.chat.clear_highlighted_message(); @@ -1642,9 +1713,6 @@ impl App { } fn autocomplete_and_submit(&mut self) { - if self.is_streaming { - return; - } if let Some(selected) = get_selected_suggestion(&self.suggestions_popup_state) { let command = format!("/{}", selected.name); @@ -1655,7 +1723,73 @@ impl App { self.input.clear(); } + self.clear_suggestions_and_blur(); + } + + fn clear_suggestions_and_blur(&mut self) { clear_suggestions(&mut self.suggestions_popup_state); + if self.overlay_focus == OverlayFocus::SuggestionsPopup { + self.overlay_focus = OverlayFocus::None; + } + } + + fn copy_session_transcript(&mut self) { + let messages = &self.chat_state.chat.messages; + let session_title = self + .session_manager + .get_current_session() + .map(|s| s.title.clone()) + .unwrap_or_else(|| "Untitled".to_string()); + let mut transcript = format!("# {}\n\n", session_title); + for msg in messages { + match msg.role { + crate::session::types::MessageRole::User => { + transcript.push_str("## User\n\n"); + transcript.push_str(&msg.content); + transcript.push_str("\n\n---\n\n"); + } + crate::session::types::MessageRole::Assistant => { + let agent = msg.agent_mode.as_ref().map_or("Build", |a| a.as_str()); + let model = msg.model.as_deref().unwrap_or("unknown"); + let duration = msg + .duration_ms + .map(|ms| format!(" · {:.1}s", ms as f64 / 1000.0)) + .unwrap_or_default(); + transcript.push_str(&format!("## Assistant ({agent} · {model}{duration})\n\n")); + transcript.push_str(&msg.content); + transcript.push_str("\n\n---\n\n"); + } + crate::session::types::MessageRole::Tool => { + transcript.push_str("**Tool Result**\n\n"); + if let Ok(v) = serde_json::from_str::<serde_json::Value>(&msg.content) { + if let Some(name) = v.get("name").and_then(|n| n.as_str()) { + transcript.push_str(&format!("**Tool:** {}\n", name)); + } + if let Some(preview) = v.get("output_preview").and_then(|p| p.as_str()) { + transcript.push_str(&format!("```\n{}\n```\n", preview)); + } + } + transcript.push_str("\n---\n\n"); + } + _ => {} + } + } + match crate::utils::clipboard::copy_text(&transcript) { + Ok(_) => { + push_toast(Toast::new( + "Session transcript copied to clipboard!", + ToastLevel::Info, + None, + )); + } + Err(e) => { + push_toast(Toast::new( + format!("Failed to copy: {}", e), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + } } async fn process_input(&mut self, input: &str) { @@ -1664,68 +1798,7 @@ impl App { match parse_input(input) { InputType::Command(mut parsed) => { if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { - let messages = &self.chat_state.chat.messages; - let session_title = self - .session_manager - .get_current_session() - .map(|s| s.title.clone()) - .unwrap_or_else(|| "Untitled".to_string()); - let mut transcript = format!("# {}\n\n", session_title); - for msg in messages { - match msg.role { - crate::session::types::MessageRole::User => { - transcript.push_str("## User\n\n"); - transcript.push_str(&msg.content); - transcript.push_str("\n\n---\n\n"); - } - crate::session::types::MessageRole::Assistant => { - let agent = msg.agent_mode.as_ref().map_or("Build", |a| a.as_str()); - let model = msg.model.as_deref().unwrap_or("unknown"); - let duration = msg - .duration_ms - .map(|ms| format!(" · {:.1}s", ms as f64 / 1000.0)) - .unwrap_or_default(); - transcript.push_str(&format!( - "## Assistant ({agent} · {model}{duration})\n\n" - )); - transcript.push_str(&msg.content); - transcript.push_str("\n\n---\n\n"); - } - crate::session::types::MessageRole::Tool => { - transcript.push_str("**Tool Result**\n\n"); - if let Ok(v) = - serde_json::from_str::<serde_json::Value>(&msg.content) - { - if let Some(name) = v.get("name").and_then(|n| n.as_str()) { - transcript.push_str(&format!("**Tool:** {}\n", name)); - } - if let Some(preview) = - v.get("output_preview").and_then(|p| p.as_str()) - { - transcript.push_str(&format!("```\n{}\n```\n", preview)); - } - } - transcript.push_str("\n---\n\n"); - } - _ => {} - } - } - match crate::utils::clipboard::copy_text(&transcript) { - Ok(_) => { - push_toast(Toast::new( - "Session transcript copied to clipboard!", - ToastLevel::Info, - None, - )); - } - Err(e) => { - push_toast(Toast::new( - format!("Failed to copy: {}", e), - ToastLevel::Error, - Some(std::time::Duration::from_secs(3)), - )); - } - } + self.copy_session_transcript(); return; } if parsed.name == "themes" { @@ -1867,10 +1940,18 @@ impl App { &mut self, mut parsed: crate::command::parser::ParsedCommand<'_>, ) { + if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { + self.copy_session_transcript(); + return; + } if parsed.name == "themes" { self.show_themes_dialog(); return; } + if parsed.name == "skills" { + self.show_skills_dialog(); + return; + } if parsed.name == "rename" && parsed.args.is_empty() && self.base_focus == BaseFocus::Chat { if let Some(session) = self.session_manager.get_current_session() { let id = session.id.clone(); @@ -3412,3 +3493,24 @@ impl Default for App { Self::new().expect("Failed to initialize App") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::parser::parse_input; + + #[test] + fn commands_can_submit_while_streaming() { + let input_type = parse_input("/models"); + + assert!(App::can_submit_input(&input_type, true)); + } + + #[test] + fn messages_wait_until_streaming_finishes() { + let input_type = parse_input("send another prompt"); + + assert!(!App::can_submit_input(&input_type, true)); + assert!(App::can_submit_input(&input_type, false)); + } +} diff --git a/src/theme.rs b/src/theme.rs index 1a72c18..8a2258e 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::fs; use std::path::Path; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ThemeColors { pub primary: ratatui::style::Color, pub secondary: ratatui::style::Color, diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 5cc7780..4475078 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1,7 +1,9 @@ use crate::session::types::{Message, MessageRole}; use crate::theme::{contrast_text, ThemeColors}; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; -use crate::ui::scrollbar::{render_scrollbar, scrollbar_offset_from_row, ScrollMetrics}; +use crate::ui::scrollbar::{ + render_scrollbar, scrollbar_grab_offset, scrollbar_offset_from_row_with_grab, ScrollMetrics, +}; use crate::ui::selection::Selection; use crate::ui::wrapping::{wrap_styled_line, WrapOptions}; use crate::utils::token_counter::StreamingTokenCounter; @@ -22,6 +24,7 @@ pub struct Chat { pub scroll_offset: usize, pub scrollbar_state: ScrollbarState, pub is_dragging_scrollbar: bool, + scrollbar_drag_offset: Option<u16>, pub content_height: usize, pub viewport_height: usize, // Streaming metrics tracking (per streaming turn) @@ -86,6 +89,7 @@ impl Chat { scroll_offset: 0, scrollbar_state: ScrollbarState::default(), is_dragging_scrollbar: false, + scrollbar_drag_offset: None, content_height: 0, viewport_height: 0, streaming_start_time: None, @@ -120,6 +124,7 @@ impl Chat { scroll_offset: 0, scrollbar_state: ScrollbarState::default(), is_dragging_scrollbar: false, + scrollbar_drag_offset: None, content_height: 0, viewport_height: 0, streaming_start_time: None, @@ -261,6 +266,8 @@ impl Chat { self.messages.clear(); self.scroll_offset = 0; self.scrollbar_state = ScrollbarState::default(); + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; self.content_height = 0; self.streaming_start_time = None; self.streaming_first_token_time = None; @@ -282,12 +289,13 @@ impl Chat { self.cached_fingerprint = 0; } - fn compute_fingerprint(&self, max_width: usize) -> u64 { + fn compute_fingerprint(&self, max_width: usize, colors: &ThemeColors) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // Bump this whenever rendering logic changes (tables, markdown, etc.) const RENDER_VERSION: u64 = 3; RENDER_VERSION.hash(&mut h); + colors.hash(&mut h); self.messages.len().hash(&mut h); for msg in &self.messages { std::mem::discriminant(&msg.role).hash(&mut h); @@ -649,8 +657,31 @@ impl Chat { use ratatui::layout::Position; let point = Position::new(event.column, event.row); + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y, + width: 1, + height: area.height, + }; + + if self.is_dragging_scrollbar { + match event.kind { + MouseEventKind::Drag(MouseButton::Left) => { + self.scroll_to_position(event.row, scrollbar_area); + return true; + } + MouseEventKind::Up(_) => { + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; + return true; + } + _ => {} + } + } + if !area.contains(point) { self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; // If dragging selection outside area, finalize it if self.selection.is_dragging { self.selection.finish(); @@ -676,14 +707,6 @@ impl Chat { height: content_area.height.saturating_sub(visual_y_offset), }; - // Calculate scrollbar area (rightmost column) - let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(1), - y: area.y, - width: 1, - height: area.height, - }; - let is_on_scrollbar = scrollbar_area.contains(point); let is_in_content = rendered_content_area.contains(point); @@ -698,9 +721,21 @@ impl Chat { } MouseEventKind::Down(MouseButton::Left) => { if is_on_scrollbar { - self.is_dragging_scrollbar = true; - self.scroll_to_position(event.row, scrollbar_area); - true + let metrics = ScrollMetrics::new( + self.content_height, + self.viewport_height, + self.scroll_offset, + ); + if let Some(grab_offset) = + scrollbar_grab_offset(metrics, scrollbar_area, event.row) + { + self.is_dragging_scrollbar = true; + self.scrollbar_drag_offset = Some(grab_offset); + self.scroll_to_position(event.row, scrollbar_area); + true + } else { + false + } } else if is_in_content { let content_line = (event.row.saturating_sub(rendered_content_area.y) as usize) .saturating_add(self.scroll_offset); @@ -740,6 +775,7 @@ impl Chat { MouseEventKind::Up(MouseButton::Left) => { if self.is_dragging_scrollbar { self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; true } else if self.selection.is_dragging { let ((s_line, s_col), (e_line, e_col)) = self.selection.range(); @@ -799,7 +835,12 @@ impl Chat { self.viewport_height, self.scroll_offset, ); - let new_offset = scrollbar_offset_from_row(metrics, scrollbar_area, row); + let grab_offset = self + .scrollbar_drag_offset + .or_else(|| scrollbar_grab_offset(metrics, scrollbar_area, row)) + .unwrap_or(0); + let new_offset = + scrollbar_offset_from_row_with_grab(metrics, scrollbar_area, row, grab_offset); self.scroll_offset = new_offset.min(max_offset); // Track if user scrolled away from bottom self.user_scrolled_up = self.scroll_offset < max_offset; @@ -827,7 +868,7 @@ impl Chat { let max_width = content_area.width as usize; - let fingerprint = self.compute_fingerprint(max_width); + let fingerprint = self.compute_fingerprint(max_width, colors); let cache_valid = !self.cached_lines.is_empty() && fingerprint == self.cached_fingerprint; let mut positions: Vec<usize>; @@ -2022,10 +2063,26 @@ mod tests { fn test_render_fingerprint_changes_for_same_length_content_mutation() { let mut chat = Chat::new(); chat.add_assistant_message("abcd"); + let colors = test_colors(); - let before = chat.compute_fingerprint(80); + let before = chat.compute_fingerprint(80, &colors); chat.messages[0].content = "wxyz".to_string(); - let after = chat.compute_fingerprint(80); + let after = chat.compute_fingerprint(80, &colors); + + assert_ne!(before, after); + } + + #[test] + fn test_render_fingerprint_changes_when_theme_changes() { + let mut chat = Chat::new(); + chat.add_assistant_message("plain markdown text"); + let mut first = test_colors(); + first.markdown_text = Color::Rgb(10, 20, 30); + let mut second = first; + second.markdown_text = Color::Rgb(200, 210, 220); + + let before = chat.compute_fingerprint(80, &first); + let after = chat.compute_fingerprint(80, &second); assert_ne!(before, after); } @@ -2464,6 +2521,67 @@ mod tests { assert_eq!(chat.scroll_offset, 80); } + #[test] + fn test_chat_scrollbar_drag_continues_outside_area() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 39, + 0, + KeyModifiers::NONE, + ), + area, + )); + assert!(chat.is_dragging_scrollbar); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Drag(MouseButton::Left), + 80, + 9, + KeyModifiers::NONE, + ), + area, + )); + assert_eq!(chat.scroll_offset, 90); + assert!(chat.is_dragging_scrollbar); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 80, + 9, + KeyModifiers::NONE, + ), + area, + )); + assert!(!chat.is_dragging_scrollbar); + assert_eq!(chat.scrollbar_drag_offset, None); + } + + #[test] + fn test_chat_scrollbar_thumb_click_preserves_grab_point() { + let mut chat = chat_with_content_height(30); + chat.scroll_offset = 6; + let area = Rect::new(0, 0, 40, 10); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 39, + 4, + KeyModifiers::NONE, + ), + area, + )); + + assert_eq!(chat.scroll_offset, 6); + assert_eq!(chat.scrollbar_drag_offset, Some(2)); + } + #[test] fn test_chat_scroll_to_bottom_after_add() { let mut chat = Chat::new(); diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index c208f6f..1a2662d 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -1,5 +1,7 @@ use crate::theme::{contrast_text, ThemeColors}; -use crate::ui::scrollbar::{render_scrollbar, scrollbar_offset_from_row, ScrollMetrics}; +use crate::ui::scrollbar::{ + render_scrollbar, scrollbar_grab_offset, scrollbar_offset_from_row_with_grab, ScrollMetrics, +}; use nucleo_matcher::{ pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, @@ -70,6 +72,7 @@ pub struct Dialog { pub search_textarea: TextArea<'static>, pub scrollbar_state: ScrollbarState, pub is_dragging_scrollbar: bool, + scrollbar_drag_offset: Option<u16>, pub visible_row_count: usize, pub actions: Vec<DialogAction>, pub position: DialogPosition, @@ -101,6 +104,7 @@ impl Dialog { search_textarea, scrollbar_state: ScrollbarState::default(), is_dragging_scrollbar: false, + scrollbar_drag_offset: None, visible_row_count: 0, actions: Vec::new(), position: DialogPosition::Center, @@ -183,6 +187,8 @@ impl Dialog { self.search_query.clear(); self.search_textarea = TextArea::default(); self.search_textarea.set_placeholder_text("Search"); + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; } pub fn toggle(&mut self) { @@ -535,14 +541,6 @@ impl Dialog { use ratatui::layout::Position; let point = Position::new(event.column, event.row); - if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) - && !self.dialog_area.contains(point) - { - self.hide(); - self.is_dragging_scrollbar = false; - return true; - } - let padding = match self.position { DialogPosition::Center => 3u16, DialogPosition::Left | DialogPosition::Right => 1u16, @@ -554,11 +552,6 @@ impl Dialog { height: self.dialog_area.height.saturating_sub(padding * 2), }; - if !content_area.contains(point) { - self.is_dragging_scrollbar = false; - return false; - } - let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ @@ -573,12 +566,40 @@ impl Dialog { let list_area = chunks[3]; let scrollbar_area = Rect { - x: list_area.x + list_area.width - 1, + x: list_area.x + list_area.width.saturating_sub(1), y: list_area.y, width: 1, height: list_area.height, }; + if self.is_dragging_scrollbar { + match event.kind { + MouseEventKind::Drag(MouseButton::Left) => { + self.scroll_to_position(event.row, scrollbar_area); + return true; + } + MouseEventKind::Up(_) => { + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; + return true; + } + _ => {} + } + } + + if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) + && !self.dialog_area.contains(point) + { + self.hide(); + return true; + } + + if !content_area.contains(point) { + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; + return false; + } + let is_on_scrollbar = scrollbar_area.contains(point); match event.kind { @@ -592,9 +613,19 @@ impl Dialog { } MouseEventKind::Down(MouseButton::Left) => { if is_on_scrollbar { - self.is_dragging_scrollbar = true; - self.scroll_to_position(event.row, scrollbar_area); - true + let total_lines = self.get_content_line_count(); + let visible_rows = scrollbar_area.height as usize; + let metrics = ScrollMetrics::new(total_lines, visible_rows, self.scroll_offset); + if let Some(grab_offset) = + scrollbar_grab_offset(metrics, scrollbar_area, event.row) + { + self.is_dragging_scrollbar = true; + self.scrollbar_drag_offset = Some(grab_offset); + self.scroll_to_position(event.row, scrollbar_area); + true + } else { + false + } } else { if let Some(item_index) = self.item_index_at_position(event.column, event.row) { self.selected_index = item_index; @@ -624,6 +655,7 @@ impl Dialog { MouseEventKind::Up(_) => { if self.is_dragging_scrollbar { self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; true } else { false @@ -729,7 +761,12 @@ impl Dialog { let visible_rows = scrollbar_area.height as usize; let max_offset = total_lines.saturating_sub(visible_rows); let metrics = ScrollMetrics::new(total_lines, visible_rows, self.scroll_offset); - let new_offset = scrollbar_offset_from_row(metrics, scrollbar_area, row); + let grab_offset = self + .scrollbar_drag_offset + .or_else(|| scrollbar_grab_offset(metrics, scrollbar_area, row)) + .unwrap_or(0); + let new_offset = + scrollbar_offset_from_row_with_grab(metrics, scrollbar_area, row, grab_offset); self.scroll_offset = new_offset.min(max_offset); let flat_items = self.get_flat_items(); @@ -1053,6 +1090,7 @@ impl Clone for Dialog { search_textarea: self.search_textarea.clone(), scrollbar_state: self.scrollbar_state, is_dragging_scrollbar: self.is_dragging_scrollbar, + scrollbar_drag_offset: self.scrollbar_drag_offset, visible_row_count: self.visible_row_count, actions: self.actions.clone(), position: self.position, @@ -1095,6 +1133,19 @@ mod tests { ] } + fn create_many_test_items(count: usize) -> Vec<DialogItem> { + (0..count) + .map(|idx| DialogItem { + id: idx.to_string(), + name: format!("Model {}", idx), + group: "Group".to_string(), + description: "".to_string(), + tip: None, + provider_id: "p".to_string(), + }) + .collect() + } + fn create_fuzzy_test_items() -> Vec<DialogItem> { vec![ DialogItem { @@ -1213,6 +1264,56 @@ mod tests { assert!(dialog.is_visible()); } + #[test] + fn test_dialog_scrollbar_drag_continues_outside_content_area() { + let mut dialog = Dialog::with_items("Models", create_many_test_items(40)); + dialog.show(); + dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 40, + height: 20, + }; + dialog.visible_row_count = 7; + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 36, + row: 8, + modifiers: KeyModifiers::NONE, + }); + + assert!(handled); + assert!(dialog.is_dragging_scrollbar); + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 80, + row: 14, + modifiers: KeyModifiers::NONE, + }); + + assert!(handled); + assert!(dialog.is_dragging_scrollbar); + assert_eq!( + dialog.scroll_offset, + dialog + .get_content_line_count() + .saturating_sub(dialog.get_visible_row_count()) + ); + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 80, + row: 14, + modifiers: KeyModifiers::NONE, + }); + + assert!(handled); + assert!(!dialog.is_dragging_scrollbar); + assert_eq!(dialog.scrollbar_drag_offset, None); + } + #[test] fn test_dialog_toggle() { let mut dialog = Dialog::new("Test"); diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs index 8418560..c9c4a23 100644 --- a/src/ui/scrollbar.rs +++ b/src/ui/scrollbar.rs @@ -72,10 +72,38 @@ pub(crate) fn scrollbar_offset_from_row(metrics: ScrollMetrics, track: Rect, row return 0; }; - let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1)); + scrollbar_offset_from_row_with_grab(metrics, track, row, thumb.len / 2) +} + +pub(crate) fn scrollbar_grab_offset(metrics: ScrollMetrics, track: Rect, row: u16) -> Option<u16> { + let thumb = scrollbar_thumb(metrics, track)?; + let clamped_row = row.clamp( + track.y, + track.y.saturating_add(track.height.saturating_sub(1)), + ); + if clamped_row >= thumb.top && clamped_row < thumb.top.saturating_add(thumb.len) { + Some(clamped_row.saturating_sub(thumb.top)) + } else { + Some(thumb.len / 2) + } +} + +pub(crate) fn scrollbar_offset_from_row_with_grab( + metrics: ScrollMetrics, + track: Rect, + row: u16, + grab_offset: u16, +) -> usize { + if scrollbar_thumb(metrics, track).is_none() { + return 0; + } + + let clamped_row = row.clamp( + track.y, + track.y.saturating_add(track.height.saturating_sub(1)), + ); let row_offset = clamped_row.saturating_sub(track.y) as usize; - let thumb_center = (thumb.len as usize) / 2; - let desired_top = row_offset.saturating_sub(thumb_center); + let desired_top = row_offset.saturating_sub(grab_offset as usize); scrollbar_offset_from_thumb_top(metrics, track, desired_top) } @@ -153,6 +181,32 @@ mod tests { assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 20); } + #[test] + fn scrollbar_drag_preserves_grab_point_inside_thumb() { + let metrics = ScrollMetrics::new(30, 10, 6); + let track = Rect::new(9, 0, 1, 10); + let thumb = scrollbar_thumb(metrics, track).expect("thumb"); + let row = thumb.top + thumb.len - 1; + let grab_offset = scrollbar_grab_offset(metrics, track, row).expect("grab"); + + assert_eq!(grab_offset, thumb.len - 1); + assert_eq!( + scrollbar_offset_from_row_with_grab(metrics, track, row, grab_offset), + 6 + ); + } + + #[test] + fn scrollbar_drag_clamps_after_pointer_leaves_track_vertically() { + let metrics = ScrollMetrics::new(100, 10, 0); + let track = Rect::new(9, 5, 1, 10); + + assert_eq!( + scrollbar_offset_from_row_with_grab(metrics, track, 100, 0), + 90 + ); + } + #[test] fn render_scrollbar_uses_thin_track_and_thumb_symbols() { let backend = TestBackend::new(1, 5); diff --git a/src/views/models_dialog.rs b/src/views/models_dialog.rs index 2993d95..45eff74 100644 --- a/src/views/models_dialog.rs +++ b/src/views/models_dialog.rs @@ -1,4 +1,6 @@ -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use ratatui::{layout::Rect, Frame}; use crate::theme::ThemeColors; @@ -119,6 +121,103 @@ pub fn handle_models_dialog_key_event( pub fn handle_models_dialog_mouse_event( dialog_state: &mut ModelsDialogState, event: MouseEvent, -) -> bool { - dialog_state.dialog.handle_mouse_event(event) +) -> ModelsDialogAction { + if !dialog_state.dialog.is_visible() { + return ModelsDialogAction::None; + } + + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + dialog_state + .dialog + .item_index_at_position(event.column, event.row) + } else { + None + }; + + dialog_state.dialog.handle_mouse_event(event); + + if clicked_item.is_some() && dialog_state.dialog.is_visible() { + if let Some(selected) = dialog_state.dialog.get_selected() { + let provider_id = selected.provider_id.clone(); + let model_id = selected.id.clone(); + dialog_state.dialog.hide(); + return ModelsDialogAction::SelectModel { + provider_id, + model_id, + }; + } + } + + ModelsDialogAction::None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn model_item(id: &str, name: &str, provider_id: &str) -> DialogItem { + DialogItem { + id: id.to_string(), + name: name.to_string(), + group: "OpenAI".to_string(), + description: String::new(), + tip: None, + provider_id: provider_id.to_string(), + } + } + + fn left_click(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn mouse_click_on_item_selects_model() { + let mut state = init_models_dialog( + "Models", + vec![ + model_item("gpt-5", "GPT-5", "openai"), + model_item("claude-sonnet", "Claude Sonnet", "anthropic"), + ], + ); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_models_dialog_mouse_event(&mut state, left_click(4, 10)); + + assert_eq!( + action, + ModelsDialogAction::SelectModel { + provider_id: "anthropic".to_string(), + model_id: "claude-sonnet".to_string(), + } + ); + assert!(!state.dialog.is_visible()); + } + + #[test] + fn mouse_click_on_group_header_does_not_select_model() { + let mut state = init_models_dialog("Models", vec![model_item("gpt-5", "GPT-5", "openai")]); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_models_dialog_mouse_event(&mut state, left_click(4, 8)); + + assert_eq!(action, ModelsDialogAction::None); + assert!(state.dialog.is_visible()); + } } diff --git a/src/views/themes_dialog.rs b/src/views/themes_dialog.rs index 52d3802..5ca368d 100644 --- a/src/views/themes_dialog.rs +++ b/src/views/themes_dialog.rs @@ -1,4 +1,4 @@ -use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{layout::Rect, Frame}; use crate::theme::ThemeColors; @@ -98,6 +98,125 @@ pub fn handle_themes_dialog_key_event( pub fn handle_themes_dialog_mouse_event( dialog_state: &mut ThemesDialogState, event: MouseEvent, -) -> bool { - dialog_state.dialog.handle_mouse_event(event) +) -> ThemesDialogAction { + if !dialog_state.dialog.is_visible() { + return ThemesDialogAction::None; + } + + let before = dialog_state.dialog.get_selected().map(|it| it.id.clone()); + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + dialog_state + .dialog + .item_index_at_position(event.column, event.row) + } else { + None + }; + + dialog_state.dialog.handle_mouse_event(event); + + if clicked_item.is_some() && dialog_state.dialog.is_visible() { + if let Some(selected) = dialog_state.dialog.get_selected() { + let theme_id = selected.id.clone(); + dialog_state.dialog.hide(); + return ThemesDialogAction::SelectTheme { theme_id }; + } + } + + if dialog_state.dialog.is_visible() { + let after = dialog_state.dialog.get_selected().map(|it| it.id.clone()); + + if before != after { + if let Some(theme_id) = after { + return ThemesDialogAction::PreviewTheme { theme_id }; + } + } + } + + ThemesDialogAction::None +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::KeyModifiers; + + fn theme_item(id: &str, name: &str) -> DialogItem { + DialogItem { + id: id.to_string(), + name: name.to_string(), + group: "Built in".to_string(), + description: String::new(), + tip: None, + provider_id: String::new(), + } + } + + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn mouse_click_on_item_selects_theme() { + let mut state = init_themes_dialog( + "Themes", + vec![ + theme_item("ayu", "Ayu"), + theme_item("tokyonight", "Tokyo Night"), + ], + ); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_themes_dialog_mouse_event( + &mut state, + mouse(MouseEventKind::Down(MouseButton::Left), 4, 10), + ); + + assert_eq!( + action, + ThemesDialogAction::SelectTheme { + theme_id: "tokyonight".to_string(), + } + ); + assert!(!state.dialog.is_visible()); + } + + #[test] + fn mouse_move_previews_theme() { + let mut state = init_themes_dialog( + "Themes", + vec![ + theme_item("ayu", "Ayu"), + theme_item("tokyonight", "Tokyo Night"), + ], + ); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = + handle_themes_dialog_mouse_event(&mut state, mouse(MouseEventKind::Moved, 4, 10)); + + assert_eq!( + action, + ThemesDialogAction::PreviewTheme { + theme_id: "tokyonight".to_string(), + } + ); + assert!(state.dialog.is_visible()); + } } diff --git a/src/views/timeline_dialog.rs b/src/views/timeline_dialog.rs index 868ab57..bc18520 100644 --- a/src/views/timeline_dialog.rs +++ b/src/views/timeline_dialog.rs @@ -3,7 +3,7 @@ use crate::theme::ThemeColors; use crate::ui::components::dialog::{ Dialog, DialogAction as FooterAction, DialogItem, DialogPosition, }; -use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{layout::Rect, Frame}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -191,24 +191,42 @@ pub fn handle_timeline_dialog_key_event( pub fn handle_timeline_dialog_mouse_event( state: &mut TimelineDialogState, event: MouseEvent, -) -> Option<usize> { +) -> TimelineDialogAction { + let was_visible = state.dialog.is_visible(); let prev_selected = state.dialog.selected_index; + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + state.dialog.item_index_at_position(event.column, event.row) + } else { + None + }; + let handled = state.dialog.handle_mouse_event(event); - if !state.dialog.is_visible() { - return None; + if was_visible && !state.dialog.is_visible() { + return TimelineDialogAction::Close; + } + + if clicked_item.is_some() { + if let Some(selected) = state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::<usize>() { + return TimelineDialogAction::Select(idx); + } + } } - // On click selection, return the selected message index if handled && state.dialog.selected_index != prev_selected { if let Some(selected) = state.dialog.get_selected() { if let Ok(idx) = selected.id.parse::<usize>() { - return Some(idx); + return TimelineDialogAction::Navigate(idx); } } } - None + if handled { + TimelineDialogAction::Handled + } else { + TimelineDialogAction::NotHandled + } } #[derive(Debug, Clone, PartialEq)] @@ -223,6 +241,7 @@ pub enum TimelineDialogAction { #[cfg(test)] mod tests { use super::*; + use ratatui::crossterm::event::KeyModifiers; fn item_names(state: &TimelineDialogState) -> Vec<String> { state @@ -242,6 +261,15 @@ mod tests { .collect() } + fn left_click(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } + } + #[test] fn assistant_segments_between_user_messages_collapse_into_one_timeline_item() { let messages = vec![ @@ -283,4 +311,42 @@ mod tests { assert_eq!(item_names(&state), vec!["You: Run tools", "Agent: (empty)"]); assert_eq!(item_ids(&state), vec!["0", "1"]); } + + #[test] + fn mouse_click_on_item_selects_message() { + let messages = vec![ + Message::user("First prompt"), + Message::assistant("First answer"), + ]; + let mut state = TimelineDialogState::build_from_messages(&messages); + state.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 45, + height: 30, + }; + + let action = handle_timeline_dialog_mouse_event(&mut state, left_click(2, 6)); + + assert_eq!(action, TimelineDialogAction::Select(0)); + } + + #[test] + fn mouse_click_outside_closes_timeline() { + let messages = vec![Message::user("First prompt")]; + let mut state = TimelineDialogState::build_from_messages(&messages); + state.show(); + state.dialog.dialog_area = Rect { + x: 10, + y: 0, + width: 45, + height: 30, + }; + + let action = handle_timeline_dialog_mouse_event(&mut state, left_click(2, 6)); + + assert_eq!(action, TimelineDialogAction::Close); + assert!(!state.dialog.is_visible()); + } } From 1e41203d0a019ede73ddcf60b5c6e841a8005a65 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 18 May 2026 22:26:11 +0800 Subject: [PATCH 081/226] docs: updated docs on multiworkspace. --- _plans/MULTIWORKSPACE.md | 460 +++++++++++++++++++++++++++++++++++++++ _plans/__TODOS.md | 2 + 2 files changed, 462 insertions(+) create mode 100644 _plans/MULTIWORKSPACE.md diff --git a/_plans/MULTIWORKSPACE.md b/_plans/MULTIWORKSPACE.md new file mode 100644 index 0000000..af11a05 --- /dev/null +++ b/_plans/MULTIWORKSPACE.md @@ -0,0 +1,460 @@ +# Multi-Workspace Sessions Plan + +## Goal + +Make `crabcode` work more like a multi-chat agent TUI by default. A terminal run of `crabcode` should behave like a client tab into a shared set of sessions, not like an isolated one-off process. + +The important shift is this: + +- A **workspace** is a folder/project root. +- A **session** is a chat thread inside a workspace. +- A **client** is one running TUI instance. +- A **generation** is one assistant/agent turn that may be streaming, using tools, waiting for permission, completed, failed, or cancelled. +- A **runtime** is the process layer that owns active generations so they can keep running after a TUI exits. + +This is intentionally not a worktree feature for now. Multiple sessions can exist for the same folder, but they share the same filesystem checkout. + +The user-facing name should be **multiworkspace**, following Zed's wording. + +## Desired Product Shape + +`crabcode` should feel like a terminal-native chat app: + +- Start in the current workspace, with a current session selected or ready to create one. +- Create multiple sessions from the same TUI run. +- Switch sessions without breaking the active render state of either session. +- Open multiple terminal instances of `crabcode` and see the same sessions and streaming statuses. +- Close a terminal while a generation is running, reopen later, and see the generation still running or completed. +- Use `/sessions` as the main session switcher, with a left-side sheet/sidebar rather than a centered modal. +- Group `/sessions` by folder/workspace, not by Today/date buckets. +- Keep workspace group ordering stable. Do not reorder groups every time a session updates; default to "whatever workspace was added first" and allow explicit reordering. +- Support an Active/All visibility toggle like Codex: + - Active shows sessions in the current workspace plus any running/waiting sessions from any workspace. + - All shows every unarchived workspace/session. + - Archived sessions/workspaces are hidden unless the user explicitly opens an archive view/filter. +- Support pin/favorite. Pinned sessions are first-class navigation items, not a later nice-to-have. +- Show each session's live state: idle, loading, streaming, waiting for permission, done, failed, cancelled. +- Use the Claude-style/lazygitrs loading glyph for running sessions. The reference implementation in `/Users/carlo/Desktop/Projects/lazygitrs` uses: + +```rust +const SPINNER_CHARS: &[char] = &['·', '✻', '✽', '✶', '✳', '✢']; +``` + +Existing `crabcode` also has `src/ui/components/wave_spinner.rs`, which is better for the chat footer. The session list probably wants the compact glyph cycle instead. + +## Current State + +Useful pieces already exist: + +- `src/session/manager.rs` has session CRUD and an in-memory `HashMap<String, Session>`. +- `src/session/types.rs` has `Session` and `Message`. +- `src/persistence/history.rs` persists sessions/messages into SQLite. +- `src/views/sessions_dialog.rs` implements the current `/sessions` dialog. +- `src/app.rs` already wires session switching, rename/delete, chat rendering, streaming, cancellation, and completion persistence. +- `src/ui/components/wave_spinner.rs` has an existing animated loading component. + +The blocking limitation is that `App` is still one-active-chat oriented: + +- One `ChatState`. +- One global `is_streaming`. +- One `chunk_receiver`. +- One `streaming_cancel_token`. +- One active `base_focus`. +- Completed assistant/tool messages are persisted at stream end, while in-progress stream state mostly lives in memory. + +That is fine for the current app, but it cannot support multiple concurrent sessions or cross-process streaming visibility. + +## Reference Architecture + +Use `/Users/carlo/Desktop/Projects/ai-studio` as the main app architecture reference for client-side session isolation. + +Relevant files: + +- `/Users/carlo/Desktop/Projects/ai-studio/src/contexts/active-chat.context.tsx` +- `/Users/carlo/Desktop/Projects/ai-studio/src/contexts/chat.context.tsx` +- `/Users/carlo/Desktop/Projects/ai-studio/src/pages/chat/+Layout.tsx` +- `/Users/carlo/Desktop/Projects/ai-studio/src/server/modules/chat/stream.handler.ts` +- `/Users/carlo/Desktop/Projects/ai-studio/src/server/modules/chat/chat.dao.ts` +- `/Users/carlo/Desktop/Projects/ai-studio/src/server/modules/chat/chat.controller.ts` + +The key pattern to copy is the per-conversation instance registry: + +- `ActiveChatContextProvider` owns the active conversation id plus a list of alive chat instances. +- Each alive `ChatInstance` has a stable key and its own mutable conversation id. +- The chat layout renders one `ChatContextProvider` per alive instance. +- Only the active provider reveals UI children; inactive providers stay mounted/headless. +- Each provider owns its own `useChat`, message state, status, error, stop handler, and stream transport. +- Switching conversations changes the active key; it does not move one stream into another conversation's UI. +- A fresh conversation can receive its server id mid-stream without remounting because the instance key stays stable. + +The server side is also relevant: + +- User messages are persisted immediately. +- The conversation row stores an `activeStreamId`. +- The sidebar detects streaming conversations from both local client state and server-polled `activeStreamId`. +- Finished assistant messages are saved on stream finish. +- Resumable streams let another tab/client reconnect to an active conversation stream. + +For `crabcode`, this maps to `ClientSessionState` instances in the TUI plus a global runtime-backed stream owner. The TUI should preserve the ai-studio property that each session has its own state/context and cannot accidentally render another session's stream. + +## Non-Goals For The First Version + +- No worktree orchestration. +- No collaborative editing. +- No cloud sync. +- No remote server. +- No attempt to make filesystem mutations conflict-free. +- No full redesign of the chat renderer beyond what is needed to isolate per-session state. + +Concurrent Build sessions in the same checkout are allowed. Treat them like two agents working in the same workspace; do not add complex write-guarding for this feature. + +## Architecture Direction + +### 1. Split Durable State From TUI State + +Introduce a durable session store that can answer: + +- What sessions exist for this workspace? +- Which sessions are currently running? +- What is the current transcript snapshot for a session? +- What is the current generation status? +- What live events happened since sequence N? +- Has this generation been cancelled/interrupted? + +SQLite can stay the source of truth, but it needs to become streaming-aware instead of completion-only. + +Suggested tables/fields: + +- `workspaces` + - `id` + - `root_path` + - `display_name` + - `sort_order` + - `archived_at` + - `last_opened_at` +- `sessions` + - existing fields + - `workspace_id` + - `status` + - `active_generation_id` + - `last_error` + - `last_event_seq` + - `pinned_at` + - `archived_at` +- `generations` + - `id` + - `session_id` + - `agent_mode` + - `provider` + - `model` + - `status` + - `started_at` + - `ended_at` + - `cancel_requested_at` + - timing/token metrics +- `generation_events` + - `id` or monotonic `seq` + - `session_id` + - `generation_id` + - `kind` + - `payload_json` + - `created_at` +- `messages` + - keep current transcript rows + - allow an assistant/tool message to be incomplete + - update or snapshot streaming content during generation + +For first implementation, prefer throttled snapshots plus an event log: + +- Snapshots make reload/render fast. +- Events let attached TUIs stream incrementally. +- If a client misses events, it can reload the snapshot and resume from the latest sequence. +- Do not persist every token as its own durable row unless it turns out to be necessary. +- Persist user messages immediately. +- Persist assistant/tool message snapshots during streaming on a throttle, such as every 250-500 ms, on newline boundaries, on tool state changes, and at stream end. +- Persist explicit events for status changes, permission/question waits, tool calls/results, title changes, errors, cancellation, and completion. + +### 2. Add A Runtime Layer + +The runtime owns active generations. The TUI should request work; it should not directly own the long-lived stream. + +Possible shape: + +- `crabcode` starts or connects to one global local runtime for the user. +- The runtime is app-global, not per workspace. +- Runtime uses a Unix domain socket on Unix/macOS, likely under the crabcode state dir. +- TUI clients send commands like: + - `CreateSession` + - `StartGeneration` + - `SubscribeSession` + - `CancelGeneration` + - `ListSessions` + - `LoadSession` +- Runtime writes all durable state to SQLite. +- Runtime broadcasts lightweight events to connected clients. +- If all clients disconnect, runtime keeps running while generations are active. +- When idle for some timeout, runtime can exit. + +This can be implemented as a daemon-ish process without forcing the user to manage a service. If no runtime is found, the first `crabcode` instance starts one and attaches. + +### 3. Make Session State Isolated In The TUI + +The TUI needs a per-session view model instead of one global chat state. + +Suggested local shape: + +```rust +struct ClientSessionState { + session_id: String, + chat: ChatState, + input_draft: String, + scroll_offset: usize, + selection: Option<...>, + loaded_until_seq: i64, + status: SessionStatus, + active_generation_id: Option<String>, + loading: bool, +} +``` + +Then `App` becomes closer to: + +```rust +struct App { + active_session_id: Option<String>, + sessions: HashMap<String, ClientSessionState>, + runtime: RuntimeClient, + sessions_panel: SessionsPanelState, + ... +} +``` + +Switching sessions should only change `active_session_id`. It should not clear/rebuild global chat state unless the session has not been loaded yet. + +Each session should preserve its own input draft, scroll offset, selection state, stream status, and pending prompt state. Switching sessions should feel like switching browser tabs. + +For inactive sessions, keep raw message state, stream status, pending prompt state, and transcript snapshots current, but do not keep expensive markdown/wrapping render caches hot while hidden. Rebuild visual caches when a session is focused again. This matches the `ai-studio` pattern: inactive chat providers stay alive, but inactive UI rendering does not keep paying the full render cost. + +### 4. Move Streaming Flow Behind Runtime APIs + +Current flow in `src/app.rs`: + +- user submits message +- app appends message locally +- app creates mpsc channel +- app stores `chunk_receiver` +- app spawns `stream_llm_with_cancellation` +- app processes chunks +- app persists completed assistant/tool messages + +Future flow: + +- user submits message +- TUI sends `StartGeneration(session_id, user_message, agent/model/provider/cwd)` +- runtime persists the user message +- runtime starts generation worker +- runtime persists stream snapshots/events +- all attached clients receive stream events +- TUI renders active session events, and updates inactive session badges/status +- on completion, runtime marks generation/session complete + +The `stream_llm_with_cancellation` function can remain useful, but it should run inside the runtime worker and publish events through a runtime sender/store instead of directly into `App`. + +### 5. Sessions Panel + +`/sessions` should become a left-side session switcher. + +Behavior: + +- Opens from any screen. +- Search/filter remains useful. +- Groups by workspace folder. +- Shows workspace groups in stable `sort_order`, with new workspaces appended by default. +- Does not reorder groups by "current" or "recent" automatically; avoid layout shifts. +- Shows pinned sessions first, then running/waiting sessions, then the rest. +- Selected row can switch active session. +- New session action creates a session in the workspace where this TUI was launched, then switches to it. +- `/new` should use the same workspace behavior. +- Delete/rename remain. +- Pin/unpin is important v1 behavior. +- Archive/unarchive should exist separately from delete. +- Loading state appears when hydrating a session snapshot. +- Visibility modes: + - `Active`: current workspace plus sessions/workspaces that are currently running, waiting, or otherwise active. + - `All`: every unarchived workspace/session in stable order. + - `Archive`: archived sessions/workspaces, available through a filter/action rather than shown by default. +- Workspace group ordering can be changed explicitly: + - Keyboard: when a workspace header is focused, `J` moves it down and `K` moves it up. + - Mouse: drag a workspace header to reorder groups. + - Persist the resulting `sort_order`. +- Session creation shortcut: + - `ctrl+n` creates a new session from `/sessions`. + - Plain `n` remains normal search input. + +Row rendering should stay close to the current sessions dialog item style. Avoid right-side metrics like `23t/s`, `waiting`, `failed`, or `4 msgs` for v1. Add only one compact status marker: + +- Loading glyph when the session is actively streaming/loading. +- Green circle when a completed stream has unread output that the user has not checked yet. +- No marker for ordinary idle/read sessions. + +Possible row format: + +```text +~/Projects/crabcode + ✻ Fix model picker persistence + ● Review config docs + Update model picker + +~/Projects/lazygitrs + ✽ Generate branch UI patch +``` + +This should avoid date grouping. Recency can still decide sort order within a folder. + +### 6. Interruption Model + +Interruption should stay focused on the active chat for v1: + +- Active session interruption: `Esc` cancels the active generation. +- `/sessions` interruption: none for v1. The sessions panel is navigation, not a stop UI. + +Possible command/shortcut names: + +- `Esc` in chat: cancel active generation. +- `Esc` in `/sessions`: close the panel only; do not stop a running session. +- `/stop` can remain as a command alias later if useful, but the primary UX is `Esc`. + +Runtime cancellation should be durable: + +- mark `generations.cancel_requested_at` +- signal the worker cancellation token if the worker is local/alive +- let other clients immediately show `cancelling` +- finalize as `cancelled` when the worker confirms +- recover stale `cancelling/running` rows on runtime restart by marking them `failed` or `interrupted`, depending on policy + +Permission and question waits are not cancellation: + +- If a tool asks for permission and no TUI is attached, pause indefinitely. +- Persist the pending request id, prompt, options, generation id, and session id. +- Mark the session/generation as `waiting_permission` or `waiting_question`. +- When any TUI reconnects, `/sessions` should show the waiting state and entering the session should surface the prompt. +- While paused, the model is not consuming tokens. +- If the runtime itself exits while waiting, recovery may need to mark the generation as interrupted unless the provider/runtime stream can be resumed safely. The target behavior is still indefinite pause while the runtime is alive. +- If the global runtime exits, treat it like every active generation received `Esc`: mark running/waiting/cancelling generations as `interrupted`. + +### 7. Cross-Process Consistency + +Multiple TUI clients should be able to attach without corrupting state. + +Rules: + +- SQLite should use WAL mode. +- A generation has exactly one owner worker. +- Runtime should acquire a lease/lock before starting a generation. +- Session/message writes go through the runtime where possible. +- Direct SQLite reads are fine for snapshots, but commands that mutate running state should go through IPC. +- Runtime heartbeats allow stale worker detection. + +Important case: + +- Terminal A starts a generation. +- Terminal B opens `/sessions`. +- Terminal B sees the same session as streaming. +- Terminal B switches to it and receives snapshot plus live events. +- Terminal A exits. +- Runtime keeps the generation going. +- Terminal B can switch into the session and press `Esc` from the chat if it needs to interrupt it. + +Because the runtime is global, the same process owns running generations across all workspace folders. Workspaces are only grouping and context boundaries, not runtime boundaries. + +If the global runtime crashes or is intentionally stopped, all active generations should recover as interrupted. This is equivalent to every client pressing `Esc` at the same time. + +## Migration Path + +### Phase 0: References And Terms + +- Use `/Users/carlo/Desktop/Projects/ai-studio` as the client-state reference. +- Treat "workspace" as folder/project root for this version. +- Use one global runtime for all workspaces. + +### Phase 1: Streaming-Aware Persistence + +- Add session/generation status fields. +- Persist incomplete assistant/tool messages. +- Add event sequence or generation event rows. +- Keep current single active TUI behavior. +- Acceptance: killing/restarting the TUI can show an incomplete or failed generation cleanly. + +### Phase 2: Per-Session View State In One Process + +- Replace one global `ChatState` with `HashMap<SessionId, ClientSessionState>`. +- Move global `is_streaming` into per-session status. +- Allow switching sessions while one is running. +- Keep runtime in-process for this phase. +- Acceptance: two sessions can exist in one TUI and switching does not clear scroll/render/input state. + +### Phase 3: Sessions Panel Redesign + +- Move `/sessions` to a left-side panel. +- Group by workspace/folder. +- Add Active/All/Archive visibility modes. +- Keep workspace groups in stable insertion/sort order. +- Add `J`/`K` and mouse-drag reordering for workspace groups. +- Add loading/running/waiting/failed/done indicators. +- Add `ctrl+n` new session, pin/unpin, archive/unarchive actions. +- Use compact loading glyphs for running rows. +- Use a green unread-complete marker for sessions that finished streaming while not focused. +- Acceptance: the panel is the primary navigation for sessions. + +### Phase 4: Runtime Client Boundary + +- Introduce `RuntimeClient` and `RuntimeEvent`. +- Move stream start/cancel/list/load behind the boundary. +- Keep an in-process runtime implementation first so the app compiles through the refactor. +- Acceptance: `App` no longer directly owns generation workers. + +### Phase 5: Local Background Runtime + +- Add socket IPC. +- Auto-start the global runtime if missing. +- Let runtime keep active generations alive after the TUI exits. +- Let multiple TUI clients subscribe to the same sessions. +- Acceptance: Terminal B can watch a generation started in Terminal A, switch into that chat, and interrupt it with `Esc`. + +### Phase 6: Recovery And Polish + +- Add stale runtime/generation recovery. +- Runtime-exit recovery marks active generations as interrupted. +- Verify concurrent Build sessions behave understandably in one checkout. +- Add better session loading states. +- Add tests around event replay, cancellation, duplicate worker prevention, and session switching. +- Add tests for waiting permission/question recovery while runtime stays alive. +- Acceptance: closed terminals, crashed clients, and restarted runtimes leave understandable session state. + +## Main Risks + +- Shared checkout conflicts if multiple Build sessions edit files at the same time. +- Persisting every stream chunk may be too chatty; throttled snapshots plus events may be better. +- Tool permission dialogs become harder when the generating session is not active or no TUI is attached. +- Cross-process runtime bugs are more expensive than local UI bugs. +- Session title/metadata updates can race unless runtime owns writes. + +## Acceptance Criteria + +- `/sessions` shows sessions grouped by folder/workspace. +- Running sessions have an animated status indicator. +- Sessions that finished streaming in the background show a green unread-complete marker until checked. +- A user can start session A, switch to session B, and session A keeps streaming. +- Returning to session A shows the stream where it is, not a reset or stale copy. +- Another `crabcode` terminal can see the same running session. +- Closing the original terminal does not stop an active generation. +- A running session can be interrupted from the active chat with `Esc`. +- `/sessions` `Esc` closes only the panel and never stops a running session. +- Completed, failed, and cancelled generations survive restart with clear status. +- Permission/question waits can pause without a TUI attached and resume when a TUI opens the session. +- Workspace group order stays stable until the user reorders it. +- The sessions panel can switch between Active, All, and Archive views. +- Sessions can be pinned/favorited. +- Session and workspace archive are both supported. +- Each session preserves its own input draft, scroll, selection, and pending UI state. +- Inactive streaming sessions keep raw state live and rebuild visual render caches when focused. diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index bcff94b..3358ed9 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -68,3 +68,5 @@ - [x] Scroll like herdr. Stuff I like: as thin, as tall (no arrows - currently ours also has no arrows but it was a hack, we just remove the arrows with "" chars so they still take a height. The one from herdr looks like it's a pure scrollbar thumb without arrows and thin enough that I like) - [x] Highlight enhancements, if I click 1 place, then shift+click another. Treat it like the highlight in the browser that doesn't need a drag. Whatever I last clicked (without shift+click), treat it as the anchor for the "select start", and then whatever I shift+click after, treat it as a "select end" and autohighlight that part. (not supported) + +- [ ] Remote usage. From 3607962ffd538aad8dd93fc45f3e2e72a37fe07b Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Mon, 18 May 2026 23:37:05 +0800 Subject: [PATCH 082/226] feat(BIG): add multi-session management with workspace-aware streaming, pin/archive, and status tracking. Introduce per-session streaming state (`SessionStreamState`, `ClientSessionState`) so each session independently tracks its own chunk receiver, cancel token, and tool calls. Add workspace-scoped session organization via a new `workspaces` SQLite table, including pinning, archiving, and status (idle/streaming/waiting/failed/interrupted). Extend the sessions dialog with collapsible workspace groups, filter tabs (Active/All/Archive), mouse wheel scrolling, relative timestamps, and multi-line action footers. Support `/sessions`, `/new`, `/home` slash commands and `Ctrl+N` to create sessions. Refactor streaming lifecycle into per-session methods (`finish_streaming_session`, `fail_streaming_session`, etc.) and persist session status through the `SessionManager`. Add `relative_readable_time` utility and database migration v2. --- src/app.rs | 1043 ++++++++++++++++++++++++--------- src/command/handlers.rs | 51 +- src/persistence/history.rs | 266 ++++++++- src/persistence/migrations.rs | 54 ++ src/session/manager.rs | 163 +++++- src/session/types.rs | 53 ++ src/ui/components/chat.rs | 6 + src/ui/components/dialog.rs | 582 ++++++++++++++---- src/utils/mod.rs | 1 + src/utils/time.rs | 72 +++ src/views/sessions_dialog.rs | 276 +++++++-- 11 files changed, 2065 insertions(+), 502 deletions(-) create mode 100644 src/utils/time.rs diff --git a/src/app.rs b/src/app.rs index 7ad09fa..0a28e09 100644 --- a/src/app.rs +++ b/src/app.rs @@ -42,7 +42,7 @@ use crate::views::session_rename_dialog::{ }; use crate::views::sessions_dialog::{ handle_sessions_dialog_key_event, handle_sessions_dialog_mouse_event, init_sessions_dialog, - render_sessions_dialog, SessionsDialogAction, + render_sessions_dialog, SessionsDialogAction, SessionsDialogFilter, }; use crate::views::suggestions_popup::{ clear_suggestions, get_selected_suggestion, handle_suggestions_popup_key_event, @@ -115,6 +115,36 @@ enum OpenAIOAuthTaskMessage { Failed(String), } +#[derive(Debug)] +struct SessionStreamState { + chunk_receiver: crate::llm::ChunkReceiver, + cancel_token: tokio_util::sync::CancellationToken, + streaming_model: Option<String>, + streaming_provider: Option<String>, + chat_len_before_assistant: usize, + tool_call_message_indices: std::collections::HashMap<String, usize>, + tool_call_order: Vec<String>, +} + +#[derive(Debug)] +struct ClientSessionState { + chat: Chat, + input_draft: String, + stream: Option<SessionStreamState>, + unread_completed: bool, +} + +impl ClientSessionState { + fn with_messages(messages: Vec<crate::session::types::Message>) -> Self { + Self { + chat: Chat::with_messages(messages), + input_draft: String::new(), + stream: None, + unread_completed: false, + } + } +} + pub struct App { pub running: bool, pub version: String, @@ -161,16 +191,11 @@ pub struct App { pub tool_permissions: crate::tools::ToolPermissions, pub skills_dirs: Vec<std::path::PathBuf>, pub is_streaming: bool, - chunk_sender: Option<crate::llm::ChunkSender>, - chunk_receiver: Option<crate::llm::ChunkReceiver>, - streaming_cancel_token: Option<tokio_util::sync::CancellationToken>, + session_view_states: std::collections::HashMap<String, ClientSessionState>, + session_spinner_frame: usize, last_frame_size: ratatui::layout::Rect, - streaming_model: Option<String>, - streaming_provider: Option<String>, last_animation_update: std::time::Instant, - streaming_chat_len_before_assistant: usize, - tool_call_message_indices: std::collections::HashMap<String, usize>, - tool_call_order: Vec<String>, + last_session_spinner_update: std::time::Instant, discovery: Option<crate::model::discovery::Discovery>, cached_usage_text: String, cached_usage_check: (usize, usize), @@ -366,16 +391,11 @@ impl App { skills_dirs: loaded_config.inventory.opencode_skills_dirs, // Note: skills_dirs is legacy; skill loading is now handled by src/skill/mod.rs is_streaming: false, - chunk_sender: None, - chunk_receiver: None, - streaming_cancel_token: None, + session_view_states: std::collections::HashMap::new(), + session_spinner_frame: 0, last_frame_size: ratatui::layout::Rect::default(), - streaming_model: None, - streaming_provider: None, last_animation_update: std::time::Instant::now(), - streaming_chat_len_before_assistant: 0, - tool_call_message_indices: std::collections::HashMap::new(), - tool_call_order: Vec::new(), + last_session_spinner_update: std::time::Instant::now(), discovery, cached_usage_text: String::new(), cached_usage_check: (0, 0), @@ -434,6 +454,149 @@ impl App { None } + fn completion_notification_stats_for_chat(chat: &Chat) -> Option<String> { + let message = chat.messages.iter().rev().find(|msg| { + msg.role == crate::session::types::MessageRole::Assistant && msg.is_complete + })?; + + if let (Some(t0), Some(t1), Some(tn)) = (message.t0_ms, message.t1_ms, message.tn_ms) { + let output_tokens = message.output_tokens.or(message.token_count).unwrap_or(0); + let total_ms = tn.saturating_sub(t0); + let decode_ms = tn.saturating_sub(t1); + + let total_sec = total_ms as f64 / 1000.0; + let tokens_per_sec = if decode_ms > 0 && output_tokens > 0 { + (output_tokens as f64) / (decode_ms as f64 / 1000.0) + } else { + 0.0 + }; + + return Some(format!("{:.1}s | {:.0}t/s", total_sec, tokens_per_sec)); + } + + if let (Some(token_count), Some(duration_ms)) = (message.token_count, message.duration_ms) { + let duration_sec = duration_ms as f64 / 1000.0; + let tokens_per_sec = if duration_ms > 0 { + (token_count as f64) / (duration_ms as f64 / 1000.0) + } else { + 0.0 + }; + + return Some(format!("{:.1}s | {:.0}t/s", duration_sec, tokens_per_sec)); + } + + None + } + + fn is_active_session(&self, session_id: &str) -> bool { + self.session_manager + .get_current_session_id() + .is_some_and(|current| current == session_id) + } + + fn ensure_session_view_state(&mut self, session_id: &str) { + if self.session_view_states.contains_key(session_id) { + return; + } + + let messages = self + .session_manager + .get_session(session_id) + .map(|session| session.messages.clone()) + .unwrap_or_default(); + + self.session_view_states.insert( + session_id.to_string(), + ClientSessionState::with_messages(messages), + ); + } + + fn save_active_session_view_state(&mut self) { + let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { + return; + }; + + self.ensure_session_view_state(&session_id); + + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.chat = self.chat_state.chat.clone(); + state.input_draft = self.input.get_text(); + } + } + + fn load_session_view_state(&mut self, session_id: &str) { + self.ensure_session_view_state(session_id); + + if let Some(state) = self.session_view_states.get_mut(session_id) { + self.chat_state.chat = state.chat.clone(); + self.chat_state.chat.scroll_to_bottom_on_next_render(); + self.input.set_text(&state.input_draft); + state.unread_completed = false; + self.is_streaming = state.stream.is_some(); + } else { + self.chat_state.chat.clear(); + self.input.clear(); + self.is_streaming = false; + } + + self.cached_usage_check = (usize::MAX, usize::MAX); + } + + fn switch_to_session(&mut self, session_id: &str) -> bool { + self.save_active_session_view_state(); + if !self.session_manager.switch_session(session_id) { + return false; + } + self.load_session_view_state(session_id); + self.base_focus = if self.chat_state.chat.messages.is_empty() && !self.is_streaming { + BaseFocus::Home + } else { + BaseFocus::Chat + }; + true + } + + fn create_new_session(&mut self, title: Option<String>) -> String { + self.save_active_session_view_state(); + let session_id = self.session_manager.create_session(title); + self.session_view_states.insert( + session_id.clone(), + ClientSessionState::with_messages(Vec::new()), + ); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.is_streaming = false; + self.cached_usage_check = (usize::MAX, usize::MAX); + self.refresh_sessions_dialog(); + session_id + } + + fn chat_for_session_mut(&mut self, session_id: &str) -> Option<&mut Chat> { + if self.is_active_session(session_id) { + Some(&mut self.chat_state.chat) + } else { + self.ensure_session_view_state(session_id); + self.session_view_states + .get_mut(session_id) + .map(|state| &mut state.chat) + } + } + + fn stream_for_session_mut(&mut self, session_id: &str) -> Option<&mut SessionStreamState> { + self.session_view_states + .get_mut(session_id) + .and_then(|state| state.stream.as_mut()) + } + + fn sync_active_streaming_flag(&mut self) { + self.is_streaming = self + .session_manager + .get_current_session_id() + .and_then(|id| self.session_view_states.get(id)) + .is_some_and(|state| state.stream.is_some()); + } + fn get_random_placeholder() -> String { let suggestions = vec![ "Fix a TODO in the codebase", @@ -871,28 +1034,76 @@ impl App { true } SessionsDialogAction::Select(id) => { - self.session_manager.switch_session(&id); - if let Some(session) = self.session_manager.get_session(&id) { - self.chat_state.chat.clear(); - for message in &session.messages { - self.chat_state.chat.add_message(message.clone()); - } - } - self.base_focus = BaseFocus::Chat; + self.switch_to_session(&id); + self.sessions_dialog_state.dialog.hide(); + self.overlay_focus = OverlayFocus::None; + true + } + SessionsDialogAction::NewSession => { + self.create_new_session(None); self.sessions_dialog_state.dialog.hide(); self.overlay_focus = OverlayFocus::None; true } + SessionsDialogAction::ChangeFilter(_) => { + self.refresh_sessions_dialog(); + true + } + SessionsDialogAction::TogglePin(id) => { + match self.session_manager.toggle_session_pin(&id) { + Ok(true) => { + push_toast(Toast::new("Pinned session", ToastLevel::Info, None)) + } + Ok(false) => { + push_toast(Toast::new("Unpinned session", ToastLevel::Info, None)) + } + Err(err) => push_toast(Toast::new( + format!("Failed to pin session: {:?}", err), + ToastLevel::Error, + None, + )), + } + self.refresh_sessions_dialog(); + self.sessions_dialog_state.dialog.select_item_by_id(&id); + true + } + SessionsDialogAction::Archive(id) => { + let previous_selected_index = + self.sessions_dialog_state.dialog.selected_index; + let archived = + self.sessions_dialog_state.filter != SessionsDialogFilter::Archived; + let was_current = self + .session_manager + .get_current_session_id() + .map_or(false, |current| *current == id); + let _ = self.session_manager.set_session_archived(&id, archived); + if was_current && archived { + self.save_active_session_view_state(); + self.session_manager.clear_current_session(); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.sync_active_streaming_flag(); + } + self.refresh_sessions_dialog(); + let _ = self + .sessions_dialog_state + .dialog + .select_index_clamped(previous_selected_index); + true + } SessionsDialogAction::Delete(id) => { let was_current = self .session_manager .get_current_session_id() .map_or(false, |current| *current == id); self.session_manager.delete_session(&id); + self.session_view_states.remove(&id); if let Some(pending) = crate::views::sessions_dialog::get_pending_delete( &mut self.sessions_dialog_state, ) { self.session_manager.delete_session(&pending); + self.session_view_states.remove(&pending); } let remaining = self.session_manager.list_sessions(); if remaining.is_empty() { @@ -934,6 +1145,7 @@ impl App { RenameAction::Submit(id, new_title) => { let _ = self.session_manager.rename_session(&id, new_title); self.refresh_sessions_dialog(); + let _ = self.sessions_dialog_state.dialog.select_item_by_id(&id); self.sessions_dialog_state.dialog.show(); self.overlay_focus = OverlayFocus::SessionsDialog; true @@ -950,6 +1162,15 @@ impl App { self.overlay_focus = OverlayFocus::PermissionDialog; } else { self.chat_state.chat.resume_streaming_tps_timer(); + if let Some(session_id) = + self.session_manager.get_current_session_id().cloned() + { + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + } self.overlay_focus = OverlayFocus::None; } true @@ -967,6 +1188,15 @@ impl App { self.overlay_focus = OverlayFocus::QuestionDialog; } else { self.chat_state.chat.resume_streaming_tps_timer(); + if let Some(session_id) = + self.session_manager.get_current_session_id().cloned() + { + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + } self.overlay_focus = OverlayFocus::None; } true @@ -1141,6 +1371,10 @@ impl App { self.which_key_state.show(); true } + KeyCode::Char('n') if key.modifiers == event::KeyModifiers::CONTROL => { + self.create_new_session(None); + true + } KeyCode::Tab => { if self.agent == "Plan" { self.agent = "Build".to_string(); @@ -1408,14 +1642,7 @@ impl App { let action = handle_sessions_dialog_mouse_event(&mut self.sessions_dialog_state, mouse); match action { SessionsDialogAction::Select(id) => { - self.session_manager.switch_session(&id); - if let Some(session) = self.session_manager.get_session(&id) { - self.chat_state.chat.clear(); - for message in &session.messages { - self.chat_state.chat.add_message(message.clone()); - } - } - self.base_focus = BaseFocus::Chat; + self.switch_to_session(&id); self.sessions_dialog_state.dialog.hide(); self.overlay_focus = OverlayFocus::None; } @@ -1801,6 +2028,28 @@ impl App { self.copy_session_transcript(); return; } + if parsed.name == "sessions" { + self.open_sessions_dialog(); + return; + } + if parsed.name == "new" { + let title = if parsed.args.is_empty() { + None + } else { + Some(parsed.args.join(" ")) + }; + self.create_new_session(title); + return; + } + if parsed.name == "home" { + self.save_active_session_view_state(); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.session_manager.clear_current_session(); + self.sync_active_streaming_flag(); + return; + } if parsed.name == "themes" { self.show_themes_dialog(); return; @@ -1813,10 +2062,11 @@ impl App { && parsed.args.is_empty() && self.base_focus == BaseFocus::Chat { - if let Some(session) = self.session_manager.get_current_session() { - let id = session.id.clone(); - let title = session.title.clone(); - drop(session); + let session_info = self + .session_manager + .get_current_session() + .map(|session| (session.id.clone(), session.title.clone())); + if let Some((id, title)) = session_info { self.session_rename_dialog_state .set_colors(self.get_current_theme_colors()); self.session_rename_dialog_state.show(id, title); @@ -1944,6 +2194,28 @@ impl App { self.copy_session_transcript(); return; } + if parsed.name == "sessions" { + self.open_sessions_dialog(); + return; + } + if parsed.name == "new" { + let title = if parsed.args.is_empty() { + None + } else { + Some(parsed.args.join(" ")) + }; + self.create_new_session(title); + return; + } + if parsed.name == "home" { + self.save_active_session_view_state(); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.session_manager.clear_current_session(); + self.sync_active_streaming_flag(); + return; + } if parsed.name == "themes" { self.show_themes_dialog(); return; @@ -1953,10 +2225,11 @@ impl App { return; } if parsed.name == "rename" && parsed.args.is_empty() && self.base_focus == BaseFocus::Chat { - if let Some(session) = self.session_manager.get_current_session() { - let id = session.id.clone(); - let title = session.title.clone(); - drop(session); + let session_info = self + .session_manager + .get_current_session() + .map(|session| (session.id.clone(), session.title.clone())); + if let Some((id, title)) = session_info { self.session_rename_dialog_state .set_colors(self.get_current_theme_colors()); self.session_rename_dialog_state.show(id, title); @@ -2075,40 +2348,70 @@ impl App { } fn refresh_sessions_dialog(&mut self) { - use chrono::{DateTime, Local, Timelike, Utc}; - let mut sessions = self.session_manager.list_sessions(); - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + let current_workspace_id = self.session_manager.current_workspace_id(); + let filter = self.sessions_dialog_state.filter; + + sessions.retain(|session| { + let is_archived = session.archived_at.is_some(); + let is_running = session.status.is_active() + || self + .session_view_states + .get(&session.id) + .is_some_and(|state| state.stream.is_some()); + + match filter { + SessionsDialogFilter::Active => { + !is_archived && (session.workspace_id == current_workspace_id || is_running) + } + SessionsDialogFilter::All => !is_archived, + SessionsDialogFilter::Archived => is_archived, + } + }); + + sessions.sort_by(|a, b| { + a.workspace_id + .cmp(&b.workspace_id) + .then_with(|| b.pinned_at.is_some().cmp(&a.pinned_at.is_some())) + .then_with(|| b.status.is_active().cmp(&a.status.is_active())) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); let items: Vec<crate::ui::components::dialog::DialogItem> = sessions .into_iter() .map(|session| { - let date_group = { - let datetime: DateTime<Local> = session.updated_at.into(); - let now: DateTime<Local> = Utc::now().into(); - let duration = now.signed_duration_since(datetime); - - if duration.num_days() == 0 { - "Today".to_string() - } else { - datetime.format("%a %b %d %Y").to_string() - } + let view_state = self.session_view_states.get(&session.id); + let is_streaming = view_state.is_some_and(|state| state.stream.is_some()) + || session.status.is_active(); + let unread_completed = view_state.is_some_and(|state| state.unread_completed); + let marker = if is_streaming { + format!("{} ", self.session_loading_glyph()) + } else if unread_completed { + "● ".to_string() + } else { + String::new() }; - - let time = { - let datetime: DateTime<Local> = session.updated_at.into(); - let hour = datetime.time().hour12(); - let am_pm = if hour.0 { "PM" } else { "AM" }; - format!("{}:{:02} {}", hour.1, datetime.time().minute(), am_pm) + let pin = if session.pinned_at.is_some() { + "★ " + } else { + "" + }; + let name = format!("{}{}{}", marker, pin, session.title); + let group = if session.workspace_name.trim().is_empty() { + session.workspace_path.clone() + } else { + session.workspace_name.clone() }; crate::ui::components::dialog::DialogItem { id: session.id.clone(), - name: session.title.clone(), - group: date_group, + name, + group, description: String::new(), - tip: Some(time), - provider_id: String::new(), + tip: Some(crate::utils::time::relative_readable_time_from_now( + session.updated_at, + )), + provider_id: session.title.clone(), } }) .collect(); @@ -2116,6 +2419,11 @@ impl App { self.sessions_dialog_state.refresh_items(items); } + fn session_loading_glyph(&self) -> &'static str { + const SPINNER_CHARS: &[&str] = &["·", "✻", "✽", "✶", "✳", "✢"]; + SPINNER_CHARS[self.session_spinner_frame % SPINNER_CHARS.len()] + } + fn open_timeline_dialog(&mut self) { let messages: Vec<crate::session::types::Message> = match self.session_manager.get_current_session() { @@ -2488,7 +2796,21 @@ impl App { let _ = self .sessions_dialog_state .dialog - .select_item_by_key(&session_id, ""); + .select_item_by_id(&session_id); + } + + self.sessions_dialog_state.dialog.show(); + self.overlay_focus = OverlayFocus::SessionsDialog; + } + + fn open_sessions_dialog(&mut self) { + self.refresh_sessions_dialog(); + + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { + let _ = self + .sessions_dialog_state + .dialog + .select_item_by_id(&session_id); } self.sessions_dialog_state.dialog.show(); @@ -2862,259 +3184,405 @@ impl App { } fn cleanup_streaming(&mut self) { - self.chat_state.chat.resume_streaming_tps_timer(); - self.permission_dialog_state.clear_with_deny(); - self.question_dialog_state.clear_with_empty(); - if self.overlay_focus == OverlayFocus::PermissionDialog { - self.overlay_focus = OverlayFocus::None; + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { + self.cleanup_streaming_for_session(&session_id); } - if self.overlay_focus == OverlayFocus::QuestionDialog { - self.overlay_focus = OverlayFocus::None; + } + + fn cleanup_streaming_for_session(&mut self, session_id: &str) { + let was_active = self.is_active_session(session_id); + + if let Some(state) = self.session_view_states.get_mut(session_id) { + state.stream = None; + } + + if was_active { + self.chat_state.chat.resume_streaming_tps_timer(); + if self.overlay_focus == OverlayFocus::PermissionDialog { + self.permission_dialog_state.clear_with_deny(); + self.overlay_focus = OverlayFocus::None; + } + if self.overlay_focus == OverlayFocus::QuestionDialog { + self.question_dialog_state.clear_with_empty(); + self.overlay_focus = OverlayFocus::None; + } } - self.chunk_sender = None; - self.chunk_receiver = None; - self.streaming_cancel_token = None; + + self.sync_active_streaming_flag(); } fn cancel_streaming(&mut self) { - if let Some(token) = &self.streaming_cancel_token { - token.cancel(); + let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { + return; + }; + + if let Some(stream) = self.stream_for_session_mut(&session_id) { + stream.cancel_token.cancel(); } } pub fn update_animations(&mut self) { // Only update animations at 20fps (50ms intervals) regardless of render rate const ANIMATION_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); + const SESSION_SPINNER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(160); if self.last_animation_update.elapsed() >= ANIMATION_INTERVAL { self.chat_state.wave_spinner.update(); self.home_state.tick(); self.last_animation_update = std::time::Instant::now(); } + + if self.last_session_spinner_update.elapsed() >= SESSION_SPINNER_INTERVAL { + self.session_spinner_frame = (self.session_spinner_frame + 1) % 6; + self.last_session_spinner_update = std::time::Instant::now(); + } } pub fn is_animation_running(&self) -> bool { - self.base_focus == BaseFocus::Home || self.is_streaming + self.base_focus == BaseFocus::Home + || self.is_streaming + || self + .session_view_states + .values() + .any(|state| state.stream.is_some()) + || (self.overlay_focus == OverlayFocus::SessionsDialog + && self.sessions_dialog_state.dialog.is_visible()) } pub fn process_streaming_chunks(&mut self) { self.process_openai_oauth_events(); - let mut chunks = Vec::new(); + let streaming_ids: Vec<String> = self + .session_view_states + .iter() + .filter_map(|(id, state)| state.stream.as_ref().map(|_| id.clone())) + .collect(); + + for session_id in streaming_ids { + let mut chunks = Vec::new(); + + if let Some(stream) = self.stream_for_session_mut(&session_id) { + while let Ok(chunk) = stream.chunk_receiver.try_recv() { + chunks.push(chunk); + } + } - if let Some(receiver) = &mut self.chunk_receiver { - while let Ok(chunk) = receiver.try_recv() { - chunks.push(chunk); + for chunk in chunks { + self.process_streaming_chunk_for_session(&session_id, chunk); } } - for chunk in chunks { - match chunk { - crate::llm::ChunkMessage::Text(text) => { - self.chat_state.chat.append_to_last_assistant(&text); + self.sync_active_streaming_flag(); + + if self.overlay_focus == OverlayFocus::SessionsDialog + && self.sessions_dialog_state.dialog.is_visible() + && !self.sessions_dialog_state.dialog.is_dragging_scrollbar + { + self.refresh_sessions_dialog(); + } + } + + fn process_streaming_chunk_for_session( + &mut self, + session_id: &str, + chunk: crate::llm::ChunkMessage, + ) { + match chunk { + crate::llm::ChunkMessage::Text(text) => { + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.append_to_last_assistant(&text); } - crate::llm::ChunkMessage::Reasoning(reasoning) => { - self.chat_state - .chat - .append_reasoning_to_last_assistant(&reasoning); + } + crate::llm::ChunkMessage::Reasoning(reasoning) => { + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.append_reasoning_to_last_assistant(&reasoning); } - crate::llm::ChunkMessage::Warning(msg) => { - push_toast(Toast::new(msg, ToastLevel::Warning, None)); + } + crate::llm::ChunkMessage::Warning(msg) => { + push_toast(Toast::new(msg, ToastLevel::Warning, None)); + } + crate::llm::ChunkMessage::End => { + self.finish_streaming_session(session_id); + } + crate::llm::ChunkMessage::Failed(error) => { + self.fail_streaming_session(session_id, error); + } + crate::llm::ChunkMessage::Cancelled => { + self.cancelled_streaming_session(session_id); + } + crate::llm::ChunkMessage::Metrics { .. } => {} + crate::llm::ChunkMessage::ToolCalls(tool_calls) => { + self.add_tool_calls_to_session(session_id, tool_calls); + } + crate::llm::ChunkMessage::ToolResult(result) => { + self.add_tool_result_to_session(session_id, result); + } + crate::llm::ChunkMessage::PermissionRequest(prompt) => { + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Waiting, + None, + ); + if !self.is_active_session(session_id) { + let _ = self.switch_to_session(session_id); + } + self.play_sound_event(crate::sound::SoundEvent::Permission); + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.pause_streaming_tps_timer(); + } + self.permission_dialog_state.enqueue(prompt); + self.overlay_focus = OverlayFocus::PermissionDialog; + } + crate::llm::ChunkMessage::QuestionRequest { + questions, + response_tx, + } => { + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Waiting, + None, + ); + if !self.is_active_session(session_id) { + let _ = self.switch_to_session(session_id); } - crate::llm::ChunkMessage::End => { - // Capture end timestamp for TTFT/TPS/latency calculations. - self.chat_state.chat.mark_streaming_end(); + self.play_sound_event(crate::sound::SoundEvent::Question); + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.pause_streaming_tps_timer(); + } + self.question_dialog_state.enqueue(questions, response_tx); + self.overlay_focus = OverlayFocus::QuestionDialog; + } + } + } - // Finalize streaming metrics from the chat's tracked values - self.chat_state.chat.finalize_streaming_metrics(); + fn finish_streaming_session(&mut self, session_id: &str) { + let (start, model, provider) = match self.stream_for_session_mut(session_id) { + Some(stream) => ( + stream.chat_len_before_assistant, + stream.streaming_model.clone(), + stream.streaming_provider.clone(), + ), + None => return, + }; - // Persist all new assistant/tool messages for this streaming turn. - let start = self.streaming_chat_len_before_assistant; - for msg in self.chat_state.chat.messages.iter_mut().skip(start) { - match msg.role { - crate::session::types::MessageRole::Assistant => { - if !msg.is_complete { - msg.mark_complete(); - } - msg.model = self.streaming_model.clone(); - msg.provider = self.streaming_provider.clone(); - let _ = self.session_manager.add_message_to_current_session(msg); - } - crate::session::types::MessageRole::Tool => { - let _ = self.session_manager.add_message_to_current_session(msg); - } - _ => {} - } - } - self.is_streaming = false; - self.streaming_model = None; - self.streaming_provider = None; - self.cleanup_streaming(); + let mut messages_to_persist = Vec::new(); + let completion_stats = if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.mark_streaming_end(); + chat.finalize_streaming_metrics(); - let completion_stats = self.completion_notification_stats(); - self.play_sound_event_with_notification_detail( - crate::sound::SoundEvent::Complete, - completion_stats.as_deref(), - ); - } - crate::llm::ChunkMessage::Failed(error) => { - self.is_streaming = false; - self.chat_state.chat.mark_streaming_end(); - self.chat_state.chat.finalize_streaming_metrics(); - self.play_sound_event(crate::sound::SoundEvent::Error); - push_toast(Toast::new( - format!("LLM error: {}", error), - ToastLevel::Error, - None, - )); - self.chat_state - .chat - .messages - .truncate(self.streaming_chat_len_before_assistant); - self.cleanup_streaming(); - } - crate::llm::ChunkMessage::Cancelled => { - self.is_streaming = false; - self.chat_state.chat.mark_streaming_end(); - self.chat_state.chat.finalize_streaming_metrics(); - push_toast(Toast::new("Streaming cancelled", ToastLevel::Info, None)); - self.chat_state - .chat - .messages - .truncate(self.streaming_chat_len_before_assistant); - self.cleanup_streaming(); - } - crate::llm::ChunkMessage::Metrics { .. } => { - // Metrics are now calculated locally from streaming data - // This arm is kept for backward compatibility but ignored - } - crate::llm::ChunkMessage::ToolCalls(tool_calls) => { - // Seal the current assistant segment so subsequent model text can appear - // after tool rows (interleaved timeline). - if let Some(idx) = self - .chat_state - .chat - .messages - .iter() - .rposition(|m| m.role == crate::session::types::MessageRole::Assistant) - { - if let Some(msg) = self.chat_state.chat.messages.get_mut(idx) { - if !msg.is_complete { - msg.mark_complete(); - } + for msg in chat.messages.iter_mut().skip(start) { + match msg.role { + crate::session::types::MessageRole::Assistant => { + if !msg.is_complete { + msg.mark_complete(); } + msg.model = model.clone(); + msg.provider = provider.clone(); + messages_to_persist.push(msg.clone()); + } + crate::session::types::MessageRole::Tool => { + messages_to_persist.push(msg.clone()); } + _ => {} + } + } - for call in tool_calls { - let args_value: serde_json::Value = - serde_json::from_str(&call.function.arguments).unwrap_or_else(|_| { - serde_json::Value::String(call.function.arguments.clone()) - }); + Self::completion_notification_stats_for_chat(chat) + } else { + None + }; - let content = serde_json::json!({ - "id": call.id, - "name": call.function.name, - "status": "running", - "args": args_value, - }) - .to_string(); + for msg in &messages_to_persist { + let _ = self.session_manager.add_message_to_session(session_id, msg); + } + + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Idle, + None, + ); + + if !self.is_active_session(session_id) { + if let Some(state) = self.session_view_states.get_mut(session_id) { + state.unread_completed = true; + } + } + + self.cleanup_streaming_for_session(session_id); + self.play_sound_event_with_notification_detail( + crate::sound::SoundEvent::Complete, + completion_stats.as_deref(), + ); + } - self.chat_state - .chat - .add_message(crate::session::types::Message::tool(content)); + fn fail_streaming_session(&mut self, session_id: &str, error: String) { + let start = self + .stream_for_session_mut(session_id) + .map(|stream| stream.chat_len_before_assistant) + .unwrap_or(0); - let idx = self.chat_state.chat.messages.len().saturating_sub(1); - self.tool_call_message_indices.insert(call.id.clone(), idx); - self.tool_call_order.push(call.id); + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.mark_streaming_end(); + chat.finalize_streaming_metrics(); + chat.messages.truncate(start); + } + + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Failed, + Some(&error), + ); + + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("LLM error: {}", error), + ToastLevel::Error, + None, + )); + self.cleanup_streaming_for_session(session_id); + } + + fn cancelled_streaming_session(&mut self, session_id: &str) { + let start = self + .stream_for_session_mut(session_id) + .map(|stream| stream.chat_len_before_assistant) + .unwrap_or(0); + + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.mark_streaming_end(); + chat.finalize_streaming_metrics(); + chat.messages.truncate(start); + } + + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Interrupted, + None, + ); + + push_toast(Toast::new("Streaming cancelled", ToastLevel::Info, None)); + self.cleanup_streaming_for_session(session_id); + } + + fn add_tool_calls_to_session( + &mut self, + session_id: &str, + tool_calls: Vec<crate::llm::ToolCall>, + ) { + let mut inserted = Vec::new(); + + if let Some(chat) = self.chat_for_session_mut(session_id) { + if let Some(idx) = chat + .messages + .iter() + .rposition(|m| m.role == crate::session::types::MessageRole::Assistant) + { + if let Some(msg) = chat.messages.get_mut(idx) { + if !msg.is_complete { + msg.mark_complete(); } } - crate::llm::ChunkMessage::ToolResult(result) => { - if let Some(idx) = self - .tool_call_message_indices - .get(&result.tool_call_id) - .copied() + } + + for call in tool_calls { + let args_value: serde_json::Value = serde_json::from_str(&call.function.arguments) + .unwrap_or_else(|_| serde_json::Value::String(call.function.arguments.clone())); + + let call_id = call.id.clone(); + let content = serde_json::json!({ + "id": call.id, + "name": call.function.name, + "status": "running", + "args": args_value, + }) + .to_string(); + + chat.add_message(crate::session::types::Message::tool(content)); + + let idx = chat.messages.len().saturating_sub(1); + inserted.push((call_id, idx)); + } + } + + if let Some(stream) = self.stream_for_session_mut(session_id) { + for (call_id, idx) in inserted { + stream + .tool_call_message_indices + .insert(call_id.clone(), idx); + stream.tool_call_order.push(call_id); + } + } + } + + fn add_tool_result_to_session(&mut self, session_id: &str, result: crate::llm::ToolCallResult) { + let target_idx = self.stream_for_session_mut(session_id).and_then(|stream| { + stream + .tool_call_message_indices + .get(&result.tool_call_id) + .copied() + }); + + if let Some(chat) = self.chat_for_session_mut(session_id) { + if let Some(idx) = target_idx { + if let Some(msg) = chat.messages.get_mut(idx) { + let mut v: serde_json::Value = serde_json::from_str(&msg.content) + .unwrap_or_else(|_| serde_json::json!({})); + v["id"] = serde_json::Value::String(result.tool_call_id.clone()); + v["name"] = serde_json::Value::String(result.name.clone()); + + if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&result.content) { - if let Some(msg) = self.chat_state.chat.messages.get_mut(idx) { - let mut v: serde_json::Value = serde_json::from_str(&msg.content) - .unwrap_or_else(|_| serde_json::json!({})); - v["id"] = serde_json::Value::String(result.tool_call_id.clone()); - v["name"] = serde_json::Value::String(result.name.clone()); - - // Merge structured payloads from the AISDK bridge if present. - if let Ok(payload) = - serde_json::from_str::<serde_json::Value>(&result.content) - { - if payload.is_object() { - if v.get("status").is_none() { - v["status"] = - payload.get("status").cloned().unwrap_or_else(|| { - serde_json::Value::String("ok".to_string()) - }); - } else { - v["status"] = payload - .get("status") - .cloned() - .unwrap_or_else(|| v["status"].clone()); - } - if let Some(title) = payload.get("title") { - v["title"] = title.clone(); - } - if let Some(meta) = payload.get("metadata") { - v["metadata"] = meta.clone(); - } - if let Some(line_count) = payload.get("line_count") { - v["line_count"] = line_count.clone(); - } - if let Some(out) = payload.get("output_preview") { - v["output_preview"] = out.clone(); - } - } else { - v["status"] = serde_json::Value::String("ok".to_string()); - v["output_preview"] = - serde_json::Value::String(result.content.clone()); - } + if payload.is_object() { + if v.get("status").is_none() { + v["status"] = payload + .get("status") + .cloned() + .unwrap_or_else(|| serde_json::Value::String("ok".to_string())); } else { - let status = if result.content.trim_start().starts_with("Error:") { - "error" - } else { - "ok" - }; - v["status"] = serde_json::Value::String(status.to_string()); - v["output_preview"] = - serde_json::Value::String(result.content.clone()); + v["status"] = payload + .get("status") + .cloned() + .unwrap_or_else(|| v["status"].clone()); } - - msg.content = v.to_string(); + if let Some(title) = payload.get("title") { + v["title"] = title.clone(); + } + if let Some(meta) = payload.get("metadata") { + v["metadata"] = meta.clone(); + } + if let Some(line_count) = payload.get("line_count") { + v["line_count"] = line_count.clone(); + } + if let Some(out) = payload.get("output_preview") { + v["output_preview"] = out.clone(); + } + } else { + v["status"] = serde_json::Value::String("ok".to_string()); + v["output_preview"] = serde_json::Value::String(result.content.clone()); } } else { - let content = serde_json::json!({ - "id": result.tool_call_id, - "name": result.name, - "status": "ok", - "output_preview": result.content, - }) - .to_string(); - self.chat_state - .chat - .add_message(crate::session::types::Message::tool(content)); + let status = if result.content.trim_start().starts_with("Error:") { + "error" + } else { + "ok" + }; + v["status"] = serde_json::Value::String(status.to_string()); + v["output_preview"] = serde_json::Value::String(result.content.clone()); } - } - crate::llm::ChunkMessage::PermissionRequest(prompt) => { - self.play_sound_event(crate::sound::SoundEvent::Permission); - self.chat_state.chat.pause_streaming_tps_timer(); - self.permission_dialog_state.enqueue(prompt); - self.overlay_focus = OverlayFocus::PermissionDialog; - } - crate::llm::ChunkMessage::QuestionRequest { - questions, - response_tx, - } => { - self.play_sound_event(crate::sound::SoundEvent::Question); - self.chat_state.chat.pause_streaming_tps_timer(); - self.question_dialog_state.enqueue(questions, response_tx); - self.overlay_focus = OverlayFocus::QuestionDialog; + + msg.content = v.to_string(); + return; } } + + let content = serde_json::json!({ + "id": result.tool_call_id, + "name": result.name, + "status": "ok", + "output_preview": result.content, + }) + .to_string(); + chat.add_message(crate::session::types::Message::tool(content)); } } @@ -3124,26 +3592,28 @@ impl App { ) -> Result<(), Box<dyn std::error::Error>> { use tokio::sync::mpsc; + let session_id = self + .session_manager + .get_current_session_id() + .cloned() + .ok_or_else(|| "No active session".to_string())?; + self.ensure_session_view_state(&session_id); + let (sender, receiver) = mpsc::unbounded_channel(); let sender_clone = sender.clone(); - self.chunk_sender = Some(sender); - self.chunk_receiver = Some(receiver); let cancel_token = tokio_util::sync::CancellationToken::new(); - self.streaming_cancel_token = Some(cancel_token.clone()); self.is_streaming = true; // Track the message boundary for this streaming turn so we can cleanly // roll back assistant/tool messages on failure or cancellation. - self.streaming_chat_len_before_assistant = self.chat_state.chat.messages.len(); - self.tool_call_message_indices.clear(); - self.tool_call_order.clear(); + let chat_len_before_assistant = self.chat_state.chat.messages.len(); // Capture the current model and provider at the start of streaming // so they don't change if the user switches models during streaming - self.streaming_model = Some(self.model.clone()); - self.streaming_provider = Some(self.provider_name.clone()); + let streaming_model = Some(self.model.clone()); + let streaming_provider = Some(self.provider_name.clone()); self.chat_state .chat .prepare_streaming_token_counter(&self.model); @@ -3156,6 +3626,24 @@ impl App { // Initialize per-turn streaming timing primitives (T0). self.chat_state.chat.begin_streaming_turn(); + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: cancel_token.clone(), + streaming_model: streaming_model.clone(), + streaming_provider: streaming_provider.clone(), + chat_len_before_assistant, + tool_call_message_indices: std::collections::HashMap::new(), + tool_call_order: Vec::new(), + }); + state.unread_completed = false; + } + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + let provider_name = self.provider_name.clone(); let model = self.model.clone(); let agent_mode = self.agent.clone(); @@ -3235,7 +3723,7 @@ impl App { if !msg.is_empty() && self.base_focus == BaseFocus::Home { if self.session_manager.get_current_session_id().is_none() { let session_title = Self::generate_title_from_message(&msg); - self.session_manager.create_session(Some(session_title)); + self.create_new_session(Some(session_title)); } let mut user_message = crate::session::types::Message::user(&msg); user_message.agent_mode = Some(self.agent.clone()); @@ -3257,6 +3745,9 @@ impl App { )); } } else if !msg.is_empty() && self.base_focus == BaseFocus::Chat { + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { + self.ensure_session_view_state(&session_id); + } let mut user_message = crate::session::types::Message::user(&msg); user_message.agent_mode = Some(self.agent.clone()); user_message.model = Some(self.model.clone()); diff --git a/src/command/handlers.rs b/src/command/handlers.rs index ebbe44c..2e6ceab 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -3,7 +3,6 @@ use crate::command::registry::{Command, CommandResult, Registry}; use crate::push_toast; use crate::session::manager::SessionManager; use crate::toast::{Toast, ToastLevel}; -use chrono::{DateTime, Local, Utc}; use std::pin::Pin; pub fn handle_exit<'a>( @@ -18,22 +17,36 @@ pub fn handle_sessions<'a>( sm: &'a mut SessionManager, ) -> Pin<Box<dyn std::future::Future<Output = CommandResult> + Send + 'a>> { Box::pin(async move { + let current_workspace_id = sm.current_workspace_id(); let mut sessions = sm.list_sessions(); - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + sessions.retain(|session| { + session.archived_at.is_none() + && (session.workspace_id == current_workspace_id || session.status.is_active()) + }); + sessions.sort_by(|a, b| { + a.workspace_id + .cmp(&b.workspace_id) + .then_with(|| b.pinned_at.is_some().cmp(&a.pinned_at.is_some())) + .then_with(|| b.status.is_active().cmp(&a.status.is_active())) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); let items: Vec<crate::command::registry::DialogItem> = sessions .into_iter() .map(|session| { - let date_group = format_date_group(session.updated_at); - let time = format_time(session.updated_at); + let name = if session.pinned_at.is_some() { + format!("★ {}", session.title) + } else { + session.title.clone() + }; crate::command::registry::DialogItem { id: session.id.clone(), - name: session.title.clone(), - group: date_group, + name, + group: session.workspace_name.clone(), description: String::new(), - tip: Some(time), - provider_id: String::new(), + tip: None, + provider_id: session.title.clone(), } }) .collect(); @@ -45,26 +58,6 @@ pub fn handle_sessions<'a>( }) } -fn format_date_group(created_at: std::time::SystemTime) -> String { - let datetime: DateTime<Local> = created_at.into(); - let now: DateTime<Local> = Utc::now().into(); - let duration = now.signed_duration_since(datetime); - - if duration.num_days() == 0 { - "Today".to_string() - } else { - datetime.format("%a %b %d %Y").to_string() - } -} - -fn format_time(created_at: std::time::SystemTime) -> String { - use chrono::Timelike; - let datetime: DateTime<Local> = created_at.into(); - let hour = datetime.time().hour12(); - let am_pm = if hour.0 { "PM" } else { "AM" }; - format!("{}:{:02} {}", hour.1, datetime.time().minute(), am_pm) -} - pub fn handle_new<'a>( _parsed: &'a ParsedCommand<'a>, _sm: &'a mut SessionManager, @@ -579,7 +572,7 @@ pub fn register_all_commands(registry: &mut Registry) { registry.register(Command { name: "new".to_string(), - description: "Switch to home screen".to_string(), + description: "Create a new session".to_string(), handler: handle_new, hidden_tokens: vec![], chat_only: false, diff --git a/src/persistence/history.rs b/src/persistence/history.rs index 1b2f1d9..f883044 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -1,9 +1,20 @@ use anyhow::Result; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; +use std::path::Path; use super::{ensure_data_dir, get_data_dir, migrations::run_migrations}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + pub id: i64, + pub root_path: String, + pub display_name: String, + pub sort_order: i64, + pub archived_at: Option<i64>, + pub last_opened_at: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: i64, @@ -15,6 +26,12 @@ pub struct Session { pub total_cost: f64, pub total_time_sec: f64, pub avg_tokens_per_sec: f64, + pub workspace_id: i64, + pub workspace_path: String, + pub workspace_name: String, + pub status: String, + pub pinned_at: Option<i64>, + pub archived_at: Option<i64>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -45,6 +62,9 @@ pub struct Message { pub struct HistoryDAO { conn: Connection, + current_workspace_id: i64, + current_workspace_path: String, + current_workspace_name: String, } impl HistoryDAO { @@ -62,48 +82,145 @@ impl HistoryDAO { [], ); - Ok(Self { conn }) + let current_workspace_path = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .to_string_lossy() + .to_string(); + let current_workspace_name = workspace_display_name(¤t_workspace_path); + let current_workspace_id = + ensure_workspace(&conn, ¤t_workspace_path, ¤t_workspace_name)?; + + conn.execute( + "UPDATE sessions + SET workspace_id = ?1 + WHERE workspace_id IS NULL", + params![current_workspace_id], + )?; + conn.execute( + "UPDATE workspaces + SET last_opened_at = strftime('%s', 'now') + WHERE id = ?1", + params![current_workspace_id], + )?; + + Ok(Self { + conn, + current_workspace_id, + current_workspace_path, + current_workspace_name, + }) } pub fn create_session(&self, identifier: &str, name: String) -> Result<i64> { self.conn.execute( - "INSERT INTO sessions (session_identifier, name) VALUES (?1, ?2)", - params![identifier, name], + "INSERT INTO sessions (session_identifier, name, workspace_id, status) + VALUES (?1, ?2, ?3, 'idle')", + params![identifier, name, self.current_workspace_id], )?; Ok(self.conn.last_insert_rowid()) } - pub fn list_sessions(&self) -> Result<Vec<Session>> { + pub fn current_workspace_id(&self) -> i64 { + self.current_workspace_id + } + + pub fn current_workspace_path(&self) -> &str { + &self.current_workspace_path + } + + pub fn current_workspace_name(&self) -> &str { + &self.current_workspace_name + } + + pub fn list_workspaces(&self) -> Result<Vec<Workspace>> { let mut stmt = self.conn.prepare( - "SELECT id, session_identifier, name, created_at, updated_at, total_tokens, total_cost, total_time_sec, avg_tokens_per_sec - FROM sessions ORDER BY updated_at DESC" + "SELECT id, root_path, display_name, sort_order, archived_at, last_opened_at + FROM workspaces + ORDER BY sort_order ASC, id ASC", )?; - let session_iter = stmt.query_map([], |row| { - Ok(Session { + let iter = stmt.query_map([], |row| { + Ok(Workspace { id: row.get(0)?, - session_identifier: row.get(1)?, - name: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, - total_tokens: row.get(5)?, - total_cost: row.get(6)?, - total_time_sec: row.get(7)?, - avg_tokens_per_sec: row.get(8)?, + root_path: row.get(1)?, + display_name: row.get(2)?, + sort_order: row.get(3)?, + archived_at: row.get(4)?, + last_opened_at: row.get(5)?, }) })?; + let result: Result<Vec<_>, _> = iter.collect(); + result.map_err(Into::into) + } + + pub fn list_sessions(&self) -> Result<Vec<Session>> { + let mut stmt = self.conn.prepare( + "SELECT s.id, s.session_identifier, s.name, s.created_at, s.updated_at, + s.total_tokens, s.total_cost, s.total_time_sec, s.avg_tokens_per_sec, + COALESCE(s.workspace_id, ?1) AS workspace_id, + COALESCE(w.root_path, ?2) AS workspace_path, + COALESCE(w.display_name, ?3) AS workspace_name, + COALESCE(s.status, 'idle') AS status, + s.pinned_at, + s.archived_at + FROM sessions s + LEFT JOIN workspaces w ON w.id = s.workspace_id + ORDER BY s.updated_at DESC", + )?; + + let session_iter = stmt.query_map( + params![ + self.current_workspace_id, + self.current_workspace_path.as_str(), + self.current_workspace_name.as_str() + ], + |row| { + Ok(Session { + id: row.get(0)?, + session_identifier: row.get(1)?, + name: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + total_tokens: row.get(5)?, + total_cost: row.get(6)?, + total_time_sec: row.get(7)?, + avg_tokens_per_sec: row.get(8)?, + workspace_id: row.get(9)?, + workspace_path: row.get(10)?, + workspace_name: row.get(11)?, + status: row.get(12)?, + pinned_at: row.get(13)?, + archived_at: row.get(14)?, + }) + }, + )?; + let result: Result<Vec<_>, _> = session_iter.collect(); result.map_err(Into::into) } pub fn get_session(&self, id: i64) -> Result<Option<Session>> { let mut stmt = self.conn.prepare( - "SELECT id, session_identifier, name, created_at, updated_at, total_tokens, total_cost, total_time_sec, avg_tokens_per_sec - FROM sessions WHERE id = ?1" + "SELECT s.id, s.session_identifier, s.name, s.created_at, s.updated_at, + s.total_tokens, s.total_cost, s.total_time_sec, s.avg_tokens_per_sec, + COALESCE(s.workspace_id, ?2) AS workspace_id, + COALESCE(w.root_path, ?3) AS workspace_path, + COALESCE(w.display_name, ?4) AS workspace_name, + COALESCE(s.status, 'idle') AS status, + s.pinned_at, + s.archived_at + FROM sessions s + LEFT JOIN workspaces w ON w.id = s.workspace_id + WHERE s.id = ?1", )?; - let mut rows = stmt.query(params![id])?; + let mut rows = stmt.query(params![ + id, + self.current_workspace_id, + self.current_workspace_path.as_str(), + self.current_workspace_name.as_str() + ])?; if let Some(row) = rows.next()? { Ok(Some(Session { id: row.get(0)?, @@ -115,6 +232,12 @@ impl HistoryDAO { total_cost: row.get(6)?, total_time_sec: row.get(7)?, avg_tokens_per_sec: row.get(8)?, + workspace_id: row.get(9)?, + workspace_path: row.get(10)?, + workspace_name: row.get(11)?, + status: row.get(12)?, + pinned_at: row.get(13)?, + archived_at: row.get(14)?, })) } else { Ok(None) @@ -240,6 +363,77 @@ impl HistoryDAO { Ok(()) } + pub fn set_session_status( + &self, + id: i64, + status: &str, + last_error: Option<&str>, + ) -> Result<()> { + self.conn.execute( + "UPDATE sessions + SET status = ?1, + last_error = ?2, + updated_at = strftime('%s', 'now') + WHERE id = ?3", + params![status, last_error, id], + )?; + Ok(()) + } + + pub fn set_session_pinned(&self, id: i64, pinned: bool) -> Result<Option<i64>> { + if pinned { + self.conn.execute( + "UPDATE sessions + SET pinned_at = strftime('%s', 'now'), + updated_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + } else { + self.conn.execute( + "UPDATE sessions + SET pinned_at = NULL, + updated_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + } + + let pinned_at = self.conn.query_row( + "SELECT pinned_at FROM sessions WHERE id = ?1", + params![id], + |row| row.get::<_, Option<i64>>(0), + )?; + Ok(pinned_at) + } + + pub fn set_session_archived(&self, id: i64, archived: bool) -> Result<Option<i64>> { + if archived { + self.conn.execute( + "UPDATE sessions + SET archived_at = strftime('%s', 'now'), + updated_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + } else { + self.conn.execute( + "UPDATE sessions + SET archived_at = NULL, + updated_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + } + + let archived_at = self.conn.query_row( + "SELECT archived_at FROM sessions WHERE id = ?1", + params![id], + |row| row.get::<_, Option<i64>>(0), + )?; + Ok(archived_at) + } + pub fn get_full_session(&self, id: i64) -> Result<Option<(Session, Vec<Message>)>> { let session = self.get_session(id)?; if let Some(session) = session { @@ -250,3 +444,37 @@ impl HistoryDAO { } } } + +fn workspace_display_name(root_path: &str) -> String { + Path::new(root_path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(root_path) + .to_string() +} + +fn ensure_workspace(conn: &Connection, root_path: &str, display_name: &str) -> Result<i64> { + if let Ok(id) = conn.query_row( + "SELECT id FROM workspaces WHERE root_path = ?1", + params![root_path], + |row| row.get::<_, i64>(0), + ) { + return Ok(id); + } + + let next_sort_order = conn + .query_row( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM workspaces", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0); + + conn.execute( + "INSERT INTO workspaces (root_path, display_name, sort_order) + VALUES (?1, ?2, ?3)", + params![root_path, display_name, next_sort_order], + )?; + Ok(conn.last_insert_rowid()) +} diff --git a/src/persistence/migrations.rs b/src/persistence/migrations.rs index 67c634c..81a886c 100644 --- a/src/persistence/migrations.rs +++ b/src/persistence/migrations.rs @@ -8,6 +8,10 @@ pub fn run_migrations(db: &mut Connection) -> Result<()> { migrate_to_v1(db)?; } + if current_version < 2 { + migrate_to_v2(db)?; + } + Ok(()) } @@ -99,3 +103,53 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { tx.commit()?; Ok(()) } + +fn migrate_to_v2(db: &mut Connection) -> Result<()> { + let tx = db.transaction()?; + + tx.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + root_path TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + last_opened_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ); + + CREATE INDEX IF NOT EXISTS idx_workspaces_sort ON workspaces(sort_order ASC, id ASC); + CREATE INDEX IF NOT EXISTS idx_workspaces_path ON workspaces(root_path); + "#, + )?; + + let _ = tx.execute("ALTER TABLE sessions ADD COLUMN workspace_id INTEGER", []); + let _ = tx.execute( + "ALTER TABLE sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'", + [], + ); + let _ = tx.execute( + "ALTER TABLE sessions ADD COLUMN active_generation_id TEXT", + [], + ); + let _ = tx.execute("ALTER TABLE sessions ADD COLUMN last_error TEXT", []); + let _ = tx.execute("ALTER TABLE sessions ADD COLUMN pinned_at INTEGER", []); + let _ = tx.execute("ALTER TABLE sessions ADD COLUMN archived_at INTEGER", []); + + tx.execute_batch( + r#" + CREATE INDEX IF NOT EXISTS idx_sessions_workspace ON sessions(workspace_id, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); + CREATE INDEX IF NOT EXISTS idx_sessions_pinned ON sessions(pinned_at DESC); + CREATE INDEX IF NOT EXISTS idx_sessions_archived ON sessions(archived_at); + "#, + )?; + + tx.execute( + "INSERT OR IGNORE INTO migrations (version, applied_at) VALUES (2, strftime('%s', 'now'))", + params![], + )?; + + tx.commit()?; + Ok(()) +} diff --git a/src/session/manager.rs b/src/session/manager.rs index 1d4dadd..67620ad 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -1,5 +1,5 @@ use crate::persistence::HistoryDAO; -use crate::session::types::Session; +use crate::session::types::{Session, SessionStatus}; use std::collections::HashMap; use std::time::SystemTime; @@ -22,6 +22,12 @@ pub struct SessionInfo { pub created_at: SystemTime, pub updated_at: SystemTime, pub message_count: usize, + pub workspace_id: i64, + pub workspace_path: String, + pub workspace_name: String, + pub status: SessionStatus, + pub pinned_at: Option<SystemTime>, + pub archived_at: Option<SystemTime>, } pub struct SessionManager { @@ -31,10 +37,19 @@ pub struct SessionManager { history_dao: Option<HistoryDAO>, id_mapping: HashMap<String, i64>, db_id_to_id: HashMap<i64, String>, + current_workspace_id: i64, + current_workspace_path: String, + current_workspace_name: String, } impl SessionManager { pub fn new() -> Self { + let current_workspace_path = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .to_string_lossy() + .to_string(); + let current_workspace_name = workspace_display_name(¤t_workspace_path); + Self { sessions: HashMap::new(), current_session_id: None, @@ -42,12 +57,18 @@ impl SessionManager { history_dao: None, id_mapping: HashMap::new(), db_id_to_id: HashMap::new(), + current_workspace_id: 0, + current_workspace_path, + current_workspace_name, } } pub fn with_history(mut self) -> Result<Self, SessionError> { let history_dao = HistoryDAO::new().map_err(|e| SessionError::PersistenceError(e.to_string()))?; + self.current_workspace_id = history_dao.current_workspace_id(); + self.current_workspace_path = history_dao.current_workspace_path().to_string(); + self.current_workspace_name = history_dao.current_workspace_name().to_string(); self.load_sessions_from_db(&history_dao)?; self.history_dao = Some(history_dao); Ok(self) @@ -76,6 +97,20 @@ impl SessionManager { + std::time::Duration::from_secs(db_session.created_at as u64); session.updated_at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(db_session.updated_at as u64); + session.workspace_id = db_session.workspace_id; + session.workspace_path = db_session.workspace_path; + session.workspace_name = db_session.workspace_name; + session.status = SessionStatus::from_str(&db_session.status); + if session.status.is_active() { + session.status = SessionStatus::Interrupted; + let _ = dao.set_session_status(db_session.id, session.status.as_str(), None); + } + session.pinned_at = db_session + .pinned_at + .map(|ts| std::time::UNIX_EPOCH + std::time::Duration::from_secs(ts as u64)); + session.archived_at = db_session + .archived_at + .map(|ts| std::time::UNIX_EPOCH + std::time::Duration::from_secs(ts as u64)); let session_id = session.id.clone(); self.sessions.insert(session_id.clone(), session); @@ -98,6 +133,9 @@ impl SessionManager { let mut session = Session::with_title(title.clone()); session.id = session_id.clone(); + session.workspace_id = self.current_workspace_id; + session.workspace_path = self.current_workspace_path.clone(); + session.workspace_name = self.current_workspace_name.clone(); self.sessions.insert(session_id.clone(), session); self.current_session_id = Some(session_id.clone()); @@ -122,6 +160,12 @@ impl SessionManager { created_at: session.created_at, updated_at: session.updated_at, message_count: session.messages.len(), + workspace_id: session.workspace_id, + workspace_path: session.workspace_path.clone(), + workspace_name: session.workspace_name.clone(), + status: session.status, + pinned_at: session.pinned_at, + archived_at: session.archived_at, }) .collect() } @@ -151,6 +195,18 @@ impl SessionManager { self.current_session_id.as_ref() } + pub fn current_workspace_id(&self) -> i64 { + self.current_workspace_id + } + + pub fn current_workspace_path(&self) -> &str { + &self.current_workspace_path + } + + pub fn current_workspace_name(&self) -> &str { + &self.current_workspace_name + } + pub fn clear_current_session(&mut self) { self.current_session_id = None; } @@ -163,13 +219,24 @@ impl SessionManager { &mut self, message: &crate::session::types::Message, ) -> Result<(), SessionError> { - if let Some(session_id) = &self.current_session_id.clone() { - if let Some(session) = self.sessions.get_mut(session_id) { - session.add_message(message.clone()); - } + let Some(session_id) = self.current_session_id.clone() else { + return Ok(()); + }; + self.add_message_to_session(&session_id, message) + } + + pub fn add_message_to_session( + &mut self, + session_id: &str, + message: &crate::session::types::Message, + ) -> Result<(), SessionError> { + if let Some(session) = self.sessions.get_mut(session_id) { + session.add_message(message.clone()); + } else { + return Err(SessionError::NotFound(session_id.to_string())); } - if let (Some(session_id), Some(ref dao)) = (&self.current_session_id, &self.history_dao) { + if let Some(ref dao) = self.history_dao { if let Some(db_id) = self.id_mapping.get(session_id) { let mut db_message: crate::persistence::Message = message.clone().into(); db_message.session_id = *db_id; @@ -181,6 +248,81 @@ impl SessionManager { Ok(()) } + pub fn set_session_status( + &mut self, + id: &str, + status: SessionStatus, + last_error: Option<&str>, + ) -> Result<(), SessionError> { + if let Some(session) = self.sessions.get_mut(id) { + session.status = status; + session.updated_at = SystemTime::now(); + } else { + return Err(SessionError::NotFound(id.to_string())); + } + + if let Some(ref dao) = self.history_dao { + if let Some(db_id) = self.id_mapping.get(id) { + let _ = dao.set_session_status(*db_id, status.as_str(), last_error); + } + } + + Ok(()) + } + + pub fn toggle_session_pin(&mut self, id: &str) -> Result<bool, SessionError> { + let pinned = if let Some(session) = self.sessions.get_mut(id) { + if session.pinned_at.is_some() { + session.pinned_at = None; + false + } else { + session.pinned_at = Some(SystemTime::now()); + true + } + } else { + return Err(SessionError::NotFound(id.to_string())); + }; + + if let Some(ref dao) = self.history_dao { + if let Some(db_id) = self.id_mapping.get(id) { + let pinned_at = dao.set_session_pinned(*db_id, pinned).ok().flatten(); + if let Some(session) = self.sessions.get_mut(id) { + session.pinned_at = pinned_at.map(|ts| { + std::time::UNIX_EPOCH + std::time::Duration::from_secs(ts as u64) + }); + } + } + } + + Ok(pinned) + } + + pub fn set_session_archived(&mut self, id: &str, archived: bool) -> Result<(), SessionError> { + if let Some(session) = self.sessions.get_mut(id) { + session.archived_at = if archived { + Some(SystemTime::now()) + } else { + None + }; + session.updated_at = SystemTime::now(); + } else { + return Err(SessionError::NotFound(id.to_string())); + } + + if let Some(ref dao) = self.history_dao { + if let Some(db_id) = self.id_mapping.get(id) { + let archived_at = dao.set_session_archived(*db_id, archived).ok().flatten(); + if let Some(session) = self.sessions.get_mut(id) { + session.archived_at = archived_at.map(|ts| { + std::time::UNIX_EPOCH + std::time::Duration::from_secs(ts as u64) + }); + } + } + } + + Ok(()) + } + pub fn rename_session(&mut self, id: &str, new_title: String) -> Result<(), SessionError> { if let Some(session) = self.sessions.get_mut(id) { session.title = new_title.clone(); @@ -219,6 +361,15 @@ impl SessionManager { } } +fn workspace_display_name(root_path: &str) -> String { + std::path::Path::new(root_path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(root_path) + .to_string() +} + impl Default for SessionManager { fn default() -> Self { Self::new() diff --git a/src/session/types.rs b/src/session/types.rs index 4b81eda..488df91 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -1,5 +1,40 @@ use std::time::SystemTime; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionStatus { + Idle, + Streaming, + Waiting, + Failed, + Interrupted, +} + +impl SessionStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Streaming => "streaming", + Self::Waiting => "waiting", + Self::Failed => "failed", + Self::Interrupted => "interrupted", + } + } + + pub fn from_str(value: &str) -> Self { + match value { + "streaming" => Self::Streaming, + "waiting" => Self::Waiting, + "failed" => Self::Failed, + "interrupted" => Self::Interrupted, + _ => Self::Idle, + } + } + + pub fn is_active(self) -> bool { + matches!(self, Self::Streaming | Self::Waiting) + } +} + #[derive(Debug, Clone, PartialEq)] pub enum MessageRole { User, @@ -106,6 +141,12 @@ pub struct Session { pub title: String, pub created_at: SystemTime, pub updated_at: SystemTime, + pub workspace_id: i64, + pub workspace_path: String, + pub workspace_name: String, + pub status: SessionStatus, + pub pinned_at: Option<SystemTime>, + pub archived_at: Option<SystemTime>, pub messages: Vec<Message>, } @@ -123,6 +164,12 @@ impl Session { title: "New Session".to_string(), created_at: now, updated_at: now, + workspace_id: 0, + workspace_path: String::new(), + workspace_name: "Workspace".to_string(), + status: SessionStatus::Idle, + pinned_at: None, + archived_at: None, messages: Vec::new(), } } @@ -134,6 +181,12 @@ impl Session { title: title.into(), created_at: now, updated_at: now, + workspace_id: 0, + workspace_path: String::new(), + workspace_name: "Workspace".to_string(), + status: SessionStatus::Idle, + pinned_at: None, + archived_at: None, messages: Vec::new(), } } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 4475078..4c4cea2 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -560,6 +560,12 @@ impl Chat { self.update_scrollbar(); } + pub fn scroll_to_bottom_on_next_render(&mut self) { + self.scroll_offset = usize::MAX; + self.user_scrolled_up = false; + self.update_scrollbar(); + } + pub fn get_message_line_positions( &self, max_width: usize, diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 1a2662d..8fd324a 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -16,9 +16,9 @@ use ratatui::{ widgets::{Clear, Paragraph, ScrollbarState}, Frame, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tui_textarea::{Input as TuiInput, TextArea}; -use unicode_width::UnicodeWidthStr; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum DialogPosition { @@ -77,6 +77,8 @@ pub struct Dialog { pub actions: Vec<DialogAction>, pub position: DialogPosition, pub pending_delete_id: Option<String>, + collapsible_groups: bool, + collapsed_groups: HashSet<String>, matcher: Matcher, } @@ -109,6 +111,8 @@ impl Dialog { actions: Vec::new(), position: DialogPosition::Center, pending_delete_id: None, + collapsible_groups: false, + collapsed_groups: HashSet::new(), matcher: Matcher::new(Config::DEFAULT), } } @@ -118,6 +122,14 @@ impl Dialog { self } + pub fn with_collapsible_groups(mut self, enabled: bool) -> Self { + self.collapsible_groups = enabled; + if !enabled { + self.collapsed_groups.clear(); + } + self + } + pub fn with_items(title: impl Into<String>, items: Vec<DialogItem>) -> Self { let mut dialog = Self::new(title); dialog.set_items(items); @@ -175,6 +187,10 @@ impl Dialog { }); self.groups = special.into_iter().chain(regular.into_iter()).collect(); + + let valid_groups: HashSet<String> = self.groups.iter().cloned().collect(); + self.collapsed_groups + .retain(|group| valid_groups.contains(group)); } pub fn show(&mut self) { @@ -201,14 +217,63 @@ impl Dialog { pub fn set_search_query(&mut self, query: impl Into<String>) { self.search_query = query.into(); + self.search_textarea = TextArea::default(); + self.search_textarea.set_placeholder_text("Search"); + if !self.search_query.is_empty() { + self.search_textarea.insert_str(&self.search_query); + } self.apply_filter(); } pub fn clear_search(&mut self) { self.search_query.clear(); + self.search_textarea = TextArea::default(); + self.search_textarea.set_placeholder_text("Search"); self.apply_filter(); } + pub fn is_group_collapsed(&self, group: &str) -> bool { + self.collapsible_groups && self.collapsed_groups.contains(group) + } + + pub fn toggle_group_collapsed(&mut self, group: &str) { + if !self.collapsible_groups { + return; + } + + if self.collapsed_groups.contains(group) { + self.collapsed_groups.remove(group); + } else { + self.collapsed_groups.insert(group.to_string()); + } + + self.reconcile_selection_after_filter(None); + self.update_scrollbar(); + } + + pub fn collapsed_groups(&self) -> HashSet<String> { + self.collapsed_groups.clone() + } + + pub fn set_collapsed_groups(&mut self, groups: HashSet<String>) { + self.collapsed_groups = if self.collapsible_groups { + groups + } else { + HashSet::new() + }; + + let valid_groups: HashSet<String> = self.groups.iter().cloned().collect(); + self.collapsed_groups + .retain(|group| valid_groups.contains(group)); + self.reconcile_selection_after_filter(None); + self.update_scrollbar(); + } + + pub fn preserve_scrollbar_drag_state_from(&mut self, previous: &Self) { + self.is_dragging_scrollbar = previous.is_dragging_scrollbar; + self.scrollbar_drag_offset = previous.scrollbar_drag_offset; + } + fn apply_filter(&mut self) { let preferred_selected = self .get_selected() @@ -387,7 +452,10 @@ impl Dialog { fn get_flat_items(&self) -> Vec<&DialogItem> { let mut items = Vec::new(); - for (_, group_items) in &self.filtered_items { + for (group, group_items) in &self.filtered_items { + if self.is_group_collapsed(group) { + continue; + } for item in group_items { items.push(item); } @@ -396,16 +464,20 @@ impl Dialog { } fn get_content_line_count(&self) -> usize { - let flat_items = self.get_flat_items(); - if flat_items.is_empty() { + if self.filtered_items.is_empty() { return 1; } let mut count = 0; for (group, items) in &self.filtered_items { let header = if Self::group_has_header(group) { 1 } else { 0 }; - count += items.len() + header; + let visible_items = if self.is_group_collapsed(group) { + 0 + } else { + items.len() + }; + count += visible_items + header; } - count + count.max(1) } fn get_line_index_of_item(&self, item_index: usize) -> usize { @@ -421,6 +493,10 @@ impl Dialog { line_index += 1; } + if self.is_group_collapsed(group) { + continue; + } + for _item in items { if current_item_index == item_index { return line_index; @@ -457,13 +533,15 @@ impl Dialog { if self.visible_row_count > 0 { self.visible_row_count } else { - const DIALOG_WIDTH_CENTER: u16 = 70; const DIALOG_HEIGHT_CENTER: u16 = 25; - const DIALOG_WIDTH_SIDE: u16 = 40; - const PADDING: u16 = 3; - let total_fixed_height = 1 + 1 + 3 + 1 + 1; - let padding_total = PADDING * 2; + let footer_height = self.footer_height(); + let total_fixed_height = 1 + 1 + 3 + 1 + footer_height; + let padding = match self.position { + DialogPosition::Center => 3u16, + DialogPosition::Left | DialogPosition::Right => 1u16, + }; + let padding_total = padding * 2; match self.position { DialogPosition::Center => { @@ -498,6 +576,175 @@ impl Dialog { false } + pub fn select_item_by_id(&mut self, id: &str) -> bool { + let flat_items = self.get_flat_items(); + if let Some(pos) = flat_items.iter().position(|item| item.id == id) { + self.selected_index = pos; + self.adjust_scroll(); + return true; + } + false + } + + pub fn select_index_clamped(&mut self, index: usize) -> bool { + let item_count = self.get_flat_items().len(); + if item_count == 0 { + self.selected_index = 0; + self.scroll_offset = 0; + self.update_scrollbar(); + return false; + } + + self.selected_index = index.min(item_count.saturating_sub(1)); + self.adjust_scroll(); + true + } + + fn footer_height(&self) -> u16 { + if self.actions.len() > 4 { + 3 + } else if self.actions.len() > 2 { + 2 + } else { + 1 + } + } + + fn layout_constraints(&self) -> [ratatui::layout::Constraint; 6] { + [ + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(3), + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(self.footer_height()), + ] + } + + fn truncate_to_width(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + + if text.width() <= max_width { + return text.to_string(); + } + + const ELLIPSIS: &str = "..."; + let ellipsis_width = ELLIPSIS.width(); + if max_width <= ellipsis_width { + return ".".repeat(max_width); + } + + let content_width = max_width - ellipsis_width; + let mut result = String::new(); + let mut width = 0usize; + + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > content_width { + break; + } + result.push(ch); + width += ch_width; + } + + result.push_str(ELLIPSIS); + result + } + + fn left_item_spans_for_width( + item: &DialogItem, + width: usize, + colors: ThemeColors, + ) -> (Vec<Span<'static>>, usize) { + if width == 0 { + return (Vec::new(), 0); + } + + let indent_width = width.min(2); + let indent = " ".repeat(indent_width); + let has_description = !item.description.is_empty(); + + if !has_description { + let name = Self::truncate_to_width(&item.name, width.saturating_sub(indent_width)); + let text = format!("{indent}{name}"); + let text_width = text.width(); + return (vec![Span::raw(text)], text_width); + } + + let separator_width = 2usize; + let full_name_width = item.name.width(); + if indent_width + full_name_width + separator_width >= width { + let name = Self::truncate_to_width(&item.name, width.saturating_sub(indent_width)); + let text = format!("{indent}{name}"); + let text_width = text.width(); + return (vec![Span::raw(text)], text_width); + } + + let desc_budget = width.saturating_sub(indent_width + full_name_width + separator_width); + let description = Self::truncate_to_width(&item.description, desc_budget); + let prefix = format!("{indent}{} ", item.name); + let text_width = prefix.width() + description.width(); + + ( + vec![ + Span::raw(prefix), + Span::styled( + description, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ], + text_width, + ) + } + + fn item_spans_for_width( + item: &DialogItem, + width: usize, + colors: ThemeColors, + ) -> Vec<Span<'static>> { + if width == 0 { + return Vec::new(); + } + + let has_description = !item.description.is_empty(); + let tip = item + .tip + .as_ref() + .map(|tip| Self::truncate_to_width(tip, width)); + let tip_width = tip.as_ref().map(|tip| tip.width()).unwrap_or(0); + let minimum_gap = if tip_width > 0 && width > tip_width { + 1 + } else { + 0 + }; + let left_budget = width.saturating_sub(tip_width + minimum_gap); + let (mut spans, left_width) = Self::left_item_spans_for_width(item, left_budget, colors); + + if let Some(tip) = tip { + let padding_len = width.saturating_sub(left_width + tip_width); + spans.push(Span::raw(" ".repeat(padding_len))); + + let tip_style = if has_description { + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM) + }; + spans.push(Span::styled(tip, tip_style)); + } else { + spans.push(Span::raw(" ".repeat(width.saturating_sub(left_width)))); + } + + spans + } + pub fn is_visible(&self) -> bool { self.visible } @@ -554,14 +801,7 @@ impl Dialog { let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(3), - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ]) + .constraints(self.layout_constraints()) .split(content_area); let list_area = chunks[3]; @@ -594,6 +834,19 @@ impl Dialog { return true; } + if matches!( + event.kind, + MouseEventKind::ScrollDown | MouseEventKind::ScrollUp + ) && self.dialog_area.contains(point) + { + match event.kind { + MouseEventKind::ScrollDown => self.scroll_down(), + MouseEventKind::ScrollUp => self.scroll_up(), + _ => {} + } + return true; + } + if !content_area.contains(point) { self.is_dragging_scrollbar = false; self.scrollbar_drag_offset = None; @@ -694,14 +947,7 @@ impl Dialog { let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(3), - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ]) + .constraints(self.layout_constraints()) .split(content_area); let list_area = chunks[3]; @@ -719,12 +965,92 @@ impl Dialog { self.get_item_index_from_y(row, list_area) } + pub fn group_at_position(&self, column: u16, row: u16) -> Option<String> { + if !self.visible || !self.collapsible_groups { + return None; + } + + use ratatui::layout::Position; + let point = Position::new(column, row); + + if !self.dialog_area.contains(point) { + return None; + } + + let padding = match self.position { + DialogPosition::Center => 3u16, + DialogPosition::Left | DialogPosition::Right => 1u16, + }; + let content_area = Rect { + x: self.dialog_area.x + padding, + y: self.dialog_area.y + padding, + width: self.dialog_area.width.saturating_sub(padding * 2), + height: self.dialog_area.height.saturating_sub(padding * 2), + }; + + if !content_area.contains(point) { + return None; + } + + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints(self.layout_constraints()) + .split(content_area); + + let list_area = chunks[3]; + let list_content_area = Rect { + x: list_area.x, + y: list_area.y, + width: list_area.width.saturating_sub(2), + height: list_area.height, + }; + + if !list_content_area.contains(point) { + return None; + } + + let relative_y = row.saturating_sub(list_area.y) as usize; + let content_line = self.scroll_offset + relative_y; + self.get_group_from_line(content_line) + } + fn get_item_index_from_y(&self, row: u16, list_area: Rect) -> Option<usize> { let relative_y = row.saturating_sub(list_area.y) as usize; let content_line = self.scroll_offset + relative_y; self.get_item_index_from_line(content_line) } + fn get_group_from_line(&self, line: usize) -> Option<String> { + let mut current_line = 0; + + for (group, items) in &self.filtered_items { + if items.is_empty() { + continue; + } + + if Self::group_has_header(group) { + if line == current_line { + return Some(group.clone()); + } + current_line += 1; + } + + let visible_items = if self.is_group_collapsed(group) { + 0 + } else { + items.len() + }; + + if line < current_line + visible_items { + return None; + } + + current_line += visible_items; + } + + None + } + fn get_item_index_from_line(&self, line: usize) -> Option<usize> { let mut current_line = 0; let mut item_index = 0; @@ -739,14 +1065,19 @@ impl Dialog { } else { current_line }; - let items_end_line = items_start_line + items.len(); + let visible_items = if self.is_group_collapsed(group) { + 0 + } else { + items.len() + }; + let items_end_line = items_start_line + visible_items; if line >= items_start_line && line < items_end_line { return Some(item_index + (line - items_start_line)); } current_line = items_end_line; - item_index += items.len(); + item_index += visible_items; } None @@ -844,14 +1175,7 @@ impl Dialog { let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(3), - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ]) + .constraints(self.layout_constraints()) .split(self.content_area); let esc_text = "esc"; @@ -885,11 +1209,10 @@ impl Dialog { frame.render_widget(&self.search_textarea, chunks[2]); let mut content_lines = Vec::new(); - let flat_items = self.get_flat_items(); let list_area_width = chunks[3].width.saturating_sub(2); // Subtract scrollbar width let filtered_items = self.filtered_items.clone(); - if flat_items.is_empty() { + if self.filtered_items.is_empty() { content_lines.push(Line::from(vec![Span::styled( "No results found", Style::default().fg(colors.text_weak), @@ -903,84 +1226,56 @@ impl Dialog { } if Self::group_has_header(group) { - content_lines.push(Line::from(vec![Span::styled( - group.clone(), - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - )])); - } - - for item in items { - let is_selected = item_index == self.selected_index; - let is_pending_delete = self.pending_delete_id.as_ref() == Some(&item.id); - let has_description = !item.description.is_empty(); - - let mut spans: Vec<Span> = if let Some(tip) = &item.tip { - let base_len = if has_description { - item.name.width() + item.description.width() + 4 + let header_spans = if self.collapsible_groups { + let chevron = if self.is_group_collapsed(group) { + "⏷" } else { - item.name.width() + 2 + "⏶" }; - let tip_width = tip.width(); - let padding_len = - (list_area_width as usize).saturating_sub(base_len + tip_width); - let padding_after_tip = (list_area_width as usize) - .saturating_sub(base_len + tip_width + 2 + padding_len); - - if has_description { - vec![ - Span::raw(format!(" {} ", item.name)), - Span::styled( - item.description.clone(), - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), - Span::raw(" ".repeat(padding_len)), - Span::styled( - tip, - Style::default() - .fg(colors.text) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" ".repeat(padding_after_tip)), - ] - } else { - vec![ - Span::raw(format!(" {}", item.name)), - Span::raw(" ".repeat(padding_len)), - Span::styled( - tip, - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), - Span::raw(" ".repeat(padding_after_tip)), - ] - } - } else if has_description { - let text_len = item.name.width() + item.description.width() + 4; - let padding_len = (list_area_width as usize).saturating_sub(text_len); + let chevron_width = chevron.width(); + let group = Self::truncate_to_width( + group, + (list_area_width as usize).saturating_sub(chevron_width), + ); + let padding_len = (list_area_width as usize) + .saturating_sub(group.width() + chevron_width); vec![ - Span::raw(format!(" {} ", item.name)), Span::styled( - item.description.clone(), + group, Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), + .fg(colors.primary) + .add_modifier(Modifier::BOLD), ), Span::raw(" ".repeat(padding_len)), + Span::styled( + chevron, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), ] } else { - let text_len = item.name.width() + 2; - let padding_len = (list_area_width as usize).saturating_sub(text_len); - vec![ - Span::raw(format!(" {}", item.name)), - Span::raw(" ".repeat(padding_len)), - ] + vec![Span::styled( + Self::truncate_to_width(group, list_area_width as usize), + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )] }; + content_lines.push(Line::from(header_spans)); + } + + if self.is_group_collapsed(group) { + continue; + } + + for item in items { + let is_selected = item_index == self.selected_index; + let is_pending_delete = self.pending_delete_id.as_ref() == Some(&item.id); + let mut spans = + Self::item_spans_for_width(item, list_area_width as usize, colors); + if is_selected { let fg = contrast_text(colors.primary); for span in &mut spans { @@ -1035,35 +1330,62 @@ impl Dialog { colors.text_weak, ); - let mut footer_spans = vec![]; - for (i, action) in self.actions.iter().enumerate() { - if i > 0 { - footer_spans.push(Span::raw(" ")); + let footer_paragraph = Paragraph::new(self.footer_lines(chunks[5].width, colors)) + .alignment(ratatui::layout::Alignment::Left); + frame.render_widget(footer_paragraph, chunks[5]); + } + + fn footer_lines(&self, width: u16, colors: ThemeColors) -> Vec<Line<'static>> { + if self.actions.is_empty() { + return vec![Line::from(vec![])]; + } + + let max_lines = self.footer_height() as usize; + let max_width = width.max(1) as usize; + let mut lines: Vec<Vec<Span<'static>>> = Vec::new(); + let mut current: Vec<Span<'static>> = Vec::new(); + let mut current_width = 0usize; + + for action in &self.actions { + let action_width = action.label.width() + action.key.width() + 2; + let spacer_width = if current.is_empty() { 0 } else { 2 }; + + if !current.is_empty() + && current_width + spacer_width + action_width > max_width + && lines.len() + 1 < max_lines + { + lines.push(current); + current = Vec::new(); + current_width = 0; } - footer_spans.push(Span::styled( - &action.label, + + if !current.is_empty() { + current.push(Span::raw(" ")); + current_width += 2; + } + + current.push(Span::styled( + action.label.clone(), Style::default() .fg(colors.primary) .add_modifier(Modifier::BOLD), )); - footer_spans.push(Span::raw(" ")); - footer_spans.push(Span::styled( - &action.key, + current.push(Span::raw(" ")); + current.push(Span::styled( + action.key.clone(), Style::default() .fg(colors.text_weak) .add_modifier(Modifier::DIM), )); + current_width += action_width; } - let footer_line = if footer_spans.is_empty() { - Line::from(vec![]) - } else { - Line::from(footer_spans) - }; + lines.push(current); + while lines.len() < max_lines { + lines.push(Vec::new()); + } - let footer_paragraph = - Paragraph::new(footer_line).alignment(ratatui::layout::Alignment::Left); - frame.render_widget(footer_paragraph, chunks[5]); + lines.into_iter().map(Line::from).collect() } } @@ -1095,6 +1417,8 @@ impl Clone for Dialog { actions: self.actions.clone(), position: self.position, pending_delete_id: self.pending_delete_id.clone(), + collapsible_groups: self.collapsible_groups, + collapsed_groups: self.collapsed_groups.clone(), matcher: Matcher::new(Config::DEFAULT), } } @@ -1418,6 +1742,16 @@ mod tests { assert_eq!(selected.unwrap().name, "Model A"); } + #[test] + fn test_dialog_select_index_clamped_uses_last_available_item() { + let mut dialog = Dialog::with_items("Models", create_test_items()); + + assert!(dialog.select_index_clamped(99)); + + assert_eq!(dialog.selected_index, 2); + assert_eq!(dialog.get_selected().unwrap().name, "Model C"); + } + #[test] fn test_dialog_empty_items() { let mut dialog = Dialog::new("Models"); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 4d43d14..5c6ab30 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,4 +2,5 @@ pub mod clipboard; pub mod frecency; pub mod git; pub mod ignore; +pub mod time; pub mod token_counter; diff --git a/src/utils/time.rs b/src/utils/time.rs new file mode 100644 index 0000000..8b4647b --- /dev/null +++ b/src/utils/time.rs @@ -0,0 +1,72 @@ +use std::time::{Duration, SystemTime}; + +pub fn relative_readable_time_from_now(time: SystemTime) -> String { + relative_readable_time(time, SystemTime::now()) +} + +pub fn relative_readable_time(time: SystemTime, now: SystemTime) -> String { + let elapsed = now.duration_since(time).unwrap_or(Duration::ZERO); + let seconds = elapsed.as_secs(); + + if seconds < 60 { + return format!("{}s ago", seconds); + } + + let minutes = seconds / 60; + if minutes < 60 { + return format!("{}m ago", minutes); + } + + let hours = minutes / 60; + if hours < 24 { + return format!("{}{} ago", hours, if hours == 1 { "hr" } else { "hrs" }); + } + + let days = hours / 24; + if days < 30 { + return format!("{}d ago", days); + } + + let months = days / 30; + if months < 12 { + return format!("{}{} ago", months, if months == 1 { "mo" } else { "mos" }); + } + + let years = days / 365; + format!("{}{} ago", years, if years == 1 { "yr" } else { "yrs" }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ago(seconds: u64) -> (SystemTime, SystemTime) { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000_000); + (now - Duration::from_secs(seconds), now) + } + + #[test] + fn formats_single_relative_unit() { + let cases = [ + (2, "2s ago"), + (120, "2m ago"), + (7_200, "2hrs ago"), + (172_800, "2d ago"), + (5_184_000, "2mos ago"), + (63_072_000, "2yrs ago"), + ]; + + for (seconds, expected) in cases { + let (time, now) = ago(seconds); + assert_eq!(relative_readable_time(time, now), expected); + } + } + + #[test] + fn clamps_future_times_to_zero_seconds() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10); + let future = now + Duration::from_secs(5); + + assert_eq!(relative_readable_time(future, now), "0s ago"); + } +} diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index 9070eac..4c0c6bf 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -1,14 +1,42 @@ use crate::theme::ThemeColors; -use crate::ui::components::dialog::{Dialog, DialogAction as FooterAction, DialogItem}; +use crate::ui::components::dialog::{ + Dialog, DialogAction as FooterAction, DialogItem, DialogPosition, +}; use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::{layout::Rect, Frame}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionsDialogFilter { + Active, + All, + Archived, +} + +impl SessionsDialogFilter { + pub fn next(self) -> Self { + match self { + Self::Active => Self::All, + Self::All => Self::Archived, + Self::Archived => Self::Active, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Active => "Active", + Self::All => "All", + Self::Archived => "Archive", + } + } +} + #[derive(Debug)] pub struct SessionsDialogState { pub dialog: Dialog, pub pending_delete: Option<String>, + pub filter: SessionsDialogFilter, } impl SessionsDialogState { @@ -16,44 +44,38 @@ impl SessionsDialogState { Self { dialog, pending_delete: None, + filter: SessionsDialogFilter::Active, } } pub fn with_items(title: impl Into<String>, items: Vec<DialogItem>) -> Self { - let mut dialog = Dialog::with_items(title, items); - dialog = dialog.with_actions(vec![ - FooterAction { - label: "Delete".to_string(), - key: "ctrl+d".to_string(), - }, - FooterAction { - label: "Rename".to_string(), - key: "ctrl+r".to_string(), - }, - ]); + let dialog = Dialog::with_items(title, items) + .with_position(DialogPosition::Left) + .with_collapsible_groups(true); Self { - dialog, + dialog: with_sessions_actions(dialog, SessionsDialogFilter::Active, false), pending_delete: None, + filter: SessionsDialogFilter::Active, } } pub fn refresh_items(&mut self, items: Vec<DialogItem>) { + let previous_dialog = self.dialog.clone(); let title = self.dialog.title.clone(); let was_visible = self.dialog.is_visible(); let selected_index = self.dialog.selected_index; + let scroll_offset = self.dialog.scroll_offset; let items_clone = items.clone(); + let search_query = self.dialog.search_query.clone(); + let collapsed_groups = self.dialog.collapsed_groups(); + let filter = self.filter; - self.dialog = Dialog::with_items(title, items); - self.dialog = self.dialog.clone().with_actions(vec![ - FooterAction { - label: "Delete".to_string(), - key: "ctrl+d".to_string(), - }, - FooterAction { - label: "Rename".to_string(), - key: "ctrl+r".to_string(), - }, - ]); + self.dialog = Dialog::with_items(title, items) + .with_position(DialogPosition::Left) + .with_collapsible_groups(true); + self.dialog.set_collapsed_groups(collapsed_groups); + self.dialog = with_sessions_actions(self.dialog.clone(), filter, false); + self.dialog.set_search_query(search_query); if was_visible { self.dialog.show(); @@ -62,6 +84,9 @@ impl SessionsDialogState { if selected_index < items_clone.len() { self.dialog.selected_index = selected_index; } + self.dialog.scroll_offset = scroll_offset; + self.dialog + .preserve_scrollbar_drag_state_from(&previous_dialog); } } @@ -80,25 +105,11 @@ pub fn render_sessions_dialog( ) { dialog_state.dialog.pending_delete_id = dialog_state.pending_delete.clone(); if dialog_state.pending_delete.is_some() { - let existing_actions = dialog_state.dialog.actions.clone(); - let has_confirm = existing_actions.iter().any(|a| a.label == "confirm"); - if !has_confirm { - dialog_state.dialog.actions = vec![crate::ui::components::dialog::DialogAction { - label: "confirm".to_string(), - key: "ctrl+d".to_string(), - }]; - } + dialog_state.dialog = + with_sessions_actions(dialog_state.dialog.clone(), dialog_state.filter, true); } else { - dialog_state.dialog.actions = vec![ - crate::ui::components::dialog::DialogAction { - label: "Delete".to_string(), - key: "ctrl+d".to_string(), - }, - crate::ui::components::dialog::DialogAction { - label: "Rename".to_string(), - key: "ctrl+r".to_string(), - }, - ]; + dialog_state.dialog = + with_sessions_actions(dialog_state.dialog.clone(), dialog_state.filter, false); } dialog_state.dialog.render(f, area, colors); } @@ -109,6 +120,28 @@ pub fn handle_sessions_dialog_key_event( ) -> SessionsDialogAction { let was_visible = dialog_state.dialog.is_visible(); + if event.code == KeyCode::Char('n') && event.modifiers == KeyModifiers::CONTROL { + return SessionsDialogAction::NewSession; + } + + if event.code == KeyCode::Tab { + dialog_state.filter = dialog_state.filter.next(); + dialog_state.pending_delete = None; + return SessionsDialogAction::ChangeFilter(dialog_state.filter); + } + + if event.code == KeyCode::Char('p') && event.modifiers == KeyModifiers::CONTROL { + if let Some(selected) = dialog_state.dialog.get_selected() { + return SessionsDialogAction::TogglePin(selected.id.clone()); + } + } + + if event.code == KeyCode::Char('a') && event.modifiers == KeyModifiers::CONTROL { + if let Some(selected) = dialog_state.dialog.get_selected() { + return SessionsDialogAction::Archive(selected.id.clone()); + } + } + if event.code == KeyCode::Char('d') && event.modifiers == KeyModifiers::CONTROL { if let Some(selected) = dialog_state.dialog.get_selected() { if dialog_state.pending_delete.as_ref() == Some(&selected.id) { @@ -122,7 +155,12 @@ pub fn handle_sessions_dialog_key_event( if event.code == KeyCode::Char('r') && event.modifiers == KeyModifiers::CONTROL { if let Some(selected) = dialog_state.dialog.get_selected() { - return SessionsDialogAction::Rename(selected.id.clone(), selected.name.clone()); + let title = if selected.provider_id.is_empty() { + selected.name.clone() + } else { + selected.provider_id.clone() + }; + return SessionsDialogAction::Rename(selected.id.clone(), title); } } @@ -156,6 +194,18 @@ pub fn handle_sessions_dialog_mouse_event( ) -> SessionsDialogAction { let was_visible = dialog_state.dialog.is_visible(); let previous_index = dialog_state.dialog.selected_index; + + if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + if let Some(group) = dialog_state + .dialog + .group_at_position(event.column, event.row) + { + dialog_state.dialog.toggle_group_collapsed(&group); + dialog_state.pending_delete = None; + return SessionsDialogAction::Handled; + } + } + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { dialog_state .dialog @@ -199,20 +249,68 @@ pub enum SessionsDialogAction { NotHandled, Close, Select(String), + NewSession, + ChangeFilter(SessionsDialogFilter), + TogglePin(String), + Archive(String), Delete(String), PendingDelete(String), Rename(String, String), } +fn with_sessions_actions( + dialog: Dialog, + filter: SessionsDialogFilter, + confirm_delete: bool, +) -> Dialog { + if confirm_delete { + return dialog.with_actions(vec![FooterAction { + label: "confirm".to_string(), + key: "ctrl+d".to_string(), + }]); + } + + dialog.with_actions(vec![ + FooterAction { + label: filter.label().to_string(), + key: "tab".to_string(), + }, + FooterAction { + label: "New".to_string(), + key: "ctrl+n".to_string(), + }, + FooterAction { + label: "Pin".to_string(), + key: "ctrl+p".to_string(), + }, + FooterAction { + label: "Archive".to_string(), + key: "ctrl+a".to_string(), + }, + FooterAction { + label: "Delete".to_string(), + key: "ctrl+d".to_string(), + }, + FooterAction { + label: "Rename".to_string(), + key: "ctrl+r".to_string(), + }, + ]) +} + #[cfg(test)] mod tests { use super::*; fn session_item(id: &str, name: &str) -> DialogItem { + session_item_in_group(id, name, "Today") + } + + fn session_item_in_group(id: &str, name: &str, group: &str) -> DialogItem { DialogItem { id: id.to_string(), name: name.to_string(), - group: "Today".to_string(), + group: group.to_string(), description: String::new(), tip: None, provider_id: String::new(), @@ -228,6 +326,15 @@ mod tests { } } + fn scroll_down(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::ScrollDown, + column, + row, + modifiers: KeyModifiers::NONE, + } + } + #[test] fn mouse_click_on_item_selects_session() { let mut state = init_sessions_dialog( @@ -245,7 +352,7 @@ mod tests { height: 30, }; - let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 10)); + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 8)); assert_eq!( action, @@ -255,7 +362,7 @@ mod tests { } #[test] - fn mouse_click_on_group_header_does_not_select_session() { + fn mouse_click_on_group_header_toggles_workspace_collapse() { let mut state = init_sessions_dialog("Sessions", vec![session_item("session-1", "First session")]); state.dialog.show(); @@ -266,9 +373,82 @@ mod tests { height: 30, }; - let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 8)); + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 6)); - assert_eq!(action, SessionsDialogAction::NotHandled); + assert_eq!(action, SessionsDialogAction::Handled); + assert!(state.dialog.is_group_collapsed("Today")); assert_eq!(state.dialog.selected_index, 0); + + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 6)); + + assert_eq!(action, SessionsDialogAction::Handled); + assert!(!state.dialog.is_group_collapsed("Today")); + } + + #[test] + fn mouse_wheel_scrolls_session_list() { + let items = (0..20) + .map(|idx| session_item(&format!("session-{idx}"), &format!("Session {idx}"))) + .collect(); + let mut state = init_sessions_dialog("Sessions", items); + state.dialog.show(); + state.dialog.visible_row_count = 5; + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_sessions_dialog_mouse_event(&mut state, scroll_down(4, 8)); + + assert_eq!(action, SessionsDialogAction::Handled); + assert!(state.dialog.scroll_offset > 0); + } + + #[test] + fn refresh_preserves_scroll_and_visible_search_query() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item("session-1", "First session"), + session_item("session-2", "Second session"), + ], + ); + state.dialog.show(); + state.dialog.set_search_query("Second"); + state.dialog.scroll_offset = 3; + + state.refresh_items(vec![ + session_item("session-1", "First session"), + session_item("session-2", "Second session"), + session_item("session-3", "Third session"), + ]); + + assert_eq!(state.dialog.search_query, "Second"); + assert_eq!(state.dialog.search_textarea.lines().join(""), "Second"); + assert_eq!(state.dialog.scroll_offset, 3); + } + + #[test] + fn refresh_preserves_collapsed_workspaces() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item_in_group("session-1", "First session", "Workspace A"), + session_item_in_group("session-2", "Second session", "Workspace B"), + ], + ); + state.dialog.show(); + state.dialog.toggle_group_collapsed("Workspace A"); + + state.refresh_items(vec![ + session_item_in_group("session-1", "First session", "Workspace A"), + session_item_in_group("session-2", "Second session", "Workspace B"), + session_item_in_group("session-3", "Third session", "Workspace A"), + ]); + + assert!(state.dialog.is_group_collapsed("Workspace A")); + assert!(!state.dialog.is_group_collapsed("Workspace B")); } } From 611c8cbd2184ee79987335e9503dfa02e1b218f5 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 00:34:05 +0800 Subject: [PATCH 083/226] fix: defer session creation until first message with pending title support. Extract `start_blank_session` to create a blank UI state without immediately persisting a session record. Store the optional title as `pending_session_title` and only pass it to `create_new_session` when the user sends their first message via `handle_message_input`. Move `Ctrl+N` from a global shortcut to the sessions dialog only, and refactor `/new` and `/home` slash commands to use the new method. Clear `pending_session_title` on session switch, archive, delete, and fork to prevent stale titles. --- src/app.rs | 161 ++++++++++++++++++++++++++++++----- src/views/sessions_dialog.rs | 14 +++ 2 files changed, 154 insertions(+), 21 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0a28e09..9253355 100644 --- a/src/app.rs +++ b/src/app.rs @@ -191,6 +191,7 @@ pub struct App { pub tool_permissions: crate::tools::ToolPermissions, pub skills_dirs: Vec<std::path::PathBuf>, pub is_streaming: bool, + pending_session_title: Option<String>, session_view_states: std::collections::HashMap<String, ClientSessionState>, session_spinner_frame: usize, last_frame_size: ratatui::layout::Rect, @@ -391,6 +392,7 @@ impl App { skills_dirs: loaded_config.inventory.opencode_skills_dirs, // Note: skills_dirs is legacy; skill loading is now handled by src/skill/mod.rs is_streaming: false, + pending_session_title: None, session_view_states: std::collections::HashMap::new(), session_spinner_frame: 0, last_frame_size: ratatui::layout::Rect::default(), @@ -547,6 +549,7 @@ impl App { if !self.session_manager.switch_session(session_id) { return false; } + self.pending_session_title = None; self.load_session_view_state(session_id); self.base_focus = if self.chat_state.chat.messages.is_empty() && !self.is_streaming { BaseFocus::Home @@ -556,8 +559,28 @@ impl App { true } + fn start_blank_session(&mut self, title: Option<String>) { + self.save_active_session_view_state(); + self.pending_session_title = title.and_then(|title| { + let title = title.trim().to_string(); + if title.is_empty() { + None + } else { + Some(title) + } + }); + self.session_manager.clear_current_session(); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, usize::MAX); + self.refresh_sessions_dialog(); + } + fn create_new_session(&mut self, title: Option<String>) -> String { self.save_active_session_view_state(); + self.pending_session_title = None; let session_id = self.session_manager.create_session(title); self.session_view_states.insert( session_id.clone(), @@ -1040,7 +1063,7 @@ impl App { true } SessionsDialogAction::NewSession => { - self.create_new_session(None); + self.start_blank_session(None); self.sessions_dialog_state.dialog.hide(); self.overlay_focus = OverlayFocus::None; true @@ -1079,6 +1102,7 @@ impl App { let _ = self.session_manager.set_session_archived(&id, archived); if was_current && archived { self.save_active_session_view_state(); + self.pending_session_title = None; self.session_manager.clear_current_session(); self.chat_state.chat.clear(); self.input.clear(); @@ -1112,6 +1136,7 @@ impl App { } self.refresh_sessions_dialog(); if was_current { + self.pending_session_title = None; self.chat_state.chat.clear(); self.base_focus = BaseFocus::Home; self.sessions_dialog_state.dialog.hide(); @@ -1371,10 +1396,6 @@ impl App { self.which_key_state.show(); true } - KeyCode::Char('n') if key.modifiers == event::KeyModifiers::CONTROL => { - self.create_new_session(None); - true - } KeyCode::Tab => { if self.agent == "Plan" { self.agent = "Build".to_string(); @@ -2038,16 +2059,11 @@ impl App { } else { Some(parsed.args.join(" ")) }; - self.create_new_session(title); + self.start_blank_session(title); return; } if parsed.name == "home" { - self.save_active_session_view_state(); - self.chat_state.chat.clear(); - self.input.clear(); - self.base_focus = BaseFocus::Home; - self.session_manager.clear_current_session(); - self.sync_active_streaming_flag(); + self.start_blank_session(None); return; } if parsed.name == "themes" { @@ -2090,6 +2106,7 @@ impl App { if parsed.name == "new" || parsed.name == "home" { self.chat_state.chat.clear(); self.base_focus = BaseFocus::Home; + self.pending_session_title = None; self.session_manager.clear_current_session(); } else if self.base_focus == BaseFocus::Home && parsed.name != "refreshmodels" @@ -2204,16 +2221,11 @@ impl App { } else { Some(parsed.args.join(" ")) }; - self.create_new_session(title); + self.start_blank_session(title); return; } if parsed.name == "home" { - self.save_active_session_view_state(); - self.chat_state.chat.clear(); - self.input.clear(); - self.base_focus = BaseFocus::Home; - self.session_manager.clear_current_session(); - self.sync_active_streaming_flag(); + self.start_blank_session(None); return; } if parsed.name == "themes" { @@ -2253,6 +2265,7 @@ impl App { if parsed.name == "new" || parsed.name == "home" { self.chat_state.chat.clear(); self.base_focus = BaseFocus::Home; + self.pending_session_title = None; self.session_manager.clear_current_session(); } else if self.base_focus == BaseFocus::Home && parsed.name != "refreshmodels" { self.base_focus = BaseFocus::Chat; @@ -2523,7 +2536,7 @@ impl App { }) .unwrap_or_default(); - let _ = self.session_manager.create_session(Some(fork_title)); + let _ = self.create_new_session(Some(fork_title)); for msg in &messages_to_fork { let _ = self.session_manager.add_message_to_current_session(msg); } @@ -3722,7 +3735,10 @@ impl App { fn handle_message_input(&mut self, msg: String) { if !msg.is_empty() && self.base_focus == BaseFocus::Home { if self.session_manager.get_current_session_id().is_none() { - let session_title = Self::generate_title_from_message(&msg); + let session_title = self + .pending_session_title + .take() + .unwrap_or_else(|| Self::generate_title_from_message(&msg)); self.create_new_session(Some(session_title)); } let mut user_message = crate::session::types::Message::user(&msg); @@ -3990,6 +4006,72 @@ mod tests { use super::*; use crate::command::parser::parse_input; + fn test_app() -> App { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + + let theme = Theme::load_from_file("src/theme.json") + .unwrap_or_else(|_| Theme::load_from_file("src/generated_themes/ayu.json").unwrap()); + let colors = theme.get_colors(true); + + App { + running: true, + version: "test".to_string(), + input: Input::new(), + command_registry: registry, + session_manager: SessionManager::new(), + home_state: init_home(), + chat_state: init_chat(Chat::new(), "Build", &colors), + suggestions_popup_state: init_suggestions_popup(Popup::new()), + models_dialog_state: init_models_dialog("Models", vec![]), + themes_dialog_state: init_themes_dialog("Themes", vec![]), + themes_dialog_original_theme_index: 0, + themes_dialog_committed: false, + connect_dialog_state: init_connect_dialog(), + connect_dialog_mode: ConnectDialogMode::ProviderSelection, + openai_oauth_flow_state: init_openai_oauth_flow(), + sessions_dialog_state: init_sessions_dialog("Sessions", vec![]), + session_rename_dialog_state: init_session_rename_dialog(colors), + permission_dialog_state: init_permission_dialog(), + question_dialog_state: init_question_dialog(), + skills_dialog_state: crate::views::skills_dialog::init_skills_dialog("Skills", vec![]), + which_key_state: crate::views::which_key::init_which_key(), + timeline_dialog_state: crate::views::timeline_dialog::init_timeline_dialog(), + message_actions_index: None, + message_actions_dialog: None, + api_key_input: crate::ui::components::api_key_input::ApiKeyInput::new(), + openai_oauth_receiver: None, + openai_oauth_in_progress: false, + prefs_dao: None, + agent: "Build".to_string(), + agent_steps: std::collections::HashMap::new(), + provider_timeouts: std::collections::HashMap::new(), + model: "test-model".to_string(), + provider_name: "test-provider".to_string(), + cwd: ".".to_string(), + base_focus: BaseFocus::Home, + overlay_focus: OverlayFocus::None, + ctrl_c_press_count: 0, + last_ctrl_c_time: std::time::Instant::now(), + themes: vec![theme], + current_theme_index: 0, + dark_mode: true, + sounds: crate::sound::ResolvedSoundsConfig::default(), + tool_permissions: crate::tools::ToolPermissions::new(".".to_string()), + skills_dirs: Vec::new(), + is_streaming: false, + pending_session_title: None, + session_view_states: std::collections::HashMap::new(), + session_spinner_frame: 0, + last_frame_size: ratatui::layout::Rect::default(), + last_animation_update: std::time::Instant::now(), + last_session_spinner_update: std::time::Instant::now(), + discovery: None, + cached_usage_text: String::new(), + cached_usage_check: (0, 0), + } + } + #[test] fn commands_can_submit_while_streaming() { let input_type = parse_input("/models"); @@ -4004,4 +4086,41 @@ mod tests { assert!(!App::can_submit_input(&input_type, true)); assert!(App::can_submit_input(&input_type, false)); } + + #[test] + fn start_blank_session_does_not_create_session_record() { + let mut app = test_app(); + app.create_new_session(Some("Existing".to_string())); + + app.start_blank_session(None); + + assert!(app.session_manager.get_current_session_id().is_none()); + assert_eq!(app.session_manager.list_sessions().len(), 1); + assert_eq!(app.base_focus, BaseFocus::Home); + } + + #[test] + fn start_blank_session_keeps_optional_title_for_next_real_session() { + let mut app = test_app(); + + app.start_blank_session(Some(" Named draft ".to_string())); + + assert!(app.session_manager.list_sessions().is_empty()); + assert_eq!(app.pending_session_title.as_deref(), Some("Named draft")); + } + + #[test] + fn ctrl_n_is_not_a_global_new_session_shortcut() { + let mut app = test_app(); + app.create_new_session(Some("Existing".to_string())); + + let handled = app.handle_base_keys(KeyEvent::new( + KeyCode::Char('n'), + event::KeyModifiers::CONTROL, + )); + + assert!(!handled); + assert!(app.session_manager.get_current_session_id().is_some()); + assert_eq!(app.session_manager.list_sessions().len(), 1); + } } diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index 4c0c6bf..26d746f 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -335,6 +335,20 @@ mod tests { } } + #[test] + fn ctrl_n_requests_new_session_when_sessions_dialog_is_focused() { + let mut state = + init_sessions_dialog("Sessions", vec![session_item("session-1", "First session")]); + state.dialog.show(); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + ); + + assert_eq!(action, SessionsDialogAction::NewSession); + } + #[test] fn mouse_click_on_item_selects_session() { let mut state = init_sessions_dialog( From 6ac36eba269f8fd8e67417b61d9456891a0023ad Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 01:31:11 +0800 Subject: [PATCH 084/226] feat: implement multi-step subagent tool loops with child session navigation. - Refactor `run_subagent()` to use `stream_with_tools()` for full agentic loops - Create child sessions with `parent_session_identifier` for session tree navigation - Enable `ctrl+x` down / left-right / up navigation between parent and child sessions - Replay `MessageRole::Tool` as model-visible observations via `tool_message_observation()` - Stream subagent text/reasoning/tool chunks into dedicated child session views - Add database migration v3 for `parent_session_identifier` column - Introduce `SubagentStarted`/`SubagentChunk` chunk messages for real-time streaming - Render subagent tab bar and OpenCode-style tool call summaries in chat UI --- _docs/__PARITY.md | 66 ++++-- src/agent/subagent.rs | 66 +++++- src/app.rs | 379 ++++++++++++++++++++++++++++++--- src/llm/client.rs | 35 ++- src/llm/mod.rs | 12 ++ src/main.rs | 1 + src/persistence/conversions.rs | 3 +- src/persistence/history.rs | 87 +++++--- src/persistence/migrations.rs | 28 +++ src/session/manager.rs | 75 ++++++- src/session/types.rs | 3 + src/tools/aisdk_bridge.rs | 14 +- src/tools/init.rs | 4 +- src/tools/task.rs | 88 +++++++- src/ui/components/chat.rs | 142 ++++++++++++ src/views/chat.rs | 65 ++++++ src/views/which_key.rs | 40 ++++ 17 files changed, 1024 insertions(+), 84 deletions(-) diff --git a/_docs/__PARITY.md b/_docs/__PARITY.md index 611c295..7a29b55 100644 --- a/_docs/__PARITY.md +++ b/_docs/__PARITY.md @@ -1,6 +1,6 @@ # Crabcode vs OpenCode — Core Harness Feature Parity Audit -> Generated: 2026-05-11 | Scope: agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands, permissions. +> Generated: 2026-05-11 | Updated: 2026-05-19 | Scope: agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands, permissions. ## Feature Table @@ -13,6 +13,7 @@ | **1.5** | Plan/Build mode toggle | User-toggleable mode; plan = read-only tools | `AgentToolPolicies` at `src/tools/permission.rs:71` — plan blocks write/edit/bash, build allows all. No user-facing toggle; mode set at stream start | **Partial**: Mode exists but not user-toggleable mid-conversation | | **1.6** | Permission preflight during tool execution | `preflight()` checks before each tool call, mid-stream permission dialogs | `permissions.preflight()` in `aisdk_bridge.rs:90-98`, sends `PermissionRequest` chunk, awaits UI response via oneshot | **OK** | | **1.7** | Configurable max steps per agent | Per-agent `max_steps` in config; "max steps reached" prompt injected | `agent_max_steps: Option<usize>` at `src/llm/client.rs:87` | **OK** | +| **1.8** | Model-visible replayable history across turns | `MessageV2` persists text, reasoning, tool calls/results, attachments, and reconstructs provider messages | `convert_messages()` now replays `MessageRole::Tool` as model-visible observations via `tool_message_observation()` | **Partial**: tool results are visible across turns, but not canonical typed tool-call/result parts with attachments | | **2.1** | Provider-specific header (Beast for OpenAI) | Detailed "beast" prompt for OpenAI, concise for Anthropic | `get_beast_prompt()` at `src/prompt/mod.rs:100`, `get_anthropic_prompt()` at `:135`, `get_codex_prompt()` at `:187` | **OK** | | **2.2** | Provider-specific behavior instructions | Anthropic-specific, Gemini-specific, Codex-specific | `get_gemini_prompt()` at `src/prompt/mod.rs:160`, `get_codex_prompt()` at `:187` | **OK** | | **2.3** | Environment context block (workdir, git, platform, date) | `<env>` XML block | `get_environment_context()` at `src/prompt/mod.rs:224` | **OK** | @@ -26,8 +27,8 @@ | **3.4** | Scout subagent | Read-only, can clone repos for external docs/deps research | **Not implemented** | **GAP** | | **3.5** | VLM-agent subagent | For image analysis (delegates to vision models) | **Not implemented** | **GAP** | | **3.6** | Compaction/Title/Summary hidden agents | System agents that run automatically for session compaction, title generation, summarization | **Not implemented** | **GAP** | -| **3.7** | Subagent multi-step iteration (tool-calling loop within subagent) | Subagents run full agentic loops (stream + tool execution + recursion) | `run_subagent()` at `src/agent/subagent.rs:119` — runs a **single** `stream_text()` call and collects text output. No tool-calling iteration loop inside subagents | **CRITICAL GAP**: Subagents are single-shot LLM calls, not multi-step agents | -| **3.8** | Child sessions / session tree (parent/child navigation) | Subagents create child sessions, navigable in UI | No session tree. Subagents just return a string result | **GAP** | +| **3.7** | Subagent multi-step iteration (tool-calling loop within subagent) | Subagents run full agentic loops (stream + tool execution + recursion) | `run_subagent()` uses `stream_with_tools()` with a scoped registry and relays text/reasoning/tool rows into the child stream | **OK** | +| **3.8** | Child sessions / session tree (parent/child navigation) | Subagents create child sessions, navigable in UI | Task calls create persisted child sessions with `parent_id`, stream transcript rows into them, and expose OpenCode-style `ctrl+x` down / left-right / up navigation | **Partial**: no background resume/task_status integration yet | | **3.9** | Agent mode system (primary vs subagent vs all) | Each agent has a `mode` that controls visibility and invocation | No mode field. Plan/build handled separately via policies | **GAP** | | **3.10** | Hidden agents (hidden from autocomplete, invokable via Task) | Agents can be marked `hidden: true` | No hidden agent concept | **GAP** | | **3.11** | Task permissions (which agents can invoke which subagents) | Per-agent `task_permissions` control | No task permission system. Primary agent can always invoke explore/general | **GAP** | @@ -44,10 +45,15 @@ | **4.10** | todowrite | ✓ | `src/tools/todowrite.rs` (JSON-validated structured task list) | **OK** | | **4.11** | webfetch | ✓ | `src/tools/webfetch.rs` (fetch + handcrafted HTML-to-markdown) | **OK** | | **4.12** | question | ✓ | `src/tools/question.rs` (oneshot-based UI question prompts) | **OK** | -| **4.13** | websearch | Exa AI web search | **Not implemented** | **GAP** | -| **4.14** | extract-images | Save session images to disk for VLM | **Not implemented** | **GAP** | +| **4.13** | websearch | Exa/Parallel web search | **Not implemented** | **GAP** | +| **4.14** | Tool/media attachments | Tool outputs can carry attachments; images/resources are normalized and replayed to the model | **Not implemented** | **GAP** | | **4.15** | apply_patch | Apply diffs/patch files | **Not implemented** | **GAP** | | **4.16** | lsp | LSP code intelligence (experimental) | **Not implemented** | **GAP** | +| **4.17** | task_status | Poll/wait for background subagent tasks | **Not implemented** | **GAP** | +| **4.18** | repo_clone | Clone external repositories into managed cache for scout/reference workflows | **Not implemented** | **GAP** | +| **4.19** | repo_overview | Summarize cached/local repository structure and dependency files | **Not implemented** | **GAP** | +| **4.20** | plan_exit | Model-callable plan approval / build-agent handoff | **Not implemented** | **GAP** | +| **4.21** | invalid | Fallback tool for unknown/invalid tool calls | **Not implemented** | **GAP** | | **5.1** | Discovery: `.opencode/skills/<name>/SKILL.md` | OpenCode native layout | Scanned via `{skill,skills}/**/SKILL.md` in `.opencode/`, `.crabcode/`, config dirs at `src/skill/mod.rs:67-77` | **OK** | | **5.2** | Discovery: `~/.config/opencode/skills/<name>/SKILL.md` | Global config skills | `global_opencode` at `src/skill/mod.rs:39` | **OK** | | **5.3** | Discovery: `.claude/skills/` (project + home) | Claude Code compat | Walk-up `.claude/skills/**/SKILL.md` + `~/.claude/skills/**/SKILL.md` at `src/skill/mod.rs:46-64` | **OK** | @@ -56,12 +62,17 @@ | **5.6** | YAML frontmatter with `name` and `description` | Required in SKILL.md | Parsed at `src/skill/mod.rs:184-233`, with fallback YAML sanitization for Claude Code compat | **OK** | | **5.7** | Pattern-based skill permissions | `"internal-*": "deny"` style glob patterns | **Not implemented** | **GAP** | | **5.8** | Skill tool lists available skills in description | Skill names embedded in tool definition description | `build_description()` at `src/tools/skill.rs:15-48` appends `<available_skills>` XML to tool description | **OK** | -| **6.1** | Agent config via `opencode.json` | `agents` field in JSON config | Crabcode reads opencode.json for compat via `src/config/configuration.rs` | **OK** | +| **6.1** | Agent config via `opencode.json` | `agent` field in JSON config | Crabcode reads opencode.json, but only applies limited `agent.*.tools` and `agent.*.steps` fields in `src/config/configuration.rs` | **Partial** | | **6.2** | Agent config via `~/.config/opencode/agents/<name>.md` | Markdown frontmatter with agent definitions | **Not implemented** | **GAP** | | **6.3** | Per-agent: description, model, temperature, max_steps | Full per-agent override of all params | Only has global `LlmSessionConfig` at `src/agent/config.rs:4` (provider, model, api_key). No per-agent overrides | **GAP** | | **6.4** | Per-agent: mode (primary/subagent/all) | Controls where agent is visible/usable | Not implemented (only plan/build context) | **GAP** | | **6.5** | Per-agent: hidden, color, top_p, permissions, task_permissions | Agent metadata fields | Not implemented | **GAP** | | **6.6** | Agent creation wizard (`opencode agent create`) | Interactive agent creation | Not implemented | **GAP** | +| **6.7** | Config `instructions` array | Additional instruction files/patterns beyond AGENTS/CLAUDE discovery | Key is allowed but marked unimplemented by `collect_unimplemented_keys()` | **GAP** | +| **6.8** | Config `reference` aliases | Named local/git references that can be mentioned as `@alias` or `@alias/path` | Not implemented; key is not accepted by `opencode_allowed_keys()` | **GAP** | +| **6.9** | Config `small_model`, `username` | Small model for title/summary agents and display username override | Not implemented | **GAP** | +| **6.10** | Provider config merge and enable/disable filters | Custom provider/model overrides plus `enabled_providers` / `disabled_providers` | Only `provider.*.options.timeout` is parsed; provider/model merge and filters are not applied | **GAP** | +| **6.11** | Formatter, LSP, attachment, tool_output config | Runtime config for formatters, LSP servers, image limits, and truncation thresholds | Keys are mostly absent or marked unimplemented; no corresponding runtime services | **GAP** | | **7.1** | User-defined commands via `.opencode/commands/<name>.md` | Markdown files define custom slash commands | Not implemented. Only Rust function handlers for built-in commands | **MAJOR GAP** | | **7.2** | Command frontmatter: description, agent, model, subtask | YAML frontmatter in custom command files | Not implemented | **GAP** | | **7.3** | Template variables ($ARGUMENTS, $INPUT, $CWD, etc.) | Template substitution in custom commands | Not implemented | **GAP** | @@ -73,6 +84,17 @@ | **8.4** | Per-agent override of global permissions | Agent-level permission config overrides global | Not implemented. Only mode-based (plan/build) | **GAP** | | **8.5** | External directory gating | Blocks/prompts for paths outside workdir | `is_outside_workdir()` at `src/tools/permission.rs:377` | **OK** | | **8.6** | Doom loop recovery prompts | Persistent tool failures trigger recovery | Not implemented | **GAP** | +| **9.1** | MCP runtime | Stdio/SSE/Streamable HTTP clients, OAuth, tools, prompts, resources | Config key is accepted, but there is no MCP runtime or tool/resource integration | **MAJOR GAP** | +| **9.2** | Plugin runtime and hooks | Built-in/external plugins plus hooks like `tool.execute.before`, `tool.execute.after`, system transforms | OpenCode `plugin` key is ignored by Crabcode; no plugin loader or hook pipeline | **MAJOR GAP** | +| **9.3** | Local custom JS/TS tools | Loads `{tool,tools}/*.{js,ts}` from config directories and exposes exports as tools | Not implemented | **GAP** | +| **9.4** | Plugin auth/provider integrations | Internal plugins add provider/auth behavior for Codex, Copilot, GitLab, Poe, Cloudflare, Azure, DigitalOcean | Not implemented | **GAP** | +| **10.1** | Snapshots and patch parts | Tracks filesystem snapshots before/after steps and persists patch parts on messages | Not implemented | **GAP** | +| **10.2** | File-state revert/unrevert | Reverts file changes and can undo/redo snapshot state for a message range | Crabcode message action "Undo" only truncates messages; it does not revert file changes | **GAP** | +| **10.3** | Session sharing | `share: manual/auto/disabled` and shared session URLs | Not implemented; OpenCode `share` key is ignored | **GAP** | +| **10.4** | Background/resumable subagents | `task(background=true)`, `task_id` resume, `task_status`, and parent continuation | Not implemented | **GAP** | +| **10.5** | Durable tool-output truncation | Large output saved to data dir with preview and `tool_output` limits | Crabcode truncates some tool output inline; no durable output file/index | **GAP** | +| **10.6** | User and tool attachments | File/image/PDF prompt parts, image normalization, media extraction from tool results | Not implemented | **GAP** | +| **10.7** | Post-edit formatting and diagnostics | Edit/write/patch run configured formatters and surface LSP diagnostics | Not implemented | **GAP** | ## Priority-Ranked Actionable Gaps @@ -80,8 +102,11 @@ | # | Gap | Location | Notes | |---|-----|----------|-------| -| **C1** | **Subagents are single-shot, not multi-step** | `src/agent/subagent.rs:119-238` | `run_subagent()` calls `stream_text()` once and collects text. No tool-calling iteration loop. The relay loop at lines `219-232` only handles `Text`/`Failed`/`End` — it doesn't relay tool results back to the model for another step. Need a full agentic loop inside subagents (call → tool results → next call, up to step limit). | -| **C2** | **No custom user-defined commands** | `src/command/` | OpenCode's `.opencode/commands/<name>.md` system is entirely absent. Crabcode only has hardcoded Rust function handlers. Need: (a) `.opencode/commands/` + `~/.config/opencode/commands/` directory discovery, (b) Markdown file parser with YAML frontmatter, (c) template engine for `$ARGUMENTS`, `$INPUT`, `$CWD`, (d) shell injection `$(...)`, (e) `@file` references. Entirely new module needed. | +| **C1** | **No custom user-defined commands** | `src/command/` | OpenCode's `.opencode/commands/<name>.md` system is entirely absent. Crabcode only has hardcoded Rust function handlers. Need: (a) `.opencode/commands/` + `~/.config/opencode/commands/` directory discovery, (b) Markdown file parser with YAML frontmatter, (c) template engine for `$ARGUMENTS`, `$INPUT`, `$CWD`, (d) shell injection `$(...)`, (e) `@file` references. Entirely new module needed. | + +Recently addressed from the previous critical list: +- Subagents now run through the multi-step `stream_with_tools()` loop and stream into child sessions. +- Tool-result messages are now replayed into later model requests as observations. Remaining work is canonical MessageV2-style typed history with attachments/full outputs. ### HIGH @@ -89,25 +114,39 @@ |---|-----|----------|-------| | **H1** | **No multi-agent config (per-agent model, temp, max_steps, mode)** | `src/agent/config.rs`, `src/agent/manager.rs` | `LlmSessionConfig` is a global singleton (`OnceLock`). Need a `config/agents/<name>.md` parser + per-agent struct with: description, temperature, model, max_steps, mode (primary/subagent/all), hidden, color, top_p, permissions, task_permissions. The `AgentManager::new()` at `manager.rs:42` hardcodes `name: "default"` and uses a global provider config. | | **H2** | **No agent modes (primary/subagent/all/hidden)** | `src/agent/types.rs`, `src/agent/manager.rs` | `Agent` struct at `manager.rs:10` has no `mode` field. Need: enum `AgentMode::Primary | Subagent | All`, hidden flag, integration with tool permission filtering and system prompt visibility. | -| **H3** | **No child sessions / session tree for subagents** | `src/agent/subagent.rs`, `src/session/` | Subagents return a raw string. OpenCode creates child sessions with parent→child navigation. Need: session tree in `SessionManager`, parent_id on Session, UI for navigating child sessions in timeline. | +| **H3** | **Child session tree still lacks background/task_status semantics** | `src/agent/subagent.rs`, `src/session/`, `src/app.rs` | Task-created child sessions now exist and are navigable in the TUI. Remaining OpenCode parity: background task resume, task_status polling, and richer child-session lifecycle metadata. | | **H4** | **Wildcard and pattern-based permission system** | `src/tools/permission.rs` | `AgentToolPolicies` only supports exact tool name matching per mode. Need: glob/wildcard matching (`"mymcp_*": "deny"`), pattern-specific bash permissions (`"git push": "ask"`, `"git *": "allow"`), per-agent permission overrides. | | **H5** | **Scout subagent** | New: `src/agent/subagent.rs` | Read-only subagent that can clone repos for researching external docs/dependencies. Similar to Explore but with git clone capability and web search. | -| **H6** | **VLM-agent subagent** | New: `src/agent/subagent.rs` | Subagent for image analysis. Needs: `extract-images` tool, forwarding images to vision-capable models, returning analysis results. | +| **H6** | **VLM-agent subagent** | New: `src/agent/subagent.rs` | Subagent for image analysis. Needs attachment/media support, vision-capable model routing, and image/PDF prompt-part handling. | | **H7** | **Hidden/auto agents (compaction, title, summary)** | New: `src/agent/` | System agents that run automatically: compaction (truncates conversation context), title (generates session title), summary (summarizes long contexts). These are hidden from user but invokable via Task tool. | +| **H8** | **No MCP runtime** | New: `src/mcp/`, `src/tools/registry.rs` | OpenCode supports stdio/SSE/Streamable HTTP MCP servers, OAuth, tools, prompts, and resources. Crabcode only accepts the `mcp` config key and does not expose MCP tools/resources to the model. | +| **H9** | **No plugin runtime, hooks, or dynamic custom tools** | New: `src/plugin/`, `src/tools/registry.rs` | OpenCode loads internal/external plugins, fires hooks around system/tool execution, and loads local `{tool,tools}/*.{js,ts}` exports as tools. Crabcode ignores OpenCode `plugin` config and has no plugin hook pipeline. | +| **H10** | **No snapshot-backed file revert** | New: `src/snapshot/`, `src/session/revert.rs` | OpenCode captures snapshots around steps, persists patch parts, and can revert/unrevert file changes. Crabcode's message action "Undo" only truncates messages and does not restore the filesystem. | +| **H11** | **No background/resumable task jobs** | `src/tools/task.rs`, `src/agent/subagent.rs` | OpenCode `task` supports `background`, `task_id` resume, `task_status`, background result injection, and automatic parent continuation. Crabcode task returns a single string from a fresh subagent call. | +| **H12** | **No attachment/media pipeline** | `src/session/`, `src/llm/`, `src/tools/` | OpenCode supports file/image/PDF prompt parts, image normalization, tool-result attachments, and provider-specific media replay. Crabcode has no corresponding session or provider message shape. | +| **H13** | **OpenCode config schema is only partially implemented** | `src/config/configuration.rs` | Additional OpenCode config fields such as `instructions`, `reference`, `small_model`, `username`, `enabled_providers`, `disabled_providers`, `formatter`, `lsp`, `attachment`, `tool_output`, `snapshot`, `share`, `plugin`, and `experimental` are absent, ignored, or reported as unimplemented. | ### MEDIUM | # | Gap | Location | Notes | |---|-----|----------|-------| | **M1** | **No @mention subagent invocation** | `src/command/parser.rs` | User typing `@explore find all tests` should route directly to the explore subagent. Need: extend `parse_input()` to detect `@subagent_name` prefix. | -| **M2** | **No websearch tool** | New: `src/tools/websearch.rs` | OpenCode uses Exa AI for web search. Crabcode has no equivalent. | -| **M3** | **No extract-images tool** | New: `src/tools/extract_images.rs` | Tool to save session images to disk for VLM agent consumption. Prerequisite for VLM-agent. | +| **M2** | **No websearch tool** | New: `src/tools/websearch.rs` | OpenCode supports Exa/Parallel web search. Crabcode has no equivalent. | +| **M3** | **No tool/media attachments** | `src/tools/types.rs`, `src/session/types.rs` | Tool results cannot carry attachments or media content, so current OpenCode behavior for MCP image/resource results, webfetch image output, and vision workflows has no place to land. | | **M4** | **No apply_patch tool** | New: `src/tools/apply_patch.rs` | Apply unified diffs to files. Needed for patch-based editing workflows. | | **M5** | **No LSP tool** | New: `src/tools/lsp.rs` | LSP code intelligence (go-to-def, find-references, diagnostics). | | **M6** | **No doom loop recovery** | `src/tools/permission.rs`, `src/llm/client.rs` | When tools persistently fail, inject recovery prompts to break the loop. | | **M7** | **Skill walk-up not bounded by git root** | `src/skill/mod.rs:50-64` | Walk-up for `.claude/` and `.agents/` skill dirs goes all the way to filesystem root. Should stop at git worktree boundary (like OpenCode). | | **M8** | **No pattern-based skill permissions** | `src/skill/mod.rs`, `src/tools/skill.rs` | OpenCode supports `"internal-*": "deny"` style skill access control. Crabcode loads all skills unconditionally. | | **M9** | **Plan/Build mode not user-toggleable mid-conversation** | `src/app.rs` (streaming setup) | Agent mode is set once at stream start. User should be able to toggle plan/build during conversations. | +| **M10** | **No task_status tool** | New: `src/tools/task_status.rs` | Needed once background subagents exist; lets the model poll or wait for asynchronous child-session work. | +| **M11** | **No repo_clone / repo_overview tools or reference aliases** | New: `src/tools/repo_clone.rs`, `src/tools/repo_overview.rs`, `src/reference/` | Required for OpenCode's scout/reference workflow: clone external repositories into a managed cache and inspect their structure without touching the user workspace. | +| **M12** | **No plan_exit handoff tool** | `src/agent/plan.rs`, `src/tools/` | OpenCode uses a model-callable plan exit flow to ask for plan approval and switch to build agent. Crabcode has plan/build policies but no equivalent tool-mediated handoff. | +| **M13** | **No formatter service or post-edit diagnostics** | New: `src/format/`, `src/lsp/` | OpenCode runs configured formatters after write/edit/patch and reports LSP diagnostics after patch. Crabcode edits files without that post-processing loop. | +| **M14** | **No durable tool-output truncation files** | New: `src/tools/truncate.rs` | OpenCode writes large outputs to a data-dir file and returns a preview plus path. Crabcode has per-tool inline truncation only, which prevents later grep/read over the full captured output. | +| **M15** | **No `instructions` config ingestion** | `src/config/configuration.rs`, `src/prompt/` | OpenCode supports extra instruction files/patterns from config. Crabcode allows the key but marks it unimplemented and only uses AGENTS/CLAUDE-style rule discovery. | +| **M16** | **No session sharing/autoshare** | `src/session/`, `src/config/configuration.rs` | OpenCode's `share` / `autoshare` config and shared session URLs are not represented. Crabcode ignores the OpenCode `share` key. | +| **M17** | **No invalid-tool fallback** | `src/tools/registry.rs` | OpenCode has an `invalid` tool to handle unknown/invalid tool calls cleanly. Crabcode has no equivalent fallback. | ### LOW @@ -117,3 +156,6 @@ | **L2** | **No agent color theming** | `src/agent/config.rs` | Per-agent color for UI differentiation of which agent is speaking. | | **L3** | **No agent creation wizard** | New: command handler | `opencode agent create` interactive wizard missing. UX feature but tied to multi-agent config. | | **L4** | **No per-agent top_p** | `src/agent/config.rs` | Per-agent LLM sampling parameter. | +| **L5** | **No `small_model` selection** | `src/config/configuration.rs`, `src/agent/` | OpenCode uses small-model config for title/summary/utility agents. Crabcode has no utility-model selection. | +| **L6** | **No username display override** | `src/config/configuration.rs`, `src/ui/` | OpenCode config can override the displayed username in conversations. | +| **L7** | **No provider enable/disable filters** | `src/config/configuration.rs`, `src/model/discovery.rs` | OpenCode can restrict loaded providers with `enabled_providers` and `disabled_providers`; Crabcode allows but does not apply these keys. | diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 9c08b20..ffa40b5 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -84,6 +84,11 @@ pub struct SubAgentDef { pub description: String, } +pub struct SubAgentRunResult { + pub output: String, + pub tool_call_count: usize, +} + impl SubAgentDef { pub fn all() -> Vec<SubAgentDef> { vec![ @@ -126,7 +131,9 @@ pub async fn run_subagent( description: &str, prompt: &str, full_registry: &ToolRegistry, -) -> Result<String, String> { + sender: Option<crate::llm::ChunkSender>, + session_id: String, +) -> Result<SubAgentRunResult, String> { use aisdk::core::{ chunk::ChunkType, response::{stream_with_tools, StreamTextResponse}, @@ -144,9 +151,11 @@ pub async fn run_subagent( let aisdk_tools = crate::tools::aisdk_bridge::convert_to_aisdk_tools( &scoped_registry, - None, + sender.clone(), "build".to_string(), permissions, + Some(session_id), + None, ) .await; @@ -206,13 +215,32 @@ pub async fn run_subagent( }; let mut collected_text = String::new(); + let mut tool_call_count = 0usize; while let Some(chunk) = response.stream.next().await { match chunk { ChunkType::Text(text) => { collected_text.push_str(&text); + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Text(text)); + } + } + ChunkType::Reasoning(reasoning) => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); + } + } + ChunkType::ToolCall(tool_call) => { + let calls = serde_json::from_str::<serde_json::Value>(&tool_call) + .ok() + .and_then(|value| value.as_array().map(|items| items.len())) + .unwrap_or(1); + tool_call_count = tool_call_count.saturating_add(calls); } ChunkType::Failed(err) => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); + } return Err(format!("Subagent streaming failed: {}", err)); } ChunkType::End(_) => { @@ -222,9 +250,37 @@ pub async fn run_subagent( } } - if collected_text.trim().is_empty() { - return Err("Subagent returned no output".to_string()); + Ok(SubAgentRunResult { + output: normalize_subagent_output(collected_text), + tool_call_count, + }) +} + +fn normalize_subagent_output(output: String) -> String { + if output.trim().is_empty() { + "Subagent completed without a final text response.".to_string() + } else { + output + } +} + +#[cfg(test)] +mod tests { + use super::normalize_subagent_output; + + #[test] + fn empty_subagent_output_is_not_an_error_payload() { + assert_eq!( + normalize_subagent_output(" \n".to_string()), + "Subagent completed without a final text response." + ); } - Ok(collected_text) + #[test] + fn non_empty_subagent_output_is_preserved() { + assert_eq!( + normalize_subagent_output("Hi there".to_string()), + "Hi there" + ); + } } diff --git a/src/app.rs b/src/app.rs index 9253355..874ad03 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use crate::ui::components::input::Input; use crate::ui::components::popup::Popup; use crate::utils::git; -use crate::views::chat::{init_chat, render_chat}; +use crate::views::chat::{init_chat, render_chat, SubagentTab, SubagentTabs}; use crate::views::connect_dialog::{ get_pending_selection, handle_connect_dialog_key_event, handle_connect_dialog_mouse_event, init_connect_dialog, render_connect_dialog, @@ -122,6 +122,17 @@ struct SessionStreamState { streaming_model: Option<String>, streaming_provider: Option<String>, chat_len_before_assistant: usize, +} + +#[derive(Debug, Clone)] +struct ExternalStreamState { + streaming_model: Option<String>, + streaming_provider: Option<String>, + chat_len_before_assistant: usize, +} + +#[derive(Debug, Default)] +struct ToolCallViewState { tool_call_message_indices: std::collections::HashMap<String, usize>, tool_call_order: Vec<String>, } @@ -131,6 +142,8 @@ struct ClientSessionState { chat: Chat, input_draft: String, stream: Option<SessionStreamState>, + external_stream: Option<ExternalStreamState>, + tool_calls: ToolCallViewState, unread_completed: bool, } @@ -140,6 +153,8 @@ impl ClientSessionState { chat: Chat::with_messages(messages), input_draft: String::new(), stream: None, + external_stream: None, + tool_calls: ToolCallViewState::default(), unread_completed: false, } } @@ -534,7 +549,7 @@ impl App { self.chat_state.chat.scroll_to_bottom_on_next_render(); self.input.set_text(&state.input_draft); state.unread_completed = false; - self.is_streaming = state.stream.is_some(); + self.is_streaming = state.stream.is_some() || state.external_stream.is_some(); } else { self.chat_state.chat.clear(); self.input.clear(); @@ -559,6 +574,110 @@ impl App { true } + fn should_handle_child_session_arrow(&self) -> bool { + if self.base_focus != BaseFocus::Chat || !self.input.get_text().is_empty() { + return false; + } + + self.session_manager + .get_current_session_id() + .is_some_and(|id| self.session_manager.parent_id_of(id).is_some()) + } + + fn switch_to_first_child_session(&mut self) -> bool { + let Some(current_id) = self.session_manager.get_current_session_id().cloned() else { + return false; + }; + let Some(root_id) = self.session_manager.root_session_id_for(¤t_id) else { + return false; + }; + let Some(first_child) = self.session_manager.child_sessions(&root_id).first().cloned() + else { + return false; + }; + + self.switch_to_session(&first_child.id) + } + + fn switch_to_parent_session(&mut self) -> bool { + let Some(current_id) = self.session_manager.get_current_session_id().cloned() else { + return false; + }; + let Some(parent_id) = self.session_manager.parent_id_of(¤t_id).map(str::to_string) + else { + return false; + }; + + self.switch_to_session(&parent_id) + } + + fn switch_child_session(&mut self, direction: isize) -> bool { + let Some(current_id) = self.session_manager.get_current_session_id().cloned() else { + return false; + }; + let Some(root_id) = self.session_manager.root_session_id_for(¤t_id) else { + return false; + }; + + let children = self.session_manager.child_sessions(&root_id); + if children.len() <= 1 { + return false; + } + + let Some(current_idx) = children.iter().position(|child| child.id == current_id) else { + return false; + }; + + let len = children.len() as isize; + let next_idx = (current_idx as isize + direction).rem_euclid(len) as usize; + self.switch_to_session(&children[next_idx].id) + } + + fn subagent_tabs_for_current_session(&self) -> Option<SubagentTabs> { + let current_id = self.session_manager.get_current_session_id()?.clone(); + let root_id = self.session_manager.root_session_id_for(¤t_id)?; + let root = self.session_manager.get_session_ref(&root_id)?; + let children = self.session_manager.child_sessions(&root_id); + if children.is_empty() { + return None; + } + + let mut tabs = Vec::with_capacity(children.len() + 1); + tabs.push(SubagentTab { + label: "main".to_string(), + active: current_id == root_id, + running: root.status.is_active() + || self + .session_view_states + .get(&root_id) + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()), + }); + + for child in children { + let label = child + .title + .split_whitespace() + .take(4) + .collect::<Vec<_>>() + .join(" "); + let running = child.status.is_active() + || self + .session_view_states + .get(&child.id) + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); + tabs.push(SubagentTab { + label: if label.is_empty() { child.id.clone() } else { label }, + active: current_id == child.id, + running, + }); + } + + Some(SubagentTabs { + is_child_session: current_id != root_id, + tabs, + }) + } + fn start_blank_session(&mut self, title: Option<String>) { self.save_active_session_view_state(); self.pending_session_title = title.and_then(|title| { @@ -612,12 +731,34 @@ impl App { .and_then(|state| state.stream.as_mut()) } + fn streaming_boundary_for_session( + &self, + session_id: &str, + ) -> Option<(usize, Option<String>, Option<String>)> { + let state = self.session_view_states.get(session_id)?; + if let Some(stream) = state.stream.as_ref() { + return Some(( + stream.chat_len_before_assistant, + stream.streaming_model.clone(), + stream.streaming_provider.clone(), + )); + } + + state.external_stream.as_ref().map(|stream| { + ( + stream.chat_len_before_assistant, + stream.streaming_model.clone(), + stream.streaming_provider.clone(), + ) + }) + } + fn sync_active_streaming_flag(&mut self) { self.is_streaming = self .session_manager .get_current_session_id() .and_then(|id| self.session_view_states.get(id)) - .is_some_and(|state| state.stream.is_some()); + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); } fn get_random_placeholder() -> String { @@ -1333,6 +1474,22 @@ impl App { self.overlay_focus = OverlayFocus::None; self.open_timeline_dialog(); } + crate::views::which_key::WhichKeyAction::GoChild => { + self.overlay_focus = OverlayFocus::None; + let _ = self.switch_to_first_child_session(); + } + crate::views::which_key::WhichKeyAction::GoParent => { + self.overlay_focus = OverlayFocus::None; + let _ = self.switch_to_parent_session(); + } + crate::views::which_key::WhichKeyAction::PreviousChild => { + self.overlay_focus = OverlayFocus::None; + let _ = self.switch_child_session(-1); + } + crate::views::which_key::WhichKeyAction::NextChild => { + self.overlay_focus = OverlayFocus::None; + let _ = self.switch_child_session(1); + } crate::views::which_key::WhichKeyAction::NewSession => { self.overlay_focus = OverlayFocus::None; tokio::task::block_in_place(|| { @@ -1396,6 +1553,24 @@ impl App { self.which_key_state.show(); true } + KeyCode::Left + if key.modifiers == event::KeyModifiers::NONE + && self.should_handle_child_session_arrow() => + { + self.switch_child_session(-1) + } + KeyCode::Right + if key.modifiers == event::KeyModifiers::NONE + && self.should_handle_child_session_arrow() => + { + self.switch_child_session(1) + } + KeyCode::Up + if key.modifiers == event::KeyModifiers::NONE + && self.should_handle_child_session_arrow() => + { + self.switch_to_parent_session() + } KeyCode::Tab => { if self.agent == "Plan" { self.agent = "Build".to_string(); @@ -2366,12 +2541,16 @@ impl App { let filter = self.sessions_dialog_state.filter; sessions.retain(|session| { + if session.parent_id.is_some() { + return false; + } + let is_archived = session.archived_at.is_some(); let is_running = session.status.is_active() || self .session_view_states .get(&session.id) - .is_some_and(|state| state.stream.is_some()); + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); match filter { SessionsDialogFilter::Active => { @@ -2394,7 +2573,8 @@ impl App { .into_iter() .map(|session| { let view_state = self.session_view_states.get(&session.id); - let is_streaming = view_state.is_some_and(|state| state.stream.is_some()) + let is_streaming = view_state + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()) || session.status.is_active(); let unread_completed = view_state.is_some_and(|state| state.unread_completed); let marker = if is_streaming { @@ -3207,6 +3387,7 @@ impl App { if let Some(state) = self.session_view_states.get_mut(session_id) { state.stream = None; + state.external_stream = None; } if was_active { @@ -3257,7 +3438,7 @@ impl App { || self .session_view_states .values() - .any(|state| state.stream.is_some()) + .any(|state| state.stream.is_some() || state.external_stream.is_some()) || (self.overlay_focus == OverlayFocus::SessionsDialog && self.sessions_dialog_state.dialog.is_visible()) } @@ -3330,6 +3511,29 @@ impl App { crate::llm::ChunkMessage::ToolResult(result) => { self.add_tool_result_to_session(session_id, result); } + crate::llm::ChunkMessage::SubagentStarted { + parent_session_id, + session_id, + title, + subagent_type, + description, + prompt, + } => { + self.start_subagent_session( + parent_session_id, + session_id, + title, + subagent_type, + description, + prompt, + ); + } + crate::llm::ChunkMessage::SubagentChunk { + session_id, + chunk, + } => { + self.process_streaming_chunk_for_session(&session_id, *chunk); + } crate::llm::ChunkMessage::PermissionRequest(prompt) => { let _ = self.session_manager.set_session_status( session_id, @@ -3368,14 +3572,76 @@ impl App { } } + fn start_subagent_session( + &mut self, + parent_session_id: String, + session_id: String, + title: String, + subagent_type: String, + description: String, + prompt: String, + ) { + if self + .session_manager + .get_session_ref(&session_id) + .is_none() + { + self.session_manager.create_child_session( + parent_session_id, + session_id.clone(), + title.clone(), + ); + } + + self.ensure_session_view_state(&session_id); + + let user_content = format!( + "## Task Description\n{}\n\n## Task Prompt\n{}", + description, prompt + ); + + let mut user_message = crate::session::types::Message::user(&user_content); + user_message.agent_mode = Some(subagent_type.clone()); + + let mut persist_user = false; + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.chat = Chat::with_messages(Vec::new()); + state.tool_calls = ToolCallViewState::default(); + state.chat.add_message(user_message.clone()); + state.chat.add_assistant_message(""); + if let Some(last_msg) = state.chat.messages.last_mut() { + last_msg.is_complete = false; + last_msg.agent_mode = Some(subagent_type); + } + state.chat.begin_streaming_turn(); + state.external_stream = Some(ExternalStreamState { + streaming_model: Some(self.model.clone()), + streaming_provider: Some(self.provider_name.clone()), + chat_len_before_assistant: 1, + }); + state.unread_completed = true; + persist_user = true; + } + + if persist_user { + let _ = self + .session_manager + .add_message_to_session(&session_id, &user_message); + } + + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + + self.refresh_sessions_dialog(); + self.sync_active_streaming_flag(); + } + fn finish_streaming_session(&mut self, session_id: &str) { - let (start, model, provider) = match self.stream_for_session_mut(session_id) { - Some(stream) => ( - stream.chat_len_before_assistant, - stream.streaming_model.clone(), - stream.streaming_provider.clone(), - ), - None => return, + let Some((start, model, provider)) = self.streaming_boundary_for_session(session_id) else { + return; }; let mut messages_to_persist = Vec::new(); @@ -3430,8 +3696,8 @@ impl App { fn fail_streaming_session(&mut self, session_id: &str, error: String) { let start = self - .stream_for_session_mut(session_id) - .map(|stream| stream.chat_len_before_assistant) + .streaming_boundary_for_session(session_id) + .map(|(start, _, _)| start) .unwrap_or(0); if let Some(chat) = self.chat_for_session_mut(session_id) { @@ -3457,8 +3723,8 @@ impl App { fn cancelled_streaming_session(&mut self, session_id: &str) { let start = self - .stream_for_session_mut(session_id) - .map(|stream| stream.chat_len_before_assistant) + .streaming_boundary_for_session(session_id) + .map(|(start, _, _)| start) .unwrap_or(0); if let Some(chat) = self.chat_for_session_mut(session_id) { @@ -3517,19 +3783,21 @@ impl App { } } - if let Some(stream) = self.stream_for_session_mut(session_id) { + if let Some(state) = self.session_view_states.get_mut(session_id) { for (call_id, idx) in inserted { - stream + state + .tool_calls .tool_call_message_indices .insert(call_id.clone(), idx); - stream.tool_call_order.push(call_id); + state.tool_calls.tool_call_order.push(call_id); } } } fn add_tool_result_to_session(&mut self, session_id: &str, result: crate::llm::ToolCallResult) { - let target_idx = self.stream_for_session_mut(session_id).and_then(|stream| { - stream + let target_idx = self.session_view_states.get(session_id).and_then(|state| { + state + .tool_calls .tool_call_message_indices .get(&result.tool_call_id) .copied() @@ -3646,9 +3914,8 @@ impl App { streaming_model: streaming_model.clone(), streaming_provider: streaming_provider.clone(), chat_len_before_assistant, - tool_call_message_indices: std::collections::HashMap::new(), - tool_call_order: Vec::new(), }); + state.tool_calls = ToolCallViewState::default(); state.unread_completed = false; } let _ = self.session_manager.set_session_status( @@ -3699,6 +3966,7 @@ impl App { tokio::spawn(async move { let stream = stream_llm_with_cancellation( cancel_token, + session_id, provider_name, model, agent_mode, @@ -3850,6 +4118,7 @@ impl App { } } BaseFocus::Chat => { + let subagent_tabs = self.subagent_tabs_for_current_session(); render_chat( f, &mut self.chat_state, @@ -3863,6 +4132,7 @@ impl App { &colors, self.is_streaming, &usage_text, + subagent_tabs, ); if is_suggestions_visible(&self.suggestions_popup_state) @@ -4123,4 +4393,65 @@ mod tests { assert!(app.session_manager.get_current_session_id().is_some()); assert_eq!(app.session_manager.list_sessions().len(), 1); } + + #[test] + fn child_session_navigation_matches_opencode_flow() { + let mut app = test_app(); + let parent_id = app.create_new_session(Some("Parent".to_string())); + app.base_focus = BaseFocus::Chat; + + app.start_subagent_session( + parent_id.clone(), + "child-a".to_string(), + "Explore task (@explore subagent)".to_string(), + "explore".to_string(), + "Explore task".to_string(), + "Find files".to_string(), + ); + app.start_subagent_session( + parent_id.clone(), + "child-b".to_string(), + "General task (@general subagent)".to_string(), + "general".to_string(), + "General task".to_string(), + "Check implementation".to_string(), + ); + + assert_eq!( + app.session_manager.get_current_session_id(), + Some(&parent_id) + ); + assert!(app.switch_to_first_child_session()); + assert_eq!( + app.session_manager.get_current_session_id().map(String::as_str), + Some("child-a") + ); + + assert!(app.handle_base_keys(KeyEvent::new( + KeyCode::Right, + event::KeyModifiers::NONE, + ))); + assert_eq!( + app.session_manager.get_current_session_id().map(String::as_str), + Some("child-b") + ); + + assert!(app.handle_base_keys(KeyEvent::new( + KeyCode::Left, + event::KeyModifiers::NONE, + ))); + assert_eq!( + app.session_manager.get_current_session_id().map(String::as_str), + Some("child-a") + ); + + assert!(app.handle_base_keys(KeyEvent::new( + KeyCode::Up, + event::KeyModifiers::NONE, + ))); + assert_eq!( + app.session_manager.get_current_session_id(), + Some(&parent_id) + ); + } } diff --git a/src/llm/client.rs b/src/llm/client.rs index 0532ee9..7bc98e7 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -78,6 +78,7 @@ enum StreamRelayOutcome { pub async fn stream_llm_with_cancellation( cancel_token: CancellationToken, + session_id: String, provider_name: String, model: String, agent_mode: String, @@ -113,6 +114,8 @@ pub async fn stream_llm_with_cancellation( Some(sender.clone()), agent_mode, tool_permissions, + Some(session_id), + None, ) .await; @@ -520,7 +523,7 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec<AisdkMes aisdk_messages.push(AisdkMessage::assistant(msg.content.clone())); } crate::session::types::MessageRole::Tool => { - continue; + aisdk_messages.push(AisdkMessage::user(tool_message_observation(&msg.content))); } } } @@ -528,6 +531,36 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec<AisdkMes aisdk_messages } +fn tool_message_observation(content: &str) -> String { + let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else { + return format!("Tool result:\n{}", content); + }; + + let Some(obj) = value.as_object() else { + return format!("Tool result:\n{}", content); + }; + + let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("ok"); + let title = obj.get("title").and_then(|v| v.as_str()); + let output = obj + .get("output_preview") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or(""); + + let mut observation = format!("Tool `{}` result ({})", name, status); + if let Some(title) = title { + observation.push_str(&format!(": {}", title)); + } + if !output.is_empty() { + observation.push_str("\n"); + observation.push_str(output); + } + + observation +} + fn is_openai_oauth_model_allowed(model: &str) -> bool { matches!( model, diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 2b2f207..b289c87 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -12,6 +12,18 @@ pub enum ChunkMessage { Warning(String), ToolCalls(Vec<ToolCall>), ToolResult(ToolCallResult), + SubagentStarted { + parent_session_id: String, + session_id: String, + title: String, + subagent_type: String, + description: String, + prompt: String, + }, + SubagentChunk { + session_id: String, + chunk: Box<ChunkMessage>, + }, PermissionRequest(crate::tools::PermissionPrompt), QuestionRequest { questions: serde_json::Value, diff --git a/src/main.rs b/src/main.rs index 9e04687..7bf202c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,6 +155,7 @@ async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<() let cancel_token = tokio_util::sync::CancellationToken::new(); let _ = stream_llm_with_cancellation( cancel_token, + cuid2::create_id(), provider_name_clone, model_clone, agent_mode.clone(), diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index b2abe5c..2c3e725 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -121,10 +121,11 @@ pub fn session_to_persistence(name: String, session: &Session) -> (String, Vec<M } pub fn persistence_to_session( - _persistence_session: PersistenceSession, + persistence_session: PersistenceSession, messages: Vec<Message>, ) -> Result<Session, anyhow::Error> { let mut session = Session::new(); + session.parent_id = persistence_session.parent_session_identifier; for msg in messages { session.add_message(msg.try_into()?); } diff --git a/src/persistence/history.rs b/src/persistence/history.rs index f883044..51b344f 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -19,6 +19,7 @@ pub struct Workspace { pub struct Session { pub id: i64, pub session_identifier: String, + pub parent_session_identifier: Option<String>, pub name: String, pub created_at: i64, pub updated_at: i64, @@ -81,6 +82,10 @@ impl HistoryDAO { "ALTER TABLE sessions ADD COLUMN session_identifier TEXT NOT NULL DEFAULT ''", [], ); + let _ = conn.execute( + "ALTER TABLE sessions ADD COLUMN parent_session_identifier TEXT", + [], + ); let current_workspace_path = std::env::current_dir() .unwrap_or_else(|_| std::path::PathBuf::from(".")) @@ -112,10 +117,26 @@ impl HistoryDAO { } pub fn create_session(&self, identifier: &str, name: String) -> Result<i64> { + self.create_session_with_parent(identifier, name, None) + } + + pub fn create_session_with_parent( + &self, + identifier: &str, + name: String, + parent_identifier: Option<&str>, + ) -> Result<i64> { self.conn.execute( - "INSERT INTO sessions (session_identifier, name, workspace_id, status) - VALUES (?1, ?2, ?3, 'idle')", - params![identifier, name, self.current_workspace_id], + "INSERT INTO sessions ( + session_identifier, parent_session_identifier, name, workspace_id, status + ) + VALUES (?1, ?2, ?3, ?4, 'idle')", + params![ + identifier, + parent_identifier, + name, + self.current_workspace_id + ], )?; Ok(self.conn.last_insert_rowid()) } @@ -156,7 +177,8 @@ impl HistoryDAO { pub fn list_sessions(&self) -> Result<Vec<Session>> { let mut stmt = self.conn.prepare( - "SELECT s.id, s.session_identifier, s.name, s.created_at, s.updated_at, + "SELECT s.id, s.session_identifier, s.parent_session_identifier, + s.name, s.created_at, s.updated_at, s.total_tokens, s.total_cost, s.total_time_sec, s.avg_tokens_per_sec, COALESCE(s.workspace_id, ?1) AS workspace_id, COALESCE(w.root_path, ?2) AS workspace_path, @@ -179,19 +201,20 @@ impl HistoryDAO { Ok(Session { id: row.get(0)?, session_identifier: row.get(1)?, - name: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, - total_tokens: row.get(5)?, - total_cost: row.get(6)?, - total_time_sec: row.get(7)?, - avg_tokens_per_sec: row.get(8)?, - workspace_id: row.get(9)?, - workspace_path: row.get(10)?, - workspace_name: row.get(11)?, - status: row.get(12)?, - pinned_at: row.get(13)?, - archived_at: row.get(14)?, + parent_session_identifier: row.get(2)?, + name: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + total_tokens: row.get(6)?, + total_cost: row.get(7)?, + total_time_sec: row.get(8)?, + avg_tokens_per_sec: row.get(9)?, + workspace_id: row.get(10)?, + workspace_path: row.get(11)?, + workspace_name: row.get(12)?, + status: row.get(13)?, + pinned_at: row.get(14)?, + archived_at: row.get(15)?, }) }, )?; @@ -202,7 +225,8 @@ impl HistoryDAO { pub fn get_session(&self, id: i64) -> Result<Option<Session>> { let mut stmt = self.conn.prepare( - "SELECT s.id, s.session_identifier, s.name, s.created_at, s.updated_at, + "SELECT s.id, s.session_identifier, s.parent_session_identifier, + s.name, s.created_at, s.updated_at, s.total_tokens, s.total_cost, s.total_time_sec, s.avg_tokens_per_sec, COALESCE(s.workspace_id, ?2) AS workspace_id, COALESCE(w.root_path, ?3) AS workspace_path, @@ -225,19 +249,20 @@ impl HistoryDAO { Ok(Some(Session { id: row.get(0)?, session_identifier: row.get(1)?, - name: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, - total_tokens: row.get(5)?, - total_cost: row.get(6)?, - total_time_sec: row.get(7)?, - avg_tokens_per_sec: row.get(8)?, - workspace_id: row.get(9)?, - workspace_path: row.get(10)?, - workspace_name: row.get(11)?, - status: row.get(12)?, - pinned_at: row.get(13)?, - archived_at: row.get(14)?, + parent_session_identifier: row.get(2)?, + name: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + total_tokens: row.get(6)?, + total_cost: row.get(7)?, + total_time_sec: row.get(8)?, + avg_tokens_per_sec: row.get(9)?, + workspace_id: row.get(10)?, + workspace_path: row.get(11)?, + workspace_name: row.get(12)?, + status: row.get(13)?, + pinned_at: row.get(14)?, + archived_at: row.get(15)?, })) } else { Ok(None) diff --git a/src/persistence/migrations.rs b/src/persistence/migrations.rs index 81a886c..1fedbbf 100644 --- a/src/persistence/migrations.rs +++ b/src/persistence/migrations.rs @@ -12,6 +12,10 @@ pub fn run_migrations(db: &mut Connection) -> Result<()> { migrate_to_v2(db)?; } + if current_version < 3 { + migrate_to_v3(db)?; + } + Ok(()) } @@ -153,3 +157,27 @@ fn migrate_to_v2(db: &mut Connection) -> Result<()> { tx.commit()?; Ok(()) } + +fn migrate_to_v3(db: &mut Connection) -> Result<()> { + let tx = db.transaction()?; + + let _ = tx.execute( + "ALTER TABLE sessions ADD COLUMN parent_session_identifier TEXT", + [], + ); + + tx.execute_batch( + r#" + CREATE INDEX IF NOT EXISTS idx_sessions_parent_identifier + ON sessions(parent_session_identifier, updated_at DESC); + "#, + )?; + + tx.execute( + "INSERT OR IGNORE INTO migrations (version, applied_at) VALUES (3, strftime('%s', 'now'))", + params![], + )?; + + tx.commit()?; + Ok(()) +} diff --git a/src/session/manager.rs b/src/session/manager.rs index 67620ad..72687bf 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -15,9 +15,10 @@ impl From<anyhow::Error> for SessionError { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SessionInfo { pub id: String, + pub parent_id: Option<String>, pub title: String, pub created_at: SystemTime, pub updated_at: SystemTime, @@ -92,6 +93,7 @@ impl SessionManager { }; session.id = db_session.session_identifier.clone(); + session.parent_id = db_session.parent_session_identifier.clone(); session.title = db_session.name; session.created_at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(db_session.created_at as u64); @@ -124,25 +126,47 @@ impl SessionManager { } pub fn create_session(&mut self, name: Option<String>) -> String { + self.create_session_record(name, None, None, true) + } + + pub fn create_child_session( + &mut self, + parent_id: String, + session_id: String, + name: String, + ) -> String { + self.create_session_record(Some(name), Some(session_id), Some(parent_id), false) + } + + fn create_session_record( + &mut self, + name: Option<String>, + requested_id: Option<String>, + parent_id: Option<String>, + make_current: bool, + ) -> String { self.session_counter += 1; let title = name .clone() .unwrap_or_else(|| format!("session-{}", self.session_counter)); - let session_id = cuid2::create_id(); + let session_id = requested_id.unwrap_or_else(cuid2::create_id); let mut session = Session::with_title(title.clone()); session.id = session_id.clone(); + session.parent_id = parent_id.clone(); session.workspace_id = self.current_workspace_id; session.workspace_path = self.current_workspace_path.clone(); session.workspace_name = self.current_workspace_name.clone(); self.sessions.insert(session_id.clone(), session); - self.current_session_id = Some(session_id.clone()); + if make_current { + self.current_session_id = Some(session_id.clone()); + } if let Some(ref dao) = self.history_dao { let db_id = dao - .create_session(&session_id, title.clone()) + .create_session_with_parent(&session_id, title.clone(), parent_id.as_deref()) .unwrap_or_else(|_| self.session_counter as i64); self.id_mapping.insert(session_id.clone(), db_id); self.db_id_to_id.insert(db_id, session_id.clone()); @@ -156,6 +180,7 @@ impl SessionManager { .iter() .map(|(id, session)| SessionInfo { id: id.clone(), + parent_id: session.parent_id.clone(), title: session.title.clone(), created_at: session.created_at, updated_at: session.updated_at, @@ -182,6 +207,48 @@ impl SessionManager { self.sessions.get_mut(id) } + pub fn get_session_ref(&self, id: &str) -> Option<&Session> { + self.sessions.get(id) + } + + pub fn parent_id_of(&self, id: &str) -> Option<&str> { + self.sessions.get(id).and_then(|s| s.parent_id.as_deref()) + } + + pub fn root_session_id_for(&self, id: &str) -> Option<String> { + let session = self.sessions.get(id)?; + Some(session.parent_id.clone().unwrap_or_else(|| id.to_string())) + } + + pub fn child_sessions(&self, parent_id: &str) -> Vec<SessionInfo> { + let mut children: Vec<SessionInfo> = self + .sessions + .iter() + .filter(|(_, session)| session.parent_id.as_deref() == Some(parent_id)) + .map(|(id, session)| SessionInfo { + id: id.clone(), + parent_id: session.parent_id.clone(), + title: session.title.clone(), + created_at: session.created_at, + updated_at: session.updated_at, + message_count: session.messages.len(), + workspace_id: session.workspace_id, + workspace_path: session.workspace_path.clone(), + workspace_name: session.workspace_name.clone(), + status: session.status, + pinned_at: session.pinned_at, + archived_at: session.archived_at, + }) + .collect(); + + children.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + children + } + pub fn switch_session(&mut self, id: &str) -> bool { if self.sessions.contains_key(id) { self.current_session_id = Some(id.to_string()); diff --git a/src/session/types.rs b/src/session/types.rs index 488df91..a85ff21 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -138,6 +138,7 @@ impl Message { #[derive(Debug, Clone, PartialEq)] pub struct Session { pub id: String, + pub parent_id: Option<String>, pub title: String, pub created_at: SystemTime, pub updated_at: SystemTime, @@ -161,6 +162,7 @@ impl Session { let now = SystemTime::now(); Self { id: cuid2::create_id(), + parent_id: None, title: "New Session".to_string(), created_at: now, updated_at: now, @@ -178,6 +180,7 @@ impl Session { let now = SystemTime::now(); Self { id: cuid2::create_id(), + parent_id: None, title: title.into(), created_at: now, updated_at: now, diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 9cc85ca..28247c8 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -14,6 +14,8 @@ pub async fn convert_to_aisdk_tools( sender: Option<ChunkSender>, agent_mode: String, permissions: crate::tools::ToolPermissions, + session_id: Option<String>, + message_id: Option<String>, ) -> Vec<Tool> { let mut aisdk_tools = Vec::new(); let tools = registry.list().await; @@ -33,6 +35,8 @@ pub async fn convert_to_aisdk_tools( let sender = sender.clone(); let agent_mode = agent_mode.clone(); let permissions = permissions.clone(); + let session_id = session_id.clone(); + let message_id = message_id.clone(); let execute = ToolExecute::new(move |input: Value| { let tool_id = tool_id.clone(); @@ -45,6 +49,8 @@ pub async fn convert_to_aisdk_tools( let sender = sender.clone(); let agent_mode = agent_mode.clone(); let permissions = permissions.clone(); + let session_id = session_id.clone(); + let message_id = message_id.clone(); async move { let call_seq = TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1; @@ -84,7 +90,13 @@ pub async fn convert_to_aisdk_tools( .map_err(|e| format!("{}", e))?; let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); - let ctx = ToolContext::new("session", "message", agent_mode.clone(), abort_rx); + let ctx = ToolContext::new( + session_id.unwrap_or_else(|| "session".to_string()), + message_id.unwrap_or_else(|| "message".to_string()), + agent_mode.clone(), + abort_rx, + ) + .with_call_id(call_id.clone()); let tool_result = handler .execute(input, &ctx) diff --git a/src/tools/init.rs b/src/tools/init.rs index 6f01719..cbe8d58 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -27,10 +27,10 @@ pub async fn register_dynamic_tools( sender: Option<crate::llm::ChunkSender>, ) { registry - .register(Arc::new(QuestionTool::new().with_sender_opt(sender))) + .register(Arc::new(QuestionTool::new().with_sender_opt(sender.clone()))) .await; registry - .register(Arc::new(TaskTool::new(registry.clone()))) + .register(Arc::new(TaskTool::new(registry.clone()).with_sender_opt(sender))) .await; } diff --git a/src/tools/task.rs b/src/tools/task.rs index cb5e21f..c188db8 100644 --- a/src/tools/task.rs +++ b/src/tools/task.rs @@ -9,14 +9,21 @@ use std::sync::Arc; pub struct TaskTool { tool_registry: Arc<ToolRegistry>, + sender: Option<crate::llm::ChunkSender>, } impl TaskTool { pub fn new(tool_registry: ToolRegistry) -> Self { Self { tool_registry: Arc::new(tool_registry), + sender: None, } } + + pub fn with_sender_opt(mut self, sender: Option<crate::llm::ChunkSender>) -> Self { + self.sender = sender; + self + } } #[async_trait] @@ -75,19 +82,94 @@ impl ToolHandler for TaskTool { return Err(ToolError::Execution("Subagent cancelled".to_string())); } + let child_session_id = cuid2::create_id(); + let title = format!( + "{} (@{} subagent)", + if description.trim().is_empty() { + "Task" + } else { + description.trim() + }, + subagent_type.name() + ); + + let child_sender = self.start_child_session_stream( + ctx.session_id.clone(), + child_session_id.clone(), + title.clone(), + subagent_type.name().to_string(), + description.clone(), + prompt.clone(), + ); + + let started_at = std::time::Instant::now(); let result = subagent::run_subagent( subagent_type.clone(), &description, &prompt, &self.tool_registry, + child_sender.clone(), + child_session_id.clone(), ) .await - .map_err(|e| ToolError::Execution(format!("Subagent error: {}", e)))?; + .map_err(|e| { + if let Some(sender) = child_sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Failed(e.clone())); + } + ToolError::Execution(format!("Subagent error: {}", e)) + })?; + + if let Some(sender) = child_sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::End); + } + let duration_ms = started_at.elapsed().as_millis() as u64; Ok(ToolResult::new( format!("Subagent ({}) result", subagent_type.name()), - result, + result.output, + ) + .with_metadata("subagent_type", serde_json::json!(subagent_type.name())) + .with_metadata("child_session_id", serde_json::json!(child_session_id)) + .with_metadata("child_session_title", serde_json::json!(title)) + .with_metadata( + "child_tool_call_count", + serde_json::json!(result.tool_call_count), ) - .with_metadata("subagent_type", serde_json::json!(subagent_type.name()))) + .with_metadata("duration_ms", serde_json::json!(duration_ms))) + } +} + +impl TaskTool { + fn start_child_session_stream( + &self, + parent_session_id: String, + session_id: String, + title: String, + subagent_type: String, + description: String, + prompt: String, + ) -> Option<crate::llm::ChunkSender> { + let ui_sender = self.sender.as_ref()?.clone(); + let (child_tx, mut child_rx) = tokio::sync::mpsc::unbounded_channel(); + + let _ = ui_sender.send(crate::llm::ChunkMessage::SubagentStarted { + parent_session_id, + session_id: session_id.clone(), + title, + subagent_type, + description, + prompt, + }); + + tokio::spawn(async move { + while let Some(chunk) = child_rx.recv().await { + let _ = ui_sender.send(crate::llm::ChunkMessage::SubagentChunk { + session_id: session_id.clone(), + chunk: Box::new(chunk), + }); + } + }); + + Some(child_tx) } } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 4c4cea2..814800a 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1354,6 +1354,22 @@ impl Chat { } } + fn titlecase_ascii(value: &str) -> String { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + first.to_ascii_uppercase().to_string() + chars.as_str() + } + + fn format_duration_ms(ms: u64) -> String { + if ms >= 1000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + format!("{}ms", ms) + } + } + fn push_wrapped<'a>( out: &mut Vec<Line<'a>>, line: Line<'static>, @@ -1447,6 +1463,7 @@ impl Chat { "grep" => "Grep", "todowrite" => "Todos", "question" => "Questions", + "task" => "Task", other => other, }; @@ -1567,6 +1584,92 @@ impl Chat { } out.extend(panel_lines); + } else if name == "task" { + let subagent_type = args_obj + .and_then(|o| o.get("subagent_type")) + .and_then(|v| v.as_str()) + .or_else(|| { + metadata + .as_ref() + .and_then(|m| m.get("subagent_type")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("general"); + let description = args_obj + .and_then(|o| o.get("description")) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or("Task"); + let header_text = format!( + "{} Task — {}", + titlecase_ascii(subagent_type), + description.trim() + ); + + let count = metadata + .as_ref() + .and_then(|m| m.get("child_tool_call_count")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let plural = if count == 1 { "toolcall" } else { "toolcalls" }; + let duration = metadata + .as_ref() + .and_then(|m| m.get("duration_ms")) + .and_then(|v| v.as_u64()) + .map(format_duration_ms); + let stats = match status.as_str() { + "running" => "running".to_string(), + "error" => "failed".to_string(), + _ => { + let base = format!("{} {}", count, plural); + duration + .map(|d| format!("{} · {}", base, d)) + .unwrap_or(base) + } + }; + + let connector_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let header_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let stats_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let hint_key_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let hint_style = Style::default().fg(colors.text_weak); + + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(" ┌ ", connector_style), + Span::styled(header_text, header_style), + ]), + max_width, + Line::from(Span::styled(" ", header_style)), + ); + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(" │ ", connector_style), + Span::styled(stats, stats_style), + ]), + max_width, + Line::from(Span::styled(" ", stats_style)), + ); + + out.push(Line::from("")); + out.push(Line::from(vec![ + Span::styled("ctrl+x", hint_key_style), + Span::raw(" "), + Span::styled("down", hint_key_style), + Span::raw(" "), + Span::styled("view subagents", hint_style), + ])); + out.push(Line::from("")); } else if name == "todowrite" && status == "ok" { if let Some(ref preview) = output_preview { let bg = colors.background_element; @@ -1823,6 +1926,7 @@ fn is_compact_tool_panel(content: &str) -> bool { Some(match name { "question" => status != "error", "todowrite" => status == "ok", + "task" => true, _ => false, }) }) @@ -2170,6 +2274,44 @@ mod tests { assert!(!rendered.iter().any(|line| line.trim() == "Question")); } + #[test] + fn test_task_tool_renders_opencode_style_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "task", + "status": "ok", + "args": { + "subagent_type": "general", + "description": "Say hi", + "prompt": "Say hi" + }, + "metadata": { + "subagent_type": "general", + "child_tool_call_count": 0, + "duration_ms": 4100 + }, + "output_preview": "Hi there!" + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert!(rendered + .iter() + .any(|line| line.contains("General Task") && line.contains("Say hi"))); + assert!(rendered + .iter() + .any(|line| line.contains("0 toolcalls") && line.contains("4.1s"))); + assert!(rendered + .iter() + .any(|line| line.contains("ctrl+x down view subagents"))); + assert!(!rendered.iter().any(|line| line.contains("prompt=\"Say hi\""))); + assert!(!rendered.iter().any(|line| line.contains("Hi there!"))); + } + #[test] fn test_todowrite_panel_keeps_padding_without_extra_gap() { let chat = Chat::new(); diff --git a/src/views/chat.rs b/src/views/chat.rs index b983efd..166aa86 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -18,6 +18,19 @@ pub struct ChatState { pub wave_spinner: WaveSpinner, } +#[derive(Debug, Clone)] +pub struct SubagentTab { + pub label: String, + pub active: bool, + pub running: bool, +} + +#[derive(Debug, Clone)] +pub struct SubagentTabs { + pub is_child_session: bool, + pub tabs: Vec<SubagentTab>, +} + impl ChatState { pub fn new(chat: Chat, agent_color: ratatui::style::Color) -> Self { Self { @@ -57,6 +70,7 @@ pub fn render_chat( colors: &ThemeColors, is_streaming: bool, usage_text: &str, + subagent_tabs: Option<SubagentTabs>, ) { let size = f.area(); @@ -81,6 +95,10 @@ pub fn render_chat( ) .split(main_chunks[0]); + if let Some(tabs) = subagent_tabs.as_ref() { + render_subagent_tabs(f, above_status_chunks[0], tabs, colors); + } + chat_state .chat .render(f, above_status_chunks[1], &agent, &model, colors); @@ -183,3 +201,50 @@ pub fn render_chat( let status_bar = StatusBar::new(version, cwd, branch, agent, model); status_bar.render(f, main_chunks[1], colors); } + +fn render_subagent_tabs( + f: &mut Frame, + area: ratatui::layout::Rect, + tabs: &SubagentTabs, + colors: &ThemeColors, +) { + if tabs.tabs.is_empty() || area.width == 0 { + return; + } + + let mut spans = Vec::new(); + let hint = if tabs.is_child_session { + "up parent left/right siblings" + } else { + "ctrl+x down subagents" + }; + + spans.push(Span::styled( + hint, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + spans.push(Span::raw(" ")); + + for tab in &tabs.tabs { + let style = if tab.active { + Style::default() + .fg(colors.background) + .bg(colors.primary) + .add_modifier(Modifier::BOLD) + } else if tab.running { + Style::default().fg(colors.info) + } else { + Style::default().fg(colors.text_weak) + }; + let suffix = if tab.running { " ~" } else { "" }; + spans.push(Span::styled( + format!(" {}{} ", tab.label, suffix), + style, + )); + spans.push(Span::raw(" ")); + } + + f.render_widget(Paragraph::new(Line::from(spans)), area); +} diff --git a/src/views/which_key.rs b/src/views/which_key.rs index e628c61..509d4ac 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -18,6 +18,10 @@ pub enum WhichKeyAction { ShowThemes, ShowSessions, ShowTimeline, + GoChild, + GoParent, + PreviousChild, + NextChild, NewSession, Quit, ScrollUp, @@ -72,6 +76,26 @@ impl WhichKeyState { ]; let chat_bindings = vec![ + KeyBinding { + key: "↓".to_string(), + description: "Go to first subagent session".to_string(), + action: WhichKeyAction::GoChild, + }, + KeyBinding { + key: "↑".to_string(), + description: "Go to parent session".to_string(), + action: WhichKeyAction::GoParent, + }, + KeyBinding { + key: "←".to_string(), + description: "Previous subagent session".to_string(), + action: WhichKeyAction::PreviousChild, + }, + KeyBinding { + key: "→".to_string(), + description: "Next subagent session".to_string(), + action: WhichKeyAction::NextChild, + }, KeyBinding { key: "g".to_string(), description: "Open Messages Timeline dialog".to_string(), @@ -131,6 +155,22 @@ impl WhichKeyState { self.hide(); WhichKeyAction::ShowTimeline } + KeyCode::Down if self.is_chat_active => { + self.hide(); + WhichKeyAction::GoChild + } + KeyCode::Up if self.is_chat_active => { + self.hide(); + WhichKeyAction::GoParent + } + KeyCode::Left if self.is_chat_active => { + self.hide(); + WhichKeyAction::PreviousChild + } + KeyCode::Right if self.is_chat_active => { + self.hide(); + WhichKeyAction::NextChild + } KeyCode::Char('m') | KeyCode::Char('M') => { self.hide(); WhichKeyAction::ShowModels From b8bd0cd32ef5c865612ade6f7b86dc1c0f6ea746 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 01:31:22 +0800 Subject: [PATCH 085/226] docs: added codex parity docs (but might not use). --- _docs/CODEX_PARITY.md | 205 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 _docs/CODEX_PARITY.md diff --git a/_docs/CODEX_PARITY.md b/_docs/CODEX_PARITY.md new file mode 100644 index 0000000..02fb673 --- /dev/null +++ b/_docs/CODEX_PARITY.md @@ -0,0 +1,205 @@ +# Codex Parity Roadmap + +> Created: 2026-05-18 +> Scope: harness behavior needed to make Crabcode perform like Codex with Codex/GPT-5.x models, including GPT-5.5, while keeping Crabcode's multi-workspace UI, theming, sessions, and non-chat UX. + +## Goal + +Make Crabcode's Codex/GPT-5.x path, including GPT-5.5, behave like Codex CLI from the model's point of view. + +This is not a product-clone checklist. It is a harness-contract checklist: prompts, model request shape, tool names, tool schemas, tool result history, turn loop behavior, subagent semantics, permissions, sandboxing, compaction, and the chat-panel rendering that makes tool work understandable. + +## Reference Files + +- Codex reference root: `.devrefs/references/openai/codex` +- Codex base instructions: `.devrefs/references/openai/codex/codex-rs/protocol/src/prompts/base_instructions/default.md` +- Codex turn loop: `.devrefs/references/openai/codex/codex-rs/core/src/session/turn.rs` +- Codex tool routing: `.devrefs/references/openai/codex/codex-rs/core/src/tools/spec_plan.rs` +- Codex shell tool spec: `.devrefs/references/openai/codex/codex-rs/core/src/tools/handlers/shell_spec.rs` +- Codex apply_patch spec: `.devrefs/references/openai/codex/codex-rs/core/src/tools/handlers/apply_patch_spec.rs` +- Codex multi-agent tools: `.devrefs/references/openai/codex/codex-rs/core/src/tools/handlers/multi_agents_spec.rs` +- Codex tool-call UI: `.devrefs/references/openai/codex/codex-rs/tui/src/exec_cell/render.rs` +- Crabcode OpenAI/Codex transport: `src/llm/client.rs` +- Crabcode prompt composer: `src/prompt/mod.rs` +- Crabcode AI SDK bridge: `src/tools/aisdk_bridge.rs` +- Crabcode current subagents: `src/agent/subagent.rs` +- Crabcode chat tool renderer: `src/ui/components/chat.rs` + +## Current Snapshot + +Crabcode already has useful pieces: OpenAI OAuth token refresh, `/backend-api/codex/responses` routing, `store=false`, provider/model selection, permissions, a multi-step AI SDK tool loop, dynamic `question`/`task` tools, skills, AGENTS/CLAUDE rule loading, and session UI. + +The parity blockers are still fundamental: + +- OpenAI OAuth currently sets `strip_system_and_developer_messages(true)`, so `SystemPromptComposer` output, AGENTS instructions, environment context, skills, and subagents can be dropped for Codex-backed requests. +- The AI SDK loop converts tool results into synthetic user messages (`Tool x result`) instead of preserving Responses API function-call/function-output items. +- Crabcode persists UI tool panels, but `convert_messages()` skips `MessageRole::Tool`, so later turns lose model-visible tool call history. +- Tool names are Crabcode/OpenCode-style (`bash`, `read`, `grep`, `glob`, `edit`, `write`, `todowrite`, `task`) rather than Codex-style (`exec_command`, `write_stdin`, `apply_patch`, `update_plan`, `view_image`, `spawn_agent`, `wait_agent`, etc.). +- Current `task` subagents are single-shot model calls. Codex subagents are real child threads with their own turn loops, tool calls, status, waiting, resuming, and closure. +- Tool-call UI renders generic JSON tool rows. Codex renders semantic cells: `Ran`, `Running`, `Explored`, `Called`, with grouped read/search/list commands and concise output gutters. + +## Priority Checklist + +### P0 - Model Contract + +- [ ] Add a request/response trace harness for Codex mode. + - Capture sanitized outbound request JSON for the same fixture prompt. + - Capture `instructions`, `input`, `tools`, `parallel_tool_calls`, model, effort, service tier, and output schema. + - Compare Crabcode against Codex reference behavior before changing large pieces. + +- [ ] Preserve Codex instructions for OpenAI OAuth. + - Do not silently drop `SystemPromptComposer` output. + - Move base instructions, AGENTS, environment, permissions, skills, and app/plugin instructions into fields accepted by the ChatGPT Codex backend. + - Keep the Codex base prompt close to the reference instead of the current short fallback. + +- [ ] Store model-visible conversation items. + - Persist assistant messages, function calls, function outputs, reasoning summaries, and tool outputs in a model-replayable form. + - Keep UI tool panels as a render layer, not the canonical model history. + - Rehydrate the next turn from canonical Responses-style items, not only text messages. + +- [ ] Replace synthetic tool result messages. + - Stop feeding tool results back as plain user text in Codex mode. + - Return function-call outputs using the provider's native item shape. + - Preserve call IDs exactly. + +- [ ] Match Codex request options. + - Send `parallel_tool_calls` based on model support. + - Support reasoning effort, reasoning summary, verbosity, service tier, and final output schema when available. + - Keep `store=false` for ChatGPT Codex transport. + +### P0 - Tool Surface + +- [ ] Add a Codex tool profile. + - Use Codex names and schemas for Codex/GPT-5.x models. + - Keep Crabcode's existing tool profile for non-Codex providers where useful. + +- [ ] Implement `exec_command`. + - Replace model-visible `bash` with Codex's `exec_command` schema. + - Include `cmd`, `workdir`, `shell`, `login`, `tty`, `yield_time_ms`, `max_output_tokens`, `sandbox_permissions`, `justification`, and `prefix_rule`. + - Return structured output with wall time, exit code, session ID for background commands, original token count, and truncated output. + +- [ ] Implement `write_stdin`. + - Support polling and writing to an existing background/PTY session. + - Preserve command session IDs across tool calls. + +- [ ] Implement freeform `apply_patch`. + - Use the Codex Lark grammar shape. + - Do not wrap patch input in JSON. + - Emit patch progress/diff events for the UI. + +- [ ] Implement `update_plan`. + - Replace `todowrite` in Codex mode. + - Render plan updates as their own user-visible progress surface. + +- [ ] Add Codex-compatible utility tools. + - `view_image` + - `web_search` when enabled + - `request_user_input` or an equivalent bounded user-question flow + - MCP resource tools: `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource` + +### P1 - Turn Loop + +- [ ] Own the Responses-style turn loop. + - Sample model output. + - Stream assistant text/reasoning/tool argument deltas. + - Dispatch tool calls. + - Append native tool outputs. + - Continue sampling until `end_turn` or no follow-up is needed. + +- [ ] Execute parallel-safe tools concurrently. + - Use per-tool parallel support flags. + - Serialize tools that mutate shared state or require exclusive terminal access. + +- [ ] Add retry and fallback behavior. + - Retry transient stream failures with backoff. + - Keep the same turn-scoped client/session when retrying. + - Surface reconnect warnings without corrupting history. + +- [ ] Add compaction. + - Pre-turn compaction when context exceeds the active model's limit. + - Mid-turn compaction when tools or pending input require continuation. + - Model-downshift compaction when switching to a smaller context window. + +### P1 - Permissions And Sandboxing + +- [ ] Move permission checks into a tool orchestrator. + - Approval preflight. + - Sandbox selection. + - Retry after sandbox denial with escalation request. + - Prefix-rule persistence for approved command families. + +- [ ] Match Codex command approval semantics. + - `sandbox_permissions="require_escalated"` + - Required `justification` + - Optional `prefix_rule` + - No broad prefix rules for arbitrary scripting or destructive commands. + +- [ ] Add network and filesystem policy concepts. + - Workspace-write default. + - Extra writable roots. + - Network-denial handling. + - Per-turn/session granted permissions. + +### P1 - Subagents + +- [ ] Replace `task` with Codex-style agent control in Codex mode. + - `spawn_agent` + - `send_input` + - `wait_agent` + - `resume_agent` + - `close_agent` + +- [ ] Make spawned agents real sessions. + - Child thread IDs. + - Parent/child relation. + - Own prompt, tools, permissions, cancellation, status, and history. + - Optional `fork_context`. + - Model and reasoning overrides only when explicitly requested or clearly needed. + +- [ ] Add subagent usage rules to the prompt. + - Tell the model when to delegate. + - Prevent subagent spawning unless the user explicitly asks for agents/delegation or the configured tool profile allows it. + - Keep wait behavior sparse and non-blocking. + +### P2 - Chat Panel UX + +- [ ] Replace JSON tool rows with Codex-like history cells. + - `Running <cmd>` while active. + - `Ran <cmd>` when complete. + - `(no output)` for empty output. + - Tree gutters: `└`, `│`, continuation indentation. + - Red/green status bullets. + +- [ ] Add `Explored` grouping. + - Parse `exec_command` shell commands into semantic read/list/search operations. + - Coalesce adjacent read/search/list commands into one exploration cell. + - Render examples like `Read dialog.rs, app.rs` and `Search shimmer_spans`. + +- [ ] Add patch cells. + - Stream partial `apply_patch` changes. + - Show concise file-level diffs. + - Keep full details available in transcript/history. + +- [ ] Add agent cells. + - Spawned/running/completed/errored subagent states. + - Wait summaries. + - Final subagent result summaries. + +## First Implementation Slice + +Start with a "Codex mode contract" slice before polishing UI: + +1. Add a debug request recorder around `aisdk/src/providers/openai.rs` or the higher-level LLM client. +2. Add a Codex-mode prompt/request fixture test that asserts the request contains full instructions, environment, AGENTS text, model-visible tools, and no dropped context. +3. Change OpenAI OAuth request construction so full Codex instructions survive the ChatGPT Codex backend path. +4. Add a canonical model-history representation that can store native function calls and function outputs. +5. Add Codex aliases for `exec_command`, `apply_patch`, and `update_plan`, even if the first handlers delegate internally to existing Bash/edit/todo code. + +Only after this slice should the chat rendering be rewritten. The Codex-style renderer depends on getting the event model right; otherwise the UI will be pretty but still model-divergent. + +## Non-Goals + +- Do not replace Crabcode's multi-workspace setup. +- Do not replace themes, dialogs, model picker, sessions dialog, or global app layout. +- Do not remove the existing non-Codex tool profile unless it blocks Codex mode. +- Do not chase every Codex app/plugin/cloud feature before the local harness contract is correct. From 5521897e863793086db367dd7fa807862c1b44dd Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 01:39:27 +0800 Subject: [PATCH 086/226] feat(chat): remove vertical centering of content, render at top. --- src/ui/components/chat.rs | 57 ++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 814800a..9ab8905 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -704,13 +704,11 @@ impl Chat { width: area.width.saturating_sub(2), height: area.height, }; - let visual_y_offset = - content_visual_y_offset(self.content_height, self.viewport_height) as u16; let rendered_content_area = Rect { x: content_area.x, - y: content_area.y.saturating_add(visual_y_offset), + y: content_area.y, width: content_area.width, - height: content_area.height.saturating_sub(visual_y_offset), + height: content_area.height, }; let is_on_scrollbar = scrollbar_area.contains(point); @@ -918,12 +916,11 @@ impl Chat { let viewport = self.viewport_height; let max_offset = content_height.saturating_sub(viewport); let clamped_scroll = self.scroll_offset.min(max_offset); - let visual_y_offset = content_visual_y_offset(content_height, viewport) as u16; let render_area = Rect { x: content_area.x, - y: content_area.y.saturating_add(visual_y_offset), + y: content_area.y, width: content_area.width, - height: content_area.height.saturating_sub(visual_y_offset), + height: content_area.height, }; // Render timeline highlight as a full-width background overlay @@ -952,7 +949,6 @@ impl Chat { if vis_end > vis_start { let y = content_area .y - .saturating_add(visual_y_offset) .saturating_add((vis_start - clamped_scroll) as u16); let height = (vis_end - vis_start).saturating_sub(1) as u16; if height > 0 { @@ -1937,14 +1933,6 @@ fn is_synthetic_tool_result_text(content: &str) -> bool { content.trim_start().starts_with("[tool result:") } -fn content_visual_y_offset(content_height: usize, viewport_height: usize) -> usize { - if content_height == 0 { - 0 - } else { - viewport_height.saturating_sub(content_height) - } -} - fn render_line_backgrounds( f: &mut Frame, area: Rect, @@ -2308,7 +2296,9 @@ mod tests { assert!(rendered .iter() .any(|line| line.contains("ctrl+x down view subagents"))); - assert!(!rendered.iter().any(|line| line.contains("prompt=\"Say hi\""))); + assert!(!rendered + .iter() + .any(|line| line.contains("prompt=\"Say hi\""))); assert!(!rendered.iter().any(|line| line.contains("Hi there!"))); } @@ -2362,12 +2352,37 @@ mod tests { .collect::<Vec<_>>(); assert!(rows[0].trim().is_empty()); + assert_eq!(rows[1].trim(), "# Todos"); + assert!(rows[4].contains("Implement rendering")); + assert!(rows[5].trim().is_empty()); + assert_eq!(buffer[(0, 0)].bg, colors.background_element); + assert_eq!(buffer[(0, 5)].bg, colors.background_element); + assert!(rows[6].trim().is_empty()); + assert!(rows[7].trim().is_empty()); + } + + #[test] + fn test_short_chat_content_renders_at_top() { + use ratatui::{backend::TestBackend, Terminal}; + + let colors = test_colors(); + let mut chat = Chat::new(); + chat.add_user_message("hello"); + + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| chat.render(f, Rect::new(0, 0, 40, 8), "Plan", "model", &colors)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let rows = (0..8) + .map(|y| buffer_row_text(buffer, 38, y)) + .collect::<Vec<_>>(); + + assert!(rows[0].contains("hello")); assert!(rows[1].trim().is_empty()); assert!(rows[2].trim().is_empty()); - assert_eq!(rows[3].trim(), "# Todos"); - assert!(rows[6].contains("Implement rendering")); - assert!(rows[7].trim().is_empty()); - assert_eq!(buffer[(0, 7)].bg, colors.background_element); } #[test] From 968360b5ac9e213f120c52bcebfa2dcff7cbdbe8 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 01:49:38 +0800 Subject: [PATCH 087/226] fix: add vertical padding and background styling to user message bubbles. --- src/ui/components/chat.rs | 51 +++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 9ab8905..7928ec0 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1060,23 +1060,44 @@ impl Chat { // User message: Box with left border colored by agent mode let border_color = crate::theme::agent_mode_color(message.agent_mode.as_deref(), colors); + let bg = colors.background_element; + let border_style = Style::default().fg(border_color); + let pad_style = Style::default().bg(bg); + let text_style = Style::default().fg(colors.text).bg(bg); let content = message.content.clone(); + let horizontal_padding = 2usize; + let right_padding = 2usize; + let wrap_width = max_width + .saturating_sub(1 + horizontal_padding + right_padding) + .max(1); + + let padding_line = || { + Line::from(vec![ + Span::styled("▌", border_style), + Span::styled(" ".repeat(max_width.saturating_sub(1)), pad_style), + ]) + }; // Wrap content to fit within max_width - padding - let wrapped_lines = textwrap::wrap(&content, max_width.saturating_sub(4).max(1)); + let wrapped_lines = textwrap::wrap(&content, wrap_width); + + lines.push(padding_line()); for line in wrapped_lines.iter() { - let left_border = "▌ "; let line_width = UnicodeWidthStr::width(line.as_ref()); - let right_padding = " ".repeat(max_width.saturating_sub(line_width + 3)); + let trailing_padding = + " ".repeat(max_width.saturating_sub(1 + horizontal_padding + line_width)); lines.push(Line::from(vec![ - Span::styled(left_border, Style::default().fg(border_color)), - Span::raw(line.to_string()), - Span::raw(right_padding), + Span::styled("▌", border_style), + Span::styled(" ".repeat(horizontal_padding), pad_style), + Span::styled(line.to_string(), text_style), + Span::styled(trailing_padding, pad_style), ])); } + lines.push(padding_line()); + // Add empty line after user message lines.push(Line::from("")); } @@ -2365,7 +2386,8 @@ mod tests { fn test_short_chat_content_renders_at_top() { use ratatui::{backend::TestBackend, Terminal}; - let colors = test_colors(); + let mut colors = test_colors(); + colors.background_element = Color::Indexed(236); let mut chat = Chat::new(); chat.add_user_message("hello"); @@ -2380,9 +2402,18 @@ mod tests { .map(|y| buffer_row_text(buffer, 38, y)) .collect::<Vec<_>>(); - assert!(rows[0].contains("hello")); - assert!(rows[1].trim().is_empty()); - assert!(rows[2].trim().is_empty()); + assert!(rows[0].starts_with("▌")); + assert!(!rows[0].contains("hello")); + assert!(rows[1].starts_with("▌")); + assert!(rows[1].contains("hello")); + assert!(rows[2].starts_with("▌")); + assert!(!rows[2].contains("hello")); + assert!(rows[3].trim().is_empty()); + + assert_eq!(buffer[(1, 0)].bg, colors.background_element); + assert_eq!(buffer[(1, 1)].bg, colors.background_element); + assert_eq!(buffer[(1, 2)].bg, colors.background_element); + assert_ne!(buffer[(1, 3)].bg, colors.background_element); } #[test] From 26ef4985f28f09317e9dd2d879de02a9c1dcbd51 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 02:21:02 +0800 Subject: [PATCH 088/226] feat: bound tool output and compact read/list UI. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract `truncate_tool_output` helper with separate preview (4 KB) and model-output (60 KB) limits; apply to UI rendering and AI SDK bridge. - Limit `list` tool to depth 3 and 1,000 output lines; skip common generated/dependency directories (.git, target, node_modules, etc.); emit truncation metadata. - Render `read` and `list` tool calls as compact Codex-style summaries (e.g. "• Explored └ Read AGENTS.md") instead of full JSON args. --- src/tools/aisdk_bridge.rs | 55 ++++++++++++--- src/tools/fs/list.rs | 139 ++++++++++++++++++++++++++++++++----- src/ui/components/chat.rs | 142 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 30 deletions(-) diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 28247c8..0a67f09 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -7,6 +7,9 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use crate::llm::ChunkSender; +const TOOL_UI_PREVIEW_LIMIT: usize = 4_000; +const TOOL_MODEL_OUTPUT_LIMIT: usize = 60_000; + static TOOL_CALL_SEQ: AtomicUsize = AtomicUsize::new(0); pub async fn convert_to_aisdk_tools( @@ -30,7 +33,6 @@ pub async fn convert_to_aisdk_tools( } let tool_id = tool_def.id.clone(); - let tool_description = tool_def.description.clone(); let registry = registry.clone(); let sender = sender.clone(); let agent_mode = agent_mode.clone(); @@ -43,8 +45,6 @@ pub async fn convert_to_aisdk_tools( let tool_id_for_exec = tool_id.clone(); let tool_id_for_ui = tool_id.clone(); - let tool_description = tool_description.clone(); - let tool_description_for_ui = tool_description.clone(); let registry = registry.clone(); let sender = sender.clone(); let agent_mode = agent_mode.clone(); @@ -109,14 +109,11 @@ pub async fn convert_to_aisdk_tools( tool_result.output.len() )); + let model_output = + truncate_tool_output(&tool_result.output, TOOL_MODEL_OUTPUT_LIMIT); + if let Some(ref sender) = sender { - let preview_limit: usize = 4000; - let mut preview = tool_result.output.clone(); - if preview.len() > preview_limit { - let boundary = preview.floor_char_boundary(preview_limit); - preview.truncate(boundary); - preview.push_str("... (truncated)"); - } + let preview = truncate_tool_output(&tool_result.output, TOOL_UI_PREVIEW_LIMIT); let line_count = tool_result.output.lines().count(); let meta = serde_json::Value::Object( @@ -145,7 +142,7 @@ pub async fn convert_to_aisdk_tools( )); } - Ok(tool_result.output) + Ok(model_output) } }); @@ -198,6 +195,20 @@ pub async fn convert_to_aisdk_tools( aisdk_tools } +fn truncate_tool_output(output: &str, limit: usize) -> String { + if output.len() <= limit { + return output.to_string(); + } + + let boundary = output.floor_char_boundary(limit); + let mut truncated = output[..boundary].to_string(); + truncated.push_str(&format!( + "\n\n... (tool output truncated to {} bytes; narrow the request for more)", + limit + )); + truncated +} + fn param_to_json_schema(param_type: &crate::tools::ParameterType) -> serde_json::Value { use crate::tools::ParameterType; @@ -223,3 +234,25 @@ fn param_to_json_schema(param_type: &crate::tools::ParameterType) -> serde_json: } } } + +#[cfg(test)] +mod tests { + use super::truncate_tool_output; + + #[test] + fn truncate_tool_output_bounds_large_results() { + let output = "a".repeat(70_000); + + let truncated = truncate_tool_output(&output, 60_000); + + assert!(truncated.len() < output.len()); + assert!(truncated.contains("tool output truncated to 60000 bytes")); + } + + #[test] + fn truncate_tool_output_preserves_small_results() { + let output = "small result"; + + assert_eq!(truncate_tool_output(output, 60_000), output); + } +} diff --git a/src/tools/fs/list.rs b/src/tools/fs/list.rs index 58b1317..ed8c960 100644 --- a/src/tools/fs/list.rs +++ b/src/tools/fs/list.rs @@ -6,6 +6,20 @@ use async_trait::async_trait; use serde_json::Value; use std::path::Path; +const MAX_DEPTH: usize = 3; +const MAX_OUTPUT_LINES: usize = 1_000; +const DEFAULT_SKIPPED_DIRS: &[&str] = &[ + ".git", + "target", + "node_modules", + ".next", + ".turbo", + ".cache", + "dist", + "build", + "coverage", +]; + pub struct ListTool; impl ListTool { @@ -14,15 +28,25 @@ impl ListTool { } fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool { - // Keep repository internals out of default tree output while still - // surfacing other dotfiles (for example .env, .env.local). - if name == ".git" { + // Keep expensive generated/dependency trees out of default recursive + // output while still surfacing ordinary dotfiles such as .env. + if DEFAULT_SKIPPED_DIRS.contains(&name) { return true; } ignore_patterns.iter().any(|p| name.contains(p)) } + fn push_output(output: &mut Vec<String>, line: String, truncated: &mut bool) -> bool { + if output.len() >= MAX_OUTPUT_LINES { + *truncated = true; + return false; + } + + output.push(line); + true + } + fn list_directory( path: &Path, ignore_patterns: &[String], @@ -30,21 +54,26 @@ impl ListTool { is_last: bool, output: &mut Vec<String>, depth: usize, - ) -> Result<(), ToolError> { - const MAX_DEPTH: usize = 10; - + truncated: &mut bool, + ) -> Result<bool, ToolError> { if depth > MAX_DEPTH { - return Ok(()); + return Ok(true); } let connector = if is_last { "└── " } else { "├── " }; if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - output.push(format!("{}{}{}", prefix, connector, name)); + if !Self::push_output( + output, + format!("{}{}{}", prefix, connector, name), + truncated, + ) { + return Ok(false); + } } if !path.is_dir() { - return Ok(()); + return Ok(true); } let entries: Vec<_> = std::fs::read_dir(path) @@ -80,17 +109,20 @@ impl ListTool { let count = filtered.len(); for (i, entry) in filtered.iter().enumerate() { let is_last_entry = i == count - 1; - Self::list_directory( + if !Self::list_directory( &entry.path(), ignore_patterns, &new_prefix, is_last_entry, output, depth + 1, - )?; + truncated, + )? { + return Ok(false); + } } - Ok(()) + Ok(true) } } @@ -99,7 +131,7 @@ impl ToolHandler for ListTool { fn definition(&self) -> Tool { Tool { id: "list".to_string(), - description: "List directory contents in a tree format. Includes hidden and gitignored files (except .git internals)." + description: "List directory contents in a bounded tree format. Includes hidden files, while skipping common generated/dependency directories unless listed directly." .to_string(), parameters: vec![ ParameterSchema { @@ -153,11 +185,12 @@ impl ToolHandler for ListTool { } let mut output = Vec::new(); + let mut truncated = false; if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - output.push(name.to_string()); + Self::push_output(&mut output, name.to_string(), &mut truncated); } else { - output.push(path_str.clone()); + Self::push_output(&mut output, path_str.clone(), &mut truncated); } let entries: Vec<_> = std::fs::read_dir(path) @@ -187,22 +220,57 @@ impl ToolHandler for ListTool { let count = filtered.len(); for (i, entry) in filtered.iter().enumerate() { let is_last = i == count - 1; - Self::list_directory(&entry.path(), &ignore_patterns, "", is_last, &mut output, 1)?; + if !Self::list_directory( + &entry.path(), + &ignore_patterns, + "", + is_last, + &mut output, + 1, + &mut truncated, + )? { + break; + } } - let result_text = if output.len() <= 1 { + let mut result_text = if output.len() <= 1 { format!("{}\n(empty directory)", output.join("\n")) } else { output.join("\n") }; - Ok(ToolResult::new(format!("List: {}", path_str), result_text)) + if truncated { + result_text.push_str(&format!( + "\n\n... output truncated after {} entries. Narrow the path or add ignore patterns for more.", + MAX_OUTPUT_LINES + )); + } + + Ok(ToolResult::new(format!("List: {}", path_str), result_text) + .with_metadata("truncated", serde_json::json!(truncated)) + .with_metadata("limit", serde_json::json!(MAX_OUTPUT_LINES)) + .with_metadata("max_depth", serde_json::json!(MAX_DEPTH))) } } #[cfg(test)] mod tests { - use super::ListTool; + use super::{ListTool, MAX_OUTPUT_LINES}; + use crate::tools::{ToolContext, ToolHandler}; + use serde_json::json; + + fn unique_temp_dir(prefix: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be monotonic enough for tests") + .as_nanos(); + std::env::temp_dir().join(format!("{}_{}", prefix, nanos)) + } + + fn tool_context() -> ToolContext { + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); + ToolContext::new("session", "message", "Plan", abort_rx) + } #[test] fn should_skip_entry_keeps_dotenv_visible() { @@ -214,4 +282,37 @@ mod tests { fn should_skip_entry_hides_git_metadata_directory() { assert!(ListTool::should_skip_entry(".git", &[])); } + + #[test] + fn should_skip_entry_hides_generated_directories() { + assert!(ListTool::should_skip_entry("target", &[])); + assert!(ListTool::should_skip_entry("node_modules", &[])); + } + + #[test] + fn list_output_is_bounded() { + let dir = unique_temp_dir("crabcode_list_tool_test"); + std::fs::create_dir_all(&dir).expect("temp dir should be created"); + + for idx in 0..(MAX_OUTPUT_LINES + 25) { + std::fs::write(dir.join(format!("file_{idx:04}.txt")), "x") + .expect("test file should be written"); + } + + let tool = ListTool::new(); + let result = tokio_test::block_on(tool.execute( + json!({ "path": dir.to_string_lossy().to_string() }), + &tool_context(), + )) + .expect("list should succeed"); + + assert!(result.output.contains("output truncated")); + assert_eq!( + result.metadata.get("truncated").and_then(|v| v.as_bool()), + Some(true) + ); + assert!(result.output.lines().count() <= MAX_OUTPUT_LINES + 2); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 7928ec0..029a76e 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1272,6 +1272,68 @@ impl Chat { } } + fn arg_string<'v>( + obj: Option<&'v serde_json::Map<String, JsonValue>>, + keys: &[&str], + ) -> Option<&'v str> { + keys.iter() + .find_map(|key| obj.and_then(|o| o.get(*key)).and_then(|v| v.as_str())) + .filter(|value| !value.trim().is_empty()) + } + + fn strip_tool_title<'v>(title: Option<&'v str>, label: &str) -> Option<&'v str> { + let prefix = format!("{}:", label); + title + .and_then(|value| value.strip_prefix(&prefix)) + .map(str::trim) + .filter(|value| !value.is_empty()) + } + + fn display_path(raw: &str, basename_only: bool) -> String { + let trimmed = raw.trim(); + let path = std::path::Path::new(trimmed); + + if basename_only { + return path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(trimmed) + .to_string(); + } + + if path.is_absolute() { + if let Ok(cwd) = std::env::current_dir() { + if let Ok(rel) = path.strip_prefix(cwd) { + let rendered = rel.to_string_lossy(); + return if rendered.is_empty() { + ".".to_string() + } else { + rendered.into_owned() + }; + } + } + } + + trimmed.to_string() + } + + fn explored_tool_target( + name: &str, + args_obj: Option<&serde_json::Map<String, JsonValue>>, + title: Option<&str>, + ) -> Option<String> { + match name { + "read" => arg_string(args_obj, &["file_path", "filePath", "path"]) + .or_else(|| strip_tool_title(title, "Read")) + .map(|path| display_path(path, true)), + "list" => arg_string(args_obj, &["path"]) + .or_else(|| strip_tool_title(title, "List")) + .map(|path| display_path(path, false)), + _ => None, + } + } + fn question_values( args: &Option<JsonValue>, metadata: &Option<JsonValue>, @@ -1485,6 +1547,48 @@ impl Chat { }; let args_obj = args.as_ref().and_then(|v| v.as_object()); + if status != "error" { + if let Some(target) = explored_tool_target(&name, args_obj, title.as_deref()) { + let action_label = if name == "read" { "Read" } else { "List" }; + let gutter_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let action_style = Style::default() + .fg(colors.accent) + .add_modifier(Modifier::BOLD); + let target_style = Style::default().fg(colors.text); + let marker = if status == "running" { "~" } else { "•" }; + let heading = if status == "running" { + "Exploring" + } else { + "Explored" + }; + + out.push(Line::from(vec![ + Span::styled(marker, gutter_style), + Span::raw(" "), + Span::styled(heading, title_style), + ])); + + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(" └ ", gutter_style), + Span::styled(action_label, action_style), + Span::raw(" "), + Span::styled(target, target_style), + ]), + max_width, + Line::from(Span::styled(" ", gutter_style)), + ); + + return out; + } + } + let args_str = if name == "glob" { let pat = args_obj .and_then(|o| o.get("pattern")) @@ -2230,6 +2334,44 @@ mod tests { assert!(rendered.len() <= TOOL_RESULT_MAX_SCREEN_LINES + 2); } + #[test] + fn test_read_tool_renders_codex_style_explored_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "read", + "status": "ok", + "args": { "file_path": "/Users/carlo/Desktop/Projects/crabcode/AGENTS.md" }, + "output_preview": "00001| # Agent Context\n00002| More content", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert_eq!(rendered, vec!["• Explored", " └ Read AGENTS.md"]); + } + + #[test] + fn test_list_tool_renders_codex_style_explored_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "list", + "status": "ok", + "args": { "path": "src/ui" }, + "output_preview": "src/ui\ncomponents\nmarkdown", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert_eq!(rendered, vec!["• Explored", " └ List src/ui"]); + } + #[test] fn test_question_panel_keeps_padding_without_extra_gap() { let chat = Chat::new(); From 13a6b0c9c1bcff69bda30641d5a07ef177a52981 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 02:32:24 +0800 Subject: [PATCH 089/226] feat(chat): group consecutive exploration tool messages and refactor list tool. Replace the tree-based list tool with a flat paginated directory listing (offset/limit, XML-like markup, directory markers). Group adjacent read/list/glob/grep tool messages into a single "Explored/Exploring" block with deduplicated Read targets. --- src/tools/fs/list.rs | 319 +++++++++------------- src/ui/components/chat.rs | 554 ++++++++++++++++++++++++++------------ 2 files changed, 499 insertions(+), 374 deletions(-) diff --git a/src/tools/fs/list.rs b/src/tools/fs/list.rs index ed8c960..73adfc6 100644 --- a/src/tools/fs/list.rs +++ b/src/tools/fs/list.rs @@ -1,24 +1,12 @@ use crate::tools::{ - get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, - ToolError, ToolHandler, ToolResult, + get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; use std::path::Path; -const MAX_DEPTH: usize = 3; -const MAX_OUTPUT_LINES: usize = 1_000; -const DEFAULT_SKIPPED_DIRS: &[&str] = &[ - ".git", - "target", - "node_modules", - ".next", - ".turbo", - ".cache", - "dist", - "build", - "coverage", -]; +const DEFAULT_LIMIT: usize = 2_000; pub struct ListTool; @@ -27,102 +15,60 @@ impl ListTool { Self } - fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool { - // Keep expensive generated/dependency trees out of default recursive - // output while still surfacing ordinary dotfiles such as .env. - if DEFAULT_SKIPPED_DIRS.contains(&name) { - return true; - } - - ignore_patterns.iter().any(|p| name.contains(p)) - } - - fn push_output(output: &mut Vec<String>, line: String, truncated: &mut bool) -> bool { - if output.len() >= MAX_OUTPUT_LINES { - *truncated = true; - return false; - } + fn entry_name(path: &Path, entry: &std::fs::DirEntry) -> Option<String> { + let name = entry.file_name().to_string_lossy().to_string(); + let kind = entry.file_type().ok()?; + let is_dir = if kind.is_dir() { + true + } else if kind.is_symlink() { + std::fs::metadata(path.join(&name)) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + } else { + false + }; - output.push(line); - true + Some(if is_dir { format!("{}/", name) } else { name }) } - fn list_directory( - path: &Path, - ignore_patterns: &[String], - prefix: &str, - is_last: bool, - output: &mut Vec<String>, - depth: usize, - truncated: &mut bool, - ) -> Result<bool, ToolError> { - if depth > MAX_DEPTH { - return Ok(true); - } - - let connector = if is_last { "└── " } else { "├── " }; - - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if !Self::push_output( - output, - format!("{}{}{}", prefix, connector, name), - truncated, - ) { - return Ok(false); - } - } - - if !path.is_dir() { - return Ok(true); - } - - let entries: Vec<_> = std::fs::read_dir(path) + fn list_entries(path: &Path) -> Result<Vec<String>, ToolError> { + let mut entries: Vec<String> = std::fs::read_dir(path) .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))? - .filter_map(|e| e.ok()) - .collect(); - - let mut filtered: Vec<_> = entries - .into_iter() - .filter(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - !Self::should_skip_entry(&name, ignore_patterns) + .filter_map(|entry| { + let entry = entry.ok()?; + Self::entry_name(path, &entry) }) .collect(); - filtered.sort_by(|a, b| { - let a_is_dir = a.file_type().map(|t| t.is_dir()).unwrap_or(false); - let b_is_dir = b.file_type().map(|t| t.is_dir()).unwrap_or(false); + entries.sort(); + Ok(entries) + } - match (a_is_dir, b_is_dir) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.file_name().cmp(&b.file_name()), - } - }); + fn format_entries(path: &Path, entries: &[String], offset: usize, limit: usize) -> String { + let start = offset.min(entries.len()); + let end = start.saturating_add(limit).min(entries.len()); + let selected = &entries[start..end]; + let truncated = end < entries.len(); - let new_prefix = if is_last { - format!("{} ", prefix) - } else { - format!("{}│ ", prefix) - }; + let mut output = String::new(); + output.push_str(&format!("<path>{}</path>\n", path.display())); + output.push_str("<type>directory</type>\n"); + output.push_str("<entries>\n"); + output.push_str(&selected.join("\n")); - let count = filtered.len(); - for (i, entry) in filtered.iter().enumerate() { - let is_last_entry = i == count - 1; - if !Self::list_directory( - &entry.path(), - ignore_patterns, - &new_prefix, - is_last_entry, - output, - depth + 1, - truncated, - )? { - return Ok(false); - } + if truncated { + output.push_str(&format!( + "\n\n(Showing {} of {} entries. Use offset {} to continue)\n", + selected.len(), + entries.len(), + end + )); + } else { + output.push_str(&format!("\n\n({} entries)\n", entries.len())); } - Ok(true) + output.push_str("</entries>"); + output } } @@ -131,8 +77,9 @@ impl ToolHandler for ListTool { fn definition(&self) -> Tool { Tool { id: "list".to_string(), - description: "List directory contents in a bounded tree format. Includes hidden files, while skipping common generated/dependency directories unless listed directly." - .to_string(), + description: + "List direct directory entries with pagination. Directories are suffixed with `/`." + .to_string(), parameters: vec![ ParameterSchema { name: "path".to_string(), @@ -141,10 +88,16 @@ impl ToolHandler for ListTool { param_type: ParameterType::String, }, ParameterSchema { - name: "ignore".to_string(), - description: "Patterns to ignore (e.g., ['node_modules', 'target'])".to_string(), + name: "offset".to_string(), + description: "Entry offset to start from (0-based, default: 0)".to_string(), + required: false, + param_type: ParameterType::Integer, + }, + ParameterSchema { + name: "limit".to_string(), + description: "Maximum number of entries to return (default: 2000)".to_string(), required: false, - param_type: ParameterType::Array(Box::new(ParameterType::String)), + param_type: ParameterType::Integer, }, ], } @@ -157,16 +110,18 @@ impl ToolHandler for ListTool { async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> { let path_str = get_string_param(¶ms, "path") .ok_or_else(|| ToolError::Validation("path is required".to_string()))?; - - let ignore_patterns: Vec<String> = params - .get("ignore") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() + let offset = get_integer_param(¶ms, "offset") + .map(|value| value.max(0) as usize) + .unwrap_or(0); + let limit = get_integer_param(¶ms, "limit") + .map(|value| { + if value <= 0 { + DEFAULT_LIMIT + } else { + value as usize + } }) - .unwrap_or_default(); + .unwrap_or(DEFAULT_LIMIT); let path = Path::new(&path_str); @@ -184,78 +139,31 @@ impl ToolHandler for ListTool { ))); } - let mut output = Vec::new(); - let mut truncated = false; - - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - Self::push_output(&mut output, name.to_string(), &mut truncated); - } else { - Self::push_output(&mut output, path_str.clone(), &mut truncated); - } - - let entries: Vec<_> = std::fs::read_dir(path) - .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))? - .filter_map(|e| e.ok()) - .collect(); - - let mut filtered: Vec<_> = entries - .into_iter() - .filter(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - !Self::should_skip_entry(&name, &ignore_patterns) - }) - .collect(); - - filtered.sort_by(|a, b| { - let a_is_dir = a.file_type().map(|t| t.is_dir()).unwrap_or(false); - let b_is_dir = b.file_type().map(|t| t.is_dir()).unwrap_or(false); - - match (a_is_dir, b_is_dir) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.file_name().cmp(&b.file_name()), - } - }); - - let count = filtered.len(); - for (i, entry) in filtered.iter().enumerate() { - let is_last = i == count - 1; - if !Self::list_directory( - &entry.path(), - &ignore_patterns, - "", - is_last, - &mut output, - 1, - &mut truncated, - )? { - break; - } - } - - let mut result_text = if output.len() <= 1 { - format!("{}\n(empty directory)", output.join("\n")) - } else { - output.join("\n") - }; - - if truncated { - result_text.push_str(&format!( - "\n\n... output truncated after {} entries. Narrow the path or add ignore patterns for more.", - MAX_OUTPUT_LINES - )); - } + let entries = Self::list_entries(path)?; + let end = offset.saturating_add(limit).min(entries.len()); + let truncated = end < entries.len(); + let result_text = Self::format_entries(path, &entries, offset, limit); + let preview = entries + .iter() + .skip(offset) + .take(limit) + .take(20) + .cloned() + .collect::<Vec<_>>() + .join("\n"); Ok(ToolResult::new(format!("List: {}", path_str), result_text) .with_metadata("truncated", serde_json::json!(truncated)) - .with_metadata("limit", serde_json::json!(MAX_OUTPUT_LINES)) - .with_metadata("max_depth", serde_json::json!(MAX_DEPTH))) + .with_metadata("count", serde_json::json!(entries.len())) + .with_metadata("offset", serde_json::json!(offset)) + .with_metadata("limit", serde_json::json!(limit)) + .with_metadata("preview", serde_json::json!(preview))) } } #[cfg(test)] mod tests { - use super::{ListTool, MAX_OUTPUT_LINES}; + use super::ListTool; use crate::tools::{ToolContext, ToolHandler}; use serde_json::json; @@ -273,45 +181,62 @@ mod tests { } #[test] - fn should_skip_entry_keeps_dotenv_visible() { - assert!(!ListTool::should_skip_entry(".env", &[])); - assert!(!ListTool::should_skip_entry(".env.local", &[])); - } + fn list_outputs_direct_entries_sorted_with_directory_markers() { + let dir = unique_temp_dir("crabcode_list_tool_test"); + std::fs::create_dir_all(&dir).expect("temp dir should be created"); + std::fs::create_dir_all(dir.join("src")).expect("child dir should be created"); + std::fs::write(dir.join("README.md"), "x").expect("test file should be written"); + std::fs::write(dir.join("src").join("nested.rs"), "x") + .expect("nested file should be written"); - #[test] - fn should_skip_entry_hides_git_metadata_directory() { - assert!(ListTool::should_skip_entry(".git", &[])); - } + let tool = ListTool::new(); + let result = tokio_test::block_on(tool.execute( + json!({ "path": dir.to_string_lossy().to_string() }), + &tool_context(), + )) + .expect("list should succeed"); - #[test] - fn should_skip_entry_hides_generated_directories() { - assert!(ListTool::should_skip_entry("target", &[])); - assert!(ListTool::should_skip_entry("node_modules", &[])); + assert!(result.output.contains("<type>directory</type>")); + assert!(result.output.contains("README.md")); + assert!(result.output.contains("src/")); + assert!(!result.output.contains("nested.rs")); + assert!(result.output.contains("(2 entries)")); + assert_eq!( + result.metadata.get("truncated").and_then(|v| v.as_bool()), + Some(false) + ); + + let _ = std::fs::remove_dir_all(&dir); } #[test] - fn list_output_is_bounded() { - let dir = unique_temp_dir("crabcode_list_tool_test"); + fn list_supports_offset_and_limit() { + let dir = unique_temp_dir("crabcode_list_tool_page_test"); std::fs::create_dir_all(&dir).expect("temp dir should be created"); - for idx in 0..(MAX_OUTPUT_LINES + 25) { - std::fs::write(dir.join(format!("file_{idx:04}.txt")), "x") - .expect("test file should be written"); + for name in ["a.txt", "b.txt", "c.txt"] { + std::fs::write(dir.join(name), "x").expect("test file should be written"); } let tool = ListTool::new(); let result = tokio_test::block_on(tool.execute( - json!({ "path": dir.to_string_lossy().to_string() }), + json!({ + "path": dir.to_string_lossy().to_string(), + "offset": 1, + "limit": 1, + }), &tool_context(), )) .expect("list should succeed"); - assert!(result.output.contains("output truncated")); + assert!(!result.output.contains("a.txt")); + assert!(result.output.contains("b.txt")); + assert!(!result.output.contains("c.txt")); + assert!(result.output.contains("Showing 1 of 3 entries")); assert_eq!( result.metadata.get("truncated").and_then(|v| v.as_bool()), Some(true) ); - assert!(result.output.lines().count() <= MAX_OUTPUT_LINES + 2); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 029a76e..2208e17 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -69,6 +69,23 @@ pub struct Chat { const MIN_TOKENS_PER_SECOND_ELAPSED_MS: u128 = 250; const TOOL_RESULT_MAX_SCREEN_LINES: usize = 8; +#[derive(Debug, Clone)] +struct ParsedToolMessage { + name: String, + status: String, + args: Option<JsonValue>, + metadata: Option<JsonValue>, + output_preview: Option<String>, + title: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExplorationToolItem { + label: &'static str, + target: String, + active: bool, +} + fn now_epoch_ms() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() @@ -82,6 +99,150 @@ fn estimate_tokens(text: &str) -> usize { (chars.saturating_add(3)) / 4 } +fn parse_tool_message(content: &str) -> Option<ParsedToolMessage> { + let JsonValue::Object(obj) = serde_json::from_str::<JsonValue>(content).ok()? else { + return None; + }; + + Some(ParsedToolMessage { + name: obj + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("tool") + .to_string(), + status: obj + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("ok") + .to_string(), + args: obj.get("args").cloned(), + metadata: obj.get("metadata").cloned(), + output_preview: obj + .get("output_preview") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + title: obj + .get("title") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }) +} + +fn arg_string<'a>( + obj: Option<&'a serde_json::Map<String, JsonValue>>, + keys: &[&str], +) -> Option<&'a str> { + keys.iter() + .find_map(|key| obj.and_then(|o| o.get(*key)).and_then(|v| v.as_str())) + .filter(|value| !value.trim().is_empty()) +} + +fn strip_tool_title<'a>(title: Option<&'a str>, label: &str) -> Option<&'a str> { + let prefix = format!("{}:", label); + title + .and_then(|value| value.strip_prefix(&prefix)) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn display_path(raw: &str, basename_only: bool) -> String { + let trimmed = raw.trim(); + let path = std::path::Path::new(trimmed); + + if basename_only { + return path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(trimmed) + .to_string(); + } + + if path.is_absolute() { + if let Ok(cwd) = std::env::current_dir() { + if let Ok(rel) = path.strip_prefix(cwd) { + let rendered = rel.to_string_lossy(); + return if rendered.is_empty() { + ".".to_string() + } else { + rendered.into_owned() + }; + } + } + } + + trimmed.to_string() +} + +fn search_target( + args_obj: Option<&serde_json::Map<String, JsonValue>>, + title: Option<&str>, + title_label: &str, +) -> Option<String> { + let query = arg_string(args_obj, &["pattern", "query"]) + .or_else(|| strip_tool_title(title, title_label)) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let path = arg_string(args_obj, &["path"]); + let include = arg_string(args_obj, &["include"]); + + let mut target = query.to_string(); + if let Some(path) = path.filter(|path| *path != ".") { + target.push_str(" in "); + target.push_str(&display_path(path, false)); + } + if let Some(include) = include { + target.push_str(" include="); + target.push_str(include); + } + + Some(target) +} + +fn exploration_tool_item(info: &ParsedToolMessage) -> Option<ExplorationToolItem> { + if info.status == "error" { + return None; + } + + let args_obj = info.args.as_ref().and_then(|v| v.as_object()); + let title = info.title.as_deref(); + let active = matches!(info.status.as_str(), "running" | "pending"); + + let (label, target) = match info.name.as_str() { + "read" => { + let target = arg_string(args_obj, &["file_path", "filePath", "path"]) + .or_else(|| strip_tool_title(title, "Read")) + .map(|path| display_path(path, true))?; + ("Read", target) + } + "list" => { + let target = arg_string(args_obj, &["path"]) + .or_else(|| strip_tool_title(title, "List")) + .map(|path| display_path(path, false))?; + ("List", target) + } + "glob" => ("Search", search_target(args_obj, title, "Glob")?), + "grep" => ("Search", search_target(args_obj, title, "Grep")?), + _ => return None, + }; + + Some(ExplorationToolItem { + label, + target, + active, + }) +} + +fn exploration_tool_item_for_message(message: &Message) -> Option<ExplorationToolItem> { + if message.role != MessageRole::Tool { + return None; + } + + parse_tool_message(&message.content) + .as_ref() + .and_then(exploration_tool_item) +} + impl Chat { pub fn new() -> Self { Self { @@ -293,7 +454,7 @@ impl Chat { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // Bump this whenever rendering logic changes (tables, markdown, etc.) - const RENDER_VERSION: u64 = 3; + const RENDER_VERSION: u64 = 4; RENDER_VERSION.hash(&mut h); colors.hash(&mut h); self.messages.len().hash(&mut h); @@ -572,31 +733,8 @@ impl Chat { model: &str, colors: &ThemeColors, ) -> Vec<usize> { - let mut positions = Vec::with_capacity(self.messages.len()); - let mut line = 0; - let message_count = self.messages.len(); - let streaming_idx = self.streaming_assistant_idx(); - let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); - - for (idx, message) in self.messages.iter().enumerate() { - positions.push(line); - let attached_to_assistant = - idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; - let message_lines = self.format_message( - message, - max_width, - idx, - message_count, - streaming_content, - streaming_idx, - model, - colors, - attached_to_assistant, - ); - line += message_lines.len(); - } - - positions + self.build_all_lines_with_positions(max_width, model, colors) + .1 } pub fn scroll_to_message_index(&mut self, idx: usize) { @@ -875,36 +1013,17 @@ impl Chat { let fingerprint = self.compute_fingerprint(max_width, colors); let cache_valid = !self.cached_lines.is_empty() && fingerprint == self.cached_fingerprint; - let mut positions: Vec<usize>; + let positions: Vec<usize>; let mut all_lines: Vec<Line<'static>>; if cache_valid { positions = self.cached_positions.clone(); all_lines = self.cached_lines.clone(); } else { - let message_count = self.messages.len(); - let streaming_idx = self.streaming_assistant_idx(); - let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); - - positions = Vec::with_capacity(message_count); - all_lines = Vec::new(); - - for (idx, message) in self.messages.iter().enumerate() { - positions.push(all_lines.len()); - let attached = idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; - let message_lines = self.format_message( - message, - max_width, - idx, - message_count, - streaming_content, - streaming_idx, - model, - colors, - attached, - ); - all_lines.extend(message_lines.into_iter().map(line_to_static)); - } + let (message_lines, message_positions) = + self.build_all_lines_with_positions(max_width, model, colors); + positions = message_positions; + all_lines = message_lines.into_iter().map(line_to_static).collect(); self.cached_lines = all_lines.clone(); self.cached_positions = positions.clone(); @@ -1013,12 +1132,36 @@ impl Chat { model: &'a str, colors: &'a ThemeColors, ) -> Vec<Line<'a>> { + self.build_all_lines_with_positions(max_width, model, colors) + .0 + } + + fn build_all_lines_with_positions<'a>( + &'a self, + max_width: usize, + model: &'a str, + colors: &'a ThemeColors, + ) -> (Vec<Line<'a>>, Vec<usize>) { let mut all_lines: Vec<Line<'a>> = Vec::new(); let message_count = self.messages.len(); let streaming_idx = self.streaming_assistant_idx(); let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); + let mut positions = Vec::with_capacity(message_count); + let mut idx = 0usize; + + while idx < self.messages.len() { + positions.push(all_lines.len()); + if let Some(items) = self.exploration_group_at(idx) { + let group_start = all_lines.len(); + let group_len = items.len(); + all_lines.extend(self.format_exploration_group(&items, max_width, colors)); + all_lines.push(Line::from("")); + positions.extend(std::iter::repeat(group_start).take(group_len.saturating_sub(1))); + idx += group_len; + continue; + } - for (idx, message) in self.messages.iter().enumerate() { + let message = &self.messages[idx]; let attached_to_assistant = idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; let message_lines = self.format_message( @@ -1033,9 +1176,107 @@ impl Chat { attached_to_assistant, ); all_lines.extend(message_lines); + idx += 1; + } + + (all_lines, positions) + } + + fn exploration_group_at(&self, start: usize) -> Option<Vec<ExplorationToolItem>> { + let first = exploration_tool_item_for_message(self.messages.get(start)?)?; + let mut items = vec![first]; + + for message in self.messages.iter().skip(start + 1) { + let Some(item) = exploration_tool_item_for_message(message) else { + break; + }; + items.push(item); + } + + Some(items) + } + + fn format_exploration_group<'a>( + &'a self, + items: &[ExplorationToolItem], + max_width: usize, + colors: &'a ThemeColors, + ) -> Vec<Line<'a>> { + fn push_wrapped<'a>( + out: &mut Vec<Line<'a>>, + line: Line<'static>, + max_width: usize, + subsequent_indent: Line<'static>, + ) { + out.extend(wrap_styled_line( + &line, + WrapOptions::new(max_width.max(1)).subsequent_indent(subsequent_indent), + )); + } + + let mut out = Vec::new(); + if items.is_empty() { + return out; + } + + let active = items.iter().any(|item| item.active); + let display_items = if items.iter().all(|item| item.label == "Read") { + let mut targets: Vec<String> = Vec::new(); + for item in items { + if !targets.iter().any(|target| target == &item.target) { + targets.push(item.target.clone()); + } + } + vec![ExplorationToolItem { + label: "Read", + target: targets.join(", "), + active, + }] + } else { + items.to_vec() + }; + let marker = if active { "~" } else { "•" }; + let heading = if active { "Exploring" } else { "Explored" }; + + let gutter_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let action_style = Style::default() + .fg(colors.accent) + .add_modifier(Modifier::BOLD); + let target_style = Style::default().fg(colors.text); + + out.push(Line::from(vec![ + Span::styled(marker, gutter_style), + Span::raw(" "), + Span::styled(heading, title_style), + ])); + + for (idx, item) in display_items.iter().enumerate() { + let branch = if idx == 0 { " └ " } else { " " }; + let indent_width = + UnicodeWidthStr::width(branch) + UnicodeWidthStr::width(item.label) + 1; + let mut spans = vec![ + Span::styled(branch.to_string(), gutter_style), + Span::styled(item.label.to_string(), action_style), + ]; + if !item.target.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled(item.target.clone(), target_style)); + } + + push_wrapped( + &mut out, + Line::from(spans), + max_width, + Line::from(Span::styled(" ".repeat(indent_width), gutter_style)), + ); } - all_lines + out } fn format_message<'a>( @@ -1272,68 +1513,6 @@ impl Chat { } } - fn arg_string<'v>( - obj: Option<&'v serde_json::Map<String, JsonValue>>, - keys: &[&str], - ) -> Option<&'v str> { - keys.iter() - .find_map(|key| obj.and_then(|o| o.get(*key)).and_then(|v| v.as_str())) - .filter(|value| !value.trim().is_empty()) - } - - fn strip_tool_title<'v>(title: Option<&'v str>, label: &str) -> Option<&'v str> { - let prefix = format!("{}:", label); - title - .and_then(|value| value.strip_prefix(&prefix)) - .map(str::trim) - .filter(|value| !value.is_empty()) - } - - fn display_path(raw: &str, basename_only: bool) -> String { - let trimmed = raw.trim(); - let path = std::path::Path::new(trimmed); - - if basename_only { - return path - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .unwrap_or(trimmed) - .to_string(); - } - - if path.is_absolute() { - if let Ok(cwd) = std::env::current_dir() { - if let Ok(rel) = path.strip_prefix(cwd) { - let rendered = rel.to_string_lossy(); - return if rendered.is_empty() { - ".".to_string() - } else { - rendered.into_owned() - }; - } - } - } - - trimmed.to_string() - } - - fn explored_tool_target( - name: &str, - args_obj: Option<&serde_json::Map<String, JsonValue>>, - title: Option<&str>, - ) -> Option<String> { - match name { - "read" => arg_string(args_obj, &["file_path", "filePath", "path"]) - .or_else(|| strip_tool_title(title, "Read")) - .map(|path| display_path(path, true)), - "list" => arg_string(args_obj, &["path"]) - .or_else(|| strip_tool_title(title, "List")) - .map(|path| display_path(path, false)), - _ => None, - } - } - fn question_values( args: &Option<JsonValue>, metadata: &Option<JsonValue>, @@ -1490,30 +1669,17 @@ impl Chat { let indent = ""; let mut out: Vec<Line<'a>> = Vec::new(); - let parsed: Option<JsonValue> = serde_json::from_str(&message.content).ok(); + let parsed = parse_tool_message(&message.content); let (name, status, args, metadata, output_preview, title) = - if let Some(JsonValue::Object(obj)) = parsed { - let name = obj - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("tool") - .to_string(); - let status = obj - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("ok") - .to_string(); - let args = obj.get("args").cloned(); - let metadata = obj.get("metadata").cloned(); - let output_preview = obj - .get("output_preview") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let title = obj - .get("title") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - (name, status, args, metadata, output_preview, title) + if let Some(info) = parsed.as_ref() { + ( + info.name.clone(), + info.status.clone(), + info.args.clone(), + info.metadata.clone(), + info.output_preview.clone(), + info.title.clone(), + ) } else { ( "tool".to_string(), @@ -1547,46 +1713,8 @@ impl Chat { }; let args_obj = args.as_ref().and_then(|v| v.as_object()); - if status != "error" { - if let Some(target) = explored_tool_target(&name, args_obj, title.as_deref()) { - let action_label = if name == "read" { "Read" } else { "List" }; - let gutter_style = Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM); - let title_style = Style::default() - .fg(colors.text) - .add_modifier(Modifier::BOLD); - let action_style = Style::default() - .fg(colors.accent) - .add_modifier(Modifier::BOLD); - let target_style = Style::default().fg(colors.text); - let marker = if status == "running" { "~" } else { "•" }; - let heading = if status == "running" { - "Exploring" - } else { - "Explored" - }; - - out.push(Line::from(vec![ - Span::styled(marker, gutter_style), - Span::raw(" "), - Span::styled(heading, title_style), - ])); - - push_wrapped( - &mut out, - Line::from(vec![ - Span::styled(" └ ", gutter_style), - Span::styled(action_label, action_style), - Span::raw(" "), - Span::styled(target, target_style), - ]), - max_width, - Line::from(Span::styled(" ", gutter_style)), - ); - - return out; - } + if let Some(item) = parsed.as_ref().and_then(exploration_tool_item) { + return self.format_exploration_group(&[item], max_width, colors); } let args_str = if name == "glob" { @@ -2372,6 +2500,78 @@ mod tests { assert_eq!(rendered, vec!["• Explored", " └ List src/ui"]); } + #[test] + fn test_adjacent_context_tools_render_as_one_explored_group() { + let mut chat = Chat::new(); + chat.add_message(Message::tool( + serde_json::json!({ + "name": "list", + "status": "ok", + "args": { "path": ". " }, + "output_preview": "README.md\nsrc/", + }) + .to_string(), + )); + chat.add_message(Message::tool( + serde_json::json!({ + "name": "read", + "status": "ok", + "args": { "file_path": "/Users/carlo/Desktop/Projects/crabcode/README.md" }, + "output_preview": "00001| # CrabCode", + }) + .to_string(), + )); + chat.add_message(Message::tool( + serde_json::json!({ + "name": "grep", + "status": "ok", + "args": { "pattern": "opencode|codex", "path": "references" }, + "output_preview": "references/codex", + }) + .to_string(), + )); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert_eq!( + rendered, + vec![ + "• Explored", + " └ List .", + " Read README.md", + " Search opencode|codex in references", + "" + ] + ); + } + + #[test] + fn test_read_only_context_group_collapses_targets() { + let mut chat = Chat::new(); + for file in ["README.md", "AGENTS.md"] { + chat.add_message(Message::tool( + serde_json::json!({ + "name": "read", + "status": "ok", + "args": { "file_path": format!("/repo/{file}") }, + "output_preview": "content", + }) + .to_string(), + )); + } + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert_eq!( + rendered, + vec!["• Explored", " └ Read README.md, AGENTS.md", ""] + ); + } + #[test] fn test_question_panel_keeps_padding_without_extra_gap() { let chat = Chat::new(); From 0c6546d7301ef24f90c5a9e28a1c92d1d5595593 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 02:59:37 +0800 Subject: [PATCH 090/226] feat: better "diff" more similar to codex when editing. --- src/tools/edit.rs | 7 +- src/ui/components/chat.rs | 205 +++++++++++++++++++++++++++++++++----- src/ui/diff.rs | 145 +++++++++++++++++++++++---- 3 files changed, 308 insertions(+), 49 deletions(-) diff --git a/src/tools/edit.rs b/src/tools/edit.rs index a99e5cf..966842d 100644 --- a/src/tools/edit.rs +++ b/src/tools/edit.rs @@ -143,7 +143,8 @@ impl ToolHandler for EditTool { return Ok(ToolResult::new( format!("Edit: {}", file_path), format!("Replaced {} occurrence(s)", count), - )); + ) + .with_metadata("replace_count", serde_json::json!(count))); } match Self::find_best_match(&content, &old_string) { @@ -162,7 +163,9 @@ impl ToolHandler for EditTool { Ok(ToolResult::new( format!("Edit: {}", file_path), format!("Replaced at line {}", line_num), - )) + ) + .with_metadata("line_number", serde_json::json!(line_num)) + .with_metadata("replace_count", serde_json::json!(1))) } None => Err(ToolError::NotFound(format!( "Could not find text to replace: {}", diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 2208e17..97cecf0 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -243,6 +243,27 @@ fn exploration_tool_item_for_message(message: &Message) -> Option<ExplorationToo .and_then(exploration_tool_item) } +fn metadata_usize(metadata: Option<&JsonValue>, keys: &[&str]) -> Option<usize> { + keys.iter() + .find_map(|key| { + metadata + .and_then(|m| m.get(*key)) + .and_then(|value| value.as_u64()) + }) + .map(|value| value as usize) +} + +fn parse_line_number(text: &str) -> Option<usize> { + let lower = text.to_ascii_lowercase(); + let start = lower.find("line ")? + "line ".len(); + let digits: String = lower[start..] + .chars() + .skip_while(|ch| ch.is_ascii_whitespace()) + .take_while(|ch| ch.is_ascii_digit()) + .collect(); + digits.parse().ok() +} + impl Chat { pub fn new() -> Self { Self { @@ -1968,6 +1989,103 @@ impl Chat { out.extend(panel_lines); } + } else if matches!(name.as_str(), "edit" | "write") && status != "error" { + let file_path = args_obj + .and_then(|o| o.get("file_path").or_else(|| o.get("filePath"))) + .and_then(|v| v.as_str()) + .or_else(|| strip_tool_title(title.as_deref(), tool_label)) + .map(|path| display_path(path, false)) + .unwrap_or_else(|| "file".to_string()); + + let (old_str, new_str) = if name == "edit" { + args_obj + .map(|obj| { + ( + obj.get("old_string").and_then(|v| v.as_str()).unwrap_or(""), + obj.get("new_string").and_then(|v| v.as_str()).unwrap_or(""), + ) + }) + .unwrap_or(("", "")) + } else { + ( + "", + args_obj + .and_then(|obj| obj.get("content")) + .and_then(|v| v.as_str()) + .unwrap_or(""), + ) + }; + + let stats = crate::ui::diff::compute_diff_stats(old_str, new_str); + let active = matches!(status.as_str(), "running" | "pending"); + let verb = if name == "edit" { + if active { + "Editing" + } else { + "Edited" + } + } else if active { + "Writing" + } else if output_preview + .as_deref() + .map(|preview| preview.starts_with("Created file")) + .unwrap_or(false) + { + "Added" + } else if output_preview + .as_deref() + .map(|preview| preview.starts_with("Updated file")) + .unwrap_or(false) + { + "Edited" + } else { + "Wrote" + }; + + let marker = if active { "~" } else { "•" }; + let marker_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let target_style = Style::default().fg(colors.text); + let add_style = Style::default() + .fg(colors.diff_add) + .add_modifier(Modifier::BOLD); + let remove_style = Style::default() + .fg(colors.diff_remove) + .add_modifier(Modifier::BOLD); + + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(marker.to_string(), marker_style), + Span::raw(" "), + Span::styled(verb.to_string(), title_style), + Span::raw(" "), + Span::styled(file_path, target_style), + Span::raw(" ("), + Span::styled(format!("+{}", stats.added), add_style), + Span::raw(" "), + Span::styled(format!("-{}", stats.removed), remove_style), + Span::raw(")"), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + + let start_line = + metadata_usize(metadata.as_ref(), &["line_number", "line", "start_line"]) + .or_else(|| output_preview.as_deref().and_then(parse_line_number)) + .unwrap_or(1); + + if !old_str.is_empty() || !new_str.is_empty() { + let diff_lines = crate::ui::diff::format_edit_diff_with_start( + old_str, new_str, start_line, max_width, colors, " ", + ); + out.extend(diff_lines); + } } else { // Default header for all other tools. let header_style = Style::default() @@ -1980,31 +2098,6 @@ impl Chat { Line::from(Span::styled(" ", header_style)), ); - // For edit tools, render a unified diff preview of old_string -> new_string - if name == "edit" { - if let Some(obj) = args_obj { - let old_str = obj.get("old_string").and_then(|v| v.as_str()).unwrap_or(""); - let new_str = obj.get("new_string").and_then(|v| v.as_str()).unwrap_or(""); - if !old_str.is_empty() || !new_str.is_empty() { - let diff_lines = - crate::ui::diff::format_edit_diff(old_str, new_str, max_width, colors); - out.extend(diff_lines); - } - } - } - - // For write tools, render the content as an all-additions diff. - if name == "write" { - if let Some(obj) = args_obj { - let content = obj.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if !content.is_empty() { - let diff_lines = - crate::ui::diff::format_edit_diff("", content, max_width, colors); - out.extend(diff_lines); - } - } - } - // Render a subtle result line for completed tools. if status == "ok" { if let Some(ref preview) = output_preview { @@ -2321,6 +2414,10 @@ mod tests { .collect() } + fn trimmed_line_text(line: &Line<'_>) -> String { + line_text(line).trim_end().to_string() + } + fn buffer_row_text(buffer: &ratatui::buffer::Buffer, width: u16, y: u16) -> String { (0..width) .map(|x| buffer[(x, y)].symbol()) @@ -2572,6 +2669,64 @@ mod tests { ); } + #[test] + fn test_edit_tool_renders_codex_style_diff_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "edit", + "status": "ok", + "args": { + "file_path": "/Users/carlo/Desktop/Projects/crabcode/README.md", + "old_string": "alpha\nbeta\nomega", + "new_string": "alpha\nbravo\nomega", + }, + "metadata": { "line_number": 3 }, + "output_preview": "Replaced at line 3", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(trimmed_line_text).collect::<Vec<_>>(); + + assert_eq!( + rendered, + vec![ + "• Edited README.md (+1 -1)", + " 3 alpha", + " 4 -beta", + " 4 +bravo", + " 5 omega", + ] + ); + } + + #[test] + fn test_write_tool_renders_added_diff_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "write", + "status": "ok", + "args": { + "file_path": "src/new.rs", + "content": "fn main() {}\n", + }, + "output_preview": "Created file with 13 bytes", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(trimmed_line_text).collect::<Vec<_>>(); + + assert_eq!( + rendered, + vec!["• Added src/new.rs (+1 -0)", " 1 +fn main() {}"] + ); + } + #[test] fn test_question_panel_keeps_padding_without_extra_gap() { let chat = Chat::new(); diff --git a/src/ui/diff.rs b/src/ui/diff.rs index 01c633f..ef5b0d8 100644 --- a/src/ui/diff.rs +++ b/src/ui/diff.rs @@ -1,5 +1,5 @@ use crate::theme::ThemeColors; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; @@ -15,35 +15,63 @@ pub enum DiffLineType { pub struct DiffLine { pub line_type: DiffLineType, + pub line_number: Option<usize>, pub text: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiffStats { + pub added: usize, + pub removed: usize, +} + /// Compute a unified line-based diff between old and new text. /// Returns at most `MAX_DIFF_LINES` with `CONTEXT_LINES` of context around changes. pub fn compute_unified_diff(old_text: &str, new_text: &str) -> Vec<DiffLine> { - let raw_diff = diff::lines(old_text, new_text); + compute_unified_diff_with_start(old_text, new_text, 1, 1) +} + +/// Compute a unified line-based diff with explicit old/new starting line numbers. +pub fn compute_unified_diff_with_start( + old_text: &str, + new_text: &str, + old_start_line: usize, + new_start_line: usize, +) -> Vec<DiffLine> { + let old_lines: Vec<&str> = old_text.lines().collect(); + let new_lines: Vec<&str> = new_text.lines().collect(); + let raw_diff = diff::slice(&old_lines, &new_lines); // First pass: collect all lines with their type let mut all_lines: Vec<DiffLine> = Vec::new(); + let mut old_line = old_start_line.max(1); + let mut new_line = new_start_line.max(1); for result in raw_diff { match result { diff::Result::Left(line) => { all_lines.push(DiffLine { line_type: DiffLineType::Remove, - text: line.to_string(), + line_number: Some(old_line), + text: (*line).to_string(), }); + old_line += 1; } diff::Result::Both(line, _) => { all_lines.push(DiffLine { line_type: DiffLineType::Context, - text: line.to_string(), + line_number: Some(new_line), + text: (*line).to_string(), }); + old_line += 1; + new_line += 1; } diff::Result::Right(line) => { all_lines.push(DiffLine { line_type: DiffLineType::Add, - text: line.to_string(), + line_number: Some(new_line), + text: (*line).to_string(), }); + new_line += 1; } } } @@ -86,6 +114,7 @@ pub fn compute_unified_diff(old_text: &str, new_text: &str) -> Vec<DiffLine> { } else if !in_ellipsis { result.push(DiffLine { line_type: DiffLineType::Context, + line_number: None, text: "⋯".to_string(), }); in_ellipsis = true; @@ -95,6 +124,25 @@ pub fn compute_unified_diff(old_text: &str, new_text: &str) -> Vec<DiffLine> { result } +pub fn compute_diff_stats(old_text: &str, new_text: &str) -> DiffStats { + let mut stats = DiffStats { + added: 0, + removed: 0, + }; + + let old_lines: Vec<&str> = old_text.lines().collect(); + let new_lines: Vec<&str> = new_text.lines().collect(); + for result in diff::slice(&old_lines, &new_lines) { + match result { + diff::Result::Left(_) => stats.removed += 1, + diff::Result::Right(_) => stats.added += 1, + diff::Result::Both(_, _) => {} + } + } + + stats +} + /// Render a unified diff as ratatui Lines with proper colors and gutter. /// Every line is padded to `max_width` so the background spans the full row. pub fn render_unified_diff( @@ -102,33 +150,57 @@ pub fn render_unified_diff( max_width: usize, colors: &ThemeColors, ) -> Vec<Line<'static>> { - let gutter_width = 2usize; // "- ", "+ ", " " - let content_width = max_width.saturating_sub(gutter_width).max(1); + render_unified_diff_with_indent(diff_lines, max_width, colors, "") +} + +/// Render a unified diff with a fixed left indent before the line-number gutter. +pub fn render_unified_diff_with_indent( + diff_lines: &[DiffLine], + max_width: usize, + colors: &ThemeColors, + indent: &str, +) -> Vec<Line<'static>> { + let max_line_number = diff_lines + .iter() + .filter_map(|line| line.line_number) + .max() + .unwrap_or(1); + let line_number_width = max_line_number.to_string().len().max(1); + let indent_width = UnicodeWidthStr::width(indent); + let gutter_width = line_number_width + 2; // line number, spacer, sign + let content_width = max_width.saturating_sub(indent_width + gutter_width).max(1); let mut lines: Vec<Line<'static>> = Vec::new(); - if max_width < 4 { + if max_width < indent_width + gutter_width + 1 { return lines; } for diff_line in diff_lines { - let (gutter, fg, bg) = match diff_line.line_type { - DiffLineType::Remove => ("- ", colors.diff_remove, colors.diff_remove_bg), - DiffLineType::Add => ("+ ", colors.diff_add, colors.diff_add_bg), - DiffLineType::Context => (" ", colors.text_weak, colors.background), + let (sign, fg, bg) = match diff_line.line_type { + DiffLineType::Remove => ('-', colors.diff_remove, colors.diff_remove_bg), + DiffLineType::Add => ('+', colors.diff_add, colors.diff_add_bg), + DiffLineType::Context => (' ', colors.text_weak, colors.background), }; - let gutter_style = Style::default().fg(colors.diff_gutter).bg(bg); + let indent_style = Style::default().bg(bg); + let gutter_style = Style::default() + .fg(colors.diff_gutter) + .bg(bg) + .add_modifier(Modifier::DIM); + let sign_style = Style::default().fg(fg).bg(bg); let content_style = Style::default().fg(fg).bg(bg); let pad_style = Style::default().bg(bg); // Handle ellipsis specially if diff_line.text == "⋯" { - let full_line = format!("{}⋯", gutter); - let remaining = max_width.saturating_sub(full_line.len()); + let number = " ".repeat(line_number_width); + let full_line = format!("{}{} ⋯", indent, number); + let remaining = max_width.saturating_sub(UnicodeWidthStr::width(full_line.as_str())); let padding = "─".repeat(remaining); let mut spans = vec![ - Span::styled(gutter.to_string(), gutter_style), + Span::styled(indent.to_string(), indent_style), + Span::styled(format!("{} ", number), gutter_style), Span::styled( format!("⋯{}", padding), content_style.add_modifier(Modifier::DIM), @@ -152,13 +224,23 @@ pub fn render_unified_diff( // Wrap content if needed let wrapped = textwrap::wrap(&diff_line.text, content_width); for (chunk_idx, chunk) in wrapped.iter().enumerate() { - let gutter_text = if chunk_idx == 0 { - gutter.to_string() + let number_text = if chunk_idx == 0 { + diff_line + .line_number + .map(|line_number| format!("{line_number:>line_number_width$} ")) + .unwrap_or_else(|| format!("{:line_number_width$} ", "")) + } else { + format!("{:line_number_width$} ", "") + }; + let sign_text = if chunk_idx == 0 { + sign.to_string() } else { - " ".to_string() + " ".to_string() }; let mut spans = vec![ - Span::styled(gutter_text.clone(), gutter_style), + Span::styled(indent.to_string(), indent_style), + Span::styled(number_text, gutter_style), + Span::styled(sign_text, sign_style), Span::styled(chunk.to_string(), content_style), ]; // Pad to full width so the background spans the entire row @@ -186,8 +268,20 @@ pub fn format_edit_diff( max_width: usize, colors: &ThemeColors, ) -> Vec<Line<'static>> { - let diff_lines = compute_unified_diff(old_string, new_string); - render_unified_diff(&diff_lines, max_width, colors) + format_edit_diff_with_start(old_string, new_string, 1, max_width, colors, "") +} + +pub fn format_edit_diff_with_start( + old_string: &str, + new_string: &str, + start_line: usize, + max_width: usize, + colors: &ThemeColors, + indent: &str, +) -> Vec<Line<'static>> { + let diff_lines = + compute_unified_diff_with_start(old_string, new_string, start_line, start_line); + render_unified_diff_with_indent(&diff_lines, max_width, colors, indent) } #[cfg(test)] @@ -265,6 +359,13 @@ mod tests { assert_eq!(diff[1].text, "line2"); } + #[test] + fn test_compute_diff_stats_ignores_terminal_newline() { + let stats = compute_diff_stats("", "line1\n"); + assert_eq!(stats.added, 1); + assert_eq!(stats.removed, 0); + } + #[test] fn test_compute_unified_diff_deletion() { let old = "line1\nline2"; From e7fe4b18504d4975255a0fbc1eaeb862439c4808 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 10:40:24 +0800 Subject: [PATCH 091/226] feat: add image attachment support with clipboard paste and @-file autocomplete. - Add clipboard image paste via `arboard` (Ctrl+V), saving to temp PNG - Support `@` file-path autocomplete with fuzzy matching via `nucleo` - Attach images from pasted file paths and from `@` selection - Persist local image paths in messages and serialize/deserialize to/from DB - Render images as `[Image #N]` placeholders in the input, clickable to open via OS - Encode images as base64 data URLs and inject into provider messages: OpenAI (responses API: `input_image`), Anthropic (content blocks: `image`), OpenAI-compatible (Chat API: `image_url`) - Add `image`, `tempfile`, `url`, `shlex` dependencies; update `arboard` + transitive `objc2` deps --- Cargo.lock | 312 +++++++++++++++++++++++-- Cargo.toml | 5 + _docs/__PARITY.md | 267 ++++++++++----------- _plans/__TODOS.md | 12 +- aisdk/src/message.rs | 22 +- aisdk/src/providers/anthropic.rs | 34 ++- aisdk/src/providers/compatible.rs | 25 +- aisdk/src/providers/openai.rs | 23 +- src/app.rs | 166 +++++++++++--- src/autocomplete/command.rs | 51 ++++- src/autocomplete/file.rs | 259 +++++++++++++++------ src/autocomplete/mod.rs | 12 +- src/llm/client.rs | 33 ++- src/persistence/conversions.rs | 21 ++ src/session/types.rs | 3 + src/ui/components/input.rs | 369 ++++++++++++++++++++++++++---- src/ui/components/popup.rs | 133 ++++------- src/utils/image_attachment.rs | 183 +++++++++++++++ src/utils/mod.rs | 1 + 19 files changed, 1517 insertions(+), 414 deletions(-) create mode 100644 src/utils/image_attachment.rs diff --git a/Cargo.lock b/Cargo.lock index a7fcb17..fb93ac4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,26 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -204,7 +224,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2", + "objc2 0.5.2", ] [[package]] @@ -224,6 +244,18 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -349,6 +381,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -399,9 +437,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" dependencies = [ "clipboard-win", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "smithay-clipboard", "x11-clipboard", ] @@ -437,6 +475,7 @@ version = "0.0.1" dependencies = [ "aisdk", "anyhow", + "arboard", "async-trait", "base64", "chrono", @@ -448,6 +487,7 @@ dependencies = [ "futures", "glob", "ignore", + "image", "json5", "lazy_static", "nucleo-matcher", @@ -463,7 +503,9 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "shlex", "strsim", + "tempfile", "textwrap", "thiserror 1.0.69", "tiktoken-rs", @@ -473,6 +515,7 @@ dependencies = [ "tui-markdown", "tui-textarea", "unicode-width 0.1.14", + "url", ] [[package]] @@ -534,6 +577,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -716,6 +765,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -825,6 +884,35 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -1032,6 +1120,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "glob" version = "0.3.3" @@ -1070,6 +1168,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1389,6 +1498,35 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1641,6 +1779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1655,6 +1794,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1796,6 +1945,15 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + [[package]] name = "objc2-app-kit" version = "0.2.2" @@ -1805,13 +1963,25 @@ dependencies = [ "bitflags", "block2", "libc", - "objc2", + "objc2 0.5.2", "objc2-core-data", "objc2-core-image", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-quartz-core", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -1820,8 +1990,32 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] @@ -1831,8 +2025,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-metal", ] @@ -1851,7 +2045,29 @@ dependencies = [ "bitflags", "block2", "libc", - "objc2", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2 0.6.4", + "objc2-core-foundation", ] [[package]] @@ -1862,8 +2078,8 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -1874,8 +2090,8 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-metal", ] @@ -2072,6 +2288,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -2157,6 +2386,18 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -2734,6 +2975,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simdutf8" version = "0.1.5" @@ -3010,6 +3257,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tiktoken-rs" version = "0.9.1" @@ -3655,6 +3916,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -4170,3 +4437,18 @@ name = "zmij" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 816b5e1..81c5064 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,11 @@ serde_yaml = "0.9" sha2 = "0.10" rand = "0.8" diff = "0.1" +arboard = "3.6" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } +tempfile = "3.13" +url = "2.5" +shlex = "1.3" [dev-dependencies] tokio-test = "0.4" diff --git a/_docs/__PARITY.md b/_docs/__PARITY.md index 7a29b55..affc1fb 100644 --- a/_docs/__PARITY.md +++ b/_docs/__PARITY.md @@ -1,161 +1,142 @@ -# Crabcode vs OpenCode — Core Harness Feature Parity Audit +# Crabcode Harness Parity Audit -> Generated: 2026-05-11 | Updated: 2026-05-19 | Scope: agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands, permissions. +Checked: 2026-05-19. -## Feature Table +Scope: core harness behavior only: agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands, and permissions. This compares crabcode source against the local opencode reference in `.devrefs/references/anomalyco/opencode` plus the requested opencode behavior. + +## Feature Matrix | # | Feature | OpenCode | Crabcode | Gap | |---|---------|----------|----------|-----| -| **1.1** | Multi-step agentic iteration (LLM streaming + tool calling) | `stream_text()` with `step_count_is(N)` hook, tool execution loop | `stream_llm_with_cancellation()` at `src/llm/client.rs:82`, `stop_when(step_count_is(max_steps))` at `:377` | **OK** | -| **1.2** | Cancellation token for user interruption | `CancellationToken`, checked in relay loop | `CancellationToken` at `src/llm/client.rs:83`, emits `ChunkMessage::Cancelled` at `:474` | **OK** | -| **1.3** | Step limit enforcement with text-only summary fallback | `stop_when(step_count_is(N))` + follow-up request with `MAX_STEPS_REACHED` prompt, tools stripped | `MAX_STEPS_REACHED_PROMPT` at `src/llm/client.rs:18`, `reached_step_limit()` at `:514`, follow-up stream at `:161-173` with empty tools vec | **OK** | -| **1.4** | Chunk relay: text, reasoning, tool_calls, tool_results, errors, metrics, cancelled | `ChunkType` dispatched per-kind to UI | `ChunkMessage` at `src/llm/mod.rs:9` — Text, Reasoning, ToolCalls, ToolResult, PermissionRequest, QuestionRequest, End, Failed, Cancelled, Metrics, Warning | **OK** | -| **1.5** | Plan/Build mode toggle | User-toggleable mode; plan = read-only tools | `AgentToolPolicies` at `src/tools/permission.rs:71` — plan blocks write/edit/bash, build allows all. No user-facing toggle; mode set at stream start | **Partial**: Mode exists but not user-toggleable mid-conversation | -| **1.6** | Permission preflight during tool execution | `preflight()` checks before each tool call, mid-stream permission dialogs | `permissions.preflight()` in `aisdk_bridge.rs:90-98`, sends `PermissionRequest` chunk, awaits UI response via oneshot | **OK** | -| **1.7** | Configurable max steps per agent | Per-agent `max_steps` in config; "max steps reached" prompt injected | `agent_max_steps: Option<usize>` at `src/llm/client.rs:87` | **OK** | -| **1.8** | Model-visible replayable history across turns | `MessageV2` persists text, reasoning, tool calls/results, attachments, and reconstructs provider messages | `convert_messages()` now replays `MessageRole::Tool` as model-visible observations via `tool_message_observation()` | **Partial**: tool results are visible across turns, but not canonical typed tool-call/result parts with attachments | -| **2.1** | Provider-specific header (Beast for OpenAI) | Detailed "beast" prompt for OpenAI, concise for Anthropic | `get_beast_prompt()` at `src/prompt/mod.rs:100`, `get_anthropic_prompt()` at `:135`, `get_codex_prompt()` at `:187` | **OK** | -| **2.2** | Provider-specific behavior instructions | Anthropic-specific, Gemini-specific, Codex-specific | `get_gemini_prompt()` at `src/prompt/mod.rs:160`, `get_codex_prompt()` at `:187` | **OK** | -| **2.3** | Environment context block (workdir, git, platform, date) | `<env>` XML block | `get_environment_context()` at `src/prompt/mod.rs:224` | **OK** | -| **2.4** | Tool schemas block (all registered tools as JSON) | All tools rendered as JSON schemas | `get_tools_context()` at `src/prompt/mod.rs:239` — `registry.list_schemas()` serialized as pretty JSON | **OK** | -| **2.5** | Custom instructions from AGENTS.md/CLAUDE.md (walk-up + global) | Walk-up directory discovery + global fallback at `~/.config/opencode/AGENTS.md` and `~/.claude/CLAUDE.md` | `src/prompt/rules.rs` — `resolve_local_rules()` walks up from workdir for AGENTS.md then CLAUDE.md; `resolve_global_rules()` checks `~/.config/crabcode/AGENTS.md` and `~/.claude/CLAUDE.md` | **OK** | -| **2.6** | Available skills as `<available_skills>` XML | Lists skill name, description, location | `src/prompt/mod.rs:267-295` — iterates `SkillStore::all()`, emits `<available_skills>` XML | **OK** | -| **2.7** | Available subagents listing in system prompt | Lists subagent names and descriptions so primary agent knows when to use Task tool | `src/prompt/mod.rs:298-320` — iterates `SubAgentDef::all()`, emits `<available_subagents>` XML | **OK** | -| **3.1** | Task tool (primary agent spawns subagents) | `task` tool with subagent_type, description, prompt params | `src/tools/task.rs` — full TaskTool with explore/general enum validation | **OK** | -| **3.2** | Explore subagent | Read-only: glob, grep, read, list. Fast codebase exploration | `src/agent/subagent.rs:4` — ExploreAgent with EXPLORE_SYSTEM_PROMPT, scoped to glob/grep/read/list | **OK** | -| **3.3** | General subagent | Full tool access (minus todowrite). Complex multi-step tasks | `src/agent/subagent.rs:23` — GeneralAgent with GENERAL_SYSTEM_PROMPT, scoped to bash/edit/write/read/grep/glob/list/skill/webfetch | **OK** | -| **3.4** | Scout subagent | Read-only, can clone repos for external docs/deps research | **Not implemented** | **GAP** | -| **3.5** | VLM-agent subagent | For image analysis (delegates to vision models) | **Not implemented** | **GAP** | -| **3.6** | Compaction/Title/Summary hidden agents | System agents that run automatically for session compaction, title generation, summarization | **Not implemented** | **GAP** | -| **3.7** | Subagent multi-step iteration (tool-calling loop within subagent) | Subagents run full agentic loops (stream + tool execution + recursion) | `run_subagent()` uses `stream_with_tools()` with a scoped registry and relays text/reasoning/tool rows into the child stream | **OK** | -| **3.8** | Child sessions / session tree (parent/child navigation) | Subagents create child sessions, navigable in UI | Task calls create persisted child sessions with `parent_id`, stream transcript rows into them, and expose OpenCode-style `ctrl+x` down / left-right / up navigation | **Partial**: no background resume/task_status integration yet | -| **3.9** | Agent mode system (primary vs subagent vs all) | Each agent has a `mode` that controls visibility and invocation | No mode field. Plan/build handled separately via policies | **GAP** | -| **3.10** | Hidden agents (hidden from autocomplete, invokable via Task) | Agents can be marked `hidden: true` | No hidden agent concept | **GAP** | -| **3.11** | Task permissions (which agents can invoke which subagents) | Per-agent `task_permissions` control | No task permission system. Primary agent can always invoke explore/general | **GAP** | -| **3.12** | @mention subagent invocation from user input | `@explore` / `@general` in user input routes to subagent | Not implemented | **GAP** | -| **4.1** | bash | ✓ | `src/tools/bash.rs` | **OK** | -| **4.2** | edit | ✓ | `src/tools/edit.rs` (exact string replacement, fuzzy fallback) | **OK** | -| **4.3** | write | ✓ | `src/tools/fs/write.rs` (atomic write via temp+rename) | **OK** | -| **4.4** | read | ✓ | `src/tools/fs/read.rs` (offset/limit pagination, also reads dirs) | **OK** | -| **4.5** | grep | ✓ | `src/tools/fs/grep.rs` (regex + include filters) | **OK** | -| **4.6** | glob | ✓ | `src/tools/fs/glob.rs` (pattern matching) | **OK** | -| **4.7** | list | ✓ | `src/tools/fs/list.rs` (tree-style directory listing) | **OK** | -| **4.8** | skill | ✓ | `src/tools/skill.rs` (loads SKILL.md by name, injects content) | **OK** | -| **4.9** | task | ✓ | `src/tools/task.rs` (spawns explore/general subagents) | **OK** | -| **4.10** | todowrite | ✓ | `src/tools/todowrite.rs` (JSON-validated structured task list) | **OK** | -| **4.11** | webfetch | ✓ | `src/tools/webfetch.rs` (fetch + handcrafted HTML-to-markdown) | **OK** | -| **4.12** | question | ✓ | `src/tools/question.rs` (oneshot-based UI question prompts) | **OK** | -| **4.13** | websearch | Exa/Parallel web search | **Not implemented** | **GAP** | -| **4.14** | Tool/media attachments | Tool outputs can carry attachments; images/resources are normalized and replayed to the model | **Not implemented** | **GAP** | -| **4.15** | apply_patch | Apply diffs/patch files | **Not implemented** | **GAP** | -| **4.16** | lsp | LSP code intelligence (experimental) | **Not implemented** | **GAP** | -| **4.17** | task_status | Poll/wait for background subagent tasks | **Not implemented** | **GAP** | -| **4.18** | repo_clone | Clone external repositories into managed cache for scout/reference workflows | **Not implemented** | **GAP** | -| **4.19** | repo_overview | Summarize cached/local repository structure and dependency files | **Not implemented** | **GAP** | -| **4.20** | plan_exit | Model-callable plan approval / build-agent handoff | **Not implemented** | **GAP** | -| **4.21** | invalid | Fallback tool for unknown/invalid tool calls | **Not implemented** | **GAP** | -| **5.1** | Discovery: `.opencode/skills/<name>/SKILL.md` | OpenCode native layout | Scanned via `{skill,skills}/**/SKILL.md` in `.opencode/`, `.crabcode/`, config dirs at `src/skill/mod.rs:67-77` | **OK** | -| **5.2** | Discovery: `~/.config/opencode/skills/<name>/SKILL.md` | Global config skills | `global_opencode` at `src/skill/mod.rs:39` | **OK** | -| **5.3** | Discovery: `.claude/skills/` (project + home) | Claude Code compat | Walk-up `.claude/skills/**/SKILL.md` + `~/.claude/skills/**/SKILL.md` at `src/skill/mod.rs:46-64` | **OK** | -| **5.4** | Discovery: `.agents/skills/` (project + home) | OpenCode compat | Walk-up `.agents/skills/**/SKILL.md` + `~/.agents/skills/**/SKILL.md` at `src/skill/mod.rs:46-64` | **OK** | -| **5.5** | Walk-up bounded to git worktree | Walks up only to git root | Walks up to filesystem root (no git boundary) at `src/skill/mod.rs:50-64` | **Partial**: No git worktree boundary for walk-up | -| **5.6** | YAML frontmatter with `name` and `description` | Required in SKILL.md | Parsed at `src/skill/mod.rs:184-233`, with fallback YAML sanitization for Claude Code compat | **OK** | -| **5.7** | Pattern-based skill permissions | `"internal-*": "deny"` style glob patterns | **Not implemented** | **GAP** | -| **5.8** | Skill tool lists available skills in description | Skill names embedded in tool definition description | `build_description()` at `src/tools/skill.rs:15-48` appends `<available_skills>` XML to tool description | **OK** | -| **6.1** | Agent config via `opencode.json` | `agent` field in JSON config | Crabcode reads opencode.json, but only applies limited `agent.*.tools` and `agent.*.steps` fields in `src/config/configuration.rs` | **Partial** | -| **6.2** | Agent config via `~/.config/opencode/agents/<name>.md` | Markdown frontmatter with agent definitions | **Not implemented** | **GAP** | -| **6.3** | Per-agent: description, model, temperature, max_steps | Full per-agent override of all params | Only has global `LlmSessionConfig` at `src/agent/config.rs:4` (provider, model, api_key). No per-agent overrides | **GAP** | -| **6.4** | Per-agent: mode (primary/subagent/all) | Controls where agent is visible/usable | Not implemented (only plan/build context) | **GAP** | -| **6.5** | Per-agent: hidden, color, top_p, permissions, task_permissions | Agent metadata fields | Not implemented | **GAP** | -| **6.6** | Agent creation wizard (`opencode agent create`) | Interactive agent creation | Not implemented | **GAP** | -| **6.7** | Config `instructions` array | Additional instruction files/patterns beyond AGENTS/CLAUDE discovery | Key is allowed but marked unimplemented by `collect_unimplemented_keys()` | **GAP** | -| **6.8** | Config `reference` aliases | Named local/git references that can be mentioned as `@alias` or `@alias/path` | Not implemented; key is not accepted by `opencode_allowed_keys()` | **GAP** | -| **6.9** | Config `small_model`, `username` | Small model for title/summary agents and display username override | Not implemented | **GAP** | -| **6.10** | Provider config merge and enable/disable filters | Custom provider/model overrides plus `enabled_providers` / `disabled_providers` | Only `provider.*.options.timeout` is parsed; provider/model merge and filters are not applied | **GAP** | -| **6.11** | Formatter, LSP, attachment, tool_output config | Runtime config for formatters, LSP servers, image limits, and truncation thresholds | Keys are mostly absent or marked unimplemented; no corresponding runtime services | **GAP** | -| **7.1** | User-defined commands via `.opencode/commands/<name>.md` | Markdown files define custom slash commands | Not implemented. Only Rust function handlers for built-in commands | **MAJOR GAP** | -| **7.2** | Command frontmatter: description, agent, model, subtask | YAML frontmatter in custom command files | Not implemented | **GAP** | -| **7.3** | Template variables ($ARGUMENTS, $INPUT, $CWD, etc.) | Template substitution in custom commands | Not implemented | **GAP** | -| **7.4** | Shell output injection (`$(command)`) | Inline shell execution in commands | Not implemented | **GAP** | -| **7.5** | File references (`@path/to/file`) | File content insertion in command text | Not implemented | **GAP** | -| **8.1** | Per-tool: allow, deny, ask | Global permission rules per tool | `AgentToolPolicies` at `src/tools/permission.rs:71` — per-mode tool allowlists only (not global per-tool deny/ask rules) | **Partial** | -| **8.2** | Wildcard pattern permissions | `"mymcp_*": "deny"` | Not implemented. Only exact tool name matching | **GAP** | -| **8.3** | Pattern-specific bash permissions | `"git push": "ask"`, `"git *": "allow"` | Not implemented. Bash only gets a generic "bash requires permission" check | **GAP** | -| **8.4** | Per-agent override of global permissions | Agent-level permission config overrides global | Not implemented. Only mode-based (plan/build) | **GAP** | -| **8.5** | External directory gating | Blocks/prompts for paths outside workdir | `is_outside_workdir()` at `src/tools/permission.rs:377` | **OK** | -| **8.6** | Doom loop recovery prompts | Persistent tool failures trigger recovery | Not implemented | **GAP** | -| **9.1** | MCP runtime | Stdio/SSE/Streamable HTTP clients, OAuth, tools, prompts, resources | Config key is accepted, but there is no MCP runtime or tool/resource integration | **MAJOR GAP** | -| **9.2** | Plugin runtime and hooks | Built-in/external plugins plus hooks like `tool.execute.before`, `tool.execute.after`, system transforms | OpenCode `plugin` key is ignored by Crabcode; no plugin loader or hook pipeline | **MAJOR GAP** | -| **9.3** | Local custom JS/TS tools | Loads `{tool,tools}/*.{js,ts}` from config directories and exposes exports as tools | Not implemented | **GAP** | -| **9.4** | Plugin auth/provider integrations | Internal plugins add provider/auth behavior for Codex, Copilot, GitLab, Poe, Cloudflare, Azure, DigitalOcean | Not implemented | **GAP** | -| **10.1** | Snapshots and patch parts | Tracks filesystem snapshots before/after steps and persists patch parts on messages | Not implemented | **GAP** | -| **10.2** | File-state revert/unrevert | Reverts file changes and can undo/redo snapshot state for a message range | Crabcode message action "Undo" only truncates messages; it does not revert file changes | **GAP** | -| **10.3** | Session sharing | `share: manual/auto/disabled` and shared session URLs | Not implemented; OpenCode `share` key is ignored | **GAP** | -| **10.4** | Background/resumable subagents | `task(background=true)`, `task_id` resume, `task_status`, and parent continuation | Not implemented | **GAP** | -| **10.5** | Durable tool-output truncation | Large output saved to data dir with preview and `tool_output` limits | Crabcode truncates some tool output inline; no durable output file/index | **GAP** | -| **10.6** | User and tool attachments | File/image/PDF prompt parts, image normalization, media extraction from tool results | Not implemented | **GAP** | -| **10.7** | Post-edit formatting and diagnostics | Edit/write/patch run configured formatters and surface LSP diagnostics | Not implemented | **GAP** | - -## Priority-Ranked Actionable Gaps +| 1.1 | Multi-step agentic iteration | Streams model responses, accumulates tool calls, executes tools, appends observations, and continues until stop or step limit. | Present. `src/llm/client.rs` calls `stream_with_tools`; `aisdk/src/response.rs` loops over steps, tool calls, and observations. | No major parity gap for the core loop. | +| 1.2 | Cancellation token support | User interruption cancels active generation and agent work. | Present for model streaming. `src/llm/client.rs` relays cancellation and emits `ChunkMessage::Cancelled`; tools get abort channels through the AI SDK bridge. | Tool cancellation is weaker: `src/tools/aisdk_bridge.rs` creates a fresh abort channel instead of wiring the top-level cancellation token through every long-running tool. | +| 1.3 | Step limit and fallback | Enforces configured max steps, then injects a max-steps prompt and performs a text-only completion. | Mostly present. `MAX_STEPS_REACHED_PROMPT` is injected and `src/llm/client.rs` calls the provider again with no tools when the loop reaches the limit. | `maxSteps` alias is explicitly unsupported; behavior is tied to the current `steps` config path. | +| 1.4 | Chunk-based streaming | Emits text, reasoning, tool calls, tool results, errors, metrics, and cancellation chunks. | Present. `src/llm/mod.rs` defines `Text`, `Reasoning`, `ToolCalls`, `ToolResult`, `Failed`, `Metrics`, `Cancelled`, plus permission, question, and subagent chunks. | No major parity gap for the listed chunk types. | +| 1.5 | Plan/Build mode toggle | Plan mode is read-only; build mode can execute write-capable tools. | Present. `src/app.rs` toggles Plan/Build; `src/tools/permission.rs` denies `write`, `edit`, and `bash` in Plan. | This is mode-based policy only, not the full opencode agent-mode registry. | +| 1.6 | Permission preflight during tool execution | Tool calls are preflighted and can surface permission dialogs mid-run. | Present but limited. `src/tools/aisdk_bridge.rs` preflights before execution; `src/tools/permission.rs` emits permission requests; `src/app.rs` handles the dialog. | Policy inputs are hardcoded/in-memory rather than driven by opencode-style config rules. | +| 1.7 | Configurable max steps per agent | Each agent can define max steps; limit injects the max-steps prompt. | Partially present. `src/config/configuration.rs` parses `agent.<name>.steps`; app and print paths pass agent-specific step counts into the LLM call. | Only `steps` is supported; broader per-agent config and deprecated `maxSteps` compatibility are missing. | +| 2.1 | Provider-specific prompt header and behavior | Chooses provider/model-specific prompts such as Beast/OpenAI, Anthropic, Gemini, Codex, and other provider variants. | Partial. `src/prompt/mod.rs` has Beast, Anthropic, Gemini, and Codex prompt branches. | Prompt set is simpler than opencode, with fewer provider/model variants and less complete behavioral parity. | +| 2.2 | Environment context block | Includes workdir, git status/repo, platform, and date in the system prompt. | Present. `src/prompt/mod.rs` emits workdir, git repo status, platform, and current date. | Minor wording/content differences only. | +| 2.3 | Tool schemas block | Lists all registered tools as JSON in the system prompt. | Partial. `SystemPromptComposer` can emit tool schemas if built with a tool registry, but runtime app and print composition do not call `.with_tool_registry(...)`. | Actual runtime system prompts do not include the tool schemas block even though provider requests still receive tool schemas through the AI SDK. | +| 2.4 | Custom instructions discovery | Walks up for project instructions and supports global fallback. | Partial. `src/prompt/rules.rs` finds local `AGENTS.md`/`CLAUDE.md` and global crabcode/Claude files. | Does not stop at git worktree boundary, does not include opencode global paths, and does not support config-driven instruction entries. | +| 2.5 | Available skills block | Emits `<available_skills>` with names and descriptions. | Present in interactive mode. `src/prompt/mod.rs` renders skills when `SkillStore` is attached; `src/app.rs` initializes the store. | Print mode does not initialize/attach the skill store, so the block can be absent outside the app path. | +| 2.6 | Available subagents block | Lists subagent names and descriptions so the primary agent can pick a Task target. | Present. `src/prompt/mod.rs` emits `<available_subagents>`; `src/agent/subagent.rs` supplies definitions. | Only the currently implemented subagents are listed; missing scout, VLM, and hidden/system agents. | +| 2.7 | Prompt-level subagent selection guidance | Primary agent sees when to use the Task tool and which subagent to choose. | Partial. The prompt lists subagent descriptions and the Task tool schema constrains allowed types. | No task-permission matrix or hidden-agent metadata in the prompt. | +| 3.1 | Task tool | Primary agents spawn subagents through a Task tool. | Present. `src/tools/task.rs` implements Task; `src/tools/init.rs` registers it dynamically. | Missing opencode parameters such as background execution, task IDs, command routing, and task status. | +| 3.2 | `explore` subagent | Fast read-only subagent with glob, grep, read, and list. | Present. `src/agent/subagent.rs` defines `Explore` with those read-only tools. | No major parity gap for the basic explore profile. | +| 3.3 | `general` subagent | Full multi-step subagent, excluding `todowrite`. | Present. `src/agent/subagent.rs` defines `General` with broad tools and excludes `todowrite`. | Permission behavior is still governed by crabcode's simpler policy engine. | +| 3.4 | `scout` subagent | Read-only external research agent that can clone repositories. | Missing. `SubAgentType` only has `Explore` and `General`. | Need scout definition, repo clone/overview tools, and external research permissions. | +| 3.5 | `vlm-agent` | Image-analysis subagent. | Missing. | Need image input plumbing, VLM model selection, and a VLM-capable subagent definition. | +| 3.6 | Hidden/system agents | Compaction, title, and summary agents run automatically and are hidden from user autocomplete. | Missing as an agent system. | Need hidden agent definitions and automatic invocation hooks for compaction, title, and summary flows. | +| 3.7 | Child sessions | Subagent work is represented as child sessions with parent/child navigation. | Partial. `src/session/manager.rs` supports child sessions; `src/tools/task.rs` creates subagent sessions; `src/app.rs` renders subagent events. | Lacks opencode-style background tasks, task status tracking, and richer child-session lifecycle controls. | +| 3.8 | `@mention` subagent invocation | User input can invoke subagents by mention. | Missing. Slash command parsing exists in `src/command/parser.rs`; autocomplete focuses on files/commands, not subagents. | Need parser, autocomplete, and dispatch path for `@subagent` invocation. | +| 3.9 | Agent mode: primary, subagent, all | Agent definitions declare where they are available. | Missing. | Need agent registry fields and enforcement for primary-only, subagent-only, and all-mode agents. | +| 3.10 | Hidden agents from autocomplete | Agents can be invokable but hidden from autocomplete. | Missing. | Requires hidden metadata in agent definitions and autocomplete filtering. | +| 3.11 | Task permissions | Controls which agents can invoke which subagents. | Missing. Task validates only the hardcoded subagent enum. | Need per-agent task permission rules and enforcement before spawning a child agent. | +| 4.1 | `bash` tool | Executes shell commands. | Present. `src/tools/bash.rs`; registered in `src/tools/init.rs`. | Policy granularity differs from opencode. | +| 4.2 | `edit` tool | Exact string replacement in files. | Present. `src/tools/edit.rs`; registered in `src/tools/init.rs`. | No major parity gap for the basic tool. | +| 4.3 | `write` tool | Creates or overwrites files. | Present. `src/tools/fs/write.rs`; registered in `src/tools/init.rs`. | No major parity gap for the basic tool. | +| 4.4 | `read` tool | Reads files with offset/limit pagination and can inspect directories. | Present. `src/tools/fs/read.rs`; registered in `src/tools/init.rs`. | Confirm directory behavior stays aligned with opencode's separate `list` semantics during future changes. | +| 4.5 | `grep` tool | Regex search with include filters. | Present. `src/tools/fs/grep.rs`; registered in `src/tools/init.rs`. | No major parity gap for the basic tool. | +| 4.6 | `glob` tool | File pattern matching. | Present. `src/tools/fs/glob.rs`; registered in `src/tools/init.rs`. | No major parity gap for the basic tool. | +| 4.7 | `list` tool | Deliberate tree-style directory listing, separate from read-directory behavior. | Partial. `src/tools/fs/list.rs` lists direct entries. | Needs recursive/tree-style output parity with opencode's `list`. | +| 4.8 | `skill` tool | Loads `SKILL.md` by name and lists available skills in its description. | Present. `src/tools/skill.rs`; registered in `src/tools/init.rs`. | Availability filtering does not honor skill permission patterns. | +| 4.9 | `task` tool | Spawns subagents. | Present. `src/tools/task.rs`; dynamically registered in `src/tools/init.rs`. | Missing background/status/command/task-permission behavior. | +| 4.10 | `todowrite` tool | Manages structured task lists. | Present. `src/tools/todowrite.rs`; registered in `src/tools/init.rs`. | No major parity gap for registration; behavior should be checked separately if exact todo schema parity is required. | +| 4.11 | `webfetch` tool | Fetches web content and converts it to readable text/markdown. | Present. `src/tools/webfetch.rs`; registered in `src/tools/init.rs`. | Network and markdown-conversion fidelity may differ, but the core tool exists. | +| 4.12 | `websearch` tool | Searches the web through Exa AI. | Missing. | Need search provider integration, schema, permissions, and registration. | +| 4.13 | `question` tool | Asks the user questions during execution. | Present. `src/tools/question.rs`; dynamically registered in `src/tools/init.rs`. | No major parity gap for basic interactive questions. | +| 4.14 | `extract-images` tool | Saves session images to disk. | Missing. | Need session attachment/image storage model and tool registration. | +| 4.15 | `apply_patch` tool | Applies diffs/patches. | Missing. | Need patch application tool, schema, permissions, and safe failure handling. | +| 4.16 | `lsp` tool | Experimental LSP code intelligence. | Missing. | Need LSP client/session management and tool schema. | +| 5.1 | Skill discovery locations | Searches project/global opencode, Claude, and agents skill locations. | Partial. `src/skill/mod.rs` scans crabcode/opencode globals plus project/global `.claude` and `.agents`. | Missing config `skills.paths` and URL-based skills; project `.opencode`/`.crabcode` discovery is rooted, not fully walk-up like `.claude`/`.agents`. | +| 5.2 | Walk-up to git worktree | Walks up project directories until the git worktree boundary. | Partial. `src/skill/mod.rs` walks up for `.claude` and `.agents`. | Walk-up does not stop at the git worktree boundary and is not consistently applied to all project skill roots. | +| 5.3 | Skill frontmatter | Requires YAML frontmatter with `name` and `description`. | Partial. `name` is required, but `description` is optional in `src/skill/mod.rs`. | Enforce required descriptions for opencode compatibility. | +| 5.4 | Pattern-based skill permissions | Supports allow/deny rules such as `internal-* = deny`. | Missing. | Need skill permission parsing, glob matching, and filtering before prompt/tool exposure. | +| 5.5 | Skill tool list in description | Skill tool description lists available skills. | Present. `src/tools/skill.rs` builds a description from `SkillStore::list()`. | Should be filtered by skill permissions once those exist. | +| 6.1 | JSON agent config | Supports agent config in `opencode.json`. | Partial. Crabcode config parses agent tool allowlists and `steps` from JSON/JSONC. | Missing most opencode agent fields and full `opencode.json` agent compatibility. | +| 6.2 | Markdown agent config | Supports `~/.config/opencode/agents/<name>.md` frontmatter. | Missing as runtime config. `src/config/configuration.rs` inventories agent markdown files but does not parse/apply them. | Need markdown frontmatter parser and merge logic. | +| 6.3 | Per-agent execution fields | Supports description, temperature, model, max steps, mode, hidden, color, top_p, permissions, and task permissions. | Mostly missing. `src/agent/config.rs` is global LLM session state; config currently supports only tool policies and `steps`. | Need a first-class agent definition model and enforcement path. | +| 6.4 | Agent creation wizard | `opencode agent create` scaffolds new agent config. | Missing. | Add command/CLI flow to create agent markdown or JSON config entries. | +| 7.1 | User-defined command files | Loads `.opencode/commands/<name>.md`. | Missing. `src/command` only implements built-in slash commands and skill-backed commands. | Need command file discovery, parsing, and registration. | +| 7.2 | Command frontmatter | Supports description, agent, model, and subtask. | Missing. | Need command frontmatter schema and dispatch behavior. | +| 7.3 | Template variables | Expands `$ARGUMENTS`, `$1`, `$2`, and similar variables. | Missing. | Add command template expansion before sending prompts. | +| 7.4 | Shell output injection | Expands command-substitution snippets inside command prompts. | Missing. | Add shell execution path with permission checks and output injection. | +| 7.5 | File references | Expands `@path/to/file` references inside command prompts. | Missing. | Reuse file reference parsing/attachment code or add command-specific resolver. | +| 7.6 | Subtask command routing | Commands can run as subtasks through the Task tool. | Missing. | Add `subtask` handling that routes through Task with the requested agent. | +| 8.1 | Per-tool permissions | Per-tool rules support allow, deny, and ask. | Partial. `src/tools/permission.rs` has Plan/Build defaults and in-memory allow/deny/ask outcomes. | No config-level per-tool allow/deny/ask matrix. | +| 8.2 | Wildcard permission patterns | Supports patterns such as `mymcp_* = deny`. | Missing. | Add wildcard matcher and config schema. | +| 8.3 | Bash command patterns | Supports command-specific bash permissions such as `git push = ask` and `git * = allow`. | Missing. | Replace hardcoded bash ask behavior with ordered pattern-specific rules. | +| 8.4 | Per-agent permission override | Agent config can override global permissions. | Missing. | Requires first-class agent config plus permission merge order. | +| 8.5 | External directory gating | Writes/commands outside workspace are gated. | Present. `src/tools/permission.rs` checks external paths and sensitive paths. | No major parity gap for the basic safety gate, but rule configurability is missing. | +| 8.6 | Doom loop recovery prompts | Detects repeated permission/operation loops and prompts for recovery. | Missing. | Need loop detection in agent execution and recovery prompt injection. | + +## Priority Gaps ### CRITICAL -| # | Gap | Location | Notes | -|---|-----|----------|-------| -| **C1** | **No custom user-defined commands** | `src/command/` | OpenCode's `.opencode/commands/<name>.md` system is entirely absent. Crabcode only has hardcoded Rust function handlers. Need: (a) `.opencode/commands/` + `~/.config/opencode/commands/` directory discovery, (b) Markdown file parser with YAML frontmatter, (c) template engine for `$ARGUMENTS`, `$INPUT`, `$CWD`, (d) shell injection `$(...)`, (e) `@file` references. Entirely new module needed. | +1. Runtime system prompt omits the tool schemas block. + - Files: `src/prompt/mod.rs`, `src/app.rs`, `src/main.rs`, `src/tools/init.rs`. + - `SystemPromptComposer` can render tool schemas, but the app and print paths do not pass a registry with `.with_tool_registry(...)`. Build the static and dynamic tool registries before composing the system prompt, then include `question` and `task` as dynamic schemas. + +2. OpenCode-compatible custom commands are absent. + - Files: `src/command/parser.rs`, `src/command/registry.rs`, `src/command/handlers.rs`. + - Add discovery for `.opencode/commands/<name>.md`, frontmatter parsing for `description`, `agent`, `model`, and `subtask`, template expansion for `$ARGUMENTS` and positional args, command-substitution injection with permission checks, file-reference expansion, and Task routing for subtask commands. -Recently addressed from the previous critical list: -- Subagents now run through the multi-step `stream_with_tools()` loop and stream into child sessions. -- Tool-result messages are now replayed into later model requests as observations. Remaining work is canonical MessageV2-style typed history with attachments/full outputs. +3. Permission system is not OpenCode-compatible. + - Files: `src/tools/permission.rs`, `src/config/configuration.rs`, `crabcode.schema.json`, `_docs/config.mdx`. + - Add config-driven `allow`, `deny`, and `ask` rules; wildcard tool matching; ordered bash command patterns; per-agent override merging; task permissions; skill permissions; and durable approvals where appropriate. + +4. First-class agent registry/config is missing. + - Files: `src/agent/config.rs`, `src/agent/subagent.rs`, `src/config/configuration.rs`. + - Introduce an agent definition model covering description, model, temperature, top_p, steps/max_steps aliases, mode, hidden, color, permissions, and task permissions. Parse both JSON config and markdown frontmatter agent files. ### HIGH -| # | Gap | Location | Notes | -|---|-----|----------|-------| -| **H1** | **No multi-agent config (per-agent model, temp, max_steps, mode)** | `src/agent/config.rs`, `src/agent/manager.rs` | `LlmSessionConfig` is a global singleton (`OnceLock`). Need a `config/agents/<name>.md` parser + per-agent struct with: description, temperature, model, max_steps, mode (primary/subagent/all), hidden, color, top_p, permissions, task_permissions. The `AgentManager::new()` at `manager.rs:42` hardcodes `name: "default"` and uses a global provider config. | -| **H2** | **No agent modes (primary/subagent/all/hidden)** | `src/agent/types.rs`, `src/agent/manager.rs` | `Agent` struct at `manager.rs:10` has no `mode` field. Need: enum `AgentMode::Primary | Subagent | All`, hidden flag, integration with tool permission filtering and system prompt visibility. | -| **H3** | **Child session tree still lacks background/task_status semantics** | `src/agent/subagent.rs`, `src/session/`, `src/app.rs` | Task-created child sessions now exist and are navigable in the TUI. Remaining OpenCode parity: background task resume, task_status polling, and richer child-session lifecycle metadata. | -| **H4** | **Wildcard and pattern-based permission system** | `src/tools/permission.rs` | `AgentToolPolicies` only supports exact tool name matching per mode. Need: glob/wildcard matching (`"mymcp_*": "deny"`), pattern-specific bash permissions (`"git push": "ask"`, `"git *": "allow"`), per-agent permission overrides. | -| **H5** | **Scout subagent** | New: `src/agent/subagent.rs` | Read-only subagent that can clone repos for researching external docs/dependencies. Similar to Explore but with git clone capability and web search. | -| **H6** | **VLM-agent subagent** | New: `src/agent/subagent.rs` | Subagent for image analysis. Needs attachment/media support, vision-capable model routing, and image/PDF prompt-part handling. | -| **H7** | **Hidden/auto agents (compaction, title, summary)** | New: `src/agent/` | System agents that run automatically: compaction (truncates conversation context), title (generates session title), summary (summarizes long contexts). These are hidden from user but invokable via Task tool. | -| **H8** | **No MCP runtime** | New: `src/mcp/`, `src/tools/registry.rs` | OpenCode supports stdio/SSE/Streamable HTTP MCP servers, OAuth, tools, prompts, and resources. Crabcode only accepts the `mcp` config key and does not expose MCP tools/resources to the model. | -| **H9** | **No plugin runtime, hooks, or dynamic custom tools** | New: `src/plugin/`, `src/tools/registry.rs` | OpenCode loads internal/external plugins, fires hooks around system/tool execution, and loads local `{tool,tools}/*.{js,ts}` exports as tools. Crabcode ignores OpenCode `plugin` config and has no plugin hook pipeline. | -| **H10** | **No snapshot-backed file revert** | New: `src/snapshot/`, `src/session/revert.rs` | OpenCode captures snapshots around steps, persists patch parts, and can revert/unrevert file changes. Crabcode's message action "Undo" only truncates messages and does not restore the filesystem. | -| **H11** | **No background/resumable task jobs** | `src/tools/task.rs`, `src/agent/subagent.rs` | OpenCode `task` supports `background`, `task_id` resume, `task_status`, background result injection, and automatic parent continuation. Crabcode task returns a single string from a fresh subagent call. | -| **H12** | **No attachment/media pipeline** | `src/session/`, `src/llm/`, `src/tools/` | OpenCode supports file/image/PDF prompt parts, image normalization, tool-result attachments, and provider-specific media replay. Crabcode has no corresponding session or provider message shape. | -| **H13** | **OpenCode config schema is only partially implemented** | `src/config/configuration.rs` | Additional OpenCode config fields such as `instructions`, `reference`, `small_model`, `username`, `enabled_providers`, `disabled_providers`, `formatter`, `lsp`, `attachment`, `tool_output`, `snapshot`, `share`, `plugin`, and `experimental` are absent, ignored, or reported as unimplemented. | +1. Missing subagent set beyond `explore` and `general`. + - Files: `src/agent/subagent.rs`, `src/tools/task.rs`, `src/tools/init.rs`. + - Add `scout`, `vlm-agent`, and hidden `compaction`, `title`, and `summary` agents. Scout also needs repo clone/overview tools and external research permissions; VLM needs image input/model routing. + +2. Task tool lacks background/status/command parity. + - Files: `src/tools/task.rs`, `src/session/manager.rs`, `src/app.rs`. + - Add `task_id`, `background`, `command`, and task-status support. Enforce task permissions before child session creation and expose background task lifecycle events. + +3. Missing or partial built-in tools. + - Files: `src/tools/init.rs`, `src/tools/fs/list.rs`, new tool modules. + - Add `websearch`, `extract-images`, `apply_patch`, and `lsp`. Update `list` to produce opencode-style tree output rather than only direct directory entries. + +4. Instruction discovery is incomplete. + - Files: `src/prompt/rules.rs`, `src/config/configuration.rs`, `src/main.rs`. + - Stop walk-up at the git worktree boundary, include opencode global instruction paths, support config-provided instruction entries, and attach `SkillStore` in print mode so available skills appear consistently. ### MEDIUM -| # | Gap | Location | Notes | -|---|-----|----------|-------| -| **M1** | **No @mention subagent invocation** | `src/command/parser.rs` | User typing `@explore find all tests` should route directly to the explore subagent. Need: extend `parse_input()` to detect `@subagent_name` prefix. | -| **M2** | **No websearch tool** | New: `src/tools/websearch.rs` | OpenCode supports Exa/Parallel web search. Crabcode has no equivalent. | -| **M3** | **No tool/media attachments** | `src/tools/types.rs`, `src/session/types.rs` | Tool results cannot carry attachments or media content, so current OpenCode behavior for MCP image/resource results, webfetch image output, and vision workflows has no place to land. | -| **M4** | **No apply_patch tool** | New: `src/tools/apply_patch.rs` | Apply unified diffs to files. Needed for patch-based editing workflows. | -| **M5** | **No LSP tool** | New: `src/tools/lsp.rs` | LSP code intelligence (go-to-def, find-references, diagnostics). | -| **M6** | **No doom loop recovery** | `src/tools/permission.rs`, `src/llm/client.rs` | When tools persistently fail, inject recovery prompts to break the loop. | -| **M7** | **Skill walk-up not bounded by git root** | `src/skill/mod.rs:50-64` | Walk-up for `.claude/` and `.agents/` skill dirs goes all the way to filesystem root. Should stop at git worktree boundary (like OpenCode). | -| **M8** | **No pattern-based skill permissions** | `src/skill/mod.rs`, `src/tools/skill.rs` | OpenCode supports `"internal-*": "deny"` style skill access control. Crabcode loads all skills unconditionally. | -| **M9** | **Plan/Build mode not user-toggleable mid-conversation** | `src/app.rs` (streaming setup) | Agent mode is set once at stream start. User should be able to toggle plan/build during conversations. | -| **M10** | **No task_status tool** | New: `src/tools/task_status.rs` | Needed once background subagents exist; lets the model poll or wait for asynchronous child-session work. | -| **M11** | **No repo_clone / repo_overview tools or reference aliases** | New: `src/tools/repo_clone.rs`, `src/tools/repo_overview.rs`, `src/reference/` | Required for OpenCode's scout/reference workflow: clone external repositories into a managed cache and inspect their structure without touching the user workspace. | -| **M12** | **No plan_exit handoff tool** | `src/agent/plan.rs`, `src/tools/` | OpenCode uses a model-callable plan exit flow to ask for plan approval and switch to build agent. Crabcode has plan/build policies but no equivalent tool-mediated handoff. | -| **M13** | **No formatter service or post-edit diagnostics** | New: `src/format/`, `src/lsp/` | OpenCode runs configured formatters after write/edit/patch and reports LSP diagnostics after patch. Crabcode edits files without that post-processing loop. | -| **M14** | **No durable tool-output truncation files** | New: `src/tools/truncate.rs` | OpenCode writes large outputs to a data-dir file and returns a preview plus path. Crabcode has per-tool inline truncation only, which prevents later grep/read over the full captured output. | -| **M15** | **No `instructions` config ingestion** | `src/config/configuration.rs`, `src/prompt/` | OpenCode supports extra instruction files/patterns from config. Crabcode allows the key but marks it unimplemented and only uses AGENTS/CLAUDE-style rule discovery. | -| **M16** | **No session sharing/autoshare** | `src/session/`, `src/config/configuration.rs` | OpenCode's `share` / `autoshare` config and shared session URLs are not represented. Crabcode ignores the OpenCode `share` key. | -| **M17** | **No invalid-tool fallback** | `src/tools/registry.rs` | OpenCode has an `invalid` tool to handle unknown/invalid tool calls cleanly. Crabcode has no equivalent fallback. | +1. Skill loader needs compatibility hardening. + - Files: `src/skill/mod.rs`, `src/tools/skill.rs`, `src/config/configuration.rs`. + - Add config `skills.paths` and URL skills, enforce required descriptions, apply permission-pattern filtering, and bound walk-up discovery by the git worktree. + +2. `@mention` subagent invocation is missing. + - Files: `src/command/parser.rs`, `src/autocomplete`, `src/app.rs`. + - Add parser/autocomplete support for subagent mentions and route mentioned subagents into the Task flow with the rest of the message as the prompt. + +3. Tool-call cancellation should propagate into tools. + - Files: `src/llm/client.rs`, `src/tools/aisdk_bridge.rs`, tool implementations that can block. + - Wire the top-level cancellation token into the per-tool abort channel so bash, webfetch, and subagent execution stop promptly on user interruption. + +4. Max-step compatibility should accept OpenCode aliases. + - Files: `src/config/configuration.rs`, `crabcode.schema.json`, `_docs/config.mdx`. + - Accept `max_steps` and deprecated `maxSteps` as aliases for `steps`, with a warning only if necessary. ### LOW -| # | Gap | Location | Notes | -|---|-----|----------|-------| -| **L1** | **No task permission controls** | `src/tools/task.rs` | Primary agent can always invoke any subagent. OpenCode has per-agent `task_permissions` to restrict which subagents an agent can spawn. | -| **L2** | **No agent color theming** | `src/agent/config.rs` | Per-agent color for UI differentiation of which agent is speaking. | -| **L3** | **No agent creation wizard** | New: command handler | `opencode agent create` interactive wizard missing. UX feature but tied to multi-agent config. | -| **L4** | **No per-agent top_p** | `src/agent/config.rs` | Per-agent LLM sampling parameter. | -| **L5** | **No `small_model` selection** | `src/config/configuration.rs`, `src/agent/` | OpenCode uses small-model config for title/summary/utility agents. Crabcode has no utility-model selection. | -| **L6** | **No username display override** | `src/config/configuration.rs`, `src/ui/` | OpenCode config can override the displayed username in conversations. | -| **L7** | **No provider enable/disable filters** | `src/config/configuration.rs`, `src/model/discovery.rs` | OpenCode can restrict loaded providers with `enabled_providers` and `disabled_providers`; Crabcode allows but does not apply these keys. | +1. Agent creation wizard is missing. + - Files: `src/command/handlers.rs`, `src/main.rs`. + - Add `crabcode agent create` or a slash-command equivalent after the agent definition format is implemented. + +2. Provider prompt set is simplified. + - Files: `src/prompt/mod.rs`. + - Add remaining opencode provider/model prompt variants only after the core config, command, permission, and tool gaps are closed. + +3. Per-agent visual metadata can wait. + - Files: `src/agent/config.rs`, UI consumers later. + - `color` is part of opencode agent config, but it does not affect harness execution and should be implemented after execution semantics are compatible. diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 3358ed9..e88e7b1 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -48,10 +48,10 @@ - [x] Rendering: Thinking Rendering always has this massive space below it, even if the agent didn't really think much. -- [ ] Tool call rendering: - - [ ] editing files w/ diffs, like opencode does. - - [ ] todowrite - better looking, like opencode does. - - [ ] rendering subagents - just like opencode, clickable to go into their page.. OR I can do `ctrl-x ↓` to go into it if there's a subagent running. I can also switch between subagents with `←` and `→` +- [x] Tool call rendering: + - [x] editing files w/ diffs, like opencode does. + - [x] todowrite - better looking, like opencode does. + - [x] rendering subagents - just like opencode, clickable to go into their page.. OR I can do `ctrl-x ↓` to go into it if there's a subagent running. I can also switch between subagents with `←` and `→` - [x] Fix: Chat content colors. Currently no matter what theme I use, the color of the chat especially the main text colors in markdown, are the default theme colors that were set during start time - meaning at config. Whatever I change via `/themes` dialog, it doesn't update the chat colors themes. @@ -59,7 +59,7 @@ - [x] A single AI response, is considered 1 message. So combine all its parts into a single message record. Not that every message part becomes a separate message in the timeline dialog. -- [ ] Allow me to paste images i.e. [Image #1] [Image #2] [Image #3]. When I click on them, the image would be opened with my Finder (OS-specific) +- [x] Allow me to paste images i.e. [Image #1] [Image #2] [Image #3]. When I click on them, the image would be opened with my Finder (OS-specific) - [x] Let's make the 'questions' a bit more mouse-driven. @@ -70,3 +70,5 @@ - [x] Highlight enhancements, if I click 1 place, then shift+click another. Treat it like the highlight in the browser that doesn't need a drag. Whatever I last clicked (without shift+click), treat it as the anchor for the "select start", and then whatever I shift+click after, treat it as a "select end" and autohighlight that part. (not supported) - [ ] Remote usage. + +- [ ] File referencing with @ diff --git a/aisdk/src/message.rs b/aisdk/src/message.rs index 396cfc8..4fc36f7 100644 --- a/aisdk/src/message.rs +++ b/aisdk/src/message.rs @@ -21,6 +21,14 @@ impl Message { pub fn user(content: impl Into<String>) -> Self { Self::User(UserMessage { content: content.into(), + images: Vec::new(), + }) + } + + pub fn user_with_images(content: impl Into<String>, images: Vec<ImageContent>) -> Self { + Self::User(UserMessage { + content: content.into(), + images, }) } @@ -39,6 +47,14 @@ pub struct SystemMessage { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserMessage { pub content: String, + #[serde(default)] + pub images: Vec<ImageContent>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageContent { + pub data_url: String, + pub media_type: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -62,7 +78,10 @@ impl From<&str> for SystemMessage { impl From<String> for UserMessage { fn from(content: String) -> Self { - Self { content } + Self { + content, + images: Vec::new(), + } } } @@ -70,6 +89,7 @@ impl From<&str> for UserMessage { fn from(content: &str) -> Self { Self { content: content.to_string(), + images: Vec::new(), } } } diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs index f9d404d..8e7f6f2 100644 --- a/aisdk/src/providers/anthropic.rs +++ b/aisdk/src/providers/anthropic.rs @@ -101,7 +101,7 @@ impl Provider for Anthropic { .filter_map(|m| match m { Message::User(u) => Some(serde_json::json!({ "role": "user", - "content": u.content, + "content": anthropic_user_content(u), })), Message::Assistant(a) => Some(serde_json::json!({ "role": "assistant", @@ -233,3 +233,35 @@ impl Provider for Anthropic { Ok(stream) } } + +fn anthropic_user_content(user: &crate::message::UserMessage) -> serde_json::Value { + if user.images.is_empty() { + return serde_json::json!(user.content); + } + + let mut parts = Vec::new(); + if !user.content.is_empty() { + parts.push(serde_json::json!({ + "type": "text", + "text": user.content, + })); + } + + parts.extend(user.images.iter().map(|image| { + let data = image + .data_url + .split_once(',') + .map(|(_, data)| data) + .unwrap_or(image.data_url.as_str()); + serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": image.media_type, + "data": data, + }, + }) + })); + + serde_json::Value::Array(parts) +} diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index 87d1405..7d28f59 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -99,7 +99,7 @@ impl Provider for OpenAICompatible { }), Message::User(u) => serde_json::json!({ "role": "user", - "content": u.content, + "content": openai_compatible_user_content(u), }), Message::Assistant(a) => serde_json::json!({ "role": "assistant", @@ -174,6 +174,29 @@ impl Provider for OpenAICompatible { } } +fn openai_compatible_user_content(user: &crate::message::UserMessage) -> serde_json::Value { + if user.images.is_empty() { + return serde_json::json!(user.content); + } + + let mut parts = Vec::new(); + if !user.content.is_empty() { + parts.push(serde_json::json!({ + "type": "text", + "text": user.content, + })); + } + parts.extend(user.images.iter().map(|image| { + serde_json::json!({ + "type": "image_url", + "image_url": { + "url": image.data_url, + }, + }) + })); + serde_json::Value::Array(parts) +} + fn debug_log(msg: &str) { let _ = std::fs::OpenOptions::new() .create(true) diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index 9ae6096..ec59535 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -339,7 +339,7 @@ fn build_openai_messages(messages: &[Message], strip_system: bool) -> Vec<serde_ })), Message::User(u) => Some(serde_json::json!({ "role": "user", - "content": u.content, + "content": openai_responses_user_content(u), })), Message::Assistant(a) => Some(serde_json::json!({ "role": "assistant", @@ -349,3 +349,24 @@ fn build_openai_messages(messages: &[Message], strip_system: bool) -> Vec<serde_ }) .collect() } + +fn openai_responses_user_content(user: &crate::message::UserMessage) -> serde_json::Value { + if user.images.is_empty() { + return serde_json::json!(user.content); + } + + let mut parts = Vec::new(); + if !user.content.is_empty() { + parts.push(serde_json::json!({ + "type": "input_text", + "text": user.content, + })); + } + parts.extend(user.images.iter().map(|image| { + serde_json::json!({ + "type": "input_image", + "image_url": image.data_url, + }) + })); + serde_json::Value::Array(parts) +} diff --git a/src/app.rs b/src/app.rs index 874ad03..b2f7996 100644 --- a/src/app.rs +++ b/src/app.rs @@ -960,6 +960,10 @@ impl App { pub fn handle_keys(&mut self, key: KeyEvent) { match key.code { + KeyCode::Char('v') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + self.handle_clipboard_image_paste(); + return; + } KeyCode::Char('c') if key.modifiers == event::KeyModifiers::CONTROL => { // If text is selected (chat or input), copy to clipboard first if self.try_copy_selection() { @@ -1621,7 +1625,8 @@ impl App { match key.code { KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { let input_text = self.input.get_text(); - if !input_text.is_empty() { + let image_paths = self.input.local_image_paths_for_submission(); + if !input_text.is_empty() || !image_paths.is_empty() { use crate::command::parser::parse_input; let input_type = parse_input(&input_text); @@ -1639,8 +1644,10 @@ impl App { } crate::command::parser::InputType::Message(msg) => { // Only save messages (not commands) to prompt history - self.input.save_current_to_history(); - self.handle_message_input(msg); + if image_paths.is_empty() { + self.input.save_current_to_history(); + } + self.handle_message_input_with_images(msg, image_paths); } } @@ -2011,6 +2018,75 @@ impl App { } } + fn handle_clipboard_image_paste(&mut self) { + if !matches!( + (self.base_focus, self.overlay_focus), + (BaseFocus::Home, OverlayFocus::None) + | (BaseFocus::Chat, OverlayFocus::None) + | (_, OverlayFocus::SuggestionsPopup) + ) { + return; + } + + match crate::utils::image_attachment::paste_image_to_temp_png() { + Ok(path) => { + self.input.attach_image(path); + self.input.insert_str(" "); + self.update_suggestions(); + push_toast(Toast::new( + "Attached image from clipboard", + ToastLevel::Info, + None, + )); + } + Err(err) => push_toast(Toast::new( + format!("Clipboard image paste failed: {}", err), + ToastLevel::Warning, + None, + )), + } + } + + fn try_attach_pasted_image_paths(&mut self, text: &str) -> bool { + let image_paths = crate::utils::image_attachment::image_paths_from_paste(text); + if image_paths.is_empty() { + return false; + } + + let exact_single_image = crate::utils::image_attachment::normalize_pasted_path(text) + .map(|path| crate::utils::image_attachment::is_supported_image_path(&path)) + .unwrap_or(false); + let token_count = shlex::split(text) + .map(|parts| { + parts + .into_iter() + .filter(|part| !part.trim().is_empty()) + .count() + }) + .unwrap_or_else(|| text.lines().filter(|line| !line.trim().is_empty()).count()); + + if !exact_single_image && token_count != image_paths.len() { + return false; + } + + let count = image_paths.len(); + for path in image_paths { + self.input.attach_image(path); + self.input.insert_str(" "); + } + self.update_suggestions(); + push_toast(Toast::new( + if count == 1 { + "Attached image".to_string() + } else { + format!("Attached {} images", count) + }, + ToastLevel::Info, + None, + )); + true + } + pub fn handle_paste(&mut self, text: String) { const MAX_PASTE_SIZE: usize = 20 * 1024 * 1024; @@ -2028,6 +2104,9 @@ impl App { match (self.base_focus, self.overlay_focus) { (BaseFocus::Home, OverlayFocus::None) | (BaseFocus::Chat, OverlayFocus::None) => { + if self.try_attach_pasted_image_paths(&text) { + return; + } self.input.insert_str(&text); } (_, OverlayFocus::ModelsDialog) => { @@ -2125,6 +2204,9 @@ impl App { self.api_key_input.text_area.insert_str(&text); } (_, OverlayFocus::SuggestionsPopup) => { + if self.try_attach_pasted_image_paths(&text) { + return; + } self.input.insert_str(&text); self.update_suggestions(); } @@ -2136,15 +2218,23 @@ impl App { } fn autocomplete_and_submit(&mut self) { - if let Some(selected) = get_selected_suggestion(&self.suggestions_popup_state) { - let command = format!("/{}", selected.name); + if let Some(selected) = get_selected_suggestion(&self.suggestions_popup_state).cloned() { + match selected.kind { + crate::autocomplete::SuggestionKind::Command => { + let command = format!("/{}", selected.name); - tokio::task::block_in_place(|| { - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.process_input(&command)); - }); + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_input(&command)); + }); - self.input.clear(); + self.input.clear(); + } + crate::autocomplete::SuggestionKind::File => { + self.input.apply_suggestion(&selected); + self.update_suggestions(); + } + } } self.clear_suggestions_and_blur(); } @@ -4001,7 +4091,15 @@ impl App { } fn handle_message_input(&mut self, msg: String) { - if !msg.is_empty() && self.base_focus == BaseFocus::Home { + self.handle_message_input_with_images(msg, Vec::new()); + } + + fn handle_message_input_with_images( + &mut self, + msg: String, + image_paths: Vec<std::path::PathBuf>, + ) { + if (!msg.is_empty() || !image_paths.is_empty()) && self.base_focus == BaseFocus::Home { if self.session_manager.get_current_session_id().is_none() { let session_title = self .pending_session_title @@ -4010,15 +4108,17 @@ impl App { self.create_new_session(Some(session_title)); } let mut user_message = crate::session::types::Message::user(&msg); + user_message.local_image_paths = image_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(); user_message.agent_mode = Some(self.agent.clone()); user_message.model = Some(self.model.clone()); user_message.provider = Some(self.provider_name.clone()); let _ = self .session_manager .add_message_to_current_session(&user_message); - self.chat_state - .chat - .add_user_message_with_agent_mode(&msg, self.agent.clone()); + self.chat_state.chat.add_message(user_message.clone()); self.base_focus = BaseFocus::Chat; if let Err(e) = self.start_llm_streaming(&msg) { @@ -4028,20 +4128,23 @@ impl App { None, )); } - } else if !msg.is_empty() && self.base_focus == BaseFocus::Chat { + } else if (!msg.is_empty() || !image_paths.is_empty()) && self.base_focus == BaseFocus::Chat + { if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { self.ensure_session_view_state(&session_id); } let mut user_message = crate::session::types::Message::user(&msg); + user_message.local_image_paths = image_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(); user_message.agent_mode = Some(self.agent.clone()); user_message.model = Some(self.model.clone()); user_message.provider = Some(self.provider_name.clone()); let _ = self .session_manager .add_message_to_current_session(&user_message); - self.chat_state - .chat - .add_user_message_with_agent_mode(&msg, self.agent.clone()); + self.chat_state.chat.add_message(user_message.clone()); if let Err(e) = self.start_llm_streaming(&msg) { push_toast(Toast::new( @@ -4423,32 +4526,29 @@ mod tests { ); assert!(app.switch_to_first_child_session()); assert_eq!( - app.session_manager.get_current_session_id().map(String::as_str), + app.session_manager + .get_current_session_id() + .map(String::as_str), Some("child-a") ); - assert!(app.handle_base_keys(KeyEvent::new( - KeyCode::Right, - event::KeyModifiers::NONE, - ))); + assert!(app.handle_base_keys(KeyEvent::new(KeyCode::Right, event::KeyModifiers::NONE,))); assert_eq!( - app.session_manager.get_current_session_id().map(String::as_str), + app.session_manager + .get_current_session_id() + .map(String::as_str), Some("child-b") ); - assert!(app.handle_base_keys(KeyEvent::new( - KeyCode::Left, - event::KeyModifiers::NONE, - ))); + assert!(app.handle_base_keys(KeyEvent::new(KeyCode::Left, event::KeyModifiers::NONE,))); assert_eq!( - app.session_manager.get_current_session_id().map(String::as_str), + app.session_manager + .get_current_session_id() + .map(String::as_str), Some("child-a") ); - assert!(app.handle_base_keys(KeyEvent::new( - KeyCode::Up, - event::KeyModifiers::NONE, - ))); + assert!(app.handle_base_keys(KeyEvent::new(KeyCode::Up, event::KeyModifiers::NONE,))); assert_eq!( app.session_manager.get_current_session_id(), Some(&parent_id) diff --git a/src/autocomplete/command.rs b/src/autocomplete/command.rs index b365308..a209a11 100644 --- a/src/autocomplete/command.rs +++ b/src/autocomplete/command.rs @@ -1,10 +1,54 @@ use crate::command::registry::Registry; use std::collections::HashSet; -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SuggestionKind { + Command, + File, +} + +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Suggestion { pub name: String, pub description: String, + pub replacement: String, + pub kind: SuggestionKind, + pub is_directory: bool, +} + +impl Suggestion { + pub fn command(name: impl Into<String>, description: impl Into<String>) -> Self { + let name = name.into(); + Self { + replacement: name.clone(), + name, + description: description.into(), + kind: SuggestionKind::Command, + is_directory: false, + } + } + + pub fn file(path: impl Into<String>, is_directory: bool) -> Self { + let path = path.into(); + Self { + name: path.clone(), + replacement: path, + description: if is_directory { + "directory".to_string() + } else { + String::new() + }, + kind: SuggestionKind::File, + is_directory, + } + } + + pub fn display_prefix(&self) -> &'static str { + match self.kind { + SuggestionKind::Command => "/", + SuggestionKind::File => "", + } + } } #[derive(Default)] @@ -19,10 +63,7 @@ impl CommandAuto { let commands: Vec<Suggestion> = registry .list_commands() .iter() - .map(|cmd| Suggestion { - name: cmd.name.clone(), - description: cmd.description.clone(), - }) + .map(|cmd| Suggestion::command(cmd.name.clone(), cmd.description.clone())) .collect(); let hidden_token_map: Vec<(String, String)> = registry diff --git a/src/autocomplete/file.rs b/src/autocomplete/file.rs index eb96aa4..645ec83 100644 --- a/src/autocomplete/file.rs +++ b/src/autocomplete/file.rs @@ -1,86 +1,153 @@ -use std::fs; -use std::path::PathBuf; +use crate::autocomplete::Suggestion; +use ignore::WalkBuilder; +use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const MAX_SUGGESTIONS: usize = 80; +const CACHE_TTL: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug)] +struct FileEntry { + path: String, + is_directory: bool, +} -pub struct FileAuto; +#[derive(Default)] +struct FileAutoCache { + entries: Vec<FileEntry>, + refreshed_at: Option<Instant>, +} + +pub struct FileAuto { + root: PathBuf, + cache: Mutex<FileAutoCache>, +} impl FileAuto { pub fn new() -> Self { - Self + Self::new_at(".") } - pub fn get_suggestions(&self, input: &str) -> Vec<String> { - if input.is_empty() { - return Vec::new(); + pub fn new_at(root: impl Into<PathBuf>) -> Self { + Self { + root: root.into(), + cache: Mutex::new(FileAutoCache::default()), } + } - let path = PathBuf::from(input); - let parent_dir = if input.ends_with('/') || path.has_root() { - path.clone() - } else { - match path.parent() { - Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), - _ => PathBuf::from("."), - } - }; - - let prefix = if input.ends_with('/') || path.has_root() { - String::new() - } else { - match path.file_name() { - Some(name) => name.to_string_lossy().to_string(), - None => String::new(), - } - }; - - if let Ok(entries) = fs::read_dir(&parent_dir) { - entries - .filter_map(|entry| entry.ok()) - .map(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - if entry.path().is_dir() { - format!("{}/", name) - } else { - name - } - }) - .filter(|name| name.to_lowercase().starts_with(&prefix.to_lowercase())) - .collect() - } else { - Vec::new() + pub fn get_suggestions(&self, input: &str) -> Vec<Suggestion> { + let entries = self.entries(); + let query = input.trim(); + + if query.is_empty() { + return entries + .iter() + .take(MAX_SUGGESTIONS) + .map(|entry| Suggestion::file(entry.path.clone(), entry.is_directory)) + .collect(); } + + let pattern = Pattern::new( + query, + CaseMatching::Ignore, + Normalization::Smart, + AtomKind::Fuzzy, + ); + let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); + let mut scored = entries + .iter() + .filter_map(|entry| { + let mut buf = Vec::new(); + pattern + .score(Utf32Str::new(&entry.path, &mut buf), &mut matcher) + .map(|score| (entry, score)) + }) + .collect::<Vec<_>>(); + + scored.sort_by(|(a, a_score), (b, b_score)| { + b_score + .cmp(a_score) + .then_with(|| a.path.len().cmp(&b.path.len())) + .then_with(|| a.path.cmp(&b.path)) + }); + + scored + .into_iter() + .take(MAX_SUGGESTIONS) + .map(|(entry, _)| Suggestion::file(entry.path.clone(), entry.is_directory)) + .collect() } pub fn expand_path(&self, input: &str) -> Option<String> { - if input.is_empty() { - return None; - } - let suggestions = self.get_suggestions(input); - if suggestions.len() == 1 { - let suggestion = &suggestions[0]; - let path = PathBuf::from(input); - let parent_dir = if input.ends_with('/') || path.has_root() { - path - } else { - match path.parent() { - Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), - _ => PathBuf::from("."), - } - }; - - let full_path = if parent_dir.as_os_str().is_empty() { - suggestion.clone() - } else { - format!("{}/{}", parent_dir.display(), suggestion) - }; - - Some(full_path) - } else { - None + (suggestions.len() == 1).then(|| suggestions[0].replacement.clone()) + } + + fn entries(&self) -> Vec<FileEntry> { + let mut cache = self.cache.lock().expect("file autocomplete cache poisoned"); + let should_refresh = cache + .refreshed_at + .map(|refreshed_at| refreshed_at.elapsed() > CACHE_TTL) + .unwrap_or(true); + + if should_refresh { + cache.entries = collect_entries(&self.root); + cache.refreshed_at = Some(Instant::now()); } + + cache.entries.clone() } } +fn collect_entries(root: &Path) -> Vec<FileEntry> { + let mut builder = WalkBuilder::new(root); + builder + .hidden(false) + .follow_links(true) + .require_git(true) + .filter_entry(|entry| entry.file_name() != ".git"); + + let root_input = root.to_path_buf(); + let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let mut entries = builder + .build() + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if path == root_input || path == root_abs || path == Path::new(".") { + return None; + } + let file_type = entry.file_type()?; + let is_directory = file_type.is_dir(); + let rel = path + .strip_prefix(&root_input) + .or_else(|_| path.strip_prefix(&root_abs)) + .unwrap_or(path); + let mut display = rel.to_string_lossy().replace('\\', "/"); + if display.is_empty() { + return None; + } + if is_directory && !display.ends_with('/') { + display.push('/'); + } + Some(FileEntry { + path: display, + is_directory, + }) + }) + .collect::<Vec<_>>(); + + entries.sort_by(|a, b| { + b.is_directory + .cmp(&a.is_directory) + .then_with(|| a.path.cmp(&b.path)) + }); + entries +} + impl Default for FileAuto { fn default() -> Self { Self::new() @@ -90,6 +157,7 @@ impl Default for FileAuto { #[cfg(test)] mod tests { use super::*; + use std::fs; #[test] fn test_file_auto_creation() { @@ -98,20 +166,69 @@ mod tests { #[test] fn test_file_auto_default() { - let _auto = FileAuto; + let _auto = FileAuto::default(); } #[test] - fn test_get_suggestions_empty() { - let auto = FileAuto::new(); + fn test_get_suggestions_empty_query_lists_files() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("alpha.rs"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + let suggestions = auto.get_suggestions(""); - assert!(suggestions.is_empty()); + + assert!(suggestions.iter().any(|s| s.name == "alpha.rs")); } #[test] fn test_get_suggestions_no_match() { - let auto = FileAuto::new(); + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("alpha.rs"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + let suggestions = auto.get_suggestions("xyz123abc"); + assert!(suggestions.is_empty()); } + + #[test] + fn test_hidden_files_are_suggested() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(".env"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + + let suggestions = auto.get_suggestions("env"); + + assert!(suggestions.iter().any(|s| s.name == ".env")); + } + + #[test] + fn test_gitignore_is_respected_inside_git_repo() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join(".git")).unwrap(); + fs::write(temp.path().join(".gitignore"), "target/\n").unwrap(); + fs::create_dir(temp.path().join("target")).unwrap(); + fs::write(temp.path().join("target/ignored.txt"), "").unwrap(); + fs::write(temp.path().join("kept.txt"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + + let suggestions = auto.get_suggestions("txt"); + + assert!(suggestions.iter().any(|s| s.name == "kept.txt")); + assert!(!suggestions.iter().any(|s| s.name == "target/ignored.txt")); + } + + #[test] + fn test_ignore_negation_can_make_file_visible() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(".ignore"), "*.tmp\n!important.tmp\n").unwrap(); + fs::write(temp.path().join("hidden.tmp"), "").unwrap(); + fs::write(temp.path().join("important.tmp"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + + let suggestions = auto.get_suggestions("tmp"); + + assert!(suggestions.iter().any(|s| s.name == "important.tmp")); + assert!(!suggestions.iter().any(|s| s.name == "hidden.tmp")); + } } diff --git a/src/autocomplete/mod.rs b/src/autocomplete/mod.rs index a3aa9c6..60de27e 100644 --- a/src/autocomplete/mod.rs +++ b/src/autocomplete/mod.rs @@ -1,7 +1,7 @@ pub mod command; pub mod file; -pub use command::{CommandAuto, Suggestion}; +pub use command::{CommandAuto, Suggestion, SuggestionKind}; pub use file::FileAuto; pub enum AutoCompleteMode { @@ -27,15 +27,7 @@ impl AutoComplete { pub fn get_suggestions(&self, input: &str, is_chat: bool) -> Vec<Suggestion> { match &self.mode { AutoCompleteMode::Command => self.command_auto.get_suggestions(input, is_chat), - AutoCompleteMode::File => self - .file_auto - .get_suggestions(input) - .into_iter() - .map(|name| Suggestion { - name, - description: String::new(), - }) - .collect(), + AutoCompleteMode::File => self.file_auto.get_suggestions(input), } } } diff --git a/src/llm/client.rs b/src/llm/client.rs index 7bc98e7..003b7ba 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -4,6 +4,7 @@ use aisdk::core::{ stop::{step_count_is, StopReason}, Message as AisdkMessage, Tool, }; +use aisdk::message::ImageContent; use aisdk::{Anthropic, OpenAI, OpenAICompatible}; use futures::StreamExt; use std::{collections::HashMap, time::Instant}; @@ -517,7 +518,37 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec<AisdkMes aisdk_messages.push(AisdkMessage::system(msg.content.clone())); } crate::session::types::MessageRole::User => { - aisdk_messages.push(AisdkMessage::user(msg.content.clone())); + let images = msg + .local_image_paths + .iter() + .filter_map(|path| { + let path = std::path::Path::new(path); + match crate::utils::image_attachment::data_url_for_path(path) { + Ok(data_url) => Some(ImageContent { + data_url, + media_type: crate::utils::image_attachment::mime_type_for_path( + path, + ) + .to_string(), + }), + Err(err) => { + let _ = log(&format!( + "failed to attach image {}: {}", + path.display(), + err + )); + None + } + } + }) + .collect::<Vec<_>>(); + + if images.is_empty() { + aisdk_messages.push(AisdkMessage::user(msg.content.clone())); + } else { + aisdk_messages + .push(AisdkMessage::user_with_images(msg.content.clone(), images)); + } } crate::session::types::MessageRole::Assistant => { aisdk_messages.push(AisdkMessage::assistant(msg.content.clone())); diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index 2c3e725..1bfeae5 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -18,6 +18,13 @@ impl From<SessionMessage> for Message { } } + for path in &msg.local_image_paths { + parts.push(MessagePart { + part_type: "local_image".to_string(), + data: serde_json::json!({ "path": path }), + }); + } + Message { id: cuid2::create_id(), session_id: 0, @@ -72,6 +79,19 @@ impl TryFrom<Message> for SessionMessage { .and_then(|p| p.data.get("text").and_then(|v| v.as_str())) .map(|s| s.to_string()); + let local_image_paths = msg + .parts + .iter() + .filter_map(|p| { + if p.part_type == "local_image" { + p.data.get("path").and_then(|v| v.as_str()) + } else { + None + } + }) + .map(|path| path.to_string()) + .collect(); + let role = match msg.role.as_str() { "user" => MessageRole::User, "assistant" => MessageRole::Assistant, @@ -111,6 +131,7 @@ impl TryFrom<Message> for SessionMessage { .and_then(|v| if v > 0 { Some(v as usize) } else { None }), model: msg.model.clone(), provider: msg.provider.clone(), + local_image_paths, }) } } diff --git a/src/session/types.rs b/src/session/types.rs index a85ff21..62bd623 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -61,6 +61,7 @@ pub struct Message { pub output_tokens: Option<usize>, pub model: Option<String>, pub provider: Option<String>, + pub local_image_paths: Vec<String>, } impl Message { @@ -80,6 +81,7 @@ impl Message { output_tokens: None, model: None, provider: None, + local_image_paths: Vec::new(), } } @@ -115,6 +117,7 @@ impl Message { output_tokens: None, model: None, provider: None, + local_image_paths: Vec::new(), } } diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index e67044e..212ca13 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -1,12 +1,17 @@ -use crate::autocomplete::{AutoComplete, Suggestion}; +use crate::autocomplete::{AutoComplete, Suggestion, SuggestionKind}; use crate::persistence::PromptHistoryCache; +use crate::push_toast; use crate::theme::{agent_color, ThemeColors}; +use crate::toast::{Toast, ToastLevel}; +use crate::utils::image_attachment; use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::prelude::{Rect, Style}; use ratatui::symbols::border; use ratatui::widgets::{Block, Borders, Paragraph}; +use std::ops::Range; +use std::path::PathBuf; use tui_textarea::{CursorMove, Input as TuiInput, TextArea}; use unicode_width::UnicodeWidthChar; @@ -54,6 +59,19 @@ pub struct Input { viewport_top: usize, prompt_history: Option<PromptHistoryCache>, draft_text: Option<String>, + local_images: Vec<LocalImageAttachment>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalImageAttachment { + pub placeholder: String, + pub path: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CompletionToken { + query: String, + range: Range<usize>, } impl Input { @@ -74,6 +92,7 @@ impl Input { viewport_top: 0, prompt_history, draft_text: None, + local_images: Vec::new(), } } @@ -200,12 +219,14 @@ impl Input { // Check for Shift+Enter (works in most terminals) if event.code == KeyCode::Enter && event.modifiers.contains(KeyModifiers::SHIFT) { self.textarea.insert_newline(); + self.sync_image_placeholders(); return true; } // Fallback: Alt+Enter for terminals where Shift+Enter doesn't work if event.code == KeyCode::Enter && event.modifiers.contains(KeyModifiers::ALT) { self.textarea.insert_newline(); + self.sync_image_placeholders(); return true; } @@ -280,6 +301,7 @@ impl Input { match event.code { KeyCode::Char('j') if event.modifiers == KeyModifiers::CONTROL => { self.textarea.insert_newline(); + self.sync_image_placeholders(); true } KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => false, @@ -293,18 +315,23 @@ impl Input { self.textarea.delete_char(); } } + self.sync_image_placeholders(); true } KeyCode::Tab => false, KeyCode::Esc => false, + KeyCode::Backspace if self.remove_placeholder_at_cursor(false) => true, + KeyCode::Delete if self.remove_placeholder_at_cursor(true) => true, KeyCode::Backspace if event.modifiers.contains(KeyModifiers::ALT) => { // Handle Alt+Backspace (word-delete) ourselves to avoid // tui-textarea's buggy word boundary with multi-byte emoji self.delete_word_backward(); + self.sync_image_placeholders(); true } _ => { self.textarea.input(input); + self.sync_image_placeholders(); true } } @@ -371,6 +398,22 @@ impl Input { if target_row < lines.len() { let line = &lines[target_row]; let target_col = display_col_to_byte_offset(line, relative_x as usize); + let offset = self.flat_offset_for_position(target_row, target_col); + if let Some(image) = self.image_at_offset(offset) { + match image_attachment::open_path(&image.path) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", image.placeholder), + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open image: {}", err), + ToastLevel::Error, + None, + )), + } + return true; + } // Position cursor and start selection for potential drag self.textarea .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); @@ -515,9 +558,244 @@ impl Input { } } - pub fn should_show_suggestions(&self) -> bool { + fn current_at_token(&self, allow_empty: bool) -> Option<CompletionToken> { + let text = self.get_text(); + let cursor = self.flat_cursor_offset().min(text.len()); + if !text.is_char_boundary(cursor) { + return None; + } + let before_cursor = &text[..cursor]; + let at_index = before_cursor.rfind('@')?; + + if at_index > 0 { + let before_at = &text[..at_index]; + if !before_at + .chars() + .last() + .map(char::is_whitespace) + .unwrap_or(true) + { + return None; + } + } + + let query = &text[at_index + 1..cursor]; + if (!allow_empty && query.is_empty()) || query.chars().any(char::is_whitespace) { + return None; + } + + let end = cursor + + text[cursor..] + .find(char::is_whitespace) + .unwrap_or_else(|| text.len().saturating_sub(cursor)); + + Some(CompletionToken { + query: query.to_string(), + range: at_index..end, + }) + } + + fn command_query(&self) -> Option<String> { + let text = self.get_text(); + if !text.starts_with('/') || text.contains('\n') { + return None; + } + Some(text.trim_start_matches('/').to_string()) + } + + fn flat_cursor_offset(&self) -> usize { + let (row, col) = self.textarea.cursor(); + let lines = self.textarea.lines(); + let mut offset = 0; + for line in lines.iter().take(row) { + offset += line.len() + 1; + } + offset + col.min(lines.get(row).map(|line| line.len()).unwrap_or(0)) + } + + fn flat_offset_for_position(&self, row: usize, col: usize) -> usize { + let lines = self.textarea.lines(); + let mut offset = 0; + for line in lines.iter().take(row) { + offset += line.len() + 1; + } + offset + col.min(lines.get(row).map(|line| line.len()).unwrap_or(0)) + } + + fn cursor_for_flat_offset(text: &str, mut offset: usize) -> (usize, usize) { + offset = char_boundary_before(text, offset); + let mut consumed = 0; + for (row, line) in text.split('\n').enumerate() { + let line_end = consumed + line.len(); + if offset <= line_end { + return (row, offset - consumed); + } + consumed = line_end + 1; + } + let last_line = text.rsplit('\n').next().unwrap_or(""); + (text.lines().count().saturating_sub(1), last_line.len()) + } + + fn reset_textarea(&mut self) { + self.textarea = TextArea::default(); + self.textarea.set_cursor_line_style(Style::default()); + self.textarea.set_selection_style( + Style::default() + .bg(ratatui::style::Color::Rgb(255, 140, 0)) + .fg(ratatui::style::Color::Reset), + ); + } + + fn set_text_preserving_images(&mut self, text: &str, cursor_offset: usize) { + self.reset_textarea(); + self.textarea.insert_str(text); + let cursor_offset = char_boundary_before(text, cursor_offset.min(text.len())); + let (row, col) = Self::cursor_for_flat_offset(text, cursor_offset); + self.textarea + .move_cursor(CursorMove::Jump(row as u16, col as u16)); + self.viewport_top = 0; + } + + fn image_placeholder(number: usize) -> String { + format!("[Image #{}]", number) + } + + fn replace_range(&mut self, range: Range<usize>, replacement: &str) { + let text = self.get_text(); + if range.start > range.end || range.end > text.len() { + return; + } + let mut new_text = String::new(); + new_text.push_str(&text[..range.start]); + new_text.push_str(replacement); + new_text.push_str(&text[range.end..]); + let cursor_offset = range.start + replacement.len(); + self.set_text_preserving_images(&new_text, cursor_offset); + self.sync_image_placeholders(); + } + + fn quote_completion_path(path: &str) -> String { + if path.chars().any(char::is_whitespace) { + format!("\"{}\"", path.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + path.to_string() + } + } + + fn remove_placeholder_at_cursor(&mut self, forward: bool) -> bool { let text = self.get_text(); - !text.is_empty() && text.starts_with('/') + let cursor = self.flat_cursor_offset().min(text.len()); + let target = self.local_images.iter().find_map(|image| { + text.match_indices(&image.placeholder) + .find_map(|(start, _)| { + let end = start + image.placeholder.len(); + let should_remove = if forward { + cursor >= start && cursor < end + } else { + cursor > start && cursor <= end + }; + should_remove.then_some(start..end) + }) + }); + + if let Some(range) = target { + self.replace_range(range, ""); + true + } else { + false + } + } + + fn image_at_offset(&self, offset: usize) -> Option<LocalImageAttachment> { + let text = self.get_text(); + self.local_images.iter().find_map(|image| { + text.match_indices(&image.placeholder) + .any(|(start, _)| offset >= start && offset < start + image.placeholder.len()) + .then(|| image.clone()) + }) + } + + pub fn attach_image(&mut self, path: PathBuf) { + let placeholder = Self::image_placeholder(self.local_images.len() + 1); + self.textarea.insert_str(&placeholder); + self.local_images + .push(LocalImageAttachment { placeholder, path }); + self.sync_image_placeholders(); + } + + pub fn local_image_paths_for_submission(&mut self) -> Vec<PathBuf> { + self.sync_image_placeholders(); + self.local_images + .iter() + .map(|image| image.path.clone()) + .collect() + } + + fn sync_image_placeholders(&mut self) { + if self.local_images.is_empty() { + return; + } + + let mut text = self.get_text(); + let mut kept = self + .local_images + .iter() + .filter(|image| text.contains(&image.placeholder)) + .cloned() + .collect::<Vec<_>>(); + + if kept.len() == self.local_images.len() + && kept + .iter() + .enumerate() + .all(|(idx, image)| image.placeholder == Self::image_placeholder(idx + 1)) + { + return; + } + + let cursor = self.flat_cursor_offset().min(text.len()); + for (idx, image) in kept.iter_mut().enumerate() { + let next_placeholder = Self::image_placeholder(idx + 1); + if image.placeholder != next_placeholder { + text = text.replacen(&image.placeholder, &next_placeholder, 1); + image.placeholder = next_placeholder; + } + } + + self.local_images = kept; + self.set_text_preserving_images(&text, cursor); + } + + pub fn apply_suggestion(&mut self, suggestion: &Suggestion) { + match suggestion.kind { + SuggestionKind::Command => { + let replacement = format!("/{}", suggestion.replacement); + let text = self.get_text(); + self.replace_range(0..text.len(), &replacement); + } + SuggestionKind::File => { + let Some(token) = self.current_at_token(true) else { + return; + }; + let path = PathBuf::from(&suggestion.replacement); + if !suggestion.is_directory && image_attachment::is_supported_image_path(&path) { + let placeholder = Self::image_placeholder(self.local_images.len() + 1); + let replacement = format!("{placeholder} "); + self.replace_range(token.range, &replacement); + self.local_images + .push(LocalImageAttachment { placeholder, path }); + self.sync_image_placeholders(); + } else { + let replacement = + format!("{} ", Self::quote_completion_path(&suggestion.replacement)); + self.replace_range(token.range, &replacement); + } + } + } + } + + pub fn should_show_suggestions(&self) -> bool { + self.command_query().is_some() || self.current_at_token(true).is_some() } pub fn is_slash_at_end(&self) -> bool { @@ -526,28 +804,21 @@ impl Input { } pub fn complete_selection(&mut self, is_chat: bool) { - if let Some(selected) = self.get_autocomplete_selection(is_chat) { - let current_text = self.get_text(); - let start_index = current_text.rfind('/').map_or(0, |i| i + 1); - - let new_text = if start_index == 0 { - selected.clone() - } else { - format!("{}{}", ¤t_text[..start_index], selected) - }; - - self.set_text(&new_text); + if self.autocomplete.is_some() { + if let Some(selected) = self.get_autocomplete_suggestions(is_chat).first().cloned() { + self.apply_suggestion(&selected); + } } } pub fn get_autocomplete_selection(&self, is_chat: bool) -> Option<String> { if let Some(autocomplete) = &self.autocomplete { - let text = self.get_text(); - let suggestions = if text.starts_with('/') { - let filter = text.trim_start_matches('/'); - autocomplete.get_suggestions(filter, is_chat) + let suggestions = if let Some(filter) = self.command_query() { + autocomplete.command_auto.get_suggestions(&filter, is_chat) + } else if let Some(token) = self.current_at_token(true) { + autocomplete.file_auto.get_suggestions(&token.query) } else { - autocomplete.get_suggestions(&text, is_chat) + Vec::new() }; if !suggestions.is_empty() { return Some(suggestions[0].name.clone()); @@ -565,15 +836,10 @@ impl Input { } pub fn clear(&mut self) { - self.textarea = TextArea::default(); - self.textarea.set_cursor_line_style(Style::default()); - self.textarea.set_selection_style( - Style::default() - .bg(ratatui::style::Color::Rgb(255, 140, 0)) - .fg(ratatui::style::Color::Reset), - ); + self.reset_textarea(); self.viewport_top = 0; self.draft_text = None; + self.local_images.clear(); if let Some(ref mut history) = self.prompt_history { history.reset_navigation(); } @@ -597,33 +863,29 @@ impl Input { } pub fn set_text(&mut self, text: &str) { - self.textarea = TextArea::default(); - self.textarea.set_cursor_line_style(Style::default()); - self.textarea.set_selection_style( - Style::default() - .bg(ratatui::style::Color::Rgb(255, 140, 0)) - .fg(ratatui::style::Color::Reset), - ); + self.reset_textarea(); self.textarea.insert_str(text); self.viewport_top = 0; + self.local_images.clear(); } pub fn insert_char(&mut self, c: char) { self.textarea.insert_str(c.to_string().as_str()); + self.sync_image_placeholders(); } pub fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); + self.sync_image_placeholders(); } pub fn get_autocomplete_suggestions(&self, is_chat: bool) -> Vec<Suggestion> { if let Some(autocomplete) = &self.autocomplete { - let text = self.get_text(); - if text.starts_with('/') { - let filter = text.trim_start_matches('/'); - return autocomplete.get_suggestions(filter, is_chat); - } else { - return autocomplete.get_suggestions(&text, is_chat); + if let Some(filter) = self.command_query() { + return autocomplete.command_auto.get_suggestions(&filter, is_chat); + } + if let Some(token) = self.current_at_token(true) { + return autocomplete.file_auto.get_suggestions(&token.query); } } Vec::new() @@ -705,4 +967,33 @@ mod tests { let handled = input.handle_event(event); assert!(!handled); } + + #[test] + fn test_attach_image_inserts_placeholder() { + let mut input = Input::new(); + let path = PathBuf::from("/tmp/example.png"); + + input.attach_image(path.clone()); + + assert_eq!(input.get_text(), "[Image #1]"); + assert_eq!(input.local_image_paths_for_submission(), vec![path]); + } + + #[test] + fn test_backspace_removes_image_placeholder() { + let mut input = Input::new(); + input.attach_image(PathBuf::from("/tmp/example.png")); + let event = KeyEvent { + code: KeyCode::Backspace, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; + + let handled = input.handle_event(event); + + assert!(handled); + assert_eq!(input.get_text(), ""); + assert!(input.local_image_paths_for_submission().is_empty()); + } } diff --git a/src/ui/components/popup.rs b/src/ui/components/popup.rs index e70a793..6a9e358 100644 --- a/src/ui/components/popup.rs +++ b/src/ui/components/popup.rs @@ -1,4 +1,4 @@ -use crate::autocomplete::Suggestion; +use crate::autocomplete::{Suggestion, SuggestionKind}; use crate::theme::{contrast_text, ThemeColors}; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ @@ -133,9 +133,19 @@ impl Popup { let max_name_len = self .suggestions .iter() - .map(|s| s.name.len()) + .map(|s| s.display_prefix().len() + s.name.len()) .max() .unwrap_or(0); + let title = if self + .suggestions + .first() + .map(|s| s.kind == SuggestionKind::File) + .unwrap_or(false) + { + "Files" + } else { + "Commands" + }; use ratatui::text::Span; @@ -162,32 +172,32 @@ impl Popup { let left_padding = " ".repeat(ITEM_HORIZONTAL_PADDING); let right_padding = " ".repeat(ITEM_HORIZONTAL_PADDING); + let display_name = format!("{}{}", suggestion.display_prefix(), suggestion.name); + let display_name_len = display_name.len(); + let line = if !suggestion.description.is_empty() { - let mid_padding = " ".repeat(max_name_len + 3 - suggestion.name.len()); - let content_len = suggestion.name.len() + let mid_padding = " ".repeat(max_name_len + 3 - display_name_len); + let content_len = display_name_len + suggestion.description.len() + mid_padding.len() - + 1 + ITEM_HORIZONTAL_PADDING + ITEM_HORIZONTAL_PADDING; let end_padding = " ".repeat(item_width.saturating_sub(content_len)); Line::from(vec![ Span::styled(left_padding, padding_style), - Span::styled(format!("/{}", suggestion.name), name_style), + Span::styled(display_name, name_style), Span::styled(mid_padding, padding_style), Span::styled(suggestion.description.clone(), desc_style), Span::styled(end_padding, padding_style), Span::styled(right_padding, padding_style), ]) } else { - let content_len = suggestion.name.len() - + 1 - + ITEM_HORIZONTAL_PADDING - + ITEM_HORIZONTAL_PADDING; + let content_len = + display_name_len + ITEM_HORIZONTAL_PADDING + ITEM_HORIZONTAL_PADDING; let end_padding = " ".repeat(item_width.saturating_sub(content_len)); Line::from(vec![ Span::styled(left_padding, padding_style), - Span::styled(format!("/{}", suggestion.name), name_style), + Span::styled(display_name, name_style), Span::styled(end_padding, padding_style), Span::styled(right_padding, padding_style), ]) @@ -206,7 +216,7 @@ impl Popup { Block::default() .borders(Borders::ALL) .border_style(border_style) - .title("Commands"), + .title(title), ); frame.render_widget(list, popup_area); @@ -231,6 +241,10 @@ impl Default for Popup { mod tests { use super::*; + fn suggestion(name: &str, description: &str) -> Suggestion { + Suggestion::command(name, description) + } + #[test] fn test_popup_creation() { let popup = Popup::new(); @@ -249,14 +263,8 @@ mod tests { fn test_set_suggestions() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), ]); assert!(popup.is_visible()); assert!(popup.has_suggestions()); @@ -267,10 +275,7 @@ mod tests { #[test] fn test_clear() { let mut popup = Popup::new(); - popup.set_suggestions(vec![Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }]); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); popup.clear(); assert!(!popup.is_visible()); assert!(!popup.has_suggestions()); @@ -281,18 +286,9 @@ mod tests { fn test_next() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, - Suggestion { - name: "item3".to_string(), - description: "desc3".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), + suggestion("item3", "desc3"), ]); popup.next(); assert_eq!(popup.selected_index, 1); @@ -306,18 +302,9 @@ mod tests { fn test_previous() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, - Suggestion { - name: "item3".to_string(), - description: "desc3".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), + suggestion("item3", "desc3"), ]); popup.previous(); assert_eq!(popup.selected_index, 2); @@ -329,14 +316,8 @@ mod tests { fn test_get_selected() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), ]); assert_eq!(popup.get_selected().map(|s| s.name.as_str()), Some("item1")); popup.next(); @@ -348,10 +329,7 @@ mod tests { let mut popup = Popup::new(); popup.set_suggestions( (0..10) - .map(|i| Suggestion { - name: format!("item{}", i), - description: String::new(), - }) + .map(|i| Suggestion::command(format!("item{}", i), "")) .collect(), ); @@ -394,14 +372,8 @@ mod tests { fn test_handle_key_event_down() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), ]); let key = KeyEvent { code: KeyCode::Down, @@ -418,14 +390,8 @@ mod tests { fn test_handle_key_event_up() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), ]); let key = KeyEvent { code: KeyCode::Up, @@ -441,10 +407,7 @@ mod tests { #[test] fn test_handle_key_event_tab() { let mut popup = Popup::new(); - popup.set_suggestions(vec![Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }]); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); let key = KeyEvent { code: KeyCode::Tab, modifiers: ratatui::crossterm::event::KeyModifiers::empty(), @@ -458,10 +421,7 @@ mod tests { #[test] fn test_handle_key_event_esc() { let mut popup = Popup::new(); - popup.set_suggestions(vec![Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }]); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); let key = KeyEvent { code: KeyCode::Esc, modifiers: ratatui::crossterm::event::KeyModifiers::empty(), @@ -476,10 +436,7 @@ mod tests { #[test] fn test_handle_key_event_char() { let mut popup = Popup::new(); - popup.set_suggestions(vec![Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }]); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); let key = KeyEvent { code: KeyCode::Char('a'), modifiers: ratatui::crossterm::event::KeyModifiers::empty(), diff --git a/src/utils/image_attachment.rs b/src/utils/image_attachment.rs new file mode 100644 index 0000000..4683fd8 --- /dev/null +++ b/src/utils/image_attachment.rs @@ -0,0 +1,183 @@ +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose, Engine as _}; +use std::io::{Cursor, Write}; +use std::path::{Path, PathBuf}; + +const SUPPORTED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; + +pub fn is_supported_image_path(path: &Path) -> bool { + if !path.is_file() { + return false; + } + + let supported_extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + SUPPORTED_EXTENSIONS + .iter() + .any(|known| known.eq_ignore_ascii_case(ext)) + }) + .unwrap_or(false); + + supported_extension && image::image_dimensions(path).is_ok() +} + +pub fn mime_type_for_path(path: &Path) -> &'static str { + match path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + _ => "image/png", + } +} + +pub fn data_url_for_path(path: &Path) -> Result<String> { + let bytes = + std::fs::read(path).with_context(|| format!("failed to read image {}", path.display()))?; + let mime_type = mime_type_for_path(path); + let encoded = general_purpose::STANDARD.encode(bytes); + Ok(format!("data:{mime_type};base64,{encoded}")) +} + +pub fn normalize_pasted_path(raw: &str) -> Option<PathBuf> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let unwrapped = unwrap_quotes(trimmed); + if let Some(path) = file_url_to_path(unwrapped) { + return Some(path); + } + + if let Some(parts) = shlex::split(trimmed) { + if parts.len() == 1 { + let part = unwrap_quotes(parts[0].trim()); + if let Some(path) = file_url_to_path(part) { + return Some(path); + } + return Some(PathBuf::from(part)); + } + } + + Some(PathBuf::from(unwrapped)) +} + +pub fn image_paths_from_paste(text: &str) -> Vec<PathBuf> { + let mut paths = Vec::new(); + + if let Some(parts) = shlex::split(text) { + for part in parts { + if let Some(path) = normalize_pasted_path(&part) { + if is_supported_image_path(&path) { + paths.push(path); + } + } + } + } + + if paths.is_empty() { + for line in text.lines() { + if let Some(path) = normalize_pasted_path(line) { + if is_supported_image_path(&path) { + paths.push(path); + } + } + } + } + + let mut seen = std::collections::HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); + paths +} + +pub fn paste_image_to_temp_png() -> Result<PathBuf> { + let mut clipboard = arboard::Clipboard::new().context("failed to access clipboard")?; + + if let Ok(files) = clipboard.get().file_list() { + if let Some(path) = files.into_iter().find(|path| is_supported_image_path(path)) { + return Ok(path); + } + } + + let image = clipboard + .get_image() + .context("clipboard does not contain an image")?; + let bytes = image.bytes.into_owned(); + let rgba = image::RgbaImage::from_raw(image.width as u32, image.height as u32, bytes) + .ok_or_else(|| anyhow!("clipboard image had invalid RGBA data"))?; + let mut png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(rgba) + .write_to(&mut png, image::ImageFormat::Png) + .context("failed to encode clipboard image as PNG")?; + + let mut temp = tempfile::Builder::new() + .prefix("crabcode-clipboard-") + .suffix(".png") + .tempfile() + .context("failed to create clipboard image file")?; + temp.write_all(&png.into_inner()) + .context("failed to write clipboard image file")?; + let (_file, path) = temp.keep().context("failed to persist clipboard image")?; + Ok(path) +} + +pub fn open_path(path: &Path) -> Result<()> { + if !path.exists() { + return Err(anyhow!("image no longer exists: {}", path.display())); + } + + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + return Ok(()); + } + + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "start", ""]) + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + return Ok(()); + } + + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + std::process::Command::new("xdg-open") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + Ok(()) + } +} + +fn unwrap_quotes(value: &str) -> &str { + let bytes = value.as_bytes(); + if bytes.len() >= 2 + && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')) + { + &value[1..value.len() - 1] + } else { + value + } +} + +fn file_url_to_path(value: &str) -> Option<PathBuf> { + if !value.starts_with("file://") { + return None; + } + + url::Url::parse(value).ok()?.to_file_path().ok() +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 5c6ab30..2997a34 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,5 +2,6 @@ pub mod clipboard; pub mod frecency; pub mod git; pub mod ignore; +pub mod image_attachment; pub mod time; pub mod token_counter; From 11f5ef5885c4b1e9d8788bcc8bda4cdcf47dd0de Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 10:51:18 +0800 Subject: [PATCH 092/226] feat: handle SSE metadata lines and preserve sessions dialog on delete. - Filter out SSE metadata/comment lines (`:`, `event:`, `id:`, `retry:`) in compatible provider's SSE stream processing - Add `is_sse_metadata_line` helper with tests - Keep sessions dialog open when deleting a session; clamp selection to previous index - Reset `cached_usage_check` on session switch/delete - Mark file referencing `@` as complete in TODOs --- _plans/__TODOS.md | 4 +- aisdk/src/providers/compatible.rs | 51 ++++++++++++- src/app.rs | 123 ++++++++++++++++++++++++++++-- src/tools/init.rs | 8 +- src/views/chat.rs | 5 +- 5 files changed, 173 insertions(+), 18 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index e88e7b1..bc0d686 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -71,4 +71,6 @@ - [ ] Remote usage. -- [ ] File referencing with @ +- [x] File referencing with @ + +- [ ] compaction diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index 7d28f59..bdd1041 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -209,9 +209,11 @@ fn debug_log(msg: &str) { } fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { + let data = data.trim(); + // [DONE] is ignored — the HTTP stream end signals completion. - if data == "[DONE]" || data.is_empty() { - debug_log(&format!("[SSE] Ignored: [DONE] or empty")); + if data == "[DONE]" || data.is_empty() || is_sse_metadata_line(data) { + debug_log("[SSE] Ignored: [DONE], empty, or metadata/comment"); return vec![]; } @@ -349,6 +351,37 @@ mod tests { assert!(chunks.is_empty()); } + + #[test] + fn ignores_sse_comments_and_metadata() { + for data in [ + ": OPENROUTER PROCESSING", + "event: ping", + "id: chatcmpl-123", + "retry: 1000", + ] { + assert!(process_sse_data(data).is_empty()); + } + } + + #[test] + fn bytes_to_lines_skips_sse_comments_and_metadata() { + let byte_stream = stream::iter(vec![ + Ok::<_, reqwest::Error>(bytes::Bytes::from_static(b": OPENROUTER PROCESSING\n")), + Ok::<_, reqwest::Error>(bytes::Bytes::from_static(b"event: ping\n")), + Ok::<_, reqwest::Error>(bytes::Bytes::from_static( + br#"data: {"choices":[{"delta":{"content":"hello"}}]} +"#, + )), + ]); + + let lines = futures::executor::block_on(bytes_to_lines(byte_stream).collect::<Vec<_>>()); + + assert_eq!( + lines, + vec![r#"{"choices":[{"delta":{"content":"hello"}}]}"#.to_string()] + ); + } } /// Convert a byte stream into a stream of lines, handling both SSE (`data: ...`) and raw NDJSON. @@ -365,7 +398,7 @@ where let line_bytes: Vec<u8> = buffer.drain(..=pos).collect(); let line = String::from_utf8_lossy(&line_bytes); let line = line.trim_end_matches('\n').trim_end_matches('\r'); - if line.is_empty() { + if line.is_empty() || is_sse_metadata_line(line.trim()) { continue; } let data = if let Some(stripped) = line.strip_prefix("data:") { @@ -391,7 +424,10 @@ where None => { let remaining = String::from_utf8_lossy(&buffer).trim().to_string(); buffer.clear(); - if remaining.is_empty() || remaining == "[DONE]" { + if remaining.is_empty() + || remaining == "[DONE]" + || is_sse_metadata_line(&remaining) + { debug_log("[LINE] Stream ended, no remaining data"); return None; } @@ -409,6 +445,13 @@ where ) } +fn is_sse_metadata_line(line: &str) -> bool { + line.starts_with(':') + || line.starts_with("event:") + || line.starts_with("id:") + || line.starts_with("retry:") +} + fn has_version_segment(base_url: &str) -> bool { // Check if the URL path already contains a /vN segment (e.g., /v4, /v1) if let Some(pos) = base_url.find("://") { diff --git a/src/app.rs b/src/app.rs index b2f7996..bf80754 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1253,6 +1253,7 @@ impl App { self.input.clear(); self.base_focus = BaseFocus::Home; self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, usize::MAX); } self.refresh_sessions_dialog(); let _ = self @@ -1262,6 +1263,8 @@ impl App { true } SessionsDialogAction::Delete(id) => { + let previous_selected_index = + self.sessions_dialog_state.dialog.selected_index; let was_current = self .session_manager .get_current_session_id() @@ -1274,18 +1277,18 @@ impl App { self.session_manager.delete_session(&pending); self.session_view_states.remove(&pending); } - let remaining = self.session_manager.list_sessions(); - if remaining.is_empty() { - self.sessions_dialog_state.dialog.hide(); - self.overlay_focus = OverlayFocus::None; - } self.refresh_sessions_dialog(); + let _ = self + .sessions_dialog_state + .dialog + .select_index_clamped(previous_selected_index); if was_current { self.pending_session_title = None; self.chat_state.chat.clear(); + self.input.clear(); self.base_focus = BaseFocus::Home; - self.sessions_dialog_state.dialog.hide(); - self.overlay_focus = OverlayFocus::None; + self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, usize::MAX); } true } @@ -4497,6 +4500,112 @@ mod tests { assert_eq!(app.session_manager.list_sessions().len(), 1); } + #[test] + fn deleting_current_session_keeps_sessions_dialog_focused() { + let mut app = test_app(); + app.create_new_session(Some("First".to_string())); + app.create_new_session(Some("Second".to_string())); + app.open_sessions_dialog(); + + assert!(app + .sessions_dialog_state + .dialog + .select_index_clamped(usize::MAX)); + let deleted_id = app + .sessions_dialog_state + .dialog + .get_selected() + .map(|item| item.id.clone()) + .expect("selected session"); + assert!(app.switch_to_session(&deleted_id)); + + app.handle_keys(KeyEvent::new( + KeyCode::Char('d'), + event::KeyModifiers::CONTROL, + )); + app.handle_keys(KeyEvent::new( + KeyCode::Char('d'), + event::KeyModifiers::CONTROL, + )); + + assert_eq!(app.overlay_focus, OverlayFocus::SessionsDialog); + assert!(app.sessions_dialog_state.dialog.is_visible()); + assert!(app.session_manager.get_current_session_id().is_none()); + assert!(app.session_manager.get_session_ref(&deleted_id).is_none()); + assert_eq!(app.sessions_dialog_state.dialog.selected_index, 0); + assert_ne!( + app.sessions_dialog_state + .dialog + .get_selected() + .map(|item| item.id.as_str()), + Some(deleted_id.as_str()) + ); + } + + #[test] + fn deleting_only_current_session_keeps_empty_sessions_dialog_open() { + let mut app = test_app(); + app.create_new_session(Some("Only".to_string())); + app.open_sessions_dialog(); + + app.handle_keys(KeyEvent::new( + KeyCode::Char('d'), + event::KeyModifiers::CONTROL, + )); + app.handle_keys(KeyEvent::new( + KeyCode::Char('d'), + event::KeyModifiers::CONTROL, + )); + + assert_eq!(app.overlay_focus, OverlayFocus::SessionsDialog); + assert!(app.sessions_dialog_state.dialog.is_visible()); + assert!(app.session_manager.list_sessions().is_empty()); + assert!(app.session_manager.get_current_session_id().is_none()); + assert!(app.sessions_dialog_state.dialog.get_selected().is_none()); + } + + #[test] + fn archiving_last_visible_current_session_focuses_previous_session() { + let mut app = test_app(); + app.create_new_session(Some("First".to_string())); + app.create_new_session(Some("Second".to_string())); + app.open_sessions_dialog(); + + assert!(app + .sessions_dialog_state + .dialog + .select_index_clamped(usize::MAX)); + let archived_id = app + .sessions_dialog_state + .dialog + .get_selected() + .map(|item| item.id.clone()) + .expect("selected session"); + assert!(app.switch_to_session(&archived_id)); + + app.handle_keys(KeyEvent::new( + KeyCode::Char('a'), + event::KeyModifiers::CONTROL, + )); + + assert_eq!(app.overlay_focus, OverlayFocus::SessionsDialog); + assert!(app.sessions_dialog_state.dialog.is_visible()); + assert!(app.session_manager.get_current_session_id().is_none()); + assert!(app + .session_manager + .get_session_ref(&archived_id) + .and_then(|session| session.archived_at) + .is_some()); + assert_eq!(app.sessions_dialog_state.dialog.selected_index, 0); + assert_ne!( + app.sessions_dialog_state + .dialog + .get_selected() + .map(|item| item.id.as_str()), + Some(archived_id.as_str()) + ); + } + #[test] fn child_session_navigation_matches_opencode_flow() { let mut app = test_app(); diff --git a/src/tools/init.rs b/src/tools/init.rs index cbe8d58..5632a7b 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -27,10 +27,14 @@ pub async fn register_dynamic_tools( sender: Option<crate::llm::ChunkSender>, ) { registry - .register(Arc::new(QuestionTool::new().with_sender_opt(sender.clone()))) + .register(Arc::new( + QuestionTool::new().with_sender_opt(sender.clone()), + )) .await; registry - .register(Arc::new(TaskTool::new(registry.clone()).with_sender_opt(sender))) + .register(Arc::new( + TaskTool::new(registry.clone()).with_sender_opt(sender), + )) .await; } diff --git a/src/views/chat.rs b/src/views/chat.rs index 166aa86..371370e 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -239,10 +239,7 @@ fn render_subagent_tabs( Style::default().fg(colors.text_weak) }; let suffix = if tab.running { " ~" } else { "" }; - spans.push(Span::styled( - format!(" {}{} ", tab.label, suffix), - style, - )); + spans.push(Span::styled(format!(" {}{} ", tab.label, suffix), style)); spans.push(Span::raw(" ")); } From 2306d817eeba563d990c01ed97cb513bd4990438 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 11:37:36 +0800 Subject: [PATCH 093/226] feat: add session compaction for reducing context token usage. Introduce `/compact` command that summarizes older messages via the LLM into a handoff summary, keeping the last 2 turns intact. Compaction runs as a background task, shows "compacting" status in the streaming bar, persists the compacted messages to the database, and displays a compaction marker in the chat UI with token savings stats. --- README.md | 4 +- _plans/__TODOS.md | 23 +- src/app.rs | 478 +++++++++++++++++++++++++++++---- src/autocomplete/command.rs | 23 +- src/command/handlers.rs | 28 +- src/command/registry.rs | 21 ++ src/llm/client.rs | 36 +++ src/persistence/conversions.rs | 44 ++- src/persistence/history.rs | 77 +++++- src/session/compaction.rs | 318 ++++++++++++++++++++++ src/session/manager.rs | 31 +++ src/session/mod.rs | 1 + src/session/types.rs | 26 ++ src/ui/components/chat.rs | 74 +++++ src/views/chat.rs | 52 ++-- 15 files changed, 1145 insertions(+), 91 deletions(-) create mode 100644 src/session/compaction.rs diff --git a/README.md b/README.md index a6894d9..10c7e77 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,8 @@ This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/o - [x] A ding sound, my only opencode plugin at the moment. - [x] No reverse-engineering oauth from big AI (Claude Code, Gemini), at least for now (Don't wanna get in trouble). - [x] Exception: ChatGPT oauth (because I use it) -- [ ] Copy chat contents, copy the chat input -- [ ] Image inputs +- [x] Copy chat contents, copy the chat input +- [x] Image inputs - [ ] Possibly ralphy? (very far, idk how to do that) - [ ] ACP w/ Zed? (very far, idk how to do that) - [x] No Claude Code oauth spoofing. diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index bc0d686..7312f2c 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -1,4 +1,4 @@ -- [ ] VERY VERY far future. Rearchitect - multi-workspace, just like the codex desktop app. +- [x] VERY VERY far future. Rearchitect - multi-workspace, just like the codex desktop app. - Since it's a terminal, we have a special case to make it run even when closed, or when there are multiple instances of the program running. They have the same sort of "streaming" state. I will elaborate. - Mutli-workspace feature is essentially having multiple "chat sessions" running. Currently.. Every run of `crabcode` is its own isolated session. - We want to change that by making `crabcode` a multi-workspace agentic TUI by default, just like the codex desktop app, superconductor, etc. But simpler because the idea is literally just like a chat app on the web. Wherein, I want to be able to check the "sessions" in the sidebar, create new chats in the same tab (in this case a tab is a run of `crabcode`). @@ -32,7 +32,7 @@ - [x] Chore: Create a /checkparity-opencode (the most important thing is only the agent-loop, nothing else. We do differ a bit in terms of UX anyway, but the agent-loop, tool calling, etc has to be very very close so that the performance is mostly the same) and /checkparity-codex (au) command -- [ ] Feature: Subagents just like opencode. +- [x] Feature: Subagents just like opencode. - [x] Feature: Rename command `/rename` - parity with opencode. @@ -50,6 +50,7 @@ - [x] Tool call rendering: - [x] editing files w/ diffs, like opencode does. + - [ ] webfetch rendering like codex does. - [x] todowrite - better looking, like opencode does. - [x] rendering subagents - just like opencode, clickable to go into their page.. OR I can do `ctrl-x ↓` to go into it if there's a subagent running. I can also switch between subagents with `←` and `→` @@ -69,8 +70,22 @@ - [x] Highlight enhancements, if I click 1 place, then shift+click another. Treat it like the highlight in the browser that doesn't need a drag. Whatever I last clicked (without shift+click), treat it as the anchor for the "select start", and then whatever I shift+click after, treat it as a "select end" and autohighlight that part. (not supported) -- [ ] Remote usage. +- [ ] Remote usage. Also talk about how to use for remote usages in the docs later. I can imagine multiple usecases. But this stands out in particular: + - Remotely accessing crabcode on VPS / another device. + - via another PC. + - via phone. + - More questions from me: + - Do we need a separate app? + - Should we recommend tailscale - [x] File referencing with @ -- [ ] compaction +- [x] compaction + +- [ ] More mouse-friendly chat input box floating popovers i.e. `@` for files. `/` for commands. Requirements: + - scroll w/ my mouse (no thumbs, just scroll) + - click the item with my mouse + +- [ ] Benchmark script to test performance against opencode + codex in comparison. As cheaply as possible. Using the same models. It doesn't need to be a state-of-the-art benchmark. It just needs to test a couple of usual things i.e. small stuff, see if the agent is at least just as capable, because what we're chasing is kinda exactly just the same as codex/opencode, not better. The "better" will be in the UX, it will have the better UX changes I want. So I will want to also explicitly say it's a make-shift benchmark. I want the benchmark to output: + - [ ] Cost to test - this is just my personal add + - [ ] Idk what metric usually is used, to define "better". - the goal is crabcode will have the same score as the others. diff --git a/src/app.rs b/src/app.rs index bf80754..14d0819 100644 --- a/src/app.rs +++ b/src/app.rs @@ -115,6 +115,25 @@ enum OpenAIOAuthTaskMessage { Failed(String), } +#[derive(Debug)] +enum CompactionTaskMessage { + Success { + session_id: String, + messages: Vec<crate::session::types::Message>, + stats: crate::session::types::CompactionStats, + }, + Failed { + session_id: String, + error: String, + }, +} + +#[derive(Debug, Clone)] +struct CompactionPending { + session_id: String, + before_tokens: usize, +} + #[derive(Debug)] struct SessionStreamState { chunk_receiver: crate::llm::ChunkReceiver, @@ -188,6 +207,8 @@ pub struct App { pub api_key_input: crate::ui::components::api_key_input::ApiKeyInput, openai_oauth_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<OpenAIOAuthTaskMessage>>, openai_oauth_in_progress: bool, + compaction_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<CompactionTaskMessage>>, + compaction_pending: Option<CompactionPending>, pub prefs_dao: Option<crate::persistence::PrefsDAO>, pub agent: String, pub agent_steps: std::collections::HashMap<String, usize>, @@ -388,6 +409,8 @@ impl App { api_key_input, openai_oauth_receiver: None, openai_oauth_in_progress: false, + compaction_receiver: None, + compaction_pending: None, prefs_dao, agent, agent_steps, @@ -549,13 +572,12 @@ impl App { self.chat_state.chat.scroll_to_bottom_on_next_render(); self.input.set_text(&state.input_draft); state.unread_completed = false; - self.is_streaming = state.stream.is_some() || state.external_stream.is_some(); } else { self.chat_state.chat.clear(); self.input.clear(); - self.is_streaming = false; } + self.sync_active_streaming_flag(); self.cached_usage_check = (usize::MAX, usize::MAX); } @@ -708,7 +730,7 @@ impl App { self.chat_state.chat.clear(); self.input.clear(); self.base_focus = BaseFocus::Home; - self.is_streaming = false; + self.sync_active_streaming_flag(); self.cached_usage_check = (usize::MAX, usize::MAX); self.refresh_sessions_dialog(); session_id @@ -754,11 +776,12 @@ impl App { } fn sync_active_streaming_flag(&mut self) { - self.is_streaming = self - .session_manager - .get_current_session_id() - .and_then(|id| self.session_view_states.get(id)) - .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); + self.is_streaming = self.compaction_receiver.is_some() + || self + .session_manager + .get_current_session_id() + .and_then(|id| self.session_view_states.get(id)) + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); } fn get_random_placeholder() -> String { @@ -788,49 +811,57 @@ impl App { } fn session_usage_text(&self) -> String { - let total_tokens: usize = self - .chat_state - .chat - .messages - .iter() - .filter_map(|m| m.token_count) - .sum(); - - if total_tokens == 0 { - return String::new(); - } + let messages = &self.chat_state.chat.messages; + let total_tokens = crate::session::compaction::total_context_tokens(messages); - let token_text = format_token_count(total_tokens); - let mut text = token_text; + let mut text = if total_tokens == 0 { + String::new() + } else { + crate::session::compaction::format_token_count(total_tokens) + }; - if let Some(ref discovery) = self.discovery { - if let Some(limit) = - discovery.get_model_limit(&self.provider_name.to_lowercase(), &self.model) - { - if limit > 0 { - let pct = ((total_tokens as f64 / limit as f64) * 100.0).round() as u32; - text = format!("{} ({}%)", text, pct); + if total_tokens > 0 { + if let Some(ref discovery) = self.discovery { + if let Some(limit) = + discovery.get_model_limit(&self.provider_name.to_lowercase(), &self.model) + { + if limit > 0 { + let pct = ((total_tokens as f64 / limit as f64) * 100.0).round() as u32; + text = format!("{} ({}%)", text, pct); + } } - } - if let Some(cost) = - discovery.get_model_pricing(&self.provider_name.to_lowercase(), &self.model) - { - let output_tokens: usize = self - .chat_state - .chat - .messages - .iter() - .filter_map(|m| m.output_tokens) - .sum(); - let total = (output_tokens.max(total_tokens)) as f64; - let price = total / 1_000_000.0 * cost.output; - if price > 0.001 { - return format!("{} \u{00b7} ${:.2}", text, price); + if let Some(cost) = + discovery.get_model_pricing(&self.provider_name.to_lowercase(), &self.model) + { + let output_tokens: usize = + messages.iter().filter_map(|m| m.output_tokens).sum(); + let total = (output_tokens.max(total_tokens)) as f64; + let price = total / 1_000_000.0 * cost.output; + if price > 0.001 { + text = format!("{} \u{00b7} ${:.2}", text, price); + } } } } + if let Some(pending) = self.compaction_pending.as_ref().filter(|pending| { + self.session_manager + .get_current_session_id() + .is_some_and(|id| id == &pending.session_id) + }) { + let suffix = format!( + "compacting {}", + crate::session::compaction::format_token_count(pending.before_tokens) + ); + return append_usage_suffix(text, suffix); + } + + if let Some(stats) = crate::session::compaction::latest_compaction_stats(messages) { + let suffix = format!("last compact {}%", stats.reduction_percent()); + return append_usage_suffix(text, suffix); + } + text } @@ -2308,6 +2339,136 @@ impl App { } } + fn reject_chat_only_command_outside_chat(&mut self, command_name: &str) -> bool { + if self.base_focus == BaseFocus::Chat || !self.command_registry.is_chat_only(command_name) { + return false; + } + + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("/{command_name} is only available during chat"), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + true + } + + async fn compact_current_session(&mut self) { + if self.compaction_receiver.is_some() { + push_toast(Toast::new( + "Compaction is already running", + ToastLevel::Info, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + if self.is_streaming { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Cannot compact while a response is running", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "No active session to compact", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + }; + + let messages = self.chat_state.chat.messages.clone(); + let Some(selection) = crate::session::compaction::select_messages( + &messages, + crate::session::compaction::DEFAULT_TAIL_TURNS, + ) else { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Nothing to compact", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + }; + + let before_tokens = crate::session::compaction::total_context_tokens(&messages); + let before_messages = messages.len(); + let prompt = crate::session::compaction::build_prompt(&selection.messages_to_summarize); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<CompactionTaskMessage>(); + self.compaction_receiver = Some(receiver); + self.compaction_pending = Some(CompactionPending { + session_id: session_id.clone(), + before_tokens, + }); + self.is_streaming = true; + self.cached_usage_check = (usize::MAX, usize::MAX); + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Waiting, + None, + ); + push_toast(Toast::new( + "Compacting session...", + ToastLevel::Info, + Some(std::time::Duration::from_secs(2)), + )); + + let provider_name = self.provider_name.clone(); + let model = self.model.clone(); + let agent = self.agent.clone(); + let tail_messages = selection.tail_messages; + let task_session_id = session_id.clone(); + + tokio::spawn(async move { + let result = crate::llm::client::summarize_for_compaction( + provider_name.clone(), + model.clone(), + prompt, + ) + .await + .map(|summary| { + let mut messages = crate::session::compaction::build_compacted_messages( + &summary, + tail_messages, + Some(model), + Some(provider_name), + Some(agent), + None, + ); + let after_tokens = crate::session::compaction::total_context_tokens(&messages); + let stats = crate::session::types::CompactionStats { + before_tokens, + after_tokens, + before_messages, + after_messages: messages.len(), + }; + if let Some(summary_message) = messages.first_mut() { + summary_message.compaction_stats = Some(stats); + } + (messages, stats) + }); + + let message = match result { + Ok((messages, stats)) => CompactionTaskMessage::Success { + session_id: task_session_id, + messages, + stats, + }, + Err(err) => CompactionTaskMessage::Failed { + session_id: task_session_id, + error: err.to_string(), + }, + }; + let _ = sender.send(message); + }); + } + async fn process_input(&mut self, input: &str) { use crate::command::parser::parse_input; @@ -2362,6 +2523,22 @@ impl App { self.open_timeline_dialog(); return; } + if parsed.name == "compact" && self.base_focus == BaseFocus::Chat { + if !parsed.args.is_empty() { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Usage: /compact", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } else { + self.compact_current_session().await; + } + return; + } + if self.reject_chat_only_command_outside_chat(&parsed.name) { + return; + } parsed.prefs_dao = self.prefs_dao.as_ref(); parsed.active_model_id = Some(self.model.clone()); @@ -2521,6 +2698,22 @@ impl App { self.open_timeline_dialog(); return; } + if parsed.name == "compact" && self.base_focus == BaseFocus::Chat { + if !parsed.args.is_empty() { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Usage: /compact", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } else { + self.compact_current_session().await; + } + return; + } + if self.reject_chat_only_command_outside_chat(&parsed.name) { + return; + } parsed.prefs_dao = self.prefs_dao.as_ref(); parsed.active_model_id = Some(self.model.clone()); @@ -3469,6 +3662,105 @@ impl App { } } + fn process_compaction_events(&mut self) { + let mut events = Vec::new(); + let mut disconnected = false; + + if let Some(receiver) = &mut self.compaction_receiver { + loop { + match receiver.try_recv() { + Ok(event) => events.push(event), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + if disconnected || !events.is_empty() { + self.compaction_receiver = None; + self.compaction_pending = None; + self.cached_usage_check = (usize::MAX, usize::MAX); + } + + for event in events { + match event { + CompactionTaskMessage::Success { + session_id, + messages, + stats, + } => { + match self + .session_manager + .replace_session_messages(&session_id, messages.clone()) + { + Ok(()) => { + let is_active = self.is_active_session(&session_id); + if is_active { + self.chat_state.chat = Chat::with_messages(messages.clone()); + self.chat_state.chat.scroll_to_bottom_on_next_render(); + self.chat_state.chat.clear_highlighted_message(); + } + + self.ensure_session_view_state(&session_id); + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.chat = Chat::with_messages(messages); + state.tool_calls = ToolCallViewState::default(); + state.unread_completed = !is_active; + } + + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Idle, + None, + ); + self.cached_usage_check = (usize::MAX, usize::MAX); + self.refresh_sessions_dialog(); + push_toast(Toast::new( + format!( + "Session compacted: {}", + crate::session::compaction::format_compaction_stats(stats) + ), + ToastLevel::Info, + Some(std::time::Duration::from_secs(3)), + )); + } + Err(err) => { + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Idle, + None, + ); + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("Failed to save compacted session: {:?}", err), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + } + } + CompactionTaskMessage::Failed { session_id, error } => { + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Idle, + None, + ); + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("Failed to compact session: {}", error), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + } + } + + self.sync_active_streaming_flag(); + } + fn cleanup_streaming(&mut self) { if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { self.cleanup_streaming_for_session(&session_id); @@ -3528,6 +3820,7 @@ impl App { pub fn is_animation_running(&self) -> bool { self.base_focus == BaseFocus::Home || self.is_streaming + || self.compaction_receiver.is_some() || self .session_view_states .values() @@ -3538,6 +3831,7 @@ impl App { pub fn process_streaming_chunks(&mut self) { self.process_openai_oauth_events(); + self.process_compaction_events(); let streaming_ids: Vec<String> = self .session_view_states @@ -4166,12 +4460,7 @@ impl App { let fingerprint: (usize, usize) = ( self.chat_state.chat.messages.len(), - self.chat_state - .chat - .messages - .iter() - .filter_map(|m| m.token_count) - .sum(), + crate::session::compaction::total_context_tokens(&self.chat_state.chat.messages), ); if self.cached_usage_check != fingerprint { self.cached_usage_check = fingerprint; @@ -4237,6 +4526,7 @@ impl App { self.provider_name.clone(), &colors, self.is_streaming, + self.compaction_receiver.is_some(), &usage_text, subagent_tabs, ); @@ -4359,16 +4649,14 @@ impl App { } } -fn format_token_count(count: usize) -> String { - if count < 1000 { - return count.to_string(); - } - if count < 1_000_000 { - let k = count as f64 / 1000.0; - return format!("{:.1}K", k); +fn append_usage_suffix(mut text: String, suffix: String) -> String { + if text.is_empty() { + suffix + } else { + text.push_str(" \u{00b7} "); + text.push_str(&suffix); + text } - let m = count as f64 / 1_000_000.0; - format!("{:.1}M", m) } impl Default for App { @@ -4418,6 +4706,8 @@ mod tests { api_key_input: crate::ui::components::api_key_input::ApiKeyInput::new(), openai_oauth_receiver: None, openai_oauth_in_progress: false, + compaction_receiver: None, + compaction_pending: None, prefs_dao: None, agent: "Build".to_string(), agent_steps: std::collections::HashMap::new(), @@ -4463,6 +4753,78 @@ mod tests { assert!(App::can_submit_input(&input_type, false)); } + #[test] + fn chat_only_commands_are_rejected_outside_chat() { + let mut app = test_app(); + + assert!(app.reject_chat_only_command_outside_chat("compact")); + + app.base_focus = BaseFocus::Chat; + assert!(!app.reject_chat_only_command_outside_chat("compact")); + } + + #[test] + fn compaction_result_is_applied_from_receiver() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Compact".to_string())); + app.base_focus = BaseFocus::Chat; + + let stats = crate::session::types::CompactionStats { + before_tokens: 1_000, + after_tokens: 120, + before_messages: 5, + after_messages: 1, + }; + let mut summary = crate::session::types::Message::user("summary"); + summary.compaction_stats = Some(stats); + let compacted_messages = vec![summary]; + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + sender + .send(CompactionTaskMessage::Success { + session_id: session_id.clone(), + messages: compacted_messages.clone(), + stats, + }) + .unwrap(); + drop(sender); + app.compaction_receiver = Some(receiver); + app.compaction_pending = Some(CompactionPending { + session_id: session_id.clone(), + before_tokens: stats.before_tokens, + }); + app.is_streaming = true; + + app.process_compaction_events(); + + assert!(app.compaction_receiver.is_none()); + assert!(app.compaction_pending.is_none()); + assert!(!app.is_streaming); + assert_eq!(app.chat_state.chat.messages, compacted_messages); + assert_eq!( + app.session_manager + .get_session_ref(&session_id) + .map(|session| session.messages.clone()), + Some(compacted_messages) + ); + } + + #[test] + fn session_usage_text_includes_compaction_stats() { + let mut app = test_app(); + let stats = crate::session::types::CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }; + let mut summary = crate::session::types::Message::user("summary"); + summary.token_count = Some(stats.after_tokens); + summary.compaction_stats = Some(stats); + app.chat_state.chat.messages.push(summary); + + assert_eq!(app.session_usage_text(), "360 \u{00b7} last compact 97%"); + } + #[test] fn start_blank_session_does_not_create_session_record() { let mut app = test_app(); diff --git a/src/autocomplete/command.rs b/src/autocomplete/command.rs index a209a11..0684be0 100644 --- a/src/autocomplete/command.rs +++ b/src/autocomplete/command.rs @@ -161,6 +161,13 @@ mod tests { hidden_tokens: vec![], chat_only: false, }); + registry.register(Command { + name: "compact".to_string(), + description: "Compact session".to_string(), + handler: dummy_handler, + hidden_tokens: vec![], + chat_only: true, + }); registry } @@ -168,7 +175,7 @@ mod tests { fn test_command_auto_creation() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - assert_eq!(auto.commands.len(), 3); + assert_eq!(auto.commands.len(), 4); } #[test] @@ -182,7 +189,19 @@ mod tests { let registry = setup_registry(); let auto = CommandAuto::new(®istry); let suggestions = auto.get_suggestions("", true); - assert_eq!(suggestions.len(), 3); + assert_eq!(suggestions.len(), 4); + } + + #[test] + fn test_chat_only_suggestions_hidden_outside_chat() { + let registry = setup_registry(); + let auto = CommandAuto::new(®istry); + + let home_suggestions = auto.get_suggestions("c", false); + assert!(home_suggestions.iter().all(|s| s.name != "compact")); + + let chat_suggestions = auto.get_suggestions("c", true); + assert!(chat_suggestions.iter().any(|s| s.name == "compact")); } #[test] diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 2e6ceab..317b34a 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -430,6 +430,22 @@ pub fn handle_timeline<'a>( }) } +pub fn handle_compact<'a>( + parsed: &'a ParsedCommand<'a>, + _sm: &'a mut SessionManager, +) -> Pin<Box<dyn std::future::Future<Output = CommandResult> + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error("Usage: /compact".to_string()); + } + + // The app intercepts /compact because it needs access to the active chat state. + CommandResult::Success(String::new()) + }) +} + pub fn handle_skills<'a>( parsed: &'a ParsedCommand<'a>, _sm: &'a mut SessionManager, @@ -642,6 +658,14 @@ pub fn register_all_commands(registry: &mut Registry) { chat_only: true, }); + registry.register(Command { + name: "compact".to_string(), + description: "Summarize this session to reduce context".to_string(), + handler: handle_compact, + hidden_tokens: vec![], + chat_only: true, + }); + registry.register(Command { name: "skills".to_string(), description: "List available skills".to_string(), @@ -926,7 +950,7 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 12); + assert_eq!(names.len(), 13); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); @@ -936,7 +960,9 @@ mod tests { assert!(names.contains(&"home".to_string())); assert!(names.contains(&"refreshmodels".to_string())); assert!(names.contains(&"timeline".to_string())); + assert!(names.contains(&"compact".to_string())); assert!(names.contains(&"skills".to_string())); + assert!(registry.is_chat_only("compact")); } #[tokio::test] diff --git a/src/command/registry.rs b/src/command/registry.rs index 5723e93..2e72308 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -66,6 +66,10 @@ impl Registry { None } + pub fn is_chat_only(&self, name: &str) -> bool { + self.get(name).is_some_and(|cmd| cmd.chat_only) + } + pub async fn execute<'a>( &self, parsed: &'a ParsedCommand<'a>, @@ -189,6 +193,23 @@ mod tests { assert_eq!(registry.get("alias").unwrap().name, "test"); } + #[test] + fn test_is_chat_only_checks_hidden_token() { + let mut registry = Registry::new(); + let command = Command { + name: "test".to_string(), + description: "Test command".to_string(), + handler: dummy_handler, + hidden_tokens: vec!["alias".to_string()], + chat_only: true, + }; + registry.register(command); + + assert!(registry.is_chat_only("test")); + assert!(registry.is_chat_only("alias")); + assert!(!registry.is_chat_only("missing")); + } + #[tokio::test] async fn test_execute_command() { let mut registry = Registry::new(); diff --git a/src/llm/client.rs b/src/llm/client.rs index 003b7ba..ba2d954 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -177,6 +177,42 @@ pub async fn stream_llm_with_cancellation( Ok(()) } +pub async fn summarize_for_compaction( + provider_name: String, + model: String, + prompt: String, +) -> Result<String, DynError> { + let (warning_sender, _warning_receiver) = tokio::sync::mpsc::unbounded_channel(); + let request_config = prepare_request_config(&provider_name, model, &warning_sender).await?; + let messages = vec![AisdkMessage::user(prompt)]; + let mut response = stream_provider_request(&request_config, messages, Vec::new(), None).await?; + + let mut summary = String::new(); + while let Some(chunk) = response.stream.next().await { + match chunk { + ChunkType::Text(text) => summary.push_str(&text), + ChunkType::Failed(err) => { + return Err(anyhow::anyhow!("Compaction failed: {}", err).into()); + } + ChunkType::NotSupported(msg) => { + return Err(anyhow::anyhow!("Compaction unsupported: {}", msg).into()); + } + ChunkType::Reasoning(_) + | ChunkType::ToolCall(_) + | ChunkType::End(_) + | ChunkType::Start + | ChunkType::Incomplete(_) => {} + } + } + + let summary = summary.trim().to_string(); + if summary.is_empty() { + return Err(anyhow::anyhow!("Compaction returned an empty summary").into()); + } + + Ok(summary) +} + async fn prepare_request_config( provider_name: &str, model: String, diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index 1bfeae5..29ef371 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -1,5 +1,5 @@ use crate::persistence::{Message, MessagePart, Session as PersistenceSession}; -use crate::session::types::{Message as SessionMessage, MessageRole, Session}; +use crate::session::types::{CompactionStats, Message as SessionMessage, MessageRole, Session}; impl From<SessionMessage> for Message { fn from(msg: SessionMessage) -> Self { @@ -25,6 +25,15 @@ impl From<SessionMessage> for Message { }); } + if let Some(stats) = msg.compaction_stats { + if let Ok(data) = serde_json::to_value(stats) { + parts.push(MessagePart { + part_type: "compaction_stats".to_string(), + data, + }); + } + } + Message { id: cuid2::create_id(), session_id: 0, @@ -92,6 +101,12 @@ impl TryFrom<Message> for SessionMessage { .map(|path| path.to_string()) .collect(); + let compaction_stats = msg + .parts + .iter() + .find(|p| p.part_type == "compaction_stats") + .and_then(|p| serde_json::from_value::<CompactionStats>(p.data.clone()).ok()); + let role = match msg.role.as_str() { "user" => MessageRole::User, "assistant" => MessageRole::Assistant, @@ -132,6 +147,7 @@ impl TryFrom<Message> for SessionMessage { model: msg.model.clone(), provider: msg.provider.clone(), local_image_paths, + compaction_stats, }) } } @@ -152,3 +168,29 @@ pub fn persistence_to_session( } Ok(session) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compaction_stats_round_trip_through_message_parts() { + let stats = CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }; + let mut session_message = SessionMessage::user("summary"); + session_message.compaction_stats = Some(stats); + + let persistence_message: Message = session_message.into(); + assert!(persistence_message + .parts + .iter() + .any(|part| part.part_type == "compaction_stats")); + + let restored = SessionMessage::try_from(persistence_message).unwrap(); + assert_eq!(restored.compaction_stats, Some(stats)); + } +} diff --git a/src/persistence/history.rs b/src/persistence/history.rs index 51b344f..099187a 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -274,15 +274,16 @@ impl HistoryDAO { self.conn.execute( "INSERT INTO messages ( - id, session_id, role, parts, tokens_used, model, provider, agent_mode, duration_ms, + id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, t0_ms, t1_ms, tn_ms, output_tokens ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", params![ &msg.id, msg.session_id, &msg.role, &parts_json, + msg.timestamp, msg.tokens_used, msg.model.as_deref(), msg.provider.as_deref(), @@ -299,11 +300,81 @@ impl HistoryDAO { Ok(()) } + pub fn replace_messages(&self, session_id: i64, messages: &[Message]) -> Result<()> { + self.conn.execute( + "DELETE FROM messages WHERE session_id = ?1", + params![session_id], + )?; + + let mut total_tokens: i64 = 0; + let mut updated_at = chrono::Utc::now().timestamp(); + + for msg in messages { + let parts_json = serde_json::to_string(&msg.parts)?; + total_tokens += msg.tokens_used as i64; + updated_at = msg.timestamp; + + self.conn.execute( + "INSERT INTO messages ( + id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, + t0_ms, t1_ms, tn_ms, output_tokens + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + params![ + &msg.id, + session_id, + &msg.role, + &parts_json, + msg.timestamp, + msg.tokens_used, + msg.model.as_deref(), + msg.provider.as_deref(), + msg.agent_mode.as_deref(), + msg.duration_ms, + msg.t0_ms, + msg.t1_ms, + msg.tn_ms, + msg.output_tokens, + ], + )?; + } + + let session = self.get_session(session_id)?; + let total_time_sec = session + .as_ref() + .map(|session| (updated_at - session.created_at).max(0) as f64) + .unwrap_or(0.0); + let avg_tokens_per_sec = if total_time_sec > 0.0 { + total_tokens as f64 / total_time_sec + } else { + 0.0 + }; + + self.conn.execute( + "UPDATE sessions + SET total_tokens = ?1, + total_cost = 0, + total_time_sec = ?2, + avg_tokens_per_sec = ?3, + updated_at = ?4 + WHERE id = ?5", + params![ + total_tokens, + total_time_sec, + avg_tokens_per_sec, + updated_at, + session_id + ], + )?; + + Ok(()) + } + pub fn get_messages(&self, session_id: i64) -> Result<Vec<Message>> { let mut stmt = self.conn.prepare( "SELECT id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, t0_ms, t1_ms, tn_ms, output_tokens - FROM messages WHERE session_id = ?1 ORDER BY timestamp ASC", + FROM messages WHERE session_id = ?1 ORDER BY timestamp ASC, rowid ASC", )?; let message_iter = stmt.query_map(params![session_id], |row| { diff --git a/src/session/compaction.rs b/src/session/compaction.rs new file mode 100644 index 0000000..f84f4c6 --- /dev/null +++ b/src/session/compaction.rs @@ -0,0 +1,318 @@ +use crate::session::types::{CompactionStats, Message, MessageRole}; + +pub const DEFAULT_TAIL_TURNS: usize = 2; +pub const SUMMARY_PREFIX: &str = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:"; + +const SUMMARIZATION_PROMPT: &str = r#"You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. + +Output exactly this Markdown structure and keep the section order unchanged: + +## Goal +- [single-sentence task summary] + +## Constraints & Preferences +- [user constraints, preferences, specs, or "(none)"] + +## Progress +### Done +- [completed work or "(none)"] + +### In Progress +- [current work or "(none)"] + +### Blocked +- [blockers or "(none)"] + +## Key Decisions +- [decision and why, or "(none)"] + +## Next Steps +- [ordered next actions or "(none)"] + +## Critical Context +- [important technical facts, errors, open questions, or "(none)"] + +## Relevant Files +- [file or directory path: why it matters, or "(none)"] + +Rules: +- Keep every section, even when empty. +- Use terse bullets, not prose paragraphs. +- Preserve exact file paths, commands, error strings, and identifiers when known. +- Do not mention the summary process or that context was compacted."#; + +const TOOL_OUTPUT_MAX_CHARS: usize = 2_000; + +#[derive(Debug, Clone, PartialEq)] +pub struct CompactionSelection { + pub messages_to_summarize: Vec<Message>, + pub tail_messages: Vec<Message>, +} + +pub fn select_messages(messages: &[Message], tail_turns: usize) -> Option<CompactionSelection> { + if messages.is_empty() + || !messages + .iter() + .any(|msg| matches!(msg.role, MessageRole::User)) + { + return None; + } + + let user_indices: Vec<usize> = messages + .iter() + .enumerate() + .filter_map(|(idx, msg)| matches!(msg.role, MessageRole::User).then_some(idx)) + .collect(); + + let tail_start = if tail_turns > 0 && user_indices.len() > tail_turns { + user_indices[user_indices.len() - tail_turns] + } else { + messages.len() + }; + + let messages_to_summarize = if tail_start == messages.len() { + messages.to_vec() + } else { + messages[..tail_start].to_vec() + }; + let tail_messages = if tail_start == messages.len() { + Vec::new() + } else { + messages[tail_start..].to_vec() + }; + + Some(CompactionSelection { + messages_to_summarize, + tail_messages, + }) +} + +pub fn build_prompt(messages: &[Message]) -> String { + let mut prompt = String::new(); + prompt.push_str("Summarize the following session transcript.\n\n<session-transcript>\n"); + + for (idx, message) in messages.iter().enumerate() { + let content = message_content_for_prompt(message); + if content.trim().is_empty() { + continue; + } + + prompt.push_str(&format!( + "\n### Message {} ({})\n{}\n", + idx + 1, + role_label(message.role.clone()), + content + )); + } + + prompt.push_str("\n</session-transcript>\n\n"); + prompt.push_str(SUMMARIZATION_PROMPT); + prompt +} + +pub fn build_compacted_messages( + summary: &str, + tail_messages: Vec<Message>, + model: Option<String>, + provider: Option<String>, + agent_mode: Option<String>, + stats: Option<CompactionStats>, +) -> Vec<Message> { + let mut summary_message = Message::user(format!("{}\n{}", SUMMARY_PREFIX, summary.trim())); + summary_message.model = model; + summary_message.provider = provider; + summary_message.agent_mode = agent_mode; + summary_message.token_count = Some(estimate_tokens(&summary_message.content)); + summary_message.compaction_stats = stats; + if let Some(first_tail) = tail_messages.first() { + summary_message.timestamp = first_tail + .timestamp + .checked_sub(std::time::Duration::from_secs(1)) + .unwrap_or(first_tail.timestamp); + } + + let mut messages = vec![summary_message]; + messages.extend(tail_messages); + messages +} + +pub fn total_context_tokens(messages: &[Message]) -> usize { + messages.iter().map(message_context_tokens).sum() +} + +pub fn message_context_tokens(message: &Message) -> usize { + message + .token_count + .unwrap_or_else(|| estimate_tokens(&message.content)) +} + +pub fn latest_compaction_stats(messages: &[Message]) -> Option<CompactionStats> { + messages + .iter() + .rev() + .find_map(|message| message.compaction_stats) +} + +pub fn is_compaction_summary(message: &Message) -> bool { + message.compaction_stats.is_some() || message.content.starts_with(SUMMARY_PREFIX) +} + +pub fn format_token_count(count: usize) -> String { + if count < 1000 { + return count.to_string(); + } + if count < 1_000_000 { + let k = count as f64 / 1000.0; + return format!("{:.1}K", k); + } + let m = count as f64 / 1_000_000.0; + format!("{:.1}M", m) +} + +pub fn format_compaction_stats(stats: CompactionStats) -> String { + format!( + "{} -> {}, saved {}%", + format_token_count(stats.before_tokens), + format_token_count(stats.after_tokens), + stats.reduction_percent() + ) +} + +fn message_content_for_prompt(message: &Message) -> String { + let mut content = match message.role { + MessageRole::Tool => tool_content_for_prompt(&message.content), + _ => message.content.clone(), + }; + + if !message.local_image_paths.is_empty() { + if !content.trim().is_empty() { + content.push('\n'); + } + content.push_str("Attached local images:\n"); + for path in &message.local_image_paths { + content.push_str("- "); + content.push_str(path); + content.push('\n'); + } + } + + content +} + +fn tool_content_for_prompt(content: &str) -> String { + let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else { + return truncate_chars(content, TOOL_OUTPUT_MAX_CHARS); + }; + + let Some(obj) = value.as_object() else { + return truncate_chars(content, TOOL_OUTPUT_MAX_CHARS); + }; + + let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("ok"); + let mut out = format!("Tool `{}` result ({})", name, status); + + if let Some(title) = obj.get("title").and_then(|v| v.as_str()) { + out.push_str(": "); + out.push_str(title); + } + + if let Some(preview) = obj + .get("output_preview") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + { + out.push('\n'); + out.push_str(&truncate_chars(preview, TOOL_OUTPUT_MAX_CHARS)); + } + + out +} + +fn role_label(role: MessageRole) -> &'static str { + match role { + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::System => "system", + MessageRole::Tool => "tool", + } +} + +fn truncate_chars(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{}\n[truncated]", truncated) + } else { + truncated + } +} + +fn estimate_tokens(content: &str) -> usize { + content.chars().count().saturating_add(3) / 4 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_messages_keeps_recent_tail_turns_when_available() { + let messages = vec![ + Message::user("u1"), + Message::assistant("a1"), + Message::user("u2"), + Message::assistant("a2"), + Message::user("u3"), + Message::assistant("a3"), + ]; + + let selected = select_messages(&messages, 2).expect("selection"); + + assert_eq!(selected.messages_to_summarize.len(), 2); + assert_eq!(selected.messages_to_summarize[0].content, "u1"); + assert_eq!(selected.tail_messages.len(), 4); + assert_eq!(selected.tail_messages[0].content, "u2"); + } + + #[test] + fn select_messages_summarizes_all_when_shorter_than_tail() { + let messages = vec![Message::user("u1"), Message::assistant("a1")]; + + let selected = select_messages(&messages, 2).expect("selection"); + + assert_eq!(selected.messages_to_summarize, messages); + assert!(selected.tail_messages.is_empty()); + } + + #[test] + fn build_compacted_messages_prefixes_summary() { + let compacted = build_compacted_messages( + "summary", + vec![Message::user("tail")], + None, + None, + None, + None, + ); + + assert_eq!(compacted.len(), 2); + assert!(compacted[0].content.starts_with(SUMMARY_PREFIX)); + assert_eq!(compacted[1].content, "tail"); + assert!(compacted[0].timestamp <= compacted[1].timestamp); + } + + #[test] + fn compaction_stats_formats_reduction() { + let stats = CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 10, + after_messages: 3, + }; + + assert_eq!(stats.saved_tokens(), 11_640); + assert_eq!(stats.reduction_percent(), 97); + assert_eq!(format_compaction_stats(stats), "12.0K -> 360, saved 97%"); + } +} diff --git a/src/session/manager.rs b/src/session/manager.rs index 72687bf..4acf8b9 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -315,6 +315,37 @@ impl SessionManager { Ok(()) } + pub fn replace_session_messages( + &mut self, + session_id: &str, + messages: Vec<crate::session::types::Message>, + ) -> Result<(), SessionError> { + if let Some(session) = self.sessions.get_mut(session_id) { + session.messages = messages.clone(); + session.updated_at = SystemTime::now(); + } else { + return Err(SessionError::NotFound(session_id.to_string())); + } + + if let Some(ref dao) = self.history_dao { + if let Some(db_id) = self.id_mapping.get(session_id) { + let persistence_messages: Vec<crate::persistence::Message> = messages + .into_iter() + .map(|message| { + let mut db_message: crate::persistence::Message = message.into(); + db_message.session_id = *db_id; + db_message + }) + .collect(); + + dao.replace_messages(*db_id, &persistence_messages) + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + } + } + + Ok(()) + } + pub fn set_session_status( &mut self, id: &str, diff --git a/src/session/mod.rs b/src/session/mod.rs index 22ab0da..cb5294f 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,2 +1,3 @@ +pub mod compaction; pub mod manager; pub mod types; diff --git a/src/session/types.rs b/src/session/types.rs index 62bd623..e910fcd 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use std::time::SystemTime; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -43,6 +44,28 @@ pub enum MessageRole { Tool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CompactionStats { + pub before_tokens: usize, + pub after_tokens: usize, + pub before_messages: usize, + pub after_messages: usize, +} + +impl CompactionStats { + pub fn saved_tokens(self) -> usize { + self.before_tokens.saturating_sub(self.after_tokens) + } + + pub fn reduction_percent(self) -> u32 { + if self.before_tokens == 0 { + return 0; + } + + ((self.saved_tokens() as f64 / self.before_tokens as f64) * 100.0).round() as u32 + } +} + #[derive(Debug, Clone, PartialEq)] pub struct Message { pub role: MessageRole, @@ -62,6 +85,7 @@ pub struct Message { pub model: Option<String>, pub provider: Option<String>, pub local_image_paths: Vec<String>, + pub compaction_stats: Option<CompactionStats>, } impl Message { @@ -82,6 +106,7 @@ impl Message { model: None, provider: None, local_image_paths: Vec::new(), + compaction_stats: None, } } @@ -118,6 +143,7 @@ impl Message { model: None, provider: None, local_image_paths: Vec::new(), + compaction_stats: None, } } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 97cecf0..e34d4f9 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -493,6 +493,7 @@ impl Chat { msg.output_tokens.hash(&mut h); msg.model.hash(&mut h); msg.provider.hash(&mut h); + msg.compaction_stats.hash(&mut h); } max_width.hash(&mut h); h.finish() @@ -1200,6 +1201,11 @@ impl Chat { idx += 1; } + if let Some(stats) = latest_compaction_marker_stats(&self.messages) { + all_lines.extend(format_compaction_marker(stats, max_width, colors)); + all_lines.push(Line::from("")); + } + (all_lines, positions) } @@ -1319,6 +1325,10 @@ impl Chat { match message.role { MessageRole::User => { + if crate::session::compaction::is_compaction_summary(message) { + return lines; + } + // User message: Box with left border colored by agent mode let border_color = crate::theme::agent_mode_color(message.agent_mode.as_deref(), colors); @@ -2259,6 +2269,44 @@ impl Chat { } } +fn latest_compaction_marker_stats( + messages: &[Message], +) -> Option<Option<crate::session::types::CompactionStats>> { + messages + .iter() + .rev() + .find(|message| crate::session::compaction::is_compaction_summary(message)) + .map(|message| message.compaction_stats) +} + +fn format_compaction_marker<'a>( + stats: Option<crate::session::types::CompactionStats>, + max_width: usize, + colors: &'a ThemeColors, +) -> Vec<Line<'a>> { + let detail = stats + .map(crate::session::compaction::format_compaction_stats) + .unwrap_or_else(|| "summary retained".to_string()); + + let line = Line::from(vec![ + Span::styled("• ", Style::default().fg(colors.info)), + Span::styled( + "Context compacted", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" ({})", detail), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ]); + + wrap_styled_line(&line, WrapOptions::new(max_width.max(1))) +} + fn is_compact_tool_panel(content: &str) -> bool { serde_json::from_str::<JsonValue>(content) .ok() @@ -2727,6 +2775,32 @@ mod tests { ); } + #[test] + fn test_compaction_summary_renders_marker() { + let mut msg = Message::user(format!( + "{}\nsummary content that should stay hidden", + crate::session::compaction::SUMMARY_PREFIX + )); + msg.compaction_stats = Some(crate::session::types::CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }); + let chat = Chat::with_messages(vec![msg, Message::user("tail")]); + let colors = test_colors(); + + let lines = chat.build_all_lines(80, "model", &colors); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert!(!rendered.iter().any(|line| line.contains("summary content"))); + assert!(rendered.iter().any(|line| line.contains("tail"))); + assert_eq!( + rendered.iter().rev().find(|line| !line.is_empty()), + Some(&"• Context compacted (12.0K -> 360, saved 97%)".to_string()) + ); + } + #[test] fn test_question_panel_keeps_padding_without_extra_gap() { let chat = Chat::new(); diff --git a/src/views/chat.rs b/src/views/chat.rs index 371370e..c018e78 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -69,6 +69,7 @@ pub fn render_chat( provider_name: String, colors: &ThemeColors, is_streaming: bool, + is_compacting: bool, usage_text: &str, subagent_tabs: Option<SubagentTabs>, ) { @@ -152,34 +153,45 @@ pub fn render_chat( let mut streaming_text = chat_state.wave_spinner.spans(); - let tps = chat_state.chat.get_streaming_tokens_per_sec(); - - if let Some(tps) = tps { + if is_compacting { streaming_text.push(Span::raw(" ")); streaming_text.push(Span::styled( - format!("{:.0}t/s", tps), + "compacting context", Style::default().fg(colors.info), )); - } - if let Some(elapsed) = chat_state.chat.get_streaming_elapsed_seconds() { - streaming_text.push(Span::raw(if tps.is_some() { " · " } else { " " })); + let streaming_paragraph = Paragraph::new(Line::from(streaming_text)); + f.render_widget(streaming_paragraph, status_chunks[0]); + } else { + let tps = chat_state.chat.get_streaming_tokens_per_sec(); + + if let Some(tps) = tps { + streaming_text.push(Span::raw(" ")); + streaming_text.push(Span::styled( + format!("{:.0}t/s", tps), + Style::default().fg(colors.info), + )); + } + + if let Some(elapsed) = chat_state.chat.get_streaming_elapsed_seconds() { + streaming_text.push(Span::raw(if tps.is_some() { " · " } else { " " })); + streaming_text.push(Span::styled( + format!("{:.1}s", elapsed), + Style::default().fg(colors.info), + )); + } + + streaming_text.push(Span::raw(" ")); streaming_text.push(Span::styled( - format!("{:.1}s", elapsed), - Style::default().fg(colors.info), + "esc to stop", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), )); - } - - streaming_text.push(Span::raw(" ")); - streaming_text.push(Span::styled( - "esc to stop", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - )); - let streaming_paragraph = Paragraph::new(Line::from(streaming_text)); - f.render_widget(streaming_paragraph, status_chunks[0]); + let streaming_paragraph = Paragraph::new(Line::from(streaming_text)); + f.render_widget(streaming_paragraph, status_chunks[0]); + } } if !usage_text.is_empty() { From 31490080ad9623080998947c6a33cf3ff615a1de Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 16:38:54 +0800 Subject: [PATCH 094/226] feat: made mouse in command and file popovers. --- _plans/__TODOS.md | 2 +- src/app.rs | 123 ++++++++++------- src/ui/components/popup.rs | 237 ++++++++++++++++++++++++++++++--- src/views/suggestions_popup.rs | 10 +- 4 files changed, 306 insertions(+), 66 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 7312f2c..a42f1f3 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -82,7 +82,7 @@ - [x] compaction -- [ ] More mouse-friendly chat input box floating popovers i.e. `@` for files. `/` for commands. Requirements: +- [x] More mouse-friendly chat input box floating popovers i.e. `@` for files. `/` for commands. Requirements: - scroll w/ my mouse (no thumbs, just scroll) - click the item with my mouse diff --git a/src/app.rs b/src/app.rs index 14d0819..6fddff2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,7 +46,8 @@ use crate::views::sessions_dialog::{ }; use crate::views::suggestions_popup::{ clear_suggestions, get_selected_suggestion, handle_suggestions_popup_key_event, - init_suggestions_popup, is_suggestions_visible, render_suggestions_popup, set_suggestions, + handle_suggestions_popup_mouse_event, init_suggestions_popup, is_suggestions_visible, + render_suggestions_popup, set_suggestions, }; use crate::views::themes_dialog::{ handle_themes_dialog_key_event, handle_themes_dialog_mouse_event, init_themes_dialog, @@ -1718,6 +1719,47 @@ impl App { } } + fn suggestions_popup_anchor_area(&self) -> ratatui::layout::Rect { + let main_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ratatui::layout::Constraint::Min(0)].as_ref()) + .split(self.last_frame_size); + let input_height = self.input.get_height(); + let input_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(input_height), + ] + .as_ref(), + ) + .split(main_chunks[0]); + + input_chunks[1] + } + + fn handle_input_mouse_event(&mut self, mouse: MouseEvent) -> bool { + if !self.input.handle_mouse_event(mouse) { + return false; + } + + if matches!( + mouse.kind, + ratatui::crossterm::event::MouseEventKind::Up( + ratatui::crossterm::event::MouseButton::Left + ) + ) { + let text = self.input.get_selected_text(); + if !text.is_empty() { + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + } + } + self.update_suggestions(); + true + } + pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() { let _ = crate::logging::log(&format!( @@ -1948,6 +1990,32 @@ impl App { { self.close_message_actions(); } + } else if self.overlay_focus == OverlayFocus::SuggestionsPopup { + let anchor_area = self.suggestions_popup_anchor_area(); + let action = handle_suggestions_popup_mouse_event( + &mut self.suggestions_popup_state, + mouse, + anchor_area, + ); + match action { + crate::ui::components::popup::PopupAction::Handled => {} + crate::ui::components::popup::PopupAction::Autocomplete => { + self.autocomplete_and_submit(); + } + crate::ui::components::popup::PopupAction::NotHandled => { + if self.handle_input_mouse_event(mouse) { + return; + } + if matches!( + mouse.kind, + ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left + ) + ) { + self.clear_suggestions_and_blur(); + } + } + } } else if self.overlay_focus == OverlayFocus::None { // If chat has a selection and user clicks outside chat area, clear it if self.chat_state.chat.has_selection() && self.base_focus == BaseFocus::Chat { @@ -2033,22 +2101,7 @@ impl App { } // Handle mouse events for the main input when no overlay is focused - if self.input.handle_mouse_event(mouse) { - // Auto-copy input selection on mouse up (after drag select) - if matches!( - mouse.kind, - ratatui::crossterm::event::MouseEventKind::Up( - ratatui::crossterm::event::MouseButton::Left - ) - ) { - let text = self.input.get_selected_text(); - if !text.is_empty() { - let _ = crate::utils::clipboard::copy_text(&text); - push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); - } - } - self.update_suggestions(); - } + self.handle_input_mouse_event(mouse); } } @@ -4488,25 +4541,11 @@ impl App { && self.overlay_focus != OverlayFocus::ModelsDialog && self.overlay_focus != OverlayFocus::ThemesDialog { - let main_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ratatui::layout::Constraint::Min(0)].as_ref()) - .split(size); - let input_height = self.input.get_height(); - let home_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(input_height), - ] - .as_ref(), - ) - .split(main_chunks[0]); + let anchor_area = self.suggestions_popup_anchor_area(); render_suggestions_popup( f, &self.suggestions_popup_state, - home_chunks[1], + anchor_area, self.overlay_focus == OverlayFocus::SuggestionsPopup, colors, ); @@ -4535,25 +4574,11 @@ impl App { && self.overlay_focus != OverlayFocus::ModelsDialog && self.overlay_focus != OverlayFocus::ThemesDialog { - let input_height = self.input.get_height(); - let main_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ratatui::layout::Constraint::Min(0)].as_ref()) - .split(size); - let chat_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(input_height), - ] - .as_ref(), - ) - .split(main_chunks[0]); + let anchor_area = self.suggestions_popup_anchor_area(); render_suggestions_popup( f, &self.suggestions_popup_state, - chat_chunks[1], + anchor_area, self.overlay_focus == OverlayFocus::SuggestionsPopup, colors, ); diff --git a/src/ui/components/popup.rs b/src/ui/components/popup.rs index 6a9e358..e58cca7 100644 --- a/src/ui/components/popup.rs +++ b/src/ui/components/popup.rs @@ -1,8 +1,8 @@ use crate::autocomplete::{Suggestion, SuggestionKind}; use crate::theme::{contrast_text, ThemeColors}; -use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ - prelude::Rect, + prelude::{Position, Rect}, style::{Color, Modifier, Style}, text::Line, widgets::{Block, Borders, Clear, List, ListItem}, @@ -23,6 +23,7 @@ pub struct Popup { pub suggestions: Vec<Suggestion>, pub selected_index: usize, pub visible: bool, + scroll_offset: usize, } impl Popup { @@ -31,24 +32,28 @@ impl Popup { suggestions: Vec::new(), selected_index: 0, visible: false, + scroll_offset: 0, } } pub fn set_suggestions(&mut self, suggestions: Vec<Suggestion>) { self.suggestions = suggestions; self.selected_index = 0; + self.scroll_offset = 0; self.visible = !self.suggestions.is_empty(); } pub fn clear(&mut self) { self.suggestions.clear(); self.selected_index = 0; + self.scroll_offset = 0; self.visible = false; } pub fn next(&mut self) { if !self.suggestions.is_empty() { self.selected_index = (self.selected_index + 1) % self.suggestions.len(); + self.keep_selected_visible(); } } @@ -59,6 +64,7 @@ impl Popup { } else { self.selected_index - 1 }; + self.keep_selected_visible(); } } @@ -66,6 +72,21 @@ impl Popup { self.suggestions.get(self.selected_index) } + fn popup_area(&self, area: Rect) -> Option<Rect> { + if !self.visible || self.suggestions.is_empty() { + return None; + } + + let popup_height = (self.visible_range().len() as u16) + 2; + + Some(Rect { + x: area.x, + y: area.y.saturating_sub(popup_height).saturating_sub(3), + width: area.width, + height: popup_height, + }) + } + fn visible_range(&self) -> Range<usize> { let item_count = self.suggestions.len(); if item_count == 0 { @@ -73,14 +94,63 @@ impl Popup { } let visible_count = item_count.min(MAX_VISIBLE_ITEMS); - let selected_index = self.selected_index.min(item_count.saturating_sub(1)); - let start = selected_index - .saturating_add(1) - .saturating_sub(visible_count); + let max_start = item_count.saturating_sub(visible_count); + let start = self.scroll_offset.min(max_start); start..start + visible_count } + fn keep_selected_visible(&mut self) { + if self.suggestions.is_empty() { + self.scroll_offset = 0; + return; + } + + let visible_count = self.suggestions.len().min(MAX_VISIBLE_ITEMS); + if self.selected_index < self.scroll_offset { + self.scroll_offset = self.selected_index; + } else if self.selected_index >= self.scroll_offset + visible_count { + self.scroll_offset = self.selected_index + 1 - visible_count; + } + } + + fn scroll_down(&mut self) { + let visible_count = self.suggestions.len().min(MAX_VISIBLE_ITEMS); + let max_start = self.suggestions.len().saturating_sub(visible_count); + self.scroll_offset = self.scroll_offset.saturating_add(1).min(max_start); + } + + fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + fn item_index_at(&self, area: Rect, position: Position) -> Option<usize> { + let popup_area = self.popup_area(area)?; + if !popup_area.contains(position) + || position.x <= popup_area.x + || position.x + >= popup_area + .x + .saturating_add(popup_area.width) + .saturating_sub(1) + { + return None; + } + + let relative_y = position.y.saturating_sub(popup_area.y); + if relative_y == 0 || relative_y >= popup_area.height.saturating_sub(1) { + return None; + } + + let visible_range = self.visible_range(); + let item_offset = (relative_y - 1) as usize; + if item_offset >= visible_range.len() { + return None; + } + + Some(visible_range.start + item_offset) + } + pub fn handle_key_event(&mut self, event: KeyEvent) -> PopupAction { if !self.visible { return PopupAction::NotHandled; @@ -111,6 +181,47 @@ impl Popup { } } + pub fn handle_mouse_event(&mut self, event: MouseEvent, area: Rect) -> PopupAction { + if !self.visible || self.suggestions.is_empty() { + return PopupAction::NotHandled; + } + + let position = Position::new(event.column, event.row); + let Some(popup_area) = self.popup_area(area) else { + return PopupAction::NotHandled; + }; + + match event.kind { + MouseEventKind::ScrollDown if popup_area.contains(position) => { + self.scroll_down(); + PopupAction::Handled + } + MouseEventKind::ScrollUp if popup_area.contains(position) => { + self.scroll_up(); + PopupAction::Handled + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(index) = self.item_index_at(area, position) { + self.selected_index = index; + PopupAction::Autocomplete + } else if popup_area.contains(position) { + PopupAction::Handled + } else { + PopupAction::NotHandled + } + } + MouseEventKind::Moved => { + if let Some(index) = self.item_index_at(area, position) { + self.selected_index = index; + PopupAction::Handled + } else { + PopupAction::NotHandled + } + } + _ => PopupAction::NotHandled, + } + } + pub fn render(&self, frame: &mut Frame, area: Rect, has_focus: bool, colors: ThemeColors) { if !self.visible || self.suggestions.is_empty() { return; @@ -119,13 +230,8 @@ impl Popup { let popup_width = area.width; let item_width = popup_width.saturating_sub(2) as usize; let visible_range = self.visible_range(); - let popup_height = (visible_range.len() as u16) + 2; - - let popup_area = Rect { - x: area.x, - y: area.y.saturating_sub(popup_height).saturating_sub(3), - width: popup_width, - height: popup_height, + let Some(popup_area) = self.popup_area(area) else { + return; }; frame.render_widget(Clear, popup_area); @@ -245,6 +351,15 @@ mod tests { Suggestion::command(name, description) } + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: ratatui::crossterm::event::KeyModifiers::empty(), + } + } + #[test] fn test_popup_creation() { let popup = Popup::new(); @@ -270,6 +385,7 @@ mod tests { assert!(popup.has_suggestions()); assert_eq!(popup.suggestions.len(), 2); assert_eq!(popup.selected_index, 0); + assert_eq!(popup.scroll_offset, 0); } #[test] @@ -280,6 +396,7 @@ mod tests { assert!(!popup.is_visible()); assert!(!popup.has_suggestions()); assert_eq!(popup.suggestions.len(), 0); + assert_eq!(popup.scroll_offset, 0); } #[test] @@ -335,10 +452,12 @@ mod tests { assert_eq!(popup.visible_range(), 0..8); - popup.selected_index = 8; + for _ in 0..8 { + popup.next(); + } assert_eq!(popup.visible_range(), 1..9); - popup.selected_index = 9; + popup.next(); assert_eq!(popup.visible_range(), 2..10); } @@ -446,4 +565,92 @@ mod tests { let action = popup.handle_key_event(key); assert!(matches!(action, PopupAction::NotHandled)); } + + #[test] + fn test_handle_mouse_scroll_down_moves_visible_range_without_changing_selection() { + let mut popup = Popup::new(); + popup.set_suggestions( + (0..10) + .map(|i| Suggestion::command(format!("item{}", i), "")) + .collect(), + ); + let anchor = Rect::new(0, 20, 40, 4); + let popup_area = popup.popup_area(anchor).expect("popup area"); + popup.selected_index = 5; + + let action = popup.handle_mouse_event( + mouse( + MouseEventKind::ScrollDown, + popup_area.x + 1, + popup_area.y + 1, + ), + anchor, + ); + + assert!(matches!(action, PopupAction::Handled)); + assert_eq!(popup.selected_index, 5); + assert_eq!(popup.visible_range(), 1..9); + } + + #[test] + fn test_handle_mouse_scroll_up_moves_visible_range_without_changing_selection() { + let mut popup = Popup::new(); + popup.set_suggestions( + (0..10) + .map(|i| Suggestion::command(format!("item{}", i), "")) + .collect(), + ); + popup.scroll_offset = 2; + popup.selected_index = 5; + let anchor = Rect::new(0, 20, 40, 4); + let popup_area = popup.popup_area(anchor).expect("popup area"); + + let action = popup.handle_mouse_event( + mouse(MouseEventKind::ScrollUp, popup_area.x + 1, popup_area.y + 1), + anchor, + ); + + assert!(matches!(action, PopupAction::Handled)); + assert_eq!(popup.selected_index, 5); + assert_eq!(popup.visible_range(), 1..9); + } + + #[test] + fn test_handle_mouse_click_autocompletes_clicked_item() { + let mut popup = Popup::new(); + popup.set_suggestions(vec![ + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), + suggestion("item3", "desc3"), + ]); + let anchor = Rect::new(0, 20, 40, 4); + let popup_area = popup.popup_area(anchor).expect("popup area"); + + let action = popup.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + popup_area.x + 1, + popup_area.y + 3, + ), + anchor, + ); + + assert!(matches!(action, PopupAction::Autocomplete)); + assert_eq!(popup.selected_index, 2); + } + + #[test] + fn test_handle_mouse_click_outside_popup_not_handled() { + let mut popup = Popup::new(); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); + let anchor = Rect::new(0, 20, 40, 4); + + let action = popup.handle_mouse_event( + mouse(MouseEventKind::Down(MouseButton::Left), 50, 20), + anchor, + ); + + assert!(matches!(action, PopupAction::NotHandled)); + assert_eq!(popup.selected_index, 0); + } } diff --git a/src/views/suggestions_popup.rs b/src/views/suggestions_popup.rs index f64177e..5ca87da 100644 --- a/src/views/suggestions_popup.rs +++ b/src/views/suggestions_popup.rs @@ -1,4 +1,4 @@ -use ratatui::crossterm::event::KeyEvent; +use ratatui::crossterm::event::{KeyEvent, MouseEvent}; use ratatui::{layout::Rect, Frame}; use crate::autocomplete::Suggestion; @@ -36,6 +36,14 @@ pub fn handle_suggestions_popup_key_event( popup_state.popup.handle_key_event(event) } +pub fn handle_suggestions_popup_mouse_event( + popup_state: &mut SuggestionsPopupState, + event: MouseEvent, + area: Rect, +) -> PopupAction { + popup_state.popup.handle_mouse_event(event, area) +} + pub fn set_suggestions(popup_state: &mut SuggestionsPopupState, suggestions: Vec<Suggestion>) { popup_state.popup.set_suggestions(suggestions); } From 0cbb00b7931eeade3cd61213875fc083ba53da8e Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 16:50:02 +0800 Subject: [PATCH 095/226] fix: issue w/ opencodego models, replace parse_tool_calls with streaming ToolCallAccumulator. The new ToolCallAccumulator properly handles OpenAI-style streaming tool call deltas where arguments arrive incrementally across multiple stream chunks. This fixes tool call execution with providers that stream tool calls by accumulating argument fragments and validating the final result, rather than attempting to parse each chunk independently. --- aisdk/src/response.rs | 262 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 234 insertions(+), 28 deletions(-) diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index cc9c74b..d0fc543 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -113,7 +113,7 @@ pub async fn stream_with_tools<P: Provider>( }; let mut has_tool_call = false; - let mut tool_calls_to_execute: Vec<(String, String, serde_json::Value)> = Vec::new(); + let mut tool_call_accumulator = ToolCallAccumulator::default(); let mut accumulated_text = String::new(); while let Some(chunk) = stream.next().await { @@ -128,10 +128,10 @@ pub async fn stream_with_tools<P: Provider>( Ok(ChunkType::ToolCall(json_str)) => { has_tool_call = true; let _ = tx_loop.send(ChunkType::ToolCall(json_str.clone())); - if let Ok(parsed) = parse_tool_calls(&json_str) { - for (id, name, args) in parsed { - tool_calls_to_execute.push((id, name, args)); - } + if let Err(err) = tool_call_accumulator.ingest(&json_str) { + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; } } Ok(ChunkType::End(_content)) => { @@ -176,6 +176,21 @@ pub async fn stream_with_tools<P: Provider>( break; } + let tool_calls_to_execute = match tool_call_accumulator.finish() { + Ok(tool_calls) if !tool_calls.is_empty() => tool_calls, + Ok(_) => { + let err = "Tool call stream did not contain executable tool calls".to_string(); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + Err(err) => { + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + }; + for (_call_id, tool_name, args) in &tool_calls_to_execute { let tool = tools.iter().find(|t| &t.name == tool_name); match tool { @@ -206,32 +221,223 @@ pub async fn stream_with_tools<P: Provider>( Ok(response) } -fn parse_tool_calls( - json_str: &str, -) -> std::result::Result<Vec<(String, String, serde_json::Value)>, serde_json::Error> { - let parsed: serde_json::Value = serde_json::from_str(json_str)?; - let mut results = Vec::new(); - - if let Some(arr) = parsed.as_array() { - for item in arr { - if let (Some(id), Some(function)) = ( - item.get("id").and_then(|v| v.as_str()), - item.get("function"), - ) { - let name = function +#[derive(Debug, Default)] +struct ToolCallAccumulator { + calls: Vec<PendingToolCall>, +} + +#[derive(Debug)] +struct PendingToolCall { + key: String, + id: Option<String>, + name: Option<String>, + arguments: String, + saw_arguments: bool, +} + +impl ToolCallAccumulator { + fn ingest(&mut self, json_str: &str) -> std::result::Result<(), String> { + let parsed: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| format!("Invalid tool call delta: {}", e))?; + + let items = parsed + .as_array() + .ok_or_else(|| "Unsupported tool call delta shape".to_string())?; + + for (array_index, item) in items.iter().enumerate() { + self.ingest_openai_delta(item, array_index)?; + } + + Ok(()) + } + + fn finish(self) -> std::result::Result<Vec<(String, String, serde_json::Value)>, String> { + let mut results = Vec::new(); + + for call in self.calls { + let name = call + .name + .filter(|name| !name.is_empty()) + .ok_or_else(|| format!("Tool call '{}' missing function name", call.key))?; + + let id = call.id.unwrap_or(call.key); + let args = if !call.saw_arguments || call.arguments.trim().is_empty() { + serde_json::Value::Object(Default::default()) + } else { + serde_json::from_str(&call.arguments).map_err(|e| { + format!( + "Tool call '{}' arguments are incomplete or invalid JSON: {}", + id, e + ) + })? + }; + + results.push((id, name, args)); + } + + Ok(results) + } + + fn ingest_openai_delta( + &mut self, + item: &serde_json::Value, + array_index: usize, + ) -> std::result::Result<(), String> { + let key = tool_call_key(item, array_index); + let pending = self.pending_for_key(key, item); + + if pending.id.is_none() { + pending.id = item + .get("id") + .and_then(|value| value.as_str()) + .filter(|id| !id.is_empty()) + .map(ToString::to_string); + } + + if let Some(function) = item.get("function") { + if pending.name.is_none() { + pending.name = function .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let args = function - .get("arguments") - .and_then(|v| v.as_str()) - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(serde_json::Value::Object(Default::default())); - results.push((id.to_string(), name, args)); + .and_then(|value| value.as_str()) + .filter(|name| !name.is_empty()) + .map(ToString::to_string); + } + + if let Some(arguments) = function.get("arguments") { + pending.saw_arguments = true; + match arguments { + serde_json::Value::String(delta) => pending.arguments.push_str(delta), + serde_json::Value::Null => {} + value => pending.arguments.push_str(&value.to_string()), + } } } + + Ok(()) } - Ok(results) + fn pending_for_key(&mut self, key: String, item: &serde_json::Value) -> &mut PendingToolCall { + if let Some(index) = self.calls.iter().position(|call| call.key == key) { + return &mut self.calls[index]; + } + + if let Some(id) = item + .get("id") + .and_then(|value| value.as_str()) + .filter(|id| !id.is_empty()) + { + if let Some(index) = self + .calls + .iter() + .position(|call| call.id.as_deref() == Some(id)) + { + return &mut self.calls[index]; + } + } + + self.calls.push(PendingToolCall { + key, + id: None, + name: None, + arguments: String::new(), + saw_arguments: false, + }); + self.calls.last_mut().expect("pending tool call exists") + } +} + +fn tool_call_key(item: &serde_json::Value, array_index: usize) -> String { + if let Some(index) = item.get("index").and_then(|value| value.as_u64()) { + return format!("index:{}", index); + } + + if let Some(id) = item + .get("id") + .and_then(|value| value.as_str()) + .filter(|id| !id.is_empty()) + { + return format!("id:{}", id); + } + + format!("position:{}", array_index) +} + +#[cfg(test)] +mod tests { + use super::ToolCallAccumulator; + + #[test] + fn accumulates_streamed_openai_tool_call_arguments() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"command\""}}]"#, + ) + .unwrap(); + accumulator + .ingest(r#"[{"index":0,"function":{"arguments":":\"ls -la\"}"}}]"#) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "call_1"); + assert_eq!(calls[0].1, "bash"); + assert_eq!(calls[0].2["command"], "ls -la"); + } + + #[test] + fn rejects_incomplete_tool_call_arguments() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"command\""}}]"#, + ) + .unwrap(); + + let error = accumulator.finish().unwrap_err(); + + assert!(error.contains("arguments are incomplete or invalid JSON")); + } + + #[test] + fn supports_multiple_tool_calls_by_index() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"file_path\""}},{"index":1,"id":"call_2","type":"function","function":{"name":"bash","arguments":"{\"command\""}}]"#, + ) + .unwrap(); + accumulator + .ingest( + r#"[{"index":0,"function":{"arguments":":\"Cargo.toml\"}"}},{"index":1,"function":{"arguments":":\"cargo test\"}"}}]"#, + ) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].1, "read"); + assert_eq!(calls[0].2["file_path"], "Cargo.toml"); + assert_eq!(calls[1].1, "bash"); + assert_eq!(calls[1].2["command"], "cargo test"); + } + + #[test] + fn empty_arguments_become_empty_object() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"list","arguments":""}}]"#, + ) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls[0].2, serde_json::json!({})); + } } From 78f8fcf31bfa411fa381dc26b6f4d38dfb1d2666 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 16:57:36 +0800 Subject: [PATCH 096/226] fix: remove compact tool panel spacing special case. Consistently add a blank line between all messages instead of skipping it for compact tool panels (question, todowrite, task). This simplifies the rendering logic and fixes inconsistent spacing. --- src/ui/components/chat.rs | 47 +++++++++------------------------------ 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index e34d4f9..17eb5f4 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -475,7 +475,7 @@ impl Chat { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // Bump this whenever rendering logic changes (tables, markdown, etc.) - const RENDER_VERSION: u64 = 4; + const RENDER_VERSION: u64 = 5; RENDER_VERSION.hash(&mut h); colors.hash(&mut h); self.messages.len().hash(&mut h); @@ -1456,16 +1456,7 @@ impl Chat { lines.push(Line::from(metadata)); lines.push(Line::from("")); } else { - // Keep spacing consistent between segments, but skip the - // blank line when the next message is a compact tool panel. - let next_is_compact_tool_panel = self - .messages - .get(idx + 1) - .map(|m| m.role == MessageRole::Tool && is_compact_tool_panel(&m.content)) - .unwrap_or(false); - if !next_is_compact_tool_panel { - lines.push(Line::from("")); - } + lines.push(Line::from("")); } } MessageRole::System => { @@ -1483,10 +1474,7 @@ impl Chat { colors, attached_to_assistant, )); - // Panel-style tools already own their vertical spacing. - if !is_compact_tool_panel(&message.content) { - lines.push(Line::from("")); - } + lines.push(Line::from("")); } } @@ -1949,7 +1937,6 @@ impl Chat { Span::raw(" "), Span::styled("view subagents", hint_style), ])); - out.push(Line::from("")); } else if name == "todowrite" && status == "ok" { if let Some(ref preview) = output_preview { let bg = colors.background_element; @@ -2307,22 +2294,6 @@ fn format_compaction_marker<'a>( wrap_styled_line(&line, WrapOptions::new(max_width.max(1))) } -fn is_compact_tool_panel(content: &str) -> bool { - serde_json::from_str::<JsonValue>(content) - .ok() - .and_then(|v| { - let name = v.get("name").and_then(|n| n.as_str())?; - let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("ok"); - Some(match name { - "question" => status != "error", - "todowrite" => status == "ok", - "task" => true, - _ => false, - }) - }) - .unwrap_or(false) -} - fn is_synthetic_tool_result_text(content: &str) -> bool { content.trim_start().starts_with("[tool result:") } @@ -2802,7 +2773,7 @@ mod tests { } #[test] - fn test_question_panel_keeps_padding_without_extra_gap() { + fn test_question_panel_uses_bottom_margin_and_inner_padding() { let chat = Chat::new(); let content = serde_json::json!({ "name": "question", @@ -2822,11 +2793,12 @@ mod tests { let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); - assert_eq!(rendered.len(), 5); + assert_eq!(rendered.len(), 6); assert!(rendered[0].trim().is_empty()); assert_eq!(rendered[1].trim(), "# Questions"); assert!(rendered[3].contains("Provide columns and rows")); assert!(rendered[4].trim().is_empty()); + assert!(rendered[5].trim().is_empty()); } #[test] @@ -2895,7 +2867,7 @@ mod tests { } #[test] - fn test_todowrite_panel_keeps_padding_without_extra_gap() { + fn test_todowrite_panel_uses_bottom_margin_and_inner_padding() { let chat = Chat::new(); let content = serde_json::json!({ "name": "todowrite", @@ -2909,15 +2881,16 @@ mod tests { let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); - assert_eq!(rendered.len(), 6); + assert_eq!(rendered.len(), 7); assert!(rendered[0].trim().is_empty()); assert_eq!(rendered[1].trim(), "# Todos"); assert!(rendered[4].contains("Implement rendering")); assert!(rendered[5].trim().is_empty()); + assert!(rendered[6].trim().is_empty()); } #[test] - fn test_short_tool_panel_renders_without_trailing_blank_row() { + fn test_short_tool_panel_renders_with_bottom_margin() { use ratatui::{backend::TestBackend, Terminal}; let mut colors = test_colors(); From f9a2679ef9e5fbc51f0fe2f73061763b7b0b0fa7 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 20:16:14 +0800 Subject: [PATCH 097/226] feat: (better) refactor subagent UI to footer with locked input in child sessions. Replace the top tab bar with a compact footer showing the active subagent, navigation hints, usage, and streaming status. Block text input, clipboard paste, and keyboard interaction in child (subagent) sessions. Extract agent type labels from title markers (e.g. `(@explore subagent)`) for display. --- src/app.rs | 194 ++++++++++++++++++++++++++++++------ src/views/chat.rs | 247 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 353 insertions(+), 88 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6fddff2..18d53c0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,9 @@ use crate::ui::components::input::Input; use crate::ui::components::popup::Popup; use crate::utils::git; -use crate::views::chat::{init_chat, render_chat, SubagentTab, SubagentTabs}; +use crate::views::chat::{ + agent_color_for_tab, init_chat, render_chat, SubagentTab, SubagentTabs, SUBAGENT_FOOTER_HEIGHT, +}; use crate::views::connect_dialog::{ get_pending_selection, handle_connect_dialog_key_event, handle_connect_dialog_mouse_event, init_connect_dialog, render_connect_dialog, @@ -556,22 +558,33 @@ impl App { let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { return; }; + let is_child_session = self.session_manager.parent_id_of(&session_id).is_some(); self.ensure_session_view_state(&session_id); if let Some(state) = self.session_view_states.get_mut(&session_id) { state.chat = self.chat_state.chat.clone(); - state.input_draft = self.input.get_text(); + state.input_draft = if is_child_session { + String::new() + } else { + self.input.get_text() + }; } } fn load_session_view_state(&mut self, session_id: &str) { self.ensure_session_view_state(session_id); + let is_child_session = self.session_manager.parent_id_of(session_id).is_some(); if let Some(state) = self.session_view_states.get_mut(session_id) { self.chat_state.chat = state.chat.clone(); self.chat_state.chat.scroll_to_bottom_on_next_render(); - self.input.set_text(&state.input_draft); + if is_child_session { + self.input.clear(); + state.input_draft.clear(); + } else { + self.input.set_text(&state.input_draft); + } state.unread_completed = false; } else { self.chat_state.chat.clear(); @@ -589,7 +602,11 @@ impl App { } self.pending_session_title = None; self.load_session_view_state(session_id); - self.base_focus = if self.chat_state.chat.messages.is_empty() && !self.is_streaming { + let is_child_session = self.session_manager.parent_id_of(session_id).is_some(); + self.base_focus = if !is_child_session + && self.chat_state.chat.messages.is_empty() + && !self.is_streaming + { BaseFocus::Home } else { BaseFocus::Chat @@ -597,8 +614,14 @@ impl App { true } + fn is_subagent_session_active(&self) -> bool { + self.session_manager + .get_current_session_id() + .is_some_and(|id| self.session_manager.parent_id_of(id).is_some()) + } + fn should_handle_child_session_arrow(&self) -> bool { - if self.base_focus != BaseFocus::Chat || !self.input.get_text().is_empty() { + if self.base_focus != BaseFocus::Chat { return false; } @@ -614,7 +637,11 @@ impl App { let Some(root_id) = self.session_manager.root_session_id_for(¤t_id) else { return false; }; - let Some(first_child) = self.session_manager.child_sessions(&root_id).first().cloned() + let Some(first_child) = self + .session_manager + .child_sessions(&root_id) + .first() + .cloned() else { return false; }; @@ -626,7 +653,10 @@ impl App { let Some(current_id) = self.session_manager.get_current_session_id().cloned() else { return false; }; - let Some(parent_id) = self.session_manager.parent_id_of(¤t_id).map(str::to_string) + let Some(parent_id) = self + .session_manager + .parent_id_of(¤t_id) + .map(str::to_string) else { return false; }; @@ -674,24 +704,22 @@ impl App { .session_view_states .get(&root_id) .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()), + color: crate::theme::agent_color(&self.agent, &self.get_current_theme_colors()), }); - for child in children { - let label = child - .title - .split_whitespace() - .take(4) - .collect::<Vec<_>>() - .join(" "); + let colors = self.get_current_theme_colors(); + for (idx, child) in children.into_iter().enumerate() { + let label = subagent_tab_label(&child.title, &child.id); let running = child.status.is_active() || self .session_view_states .get(&child.id) .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); tabs.push(SubagentTab { - label: if label.is_empty() { child.id.clone() } else { label }, + label, active: current_id == child.id, running, + color: agent_color_for_tab(idx, &colors), }); } @@ -993,6 +1021,14 @@ impl App { pub fn handle_keys(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('v') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + if self.is_subagent_session_active() + && matches!( + self.overlay_focus, + OverlayFocus::None | OverlayFocus::SuggestionsPopup + ) + { + return; + } self.handle_clipboard_image_paste(); return; } @@ -1028,6 +1064,10 @@ impl App { let popup_handled = self.handle_suggestions_popup_keys(key); if popup_handled { true + } else if self.is_subagent_session_active() { + clear_suggestions(&mut self.suggestions_popup_state); + self.overlay_focus = OverlayFocus::None; + true } else { let input_handled = self.input.handle_event(key); self.update_suggestions(); @@ -1657,6 +1697,12 @@ impl App { // (unless it's Ctrl+C or Escape which are handled earlier) self.chat_state.chat.selection.clear(); + if self.is_subagent_session_active() { + clear_suggestions(&mut self.suggestions_popup_state); + self.overlay_focus = OverlayFocus::None; + return; + } + match key.code { KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { let input_text = self.input.get_text(); @@ -1740,6 +1786,10 @@ impl App { } fn handle_input_mouse_event(&mut self, mouse: MouseEvent) -> bool { + if self.is_subagent_session_active() { + return false; + } + if !self.input.handle_mouse_event(mouse) { return false; } @@ -2031,16 +2081,26 @@ impl App { ) .split(size); let input_height = self.input.get_height() as u16; + let input_height = if self.is_subagent_session_active() { + SUBAGENT_FOOTER_HEIGHT + } else { + input_height + }; + let help_height = if self.is_subagent_session_active() { + 0 + } else { + 1 + }; let above_status_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints( [ - ratatui::layout::Constraint::Length(1), // Top padding + ratatui::layout::Constraint::Length(0), // Reserved subagent header removed ratatui::layout::Constraint::Min(0), // Chat content ratatui::layout::Constraint::Length(0), // Bottom padding ratatui::layout::Constraint::Length(input_height), - ratatui::layout::Constraint::Length(1), // Help bar - ratatui::layout::Constraint::Length(1), // Blank + ratatui::layout::Constraint::Length(help_height), // Help bar + ratatui::layout::Constraint::Length(1), // Blank ] .as_ref(), ) @@ -2069,16 +2129,26 @@ impl App { ) .split(size); let input_height = self.input.get_height() as u16; + let input_height = if self.is_subagent_session_active() { + SUBAGENT_FOOTER_HEIGHT + } else { + input_height + }; + let help_height = if self.is_subagent_session_active() { + 0 + } else { + 1 + }; let above_status_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints( [ - ratatui::layout::Constraint::Length(1), // Top padding + ratatui::layout::Constraint::Length(0), // Reserved subagent header removed ratatui::layout::Constraint::Min(0), // Chat content ratatui::layout::Constraint::Length(0), // Bottom padding ratatui::layout::Constraint::Length(input_height), - ratatui::layout::Constraint::Length(1), // Help bar - ratatui::layout::Constraint::Length(1), // Blank + ratatui::layout::Constraint::Length(help_height), // Help bar + ratatui::layout::Constraint::Length(1), // Blank ] .as_ref(), ) @@ -2106,6 +2176,10 @@ impl App { } fn handle_clipboard_image_paste(&mut self) { + if self.is_subagent_session_active() { + return; + } + if !matches!( (self.base_focus, self.overlay_focus), (BaseFocus::Home, OverlayFocus::None) @@ -2191,6 +2265,9 @@ impl App { match (self.base_focus, self.overlay_focus) { (BaseFocus::Home, OverlayFocus::None) | (BaseFocus::Chat, OverlayFocus::None) => { + if self.is_subagent_session_active() { + return; + } if self.try_attach_pasted_image_paths(&text) { return; } @@ -2291,6 +2368,11 @@ impl App { self.api_key_input.text_area.insert_str(&text); } (_, OverlayFocus::SuggestionsPopup) => { + if self.is_subagent_session_active() { + clear_suggestions(&mut self.suggestions_popup_state); + self.overlay_focus = OverlayFocus::None; + return; + } if self.try_attach_pasted_image_paths(&text) { return; } @@ -3968,10 +4050,7 @@ impl App { prompt, ); } - crate::llm::ChunkMessage::SubagentChunk { - session_id, - chunk, - } => { + crate::llm::ChunkMessage::SubagentChunk { session_id, chunk } => { self.process_streaming_chunk_for_session(&session_id, *chunk); } crate::llm::ChunkMessage::PermissionRequest(prompt) => { @@ -4021,11 +4100,7 @@ impl App { description: String, prompt: String, ) { - if self - .session_manager - .get_session_ref(&session_id) - .is_none() - { + if self.session_manager.get_session_ref(&session_id).is_none() { self.session_manager.create_child_session( parent_session_id, session_id.clone(), @@ -4684,6 +4759,34 @@ fn append_usage_suffix(mut text: String, suffix: String) -> String { } } +fn subagent_tab_label(title: &str, fallback: &str) -> String { + if let Some(start) = title.find("(@") { + let after_marker = &title[start + 2..]; + if let Some(agent) = after_marker.strip_suffix(" subagent)") { + return titlecase_ascii(agent); + } + } + + let label = title + .split_whitespace() + .take(4) + .collect::<Vec<_>>() + .join(" "); + if label.is_empty() { + fallback.to_string() + } else { + label + } +} + +fn titlecase_ascii(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), + None => String::new(), + } +} + impl Default for App { fn default() -> Self { Self::new().expect("Failed to initialize App") @@ -5050,4 +5153,35 @@ mod tests { Some(&parent_id) ); } + + #[test] + fn subagent_session_ignores_text_input() { + let mut app = test_app(); + let parent_id = app.create_new_session(Some("Parent".to_string())); + app.base_focus = BaseFocus::Chat; + + app.start_subagent_session( + parent_id, + "child-a".to_string(), + "General task (@general subagent)".to_string(), + "general".to_string(), + "General task".to_string(), + "Check implementation".to_string(), + ); + + assert!(app.switch_to_first_child_session()); + app.handle_keys(KeyEvent::new(KeyCode::Char('h'), event::KeyModifiers::NONE)); + app.handle_paste(" pasted".to_string()); + + assert_eq!(app.input.get_text(), ""); + } + + #[test] + fn subagent_tab_label_prefers_agent_type_marker() { + assert_eq!( + subagent_tab_label("Find files (@explore subagent)", "fallback"), + "Explore" + ); + assert_eq!(subagent_tab_label("", "fallback"), "fallback"); + } } diff --git a/src/views/chat.rs b/src/views/chat.rs index c018e78..0c0c30c 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -1,8 +1,9 @@ use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, + symbols::border, text::{Line, Span}, - widgets::{Block, Paragraph}, + widgets::{Block, Borders, Paragraph}, Frame, }; @@ -12,6 +13,8 @@ use crate::ui::components::input::Input; use crate::ui::components::status_bar::StatusBar; use crate::ui::components::wave_spinner::WaveSpinner; +pub const SUBAGENT_FOOTER_HEIGHT: u16 = 3; + #[derive(Debug)] pub struct ChatState { pub chat: Chat, @@ -23,6 +26,7 @@ pub struct SubagentTab { pub label: String, pub active: bool, pub running: bool, + pub color: ratatui::style::Color, } #[derive(Debug, Clone)] @@ -46,14 +50,16 @@ pub fn init_chat(chat: Chat, agent: &str, colors: &ThemeColors) -> ChatState { } pub fn agent_color_for_tab(agent_index: usize, colors: &ThemeColors) -> ratatui::style::Color { - // Matches OpenCode's rotation: primary/secondary/accent/success/warning/error - match agent_index % 6 { - 0 => colors.primary, - 1 => colors.secondary, - 2 => colors.accent, - 3 => colors.success, - 4 => colors.warning, - _ => colors.error, + // Matches OpenCode's visible agent rotation: + // secondary/accent/success/warning/primary/error/info. + match agent_index % 7 { + 0 => colors.secondary, + 1 => colors.accent, + 2 => colors.success, + 3 => colors.warning, + 4 => colors.primary, + 5 => colors.error, + _ => colors.info, } } @@ -74,43 +80,72 @@ pub fn render_chat( subagent_tabs: Option<SubagentTabs>, ) { let size = f.area(); + let is_subagent_view = subagent_tabs + .as_ref() + .is_some_and(|tabs| tabs.is_child_session); let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) .split(size); - let input_height = input.get_height(); + let input_height = if is_subagent_view { + SUBAGENT_FOOTER_HEIGHT + } else { + input.get_height() + }; + let help_height = if is_subagent_view { 0 } else { 1 }; let above_status_chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ - Constraint::Length(1), // Top padding + Constraint::Length(0), // Reserved subagent header removed Constraint::Min(0), // Chat content Constraint::Length(0), // Bottom padding Constraint::Length(input_height), - Constraint::Length(1), + Constraint::Length(help_height), Constraint::Length(1), ] .as_ref(), ) .split(main_chunks[0]); - if let Some(tabs) = subagent_tabs.as_ref() { - render_subagent_tabs(f, above_status_chunks[0], tabs, colors); - } - chat_state .chat .render(f, above_status_chunks[1], &agent, &model, colors); - input.render( - f, - above_status_chunks[3], - &agent, - &model, - &provider_name, - colors, - ); + + if is_subagent_view { + if let Some(tabs) = subagent_tabs.as_ref() { + render_subagent_footer( + f, + above_status_chunks[3], + tabs, + usage_text, + colors, + is_streaming, + is_compacting, + &mut chat_state.wave_spinner, + ); + } + } else { + input.render( + f, + above_status_chunks[3], + &agent, + &model, + &provider_name, + colors, + ); + } + + if is_subagent_view { + let blank = Block::default(); + f.render_widget(blank, above_status_chunks[5]); + + let status_bar = StatusBar::new(version, cwd, branch, agent, model); + status_bar.render(f, main_chunks[1], colors); + return; + } let help_text = vec![ Span::styled("/", Style::default().fg(colors.info)), @@ -132,12 +167,6 @@ pub fn render_chat( } else { 0 }; - let middle_width = if usage_width > 0 { - available_width.saturating_sub(help_width + usage_width) - } else { - available_width.saturating_sub(help_width) - }; - let status_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -214,46 +243,148 @@ pub fn render_chat( status_bar.render(f, main_chunks[1], colors); } -fn render_subagent_tabs( +fn render_subagent_footer( f: &mut Frame, area: ratatui::layout::Rect, tabs: &SubagentTabs, + usage_text: &str, colors: &ThemeColors, + is_streaming: bool, + is_compacting: bool, + wave_spinner: &mut WaveSpinner, ) { - if tabs.tabs.is_empty() || area.width == 0 { + if tabs.tabs.is_empty() || area.width == 0 || area.height == 0 { return; } - let mut spans = Vec::new(); - let hint = if tabs.is_child_session { - "up parent left/right siblings" - } else { - "ctrl+x down subagents" + let child_tabs = tabs.tabs.iter().skip(1).collect::<Vec<_>>(); + let total = child_tabs.len().max(1); + let active_index = child_tabs.iter().position(|tab| tab.active).unwrap_or(0); + let active_tab = child_tabs + .get(active_index) + .copied() + .or_else(|| child_tabs.first().copied()); + let label = active_tab + .map(|tab| tab.label.as_str()) + .unwrap_or("Subagent"); + let running = active_tab.is_some_and(|tab| tab.running); + let active_color = active_tab.map(|tab| tab.color).unwrap_or(colors.primary); + + let border_set = border::Set { + vertical_left: "┃", + ..border::PLAIN }; + let border = Block::new() + .borders(Borders::LEFT) + .border_set(border_set) + .border_style(Style::default().fg(active_color)); + let inner_area = border.inner(area); + + let bg = Block::default().style(Style::default().bg(colors.background_element)); + f.render_widget(bg, area); + f.render_widget(border, area); + + let content_area = centered_subagent_footer_content(inner_area); + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let mut left_spans = vec![ + Span::styled( + label.to_string(), + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" ({} of {})", active_index + 1, total)), + ]; - spans.push(Span::styled( - hint, - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - )); - spans.push(Span::raw(" ")); + if running { + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled("~", Style::default().fg(active_color))); + } - for tab in &tabs.tabs { - let style = if tab.active { + if !usage_text.is_empty() { + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + usage_text.to_string(), Style::default() - .fg(colors.background) - .bg(colors.primary) - .add_modifier(Modifier::BOLD) - } else if tab.running { - Style::default().fg(colors.info) + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + + if is_streaming { + wave_spinner.set_color(active_color); + left_spans.push(Span::raw(" ")); + if is_compacting { + left_spans.push(Span::styled( + "compacting context", + Style::default().fg(colors.info), + )); } else { - Style::default().fg(colors.text_weak) - }; - let suffix = if tab.running { " ~" } else { "" }; - spans.push(Span::styled(format!(" {}{} ", tab.label, suffix), style)); - spans.push(Span::raw(" ")); + left_spans.extend(wave_spinner.spans()); + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + "esc to stop", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + } + + let nav_line = Line::from(vec![ + Span::styled( + "Parent ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled("up", Style::default().fg(colors.text)), + Span::raw(" "), + Span::styled( + "Prev ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled("left", Style::default().fg(colors.text)), + Span::raw(" "), + Span::styled( + "Next ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled("right", Style::default().fg(colors.text)), + ]); + + let nav_width = nav_line.width() as u16; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), + Constraint::Length(nav_width.min(content_area.width)), + ]) + .split(content_area); + + f.render_widget(Paragraph::new(Line::from(left_spans)), chunks[0]); + f.render_widget( + Paragraph::new(nav_line).alignment(Alignment::Right), + chunks[1], + ); +} + +fn centered_subagent_footer_content(area: Rect) -> Rect { + if area.width <= 3 || area.height == 0 { + return Rect::new(area.x, area.y, area.width, area.height.min(1)); } - f.render_widget(Paragraph::new(Line::from(spans)), area); + Rect { + x: area.x + 2, + y: area.y + area.height / 2, + width: area.width.saturating_sub(3), + height: 1, + } } From c7063c42d39fbcc37fe7b133509666b7e373352c Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 20:35:43 +0800 Subject: [PATCH 098/226] refactor(perf): performance optimizations in chat loading. It's pretty fast on `cargo install --path .` though which is good. --- src/app.rs | 85 ++++++++++++------- src/main.rs | 10 ++- src/session/manager.rs | 133 +++++++++++++++++++++--------- src/ui/components/chat.rs | 169 +++++++++++++++++++++++++------------- src/ui/selection.rs | 15 +++- 5 files changed, 285 insertions(+), 127 deletions(-) diff --git a/src/app.rs b/src/app.rs index 18d53c0..f8470f0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -236,9 +236,11 @@ pub struct App { last_frame_size: ratatui::layout::Rect, last_animation_update: std::time::Instant, last_session_spinner_update: std::time::Instant, + cached_git_branch: Option<String>, + last_git_branch_check: std::time::Instant, discovery: Option<crate::model::discovery::Discovery>, cached_usage_text: String, - cached_usage_check: (usize, usize), + cached_usage_check: (usize, u64), } impl App { @@ -383,6 +385,8 @@ impl App { .with_agent_policies(agent_policies); let discovery = crate::model::discovery::Discovery::new().ok(); + let cached_git_branch = git::get_current_branch(); + let now = std::time::Instant::now(); Ok(Self { running: true, @@ -437,8 +441,10 @@ impl App { session_view_states: std::collections::HashMap::new(), session_spinner_frame: 0, last_frame_size: ratatui::layout::Rect::default(), - last_animation_update: std::time::Instant::now(), - last_session_spinner_update: std::time::Instant::now(), + last_animation_update: now, + last_session_spinner_update: now, + cached_git_branch, + last_git_branch_check: now, discovery, cached_usage_text: String::new(), cached_usage_check: (0, 0), @@ -563,7 +569,7 @@ impl App { self.ensure_session_view_state(&session_id); if let Some(state) = self.session_view_states.get_mut(&session_id) { - state.chat = self.chat_state.chat.clone(); + state.chat = std::mem::take(&mut self.chat_state.chat); state.input_draft = if is_child_session { String::new() } else { @@ -577,7 +583,7 @@ impl App { let is_child_session = self.session_manager.parent_id_of(session_id).is_some(); if let Some(state) = self.session_view_states.get_mut(session_id) { - self.chat_state.chat = state.chat.clone(); + self.chat_state.chat = std::mem::take(&mut state.chat); self.chat_state.chat.scroll_to_bottom_on_next_render(); if is_child_session { self.input.clear(); @@ -592,14 +598,15 @@ impl App { } self.sync_active_streaming_flag(); - self.cached_usage_check = (usize::MAX, usize::MAX); + self.cached_usage_check = (usize::MAX, u64::MAX); } fn switch_to_session(&mut self, session_id: &str) -> bool { - self.save_active_session_view_state(); - if !self.session_manager.switch_session(session_id) { + if self.session_manager.get_session_ref(session_id).is_none() { return false; } + self.save_active_session_view_state(); + self.session_manager.switch_session(session_id); self.pending_session_title = None; self.load_session_view_state(session_id); let is_child_session = self.session_manager.parent_id_of(session_id).is_some(); @@ -744,7 +751,7 @@ impl App { self.input.clear(); self.base_focus = BaseFocus::Home; self.sync_active_streaming_flag(); - self.cached_usage_check = (usize::MAX, usize::MAX); + self.cached_usage_check = (usize::MAX, u64::MAX); self.refresh_sessions_dialog(); } @@ -760,7 +767,7 @@ impl App { self.input.clear(); self.base_focus = BaseFocus::Home; self.sync_active_streaming_flag(); - self.cached_usage_check = (usize::MAX, usize::MAX); + self.cached_usage_check = (usize::MAX, u64::MAX); self.refresh_sessions_dialog(); session_id } @@ -941,6 +948,17 @@ impl App { theme.get_colors(self.dark_mode) } + fn current_git_branch(&mut self) -> Option<String> { + const GIT_BRANCH_REFRESH: std::time::Duration = std::time::Duration::from_secs(2); + + if self.last_git_branch_check.elapsed() >= GIT_BRANCH_REFRESH { + self.cached_git_branch = git::get_current_branch(); + self.last_git_branch_check = std::time::Instant::now(); + } + + self.cached_git_branch.clone() + } + pub fn cycle_theme(&mut self) { if !self.themes.is_empty() { self.current_theme_index = (self.current_theme_index + 1) % self.themes.len(); @@ -1098,6 +1116,7 @@ impl App { let provider_id_clone = provider_id.clone(); self.model = model_id_clone.clone(); self.provider_name = provider_id_clone.clone(); + self.cached_usage_check = (usize::MAX, u64::MAX); if let Some(ref dao) = self.prefs_dao { if let Err(e) = @@ -1325,7 +1344,7 @@ impl App { self.input.clear(); self.base_focus = BaseFocus::Home; self.sync_active_streaming_flag(); - self.cached_usage_check = (usize::MAX, usize::MAX); + self.cached_usage_check = (usize::MAX, u64::MAX); } self.refresh_sessions_dialog(); let _ = self @@ -1360,7 +1379,7 @@ impl App { self.input.clear(); self.base_focus = BaseFocus::Home; self.sync_active_streaming_flag(); - self.cached_usage_check = (usize::MAX, usize::MAX); + self.cached_usage_check = (usize::MAX, u64::MAX); } true } @@ -1847,6 +1866,7 @@ impl App { let provider_id_clone = provider_id.clone(); self.model = model_id_clone.clone(); self.provider_name = provider_id_clone; + self.cached_usage_check = (usize::MAX, u64::MAX); if let Some(ref dao) = self.prefs_dao { if let Err(e) = @@ -2542,7 +2562,7 @@ impl App { before_tokens, }); self.is_streaming = true; - self.cached_usage_check = (usize::MAX, usize::MAX); + self.cached_usage_check = (usize::MAX, u64::MAX); let _ = self.session_manager.set_session_status( &session_id, crate::session::types::SessionStatus::Waiting, @@ -3143,7 +3163,7 @@ impl App { } self.chat_state.chat.clear(); - self.chat_state.chat.messages = messages_to_fork; + self.chat_state.chat.replace_messages(messages_to_fork); self.chat_state.chat.scroll_offset = usize::MAX; self.chat_state.chat.clear_highlighted_message(); self.base_focus = BaseFocus::Chat; @@ -3177,10 +3197,7 @@ impl App { } }; - self.chat_state.chat.clear(); - for msg in &remaining { - self.chat_state.chat.add_message(msg.clone()); - } + self.chat_state.chat.replace_messages(remaining); self.chat_state.chat.scroll_offset = usize::MAX; self.chat_state.chat.clear_highlighted_message(); @@ -3817,7 +3834,7 @@ impl App { if disconnected || !events.is_empty() { self.compaction_receiver = None; self.compaction_pending = None; - self.cached_usage_check = (usize::MAX, usize::MAX); + self.cached_usage_check = (usize::MAX, u64::MAX); } for event in events { @@ -3841,7 +3858,11 @@ impl App { self.ensure_session_view_state(&session_id); if let Some(state) = self.session_view_states.get_mut(&session_id) { - state.chat = Chat::with_messages(messages); + state.chat = if is_active { + Chat::new() + } else { + Chat::with_messages(messages) + }; state.tool_calls = ToolCallViewState::default(); state.unread_completed = !is_active; } @@ -3851,7 +3872,7 @@ impl App { crate::session::types::SessionStatus::Idle, None, ); - self.cached_usage_check = (usize::MAX, usize::MAX); + self.cached_usage_check = (usize::MAX, u64::MAX); self.refresh_sessions_dialog(); push_toast(Toast::new( format!( @@ -4128,6 +4149,7 @@ impl App { last_msg.is_complete = false; last_msg.agent_mode = Some(subagent_type); } + state.chat.mark_render_dirty(); state.chat.begin_streaming_turn(); state.external_stream = Some(ExternalStreamState { streaming_model: Some(self.model.clone()), @@ -4180,6 +4202,7 @@ impl App { _ => {} } } + chat.mark_render_dirty(); Self::completion_notification_stats_for_chat(chat) } else { @@ -4218,7 +4241,7 @@ impl App { if let Some(chat) = self.chat_for_session_mut(session_id) { chat.mark_streaming_end(); chat.finalize_streaming_metrics(); - chat.messages.truncate(start); + chat.truncate_messages(start); } let _ = self.session_manager.set_session_status( @@ -4245,7 +4268,7 @@ impl App { if let Some(chat) = self.chat_for_session_mut(session_id) { chat.mark_streaming_end(); chat.finalize_streaming_metrics(); - chat.messages.truncate(start); + chat.truncate_messages(start); } let _ = self.session_manager.set_session_status( @@ -4274,6 +4297,7 @@ impl App { if let Some(msg) = chat.messages.get_mut(idx) { if !msg.is_complete { msg.mark_complete(); + chat.mark_render_dirty(); } } } @@ -4367,6 +4391,7 @@ impl App { } msg.content = v.to_string(); + chat.mark_render_dirty(); return; } } @@ -4418,6 +4443,7 @@ impl App { if let Some(last_msg) = self.chat_state.chat.messages.last_mut() { last_msg.is_complete = false; } + self.chat_state.chat.mark_render_dirty(); // Initialize per-turn streaming timing primitives (T0). self.chat_state.chat.begin_streaming_turn(); @@ -4586,14 +4612,15 @@ impl App { self.last_frame_size = size; let colors = self.get_current_theme_colors(); - let fingerprint: (usize, usize) = ( + let fingerprint = ( self.chat_state.chat.messages.len(), - crate::session::compaction::total_context_tokens(&self.chat_state.chat.messages), + self.chat_state.chat.render_revision(), ); if self.cached_usage_check != fingerprint { self.cached_usage_check = fingerprint; self.cached_usage_text = self.session_usage_text(); } + let branch = self.current_git_branch(); let usage_text = &self.cached_usage_text; match self.base_focus { @@ -4604,7 +4631,7 @@ impl App { &self.home_state, self.version.clone(), self.cwd.clone(), - git::get_current_branch(), + branch.clone(), self.agent.clone(), self.model.clone(), self.provider_name.clone(), @@ -4634,7 +4661,7 @@ impl App { &mut self.input, self.version.clone(), self.cwd.clone(), - git::get_current_branch(), + branch, self.agent.clone(), self.model.clone(), self.provider_name.clone(), @@ -4860,6 +4887,8 @@ mod tests { last_frame_size: ratatui::layout::Rect::default(), last_animation_update: std::time::Instant::now(), last_session_spinner_update: std::time::Instant::now(), + cached_git_branch: None, + last_git_branch_check: std::time::Instant::now(), discovery: None, cached_usage_text: String::new(), cached_usage_check: (0, 0), @@ -4948,7 +4977,7 @@ mod tests { let mut summary = crate::session::types::Message::user("summary"); summary.token_count = Some(stats.after_tokens); summary.compaction_stats = Some(stats); - app.chat_state.chat.messages.push(summary); + app.chat_state.chat.add_message(summary); assert_eq!(app.session_usage_text(), "360 \u{00b7} last compact 97%"); } diff --git a/src/main.rs b/src/main.rs index 7bf202c..11575fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -342,6 +342,8 @@ async fn run_event_loop( const FAST_POLL: Duration = Duration::from_millis(16); // ~60fps for animations const SLOW_POLL: Duration = Duration::from_millis(250); // ~4fps idle + let mut needs_redraw = true; + while app.running { let loop_start = std::time::Instant::now(); @@ -350,7 +352,10 @@ async fn run_event_loop( app.process_streaming_chunks(); app.update_animations(); remove_expired_toasts(); - terminal.draw(|f| app.render(f))?; + if needs_redraw || animation_needed { + terminal.draw(|f| app.render(f))?; + needs_redraw = false; + } let poll_duration = if animation_needed { FAST_POLL @@ -429,12 +434,15 @@ async fn run_event_loop( } else { app.handle_mouse_event(mouse); } + needs_redraw = true; } event::Event::Key(key) => { app.handle_keys(key); + needs_redraw = true; } event::Event::Paste(text) => { app.handle_paste(text); + needs_redraw = true; } _ => {} } diff --git a/src/session/manager.rs b/src/session/manager.rs index 4acf8b9..180d33c 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -33,6 +33,7 @@ pub struct SessionInfo { pub struct SessionManager { pub sessions: HashMap<String, Session>, + children_by_parent: HashMap<String, Vec<String>>, current_session_id: Option<String>, session_counter: usize, history_dao: Option<HistoryDAO>, @@ -53,6 +54,7 @@ impl SessionManager { Self { sessions: HashMap::new(), + children_by_parent: HashMap::new(), current_session_id: None, session_counter: 0, history_dao: None, @@ -115,16 +117,86 @@ impl SessionManager { .map(|ts| std::time::UNIX_EPOCH + std::time::Duration::from_secs(ts as u64)); let session_id = session.id.clone(); + let parent_id = session.parent_id.clone(); self.sessions.insert(session_id.clone(), session); + if let Some(parent_id) = parent_id { + self.index_child_session(&parent_id, &session_id); + } self.id_mapping.insert(session_id.clone(), db_session.id); self.db_id_to_id.insert(db_session.id, session_id); self.session_counter += 1; } + self.sort_child_session_indexes(); + Ok(()) } + fn index_child_session(&mut self, parent_id: &str, child_id: &str) { + let children = self + .children_by_parent + .entry(parent_id.to_string()) + .or_default(); + if !children.iter().any(|id| id == child_id) { + children.push(child_id.to_string()); + } + } + + fn unindex_child_session(&mut self, parent_id: &str, child_id: &str) { + let should_remove = if let Some(children) = self.children_by_parent.get_mut(parent_id) { + children.retain(|id| id != child_id); + children.is_empty() + } else { + false + }; + + if should_remove { + self.children_by_parent.remove(parent_id); + } + } + + fn sort_child_session_indexes(&mut self) { + let sessions = &self.sessions; + for children in self.children_by_parent.values_mut() { + children.sort_by(|a, b| { + let a_session = sessions.get(a); + let b_session = sessions.get(b); + match (a_session, b_session) { + (Some(a_session), Some(b_session)) => a_session + .created_at + .cmp(&b_session.created_at) + .then_with(|| a.cmp(b)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.cmp(b), + } + }); + } + } + + fn insert_child_session_index_sorted(&mut self, parent_id: &str, child_id: &str) { + self.index_child_session(parent_id, child_id); + self.sort_child_session_indexes(); + } + + fn session_info_from_session(id: &str, session: &Session) -> SessionInfo { + SessionInfo { + id: id.to_string(), + parent_id: session.parent_id.clone(), + title: session.title.clone(), + created_at: session.created_at, + updated_at: session.updated_at, + message_count: session.messages.len(), + workspace_id: session.workspace_id, + workspace_path: session.workspace_path.clone(), + workspace_name: session.workspace_name.clone(), + status: session.status, + pinned_at: session.pinned_at, + archived_at: session.archived_at, + } + } + pub fn create_session(&mut self, name: Option<String>) -> String { self.create_session_record(name, None, None, true) } @@ -160,6 +232,9 @@ impl SessionManager { session.workspace_name = self.current_workspace_name.clone(); self.sessions.insert(session_id.clone(), session); + if let Some(ref parent_id) = parent_id { + self.insert_child_session_index_sorted(parent_id, &session_id); + } if make_current { self.current_session_id = Some(session_id.clone()); } @@ -178,20 +253,7 @@ impl SessionManager { pub fn list_sessions(&self) -> Vec<SessionInfo> { self.sessions .iter() - .map(|(id, session)| SessionInfo { - id: id.clone(), - parent_id: session.parent_id.clone(), - title: session.title.clone(), - created_at: session.created_at, - updated_at: session.updated_at, - message_count: session.messages.len(), - workspace_id: session.workspace_id, - workspace_path: session.workspace_path.clone(), - workspace_name: session.workspace_name.clone(), - status: session.status, - pinned_at: session.pinned_at, - archived_at: session.archived_at, - }) + .map(|(id, session)| Self::session_info_from_session(id, session)) .collect() } @@ -221,32 +283,16 @@ impl SessionManager { } pub fn child_sessions(&self, parent_id: &str) -> Vec<SessionInfo> { - let mut children: Vec<SessionInfo> = self - .sessions - .iter() - .filter(|(_, session)| session.parent_id.as_deref() == Some(parent_id)) - .map(|(id, session)| SessionInfo { - id: id.clone(), - parent_id: session.parent_id.clone(), - title: session.title.clone(), - created_at: session.created_at, - updated_at: session.updated_at, - message_count: session.messages.len(), - workspace_id: session.workspace_id, - workspace_path: session.workspace_path.clone(), - workspace_name: session.workspace_name.clone(), - status: session.status, - pinned_at: session.pinned_at, - archived_at: session.archived_at, + self.children_by_parent + .get(parent_id) + .into_iter() + .flat_map(|children| children.iter()) + .filter_map(|id| { + self.sessions + .get(id) + .map(|session| Self::session_info_from_session(id, session)) }) - .collect(); - - children.sort_by(|a, b| { - a.created_at - .cmp(&b.created_at) - .then_with(|| a.id.cmp(&b.id)) - }); - children + .collect() } pub fn switch_session(&mut self, id: &str) -> bool { @@ -445,7 +491,16 @@ impl SessionManager { } } + let parent_id = self + .sessions + .get(id) + .and_then(|session| session.parent_id.clone()); + if self.sessions.remove(id).is_some() { + if let Some(parent_id) = parent_id { + self.unindex_child_session(&parent_id, id); + } + self.children_by_parent.remove(id); if let Some(db_id) = self.id_mapping.remove(id) { self.db_id_to_id.remove(&db_id); } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 17eb5f4..674fcdb 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -59,9 +59,14 @@ pub struct Chat { pending_click_anchor: Option<(usize, usize)>, /// Index of the message highlighted by timeline navigation (None = no highlight) pub highlighted_message_index: Option<usize>, - /// Render cache — fingerprints content to skip expensive re-formatting + /// Monotonic marker for render-affecting message changes. + render_revision: u64, + /// Render cache keyed by revision, width, and theme to skip expensive re-formatting. cached_lines: Vec<Line<'static>>, cached_positions: Vec<usize>, + cached_revision: u64, + cached_width: usize, + cached_colors_hash: u64, cached_fingerprint: u64, } @@ -294,8 +299,12 @@ impl Chat { selection: Selection::new(), pending_click_anchor: None, highlighted_message_index: None, + render_revision: 1, cached_lines: Vec::new(), cached_positions: Vec::new(), + cached_revision: 0, + cached_width: 0, + cached_colors_hash: 0, cached_fingerprint: 0, } } @@ -329,8 +338,12 @@ impl Chat { selection: Selection::new(), pending_click_anchor: None, highlighted_message_index: None, + render_revision: 1, cached_lines: Vec::new(), cached_positions: Vec::new(), + cached_revision: 0, + cached_width: 0, + cached_colors_hash: 0, cached_fingerprint: 0, } } @@ -346,6 +359,24 @@ impl Chat { } } + pub fn replace_messages(&mut self, messages: Vec<Message>) { + self.messages = messages; + self.invalidate_cache(); + } + + pub fn truncate_messages(&mut self, len: usize) { + self.messages.truncate(len); + self.invalidate_cache(); + } + + pub fn mark_render_dirty(&mut self) { + self.invalidate_cache(); + } + + pub fn render_revision(&self) -> u64 { + self.render_revision + } + fn should_autoscroll(&self) -> bool { self.autoscroll_enabled && !self.user_scrolled_up } @@ -391,6 +422,8 @@ impl Chat { self.add_message(Message::incomplete(chunk_str)); } + self.invalidate_cache(); + let now = std::time::Instant::now(); if self.streaming_start_time.is_none() { // Fallback: streaming should normally be initialized by begin_streaming_turn(). @@ -464,13 +497,26 @@ impl Chat { self.selection.reset(); self.pending_click_anchor = None; self.cached_lines.clear(); + self.cached_positions.clear(); + self.cached_revision = 0; + self.cached_width = 0; + self.cached_colors_hash = 0; self.cached_fingerprint = 0; + self.invalidate_cache(); } fn invalidate_cache(&mut self) { + self.render_revision = self.render_revision.wrapping_add(1).max(1); self.cached_fingerprint = 0; } + fn cache_colors_hash(colors: &ThemeColors) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + colors.hash(&mut h); + h.finish() + } + fn compute_fingerprint(&self, max_width: usize, colors: &ThemeColors) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -625,6 +671,7 @@ impl Chat { self.streaming_renderer = None; self.streaming_message_idx = None; self.streaming_token_counter = None; + self.invalidate_cache(); } pub fn prepare_streaming_token_counter(&mut self, model: &str) { @@ -1032,40 +1079,32 @@ impl Chat { let max_width = content_area.width as usize; - let fingerprint = self.compute_fingerprint(max_width, colors); - let cache_valid = !self.cached_lines.is_empty() && fingerprint == self.cached_fingerprint; + let colors_hash = Self::cache_colors_hash(colors); + let cache_valid = self.cached_revision == self.render_revision + && self.cached_width == max_width + && self.cached_colors_hash == colors_hash; - let positions: Vec<usize>; - let mut all_lines: Vec<Line<'static>>; - - if cache_valid { - positions = self.cached_positions.clone(); - all_lines = self.cached_lines.clone(); - } else { + if !cache_valid { let (message_lines, message_positions) = self.build_all_lines_with_positions(max_width, model, colors); - positions = message_positions; - all_lines = message_lines.into_iter().map(line_to_static).collect(); - - self.cached_lines = all_lines.clone(); - self.cached_positions = positions.clone(); - self.cached_fingerprint = fingerprint; + self.cached_lines = message_lines.into_iter().map(line_to_static).collect(); + self.cached_positions = message_positions; + self.cached_revision = self.render_revision; + self.cached_width = max_width; + self.cached_colors_hash = colors_hash; } - let content_height = all_lines.len(); + let all_lines = &self.cached_lines; + let positions = &self.cached_positions; + let content_height = all_lines.len(); let viewport = self.viewport_height; let max_offset = content_height.saturating_sub(viewport); let clamped_scroll = self.scroll_offset.min(max_offset); - let render_area = Rect { - x: content_area.x, - y: content_area.y, - width: content_area.width, - height: content_area.height, - }; + let visible_start = clamped_scroll.min(content_height); + let visible_end = content_height.min(clamped_scroll.saturating_add(viewport)); - // Render timeline highlight as a full-width background overlay - if let Some(hl) = self.highlighted_message_index { + let highlight_range = self.highlighted_message_index.and_then(|hl| { if hl < positions.len() { let start = positions[hl]; let end = if hl + 1 < positions.len() { @@ -1073,36 +1112,52 @@ impl Chat { } else { content_height }; + (end > start).then_some((start, end)) + } else { + None + } + }); - if end > start { - let hl_color = colors.interactive; - let hl_fg = contrast_text(hl_color); + let mut content_lines: Vec<Line<'static>> = all_lines[visible_start..visible_end].to_vec(); - for line in all_lines.iter_mut().take(end).skip(start) { - for span in line.spans.iter_mut() { - span.style = span.style.fg(hl_fg); - } + if let Some((start, end)) = highlight_range { + let hl_fg = contrast_text(colors.interactive); + for (line_idx, line) in content_lines.iter_mut().enumerate() { + let global_idx = visible_start + line_idx; + if global_idx >= start && global_idx < end { + for span in line.spans.iter_mut() { + span.style = span.style.fg(hl_fg); } + } + } + } - let vis_start = start.max(clamped_scroll); - let vis_end = end.min(clamped_scroll.saturating_add(viewport)); - - if vis_end > vis_start { - let y = content_area - .y - .saturating_add((vis_start - clamped_scroll) as u16); - let height = (vis_end - vis_start).saturating_sub(1) as u16; - if height > 0 { - let hl_area = Rect { - x: content_area.x, - y, - width: content_area.width, - height, - }; - let hl_block = Block::new().style(Style::default().bg(hl_color)); - f.render_widget(hl_block, hl_area); - } - } + let render_area = Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width, + height: content_area.height, + }; + + // Render timeline highlight as a full-width background overlay + if let Some((start, end)) = highlight_range { + let vis_start = start.max(clamped_scroll); + let vis_end = end.min(clamped_scroll.saturating_add(viewport)); + + if vis_end > vis_start { + let y = content_area + .y + .saturating_add((vis_start - clamped_scroll) as u16); + let height = (vis_end - vis_start).saturating_sub(1) as u16; + if height > 0 { + let hl_area = Rect { + x: content_area.x, + y, + width: content_area.width, + height, + }; + let hl_block = Block::new().style(Style::default().bg(colors.interactive)); + f.render_widget(hl_block, hl_area); } } } @@ -1110,25 +1165,25 @@ impl Chat { render_line_backgrounds( f, render_area, - &all_lines, + all_lines, clamped_scroll, render_area.height as usize, colors.background_element, ); - let content_lines = crate::ui::selection::apply_selection_to_lines( - all_lines, + let content_lines = crate::ui::selection::apply_selection_to_lines_with_offset( + content_lines, &self.selection, colors.accent, + visible_start, ); - let paragraph = - Paragraph::new(Text::from(content_lines)).scroll((clamped_scroll as u16, 0)); + let paragraph = Paragraph::new(Text::from(content_lines)); f.render_widget(paragraph, render_area); self.content_height = content_height; - self.message_line_positions = positions; + self.message_line_positions = positions.to_vec(); self.scroll_offset = clamped_scroll; self.update_scrollbar(); diff --git a/src/ui/selection.rs b/src/ui/selection.rs index 43fc42a..15f9f2f 100644 --- a/src/ui/selection.rs +++ b/src/ui/selection.rs @@ -3,7 +3,6 @@ use ratatui::{ style::{Color, Modifier, Style}, text::Span, }; -use unicode_width::UnicodeWidthStr; /// Represents a text selection range in the chat content. /// Coordinates are in rendered-content space (line index, column within line). @@ -180,6 +179,17 @@ pub fn apply_selection_to_lines<'a>( lines: Vec<ratatui::text::Line<'a>>, selection: &Selection, accent: Color, +) -> Vec<ratatui::text::Line<'a>> { + apply_selection_to_lines_with_offset(lines, selection, accent, 0) +} + +/// Apply selection styling to visible lines whose first line starts at +/// `line_offset` in the full rendered transcript. +pub fn apply_selection_to_lines_with_offset<'a>( + lines: Vec<ratatui::text::Line<'a>>, + selection: &Selection, + accent: Color, + line_offset: usize, ) -> Vec<ratatui::text::Line<'a>> { if !selection.active { return lines; @@ -189,7 +199,8 @@ pub fn apply_selection_to_lines<'a>( lines .into_iter() .enumerate() - .map(|(line_idx, line)| { + .map(|(visible_idx, line)| { + let line_idx = line_offset + visible_idx; if line_idx < s_line || line_idx > e_line { return line; } From 511268627d527935fd832e49542eaee821b33aad Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 21:27:54 +0800 Subject: [PATCH 099/226] feat(aisdk): execute same-step tool calls concurrently and deduplicate repeated task calls. - Run all tool calls in a single step concurrently using `join_all` - Cache results of `task` tool calls by canonical JSON args and skip exact repeats within the same response - Add `repeatable_tool_cache_key` and `canonical_json` helpers for deterministic caching - Add `format_tool_observation` to handle single vs batch result formatting - Update LICENSE copyright notice --- LICENSE | 2 +- _plans/__TODOS.md | 2 + aisdk/src/response.rs | 373 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 355 insertions(+), 22 deletions(-) diff --git a/LICENSE b/LICENSE index 808f2f0..e4c4945 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Blankeos +Copyright (c) 2026 Carlo Taleon (Blankeos) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index a42f1f3..37a8cd7 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -89,3 +89,5 @@ - [ ] Benchmark script to test performance against opencode + codex in comparison. As cheaply as possible. Using the same models. It doesn't need to be a state-of-the-art benchmark. It just needs to test a couple of usual things i.e. small stuff, see if the agent is at least just as capable, because what we're chasing is kinda exactly just the same as codex/opencode, not better. The "better" will be in the UX, it will have the better UX changes I want. So I will want to also explicitly say it's a make-shift benchmark. I want the benchmark to output: - [ ] Cost to test - this is just my personal add - [ ] Idk what metric usually is used, to define "better". - the goal is crabcode will have the same score as the others. + +- [ ] Paste compaction i.e. [Pasted Content 1865 chars] diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index d0fc543..fe0da52 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -4,8 +4,8 @@ use crate::message::Message; use crate::provider::Provider; use crate::stop::{StopReason, StopWhenFn}; use crate::tool::Tool; -use futures::StreamExt; -use std::collections::HashMap; +use futures::{future::join_all, StreamExt}; +use std::collections::{BTreeMap, HashMap}; use std::pin::Pin; use std::sync::Arc; use tokio::sync::mpsc; @@ -82,6 +82,7 @@ pub async fn stream_with_tools<P: Provider>( let mut current_messages = messages; let mut step_idx: usize = 0; let max_steps = max_steps.unwrap_or(usize::MAX); + let mut cached_repeatable_tool_results: HashMap<String, String> = HashMap::new(); loop { step_idx += 1; @@ -191,30 +192,75 @@ pub async fn stream_with_tools<P: Provider>( } }; - for (_call_id, tool_name, args) in &tool_calls_to_execute { - let tool = tools.iter().find(|t| &t.name == tool_name); - match tool { - Some(t) => match t.execute.call(args.clone()).await { - Ok(result) => { - let observation = format!("Tool `{}` result:\n{}", tool_name, result); - current_messages.push(Message::user(observation.clone())); - messages_arc.lock().await.push(Message::user(observation)); + let mut successful_tool_results = Vec::new(); + let mut tool_calls_to_run = Vec::new(); + + for (call_id, tool_name, args) in tool_calls_to_execute { + let cache_key = repeatable_tool_cache_key(&tool_name, &args); + if let Some(cached_output) = cache_key + .as_ref() + .and_then(|key| cached_repeatable_tool_results.get(key)) + .cloned() + { + successful_tool_results.push(ToolExecutionResult { + call_id, + tool_name, + output: format!( + "Duplicate task call skipped; reusing the prior result from this response.\n\n{}", + cached_output + ), + cache_key: None, + }); + } else { + tool_calls_to_run.push((call_id, tool_name, args, cache_key)); + } + } + + let tool_results = join_all(tool_calls_to_run.into_iter().map( + |(call_id, tool_name, args, cache_key)| { + let tool = tools.iter().find(|t| t.name == tool_name).cloned(); + + async move { + match tool { + Some(t) => t + .execute + .call(args) + .await + .map(|output| ToolExecutionResult { + call_id, + tool_name: tool_name.clone(), + output, + cache_key, + }) + .map_err(|e| format!("Tool '{}' error: {}", tool_name, e)), + None => Err(format!("Tool not found: {}", tool_name)), } - Err(e) => { - let _ = tx_loop.send(ChunkType::Failed(format!( - "Tool '{}' error: {}", - tool_name, e - ))); + } + }, + )) + .await; + + for result in tool_results { + match result { + Ok(result) => { + if let Some(cache_key) = result.cache_key.as_ref() { + cached_repeatable_tool_results + .insert(cache_key.clone(), result.output.clone()); } - }, - None => { - let _ = tx_loop - .send(ChunkType::Failed(format!("Tool not found: {}", tool_name))); + successful_tool_results.push(result); + } + Err(err) => { + let _ = tx_loop.send(ChunkType::Failed(err)); } } } + + if !successful_tool_results.is_empty() { + let observation = format_tool_observation(&successful_tool_results); + current_messages.push(Message::user(observation.clone())); + messages_arc.lock().await.push(Message::user(observation)); + } } - let _ = std::fs::write("aisdk_debug.log", "spawned task done, dropping tx\n"); }); response.add_handle(handle); @@ -235,6 +281,71 @@ struct PendingToolCall { saw_arguments: bool, } +#[derive(Debug)] +struct ToolExecutionResult { + call_id: String, + tool_name: String, + output: String, + cache_key: Option<String>, +} + +fn repeatable_tool_cache_key(tool_name: &str, args: &serde_json::Value) -> Option<String> { + if tool_name != "task" { + return None; + } + + Some(format!("{}:{}", tool_name, canonical_json(args))) +} + +fn canonical_json(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { + value.to_string() + } + serde_json::Value::String(s) => { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()) + } + serde_json::Value::Array(items) => { + let parts = items.iter().map(canonical_json).collect::<Vec<_>>(); + format!("[{}]", parts.join(",")) + } + serde_json::Value::Object(map) => { + let sorted = map.iter().collect::<BTreeMap<_, _>>(); + let parts = sorted + .into_iter() + .map(|(key, value)| { + let key = serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string()); + format!("{}:{}", key, canonical_json(value)) + }) + .collect::<Vec<_>>(); + format!("{{{}}}", parts.join(",")) + } + } +} + +fn format_tool_observation(results: &[ToolExecutionResult]) -> String { + if let [result] = results { + return format!("Tool `{}` result:\n{}", result.tool_name, result.output); + } + + let mut observation = format!( + "Tool batch results: {} tool calls completed. Use these results to answer the user's request. Do not repeat the same tool calls unless the results are missing or insufficient.", + results.len() + ); + + for (idx, result) in results.iter().enumerate() { + observation.push_str(&format!( + "\n\n<tool_result index=\"{}\" tool=\"{}\" call_id=\"{}\">\n{}\n</tool_result>", + idx + 1, + result.tool_name, + result.call_id, + result.output + )); + } + + observation +} + impl ToolCallAccumulator { fn ingest(&mut self, json_str: &str) -> std::result::Result<(), String> { let parsed: serde_json::Value = serde_json::from_str(json_str) @@ -364,7 +475,227 @@ fn tool_call_key(item: &serde_json::Value, array_index: usize) -> String { #[cfg(test)] mod tests { - use super::ToolCallAccumulator; + use super::{stream_with_tools, ToolCallAccumulator}; + use crate::chunk::ChunkType; + use crate::message::Message; + use crate::provider::{Provider, ProviderStream}; + use crate::tool::{Tool, ToolExecute}; + use async_trait::async_trait; + use futures::StreamExt; + use schemars::Schema; + use std::collections::HashMap; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use std::time::Duration; + use tokio::sync::Barrier; + + #[derive(Debug, Clone)] + struct TwoToolCallProvider { + requests: Arc<AtomicUsize>, + } + + #[derive(Debug, Clone)] + struct RepeatingTaskProvider { + requests: Arc<AtomicUsize>, + } + + #[async_trait] + impl Provider for TwoToolCallProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = if request == 0 { + vec![ + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"wait","arguments":"{\"id\":1}"}},{"index":1,"id":"call_2","type":"function","function":{"name":"wait","arguments":"{\"id\":2}"}}]"# + .to_string(), + )), + Ok(ChunkType::End(String::new())), + ] + } else { + vec![ + Ok(ChunkType::Text("done".to_string())), + Ok(ChunkType::End(String::new())), + ] + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + + #[async_trait] + impl Provider for RepeatingTaskProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = match request { + 0 | 1 => vec![ + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_repeat","type":"function","function":{"name":"task","arguments":"{\"description\":\"Write haiku\",\"prompt\":\"Write a haiku\",\"subagent_type\":\"general\"}"}}]"# + .to_string(), + )), + Ok(ChunkType::End(String::new())), + ], + _ => vec![ + Ok(ChunkType::Text("done".to_string())), + Ok(ChunkType::End(String::new())), + ], + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + + #[tokio::test] + async fn executes_same_step_tool_calls_concurrently() { + let provider = TwoToolCallProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + let barrier = Arc::new(Barrier::new(2)); + let executions = Arc::new(AtomicUsize::new(0)); + + let tool_barrier = barrier.clone(); + let tool_executions = executions.clone(); + let wait_tool = Tool::builder() + .name("wait") + .description("wait for a peer tool call") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(move |_input| { + let barrier = tool_barrier.clone(); + let executions = tool_executions.clone(); + async move { + executions.fetch_add(1, Ordering::SeqCst); + barrier.wait().await; + Ok("ok".to_string()) + } + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider, + vec![Message::user("run both")], + vec![wait_tool], + None, + None, + HashMap::new(), + ) + .await + .unwrap(); + + let saw_done = tokio::time::timeout(Duration::from_secs(1), async { + let mut saw_done = false; + while let Some(chunk) = response.stream.next().await { + if let ChunkType::Text(text) = chunk { + saw_done |= text == "done"; + } + } + saw_done + }) + .await + .expect("tool calls in the same step should not run serially"); + + assert!(saw_done); + assert_eq!(executions.load(Ordering::SeqCst), 2); + + let observations = response + .messages() + .await + .into_iter() + .filter_map(|message| match message { + Message::User(user) if user.content.starts_with("Tool batch results:") => { + Some(user.content) + } + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(observations.len(), 1); + assert!(observations[0].contains("call_1")); + assert!(observations[0].contains("call_2")); + } + + #[tokio::test] + async fn skips_exact_repeated_task_call_in_same_response() { + let provider = RepeatingTaskProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + let executions = Arc::new(AtomicUsize::new(0)); + + let tool_executions = executions.clone(); + let task_tool = Tool::builder() + .name("task") + .description("launch subagent") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(move |_input| { + let executions = tool_executions.clone(); + async move { + executions.fetch_add(1, Ordering::SeqCst); + Ok("subagent result".to_string()) + } + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider, + vec![Message::user("run task")], + vec![task_tool], + None, + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut saw_done = false; + while let Some(chunk) = response.stream.next().await { + if let ChunkType::Text(text) = chunk { + saw_done |= text == "done"; + } + } + + assert!(saw_done); + assert_eq!(executions.load(Ordering::SeqCst), 1); + + let observations = response + .messages() + .await + .into_iter() + .filter_map(|message| match message { + Message::User(user) if user.content.contains("Duplicate task call skipped") => { + Some(user.content) + } + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(observations.len(), 1); + } #[test] fn accumulates_streamed_openai_tool_call_arguments() { From e0da61ad988635d0881066685d4c8bf955ece096 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 21:56:14 +0800 Subject: [PATCH 100/226] feat: rename `todowrite` tool to `update_plan` and overhaul tool rendering UI. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the `todowrite` tool to `update_plan` with a simplified plan-step model (instead of content/status/priority), supporting both structured JSON and plain checkbox text. Backward-compatible with legacy `todos` parameter. Enhance the chat UI tool rendering: - Replace static tool icons (•/~) with animated markers (⬡/⬢) that pulse while any tool call is active, keeping animations running during streaming - Group consecutive `task` tool messages into a collapsible subagent summary with count, agent type, and description per subagent - Render `update_plan`/`todowrite` results as formatted plan lists with pending/in-progress/completed states - Improve `webfetch` and `bash` tool displays with semantic verbs (Webfetch/Ran) and output previews - Display running questions inline with question text visible during execution - Replace truncated result display with a context-preserving head/tail preview Fix `is_animation_running` to include active tool messages so the animation timer stays live while tools are executing. --- src/agent/subagent.rs | 2 +- src/app.rs | 1 + src/main.rs | 4 +- src/prompt/mod.rs | 4 +- src/tools/init.rs | 4 +- src/tools/mod.rs | 4 +- src/tools/todowrite.rs | 330 ---------- src/tools/update_plan.rs | 346 +++++++++++ src/ui/components/chat.rs | 1200 ++++++++++++++++++++++++++++--------- src/ui/markdown/table.rs | 4 +- 10 files changed, 1273 insertions(+), 626 deletions(-) delete mode 100644 src/tools/todowrite.rs create mode 100644 src/tools/update_plan.rs diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index ffa40b5..6616ee3 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -28,7 +28,7 @@ IMPORTANT RULES: - Be thorough and verify your work using available tools - Return a single comprehensive message with your results - Do NOT ask questions back to the user - just complete the task -- Do NOT use the todowrite tool +- Do NOT use the update_plan tool You will receive a detailed task description from the primary agent. Complete it and return your findings in a single comprehensive message."#; diff --git a/src/app.rs b/src/app.rs index f8470f0..381857e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3976,6 +3976,7 @@ impl App { pub fn is_animation_running(&self) -> bool { self.base_focus == BaseFocus::Home || self.is_streaming + || self.chat_state.chat.has_active_tool_messages() || self.compaction_receiver.is_some() || self .session_view_states diff --git a/src/main.rs b/src/main.rs index 11575fe..568571a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -177,11 +177,11 @@ async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<() crate::llm::ChunkMessage::ToolCalls(calls) => { println!(); for call in &calls { - println!(" 🔧 {}", call.function.name); + println!(" ⬡ {}", call.function.name); } } crate::llm::ChunkMessage::ToolResult(result) => { - println!(" ✓ {}", result.name); + println!(" ⬢ {}", result.name); } crate::llm::ChunkMessage::End => { println!(); diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 66b5043..ef68028 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -191,7 +191,7 @@ Core Directives: - Keep responses concise, direct, friendly - Send brief preambles before tool calls (8-12 words) - Break tasks into meaningful, logically ordered steps -- Don't repeat full plan after todowrite +- Don't repeat full plan after update_plan - Fix root cause, not surface patches - Keep changes minimal and focused - Validate work via tests/build @@ -205,7 +205,7 @@ Output Philosophy: - Minimal markdown formatting Planning: -- Use plan tool for non-trivial, multi-phase work +- Use update_plan for non-trivial, multi-phase work - Plans should break task into logical dependencies - Don't pad with obvious steps - Update plans mid-task if needed with explanation diff --git a/src/tools/init.rs b/src/tools/init.rs index 5632a7b..36c4582 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,6 +1,6 @@ use crate::tools::{ fs::{GlobTool, GrepTool, ListTool, ReadTool, WriteTool}, - BashTool, EditTool, QuestionTool, SkillTool, TaskTool, TodowriteTool, ToolRegistry, + BashTool, EditTool, QuestionTool, SkillTool, TaskTool, ToolRegistry, UpdatePlanTool, WebfetchTool, }; use std::sync::Arc; @@ -17,7 +17,7 @@ pub async fn initialize_tool_registry() -> ToolRegistry { registry.register(Arc::new(EditTool::new())).await; registry.register(Arc::new(SkillTool::new())).await; registry.register(Arc::new(WebfetchTool::new())).await; - registry.register(Arc::new(TodowriteTool::new())).await; + registry.register(Arc::new(UpdatePlanTool::new())).await; registry } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 1d966d3..b1a6d10 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -12,8 +12,8 @@ pub mod question; pub mod registry; pub mod skill; pub mod task; -pub mod todowrite; pub mod types; +pub mod update_plan; pub mod webfetch; pub use bash::BashTool; @@ -27,8 +27,8 @@ pub use question::QuestionTool; pub use registry::ToolRegistry; pub use skill::SkillTool; pub use task::TaskTool; -pub use todowrite::TodowriteTool; pub use types::{ParameterSchema, ParameterType, Tool, ToolError, ToolId, ToolResult}; +pub use update_plan::UpdatePlanTool; pub use webfetch::WebfetchTool; #[async_trait] diff --git a/src/tools/todowrite.rs b/src/tools/todowrite.rs deleted file mode 100644 index faca34a..0000000 --- a/src/tools/todowrite.rs +++ /dev/null @@ -1,330 +0,0 @@ -use crate::tools::{ - validate_required, ParameterSchema, ParameterType, Tool, ToolContext, ToolError, ToolHandler, - ToolResult, -}; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; - -#[derive(Debug, Deserialize, Serialize)] -struct TodoItem { - content: String, - status: String, - priority: String, -} - -fn todo_item_param_type() -> ParameterType { - let mut props = HashMap::new(); - props.insert("content".to_string(), ParameterType::String); - props.insert("status".to_string(), ParameterType::String); - props.insert("priority".to_string(), ParameterType::String); - ParameterType::Object(props) -} - -fn normalize_status(status: Option<&str>) -> String { - match status - .unwrap_or("pending") - .trim() - .to_ascii_lowercase() - .as_str() - { - "todo" | "open" | "not_started" | "not-started" => "pending".to_string(), - "doing" | "active" | "in-progress" | "in progress" => "in_progress".to_string(), - "done" | "complete" => "completed".to_string(), - "canceled" => "cancelled".to_string(), - value => value.to_string(), - } -} - -fn normalize_priority(priority: Option<&str>) -> String { - match priority - .unwrap_or("medium") - .trim() - .to_ascii_lowercase() - .as_str() - { - "normal" => "medium".to_string(), - value => value.to_string(), - } -} - -fn todo_from_plain(content: &str, status: &str) -> TodoItem { - TodoItem { - content: content.trim().to_string(), - status: status.to_string(), - priority: "medium".to_string(), - } -} - -fn strip_list_marker(line: &str) -> &str { - let trimmed = line.trim(); - if let Some(rest) = trimmed - .strip_prefix("- ") - .or_else(|| trimmed.strip_prefix("* ")) - .or_else(|| trimmed.strip_prefix("+ ")) - { - return rest.trim_start(); - } - - if let Some((prefix, rest)) = trimmed.split_once(". ") { - if !prefix.is_empty() && prefix.chars().all(|ch| ch.is_ascii_digit()) { - return rest.trim_start(); - } - } - - trimmed -} - -fn parse_checkbox_line(line: &str) -> Option<TodoItem> { - let line = strip_list_marker(line); - let (status, rest) = if let Some(rest) = line.strip_prefix("[ ]") { - ("pending", rest) - } else if let Some(rest) = line.strip_prefix("[x]") { - ("completed", rest) - } else if let Some(rest) = line.strip_prefix("[X]") { - ("completed", rest) - } else if let Some(rest) = line.strip_prefix("[✓]") { - ("completed", rest) - } else if let Some(rest) = line.strip_prefix("[✔]") { - ("completed", rest) - } else if let Some(rest) = line.strip_prefix("[•]") { - ("in_progress", rest) - } else { - return None; - }; - - let content = rest.trim(); - if content.is_empty() { - None - } else { - Some(todo_from_plain(content, status)) - } -} - -fn parse_plain_todos(raw: &str) -> Vec<TodoItem> { - raw.lines() - .filter_map(|line| { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - parse_checkbox_line(trimmed).or_else(|| { - let content = strip_list_marker(trimmed); - if content.is_empty() { - None - } else { - Some(todo_from_plain(content, "pending")) - } - }) - }) - .collect() -} - -fn parse_todo_value(value: &Value) -> Result<TodoItem, ToolError> { - match value { - Value::Object(obj) => { - let content = obj - .get("content") - .or_else(|| obj.get("todo")) - .or_else(|| obj.get("task")) - .or_else(|| obj.get("title")) - .or_else(|| obj.get("description")) - .and_then(|v| v.as_str()) - .unwrap_or_default(); - - Ok(TodoItem { - content: content.trim().to_string(), - status: normalize_status(obj.get("status").and_then(|v| v.as_str())), - priority: normalize_priority(obj.get("priority").and_then(|v| v.as_str())), - }) - } - Value::String(content) => Ok(todo_from_plain(content, "pending")), - _ => Err(ToolError::Validation( - "Each todo must be an object or string".to_string(), - )), - } -} - -fn parse_todos_value(value: &Value) -> Result<Vec<TodoItem>, ToolError> { - match value { - Value::Array(items) => items.iter().map(parse_todo_value).collect(), - Value::Object(_) | Value::String(_) => Ok(vec![parse_todo_value(value)?]), - _ => Err(ToolError::Validation( - "todos must be an array, object, string, or JSON string".to_string(), - )), - } -} - -fn parse_todos_string(raw: &str) -> Result<Vec<TodoItem>, ToolError> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(ToolError::Validation( - "Todos parameter cannot be empty".to_string(), - )); - } - - if trimmed - .lines() - .any(|line| parse_checkbox_line(line).is_some()) - { - return Ok(parse_plain_todos(trimmed)); - } - - let starts_like_json = trimmed.starts_with('[') || trimmed.starts_with('{'); - if !starts_like_json { - return Ok(parse_plain_todos(trimmed)); - } - - let parsed = serde_json::from_str::<Value>(trimmed) - .map_err(|e| ToolError::Validation(format!("Invalid todo JSON: {}", e)))?; - parse_todos_value(&parsed) -} - -fn parse_todos_param(params: &Value) -> Result<Vec<TodoItem>, ToolError> { - let raw = params - .get("todos") - .ok_or_else(|| ToolError::Validation("Missing required parameter: todos".to_string()))?; - - match raw { - Value::String(s) => parse_todos_string(s), - Value::Array(_) | Value::Object(_) => parse_todos_value(raw), - _ => Err(ToolError::Validation( - "todos must be an array, object, string, or JSON string".to_string(), - )), - } -} - -fn validate_todos(todos: &[TodoItem]) -> Result<(), ToolError> { - if todos.is_empty() { - return Err(ToolError::Validation( - "Todos array must contain at least one item".to_string(), - )); - } - - for (i, todo) in todos.iter().enumerate() { - if todo.content.trim().is_empty() { - return Err(ToolError::Validation(format!( - "Todo item {} has empty content", - i - ))); - } - if !matches!( - todo.status.as_str(), - "pending" | "in_progress" | "completed" | "cancelled" - ) { - return Err(ToolError::Validation(format!( - "Todo item '{}' has invalid status: {}. Must be one of: pending, in_progress, completed, cancelled", - todo.content, todo.status - ))); - } - if !matches!(todo.priority.as_str(), "high" | "medium" | "low") { - return Err(ToolError::Validation(format!( - "Todo item '{}' has invalid priority: {}. Must be one of: high, medium, low", - todo.content, todo.priority - ))); - } - } - - Ok(()) -} - -pub struct TodowriteTool; - -impl TodowriteTool { - pub fn new() -> Self { - Self - } -} - -#[async_trait] -impl ToolHandler for TodowriteTool { - fn definition(&self) -> Tool { - Tool { - id: "todowrite".to_string(), - description: "Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.\n\n## When to Use This Tool\nUse this tool proactively in these scenarios:\n\n1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions\n2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations\n3. User explicitly requests todo list - When the user directly asks you to use the todo list\n4. User provides multiple tasks - When users provide a list of things to be done\n5. After receiving new instructions - Immediately capture user requirements as todos\n6. After completing a task - Mark it complete and add any new follow-up tasks\n\n## Task States and Management\n\n1. **Task States**: Use these states:\n - pending: Task not yet started\n - in_progress: Currently working on (limit to ONE at a time)\n - completed: Task finished successfully\n - cancelled: Task no longer needed\n\n2. **Task Management**:\n - Update task status in real-time as you work\n - Mark tasks complete IMMEDIATELY after finishing\n - Only have ONE task in_progress at any time\n - Complete current tasks before starting new ones\n\nParameters:\n- todos: Array of todo items, each with content, status (pending/in_progress/completed/cancelled), and priority (high/medium/low)".to_string(), - parameters: vec![ParameterSchema { - name: "todos".to_string(), - description: "Array of todo items, each with: content, status, priority. Plain checklist text is also accepted for compatibility.".to_string(), - required: true, - param_type: ParameterType::Array(Box::new(todo_item_param_type())), - }], - } - } - - fn validate(&self, params: &Value) -> Result<(), ToolError> { - validate_required(params, &["todos"])?; - let todos = parse_todos_param(params)?; - validate_todos(&todos) - } - - async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> { - let todos = parse_todos_param(¶ms)?; - validate_todos(&todos)?; - - let mut output = String::new(); - - for todo in &todos { - let mark = match todo.status.as_str() { - "completed" => "[✓]", - "in_progress" => "[•]", - _ => "[ ]", - }; - output.push_str(&format!("{} {}\n", mark, todo.content)); - } - - Ok(ToolResult::new("Todo list updated", output.clone()) - .with_metadata("todo_items", serde_json::json!(todos))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn parse_todos_accepts_structured_array() { - let params = json!({ - "todos": [{ - "content": "Implement rendering", - "status": "in_progress", - "priority": "high" - }] - }); - - let todos = parse_todos_param(¶ms).unwrap(); - - assert_eq!(todos.len(), 1); - assert_eq!(todos[0].content, "Implement rendering"); - assert_eq!(todos[0].status, "in_progress"); - assert_eq!(todos[0].priority, "high"); - } - - #[test] - fn parse_todos_accepts_json_string_for_compatibility() { - let params = json!({ - "todos": r#"[{"content":"Choose rendering file","status":"pending","priority":"medium"}]"# - }); - - let todos = parse_todos_param(¶ms).unwrap(); - - assert_eq!(todos.len(), 1); - assert_eq!(todos[0].content, "Choose rendering file"); - } - - #[test] - fn parse_todos_accepts_plain_checkbox_text() { - let params = json!({ - "todos": "[ ] Define table data\n[•] Implement rendering\n[✓] Verify output" - }); - - let todos = parse_todos_param(¶ms).unwrap(); - - assert_eq!(todos.len(), 3); - assert_eq!(todos[0].status, "pending"); - assert_eq!(todos[1].status, "in_progress"); - assert_eq!(todos[2].status, "completed"); - assert_eq!(todos[0].priority, "medium"); - } -} diff --git a/src/tools/update_plan.rs b/src/tools/update_plan.rs new file mode 100644 index 0000000..4be56b4 --- /dev/null +++ b/src/tools/update_plan.rs @@ -0,0 +1,346 @@ +use crate::tools::{ + ParameterSchema, ParameterType, Tool, ToolContext, ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct PlanItem { + step: String, + status: String, +} + +#[derive(Debug, Clone)] +struct PlanUpdate { + explanation: Option<String>, + plan: Vec<PlanItem>, +} + +pub struct UpdatePlanTool; + +impl UpdatePlanTool { + pub fn new() -> Self { + Self + } +} + +fn plan_item_param_type() -> ParameterType { + let mut props = HashMap::new(); + props.insert("step".to_string(), ParameterType::String); + props.insert("status".to_string(), ParameterType::String); + ParameterType::Object(props) +} + +fn normalize_status(status: Option<&str>) -> String { + match status + .unwrap_or("pending") + .trim() + .to_ascii_lowercase() + .as_str() + { + "todo" | "open" | "not_started" | "not-started" => "pending".to_string(), + "doing" | "active" | "in-progress" | "in progress" => "in_progress".to_string(), + "done" | "complete" => "completed".to_string(), + // Legacy todo lists could mark an item cancelled. The Codex plan UI has + // only three states, so preserve the item without implying it completed. + "cancelled" | "canceled" => "pending".to_string(), + value => value.to_string(), + } +} + +fn item_from_plain(step: &str, status: &str) -> PlanItem { + PlanItem { + step: step.trim().to_string(), + status: status.to_string(), + } +} + +fn strip_list_marker(line: &str) -> &str { + let trimmed = line.trim(); + if let Some(rest) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + .or_else(|| trimmed.strip_prefix("+ ")) + { + return rest.trim_start(); + } + + if let Some((prefix, rest)) = trimmed.split_once(". ") { + if !prefix.is_empty() && prefix.chars().all(|ch| ch.is_ascii_digit()) { + return rest.trim_start(); + } + } + + trimmed +} + +fn parse_checkbox_line(line: &str) -> Option<PlanItem> { + let line = strip_list_marker(line); + let (status, rest) = if let Some(rest) = line.strip_prefix("[ ]") { + ("pending", rest) + } else if let Some(rest) = line.strip_prefix("[x]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[X]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[✓]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[✔]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("✔") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[•]") { + ("in_progress", rest) + } else if let Some(rest) = line.strip_prefix("□") { + ("pending", rest) + } else { + return None; + }; + + let step = rest.trim(); + if step.is_empty() { + None + } else { + Some(item_from_plain(step, status)) + } +} + +fn parse_plain_plan(raw: &str) -> Vec<PlanItem> { + raw.lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + parse_checkbox_line(trimmed).or_else(|| { + let step = strip_list_marker(trimmed); + if step.is_empty() { + None + } else { + Some(item_from_plain(step, "pending")) + } + }) + }) + .collect() +} + +fn parse_plan_item(value: &Value) -> Result<PlanItem, ToolError> { + match value { + Value::Object(obj) => { + let step = obj + .get("step") + .or_else(|| obj.get("content")) + .or_else(|| obj.get("todo")) + .or_else(|| obj.get("task")) + .or_else(|| obj.get("title")) + .or_else(|| obj.get("description")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + Ok(PlanItem { + step: step.trim().to_string(), + status: normalize_status(obj.get("status").and_then(|v| v.as_str())), + }) + } + Value::String(step) => Ok(item_from_plain(step, "pending")), + _ => Err(ToolError::Validation( + "Each plan item must be an object or string".to_string(), + )), + } +} + +fn parse_plan_items(value: &Value) -> Result<Vec<PlanItem>, ToolError> { + match value { + Value::Array(items) => items.iter().map(parse_plan_item).collect(), + Value::Object(_) => Ok(vec![parse_plan_item(value)?]), + Value::String(raw) => parse_plan_string(raw), + _ => Err(ToolError::Validation( + "plan must be an array, object, string, or JSON string".to_string(), + )), + } +} + +fn parse_plan_string(raw: &str) -> Result<Vec<PlanItem>, ToolError> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ToolError::Validation( + "Plan parameter cannot be empty".to_string(), + )); + } + + if trimmed + .lines() + .any(|line| parse_checkbox_line(line).is_some()) + { + return Ok(parse_plain_plan(trimmed)); + } + + let starts_like_json = trimmed.starts_with('[') || trimmed.starts_with('{'); + if !starts_like_json { + return Ok(parse_plain_plan(trimmed)); + } + + let parsed = serde_json::from_str::<Value>(trimmed) + .map_err(|e| ToolError::Validation(format!("Invalid plan JSON: {}", e)))?; + parse_plan_items(&parsed) +} + +fn parse_update_plan(params: &Value) -> Result<PlanUpdate, ToolError> { + let obj = params + .as_object() + .ok_or_else(|| ToolError::Validation("Parameters must be an object".to_string()))?; + + let explanation = obj + .get("explanation") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + let plan_value = obj.get("plan").or_else(|| obj.get("todos")); + let Some(plan_value) = plan_value else { + return Err(ToolError::Validation( + "Missing required parameter: plan".to_string(), + )); + }; + + let plan = parse_plan_items(plan_value)?; + validate_plan_items(&plan)?; + + Ok(PlanUpdate { explanation, plan }) +} + +fn validate_plan_items(plan: &[PlanItem]) -> Result<(), ToolError> { + if plan.is_empty() { + return Err(ToolError::Validation( + "Plan must contain at least one item".to_string(), + )); + } + + for (idx, item) in plan.iter().enumerate() { + if item.step.trim().is_empty() { + return Err(ToolError::Validation(format!( + "Plan item {} has empty step", + idx + 1 + ))); + } + + if !matches!( + item.status.as_str(), + "pending" | "in_progress" | "completed" + ) { + return Err(ToolError::Validation(format!( + "Plan item '{}' has invalid status: {}. Must be one of: pending, in_progress, completed", + item.step, item.status + ))); + } + } + + Ok(()) +} + +#[async_trait] +impl ToolHandler for UpdatePlanTool { + fn definition(&self) -> Tool { + Tool { + id: "update_plan".to_string(), + description: "Update the current task plan. Use this for non-trivial, multi-step work. Provide an optional explanation and a plan array with step/status items. Status must be pending, in_progress, or completed.".to_string(), + parameters: vec![ + ParameterSchema { + name: "explanation".to_string(), + description: "Optional short explanation for this plan update".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "plan".to_string(), + description: "Array of plan items, each with step and status (pending, in_progress, completed)".to_string(), + required: true, + param_type: ParameterType::Array(Box::new(plan_item_param_type())), + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + parse_update_plan(params).map(|_| ()) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> { + let update = parse_update_plan(¶ms)?; + + let mut output = String::new(); + if let Some(explanation) = update.explanation.as_deref() { + output.push_str(explanation); + output.push('\n'); + } + for item in &update.plan { + let marker = match item.status.as_str() { + "completed" => "✔", + "in_progress" => "□", + _ => "□", + }; + output.push_str(&format!("{} {}\n", marker, item.step)); + } + + Ok(ToolResult::new("Plan updated", output) + .with_metadata("explanation", serde_json::json!(update.explanation)) + .with_metadata("plan", serde_json::json!(update.plan))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_update_plan_accepts_codex_shape() { + let params = json!({ + "explanation": "Now implementing.", + "plan": [{ + "step": "Implement rendering", + "status": "in_progress" + }] + }); + + let update = parse_update_plan(¶ms).unwrap(); + + assert_eq!(update.explanation.as_deref(), Some("Now implementing.")); + assert_eq!(update.plan.len(), 1); + assert_eq!(update.plan[0].step, "Implement rendering"); + assert_eq!(update.plan[0].status, "in_progress"); + } + + #[test] + fn parse_update_plan_accepts_legacy_todos_shape() { + let params = json!({ + "todos": [{ + "content": "Choose rendering file", + "status": "pending", + "priority": "medium" + }] + }); + + let update = parse_update_plan(¶ms).unwrap(); + + assert_eq!(update.plan.len(), 1); + assert_eq!(update.plan[0].step, "Choose rendering file"); + assert_eq!(update.plan[0].status, "pending"); + } + + #[test] + fn parse_update_plan_accepts_plain_checkbox_text() { + let params = json!({ + "plan": "[ ] Define table data\n[•] Implement rendering\n[✓] Verify output" + }); + + let update = parse_update_plan(¶ms).unwrap(); + + assert_eq!(update.plan.len(), 3); + assert_eq!(update.plan[0].status, "pending"); + assert_eq!(update.plan[1].status, "in_progress"); + assert_eq!(update.plan[2].status, "completed"); + } +} diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 674fcdb..5c34dfc 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -68,11 +68,14 @@ pub struct Chat { cached_width: usize, cached_colors_hash: u64, cached_fingerprint: u64, + tool_marker_animation_phase: bool, } // Minimum elapsed time before showing tokens/s (250ms) const MIN_TOKENS_PER_SECOND_ELAPSED_MS: u128 = 250; const TOOL_RESULT_MAX_SCREEN_LINES: usize = 8; +const TOOL_MARKER_ACTIVE: &str = "⬡"; +const TOOL_MARKER_DONE: &str = "⬢"; #[derive(Debug, Clone)] struct ParsedToolMessage { @@ -91,6 +94,33 @@ struct ExplorationToolItem { active: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct TaskToolItem { + subagent_type: String, + description: String, + active: bool, + failed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PlanStepStatus { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlanStep { + step: String, + status: PlanStepStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlanUpdateDisplay { + explanation: Option<String>, + plan: Vec<PlanStep>, +} + fn now_epoch_ms() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() @@ -248,6 +278,53 @@ fn exploration_tool_item_for_message(message: &Message) -> Option<ExplorationToo .and_then(exploration_tool_item) } +fn task_tool_item(info: &ParsedToolMessage) -> Option<TaskToolItem> { + if info.name != "task" { + return None; + } + + let args_obj = info.args.as_ref().and_then(|v| v.as_object()); + let subagent_type = args_obj + .and_then(|o| o.get("subagent_type")) + .and_then(|v| v.as_str()) + .or_else(|| { + info.metadata + .as_ref() + .and_then(|m| m.get("subagent_type")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("general"); + let description = args_obj + .and_then(|o| o.get("description")) + .and_then(|v| v.as_str()) + .or_else(|| { + info.metadata + .as_ref() + .and_then(|m| m.get("child_session_title")) + .and_then(|v| v.as_str()) + }) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("Task"); + + Some(TaskToolItem { + subagent_type: titlecase_ascii(subagent_type), + description: description.to_string(), + active: matches!(info.status.as_str(), "running" | "pending"), + failed: info.status == "error", + }) +} + +fn task_tool_item_for_message(message: &Message) -> Option<TaskToolItem> { + if message.role != MessageRole::Tool { + return None; + } + + parse_tool_message(&message.content) + .as_ref() + .and_then(task_tool_item) +} + fn metadata_usize(metadata: Option<&JsonValue>, keys: &[&str]) -> Option<usize> { keys.iter() .find_map(|key| { @@ -269,6 +346,207 @@ fn parse_line_number(text: &str) -> Option<usize> { digits.parse().ok() } +fn titlecase_ascii(value: &str) -> String { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + first.to_ascii_uppercase().to_string() + chars.as_str() +} + +fn normalize_plan_status(status: Option<&str>) -> PlanStepStatus { + match status + .unwrap_or("pending") + .trim() + .to_ascii_lowercase() + .as_str() + { + "completed" | "complete" | "done" | "x" | "✓" | "✔" => PlanStepStatus::Completed, + "in_progress" | "in-progress" | "in progress" | "doing" | "active" | "current" => { + PlanStepStatus::InProgress + } + _ => PlanStepStatus::Pending, + } +} + +fn strip_plain_list_marker(line: &str) -> &str { + let trimmed = line.trim(); + if let Some(rest) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + .or_else(|| trimmed.strip_prefix("+ ")) + { + return rest.trim_start(); + } + + if let Some((prefix, rest)) = trimmed.split_once(". ") { + if !prefix.is_empty() && prefix.chars().all(|ch| ch.is_ascii_digit()) { + return rest.trim_start(); + } + } + + trimmed +} + +fn parse_plan_checkbox_line(line: &str) -> Option<PlanStep> { + let line = strip_plain_list_marker(line); + let (status, rest) = if let Some(rest) = line.strip_prefix("[ ]") { + (PlanStepStatus::Pending, rest) + } else if let Some(rest) = line.strip_prefix("[x]") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("[X]") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("[✓]") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("[✔]") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("✔") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("[•]") { + (PlanStepStatus::InProgress, rest) + } else if let Some(rest) = line.strip_prefix("□") { + (PlanStepStatus::Pending, rest) + } else { + return None; + }; + + let step = rest.trim(); + if step.is_empty() { + None + } else { + Some(PlanStep { + step: step.to_string(), + status, + }) + } +} + +fn plan_steps_from_text(raw: &str) -> Vec<PlanStep> { + raw.lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + parse_plan_checkbox_line(trimmed).or_else(|| { + let step = strip_plain_list_marker(trimmed); + if step.is_empty() { + None + } else { + Some(PlanStep { + step: step.to_string(), + status: PlanStepStatus::Pending, + }) + } + }) + }) + .collect() +} + +fn plan_step_from_json(value: &JsonValue) -> Option<PlanStep> { + match value { + JsonValue::Object(obj) => { + let step = ["step", "content", "todo", "task", "title", "description"] + .iter() + .find_map(|key| obj.get(*key).and_then(|v| v.as_str())) + .map(str::trim) + .filter(|value| !value.is_empty())?; + Some(PlanStep { + step: step.to_string(), + status: normalize_plan_status(obj.get("status").and_then(|v| v.as_str())), + }) + } + JsonValue::String(step) => { + let trimmed = step.trim(); + if trimmed.is_empty() { + None + } else if trimmed.lines().count() > 1 + || trimmed + .lines() + .any(|line| parse_plan_checkbox_line(line).is_some()) + { + let steps = plan_steps_from_text(trimmed); + if steps.len() == 1 { + steps.into_iter().next() + } else { + None + } + } else { + Some(PlanStep { + step: trimmed.to_string(), + status: PlanStepStatus::Pending, + }) + } + } + _ => None, + } +} + +fn plan_steps_from_json(value: &JsonValue) -> Vec<PlanStep> { + match value { + JsonValue::Array(items) => items.iter().filter_map(plan_step_from_json).collect(), + JsonValue::Object(_) => plan_step_from_json(value).into_iter().collect(), + JsonValue::String(raw) => { + let trimmed = raw.trim(); + if trimmed.starts_with('[') || trimmed.starts_with('{') { + if let Ok(parsed) = serde_json::from_str::<JsonValue>(trimmed) { + let parsed_steps = plan_steps_from_json(&parsed); + if !parsed_steps.is_empty() { + return parsed_steps; + } + } + } + plan_steps_from_text(trimmed) + } + _ => Vec::new(), + } +} + +fn plan_update_display( + name: &str, + args: &Option<JsonValue>, + metadata: &Option<JsonValue>, + output_preview: &Option<String>, +) -> Option<PlanUpdateDisplay> { + if !matches!(name, "update_plan" | "todowrite") { + return None; + } + + let explanation = metadata + .as_ref() + .and_then(|m| m.get("explanation")) + .and_then(|v| v.as_str()) + .or_else(|| { + args.as_ref() + .and_then(|a| a.get("explanation")) + .and_then(|v| v.as_str()) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + let plan_value = metadata + .as_ref() + .and_then(|m| m.get("plan").or_else(|| m.get("todo_items"))) + .or_else(|| { + args.as_ref() + .and_then(|a| a.get("plan").or_else(|| a.get("todos"))) + }); + + let mut plan = plan_value.map(plan_steps_from_json).unwrap_or_default(); + if plan.is_empty() { + if let Some(preview) = output_preview.as_deref() { + plan = plan_steps_from_text(preview); + } + } + + if plan.is_empty() { + None + } else { + Some(PlanUpdateDisplay { explanation, plan }) + } +} + impl Chat { pub fn new() -> Self { Self { @@ -306,6 +584,7 @@ impl Chat { cached_width: 0, cached_colors_hash: 0, cached_fingerprint: 0, + tool_marker_animation_phase: false, } } @@ -345,6 +624,7 @@ impl Chat { cached_width: 0, cached_colors_hash: 0, cached_fingerprint: 0, + tool_marker_animation_phase: false, } } @@ -502,6 +782,7 @@ impl Chat { self.cached_width = 0; self.cached_colors_hash = 0; self.cached_fingerprint = 0; + self.tool_marker_animation_phase = false; self.invalidate_cache(); } @@ -674,6 +955,35 @@ impl Chat { self.invalidate_cache(); } + fn current_tool_marker_animation_phase() -> bool { + (now_epoch_ms() / 500) % 2 == 1 + } + + fn active_tool_marker(&self) -> &'static str { + if self.tool_marker_animation_phase { + TOOL_MARKER_DONE + } else { + TOOL_MARKER_ACTIVE + } + } + + fn tool_marker(&self, active: bool) -> &'static str { + if active { + self.active_tool_marker() + } else { + TOOL_MARKER_DONE + } + } + + pub(crate) fn has_active_tool_messages(&self) -> bool { + self.messages.iter().rev().any(|message| { + message.role == MessageRole::Tool + && parse_tool_message(&message.content) + .map(|info| matches!(info.status.as_str(), "running" | "pending")) + .unwrap_or(false) + }) + } + pub fn prepare_streaming_token_counter(&mut self, model: &str) { self.streaming_token_counter = Some(StreamingTokenCounter::new(model)); } @@ -1080,6 +1390,19 @@ impl Chat { let max_width = content_area.width as usize; let colors_hash = Self::cache_colors_hash(colors); + let has_active_tools = self.has_active_tool_messages(); + let animation_phase = if has_active_tools { + Self::current_tool_marker_animation_phase() + } else { + false + }; + if self.tool_marker_animation_phase != animation_phase { + self.tool_marker_animation_phase = animation_phase; + if has_active_tools { + self.cached_revision = 0; + } + } + let cache_valid = self.cached_revision == self.render_revision && self.cached_width == max_width && self.cached_colors_hash == colors_hash; @@ -1228,6 +1551,16 @@ impl Chat { while idx < self.messages.len() { positions.push(all_lines.len()); + if let Some(items) = self.task_group_at(idx) { + let group_start = all_lines.len(); + let group_len = items.len(); + all_lines.extend(self.format_task_group(&items, max_width, colors)); + all_lines.push(Line::from("")); + positions.extend(std::iter::repeat(group_start).take(group_len.saturating_sub(1))); + idx += group_len; + continue; + } + if let Some(items) = self.exploration_group_at(idx) { let group_start = all_lines.len(); let group_len = items.len(); @@ -1278,6 +1611,123 @@ impl Chat { Some(items) } + fn task_group_at(&self, start: usize) -> Option<Vec<TaskToolItem>> { + let first = task_tool_item_for_message(self.messages.get(start)?)?; + let mut items = vec![first]; + + for message in self.messages.iter().skip(start + 1) { + let Some(item) = task_tool_item_for_message(message) else { + break; + }; + items.push(item); + } + + Some(items) + } + + fn format_task_group<'a>( + &'a self, + items: &[TaskToolItem], + max_width: usize, + colors: &'a ThemeColors, + ) -> Vec<Line<'a>> { + fn push_wrapped<'a>( + out: &mut Vec<Line<'a>>, + line: Line<'static>, + max_width: usize, + subsequent_indent: Line<'static>, + ) { + out.extend(wrap_styled_line( + &line, + WrapOptions::new(max_width.max(1)).subsequent_indent(subsequent_indent), + )); + } + + let mut out = Vec::new(); + if items.is_empty() { + return out; + } + + let active = items.iter().any(|item| item.active); + let failed = items.iter().any(|item| item.failed); + let marker = self.tool_marker(active); + let marker_color = if failed { + colors.error + } else if active { + colors.accent + } else { + colors.success + }; + let marker_style = Style::default() + .fg(marker_color) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(if failed { colors.error } else { colors.text }) + .add_modifier(Modifier::BOLD); + let hint_key_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let hint_style = Style::default().fg(colors.text_weak); + + let noun = if items.len() == 1 { + "subagent" + } else { + "subagents" + }; + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(marker.to_string(), marker_style), + Span::raw(" "), + Span::styled(format!("Started {} {}", items.len(), noun), title_style), + Span::styled(" - ", hint_style), + Span::styled("ctrl+x", hint_key_style), + Span::raw(" "), + Span::styled("down", hint_key_style), + Span::raw(" "), + Span::styled("to view subagents", hint_style), + ]), + max_width, + Line::from(Span::styled(" ", hint_style)), + ); + + let gutter_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let type_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let desc_style = Style::default().fg(colors.text_weak); + for (idx, item) in items.iter().enumerate() { + let item_marker = self.tool_marker(item.active); + let item_marker_style = Style::default() + .fg(if item.failed { + colors.error + } else if item.active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(" ".to_string(), gutter_style), + Span::styled(item_marker.to_string(), item_marker_style), + Span::raw(" "), + Span::styled(item.subagent_type.clone(), type_style), + Span::styled(" - ".to_string(), desc_style), + Span::styled(item.description.clone(), desc_style), + Span::styled(format!(" #{}", idx + 1), desc_style), + ]), + max_width, + Line::from(Span::styled(" ", gutter_style)), + ); + } + + out + } + fn format_exploration_group<'a>( &'a self, items: &[ExplorationToolItem], @@ -1317,9 +1767,16 @@ impl Chat { } else { items.to_vec() }; - let marker = if active { "~" } else { "•" }; + let marker = self.tool_marker(active); let heading = if active { "Exploring" } else { "Explored" }; + let marker_style = Style::default() + .fg(if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); let gutter_style = Style::default() .fg(colors.text_weak) .add_modifier(Modifier::DIM); @@ -1332,7 +1789,7 @@ impl Chat { let target_style = Style::default().fg(colors.text); out.push(Line::from(vec![ - Span::styled(marker, gutter_style), + Span::styled(marker, marker_style), Span::raw(" "), Span::styled(heading, title_style), ])); @@ -1686,22 +2143,6 @@ impl Chat { } } - fn titlecase_ascii(value: &str) -> String { - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return String::new(); - }; - first.to_ascii_uppercase().to_string() + chars.as_str() - } - - fn format_duration_ms(ms: u64) -> String { - if ms >= 1000 { - format!("{:.1}s", ms as f64 / 1000.0) - } else { - format!("{}ms", ms) - } - } - fn push_wrapped<'a>( out: &mut Vec<Line<'a>>, line: Line<'static>, @@ -1714,29 +2155,65 @@ impl Chat { )); } - fn push_limited_wrapped<'a>( + fn push_preview_lines<'a>( out: &mut Vec<Line<'a>>, - line: Line<'static>, + preview: &str, max_width: usize, - subsequent_indent: Line<'static>, - max_lines: usize, style: Style, ) { - let wrapped = wrap_styled_line( - &line, - WrapOptions::new(max_width.max(1)).subsequent_indent(subsequent_indent), - ); - if wrapped.len() <= max_lines { - out.extend(wrapped); + let trimmed = preview.trim_matches('\n'); + if trimmed.trim().is_empty() { return; } - let omitted = wrapped.len().saturating_sub(max_lines.saturating_sub(1)); - out.extend(wrapped.into_iter().take(max_lines.saturating_sub(1))); - out.push(Line::from(Span::styled( - format!(" … +{} lines", omitted), - style, - ))); + let raw_lines: Vec<&str> = trimmed.lines().collect(); + let max_lines = TOOL_RESULT_MAX_SCREEN_LINES.max(1); + let mut display_lines: Vec<String> = Vec::new(); + if raw_lines.len() <= max_lines { + display_lines.extend(raw_lines.iter().map(|line| line.to_string())); + } else { + let tail_count = if max_lines >= 3 { 1 } else { 0 }; + let head_count = max_lines.saturating_sub(tail_count + 1).max(1); + for line in raw_lines.iter().take(head_count) { + display_lines.push((*line).to_string()); + } + let omitted = raw_lines.len().saturating_sub(head_count + tail_count); + display_lines.push(format!("… +{} lines", omitted)); + if tail_count > 0 { + for line in raw_lines + .iter() + .skip(raw_lines.len().saturating_sub(tail_count)) + { + display_lines.push((*line).to_string()); + } + } + } + + for (idx, raw_line) in display_lines.into_iter().enumerate() { + let prefix = if idx == 0 { " └ " } else { " " }; + let line = Line::from(Span::styled(format!("{}{}", prefix, raw_line), style)); + out.extend(wrap_styled_line( + &line, + WrapOptions::new(max_width.max(1)) + .subsequent_indent(Line::from(Span::styled(" ", style))), + )); + } + } + + fn push_prefixed_inner_lines<'a>( + out: &mut Vec<Line<'a>>, + mut inner: Vec<Line<'static>>, + colors: &'a ThemeColors, + ) { + let gutter_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + for (idx, line) in inner.iter_mut().enumerate() { + let prefix = if idx == 0 { " └ " } else { " " }; + line.spans + .insert(0, Span::styled(prefix.to_string(), gutter_style)); + } + out.extend(inner); } let _ = attached; @@ -1765,13 +2242,6 @@ impl Chat { ) }; - let icon = match status.as_str() { - "running" => "~", - "ok" => "✓", - "error" => "✗", - _ => "•", - }; - let tool_label = match name.as_str() { "glob" => "Glob", "read" => "Read", @@ -1780,69 +2250,125 @@ impl Chat { "bash" => "Bash", "list" => "List", "grep" => "Grep", - "todowrite" => "Todos", - "question" => "Questions", + "update_plan" | "todowrite" => "Updated Plan", + "question" => "Question", "task" => "Task", + "webfetch" => "Webfetch", + "skill" => "Skill", other => other, }; let args_obj = args.as_ref().and_then(|v| v.as_object()); + if let Some(item) = parsed.as_ref().and_then(task_tool_item) { + return self.format_task_group(&[item], max_width, colors); + } + if let Some(item) = parsed.as_ref().and_then(exploration_tool_item) { return self.format_exploration_group(&[item], max_width, colors); } - let args_str = if name == "glob" { - let pat = args_obj - .and_then(|o| o.get("pattern")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let base = args_obj - .and_then(|o| o.get("path")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut s = String::new(); - if !pat.is_empty() { - s.push_str(&format!("\"{}\"", pat)); - } - if !base.is_empty() && base != "." { - if !s.is_empty() { - s.push(' '); - } - s.push_str(&format!("in \"{}\"", base)); - } - s - } else if name == "edit" { - // For edits, show only the file path in the header; the diff is rendered below. - args_obj - .and_then(|o| o.get("file_path")) - .and_then(|v| v.as_str()) - .map(|p| format!("\"{}\"", p)) - .unwrap_or_default() - } else if name == "todowrite" { - String::new() - } else { - args.as_ref().map(args_preview).unwrap_or_default() - }; + if let Some(plan_update) = plan_update_display(&name, &args, &metadata, &output_preview) { + let active = matches!(status.as_str(), "running" | "pending"); + let marker_style = Style::default() + .fg(if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let note_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::ITALIC); - let mut header = format!("{}{} {}", indent, icon, tool_label); - if !args_str.is_empty() { - header.push(' '); - header.push_str(&args_str); - } + out.push(Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled("Updated Plan", title_style), + ])); - if name == "glob" { - if let Some(mc) = metadata - .as_ref() - .and_then(|m| m.get("match_count")) - .and_then(|v| v.as_i64()) - { - header.push_str(&format!(" ({} matches)", mc)); + let inner_width = max_width.saturating_sub(4).max(1); + let mut inner: Vec<Line<'static>> = Vec::new(); + if let Some(explanation) = plan_update.explanation { + push_wrapped( + &mut inner, + Line::from(Span::styled(explanation, note_style)), + inner_width, + Line::from(Span::styled("", note_style)), + ); } - } - // Panel-style tools render header and body inside one solid background - // and skip the normal dim header path. - if name == "question" && status != "error" { + for item in plan_update.plan { + let (marker, item_style) = match item.status { + PlanStepStatus::Completed => ( + "✔ ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM | Modifier::CROSSED_OUT), + ), + PlanStepStatus::InProgress => ( + "□ ", + Style::default() + .fg(colors.accent) + .add_modifier(Modifier::BOLD), + ), + PlanStepStatus::Pending => ( + "□ ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + }; + push_wrapped( + &mut inner, + Line::from(vec![ + Span::styled(marker.to_string(), item_style), + Span::styled(item.step, item_style), + ]), + inner_width, + Line::from(Span::styled(" ", item_style)), + ); + } + + push_prefixed_inner_lines(&mut out, inner, colors); + } else if name == "question" && status != "error" { + let active = matches!(status.as_str(), "running" | "pending"); + let questions = question_values(&args, &metadata); + let count = questions.len(); + let header_text = if matches!(status.as_str(), "running" | "pending") { + if count == 1 { + "Asking 1 question...".to_string() + } else if count > 1 { + format!("Asking {} questions...", count) + } else { + "Asking questions...".to_string() + } + } else { + "Questions".to_string() + }; + let marker_style = Style::default() + .fg(if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(header_text, title_style), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + let bg = colors.background_element; let pad_style = Style::default().bg(bg); let header_style = Style::default() @@ -1856,7 +2382,6 @@ impl Chat { .bg(bg); let panel_width = max_width.saturating_sub(2).max(10); - let questions = question_values(&args, &metadata); let answers = answer_values(&metadata, &output_preview); let mut panel_lines: Vec<Line<'_>> = Vec::new(); @@ -1864,15 +2389,27 @@ impl Chat { panel_lines.push(Line::from(vec![Span::styled("# Questions", header_style)])); if status == "running" { - let count = questions.len(); - let text = if count == 1 { - "Asking 1 question...".to_string() - } else if count > 1 { - format!("Asking {} questions...", count) + if questions.is_empty() { + panel_lines.push(Line::from(vec![Span::styled( + "Waiting for question details...", + question_style, + )])); } else { - "Asking questions...".to_string() - }; - panel_lines.push(Line::from(vec![Span::styled(text, question_style)])); + for (idx, question) in questions.iter().enumerate() { + if idx > 0 { + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + } + let q_line = Line::from(vec![Span::styled( + question_text(question, idx), + question_style, + )]); + panel_lines.extend(wrap_styled_line( + &q_line, + WrapOptions::new(panel_width) + .subsequent_indent(Line::from(Span::styled(" ", question_style))), + )); + } + } } else { for (idx, question) in questions.iter().enumerate() { if idx > 0 { @@ -1907,139 +2444,102 @@ impl Chat { } out.extend(panel_lines); - } else if name == "task" { - let subagent_type = args_obj - .and_then(|o| o.get("subagent_type")) + } else if name == "webfetch" { + let active = matches!(status.as_str(), "running" | "pending"); + let url = metadata + .as_ref() + .and_then(|m| m.get("url")) .and_then(|v| v.as_str()) - .or_else(|| { - metadata - .as_ref() - .and_then(|m| m.get("subagent_type")) - .and_then(|v| v.as_str()) + .or_else(|| args_obj.and_then(|o| o.get("url")).and_then(|v| v.as_str())) + .or_else(|| strip_tool_title(title.as_deref(), "Fetched")) + .unwrap_or("url"); + let marker_style = Style::default() + .fg(if status == "error" { + colors.error + } else if active { + colors.accent + } else { + colors.success }) - .unwrap_or("general"); - let description = args_obj - .and_then(|o| o.get("description")) - .and_then(|v| v.as_str()) - .filter(|s| !s.trim().is_empty()) - .unwrap_or("Task"); - let header_text = format!( - "{} Task — {}", - titlecase_ascii(subagent_type), - description.trim() - ); - - let count = metadata - .as_ref() - .and_then(|m| m.get("child_tool_call_count")) - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let plural = if count == 1 { "toolcall" } else { "toolcalls" }; - let duration = metadata - .as_ref() - .and_then(|m| m.get("duration_ms")) - .and_then(|v| v.as_u64()) - .map(format_duration_ms); - let stats = match status.as_str() { - "running" => "running".to_string(), - "error" => "failed".to_string(), - _ => { - let base = format!("{} {}", count, plural); - duration - .map(|d| format!("{} · {}", base, d)) - .unwrap_or(base) - } - }; - - let connector_style = Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM); - let header_style = Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM); - let stats_style = Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM); - let hint_key_style = Style::default() - .fg(colors.text) .add_modifier(Modifier::BOLD); - let hint_style = Style::default().fg(colors.text_weak); - + let title_style = Style::default() + .fg(if status == "error" { + colors.error + } else { + colors.text + }) + .add_modifier(Modifier::BOLD); + let target_style = Style::default().fg(colors.text); push_wrapped( &mut out, Line::from(vec![ - Span::styled(" ┌ ", connector_style), - Span::styled(header_text, header_style), + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled("Webfetch", title_style), + Span::raw(" "), + Span::styled(url.to_string(), target_style), ]), max_width, - Line::from(Span::styled(" ", header_style)), + Line::from(Span::styled(" ", marker_style)), ); + if status == "ok" { + if let Some(ref preview) = output_preview { + let result_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + push_preview_lines(&mut out, preview, max_width, result_style); + } + } + } else if name == "bash" { + let command = metadata + .as_ref() + .and_then(|m| m.get("command")) + .and_then(|v| v.as_str()) + .or_else(|| { + args_obj + .and_then(|o| o.get("command")) + .and_then(|v| v.as_str()) + }) + .or_else(|| strip_tool_title(title.as_deref(), "Bash")) + .unwrap_or("command"); + let active = matches!(status.as_str(), "running" | "pending"); + let verb = if active { "Running" } else { "Ran" }; + let marker_style = Style::default() + .fg(if status == "error" { + colors.error + } else if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(if status == "error" { + colors.error + } else { + colors.text + }) + .add_modifier(Modifier::BOLD); + let command_style = Style::default().fg(colors.text); push_wrapped( &mut out, Line::from(vec![ - Span::styled(" │ ", connector_style), - Span::styled(stats, stats_style), + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(verb.to_string(), title_style), + Span::raw(" "), + Span::styled(command.to_string(), command_style), ]), max_width, - Line::from(Span::styled(" ", stats_style)), + Line::from(Span::styled(" ", marker_style)), ); - - out.push(Line::from("")); - out.push(Line::from(vec![ - Span::styled("ctrl+x", hint_key_style), - Span::raw(" "), - Span::styled("down", hint_key_style), - Span::raw(" "), - Span::styled("view subagents", hint_style), - ])); - } else if name == "todowrite" && status == "ok" { - if let Some(ref preview) = output_preview { - let bg = colors.background_element; - let pad_style = Style::default().bg(bg); - let header_style = Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM) - .bg(bg); - let item_style = Style::default().fg(colors.text).bg(bg); - - let panel_width = max_width.saturating_sub(2).max(10); - - // Panel header: # + label (opencode style) - let header_text = format!("# {}", tool_label); - let mut panel_lines: Vec<Line<'_>> = Vec::new(); - - // Padding top - panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); - - // Panel header - panel_lines.push(Line::from(vec![Span::styled(header_text, header_style)])); - - // Body: each todo item as plain text (no markdown — avoids - // brackets being interpreted as links). - let preview_trimmed = preview.trim_end(); - for raw_line in preview_trimmed.lines() { - let trimmed = raw_line.trim_end(); - if trimmed.is_empty() { - continue; - } - let line = Line::from(vec![Span::styled(trimmed.to_string(), item_style)]); - panel_lines.extend(wrap_styled_line( - &line, - WrapOptions::new(panel_width) - .subsequent_indent(Line::from(Span::styled(" ", item_style))), - )); - } - - // Padding bottom - panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); - - // Indent text one cell; the panel background is painted in a - // separate pass so padding rows do not wrap. - for line in &mut panel_lines { - line.spans.insert(0, Span::styled(" ", pad_style)); + if status == "ok" { + if let Some(ref preview) = output_preview { + let result_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + push_preview_lines(&mut out, preview, max_width, result_style); } - - out.extend(panel_lines); } } else if matches!(name.as_str(), "edit" | "write") && status != "error" { let file_path = args_obj @@ -2094,10 +2594,14 @@ impl Chat { "Wrote" }; - let marker = if active { "~" } else { "•" }; + let marker = self.tool_marker(active); let marker_style = Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM); + .fg(if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); let title_style = Style::default() .fg(colors.text) .add_modifier(Modifier::BOLD); @@ -2139,50 +2643,55 @@ impl Chat { out.extend(diff_lines); } } else { - // Default header for all other tools. - let header_style = Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM); + let active = matches!(status.as_str(), "running" | "pending"); + let marker_style = Style::default() + .fg(if status == "error" { + colors.error + } else if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(if status == "error" { + colors.error + } else { + colors.text + }) + .add_modifier(Modifier::BOLD); + let args_str = if name == "skill" { + args_obj + .and_then(|o| o.get("name")) + .and_then(|v| v.as_str()) + .or_else(|| strip_tool_title(title.as_deref(), "Loaded skill")) + .map(ToString::to_string) + .unwrap_or_default() + } else { + args.as_ref().map(args_preview).unwrap_or_default() + }; + let mut spans = vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(tool_label.to_string(), title_style), + ]; + if !args_str.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled(args_str, Style::default().fg(colors.text))); + } push_wrapped( &mut out, - Line::from(Span::styled(header, header_style)), + Line::from(spans), max_width, - Line::from(Span::styled(" ", header_style)), + Line::from(Span::styled(" ", marker_style)), ); - // Render a subtle result line for completed tools. if status == "ok" { if let Some(ref preview) = output_preview { - let mut result_text = preview.clone(); - // For edits, prepend the title (e.g. "Edit: file.rs") if available. - if name == "edit" { - if let Some(ref t) = title { - result_text = format!("{} — {}", t, preview); - } - } - let result_style = Style::default().fg(colors.text_weak); - let mut emitted = 0usize; - for (line_idx, raw_line) in result_text.lines().enumerate() { - if emitted >= TOOL_RESULT_MAX_SCREEN_LINES { - out.push(Line::from(Span::styled(" …", result_style))); - break; - } - let prefix = if line_idx == 0 { " → " } else { " " }; - let line = Line::from(Span::styled( - format!("{}{}", prefix, raw_line), - result_style, - )); - let before = out.len(); - push_limited_wrapped( - &mut out, - line, - max_width, - Line::from(Span::styled(" ", result_style)), - TOOL_RESULT_MAX_SCREEN_LINES.saturating_sub(emitted), - result_style, - ); - emitted += out.len().saturating_sub(before); - } + let result_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + push_preview_lines(&mut out, preview, max_width, result_style); } } } @@ -2633,6 +3142,82 @@ mod tests { assert!(rendered.len() <= TOOL_RESULT_MAX_SCREEN_LINES + 2); } + #[test] + fn test_webfetch_tool_renders_semantic_preview() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "webfetch", + "status": "ok", + "args": { "url": "https://gittydocs.carlo.tl/llms.txt" }, + "metadata": { "url": "https://gittydocs.carlo.tl/llms.txt" }, + "output_preview": "# gittydocs\n\nSimple, fast docs from your Markdown.", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert_eq!( + rendered[0], + "⬢ Webfetch https://gittydocs.carlo.tl/llms.txt" + ); + assert_eq!(rendered[1], " └ # gittydocs"); + assert!(rendered + .iter() + .any(|line| line.contains("Simple, fast docs"))); + assert!(!rendered.iter().any(|line| line.contains("curl"))); + } + + #[test] + fn test_active_tool_marker_uses_animation_phase() { + let mut chat = Chat::new(); + let content = serde_json::json!({ + "name": "webfetch", + "status": "running", + "args": { "url": "https://example.com" }, + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let first_frame = chat + .format_tool_row(&msg, 80, &colors, false) + .iter() + .map(line_text) + .collect::<Vec<_>>(); + chat.tool_marker_animation_phase = true; + let second_frame = chat + .format_tool_row(&msg, 80, &colors, false) + .iter() + .map(line_text) + .collect::<Vec<_>>(); + + assert_eq!(first_frame[0], "⬡ Webfetch https://example.com"); + assert_eq!(second_frame[0], "⬢ Webfetch https://example.com"); + } + + #[test] + fn test_bash_tool_renders_ran_command_preview() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "bash", + "status": "ok", + "args": { "command": "printf hello" }, + "metadata": { "command": "printf hello", "exit_code": 0 }, + "output_preview": "hello", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert_eq!(rendered, vec!["⬢ Ran printf hello", " └ hello"]); + } + #[test] fn test_read_tool_renders_codex_style_explored_summary() { let chat = Chat::new(); @@ -2649,7 +3234,7 @@ mod tests { let lines = chat.format_tool_row(&msg, 80, &colors, false); let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); - assert_eq!(rendered, vec!["• Explored", " └ Read AGENTS.md"]); + assert_eq!(rendered, vec!["⬢ Explored", " └ Read AGENTS.md"]); } #[test] @@ -2668,7 +3253,7 @@ mod tests { let lines = chat.format_tool_row(&msg, 80, &colors, false); let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); - assert_eq!(rendered, vec!["• Explored", " └ List src/ui"]); + assert_eq!(rendered, vec!["⬢ Explored", " └ List src/ui"]); } #[test] @@ -2709,7 +3294,7 @@ mod tests { assert_eq!( rendered, vec![ - "• Explored", + "⬢ Explored", " └ List .", " Read README.md", " Search opencode|codex in references", @@ -2739,7 +3324,7 @@ mod tests { assert_eq!( rendered, - vec!["• Explored", " └ Read README.md, AGENTS.md", ""] + vec!["⬢ Explored", " └ Read README.md, AGENTS.md", ""] ); } @@ -2767,7 +3352,7 @@ mod tests { assert_eq!( rendered, vec![ - "• Edited README.md (+1 -1)", + "⬢ Edited README.md (+1 -1)", " 3 alpha", " 4 -beta", " 4 +bravo", @@ -2797,7 +3382,7 @@ mod tests { assert_eq!( rendered, - vec!["• Added src/new.rs (+1 -0)", " 1 +fn main() {}"] + vec!["⬢ Added src/new.rs (+1 -0)", " 1 +fn main() {}"] ); } @@ -2848,12 +3433,13 @@ mod tests { let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); - assert_eq!(rendered.len(), 6); - assert!(rendered[0].trim().is_empty()); - assert_eq!(rendered[1].trim(), "# Questions"); - assert!(rendered[3].contains("Provide columns and rows")); - assert!(rendered[4].trim().is_empty()); + assert_eq!(rendered.len(), 7); + assert_eq!(rendered[0].trim(), "⬢ Questions"); + assert!(rendered[1].trim().is_empty()); + assert_eq!(rendered[2].trim(), "# Questions"); + assert!(rendered[4].contains("Provide columns and rows")); assert!(rendered[5].trim().is_empty()); + assert!(rendered[6].trim().is_empty()); } #[test] @@ -2882,7 +3468,7 @@ mod tests { } #[test] - fn test_task_tool_renders_opencode_style_summary() { + fn test_task_tool_renders_cursor_style_subagent_summary() { let chat = Chat::new(); let content = serde_json::json!({ "name": "task", @@ -2908,13 +3494,13 @@ mod tests { assert!(rendered .iter() - .any(|line| line.contains("General Task") && line.contains("Say hi"))); + .any(|line| line.contains("Started 1 subagent"))); assert!(rendered .iter() - .any(|line| line.contains("0 toolcalls") && line.contains("4.1s"))); + .any(|line| line.contains("ctrl+x down to view subagents"))); assert!(rendered .iter() - .any(|line| line.contains("ctrl+x down view subagents"))); + .any(|line| line.contains("⬢ General - Say hi #1"))); assert!(!rendered .iter() .any(|line| line.contains("prompt=\"Say hi\""))); @@ -2922,7 +3508,48 @@ mod tests { } #[test] - fn test_todowrite_panel_uses_bottom_margin_and_inner_padding() { + fn test_adjacent_task_tools_render_as_one_subagent_group() { + let mut chat = Chat::new(); + for (description, status) in [ + ("read", "running"), + ("write a haiku", "ok"), + ("write a haiku", "ok"), + ] { + chat.add_message(Message::tool( + serde_json::json!({ + "name": "task", + "status": status, + "args": { + "subagent_type": "explore", + "description": description, + "prompt": description + }, + "metadata": { + "subagent_type": "explore" + } + }) + .to_string(), + )); + } + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); + + assert_eq!( + rendered, + vec![ + "⬡ Started 3 subagents - ctrl+x down to view subagents", + " ⬡ Explore - read #1", + " ⬢ Explore - write a haiku #2", + " ⬢ Explore - write a haiku #3", + "", + ] + ); + } + + #[test] + fn test_legacy_todowrite_history_renders_as_updated_plan() { let chat = Chat::new(); let content = serde_json::json!({ "name": "todowrite", @@ -2936,16 +3563,20 @@ mod tests { let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); let rendered = lines.iter().map(line_text).collect::<Vec<_>>(); - assert_eq!(rendered.len(), 7); - assert!(rendered[0].trim().is_empty()); - assert_eq!(rendered[1].trim(), "# Todos"); - assert!(rendered[4].contains("Implement rendering")); - assert!(rendered[5].trim().is_empty()); - assert!(rendered[6].trim().is_empty()); + assert_eq!( + rendered, + vec![ + "⬢ Updated Plan", + " └ □ Define table data", + " □ Choose rendering file", + " □ Implement rendering", + "", + ] + ); } #[test] - fn test_short_tool_panel_renders_with_bottom_margin() { + fn test_short_updated_plan_content_renders_at_top() { use ratatui::{backend::TestBackend, Terminal}; let mut colors = test_colors(); @@ -2971,12 +3602,11 @@ mod tests { .map(|y| buffer_row_text(buffer, 38, y)) .collect::<Vec<_>>(); - assert!(rows[0].trim().is_empty()); - assert_eq!(rows[1].trim(), "# Todos"); - assert!(rows[4].contains("Implement rendering")); + assert!(rows[0].contains("⬢ Updated Plan")); + assert!(rows[1].contains("Define table data")); + assert!(rows[3].contains("Implement rendering")); + assert!(rows[4].trim().is_empty()); assert!(rows[5].trim().is_empty()); - assert_eq!(buffer[(0, 0)].bg, colors.background_element); - assert_eq!(buffer[(0, 5)].bg, colors.background_element); assert!(rows[6].trim().is_empty()); assert!(rows[7].trim().is_empty()); } diff --git a/src/ui/markdown/table.rs b/src/ui/markdown/table.rs index 173b253..376fa8c 100644 --- a/src/ui/markdown/table.rs +++ b/src/ui/markdown/table.rs @@ -321,11 +321,11 @@ mod tests { #[test] fn test_real_world_table() { - let input = "| Category | Tool | Description |\n|----------|------|-------------|\n| **File Operations** | `read` | Read file or directory contents with pagination |\n| | `write` | Create or overwrite a file |\n| | `edit` | Replace text in files with smart matching |\n| | `list` | List directory contents in tree format |\n| | `glob` | Find files by glob pattern |\n| | `grep` | Search file contents using regex |\n| **Code & Development** | `bash` | Execute shell commands with timeout and output streaming |\n| | `task` | Launch subagents for complex multi-step tasks |\n| | `explore` | Fast agent for exploring codebases (read-only) |\n| | `general` | General-purpose agent for research and complex tasks |\n| **Specialized Skills** | `skill` | Load domain-specific skills (frontend-design, ratatui) |\n| **Data & Search** | `question` | Ask user questions during execution |\n| | `todowrite` | Create and manage structured task lists |\n| | `webfetch` | Fetch content from URLs and convert to markdown |"; + let input = "| Category | Tool | Description |\n|----------|------|-------------|\n| **File Operations** | `read` | Read file or directory contents with pagination |\n| | `write` | Create or overwrite a file |\n| | `edit` | Replace text in files with smart matching |\n| | `list` | List directory contents in tree format |\n| | `glob` | Find files by glob pattern |\n| | `grep` | Search file contents using regex |\n| **Code & Development** | `bash` | Execute shell commands with timeout and output streaming |\n| | `task` | Launch subagents for complex multi-step tasks |\n| | `explore` | Fast agent for exploring codebases (read-only) |\n| | `general` | General-purpose agent for research and complex tasks |\n| **Specialized Skills** | `skill` | Load domain-specific skills (frontend-design, ratatui) |\n| **Data & Search** | `question` | Ask user questions during execution |\n| | `update_plan` | Update the current task plan |\n| | `webfetch` | Fetch content from URLs and convert to markdown |"; let result = preprocess_tables(input, 80); assert!(result.contains("File Operations")); assert!(result.contains("Specialized Skills")); - assert!(result.contains("todowrite")); + assert!(result.contains("update_plan")); // Each row should have 3 cells — no concatenation assert!(!result.contains("File Operations`read`")); assert!(!result.contains('|')); From 114ae2c6645072ef8d9b0e868356b72e416e713e Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 22:15:05 +0800 Subject: [PATCH 101/226] refactor: extract search area height into a named constant. The search area height was hardcoded as `3` in multiple places. This extracts it into a `SEARCH_AREA_HEIGHT` constant and updates related test coordinates across dialog views to match the new height. --- src/ui/components/dialog.rs | 8 +++++--- src/views/models_dialog.rs | 4 ++-- src/views/sessions_dialog.rs | 6 +++--- src/views/themes_dialog.rs | 4 ++-- src/views/timeline_dialog.rs | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 8fd324a..8d656cf 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -20,6 +20,8 @@ use std::collections::{HashMap, HashSet}; use tui_textarea::{Input as TuiInput, TextArea}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +const SEARCH_AREA_HEIGHT: u16 = 2; + #[derive(Debug, Clone, Copy, PartialEq)] pub enum DialogPosition { Left, @@ -536,7 +538,7 @@ impl Dialog { const DIALOG_HEIGHT_CENTER: u16 = 25; let footer_height = self.footer_height(); - let total_fixed_height = 1 + 1 + 3 + 1 + footer_height; + let total_fixed_height = 1 + 1 + SEARCH_AREA_HEIGHT + 1 + footer_height; let padding = match self.position { DialogPosition::Center => 3u16, DialogPosition::Left | DialogPosition::Right => 1u16, @@ -614,7 +616,7 @@ impl Dialog { [ ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(3), + ratatui::layout::Constraint::Length(SEARCH_AREA_HEIGHT), ratatui::layout::Constraint::Min(0), ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(self.footer_height()), @@ -1598,7 +1600,7 @@ mod tests { width: 40, height: 20, }; - dialog.visible_row_count = 7; + dialog.visible_row_count = 8; let handled = dialog.handle_mouse_event(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), diff --git a/src/views/models_dialog.rs b/src/views/models_dialog.rs index 45eff74..5cb9aec 100644 --- a/src/views/models_dialog.rs +++ b/src/views/models_dialog.rs @@ -192,7 +192,7 @@ mod tests { height: 30, }; - let action = handle_models_dialog_mouse_event(&mut state, left_click(4, 10)); + let action = handle_models_dialog_mouse_event(&mut state, left_click(4, 9)); assert_eq!( action, @@ -215,7 +215,7 @@ mod tests { height: 30, }; - let action = handle_models_dialog_mouse_event(&mut state, left_click(4, 8)); + let action = handle_models_dialog_mouse_event(&mut state, left_click(4, 7)); assert_eq!(action, ModelsDialogAction::None); assert!(state.dialog.is_visible()); diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index 26d746f..8aaae4b 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -366,7 +366,7 @@ mod tests { height: 30, }; - let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 8)); + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 7)); assert_eq!( action, @@ -387,13 +387,13 @@ mod tests { height: 30, }; - let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 6)); + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 5)); assert_eq!(action, SessionsDialogAction::Handled); assert!(state.dialog.is_group_collapsed("Today")); assert_eq!(state.dialog.selected_index, 0); - let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 6)); + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 5)); assert_eq!(action, SessionsDialogAction::Handled); assert!(!state.dialog.is_group_collapsed("Today")); diff --git a/src/views/themes_dialog.rs b/src/views/themes_dialog.rs index 5ca368d..f6b416b 100644 --- a/src/views/themes_dialog.rs +++ b/src/views/themes_dialog.rs @@ -179,7 +179,7 @@ mod tests { let action = handle_themes_dialog_mouse_event( &mut state, - mouse(MouseEventKind::Down(MouseButton::Left), 4, 10), + mouse(MouseEventKind::Down(MouseButton::Left), 4, 9), ); assert_eq!( @@ -209,7 +209,7 @@ mod tests { }; let action = - handle_themes_dialog_mouse_event(&mut state, mouse(MouseEventKind::Moved, 4, 10)); + handle_themes_dialog_mouse_event(&mut state, mouse(MouseEventKind::Moved, 4, 9)); assert_eq!( action, diff --git a/src/views/timeline_dialog.rs b/src/views/timeline_dialog.rs index bc18520..533dd41 100644 --- a/src/views/timeline_dialog.rs +++ b/src/views/timeline_dialog.rs @@ -327,7 +327,7 @@ mod tests { height: 30, }; - let action = handle_timeline_dialog_mouse_event(&mut state, left_click(2, 6)); + let action = handle_timeline_dialog_mouse_event(&mut state, left_click(2, 5)); assert_eq!(action, TimelineDialogAction::Select(0)); } From c89ee53bb1fa78f2621134c4dfe0841ed01821e6 Mon Sep 17 00:00:00 2001 From: Blankeos <carloantonioct@gmail.com> Date: Tue, 19 May 2026 22:30:59 +0800 Subject: [PATCH 102/226] feat(webfetch): overhaul HTML conversion, add streaming, Cloudflare handling, and content validation. - Rewrite HTML-to-markdown/text conversion with proper tag parsing, nesting, and entity decoding - Add streaming response body reader with size limit enforcement - Detect Cloudflare challenges and retry with an honest user-agent - Validate output format parameter and reject non-text content - Track final URL after redirects and return as metadata - Add metadata fallback (title/description) for empty conversions - Add HTML-to-text conversion mode - Add unit tests for core conversion logic --- src/tools/webfetch.rs | 714 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 586 insertions(+), 128 deletions(-) diff --git a/src/tools/webfetch.rs b/src/tools/webfetch.rs index 1b2630f..024ea3e 100644 --- a/src/tools/webfetch.rs +++ b/src/tools/webfetch.rs @@ -1,12 +1,24 @@ use crate::tools::{ - get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, - ToolError, ToolHandler, ToolResult, + get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; +use futures::StreamExt; +use reqwest::{ + header::{ACCEPT, ACCEPT_LANGUAGE, USER_AGENT}, + Response, StatusCode, +}; use serde_json::Value; pub struct WebfetchTool; +const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; +const DEFAULT_TIMEOUT_SECS: u64 = 30; +const MAX_TIMEOUT_SECS: u64 = 120; +const BROWSER_USER_AGENT: &str = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; +const HONEST_USER_AGENT: &str = "crabcode/0.1"; + impl WebfetchTool { pub fn new() -> Self { Self @@ -34,7 +46,7 @@ impl ToolHandler for WebfetchTool { }, ParameterSchema { name: "timeout".to_string(), - description: "Optional timeout in seconds (max 30)".to_string(), + description: "Optional timeout in seconds (max 120)".to_string(), required: false, param_type: ParameterType::Integer, }, @@ -52,18 +64,24 @@ impl ToolHandler for WebfetchTool { )); } + if let Some(format) = get_string_param(params, "format") { + if !matches!(format.as_str(), "markdown" | "text" | "html") { + return Err(ToolError::Validation( + "Format must be one of: markdown, text, html".to_string(), + )); + } + } + Ok(()) } async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> { let raw_url = get_string_param(¶ms, "url").unwrap_or_default(); let format = get_string_param(¶ms, "format").unwrap_or_else(|| "markdown".to_string()); - let timeout_secs = params - .get("timeout") - .and_then(|v| v.as_i64()) - .unwrap_or(30) + let timeout_secs = get_integer_param(¶ms, "timeout") + .unwrap_or(DEFAULT_TIMEOUT_SECS as i64) .max(1) - .min(30) as u64; + .min(MAX_TIMEOUT_SECS as i64) as u64; let url = if raw_url.starts_with("http://") { format!("https://{}", &raw_url[7..]) @@ -72,17 +90,20 @@ impl ToolHandler for WebfetchTool { }; let client = reqwest::Client::builder() - .user_agent("crabcode/0.1") .timeout(std::time::Duration::from_secs(timeout_secs)) .build() .map_err(|e| ToolError::Execution(format!("Failed to create HTTP client: {}", e)))?; - let response = client - .get(&url) - .send() + let mut response = send_request(&client, &url, &format, BROWSER_USER_AGENT) .await .map_err(|e| ToolError::Execution(format!("Failed to fetch URL: {}", e)))?; + if is_cloudflare_challenge(&response) { + response = send_request(&client, &url, &format, HONEST_USER_AGENT) + .await + .map_err(|e| ToolError::Execution(format!("Failed to fetch URL: {}", e)))?; + } + let status = response.status(); if !status.is_success() { return Err(ToolError::Execution(format!( @@ -92,6 +113,16 @@ impl ToolHandler for WebfetchTool { ))); } + if let Some(length) = response.content_length() { + if length > MAX_RESPONSE_SIZE as u64 { + return Err(ToolError::Execution(format!( + "Response too large (exceeds {}MB limit)", + MAX_RESPONSE_SIZE / 1024 / 1024 + ))); + } + } + + let final_url = response.url().to_string(); let content_type = response .headers() .get("content-type") @@ -99,14 +130,31 @@ impl ToolHandler for WebfetchTool { .unwrap_or("text/plain") .to_lowercase(); - let body = response - .text() - .await - .map_err(|e| ToolError::Execution(format!("Failed to read response body: {}", e)))?; + let body_bytes = read_limited_body(response).await?; + if !is_text_content(&content_type) { + let output = format!( + "Fetched non-text content: {} bytes ({})", + body_bytes.len(), + content_type + ); + + return Ok(ToolResult::new(format!("Fetched: {}", final_url), output) + .with_metadata("url", serde_json::json!(final_url)) + .with_metadata("content_type", serde_json::json!(content_type))); + } + + let body = String::from_utf8_lossy(&body_bytes).into_owned(); let output = match format.as_str() { "html" => body, - "text" | "markdown" => { + "text" => { + if content_type.contains("html") { + html_to_text(&body) + } else { + body + } + } + "markdown" => { if content_type.contains("html") { html_to_markdown(&body) } else { @@ -123,165 +171,575 @@ impl ToolHandler for WebfetchTool { output }; - Ok(ToolResult::new(format!("Fetched: {}", url), truncated) - .with_metadata("url", serde_json::json!(url))) + Ok( + ToolResult::new(format!("Fetched: {}", final_url), truncated) + .with_metadata("url", serde_json::json!(final_url)) + .with_metadata("content_type", serde_json::json!(content_type)), + ) } } +async fn send_request( + client: &reqwest::Client, + url: &str, + format: &str, + user_agent: &str, +) -> Result<Response, reqwest::Error> { + client + .get(url) + .header(USER_AGENT, user_agent) + .header(ACCEPT, accept_header(format)) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .send() + .await +} + +fn accept_header(format: &str) -> &'static str { + match format { + "markdown" => { + "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" + } + "text" => "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1", + "html" => { + "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" + } + _ => "*/*", + } +} + +fn is_cloudflare_challenge(response: &Response) -> bool { + response.status() == StatusCode::FORBIDDEN + && response + .headers() + .get("cf-mitigated") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("challenge")) +} + +async fn read_limited_body(response: Response) -> Result<Vec<u8>, ToolError> { + let mut stream = response.bytes_stream(); + let mut body = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk + .map_err(|e| ToolError::Execution(format!("Failed to read response body: {}", e)))?; + if body.len() + chunk.len() > MAX_RESPONSE_SIZE { + return Err(ToolError::Execution(format!( + "Response too large (exceeds {}MB limit)", + MAX_RESPONSE_SIZE / 1024 / 1024 + ))); + } + body.extend_from_slice(&chunk); + } + + Ok(body) +} + +fn is_text_content(content_type: &str) -> bool { + content_type.starts_with("text/") + || content_type.contains("json") + || content_type.contains("xml") + || content_type.contains("javascript") + || content_type.contains("x-www-form-urlencoded") + || content_type.is_empty() +} + fn html_to_markdown(html: &str) -> String { + let converted = convert_html(html, true); + if converted.trim().is_empty() { + metadata_fallback(html) + } else { + converted + } +} + +fn html_to_text(html: &str) -> String { + let converted = convert_html(html, false); + if converted.trim().is_empty() { + metadata_fallback(html) + } else { + converted + } +} + +fn convert_html(html: &str, markdown: bool) -> String { let mut result = String::new(); - let mut in_script = false; - let mut in_style = false; let mut in_tag = false; - let mut tag_name = String::new(); - let mut link_text = String::new(); - let mut link_href = String::new(); - let mut in_a = false; - let mut newlines_since_text: u32 = 0; + let mut raw_tag = String::new(); + let mut skip_tag: Option<String> = None; + let mut link: Option<LinkState> = None; for ch in html.chars() { if ch == '<' { in_tag = true; - tag_name.clear(); + raw_tag.clear(); continue; } if in_tag { if ch == '>' { in_tag = false; - let tn = tag_name.to_lowercase(); - - if tn == "script" || tn.starts_with("script ") { - in_script = true; - } else if tn == "/script" { - in_script = false; - } else if tn == "style" || tn.starts_with("style ") { - in_style = true; - } else if tn == "/style" { - in_style = false; - } else if tn == "a" || tn.starts_with("a ") { - in_a = true; - link_text.clear(); - link_href.clear(); - if let Some(href_start) = tn.find("href=") { - let after = &tn[href_start + 5..]; - if let Some(rest) = - after.strip_prefix('"').or_else(|| after.strip_prefix('\'')) - { - if let Some(end) = rest.find('"').or_else(|| rest.find('\'')) { - link_href = rest[..end].to_string(); - } - } - } - } else if tn == "/a" { - if !link_text.is_empty() && !link_href.is_empty() { - result.push_str(&format!("[{}]({})", link_text.trim(), link_href.trim())); - } else { - result.push_str(&link_text); - } - in_a = false; - link_text.clear(); - } else if tn == "br" || tn == "br/" || tn == "hr" || tn == "hr/" { - result.push('\n'); - } else if tn == "p" - || tn == "/p" - || tn == "div" - || tn == "/div" - || tn == "/h1" - || tn == "/h2" - || tn == "/h3" - || tn == "/h4" - || tn == "/h5" - || tn == "/h6" - || tn == "/li" - || tn == "/ul" - || tn == "/ol" - || tn == "/tr" - || tn == "/blockquote" - { - if !result.ends_with('\n') { - result.push('\n'); - } - result.push('\n'); - newlines_since_text = 2; - } else if tn == "li" || tn.starts_with("li ") { - result.push_str("\n- "); - } else if tn.starts_with("h1 ") - || tn.starts_with("h2 ") - || tn.starts_with("h3 ") - || tn.starts_with("h4 ") - || tn.starts_with("h5 ") - || tn.starts_with("h6 ") - { - if !result.ends_with('\n') { - result.push('\n'); - } - } - - tag_name.clear(); + handle_tag(&raw_tag, markdown, &mut result, &mut skip_tag, &mut link); + raw_tag.clear(); continue; } - if ch != '/' && !tag_name.is_empty() || ch == ' ' && !tag_name.is_empty() { - if ch == ' ' { - tag_name.push(' '); - } else if ch != '/' { - tag_name.push(ch); - } - } else if ch != '/' { - tag_name.push(ch); - } + raw_tag.push(ch); continue; } - if in_script || in_style { + if skip_tag.is_some() { continue; } - if in_a { - link_text.push(ch); + if let Some(link) = &mut link { + link.text.push(ch); continue; } - if ch.is_whitespace() { - if !result.ends_with(' ') && newlines_since_text == 0 && !result.ends_with('\n') { - result.push(' '); + push_text(&mut result, ch); + } + + if let Some(link) = link { + push_link(&mut result, link, markdown); + } + + clean_output(&result) +} + +#[derive(Debug)] +struct HtmlTag { + name: String, + attrs: String, + closing: bool, + self_closing: bool, +} + +#[derive(Debug)] +struct LinkState { + text: String, + href: Option<String>, +} + +fn handle_tag( + raw_tag: &str, + markdown: bool, + result: &mut String, + skip_tag: &mut Option<String>, + link: &mut Option<LinkState>, +) { + let Some(tag) = parse_tag(raw_tag) else { + return; + }; + + if let Some(skipped) = skip_tag.as_ref() { + if tag.closing && tag.name == *skipped { + *skip_tag = None; + } + return; + } + + if is_skipped_tag(&tag.name) && !tag.closing { + if !tag.self_closing { + *skip_tag = Some(tag.name); + } + return; + } + + if tag.name == "a" { + if tag.closing { + if let Some(link_state) = link.take() { + push_link(result, link_state, markdown); } } else { - result.push(ch); - newlines_since_text = 0; + *link = Some(LinkState { + text: String::new(), + href: extract_attr(&tag.attrs, "href"), + }); + } + return; + } + + if tag.closing { + if is_block_tag(&tag.name) || is_heading_tag(&tag.name) { + ensure_blank_line(result); + } + return; + } + + match tag.name.as_str() { + "br" => ensure_newline(result), + "hr" => { + ensure_blank_line(result); + if markdown { + result.push_str("---"); + ensure_blank_line(result); + } + } + "li" => { + ensure_newline(result); + if markdown { + result.push_str("- "); + } } + "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => { + ensure_blank_line(result); + if markdown { + let level = tag.name[1..].parse::<usize>().unwrap_or(1); + result.push_str(&"#".repeat(level)); + result.push(' '); + } + } + name if is_block_tag(name) => ensure_blank_line(result), + _ => {} + } +} + +fn parse_tag(raw_tag: &str) -> Option<HtmlTag> { + let mut tag = raw_tag.trim(); + if tag.is_empty() || tag.starts_with('!') || tag.starts_with('?') || tag.starts_with("!--") { + return None; } - if in_a && !link_text.is_empty() { - if !link_href.is_empty() { - result.push_str(&format!("[{}]({})", link_text.trim(), link_href.trim())); + let closing = tag.starts_with('/'); + if closing { + tag = tag[1..].trim_start(); + } + + let self_closing = tag.ends_with('/'); + if self_closing { + tag = tag[..tag.len().saturating_sub(1)].trim_end(); + } + + let name_end = tag + .find(|ch: char| ch.is_whitespace() || ch == '/') + .unwrap_or(tag.len()); + if name_end == 0 { + return None; + } + + Some(HtmlTag { + name: tag[..name_end].to_ascii_lowercase(), + attrs: tag[name_end..].trim().to_string(), + closing, + self_closing, + }) +} + +fn is_skipped_tag(name: &str) -> bool { + matches!( + name, + "head" | "script" | "style" | "noscript" | "iframe" | "object" | "embed" | "svg" | "canvas" + ) +} + +fn is_heading_tag(name: &str) -> bool { + matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") +} + +fn is_block_tag(name: &str) -> bool { + matches!( + name, + "address" + | "article" + | "aside" + | "blockquote" + | "div" + | "dl" + | "dt" + | "dd" + | "fieldset" + | "figcaption" + | "figure" + | "footer" + | "form" + | "header" + | "main" + | "nav" + | "ol" + | "p" + | "pre" + | "section" + | "table" + | "tbody" + | "td" + | "tfoot" + | "th" + | "thead" + | "tr" + | "ul" + ) +} + +fn extract_attr(attrs: &str, name: &str) -> Option<String> { + let pattern = format!( + r#"(?is)(?:^|\s){}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#, + regex::escape(name) + ); + let re = regex::Regex::new(&pattern).ok()?; + let captures = re.captures(attrs)?; + for idx in 1..=3 { + if let Some(value) = captures.get(idx) { + return Some(decode_html_entities(value.as_str()).trim().to_string()); + } + } + None +} + +fn push_link(result: &mut String, link: LinkState, markdown: bool) { + let text = normalize_inline(&link.text); + if text.is_empty() { + return; + } + + if markdown { + if let Some(href) = link.href.filter(|href| !href.trim().is_empty()) { + result.push_str(&format!("[{}]({})", text, href.trim())); } else { - result.push_str(&link_text); + result.push_str(&text); } + } else { + result.push_str(&text); } +} - let cleaned = result - .lines() - .map(|l| l.trim_end()) - .collect::<Vec<_>>() - .join("\n"); +fn push_text(result: &mut String, ch: char) { + if ch.is_whitespace() { + if !result + .chars() + .last() + .is_some_and(|last| last.is_whitespace()) + { + result.push(' '); + } + } else { + result.push(ch); + } +} + +fn ensure_newline(result: &mut String) { + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } +} - let trimmed = cleaned.trim().to_string(); +fn ensure_blank_line(result: &mut String) { + if result.trim().is_empty() { + return; + } + while result.ends_with(' ') || result.ends_with('\t') { + result.pop(); + } + if result.ends_with("\n\n") { + return; + } + ensure_newline(result); + result.push('\n'); +} + +fn clean_output(input: &str) -> String { + let decoded = decode_html_entities(input); let mut final_result = String::new(); let mut blank_count = 0u32; - for line in trimmed.lines() { - if line.trim().is_empty() { + + for line in decoded.lines() { + let line = normalize_inline(line); + if line.is_empty() { blank_count += 1; - if blank_count <= 2 { + if blank_count <= 1 { final_result.push('\n'); } } else { blank_count = 0; - final_result.push_str(line); + final_result.push_str(&line); final_result.push('\n'); } } final_result.trim().to_string() } + +fn normalize_inline(input: &str) -> String { + decode_html_entities(input) + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") +} + +fn decode_html_entities(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch != '&' { + output.push(ch); + continue; + } + + let mut entity = String::new(); + let mut lookahead = chars.clone(); + let mut found_semicolon = false; + + for _ in 0..32 { + let Some(next) = lookahead.next() else { + break; + }; + if next == ';' { + found_semicolon = true; + break; + } + if next.is_whitespace() || next == '&' { + break; + } + entity.push(next); + } + + if !found_semicolon { + output.push('&'); + continue; + } + + for _ in 0..=entity.chars().count() { + chars.next(); + } + + if let Some(decoded) = decode_entity(&entity) { + output.push(decoded); + } else { + output.push('&'); + output.push_str(&entity); + output.push(';'); + } + } + + output +} + +fn decode_entity(entity: &str) -> Option<char> { + match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some(' '), + _ if entity == "#39" => Some('\''), + _ if entity.starts_with("#x") || entity.starts_with("#X") => { + u32::from_str_radix(&entity[2..], 16) + .ok() + .and_then(char::from_u32) + } + _ if entity.starts_with('#') => entity[1..].parse::<u32>().ok().and_then(char::from_u32), + _ => None, + } +} + +fn metadata_fallback(html: &str) -> String { + let mut parts = Vec::new(); + + if let Some(title) = extract_title(html) { + parts.push(title); + } + if let Some(description) = extract_meta_description(html) { + parts.push(description); + } + + parts.join("\n\n") +} + +fn extract_title(html: &str) -> Option<String> { + let re = regex::Regex::new(r"(?is)<title[^>]*>(.*?)").ok()?; + let title = re.captures(html)?.get(1)?.as_str(); + let title = normalize_inline(title); + (!title.is_empty()).then_some(title) +} + +fn extract_meta_description(html: &str) -> Option { + let re = regex::Regex::new(r"(?is)]+)>").ok()?; + for captures in re.captures_iter(html) { + let attrs = captures.get(1).map(|m| m.as_str()).unwrap_or_default(); + let name = extract_attr(attrs, "name") + .or_else(|| extract_attr(attrs, "property")) + .unwrap_or_default() + .to_ascii_lowercase(); + if matches!( + name.as_str(), + "description" | "og:description" | "twitter:description" + ) { + let Some(content) = extract_attr(attrs, "content") else { + continue; + }; + let content = normalize_inline(&content); + if !content.is_empty() { + return Some(content); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn markdown_continues_after_script_with_closing_tag() { + let html = r#" + + + +

Profile Heading

+

This paragraph should remain visible after a script tag.

+ + + "#; + + let markdown = html_to_markdown(html); + + assert!(markdown.contains("# Profile Heading")); + assert!(markdown.contains("This paragraph should remain visible")); + } + + #[test] + fn markdown_skips_script_and_style_text() { + let html = r#" + + +

Visible content

+ "#; + + let markdown = html_to_markdown(html); + + assert_eq!(markdown, "Visible content"); + } + + #[test] + fn markdown_preserves_links_without_lowercasing_href() { + let html = r#"

Read the docs.

"#; + + let markdown = html_to_markdown(html); + + assert_eq!( + markdown, + "Read [the docs](https://Example.com/Path?A=1&B=2)." + ); + } + + #[test] + fn metadata_fallback_prevents_empty_html_result() { + let html = r#" + + + Example Page + + + + + "#; + + let markdown = html_to_markdown(html); + + assert_eq!(markdown, "Example Page\n\nFallback summary for the page."); + } +} From 2a42856c4c90fb711810d7ba690257ac146f5ad5 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 01:56:53 +0800 Subject: [PATCH 103/226] refactor: centralize CWD resolution and embed themes at compile time. - Extract CWD resolution into `crate::utils::cwd` with fallback (PWD, home, tmp) for when the process current directory has been deleted - Embed all bundled themes via `include_str!` so they are always available without relying on filesystem paths at runtime - Add `Theme::load_builtin_default()`, `Theme::bundled_themes()`, and `Theme::load_from_str()` to support the new approach - Refactor `discover_themes` to use bundled themes as a base layer - Migrate all call sites to use the new CWD and theme APIs --- src/agent/subagent.rs | 2 +- src/app.rs | 11 +-- src/config/configuration.rs | 33 ++++--- src/main.rs | 15 +-- src/persistence/history.rs | 3 +- src/session/manager.rs | 3 +- src/theme.rs | 91 ++++++++++++++++- src/utils/cwd.rs | 188 ++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 1 + 9 files changed, 313 insertions(+), 34 deletions(-) create mode 100644 src/utils/cwd.rs diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 6616ee3..37eaf7f 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -144,7 +144,7 @@ pub async fn run_subagent( use std::collections::HashMap; let session = get_llm_session().ok_or("LLM session not configured")?; - let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let cwd = crate::utils::cwd::current_dir_or_dot(); let scoped_registry = build_scoped_registry(full_registry, &subagent_type).await; let permissions = crate::tools::ToolPermissions::new(cwd.clone()); diff --git a/src/app.rs b/src/app.rs index 381857e..f9750d3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -254,7 +254,7 @@ impl App { let mut input = Input::new().with_autocomplete(autocomplete); input.set_placeholder(placeholder_static); - let cwd_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let cwd_path = crate::utils::cwd::current_dir()?; let cwd = cwd_path .to_str() .map(|s| s.to_string()) @@ -368,11 +368,7 @@ impl App { .get(current_theme_index) .or_else(|| themes.first()) .cloned() - .unwrap_or_else(|| { - theme::Theme::load_from_file("src/theme.json").unwrap_or_else(|_| { - theme::Theme::load_from_file("src/generated_themes/ayu.json").unwrap() - }) - }); + .unwrap_or_else(theme::Theme::load_builtin_default); let colors = theme_for_colors.get_colors(true); let chat_state = init_chat(chat, &agent, &colors); @@ -4830,8 +4826,7 @@ mod tests { let mut registry = Registry::new(); register_all_commands(&mut registry); - let theme = Theme::load_from_file("src/theme.json") - .unwrap_or_else(|_| Theme::load_from_file("src/generated_themes/ayu.json").unwrap()); + let theme = Theme::load_builtin_default(); let colors = theme.get_colors(true); App { diff --git a/src/config/configuration.rs b/src/config/configuration.rs index f71c7cc..375149a 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -14,6 +14,10 @@ pub fn discover_themes( let mut theme_by_id: HashMap = HashMap::new(); let mut themes: Vec = Vec::new(); + for theme in crate::theme::Theme::bundled_themes() { + upsert_theme(&mut themes, &mut theme_by_id, theme); + } + let mut layers: Vec> = Vec::new(); let mut built_in = Vec::new(); @@ -45,21 +49,12 @@ pub fn discover_themes( let Ok(theme) = crate::theme::Theme::load_from_file(&path) else { continue; }; - if let Some(idx) = theme_by_id.get(&theme.id).copied() { - themes[idx] = theme; - } else { - let idx = themes.len(); - theme_by_id.insert(theme.id.clone(), idx); - themes.push(theme); - } + upsert_theme(&mut themes, &mut theme_by_id, theme); } } if themes.is_empty() { - let fallback = crate::theme::Theme::load_from_file("src/theme.json").unwrap_or_else(|_| { - crate::theme::Theme::load_from_file("src/generated_themes/ayu.json").unwrap() - }); - themes.push(fallback); + themes.push(crate::theme::Theme::load_builtin_default()); } let mut selected_idx = 0usize; @@ -72,6 +67,20 @@ pub fn discover_themes( (themes, selected_idx) } +fn upsert_theme( + themes: &mut Vec, + theme_by_id: &mut HashMap, + theme: crate::theme::Theme, +) { + if let Some(idx) = theme_by_id.get(&theme.id).copied() { + themes[idx] = theme; + } else { + let idx = themes.len(); + theme_by_id.insert(theme.id.clone(), idx); + themes.push(theme); + } +} + fn list_json_files(dir: &Path) -> Vec { let mut out = Vec::new(); let rd = match fs::read_dir(dir) { @@ -192,7 +201,7 @@ pub struct ConfigLoader; impl ConfigLoader { pub fn load() -> Result { - let cwd = std::env::current_dir().context("Failed to determine current directory")?; + let cwd = crate::utils::cwd::current_dir()?; let xdg_config_home = xdg_config_home(); let project_root = discover_project_root(&cwd); diff --git a/src/main.rs b/src/main.rs index 568571a..bee025a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -121,10 +121,7 @@ async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<() .clone() .unwrap_or_else(|| "Build".to_string()); - let cwd = std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from(".")) - .to_string_lossy() - .to_string(); + let cwd = loaded_config.cwd.to_string_lossy().to_string(); let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); @@ -150,10 +147,11 @@ async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<() let provider_name_clone = provider_name.clone(); let model_clone = model_id.clone(); + let completion_sender = sender.clone(); tokio::spawn(async move { let cancel_token = tokio_util::sync::CancellationToken::new(); - let _ = stream_llm_with_cancellation( + if let Err(err) = stream_llm_with_cancellation( cancel_token, cuid2::create_id(), provider_name_clone, @@ -164,7 +162,12 @@ async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<() messages, sender, ) - .await; + .await + { + let _ = completion_sender.send(crate::llm::ChunkMessage::Failed(err.to_string())); + } + + let _ = completion_sender.send(crate::llm::ChunkMessage::End); }); while let Some(chunk) = receiver.recv().await { diff --git a/src/persistence/history.rs b/src/persistence/history.rs index 099187a..f2dbe1a 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -87,8 +87,7 @@ impl HistoryDAO { [], ); - let current_workspace_path = std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from(".")) + let current_workspace_path = crate::utils::cwd::current_dir_or_dot() .to_string_lossy() .to_string(); let current_workspace_name = workspace_display_name(¤t_workspace_path); diff --git a/src/session/manager.rs b/src/session/manager.rs index 180d33c..cbe0c6a 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -46,8 +46,7 @@ pub struct SessionManager { impl SessionManager { pub fn new() -> Self { - let current_workspace_path = std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from(".")) + let current_workspace_path = crate::utils::cwd::current_dir_or_dot() .to_string_lossy() .to_string(); let current_workspace_name = workspace_display_name(¤t_workspace_path); diff --git a/src/theme.rs b/src/theme.rs index 8a2258e..595f616 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -4,6 +4,67 @@ use std::collections::HashMap; use std::fs; use std::path::Path; +const BUNDLED_THEMES: &[(&str, &str)] = &[ + ("crabcode-orange", include_str!("theme.json")), + ("aura", include_str!("generated_themes/aura.json")), + ("ayu", include_str!("generated_themes/ayu.json")), + ("carbonfox", include_str!("generated_themes/carbonfox.json")), + ( + "catppuccin", + include_str!("generated_themes/catppuccin.json"), + ), + ( + "catppuccin-frappe", + include_str!("generated_themes/catppuccin-frappe.json"), + ), + ( + "catppuccin-macchiato", + include_str!("generated_themes/catppuccin-macchiato.json"), + ), + ("cobalt2", include_str!("generated_themes/cobalt2.json")), + ("cursor", include_str!("generated_themes/cursor.json")), + ("dracula", include_str!("generated_themes/dracula.json")), + ( + "everforest", + include_str!("generated_themes/everforest.json"), + ), + ("flexoki", include_str!("generated_themes/flexoki.json")), + ("github", include_str!("generated_themes/github.json")), + ("gruvbox", include_str!("generated_themes/gruvbox.json")), + ("kanagawa", include_str!("generated_themes/kanagawa.json")), + ( + "lucent-orng", + include_str!("generated_themes/lucent-orng.json"), + ), + ("material", include_str!("generated_themes/material.json")), + ("matrix", include_str!("generated_themes/matrix.json")), + ("mercury", include_str!("generated_themes/mercury.json")), + ("monokai", include_str!("generated_themes/monokai.json")), + ("nightowl", include_str!("generated_themes/nightowl.json")), + ("nord", include_str!("generated_themes/nord.json")), + ("one-dark", include_str!("generated_themes/one-dark.json")), + ("opencode", include_str!("generated_themes/opencode.json")), + ("orng", include_str!("generated_themes/orng.json")), + ( + "osaka-jade", + include_str!("generated_themes/osaka-jade.json"), + ), + ("palenight", include_str!("generated_themes/palenight.json")), + ("rosepine", include_str!("generated_themes/rosepine.json")), + ("solarized", include_str!("generated_themes/solarized.json")), + ( + "synthwave84", + include_str!("generated_themes/synthwave84.json"), + ), + ( + "tokyonight", + include_str!("generated_themes/tokyonight.json"), + ), + ("vercel", include_str!("generated_themes/vercel.json")), + ("vesper", include_str!("generated_themes/vesper.json")), + ("zenburn", include_str!("generated_themes/zenburn.json")), +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ThemeColors { pub primary: ratatui::style::Color, @@ -256,7 +317,6 @@ impl Theme { pub fn load_from_file>(path: P) -> Result> { let path = path.as_ref(); let content = fs::read_to_string(path)?; - let v: Value = serde_json::from_str(&content)?; // Some OpenCode theme JSONs don't include name/id; derive from filename. let derived_id = path @@ -264,6 +324,24 @@ impl Theme { .and_then(|s| s.to_str()) .unwrap_or("theme") .to_string(); + Self::load_from_str(&content, &derived_id) + } + + pub fn load_builtin_default() -> Self { + Self::load_from_str(include_str!("theme.json"), "crabcode-orange") + .expect("embedded default theme must be valid") + } + + pub fn bundled_themes() -> Vec { + BUNDLED_THEMES + .iter() + .filter_map(|(id, content)| Self::load_from_str(content, id).ok()) + .collect() + } + + fn load_from_str(content: &str, derived_id: &str) -> Result> { + let v: Value = serde_json::from_str(content)?; + let derived_id = derived_id.to_string(); let id = v .get("id") .and_then(|x| x.as_str()) @@ -293,7 +371,7 @@ impl Theme { }); } - Err(format!("Unsupported theme schema in {}", path.display()).into()) + Err(format!("Unsupported theme schema for {}", derived_id).into()) } pub fn get_colors(&self, dark: bool) -> ThemeColors { @@ -588,7 +666,7 @@ fn parse_hex(hex: &str) -> ratatui::style::Color { #[cfg(test)] mod tests { - use super::parse_hex; + use super::{parse_hex, Theme}; #[test] fn parse_hex_supports_short_rgb() { @@ -601,4 +679,11 @@ mod tests { let color = parse_hex("#112233ff"); assert_eq!(color, ratatui::style::Color::Rgb(17, 34, 51)); } + + #[test] + fn bundled_themes_include_default_theme() { + let themes = Theme::bundled_themes(); + assert!(themes.iter().any(|theme| theme.id == "crabcode-orange")); + assert!(themes.iter().any(|theme| theme.id == "ayu")); + } } diff --git a/src/utils/cwd.rs b/src/utils/cwd.rs new file mode 100644 index 0000000..c643f81 --- /dev/null +++ b/src/utils/cwd.rs @@ -0,0 +1,188 @@ +use anyhow::{anyhow, Context, Result}; +use std::ffi::OsString; +use std::io; +use std::path::PathBuf; + +pub fn current_dir() -> Result { + let resolved = resolve_current_dir( + std::env::current_dir(), + std::env::var_os("PWD"), + fallback_dir(), + )?; + + if resolved.should_chdir { + std::env::set_current_dir(&resolved.path).with_context(|| { + format!( + "Failed to recover from unavailable current directory by changing to {}", + resolved.path.display() + ) + })?; + } + + if let Some(warning) = resolved.warning { + crate::startup_diag!("Warning: {}", warning); + } + + Ok(resolved.path) +} + +pub fn current_dir_or_dot() -> PathBuf { + current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +fn fallback_dir() -> Option { + dirs::home_dir().filter(|path| path.is_dir()).or_else(|| { + let temp_dir = std::env::temp_dir(); + temp_dir.is_dir().then_some(temp_dir) + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CurrentDirResolution { + path: PathBuf, + warning: Option, + should_chdir: bool, +} + +fn resolve_current_dir( + getcwd: io::Result, + pwd: Option, + fallback: Option, +) -> Result { + match getcwd { + Ok(path) => Ok(CurrentDirResolution { + path, + warning: None, + should_chdir: false, + }), + Err(err) => { + if let Some(raw_pwd) = pwd { + if !raw_pwd.is_empty() { + let pwd = PathBuf::from(raw_pwd); + if pwd.is_dir() { + return Ok(CurrentDirResolution { + warning: Some(format!( + "Recovered from unavailable current directory ({err}) by changing to PWD: {}", + pwd.display() + )), + path: pwd, + should_chdir: true, + }); + } + + if let Some(fallback) = fallback { + return Ok(CurrentDirResolution { + warning: Some(format!( + "The previous current directory is unavailable ({err}), and PWD points to a directory that does not exist or cannot be accessed: {}. Continuing from {}.", + pwd.display(), + fallback.display() + )), + path: fallback, + should_chdir: true, + }); + } + + return Err(anyhow!( + "Failed to determine current directory. The process current directory is unavailable ({err}), PWD points to a directory that does not exist or cannot be accessed: {}, and no fallback directory was available. Run `cd ` and start crabcode again.", + pwd.display() + )); + } + } + + if let Some(fallback) = fallback { + return Ok(CurrentDirResolution { + warning: Some(format!( + "The previous current directory is unavailable ({err}). Continuing from {}.", + fallback.display() + )), + path: fallback, + should_chdir: true, + }); + } + + Err(anyhow!( + "Failed to determine current directory. The process current directory is unavailable ({err}), and no fallback directory was available. Run `cd ` and start crabcode again." + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::resolve_current_dir; + use std::ffi::OsString; + use std::io; + + #[test] + fn current_dir_falls_back_to_valid_pwd() { + let pwd = std::env::current_dir().unwrap(); + let cwd = resolve_current_dir( + Err(io::Error::new(io::ErrorKind::NotFound, "missing cwd")), + Some(OsString::from(&pwd)), + None, + ) + .unwrap(); + + assert_eq!( + cwd, + super::CurrentDirResolution { + path: pwd, + warning: Some(format!( + "Recovered from unavailable current directory (missing cwd) by changing to PWD: {}", + std::env::current_dir().unwrap().display() + )), + should_chdir: true, + } + ); + } + + #[test] + fn current_dir_recovers_from_deleted_pwd_with_fallback() { + let missing = + std::env::temp_dir().join(format!("crabcode-missing-cwd-{}", std::process::id())); + let fallback = std::env::temp_dir(); + let cwd = resolve_current_dir( + Err(io::Error::new(io::ErrorKind::NotFound, "missing cwd")), + Some(OsString::from(&missing)), + Some(fallback.clone()), + ) + .unwrap(); + + assert_eq!(cwd.path, fallback); + assert_eq!(cwd.should_chdir, true); + let warning = cwd.warning.unwrap(); + assert!(warning.contains("PWD points to a directory that does not exist")); + assert!(warning.contains(&missing.to_string_lossy().to_string())); + } + + #[test] + fn current_dir_recovers_from_missing_pwd_with_fallback() { + let fallback = std::env::temp_dir(); + let cwd = resolve_current_dir( + Err(io::Error::new(io::ErrorKind::NotFound, "missing cwd")), + None, + Some(fallback.clone()), + ) + .unwrap(); + + assert_eq!(cwd.path, fallback); + assert_eq!(cwd.should_chdir, true); + assert!(cwd + .warning + .unwrap() + .contains("The previous current directory is unavailable")); + } + + #[test] + fn current_dir_reports_when_no_fallback_exists() { + let err = resolve_current_dir( + Err(io::Error::new(io::ErrorKind::NotFound, "missing cwd")), + None, + None, + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("no fallback directory was available")); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 2997a34..857c192 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod clipboard; +pub mod cwd; pub mod frecency; pub mod git; pub mod ignore; From ececcee9258288f733786efbf487dcf224014130 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 01:57:35 +0800 Subject: [PATCH 104/226] chore(benchmax): add makeshift agent benchmarking script. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `just bench-agents` command and `scripts/bench-agents.ts` — a make-shift benchmark for comparing crabcode, opencode, and codex on small deterministic agent tasks with pass/fail checks, cost estimation, Markdown reports, and JSON output. --- .gitignore | 2 + justfile | 3 + scripts/bench-agents.ts | 890 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 895 insertions(+) create mode 100644 scripts/bench-agents.ts diff --git a/.gitignore b/.gitignore index 9ccbe36..21c6b02 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ _dev_reference2 .env node_modules .devrefs/references +.benchmarks/ +benchmark-reports/ diff --git a/justfile b/justfile index 4b76e8e..9e4a307 100644 --- a/justfile +++ b/justfile @@ -13,6 +13,9 @@ dpreview: gen-themes: bun run scripts/gen-themes.ts +bench-agents *args: + bun run scripts/bench-agents.ts {{ args }} + devdocs: gittydocs dev _docs diff --git a/scripts/bench-agents.ts b/scripts/bench-agents.ts new file mode 100644 index 0000000..9ff1e7b --- /dev/null +++ b/scripts/bench-agents.ts @@ -0,0 +1,890 @@ +// Make-shift benchmark for comparing crabcode, opencode, and codex on tiny agent tasks. +// Run via: `bun run scripts/bench-agents.ts` + +// @ts-nocheck + +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { spawn } from 'node:child_process' + +type AgentName = 'crabcode' | 'opencode' | 'codex' + +type Task = { + id: string + title: string + prompt: string + files: Record + check: (cwd: string) => CheckResult[] +} + +type CheckResult = { + name: string + pass: boolean + detail?: string +} + +type RunResult = { + agent: AgentName + task: string + ok: boolean + passedChecks: number + totalChecks: number + elapsedMs: number + estimatedInputTokens: number + estimatedOutputTokens: number + estimatedCostUsd: number + exitCode: number | null + timedOut: boolean + error?: string + workspace?: string + stdoutPath?: string + stderrPath?: string + commandPath?: string + stdoutTail?: string + stderrTail?: string +} + +const REPO_ROOT = resolve(import.meta.dir, '..') +const DEFAULT_MODEL = 'openai/gpt-5.3-codex-spark' +const DEFAULT_TIMEOUT_MS = 45_000 +const DEFAULT_RUNS = 1 +const DEFAULT_INPUT_USD_PER_MTOK = 1.25 +const DEFAULT_OUTPUT_USD_PER_MTOK = 10 +const DEFAULT_BENCHMARK_DIR = join(REPO_ROOT, '.benchmarks') +const DEFAULT_AGENTS: AgentName[] = ['crabcode', 'opencode', 'codex'] +const AGENT_LABELS: Record = { + crabcode: '🦀 crabcode', + opencode: '🔲 opencode', + codex: '⚛️ codex', +} +const activeChildren = new Set() +const activeWorkspaces = new Set() +let activeRunRoot: string | null = null +let shutdownRequested = false + +process.once('SIGINT', () => requestShutdown('SIGINT')) +process.once('SIGTERM', () => requestShutdown('SIGTERM')) + +const TASKS: Task[] = [ + { + id: 'bugfix-js', + title: 'Fix a small JavaScript bug', + files: { + 'package.json': JSON.stringify({ type: 'module' }, null, 2) + '\n', + 'stats.js': `export function average(nums) { + if (nums.length === 0) return 0 + return nums.reduce((sum, n) => sum + n, 0) +} +`, + }, + prompt: `Fix stats.js. average([2, 4, 6]) should return 4, average([10]) should return 10, and average([]) should keep returning 0. Keep the change minimal.`, + check: (cwd) => { + const stats = readFileSync(join(cwd, 'stats.js'), 'utf8') + const hasDivide = /\/\s*nums\.length/.test(stats) + const stillHandlesEmpty = /length\s*={2,3}\s*0/.test(stats) && /return\s+0/.test(stats) + return [ + { name: 'divides by length', pass: hasDivide }, + { name: 'keeps empty-array guard', pass: stillHandlesEmpty }, + ] + }, + }, + { + id: 'add-rust-test', + title: 'Add a focused Rust test', + files: { + 'src/lib.rs': `pub fn slugify(input: &str) -> String { + input + .trim() + .to_lowercase() + .split_whitespace() + .collect::>() + .join("-") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugifies_basic_text() { + assert_eq!(slugify("Hello Crab Code"), "hello-crab-code"); + } +} +`, + 'Cargo.toml': `[package] +name = "bench-fixture" +version = "0.0.0" +edition = "2021" +`, + }, + prompt: `Add one focused test in src/lib.rs for slugify that covers leading/trailing whitespace and repeated internal whitespace. Do not change the slugify implementation.`, + check: (cwd) => { + const lib = readFileSync(join(cwd, 'src/lib.rs'), 'utf8') + return [ + { name: 'adds a second test', pass: (lib.match(/#\[test\]/g) ?? []).length >= 2 }, + { name: 'covers whitespace case', pass: /\\t|\\n| {2,}|leading|trailing|whitespace/i.test(lib) }, + { name: 'does not change implementation shape', pass: lib.includes('.split_whitespace()') }, + ] + }, + }, + { + id: 'config-doc-sync', + title: 'Synchronize tiny config docs', + files: { + 'config.json': JSON.stringify( + { + model: DEFAULT_MODEL, + agent: { build: { steps: 20 } }, + }, + null, + 2, + ) + '\n', + 'README.md': `# Fixture + +Default model: openai/gpt-5.3-codex-spark +Build steps: 12 +`, + }, + prompt: `Update README.md so the documented Build steps value matches config.json. Do not change config.json.`, + check: (cwd) => { + const config = readFileSync(join(cwd, 'config.json'), 'utf8') + const readme = readFileSync(join(cwd, 'README.md'), 'utf8') + return [ + { name: 'README documents 20 steps', pass: /Build steps:\s*20/.test(readme) }, + { name: 'config remains unchanged', pass: config.includes('"steps": 20') }, + ] + }, + }, +] + +const args = parseArgs(process.argv.slice(2)) +const agents = parseAgents(args.agents ?? process.env.BENCH_AGENTS ?? DEFAULT_AGENTS.join(',')) +const selectedTasks = parseTasks(args.tasks ?? process.env.BENCH_TASKS) +const model = String(args.model ?? process.env.BENCH_MODEL ?? DEFAULT_MODEL) +const timeoutMs = Number(args['timeout-ms'] ?? process.env.BENCH_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS) +const runs = Number(args.runs ?? process.env.BENCH_RUNS ?? DEFAULT_RUNS) +const keep = Boolean(args.keep) +const estimateOnly = Boolean(args.estimate) +const inputPrice = Number(args['input-price'] ?? process.env.BENCH_INPUT_USD_PER_MTOK ?? DEFAULT_INPUT_USD_PER_MTOK) +const outputPrice = Number(args['output-price'] ?? process.env.BENCH_OUTPUT_USD_PER_MTOK ?? DEFAULT_OUTPUT_USD_PER_MTOK) +const outputPath = args.out ? resolve(String(args.out)) : null +const runId = timestampForPath() +const runRoot = createRunRoot(args.dir ?? process.env.BENCH_DIR, runId) +const workspacesRoot = join(runRoot, 'workspaces') +const logsRoot = join(runRoot, 'logs') +mkdirSync(workspacesRoot, { recursive: true }) +mkdirSync(logsRoot, { recursive: true }) +const reportPath = args['no-report'] + ? null + : args.report && args.report !== true + ? resolve(String(args.report)) + : join(runRoot, 'report.md') + +if (args.help) { + printHelp() + process.exit(0) +} + +if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('--timeout-ms must be a positive number') +} + +if (!Number.isFinite(runs) || runs <= 0) { + throw new Error('--runs must be a positive number') +} + +const plannedPrompts = selectedTasks.length * agents.length * runs +const estimatedInputTokens = selectedTasks.reduce((sum, task) => sum + estimateTokens(benchmarkPrompt(task.prompt)), 0) * agents.length * runs +const plannedCost = estimateCost(estimatedInputTokens, 0, inputPrice, outputPrice) + +printIntro() + +if (estimateOnly) { + process.exit(0) +} + +activeRunRoot = runRoot +printPaths() + +const results: RunResult[] = [] + +try { + let runNumber = 0 + + runLoop: for (let runIndex = 0; runIndex < runs; runIndex++) { + for (const task of selectedTasks) { + for (const agent of agents) { + if (shutdownRequested) break runLoop + runNumber += 1 + const result = await runBenchmark(agent, task, runIndex, runNumber, plannedPrompts) + results.push(result) + printResult(result) + } + } + } + + printSummary(results) + + if (reportPath) { + writeMarkdownReport(reportPath, { + runId, + runRoot, + workspacesRoot, + logsRoot, + model, + agents, + tasks: selectedTasks, + runs, + timeoutMs, + keep, + inputPrice, + outputPrice, + results, + stopped: shutdownRequested, + }) + console.log(`\nWrote Markdown report to ${reportPath}`) + } + + if (shutdownRequested) { + process.exitCode = 130 + } + + if (outputPath) { + writeFileSync( + outputPath, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + model, + agents, + tasks: selectedTasks.map((task) => task.id), + runs, + runId, + runRoot, + workspacesRoot, + logsRoot, + markdownReport: reportPath, + agentModels: Object.fromEntries(agents.map((agent) => [agent, modelForAgent(agent, model)])), + pricing: { + inputUsdPerMillionTokens: inputPrice, + outputUsdPerMillionTokens: outputPrice, + }, + results, + }, + null, + 2, + ) + '\n', + ) + console.log(`\nWrote JSON results to ${outputPath}`) + } +} finally { + if (keep) { + console.log(`\nKept benchmark workspaces in ${workspacesRoot}`) + } else { + cleanupWorkspaceChildren(workspacesRoot) + } + activeRunRoot = null +} + +async function runBenchmark( + agent: AgentName, + task: Task, + runIndex: number, + runNumber: number, + totalRuns: number, +): Promise { + const runLabel = `${String(runIndex + 1).padStart(2, '0')}-${agent}-${task.id}` + const workspace = join(workspacesRoot, runLabel) + mkdirSync(workspace, { recursive: true }) + activeWorkspaces.add(workspace) + + try { + writeFixture(workspace, task) + + if (model) { + writeFileSync(join(workspace, 'crabcode.jsonc'), JSON.stringify({ model }, null, 2) + '\n') + } + + const prompt = benchmarkPrompt(task.prompt) + const command = commandFor(agent, prompt) + printRunStart(runNumber, totalRuns, agent, task.id, workspace) + const started = performance.now() + const proc = await runShell(command, workspace, timeoutMs) + const elapsedMs = Math.round(performance.now() - started) + const checks = runChecks(task, workspace) + const passedChecks = checks.filter((check) => check.pass).length + const output = `${proc.stdout}\n${proc.stderr}`.trim() + const artifacts = writeRunArtifacts(runLabel, command, proc.stdout, proc.stderr) + const estimatedInputTokens = estimateTokens(prompt) + const estimatedOutputTokens = estimateTokens(output) + const ok = !shutdownRequested && !proc.timedOut && proc.exitCode === 0 && passedChecks === checks.length + const errors = [ + proc.timedOut ? `timed out after ${timeoutMs}ms` : '', + proc.exitCode !== 0 && proc.exitCode !== null ? `exit code ${proc.exitCode}` : '', + ...checks + .filter((check) => !check.pass) + .map((check) => `${check.name}${check.detail ? `: ${check.detail}` : ''}`), + proc.error ?? '', + ].filter(Boolean) + + return { + agent, + task: task.id, + ok, + passedChecks, + totalChecks: checks.length, + elapsedMs, + estimatedInputTokens, + estimatedOutputTokens, + estimatedCostUsd: estimateCost(estimatedInputTokens, estimatedOutputTokens, inputPrice, outputPrice), + exitCode: proc.exitCode, + timedOut: proc.timedOut, + error: errors.join('; ') || undefined, + workspace, + stdoutPath: artifacts.stdoutPath, + stderrPath: artifacts.stderrPath, + commandPath: artifacts.commandPath, + stdoutTail: tailText(proc.stdout), + stderrTail: tailText(proc.stderr), + } + } finally { + activeWorkspaces.delete(workspace) + } +} + +function createRunRoot(dir: string | boolean | undefined, runId: string) { + const parent = dir && dir !== true ? resolve(String(dir)) : DEFAULT_BENCHMARK_DIR + mkdirSync(parent, { recursive: true }) + const root = join(parent, runId) + mkdirSync(root, { recursive: true }) + return root +} + +function timestampForPath() { + return new Date().toISOString().replaceAll(':', '').replaceAll('.', '-') +} + +function commandFor(agent: AgentName, prompt: string) { + const defaults: Record = { + crabcode: defaultCrabcodeCommand(), + opencode: 'opencode run --dangerously-skip-permissions -m {model} {prompt}', + codex: + 'codex exec --ephemeral --skip-git-repo-check --sandbox workspace-write -c \'approval_policy="never"\' -m {model} {prompt}', + } + const envName = `BENCH_${agent.toUpperCase()}_CMD` + const template = process.env[envName] || defaults[agent] + const agentModel = modelForAgent(agent, model) + return template + .replaceAll('{repo}', shellQuote(REPO_ROOT)) + .replaceAll('{model}', shellQuote(agentModel)) + .replaceAll('{prompt}', shellQuote(prompt)) +} + +function benchmarkPrompt(prompt: string) { + return [ + 'You are running inside an isolated benchmark fixture.', + 'Modify files in the current working directory directly. Do not only describe the change.', + 'Keep the change minimal. When the task is complete, stop.', + '', + `Task: ${prompt}`, + ].join('\n') +} + +function modelForAgent(agent: AgentName, modelRef: string) { + if (agent === 'codex') { + return modelRef.replace(/^openai\//, '') + } + + return modelRef +} + +function defaultCrabcodeCommand() { + const binary = join(REPO_ROOT, 'target', 'debug', 'crabcode') + if (existsSync(binary)) { + return `${shellQuote(binary)} -p --no-session-persistence {prompt}` + } + return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- -p --no-session-persistence {prompt}` +} + +function writeFixture(workspace: string, task: Task) { + for (const [path, content] of Object.entries(task.files)) { + const fullPath = join(workspace, path) + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content) + } +} + +function runShell(command: string, cwd: string, timeoutMs: number) { + return new Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; error?: string }>( + (resolveRun) => { + const child = spawn(command, { + cwd, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + env: { + ...process.env, + NO_COLOR: '1', + CI: '1', + }, + }) + + let stdout = '' + let stderr = '' + let timedOut = false + let settled = false + activeChildren.add(child) + + const timer = setTimeout(() => { + timedOut = true + terminateChild(child, 'SIGTERM') + setTimeout(() => terminateChild(child, 'SIGKILL'), 2_000).unref() + }, timeoutMs) + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.on('error', (err) => { + if (settled) return + settled = true + activeChildren.delete(child) + clearTimeout(timer) + resolveRun({ stdout, stderr, exitCode: null, timedOut, error: err.message }) + }) + child.on('close', (code) => { + if (settled) return + settled = true + activeChildren.delete(child) + clearTimeout(timer) + resolveRun({ stdout, stderr, exitCode: code, timedOut }) + }) + }, + ) +} + +function runChecks(task: Task, workspace: string): CheckResult[] { + try { + return task.check(workspace) + } catch (err) { + return [ + { + name: 'checks completed', + pass: false, + detail: err instanceof Error ? err.message : String(err), + }, + ] + } +} + +function requestShutdown(signal: string) { + if (shutdownRequested) { + cleanupActiveWorkspaces() + process.exit(signal === 'SIGINT' ? 130 : 143) + } + + shutdownRequested = true + console.error(`\nReceived ${signal}; stopping active agent processes...`) + + for (const child of activeChildren) { + terminateChild(child, 'SIGTERM') + } + + setTimeout(() => { + for (const child of activeChildren) { + terminateChild(child, 'SIGKILL') + } + cleanupActiveWorkspaces() + process.exit(signal === 'SIGINT' ? 130 : 143) + }, 2_500).unref() +} + +function terminateChild(child: any, signal: NodeJS.Signals) { + if (!child?.pid) return + + try { + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }) + return + } + + process.kill(-child.pid, signal) + } catch { + try { + child.kill(signal) + } catch {} + } +} + +function cleanupActiveWorkspaces() { + if (keep) return + for (const workspace of activeWorkspaces) { + cleanupWorkspace(workspace) + } + activeWorkspaces.clear() + if (activeRunRoot) { + cleanupWorkspace(activeRunRoot) + } +} + +function cleanupWorkspace(workspace: string) { + try { + rmSync(workspace, { recursive: true, force: true }) + } catch {} +} + +function cleanupWorkspaceChildren(workspace: string) { + try { + for (const entry of readdirSync(workspace)) { + cleanupWorkspace(join(workspace, entry)) + } + } catch {} +} + +function printIntro() { + console.log('Agent benchmark') + console.log('') + console.log('Config') + console.log(` model: ${model}`) + console.log(` agents: ${agents.map(displayAgent).join(', ')}`) + console.log(` tasks: ${selectedTasks.map((task) => task.id).join(', ')}`) + console.log(` runs: ${runs}`) + console.log(` prompts: ${plannedPrompts}`) + console.log(` timeout: ${formatDuration(timeoutMs)}`) + console.log(` prompt cost: ${formatUsd(plannedCost)} estimated`) + console.log('') + console.log('Agent model args') + for (const agent of agents) { + console.log(` ${displayAgent(agent).padEnd(12)} ${modelForAgent(agent, model)}`) + } + console.log('') +} + +function printPaths() { + console.log('Paths') + console.log(` run: ${runRoot}`) + console.log(` workspaces: ${workspacesRoot}`) + console.log(` logs: ${logsRoot}`) + if (reportPath) { + console.log(` report: ${reportPath}`) + } + console.log('') + console.log('Notes') + console.log(' Permission-gated actions are auto-approved for opencode and codex in isolated workspaces.') + console.log(' Crabcode print mode can still deny its own permission-gated tool calls.') + if (!keep) { + console.log(' Workspaces are removed at exit. Pass --keep to preserve them.') + } + console.log('') +} + +function printRunStart(runNumber: number, totalRuns: number, agent: AgentName, taskId: string, workspace: string) { + console.log(`Run ${runNumber}/${totalRuns}: ${displayAgent(agent)} / ${taskId}`) + console.log(` workspace: ${workspace}`) +} + +function printResult(result: RunResult) { + const status = result.ok ? 'PASS' : 'FAIL' + const checks = `${result.passedChecks}/${result.totalChecks}` + console.log(` result: ${status}`) + console.log(` checks: ${checks}`) + console.log(` time: ${formatDuration(result.elapsedMs)}`) + console.log(` cost: ${formatUsd(result.estimatedCostUsd)} estimated`) + if (result.error) { + console.log(' reason:') + for (const line of result.error.split('; ')) { + console.log(` - ${line}`) + } + } + if (result.stdoutPath || result.stderrPath) { + console.log(' output:') + if (result.stdoutPath) console.log(` stdout: ${result.stdoutPath}`) + if (result.stderrPath) console.log(` stderr: ${result.stderrPath}`) + } + console.log('') +} + +function printSummary(results: RunResult[]) { + console.log('\nSummary') + console.log('| Agent | Score | Checks | Avg time | Est. tokens | Est. cost |') + console.log('|---|---:|---:|---:|---:|---:|') + + for (const row of summaryRows(results)) { + console.log(`| ${displayAgent(row.agent)} | ${row.score} | ${row.checks} | ${row.avgTime} | ${row.tokens} | ${row.cost} |`) + } + + console.log('\nMetric: Score is the percent of task runs where the command exited successfully and every deterministic check passed.') + console.log('Cost is an estimate from prompt/output text tokens only; provider dashboards are the source of truth.') +} + +function summaryRows(results: RunResult[]) { + return agents.map((agent) => { + const items = results.filter((result) => result.agent === agent) + const passCount = items.filter((result) => result.ok).length + const totalChecks = sum(items.map((item) => item.totalChecks)) + const passedChecks = sum(items.map((item) => item.passedChecks)) + const avgMs = items.length ? sum(items.map((item) => item.elapsedMs)) / items.length : 0 + const tokens = sum(items.map((item) => item.estimatedInputTokens + item.estimatedOutputTokens)) + const cost = sum(items.map((item) => item.estimatedCostUsd)) + return { + agent, + score: items.length ? `${Math.round((passCount / items.length) * 100)}%` : '0%', + checks: `${passedChecks}/${totalChecks}`, + avgTime: `${(avgMs / 1000).toFixed(1)}s`, + tokens, + cost: formatUsd(cost), + } + }) +} + +function writeMarkdownReport( + path: string, + report: { + runId: string + runRoot: string + workspacesRoot: string + logsRoot: string + model: string + agents: AgentName[] + tasks: Task[] + runs: number + timeoutMs: number + keep: boolean + inputPrice: number + outputPrice: number + results: RunResult[] + stopped: boolean + }, +) { + mkdirSync(dirname(path), { recursive: true }) + const lines: string[] = [] + + lines.push(`# Agent Benchmark Report`) + lines.push('') + lines.push(`Generated: ${new Date().toISOString()}`) + lines.push(`Run ID: \`${report.runId}\``) + lines.push(`Model: \`${report.model || '(agent defaults)'}\``) + lines.push(`Agent model args: ${report.agents.map((agent) => `\`${displayAgent(agent)}=${modelForAgent(agent, report.model)}\``).join(', ')}`) + lines.push(`Agents: ${report.agents.map((agent) => `\`${displayAgent(agent)}\``).join(', ')}`) + lines.push(`Tasks: ${report.tasks.map((task) => `\`${task.id}\``).join(', ')}`) + lines.push(`Runs per agent/task: ${report.runs}`) + lines.push(`Timeout per run: ${report.timeoutMs}ms`) + lines.push(`Benchmark run directory: \`${report.runRoot}\``) + lines.push(`Agents ran in: \`${report.workspacesRoot}\``) + lines.push(`Logs: \`${report.logsRoot}\``) + lines.push(`Workspaces kept after run: ${report.keep ? 'yes' : 'no'}`) + lines.push(`Stopped early: ${report.stopped ? 'yes' : 'no'}`) + lines.push('') + lines.push(`Permission prompts are not answered by this non-interactive benchmark. Approval-gated actions may fail or time out.`) + lines.push(`Cost is a rough estimate from prompt/output text tokens only; provider dashboards are the source of truth.`) + lines.push('') + + lines.push(`## Summary`) + lines.push('') + lines.push('| Agent | Score | Checks | Avg time | Est. tokens | Est. cost |') + lines.push('|---|---:|---:|---:|---:|---:|') + for (const row of summaryRows(report.results)) { + lines.push(`| ${displayAgent(row.agent)} | ${row.score} | ${row.checks} | ${row.avgTime} | ${row.tokens} | ${row.cost} |`) + } + lines.push('') + + lines.push(`## Runs`) + lines.push('') + lines.push('| Status | Agent | Task | Checks | Time | Est. tokens | Est. cost | Workspace | Stdout | Stderr | Error |') + lines.push('|---|---|---|---:|---:|---:|---:|---|---|---|---|') + for (const result of report.results) { + const status = result.ok ? 'PASS' : 'FAIL' + const tokens = result.estimatedInputTokens + result.estimatedOutputTokens + lines.push( + `| ${status} | ${displayAgent(result.agent)} | ${result.task} | ${result.passedChecks}/${result.totalChecks} | ${formatDuration(result.elapsedMs)} | ${tokens} | ${formatUsd(result.estimatedCostUsd)} | \`${result.workspace ?? ''}\` | \`${result.stdoutPath ?? ''}\` | \`${result.stderrPath ?? ''}\` | ${escapeMarkdownTable(result.error ?? '')} |`, + ) + } + lines.push('') + + lines.push(`## Output Tails`) + lines.push('') + for (const result of report.results) { + if (!result.stdoutTail && !result.stderrTail) continue + lines.push(`### ${displayAgent(result.agent)} / ${result.task}`) + lines.push('') + if (result.stdoutTail) { + lines.push('stdout:') + lines.push('```text') + lines.push(result.stdoutTail) + lines.push('```') + lines.push('') + } + if (result.stderrTail) { + lines.push('stderr:') + lines.push('```text') + lines.push(result.stderrTail) + lines.push('```') + lines.push('') + } + } + + lines.push(`## Tasks`) + lines.push('') + for (const task of report.tasks) { + lines.push(`### ${task.id}`) + lines.push('') + lines.push(task.title) + lines.push('') + lines.push('```text') + lines.push(task.prompt) + lines.push('```') + lines.push('') + } + + writeFileSync(path, lines.join('\n') + '\n') +} + +function escapeMarkdownTable(value: string) { + return value.replaceAll('|', '\\|').replaceAll('\n', '
') +} + +function displayAgent(agent: AgentName) { + return AGENT_LABELS[agent] ?? agent +} + +function writeRunArtifacts(runLabel: string, command: string, stdout: string, stderr: string) { + const safeLabel = sanitizePathPart(runLabel) + const commandPath = join(logsRoot, `${safeLabel}.command.txt`) + const stdoutPath = join(logsRoot, `${safeLabel}.stdout.txt`) + const stderrPath = join(logsRoot, `${safeLabel}.stderr.txt`) + + writeFileSync(commandPath, command + '\n') + writeFileSync(stdoutPath, stdout) + writeFileSync(stderrPath, stderr) + + return { commandPath, stdoutPath, stderrPath } +} + +function sanitizePathPart(value: string) { + return value.replace(/[^a-zA-Z0-9._-]+/g, '-') +} + +function tailText(value: string, maxChars = 2_000) { + if (!value.trim()) return '' + if (value.length <= maxChars) return value.trim() + return `... truncated ...\n${value.slice(value.length - maxChars).trim()}` +} + +function formatDuration(ms: number) { + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +function estimateTokens(text: string) { + return Math.ceil(text.length / 4) +} + +function estimateCost(inputTokens: number, outputTokens: number, inputUsdPerMillion: number, outputUsdPerMillion: number) { + return (inputTokens / 1_000_000) * inputUsdPerMillion + (outputTokens / 1_000_000) * outputUsdPerMillion +} + +function formatUsd(value: number) { + if (!value) return '$0.0000' + return `$${value.toFixed(4)}` +} + +function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +function parseArgs(raw: string[]) { + const parsed: Record = {} + for (let i = 0; i < raw.length; i++) { + const arg = raw[i] + if (!arg.startsWith('--')) continue + const body = arg.slice(2) + const [key, inlineValue] = body.split('=', 2) + if (inlineValue !== undefined) { + parsed[key] = inlineValue + continue + } + const next = raw[i + 1] + if (next && !next.startsWith('--')) { + parsed[key] = next + i++ + } else { + parsed[key] = true + } + } + return parsed +} + +function parseAgents(value: string): AgentName[] { + const agents = value.split(',').map((agent) => agent.trim()).filter(Boolean) + const valid = new Set(DEFAULT_AGENTS) + for (const agent of agents) { + if (!valid.has(agent as AgentName)) { + throw new Error(`Unknown agent: ${agent}. Expected one of ${DEFAULT_AGENTS.join(', ')}`) + } + } + return agents as AgentName[] +} + +function parseTasks(value?: string | boolean): Task[] { + if (!value || value === true) return TASKS + const ids = String(value).split(',').map((task) => task.trim()).filter(Boolean) + return ids.map((id) => { + const task = TASKS.find((candidate) => candidate.id === id) + if (!task) { + throw new Error(`Unknown task: ${id}. Expected one of ${TASKS.map((task) => task.id).join(', ')}`) + } + return task + }) +} + +function shellQuote(value: string) { + if (!value) return "''" + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function printHelp() { + console.log(`Usage: bun run scripts/bench-agents.ts [options] + +Options: + --model provider/model Model passed to each agent. + --agents crabcode,opencode,codex Agents to run. + --tasks bugfix-js,add-rust-test Task IDs to run. + --runs 1 Repetitions per agent/task. + --timeout-ms 45000 Timeout per run. + --estimate Print planned prompt count and prompt-only cost, then exit. + --input-price 1.25 Input USD per 1M tokens for rough cost estimates. + --output-price 10 Output USD per 1M tokens for rough cost estimates. + --out bench-results.json Write machine-readable JSON results. + --report benchmark.md Write Markdown report. Default: .benchmarks//report.md. + --no-report Disable Markdown report generation. + --dir .benchmarks Parent directory for benchmark runs. + --keep Keep temporary workspaces for inspection. + +Default params: + model: ${DEFAULT_MODEL} + agents: ${DEFAULT_AGENTS.join(',')} + tasks: ${TASKS.map((task) => task.id).join(',')} + runs: ${DEFAULT_RUNS} + timeout-ms: ${DEFAULT_TIMEOUT_MS} + input-price: ${DEFAULT_INPUT_USD_PER_MTOK} + output-price: ${DEFAULT_OUTPUT_USD_PER_MTOK} + dir: ${DEFAULT_BENCHMARK_DIR} + +Environment overrides: + BENCH_MODEL, BENCH_AGENTS, BENCH_TASKS, BENCH_RUNS, BENCH_TIMEOUT_MS, + BENCH_INPUT_USD_PER_MTOK, BENCH_OUTPUT_USD_PER_MTOK, BENCH_DIR + +Stop behavior: + Ctrl+C stops the active agent process tree and removes temporary workspaces unless --keep is set. + +Command overrides: + BENCH_CRABCODE_CMD='crabcode -p --no-session-persistence {prompt}' + BENCH_OPENCODE_CMD='opencode run --dangerously-skip-permissions -m {model} {prompt}' + BENCH_CODEX_CMD='codex exec --ephemeral --skip-git-repo-check --sandbox workspace-write -c approval_policy="never" -m {model} {prompt}' + +Template tokens: {prompt}, {model}, {repo} +Note: {model} is agent-aware; codex strips a leading openai/ provider prefix. +`) +} From 1277b7d5ba14234a505fe0cfa23962179c7eda47 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 02:18:07 +0800 Subject: [PATCH 105/226] feat: add OpenAI Responses API function call support and --dangerously-skip-permissions flag. - Parse `response.output_item.added`, `response.output_item.done`, `response.function_call_arguments.delta`, and `response.function_call_arguments.done` events into tool call chunks - Accumulate final_arguments from `arguments_done` and fall back to it when streamed delta arguments are missing or incomplete - Add `--dangerously-skip-permissions` CLI flag to bypass permission prompts in print mode for non-interactive benchmarks/CI - Handle PermissionRequest chunks in print mode by denying them --- aisdk/src/providers/openai.rs | 176 +++++++++++++++++++++++++++++++++- aisdk/src/response.rs | 91 ++++++++++++++++-- src/main.rs | 29 +++++- src/tools/permission.rs | 23 +++++ 4 files changed, 305 insertions(+), 14 deletions(-) diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index ec59535..9b39ef1 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -295,7 +295,13 @@ impl Provider for OpenAI { ChunkType::Failed("Response failed".to_string()), ))), _ => { - if event_type.contains("tool_call") { + if let Some(tool_call) = + responses_function_call_chunk(&value) + { + futures::future::ready(Some(Ok(ChunkType::ToolCall( + tool_call, + )))) + } else if event_type.contains("tool_call") { futures::future::ready(Some(Ok(ChunkType::ToolCall( data.clone(), )))) @@ -323,6 +329,127 @@ impl Provider for OpenAI { } } +fn responses_function_call_chunk(value: &serde_json::Value) -> Option { + let event_type = value.get("type").and_then(|v| v.as_str())?; + + let chunk = match event_type { + "response.output_item.added" => { + let item = value.get("item")?; + if item.get("type").and_then(|v| v.as_str())? != "function_call" { + return None; + } + + response_function_call_item_chunk(value, item, false)? + } + "response.output_item.done" => { + let item = value.get("item")?; + if item.get("type").and_then(|v| v.as_str())? != "function_call" { + return None; + } + + response_function_call_item_chunk(value, item, true)? + } + "response.function_call_arguments.delta" => { + let mut function = serde_json::Map::new(); + function.insert( + "arguments".to_string(), + value + .get("delta") + .cloned() + .unwrap_or(serde_json::Value::Null), + ); + response_function_call_chunk_base(value, function)? + } + "response.function_call_arguments.done" => { + let mut function = serde_json::Map::new(); + function.insert( + "arguments_done".to_string(), + value + .get("arguments") + .cloned() + .unwrap_or(serde_json::Value::Null), + ); + response_function_call_chunk_base(value, function)? + } + _ => return None, + }; + + serde_json::to_string(&vec![serde_json::Value::Object(chunk)]).ok() +} + +fn response_function_call_item_chunk( + value: &serde_json::Value, + item: &serde_json::Value, + include_final_arguments: bool, +) -> Option> { + let mut function = serde_json::Map::new(); + + if let Some(name) = item.get("name").and_then(|v| v.as_str()) { + function.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + } + + if include_final_arguments { + if let Some(arguments) = item.get("arguments") { + function.insert("arguments_done".to_string(), arguments.clone()); + } + } + + response_function_call_chunk_base_with_item(value, item, function) +} + +fn response_function_call_chunk_base( + value: &serde_json::Value, + function: serde_json::Map, +) -> Option> { + let mut chunk = serde_json::Map::new(); + + if let Some(index) = value.get("output_index").and_then(|v| v.as_u64()) { + chunk.insert( + "index".to_string(), + serde_json::Value::Number(serde_json::Number::from(index)), + ); + } + + if let Some(id) = value + .get("item_id") + .or_else(|| value.get("call_id")) + .and_then(|v| v.as_str()) + { + chunk.insert("id".to_string(), serde_json::Value::String(id.to_string())); + } + + chunk.insert( + "type".to_string(), + serde_json::Value::String("function".to_string()), + ); + chunk.insert("function".to_string(), serde_json::Value::Object(function)); + + Some(chunk) +} + +fn response_function_call_chunk_base_with_item( + value: &serde_json::Value, + item: &serde_json::Value, + function: serde_json::Map, +) -> Option> { + let mut chunk = response_function_call_chunk_base(value, function)?; + + if !chunk.contains_key("id") { + if let Some(id) = item + .get("id") + .or_else(|| item.get("call_id")) + .and_then(|v| v.as_str()) + { + chunk.insert("id".to_string(), serde_json::Value::String(id.to_string())); + } + } + + Some(chunk) +} + fn build_openai_messages(messages: &[Message], strip_system: bool) -> Vec { messages .iter() @@ -370,3 +497,50 @@ fn openai_responses_user_content(user: &crate::message::UserMessage) -> serde_js })); serde_json::Value::Array(parts) } + +#[cfg(test)] +mod tests { + use super::responses_function_call_chunk; + + #[test] + fn maps_responses_function_call_item_to_tool_call_shape() { + let event = serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_123", + "call_id": "call_123", + "type": "function_call", + "name": "read", + "arguments": "" + } + }); + + let chunk = responses_function_call_chunk(&event).expect("expected function call chunk"); + let parsed: serde_json::Value = serde_json::from_str(&chunk).unwrap(); + + assert_eq!(parsed[0]["index"], 0); + assert_eq!(parsed[0]["id"], "fc_123"); + assert_eq!(parsed[0]["function"]["name"], "read"); + } + + #[test] + fn maps_responses_function_call_argument_delta_to_tool_call_shape() { + let event = serde_json::json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "item_id": "fc_123", + "delta": "{\"file_path\":\"Cargo.toml\"}" + }); + + let chunk = responses_function_call_chunk(&event).expect("expected argument chunk"); + let parsed: serde_json::Value = serde_json::from_str(&chunk).unwrap(); + + assert_eq!(parsed[0]["index"], 0); + assert_eq!(parsed[0]["id"], "fc_123"); + assert_eq!( + parsed[0]["function"]["arguments"], + "{\"file_path\":\"Cargo.toml\"}" + ); + } +} diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index fe0da52..bb12a99 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -278,6 +278,7 @@ struct PendingToolCall { id: Option, name: Option, arguments: String, + final_arguments: Option, saw_arguments: bool, } @@ -372,16 +373,7 @@ impl ToolCallAccumulator { .ok_or_else(|| format!("Tool call '{}' missing function name", call.key))?; let id = call.id.unwrap_or(call.key); - let args = if !call.saw_arguments || call.arguments.trim().is_empty() { - serde_json::Value::Object(Default::default()) - } else { - serde_json::from_str(&call.arguments).map_err(|e| { - format!( - "Tool call '{}' arguments are incomplete or invalid JSON: {}", - id, e - ) - })? - }; + let args = parse_tool_arguments(&id, &call.arguments, call.final_arguments.as_deref())?; results.push((id, name, args)); } @@ -422,6 +414,16 @@ impl ToolCallAccumulator { value => pending.arguments.push_str(&value.to_string()), } } + + if let Some(arguments) = function.get("arguments_done") { + match arguments { + serde_json::Value::String(done) => { + pending.final_arguments = Some(done.clone()); + } + serde_json::Value::Null => {} + value => pending.final_arguments = Some(value.to_string()), + } + } } Ok(()) @@ -451,12 +453,61 @@ impl ToolCallAccumulator { id: None, name: None, arguments: String::new(), + final_arguments: None, saw_arguments: false, }); self.calls.last_mut().expect("pending tool call exists") } } +fn parse_tool_arguments( + id: &str, + streamed_arguments: &str, + final_arguments: Option<&str>, +) -> std::result::Result { + let streamed = streamed_arguments.trim(); + + if !streamed.is_empty() { + match serde_json::from_str(streamed_arguments) { + Ok(value) => return Ok(value), + Err(streamed_err) => { + if let Some(final_arguments) = final_arguments { + let final_trimmed = final_arguments.trim(); + if !final_trimmed.is_empty() { + return serde_json::from_str(final_arguments).map_err(|final_err| { + format!( + "Tool call '{}' arguments are incomplete or invalid JSON: {}; final arguments were also invalid: {}", + id, streamed_err, final_err + ) + }); + } + } + + return Err(format!( + "Tool call '{}' arguments are incomplete or invalid JSON: {}", + id, streamed_err + )); + } + } + } + + let Some(final_arguments) = final_arguments else { + return Ok(serde_json::Value::Object(Default::default())); + }; + + let final_trimmed = final_arguments.trim(); + if final_trimmed.is_empty() { + return Ok(serde_json::Value::Object(Default::default())); + } + + serde_json::from_str(final_arguments).map_err(|e| { + format!( + "Tool call '{}' arguments are incomplete or invalid JSON: {}", + id, e + ) + }) +} + fn tool_call_key(item: &serde_json::Value, array_index: usize) -> String { if let Some(index) = item.get("index").and_then(|value| value.as_u64()) { return format!("index:{}", index); @@ -771,4 +822,24 @@ mod tests { assert_eq!(calls[0].2, serde_json::json!({})); } + + #[test] + fn uses_final_arguments_when_delta_arguments_are_absent() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest(r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"read"}}]"#) + .unwrap(); + accumulator + .ingest( + r#"[{"index":0,"function":{"arguments_done":"{\"file_path\":\"Cargo.toml\"}"}}]"#, + ) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1, "read"); + assert_eq!(calls[0].2["file_path"], "Cargo.toml"); + } } diff --git a/src/main.rs b/src/main.rs index bee025a..47a60dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,7 +92,11 @@ fn format_post_close_message(info: Option<&PostCloseInfo>) -> String { msg } -async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<()> { +async fn run_print_mode( + prompt: &str, + no_session_persistence: bool, + dangerously_skip_permissions: bool, +) -> Result<()> { use crate::llm::client::stream_llm_with_cancellation; use crate::session::types::Message; use tokio::sync::mpsc; @@ -137,7 +141,8 @@ async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<() let (sender, mut receiver) = mpsc::unbounded_channel(); - let tool_permissions = crate::tools::ToolPermissions::new(std::path::PathBuf::from(&cwd)); + let tool_permissions = crate::tools::ToolPermissions::new(std::path::PathBuf::from(&cwd)) + .dangerously_skip_permissions(dangerously_skip_permissions); let agent_max_steps = loaded_config .merged_config @@ -197,6 +202,15 @@ async fn run_print_mode(prompt: &str, no_session_persistence: bool) -> Result<() crate::llm::ChunkMessage::Warning(warning) => { eprintln!("Warning: {}", warning); } + crate::llm::ChunkMessage::PermissionRequest(prompt) => { + eprintln!( + "Permission required: {}. Re-run with --dangerously-skip-permissions to allow non-interactive tool execution.", + prompt.reason + ); + let _ = prompt + .response_tx + .send(crate::tools::PermissionResponse::Deny); + } _ => {} } } @@ -237,6 +251,10 @@ struct Args { #[arg(long = "no-session-persistence")] no_session_persistence: bool, + /// Skip permission prompts in print mode. Intended for isolated benchmark/CI workspaces. + #[arg(long = "dangerously-skip-permissions")] + dangerously_skip_permissions: bool, + /// The prompt to run (positional, used in print mode) prompt: Vec, } @@ -253,7 +271,12 @@ async fn main() -> Result<()> { eprintln!("Usage: crabcode -p \"\""); std::process::exit(1); } - return run_print_mode(&prompt, args.no_session_persistence).await; + return run_print_mode( + &prompt, + args.no_session_persistence, + args.dangerously_skip_permissions, + ) + .await; } let mut app = App::new()?; diff --git a/src/tools/permission.rs b/src/tools/permission.rs index a352744..fdaa1d0 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -131,6 +131,7 @@ pub struct ToolPermissions { workdir: PathBuf, always_grants: Arc>>, agent_policies: Arc, + dangerously_skip_permissions: bool, } impl ToolPermissions { @@ -139,6 +140,7 @@ impl ToolPermissions { workdir: normalize_path(&workdir.into()), always_grants: Arc::new(RwLock::new(HashSet::new())), agent_policies: Arc::new(AgentToolPolicies::default()), + dangerously_skip_permissions: false, } } @@ -147,6 +149,11 @@ impl ToolPermissions { self } + pub fn dangerously_skip_permissions(mut self, enabled: bool) -> Self { + self.dangerously_skip_permissions = enabled; + self + } + pub fn workdir(&self) -> &Path { &self.workdir } @@ -169,6 +176,10 @@ impl ToolPermissions { ))); } + if self.dangerously_skip_permissions { + return Ok(()); + } + let action = PermissionAction::from_tool_id(tool_id); let path = extract_primary_path(action, params, &self.workdir); let command = if action == PermissionAction::Bash { @@ -484,4 +495,16 @@ mod tests { assert!(second.is_ok()); assert!(rx.try_recv().is_err()); } + + #[tokio::test] + async fn dangerous_skip_bypasses_permission_prompts() { + let perms = ToolPermissions::new("/tmp/workspace").dangerously_skip_permissions(true); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + + let result = perms.preflight("build", "read", ¶ms, Some(&tx)).await; + + assert!(result.is_ok()); + assert!(rx.try_recv().is_err()); + } } From bbb3c3b028947eb0fc671c81ad9be3902a2a33e1 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 02:18:23 +0800 Subject: [PATCH 106/226] docs(bench): add `--dangerously-skip-permissions` to crabcode benchmark commands. Permission-gated actions are not answered interactively in benchmarks, so crabcode needs `--dangerously-skip-permissions` (matching opencode and codex behavior) to avoid timeouts on approval-gated tool calls. --- scripts/bench-agents.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/bench-agents.ts b/scripts/bench-agents.ts index 9ff1e7b..708b5e4 100644 --- a/scripts/bench-agents.ts +++ b/scripts/bench-agents.ts @@ -401,9 +401,9 @@ function modelForAgent(agent: AgentName, modelRef: string) { function defaultCrabcodeCommand() { const binary = join(REPO_ROOT, 'target', 'debug', 'crabcode') if (existsSync(binary)) { - return `${shellQuote(binary)} -p --no-session-persistence {prompt}` + return `${shellQuote(binary)} -p --no-session-persistence --dangerously-skip-permissions {prompt}` } - return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- -p --no-session-persistence {prompt}` + return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- -p --no-session-persistence --dangerously-skip-permissions {prompt}` } function writeFixture(workspace: string, task: Task) { @@ -573,7 +573,7 @@ function printPaths() { console.log('') console.log('Notes') console.log(' Permission-gated actions are auto-approved for opencode and codex in isolated workspaces.') - console.log(' Crabcode print mode can still deny its own permission-gated tool calls.') + console.log(' Crabcode print mode is run with --dangerously-skip-permissions in isolated workspaces.') if (!keep) { console.log(' Workspaces are removed at exit. Pass --keep to preserve them.') } @@ -677,7 +677,7 @@ function writeMarkdownReport( lines.push(`Workspaces kept after run: ${report.keep ? 'yes' : 'no'}`) lines.push(`Stopped early: ${report.stopped ? 'yes' : 'no'}`) lines.push('') - lines.push(`Permission prompts are not answered by this non-interactive benchmark. Approval-gated actions may fail or time out.`) + lines.push(`Permission-gated actions are auto-approved for benchmark agent commands in isolated workspaces.`) lines.push(`Cost is a rough estimate from prompt/output text tokens only; provider dashboards are the source of truth.`) lines.push('') @@ -880,7 +880,7 @@ Stop behavior: Ctrl+C stops the active agent process tree and removes temporary workspaces unless --keep is set. Command overrides: - BENCH_CRABCODE_CMD='crabcode -p --no-session-persistence {prompt}' + BENCH_CRABCODE_CMD='crabcode -p --no-session-persistence --dangerously-skip-permissions {prompt}' BENCH_OPENCODE_CMD='opencode run --dangerously-skip-permissions -m {model} {prompt}' BENCH_CODEX_CMD='codex exec --ephemeral --skip-git-repo-check --sandbox workspace-write -c approval_policy="never" -m {model} {prompt}' From 5271e252df40e8e4547c2631731eceee7d1138a8 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 02:25:25 +0800 Subject: [PATCH 107/226] refactor: suppress tool call/result output in print mode. --- src/main.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index 47a60dd..3735841 100644 --- a/src/main.rs +++ b/src/main.rs @@ -182,15 +182,7 @@ async fn run_print_mode( use std::io::Write; let _ = std::io::stdout().flush(); } - crate::llm::ChunkMessage::ToolCalls(calls) => { - println!(); - for call in &calls { - println!(" ⬡ {}", call.function.name); - } - } - crate::llm::ChunkMessage::ToolResult(result) => { - println!(" ⬢ {}", result.name); - } + crate::llm::ChunkMessage::ToolCalls(_) | crate::llm::ChunkMessage::ToolResult(_) => {} crate::llm::ChunkMessage::End => { println!(); break; From 03f060e4dae21030dffd44c53d45acce03ce217f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 03:52:10 +0800 Subject: [PATCH 108/226] feat(bench): add live reports, safe runner, static server, and 3 new tasks. - Write live-updating markdown report after each run and on shutdown - Wrap runBenchmark in safeRunBenchmark to catch crashes gracefully - Add --report-dir option and BENCH_REPORT_DIR env var - Implement per-run static HTTP server for local site-fetch benchmarks - Add local-site-fetch, invoice-ts-fix, and jsonc-config-parser tasks - Add bunTestCheck helper for running bun tests as task checks - Improve prompt instructions: prefer exact file paths, avoid redundant calls - Update Codex command flag to --dangerously-bypass-approvals-and-sandbox - Print line noting site-fetch tasks stay on 127.0.0.1 feat(print-mode): default max steps to 16 when agent_steps unset feat(prompt): add efficiency guidance to system prompt feat(webfetch): only upgrade http:// to https:// for non-loopback URLs --- scripts/bench-agents.ts | 414 +++++++++++++++++++++++++++++++++++++--- src/main.rs | 4 +- src/prompt/mod.rs | 6 + src/tools/webfetch.rs | 66 ++++++- 4 files changed, 454 insertions(+), 36 deletions(-) diff --git a/scripts/bench-agents.ts b/scripts/bench-agents.ts index 708b5e4..55c84af 100644 --- a/scripts/bench-agents.ts +++ b/scripts/bench-agents.ts @@ -3,9 +3,10 @@ // @ts-nocheck -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { dirname, extname, join, resolve, sep } from 'node:path' +import { spawn, spawnSync } from 'node:child_process' +import { createServer } from 'node:http' type AgentName = 'crabcode' | 'opencode' | 'codex' @@ -14,6 +15,9 @@ type Task = { title: string prompt: string files: Record + site?: { + root: string + } check: (cwd: string) => CheckResult[] } @@ -51,6 +55,7 @@ const DEFAULT_RUNS = 1 const DEFAULT_INPUT_USD_PER_MTOK = 1.25 const DEFAULT_OUTPUT_USD_PER_MTOK = 10 const DEFAULT_BENCHMARK_DIR = join(REPO_ROOT, '.benchmarks') +const DEFAULT_REPORT_DIR = join(REPO_ROOT, 'benchmark-reports') const DEFAULT_AGENTS: AgentName[] = ['crabcode', 'opencode', 'codex'] const AGENT_LABELS: Record = { crabcode: '🦀 crabcode', @@ -155,6 +160,151 @@ Build steps: 12 ] }, }, + { + id: 'local-site-fetch', + title: 'Fetch local site data and update docs', + site: { + root: 'site', + }, + files: { + 'site/api/releases.json': JSON.stringify( + { + releases: [ + { + version: '1.8.0-beta.1', + channel: 'beta', + recommended: false, + migrationNote: 'Beta users should keep the experimental flag enabled.', + }, + { + version: '1.7.4', + channel: 'stable', + recommended: true, + migrationNote: 'Set `snapshotMode` to `sparse` before rollout.', + }, + ], + }, + null, + 2, + ) + '\n', + 'docs/release.md': `# Release Notes + +Recommended stable: 1.6.2 +Migration note: TBD +`, + }, + prompt: `Fetch {siteUrl}/api/releases.json, find the recommended stable release, and update docs/release.md with its version and migrationNote. Do not change files under site/.`, + check: (cwd) => { + const doc = readFileSync(join(cwd, 'docs/release.md'), 'utf8') + const siteData = readFileSync(join(cwd, 'site/api/releases.json'), 'utf8') + return [ + { name: 'documents recommended stable version', pass: /1\.7\.4/.test(doc) }, + { name: 'copies fetched migration note', pass: /snapshotMode/.test(doc) && /sparse/.test(doc) }, + { name: 'removes placeholder note', pass: !/TBD/.test(doc) }, + { name: 'keeps served fixture intact', pass: siteData.includes('"version": "1.7.4"') }, + ] + }, + }, + { + id: 'invoice-ts-fix', + title: 'Fix a cross-file TypeScript invoice bug', + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/invoice.ts': `export type InvoiceLine = { + sku: string + unitCents: number + quantity: number +} + +export function invoiceTotalCents(lines: InvoiceLine[], discountPercent = 0, taxRate = 0): number { + const subtotal = lines.reduce((sum, line) => sum + line.unitCents, 0) + const discounted = subtotal - Math.round(subtotal * discountPercent) + return Math.round(discounted * (1 + taxRate)) +} +`, + 'tests/invoice.test.ts': `import { expect, test } from 'bun:test' +import { invoiceTotalCents } from '../src/invoice' + +test('counts quantities before discount and tax', () => { + const total = invoiceTotalCents( + [ + { sku: 'seat', unitCents: 1000, quantity: 2 }, + { sku: 'addon', unitCents: 500, quantity: 1 }, + ], + 10, + 0.08, + ) + + expect(total).toBe(2430) +}) + +test('handles quantity-only totals', () => { + expect(invoiceTotalCents([{ sku: 'usage', unitCents: 333, quantity: 3 }])).toBe(999) +}) +`, + }, + prompt: `Fix src/invoice.ts so invoiceTotalCents counts line quantities, treats discountPercent as a whole percent where 10 means 10%, and keeps taxRate as a decimal. Do not change the tests or add dependencies.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/invoice.test.ts'), 'utf8') + return [ + { name: 'keeps invoice behavior tests', pass: testFile.includes('toBe(2430)') && testFile.includes('quantity: 3') }, + bunTestCheck(cwd), + ] + }, + }, + { + id: 'jsonc-config-parser', + title: 'Add tiny JSONC config parser support', + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/config.ts': `export type AppConfig = { + model: string + limits: { + maxTurns: number + } + features: string[] +} + +export function parseConfig(text: string): AppConfig { + return JSON.parse(text) +} +`, + 'tests/config.test.ts': `import { expect, test } from 'bun:test' +import { parseConfig } from '../src/config' + +test('parses line comments and trailing commas', () => { + const config = parseConfig(\`{ + // default benchmark model + "model": "openai/gpt-5.3-codex-spark", + "limits": { + "maxTurns": 8, + }, + "features": [ + "shell", + "edit", + ], + }\`) + + expect(config).toEqual({ + model: 'openai/gpt-5.3-codex-spark', + limits: { maxTurns: 8 }, + features: ['shell', 'edit'], + }) +}) +`, + }, + prompt: `Update src/config.ts so parseConfig accepts JSONC-style // line comments and trailing commas in objects/arrays. Keep the public API the same, keep the existing test, and do not add dependencies.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/config.test.ts'), 'utf8') + return [ + { + name: 'keeps JSONC coverage', + pass: testFile.includes('// default benchmark model') && testFile.includes('"maxTurns": 8,') && testFile.includes('"edit",'), + }, + bunTestCheck(cwd), + ] + }, + }, ] const args = parseArgs(process.argv.slice(2)) @@ -178,7 +328,7 @@ const reportPath = args['no-report'] ? null : args.report && args.report !== true ? resolve(String(args.report)) - : join(runRoot, 'report.md') + : join(resolve(String(args['report-dir'] ?? process.env.BENCH_REPORT_DIR ?? DEFAULT_REPORT_DIR)), `agent-benchmark-${runId}.md`) if (args.help) { printHelp() @@ -207,6 +357,7 @@ activeRunRoot = runRoot printPaths() const results: RunResult[] = [] +writeCurrentMarkdownReport() try { let runNumber = 0 @@ -216,8 +367,9 @@ try { for (const agent of agents) { if (shutdownRequested) break runLoop runNumber += 1 - const result = await runBenchmark(agent, task, runIndex, runNumber, plannedPrompts) + const result = await safeRunBenchmark(agent, task, runIndex, runNumber, plannedPrompts) results.push(result) + writeCurrentMarkdownReport() printResult(result) } } @@ -226,22 +378,7 @@ try { printSummary(results) if (reportPath) { - writeMarkdownReport(reportPath, { - runId, - runRoot, - workspacesRoot, - logsRoot, - model, - agents, - tasks: selectedTasks, - runs, - timeoutMs, - keep, - inputPrice, - outputPrice, - results, - stopped: shutdownRequested, - }) + writeCurrentMarkdownReport() console.log(`\nWrote Markdown report to ${reportPath}`) } @@ -278,6 +415,7 @@ try { console.log(`\nWrote JSON results to ${outputPath}`) } } finally { + writeCurrentMarkdownReport() if (keep) { console.log(`\nKept benchmark workspaces in ${workspacesRoot}`) } else { @@ -286,6 +424,55 @@ try { activeRunRoot = null } +function writeCurrentMarkdownReport() { + if (!reportPath || estimateOnly) return + + writeMarkdownReport(reportPath, { + runId, + runRoot, + workspacesRoot, + logsRoot, + model, + agents, + tasks: selectedTasks, + runs, + plannedPrompts, + timeoutMs, + keep, + inputPrice, + outputPrice, + results, + stopped: shutdownRequested, + }) +} + +async function safeRunBenchmark( + agent: AgentName, + task: Task, + runIndex: number, + runNumber: number, + totalRuns: number, +): Promise { + try { + return await runBenchmark(agent, task, runIndex, runNumber, totalRuns) + } catch (err) { + return { + agent, + task: task.id, + ok: false, + passedChecks: 0, + totalChecks: 0, + elapsedMs: 0, + estimatedInputTokens: 0, + estimatedOutputTokens: 0, + estimatedCostUsd: 0, + exitCode: null, + timedOut: false, + error: `benchmark runner crashed: ${err instanceof Error ? err.message : String(err)}`, + } + } +} + async function runBenchmark( agent: AgentName, task: Task, @@ -297,6 +484,7 @@ async function runBenchmark( const workspace = join(workspacesRoot, runLabel) mkdirSync(workspace, { recursive: true }) activeWorkspaces.add(workspace) + let staticServer: Awaited> | null = null try { writeFixture(workspace, task) @@ -305,9 +493,34 @@ async function runBenchmark( writeFileSync(join(workspace, 'crabcode.jsonc'), JSON.stringify({ model }, null, 2) + '\n') } - const prompt = benchmarkPrompt(task.prompt) - const command = commandFor(agent, prompt) printRunStart(runNumber, totalRuns, agent, task.id, workspace) + + if (task.site) { + try { + staticServer = await startStaticServer(join(workspace, task.site.root)) + } catch (err) { + const checks = runChecks(task, workspace) + const passedChecks = checks.filter((check) => check.pass).length + return { + agent, + task: task.id, + ok: false, + passedChecks, + totalChecks: checks.length, + elapsedMs: 0, + estimatedInputTokens: 0, + estimatedOutputTokens: 0, + estimatedCostUsd: 0, + exitCode: null, + timedOut: false, + error: `failed to start local static server: ${err instanceof Error ? err.message : String(err)}`, + workspace, + } + } + } + + const prompt = benchmarkPrompt(resolveTaskPrompt(task, staticServer?.url)) + const command = commandFor(agent, prompt) const started = performance.now() const proc = await runShell(command, workspace, timeoutMs) const elapsedMs = Math.round(performance.now() - started) @@ -348,6 +561,7 @@ async function runBenchmark( stderrTail: tailText(proc.stderr), } } finally { + await staticServer?.close() activeWorkspaces.delete(workspace) } } @@ -368,8 +582,7 @@ function commandFor(agent: AgentName, prompt: string) { const defaults: Record = { crabcode: defaultCrabcodeCommand(), opencode: 'opencode run --dangerously-skip-permissions -m {model} {prompt}', - codex: - 'codex exec --ephemeral --skip-git-repo-check --sandbox workspace-write -c \'approval_policy="never"\' -m {model} {prompt}', + codex: 'codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}', } const envName = `BENCH_${agent.toUpperCase()}_CMD` const template = process.env[envName] || defaults[agent] @@ -385,11 +598,17 @@ function benchmarkPrompt(prompt: string) { 'You are running inside an isolated benchmark fixture.', 'Modify files in the current working directory directly. Do not only describe the change.', 'Keep the change minimal. When the task is complete, stop.', + 'If the task names exact file paths, inspect those paths directly instead of listing directories first.', + 'Do not repeat identical tool calls or run optional extra checks after the requested change is complete.', '', `Task: ${prompt}`, ].join('\n') } +function resolveTaskPrompt(task: Task, siteUrl?: string) { + return task.prompt.replaceAll('{siteUrl}', siteUrl ?? '') +} + function modelForAgent(agent: AgentName, modelRef: string) { if (agent === 'codex') { return modelRef.replace(/^openai\//, '') @@ -479,14 +698,144 @@ function runChecks(task: Task, workspace: string): CheckResult[] { } } +function bunTestCheck(cwd: string): CheckResult { + const result = runCheckCommand(cwd, process.execPath, ['test']) + return { + name: 'bun test passes', + pass: result.ok, + detail: result.detail, + } +} + +function runCheckCommand(cwd: string, command: string, args: string[]) { + const proc = spawnSync(command, args, { + cwd, + encoding: 'utf8', + timeout: 15_000, + env: { + ...process.env, + NO_COLOR: '1', + CI: '1', + }, + }) + const output = `${proc.stdout ?? ''}\n${proc.stderr ?? ''}`.trim() + const detail = proc.error + ? proc.error.message + : proc.status === 0 + ? undefined + : tailText(output, 600) || `exit code ${proc.status}` + + return { + ok: proc.status === 0, + detail, + } +} + +async function startStaticServer(root: string) { + const absoluteRoot = resolve(root) + let lastError: Error | null = null + + for (let attempt = 0; attempt < 20; attempt++) { + const port = 41_000 + Math.floor(Math.random() * 20_000) + try { + return await listenStaticServer(absoluteRoot, port) + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)) + } + } + + throw lastError ?? new Error('failed to start static server') +} + +function listenStaticServer(absoluteRoot: string, port: number) { + const server = createServer((request, response) => { + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.writeHead(405, { allow: 'GET, HEAD' }) + response.end('Method not allowed') + return + } + + let requestPath = 'index.html' + try { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + requestPath = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'index.html' + } catch { + response.writeHead(400) + response.end('Bad request') + return + } + + const filePath = resolve(absoluteRoot, requestPath) + if (filePath !== absoluteRoot && !filePath.startsWith(absoluteRoot + sep)) { + response.writeHead(403) + response.end('Forbidden') + return + } + + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + response.writeHead(404) + response.end('Not found') + return + } + + response.writeHead(200, { 'content-type': contentTypeFor(filePath) }) + if (request.method === 'HEAD') { + response.end() + return + } + response.end(readFileSync(filePath)) + }) + + return new Promise<{ url: string; close: () => Promise }>((resolveStart, rejectStart) => { + let settled = false + const onError = (err: Error) => { + if (settled) return + settled = true + rejectStart(err) + } + server.once('error', onError) + try { + server.listen(port, '127.0.0.1', () => { + if (settled) return + settled = true + server.off('error', onError) + resolveStart({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolveClose) => { + server.close(() => resolveClose()) + }), + }) + }) + } catch (err) { + onError(err instanceof Error ? err : new Error(String(err))) + } + }) +} + +function contentTypeFor(path: string) { + switch (extname(path)) { + case '.json': + return 'application/json; charset=utf-8' + case '.md': + return 'text/markdown; charset=utf-8' + case '.txt': + return 'text/plain; charset=utf-8' + default: + return 'application/octet-stream' + } +} + function requestShutdown(signal: string) { if (shutdownRequested) { + writeCurrentMarkdownReport() cleanupActiveWorkspaces() process.exit(signal === 'SIGINT' ? 130 : 143) } shutdownRequested = true console.error(`\nReceived ${signal}; stopping active agent processes...`) + writeCurrentMarkdownReport() for (const child of activeChildren) { terminateChild(child, 'SIGTERM') @@ -496,6 +845,7 @@ function requestShutdown(signal: string) { for (const child of activeChildren) { terminateChild(child, 'SIGKILL') } + writeCurrentMarkdownReport() cleanupActiveWorkspaces() process.exit(signal === 'SIGINT' ? 130 : 143) }, 2_500).unref() @@ -574,6 +924,7 @@ function printPaths() { console.log('Notes') console.log(' Permission-gated actions are auto-approved for opencode and codex in isolated workspaces.') console.log(' Crabcode print mode is run with --dangerously-skip-permissions in isolated workspaces.') + console.log(' Site-fetch tasks use a per-run 127.0.0.1 static server; they do not hit the public internet.') if (!keep) { console.log(' Workspaces are removed at exit. Pass --keep to preserve them.') } @@ -650,6 +1001,7 @@ function writeMarkdownReport( agents: AgentName[] tasks: Task[] runs: number + plannedPrompts: number timeoutMs: number keep: boolean inputPrice: number @@ -670,6 +1022,7 @@ function writeMarkdownReport( lines.push(`Agents: ${report.agents.map((agent) => `\`${displayAgent(agent)}\``).join(', ')}`) lines.push(`Tasks: ${report.tasks.map((task) => `\`${task.id}\``).join(', ')}`) lines.push(`Runs per agent/task: ${report.runs}`) + lines.push(`Completed runs: ${report.results.length}/${report.plannedPrompts}`) lines.push(`Timeout per run: ${report.timeoutMs}ms`) lines.push(`Benchmark run directory: \`${report.runRoot}\``) lines.push(`Agents ran in: \`${report.workspacesRoot}\``) @@ -678,6 +1031,7 @@ function writeMarkdownReport( lines.push(`Stopped early: ${report.stopped ? 'yes' : 'no'}`) lines.push('') lines.push(`Permission-gated actions are auto-approved for benchmark agent commands in isolated workspaces.`) + lines.push(`Site-fetch tasks use a per-run 127.0.0.1 static server and do not hit the public internet.`) lines.push(`Cost is a rough estimate from prompt/output text tokens only; provider dashboards are the source of truth.`) lines.push('') @@ -850,14 +1204,15 @@ function printHelp() { Options: --model provider/model Model passed to each agent. --agents crabcode,opencode,codex Agents to run. - --tasks bugfix-js,add-rust-test Task IDs to run. + --tasks id-a,id-b Task IDs to run. --runs 1 Repetitions per agent/task. --timeout-ms 45000 Timeout per run. --estimate Print planned prompt count and prompt-only cost, then exit. --input-price 1.25 Input USD per 1M tokens for rough cost estimates. --output-price 10 Output USD per 1M tokens for rough cost estimates. --out bench-results.json Write machine-readable JSON results. - --report benchmark.md Write Markdown report. Default: .benchmarks//report.md. + --report benchmark.md Write Markdown report at an exact path. + --report-dir benchmark-reports Directory for default Markdown reports. --no-report Disable Markdown report generation. --dir .benchmarks Parent directory for benchmark runs. --keep Keep temporary workspaces for inspection. @@ -871,10 +1226,11 @@ Default params: input-price: ${DEFAULT_INPUT_USD_PER_MTOK} output-price: ${DEFAULT_OUTPUT_USD_PER_MTOK} dir: ${DEFAULT_BENCHMARK_DIR} + report-dir: ${DEFAULT_REPORT_DIR} Environment overrides: BENCH_MODEL, BENCH_AGENTS, BENCH_TASKS, BENCH_RUNS, BENCH_TIMEOUT_MS, - BENCH_INPUT_USD_PER_MTOK, BENCH_OUTPUT_USD_PER_MTOK, BENCH_DIR + BENCH_INPUT_USD_PER_MTOK, BENCH_OUTPUT_USD_PER_MTOK, BENCH_DIR, BENCH_REPORT_DIR Stop behavior: Ctrl+C stops the active agent process tree and removes temporary workspaces unless --keep is set. @@ -882,7 +1238,7 @@ Stop behavior: Command overrides: BENCH_CRABCODE_CMD='crabcode -p --no-session-persistence --dangerously-skip-permissions {prompt}' BENCH_OPENCODE_CMD='opencode run --dangerously-skip-permissions -m {model} {prompt}' - BENCH_CODEX_CMD='codex exec --ephemeral --skip-git-repo-check --sandbox workspace-write -c approval_policy="never" -m {model} {prompt}' + BENCH_CODEX_CMD='codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}' Template tokens: {prompt}, {model}, {repo} Note: {model} is agent-aware; codex strips a leading openai/ provider prefix. diff --git a/src/main.rs b/src/main.rs index 3735841..3f76a13 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,6 +44,7 @@ use std::sync::Mutex; use std::time::Duration; const POST_CLOSE_LOGO: &str = include_str!("../crabcode-logo.txt"); +const DEFAULT_PRINT_MODE_AGENT_MAX_STEPS: usize = 16; lazy_static::lazy_static! { static ref STARTUP_DIAGNOSTICS: Mutex> = Mutex::new(Vec::new()); @@ -148,7 +149,8 @@ async fn run_print_mode( .merged_config .agent_steps .get(&agent_mode.to_ascii_lowercase()) - .copied(); + .copied() + .or(Some(DEFAULT_PRINT_MODE_AGENT_MAX_STEPS)); let provider_name_clone = provider_name.clone(); let model_clone = model_id.clone(); diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index ef68028..1c0975c 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -213,11 +213,17 @@ Planning: File Handling: - Never re-read files after successful edit +- If the user names exact files, inspect those files directly instead of listing directories first +- Avoid repeating identical reads, listings, searches, or validation commands - Use git log/blame for history context - Never add copyright/license headers - Don't use one-letter variables - Use file_path format for citations +Efficiency: +- For small, explicit tasks, make the minimal required edit and stop after one relevant verification +- Do not continue searching for optional improvements once the user's requested change is complete + Your output will be displayed on a command line interface. Your responses should be short and concise (typically < 4 lines, excluding tool calls)."#.to_string() } diff --git a/src/tools/webfetch.rs b/src/tools/webfetch.rs index 024ea3e..de1f52e 100644 --- a/src/tools/webfetch.rs +++ b/src/tools/webfetch.rs @@ -30,7 +30,7 @@ impl ToolHandler for WebfetchTool { fn definition(&self) -> Tool { Tool { id: "webfetch".to_string(), - description: "Fetches content from a specified URL and returns it as markdown. Handles HTML to markdown conversion.\n\nUsage notes:\n- The URL must be a fully-formed valid URL\n- HTTP URLs will be automatically upgraded to HTTPS\n- Format options: \"markdown\" (default), \"text\", or \"html\"\n- Results may be summarized if the content is very large".to_string(), + description: "Fetches content from a specified URL and returns it as markdown. Handles HTML to markdown conversion.\n\nUsage notes:\n- The URL must be a fully-formed valid URL\n- HTTP URLs will be automatically upgraded to HTTPS, except localhost and loopback URLs\n- Format options: \"markdown\" (default), \"text\", or \"html\"\n- Results may be summarized if the content is very large".to_string(), parameters: vec![ ParameterSchema { name: "url".to_string(), @@ -83,11 +83,7 @@ impl ToolHandler for WebfetchTool { .max(1) .min(MAX_TIMEOUT_SECS as i64) as u64; - let url = if raw_url.starts_with("http://") { - format!("https://{}", &raw_url[7..]) - } else { - raw_url.clone() - }; + let url = fetch_url_for(&raw_url); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(timeout_secs)) @@ -179,6 +175,36 @@ impl ToolHandler for WebfetchTool { } } +fn fetch_url_for(raw_url: &str) -> String { + if raw_url.starts_with("http://") && !is_loopback_http_url(raw_url) { + format!("https://{}", &raw_url[7..]) + } else { + raw_url.to_string() + } +} + +fn is_loopback_http_url(raw_url: &str) -> bool { + let Ok(url) = url::Url::parse(raw_url) else { + return false; + }; + + if url.scheme() != "http" { + return false; + } + + let Some(host) = url.host_str() else { + return false; + }; + + if host.eq_ignore_ascii_case("localhost") || host.to_ascii_lowercase().ends_with(".localhost") { + return true; + } + + host.trim_matches(['[', ']']) + .parse::() + .is_ok_and(|addr| addr.is_loopback()) +} + async fn send_request( client: &reqwest::Client, url: &str, @@ -726,6 +752,34 @@ mod tests { ); } + #[test] + fn fetch_url_preserves_local_http_urls() { + assert_eq!( + fetch_url_for("http://127.0.0.1:41234/api/releases.json"), + "http://127.0.0.1:41234/api/releases.json" + ); + assert_eq!( + fetch_url_for("http://localhost:3000/index.html"), + "http://localhost:3000/index.html" + ); + assert_eq!( + fetch_url_for("http://[::1]:3000/index.html"), + "http://[::1]:3000/index.html" + ); + } + + #[test] + fn fetch_url_upgrades_public_http_urls() { + assert_eq!( + fetch_url_for("http://example.com/docs"), + "https://example.com/docs" + ); + assert_eq!( + fetch_url_for("https://example.com/docs"), + "https://example.com/docs" + ); + } + #[test] fn metadata_fallback_prevents_empty_html_result() { let html = r#" From d7728fb52a81d91600c677a84f585e51b5454e44 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 04:18:42 +0800 Subject: [PATCH 109/226] feat(bench): add workflow-planner-ts benchmark case with hidden test runner. Add a new benchmark case for dependency-aware workflow planning that tests creating execution plans with proper dependency resolution, cycle detection, and unsorted input handling. Also introduce `bunTestWithHiddenFileCheck` utility to support hidden test file verification in benchmarks. --- scripts/bench-agents.ts | 130 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/scripts/bench-agents.ts b/scripts/bench-agents.ts index 55c84af..fa1f302 100644 --- a/scripts/bench-agents.ts +++ b/scripts/bench-agents.ts @@ -305,6 +305,123 @@ test('parses line comments and trailing commas', () => { ] }, }, + { + id: 'workflow-planner-ts', + title: 'Implement dependency-aware workflow planning', + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/planner.ts': `export type WorkflowStep = { + id: string + dependsOn?: string[] + estimatedSeconds?: number +} + +export type ExecutionStage = { + parallel: string[] +} + +export function createExecutionPlan(steps: WorkflowStep[]): ExecutionStage[] { + return steps.map((step) => ({ parallel: [step.id] })) +} +`, + 'tests/planner.test.ts': `import { expect, test } from 'bun:test' +import { createExecutionPlan, type WorkflowStep } from '../src/planner' + +test('groups ready steps into stable dependency stages', () => { + const steps: WorkflowStep[] = [ + { id: 'checkout' }, + { id: 'lint', dependsOn: ['checkout'] }, + { id: 'docs', dependsOn: ['checkout'] }, + { id: 'test', dependsOn: ['lint'] }, + { id: 'package', dependsOn: ['docs', 'test'] }, + ] + + expect(createExecutionPlan(steps)).toEqual([ + { parallel: ['checkout'] }, + { parallel: ['lint', 'docs'] }, + { parallel: ['test'] }, + { parallel: ['package'] }, + ]) +}) + +test('rejects missing dependencies with useful context', () => { + expect(() => + createExecutionPlan([ + { id: 'deploy', dependsOn: ['package'] }, + ]), + ).toThrow(/package.*deploy|deploy.*package/) +}) + +test('rejects dependency cycles', () => { + expect(() => + createExecutionPlan([ + { id: 'a', dependsOn: ['b'] }, + { id: 'b', dependsOn: ['a'] }, + ]), + ).toThrow(/cycle/i) +}) +`, + }, + prompt: `Implement createExecutionPlan in src/planner.ts. Return execution stages where every step in a stage can run after all previous stages, and keep the original input order inside each stage. The function must handle input that is not already sorted, throw helpful errors for duplicate step ids, unknown dependencies, and dependency cycles, and it must not mutate the input. Do not change the tests or add dependencies.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/planner.test.ts'), 'utf8') + return [ + { + name: 'keeps visible planner coverage', + pass: + testFile.includes('groups ready steps into stable dependency stages') && + testFile.includes('rejects missing dependencies') && + testFile.includes('rejects dependency cycles'), + }, + bunTestWithHiddenFileCheck( + cwd, + 'hidden workflow planner tests pass', + 'tests/__bench_hidden_planner.test.ts', + `import { expect, test } from 'bun:test' +import { createExecutionPlan, type WorkflowStep } from '../src/planner' + +test('plans unsorted dependency input without mutating it', () => { + const steps: WorkflowStep[] = [ + { id: 'deploy', dependsOn: ['build', 'migrate'], estimatedSeconds: 30 }, + { id: 'lint', estimatedSeconds: 10 }, + { id: 'build', dependsOn: ['lint'], estimatedSeconds: 40 }, + { id: 'migrate', dependsOn: ['lint'], estimatedSeconds: 15 }, + { id: 'notify', dependsOn: ['deploy'], estimatedSeconds: 5 }, + ] + const original = structuredClone(steps) + + expect(createExecutionPlan(steps)).toEqual([ + { parallel: ['lint'] }, + { parallel: ['build', 'migrate'] }, + { parallel: ['deploy'] }, + { parallel: ['notify'] }, + ]) + expect(steps).toEqual(original) +}) + +test('rejects duplicate step ids', () => { + expect(() => + createExecutionPlan([ + { id: 'build' }, + { id: 'build', dependsOn: ['build'] }, + ]), + ).toThrow(/duplicate|build/i) +}) + +test('detects cycles even when independent work is present', () => { + expect(() => + createExecutionPlan([ + { id: 'setup' }, + { id: 'a', dependsOn: ['b'] }, + { id: 'b', dependsOn: ['a'] }, + ]), + ).toThrow(/cycle/i) +}) +`, + ), + ] + }, + }, ] const args = parseArgs(process.argv.slice(2)) @@ -707,6 +824,19 @@ function bunTestCheck(cwd: string): CheckResult { } } +function bunTestWithHiddenFileCheck(cwd: string, name: string, path: string, content: string): CheckResult { + const fullPath = join(cwd, path) + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content) + + const result = runCheckCommand(cwd, process.execPath, ['test']) + return { + name, + pass: result.ok, + detail: result.detail, + } +} + function runCheckCommand(cwd: string, command: string, args: string[]) { const proc = spawnSync(command, args, { cwd, From 55995c9b36810e69dcd01c15d4755a56732ea555 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 04:43:22 +0800 Subject: [PATCH 110/226] feat: show sessions from all workspaces with group reordering. - Sessions dialog now lists sessions from all workspaces (not just current) - Dialog defaults to "All" filter instead of "Active" - Group headers are focusable; up/down cycles between groups - Right arrow collapses/expands a focused workspace group - Alt+Up/Down reorders workspace groups by updating sort_order in DB - Status bar shows git branch for the active session's workspace path - `/sessions` command includes sessions from all unarchived workspaces --- _plans/__TODOS.md | 16 ++- src/app.rs | 113 +++++++++++++-- src/command/handlers.rs | 48 +++++-- src/persistence/history.rs | 50 ++++++- src/session/manager.rs | 81 ++++++++++- src/session/types.rs | 3 + src/ui/components/dialog.rs | 266 ++++++++++++++++++++++++++++++++--- src/utils/git.rs | 7 +- src/views/sessions_dialog.rs | 236 +++++++++++++++++++++++++++++-- 9 files changed, 753 insertions(+), 67 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 37a8cd7..35ad2cb 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -86,8 +86,18 @@ - scroll w/ my mouse (no thumbs, just scroll) - click the item with my mouse -- [ ] Benchmark script to test performance against opencode + codex in comparison. As cheaply as possible. Using the same models. It doesn't need to be a state-of-the-art benchmark. It just needs to test a couple of usual things i.e. small stuff, see if the agent is at least just as capable, because what we're chasing is kinda exactly just the same as codex/opencode, not better. The "better" will be in the UX, it will have the better UX changes I want. So I will want to also explicitly say it's a make-shift benchmark. I want the benchmark to output: - - [ ] Cost to test - this is just my personal add - - [ ] Idk what metric usually is used, to define "better". - the goal is crabcode will have the same score as the others. +- [x] Benchmark script to test performance against opencode + codex in comparison. As cheaply as possible. Using the same models. It doesn't need to be a state-of-the-art benchmark. It just needs to test a couple of usual things i.e. small stuff, see if the agent is at least just as capable, because what we're chasing is kinda exactly just the same as codex/opencode, not better. The "better" will be in the UX, it will have the better UX changes I want. So I will want to also explicitly say it's a make-shift benchmark. I want the benchmark to output: + - [x] Cost to test - this is just my personal add + - [x] Idk what metric usually is used, to define "better". - the goal is crabcode will have the same score as the others. - [ ] Paste compaction i.e. [Pasted Content 1865 chars] + +- [x] multiworkspace not working when I open other directories, I should be able to see in + +- [ ] better timeline highlighting of each "message" + +- [ ] IN /models, can we use the ❤︎ icon, but colored pink. instead of the long heart + favorite indicator. + +- [ ] Reasoning effort adjustment in /models. Or a hotkey? In opencode it's ctrl-t. + +- [ ] /commands and custom commands. diff --git a/src/app.rs b/src/app.rs index f9750d3..fd56e95 100644 --- a/src/app.rs +++ b/src/app.rs @@ -237,6 +237,7 @@ pub struct App { last_animation_update: std::time::Instant, last_session_spinner_update: std::time::Instant, cached_git_branch: Option, + cached_git_branch_path: String, last_git_branch_check: std::time::Instant, discovery: Option, cached_usage_text: String, @@ -381,7 +382,7 @@ impl App { .with_agent_policies(agent_policies); let discovery = crate::model::discovery::Discovery::new().ok(); - let cached_git_branch = git::get_current_branch(); + let cached_git_branch = git::get_branch_for_path(&cwd); let now = std::time::Instant::now(); Ok(Self { @@ -420,7 +421,7 @@ impl App { provider_timeouts, model: active_model, provider_name: active_provider_name, - cwd, + cwd: cwd.clone(), base_focus: BaseFocus::Home, overlay_focus: OverlayFocus::None, ctrl_c_press_count: 0, @@ -440,6 +441,7 @@ impl App { last_animation_update: now, last_session_spinner_update: now, cached_git_branch, + cached_git_branch_path: cwd.clone(), last_git_branch_check: now, discovery, cached_usage_text: String::new(), @@ -944,11 +946,24 @@ impl App { theme.get_colors(self.dark_mode) } - fn current_git_branch(&mut self) -> Option { + fn active_workspace_path(&self) -> String { + self.session_manager + .get_current_session_id() + .and_then(|id| self.session_manager.get_session_ref(id)) + .map(|session| session.workspace_path.trim()) + .filter(|path| !path.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| self.cwd.clone()) + } + + fn current_git_branch(&mut self, cwd: &str) -> Option { const GIT_BRANCH_REFRESH: std::time::Duration = std::time::Duration::from_secs(2); - if self.last_git_branch_check.elapsed() >= GIT_BRANCH_REFRESH { - self.cached_git_branch = git::get_current_branch(); + if self.cached_git_branch_path != cwd + || self.last_git_branch_check.elapsed() >= GIT_BRANCH_REFRESH + { + self.cached_git_branch = git::get_branch_for_path(cwd); + self.cached_git_branch_path = cwd.to_string(); self.last_git_branch_check = std::time::Instant::now(); } @@ -1386,6 +1401,29 @@ impl App { self.overlay_focus = OverlayFocus::SessionRenameDialog; true } + SessionsDialogAction::MoveWorkspaceGroup { + workspace_id, + group, + direction, + } => { + match self + .session_manager + .move_workspace_sort_order(workspace_id, direction.offset()) + { + Ok(true) => { + self.refresh_sessions_dialog(); + let _ = + self.sessions_dialog_state.dialog.focus_group_header(&group); + } + Ok(false) => {} + Err(err) => push_toast(Toast::new( + format!("Failed to move workspace: {:?}", err), + ToastLevel::Error, + None, + )), + } + true + } } } OverlayFocus::SessionRenameDialog => { @@ -2999,13 +3037,15 @@ impl App { }); sessions.sort_by(|a, b| { - a.workspace_id - .cmp(&b.workspace_id) + a.workspace_sort_order + .cmp(&b.workspace_sort_order) + .then_with(|| a.workspace_id.cmp(&b.workspace_id)) .then_with(|| b.pinned_at.is_some().cmp(&a.pinned_at.is_some())) .then_with(|| b.status.is_active().cmp(&a.status.is_active())) .then_with(|| b.updated_at.cmp(&a.updated_at)) }); + let mut workspace_group_ids = std::collections::HashMap::new(); let items: Vec = sessions .into_iter() .map(|session| { @@ -3032,6 +3072,9 @@ impl App { } else { session.workspace_name.clone() }; + workspace_group_ids + .entry(group.clone()) + .or_insert(session.workspace_id); crate::ui::components::dialog::DialogItem { id: session.id.clone(), @@ -3047,6 +3090,8 @@ impl App { .collect(); self.sessions_dialog_state.refresh_items(items); + self.sessions_dialog_state + .set_workspace_group_ids(workspace_group_ids); } fn session_loading_glyph(&self) -> &'static str { @@ -4617,7 +4662,8 @@ impl App { self.cached_usage_check = fingerprint; self.cached_usage_text = self.session_usage_text(); } - let branch = self.current_git_branch(); + let status_cwd = self.active_workspace_path(); + let branch = self.current_git_branch(&status_cwd); let usage_text = &self.cached_usage_text; match self.base_focus { @@ -4627,7 +4673,7 @@ impl App { &mut self.input, &self.home_state, self.version.clone(), - self.cwd.clone(), + status_cwd.clone(), branch.clone(), self.agent.clone(), self.model.clone(), @@ -4657,7 +4703,7 @@ impl App { &mut self.chat_state, &mut self.input, self.version.clone(), - self.cwd.clone(), + status_cwd.clone(), branch, self.agent.clone(), self.model.clone(), @@ -4884,6 +4930,7 @@ mod tests { last_animation_update: std::time::Instant::now(), last_session_spinner_update: std::time::Instant::now(), cached_git_branch: None, + cached_git_branch_path: ".".to_string(), last_git_branch_check: std::time::Instant::now(), discovery: None, cached_usage_text: String::new(), @@ -5015,6 +5062,52 @@ mod tests { assert_eq!(app.session_manager.list_sessions().len(), 1); } + #[test] + fn sessions_dialog_defaults_to_all_unarchived_workspaces() { + let mut app = test_app(); + let current_id = app.create_new_session(Some("Current".to_string())); + let other_id = app.create_new_session(Some("Other".to_string())); + let other_session = app.session_manager.get_session(&other_id).unwrap(); + other_session.workspace_id = 42; + other_session.workspace_path = "/tmp/other-workspace".to_string(); + other_session.workspace_name = "other-workspace".to_string(); + + app.open_sessions_dialog(); + + assert_eq!(app.sessions_dialog_state.filter, SessionsDialogFilter::All); + let items = &app.sessions_dialog_state.dialog.items; + assert!(items.iter().any(|item| item.id == current_id)); + assert!(items + .iter() + .any(|item| item.id == other_id && item.group == "other-workspace")); + } + + #[test] + fn status_workspace_path_follows_active_session() { + let mut app = test_app(); + app.cwd = "/tmp/fallback-workspace".to_string(); + let first_id = app.create_new_session(Some("First".to_string())); + let second_id = app.create_new_session(Some("Second".to_string())); + + app.session_manager + .get_session(&first_id) + .unwrap() + .workspace_path = "/tmp/workspace-a".to_string(); + app.session_manager + .get_session(&second_id) + .unwrap() + .workspace_path = "/tmp/workspace-b".to_string(); + + assert!(app.switch_to_session(&first_id)); + assert_eq!(app.active_workspace_path(), "/tmp/workspace-a"); + + assert!(app.switch_to_session(&second_id)); + assert_eq!(app.active_workspace_path(), "/tmp/workspace-b"); + + app.session_manager.clear_current_session(); + assert_eq!(app.active_workspace_path(), "/tmp/fallback-workspace"); + } + #[test] fn deleting_current_session_keeps_sessions_dialog_focused() { let mut app = test_app(); diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 317b34a..fe75f69 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -17,15 +17,12 @@ pub fn handle_sessions<'a>( sm: &'a mut SessionManager, ) -> Pin + Send + 'a>> { Box::pin(async move { - let current_workspace_id = sm.current_workspace_id(); let mut sessions = sm.list_sessions(); - sessions.retain(|session| { - session.archived_at.is_none() - && (session.workspace_id == current_workspace_id || session.status.is_active()) - }); + sessions.retain(|session| session.parent_id.is_none() && session.archived_at.is_none()); sessions.sort_by(|a, b| { - a.workspace_id - .cmp(&b.workspace_id) + a.workspace_sort_order + .cmp(&b.workspace_sort_order) + .then_with(|| a.workspace_id.cmp(&b.workspace_id)) .then_with(|| b.pinned_at.is_some().cmp(&a.pinned_at.is_some())) .then_with(|| b.status.is_active().cmp(&a.status.is_active())) .then_with(|| b.updated_at.cmp(&a.updated_at)) @@ -43,7 +40,11 @@ pub fn handle_sessions<'a>( crate::command::registry::DialogItem { id: session.id.clone(), name, - group: session.workspace_name.clone(), + group: if session.workspace_name.trim().is_empty() { + session.workspace_path.clone() + } else { + session.workspace_name.clone() + }, description: String::new(), tip: None, provider_id: session.title.clone(), @@ -749,6 +750,37 @@ mod tests { } } + #[tokio::test] + async fn test_handle_sessions_includes_other_workspaces() { + let mut session_manager = SessionManager::new(); + let current_id = session_manager.create_session(Some("current".to_string())); + let other_id = session_manager.create_session(Some("other".to_string())); + let other_session = session_manager.get_session(&other_id).unwrap(); + other_session.workspace_id = 42; + other_session.workspace_path = "/tmp/other-workspace".to_string(); + other_session.workspace_name = "other-workspace".to_string(); + + let parsed = ParsedCommand { + name: "sessions".to_string(), + args: vec![], + raw: "/sessions".to_string(), + prefs_dao: None, + active_model_id: None, + }; + let result = handle_sessions(&parsed, &mut session_manager).await; + match result { + CommandResult::ShowDialog { title, items } => { + assert_eq!(title, "Sessions"); + assert_eq!(items.len(), 2); + assert!(items.iter().any(|item| item.id == current_id)); + assert!(items + .iter() + .any(|item| item.id == other_id && item.group == "other-workspace")); + } + _ => panic!("Expected ShowDialog"), + } + } + #[tokio::test] async fn test_handle_new_no_args() { let parsed = ParsedCommand { diff --git a/src/persistence/history.rs b/src/persistence/history.rs index f2dbe1a..72ce3c3 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -30,6 +30,7 @@ pub struct Session { pub workspace_id: i64, pub workspace_path: String, pub workspace_name: String, + pub workspace_sort_order: i64, pub status: String, pub pinned_at: Option, pub archived_at: Option, @@ -182,6 +183,7 @@ impl HistoryDAO { COALESCE(s.workspace_id, ?1) AS workspace_id, COALESCE(w.root_path, ?2) AS workspace_path, COALESCE(w.display_name, ?3) AS workspace_name, + COALESCE(w.sort_order, COALESCE(s.workspace_id, ?1)) AS workspace_sort_order, COALESCE(s.status, 'idle') AS status, s.pinned_at, s.archived_at @@ -211,9 +213,10 @@ impl HistoryDAO { workspace_id: row.get(10)?, workspace_path: row.get(11)?, workspace_name: row.get(12)?, - status: row.get(13)?, - pinned_at: row.get(14)?, - archived_at: row.get(15)?, + workspace_sort_order: row.get(13)?, + status: row.get(14)?, + pinned_at: row.get(15)?, + archived_at: row.get(16)?, }) }, )?; @@ -230,6 +233,7 @@ impl HistoryDAO { COALESCE(s.workspace_id, ?2) AS workspace_id, COALESCE(w.root_path, ?3) AS workspace_path, COALESCE(w.display_name, ?4) AS workspace_name, + COALESCE(w.sort_order, COALESCE(s.workspace_id, ?2)) AS workspace_sort_order, COALESCE(s.status, 'idle') AS status, s.pinned_at, s.archived_at @@ -259,15 +263,49 @@ impl HistoryDAO { workspace_id: row.get(10)?, workspace_path: row.get(11)?, workspace_name: row.get(12)?, - status: row.get(13)?, - pinned_at: row.get(14)?, - archived_at: row.get(15)?, + workspace_sort_order: row.get(13)?, + status: row.get(14)?, + pinned_at: row.get(15)?, + archived_at: row.get(16)?, })) } else { Ok(None) } } + pub fn move_workspace_sort_order(&self, workspace_id: i64, offset: isize) -> Result { + let mut workspaces = self.list_workspaces()?; + let Some(index) = workspaces + .iter() + .position(|workspace| workspace.id == workspace_id) + else { + return Ok(false); + }; + + let target_index = if offset < 0 { + index.checked_sub(1) + } else if offset > 0 && index + 1 < workspaces.len() { + Some(index + 1) + } else { + None + }; + + let Some(target_index) = target_index else { + return Ok(false); + }; + + workspaces.swap(index, target_index); + + for (sort_order, workspace) in workspaces.iter().enumerate() { + self.conn.execute( + "UPDATE workspaces SET sort_order = ?1 WHERE id = ?2", + params![sort_order as i64, workspace.id], + )?; + } + + Ok(true) + } + pub fn add_message(&self, msg: &Message) -> Result<()> { let parts_json = serde_json::to_string(&msg.parts)?; diff --git a/src/session/manager.rs b/src/session/manager.rs index cbe0c6a..6a7fa62 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -26,6 +26,7 @@ pub struct SessionInfo { pub workspace_id: i64, pub workspace_path: String, pub workspace_name: String, + pub workspace_sort_order: i64, pub status: SessionStatus, pub pinned_at: Option, pub archived_at: Option, @@ -39,6 +40,7 @@ pub struct SessionManager { history_dao: Option, id_mapping: HashMap, db_id_to_id: HashMap, + workspace_sort_orders: HashMap, current_workspace_id: i64, current_workspace_path: String, current_workspace_name: String, @@ -59,6 +61,7 @@ impl SessionManager { history_dao: None, id_mapping: HashMap::new(), db_id_to_id: HashMap::new(), + workspace_sort_orders: HashMap::new(), current_workspace_id: 0, current_workspace_path, current_workspace_name, @@ -71,11 +74,23 @@ impl SessionManager { self.current_workspace_id = history_dao.current_workspace_id(); self.current_workspace_path = history_dao.current_workspace_path().to_string(); self.current_workspace_name = history_dao.current_workspace_name().to_string(); + self.refresh_workspace_sort_orders(&history_dao)?; self.load_sessions_from_db(&history_dao)?; self.history_dao = Some(history_dao); Ok(self) } + fn refresh_workspace_sort_orders(&mut self, dao: &HistoryDAO) -> Result<(), SessionError> { + let workspaces = dao + .list_workspaces() + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + self.workspace_sort_orders = workspaces + .into_iter() + .map(|workspace| (workspace.id, workspace.sort_order)) + .collect(); + Ok(()) + } + fn load_sessions_from_db(&mut self, dao: &HistoryDAO) -> Result<(), SessionError> { let db_sessions = dao .list_sessions() @@ -103,6 +118,7 @@ impl SessionManager { session.workspace_id = db_session.workspace_id; session.workspace_path = db_session.workspace_path; session.workspace_name = db_session.workspace_name; + session.workspace_sort_order = db_session.workspace_sort_order; session.status = SessionStatus::from_str(&db_session.status); if session.status.is_active() { session.status = SessionStatus::Interrupted; @@ -179,7 +195,11 @@ impl SessionManager { self.sort_child_session_indexes(); } - fn session_info_from_session(id: &str, session: &Session) -> SessionInfo { + fn session_info_from_session( + id: &str, + session: &Session, + workspace_sort_order: i64, + ) -> SessionInfo { SessionInfo { id: id.to_string(), parent_id: session.parent_id.clone(), @@ -190,6 +210,7 @@ impl SessionManager { workspace_id: session.workspace_id, workspace_path: session.workspace_path.clone(), workspace_name: session.workspace_name.clone(), + workspace_sort_order, status: session.status, pinned_at: session.pinned_at, archived_at: session.archived_at, @@ -229,6 +250,7 @@ impl SessionManager { session.workspace_id = self.current_workspace_id; session.workspace_path = self.current_workspace_path.clone(); session.workspace_name = self.current_workspace_name.clone(); + session.workspace_sort_order = self.workspace_sort_order(self.current_workspace_id); self.sessions.insert(session_id.clone(), session); if let Some(ref parent_id) = parent_id { @@ -252,7 +274,13 @@ impl SessionManager { pub fn list_sessions(&self) -> Vec { self.sessions .iter() - .map(|(id, session)| Self::session_info_from_session(id, session)) + .map(|(id, session)| { + Self::session_info_from_session( + id, + session, + self.workspace_sort_order(session.workspace_id), + ) + }) .collect() } @@ -287,9 +315,13 @@ impl SessionManager { .into_iter() .flat_map(|children| children.iter()) .filter_map(|id| { - self.sessions - .get(id) - .map(|session| Self::session_info_from_session(id, session)) + self.sessions.get(id).map(|session| { + Self::session_info_from_session( + id, + session, + self.workspace_sort_order(session.workspace_id), + ) + }) }) .collect() } @@ -319,6 +351,45 @@ impl SessionManager { &self.current_workspace_name } + pub fn workspace_sort_order(&self, workspace_id: i64) -> i64 { + self.workspace_sort_orders + .get(&workspace_id) + .copied() + .unwrap_or(workspace_id) + } + + pub fn move_workspace_sort_order( + &mut self, + workspace_id: i64, + offset: isize, + ) -> Result { + let moved = if let Some(dao) = self.history_dao.as_ref() { + let moved = dao + .move_workspace_sort_order(workspace_id, offset) + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + let workspaces = dao + .list_workspaces() + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + self.workspace_sort_orders = workspaces + .into_iter() + .map(|workspace| (workspace.id, workspace.sort_order)) + .collect(); + moved + } else { + false + }; + + let workspace_sort_orders = self.workspace_sort_orders.clone(); + for session in self.sessions.values_mut() { + session.workspace_sort_order = workspace_sort_orders + .get(&session.workspace_id) + .copied() + .unwrap_or(session.workspace_id); + } + + Ok(moved) + } + pub fn clear_current_session(&mut self) { self.current_session_id = None; } diff --git a/src/session/types.rs b/src/session/types.rs index e910fcd..fedf986 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -174,6 +174,7 @@ pub struct Session { pub workspace_id: i64, pub workspace_path: String, pub workspace_name: String, + pub workspace_sort_order: i64, pub status: SessionStatus, pub pinned_at: Option, pub archived_at: Option, @@ -198,6 +199,7 @@ impl Session { workspace_id: 0, workspace_path: String::new(), workspace_name: "Workspace".to_string(), + workspace_sort_order: 0, status: SessionStatus::Idle, pinned_at: None, archived_at: None, @@ -216,6 +218,7 @@ impl Session { workspace_id: 0, workspace_path: String::new(), workspace_name: "Workspace".to_string(), + workspace_sort_order: 0, status: SessionStatus::Idle, pinned_at: None, archived_at: None, diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 8d656cf..1d1d23a 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -81,6 +81,8 @@ pub struct Dialog { pub pending_delete_id: Option, collapsible_groups: bool, collapsed_groups: HashSet, + focusable_group_headers: bool, + focused_group_header: Option, matcher: Matcher, } @@ -115,6 +117,8 @@ impl Dialog { pending_delete_id: None, collapsible_groups: false, collapsed_groups: HashSet::new(), + focusable_group_headers: false, + focused_group_header: None, matcher: Matcher::new(Config::DEFAULT), } } @@ -132,6 +136,14 @@ impl Dialog { self } + pub fn with_focusable_group_headers(mut self, enabled: bool) -> Self { + self.focusable_group_headers = enabled; + if !enabled { + self.focused_group_header = None; + } + self + } + pub fn with_items(title: impl Into, items: Vec) -> Self { let mut dialog = Self::new(title); dialog.set_items(items); @@ -253,6 +265,28 @@ impl Dialog { self.update_scrollbar(); } + pub fn focus_group_header(&mut self, group: &str) -> bool { + if !self.focusable_group_headers || !Self::group_has_header(group) { + return false; + } + + if !self + .filtered_items + .iter() + .any(|(candidate, items)| candidate == group && !items.is_empty()) + { + return false; + } + + self.focused_group_header = Some(group.to_string()); + self.adjust_scroll(); + true + } + + pub fn get_focused_group_header(&self) -> Option<&str> { + self.focused_group_header.as_deref() + } + pub fn collapsed_groups(&self) -> HashSet { self.collapsed_groups.clone() } @@ -352,32 +386,45 @@ impl Dialog { } fn reconcile_selection_after_filter(&mut self, preferred_selected: Option<(String, String)>) { - let flat_items = self.get_flat_items(); - if flat_items.is_empty() { + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { + if let Some(group) = self.focused_group_header.clone() { + if self.focus_group_header(&group) { + return; + } + } + self.focused_group_header = None; self.selected_index = 0; self.scroll_offset = 0; self.update_scrollbar(); return; } - if let Some((id, provider_id)) = preferred_selected { - if let Some(pos) = flat_items - .iter() - .position(|item| item.id == id && item.provider_id == provider_id) - { - self.selected_index = pos; - self.adjust_scroll(); + if let Some(group) = self.focused_group_header.clone() { + if self.focus_group_header(&group) { return; } + self.focused_group_header = None; + } + + if let Some((id, provider_id)) = preferred_selected { + let selected_pos = { + let flat_items = self.get_flat_items(); + flat_items + .iter() + .position(|item| item.id == id && item.provider_id == provider_id) + .or_else(|| flat_items.iter().position(|item| item.id == id)) + }; - if let Some(pos) = flat_items.iter().position(|item| item.id == id) { + if let Some(pos) = selected_pos { self.selected_index = pos; + self.focused_group_header = None; self.adjust_scroll(); return; } } - if self.selected_index >= flat_items.len() { + if self.selected_index >= flat_len { self.selected_index = 0; } @@ -401,31 +448,35 @@ impl Dialog { } pub fn next(&mut self) { - let flat_items = self.get_flat_items(); - if flat_items.is_empty() { + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { return; } - if self.selected_index >= flat_items.len() { + self.focused_group_header = None; + + if self.selected_index >= flat_len { self.selected_index = 0; self.adjust_scroll(); return; } - if self.selected_index < flat_items.len() - 1 { + if self.selected_index < flat_len - 1 { self.selected_index += 1; self.adjust_scroll(); } } pub fn previous(&mut self) { - let flat_items = self.get_flat_items(); - if flat_items.is_empty() { + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { return; } - if self.selected_index >= flat_items.len() { - self.selected_index = flat_items.len().saturating_sub(1); + self.focused_group_header = None; + + if self.selected_index >= flat_len { + self.selected_index = flat_len.saturating_sub(1); self.adjust_scroll(); return; } @@ -436,6 +487,46 @@ impl Dialog { } } + pub fn next_wrapping(&mut self) { + if self.focusable_group_headers { + self.move_focus_wrapping(1); + return; + } + + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { + return; + } + + self.focused_group_header = None; + self.selected_index = if self.selected_index >= flat_len.saturating_sub(1) { + 0 + } else { + self.selected_index + 1 + }; + self.adjust_scroll(); + } + + pub fn previous_wrapping(&mut self) { + if self.focusable_group_headers { + self.move_focus_wrapping(-1); + return; + } + + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { + return; + } + + self.focused_group_header = None; + self.selected_index = if self.selected_index == 0 || self.selected_index >= flat_len { + flat_len.saturating_sub(1) + } else { + self.selected_index - 1 + }; + self.adjust_scroll(); + } + pub fn scroll_down(&mut self) { let total_lines = self.get_content_line_count(); if total_lines == 0 { @@ -452,6 +543,77 @@ impl Dialog { self.update_scrollbar(); } + fn move_focus_wrapping(&mut self, delta: isize) { + let rows = self.focus_rows(); + if rows.is_empty() { + return; + } + + let current = self + .current_focus_row_index(&rows) + .unwrap_or(if delta >= 0 { 0 } else { rows.len() - 1 }); + let next = if delta >= 0 { + (current + 1) % rows.len() + } else if current == 0 { + rows.len() - 1 + } else { + current - 1 + }; + + self.apply_focus_row(&rows[next]); + self.adjust_scroll(); + } + + fn focus_rows(&self) -> Vec { + let mut rows = Vec::new(); + let mut item_index = 0; + + for (group, items) in &self.filtered_items { + if items.is_empty() { + continue; + } + + if self.focusable_group_headers && Self::group_has_header(group) { + rows.push(DialogFocusRow::Group(group.clone())); + } + + if self.is_group_collapsed(group) { + continue; + } + + for _ in items { + rows.push(DialogFocusRow::Item(item_index)); + item_index += 1; + } + } + + rows + } + + fn current_focus_row_index(&self, rows: &[DialogFocusRow]) -> Option { + if let Some(group) = &self.focused_group_header { + return rows.iter().position( + |row| matches!(row, DialogFocusRow::Group(candidate) if candidate == group), + ); + } + + rows.iter().position( + |row| matches!(row, DialogFocusRow::Item(index) if *index == self.selected_index), + ) + } + + fn apply_focus_row(&mut self, row: &DialogFocusRow) { + match row { + DialogFocusRow::Group(group) => { + self.focused_group_header = Some(group.clone()); + } + DialogFocusRow::Item(index) => { + self.focused_group_header = None; + self.selected_index = *index; + } + } + } + fn get_flat_items(&self) -> Vec<&DialogItem> { let mut items = Vec::new(); for (group, group_items) in &self.filtered_items { @@ -510,9 +672,36 @@ impl Dialog { line_index } + fn get_line_index_of_group_header(&self, target_group: &str) -> usize { + let mut line_index = 0; + + for (group, items) in &self.filtered_items { + if items.is_empty() { + continue; + } + + if Self::group_has_header(group) { + if group == target_group { + return line_index; + } + line_index += 1; + } + + if !self.is_group_collapsed(group) { + line_index += items.len(); + } + } + + line_index + } + pub fn adjust_scroll(&mut self) { let visible_rows = self.get_visible_row_count().max(1); - let selected_line = self.get_line_index_of_item(self.selected_index); + let selected_line = self + .focused_group_header + .as_deref() + .map(|group| self.get_line_index_of_group_header(group)) + .unwrap_or_else(|| self.get_line_index_of_item(self.selected_index)); if selected_line < self.scroll_offset { self.scroll_offset = selected_line; @@ -524,7 +713,7 @@ impl Dialog { self.scroll_offset = selected_line.saturating_sub(visible_rows.saturating_sub(1)); } - if self.selected_index == 0 { + if self.focused_group_header.is_none() && self.selected_index == 0 { self.scroll_offset = 0; } @@ -561,6 +750,10 @@ impl Dialog { } pub fn get_selected(&self) -> Option<&DialogItem> { + if self.focused_group_header.is_some() { + return None; + } + let flat_items = self.get_flat_items(); flat_items.get(self.selected_index).copied() } @@ -572,6 +765,7 @@ impl Dialog { .position(|item| item.id == id && item.provider_id == provider_id) { self.selected_index = pos; + self.focused_group_header = None; self.adjust_scroll(); return true; } @@ -582,6 +776,7 @@ impl Dialog { let flat_items = self.get_flat_items(); if let Some(pos) = flat_items.iter().position(|item| item.id == id) { self.selected_index = pos; + self.focused_group_header = None; self.adjust_scroll(); return true; } @@ -591,12 +786,14 @@ impl Dialog { pub fn select_index_clamped(&mut self, index: usize) -> bool { let item_count = self.get_flat_items().len(); if item_count == 0 { + self.focused_group_header = None; self.selected_index = 0; self.scroll_offset = 0; self.update_scrollbar(); return false; } + self.focused_group_header = None; self.selected_index = index.min(item_count.saturating_sub(1)); self.adjust_scroll(); true @@ -884,6 +1081,7 @@ impl Dialog { } else { if let Some(item_index) = self.item_index_at_position(event.column, event.row) { self.selected_index = item_index; + self.focused_group_header = None; return true; } false @@ -900,8 +1098,10 @@ impl Dialog { MouseEventKind::Moved => { if !is_on_scrollbar { if let Some(item_index) = self.item_index_at_position(event.column, event.row) { - if item_index != self.selected_index { + if item_index != self.selected_index || self.focused_group_header.is_some() + { self.selected_index = item_index; + self.focused_group_header = None; } } } @@ -1107,6 +1307,7 @@ impl Dialog { let item_at_offset = self.get_item_index_from_line(self.scroll_offset); if let Some(idx) = item_at_offset { self.selected_index = idx; + self.focused_group_header = None; } } @@ -1265,6 +1466,16 @@ impl Dialog { )] }; + let mut header_spans = header_spans; + if self.focused_group_header.as_deref() == Some(group.as_str()) { + let fg = contrast_text(colors.primary); + for span in &mut header_spans { + let mut style = span.style.clone(); + style = style.fg(fg).bg(colors.primary); + span.style = style; + } + } + content_lines.push(Line::from(header_spans)); } @@ -1273,7 +1484,8 @@ impl Dialog { } for item in items { - let is_selected = item_index == self.selected_index; + let is_selected = + self.focused_group_header.is_none() && item_index == self.selected_index; let is_pending_delete = self.pending_delete_id.as_ref() == Some(&item.id); let mut spans = Self::item_spans_for_width(item, list_area_width as usize, colors); @@ -1421,11 +1633,19 @@ impl Clone for Dialog { pending_delete_id: self.pending_delete_id.clone(), collapsible_groups: self.collapsible_groups, collapsed_groups: self.collapsed_groups.clone(), + focusable_group_headers: self.focusable_group_headers, + focused_group_header: self.focused_group_header.clone(), matcher: Matcher::new(Config::DEFAULT), } } } +#[derive(Debug, Clone, PartialEq, Eq)] +enum DialogFocusRow { + Group(String), + Item(usize), +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/utils/git.rs b/src/utils/git.rs index 7983040..d7cd601 100644 --- a/src/utils/git.rs +++ b/src/utils/git.rs @@ -1,9 +1,12 @@ -use std::path::Path; use std::process::Command; pub fn get_current_branch() -> Option { + get_branch_for_path(".") +} + +pub fn get_branch_for_path(path: &str) -> Option { let output = Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .args(["-C", path, "rev-parse", "--abbrev-ref", "HEAD"]) .output() .ok()?; diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index 8aaae4b..7526083 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -6,6 +6,7 @@ use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::{layout::Rect, Frame}; +use std::collections::HashMap; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionsDialogFilter { @@ -17,9 +18,9 @@ pub enum SessionsDialogFilter { impl SessionsDialogFilter { pub fn next(self) -> Self { match self { - Self::Active => Self::All, - Self::All => Self::Archived, - Self::Archived => Self::Active, + Self::All => Self::Active, + Self::Active => Self::Archived, + Self::Archived => Self::All, } } @@ -37,6 +38,7 @@ pub struct SessionsDialogState { pub dialog: Dialog, pub pending_delete: Option, pub filter: SessionsDialogFilter, + workspace_group_ids: HashMap, } impl SessionsDialogState { @@ -44,18 +46,21 @@ impl SessionsDialogState { Self { dialog, pending_delete: None, - filter: SessionsDialogFilter::Active, + filter: SessionsDialogFilter::All, + workspace_group_ids: HashMap::new(), } } pub fn with_items(title: impl Into, items: Vec) -> Self { let dialog = Dialog::with_items(title, items) .with_position(DialogPosition::Left) - .with_collapsible_groups(true); + .with_collapsible_groups(true) + .with_focusable_group_headers(true); Self { - dialog: with_sessions_actions(dialog, SessionsDialogFilter::Active, false), + dialog: with_sessions_actions(dialog, SessionsDialogFilter::All, false), pending_delete: None, - filter: SessionsDialogFilter::Active, + filter: SessionsDialogFilter::All, + workspace_group_ids: HashMap::new(), } } @@ -64,6 +69,7 @@ impl SessionsDialogState { let title = self.dialog.title.clone(); let was_visible = self.dialog.is_visible(); let selected_index = self.dialog.selected_index; + let focused_group = self.dialog.get_focused_group_header().map(str::to_string); let scroll_offset = self.dialog.scroll_offset; let items_clone = items.clone(); let search_query = self.dialog.search_query.clone(); @@ -72,7 +78,8 @@ impl SessionsDialogState { self.dialog = Dialog::with_items(title, items) .with_position(DialogPosition::Left) - .with_collapsible_groups(true); + .with_collapsible_groups(true) + .with_focusable_group_headers(true); self.dialog.set_collapsed_groups(collapsed_groups); self.dialog = with_sessions_actions(self.dialog.clone(), filter, false); self.dialog.set_search_query(search_query); @@ -81,13 +88,25 @@ impl SessionsDialogState { self.dialog.show(); } - if selected_index < items_clone.len() { + if let Some(group) = focused_group { + let _ = self.dialog.focus_group_header(&group); + } else if selected_index < items_clone.len() { self.dialog.selected_index = selected_index; } self.dialog.scroll_offset = scroll_offset; self.dialog .preserve_scrollbar_drag_state_from(&previous_dialog); } + + pub fn set_workspace_group_ids(&mut self, group_ids: HashMap) { + self.workspace_group_ids = group_ids; + } + + fn focused_workspace_group(&self) -> Option<(String, i64)> { + let group = self.dialog.get_focused_group_header()?.to_string(); + let workspace_id = self.workspace_group_ids.get(&group).copied()?; + Some((group, workspace_id)) + } } pub fn init_sessions_dialog( @@ -130,6 +149,43 @@ pub fn handle_sessions_dialog_key_event( return SessionsDialogAction::ChangeFilter(dialog_state.filter); } + if event.modifiers.contains(KeyModifiers::ALT) { + if let Some((group, workspace_id)) = dialog_state.focused_workspace_group() { + match event.code { + KeyCode::Up => { + dialog_state.pending_delete = None; + return SessionsDialogAction::MoveWorkspaceGroup { + workspace_id, + group, + direction: WorkspaceGroupMoveDirection::Up, + }; + } + KeyCode::Down => { + dialog_state.pending_delete = None; + return SessionsDialogAction::MoveWorkspaceGroup { + workspace_id, + group, + direction: WorkspaceGroupMoveDirection::Down, + }; + } + _ => {} + } + } + } + + if event.code == KeyCode::Right { + if let Some(group) = dialog_state + .dialog + .get_focused_group_header() + .map(str::to_string) + { + dialog_state.dialog.toggle_group_collapsed(&group); + let _ = dialog_state.dialog.focus_group_header(&group); + dialog_state.pending_delete = None; + return SessionsDialogAction::Handled; + } + } + if event.code == KeyCode::Char('p') && event.modifiers == KeyModifiers::CONTROL { if let Some(selected) = dialog_state.dialog.get_selected() { return SessionsDialogAction::TogglePin(selected.id.clone()); @@ -164,7 +220,17 @@ pub fn handle_sessions_dialog_key_event( } } - let handled = dialog_state.dialog.handle_key_event(event); + let handled = match event.code { + KeyCode::Up if was_visible => { + dialog_state.dialog.previous_wrapping(); + true + } + KeyCode::Down if was_visible => { + dialog_state.dialog.next_wrapping(); + true + } + _ => dialog_state.dialog.handle_key_event(event), + }; // Clear pending delete when user navigates away if matches!(event.code, KeyCode::Up | KeyCode::Down | KeyCode::Esc) { @@ -200,6 +266,7 @@ pub fn handle_sessions_dialog_mouse_event( .dialog .group_at_position(event.column, event.row) { + let _ = dialog_state.dialog.focus_group_header(&group); dialog_state.dialog.toggle_group_collapsed(&group); dialog_state.pending_delete = None; return SessionsDialogAction::Handled; @@ -256,6 +323,26 @@ pub enum SessionsDialogAction { Delete(String), PendingDelete(String), Rename(String, String), + MoveWorkspaceGroup { + workspace_id: i64, + group: String, + direction: WorkspaceGroupMoveDirection, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceGroupMoveDirection { + Up, + Down, +} + +impl WorkspaceGroupMoveDirection { + pub fn offset(self) -> isize { + match self { + Self::Up => -1, + Self::Down => 1, + } + } } fn with_sessions_actions( @@ -335,6 +422,22 @@ mod tests { } } + #[test] + fn filter_cycle_starts_from_all() { + assert_eq!( + SessionsDialogFilter::All.next(), + SessionsDialogFilter::Active + ); + assert_eq!( + SessionsDialogFilter::Active.next(), + SessionsDialogFilter::Archived + ); + assert_eq!( + SessionsDialogFilter::Archived.next(), + SessionsDialogFilter::All + ); + } + #[test] fn ctrl_n_requests_new_session_when_sessions_dialog_is_focused() { let mut state = @@ -349,6 +452,119 @@ mod tests { assert_eq!(action, SessionsDialogAction::NewSession); } + #[test] + fn down_moves_from_last_session_in_workspace_to_next_workspace_header() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item_in_group("session-1", "First session", "Workspace A"), + session_item_in_group("session-2", "Second session", "Workspace A"), + session_item_in_group("session-3", "Third session", "Workspace B"), + ], + ); + state.dialog.show(); + state.dialog.selected_index = 1; + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert_eq!(state.dialog.get_focused_group_header(), Some("Workspace B")); + assert!(state.dialog.get_selected().is_none()); + } + + #[test] + fn arrow_navigation_cycles_across_workspace_groups() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item_in_group("session-1", "First session", "Workspace A"), + session_item_in_group("session-2", "Second session", "Workspace A"), + session_item_in_group("session-3", "Third session", "Workspace B"), + ], + ); + state.dialog.show(); + state.dialog.selected_index = 2; + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert_eq!(state.dialog.get_focused_group_header(), Some("Workspace A")); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert_eq!(state.dialog.get_selected().unwrap().id, "session-3"); + } + + #[test] + fn right_toggles_focused_workspace_header_collapse() { + let mut state = init_sessions_dialog( + "Sessions", + vec![session_item_in_group( + "session-1", + "First session", + "Workspace A", + )], + ); + state.dialog.show(); + assert!(state.dialog.focus_group_header("Workspace A")); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert!(state.dialog.is_group_collapsed("Workspace A")); + assert_eq!(state.dialog.get_focused_group_header(), Some("Workspace A")); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert!(!state.dialog.is_group_collapsed("Workspace A")); + } + + #[test] + fn option_arrows_request_workspace_group_move_when_header_focused() { + let mut state = init_sessions_dialog( + "Sessions", + vec![session_item_in_group( + "session-1", + "First session", + "Workspace A", + )], + ); + state.dialog.show(); + state.set_workspace_group_ids(HashMap::from([("Workspace A".to_string(), 42)])); + assert!(state.dialog.focus_group_header("Workspace A")); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Up, KeyModifiers::ALT), + ); + + assert_eq!( + action, + SessionsDialogAction::MoveWorkspaceGroup { + workspace_id: 42, + group: "Workspace A".to_string(), + direction: WorkspaceGroupMoveDirection::Up + } + ); + } + #[test] fn mouse_click_on_item_selects_session() { let mut state = init_sessions_dialog( From 9523f14d31f9cead82c82555908607c4bc074bb4 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 22:37:04 +0800 Subject: [PATCH 111/226] chore: todos --- _plans/__TODOS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 35ad2cb..fd8b9bd 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -50,7 +50,7 @@ - [x] Tool call rendering: - [x] editing files w/ diffs, like opencode does. - - [ ] webfetch rendering like codex does. + - [x] webfetch rendering like codex does. - [x] todowrite - better looking, like opencode does. - [x] rendering subagents - just like opencode, clickable to go into their page.. OR I can do `ctrl-x ↓` to go into it if there's a subagent running. I can also switch between subagents with `←` and `→` @@ -101,3 +101,7 @@ - [ ] Reasoning effort adjustment in /models. Or a hotkey? In opencode it's ctrl-t. - [ ] /commands and custom commands. + +- [ ] Read my <> (ask for permission), deny. The chat doesn't get persisted, just gone. Please save everything before errors. So we can easily say "continue" + +- [ ] wysiwyg double escape to G From 486ddf2c2263281c839bd261bdfb6eb5685b0aaa Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 20 May 2026 23:03:19 +0800 Subject: [PATCH 112/226] =?UTF-8?q?feat:=20replace=20"=E2=99=A5=EF=B8=8E?= =?UTF-8?q?=20Favorite"=20tip=20with=20standalone=20"=E2=9D=A4=EF=B8=8E"?= =?UTF-8?q?=20and=20refactor=20timeline=20highlight.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trim trailing blank lines from timeline highlight range - Fix highlight band height calculation (off-by-one) - Render line backgrounds before timeline highlight for correct layering - Apply pink bold styling to the heart tip in model dialogs - Add right padding to keep heart centered --- src/app.rs | 2 +- src/command/handlers.rs | 2 +- src/ui/components/chat.rs | 87 +++++++++++++++++++++++++++---------- src/ui/components/dialog.rs | 20 +++++++-- 4 files changed, 81 insertions(+), 30 deletions(-) diff --git a/src/app.rs b/src/app.rs index fd56e95..9e76bce 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3346,7 +3346,7 @@ impl App { let tip = if is_active { Some("Active".to_string()) } else if is_favorite { - Some("♥︎ Favorite".to_string()) + Some("❤︎".to_string()) } else { None }; diff --git a/src/command/handlers.rs b/src/command/handlers.rs index fe75f69..41facf4 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -266,7 +266,7 @@ pub fn handle_models<'a>( let tip = if is_active { Some("Active".to_string()) } else if is_favorite { - Some("♥︎ Favorite".to_string()) + Some("❤︎".to_string()) } else { None }; diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 5c34dfc..8c557ad 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1440,20 +1440,16 @@ impl Chat { None } }); + let visible_highlight_range = + trim_trailing_blank_highlight_lines(highlight_range, all_lines); let mut content_lines: Vec> = all_lines[visible_start..visible_end].to_vec(); - - if let Some((start, end)) = highlight_range { - let hl_fg = contrast_text(colors.interactive); - for (line_idx, line) in content_lines.iter_mut().enumerate() { - let global_idx = visible_start + line_idx; - if global_idx >= start && global_idx < end { - for span in line.spans.iter_mut() { - span.style = span.style.fg(hl_fg); - } - } - } - } + apply_timeline_highlight_to_lines( + &mut content_lines, + visible_highlight_range, + visible_start, + colors.interactive, + ); let render_area = Rect { x: content_area.x, @@ -1462,8 +1458,18 @@ impl Chat { height: content_area.height, }; - // Render timeline highlight as a full-width background overlay - if let Some((start, end)) = highlight_range { + render_line_backgrounds( + f, + render_area, + all_lines, + clamped_scroll, + render_area.height as usize, + colors.background_element, + ); + + // Render timeline highlight after panel backgrounds so every selected + // message has a visible full-width band. + if let Some((start, end)) = visible_highlight_range { let vis_start = start.max(clamped_scroll); let vis_end = end.min(clamped_scroll.saturating_add(viewport)); @@ -1471,7 +1477,7 @@ impl Chat { let y = content_area .y .saturating_add((vis_start - clamped_scroll) as u16); - let height = (vis_end - vis_start).saturating_sub(1) as u16; + let height = (vis_end - vis_start) as u16; if height > 0 { let hl_area = Rect { x: content_area.x, @@ -1485,15 +1491,6 @@ impl Chat { } } - render_line_backgrounds( - f, - render_area, - all_lines, - clamped_scroll, - render_area.height as usize, - colors.background_element, - ); - let content_lines = crate::ui::selection::apply_selection_to_lines_with_offset( content_lines, &self.selection, @@ -2897,6 +2894,48 @@ fn render_line_backgrounds( } } +fn apply_timeline_highlight_to_lines( + lines: &mut [Line<'static>], + highlight_range: Option<(usize, usize)>, + visible_start: usize, + bg: Color, +) { + let Some((start, end)) = highlight_range else { + return; + }; + + let fg = contrast_text(bg); + let highlight_style = Style::default().fg(fg).bg(bg); + + for (line_idx, line) in lines.iter_mut().enumerate() { + let global_idx = visible_start + line_idx; + if global_idx < start || global_idx >= end { + continue; + } + + line.style = line.style.patch(highlight_style); + for span in line.spans.iter_mut() { + span.style = span.style.fg(fg).bg(bg); + } + } +} + +fn trim_trailing_blank_highlight_lines( + highlight_range: Option<(usize, usize)>, + lines: &[Line<'_>], +) -> Option<(usize, usize)> { + let (start, mut end) = highlight_range?; + while end > start && line_is_blank(&lines[end - 1]) { + end -= 1; + } + + (end > start).then_some((start, end)) +} + +fn line_is_blank(line: &Line<'_>) -> bool { + line.spans.iter().all(|span| span.content.trim().is_empty()) +} + fn render_background_run( f: &mut Frame, area: Rect, diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 1d1d23a..26412e0 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -11,7 +11,7 @@ use ratatui::crossterm::event::{ }; use ratatui::{ prelude::Rect, - style::{Modifier, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Clear, Paragraph, ScrollbarState}, Frame, @@ -915,19 +915,28 @@ impl Dialog { .as_ref() .map(|tip| Self::truncate_to_width(tip, width)); let tip_width = tip.as_ref().map(|tip| tip.width()).unwrap_or(0); + let right_padding = tip + .as_deref() + .filter(|tip| *tip == "❤︎" && width > tip_width) + .map(|_| 1usize) + .unwrap_or(0); let minimum_gap = if tip_width > 0 && width > tip_width { 1 } else { 0 }; - let left_budget = width.saturating_sub(tip_width + minimum_gap); + let left_budget = width.saturating_sub(tip_width + minimum_gap + right_padding); let (mut spans, left_width) = Self::left_item_spans_for_width(item, left_budget, colors); if let Some(tip) = tip { - let padding_len = width.saturating_sub(left_width + tip_width); + let padding_len = width.saturating_sub(left_width + tip_width + right_padding); spans.push(Span::raw(" ".repeat(padding_len))); - let tip_style = if has_description { + let tip_style = if tip == "❤︎" { + Style::default() + .fg(Color::Rgb(255, 105, 180)) + .add_modifier(Modifier::BOLD) + } else if has_description { Style::default() .fg(colors.text) .add_modifier(Modifier::BOLD) @@ -937,6 +946,9 @@ impl Dialog { .add_modifier(Modifier::DIM) }; spans.push(Span::styled(tip, tip_style)); + if right_padding > 0 { + spans.push(Span::raw(" ".repeat(right_padding))); + } } else { spans.push(Span::raw(" ".repeat(width.saturating_sub(left_width)))); } From 4c928d2443ec8efcffb0dd94bc0e3d336c4c3449 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 00:27:21 +0800 Subject: [PATCH 113/226] feat: add reasoning effort support with Ctrl+T cycling and models dialog controls. - Add reasoning_effort field to OpenAI, Anthropic, and OpenAI-compatible providers - Implement ReasoningEffort enum and ReasoningCapability with per-model config - Add Ctrl+T hotkey to cycle active reasoning effort - Add left/right arrow cycling in models dialog - Display reasoning effort in status bar and models dialog - Persist per-model reasoning effort preferences in SQLite - Migrate LLM session config from OnceLock to RwLock for mutable access - Improve dialog search to prioritize provider matches - Only show undo option for user messages in message actions --- _plans/__TODOS.md | 6 +- aisdk/src/providers/anthropic.rs | 12 + aisdk/src/providers/compatible.rs | 12 + aisdk/src/providers/openai.rs | 12 + src/agent/config.rs | 17 +- src/agent/subagent.rs | 24 +- src/app.rs | 243 +++++++++- src/llm/client.rs | 23 +- src/main.rs | 20 + src/model/discovery.rs | 31 ++ src/model/mod.rs | 1 + src/model/reasoning.rs | 777 ++++++++++++++++++++++++++++++ src/model/types.rs | 2 + src/persistence/prefs.rs | 67 ++- src/ui/components/dialog.rs | 216 +++++++-- src/ui/components/input.rs | 17 +- src/views/chat.rs | 2 + src/views/home.rs | 11 +- src/views/models_dialog.rs | 231 ++++++++- 19 files changed, 1639 insertions(+), 85 deletions(-) create mode 100644 src/model/reasoning.rs diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index fd8b9bd..4ce5f1e 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -94,11 +94,11 @@ - [x] multiworkspace not working when I open other directories, I should be able to see in -- [ ] better timeline highlighting of each "message" +- [x] better timeline highlighting of each "message" -- [ ] IN /models, can we use the ❤︎ icon, but colored pink. instead of the long heart + favorite indicator. +- [x] IN /models, can we use the ❤︎ icon, but colored pink. instead of the long heart + favorite indicator. -- [ ] Reasoning effort adjustment in /models. Or a hotkey? In opencode it's ctrl-t. +- [x] Reasoning effort adjustment in /models. Or a hotkey? In opencode it's ctrl-t. - [ ] /commands and custom commands. diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs index 8e7f6f2..1e075cc 100644 --- a/aisdk/src/providers/anthropic.rs +++ b/aisdk/src/providers/anthropic.rs @@ -14,6 +14,7 @@ pub struct Anthropic { api_key: String, model_name: String, provider_name: String, + reasoning_effort: Option, } impl Anthropic { @@ -28,6 +29,7 @@ pub struct AnthropicBuilder { api_key: Option, model_name: Option, provider_name: Option, + reasoning_effort: Option, } impl AnthropicBuilder { @@ -51,6 +53,11 @@ impl AnthropicBuilder { self } + pub fn reasoning_effort(mut self, effort: impl Into) -> Self { + self.reasoning_effort = Some(effort.into()); + self + } + pub fn build(self) -> Result { Ok(Anthropic { base_url: self @@ -63,6 +70,7 @@ impl AnthropicBuilder { provider_name: self .provider_name .unwrap_or_else(|| "anthropic".to_string()), + reasoning_effort: self.reasoning_effort, }) } } @@ -138,6 +146,10 @@ impl Provider for Anthropic { body["tools"] = serde_json::Value::Array(tool_params); } + if let Some(effort) = &self.reasoning_effort { + body["output_config"] = serde_json::json!({ "effort": effort }); + } + let mut request_headers = reqwest::header::HeaderMap::new(); request_headers.insert( reqwest::header::CONTENT_TYPE, diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index bdd1041..f385ca5 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -14,6 +14,7 @@ pub struct OpenAICompatible { api_key: String, model_name: String, provider_name: String, + reasoning_effort: Option, } impl OpenAICompatible { @@ -28,6 +29,7 @@ pub struct OpenAICompatibleBuilder { api_key: Option, model_name: Option, provider_name: Option, + reasoning_effort: Option, } impl OpenAICompatibleBuilder { @@ -51,6 +53,11 @@ impl OpenAICompatibleBuilder { self } + pub fn reasoning_effort(mut self, effort: impl Into) -> Self { + self.reasoning_effort = Some(effort.into()); + self + } + pub fn build(self) -> Result { Ok(OpenAICompatible { base_url: self @@ -63,6 +70,7 @@ impl OpenAICompatibleBuilder { provider_name: self .provider_name .unwrap_or_else(|| "openai-compatible".to_string()), + reasoning_effort: self.reasoning_effort, }) } } @@ -133,6 +141,10 @@ impl Provider for OpenAICompatible { body["tools"] = serde_json::Value::Array(tool_params); } + if let Some(effort) = &self.reasoning_effort { + body["reasoning_effort"] = serde_json::Value::String(effort.clone()); + } + let mut request_headers = reqwest::header::HeaderMap::new(); request_headers.insert( reqwest::header::CONTENT_TYPE, diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index 9b39ef1..3ff7d0b 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -20,6 +20,7 @@ pub struct OpenAI { strip_system_and_developer_messages: bool, tool_strict_override: Option, default_instructions: Option, + reasoning_effort: Option, } impl OpenAI { @@ -40,6 +41,7 @@ pub struct OpenAIBuilder { strip_system_and_developer_messages: bool, tool_strict_override: Option, default_instructions: Option, + reasoning_effort: Option, } impl OpenAIBuilder { @@ -93,6 +95,11 @@ impl OpenAIBuilder { self } + pub fn reasoning_effort(mut self, effort: impl Into) -> Self { + self.reasoning_effort = Some(effort.into()); + self + } + pub fn build(self) -> Result { let base_url = self .base_url @@ -125,6 +132,7 @@ impl OpenAIBuilder { strip_system_and_developer_messages: self.strip_system_and_developer_messages, tool_strict_override: self.tool_strict_override, default_instructions: self.default_instructions, + reasoning_effort: self.reasoning_effort, }) } } @@ -227,6 +235,10 @@ impl Provider for OpenAI { body["store"] = serde_json::Value::Bool(store); } + if let Some(effort) = &self.reasoning_effort { + body["reasoning"] = serde_json::json!({ "effort": effort }); + } + let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() diff --git a/src/agent/config.rs b/src/agent/config.rs index d19f848..2a6b844 100644 --- a/src/agent/config.rs +++ b/src/agent/config.rs @@ -1,4 +1,4 @@ -use std::sync::OnceLock; +use std::sync::{OnceLock, RwLock}; #[derive(Debug, Clone)] pub struct LlmSessionConfig { @@ -7,6 +7,7 @@ pub struct LlmSessionConfig { pub api_key: Option, pub provider_kind: ProviderKind, pub base_url: String, + pub reasoning_effort: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -16,12 +17,18 @@ pub enum ProviderKind { Anthropic, } -static LLM_SESSION: OnceLock = OnceLock::new(); +static LLM_SESSION: OnceLock>> = OnceLock::new(); pub fn set_llm_session(config: LlmSessionConfig) { - let _ = LLM_SESSION.set(config); + let session = LLM_SESSION.get_or_init(|| RwLock::new(None)); + if let Ok(mut guard) = session.write() { + *guard = Some(config); + } } -pub fn get_llm_session() -> Option<&'static LlmSessionConfig> { - LLM_SESSION.get() +pub fn get_llm_session() -> Option { + LLM_SESSION + .get() + .and_then(|session| session.read().ok()) + .and_then(|guard| guard.clone()) } diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 37eaf7f..3d1864f 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -174,11 +174,15 @@ pub async fn run_subagent( let mut response: StreamTextResponse = match session.provider_kind { ProviderKind::OpenAICompatible => { - let provider = OpenAICompatible::builder() + let mut builder = OpenAICompatible::builder() .base_url(&session.base_url) .model_name(&session.model) .provider_name(&session.provider_name) - .api_key(session.api_key.as_deref().unwrap_or("")) + .api_key(session.api_key.as_deref().unwrap_or("")); + if let Some(effort) = session.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + let provider = builder .build() .map_err(|e| format!("Failed to build OpenAICompatible provider: {}", e))?; @@ -187,11 +191,15 @@ pub async fn run_subagent( .map_err(|e| format!("Stream error: {}", e))? } ProviderKind::Anthropic => { - let provider = Anthropic::builder() + let mut builder = Anthropic::builder() .base_url(&session.base_url) .model_name(&session.model) .provider_name(&session.provider_name) - .api_key(session.api_key.as_deref().unwrap_or("")) + .api_key(session.api_key.as_deref().unwrap_or("")); + if let Some(effort) = session.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + let provider = builder .build() .map_err(|e| format!("Failed to build Anthropic provider: {}", e))?; @@ -200,11 +208,15 @@ pub async fn run_subagent( .map_err(|e| format!("Stream error: {}", e))? } ProviderKind::OpenAI => { - let provider = OpenAI::builder() + let mut builder = OpenAI::builder() .base_url(&session.base_url) .model_name(&session.model) .provider_name(&session.provider_name) - .api_key(session.api_key.as_deref().unwrap_or("")) + .api_key(session.api_key.as_deref().unwrap_or("")); + if let Some(effort) = session.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + let provider = builder .build() .map_err(|e| format!("Failed to build OpenAI provider: {}", e))?; diff --git a/src/app.rs b/src/app.rs index 9e76bce..802d88e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -899,6 +899,114 @@ impl App { text } + fn reasoning_capability_for_model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option { + self.discovery + .as_ref() + .and_then(|discovery| discovery.get_model_reasoning_capability(provider_id, model_id)) + } + + fn saved_reasoning_effort_for_model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option { + self.prefs_dao + .as_ref() + .and_then(|dao| dao.get_model_reasoning_effort(provider_id, model_id).ok()) + .flatten() + } + + fn resolved_reasoning_effort_for_model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option { + let capability = self.reasoning_capability_for_model(provider_id, model_id)?; + let saved = self.saved_reasoning_effort_for_model(provider_id, model_id)?; + let resolved = capability.resolve(Some(saved))?; + if resolved == crate::model::reasoning::ReasoningEffort::None { + return None; + } + if saved != resolved { + if let Some(ref dao) = self.prefs_dao { + let _ = dao.set_model_reasoning_effort( + provider_id.to_string(), + model_id.to_string(), + resolved, + ); + } + } + Some(resolved) + } + + fn reasoning_control_label_for_model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option { + let capability = self.reasoning_capability_for_model(provider_id, model_id)?; + if capability.values().is_empty() { + return None; + } + + Some( + self.resolved_reasoning_effort_for_model(provider_id, model_id) + .map(|effort| effort.as_str().to_string()) + .unwrap_or_else(|| "off".to_string()), + ) + } + + fn selected_model_reasoning_control_label(&self) -> Option { + let selected = self.models_dialog_state.dialog.get_selected()?; + self.reasoning_control_label_for_model(&selected.provider_id, &selected.id) + } + + fn active_reasoning_effort(&self) -> Option { + self.resolved_reasoning_effort_for_model(&self.provider_name, &self.model) + } + + fn active_reasoning_effort_label(&self) -> Option { + self.active_reasoning_effort() + .map(|effort| effort.as_str().to_string()) + } + + fn cycle_reasoning_effort_for_model( + &mut self, + provider_id: String, + model_id: String, + direction: i8, + ) -> bool { + let Some(capability) = self.reasoning_capability_for_model(&provider_id, &model_id) else { + return false; + }; + let saved = self.saved_reasoning_effort_for_model(&provider_id, &model_id); + let Some(next) = capability.cycle_override(saved, direction) else { + return false; + }; + + if let Some(ref dao) = self.prefs_dao { + let result = if let Some(next) = next { + dao.set_model_reasoning_effort(provider_id, model_id, next) + } else { + dao.clear_model_reasoning_effort(&provider_id, &model_id) + }; + + if result.is_err() { + return false; + } + } + + true + } + + fn cycle_active_reasoning_effort(&mut self) -> bool { + self.cycle_reasoning_effort_for_model(self.provider_name.clone(), self.model.clone(), 1) + } + pub fn get_current_theme_colors(&self) -> theme::ThemeColors { if self.themes.is_empty() { return theme::ThemeColors { @@ -1166,6 +1274,15 @@ impl App { self.refresh_models_dialog(); } + crate::views::models_dialog::ModelsDialogAction::CycleReasoning { + provider_id, + model_id, + direction, + } => { + if self.cycle_reasoning_effort_for_model(provider_id, model_id, direction) { + self.refresh_models_dialog(); + } + } crate::views::models_dialog::ModelsDialogAction::None => {} } @@ -1685,6 +1802,9 @@ impl App { self.which_key_state.show(); true } + KeyCode::Char('t') if key.modifiers == event::KeyModifiers::CONTROL => { + self.cycle_active_reasoning_effort() + } KeyCode::Left if key.modifiers == event::KeyModifiers::NONE && self.should_handle_child_session_arrow() => @@ -1939,6 +2059,15 @@ impl App { self.refresh_models_dialog(); } + crate::views::models_dialog::ModelsDialogAction::CycleReasoning { + provider_id, + model_id, + direction, + } => { + if self.cycle_reasoning_effort_for_model(provider_id, model_id, direction) { + self.refresh_models_dialog(); + } + } crate::views::models_dialog::ModelsDialogAction::None => {} } if !self.models_dialog_state.dialog.is_visible() { @@ -2610,6 +2739,7 @@ impl App { let provider_name = self.provider_name.clone(); let model = self.model.clone(); + let reasoning_effort = self.active_reasoning_effort(); let agent = self.agent.clone(); let tail_messages = selection.tail_messages; let task_session_id = session_id.clone(); @@ -2618,6 +2748,7 @@ impl App { let result = crate::llm::client::summarize_for_compaction( provider_name.clone(), model.clone(), + reasoning_effort, prompt, ) .await @@ -2824,9 +2955,7 @@ impl App { provider_id: item.provider_id.clone(), }) .collect(); - self.models_dialog_state = init_models_dialog(title, dialog_items); - self.models_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::ModelsDialog; + self.show_models_dialog(title, dialog_items); } } } @@ -2993,9 +3122,7 @@ impl App { provider_id: item.provider_id.clone(), }) .collect(); - self.models_dialog_state = init_models_dialog(title, dialog_items); - self.models_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::ModelsDialog; + self.show_models_dialog(title, dialog_items); } } } @@ -3121,9 +3248,10 @@ impl App { fn show_message_actions(&mut self, idx: usize) { use crate::ui::components::dialog::{Dialog, DialogItem}; + let can_undo = self.selected_message_can_undo(idx); self.message_actions_index = Some(idx); - let items = vec![ + let mut items = vec![ DialogItem { id: "copy".to_string(), name: "Copy".to_string(), @@ -3140,15 +3268,18 @@ impl App { tip: None, provider_id: "fork".to_string(), }, - DialogItem { + ]; + + if can_undo { + items.push(DialogItem { id: "undo".to_string(), name: "Undo".to_string(), group: String::new(), description: "Remove messages from here onward".to_string(), tip: None, provider_id: "undo".to_string(), - }, - ]; + }); + } let mut dialog = Dialog::with_items("Message Actions", items); dialog.show(); @@ -3156,6 +3287,18 @@ impl App { self.overlay_focus = OverlayFocus::MessageActions; } + fn selected_message_can_undo(&self, idx: usize) -> bool { + let Some(session_id) = self.session_manager.get_current_session_id() else { + return false; + }; + + self.session_manager + .get_session_ref(session_id) + .and_then(|session| session.messages.get(idx)) + .map(|message| message.role == crate::session::types::MessageRole::User) + .unwrap_or(false) + } + fn execute_message_action(&mut self, action: &str) { let idx = match self.message_actions_index { Some(i) => i, @@ -3220,6 +3363,11 @@ impl App { self.overlay_focus = OverlayFocus::None; } "undo" => { + if !self.selected_message_can_undo(idx) { + self.close_message_actions(); + return; + } + let undone_content: Option = { if let Some(session) = self.session_manager.get_current_session() { let content = session.messages.get(idx).map(|m| m.content.clone()); @@ -3339,7 +3487,7 @@ impl App { let mut items: Vec = Vec::new(); let add_model_item = |items: &mut Vec, model: &ModelType, group: &str| { - let is_active = self.model == model.id; + let is_active = self.model == model.id && self.provider_name == model.provider_id; let is_favorite = favorites_set.contains(&(model.provider_id.clone(), model.id.clone())); @@ -3456,6 +3604,29 @@ impl App { self.models_dialog_state.refresh_items(items); } + fn show_models_dialog( + &mut self, + title: impl Into, + mut items: Vec, + ) { + for item in &mut items { + let is_active = item.id == self.model && item.provider_id == self.provider_name; + if is_active { + item.tip = Some("Active".to_string()); + } else if item.tip.as_deref() == Some("Active") { + item.tip = None; + } + } + + self.models_dialog_state = init_models_dialog(title, items); + self.models_dialog_state.dialog.show(); + let _ = self + .models_dialog_state + .dialog + .select_item_by_key(&self.model, &self.provider_name); + self.overlay_focus = OverlayFocus::ModelsDialog; + } + fn show_sessions_dialog( &mut self, title: impl Into, @@ -4509,6 +4680,7 @@ impl App { let provider_name = self.provider_name.clone(); let model = self.model.clone(); + let reasoning_effort = self.active_reasoning_effort(); let agent_mode = self.agent.clone(); let provider_timeout = self .provider_timeouts @@ -4552,6 +4724,7 @@ impl App { session_id, provider_name, model, + reasoning_effort, agent_mode, agent_max_steps, tool_permissions, @@ -4665,6 +4838,7 @@ impl App { let status_cwd = self.active_workspace_path(); let branch = self.current_git_branch(&status_cwd); let usage_text = &self.cached_usage_text; + let reasoning_effort = self.active_reasoning_effort_label(); match self.base_focus { BaseFocus::Home => { @@ -4678,6 +4852,7 @@ impl App { self.agent.clone(), self.model.clone(), self.provider_name.clone(), + reasoning_effort.clone(), &colors, &usage_text, ); @@ -4708,6 +4883,7 @@ impl App { self.agent.clone(), self.model.clone(), self.provider_name.clone(), + reasoning_effort, &colors, self.is_streaming, self.compaction_receiver.is_some(), @@ -4734,7 +4910,14 @@ impl App { if self.overlay_focus == OverlayFocus::ModelsDialog && self.models_dialog_state.dialog.is_visible() { - render_models_dialog(f, &mut self.models_dialog_state, size, colors); + let reasoning_effort = self.selected_model_reasoning_control_label(); + render_models_dialog( + f, + &mut self.models_dialog_state, + size, + colors, + reasoning_effort.as_deref(), + ); } if self.overlay_focus == OverlayFocus::ThemesDialog @@ -4938,6 +5121,42 @@ mod tests { } } + fn message_action_names(app: &App) -> Vec { + app.message_actions_dialog + .as_ref() + .map(|dialog| dialog.items.iter().map(|item| item.name.clone()).collect()) + .unwrap_or_default() + } + + #[test] + fn message_actions_include_undo_for_user_messages() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.session_manager + .add_message_to_current_session(&crate::session::types::Message::user("Prompt")) + .unwrap(); + + app.show_message_actions(0); + + assert!(message_action_names(&app).contains(&"Undo".to_string())); + } + + #[test] + fn message_actions_omit_undo_for_agent_messages() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.session_manager + .add_message_to_current_session(&crate::session::types::Message::user("Prompt")) + .unwrap(); + app.session_manager + .add_message_to_current_session(&crate::session::types::Message::assistant("Answer")) + .unwrap(); + + app.show_message_actions(1); + + assert!(!message_action_names(&app).contains(&"Undo".to_string())); + } + #[test] fn commands_can_submit_while_streaming() { let input_type = parse_input("/models"); diff --git a/src/llm/client.rs b/src/llm/client.rs index ba2d954..0ca7c8c 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -49,6 +49,7 @@ struct ProviderRequestConfig { base_url: String, model_name: String, api_key: Option, + reasoning_effort: Option, openai_options: OpenAIRequestOptions, } @@ -59,6 +60,7 @@ impl ProviderRequestConfig { base_url: String, model_name: String, api_key: Option, + reasoning_effort: Option, ) -> Self { Self { kind, @@ -66,6 +68,7 @@ impl ProviderRequestConfig { base_url, model_name, api_key, + reasoning_effort, openai_options: OpenAIRequestOptions::default(), } } @@ -82,6 +85,7 @@ pub async fn stream_llm_with_cancellation( session_id: String, provider_name: String, model: String, + reasoning_effort: Option, agent_mode: String, agent_max_steps: Option, tool_permissions: crate::tools::ToolPermissions, @@ -89,7 +93,8 @@ pub async fn stream_llm_with_cancellation( sender: crate::llm::ChunkSender, ) -> Result<(), DynError> { let _ = log("GOING TO STREAM"); - let request_config = prepare_request_config(&provider_name, model, &sender).await?; + let request_config = + prepare_request_config(&provider_name, model, reasoning_effort, &sender).await?; let aisdk_messages = convert_messages(&messages); @@ -108,6 +113,7 @@ pub async fn stream_llm_with_cancellation( ProviderKind::Anthropic => crate::agent::config::ProviderKind::Anthropic, }, base_url: request_config.base_url.clone(), + reasoning_effort: request_config.reasoning_effort, }); let aisdk_tools = convert_to_aisdk_tools( @@ -180,10 +186,12 @@ pub async fn stream_llm_with_cancellation( pub async fn summarize_for_compaction( provider_name: String, model: String, + reasoning_effort: Option, prompt: String, ) -> Result { let (warning_sender, _warning_receiver) = tokio::sync::mpsc::unbounded_channel(); - let request_config = prepare_request_config(&provider_name, model, &warning_sender).await?; + let request_config = + prepare_request_config(&provider_name, model, reasoning_effort, &warning_sender).await?; let messages = vec![AisdkMessage::user(prompt)]; let mut response = stream_provider_request(&request_config, messages, Vec::new(), None).await?; @@ -216,6 +224,7 @@ pub async fn summarize_for_compaction( async fn prepare_request_config( provider_name: &str, model: String, + reasoning_effort: Option, sender: &crate::llm::ChunkSender, ) -> Result { let auth_dao = crate::persistence::AuthDAO::new()?; @@ -235,6 +244,7 @@ async fn prepare_request_config( provider_kind.normalize_base_url(&provider.api), model, configured_api_key(auth_config.as_ref()), + reasoning_effort, ); maybe_apply_openai_oauth_overrides( @@ -393,6 +403,9 @@ async fn stream_provider_request( .base_url(&config.base_url) .model_name(&config.model_name) .provider_name(&config.provider_name); + if let Some(effort) = config.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } if let Some(key) = config.api_key.as_deref() { builder = builder.api_key(key); } @@ -406,6 +419,9 @@ async fn stream_provider_request( .base_url(&config.base_url) .model_name(&config.model_name) .provider_name(&config.provider_name); + if let Some(effort) = config.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } if let Some(key) = config.api_key.as_deref() { builder = builder.api_key(key); } @@ -419,6 +435,9 @@ async fn stream_provider_request( .base_url(&config.base_url) .model_name(&config.model_name) .provider_name(&config.provider_name); + if let Some(effort) = config.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } if let Some(key) = config.api_key.as_deref() { builder = builder.api_key(key); } diff --git a/src/main.rs b/src/main.rs index 3f76a13..740a844 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,6 +126,25 @@ async fn run_print_mode( .clone() .unwrap_or_else(|| "Build".to_string()); + let reasoning_effort = crate::model::discovery::Discovery::new() + .ok() + .and_then(|discovery| discovery.get_model_reasoning_capability(&provider_name, &model_id)) + .and_then(|capability| { + let saved = prefs_dao + .as_ref() + .and_then(|dao| { + dao.get_model_reasoning_effort(&provider_name, &model_id) + .ok() + }) + .flatten()?; + let resolved = capability.resolve(Some(saved))?; + if resolved == crate::model::reasoning::ReasoningEffort::None { + None + } else { + Some(resolved) + } + }); + let cwd = loaded_config.cwd.to_string_lossy().to_string(); let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); @@ -163,6 +182,7 @@ async fn run_print_mode( cuid2::create_id(), provider_name_clone, model_clone, + reasoning_effort, agent_mode.clone(), agent_max_steps, tool_permissions, diff --git a/src/model/discovery.rs b/src/model/discovery.rs index fcc0b45..3da072f 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -272,6 +272,10 @@ impl Discovery { capabilities.push("structured_output".to_string()); } + if model.reasoning { + capabilities.push("reasoning".to_string()); + } + let is_text_model = model.modalities.as_ref().map_or(true, |m| { m.output.contains(&"text".to_string()) && !m.output.contains(&"image".to_string()) @@ -281,9 +285,11 @@ impl Discovery { models.push(crate::model::types::Model { id: model_id.clone(), name: model.name.clone(), + family: model.family.clone(), provider_id: provider_id.clone(), provider_name: provider.name.clone(), capabilities, + reasoning: model.reasoning, }); } } @@ -316,6 +322,31 @@ impl Discovery { model.limit.as_ref().map(|l| l.context) } + pub fn get_model_reasoning_capability( + &self, + provider_id: &str, + model_id: &str, + ) -> Option { + let cache_path = self.get_cache_path(); + if !cache_path.exists() { + return None; + } + let cached_json = std::fs::read_to_string(cache_path).ok()?; + let entry: CacheEntry = serde_json::from_str(&cached_json).ok()?; + let provider = entry.data.get(provider_id)?; + let model = provider.models.get(model_id)?; + Some(crate::model::reasoning::capability_for_model( + provider_id, + &provider.npm, + model_id, + &model.id, + &model.name, + &model.family, + &model.release_date, + model.reasoning, + )) + } + pub async fn list_models(&self, provider_filter: Option<&str>) -> Result { let models = self.fetch_models().await?; diff --git a/src/model/mod.rs b/src/model/mod.rs index 7b055c1..a9df33b 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,2 +1,3 @@ pub mod discovery; +pub mod reasoning; pub mod types; diff --git a/src/model/reasoning.rs b/src/model/reasoning.rs new file mode 100644 index 0000000..b3418fa --- /dev/null +++ b/src/model/reasoning.rs @@ -0,0 +1,777 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningEffort { + None, + Minimal, + Low, + Medium, + High, + XHigh, + Max, +} + +impl ReasoningEffort { + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::XHigh => "xhigh", + Self::Max => "max", + } + } +} + +impl fmt::Display for ReasoningEffort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ReasoningEffort { + type Err = (); + + fn from_str(value: &str) -> Result { + match normalize_effort_token(value).as_str() { + "none" => Ok(Self::None), + "minimal" => Ok(Self::Minimal), + "low" => Ok(Self::Low), + "medium" => Ok(Self::Medium), + "high" => Ok(Self::High), + "xhigh" => Ok(Self::XHigh), + "max" => Ok(Self::Max), + _ => Err(()), + } + } +} + +fn normalize_effort_token(value: &str) -> String { + value + .trim() + .to_ascii_lowercase() + .replace('_', "") + .replace('-', "") + .replace("extrahigh", "xhigh") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReasoningCapability { + Unsupported, + Effort { + values: Vec, + default: ReasoningEffort, + }, +} + +impl ReasoningCapability { + pub fn effort(values: Vec, default: ReasoningEffort) -> Self { + let default = if values.contains(&default) { + default + } else { + values.first().copied().unwrap_or(default) + }; + Self::Effort { values, default } + } + + pub fn values(&self) -> &[ReasoningEffort] { + match self { + Self::Unsupported => &[], + Self::Effort { values, .. } => values, + } + } + + pub fn default_effort(&self) -> Option { + match self { + Self::Unsupported => None, + Self::Effort { default, .. } => Some(*default), + } + } + + pub fn resolve(&self, requested: Option) -> Option { + let Some(requested) = requested else { + return self.default_effort(); + }; + + if self.values().contains(&requested) { + return Some(requested); + } + + downgrade_candidates(requested) + .into_iter() + .find(|effort| self.values().contains(effort)) + } + + pub fn cycle_next(&self, current: Option) -> Option { + self.cycle(current, 1) + } + + pub fn cycle( + &self, + current: Option, + direction: i8, + ) -> Option { + let values = self.values(); + if values.is_empty() { + return None; + } + + let current = self.resolve(current).or_else(|| self.default_effort())?; + let idx = values + .iter() + .position(|effort| *effort == current) + .unwrap_or(0); + if direction < 0 { + Some(values[(idx + values.len() - 1) % values.len()]) + } else { + Some(values[(idx + 1) % values.len()]) + } + } + + pub fn cycle_override( + &self, + current: Option, + direction: i8, + ) -> Option> { + let values: Vec<_> = self + .values() + .iter() + .copied() + .filter(|effort| *effort != ReasoningEffort::None) + .collect(); + if values.is_empty() { + return None; + } + + let mut entries = Vec::with_capacity(values.len() + 1); + entries.push(None); + entries.extend(values.into_iter().map(Some)); + + let current = current + .and_then(|effort| self.resolve(Some(effort))) + .filter(|effort| *effort != ReasoningEffort::None); + let idx = entries + .iter() + .position(|effort| *effort == current) + .unwrap_or(0); + + if direction < 0 { + Some(entries[(idx + entries.len() - 1) % entries.len()]) + } else { + Some(entries[(idx + 1) % entries.len()]) + } + } +} + +fn downgrade_candidates(requested: ReasoningEffort) -> Vec { + match requested { + ReasoningEffort::Max => vec![ + ReasoningEffort::Max, + ReasoningEffort::XHigh, + ReasoningEffort::High, + ReasoningEffort::Medium, + ReasoningEffort::Low, + ], + ReasoningEffort::XHigh => vec![ + ReasoningEffort::XHigh, + ReasoningEffort::High, + ReasoningEffort::Medium, + ReasoningEffort::Low, + ], + ReasoningEffort::High => vec![ + ReasoningEffort::High, + ReasoningEffort::Medium, + ReasoningEffort::Low, + ], + ReasoningEffort::Medium => vec![ + ReasoningEffort::Medium, + ReasoningEffort::Low, + ReasoningEffort::High, + ], + ReasoningEffort::Low => vec![ + ReasoningEffort::Low, + ReasoningEffort::Minimal, + ReasoningEffort::Medium, + ], + ReasoningEffort::Minimal => vec![ReasoningEffort::Minimal, ReasoningEffort::Low], + ReasoningEffort::None => vec![ReasoningEffort::None, ReasoningEffort::Minimal], + } +} + +pub fn capability_for_model( + provider_id: &str, + provider_npm: &str, + model_id: &str, + api_id: &str, + model_name: &str, + family: &str, + release_date: &str, + models_dev_reasoning: bool, +) -> ReasoningCapability { + if !models_dev_reasoning { + return ReasoningCapability::Unsupported; + } + + let provider = provider_id.to_ascii_lowercase(); + let npm = provider_npm.to_ascii_lowercase(); + let model = model_id.to_ascii_lowercase(); + let api_id = if api_id.trim().is_empty() { + model.as_str() + } else { + api_id + }; + let api = api_id.to_ascii_lowercase(); + let name = model_name.to_ascii_lowercase(); + let family = family.to_ascii_lowercase(); + let haystack = format!("{provider} {npm} {model} {api} {name} {family}"); + + // models.dev `reasoning: true` means the model can emit thinking/reasoning + // tokens. OpenCode still requires provider/model-specific variants before + // exposing selectable effort controls. + if has_reasoning_without_selectable_effort(&haystack) { + return ReasoningCapability::Unsupported; + } + + if haystack.contains("grok") { + if haystack.contains("grok-3-mini") { + return ReasoningCapability::effort( + vec![ReasoningEffort::Low, ReasoningEffort::High], + ReasoningEffort::High, + ); + } + return ReasoningCapability::Unsupported; + } + + if provider == "openai" { + return openai_capability(&api, release_date); + } + + match npm.as_str() { + "@ai-sdk/openai" => openai_capability(&api, release_date), + "@ai-sdk/azure" => azure_capability(&api), + "@ai-sdk/openai-compatible" + | "@ai-sdk/cerebras" + | "@ai-sdk/togetherai" + | "@ai-sdk/xai" + | "@ai-sdk/deepinfra" + | "venice-ai-sdk-provider" => openai_compatible_capability(&api), + "@ai-sdk/anthropic" | "@ai-sdk/google-vertex/anthropic" => anthropic_capability(&api), + "ai-gateway-provider" => { + if api.starts_with("openai/") { + openai_capability(&api, release_date) + } else { + widely_supported_capability() + } + } + "@ai-sdk/gateway" => { + if haystack.contains("anthropic") || haystack.contains("claude") { + anthropic_capability(&api) + } else if haystack.contains("google") || haystack.contains("gemini") { + google_capability(&api) + } else { + openai_compatible_capability(&api) + } + } + "@ai-sdk/google" | "@ai-sdk/google-vertex" => google_capability(&api), + "@ai-sdk/groq" => ReasoningCapability::effort( + vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ], + ReasoningEffort::Medium, + ), + "@ai-sdk/mistral" => mistral_capability(&api), + _ if provider == "anthropic" || haystack.contains("claude") => anthropic_capability(&api), + _ if provider == "google" || haystack.contains("gemini") => google_capability(&api), + _ => ReasoningCapability::Unsupported, + } +} + +fn has_reasoning_without_selectable_effort(haystack: &str) -> bool { + [ + "deepseek-chat", + "deepseek-reasoner", + "deepseek-r1", + "deepseek-v3", + "minimax", + "glm", + "kimi", + "k2p", + "qwen", + "big-pickle", + ] + .iter() + .any(|needle| haystack.contains(needle)) +} + +fn widely_supported_capability() -> ReasoningCapability { + ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ], + ReasoningEffort::Medium, + ) +} + +fn openai_efforts(api_id: &str, release_date: &str) -> Vec { + if api_id.contains("deep-research") { + return vec![ReasoningEffort::Medium]; + } + + if is_gpt5_chat(api_id) { + return if gpt5_version(api_id).is_some() { + vec![ReasoningEffort::Medium] + } else { + Vec::new() + }; + } + + if is_gpt5_versioned_pro(api_id) { + return vec![ + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]; + } + + if is_gpt5_pro(api_id) { + return vec![ReasoningEffort::High]; + } + + if let Some(codex_efforts) = gpt5_codex_efforts(api_id) { + return codex_efforts; + } + + if let Some(versioned_efforts) = versioned_gpt5_efforts(api_id) { + return versioned_efforts; + } + + let mut efforts = vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]; + if is_gpt5_family(api_id) { + efforts.insert(0, ReasoningEffort::Minimal); + } + if release_date >= "2025-11-13" { + efforts.insert(0, ReasoningEffort::None); + } + if release_date >= "2025-12-04" { + efforts.push(ReasoningEffort::XHigh); + } + efforts +} + +fn openai_capability(api_id: &str, release_date: &str) -> ReasoningCapability { + let efforts = openai_efforts(api_id, release_date); + if efforts.is_empty() { + ReasoningCapability::Unsupported + } else { + ReasoningCapability::effort(efforts, ReasoningEffort::Medium) + } +} + +fn openai_compatible_capability(api_id: &str) -> ReasoningCapability { + if is_gpt5_chat(api_id) { + return if gpt5_version(api_id).is_some() { + ReasoningCapability::effort(vec![ReasoningEffort::Medium], ReasoningEffort::Medium) + } else { + ReasoningCapability::Unsupported + }; + } + + if is_gpt5_versioned_pro(api_id) { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ], + ReasoningEffort::Medium, + ); + } + + if is_gpt5_pro(api_id) { + return ReasoningCapability::effort(vec![ReasoningEffort::High], ReasoningEffort::High); + } + + if let Some(codex_efforts) = gpt5_codex_efforts(api_id) { + return ReasoningCapability::effort(codex_efforts, ReasoningEffort::Medium); + } + + if let Some(versioned_efforts) = versioned_gpt5_efforts(api_id) { + return ReasoningCapability::effort(versioned_efforts, ReasoningEffort::Medium); + } + + if api_id.contains("deepseek-v4") { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::Max, + ], + ReasoningEffort::Medium, + ); + } + + ReasoningCapability::effort( + vec![ + ReasoningEffort::None, + ReasoningEffort::Minimal, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ], + ReasoningEffort::Medium, + ) +} + +fn azure_capability(api_id: &str) -> ReasoningCapability { + let mut efforts = vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]; + if is_gpt5_family(api_id) && gpt5_version(api_id).is_none() { + efforts.insert(0, ReasoningEffort::Minimal); + } + ReasoningCapability::effort(efforts, ReasoningEffort::Medium) +} + +fn is_gpt5_family(api_id: &str) -> bool { + api_id == "gpt-5" + || api_id.starts_with("gpt-5.") + || api_id.starts_with("gpt-5-") + || api_id.ends_with("/gpt-5") + || api_id.contains("/gpt-5.") + || api_id.contains("/gpt-5-") +} + +fn gpt5_version(api_id: &str) -> Option { + let id = api_id.strip_prefix("openai/").unwrap_or(api_id); + let rest = id + .strip_prefix("gpt-5.") + .or_else(|| id.strip_prefix("gpt-5-"))?; + let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect(); + digits.parse().ok() +} + +fn is_gpt5_pro(api_id: &str) -> bool { + api_id == "gpt-5-pro" + || api_id.starts_with("gpt-5-pro.") + || api_id.starts_with("gpt-5-pro-") + || api_id.ends_with("/gpt-5-pro") + || api_id.contains("/gpt-5-pro.") + || api_id.contains("/gpt-5-pro-") +} + +fn is_gpt5_versioned_pro(api_id: &str) -> bool { + is_gpt5_family(api_id) && gpt5_version(api_id).is_some() && api_id.contains("pro") +} + +fn is_gpt5_chat(api_id: &str) -> bool { + is_gpt5_family(api_id) && api_id.contains("-chat") +} + +fn versioned_gpt5_efforts(api_id: &str) -> Option> { + let version = gpt5_version(api_id)?; + if version == 1 { + Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]) + } else { + Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]) + } +} + +fn gpt5_codex_efforts(api_id: &str) -> Option> { + if !is_gpt5_family(api_id) || !api_id.contains("codex") { + return None; + } + + let version = gpt5_version(api_id); + if version.is_some_and(|version| version >= 3) { + return Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]); + } + + if api_id.contains("codex-max") || version.is_some_and(|version| version >= 2) { + return Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]); + } + + Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]) +} + +fn anthropic_capability(api_id: &str) -> ReasoningCapability { + if api_id.contains("opus-4-7") || api_id.contains("opus-4.7") { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ReasoningEffort::Max, + ], + ReasoningEffort::High, + ); + } + + if api_id.contains("opus-4-6") + || api_id.contains("opus-4.6") + || api_id.contains("sonnet-4-6") + || api_id.contains("sonnet-4.6") + { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::Max, + ], + ReasoningEffort::High, + ); + } + + if api_id.contains("opus-4-5") || api_id.contains("opus-4.5") { + return widely_supported_capability(); + } + + ReasoningCapability::effort( + vec![ReasoningEffort::High, ReasoningEffort::Max], + ReasoningEffort::High, + ) +} + +fn google_capability(api_id: &str) -> ReasoningCapability { + if api_id.contains("2.5") { + return ReasoningCapability::effort( + vec![ReasoningEffort::High, ReasoningEffort::Max], + ReasoningEffort::High, + ); + } + + if !api_id.contains("gemini-3") { + return ReasoningCapability::effort( + vec![ReasoningEffort::Low, ReasoningEffort::High], + ReasoningEffort::High, + ); + } + + if api_id.contains("flash-image") { + return ReasoningCapability::effort( + vec![ReasoningEffort::Minimal, ReasoningEffort::High], + ReasoningEffort::High, + ); + } + + if api_id.contains("pro-image") { + return ReasoningCapability::effort(vec![ReasoningEffort::High], ReasoningEffort::High); + } + + if api_id.contains("flash") { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Minimal, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ], + ReasoningEffort::Medium, + ); + } + + widely_supported_capability() +} + +fn mistral_capability(api_id: &str) -> ReasoningCapability { + let reasoning_ids = [ + "mistral-small-2603", + "mistral-small-latest", + "mistral-medium-3.5", + "mistral-medium-2604", + ]; + if reasoning_ids.iter().any(|id| api_id.contains(id)) { + ReasoningCapability::effort(vec![ReasoningEffort::High], ReasoningEffort::High) + } else { + ReasoningCapability::Unsupported + } +} + +fn generic_reasoning_capability() -> ReasoningCapability { + widely_supported_capability() +} + +pub fn parse_effort(value: &serde_json::Value) -> Option { + value.as_str()?.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_xhigh_aliases() { + assert_eq!("xhigh".parse(), Ok(ReasoningEffort::XHigh)); + assert_eq!("extra-high".parse(), Ok(ReasoningEffort::XHigh)); + assert_eq!("extra_high".parse(), Ok(ReasoningEffort::XHigh)); + } + + #[test] + fn generic_reasoning_cycles_supported_values() { + let capability = generic_reasoning_capability(); + assert_eq!(capability.resolve(None), Some(ReasoningEffort::Medium)); + assert_eq!( + capability.cycle_next(Some(ReasoningEffort::Medium)), + Some(ReasoningEffort::High) + ); + assert_eq!( + capability.cycle_next(Some(ReasoningEffort::High)), + Some(ReasoningEffort::Low) + ); + } + + #[test] + fn override_cycle_includes_no_override() { + let capability = generic_reasoning_capability(); + assert_eq!( + capability.cycle_override(None, 1), + Some(Some(ReasoningEffort::Low)) + ); + assert_eq!( + capability.cycle_override(Some(ReasoningEffort::High), 1), + Some(None) + ); + assert_eq!( + capability.cycle_override(None, -1), + Some(Some(ReasoningEffort::High)) + ); + } + + #[test] + fn downgrades_to_nearest_supported_effort() { + let capability = generic_reasoning_capability(); + assert_eq!( + capability.resolve(Some(ReasoningEffort::XHigh)), + Some(ReasoningEffort::High) + ); + assert_eq!(capability.resolve(Some(ReasoningEffort::None)), None); + assert_eq!( + capability.cycle_next(Some(ReasoningEffort::None)), + Some(ReasoningEffort::High) + ); + } + + #[test] + fn unsupported_models_have_no_cycle() { + let capability = capability_for_model( + "openai", + "@ai-sdk/openai", + "gpt-4o", + "gpt-4o", + "GPT-4o", + "", + "", + false, + ); + assert_eq!(capability.resolve(None), None); + assert_eq!(capability.cycle_next(None), None); + } + + #[test] + fn reasoning_true_is_not_enough_for_selectable_effort() { + let capability = capability_for_model( + "opencode-go", + "@ai-sdk/openai-compatible", + "kimi-k2.6", + "kimi-k2.6", + "Kimi K2.6", + "kimi-k2.6", + "", + true, + ); + assert_eq!(capability.values(), &[]); + assert_eq!(capability.cycle_next(None), None); + } + + #[test] + fn deepseek_v4_reasoning_includes_max() { + let capability = capability_for_model( + "deepseek", + "@ai-sdk/openai-compatible", + "deepseek-v4-pro", + "deepseek-v4-pro", + "DeepSeek V4 Pro", + "", + "", + true, + ); + assert_eq!( + capability.values(), + &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::Max, + ] + ); + } + + #[test] + fn gpt_5_3_codex_spark_uses_opencode_style_efforts() { + let capability = capability_for_model( + "openai", + "@ai-sdk/openai", + "gpt-5.3-codex-spark", + "gpt-5.3-codex-spark", + "GPT-5.3 Codex Spark", + "", + "2026-01-01", + true, + ); + assert_eq!( + capability.values(), + &[ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ] + ); + } +} diff --git a/src/model/types.rs b/src/model/types.rs index 78f876a..9fcf19e 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -4,9 +4,11 @@ use serde::{Deserialize, Serialize}; pub struct Model { pub id: String, pub name: String, + pub family: String, pub provider_id: String, pub provider_name: String, pub capabilities: Vec, + pub reasoning: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/persistence/prefs.rs b/src/persistence/prefs.rs index f37335e..1ba0d4a 100644 --- a/src/persistence/prefs.rs +++ b/src/persistence/prefs.rs @@ -1,7 +1,7 @@ +use crate::model::reasoning::{parse_effort, ReasoningEffort}; use anyhow::Result; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use super::{ensure_data_dir, get_data_dir}; @@ -39,6 +39,10 @@ impl Default for ModelPreferences { } impl ModelPreferences { + fn model_variant_key(provider_id: &str, model_id: &str) -> String { + format!("{provider_id}/{model_id}") + } + pub fn get_active_model(&self) -> Option<&ModelRef> { self.recent.first() } @@ -78,6 +82,41 @@ impl ModelPreferences { .iter() .any(|m| m.provider_id == provider_id && m.model_id == model_id) } + + pub fn get_reasoning_effort( + &self, + provider_id: &str, + model_id: &str, + ) -> Option { + let key = Self::model_variant_key(provider_id, model_id); + self.variant + .as_object() + .and_then(|map| map.get(&key)) + .and_then(parse_effort) + } + + pub fn set_reasoning_effort( + &mut self, + provider_id: String, + model_id: String, + effort: ReasoningEffort, + ) { + let key = Self::model_variant_key(&provider_id, &model_id); + if !self.variant.is_object() { + self.variant = serde_json::json!({}); + } + + if let Some(map) = self.variant.as_object_mut() { + map.insert(key, serde_json::Value::String(effort.as_str().to_string())); + } + } + + pub fn clear_reasoning_effort(&mut self, provider_id: &str, model_id: &str) { + let key = Self::model_variant_key(provider_id, model_id); + if let Some(map) = self.variant.as_object_mut() { + map.remove(&key); + } + } } #[derive(Debug)] @@ -164,6 +203,32 @@ impl PrefsDAO { let prefs = self.get_model_preferences()?; Ok(prefs.is_favorite(provider_id, model_id)) } + + pub fn set_model_reasoning_effort( + &self, + provider_id: String, + model_id: String, + effort: ReasoningEffort, + ) -> Result<()> { + let mut prefs = self.get_model_preferences()?; + prefs.set_reasoning_effort(provider_id, model_id, effort); + self.set_model_preferences(&prefs) + } + + pub fn clear_model_reasoning_effort(&self, provider_id: &str, model_id: &str) -> Result<()> { + let mut prefs = self.get_model_preferences()?; + prefs.clear_reasoning_effort(provider_id, model_id); + self.set_model_preferences(&prefs) + } + + pub fn get_model_reasoning_effort( + &self, + provider_id: &str, + model_id: &str, + ) -> Result> { + let prefs = self.get_model_preferences()?; + Ok(prefs.get_reasoning_effort(provider_id, model_id)) + } } #[cfg(test)] diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 26412e0..fb46c19 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -4,7 +4,7 @@ use crate::ui::scrollbar::{ }; use nucleo_matcher::{ pattern::{CaseMatching, Normalization, Pattern}, - Config, Matcher, + Config, Matcher, Utf32Str, }; use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, @@ -21,6 +21,8 @@ use tui_textarea::{Input as TuiInput, TextArea}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; const SEARCH_AREA_HEIGHT: u16 = 2; +const PROVIDER_EXACT_MATCH_BOOST: u32 = 1_000_000; +const PROVIDER_PREFIX_MATCH_BOOST: u32 = 900_000; #[derive(Debug, Clone, Copy, PartialEq)] pub enum DialogPosition { @@ -77,6 +79,7 @@ pub struct Dialog { scrollbar_drag_offset: Option, pub visible_row_count: usize, pub actions: Vec, + bottom_gap_height: u16, pub position: DialogPosition, pub pending_delete_id: Option, collapsible_groups: bool, @@ -113,6 +116,7 @@ impl Dialog { scrollbar_drag_offset: None, visible_row_count: 0, actions: Vec::new(), + bottom_gap_height: 1, position: DialogPosition::Center, pending_delete_id: None, collapsible_groups: false, @@ -155,6 +159,10 @@ impl Dialog { self } + pub fn set_bottom_gap_height(&mut self, height: u16) { + self.bottom_gap_height = height.max(1); + } + pub fn set_items(&mut self, items: Vec) { self.items = items; self.group_items(); @@ -332,59 +340,147 @@ impl Dialog { CaseMatching::Ignore, Normalization::Smart, ); - let mut filtered: Vec<(String, Vec)> = Vec::new(); + let groups = self.groups.clone(); + let mut filtered: Vec<(String, Vec, u32, usize)> = Vec::new(); - for group in &self.groups { - let items = self.grouped_items.get(group).unwrap(); - - let combined_strings: Vec = items + for (group_index, group) in groups.iter().enumerate() { + let items = self.grouped_items.get(group).cloned().unwrap_or_default(); + let mut scored_items: Vec<(DialogItem, u32, usize)> = items .iter() - .map(|item| { - let base = format!("{} {}", group, item.name); - match &item.tip { - Some(tip) => format!("{} {}", base, tip), - None => base, - } + .enumerate() + .filter_map(|(item_index, item)| { + Self::search_item_score( + &pattern, + &mut self.matcher, + &self.search_query, + group, + item, + ) + .map(|score| (item.clone(), score, item_index)) }) .collect(); - let matched: Vec<(&str, u32)> = pattern.match_list( - combined_strings.iter().map(|s| s.as_str()), - &mut self.matcher, - ); - - if !matched.is_empty() { - let mut scored_items: Vec<(DialogItem, u32)> = matched - .into_iter() - .filter_map(|(combined_str, score)| { - items - .iter() - .find(|item| { - let base = format!("{} {}", group, item.name); - let s = match &item.tip { - Some(tip) => format!("{} {}", base, tip), - None => base, - }; - s == *combined_str - }) - .map(|item| (item.clone(), score)) - }) - .collect(); - - scored_items.sort_by(|a, b| b.1.cmp(&a.1)); + if !scored_items.is_empty() { + scored_items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2))); + let group_score = scored_items + .first() + .map(|(_, score, _)| *score) + .unwrap_or(0); let sorted_items: Vec = - scored_items.into_iter().map(|(item, _)| item).collect(); + scored_items.into_iter().map(|(item, _, _)| item).collect(); - filtered.push((group.clone(), sorted_items)); + filtered.push((group.clone(), sorted_items, group_score, group_index)); } } - self.filtered_items = filtered; + + filtered.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.3.cmp(&b.3))); + self.filtered_items = filtered + .into_iter() + .map(|(group, items, _, _)| (group, items)) + .collect(); } self.reconcile_selection_after_filter(preferred_selected); } + fn search_item_score( + pattern: &Pattern, + matcher: &mut Matcher, + query: &str, + group: &str, + item: &DialogItem, + ) -> Option { + let mut best_score = None; + + Self::consider_search_field( + pattern, + matcher, + group, + Self::provider_match_boost(query, group), + &mut best_score, + ); + Self::consider_search_field( + pattern, + matcher, + &item.provider_id, + Self::provider_match_boost(query, &item.provider_id), + &mut best_score, + ); + Self::consider_search_field(pattern, matcher, &item.name, 0, &mut best_score); + Self::consider_search_field(pattern, matcher, &item.description, 0, &mut best_score); + if let Some(tip) = &item.tip { + Self::consider_search_field(pattern, matcher, tip, 0, &mut best_score); + } + + let combined = match &item.tip { + Some(tip) => format!( + "{} {} {} {} {}", + group, item.provider_id, item.name, item.description, tip + ), + None => format!( + "{} {} {} {}", + group, item.provider_id, item.name, item.description + ), + }; + Self::consider_search_field(pattern, matcher, &combined, 0, &mut best_score); + + best_score + } + + fn consider_search_field( + pattern: &Pattern, + matcher: &mut Matcher, + text: &str, + boost: u32, + best_score: &mut Option, + ) { + if text.is_empty() { + return; + } + + let mut buf = Vec::new(); + if let Some(score) = pattern.score(Utf32Str::new(text, &mut buf), matcher) { + let boosted_score = score.saturating_add(boost); + *best_score = Some( + best_score + .map(|current| current.max(boosted_score)) + .unwrap_or(boosted_score), + ); + } + } + + fn provider_match_boost(query: &str, text: &str) -> u32 { + let query = Self::normalize_search_text(query); + if query.is_empty() { + return 0; + } + + let normalized_text = Self::normalize_search_text(text); + if normalized_text == query { + PROVIDER_EXACT_MATCH_BOOST + } else if normalized_text.starts_with(&query) + || Self::normalized_token_starts_with(text, &query) + { + PROVIDER_PREFIX_MATCH_BOOST + } else { + 0 + } + } + + fn normalize_search_text(text: &str) -> String { + text.chars() + .filter(|ch| ch.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() + } + + fn normalized_token_starts_with(text: &str, query: &str) -> bool { + text.split(|ch: char| !ch.is_alphanumeric()) + .map(Self::normalize_search_text) + .any(|token| !token.is_empty() && token.starts_with(query)) + } + fn reconcile_selection_after_filter(&mut self, preferred_selected: Option<(String, String)>) { let flat_len = self.get_flat_items().len(); if flat_len == 0 { @@ -799,7 +895,7 @@ impl Dialog { true } - fn footer_height(&self) -> u16 { + pub(crate) fn footer_height(&self) -> u16 { if self.actions.len() > 4 { 3 } else if self.actions.len() > 2 { @@ -815,7 +911,7 @@ impl Dialog { ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(SEARCH_AREA_HEIGHT), ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(self.bottom_gap_height), ratatui::layout::Constraint::Length(self.footer_height()), ] } @@ -932,7 +1028,7 @@ impl Dialog { let padding_len = width.saturating_sub(left_width + tip_width + right_padding); spans.push(Span::raw(" ".repeat(padding_len))); - let tip_style = if tip == "❤︎" { + let tip_style = if tip.starts_with("❤︎") { Style::default() .fg(Color::Rgb(255, 105, 180)) .add_modifier(Modifier::BOLD) @@ -1641,6 +1737,7 @@ impl Clone for Dialog { scrollbar_drag_offset: self.scrollbar_drag_offset, visible_row_count: self.visible_row_count, actions: self.actions.clone(), + bottom_gap_height: self.bottom_gap_height, position: self.position, pending_delete_id: self.pending_delete_id.clone(), collapsible_groups: self.collapsible_groups, @@ -1733,6 +1830,27 @@ mod tests { ] } + fn create_provider_weight_test_items() -> Vec { + vec![ + DialogItem { + id: "nanogpt-openai-o1".to_string(), + name: "OpenAI o1".to_string(), + group: "NanoGPT".to_string(), + description: "NanoGPT | reasoning".to_string(), + tip: None, + provider_id: "nanogpt".to_string(), + }, + DialogItem { + id: "openai-gpt-5".to_string(), + name: "GPT-5".to_string(), + group: "OpenAI".to_string(), + description: "OpenAI | reasoning, tools".to_string(), + tip: None, + provider_id: "openai".to_string(), + }, + ] + } + #[test] fn test_dialog_creation() { let dialog = Dialog::new("Test Dialog"); @@ -1900,6 +2018,20 @@ mod tests { assert_eq!(dialog.filtered_items[0].1[0].name, "Model A"); } + #[test] + fn test_dialog_search_prioritizes_provider_match_over_model_match() { + let mut dialog = Dialog::with_items("Models", create_provider_weight_test_items()); + + dialog.set_search_query("openai"); + + let flat_items = dialog.get_flat_items(); + assert_eq!(flat_items.len(), 2); + assert_eq!(flat_items[0].provider_id, "openai"); + assert_eq!(flat_items[0].name, "GPT-5"); + assert_eq!(flat_items[1].provider_id, "nanogpt"); + assert_eq!(flat_items[1].name, "OpenAI o1"); + } + #[test] fn test_dialog_clear_search() { let mut dialog = Dialog::with_items("Models", create_test_items()); diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 212ca13..9d837b4 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -108,6 +108,7 @@ impl Input { agent: &str, model: &str, provider_name: &str, + reasoning_effort: Option<&str>, colors: &ThemeColors, ) { let agent_color = agent_color(agent, colors); @@ -172,7 +173,7 @@ impl Input { frame.render_widget(&self.textarea, v_chunks[1]); - let info_text = ratatui::text::Line::from(vec![ + let mut info_spans = vec![ ratatui::text::Span::styled(agent.to_string(), Style::default().fg(agent_color)), ratatui::text::Span::raw(" "), ratatui::text::Span::styled(model.to_string(), Style::default().fg(colors.text)), @@ -183,7 +184,19 @@ impl Input { .fg(colors.text_weak) .add_modifier(ratatui::style::Modifier::DIM), ), - ]); + ]; + + if let Some(reasoning_effort) = reasoning_effort { + info_spans.push(ratatui::text::Span::raw(" ")); + info_spans.push(ratatui::text::Span::styled( + reasoning_effort.to_string(), + Style::default() + .fg(colors.warning) + .add_modifier(ratatui::style::Modifier::BOLD), + )); + } + + let info_text = ratatui::text::Line::from(info_spans); let info_paragraph = Paragraph::new(info_text); frame.render_widget(info_paragraph, v_chunks[3]); diff --git a/src/views/chat.rs b/src/views/chat.rs index 0c0c30c..32a9121 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -73,6 +73,7 @@ pub fn render_chat( agent: String, model: String, provider_name: String, + reasoning_effort: Option, colors: &ThemeColors, is_streaming: bool, is_compacting: bool, @@ -134,6 +135,7 @@ pub fn render_chat( &agent, &model, &provider_name, + reasoning_effort.as_deref(), colors, ); } diff --git a/src/views/home.rs b/src/views/home.rs index 752b63e..f3c05e1 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -75,6 +75,7 @@ pub fn render_home( agent: String, model: String, provider_name: String, + reasoning_effort: Option, colors: &ThemeColors, usage_text: &str, ) { @@ -196,7 +197,15 @@ pub fn render_home( f.render_widget(mascot, stack[0]); f.render_widget(logo, stack[2]); } - input.render(f, home_chunks[1], &agent, &model, &provider_name, colors); + input.render( + f, + home_chunks[1], + &agent, + &model, + &provider_name, + reasoning_effort.as_deref(), + colors, + ); let help_text = vec![ Span::styled("/", Style::default().fg(colors.info)), diff --git a/src/views/models_dialog.rs b/src/views/models_dialog.rs index 5cb9aec..cb8f2a3 100644 --- a/src/views/models_dialog.rs +++ b/src/views/models_dialog.rs @@ -1,7 +1,13 @@ use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; -use ratatui::{layout::Rect, Frame}; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; use crate::theme::ThemeColors; use crate::ui::components::dialog::{Dialog, DialogAction, DialogItem}; @@ -16,6 +22,11 @@ pub enum ModelsDialogAction { provider_id: String, model_id: String, }, + CycleReasoning { + provider_id: String, + model_id: String, + direction: i8, + }, None, } @@ -31,16 +42,7 @@ impl ModelsDialogState { pub fn with_items(title: impl Into, items: Vec) -> Self { Self { - dialog: Dialog::with_items(title, items).with_actions(vec![ - DialogAction { - label: "Connect provider".to_string(), - key: "ctrl+a".to_string(), - }, - DialogAction { - label: "Favorite".to_string(), - key: "ctrl+f".to_string(), - }, - ]), + dialog: Dialog::with_items(title, items).with_actions(base_actions()), } } @@ -80,8 +82,112 @@ pub fn render_models_dialog( dialog_state: &mut ModelsDialogState, area: Rect, colors: ThemeColors, + reasoning_effort: Option<&str>, ) { + dialog_state.dialog.actions = base_actions(); + dialog_state + .dialog + .set_bottom_gap_height(if reasoning_effort.is_some() { 3 } else { 1 }); dialog_state.dialog.render(f, area, colors); + + if let Some(reasoning_effort) = reasoning_effort { + render_reasoning_control(f, &dialog_state.dialog, colors, reasoning_effort); + } +} + +fn base_actions() -> Vec { + vec![ + DialogAction { + label: "Connect provider".to_string(), + key: "ctrl+a".to_string(), + }, + DialogAction { + label: "Favorite".to_string(), + key: "ctrl+f".to_string(), + }, + ] +} + +fn render_reasoning_control( + f: &mut Frame, + dialog: &Dialog, + colors: ThemeColors, + reasoning_effort: &str, +) { + let gap_height = 3; + if dialog.content_area.height < gap_height + dialog.footer_height() { + return; + } + + let gap_area = Rect { + x: dialog.content_area.x, + y: dialog.content_area.y + + dialog + .content_area + .height + .saturating_sub(dialog.footer_height() + gap_height), + width: dialog.content_area.width, + height: gap_height, + }; + let control_area = Rect { + x: gap_area.x, + y: gap_area.y + 1, + width: gap_area.width, + height: 1, + }; + let line = reasoning_control_line(reasoning_effort, control_area.width, colors); + + f.render_widget( + Paragraph::new(line).alignment(Alignment::Left), + control_area, + ); +} + +fn reasoning_control_line<'a>( + reasoning_effort: &'a str, + width: u16, + colors: ThemeColors, +) -> Line<'a> { + let width = width as usize; + let effort_width = reasoning_effort.len(); + + if width <= effort_width + 2 { + return Line::from(vec![Span::styled( + reasoning_effort.to_string(), + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )]); + } + + let effort_start = width.saturating_sub(effort_width) / 2; + let right_start = width.saturating_sub(1); + let spaces_after_left = effort_start.saturating_sub(1); + let used_through_effort = 1 + spaces_after_left + effort_width; + let spaces_after_effort = right_start.saturating_sub(used_through_effort); + + Line::from(vec![ + Span::styled( + "<", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" ".repeat(spaces_after_left)), + Span::styled( + reasoning_effort.to_string(), + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" ".repeat(spaces_after_effort)), + Span::styled( + ">", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + ]) } pub fn handle_models_dialog_key_event( @@ -110,6 +216,27 @@ pub fn handle_models_dialog_key_event( }; } } + KeyCode::Left | KeyCode::Right + if event.modifiers == KeyModifiers::NONE + || event.modifiers == KeyModifiers::CONTROL => + { + if let Some(selected) = dialog_state.dialog.get_selected() { + return ModelsDialogAction::CycleReasoning { + provider_id: selected.provider_id.clone(), + model_id: selected.id.clone(), + direction: if event.code == KeyCode::Left { -1 } else { 1 }, + }; + } + } + KeyCode::Char('t') if event.modifiers == KeyModifiers::CONTROL => { + if let Some(selected) = dialog_state.dialog.get_selected() { + return ModelsDialogAction::CycleReasoning { + provider_id: selected.provider_id.clone(), + model_id: selected.id.clone(), + direction: 1, + }; + } + } _ => { dialog_state.dialog.handle_key_event(event); } @@ -220,4 +347,86 @@ mod tests { assert_eq!(action, ModelsDialogAction::None); assert!(state.dialog.is_visible()); } + + #[test] + fn left_and_right_cycle_reasoning_for_selected_model() { + let mut state = init_models_dialog( + "Models", + vec![ + model_item("gpt-5", "GPT-5", "openai"), + model_item("claude-sonnet", "Claude Sonnet", "anthropic"), + ], + ); + state.dialog.show(); + state.dialog.next(); + + let right = handle_models_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + assert_eq!( + right, + ModelsDialogAction::CycleReasoning { + provider_id: "anthropic".to_string(), + model_id: "claude-sonnet".to_string(), + direction: 1, + } + ); + + let left = handle_models_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + ); + assert_eq!( + left, + ModelsDialogAction::CycleReasoning { + provider_id: "anthropic".to_string(), + model_id: "claude-sonnet".to_string(), + direction: -1, + } + ); + } + + #[test] + fn ctrl_t_cycles_reasoning_for_selected_model() { + let mut state = init_models_dialog("Models", vec![model_item("gpt-5", "GPT-5", "openai")]); + state.dialog.show(); + + let action = handle_models_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL), + ); + + assert_eq!( + action, + ModelsDialogAction::CycleReasoning { + provider_id: "openai".to_string(), + model_id: "gpt-5".to_string(), + direction: 1, + } + ); + } + + #[test] + fn footer_actions_do_not_include_reasoning_control() { + let actions = base_actions(); + assert_eq!(actions.len(), 2); + assert!(actions.iter().all(|action| action.label != "Reasoning")); + } + + #[test] + fn reasoning_control_line_spreads_arrows_and_value() { + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); + let line = reasoning_control_line("xhigh", 21, colors); + let rendered = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(rendered.len(), 21); + assert!(rendered.starts_with('<')); + assert!(rendered.ends_with('>')); + assert_eq!(rendered.find("xhigh"), Some((21 - "xhigh".len()) / 2)); + } } From aa66483dd1e86735bea167d9bdc2f13d5212003f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 00:51:04 +0800 Subject: [PATCH 114/226] feat: persist partial messages on streaming failure. Refactor finish/fail streaming into finalize_and_persist_streamed_messages and mark_running_tool_messages_failed. On failure, running tool messages are now marked as "error" with the failure reason instead of being truncated, preserving partial session state. --- src/app.rs | 167 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 141 insertions(+), 26 deletions(-) diff --git a/src/app.rs b/src/app.rs index 802d88e..fa0ee31 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4390,15 +4390,45 @@ impl App { } fn finish_streaming_session(&mut self, session_id: &str) { - let Some((start, model, provider)) = self.streaming_boundary_for_session(session_id) else { + let Some(completion_stats) = self.finalize_and_persist_streamed_messages(session_id, None) + else { return; }; + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Idle, + None, + ); + + if !self.is_active_session(session_id) { + if let Some(state) = self.session_view_states.get_mut(session_id) { + state.unread_completed = true; + } + } + + self.cleanup_streaming_for_session(session_id); + self.play_sound_event_with_notification_detail( + crate::sound::SoundEvent::Complete, + completion_stats.as_deref(), + ); + } + + fn finalize_and_persist_streamed_messages( + &mut self, + session_id: &str, + terminal_error: Option<&str>, + ) -> Option> { + let (start, model, provider) = self.streaming_boundary_for_session(session_id)?; let mut messages_to_persist = Vec::new(); let completion_stats = if let Some(chat) = self.chat_for_session_mut(session_id) { chat.mark_streaming_end(); chat.finalize_streaming_metrics(); + if let Some(error) = terminal_error { + Self::mark_running_tool_messages_failed(chat, start, error); + } + for msg in chat.messages.iter_mut().skip(start) { match msg.role { crate::session::types::MessageRole::Assistant => { @@ -4426,35 +4456,42 @@ impl App { let _ = self.session_manager.add_message_to_session(session_id, msg); } - let _ = self.session_manager.set_session_status( - session_id, - crate::session::types::SessionStatus::Idle, - None, - ); + Some(completion_stats) + } - if !self.is_active_session(session_id) { - if let Some(state) = self.session_view_states.get_mut(session_id) { - state.unread_completed = true; + fn mark_running_tool_messages_failed(chat: &mut Chat, start: usize, error: &str) { + for msg in chat.messages.iter_mut().skip(start) { + if msg.role != crate::session::types::MessageRole::Tool { + continue; } - } - self.cleanup_streaming_for_session(session_id); - self.play_sound_event_with_notification_detail( - crate::sound::SoundEvent::Complete, - completion_stats.as_deref(), - ); + let Ok(mut value) = serde_json::from_str::(&msg.content) else { + continue; + }; + + let is_running = value + .get("status") + .and_then(|status| status.as_str()) + .map(|status| status == "running") + .unwrap_or(true); + + if !is_running { + continue; + } + + value["status"] = serde_json::Value::String("error".to_string()); + value["title"] = serde_json::Value::String("Tool failed".to_string()); + value["output_preview"] = serde_json::Value::String(error.to_string()); + msg.content = value.to_string(); + } } fn fail_streaming_session(&mut self, session_id: &str, error: String) { - let start = self - .streaming_boundary_for_session(session_id) - .map(|(start, _, _)| start) - .unwrap_or(0); - - if let Some(chat) = self.chat_for_session_mut(session_id) { - chat.mark_streaming_end(); - chat.finalize_streaming_metrics(); - chat.truncate_messages(start); + if self + .finalize_and_persist_streamed_messages(session_id, Some(&error)) + .is_none() + { + return; } let _ = self.session_manager.set_session_status( @@ -4640,8 +4677,8 @@ impl App { self.is_streaming = true; - // Track the message boundary for this streaming turn so we can cleanly - // roll back assistant/tool messages on failure or cancellation. + // Track the message boundary for this streaming turn so terminal paths + // can persist or roll back only the assistant/tool messages from this turn. let chat_len_before_assistant = self.chat_state.chat.messages.len(); // Capture the current model and provider at the start of streaming @@ -5172,6 +5209,84 @@ mod tests { assert!(App::can_submit_input(&input_type, false)); } + #[test] + fn failed_stream_persists_partial_messages() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Failure".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete( + "I'll inspect that file.", + )); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "running", + "args": { "path": "/private/file" }, + }) + .to_string(), + )); + + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.session_view_states.get_mut(&session_id).unwrap().stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + app.is_streaming = true; + + app.fail_streaming_session(&session_id, "Permission denied by user".to_string()); + + assert_eq!(app.chat_state.chat.messages.len(), 3); + + let session_messages = &app + .session_manager + .get_session_ref(&session_id) + .unwrap() + .messages; + assert_eq!(session_messages.len(), 3); + assert_eq!( + session_messages[1].role, + crate::session::types::MessageRole::Assistant + ); + assert!(session_messages[1].is_complete); + assert_eq!(session_messages[1].model.as_deref(), Some("test-model")); + assert_eq!( + session_messages[1].provider.as_deref(), + Some("test-provider") + ); + + let tool_payload: serde_json::Value = + serde_json::from_str(&session_messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "error"); + assert_eq!(tool_payload["output_preview"], "Permission denied by user"); + + app.fail_streaming_session(&session_id, "duplicate terminal chunk".to_string()); + + assert_eq!(app.chat_state.chat.messages.len(), 3); + assert_eq!( + app.session_manager + .get_session_ref(&session_id) + .unwrap() + .messages + .len(), + 3 + ); + } + #[test] fn chat_only_commands_are_rejected_outside_chat() { let mut app = test_app(); From 5211bfe34ff5d066859d4ca4c9899632295a3f86 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 05:03:36 +0800 Subject: [PATCH 115/226] feat: implement custom slash commands. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for custom slash commands defined in config files (`crabcode.jsonc` `command` object) or as markdown files (`.opencode/commands/*.md`, `.crabcode/commands/*.md`). Commands support: - Positional (`$1`, `$2`) and `$ARGUMENTS` placeholder substitution - Shell expansion via ``!`cmd` `` blocks - File/directory references via `@path` syntax - Frontmatter-driven `agent`, `model`, `description`, and `subtask` metadata - Layered resolution (global → project, OpenCode → crabcode) with higher priority overrides Restructure documentation: split monolithic config doc into focused pages (Overview, OpenCode Compatibility, Sounds, Theme) and update sidebar. --- README.md | 2 +- _docs/config.mdx | 167 ------- _docs/config/index.mdx | 56 +++ _docs/config/opencode-compatibility.mdx | 61 +++ _docs/config/sounds.mdx | 41 ++ _docs/config/theme.mdx | 17 + _docs/gittydocs.jsonc | 10 +- _docs/index.mdx | 13 +- _docs/quickstart.mdx | 29 +- _plans/__TODOS.md | 4 +- src/app.rs | 115 ++++- src/command/custom.rs | 590 ++++++++++++++++++++++++ src/command/handlers.rs | 3 + src/command/mod.rs | 1 + src/command/parser.rs | 48 +- src/command/registry.rs | 98 ++++ src/config/configuration.rs | 140 +++++- 17 files changed, 1199 insertions(+), 196 deletions(-) delete mode 100644 _docs/config.mdx create mode 100644 _docs/config/index.mdx create mode 100644 _docs/config/opencode-compatibility.mdx create mode 100644 _docs/config/sounds.mdx create mode 100644 _docs/config/theme.mdx create mode 100644 src/command/custom.rs diff --git a/README.md b/README.md index 10c7e77..66ef75a 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Your credentials are stored in crabcode's state directory: - Default: `~/.local/state/crabcode/auth.json` - With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/auth.json` -Read the [extensive list of configs here](/_docs/config.mdx). +Read the [configuration docs here](/_docs/config/index.mdx). ### Supported Providers diff --git a/_docs/config.mdx b/_docs/config.mdx deleted file mode 100644 index 6cd770e..0000000 --- a/_docs/config.mdx +++ /dev/null @@ -1,167 +0,0 @@ ---- -title: Configuration -description: crabcode configuration—OpenCode-compatible with a few additions. ---- - -# Configure crabcode - -crabcode is designed to be a drop-in replacement for OpenCode. It reads the same config files, uses the same merge rules, and respects most of the same settings. If you're coming from OpenCode, your existing config just works. - -The only difference: crabcode adds a couple terminal-specific settings that OpenCode doesn't have (sounds, theme selection). - -For everything else, refer to the [OpenCode configuration docs](https://opencode.ai/docs/config/). - ---- - -## Quick start - -Create a `crabcode.jsonc` in your project root: - -```jsonc -{ - "theme": "default", - "model": "openai/gpt-5.2" -} -``` - -That's it. crabcode validates on startup and tells you if something's wrong. - ---- - -## Config sources - -crabcode reads up to four files and merges them (higher priority overrides lower): - -1. **OpenCode global** – `~/.config/opencode/opencode.json(c)` -2. **crabcode global** – `~/.config/crabcode/crabcode.json(c)` -3. **OpenCode local** – `.opencode/opencode.json(c)` in your project -4. **crabcode local** – `crabcode.json(c)` or `.crabcode/crabcode.json(c)` in your project - -Set your defaults globally, override per-project when needed. - ---- - -## crabcode-specific settings - -These only work in crabcode config files. They'll be silently ignored if placed in OpenCode configs. - -### `theme` - -Sets the terminal UI theme: - -```jsonc -{ - "theme": "default" -} -``` - -crabcode uses OpenCode's theme JSON format and can load themes from OpenCode's `themes/` folders. See [OpenCode's theme docs](https://opencode.ai/docs/themes/) for how to create custom themes. - -### `sounds` - -Audio feedback for events (terminal-only, so crabcode-only): - -```jsonc -{ - "sounds": { - "complete": { "enabled": true }, - "error": { "enabled": true }, - "permission": { "enabled": false }, - "question": { "enabled": false } - } -} -``` - -Each sound event key (`complete`, `error`, `permission`, `question`) accepts either: - -- object form: `{ "enabled": true, "notify": false, "file": "/absolute/path.wav" }` -- boolean shorthand: `true`/`false` (equivalent to toggling `enabled`) - -**Custom sounds:** Provide an absolute path to a sound file: - -```jsonc -{ - "sounds": { - "complete": { - "enabled": true, - "file": "/Users/you/sounds/done.wav" - } - } -} -``` - -If `file` is omitted, `complete` and `error` use bundled defaults. `permission` and `question` stay silent by default. - -### `sounds..notify` - -Trigger native desktop notifications per sound event. - -Default: `false` for each event. - -For completion notifications, crabcode includes runtime stats when available (for example `1.0s | 30t/s`). - -```jsonc -{ - "sounds": { - "complete": { "enabled": true, "notify": true }, - "error": { "enabled": true, "notify": true } - } -} -``` - -`sounds.notify` (top-level) is no longer supported. - -On macOS, crabcode uses `osascript` (Notification Center). - ---- - -## What's supported from OpenCode - -crabcode parses and merges these OpenCode settings (some are functional now, others reserved for future implementation): - -| Setting | Status | -|---------|--------| -| `model` | ✅ Works | -| `theme` | ✅ Works (crabcode config only) | -| `sounds` | ✅ Works (crabcode-only) | -| `default_agent` | ✅ Works | -| `agent` | ⚠️ Partial (`agent..tools`, `agent..steps`) | -| `provider` | ⚠️ Partial (`provider..options.timeout` as milliseconds, or `false` to disable) | -| `instructions`, `tools`, `mcp`, `command`, `permission`, `compaction`, `watcher`, `formatter`, `disabled_providers`, `enabled_providers` | ⏳ Merged but not yet implemented | - -These OpenCode settings are intentionally ignored (they don't apply to a terminal UI): - -- `keybinds`, `share`, `tui`, `server`, `plugin` -- Custom tools (the `tool` / `tools` schema extension) - ---- - -## Variable substitution - -crabcode supports OpenCode's placeholder syntax: - -```jsonc -{ - "model": "{env:CRABCODE_DEFAULT_MODEL}", - "instructions": "{file:~/prompts/system.txt}" -} -``` - -- `{env:VAR}` – Environment variable value -- `{file:path}` – File contents. `~` expands to home; relative paths resolve from the config file's directory. - ---- - -## Troubleshooting - -**Multiple config files detected?** Each layer (global OpenCode, global crabcode, local OpenCode, local crabcode) can only have one config file. If you have both `opencode.json` and `opencode.jsonc`, crabcode errors and asks you to pick one. - -**Config changes not applying?** crabcode loads config at startup. Restart the app to pick up changes. - -**Want IDE autocomplete?** Add this to get validation in VS Code: - -```jsonc -{ - "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json" -} -``` diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx new file mode 100644 index 0000000..36b2ca7 --- /dev/null +++ b/_docs/config/index.mdx @@ -0,0 +1,56 @@ +--- +title: Overview +description: How crabcode resolves OpenCode-compatible and crabcode-native configuration. +--- + +# Configure crabcode + +crabcode is meant to fit into the same config surface you already use for OpenCode. Keep shared team config in `.opencode/`, add `.crabcode/` only when you want terminal-only behavior or a crabcode override. + +## Config resolution + +crabcode finds the project root by walking up to the nearest `.git` directory. If no `.git` directory exists, the current directory is the project root. + +| Priority | Layer | Candidate files | +| --- | --- | --- | +| 1 | OpenCode global | `~/.config/opencode/opencode.jsonc`, `~/.config/opencode/opencode.json`, `~/.config/opencode.jsonc`, `~/.config/opencode.json` | +| 2 | crabcode global | `~/.config/crabcode/crabcode.jsonc`, `~/.config/crabcode/crabcode.json`, `~/.config/crabcode.jsonc`, `~/.config/crabcode.json` | +| 3 | OpenCode project | `/.opencode/opencode.jsonc`, `/.opencode/opencode.json`, `/opencode.jsonc`, `/opencode.json` | +| 4 | crabcode project | `/.crabcode/crabcode.jsonc`, `/.crabcode/crabcode.json`, `/.opencode/crabcode.jsonc`, `/.opencode/crabcode.json`, `/crabcode.jsonc`, `/crabcode.json` | + +Higher priority layers override lower priority layers. Object values are deep-merged; `null` removes a value from a lower layer. If more than one candidate exists in the same layer, crabcode stops and asks you to keep only one. + +If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the global paths. + +## File layout + +| Platform | Directory | Root file | Rules | Commands | Agents | Skills | MCP / config | +| --- | --- | --- | --- | --- | --- | --- | --- | +| OpenCode | `.opencode/` | `AGENTS.md` | none | `.opencode/commands/` | `.opencode/agents/` | `.opencode/skills/` | `.opencode/opencode.jsonc` | +| crabcode | `.crabcode/` | `AGENTS.md` | none | `.crabcode/commands/` | `.crabcode/agents/` | `.crabcode/skills/` | `.crabcode/crabcode.jsonc` | + +## Example + +```jsonc title="crabcode.jsonc" +{ + "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json", + "model": "openai/gpt-5.2", + "theme": "crabcode-orange", + "sounds": { + "complete": { "enabled": true }, + "error": { "enabled": true } + } +} +``` + +## What belongs where + +| Need | Put it in | +| --- | --- | +| Shared OpenCode-compatible project config | `.opencode/opencode.jsonc` | +| crabcode-only project settings | `.crabcode/crabcode.jsonc` or `crabcode.jsonc` | +| Shared slash commands | `.opencode/commands/` | +| crabcode-specific command overrides | `.crabcode/commands/` | +| Personal defaults | `~/.config/crabcode/crabcode.jsonc` | + +For command syntax, frontmatter, arguments, shell output, and file references, use the [OpenCode commands docs](https://opencode.ai/docs/commands/). crabcode reads the same markdown command format. diff --git a/_docs/config/opencode-compatibility.mdx b/_docs/config/opencode-compatibility.mdx new file mode 100644 index 0000000..0a78065 --- /dev/null +++ b/_docs/config/opencode-compatibility.mdx @@ -0,0 +1,61 @@ +--- +title: OpenCode Compatibility +description: What OpenCode configuration works in crabcode and what is crabcode-specific. +--- + +# Treat it like OpenCode + +> Don't think CrabCode as another agent config to manage, just treat it like configuring OpenCode. + +Most OpenCode docs apply directly: config is JSON/JSONC, project assets live under `.opencode/`, and command files use the OpenCode markdown command format. crabcode adds a small terminal-specific layer for sounds and theme selection. + +## Compatibility map + +Blank cells mean that runtime behavior is not supported by that project today. `❌` means crabcode intentionally ignores the OpenCode setting. + +| Area / setting | OpenCode | crabcode | Notes | +| --- | --- | --- | --- | +| `model` | ✅ | ✅ | Default model when no persisted active model is set. | +| `default_agent` | ✅ | ✅ | Selects the startup agent mode. | +| `command` config | ✅ | ✅ | JSON-defined slash commands with `template`, `description`, `agent`, `model`, and `subtask`. | +| `.opencode/commands/*.md` | ✅ | ✅ | Markdown command files, including nested names. | +| `.crabcode/commands/*.md` | | ✅ | crabcode-specific command overrides. | +| `$ARGUMENTS`, `$1`, `$2` | ✅ | ✅ | The final positional placeholder consumes the rest of the arguments. | +| ``!`command` `` in command files | ✅ | ✅ | Runs from the project root and injects command output. | +| `@path` file references in commands | ✅ | ✅ | File or directory content is appended to the rendered prompt. | +| `AGENTS.md` project instructions | ✅ | ✅ | crabcode walks up from the working directory and prefers `AGENTS.md` over `CLAUDE.md` in the same directory. | +| `~/.config/crabcode/AGENTS.md` | | ✅ | crabcode global instructions. | +| Claude Code fallback rules | ✅ | ✅ | `CLAUDE.md` and `~/.claude/CLAUDE.md` are read unless disabled by crabcode environment flags. | +| Skills | ✅ | ✅ | Reads `SKILL.md` files from OpenCode, crabcode, Claude, and `.agents` skill roots. | +| `agent..tools` | ✅ | partial | crabcode supports tool allowlists for configured agents. | +| `agent..steps` | ✅ | partial | crabcode supports `steps`; `maxSteps` is not supported. | +| Markdown agent files | ✅ | | Discovered for diagnostics, not applied as runtime agent definitions yet. | +| `provider..options.timeout` | ✅ | partial | Integer milliseconds or `false` to disable timeout. | +| `theme` | ✅ | ✅ | In crabcode config files only. OpenCode config `theme` is ignored by crabcode. | +| `sounds` | | ✅ | crabcode-specific terminal audio and notifications. | +| `mcp` | ✅ | | Accepted at the top level for forward compatibility, not wired to tools yet. | +| `permission` | ✅ | | Accepted at the top level, not enforced from config yet. | +| `instructions` | ✅ | | Accepted at the top level, but config-driven instruction files are not loaded yet. | +| `tools` | ✅ | | Accepted at the top level, not used as global tool config yet. | +| `compaction` | ✅ | | Accepted at the top level, not used as config yet. | +| `watcher` | ✅ | | Accepted at the top level, not used as config yet. | +| `formatter` | ✅ | | Accepted at the top level, not used as config yet. | +| `disabled_providers` | ✅ | | Accepted at the top level, not applied yet. | +| `enabled_providers` | ✅ | | Accepted at the top level, not applied yet. | +| `keybinds` | ✅ | ❌ | Ignored because crabcode does not use OpenCode keybind config. | +| `share` | ✅ | ❌ | Ignored. | +| `tui` | ✅ | ❌ | Ignored. crabcode owns its terminal UI. | +| `server` | ✅ | ❌ | Ignored. | +| `plugin` | ✅ | ❌ | Ignored. | +| custom tools (`tool`, `custom_tools`, `customTools`) | ✅ | ❌ | Ignored. | + +## Use the OpenCode docs + +| Topic | Reference | +| --- | --- | +| Config shape | [OpenCode config](https://opencode.ai/docs/config/) | +| Commands | [OpenCode commands](https://opencode.ai/docs/commands/) | +| Rules | [OpenCode rules](https://opencode.ai/docs/rules/) | +| Agents | [OpenCode agents](https://opencode.ai/docs/agents/) | +| MCP | [OpenCode MCP servers](https://opencode.ai/docs/mcp/) | +| Themes | [OpenCode themes](https://opencode.ai/docs/themes/) | diff --git a/_docs/config/sounds.mdx b/_docs/config/sounds.mdx new file mode 100644 index 0000000..a241af3 --- /dev/null +++ b/_docs/config/sounds.mdx @@ -0,0 +1,41 @@ +--- +title: Sounds +description: Configure crabcode sounds and per-event notifications. +--- + +# Sounds + +Sounds are crabcode-specific and apply only to `crabcode` config files. + +```jsonc title="crabcode.jsonc" +{ + "sounds": { + "complete": { + "enabled": true, + "notify": true, + "file": "/abs/path/to/complete.wav", + }, + "error": { + "enabled": true, + "notify": true, + "file": "/abs/path/to/error.wav", + }, + "permission": { + "enabled": false, + "notify": false, + "file": "/abs/path/to/permission.wav", + }, + "question": { + "enabled": false, + "notify": false, + "file": "/abs/path/to/question.wav", + }, + }, +} +``` + +`file` is required for custom sounds and must be an absolute path. + +- `complete` / `error` default to enabled and use bundled sounds when no `file` is set. +- `permission` / `question` default to disabled. +- `notify` is only per-event (`sounds.complete.notify`, etc.); `sounds.notify` is not supported. On macOS, notifications use Notification Center through `osascript`; on Linux, they use the available desktop notification backend. diff --git a/_docs/config/theme.mdx b/_docs/config/theme.mdx new file mode 100644 index 0000000..a546fbb --- /dev/null +++ b/_docs/config/theme.mdx @@ -0,0 +1,17 @@ +--- +title: Theme +description: Theme config in crabcode. +--- + +# Theme + +Theme support is mostly OpenCode-compatible: you can use `theme` IDs and OpenCode-style theme files. + +The one difference is intentional: `theme` is **not** cross-merged from OpenCode files like `.opencode/opencode.jsonc` (or equivalent local/global variants). Crabcode only reads theme settings from its own crabcode config files. + +Reasons: +1. `theme` is a terminal rendering concern in crabcode, while OpenCode theme settings may include desktop-only assumptions. +2. Keeping only crabcode-owned theme config makes precedence predictable for users. +3. It avoids accidental overrides when existing OpenCode configs are shared across tools. + +For theme file format and naming, use the [OpenCode theme docs](https://opencode.ai/docs/themes/). diff --git a/_docs/gittydocs.jsonc b/_docs/gittydocs.jsonc index 4e27fc6..e51833d 100644 --- a/_docs/gittydocs.jsonc +++ b/_docs/gittydocs.jsonc @@ -18,7 +18,15 @@ "items": [ { "label": "Overview", "path": "/" }, { "label": "Quickstart", "path": "/quickstart" }, - { "label": "Configuration", "path": "/config" }, + ], + }, + { + "label": "Configuration", + "items": [ + { "label": "Overview", "path": "/config" }, + { "label": "OpenCode Compatibility", "path": "/config/opencode-compatibility" }, + { "label": "Sounds", "path": "/config/sounds" }, + { "label": "Theme", "path": "/config/theme" }, ], }, ], diff --git a/_docs/index.mdx b/_docs/index.mdx index 2df320f..8ee5067 100644 --- a/_docs/index.mdx +++ b/_docs/index.mdx @@ -12,10 +12,11 @@ description: A fast, terminal-first coding agent built in Rust. This project was literally just "what if I built opencode, in rust?". Same UX, no context switching, no heavy IDE—just fast startup, keyboard-driven workflows, and the AI models you already use. ```bash -cargo install crabcode # via cargo -npm install -g crabcode # via npm -bun install -g crabcode # via bun -# Brew, coming soon +npm install -g crabcode # npm +bun install -g crabcode # or bun +cargo binstall crabcode # or cargo-binstall (prebuilt binary, faster) +cargo install crabcode # or cargo (build from source) +curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) ``` --- @@ -30,12 +31,12 @@ bun install -g crabcode # via bun crabcode is a focused, terminal-only coding agent—just the TUI, built in Rust for speed and memory-efficiency. OpenCode gives you options across multiple platforms; crabcode picks one and does it well. -**Coming from OpenCode?** Your existing config at `~/.config/opencode/config.json` is automatically picked up. +**Coming from OpenCode?** Your existing config at `~/.config/opencode/opencode.jsonc` is automatically picked up. --- ## Where to next - [Quickstart](/quickstart) – Get up and running in 5 minutes -- [Configuration](/config) – OpenCode-compatible config with a few additions +- [Configuration](/config) – OpenCode-compatible config, sounds, and themes - [GitHub](https://github.com/blankeos/crabcode) – Source code and issues diff --git a/_docs/quickstart.mdx b/_docs/quickstart.mdx index d032e4f..a08df2b 100644 --- a/_docs/quickstart.mdx +++ b/_docs/quickstart.mdx @@ -12,14 +12,13 @@ Five minutes from now you'll be pair programming with AI in your terminal. ## Install ```bash -cargo install crabcode # via cargo -npm install -g crabcode # via npm -bun install -g crabcode # via bun -# Brew, coming sooon +npm install -g crabcode # npm +bun install -g crabcode # or bun +cargo binstall crabcode # or cargo-binstall (prebuilt binary, faster) +cargo install crabcode # or cargo (build from source) +curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) ``` -**Homebrew:** Coming soon - --- ## First run @@ -32,6 +31,8 @@ crabcode Run `/connect` to add your first provider (OpenAI, Anthropic, etc.). That's it—you're ready. +🎉 That's it! If you know how to use OpenCode, you're pretty much good to go! + --- ## Your first session @@ -45,6 +46,8 @@ crabcode works best when you type naturally, like you're pair programming: Press `?` anytime to see available commands. +Custom slash commands can live in `.opencode/commands/` or `.crabcode/commands/`. crabcode reads the [OpenCode command format](https://opencode.ai/docs/commands/). + --- ## Configure crabcode @@ -66,9 +69,9 @@ crabcode uses JSONC (JSON with comments). Create a `crabcode.jsonc` in your proj crabcode reads up to four files (highest priority wins): -1. OpenCode global (`~/.config/opencode/config.json`) +1. OpenCode global (`~/.config/opencode/opencode.jsonc`) 2. crabcode global (`~/.config/crabcode/crabcode.jsonc`) -3. OpenCode local (`.opencode/opencode.json` in your project) +3. OpenCode local (`.opencode/opencode.jsonc` in your project) 4. crabcode local (`crabcode.jsonc` in your project) Set your defaults globally, override per-project when needed. See the [full configuration reference](/config). @@ -91,9 +94,9 @@ Once you're in crabcode: ## Where your data lives -| What | Default | With `XDG_STATE_HOME` | -| ------------------------ | ------------------------------------------------- | --------------------------------------------- | -| Credentials | `~/.local/state/crabcode/auth.json` | `$XDG_STATE_HOME/crabcode/auth.json` | -| Preferences and Sessions | `~/.local/state/crabcode/data.db` | `$XDG_STATE_HOME/crabcode/data.db` | +| What | Default | With `XDG_STATE_HOME` | +| ------------------------ | ----------------------------------------------------- | ------------------------------------------------------ | +| Credentials | `~/.local/state/crabcode/auth.json` | `$XDG_STATE_HOME/crabcode/auth.json` | +| Preferences and Sessions | `~/.local/state/crabcode/data.db` | `$XDG_STATE_HOME/crabcode/data.db` | | Model cache | `~/.local/state/crabcode/cache/models_dev_cache.json` | `$XDG_STATE_HOME/crabcode/cache/models_dev_cache.json` | -| Sounds | `~/.local/state/crabcode/sounds` | `$XDG_STATE_HOME/crabcode/sounds` | +| Sounds | `~/.local/state/crabcode/sounds` | `$XDG_STATE_HOME/crabcode/sounds` | diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 4ce5f1e..8464748 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -100,8 +100,8 @@ - [x] Reasoning effort adjustment in /models. Or a hotkey? In opencode it's ctrl-t. -- [ ] /commands and custom commands. +- [x] /commands and custom commands. -- [ ] Read my <> (ask for permission), deny. The chat doesn't get persisted, just gone. Please save everything before errors. So we can easily say "continue" +- [x] Read my <> (ask for permission), deny. The chat doesn't get persisted, just gone. Please save everything before errors. So we can easily say "continue" - [ ] wysiwyg double escape to G diff --git a/src/app.rs b/src/app.rs index fa0ee31..5628bcc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -249,10 +249,9 @@ impl App { let mut registry = Registry::new(); register_all_commands(&mut registry); - let autocomplete = AutoComplete::new(crate::autocomplete::CommandAuto::new(®istry)); let placeholder = Self::get_random_placeholder(); let placeholder_static: &'static str = Box::leak(placeholder.into_boxed_str()); - let mut input = Input::new().with_autocomplete(autocomplete); + let mut input = Input::new(); input.set_placeholder(placeholder_static); let cwd_path = crate::utils::cwd::current_dir()?; @@ -308,7 +307,13 @@ impl App { } crate::skill::init_skill_store(&loaded_config.xdg_config_home, &loaded_config.project_root); + for command in loaded_config.merged_config.commands.clone() { + registry.register_custom(command); + } crate::command::handlers::register_skill_commands(&mut registry); + input.autocomplete = Some(AutoComplete::new(crate::autocomplete::CommandAuto::new( + ®istry, + ))); if let Some(default_agent) = loaded_config.merged_config.default_agent.clone() { if !default_agent.trim().is_empty() { @@ -2794,6 +2799,32 @@ impl App { match parse_input(input) { InputType::Command(mut parsed) => { + if self.command_registry.is_custom_command(&parsed.name) { + parsed.prefs_dao = self.prefs_dao.as_ref(); + parsed.active_model_id = Some(self.model.clone()); + let result = self + .command_registry + .execute(&parsed, &mut self.session_manager) + .await; + match result { + crate::command::registry::CommandResult::RunPrompt { + prompt, + agent, + model, + subtask, + } => self.run_custom_command_prompt(prompt, agent, model, subtask), + crate::command::registry::CommandResult::Error(msg) => { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + msg, + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + _ => {} + } + return; + } if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { self.copy_session_transcript(); return; @@ -2909,6 +2940,12 @@ impl App { self.chat_state.chat.add_assistant_message(error_msg); } } + crate::command::registry::CommandResult::RunPrompt { + prompt, + agent, + model, + subtask, + } => self.run_custom_command_prompt(prompt, agent, model, subtask), crate::command::registry::CommandResult::ShowDialog { title, items } => { if title == "Connect a provider" { let dialog_items: Vec = @@ -2970,6 +3007,32 @@ impl App { &mut self, mut parsed: crate::command::parser::ParsedCommand<'_>, ) { + if self.command_registry.is_custom_command(&parsed.name) { + parsed.prefs_dao = self.prefs_dao.as_ref(); + parsed.active_model_id = Some(self.model.clone()); + let result = self + .command_registry + .execute(&parsed, &mut self.session_manager) + .await; + match result { + crate::command::registry::CommandResult::RunPrompt { + prompt, + agent, + model, + subtask, + } => self.run_custom_command_prompt(prompt, agent, model, subtask), + crate::command::registry::CommandResult::Error(msg) => { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + msg, + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + _ => {} + } + return; + } if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { self.copy_session_transcript(); return; @@ -3079,6 +3142,12 @@ impl App { self.chat_state.chat.add_assistant_message(error_msg); } } + crate::command::registry::CommandResult::RunPrompt { + prompt, + agent, + model, + subtask, + } => self.run_custom_command_prompt(prompt, agent, model, subtask), crate::command::registry::CommandResult::ShowDialog { title, items } => { if title == "Connect a provider" { let dialog_items: Vec = items @@ -4797,6 +4866,48 @@ impl App { self.handle_message_input_with_images(msg, Vec::new()); } + fn run_custom_command_prompt( + &mut self, + prompt: String, + agent: Option, + model: Option, + _subtask: Option, + ) { + if prompt.trim().is_empty() { + return; + } + + if self.is_streaming { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Cannot run a custom command while streaming", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + let previous_agent = self.agent.clone(); + let previous_model = self.model.clone(); + let previous_provider = self.provider_name.clone(); + + if let Some(agent) = agent.filter(|value| !value.trim().is_empty()) { + self.agent = agent; + } + + if let Some(model) = model.filter(|value| !value.trim().is_empty()) { + let (provider_id, model_id) = parse_model_ref(&model); + self.provider_name = provider_id; + self.model = model_id; + } + + self.handle_message_input(prompt); + + self.agent = previous_agent; + self.model = previous_model; + self.provider_name = previous_provider; + } + fn handle_message_input_with_images( &mut self, msg: String, diff --git a/src/command/custom.rs b/src/command/custom.rs new file mode 100644 index 0000000..0e60b0c --- /dev/null +++ b/src/command/custom.rs @@ -0,0 +1,590 @@ +use anyhow::{Context, Result}; +use regex::{Captures, Regex}; +use serde::Deserialize; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::process::Command as TokioCommand; + +const SHELL_TIMEOUT_SECONDS: u64 = 30; +const MAX_SHELL_OUTPUT_BYTES: usize = 51200; +const MAX_REFERENCED_FILE_BYTES: usize = 51200; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CustomCommandSource { + Config(PathBuf), + File(PathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomCommand { + pub name: String, + pub description: Option, + pub agent: Option, + pub model: Option, + pub subtask: Option, + pub template: String, + pub source: CustomCommandSource, + pub workdir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedCommand { + pub prompt: String, + pub agent: Option, + pub model: Option, + pub subtask: Option, +} + +impl CustomCommand { + pub async fn render(&self, raw_args: &str) -> Result { + let prompt = apply_arguments(&self.template, raw_args); + let prompt = expand_shell_blocks(&prompt, &self.workdir).await?; + let prompt = append_file_references(&prompt, &self.workdir); + + Ok(RenderedCommand { + prompt: prompt.trim().to_string(), + agent: self.agent.clone(), + model: self.model.clone(), + subtask: self.subtask, + }) + } +} + +pub fn commands_from_config_value( + value: &Value, + source_path: &Path, + workdir: &Path, + warnings: &mut Vec, +) -> Vec { + let Some(commands) = value.as_object() else { + warnings.push(format!( + "command in {} must be an object", + source_path.display() + )); + return Vec::new(); + }; + + let mut out = Vec::new(); + for (name, value) in commands { + let Some(obj) = value.as_object() else { + warnings.push(format!( + "command.{} in {} must be an object", + name, + source_path.display() + )); + continue; + }; + + let Some(template) = obj.get("template").and_then(|v| v.as_str()) else { + warnings.push(format!( + "command.{} in {} must include a string template", + name, + source_path.display() + )); + continue; + }; + + let name = name.trim(); + if name.is_empty() { + warnings.push(format!( + "command in {} has an empty name", + source_path.display() + )); + continue; + } + + let command = CustomCommand { + name: normalize_command_name(name), + description: optional_string(obj.get("description")), + agent: optional_string(obj.get("agent")), + model: optional_string(obj.get("model")), + subtask: obj.get("subtask").and_then(|v| v.as_bool()), + template: template.trim().to_string(), + source: CustomCommandSource::Config(source_path.to_path_buf()), + workdir: workdir.to_path_buf(), + }; + out.push(command); + } + + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +pub fn commands_from_directory( + dir: &Path, + workdir: &Path, + warnings: &mut Vec, +) -> Vec { + let mut out = Vec::new(); + let mut files = list_command_files(dir); + files.sort(); + files.dedup(); + + for path in files { + match command_from_file(dir, &path, workdir) { + Ok(Some(command)) => out.push(command), + Ok(None) => {} + Err(err) => warnings.push(format!( + "Failed to load command file {}: {}", + path.display(), + err + )), + } + } + + out +} + +fn optional_string(value: Option<&Value>) -> Option { + value + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) +} + +fn list_command_files(dir: &Path) -> Vec { + if !dir.is_dir() { + return Vec::new(); + } + + let mut out = Vec::new(); + for subdir in ["command", "commands"] { + let pattern = dir + .join(subdir) + .join("**") + .join("*.md") + .to_string_lossy() + .to_string(); + let Ok(entries) = glob::glob(&pattern) else { + continue; + }; + for entry in entries.flatten() { + if entry.is_file() { + out.push(entry); + } + } + } + out +} + +fn command_from_file( + config_dir: &Path, + path: &Path, + workdir: &Path, +) -> Result> { + let content = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + let (frontmatter, body) = split_frontmatter(&content); + let data = parse_frontmatter(&frontmatter)?; + let template = body.trim(); + if template.is_empty() { + return Ok(None); + } + + let Some(name) = command_name_from_path(config_dir, path) else { + return Ok(None); + }; + + Ok(Some(CustomCommand { + name, + description: data.description, + agent: data.agent, + model: data.model, + subtask: data.subtask, + template: template.to_string(), + source: CustomCommandSource::File(path.to_path_buf()), + workdir: workdir.to_path_buf(), + })) +} + +#[derive(Debug, Default, Deserialize)] +struct Frontmatter { + description: Option, + agent: Option, + model: Option, + subtask: Option, +} + +fn parse_frontmatter(frontmatter: &str) -> Result { + if frontmatter.trim().is_empty() { + return Ok(Frontmatter::default()); + } + + match serde_yaml::from_str(frontmatter) { + Ok(data) => Ok(data), + Err(_) => { + let sanitized = fallback_sanitize_yaml(frontmatter); + serde_yaml::from_str(&sanitized).context("Invalid YAML frontmatter") + } + } +} + +fn split_frontmatter(content: &str) -> (String, String) { + if let Some(rest) = content.strip_prefix("---\n") { + if let Some((frontmatter, body)) = rest.split_once("\n---") { + return (frontmatter.to_string(), body.trim_start().to_string()); + } + } + + if let Some(rest) = content.strip_prefix("---\r\n") { + if let Some((frontmatter, body)) = rest.split_once("\r\n---") { + return (frontmatter.to_string(), body.trim_start().to_string()); + } + } + + (String::new(), content.to_string()) +} + +fn fallback_sanitize_yaml(frontmatter: &str) -> String { + let mut result = String::new(); + + for line in frontmatter.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') || trimmed.is_empty() { + result.push_str(line); + result.push('\n'); + continue; + } + + if line.starts_with(' ') || line.starts_with('\t') { + result.push_str(line); + result.push('\n'); + continue; + } + + let Some((key, value)) = trimmed.split_once(':') else { + result.push_str(line); + result.push('\n'); + continue; + }; + + let value = value.trim(); + if value.is_empty() + || value == ">" + || value == "|" + || value.starts_with('"') + || value.starts_with('\'') + { + result.push_str(line); + result.push('\n'); + continue; + } + + if value.contains(':') { + result.push_str(key); + result.push_str(": |-\n "); + result.push_str(value); + result.push('\n'); + continue; + } + + result.push_str(line); + result.push('\n'); + } + + result +} + +fn command_name_from_path(config_dir: &Path, path: &Path) -> Option { + for subdir in ["command", "commands"] { + let root = config_dir.join(subdir); + if let Ok(relative) = path.strip_prefix(&root) { + let mut without_ext = relative.to_path_buf(); + without_ext.set_extension(""); + let name = without_ext.to_string_lossy().replace('\\', "/"); + let name = normalize_command_name(&name); + if !name.is_empty() { + return Some(name); + } + } + } + None +} + +fn normalize_command_name(name: &str) -> String { + name.trim().trim_start_matches('/').replace('\\', "/") +} + +fn apply_arguments(template: &str, raw_args: &str) -> String { + let args = parse_raw_arguments(raw_args); + let placeholder_re = Regex::new(r"\$(\d+)").expect("valid placeholder regex"); + let placeholders: Vec = placeholder_re + .captures_iter(template) + .filter_map(|caps| caps.get(1)?.as_str().parse::().ok()) + .collect(); + let last_placeholder = placeholders.iter().copied().max().unwrap_or(0); + let has_positional_placeholders = !placeholders.is_empty(); + let has_arguments_placeholder = template.contains("$ARGUMENTS"); + + let with_positionals = placeholder_re + .replace_all(template, |caps: &Captures<'_>| { + let position = caps + .get(1) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + if position == 0 { + return String::new(); + } + let arg_index = position - 1; + if arg_index >= args.len() { + return String::new(); + } + if position == last_placeholder { + args[arg_index..].join(" ") + } else { + args[arg_index].clone() + } + }) + .to_string(); + + let raw_args = raw_args.trim(); + let mut out = with_positionals.replace("$ARGUMENTS", raw_args); + if !has_positional_placeholders && !has_arguments_placeholder && !raw_args.is_empty() { + out.push_str("\n\n"); + out.push_str(raw_args); + } + out +} + +fn parse_raw_arguments(raw_args: &str) -> Vec { + if let Some(args) = shlex::split(raw_args) { + return args; + } + + let re = + Regex::new(r#"(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)"#).expect("valid args regex"); + re.find_iter(raw_args) + .map(|m| trim_wrapping_quotes(m.as_str()).to_string()) + .collect() +} + +fn trim_wrapping_quotes(s: &str) -> &str { + let bytes = s.as_bytes(); + if bytes.len() >= 2 + && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')) + { + &s[1..s.len() - 1] + } else { + s + } +} + +async fn expand_shell_blocks(template: &str, workdir: &Path) -> Result { + let re = Regex::new(r"!`([^`]+)`").expect("valid shell regex"); + let mut out = String::new(); + let mut last = 0usize; + + for caps in re.captures_iter(template) { + let Some(full) = caps.get(0) else { + continue; + }; + let Some(command) = caps.get(1).map(|m| m.as_str()) else { + continue; + }; + + out.push_str(&template[last..full.start()]); + out.push_str(&run_shell_block(command, workdir).await?); + last = full.end(); + } + + out.push_str(&template[last..]); + Ok(out) +} + +async fn run_shell_block(command: &str, workdir: &Path) -> Result { + let mut child = TokioCommand::new("bash"); + child.arg("-c").arg(command).current_dir(workdir); + + let output = tokio::time::timeout(Duration::from_secs(SHELL_TIMEOUT_SECONDS), child.output()) + .await + .with_context(|| { + format!( + "Command timed out after {} seconds: {}", + SHELL_TIMEOUT_SECONDS, command + ) + })? + .with_context(|| format!("Failed to run command: {}", command))?; + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&output.stdout); + if !output.stderr.is_empty() { + if !bytes.is_empty() { + bytes.extend_from_slice(b"\n"); + } + bytes.extend_from_slice(&output.stderr); + } + + if bytes.len() > MAX_SHELL_OUTPUT_BYTES { + bytes.truncate(MAX_SHELL_OUTPUT_BYTES); + bytes.extend_from_slice(b"\n[Output truncated]"); + } + + Ok(String::from_utf8_lossy(&bytes).to_string()) +} + +fn append_file_references(template: &str, workdir: &Path) -> String { + let re = Regex::new(r"(^|[^\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)") + .expect("valid file reference regex"); + let mut seen = std::collections::HashSet::new(); + let mut references = Vec::new(); + + for caps in re.captures_iter(template) { + let Some(name) = caps.get(2).map(|m| m.as_str()) else { + continue; + }; + if name.is_empty() || !seen.insert(name.to_string()) { + continue; + } + let path = resolve_reference_path(name, workdir); + if path.is_file() { + if let Ok(mut content) = fs::read_to_string(&path) { + if content.len() > MAX_REFERENCED_FILE_BYTES { + content.truncate(MAX_REFERENCED_FILE_BYTES); + content.push_str("\n[File truncated]"); + } + references.push(format!("\n{}\n", name, content)); + } + } else if path.is_dir() { + let listing = fs::read_dir(&path) + .ok() + .map(|entries| { + let mut names = entries + .flatten() + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .collect::>(); + names.sort(); + names.join("\n") + }) + .unwrap_or_default(); + if !listing.is_empty() { + references.push(format!( + "\n{}\n", + name, listing + )); + } + } + } + + if references.is_empty() { + template.to_string() + } else { + format!( + "{}\n\nReferenced files:\n{}", + template.trim_end(), + references.join("\n\n") + ) + } +} + +fn resolve_reference_path(name: &str, workdir: &Path) -> PathBuf { + if let Some(rest) = name.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + + let path = PathBuf::from(name); + if path.is_absolute() { + path + } else { + workdir.join(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_apply_arguments_replaces_arguments_placeholder() { + let result = apply_arguments("Build $ARGUMENTS", "Button primary"); + assert_eq!(result, "Build Button primary"); + } + + #[test] + fn test_apply_arguments_replaces_positionals_and_last_consumes_rest() { + let result = apply_arguments("Create $1 with $2", "file.rs src/lib extra"); + assert_eq!(result, "Create file.rs with src/lib extra"); + } + + #[test] + fn test_apply_arguments_appends_args_when_template_has_no_placeholders() { + let result = apply_arguments("Review this", "main branch"); + assert_eq!(result, "Review this\n\nmain branch"); + } + + #[test] + fn test_append_file_references_includes_file_content() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("example.txt"), "hello").unwrap(); + + let result = append_file_references("Review @example.txt", temp.path()); + + assert!(result.contains("Referenced files:")); + assert!(result.contains("")); + assert!(result.contains("hello")); + } + + #[test] + fn test_parse_raw_arguments_preserves_quoted_segments() { + let args = parse_raw_arguments(r#"config.json src "{ \"key\": \"value\" }""#); + assert_eq!(args, vec!["config.json", "src", r#"{ "key": "value" }"#]); + } + + #[test] + fn test_commands_from_config_value() { + let value = json!({ + "test": { + "template": "Run tests", + "description": "Run the test suite", + "agent": "build", + "model": "openai/gpt-5", + "subtask": true + } + }); + let mut warnings = Vec::new(); + let commands = commands_from_config_value( + &value, + Path::new("/tmp/opencode.json"), + Path::new("/workspace"), + &mut warnings, + ); + + assert!(warnings.is_empty()); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].name, "test"); + assert_eq!( + commands[0].description.as_deref(), + Some("Run the test suite") + ); + assert_eq!(commands[0].agent.as_deref(), Some("build")); + assert_eq!(commands[0].model.as_deref(), Some("openai/gpt-5")); + assert_eq!(commands[0].subtask, Some(true)); + } + + #[test] + fn test_commands_from_directory_supports_plural_and_nested_names() { + let temp = tempfile::tempdir().unwrap(); + let commands_dir = temp.path().join("commands").join("team"); + fs::create_dir_all(&commands_dir).unwrap(); + fs::write( + commands_dir.join("review.md"), + "---\ndescription: Review changes\nagent: build\n---\nReview $ARGUMENTS", + ) + .unwrap(); + + let mut warnings = Vec::new(); + let commands = commands_from_directory(temp.path(), temp.path(), &mut warnings); + + assert!(warnings.is_empty()); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].name, "team/review"); + assert_eq!(commands[0].description.as_deref(), Some("Review changes")); + assert_eq!(commands[0].template, "Review $ARGUMENTS"); + } +} diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 41facf4..3115b24 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -485,6 +485,9 @@ pub fn handle_skill_command<'a>( pub fn register_skill_commands(registry: &mut Registry) { if let Some(store) = crate::skill::get_skill_store() { for skill in store.all() { + if registry.has_public_command(&skill.name) { + continue; + } registry.register(Command { name: skill.name.clone(), description: skill.description.clone().unwrap_or_default(), diff --git a/src/command/mod.rs b/src/command/mod.rs index bf9bdd0..f061c61 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -1,3 +1,4 @@ +pub mod custom; pub mod handlers; pub mod parser; pub mod registry; diff --git a/src/command/parser.rs b/src/command/parser.rs index 02a711a..d29db7a 100644 --- a/src/command/parser.rs +++ b/src/command/parser.rs @@ -7,6 +7,18 @@ pub struct ParsedCommand<'a> { pub active_model_id: Option, } +impl<'a> ParsedCommand<'a> { + pub fn raw_args(&self) -> &str { + let Some(without_slash) = self.raw.trim().strip_prefix('/') else { + return ""; + }; + let without_name = without_slash + .strip_prefix(&self.name) + .unwrap_or(without_slash); + without_name.trim_start() + } +} + impl<'a> PartialEq for ParsedCommand<'a> { fn eq(&self, other: &Self) -> bool { self.name == other.name && self.args == other.args @@ -33,14 +45,19 @@ pub fn parse_input(input: &str) -> InputType { fn parse_command(input: &str) -> Option { let without_slash = input.strip_prefix('/')?; - let parts: Vec<&str> = without_slash.split_whitespace().collect(); + let parts = shlex::split(without_slash).unwrap_or_else(|| { + without_slash + .split_whitespace() + .map(ToOwned::to_owned) + .collect() + }); if parts.is_empty() { return None; } let name = parts[0].to_string(); - let args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + let args: Vec = parts[1..].to_vec(); Some(ParsedCommand { name, @@ -103,6 +120,33 @@ mod tests { ); } + #[test] + fn test_parse_command_with_quoted_args() { + let input = r#"/create-file config.json src "{ \"key\": \"value\" }""#; + let result = parse_command(input); + assert_eq!( + result, + Some(ParsedCommand { + name: "create-file".to_string(), + args: vec![ + "config.json".to_string(), + "src".to_string(), + r#"{ "key": "value" }"#.to_string() + ], + raw: input.to_string(), + prefs_dao: None, + active_model_id: None, + }) + ); + } + + #[test] + fn test_raw_args_preserves_user_text_after_command_name() { + let input = r#"/test "quoted arg" plain"#; + let result = parse_command(input).unwrap(); + assert_eq!(result.raw_args(), r#""quoted arg" plain"#); + } + #[test] fn test_parse_command_empty() { let input = "/"; diff --git a/src/command/registry.rs b/src/command/registry.rs index 2e72308..28152d1 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -22,6 +22,12 @@ pub struct Command { pub enum CommandResult { Success(String), Error(String), + RunPrompt { + prompt: String, + agent: Option, + model: Option, + subtask: Option, + }, ShowDialog { title: String, items: Vec, @@ -40,12 +46,14 @@ pub struct DialogItem { pub struct Registry { commands: HashMap, + custom_commands: HashMap, } impl Registry { pub fn new() -> Self { Self { commands: HashMap::new(), + custom_commands: HashMap::new(), } } @@ -53,6 +61,28 @@ impl Registry { self.commands.insert(command.name.clone(), command); } + pub fn register_custom(&mut self, command: crate::command::custom::CustomCommand) { + self.commands.insert( + command.name.clone(), + Command { + name: command.name.clone(), + description: command.description.clone().unwrap_or_default(), + handler: handle_custom_command, + hidden_tokens: vec![], + chat_only: false, + }, + ); + self.custom_commands.insert(command.name.clone(), command); + } + + pub fn has_public_command(&self, name: &str) -> bool { + self.commands.contains_key(name) + } + + pub fn is_custom_command(&self, name: &str) -> bool { + self.custom_commands.contains_key(name) + } + pub fn get(&self, name: &str) -> Option<&Command> { if let Some(cmd) = self.commands.get(name) { return Some(cmd); @@ -75,6 +105,21 @@ impl Registry { parsed: &'a ParsedCommand<'a>, session_manager: &'a mut SessionManager, ) -> CommandResult { + if let Some(command) = self.custom_commands.get(&parsed.name) { + return match command.render(parsed.raw_args()).await { + Ok(rendered) => CommandResult::RunPrompt { + prompt: rendered.prompt, + agent: rendered.agent, + model: rendered.model, + subtask: rendered.subtask, + }, + Err(err) => CommandResult::Error(format!( + "Failed to render command {}: {}", + parsed.name, err + )), + }; + } + if let Some(command) = self.get(&parsed.name) { (command.handler)(parsed, session_manager).await } else { @@ -93,6 +138,14 @@ impl Registry { } } +fn handle_custom_command<'a>( + parsed: &'a ParsedCommand<'a>, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let name = parsed.name.clone(); + Box::pin(async move { CommandResult::Error(format!("Unknown command: {}", name)) }) +} + impl Default for Registry { fn default() -> Self { Self::new() @@ -252,6 +305,51 @@ mod tests { ); } + #[tokio::test] + async fn test_custom_command_overrides_registered_command() { + let mut registry = Registry::new(); + registry.register(Command { + name: "test".to_string(), + description: "Built in test".to_string(), + handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, + }); + registry.register_custom(crate::command::custom::CustomCommand { + name: "test".to_string(), + description: Some("Custom test".to_string()), + agent: Some("build".to_string()), + model: Some("openai/gpt-5".to_string()), + subtask: Some(false), + template: "Run $ARGUMENTS".to_string(), + source: crate::command::custom::CustomCommandSource::Config(std::path::PathBuf::from( + "/tmp/opencode.json", + )), + workdir: std::path::PathBuf::from("."), + }); + + let parsed = ParsedCommand { + name: "test".to_string(), + args: vec!["unit".to_string()], + raw: "/test unit".to_string(), + prefs_dao: None, + active_model_id: None, + }; + let mut session_manager = SessionManager::new(); + let result = registry.execute(&parsed, &mut session_manager).await; + + assert_eq!( + result, + CommandResult::RunPrompt { + prompt: "Run unit".to_string(), + agent: Some("build".to_string()), + model: Some("openai/gpt-5".to_string()), + subtask: Some(false), + } + ); + assert_eq!(registry.get("test").unwrap().description, "Custom test"); + } + #[test] fn test_list_commands() { let mut registry = Registry::new(); diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 375149a..f01779a 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -125,6 +125,7 @@ pub struct ConfigDiagnostics { pub struct ConfigInventory { pub opencode_agents: Vec, pub opencode_skills_dirs: Vec, + pub command_files: Vec, } #[derive(Debug, Clone, Default)] @@ -180,6 +181,7 @@ pub struct MergedConfig { pub theme: Option, pub model: Option, pub default_agent: Option, + pub commands: Vec, pub agent_tool_policies: HashMap>, pub agent_steps: HashMap, pub provider_timeouts: HashMap, @@ -221,7 +223,7 @@ impl ConfigLoader { let mut provenance: HashMap = HashMap::new(); provenance.insert("".to_string(), cwd.clone()); - for source in sources { + for source in &sources { let parsed = match load_config_value(&source.path) { Ok(v) => v, Err(e) => { @@ -252,7 +254,15 @@ impl ConfigLoader { substitute_placeholders(&mut merged, &provenance, &mut diagnostics); - let merged_config = parse_merged_config(&merged, &mut diagnostics); + let commands = load_custom_commands( + &sources, + &xdg_config_home, + &project_root, + &mut inventory, + &mut diagnostics, + ); + let mut merged_config = parse_merged_config(&merged, &mut diagnostics); + merged_config.commands = commands; diagnostics.unimplemented_keys = collect_unimplemented_keys(&merged); Ok(LoadedConfig { @@ -356,6 +366,131 @@ fn discover_opencode_inventory( inventory.opencode_skills_dirs = skills_dirs; } +fn load_custom_commands( + sources: &[SourceFile], + xdg_config_home: &Path, + project_root: &Path, + inventory: &mut ConfigInventory, + diagnostics: &mut ConfigDiagnostics, +) -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let mut commands = Vec::new(); + let mut command_by_name: HashMap = HashMap::new(); + + for layer in command_layers(xdg_config_home, project_root, &home) { + if let Some(source) = sources.iter().find(|source| source.label == layer.label) { + merge_config_commands( + source, + project_root, + &mut commands, + &mut command_by_name, + diagnostics, + ); + } + + for dir in layer.dirs { + let discovered = crate::command::custom::commands_from_directory( + &dir, + project_root, + &mut diagnostics.warnings, + ); + for command in discovered { + if let crate::command::custom::CustomCommandSource::File(path) = &command.source { + inventory.command_files.push(path.clone()); + } + upsert_custom_command(&mut commands, &mut command_by_name, command); + } + } + } + + inventory.command_files.sort(); + inventory.command_files.dedup(); + + if !commands.is_empty() { + diagnostics + .info + .push(format!("Discovered {} custom commands", commands.len())); + } + + commands +} + +struct CommandLayer { + label: &'static str, + dirs: Vec, +} + +fn command_layers(xdg_config_home: &Path, project_root: &Path, home: &Path) -> Vec { + vec![ + CommandLayer { + label: "OpenCode global", + dirs: vec![xdg_config_home.join("opencode"), home.join(".opencode")], + }, + CommandLayer { + label: "Crabcode global", + dirs: vec![xdg_config_home.join("crabcode"), home.join(".crabcode")], + }, + CommandLayer { + label: "OpenCode local", + dirs: vec![project_root.join(".opencode")], + }, + CommandLayer { + label: "Crabcode local", + dirs: vec![project_root.join(".crabcode")], + }, + ] +} + +fn merge_config_commands( + source: &SourceFile, + project_root: &Path, + commands: &mut Vec, + command_by_name: &mut HashMap, + diagnostics: &mut ConfigDiagnostics, +) { + let parsed = match load_config_value(&source.path) { + Ok(v) => v, + Err(_) => return, + }; + let filtered = filter_top_level(parsed, source.kind); + let Some(mut command_value) = filtered.get("command").cloned() else { + return; + }; + + let base_dir = source + .path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| project_root.to_path_buf()); + let mut provenance = HashMap::new(); + provenance.insert("".to_string(), base_dir); + substitute_placeholders(&mut command_value, &provenance, diagnostics); + + let parsed_commands = crate::command::custom::commands_from_config_value( + &command_value, + &source.path, + project_root, + &mut diagnostics.warnings, + ); + for command in parsed_commands { + upsert_custom_command(commands, command_by_name, command); + } +} + +fn upsert_custom_command( + commands: &mut Vec, + command_by_name: &mut HashMap, + command: crate::command::custom::CustomCommand, +) { + if let Some(idx) = command_by_name.get(&command.name).copied() { + commands[idx] = command; + } else { + let idx = commands.len(); + command_by_name.insert(command.name.clone(), idx); + commands.push(command); + } +} + fn list_md_files(dir: &Path) -> Vec { let mut out = Vec::new(); let rd = match fs::read_dir(dir) { @@ -1062,6 +1197,7 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec { "model", "sounds", "default_agent", + "command", "agent", "provider", ] From bfdbc9194901dbeba3cead8189bbe6a79e6fc35a Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 05:13:27 +0800 Subject: [PATCH 116/226] feat(llm): preserve system message content in instructions when stripping system messages. When `disallow_system_messages` is enabled, the provider would strip system messages from the request. This meant any instructions in the system prompt (e.g. from AGENTS.md) were silently lost. Now those messages are folded into the `default_instructions` string so they still reach the model. --- src/llm/client.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/src/llm/client.rs b/src/llm/client.rs index 0ca7c8c..2302bf6 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -448,8 +448,10 @@ async fn stream_provider_request( if config.openai_options.force_store_false { builder = builder.store_override(false); } - if let Some(instructions) = &config.openai_options.default_instructions { - builder = builder.default_instructions(instructions.clone()); + if let Some(instructions) = + openai_request_instructions(&config.openai_options, &messages) + { + builder = builder.default_instructions(instructions); } if config.openai_options.disallow_system_messages { builder = builder.strip_system_and_developer_messages(true); @@ -469,6 +471,35 @@ async fn stream_provider_request( } } +fn openai_request_instructions( + options: &OpenAIRequestOptions, + messages: &[AisdkMessage], +) -> Option { + let mut parts = Vec::new(); + + if let Some(instructions) = options + .default_instructions + .as_deref() + .map(str::trim) + .filter(|instructions| !instructions.is_empty()) + { + parts.push(instructions.to_string()); + } + + if options.disallow_system_messages { + parts.extend(messages.iter().filter_map(|message| { + let AisdkMessage::System(system) = message else { + return None; + }; + + let content = system.content.trim(); + (!content.is_empty()).then(|| content.to_string()) + })); + } + + (!parts.is_empty()).then(|| parts.join("\n\n---\n\n")) +} + async fn relay_stream_to_sender( stream: &mut LanguageModelStream, cancel_token: &CancellationToken, @@ -699,3 +730,42 @@ fn normalize_anthropic_base_url(base_url: &str) -> String { trimmed.to_string() } } + +#[cfg(test)] +mod tests { + use super::{openai_request_instructions, AisdkMessage, OpenAIRequestOptions}; + + #[test] + fn openai_oauth_instructions_preserve_stripped_system_prompt() { + let options = OpenAIRequestOptions { + default_instructions: Some("base codex instructions".to_string()), + disallow_system_messages: true, + ..OpenAIRequestOptions::default() + }; + let messages = vec![ + AisdkMessage::system("rich system prompt with AGENTS.md"), + AisdkMessage::user("Go ahead"), + ]; + + let instructions = openai_request_instructions(&options, &messages) + .expect("instructions should be present"); + + assert!(instructions.contains("base codex instructions")); + assert!(instructions.contains("rich system prompt with AGENTS.md")); + } + + #[test] + fn openai_instructions_do_not_duplicate_system_when_not_stripping() { + let options = OpenAIRequestOptions { + default_instructions: Some("base codex instructions".to_string()), + disallow_system_messages: false, + ..OpenAIRequestOptions::default() + }; + let messages = vec![AisdkMessage::system("system stays in input")]; + + assert_eq!( + openai_request_instructions(&options, &messages).as_deref(), + Some("base codex instructions") + ); + } +} From 66843b5bbf229eb1df2d99e6f4508e98bcd92e0d Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 05:19:59 +0800 Subject: [PATCH 117/226] feat: normalize and expand GPT-5 model matching for OpenAI OAuth. Replace the hardcoded model allowlist with input normalization (trim + lowercase) and prefix-based GPT-5 matching. Models like `gpt-5.4`, `gpt-5.5`, and `openai/gpt-5.6` are now accepted; `gpt-5-chat-latest` and `gpt-4o` are explicitly rejected. --- src/llm/client.rs | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/llm/client.rs b/src/llm/client.rs index 2302bf6..f600417 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -679,16 +679,17 @@ fn tool_message_observation(content: &str) -> String { } fn is_openai_oauth_model_allowed(model: &str) -> bool { - matches!( - model, - "gpt-5.1-codex-max" - | "gpt-5.1-codex-mini" - | "gpt-5.2" - | "gpt-5.2-codex" - | "gpt-5.3-codex" - | "gpt-5.1-codex" - | "codex-mini-latest" - ) || model.contains("codex") + let model = model.trim().to_ascii_lowercase(); + model.contains("codex") || is_openai_oauth_gpt5_model(&model) +} + +fn is_openai_oauth_gpt5_model(model: &str) -> bool { + let model = model.strip_prefix("openai/").unwrap_or(model); + if model.contains("-chat") { + return false; + } + + model == "gpt-5" || model.starts_with("gpt-5.") || model.starts_with("gpt-5-") } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -733,7 +734,10 @@ fn normalize_anthropic_base_url(base_url: &str) -> String { #[cfg(test)] mod tests { - use super::{openai_request_instructions, AisdkMessage, OpenAIRequestOptions}; + use super::{ + is_openai_oauth_model_allowed, openai_request_instructions, AisdkMessage, + OpenAIRequestOptions, + }; #[test] fn openai_oauth_instructions_preserve_stripped_system_prompt() { @@ -768,4 +772,23 @@ mod tests { Some("base codex instructions") ); } + + #[test] + fn openai_oauth_allows_versioned_gpt5_models() { + assert!(is_openai_oauth_model_allowed("gpt-5.4")); + assert!(is_openai_oauth_model_allowed("gpt-5.5")); + assert!(is_openai_oauth_model_allowed("openai/gpt-5.6")); + } + + #[test] + fn openai_oauth_allows_codex_named_models() { + assert!(is_openai_oauth_model_allowed("gpt-5.3-codex")); + assert!(is_openai_oauth_model_allowed("codex-mini-latest")); + } + + #[test] + fn openai_oauth_rejects_known_non_codex_chat_models() { + assert!(!is_openai_oauth_model_allowed("gpt-5-chat-latest")); + assert!(!is_openai_oauth_model_allowed("gpt-4o")); + } } From ca1181026b59ef18a67f741809d5a9c4958d70ae Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 05:58:06 +0800 Subject: [PATCH 118/226] feat: compact large paste content into placeholders during input. Add large paste compaction in the input component: pastes exceeding 1000 characters are collapsed into `[Pasted Content N chars]` placeholders, reducing visual noise and scroll overhead. The original content is re-expanded only at submission via `submission_text()`. Horizontal viewport scrolling and placeholder styling are also updated to support pasted content placeholders alongside image placeholders. --- _plans/__TODOS.md | 2 +- src/app.rs | 8 +- src/ui/components/input.rs | 521 +++++++++++++++++++++++++++++++++++-- 3 files changed, 503 insertions(+), 28 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 8464748..739e220 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -90,7 +90,7 @@ - [x] Cost to test - this is just my personal add - [x] Idk what metric usually is used, to define "better". - the goal is crabcode will have the same score as the others. -- [ ] Paste compaction i.e. [Pasted Content 1865 chars] +- [x] Paste compaction i.e. [Pasted Content 1865 chars] - [x] multiworkspace not working when I open other directories, I should be able to see in diff --git a/src/app.rs b/src/app.rs index 5628bcc..6145cba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -576,7 +576,7 @@ impl App { state.input_draft = if is_child_session { String::new() } else { - self.input.get_text() + self.input.submission_text() }; } } @@ -1883,8 +1883,8 @@ impl App { match key.code { KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { - let input_text = self.input.get_text(); let image_paths = self.input.local_image_paths_for_submission(); + let input_text = self.input.submission_text(); if !input_text.is_empty() || !image_paths.is_empty() { use crate::command::parser::parse_input; @@ -2459,7 +2459,7 @@ impl App { if self.try_attach_pasted_image_paths(&text) { return; } - self.input.insert_str(&text); + self.input.insert_paste(&text); } (_, OverlayFocus::ModelsDialog) => { self.models_dialog_state @@ -2564,7 +2564,7 @@ impl App { if self.try_attach_pasted_image_paths(&text) { return; } - self.input.insert_str(&text); + self.input.insert_paste(&text); self.update_suggestions(); } (_, OverlayFocus::QuestionDialog) => { diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 9d837b4..a7d0420 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -4,6 +4,7 @@ use crate::push_toast; use crate::theme::{agent_color, ThemeColors}; use crate::toast::{Toast, ToastLevel}; use crate::utils::image_attachment; +use ratatui::buffer::Buffer; use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -14,6 +15,7 @@ use std::ops::Range; use std::path::PathBuf; use tui_textarea::{CursorMove, Input as TuiInput, TextArea}; use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; /// Convert a display-column position to a byte offset within a string. /// Handles multi-byte and wide characters (emoji, CJK, etc.) @@ -52,14 +54,24 @@ fn char_kind(c: char) -> u8 { } } +const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; + pub struct Input { textarea: TextArea<'static>, pub autocomplete: Option, textarea_area: Option, viewport_top: usize, + viewport_left: usize, prompt_history: Option, draft_text: Option, local_images: Vec, + pending_pastes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PendingPaste { + placeholder: String, + content: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -90,9 +102,11 @@ impl Input { autocomplete: None, textarea_area: None, viewport_top: 0, + viewport_left: 0, prompt_history, draft_text: None, local_images: Vec::new(), + pending_pastes: Vec::new(), } } @@ -166,12 +180,12 @@ impl Input { .bg(colors.background_element), ); - let line_count = self.textarea.lines().len(); let visible_lines = v_chunks[1].height as usize; - let max_viewport_top = line_count.saturating_sub(visible_lines); - self.viewport_top = self.viewport_top.min(max_viewport_top); + let visible_cols = v_chunks[1].width as usize; + self.update_viewport(visible_lines, visible_cols); frame.render_widget(&self.textarea, v_chunks[1]); + self.style_placeholder_ranges(frame.buffer_mut(), v_chunks[1], colors); let mut info_spans = vec![ ratatui::text::Span::styled(agent.to_string(), Style::default().fg(agent_color)), @@ -233,6 +247,7 @@ impl Input { if event.code == KeyCode::Enter && event.modifiers.contains(KeyModifiers::SHIFT) { self.textarea.insert_newline(); self.sync_image_placeholders(); + self.sync_pending_pastes(); return true; } @@ -240,6 +255,7 @@ impl Input { if event.code == KeyCode::Enter && event.modifiers.contains(KeyModifiers::ALT) { self.textarea.insert_newline(); self.sync_image_placeholders(); + self.sync_pending_pastes(); return true; } @@ -315,6 +331,7 @@ impl Input { KeyCode::Char('j') if event.modifiers == KeyModifiers::CONTROL => { self.textarea.insert_newline(); self.sync_image_placeholders(); + self.sync_pending_pastes(); true } KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => false, @@ -329,6 +346,7 @@ impl Input { } } self.sync_image_placeholders(); + self.sync_pending_pastes(); true } KeyCode::Tab => false, @@ -340,11 +358,13 @@ impl Input { // tui-textarea's buggy word boundary with multi-byte emoji self.delete_word_backward(); self.sync_image_placeholders(); + self.sync_pending_pastes(); true } _ => { self.textarea.input(input); self.sync_image_placeholders(); + self.sync_pending_pastes(); true } } @@ -410,7 +430,8 @@ impl Input { if target_row < lines.len() { let line = &lines[target_row]; - let target_col = display_col_to_byte_offset(line, relative_x as usize); + let target_col = + display_col_to_byte_offset(line, self.viewport_left + relative_x as usize); let offset = self.flat_offset_for_position(target_row, target_col); if let Some(image) = self.image_at_offset(offset) { match image_attachment::open_path(&image.path) { @@ -450,7 +471,8 @@ impl Input { if target_row < lines.len() { let line = &lines[target_row]; - let target_col = display_col_to_byte_offset(line, relative_x as usize); + let target_col = + display_col_to_byte_offset(line, self.viewport_left + relative_x as usize); // Since start_selection() was called and is_selecting() is true, // move_cursor extends the selection self.textarea @@ -667,12 +689,226 @@ impl Input { self.textarea .move_cursor(CursorMove::Jump(row as u16, col as u16)); self.viewport_top = 0; + self.viewport_left = 0; } fn image_placeholder(number: usize) -> String { format!("[Image #{}]", number) } + fn next_scroll_offset(previous: usize, cursor: usize, visible_len: usize) -> usize { + if visible_len == 0 { + return 0; + } + if cursor < previous { + cursor + } else if previous + visible_len <= cursor { + cursor + 1 - visible_len + } else { + previous + } + } + + fn update_viewport(&mut self, visible_lines: usize, visible_cols: usize) { + let (cursor_row, cursor_col) = self.textarea.cursor(); + let line_count = self.textarea.lines().len(); + let max_viewport_top = line_count.saturating_sub(visible_lines); + + self.viewport_top = self.viewport_top.min(max_viewport_top); + self.viewport_top = Self::next_scroll_offset(self.viewport_top, cursor_row, visible_lines) + .min(max_viewport_top); + self.viewport_left = Self::next_scroll_offset(self.viewport_left, cursor_col, visible_cols); + } + + fn style_placeholder_ranges(&self, buffer: &mut Buffer, area: Rect, colors: &ThemeColors) { + if area.width == 0 || area.height == 0 { + return; + } + + let placeholder_style = Style::default().fg(colors.markdown_image); + let lines = self.textarea.lines(); + + for (line_idx, line) in lines + .iter() + .enumerate() + .skip(self.viewport_top) + .take(area.height as usize) + { + let y = area.y + (line_idx - self.viewport_top) as u16; + + for image in &self.local_images { + for (start, _) in line.match_indices(&image.placeholder) { + Self::style_line_byte_range( + buffer, + area, + y, + line, + start..start + image.placeholder.len(), + self.viewport_left, + placeholder_style, + ); + } + } + + for paste in &self.pending_pastes { + for (start, _) in line.match_indices(&paste.placeholder) { + Self::style_line_byte_range( + buffer, + area, + y, + line, + start..start + paste.placeholder.len(), + self.viewport_left, + placeholder_style, + ); + } + } + } + } + + fn style_line_byte_range( + buffer: &mut Buffer, + area: Rect, + y: u16, + line: &str, + range: Range, + viewport_left: usize, + style: Style, + ) { + if range.start > range.end + || range.end > line.len() + || !line.is_char_boundary(range.start) + || !line.is_char_boundary(range.end) + { + return; + } + + let start_col = UnicodeWidthStr::width(&line[..range.start]); + let end_col = start_col + UnicodeWidthStr::width(&line[range]); + let visible_start = start_col.max(viewport_left); + let visible_end = end_col.min(viewport_left + area.width as usize); + + for col in visible_start..visible_end { + let x = area.x + (col - viewport_left) as u16; + if let Some(cell) = buffer.cell_mut((x, y)) { + cell.set_style(style); + } + } + } + + fn next_large_paste_placeholder(&self, char_count: usize) -> String { + let base = format!("[Pasted Content {char_count} chars]"); + let prefix = format!("{base} #"); + let mut max_suffix = 0usize; + + for paste in &self.pending_pastes { + if paste.placeholder == base { + max_suffix = max_suffix.max(1); + continue; + } + if let Some(suffix) = paste.placeholder.strip_prefix(&prefix) { + if let Ok(value) = suffix.parse::() { + max_suffix = max_suffix.max(value); + } + } + } + + if max_suffix == 0 { + base + } else { + format!("{base} #{}", max_suffix + 1) + } + } + + fn pending_paste_indices_by_placeholder_len(&self) -> Vec { + let mut indices = (0..self.pending_pastes.len()).collect::>(); + indices.sort_by(|&left, &right| { + self.pending_pastes[right] + .placeholder + .len() + .cmp(&self.pending_pastes[left].placeholder.len()) + .then_with(|| left.cmp(&right)) + }); + indices + } + + fn pending_paste_match_at_offset( + &self, + text: &str, + offset: usize, + indices: &[usize], + used_indices: &[usize], + ) -> Option { + indices.iter().copied().find(|idx| { + !used_indices.contains(idx) + && text[offset..].starts_with(&self.pending_pastes[*idx].placeholder) + }) + } + + fn pending_paste_indices_in_text(&self, text: &str) -> Vec { + let indices = self.pending_paste_indices_by_placeholder_len(); + let mut matched = Vec::new(); + let mut offset = 0; + + while offset < text.len() { + if let Some(idx) = self.pending_paste_match_at_offset(text, offset, &indices, &matched) + { + matched.push(idx); + offset += self.pending_pastes[idx].placeholder.len(); + } else if let Some(ch) = text[offset..].chars().next() { + offset += ch.len_utf8(); + } else { + break; + } + } + + matched + } + + fn sync_pending_pastes(&mut self) { + if self.pending_pastes.is_empty() { + return; + } + + let text = self.get_text(); + let matched = self.pending_paste_indices_in_text(&text); + if matched.len() == self.pending_pastes.len() + && matched.iter().copied().eq(0..self.pending_pastes.len()) + { + return; + } + + self.pending_pastes = matched + .into_iter() + .map(|idx| self.pending_pastes[idx].clone()) + .collect(); + } + + fn replace_pending_pastes(&self, text: &str) -> String { + let indices = self.pending_paste_indices_by_placeholder_len(); + let mut expanded = String::with_capacity(text.len()); + let mut used_indices = Vec::new(); + let mut offset = 0; + + while offset < text.len() { + if let Some(idx) = + self.pending_paste_match_at_offset(text, offset, &indices, &used_indices) + { + let paste = &self.pending_pastes[idx]; + expanded.push_str(&paste.content); + used_indices.push(idx); + offset += paste.placeholder.len(); + } else if let Some(ch) = text[offset..].chars().next() { + expanded.push(ch); + offset += ch.len_utf8(); + } else { + break; + } + } + + expanded + } + fn replace_range(&mut self, range: Range, replacement: &str) { let text = self.get_text(); if range.start > range.end || range.end > text.len() { @@ -685,6 +921,7 @@ impl Input { let cursor_offset = range.start + replacement.len(); self.set_text_preserving_images(&new_text, cursor_offset); self.sync_image_placeholders(); + self.sync_pending_pastes(); } fn quote_completion_path(path: &str) -> String { @@ -698,17 +935,28 @@ impl Input { fn remove_placeholder_at_cursor(&mut self, forward: bool) -> bool { let text = self.get_text(); let cursor = self.flat_cursor_offset().min(text.len()); - let target = self.local_images.iter().find_map(|image| { - text.match_indices(&image.placeholder) - .find_map(|(start, _)| { - let end = start + image.placeholder.len(); - let should_remove = if forward { - cursor >= start && cursor < end - } else { - cursor > start && cursor <= end - }; - should_remove.then_some(start..end) - }) + let mut placeholders = self + .local_images + .iter() + .map(|image| image.placeholder.as_str()) + .chain( + self.pending_pastes + .iter() + .map(|paste| paste.placeholder.as_str()), + ) + .collect::>(); + placeholders.sort_by_key(|placeholder| std::cmp::Reverse(placeholder.len())); + + let target = placeholders.into_iter().find_map(|placeholder| { + text.match_indices(placeholder).find_map(|(start, _)| { + let end = start + placeholder.len(); + let should_remove = if forward { + cursor >= start && cursor < end + } else { + cursor > start && cursor <= end + }; + should_remove.then_some(start..end) + }) }); if let Some(range) = target { @@ -844,6 +1092,10 @@ impl Input { self.textarea.lines().join("\n") } + pub fn submission_text(&self) -> String { + self.replace_pending_pastes(&self.get_text()) + } + pub fn is_empty(&self) -> bool { self.get_text().is_empty() } @@ -851,15 +1103,17 @@ impl Input { pub fn clear(&mut self) { self.reset_textarea(); self.viewport_top = 0; + self.viewport_left = 0; self.draft_text = None; self.local_images.clear(); + self.pending_pastes.clear(); if let Some(ref mut history) = self.prompt_history { history.reset_navigation(); } } pub fn save_current_to_history(&mut self) { - let text = self.get_text(); + let text = self.submission_text(); if !text.trim().is_empty() { if let Some(ref mut history) = self.prompt_history { let _ = history.add_prompt(&text); @@ -879,17 +1133,40 @@ impl Input { self.reset_textarea(); self.textarea.insert_str(text); self.viewport_top = 0; + self.viewport_left = 0; self.local_images.clear(); + self.pending_pastes.clear(); } pub fn insert_char(&mut self, c: char) { self.textarea.insert_str(c.to_string().as_str()); self.sync_image_placeholders(); + self.sync_pending_pastes(); } pub fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); self.sync_image_placeholders(); + self.sync_pending_pastes(); + } + + pub fn insert_paste(&mut self, text: &str) { + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + let char_count = text.chars().count(); + + if char_count > LARGE_PASTE_CHAR_THRESHOLD { + self.sync_pending_pastes(); + let placeholder = self.next_large_paste_placeholder(char_count); + self.textarea.insert_str(&placeholder); + self.pending_pastes.push(PendingPaste { + placeholder, + content: text, + }); + self.sync_image_placeholders(); + return; + } + + self.insert_str(&text); } pub fn get_autocomplete_suggestions(&self, is_chat: bool) -> Vec { @@ -915,6 +1192,76 @@ impl Default for Input { mod tests { use super::*; use ratatui::crossterm::event::{KeyEventKind, KeyEventState}; + use ratatui::style::Color; + + fn test_colors() -> ThemeColors { + ThemeColors { + primary: Color::Reset, + secondary: Color::Reset, + accent: Color::Yellow, + interactive: Color::Reset, + background: Color::Black, + dialog_background: Color::Black, + background_element: Color::Black, + text: Color::White, + text_weak: Color::Gray, + text_strong: Color::White, + border: Color::Gray, + border_weak_focus: Color::Gray, + border_focus: Color::Gray, + border_strong_focus: Color::Gray, + success: Color::Green, + warning: Color::Yellow, + error: Color::Red, + info: Color::Cyan, + markdown_text: Color::White, + markdown_heading: Color::Yellow, + markdown_link: Color::Yellow, + markdown_link_text: Color::Cyan, + markdown_code: Color::Green, + markdown_block_quote: Color::Gray, + markdown_emph: Color::Yellow, + markdown_strong: Color::Yellow, + markdown_horizontal_rule: Color::Gray, + markdown_list_item: Color::Yellow, + markdown_list_enumeration: Color::Cyan, + markdown_image: Color::Red, + markdown_image_text: Color::Blue, + markdown_code_block: Color::White, + diff_add: Color::Green, + diff_add_bg: Color::Green, + diff_remove: Color::Red, + diff_remove_bg: Color::Red, + diff_gutter: Color::Gray, + } + } + + fn backspace_event() -> KeyEvent { + KeyEvent { + code: KeyCode::Backspace, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn buffer_row_text(buffer: &ratatui::buffer::Buffer, width: u16, y: u16) -> String { + (0..width) + .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string())) + .collect() + } + + fn find_buffer_text( + buffer: &ratatui::buffer::Buffer, + width: u16, + height: u16, + needle: &str, + ) -> Option<(u16, u16)> { + (0..height).find_map(|y| { + let row = buffer_row_text(buffer, width, y); + row.find(needle).map(|x| (x as u16, y)) + }) + } #[test] fn test_input_creation() { @@ -996,12 +1343,7 @@ mod tests { fn test_backspace_removes_image_placeholder() { let mut input = Input::new(); input.attach_image(PathBuf::from("/tmp/example.png")); - let event = KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::empty(), - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }; + let event = backspace_event(); let handled = input.handle_event(event); @@ -1009,4 +1351,137 @@ mod tests { assert_eq!(input.get_text(), ""); assert!(input.local_image_paths_for_submission().is_empty()); } + + #[test] + fn test_large_paste_is_compacted_for_display() { + let mut input = Input::new(); + let paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + + input.insert_paste(&paste); + + assert_eq!( + input.get_text(), + format!("[Pasted Content {} chars]", LARGE_PASTE_CHAR_THRESHOLD + 1) + ); + assert_eq!(input.submission_text(), paste); + } + + #[test] + fn test_threshold_sized_paste_stays_inline() { + let mut input = Input::new(); + let paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD); + + input.insert_paste(&paste); + + assert_eq!(input.get_text(), paste); + assert_eq!(input.submission_text(), paste); + } + + #[test] + fn test_duplicate_large_paste_placeholders_are_unique() { + let mut input = Input::new(); + let paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + + input.insert_paste(&paste); + input.insert_paste(&paste); + + assert_eq!( + input.get_text(), + format!( + "[Pasted Content {} chars][Pasted Content {} chars] #2", + LARGE_PASTE_CHAR_THRESHOLD + 1, + LARGE_PASTE_CHAR_THRESHOLD + 1 + ) + ); + assert_eq!(input.submission_text(), format!("{paste}{paste}")); + } + + #[test] + fn test_large_paste_payload_is_pruned_after_placeholder_erasure() { + let mut input = Input::new(); + let first = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + let second = "b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + + input.insert_paste(&first); + assert!(input.handle_event(backspace_event())); + input.insert_paste(&second); + + assert_eq!( + input.get_text(), + format!("[Pasted Content {} chars]", LARGE_PASTE_CHAR_THRESHOLD + 1) + ); + assert_eq!(input.submission_text(), second); + } + + #[test] + fn test_large_paste_suffix_is_reused_after_latest_duplicate_erasure() { + let mut input = Input::new(); + let paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + let base = format!("[Pasted Content {} chars]", LARGE_PASTE_CHAR_THRESHOLD + 1); + let second = format!("{base} #2"); + + input.insert_paste(&paste); + input.insert_paste(&paste); + assert_eq!(input.get_text(), format!("{base}{second}")); + + assert!(input.handle_event(backspace_event())); + assert_eq!(input.get_text(), base); + + input.insert_paste(&paste); + assert_eq!(input.get_text(), format!("{base}{second}")); + } + + #[test] + fn test_backspace_removes_large_paste_placeholder() { + let mut input = Input::new(); + input.insert_paste(&"a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + let event = backspace_event(); + + let handled = input.handle_event(event); + + assert!(handled); + assert_eq!(input.get_text(), ""); + assert_eq!(input.submission_text(), ""); + } + + #[test] + fn test_image_and_large_paste_placeholders_render_with_same_color() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + input.attach_image(PathBuf::from("/tmp/example.png")); + input.insert_str(" "); + input.insert_paste(&"a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + + let colors = test_colors(); + let backend = TestBackend::new(80, 6); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 80, 6), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let image_pos = find_buffer_text(buffer, 80, 6, "[Image #1]").expect("image placeholder"); + let paste_pos = + find_buffer_text(buffer, 80, 6, "[Pasted Content").expect("paste placeholder"); + + assert_eq!( + buffer.cell(image_pos).expect("image cell").style().fg, + Some(colors.markdown_image) + ); + assert_eq!( + buffer.cell(paste_pos).expect("paste cell").style().fg, + Some(colors.markdown_image) + ); + } } From 9e236287180399db15f59b47942cab0088de28a2 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 05:58:59 +0800 Subject: [PATCH 119/226] feat: emit ChunkType::End on [DONE] and finish_reason; enforce terminal stream events. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework SSE stream termination across providers and response layer: - compatible: [DONE] now emits ChunkType::End instead of being dropped; propagate line-level errors through the stream; handle finish_reason (stop/length/content_filter) as terminal events - openai: [DONE] and response.completed both emit ChunkType::End; factor SSE event parsing into response_sse_data_to_chunk - response: add stream termination guard — fail if no terminal event (End/Incomplete/Failed) is observed; treat Incomplete as an error --- aisdk/src/providers/compatible.rs | 90 +++++++++++++++--- aisdk/src/providers/openai.rs | 152 ++++++++++++++++-------------- aisdk/src/response.rs | 70 +++++++++++++- 3 files changed, 224 insertions(+), 88 deletions(-) diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index f385ca5..9b12f76 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -178,8 +178,10 @@ impl Provider for OpenAICompatible { let byte_stream = response.bytes_stream(); let line_stream = bytes_to_lines(byte_stream); let stream = line_stream - .map(|line| process_sse_data(&line)) - .flat_map(|v| stream::iter(v)) + .flat_map(|line| match line { + Ok(line) => stream::iter(process_sse_data(&line)), + Err(err) => stream::iter(vec![Err(err)]), + }) .boxed(); Ok(stream) @@ -223,9 +225,13 @@ fn debug_log(msg: &str) { fn process_sse_data(data: &str) -> Vec> { let data = data.trim(); - // [DONE] is ignored — the HTTP stream end signals completion. - if data == "[DONE]" || data.is_empty() || is_sse_metadata_line(data) { - debug_log("[SSE] Ignored: [DONE], empty, or metadata/comment"); + if data == "[DONE]" { + debug_log("[SSE] Terminal: [DONE]"); + return vec![Ok(ChunkType::End(String::new()))]; + } + + if data.is_empty() || is_sse_metadata_line(data) { + debug_log("[SSE] Ignored: empty or metadata/comment"); return vec![]; } @@ -321,6 +327,17 @@ fn process_sse_data(data: &str) -> Vec> { } } + match finish_reason { + "" => {} + "length" => chunks.push(Ok(ChunkType::Incomplete( + "finish_reason=length".to_string(), + ))), + "content_filter" => chunks.push(Ok(ChunkType::Failed( + "finish_reason=content_filter".to_string(), + ))), + _ => chunks.push(Ok(ChunkType::End(String::new()))), + } + if chunks.is_empty() { debug_log(&format!( "[SSE] No chunks produced. finish_reason='{}'", @@ -364,6 +381,35 @@ mod tests { assert!(chunks.is_empty()); } + #[test] + fn done_marker_emits_terminal_chunk() { + let chunks = process_sse_data("[DONE]"); + + assert!(matches!(chunks.as_slice(), [Ok(ChunkType::End(_))])); + } + + #[test] + fn finish_reason_emits_terminal_chunk() { + let data = r#"{"choices":[{"index":0,"finish_reason":"stop","delta":{"role":"assistant","content":""}}]}"#; + + let chunks = process_sse_data(data); + + assert!(chunks + .iter() + .any(|chunk| matches!(chunk, Ok(ChunkType::End(_))))); + } + + #[test] + fn length_finish_reason_emits_incomplete_chunk() { + let data = r#"{"choices":[{"index":0,"finish_reason":"length","delta":{"role":"assistant","content":""}}]}"#; + + let chunks = process_sse_data(data); + + assert!(chunks + .iter() + .any(|chunk| matches!(chunk, Ok(ChunkType::Incomplete(_))))); + } + #[test] fn ignores_sse_comments_and_metadata() { for data in [ @@ -387,17 +433,34 @@ mod tests { )), ]); - let lines = futures::executor::block_on(bytes_to_lines(byte_stream).collect::>()); + let lines = futures::executor::block_on(bytes_to_lines(byte_stream).collect::>()) + .into_iter() + .collect::>>() + .expect("byte stream should parse"); assert_eq!( lines, vec![r#"{"choices":[{"delta":{"content":"hello"}}]}"#.to_string()] ); } + + #[test] + fn bytes_to_lines_preserves_done_marker() { + let byte_stream = stream::iter(vec![Ok::<_, reqwest::Error>(bytes::Bytes::from_static( + b"data: [DONE]\n", + ))]); + + let lines = futures::executor::block_on(bytes_to_lines(byte_stream).collect::>()) + .into_iter() + .collect::>>() + .expect("byte stream should parse"); + + assert_eq!(lines, vec!["[DONE]".to_string()]); + } } /// Convert a byte stream into a stream of lines, handling both SSE (`data: ...`) and raw NDJSON. -fn bytes_to_lines(byte_stream: S) -> impl futures::Stream +fn bytes_to_lines(byte_stream: S) -> impl futures::Stream> where S: futures::Stream> + Unpin, { @@ -418,11 +481,11 @@ where } else { line.to_string() }; - if data == "[DONE]" || data.is_empty() { + if data.is_empty() { continue; } debug_log(&format!("[LINE] Extracted: {}", data)); - return Some((data, (stream, buffer))); + return Some((Ok(data), (stream, buffer))); } match stream.next().await { Some(Ok(bytes)) => { @@ -431,15 +494,12 @@ where } Some(Err(e)) => { debug_log(&format!("[BYTES] Error: {}", e)); - return None; + return Some((Err(Error::Http(e)), (stream, buffer))); } None => { let remaining = String::from_utf8_lossy(&buffer).trim().to_string(); buffer.clear(); - if remaining.is_empty() - || remaining == "[DONE]" - || is_sse_metadata_line(&remaining) - { + if remaining.is_empty() || is_sse_metadata_line(&remaining) { debug_log("[LINE] Stream ended, no remaining data"); return None; } @@ -449,7 +509,7 @@ where remaining }; debug_log(&format!("[LINE] Remaining at EOF: {}", data)); - return Some((data, (stream, buffer))); + return Some((Ok(data), (stream, buffer))); } } } diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index 3ff7d0b..088a2f9 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -164,6 +164,14 @@ impl Provider for OpenAI { reqwest::header::CONTENT_TYPE, "application/json".parse().unwrap(), ); + request_headers.insert( + reqwest::header::ACCEPT, + "text/event-stream".parse().unwrap(), + ); + request_headers.insert( + reqwest::header::ACCEPT_ENCODING, + "identity".parse().unwrap(), + ); if !self.api_key.is_empty() { request_headers.insert( @@ -262,77 +270,11 @@ impl Provider for OpenAI { let stream = response .bytes_stream() .eventsource() - .filter_map(|ev| { - match ev { - Ok(event) => { - let data = &event.data; - // [DONE] / empty data → stream exhausts naturally - if data == "[DONE]" || data.is_empty() { - return futures::future::ready(None); - } - - match serde_json::from_str::(data) { - Ok(value) => { - let event_type = value["type"].as_str().unwrap_or(""); - - match event_type { - "response.output_text.delta" => { - let delta = value["delta"].as_str().unwrap_or(""); - futures::future::ready(Some(Ok(ChunkType::Text( - delta.to_string(), - )))) - } - "response.reasoning_summary_text.delta" => { - let delta = value["delta"].as_str().unwrap_or(""); - futures::future::ready(Some(Ok(ChunkType::Reasoning( - delta.to_string(), - )))) - } - "response.completed" => { - let resp = &value["response"]; - if let Some(error) = resp.get("error") { - if let Some(code) = error.get("code") { - return futures::future::ready(Some(Ok( - ChunkType::Failed(code.to_string()), - ))); - } - } - // Stream exhausts naturally — no End chunk forwarded - futures::future::ready(None) - } - "response.incomplete" => futures::future::ready(Some(Ok( - ChunkType::Incomplete("Response incomplete".to_string()), - ))), - "response.failed" => futures::future::ready(Some(Ok( - ChunkType::Failed("Response failed".to_string()), - ))), - _ => { - if let Some(tool_call) = - responses_function_call_chunk(&value) - { - futures::future::ready(Some(Ok(ChunkType::ToolCall( - tool_call, - )))) - } else if event_type.contains("tool_call") { - futures::future::ready(Some(Ok(ChunkType::ToolCall( - data.clone(), - )))) - } else { - futures::future::ready(None) - } - } - } - } - Err(e) => futures::future::ready(Some(Ok(ChunkType::Failed(format!( - "Invalid SSE data: {}", - e - ))))), - } - } - Err(e) => { - let err = format!("SSE error: {}", e); - futures::future::ready(Some(Ok(ChunkType::Failed(err)))) - } + .filter_map(|ev| match ev { + Ok(event) => futures::future::ready(response_sse_data_to_chunk(&event.data)), + Err(e) => { + let err = format!("SSE error: {}", e); + futures::future::ready(Some(Ok(ChunkType::Failed(err)))) } }) .boxed(); @@ -341,6 +283,54 @@ impl Provider for OpenAI { } } +fn response_sse_data_to_chunk(data: &str) -> Option> { + if data == "[DONE]" { + return Some(Ok(ChunkType::End(String::new()))); + } + if data.is_empty() { + return None; + } + + let value = match serde_json::from_str::(data) { + Ok(value) => value, + Err(err) => { + return Some(Ok(ChunkType::Failed(format!("Invalid SSE data: {}", err)))); + } + }; + + let event_type = value["type"].as_str().unwrap_or(""); + match event_type { + "response.output_text.delta" => { + let delta = value["delta"].as_str().unwrap_or(""); + Some(Ok(ChunkType::Text(delta.to_string()))) + } + "response.reasoning_summary_text.delta" => { + let delta = value["delta"].as_str().unwrap_or(""); + Some(Ok(ChunkType::Reasoning(delta.to_string()))) + } + "response.completed" => { + let resp = &value["response"]; + if let Some(error) = resp.get("error") { + if let Some(code) = error.get("code") { + return Some(Ok(ChunkType::Failed(code.to_string()))); + } + } + Some(Ok(ChunkType::End(String::new()))) + } + "response.incomplete" => Some(Ok(ChunkType::Incomplete("Response incomplete".to_string()))), + "response.failed" => Some(Ok(ChunkType::Failed("Response failed".to_string()))), + _ => { + if let Some(tool_call) = responses_function_call_chunk(&value) { + Some(Ok(ChunkType::ToolCall(tool_call))) + } else if event_type.contains("tool_call") { + Some(Ok(ChunkType::ToolCall(data.to_string()))) + } else { + None + } + } + } +} + fn responses_function_call_chunk(value: &serde_json::Value) -> Option { let event_type = value.get("type").and_then(|v| v.as_str())?; @@ -512,7 +502,25 @@ fn openai_responses_user_content(user: &crate::message::UserMessage) -> serde_js #[cfg(test)] mod tests { - use super::responses_function_call_chunk; + use super::{response_sse_data_to_chunk, responses_function_call_chunk}; + use crate::chunk::ChunkType; + + #[test] + fn done_marker_emits_terminal_chunk() { + let chunk = response_sse_data_to_chunk("[DONE]").expect("expected terminal chunk"); + + assert!(matches!(chunk, Ok(ChunkType::End(_)))); + } + + #[test] + fn response_completed_emits_terminal_chunk() { + let chunk = response_sse_data_to_chunk( + r#"{"type":"response.completed","response":{"id":"resp_123"}}"#, + ) + .expect("expected terminal chunk"); + + assert!(matches!(chunk, Ok(ChunkType::End(_)))); + } #[test] fn maps_responses_function_call_item_to_tool_call_shape() { diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index bb12a99..5ebb44e 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -116,6 +116,7 @@ pub async fn stream_with_tools( let mut has_tool_call = false; let mut tool_call_accumulator = ToolCallAccumulator::default(); let mut accumulated_text = String::new(); + let mut saw_terminal_event = false; while let Some(chunk) = stream.next().await { match chunk { @@ -140,9 +141,13 @@ pub async fn stream_with_tools( // Forwarding End would cause relay_stream_to_sender // to return Ended prematurely, dropping the channel // before tool execution / subsequent steps. + saw_terminal_event = true; } Ok(ChunkType::Incomplete(msg)) => { - let _ = tx_loop.send(ChunkType::Incomplete(msg)); + let err = format!("Provider response incomplete: {}", msg); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; } Ok(ChunkType::Failed(err)) => { let _ = tx_loop.send(ChunkType::Failed(err.clone())); @@ -164,6 +169,13 @@ pub async fn stream_with_tools( } } + if !saw_terminal_event { + let err = "Provider stream ended without a terminal completion event".to_string(); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + // Build assistant message from accumulated text deltas let assistant_text = accumulated_text.trim().to_string(); if !assistant_text.is_empty() { @@ -530,6 +542,7 @@ mod tests { use crate::chunk::ChunkType; use crate::message::Message; use crate::provider::{Provider, ProviderStream}; + use crate::stop::StopReason; use crate::tool::{Tool, ToolExecute}; use async_trait::async_trait; use futures::StreamExt; @@ -552,6 +565,9 @@ mod tests { requests: Arc, } + #[derive(Debug, Clone)] + struct UnterminatedProvider; + #[async_trait] impl Provider for TwoToolCallProvider { fn name(&self) -> &str { @@ -623,6 +639,28 @@ mod tests { } } + #[async_trait] + impl Provider for UnterminatedProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap, + ) -> crate::error::Result { + Ok(Box::pin(futures::stream::iter(vec![Ok(ChunkType::Text( + "still working".to_string(), + ))]))) + } + } + #[tokio::test] async fn executes_same_step_tool_calls_concurrently() { let provider = TwoToolCallProvider { @@ -748,6 +786,36 @@ mod tests { assert_eq!(observations.len(), 1); } + #[tokio::test] + async fn stream_without_terminal_event_fails() { + let mut response = stream_with_tools( + UnterminatedProvider, + vec![Message::user("work")], + Vec::new(), + None, + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut chunks = Vec::new(); + while let Some(chunk) = response.stream.next().await { + chunks.push(chunk); + } + + assert!(chunks.iter().any(|chunk| matches!( + chunk, + ChunkType::Failed(message) + if message.contains("without a terminal completion event") + ))); + assert!(matches!( + response.stop_reason().await, + Some(StopReason::Error(message)) + if message.contains("without a terminal completion event") + )); + } + #[test] fn accumulates_streamed_openai_tool_call_arguments() { let mut accumulator = ToolCallAccumulator::default(); From 3311acdfc6cfd2dd905e44d26b7e70de47bc0916 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 06:11:29 +0800 Subject: [PATCH 120/226] fix(llm): add structured stream logging with request/summary diagnostics. Introduce `StreamLogContext`, `RelayStats`, and `StreamRelayResult` to capture detailed per-request metadata (provider, model, message/tool counts) and per-stream chunk statistics (text/reasoning/tool-call breakdowns, character counts). Log a `[STREAM_REQUEST]` line on start, a `[STREAM_SUMMARY]` line on completion (including token estimate, elapsed time, stop reason, and stats), and enhanced `[RELAY]` / `[STREAM_ERROR]` / `[STREAM_CANCELLED]` lines throughout the relay loop. Also improve SSE error debug logging in the OpenAI provider by including `{:?}` format. --- aisdk/src/providers/openai.rs | 2 +- src/llm/client.rs | 304 ++++++++++++++++++++++++++++++++-- 2 files changed, 294 insertions(+), 12 deletions(-) diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index 088a2f9..abc12df 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -273,7 +273,7 @@ impl Provider for OpenAI { .filter_map(|ev| match ev { Ok(event) => futures::future::ready(response_sse_data_to_chunk(&event.data)), Err(e) => { - let err = format!("SSE error: {}", e); + let err = format!("SSE error: {}; debug={:?}", e, e); futures::future::ready(Some(Ok(ChunkType::Failed(err)))) } }) diff --git a/src/llm/client.rs b/src/llm/client.rs index f600417..fdfbaed 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -80,6 +80,91 @@ enum StreamRelayOutcome { Exhausted, } +#[derive(Clone, Copy, Debug)] +struct StreamLogContext<'a> { + phase: &'a str, + provider_name: &'a str, + provider_kind: ProviderKind, + base_url: &'a str, + model_name: &'a str, + message_count: usize, + tool_count: usize, + agent_max_steps: Option, +} + +impl<'a> StreamLogContext<'a> { + fn new( + phase: &'a str, + config: &'a ProviderRequestConfig, + message_count: usize, + tool_count: usize, + agent_max_steps: Option, + ) -> Self { + Self { + phase, + provider_name: &config.provider_name, + provider_kind: config.kind, + base_url: &config.base_url, + model_name: &config.model_name, + message_count, + tool_count, + agent_max_steps, + } + } + + fn describe(self) -> String { + format!( + "phase={} provider={} provider_kind={:?} base_url={} model={} messages={} tools={} agent_max_steps={:?}", + self.phase, + self.provider_name, + self.provider_kind, + self.base_url, + self.model_name, + self.message_count, + self.tool_count, + self.agent_max_steps, + ) + } +} + +#[derive(Clone, Debug, Default)] +struct RelayStats { + start_chunks: usize, + text_chunks: usize, + reasoning_chunks: usize, + tool_call_chunks: usize, + incomplete_chunks: usize, + not_supported_chunks: usize, + text_chars: usize, + reasoning_chars: usize, + tool_call_bytes: usize, + last_chunk: Option<&'static str>, +} + +impl RelayStats { + fn describe(&self) -> String { + format!( + "chunks[start={}, text={} text_chars={}, reasoning={} reasoning_chars={}, tool_calls={} tool_call_bytes={}, incomplete={}, not_supported={}, last={}]", + self.start_chunks, + self.text_chunks, + self.text_chars, + self.reasoning_chunks, + self.reasoning_chars, + self.tool_call_chunks, + self.tool_call_bytes, + self.incomplete_chunks, + self.not_supported_chunks, + self.last_chunk.unwrap_or("none"), + ) + } +} + +#[derive(Clone, Debug)] +struct StreamRelayResult { + outcome: StreamRelayOutcome, + stats: RelayStats, +} + pub async fn stream_llm_with_cancellation( cancel_token: CancellationToken, session_id: String, @@ -126,6 +211,17 @@ pub async fn stream_llm_with_cancellation( ) .await; + let message_count = aisdk_messages.len(); + let tool_count = aisdk_tools.len(); + let primary_log_context = StreamLogContext::new( + "primary", + &request_config, + message_count, + tool_count, + agent_max_steps, + ); + log_stream_request(primary_log_context, &request_config); + let mut response = stream_provider_request( &request_config, aisdk_messages, @@ -137,19 +233,50 @@ pub async fn stream_llm_with_cancellation( let start_time = Instant::now(); let mut token_count: usize = 0; - let stream_outcome = relay_stream_to_sender( + let relay_result = match relay_stream_to_sender( &mut response.stream, &cancel_token, &sender, &mut token_count, &start_time, + primary_log_context, ) - .await?; + .await + .map_err(|err| err.to_string()) + { + Ok(result) => result, + Err(error) => { + let stop_reason = response.stop_reason().await; + log_stream_summary( + primary_log_context, + "Error", + stop_reason.as_ref(), + token_count, + start_time.elapsed().as_millis(), + None, + Some(&error), + ); + return Err(anyhow::anyhow!(error).into()); + } + }; let stop_reason = response.stop_reason().await; + let stream_outcome = relay_result.outcome; let _ = log(&format!( "Stream completed: outcome={stream_outcome:?}, stop_reason={stop_reason:?}, agent_max_steps={agent_max_steps:?}", )); + log_stream_summary( + primary_log_context, + match stream_outcome { + StreamRelayOutcome::Ended => "Ended", + StreamRelayOutcome::Exhausted => "Exhausted", + }, + stop_reason.as_ref(), + token_count, + start_time.elapsed().as_millis(), + Some(&relay_result.stats), + None, + ); if stream_outcome == StreamRelayOutcome::Ended { return Ok(()); @@ -167,18 +294,59 @@ pub async fn stream_llm_with_cancellation( let mut follow_up_messages = response.messages().await; follow_up_messages.push(AisdkMessage::assistant(MAX_STEPS_REACHED_PROMPT)); + let summary_message_count = follow_up_messages.len(); + let summary_log_context = StreamLogContext::new( + "max_steps_summary", + &request_config, + summary_message_count, + 0, + None, + ); + log_stream_request(summary_log_context, &request_config); let mut summary_response = stream_provider_request(&request_config, follow_up_messages, Vec::new(), None).await?; - let _ = relay_stream_to_sender( + match relay_stream_to_sender( &mut summary_response.stream, &cancel_token, &sender, &mut token_count, &start_time, + summary_log_context, ) - .await?; + .await + .map_err(|err| err.to_string()) + { + Ok(result) => { + let stop_reason = summary_response.stop_reason().await; + log_stream_summary( + summary_log_context, + match result.outcome { + StreamRelayOutcome::Ended => "Ended", + StreamRelayOutcome::Exhausted => "Exhausted", + }, + stop_reason.as_ref(), + token_count, + start_time.elapsed().as_millis(), + Some(&result.stats), + None, + ); + } + Err(error) => { + let stop_reason = summary_response.stop_reason().await; + log_stream_summary( + summary_log_context, + "Error", + stop_reason.as_ref(), + token_count, + start_time.elapsed().as_millis(), + None, + Some(&error), + ); + return Err(anyhow::anyhow!(error).into()); + } + } Ok(()) } @@ -500,18 +668,89 @@ fn openai_request_instructions( (!parts.is_empty()).then(|| parts.join("\n\n---\n\n")) } +fn log_stream_request(context: StreamLogContext<'_>, config: &ProviderRequestConfig) { + let reasoning_effort = config + .reasoning_effort + .map(|effort| effort.as_str()) + .unwrap_or("none"); + let mut header_names = config + .openai_options + .additional_headers + .keys() + .map(String::as_str) + .collect::>(); + header_names.sort_unstable(); + let _ = log(&format!( + "[STREAM_REQUEST] {} reasoning_effort={} responses_path={:?} force_store_false={} disallow_system_messages={} force_tool_strict_false={} extra_header_names=[{}]", + context.describe(), + reasoning_effort, + config.openai_options.response_path, + config.openai_options.force_store_false, + config.openai_options.disallow_system_messages, + config.openai_options.force_tool_strict_false, + header_names.join(","), + )); +} + +fn log_stream_summary( + context: StreamLogContext<'_>, + relay_result: &str, + stop_reason: Option<&StopReason>, + token_count: usize, + elapsed_ms: u128, + stats: Option<&RelayStats>, + error: Option<&str>, +) { + let stats = stats + .map(RelayStats::describe) + .unwrap_or_else(|| "chunks=unavailable".to_string()); + let error = error + .map(|err| format!(" error={}", err)) + .unwrap_or_default(); + let _ = log(&format!( + "[STREAM_SUMMARY] {} relay_result={} stop_reason={:?} token_estimate={} elapsed_ms={} {}{}", + context.describe(), + relay_result, + stop_reason, + token_count, + elapsed_ms, + stats, + error, + )); +} + +fn is_sse_transport_error(err: &str) -> bool { + let lower = err.to_ascii_lowercase(); + lower.contains("sse error") + && (lower.contains("transport") + || lower.contains("decoding response body") + || lower.contains("body")) +} + async fn relay_stream_to_sender( stream: &mut LanguageModelStream, cancel_token: &CancellationToken, sender: &crate::llm::ChunkSender, token_count: &mut usize, start_time: &Instant, -) -> Result { - let _ = log("[RELAY] relay_stream_to_sender started"); + context: StreamLogContext<'_>, +) -> Result { + let mut stats = RelayStats::default(); + let _ = log(&format!( + "[RELAY] relay_stream_to_sender started {}", + context.describe() + )); loop { let chunk = tokio::select! { _ = cancel_token.cancelled() => { let _ = sender.send(crate::llm::ChunkMessage::Cancelled); + let _ = log(&format!( + "[STREAM_CANCELLED] {} elapsed_ms={} token_estimate={} {}", + context.describe(), + start_time.elapsed().as_millis(), + *token_count, + stats.describe(), + )); return Err(anyhow::anyhow!("Streaming cancelled by user").into()); } chunk = stream.next() => chunk, @@ -524,11 +763,17 @@ async fn relay_stream_to_sender( match chunk { ChunkType::Text(text) => { + stats.text_chunks += 1; + stats.text_chars += text.len(); + stats.last_chunk = Some("Text"); *token_count += estimate_tokens(&text); let _ = log(&format!("[RELAY] Text chunk ({} chars)", text.len())); let _ = sender.send(crate::llm::ChunkMessage::Text(text)); } ChunkType::Reasoning(reasoning) => { + stats.reasoning_chunks += 1; + stats.reasoning_chars += reasoning.len(); + stats.last_chunk = Some("Reasoning"); *token_count += estimate_tokens(&reasoning); let _ = log(&format!( "[RELAY] Reasoning chunk ({} chars)", @@ -537,6 +782,9 @@ async fn relay_stream_to_sender( let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } ChunkType::ToolCall(tool_call) => { + stats.tool_call_chunks += 1; + stats.tool_call_bytes += tool_call.len(); + stats.last_chunk = Some("ToolCall"); let names = serde_json::from_str::(&tool_call) .ok() .and_then(|value| { @@ -561,6 +809,7 @@ async fn relay_stream_to_sender( )); } ChunkType::End(_msg) => { + stats.last_chunk = Some("End"); let _ = log("[RELAY] End chunk — returning Ended"); let duration_ms = start_time.elapsed().as_millis() as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { @@ -568,23 +817,56 @@ async fn relay_stream_to_sender( duration_ms, }); let _ = sender.send(crate::llm::ChunkMessage::End); - return Ok(StreamRelayOutcome::Ended); + return Ok(StreamRelayResult { + outcome: StreamRelayOutcome::Ended, + stats, + }); } ChunkType::Start => { + stats.start_chunks += 1; + stats.last_chunk = Some("Start"); let _ = log("[RELAY] Start chunk received"); } ChunkType::Failed(err) => { + stats.last_chunk = Some("Failed"); let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); let _ = log(&format!("Stream Chunk Failed {}", err)); + let _ = log(&format!( + "[STREAM_ERROR] {} elapsed_ms={} token_estimate={} {} error={}", + context.describe(), + start_time.elapsed().as_millis(), + *token_count, + stats.describe(), + err, + )); + if is_sse_transport_error(&err) { + let _ = log("[STREAM_ERROR_HINT] SSE transport/body decode failure. This happened below the model layer while reading the response stream; if it repeats, compare network/proxy/VPN state and provider status with the request context above."); + } return Err(anyhow::anyhow!("Streaming failed: {}", err).into()); } - ChunkType::Incomplete(_msg) => {} - ChunkType::NotSupported(_msg) => {} + ChunkType::Incomplete(msg) => { + stats.incomplete_chunks += 1; + stats.last_chunk = Some("Incomplete"); + let _ = log(&format!("[RELAY] Incomplete chunk received: {}", msg)); + } + ChunkType::NotSupported(msg) => { + stats.not_supported_chunks += 1; + stats.last_chunk = Some("NotSupported"); + let _ = log(&format!("[RELAY] NotSupported chunk received: {}", msg)); + } } } - let _ = log("[RELAY] stream exhausted — returning Exhausted"); - Ok(StreamRelayOutcome::Exhausted) + let _ = log(&format!( + "[RELAY] stream exhausted — returning Exhausted {} token_estimate={} {}", + context.describe(), + *token_count, + stats.describe(), + )); + Ok(StreamRelayResult { + outcome: StreamRelayOutcome::Exhausted, + stats, + }) } async fn reached_step_limit(agent_max_steps: Option, response: &StreamTextResponse) -> bool { From 76166638bb5cf9d6fd0f17b60596546faac49c2a Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 17:06:57 +0800 Subject: [PATCH 121/226] feat(aisdk): add assistant message phase and response completed streaming support. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `AssistantMessagePhase` and `ResponseCompleted` chunk types to track assistant commentary vs final answer phases and end-of-turn signals. Parse `response.output_item.added/.done` and `response.completed` SSE events in the OpenAI provider. When the provider signals a non-final response (commentary phase or `end_turn=false`), continue the model turn for a follow-up step instead of terminating. Also overhaul streaming error diagnostics — structured request/SRE error formatting, source-chain logging, and a relay stats system that tracks timing, phase-aware text volume, and tool call details for better debugging. --- aisdk/src/chunk.rs | 9 + aisdk/src/lib.rs | 8 +- aisdk/src/providers/openai.rs | 189 +++++++++++++++++++-- aisdk/src/response.rs | 150 ++++++++++++++++- src/agent/subagent.rs | 3 + src/llm/client.rs | 307 +++++++++++++++++++++++++++++----- 6 files changed, 602 insertions(+), 64 deletions(-) diff --git a/aisdk/src/chunk.rs b/aisdk/src/chunk.rs index ee68f8f..8d08608 100644 --- a/aisdk/src/chunk.rs +++ b/aisdk/src/chunk.rs @@ -4,8 +4,17 @@ pub enum ChunkType { Text(String), Reasoning(String), ToolCall(String), + AssistantMessagePhase { phase: Option }, + ResponseCompleted { end_turn: Option }, + Metadata(String), End(String), Failed(String), Incomplete(String), NotSupported(String), } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessagePhase { + Commentary, + FinalAnswer, +} diff --git a/aisdk/src/lib.rs b/aisdk/src/lib.rs index 34677f4..3c65055 100644 --- a/aisdk/src/lib.rs +++ b/aisdk/src/lib.rs @@ -8,14 +8,16 @@ pub mod stop; pub mod tool; pub mod core { - pub use crate::chunk::ChunkType; + pub use crate::chunk::{ChunkType, MessagePhase}; pub use crate::message::Message; pub use crate::response::StreamTextResponse; pub use crate::stop::{step_count_is, StopReason}; pub use crate::tool::Tool; pub mod language_model { - pub use crate::chunk::ChunkType as LanguageModelStreamChunkType; + pub use crate::chunk::{ + ChunkType as LanguageModelStreamChunkType, MessagePhase as LanguageModelMessagePhase, + }; pub use crate::response::LanguageModelStream; pub use crate::stop::step_count_is; pub use crate::stop::StopReason; @@ -34,7 +36,7 @@ pub mod core { } pub mod chunk { - pub use crate::chunk::ChunkType; + pub use crate::chunk::{ChunkType, MessagePhase}; } pub mod response { diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index abc12df..5577eaa 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -1,12 +1,16 @@ -use crate::chunk::ChunkType; +use crate::chunk::{ChunkType, MessagePhase}; use crate::error::{Error, Result}; use crate::message::Message; use crate::provider::{Provider, ProviderStream}; use crate::tool::Tool; use async_trait::async_trait; -use eventsource_stream::Eventsource; +use eventsource_stream::{EventStreamError, Eventsource}; use futures::StreamExt; use std::collections::HashMap; +use std::error::Error as StdError; + +const OPENAI_STREAM_REQUEST_TIMEOUT_SECS: u64 = 30; +const OPENAI_ERROR_BODY_MAX_CHARS: usize = 2048; #[derive(Debug, Clone)] pub struct OpenAI { @@ -248,7 +252,9 @@ impl Provider for OpenAI { } let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs( + OPENAI_STREAM_REQUEST_TIMEOUT_SECS, + )) .build() .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; let response = client @@ -256,14 +262,22 @@ impl Provider for OpenAI { .headers(request_headers) .json(&body) .send() - .await?; + .await + .map_err(|err| Error::Provider(format_openai_request_error("send", &url, &err)))?; if !response.status().is_success() { let status = response.status(); - let text = response.text().await.unwrap_or_default(); + let response_url = sanitized_url(response.url()); + let text = match response.text().await { + Ok(text) => truncate_log_value(&text, OPENAI_ERROR_BODY_MAX_CHARS), + Err(err) => format!( + "", + format_reqwest_error("read_error_body", &err) + ), + }; return Err(Error::Provider(format!( - "OpenAI API error {}: {}", - status, text + "OpenAI API error: status={} url={} body={}", + status, response_url, text ))); } @@ -273,7 +287,7 @@ impl Provider for OpenAI { .filter_map(|ev| match ev { Ok(event) => futures::future::ready(response_sse_data_to_chunk(&event.data)), Err(e) => { - let err = format!("SSE error: {}; debug={:?}", e, e); + let err = format_openai_sse_error(&e); futures::future::ready(Some(Ok(ChunkType::Failed(err)))) } }) @@ -283,6 +297,99 @@ impl Provider for OpenAI { } } +fn format_openai_sse_error(err: &EventStreamError) -> String { + match err { + EventStreamError::Transport(source) => { + format!( + "SSE transport error: request_timeout_secs={} {}", + OPENAI_STREAM_REQUEST_TIMEOUT_SECS, + format_reqwest_error("stream_body", source), + ) + } + EventStreamError::Parser(source) => { + format!("SSE parser error: source={} debug={:?}", source, source) + } + EventStreamError::Utf8(source) => { + format!("SSE UTF-8 error: source={} debug={:?}", source, source) + } + } +} + +fn format_openai_request_error(stage: &str, request_url: &str, err: &reqwest::Error) -> String { + format!( + "OpenAI request error: request_timeout_secs={} request_url={} {}", + OPENAI_STREAM_REQUEST_TIMEOUT_SECS, + sanitized_url_str(request_url), + format_reqwest_error(stage, err), + ) +} + +fn format_reqwest_error(stage: &str, err: &reqwest::Error) -> String { + format!( + "stage={} is_timeout={} is_connect={} is_request={} is_body={} is_decode={} status={} url={} source_chain={} debug={:?}", + stage, + err.is_timeout(), + err.is_connect(), + err.is_request(), + err.is_body(), + err.is_decode(), + err.status() + .map(|status| status.as_u16().to_string()) + .unwrap_or_else(|| "none".to_string()), + sanitized_reqwest_error_url(err), + error_source_chain(err), + err, + ) +} + +fn sanitized_reqwest_error_url(err: &reqwest::Error) -> String { + err.url() + .map(sanitized_url) + .unwrap_or_else(|| "none".to_string()) +} + +fn sanitized_url_str(url: &str) -> String { + reqwest::Url::parse(url) + .map(|url| sanitized_url(&url)) + .unwrap_or_else(|_| "".to_string()) +} + +fn sanitized_url(url: &reqwest::Url) -> String { + let mut url = url.clone(); + url.set_query(None); + url.set_fragment(None); + url.to_string() +} + +fn truncate_log_value(value: &str, max_chars: usize) -> String { + let single_line = value + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + + if single_line.chars().count() <= max_chars { + single_line + } else { + let truncated = single_line.chars().take(max_chars).collect::(); + format!("{}...", truncated) + } +} + +fn error_source_chain(err: &(dyn StdError + 'static)) -> String { + let mut parts = Vec::new(); + let mut source = err.source(); + while let Some(err) = source { + parts.push(err.to_string()); + source = err.source(); + } + + if parts.is_empty() { + "none".to_string() + } else { + parts.join(" <- ") + } +} + fn response_sse_data_to_chunk(data: &str) -> Option> { if data == "[DONE]" { return Some(Ok(ChunkType::End(String::new()))); @@ -315,12 +422,16 @@ fn response_sse_data_to_chunk(data: &str) -> Option> { return Some(Ok(ChunkType::Failed(code.to_string()))); } } - Some(Ok(ChunkType::End(String::new()))) + Some(Ok(ChunkType::ResponseCompleted { + end_turn: resp.get("end_turn").and_then(|value| value.as_bool()), + })) } "response.incomplete" => Some(Ok(ChunkType::Incomplete("Response incomplete".to_string()))), "response.failed" => Some(Ok(ChunkType::Failed("Response failed".to_string()))), _ => { - if let Some(tool_call) = responses_function_call_chunk(&value) { + if let Some(message_phase) = responses_assistant_message_phase_chunk(&value) { + Some(Ok(message_phase)) + } else if let Some(tool_call) = responses_function_call_chunk(&value) { Some(Ok(ChunkType::ToolCall(tool_call))) } else if event_type.contains("tool_call") { Some(Ok(ChunkType::ToolCall(data.to_string()))) @@ -331,6 +442,38 @@ fn response_sse_data_to_chunk(data: &str) -> Option> { } } +fn responses_assistant_message_phase_chunk(value: &serde_json::Value) -> Option { + let event_type = value.get("type").and_then(|v| v.as_str())?; + if !matches!( + event_type, + "response.output_item.added" | "response.output_item.done" + ) { + return None; + } + + let item = value.get("item")?; + if item.get("type").and_then(|v| v.as_str())? != "message" + || item.get("role").and_then(|v| v.as_str()) != Some("assistant") + { + return None; + } + + Some(ChunkType::AssistantMessagePhase { + phase: item + .get("phase") + .and_then(|phase| phase.as_str()) + .and_then(parse_message_phase), + }) +} + +fn parse_message_phase(phase: &str) -> Option { + match phase { + "commentary" => Some(MessagePhase::Commentary), + "final_answer" => Some(MessagePhase::FinalAnswer), + _ => None, + } +} + fn responses_function_call_chunk(value: &serde_json::Value) -> Option { let event_type = value.get("type").and_then(|v| v.as_str())?; @@ -503,7 +646,7 @@ fn openai_responses_user_content(user: &crate::message::UserMessage) -> serde_js #[cfg(test)] mod tests { use super::{response_sse_data_to_chunk, responses_function_call_chunk}; - use crate::chunk::ChunkType; + use crate::chunk::{ChunkType, MessagePhase}; #[test] fn done_marker_emits_terminal_chunk() { @@ -515,11 +658,31 @@ mod tests { #[test] fn response_completed_emits_terminal_chunk() { let chunk = response_sse_data_to_chunk( - r#"{"type":"response.completed","response":{"id":"resp_123"}}"#, + r#"{"type":"response.completed","response":{"id":"resp_123","end_turn":false}}"#, ) .expect("expected terminal chunk"); - assert!(matches!(chunk, Ok(ChunkType::End(_)))); + assert!(matches!( + chunk, + Ok(ChunkType::ResponseCompleted { + end_turn: Some(false) + }) + )); + } + + #[test] + fn maps_responses_assistant_message_phase() { + let chunk = response_sse_data_to_chunk( + r#"{"type":"response.output_item.done","item":{"type":"message","role":"assistant","phase":"commentary"}}"#, + ) + .expect("expected message phase chunk"); + + assert!(matches!( + chunk, + Ok(ChunkType::AssistantMessagePhase { + phase: Some(MessagePhase::Commentary) + }) + )); } #[test] diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 5ebb44e..691d71b 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -1,4 +1,4 @@ -use crate::chunk::ChunkType; +use crate::chunk::{ChunkType, MessagePhase}; use crate::error::Result; use crate::message::Message; use crate::provider::Provider; @@ -100,6 +100,13 @@ pub async fn stream_with_tools( } } + let _ = tx_loop.send(ChunkType::Metadata(format!( + "provider_step_start step={} messages={} tools={}", + step_idx, + current_messages.len(), + tools.len() + ))); + let stream_result = provider_clone .stream_text(¤t_messages, &tools, &headers) .await; @@ -107,8 +114,15 @@ pub async fn stream_with_tools( let mut stream = match stream_result { Ok(s) => s, Err(e) => { - let _ = tx_loop.send(ChunkType::Failed(e.to_string())); - *stop_reason_arc.lock().await = Some(StopReason::Error(e.to_string())); + let err = format!( + "provider_step_error step={} messages={} tools={} error={}", + step_idx, + current_messages.len(), + tools.len(), + e + ); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); break; } }; @@ -117,10 +131,33 @@ pub async fn stream_with_tools( let mut tool_call_accumulator = ToolCallAccumulator::default(); let mut accumulated_text = String::new(); let mut saw_terminal_event = false; + let mut response_end_turn = None; + let mut last_assistant_message_phase = None; + let mut current_assistant_message_phase = None; while let Some(chunk) = stream.next().await { match chunk { + Ok(ChunkType::AssistantMessagePhase { phase }) => { + current_assistant_message_phase = phase; + last_assistant_message_phase = phase; + let label = match phase { + Some(MessagePhase::Commentary) => "commentary", + Some(MessagePhase::FinalAnswer) => "final_answer", + None => "unknown", + }; + let _ = tx_loop.send(ChunkType::Metadata(format!( + "assistant_message_phase={label}" + ))); + } + Ok(ChunkType::ResponseCompleted { end_turn }) => { + saw_terminal_event = true; + response_end_turn = end_turn; + let _ = tx_loop.send(ChunkType::Metadata(format!( + "response.completed end_turn={end_turn:?}" + ))); + } Ok(ChunkType::Text(text)) => { + last_assistant_message_phase = current_assistant_message_phase; accumulated_text.push_str(&text); let _ = tx_loop.send(ChunkType::Text(text)); } @@ -143,6 +180,9 @@ pub async fn stream_with_tools( // before tool execution / subsequent steps. saw_terminal_event = true; } + Ok(ChunkType::Metadata(msg)) => { + let _ = tx_loop.send(ChunkType::Metadata(msg)); + } Ok(ChunkType::Incomplete(msg)) => { let err = format!("Provider response incomplete: {}", msg); let _ = tx_loop.send(ChunkType::Failed(err.clone())); @@ -185,6 +225,19 @@ pub async fn stream_with_tools( } if !has_tool_call { + let needs_follow_up = matches!(response_end_turn, Some(false)) + || matches!(last_assistant_message_phase, Some(MessagePhase::Commentary)); + if needs_follow_up { + let reason = if matches!(response_end_turn, Some(false)) { + "end_turn=false" + } else { + "assistant_message_phase=commentary" + }; + let _ = tx_loop.send(ChunkType::Metadata(format!( + "continuing model turn after non-final assistant output ({reason})" + ))); + continue; + } *stop_reason_arc.lock().await = Some(StopReason::Finish); break; } @@ -269,6 +322,18 @@ pub async fn stream_with_tools( if !successful_tool_results.is_empty() { let observation = format_tool_observation(&successful_tool_results); + let tool_names = successful_tool_results + .iter() + .map(|result| result.tool_name.as_str()) + .collect::>() + .join(","); + let _ = tx_loop.send(ChunkType::Metadata(format!( + "tool_results_added count={} names={} observation_chars={} next_messages={}", + successful_tool_results.len(), + tool_names, + observation.len(), + current_messages.len() + 1 + ))); current_messages.push(Message::user(observation.clone())); messages_arc.lock().await.push(Message::user(observation)); } @@ -539,7 +604,7 @@ fn tool_call_key(item: &serde_json::Value, array_index: usize) -> String { #[cfg(test)] mod tests { use super::{stream_with_tools, ToolCallAccumulator}; - use crate::chunk::ChunkType; + use crate::chunk::{ChunkType, MessagePhase}; use crate::message::Message; use crate::provider::{Provider, ProviderStream}; use crate::stop::StopReason; @@ -568,6 +633,11 @@ mod tests { #[derive(Debug, Clone)] struct UnterminatedProvider; + #[derive(Debug, Clone)] + struct FollowUpProvider { + requests: Arc, + } + #[async_trait] impl Provider for TwoToolCallProvider { fn name(&self) -> &str { @@ -661,6 +731,49 @@ mod tests { } } + #[async_trait] + impl Provider for FollowUpProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap, + ) -> crate::error::Result { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = if request == 0 { + vec![ + Ok(ChunkType::AssistantMessagePhase { + phase: Some(MessagePhase::Commentary), + }), + Ok(ChunkType::Text("I'll inspect that next.".to_string())), + Ok(ChunkType::ResponseCompleted { + end_turn: Some(false), + }), + ] + } else { + vec![ + Ok(ChunkType::AssistantMessagePhase { + phase: Some(MessagePhase::FinalAnswer), + }), + Ok(ChunkType::Text("Done.".to_string())), + Ok(ChunkType::ResponseCompleted { + end_turn: Some(true), + }), + ] + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + #[tokio::test] async fn executes_same_step_tool_calls_concurrently() { let provider = TwoToolCallProvider { @@ -816,6 +929,35 @@ mod tests { )); } + #[tokio::test] + async fn continues_when_provider_marks_response_as_non_final() { + let provider = FollowUpProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("finish the task")], + Vec::new(), + Some(3), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + while let Some(chunk) = response.stream.next().await { + if let ChunkType::Text(delta) = chunk { + text.push_str(&delta); + } + } + + assert_eq!(text, "I'll inspect that next.Done."); + assert_eq!(provider.requests.load(Ordering::SeqCst), 2); + assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); + } + #[test] fn accumulates_streamed_openai_tool_call_arguments() { let mut accumulator = ToolCallAccumulator::default(); diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 3d1864f..c0eb459 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -258,6 +258,9 @@ pub async fn run_subagent( ChunkType::End(_) => { break; } + ChunkType::ResponseCompleted { .. } => { + break; + } _ => {} } } diff --git a/src/llm/client.rs b/src/llm/client.rs index fdfbaed..41af637 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1,5 +1,5 @@ use aisdk::core::{ - chunk::ChunkType, + chunk::{ChunkType, MessagePhase}, response::{stream_with_tools, LanguageModelStream, StreamTextResponse}, stop::{step_count_is, StopReason}, Message as AisdkMessage, Tool, @@ -133,32 +133,208 @@ struct RelayStats { text_chunks: usize, reasoning_chunks: usize, tool_call_chunks: usize, + assistant_phase_chunks: usize, + metadata_chunks: usize, + response_completed_chunks: usize, + failed_chunks: usize, incomplete_chunks: usize, not_supported_chunks: usize, text_chars: usize, + commentary_text_chars: usize, + final_answer_text_chars: usize, + unphased_text_chars: usize, reasoning_chars: usize, tool_call_bytes: usize, + tool_call_argument_chars: usize, + tool_call_arguments_done_chars: usize, last_chunk: Option<&'static str>, + last_progress_chunk: Option<&'static str>, + current_assistant_phase: Option<&'static str>, + last_metadata: Option, + last_tool_call_names: Option, + first_chunk_elapsed_ms: Option, + last_progress_elapsed_ms: Option, + last_text_elapsed_ms: Option, + last_tool_call_elapsed_ms: Option, } impl RelayStats { - fn describe(&self) -> String { + fn record_chunk(&mut self, name: &'static str, elapsed_ms: u128) { + if self.first_chunk_elapsed_ms.is_none() { + self.first_chunk_elapsed_ms = Some(elapsed_ms); + } + self.last_chunk = Some(name); + self.last_progress_chunk = Some(name); + self.last_progress_elapsed_ms = Some(elapsed_ms); + } + + fn record_failed_chunk(&mut self) { + self.failed_chunks += 1; + self.last_chunk = Some("Failed"); + } + + fn record_text(&mut self, len: usize, elapsed_ms: u128) { + self.last_text_elapsed_ms = Some(elapsed_ms); + match self.current_assistant_phase { + Some("commentary") => self.commentary_text_chars += len, + Some("final_answer") => self.final_answer_text_chars += len, + _ => self.unphased_text_chars += len, + } + } + + fn record_assistant_phase(&mut self, phase: Option) { + self.assistant_phase_chunks += 1; + self.current_assistant_phase = Some(message_phase_label(phase)); + } + + fn record_metadata(&mut self, message: &str) { + self.metadata_chunks += 1; + self.last_metadata = Some(truncate_log_value(message, 120)); + + if let Some(phase) = message.strip_prefix("assistant_message_phase=") { + self.current_assistant_phase = Some(match phase { + "commentary" => "commentary", + "final_answer" => "final_answer", + _ => "unknown", + }); + } + } + + fn record_tool_call(&mut self, info: &ToolCallLogInfo, elapsed_ms: u128) { + self.last_tool_call_elapsed_ms = Some(elapsed_ms); + self.tool_call_argument_chars += info.argument_chars; + self.tool_call_arguments_done_chars += info.arguments_done_chars; + if !info.names.is_empty() { + self.last_tool_call_names = Some(info.names.join(",")); + } + } + + fn describe_at(&self, elapsed_ms: Option) -> String { + let idle_since_progress_ms = elapsed_ms + .zip(self.last_progress_elapsed_ms) + .map(|(now, last)| now.saturating_sub(last)); format!( - "chunks[start={}, text={} text_chars={}, reasoning={} reasoning_chars={}, tool_calls={} tool_call_bytes={}, incomplete={}, not_supported={}, last={}]", + "chunks[start={}, text={} text_chars={} text_by_phase[commentary={}, final_answer={}, unphased={}], reasoning={} reasoning_chars={}, tool_calls={} tool_call_bytes={} tool_arg_chars={} tool_arg_done_chars={}, assistant_phase={}, metadata={}, response_completed={}, failed={}, incomplete={}, not_supported={}, last={}, last_progress={}] timing[first_chunk_ms={}, last_progress_ms={}, idle_since_progress_ms={}, last_text_ms={}, last_tool_call_ms={}] current_phase={} last_tool_names={} last_metadata={}", self.start_chunks, self.text_chunks, self.text_chars, + self.commentary_text_chars, + self.final_answer_text_chars, + self.unphased_text_chars, self.reasoning_chunks, self.reasoning_chars, self.tool_call_chunks, self.tool_call_bytes, + self.tool_call_argument_chars, + self.tool_call_arguments_done_chars, + self.assistant_phase_chunks, + self.metadata_chunks, + self.response_completed_chunks, + self.failed_chunks, self.incomplete_chunks, self.not_supported_chunks, self.last_chunk.unwrap_or("none"), + self.last_progress_chunk.unwrap_or("none"), + optional_u128(self.first_chunk_elapsed_ms), + optional_u128(self.last_progress_elapsed_ms), + optional_u128(idle_since_progress_ms), + optional_u128(self.last_text_elapsed_ms), + optional_u128(self.last_tool_call_elapsed_ms), + self.current_assistant_phase.unwrap_or("none"), + self.last_tool_call_names.as_deref().unwrap_or("none"), + self.last_metadata.as_deref().unwrap_or("none"), ) } } +#[derive(Clone, Debug, Default)] +struct ToolCallLogInfo { + names: Vec, + ids: Vec, + argument_chars: usize, + arguments_done_chars: usize, +} + +impl ToolCallLogInfo { + fn names_label(&self) -> String { + if self.names.is_empty() { + "unknown".to_string() + } else { + self.names.join(",") + } + } + + fn ids_label(&self) -> String { + if self.ids.is_empty() { + "unknown".to_string() + } else { + self.ids.join(",") + } + } +} + +fn tool_call_log_info(tool_call: &str) -> ToolCallLogInfo { + let mut info = ToolCallLogInfo::default(); + let Ok(value) = serde_json::from_str::(tool_call) else { + return info; + }; + + let Some(items) = value.as_array() else { + return info; + }; + + for item in items { + if let Some(id) = item.get("id").and_then(|id| id.as_str()) { + info.ids.push(id.to_string()); + } + + let Some(function) = item.get("function") else { + continue; + }; + + if let Some(name) = function.get("name").and_then(|name| name.as_str()) { + info.names.push(name.to_string()); + } + if let Some(arguments) = function.get("arguments").and_then(|args| args.as_str()) { + info.argument_chars += arguments.len(); + } + if let Some(arguments_done) = function + .get("arguments_done") + .and_then(|args| args.as_str()) + { + info.arguments_done_chars += arguments_done.len(); + } + } + + info +} + +fn message_phase_label(phase: Option) -> &'static str { + match phase { + Some(MessagePhase::Commentary) => "commentary", + Some(MessagePhase::FinalAnswer) => "final_answer", + None => "unknown", + } +} + +fn optional_u128(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) +} + +fn truncate_log_value(value: &str, max_chars: usize) -> String { + let mut output = String::new(); + for (index, ch) in value.chars().enumerate() { + if index >= max_chars { + output.push_str("..."); + return output; + } + output.push(ch); + } + output +} + #[derive(Clone, Debug)] struct StreamRelayResult { outcome: StreamRelayOutcome, @@ -376,6 +552,9 @@ pub async fn summarize_for_compaction( ChunkType::Reasoning(_) | ChunkType::ToolCall(_) | ChunkType::End(_) + | ChunkType::AssistantMessagePhase { .. } + | ChunkType::ResponseCompleted { .. } + | ChunkType::Metadata(_) | ChunkType::Start | ChunkType::Incomplete(_) => {} } @@ -702,7 +881,7 @@ fn log_stream_summary( error: Option<&str>, ) { let stats = stats - .map(RelayStats::describe) + .map(|stats| stats.describe_at(Some(elapsed_ms))) .unwrap_or_else(|| "chunks=unavailable".to_string()); let error = error .map(|err| format!(" error={}", err)) @@ -719,12 +898,17 @@ fn log_stream_summary( )); } -fn is_sse_transport_error(err: &str) -> bool { +fn is_transport_or_request_error(err: &str) -> bool { let lower = err.to_ascii_lowercase(); - lower.contains("sse error") + ((lower.contains("sse error") || lower.contains("sse transport error")) && (lower.contains("transport") || lower.contains("decoding response body") - || lower.contains("body")) + || lower.contains("body"))) + || (lower.contains("request error") + && (lower.contains("is_timeout=true") + || lower.contains("is_connect=true") + || lower.contains("error sending request"))) + || lower.contains("http error: error sending request") } async fn relay_stream_to_sender( @@ -743,13 +927,14 @@ async fn relay_stream_to_sender( loop { let chunk = tokio::select! { _ = cancel_token.cancelled() => { + let elapsed_ms = start_time.elapsed().as_millis(); let _ = sender.send(crate::llm::ChunkMessage::Cancelled); let _ = log(&format!( "[STREAM_CANCELLED] {} elapsed_ms={} token_estimate={} {}", context.describe(), - start_time.elapsed().as_millis(), + elapsed_ms, *token_count, - stats.describe(), + stats.describe_at(Some(elapsed_ms)), )); return Err(anyhow::anyhow!("Streaming cancelled by user").into()); } @@ -763,17 +948,20 @@ async fn relay_stream_to_sender( match chunk { ChunkType::Text(text) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Text", elapsed_ms); stats.text_chunks += 1; stats.text_chars += text.len(); - stats.last_chunk = Some("Text"); + stats.record_text(text.len(), elapsed_ms); *token_count += estimate_tokens(&text); let _ = log(&format!("[RELAY] Text chunk ({} chars)", text.len())); let _ = sender.send(crate::llm::ChunkMessage::Text(text)); } ChunkType::Reasoning(reasoning) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Reasoning", elapsed_ms); stats.reasoning_chunks += 1; stats.reasoning_chars += reasoning.len(); - stats.last_chunk = Some("Reasoning"); *token_count += estimate_tokens(&reasoning); let _ = log(&format!( "[RELAY] Reasoning chunk ({} chars)", @@ -782,36 +970,29 @@ async fn relay_stream_to_sender( let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } ChunkType::ToolCall(tool_call) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("ToolCall", elapsed_ms); stats.tool_call_chunks += 1; stats.tool_call_bytes += tool_call.len(); - stats.last_chunk = Some("ToolCall"); - let names = serde_json::from_str::(&tool_call) - .ok() - .and_then(|value| { - value.as_array().map(|items| { - items - .iter() - .filter_map(|item| { - item.get("function") - .and_then(|function| function.get("name")) - .and_then(|name| name.as_str()) - }) - .collect::>() - .join(",") - }) - }) - .filter(|names| !names.is_empty()) - .unwrap_or_else(|| "unknown".to_string()); + let info = tool_call_log_info(&tool_call); + stats.record_tool_call(&info, elapsed_ms); let _ = log(&format!( - "[RELAY] ToolCall chunk received names={} bytes={}", - names, - tool_call.len() + "[RELAY] ToolCall chunk received names={} ids={} arg_chars={} arg_done_chars={} bytes={}", + info.names_label(), + info.ids_label(), + info.argument_chars, + info.arguments_done_chars, + tool_call.len(), )); } ChunkType::End(_msg) => { - stats.last_chunk = Some("End"); - let _ = log("[RELAY] End chunk — returning Ended"); - let duration_ms = start_time.elapsed().as_millis() as u64; + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("End", elapsed_ms); + let _ = log(&format!( + "[RELAY] End chunk — returning Ended {}", + stats.describe_at(Some(elapsed_ms)) + )); + let duration_ms = elapsed_ms as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { token_count: *token_count, duration_ms, @@ -822,46 +1003,84 @@ async fn relay_stream_to_sender( stats, }); } + ChunkType::ResponseCompleted { end_turn } => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("ResponseCompleted", elapsed_ms); + stats.response_completed_chunks += 1; + let _ = log(&format!( + "[RELAY] ResponseCompleted chunk end_turn={end_turn:?} — returning Ended {}", + stats.describe_at(Some(elapsed_ms)) + )); + let duration_ms = elapsed_ms as u64; + let _ = sender.send(crate::llm::ChunkMessage::Metrics { + token_count: *token_count, + duration_ms, + }); + let _ = sender.send(crate::llm::ChunkMessage::End); + return Ok(StreamRelayResult { + outcome: StreamRelayOutcome::Ended, + stats, + }); + } + ChunkType::AssistantMessagePhase { phase } => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("AssistantMessagePhase", elapsed_ms); + stats.record_assistant_phase(phase); + let _ = log(&format!( + "[RELAY] AssistantMessagePhase chunk phase={phase:?}" + )); + } + ChunkType::Metadata(message) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Metadata", elapsed_ms); + stats.record_metadata(&message); + let _ = log(&format!("[RELAY] Metadata {}", message)); + } ChunkType::Start => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Start", elapsed_ms); stats.start_chunks += 1; - stats.last_chunk = Some("Start"); let _ = log("[RELAY] Start chunk received"); } ChunkType::Failed(err) => { - stats.last_chunk = Some("Failed"); + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_failed_chunk(); let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); let _ = log(&format!("Stream Chunk Failed {}", err)); let _ = log(&format!( "[STREAM_ERROR] {} elapsed_ms={} token_estimate={} {} error={}", context.describe(), - start_time.elapsed().as_millis(), + elapsed_ms, *token_count, - stats.describe(), + stats.describe_at(Some(elapsed_ms)), err, )); - if is_sse_transport_error(&err) { - let _ = log("[STREAM_ERROR_HINT] SSE transport/body decode failure. This happened below the model layer while reading the response stream; if it repeats, compare network/proxy/VPN state and provider status with the request context above."); + if is_transport_or_request_error(&err) { + let _ = log("[STREAM_ERROR_HINT] Request/stream transport failure. This happened below the model layer while sending or reading provider HTTP data; if it repeats, compare network/proxy/VPN state and provider status with the request and provider_step context above."); } return Err(anyhow::anyhow!("Streaming failed: {}", err).into()); } ChunkType::Incomplete(msg) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Incomplete", elapsed_ms); stats.incomplete_chunks += 1; - stats.last_chunk = Some("Incomplete"); let _ = log(&format!("[RELAY] Incomplete chunk received: {}", msg)); } ChunkType::NotSupported(msg) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("NotSupported", elapsed_ms); stats.not_supported_chunks += 1; - stats.last_chunk = Some("NotSupported"); let _ = log(&format!("[RELAY] NotSupported chunk received: {}", msg)); } } } + let elapsed_ms = start_time.elapsed().as_millis(); let _ = log(&format!( "[RELAY] stream exhausted — returning Exhausted {} token_estimate={} {}", context.describe(), *token_count, - stats.describe(), + stats.describe_at(Some(elapsed_ms)), )); Ok(StreamRelayResult { outcome: StreamRelayOutcome::Exhausted, From 896dea11d88c0cbc83699e823fcacd9202536c7c Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 17:07:23 +0800 Subject: [PATCH 122/226] refactor(dialog): extract content padding to deduplicate layout logic. - Add `content_padding()` and `padded_content_area()` methods to `Dialog` - Replace 5 repeated inline padding calculations with the new methods - Adjust which_key popup: reduce height offset from +10 to +7, separate X/Y padding (3/1) --- _plans/__TODOS.md | 19 +++++++++++ src/ui/components/dialog.rs | 68 +++++++++++++------------------------ src/views/which_key.rs | 13 +++---- 3 files changed, 49 insertions(+), 51 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 739e220..60544e5 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -105,3 +105,22 @@ - [x] Read my <> (ask for permission), deny. The chat doesn't get persisted, just gone. Please save everything before errors. So we can easily say "continue" - [ ] wysiwyg double escape to G + +- [ ] Compaction logic is a little broken. I did /compact, and the context compacted is ALWAYS at the bottom. instead of just at the part where it tried to compact the messages. Can we study how codex and opencode do it? + +- [ ] When a message is sent, the [Image #1] or [Image #2] tags, become just white, not the unique color we have for them in the chat input box. + +- [ ] Syntax highlighting during "Edited" tool calls. Check how Codex does it, because it has syntax highlighting for some reason--It's very clean. + +- [ ] I also think the /copy transcript should show "Edit" tool call results no? Right now it looks as simple as: + **Tool Result** + +**Tool:** edit + +``` +Replaced at line 239 +``` + +- [ ] Not scrolling down consistently when new stream data comes down. + +- [ ] During delete in "sessions dialog" can we color the current "to-be-deleted" list item with red instead of the primary color. And since we're showing "Confirm ctrl+d" after pressing ctrl+d the first time, can we also "esc" to cancel (instead of close the session dialog?) diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index fb46c19..dec7a41 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -816,6 +816,23 @@ impl Dialog { self.update_scrollbar(); } + fn content_padding(&self) -> (u16, u16) { + match self.position { + DialogPosition::Center => (3, 2), + DialogPosition::Left | DialogPosition::Right => (1, 1), + } + } + + fn padded_content_area(&self) -> Rect { + let (padding_x, padding_y) = self.content_padding(); + Rect { + x: self.dialog_area.x + padding_x, + y: self.dialog_area.y + padding_y, + width: self.dialog_area.width.saturating_sub(padding_x * 2), + height: self.dialog_area.height.saturating_sub(padding_y * 2), + } + } + fn get_visible_row_count(&self) -> usize { if self.visible_row_count > 0 { self.visible_row_count @@ -824,11 +841,8 @@ impl Dialog { let footer_height = self.footer_height(); let total_fixed_height = 1 + 1 + SEARCH_AREA_HEIGHT + 1 + footer_height; - let padding = match self.position { - DialogPosition::Center => 3u16, - DialogPosition::Left | DialogPosition::Right => 1u16, - }; - let padding_total = padding * 2; + let (_, padding_y) = self.content_padding(); + let padding_total = padding_y * 2; match self.position { DialogPosition::Center => { @@ -1095,16 +1109,7 @@ impl Dialog { use ratatui::layout::Position; let point = Position::new(event.column, event.row); - let padding = match self.position { - DialogPosition::Center => 3u16, - DialogPosition::Left | DialogPosition::Right => 1u16, - }; - let content_area = Rect { - x: self.dialog_area.x + padding, - y: self.dialog_area.y + padding, - width: self.dialog_area.width.saturating_sub(padding * 2), - height: self.dialog_area.height.saturating_sub(padding * 2), - }; + let content_area = self.padded_content_area(); let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) @@ -1240,16 +1245,7 @@ impl Dialog { return None; } - let padding = match self.position { - DialogPosition::Center => 3u16, - DialogPosition::Left | DialogPosition::Right => 1u16, - }; - let content_area = Rect { - x: self.dialog_area.x + padding, - y: self.dialog_area.y + padding, - width: self.dialog_area.width.saturating_sub(padding * 2), - height: self.dialog_area.height.saturating_sub(padding * 2), - }; + let content_area = self.padded_content_area(); if !content_area.contains(point) { return None; @@ -1287,16 +1283,7 @@ impl Dialog { return None; } - let padding = match self.position { - DialogPosition::Center => 3u16, - DialogPosition::Left | DialogPosition::Right => 1u16, - }; - let content_area = Rect { - x: self.dialog_area.x + padding, - y: self.dialog_area.y + padding, - width: self.dialog_area.width.saturating_sub(padding * 2), - height: self.dialog_area.height.saturating_sub(padding * 2), - }; + let content_area = self.padded_content_area(); if !content_area.contains(point) { return None; @@ -1467,16 +1454,7 @@ impl Dialog { frame.render_widget(Clear, self.dialog_area); - let padding = match self.position { - DialogPosition::Center => 3u16, - DialogPosition::Left | DialogPosition::Right => 1u16, - }; - self.content_area = Rect { - x: self.dialog_area.x + padding, - y: self.dialog_area.y + padding, - width: self.dialog_area.width.saturating_sub(padding * 2), - height: self.dialog_area.height.saturating_sub(padding * 2), - }; + self.content_area = self.padded_content_area(); frame.render_widget( ratatui::widgets::Paragraph::new("") diff --git a/src/views/which_key.rs b/src/views/which_key.rs index 509d4ac..88077f2 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -236,7 +236,7 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo const POPUP_WIDTH: u16 = 58; let popup_width = area.width.min(POPUP_WIDTH); - let popup_height = area.height.min((bindings_count + 10) as u16); + let popup_height = area.height.min((bindings_count + 7) as u16); let popup_area = Rect { x: area.x + (area.width.saturating_sub(popup_width)) / 2, @@ -253,12 +253,13 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo ); // Content area with padding (matching Dialog component) - const PADDING: u16 = 3; + const PADDING_X: u16 = 3; + const PADDING_Y: u16 = 1; let content_area = Rect { - x: popup_area.x + PADDING, - y: popup_area.y + PADDING, - width: popup_area.width.saturating_sub(PADDING * 2), - height: popup_area.height.saturating_sub(PADDING * 2), + x: popup_area.x + PADDING_X, + y: popup_area.y + PADDING_Y, + width: popup_area.width.saturating_sub(PADDING_X * 2), + height: popup_area.height.saturating_sub(PADDING_Y * 2), }; let chunks = Layout::default() From 516764712a1eff4281dea230509fac2179f72816 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 17:29:18 +0800 Subject: [PATCH 123/226] feat: add tool error diagnostics and non-fatal error recovery. - Add request diagnostics to OpenAI provider errors (input summary, tool names, headers) - Add provider step log summaries for message/tool metrics - Make tool execution errors non-fatal: errors returned to model as metadata instead of failing the stream - Add `is_error` field to `ToolExecutionResult` with "Tool `X` failed:" observation format - Send tool error results to UI channel before returning errors in aisdk bridge --- aisdk/src/providers/openai.rs | 191 ++++++++++++++++++- aisdk/src/response.rs | 335 ++++++++++++++++++++++++++++++---- src/tools/aisdk_bridge.rs | 109 ++++++++++- 3 files changed, 593 insertions(+), 42 deletions(-) diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index 5577eaa..81db722 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -251,6 +251,9 @@ impl Provider for OpenAI { body["reasoning"] = serde_json::json!({ "effort": effort }); } + let request_diagnostics = + openai_request_diagnostics(self, &input, tools, &body, &request_headers); + let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs( OPENAI_STREAM_REQUEST_TIMEOUT_SECS, @@ -263,7 +266,14 @@ impl Provider for OpenAI { .json(&body) .send() .await - .map_err(|err| Error::Provider(format_openai_request_error("send", &url, &err)))?; + .map_err(|err| { + Error::Provider(format_openai_request_error( + "send", + &url, + &err, + Some(&request_diagnostics), + )) + })?; if !response.status().is_success() { let status = response.status(); @@ -315,15 +325,190 @@ fn format_openai_sse_error(err: &EventStreamError) -> String { } } -fn format_openai_request_error(stage: &str, request_url: &str, err: &reqwest::Error) -> String { +fn format_openai_request_error( + stage: &str, + request_url: &str, + err: &reqwest::Error, + request_diagnostics: Option<&str>, +) -> String { + let request_diagnostics = request_diagnostics + .map(|diagnostics| format!(" request_diagnostics={}", diagnostics)) + .unwrap_or_default(); + format!( - "OpenAI request error: request_timeout_secs={} request_url={} {}", + "OpenAI request error: request_timeout_secs={} request_url={} {}{}", OPENAI_STREAM_REQUEST_TIMEOUT_SECS, sanitized_url_str(request_url), format_reqwest_error(stage, err), + request_diagnostics, + ) +} + +#[derive(Debug, Default)] +struct OpenAIInputLogSummary { + system_items: usize, + user_items: usize, + assistant_items: usize, + unknown_items: usize, + text_bytes: usize, + image_count: usize, + max_item_role: &'static str, + max_item_bytes: usize, + last_item_role: &'static str, + last_item_bytes: usize, + last_item_images: usize, +} + +fn openai_request_diagnostics( + provider: &OpenAI, + input: &[serde_json::Value], + tools: &[Tool], + body: &serde_json::Value, + headers: &reqwest::header::HeaderMap, +) -> String { + let input_summary = summarize_openai_input(input); + let input_json_bytes = json_bytes(input); + let tool_json_bytes = body.get("tools").map(json_bytes).unwrap_or(0); + let body_json_bytes = json_bytes(body); + let instructions_bytes = provider + .default_instructions + .as_ref() + .map(|instructions| instructions.len()) + .unwrap_or(0); + let store = provider + .store_override + .map(|store| store.to_string()) + .unwrap_or_else(|| "default".to_string()); + let reasoning_effort = provider.reasoning_effort.as_deref().unwrap_or("none"); + + format!( + "model={} responses_path={} stream=true store={} reasoning_effort={} instructions_bytes={} input_items={} input_roles[system={},user={},assistant={},unknown={}] input_text_bytes={} input_images={} input_json_bytes={} max_input[role={},bytes={}] last_input[role={},bytes={},images={}] tools={} tool_names=[{}] tool_json_bytes={} body_json_bytes={} header_names=[{}]", + provider.model_name, + provider.responses_path, + store, + reasoning_effort, + instructions_bytes, + input.len(), + input_summary.system_items, + input_summary.user_items, + input_summary.assistant_items, + input_summary.unknown_items, + input_summary.text_bytes, + input_summary.image_count, + input_json_bytes, + input_summary.max_item_role, + input_summary.max_item_bytes, + input_summary.last_item_role, + input_summary.last_item_bytes, + input_summary.last_item_images, + tools.len(), + compact_tool_names(tools), + tool_json_bytes, + body_json_bytes, + header_names(headers), ) } +fn summarize_openai_input(input: &[serde_json::Value]) -> OpenAIInputLogSummary { + let mut summary = OpenAIInputLogSummary { + max_item_role: "none", + last_item_role: "none", + ..OpenAIInputLogSummary::default() + }; + + for item in input { + let role = input_role(item); + let (text_bytes, image_count) = input_content_size(item.get("content")); + + match role { + "system" => summary.system_items += 1, + "user" => summary.user_items += 1, + "assistant" => summary.assistant_items += 1, + _ => summary.unknown_items += 1, + } + + summary.text_bytes += text_bytes; + summary.image_count += image_count; + summary.last_item_role = role; + summary.last_item_bytes = text_bytes; + summary.last_item_images = image_count; + + if text_bytes > summary.max_item_bytes { + summary.max_item_role = role; + summary.max_item_bytes = text_bytes; + } + } + + summary +} + +fn input_role(item: &serde_json::Value) -> &'static str { + match item.get("role").and_then(|role| role.as_str()) { + Some("system") => "system", + Some("user") => "user", + Some("assistant") => "assistant", + _ => "unknown", + } +} + +fn input_content_size(content: Option<&serde_json::Value>) -> (usize, usize) { + match content { + Some(serde_json::Value::String(text)) => (text.len(), 0), + Some(serde_json::Value::Array(parts)) => parts.iter().fold((0, 0), |mut acc, part| { + match part.get("type").and_then(|value| value.as_str()) { + Some("input_text") => { + acc.0 += part + .get("text") + .and_then(|value| value.as_str()) + .map(|text| text.len()) + .unwrap_or(0); + } + Some("input_image") => acc.1 += 1, + _ => acc.0 += json_bytes(part), + } + acc + }), + Some(value) => (json_bytes(value), 0), + None => (0, 0), + } +} + +fn json_bytes(value: &T) -> usize { + serde_json::to_vec(value) + .map(|bytes| bytes.len()) + .unwrap_or(0) +} + +fn compact_tool_names(tools: &[Tool]) -> String { + const MAX_TOOL_NAMES: usize = 16; + + let mut names = tools + .iter() + .take(MAX_TOOL_NAMES) + .map(|tool| tool.name.as_str()) + .collect::>() + .join(","); + + if tools.len() > MAX_TOOL_NAMES { + if !names.is_empty() { + names.push(','); + } + names.push_str(&format!("+{}", tools.len() - MAX_TOOL_NAMES)); + } + + names +} + +fn header_names(headers: &reqwest::header::HeaderMap) -> String { + let mut names = headers + .keys() + .map(|name| name.as_str().to_ascii_lowercase()) + .collect::>(); + names.sort_unstable(); + names.dedup(); + names.join(",") +} + fn format_reqwest_error(stage: &str, err: &reqwest::Error) -> String { format!( "stage={} is_timeout={} is_connect={} is_request={} is_body={} is_decode={} status={} url={} source_chain={} debug={:?}", diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 691d71b..05b1661 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -100,11 +100,13 @@ pub async fn stream_with_tools( } } + let step_summary = provider_step_log_summary(¤t_messages, &tools); let _ = tx_loop.send(ChunkType::Metadata(format!( - "provider_step_start step={} messages={} tools={}", + "provider_step_start step={} messages={} tools={} {}", step_idx, current_messages.len(), - tools.len() + tools.len(), + step_summary ))); let stream_result = provider_clone @@ -115,10 +117,11 @@ pub async fn stream_with_tools( Ok(s) => s, Err(e) => { let err = format!( - "provider_step_error step={} messages={} tools={} error={}", + "provider_step_error step={} messages={} tools={} {} error={}", step_idx, current_messages.len(), tools.len(), + step_summary, e ); let _ = tx_loop.send(ChunkType::Failed(err.clone())); @@ -257,7 +260,7 @@ pub async fn stream_with_tools( } }; - let mut successful_tool_results = Vec::new(); + let mut tool_results_to_observe = Vec::new(); let mut tool_calls_to_run = Vec::new(); for (call_id, tool_name, args) in tool_calls_to_execute { @@ -267,7 +270,7 @@ pub async fn stream_with_tools( .and_then(|key| cached_repeatable_tool_results.get(key)) .cloned() { - successful_tool_results.push(ToolExecutionResult { + tool_results_to_observe.push(ToolExecutionResult { call_id, tool_name, output: format!( @@ -275,6 +278,7 @@ pub async fn stream_with_tools( cached_output ), cache_key: None, + is_error: false, }); } else { tool_calls_to_run.push((call_id, tool_name, args, cache_key)); @@ -287,18 +291,29 @@ pub async fn stream_with_tools( async move { match tool { - Some(t) => t - .execute - .call(args) - .await - .map(|output| ToolExecutionResult { + Some(t) => match t.execute.call(args).await { + Ok(output) => ToolExecutionResult { call_id, tool_name: tool_name.clone(), output, cache_key, - }) - .map_err(|e| format!("Tool '{}' error: {}", tool_name, e)), - None => Err(format!("Tool not found: {}", tool_name)), + is_error: false, + }, + Err(err) => ToolExecutionResult { + call_id, + tool_name: tool_name.clone(), + output: format!("Tool '{}' error: {}", tool_name, err), + cache_key: None, + is_error: true, + }, + }, + None => ToolExecutionResult { + call_id, + tool_name: tool_name.clone(), + output: format!("Tool not found: {}", tool_name), + cache_key: None, + is_error: true, + }, } } }, @@ -306,31 +321,32 @@ pub async fn stream_with_tools( .await; for result in tool_results { - match result { - Ok(result) => { - if let Some(cache_key) = result.cache_key.as_ref() { - cached_repeatable_tool_results - .insert(cache_key.clone(), result.output.clone()); - } - successful_tool_results.push(result); - } - Err(err) => { - let _ = tx_loop.send(ChunkType::Failed(err)); - } + if result.is_error { + let _ = tx_loop.send(ChunkType::Metadata(format!( + "tool_result_error tool={} call_id={} output_chars={}", + result.tool_name, + result.call_id, + result.output.len() + ))); + } else if let Some(cache_key) = result.cache_key.as_ref() { + cached_repeatable_tool_results.insert(cache_key.clone(), result.output.clone()); } + tool_results_to_observe.push(result); } - if !successful_tool_results.is_empty() { - let observation = format_tool_observation(&successful_tool_results); - let tool_names = successful_tool_results + if !tool_results_to_observe.is_empty() { + let observation = format_tool_observation(&tool_results_to_observe); + let tool_names = tool_results_to_observe .iter() .map(|result| result.tool_name.as_str()) .collect::>() .join(","); + let tool_result_summary = tool_results_log_summary(&tool_results_to_observe); let _ = tx_loop.send(ChunkType::Metadata(format!( - "tool_results_added count={} names={} observation_chars={} next_messages={}", - successful_tool_results.len(), + "tool_results_added count={} names={} {} observation_chars={} next_messages={}", + tool_results_to_observe.len(), tool_names, + tool_result_summary, observation.len(), current_messages.len() + 1 ))); @@ -344,6 +360,143 @@ pub async fn stream_with_tools( Ok(response) } +#[derive(Debug, Default)] +struct MessageLogSummary { + system_messages: usize, + user_messages: usize, + assistant_messages: usize, + text_bytes: usize, + image_count: usize, + max_message_role: &'static str, + max_message_bytes: usize, + last_message_role: &'static str, + last_message_bytes: usize, + last_message_images: usize, +} + +fn provider_step_log_summary(messages: &[Message], tools: &[Tool]) -> String { + let messages = message_log_summary(messages); + let tools = tool_log_summary(tools); + + format!( + "message_roles[system={},user={},assistant={}] message_text_bytes={} images={} max_message[role={},bytes={}] last_message[role={},bytes={},images={}] {}", + messages.system_messages, + messages.user_messages, + messages.assistant_messages, + messages.text_bytes, + messages.image_count, + messages.max_message_role, + messages.max_message_bytes, + messages.last_message_role, + messages.last_message_bytes, + messages.last_message_images, + tools, + ) +} + +fn message_log_summary(messages: &[Message]) -> MessageLogSummary { + let mut summary = MessageLogSummary { + max_message_role: "none", + last_message_role: "none", + ..MessageLogSummary::default() + }; + + for message in messages { + let role = message_role(message); + let (text_bytes, image_count) = message_size(message); + + match message { + Message::System(_) => summary.system_messages += 1, + Message::User(_) => summary.user_messages += 1, + Message::Assistant(_) => summary.assistant_messages += 1, + } + + summary.text_bytes += text_bytes; + summary.image_count += image_count; + summary.last_message_role = role; + summary.last_message_bytes = text_bytes; + summary.last_message_images = image_count; + + if text_bytes > summary.max_message_bytes { + summary.max_message_role = role; + summary.max_message_bytes = text_bytes; + } + } + + summary +} + +fn message_role(message: &Message) -> &'static str { + match message { + Message::System(_) => "system", + Message::User(_) => "user", + Message::Assistant(_) => "assistant", + } +} + +fn message_size(message: &Message) -> (usize, usize) { + match message { + Message::System(message) => (message.content.len(), 0), + Message::User(message) => (message.content.len(), message.images.len()), + Message::Assistant(message) => (message.content.len(), 0), + } +} + +fn tool_log_summary(tools: &[Tool]) -> String { + let schema_bytes = tools + .iter() + .filter_map(|tool| serde_json::to_vec(&tool.input_schema).ok()) + .map(|schema| schema.len()) + .sum::(); + let description_bytes = tools + .iter() + .map(|tool| tool.description.len()) + .sum::(); + let tool_names = compact_tool_names(tools); + + format!( + "tool_names=[{}] tool_schema_bytes={} tool_description_bytes={}", + tool_names, schema_bytes, description_bytes, + ) +} + +fn compact_tool_names(tools: &[Tool]) -> String { + const MAX_TOOL_NAMES: usize = 16; + + let mut names = tools + .iter() + .take(MAX_TOOL_NAMES) + .map(|tool| tool.name.as_str()) + .collect::>() + .join(","); + + if tools.len() > MAX_TOOL_NAMES { + if !names.is_empty() { + names.push(','); + } + names.push_str(&format!("+{}", tools.len() - MAX_TOOL_NAMES)); + } + + names +} + +fn tool_results_log_summary(results: &[ToolExecutionResult]) -> String { + let output_bytes = results + .iter() + .map(|result| result.output.len()) + .sum::(); + let error_results = results.iter().filter(|result| result.is_error).count(); + let max_output = results.iter().max_by_key(|result| result.output.len()); + let (max_tool, max_bytes) = max_output + .map(|result| (result.tool_name.as_str(), result.output.len())) + .unwrap_or(("none", 0)); + + format!( + "output_bytes={} error_results={} max_output[tool={},bytes={}]", + output_bytes, error_results, max_tool, max_bytes, + ) +} + #[derive(Debug, Default)] struct ToolCallAccumulator { calls: Vec, @@ -365,6 +518,7 @@ struct ToolExecutionResult { tool_name: String, output: String, cache_key: Option, + is_error: bool, } fn repeatable_tool_cache_key(tool_name: &str, args: &serde_json::Value) -> Option { @@ -403,20 +557,30 @@ fn canonical_json(value: &serde_json::Value) -> String { fn format_tool_observation(results: &[ToolExecutionResult]) -> String { if let [result] = results { + if result.is_error { + return format!( + "Tool `{}` failed:\n{}\n\nUse this tool error to adjust the next step. Do not repeat the same tool call unchanged unless the underlying file or input has changed.", + result.tool_name, result.output + ); + } + return format!("Tool `{}` result:\n{}", result.tool_name, result.output); } + let failed = results.iter().filter(|result| result.is_error).count(); let mut observation = format!( - "Tool batch results: {} tool calls completed. Use these results to answer the user's request. Do not repeat the same tool calls unless the results are missing or insufficient.", - results.len() + "Tool batch results: {} tool calls returned, {} failed. Use these results to answer the user's request or adjust the next step. Do not repeat the same failing tool calls unchanged.", + results.len(), + failed ); for (idx, result) in results.iter().enumerate() { observation.push_str(&format!( - "\n\n\n{}\n", + "\n\n\n{}\n", idx + 1, result.tool_name, result.call_id, + if result.is_error { "error" } else { "ok" }, result.output )); } @@ -615,7 +779,7 @@ mod tests { use std::collections::HashMap; use std::sync::{ atomic::{AtomicUsize, Ordering}, - Arc, + Arc, Mutex, }; use std::time::Duration; use tokio::sync::Barrier; @@ -638,6 +802,12 @@ mod tests { requests: Arc, } + #[derive(Debug, Clone)] + struct RecoveringToolFailureProvider { + requests: Arc, + observed_follow_up: Arc>>, + } + #[async_trait] impl Provider for TwoToolCallProvider { fn name(&self) -> &str { @@ -774,6 +944,51 @@ mod tests { } } + #[async_trait] + impl Provider for RecoveringToolFailureProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + messages: &[Message], + _tools: &[Tool], + _headers: &HashMap, + ) -> crate::error::Result { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = if request == 0 { + vec![ + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_edit","type":"function","function":{"name":"edit","arguments":"{\"file_path\":\"src/lib.rs\",\"old_string\":\"missing\",\"new_string\":\"replacement\"}"}}]"# + .to_string(), + )), + Ok(ChunkType::End(String::new())), + ] + } else { + let follow_up = messages + .last() + .and_then(|message| match message { + Message::User(user) => Some(user.content.clone()), + _ => None, + }) + .unwrap_or_default(); + *self.observed_follow_up.lock().unwrap() = Some(follow_up); + + vec![ + Ok(ChunkType::Text("recovered".to_string())), + Ok(ChunkType::End(String::new())), + ] + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + #[tokio::test] async fn executes_same_step_tool_calls_concurrently() { let provider = TwoToolCallProvider { @@ -958,6 +1173,60 @@ mod tests { assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); } + #[tokio::test] + async fn tool_execution_error_is_returned_to_model_without_failing_stream() { + let observed_follow_up = Arc::new(Mutex::new(None)); + let provider = RecoveringToolFailureProvider { + requests: Arc::new(AtomicUsize::new(0)), + observed_follow_up: observed_follow_up.clone(), + }; + + let edit_tool = Tool::builder() + .name("edit") + .description("edit files") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(move |_input| async move { + Err("Execution error: Not found: Could not find text to replace".to_string()) + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("make the edit")], + vec![edit_tool], + Some(3), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + let mut failed_chunks = Vec::new(); + while let Some(chunk) = response.stream.next().await { + match chunk { + ChunkType::Text(delta) => text.push_str(&delta), + ChunkType::Failed(err) => failed_chunks.push(err), + _ => {} + } + } + + assert_eq!(text, "recovered"); + assert!(failed_chunks.is_empty()); + assert_eq!(provider.requests.load(Ordering::SeqCst), 2); + assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); + + let follow_up = observed_follow_up + .lock() + .unwrap() + .clone() + .expect("provider should receive failed tool observation"); + assert!(follow_up.contains("Tool `edit` failed")); + assert!(follow_up.contains("Could not find text to replace")); + assert!(follow_up.contains("Do not repeat the same tool call unchanged")); + } + #[test] fn accumulates_streamed_openai_tool_call_arguments() { let mut accumulator = ToolCallAccumulator::default(); diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 0a67f09..4a6264d 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -78,16 +78,41 @@ pub async fn convert_to_aisdk_tools( let handler = registry .get(&tool_id_for_exec) .await - .ok_or_else(|| format!("Tool '{}' not found", tool_id_for_exec))?; + .ok_or_else(|| format!("Tool '{}' not found", tool_id_for_exec)); + let handler = match handler { + Ok(handler) => handler, + Err(err) => { + send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] error {} {}", + tool_id_for_exec, err + )); + return Err(err); + } + }; if let Err(e) = handler.validate(&input) { - return Err(format!("Validation error: {}", e)); + let err = format!("Validation error: {}", e); + send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] error {} {}", + tool_id_for_exec, err + )); + return Err(err); } - permissions + if let Err(e) = permissions .preflight(&agent_mode, &tool_id_for_exec, &input, sender.as_ref()) .await - .map_err(|e| format!("{}", e))?; + { + let err = format!("{}", e); + send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] error {} {}", + tool_id_for_exec, err + )); + return Err(err); + } let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); let ctx = ToolContext::new( @@ -101,7 +126,18 @@ pub async fn convert_to_aisdk_tools( let tool_result = handler .execute(input, &ctx) .await - .map_err(|e| format!("Execution error: {}", e))?; + .map_err(|e| format!("Execution error: {}", e)); + let tool_result = match tool_result { + Ok(tool_result) => tool_result, + Err(err) => { + send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] error {} {}", + tool_id_for_exec, err + )); + return Err(err); + } + }; let _ = crate::logging::log(&format!( "[AISDK_TOOL] result {} bytes={}", @@ -209,6 +245,38 @@ fn truncate_tool_output(output: &str, limit: usize) -> String { truncated } +fn send_tool_error_result( + sender: Option<&ChunkSender>, + call_id: &str, + tool_name: &str, + error: &str, +) { + let Some(sender) = sender else { + return; + }; + + let preview = truncate_tool_output(error, TOOL_UI_PREVIEW_LIMIT); + let payload = serde_json::json!({ + "status": "error", + "title": "Tool failed", + "output_preview": preview, + "line_count": error.lines().count().max(1), + "metadata": { + "error": error, + }, + }) + .to_string(); + + let _ = sender.send(crate::llm::ChunkMessage::ToolResult( + crate::llm::ToolCallResult { + tool_call_id: call_id.to_string(), + role: "tool".to_string(), + name: tool_name.to_string(), + content: payload, + }, + )); +} + fn param_to_json_schema(param_type: &crate::tools::ParameterType) -> serde_json::Value { use crate::tools::ParameterType; @@ -237,7 +305,7 @@ fn param_to_json_schema(param_type: &crate::tools::ParameterType) -> serde_json: #[cfg(test)] mod tests { - use super::truncate_tool_output; + use super::{send_tool_error_result, truncate_tool_output}; #[test] fn truncate_tool_output_bounds_large_results() { @@ -255,4 +323,33 @@ mod tests { assert_eq!(truncate_tool_output(output, 60_000), output); } + + #[test] + fn send_tool_error_result_emits_error_payload() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + send_tool_error_result( + Some(&tx), + "call_1", + "edit", + "Execution error: Could not find text to replace", + ); + + let message = rx.try_recv().expect("expected tool result"); + let crate::llm::ChunkMessage::ToolResult(result) = message else { + panic!("expected tool result message"); + }; + + assert_eq!(result.tool_call_id, "call_1"); + assert_eq!(result.name, "edit"); + + let payload: serde_json::Value = + serde_json::from_str(&result.content).expect("payload should be json"); + assert_eq!(payload["status"], "error"); + assert_eq!(payload["title"], "Tool failed"); + assert_eq!( + payload["output_preview"], + "Execution error: Could not find text to replace" + ); + } } From 72ec12720aab6453ceea47916518c297f80fc5dc Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 17:35:37 +0800 Subject: [PATCH 124/226] fix(chat): style image placeholders with markdown_image color. Replace simple text wrapping with styled span generation that parses `[Image #N]` placeholders and applies a dedicated fg/bg style, so inline image references are visually distinct from regular text. --- _plans/__TODOS.md | 6 ++- src/ui/components/chat.rs | 94 ++++++++++++++++++++++++++++++++++----- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 60544e5..913fbfa 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -108,7 +108,7 @@ - [ ] Compaction logic is a little broken. I did /compact, and the context compacted is ALWAYS at the bottom. instead of just at the part where it tried to compact the messages. Can we study how codex and opencode do it? -- [ ] When a message is sent, the [Image #1] or [Image #2] tags, become just white, not the unique color we have for them in the chat input box. +- [x] When a message is sent, the [Image #1] or [Image #2] tags, become just white, not the unique color we have for them in the chat input box. - [ ] Syntax highlighting during "Edited" tool calls. Check how Codex does it, because it has syntax highlighting for some reason--It's very clean. @@ -124,3 +124,7 @@ Replaced at line 239 - [ ] Not scrolling down consistently when new stream data comes down. - [ ] During delete in "sessions dialog" can we color the current "to-be-deleted" list item with red instead of the primary color. And since we're showing "Confirm ctrl+d" after pressing ctrl+d the first time, can we also "esc" to cancel (instead of close the session dialog?) + +- [ ] Don't log to app.log with logging.rs in the future, but in the future, add a custom env build flag so that when I `cargo install --path` with this flag, I include the "development release build" - so I can use the fast compiled version while having logs. And the normal cargo install --path, will still just be like a production build. + +- [ ] Don't prevent scroll when there's a permission required dialog. diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 8c557ad..1958481 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1845,6 +1845,7 @@ impl Chat { let border_style = Style::default().fg(border_color); let pad_style = Style::default().bg(bg); let text_style = Style::default().fg(colors.text).bg(bg); + let image_style = Style::default().fg(colors.markdown_image).bg(bg); let content = message.content.clone(); let horizontal_padding = 2usize; let right_padding = 2usize; @@ -1859,22 +1860,26 @@ impl Chat { ]) }; - // Wrap content to fit within max_width - padding - let wrapped_lines = textwrap::wrap(&content, wrap_width); + let styled_content = Line::from(spans_with_image_placeholders( + &content, + text_style, + image_style, + )); + let wrapped_lines = wrap_styled_line(&styled_content, WrapOptions::new(wrap_width)); lines.push(padding_line()); - for line in wrapped_lines.iter() { - let line_width = UnicodeWidthStr::width(line.as_ref()); + for line in wrapped_lines { + let line_width = line.width(); let trailing_padding = " ".repeat(max_width.saturating_sub(1 + horizontal_padding + line_width)); + let mut spans = Vec::with_capacity(line.spans.len() + 3); + spans.push(Span::styled("▌", border_style)); + spans.push(Span::styled(" ".repeat(horizontal_padding), pad_style)); + spans.extend(line.spans); + spans.push(Span::styled(trailing_padding, pad_style)); - lines.push(Line::from(vec![ - Span::styled("▌", border_style), - Span::styled(" ".repeat(horizontal_padding), pad_style), - Span::styled(line.to_string(), text_style), - Span::styled(trailing_padding, pad_style), - ])); + lines.push(Line::from(spans)); } lines.push(padding_line()); @@ -2963,6 +2968,46 @@ fn line_uses_background(line: &Line<'_>, bg: Color) -> bool { line.spans.iter().any(|span| span.style.bg == Some(bg)) } +fn spans_with_image_placeholders( + text: &str, + text_style: Style, + image_style: Style, +) -> Vec> { + let mut spans = Vec::new(); + let mut remaining = text; + + while let Some(start) = remaining.find("[Image #") { + if start > 0 { + spans.push(Span::styled(remaining[..start].to_string(), text_style)); + } + + let placeholder_start = &remaining[start..]; + let Some(end_offset) = placeholder_start.find(']') else { + spans.push(Span::styled(placeholder_start.to_string(), text_style)); + return spans; + }; + let end = start + end_offset + 1; + let placeholder = &remaining[start..end]; + + if placeholder["[Image #".len()..placeholder.len() - 1] + .chars() + .all(|ch| ch.is_ascii_digit()) + { + spans.push(Span::styled(placeholder.to_string(), image_style)); + } else { + spans.push(Span::styled(placeholder.to_string(), text_style)); + } + + remaining = &remaining[end..]; + } + + if !remaining.is_empty() || spans.is_empty() { + spans.push(Span::styled(remaining.to_string(), text_style)); + } + + spans +} + fn line_to_static(line: Line<'_>) -> Line<'static> { Line { spans: line @@ -3425,6 +3470,35 @@ mod tests { ); } + #[test] + fn test_user_message_image_placeholders_use_markdown_image_color() { + let chat = Chat::new(); + let msg = Message::user("see [Image #1] and [Image #2]"); + let mut colors = test_colors(); + colors.text = Color::White; + colors.background_element = Color::Rgb(10, 10, 10); + colors.markdown_image = Color::Rgb(0, 200, 255); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let content_line = lines + .iter() + .find(|line| line_text(line).contains("[Image #1]")) + .expect("rendered image placeholders"); + + let image_spans = content_line + .spans + .iter() + .filter(|span| span.content.starts_with("[Image #")) + .collect::>(); + assert_eq!(image_spans.len(), 2); + assert!(image_spans + .iter() + .all(|span| span.style.fg == Some(colors.markdown_image))); + assert!(image_spans + .iter() + .all(|span| span.style.bg == Some(colors.background_element))); + } + #[test] fn test_compaction_summary_renders_marker() { let mut msg = Message::user(format!( From 8577d5a58be833af2f2800945eeb2491d53c6538 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 17:38:42 +0800 Subject: [PATCH 125/226] fix: keep chat pinned to bottom when new stream content arrives. When new stream data comes in, detect if the user was at the bottom (scroll_offset == MAX or bottom position without manual scroll-up) and snap to max_offset instead of clamping, ensuring the viewport consistently follows new content. --- _plans/__TODOS.md | 2 +- src/ui/components/chat.rs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 913fbfa..6a9c7be 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -121,7 +121,7 @@ Replaced at line 239 ``` -- [ ] Not scrolling down consistently when new stream data comes down. +- [x] Fix issue where it's not scrolling down consistently when new stream data comes down. - [ ] During delete in "sessions dialog" can we color the current "to-be-deleted" list item with red instead of the primary color. And since we're showing "Confirm ctrl+d" after pressing ctrl+d the first time, can we also "esc" to cancel (instead of close the session dialog?) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 1958481..35cd903 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1423,7 +1423,14 @@ impl Chat { let content_height = all_lines.len(); let viewport = self.viewport_height; let max_offset = content_height.saturating_sub(viewport); - let clamped_scroll = self.scroll_offset.min(max_offset); + let was_pinned_to_bottom = self.scroll_offset == usize::MAX + || (self.scroll_offset >= self.content_height.saturating_sub(self.viewport_height) + && !self.user_scrolled_up); + let clamped_scroll = if was_pinned_to_bottom { + max_offset + } else { + self.scroll_offset.min(max_offset) + }; let visible_start = clamped_scroll.min(content_height); let visible_end = content_height.min(clamped_scroll.saturating_add(viewport)); From 052a623c3e2eae672a378a9eda3a4aabb331b064 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 17:40:41 +0800 Subject: [PATCH 126/226] fix: allow scroll passthrough when permission dialog is open. When the permission dialog is displayed but the mouse event (scroll) is not handled by it, forward the scroll event to the chat widget instead of swallowing it. This prevents scroll blocking while a dialog is showing. --- _plans/__TODOS.md | 2 +- src/app.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 6a9c7be..38d5a88 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -127,4 +127,4 @@ Replaced at line 239 - [ ] Don't log to app.log with logging.rs in the future, but in the future, add a custom env build flag so that when I `cargo install --path` with this flag, I include the "development release build" - so I can use the fast compiled version while having logs. And the normal cargo install --path, will still just be like a production build. -- [ ] Don't prevent scroll when there's a permission required dialog. +- [x] Don't prevent scroll when there's a permission required dialog. diff --git a/src/app.rs b/src/app.rs index 6145cba..eb3d14d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2079,7 +2079,54 @@ impl App { self.overlay_focus = OverlayFocus::None; } } else if self.overlay_focus == OverlayFocus::PermissionDialog { - let _ = handle_permission_dialog_mouse_event(&mut self.permission_dialog_state, mouse); + let handled = handle_permission_dialog_mouse_event(&mut self.permission_dialog_state, mouse); + if !handled + && matches!( + mouse.kind, + ratatui::crossterm::event::MouseEventKind::ScrollDown + | ratatui::crossterm::event::MouseEventKind::ScrollUp + ) + && self.base_focus == BaseFocus::Chat + { + let size = self.last_frame_size; + let main_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(1), + ] + .as_ref(), + ) + .split(size); + let input_height = self.input.get_height() as u16; + let input_height = if self.is_subagent_session_active() { + SUBAGENT_FOOTER_HEIGHT + } else { + input_height + }; + let help_height = if self.is_subagent_session_active() { + 0 + } else { + 1 + }; + let above_status_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Length(0), + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(0), + ratatui::layout::Constraint::Length(input_height), + ratatui::layout::Constraint::Length(help_height), + ratatui::layout::Constraint::Length(1), + ] + .as_ref(), + ) + .split(main_chunks[0]); + let chat_area = above_status_chunks[1]; + let _ = self.chat_state.chat.handle_mouse_event(mouse, chat_area); + } } else if self.overlay_focus == OverlayFocus::QuestionDialog { let _ = handle_question_dialog_mouse_event(&mut self.question_dialog_state, mouse); } else if self.overlay_focus == OverlayFocus::ThemesDialog { From 62c3ca2c17a6ccb6863318c842c881879abfda0c Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 17:45:33 +0800 Subject: [PATCH 127/226] feat(sessions-dialog): add esc to cancel pending delete. - Esc key now cancels pending delete instead of closing the dialog - Pending delete items are styled with error color instead of primary --- _plans/__TODOS.md | 4 ++- src/ui/components/dialog.rs | 12 +++---- src/views/sessions_dialog.rs | 61 +++++++++++++++++++++++++++++++++--- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 38d5a88..c3c35aa 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -123,8 +123,10 @@ Replaced at line 239 - [x] Fix issue where it's not scrolling down consistently when new stream data comes down. -- [ ] During delete in "sessions dialog" can we color the current "to-be-deleted" list item with red instead of the primary color. And since we're showing "Confirm ctrl+d" after pressing ctrl+d the first time, can we also "esc" to cancel (instead of close the session dialog?) +- [x] During delete in "sessions dialog" can we color the current "to-be-deleted" list item with red instead of the primary color. And since we're showing "Confirm ctrl+d" after pressing ctrl+d the first time, can we also "esc" to cancel (instead of close the session dialog?) - [ ] Don't log to app.log with logging.rs in the future, but in the future, add a custom env build flag so that when I `cargo install --path` with this flag, I include the "development release build" - so I can use the fast compiled version while having logs. And the normal cargo install --path, will still just be like a production build. - [x] Don't prevent scroll when there's a permission required dialog. + +- [ ] Proper textwrapping of input for the input chatbox. I can paste a long string (that doesnt compact), or type a long sentence, and it won't wrap to the next line. It just has horizontal scrolling. I dont want horizontal scrolling. diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index dec7a41..3f477ab 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -1576,18 +1576,18 @@ impl Dialog { let mut spans = Self::item_spans_for_width(item, list_area_width as usize, colors); - if is_selected { - let fg = contrast_text(colors.primary); + if is_pending_delete { + let fg = contrast_text(colors.error); for span in &mut spans { let mut style = span.style.clone(); - style = style.fg(fg).bg(colors.primary); + style = style.fg(fg).bg(colors.error); span.style = style; } - } else if is_pending_delete { - let fg = contrast_text(colors.error); + } else if is_selected { + let fg = contrast_text(colors.primary); for span in &mut spans { let mut style = span.style.clone(); - style = style.fg(fg).bg(colors.error); + style = style.fg(fg).bg(colors.primary); span.style = style; } } diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index 7526083..a0ce322 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -209,6 +209,11 @@ pub fn handle_sessions_dialog_key_event( } } + if event.code == KeyCode::Esc && dialog_state.pending_delete.is_some() { + dialog_state.pending_delete = None; + return SessionsDialogAction::Handled; + } + if event.code == KeyCode::Char('r') && event.modifiers == KeyModifiers::CONTROL { if let Some(selected) = dialog_state.dialog.get_selected() { let title = if selected.provider_id.is_empty() { @@ -351,10 +356,16 @@ fn with_sessions_actions( confirm_delete: bool, ) -> Dialog { if confirm_delete { - return dialog.with_actions(vec![FooterAction { - label: "confirm".to_string(), - key: "ctrl+d".to_string(), - }]); + return dialog.with_actions(vec![ + FooterAction { + label: "Confirm".to_string(), + key: "ctrl+d".to_string(), + }, + FooterAction { + label: "Cancel".to_string(), + key: "esc".to_string(), + }, + ]); } dialog.with_actions(vec![ @@ -452,6 +463,48 @@ mod tests { assert_eq!(action, SessionsDialogAction::NewSession); } + #[test] + fn esc_cancels_pending_delete_without_closing_sessions_dialog() { + let mut state = + init_sessions_dialog("Sessions", vec![session_item("session-1", "First session")]); + state.dialog.show(); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + ); + assert_eq!( + action, + SessionsDialogAction::PendingDelete("session-1".to_string()) + ); + assert_eq!(state.pending_delete.as_deref(), Some("session-1")); + assert!(state.dialog.is_visible()); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert_eq!(state.pending_delete, None); + assert!(state.dialog.is_visible()); + } + + #[test] + fn esc_closes_sessions_dialog_without_pending_delete() { + let mut state = + init_sessions_dialog("Sessions", vec![session_item("session-1", "First session")]); + state.dialog.show(); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Close); + assert!(!state.dialog.is_visible()); + } + #[test] fn down_moves_from_last_session_in_workspace_to_next_workspace_header() { let mut state = init_sessions_dialog( From 82d90a2c3d260dc9e640070580e10b9c6b021dce Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 20:18:11 +0800 Subject: [PATCH 128/226] fix(providers): use connect timeout instead of request timeout for streaming SSE connections. Replace the 30s request timeout with a connect timeout across Anthropic, OpenAI, and compatible providers so that long-running SSE streams don't get killed prematurely. Also enrich OpenAI SSE error messages with the request URL and note that body timeout is disabled. --- _plans/__TODOS.md | 9 +++++++++ aisdk/src/providers/anthropic.rs | 6 +++++- aisdk/src/providers/compatible.rs | 6 +++++- aisdk/src/providers/openai.rs | 22 ++++++++++++---------- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index c3c35aa..e9dfd4b 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -130,3 +130,12 @@ Replaced at line 239 - [x] Don't prevent scroll when there's a permission required dialog. - [ ] Proper textwrapping of input for the input chatbox. I can paste a long string (that doesnt compact), or type a long sentence, and it won't wrap to the next line. It just has horizontal scrolling. I dont want horizontal scrolling. + +- [ ] Codex's "update plan" tool sometimes has a weird premble before the actual checklist shows... Is this relevant for crabcode? Should we update our tool? Can we do it too? + +- [ ] Pressing 'enter' while focusing on a grouplabel header for a "workspace". Make it show a dropdown on the right: + - Archive (can unarchive on new sessions) + - Collapse + - Uncollapse + +- [ ] The footer note for the current cwd/workspace. It trims out the very start. i.e. `...ects/_gamedev/my-game:main`. Instead of this, please show the "between" truncation ?? Just maybe, but maybe not. diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs index 1e075cc..87888c5 100644 --- a/aisdk/src/providers/anthropic.rs +++ b/aisdk/src/providers/anthropic.rs @@ -8,6 +8,8 @@ use eventsource_stream::Eventsource; use futures::StreamExt; use std::collections::HashMap; +const ANTHROPIC_STREAM_CONNECT_TIMEOUT_SECS: u64 = 30; + #[derive(Debug, Clone)] pub struct Anthropic { base_url: String, @@ -159,7 +161,9 @@ impl Provider for Anthropic { request_headers.insert("anthropic-version", "2023-06-01".parse().unwrap()); let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs( + ANTHROPIC_STREAM_CONNECT_TIMEOUT_SECS, + )) .build() .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; let response = client diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index 9b12f76..81d4997 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -8,6 +8,8 @@ use futures::stream; use futures::StreamExt; use std::collections::HashMap; +const COMPATIBLE_STREAM_CONNECT_TIMEOUT_SECS: u64 = 30; + #[derive(Debug, Clone)] pub struct OpenAICompatible { base_url: String, @@ -159,7 +161,9 @@ impl Provider for OpenAICompatible { } let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs( + COMPATIBLE_STREAM_CONNECT_TIMEOUT_SECS, + )) .build() .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; let response = client diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index 81db722..efa23af 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -9,7 +9,7 @@ use futures::StreamExt; use std::collections::HashMap; use std::error::Error as StdError; -const OPENAI_STREAM_REQUEST_TIMEOUT_SECS: u64 = 30; +const OPENAI_STREAM_CONNECT_TIMEOUT_SECS: u64 = 30; const OPENAI_ERROR_BODY_MAX_CHARS: usize = 2048; #[derive(Debug, Clone)] @@ -255,8 +255,8 @@ impl Provider for OpenAI { openai_request_diagnostics(self, &input, tools, &body, &request_headers); let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs( - OPENAI_STREAM_REQUEST_TIMEOUT_SECS, + .connect_timeout(std::time::Duration::from_secs( + OPENAI_STREAM_CONNECT_TIMEOUT_SECS, )) .build() .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; @@ -291,13 +291,14 @@ impl Provider for OpenAI { ))); } + let request_url = url.clone(); let stream = response .bytes_stream() .eventsource() - .filter_map(|ev| match ev { + .filter_map(move |ev| match ev { Ok(event) => futures::future::ready(response_sse_data_to_chunk(&event.data)), Err(e) => { - let err = format_openai_sse_error(&e); + let err = format_openai_sse_error(&e, &request_url); futures::future::ready(Some(Ok(ChunkType::Failed(err)))) } }) @@ -307,12 +308,13 @@ impl Provider for OpenAI { } } -fn format_openai_sse_error(err: &EventStreamError) -> String { +fn format_openai_sse_error(err: &EventStreamError, request_url: &str) -> String { match err { EventStreamError::Transport(source) => { format!( - "SSE transport error: request_timeout_secs={} {}", - OPENAI_STREAM_REQUEST_TIMEOUT_SECS, + "SSE transport error: stream_connect_timeout_secs={} stream_body_timeout=disabled request_url={} {}", + OPENAI_STREAM_CONNECT_TIMEOUT_SECS, + sanitized_url_str(request_url), format_reqwest_error("stream_body", source), ) } @@ -336,8 +338,8 @@ fn format_openai_request_error( .unwrap_or_default(); format!( - "OpenAI request error: request_timeout_secs={} request_url={} {}{}", - OPENAI_STREAM_REQUEST_TIMEOUT_SECS, + "OpenAI request error: stream_connect_timeout_secs={} stream_body_timeout=disabled request_url={} {}{}", + OPENAI_STREAM_CONNECT_TIMEOUT_SECS, sanitized_url_str(request_url), format_reqwest_error(stage, err), request_diagnostics, From c500dc7098a67b8a6e38377c1c3963c98faf690a Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 21:32:22 +0800 Subject: [PATCH 129/226] fix: premature stops. --- aisdk/src/response.rs | 100 ++++++++++++++++++++++++++++++++++++++---- aisdk/src/stop.rs | 16 ++++++- src/llm/client.rs | 34 ++++++++------ 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 05b1661..9221659 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -143,11 +143,7 @@ pub async fn stream_with_tools( Ok(ChunkType::AssistantMessagePhase { phase }) => { current_assistant_message_phase = phase; last_assistant_message_phase = phase; - let label = match phase { - Some(MessagePhase::Commentary) => "commentary", - Some(MessagePhase::FinalAnswer) => "final_answer", - None => "unknown", - }; + let label = message_phase_label(phase); let _ = tx_loop.send(ChunkType::Metadata(format!( "assistant_message_phase={label}" ))); @@ -228,16 +224,34 @@ pub async fn stream_with_tools( } if !has_tool_call { - let needs_follow_up = matches!(response_end_turn, Some(false)) - || matches!(last_assistant_message_phase, Some(MessagePhase::Commentary)); + let end_turn_requires_follow_up = matches!(response_end_turn, Some(false)); + let commentary_requires_follow_up = + matches!(last_assistant_message_phase, Some(MessagePhase::Commentary)); + let needs_follow_up = end_turn_requires_follow_up || commentary_requires_follow_up; + let action = if needs_follow_up { + "continue" + } else { + "finish" + }; + let _ = tx_loop.send(ChunkType::Metadata(format!( + "provider_step_finish step={} has_tool_call=false end_turn={:?} last_phase={} assistant_text_chars={} action={} preview={:?}", + step_idx, + response_end_turn, + message_phase_label(last_assistant_message_phase), + assistant_text.len(), + action, + log_preview(&assistant_text, 160) + ))); + if needs_follow_up { - let reason = if matches!(response_end_turn, Some(false)) { + let reason = if end_turn_requires_follow_up { "end_turn=false" } else { "assistant_message_phase=commentary" }; let _ = tx_loop.send(ChunkType::Metadata(format!( - "continuing model turn after non-final assistant output ({reason})" + "continuing model turn after non-final assistant output step={} reason={}", + step_idx, reason ))); continue; } @@ -497,6 +511,41 @@ fn tool_results_log_summary(results: &[ToolExecutionResult]) -> String { ) } +fn message_phase_label(phase: Option) -> &'static str { + match phase { + Some(MessagePhase::Commentary) => "commentary", + Some(MessagePhase::FinalAnswer) => "final_answer", + None => "unknown", + } +} + +fn log_preview(text: &str, max_chars: usize) -> String { + let mut preview = String::new(); + let mut chars = 0usize; + let mut previous_was_whitespace = false; + + for ch in text.trim().chars() { + if chars >= max_chars { + preview.push_str("..."); + break; + } + + if ch.is_whitespace() { + if !previous_was_whitespace && !preview.is_empty() { + preview.push(' '); + chars += 1; + } + previous_was_whitespace = true; + } else { + preview.push(ch); + chars += 1; + previous_was_whitespace = false; + } + } + + preview +} + #[derive(Debug, Default)] struct ToolCallAccumulator { calls: Vec, @@ -1173,6 +1222,39 @@ mod tests { assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); } + #[tokio::test] + async fn max_steps_allows_exact_configured_step_count() { + let provider = FollowUpProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("finish the task")], + Vec::new(), + Some(1), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + let mut incomplete = Vec::new(); + while let Some(chunk) = response.stream.next().await { + match chunk { + ChunkType::Text(delta) => text.push_str(&delta), + ChunkType::Incomplete(message) => incomplete.push(message), + _ => {} + } + } + + assert_eq!(text, "I'll inspect that next."); + assert_eq!(provider.requests.load(Ordering::SeqCst), 1); + assert_eq!(incomplete, vec!["Max steps reached".to_string()]); + assert_eq!(response.stop_reason().await, Some(StopReason::Hook)); + } + #[tokio::test] async fn tool_execution_error_is_returned_to_model_without_failing_stream() { let observed_follow_up = Arc::new(Mutex::new(None)); diff --git a/aisdk/src/stop.rs b/aisdk/src/stop.rs index 98d44bd..bbcf071 100644 --- a/aisdk/src/stop.rs +++ b/aisdk/src/stop.rs @@ -12,8 +12,22 @@ pub fn step_count_is(max_steps: usize) -> StopWhenFn { let counter = std::sync::atomic::AtomicUsize::new(0); Arc::new(move |step_count: usize| { counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - step_count >= max_steps + step_count > max_steps }) } pub type StopWhenFn = Arc bool + Send + Sync>; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn step_count_is_allows_exact_configured_steps() { + let stop = step_count_is(2); + + assert!(!stop(1)); + assert!(!stop(2)); + assert!(stop(3)); + } +} diff --git a/src/llm/client.rs b/src/llm/client.rs index 41af637..9f028c6 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1,7 +1,7 @@ use aisdk::core::{ chunk::{ChunkType, MessagePhase}, response::{stream_with_tools, LanguageModelStream, StreamTextResponse}, - stop::{step_count_is, StopReason}, + stop::StopReason, Message as AisdkMessage, Tool, }; use aisdk::message::ImageContent; @@ -80,6 +80,18 @@ enum StreamRelayOutcome { Exhausted, } +fn stream_outcome_label( + outcome: StreamRelayOutcome, + stop_reason: Option<&StopReason>, +) -> &'static str { + match (outcome, stop_reason) { + (StreamRelayOutcome::Ended, _) => "Ended", + (StreamRelayOutcome::Exhausted, Some(StopReason::Finish)) => "Finished", + (StreamRelayOutcome::Exhausted, Some(StopReason::Hook)) => "StepLimit", + (StreamRelayOutcome::Exhausted, _) => "Exhausted", + } +} + #[derive(Clone, Copy, Debug)] struct StreamLogContext<'a> { phase: &'a str, @@ -438,15 +450,13 @@ pub async fn stream_llm_with_cancellation( let stop_reason = response.stop_reason().await; let stream_outcome = relay_result.outcome; + let primary_outcome_label = stream_outcome_label(stream_outcome, stop_reason.as_ref()); let _ = log(&format!( - "Stream completed: outcome={stream_outcome:?}, stop_reason={stop_reason:?}, agent_max_steps={agent_max_steps:?}", + "Stream completed: outcome={stream_outcome:?}, effective_outcome={primary_outcome_label}, stop_reason={stop_reason:?}, agent_max_steps={agent_max_steps:?}", )); log_stream_summary( primary_log_context, - match stream_outcome { - StreamRelayOutcome::Ended => "Ended", - StreamRelayOutcome::Exhausted => "Exhausted", - }, + primary_outcome_label, stop_reason.as_ref(), token_count, start_time.elapsed().as_millis(), @@ -498,10 +508,7 @@ pub async fn stream_llm_with_cancellation( let stop_reason = summary_response.stop_reason().await; log_stream_summary( summary_log_context, - match result.outcome { - StreamRelayOutcome::Ended => "Ended", - StreamRelayOutcome::Exhausted => "Exhausted", - }, + stream_outcome_label(result.outcome, stop_reason.as_ref()), stop_reason.as_ref(), token_count, start_time.elapsed().as_millis(), @@ -741,7 +748,6 @@ async fn stream_provider_request( tools: Vec, max_steps: Option, ) -> Result { - let stop_when = max_steps.map(|s| step_count_is(s)); let headers = HashMap::new(); match config.kind { @@ -757,7 +763,7 @@ async fn stream_provider_request( builder = builder.api_key(key); } let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; - stream_with_tools(provider, messages, tools, max_steps, stop_when, headers) + stream_with_tools(provider, messages, tools, max_steps, None, headers) .await .map_err(|e| Box::new(e) as DynError) } @@ -773,7 +779,7 @@ async fn stream_provider_request( builder = builder.api_key(key); } let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; - stream_with_tools(provider, messages, tools, max_steps, stop_when, headers) + stream_with_tools(provider, messages, tools, max_steps, None, headers) .await .map_err(|e| Box::new(e) as DynError) } @@ -811,7 +817,7 @@ async fn stream_provider_request( } let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; - stream_with_tools(provider, messages, tools, max_steps, stop_when, headers) + stream_with_tools(provider, messages, tools, max_steps, None, headers) .await .map_err(|e| Box::new(e) as DynError) } From 26c7ce0d15062bd7d9694c04d02d01c13099ad9b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 21:34:03 +0800 Subject: [PATCH 130/226] fix: chat input box wrapping fixes. --- src/ui/components/input.rs | 636 +++++++++++++++++++++++++++++++------ src/views/chat.rs | 2 +- src/views/home.rs | 2 +- 3 files changed, 540 insertions(+), 100 deletions(-) diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index a7d0420..b7d348b 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -10,6 +10,7 @@ use ratatui::crossterm::event::{ }; use ratatui::prelude::{Rect, Style}; use ratatui::symbols::border; +use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Paragraph}; use std::ops::Range; use std::path::PathBuf; @@ -17,22 +18,6 @@ use tui_textarea::{CursorMove, Input as TuiInput, TextArea}; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; -/// Convert a display-column position to a byte offset within a string. -/// Handles multi-byte and wide characters (emoji, CJK, etc.) -fn display_col_to_byte_offset(line: &str, display_col: usize) -> usize { - let mut current_display = 0; - - for (byte_idx, c) in line.char_indices() { - let char_width = UnicodeWidthChar::width(c).unwrap_or(1); - if display_col < current_display + char_width { - return byte_idx; - } - current_display += char_width; - } - - line.len() -} - /// Clamp a byte offset to the nearest valid UTF-8 character boundary in `s`. fn char_boundary_before(s: &str, byte_idx: usize) -> usize { let idx = byte_idx.min(s.len()); @@ -55,13 +40,21 @@ fn char_kind(c: char) -> u8 { } const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; +const MAX_TEXTAREA_HEIGHT: usize = 6; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct VisualLine { + source_row: usize, + start_col: usize, + end_col: usize, +} pub struct Input { textarea: TextArea<'static>, pub autocomplete: Option, textarea_area: Option, viewport_top: usize, - viewport_left: usize, + preferred_visual_col: Option, prompt_history: Option, draft_text: Option, local_images: Vec, @@ -102,7 +95,7 @@ impl Input { autocomplete: None, textarea_area: None, viewport_top: 0, - viewport_left: 0, + preferred_visual_col: None, prompt_history, draft_text: None, local_images: Vec::new(), @@ -147,9 +140,6 @@ impl Input { let bg = Block::default().style(Style::default().bg(colors.background_element)); frame.render_widget(bg, bg_area); - let line_count = self.textarea.lines().len().max(1); - let textarea_height = line_count.min(6) as u16; - let h_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Horizontal) .constraints([ @@ -159,6 +149,9 @@ impl Input { ]) .split(inner_area); + let wrap_width = h_chunks[1].width as usize; + let textarea_height = self.textarea_height(wrap_width) as u16; + let v_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ @@ -181,11 +174,8 @@ impl Input { ); let visible_lines = v_chunks[1].height as usize; - let visible_cols = v_chunks[1].width as usize; - self.update_viewport(visible_lines, visible_cols); - - frame.render_widget(&self.textarea, v_chunks[1]); - self.style_placeholder_ranges(frame.buffer_mut(), v_chunks[1], colors); + self.update_viewport(visible_lines, wrap_width); + self.render_wrapped_textarea(frame, v_chunks[1], colors); let mut info_spans = vec![ ratatui::text::Span::styled(agent.to_string(), Style::default().fg(agent_color)), @@ -229,11 +219,18 @@ impl Input { } pub fn get_height(&self) -> u16 { + // The exact wrap width is only known during render; keep the existing + // compact default so layout can reserve space before the first draw. let line_count = self.textarea.lines().len().max(1); - let textarea_height = line_count.min(6) as u16; + let textarea_height = line_count.min(MAX_TEXTAREA_HEIGHT) as u16; textarea_height + 4 } + pub fn get_height_for_width(&self, area_width: u16) -> u16 { + let wrap_width = area_width.saturating_sub(5).max(1) as usize; + self.textarea_height(wrap_width) as u16 + 4 + } + pub fn handle_event(&mut self, event: KeyEvent) -> bool { let input = TuiInput::from(event); @@ -268,8 +265,11 @@ impl Input { // Handle Up arrow for prompt history navigation // Trigger when cursor is on first line if event.code == KeyCode::Up && event.modifiers == KeyModifiers::NONE { - let (cursor_row, _) = self.textarea.cursor(); - if cursor_row == 0 { + if self.move_cursor_visual(-1) { + return true; + } + + if self.is_cursor_on_first_visual_line() { let current_text = self.get_text(); if let Some(ref mut history) = self.prompt_history { if let Some(prompt) = history.navigate_up(¤t_text) { @@ -286,9 +286,11 @@ impl Input { // Handle Down arrow for prompt history navigation if event.code == KeyCode::Down && event.modifiers == KeyModifiers::NONE { - let line_count = self.textarea.lines().len(); - let (cursor_row, _) = self.textarea.cursor(); - if cursor_row == line_count.saturating_sub(1) { + if self.move_cursor_visual(1) { + return true; + } + + if self.is_cursor_on_last_visual_line() { let current_text = self.get_text(); let should_reset = if let Some(ref mut history) = self.prompt_history { if let Some(prompt) = history.navigate_down(¤t_text) { @@ -329,6 +331,7 @@ impl Input { match event.code { KeyCode::Char('j') if event.modifiers == KeyModifiers::CONTROL => { + self.preferred_visual_col = None; self.textarea.insert_newline(); self.sync_image_placeholders(); self.sync_pending_pastes(); @@ -336,6 +339,7 @@ impl Input { } KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => false, KeyCode::Char('u') if event.modifiers == KeyModifiers::CONTROL => { + self.preferred_visual_col = None; let (cursor_row, cursor_col) = self.textarea.cursor(); if let Some(line) = self.textarea.lines().get(cursor_row) { // Clamp to valid char boundary to avoid panics on multi-byte emoji @@ -354,6 +358,7 @@ impl Input { KeyCode::Backspace if self.remove_placeholder_at_cursor(false) => true, KeyCode::Delete if self.remove_placeholder_at_cursor(true) => true, KeyCode::Backspace if event.modifiers.contains(KeyModifiers::ALT) => { + self.preferred_visual_col = None; // Handle Alt+Backspace (word-delete) ourselves to avoid // tui-textarea's buggy word boundary with multi-byte emoji self.delete_word_backward(); @@ -362,6 +367,7 @@ impl Input { true } _ => { + self.preferred_visual_col = None; self.textarea.input(input); self.sync_image_placeholders(); self.sync_pending_pastes(); @@ -391,47 +397,21 @@ impl Input { match mouse.kind { MouseEventKind::ScrollDown => { - let line_count = self.textarea.lines().len(); - let visible_lines = textarea_area.height as usize; - - if line_count > visible_lines { - let max_viewport_top = line_count.saturating_sub(visible_lines); - if self.viewport_top < max_viewport_top { - self.viewport_top += 1; - let target_row = self.viewport_top + visible_lines - 1; - let (_, cursor_col) = self.textarea.cursor(); - self.textarea - .move_cursor(CursorMove::Jump(target_row as u16, cursor_col as u16)); - } - } + self.move_cursor_visual(1); true } MouseEventKind::ScrollUp => { - let line_count = self.textarea.lines().len(); - let visible_lines = textarea_area.height as usize; - - if line_count > visible_lines { - if self.viewport_top > 0 { - self.viewport_top -= 1; - let target_row = self.viewport_top; - let (_, cursor_col) = self.textarea.cursor(); - self.textarea - .move_cursor(CursorMove::Jump(target_row as u16, cursor_col as u16)); - } - } + self.move_cursor_visual(-1); true } MouseEventKind::Down(MouseButton::Left) => { + self.preferred_visual_col = None; let relative_x = mouse_x.saturating_sub(textarea_area.x); let relative_y = mouse_y.saturating_sub(textarea_area.y); - let lines = self.textarea.lines(); - let target_row = self.viewport_top + relative_y as usize; - - if target_row < lines.len() { - let line = &lines[target_row]; - let target_col = - display_col_to_byte_offset(line, self.viewport_left + relative_x as usize); + if let Some((target_row, target_col)) = + self.cursor_for_screen_position(textarea_area, relative_x, relative_y) + { let offset = self.flat_offset_for_position(target_row, target_col); if let Some(image) = self.image_at_offset(offset) { match image_attachment::open_path(&image.path) { @@ -453,8 +433,9 @@ impl Input { .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); self.textarea.start_selection(); } else { + let lines = self.textarea.lines(); let last_row = lines.len().saturating_sub(1); - let last_col = lines[last_row].len(); + let last_col = lines[last_row].chars().count(); self.textarea .move_cursor(CursorMove::Jump(last_row as u16, last_col as u16)); self.textarea.start_selection(); @@ -462,17 +443,14 @@ impl Input { true } MouseEventKind::Drag(MouseButton::Left) => { + self.preferred_visual_col = None; // Extend the ongoing selection let relative_x = mouse_x.saturating_sub(textarea_area.x); let relative_y = mouse_y.saturating_sub(textarea_area.y); - let lines = self.textarea.lines(); - let target_row = self.viewport_top + relative_y as usize; - - if target_row < lines.len() { - let line = &lines[target_row]; - let target_col = - display_col_to_byte_offset(line, self.viewport_left + relative_x as usize); + if let Some((target_row, target_col)) = + self.cursor_for_screen_position(textarea_area, relative_x, relative_y) + { // Since start_selection() was called and is_selecting() is true, // move_cursor extends the selection self.textarea @@ -638,6 +616,41 @@ impl Input { Some(text.trim_start_matches('/').to_string()) } + fn char_col_to_byte_offset(line: &str, col: usize) -> usize { + line.char_indices() + .nth(col) + .map(|(idx, _)| idx) + .unwrap_or(line.len()) + } + + fn line_char_slice(line: &str, start_col: usize, end_col: usize) -> &str { + let start = Self::char_col_to_byte_offset(line, start_col); + let end = Self::char_col_to_byte_offset(line, end_col); + &line[start..end] + } + + fn display_col_to_char_col( + line: &str, + start_col: usize, + end_col: usize, + display_col: usize, + ) -> usize { + let mut current_display = 0; + + for (offset, ch) in Self::line_char_slice(line, start_col, end_col) + .chars() + .enumerate() + { + let char_width = UnicodeWidthChar::width(ch).unwrap_or(1); + if display_col < current_display + char_width { + return start_col + offset; + } + current_display += char_width; + } + + end_col + } + fn flat_cursor_offset(&self) -> usize { let (row, col) = self.textarea.cursor(); let lines = self.textarea.lines(); @@ -645,7 +658,11 @@ impl Input { for line in lines.iter().take(row) { offset += line.len() + 1; } - offset + col.min(lines.get(row).map(|line| line.len()).unwrap_or(0)) + offset + + lines + .get(row) + .map(|line| Self::char_col_to_byte_offset(line, col)) + .unwrap_or(0) } fn flat_offset_for_position(&self, row: usize, col: usize) -> usize { @@ -654,7 +671,11 @@ impl Input { for line in lines.iter().take(row) { offset += line.len() + 1; } - offset + col.min(lines.get(row).map(|line| line.len()).unwrap_or(0)) + offset + + lines + .get(row) + .map(|line| Self::char_col_to_byte_offset(line, col)) + .unwrap_or(0) } fn cursor_for_flat_offset(text: &str, mut offset: usize) -> (usize, usize) { @@ -663,12 +684,15 @@ impl Input { for (row, line) in text.split('\n').enumerate() { let line_end = consumed + line.len(); if offset <= line_end { - return (row, offset - consumed); + return (row, line[..offset - consumed].chars().count()); } consumed = line_end + 1; } let last_line = text.rsplit('\n').next().unwrap_or(""); - (text.lines().count().saturating_sub(1), last_line.len()) + ( + text.lines().count().saturating_sub(1), + last_line.chars().count(), + ) } fn reset_textarea(&mut self) { @@ -689,7 +713,7 @@ impl Input { self.textarea .move_cursor(CursorMove::Jump(row as u16, col as u16)); self.viewport_top = 0; - self.viewport_left = 0; + self.preferred_visual_col = None; } fn image_placeholder(number: usize) -> String { @@ -709,18 +733,308 @@ impl Input { } } - fn update_viewport(&mut self, visible_lines: usize, visible_cols: usize) { + fn textarea_height(&self, wrap_width: usize) -> usize { + self.visual_lines(wrap_width) + .len() + .max(1) + .min(MAX_TEXTAREA_HEIGHT) + } + + fn visual_lines(&self, wrap_width: usize) -> Vec { + let wrap_width = wrap_width.max(1); + let mut visual_lines = Vec::new(); + + for (source_row, line) in self.textarea.lines().iter().enumerate() { + let line_len = line.chars().count(); + if line_len == 0 { + visual_lines.push(VisualLine { + source_row, + start_col: 0, + end_col: 0, + }); + continue; + } + + let mut start_col = 0; + while start_col < line_len { + let mut end_col = start_col; + let mut width = 0; + + for ch in line.chars().skip(start_col) { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if end_col > start_col && width + ch_width > wrap_width { + break; + } + width += ch_width; + end_col += 1; + if width >= wrap_width { + break; + } + } + + if end_col == start_col { + end_col += 1; + } + + visual_lines.push(VisualLine { + source_row, + start_col, + end_col, + }); + start_col = end_col; + } + } + + visual_lines + } + + fn cursor_visual_row(&self, visual_lines: &[VisualLine]) -> Option { let (cursor_row, cursor_col) = self.textarea.cursor(); - let line_count = self.textarea.lines().len(); - let max_viewport_top = line_count.saturating_sub(visible_lines); + let line_len = self + .textarea + .lines() + .get(cursor_row) + .map(|line| line.chars().count()) + .unwrap_or(0); + + visual_lines + .iter() + .enumerate() + .find_map(|(idx, visual_line)| { + if visual_line.source_row != cursor_row { + return None; + } + + let contains_cursor = cursor_col >= visual_line.start_col + && (cursor_col < visual_line.end_col + || (cursor_col == visual_line.end_col && cursor_col == line_len)); + + contains_cursor.then_some(idx) + }) + } + + fn cursor_display_col(&self, visual_line: &VisualLine) -> usize { + let (_, cursor_col) = self.textarea.cursor(); + let Some(line) = self.textarea.lines().get(visual_line.source_row) else { + return 0; + }; + let cursor_col = cursor_col.clamp(visual_line.start_col, visual_line.end_col); + UnicodeWidthStr::width(Self::line_char_slice( + line, + visual_line.start_col, + cursor_col, + )) + } + + fn move_cursor_visual(&mut self, direction: isize) -> bool { + let Some(area) = self.textarea_area else { + return false; + }; + if area.width == 0 { + return false; + } + + let visual_lines = self.visual_lines(area.width as usize); + let Some(current_idx) = self.cursor_visual_row(&visual_lines) else { + return false; + }; + + let target_idx = if direction < 0 { + match current_idx.checked_sub(1) { + Some(idx) => idx, + None => return false, + } + } else { + let idx = current_idx + 1; + if idx >= visual_lines.len() { + return false; + } + idx + }; + + let preferred_col = self + .preferred_visual_col + .unwrap_or_else(|| self.cursor_display_col(&visual_lines[current_idx])); + let target = &visual_lines[target_idx]; + let Some(line) = self.textarea.lines().get(target.source_row) else { + return false; + }; + let target_col = + Self::display_col_to_char_col(line, target.start_col, target.end_col, preferred_col); + + self.textarea.move_cursor(CursorMove::Jump( + target.source_row as u16, + target_col as u16, + )); + self.preferred_visual_col = Some(preferred_col); + true + } + + fn is_cursor_on_first_visual_line(&self) -> bool { + let Some(area) = self.textarea_area else { + return self.textarea.cursor().0 == 0; + }; + let visual_lines = self.visual_lines(area.width as usize); + self.cursor_visual_row(&visual_lines) == Some(0) + } + + fn is_cursor_on_last_visual_line(&self) -> bool { + let Some(area) = self.textarea_area else { + return self.textarea.cursor().0 == self.textarea.lines().len().saturating_sub(1); + }; + let visual_lines = self.visual_lines(area.width as usize); + self.cursor_visual_row(&visual_lines) == visual_lines.len().checked_sub(1) + } + + fn cursor_for_screen_position( + &self, + area: Rect, + relative_x: u16, + relative_y: u16, + ) -> Option<(usize, usize)> { + let visual_lines = self.visual_lines(area.width as usize); + let visual_idx = self.viewport_top + relative_y as usize; + let visual_line = visual_lines.get(visual_idx)?; + let line = self.textarea.lines().get(visual_line.source_row)?; + let target_col = Self::display_col_to_char_col( + line, + visual_line.start_col, + visual_line.end_col, + relative_x as usize, + ); + + Some((visual_line.source_row, target_col)) + } + + fn render_wrapped_textarea( + &mut self, + frame: &mut ratatui::Frame, + area: Rect, + colors: &ThemeColors, + ) { + if area.width == 0 || area.height == 0 { + return; + } + + let text_style = self.textarea.style(); + let cursor_style = self.textarea.cursor_style(); + let selection_style = self.textarea.selection_style(); + let selection_range = self.textarea.selection_range(); + let cursor = self.textarea.cursor(); + let visual_lines = self.visual_lines(area.width as usize); + + let text = if self.is_empty() && !self.textarea.placeholder_text().is_empty() { + let placeholder_style = self + .textarea + .placeholder_style() + .unwrap_or_else(|| Style::default().fg(colors.text_weak)); + Text::from(Line::from(vec![ + Span::styled(" ", cursor_style), + Span::styled( + self.textarea.placeholder_text().to_string(), + placeholder_style, + ), + ])) + } else { + let lines = self.textarea.lines(); + let rendered = visual_lines + .iter() + .skip(self.viewport_top) + .take(area.height as usize) + .filter_map(|visual_line| { + let line = lines.get(visual_line.source_row)?; + Some(Self::render_visual_line( + line, + visual_line, + text_style, + cursor_style, + selection_style, + selection_range, + cursor, + )) + }) + .collect::>(); + Text::from(rendered) + }; + + frame.render_widget(Paragraph::new(text).style(text_style), area); + self.style_placeholder_ranges(frame.buffer_mut(), area, colors, &visual_lines); + } + + fn render_visual_line( + line: &str, + visual_line: &VisualLine, + text_style: Style, + cursor_style: Style, + selection_style: Style, + selection_range: Option<((usize, usize), (usize, usize))>, + cursor: (usize, usize), + ) -> Line<'static> { + let line_len = line.chars().count(); + let mut spans = Vec::new(); + + if visual_line.start_col == visual_line.end_col { + if cursor == (visual_line.source_row, visual_line.start_col) { + spans.push(Span::styled(" ", cursor_style)); + } + return Line::from(spans); + } + + for (idx, ch) in Self::line_char_slice(line, visual_line.start_col, visual_line.end_col) + .chars() + .enumerate() + { + let col = visual_line.start_col + idx; + let mut style = text_style; + + if Self::position_in_selection(selection_range, visual_line.source_row, col) { + style = selection_style; + } + if cursor == (visual_line.source_row, col) { + style = cursor_style; + } + + spans.push(Span::styled(ch.to_string(), style)); + } + + if cursor == (visual_line.source_row, visual_line.end_col) + && visual_line.end_col == line_len + { + spans.push(Span::styled(" ", cursor_style)); + } + + Line::from(spans) + } + + fn position_in_selection( + selection_range: Option<((usize, usize), (usize, usize))>, + row: usize, + col: usize, + ) -> bool { + let Some((start, end)) = selection_range else { + return false; + }; + (row, col) >= start && (row, col) < end + } + + fn update_viewport(&mut self, visible_lines: usize, wrap_width: usize) { + let visual_lines = self.visual_lines(wrap_width); + let cursor_visual_row = self.cursor_visual_row(&visual_lines).unwrap_or(0); + let max_viewport_top = visual_lines.len().saturating_sub(visible_lines); self.viewport_top = self.viewport_top.min(max_viewport_top); - self.viewport_top = Self::next_scroll_offset(self.viewport_top, cursor_row, visible_lines) - .min(max_viewport_top); - self.viewport_left = Self::next_scroll_offset(self.viewport_left, cursor_col, visible_cols); + self.viewport_top = + Self::next_scroll_offset(self.viewport_top, cursor_visual_row, visible_lines) + .min(max_viewport_top); } - fn style_placeholder_ranges(&self, buffer: &mut Buffer, area: Rect, colors: &ThemeColors) { + fn style_placeholder_ranges( + &self, + buffer: &mut Buffer, + area: Rect, + colors: &ThemeColors, + visual_lines: &[VisualLine], + ) { if area.width == 0 || area.height == 0 { return; } @@ -728,13 +1042,16 @@ impl Input { let placeholder_style = Style::default().fg(colors.markdown_image); let lines = self.textarea.lines(); - for (line_idx, line) in lines + for (screen_row, visual_line) in visual_lines .iter() - .enumerate() .skip(self.viewport_top) .take(area.height as usize) + .enumerate() { - let y = area.y + (line_idx - self.viewport_top) as u16; + let Some(line) = lines.get(visual_line.source_row) else { + continue; + }; + let y = area.y + screen_row as u16; for image in &self.local_images { for (start, _) in line.match_indices(&image.placeholder) { @@ -744,7 +1061,7 @@ impl Input { y, line, start..start + image.placeholder.len(), - self.viewport_left, + visual_line, placeholder_style, ); } @@ -758,7 +1075,7 @@ impl Input { y, line, start..start + paste.placeholder.len(), - self.viewport_left, + visual_line, placeholder_style, ); } @@ -772,7 +1089,7 @@ impl Input { y: u16, line: &str, range: Range, - viewport_left: usize, + visual_line: &VisualLine, style: Style, ) { if range.start > range.end @@ -783,16 +1100,27 @@ impl Input { return; } - let start_col = UnicodeWidthStr::width(&line[..range.start]); - let end_col = start_col + UnicodeWidthStr::width(&line[range]); - let visible_start = start_col.max(viewport_left); - let visible_end = end_col.min(viewport_left + area.width as usize); + let range_start_col = line[..range.start].chars().count(); + let range_end_col = range_start_col + line[range].chars().count(); + let visible_start = range_start_col.max(visual_line.start_col); + let visible_end = range_end_col.min(visual_line.end_col); - for col in visible_start..visible_end { - let x = area.x + (col - viewport_left) as u16; + if visible_start >= visible_end { + return; + } + + let prefix = Self::line_char_slice(line, visual_line.start_col, visible_start); + let mut x_offset = UnicodeWidthStr::width(prefix); + + for ch in Self::line_char_slice(line, visible_start, visible_end).chars() { + if x_offset >= area.width as usize { + break; + } + let x = area.x + x_offset as u16; if let Some(cell) = buffer.cell_mut((x, y)) { cell.set_style(style); } + x_offset += UnicodeWidthChar::width(ch).unwrap_or(0); } } @@ -978,6 +1306,7 @@ impl Input { pub fn attach_image(&mut self, path: PathBuf) { let placeholder = Self::image_placeholder(self.local_images.len() + 1); + self.preferred_visual_col = None; self.textarea.insert_str(&placeholder); self.local_images .push(LocalImageAttachment { placeholder, path }); @@ -1103,7 +1432,7 @@ impl Input { pub fn clear(&mut self) { self.reset_textarea(); self.viewport_top = 0; - self.viewport_left = 0; + self.preferred_visual_col = None; self.draft_text = None; self.local_images.clear(); self.pending_pastes.clear(); @@ -1133,18 +1462,20 @@ impl Input { self.reset_textarea(); self.textarea.insert_str(text); self.viewport_top = 0; - self.viewport_left = 0; + self.preferred_visual_col = None; self.local_images.clear(); self.pending_pastes.clear(); } pub fn insert_char(&mut self, c: char) { + self.preferred_visual_col = None; self.textarea.insert_str(c.to_string().as_str()); self.sync_image_placeholders(); self.sync_pending_pastes(); } pub fn insert_str(&mut self, text: &str) { + self.preferred_visual_col = None; self.textarea.insert_str(text); self.sync_image_placeholders(); self.sync_pending_pastes(); @@ -1157,6 +1488,7 @@ impl Input { if char_count > LARGE_PASTE_CHAR_THRESHOLD { self.sync_pending_pastes(); let placeholder = self.next_large_paste_placeholder(char_count); + self.preferred_visual_col = None; self.textarea.insert_str(&placeholder); self.pending_pastes.push(PendingPaste { placeholder, @@ -1245,6 +1577,33 @@ mod tests { } } + fn key_event(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn modified_key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn mouse_event(kind: MouseEventKind) -> MouseEvent { + MouseEvent { + kind, + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + } + } + fn buffer_row_text(buffer: &ratatui::buffer::Buffer, width: u16, y: u16) -> String { (0..width) .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string())) @@ -1444,6 +1803,87 @@ mod tests { assert_eq!(input.submission_text(), ""); } + #[test] + fn test_long_unbroken_input_wraps_instead_of_scrolling() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + input.insert_str("0123456789ABCDEF"); + + let colors = test_colors(); + let backend = TestBackend::new(20, 10); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 20, 10), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let first_input_row = buffer_row_text(buffer, 20, 1); + let second_input_row = buffer_row_text(buffer, 20, 2); + + assert!(first_input_row.contains("0123456789ABCDE")); + assert!(!first_input_row.contains('F')); + assert!(second_input_row.contains('F')); + } + + #[test] + fn test_wrapped_input_and_paste_increase_height_like_newlines() { + let mut newline_input = Input::new(); + newline_input.insert_str("a"); + assert!(newline_input.handle_event(modified_key_event(KeyCode::Enter, KeyModifiers::SHIFT))); + newline_input.insert_str("b"); + + let mut wrapped_input = Input::new(); + wrapped_input.insert_str("0123456789ABCDEF"); + + let mut pasted_input = Input::new(); + pasted_input.insert_paste("0123456789ABCDEF"); + + assert_eq!(newline_input.get_height_for_width(20), 6); + assert_eq!(wrapped_input.get_height_for_width(20), 6); + assert_eq!(pasted_input.get_height_for_width(20), 6); + } + + #[test] + fn test_up_down_move_across_wrapped_visual_lines() { + let mut input = Input::new(); + input.insert_str("0123456789ABCDEF"); + input.textarea_area = Some(Rect::new(0, 0, 15, 6)); + + input.textarea.move_cursor(CursorMove::Jump(0, 0)); + assert!(input.handle_event(key_event(KeyCode::Down))); + assert_eq!(input.textarea.cursor(), (0, 15)); + assert_eq!(input.get_text(), "0123456789ABCDEF"); + + assert!(input.handle_event(key_event(KeyCode::Up))); + assert_eq!(input.textarea.cursor(), (0, 0)); + assert_eq!(input.get_text(), "0123456789ABCDEF"); + } + + #[test] + fn test_mouse_scroll_moves_across_wrapped_visual_lines() { + let mut input = Input::new(); + input.insert_str("0123456789ABCDEF"); + input.textarea_area = Some(Rect::new(0, 0, 15, 6)); + input.textarea.move_cursor(CursorMove::Jump(0, 0)); + + assert!(input.handle_mouse_event(mouse_event(MouseEventKind::ScrollDown))); + assert_eq!(input.textarea.cursor(), (0, 15)); + + assert!(input.handle_mouse_event(mouse_event(MouseEventKind::ScrollUp))); + assert_eq!(input.textarea.cursor(), (0, 0)); + } + #[test] fn test_image_and_large_paste_placeholders_render_with_same_color() { use ratatui::{backend::TestBackend, Terminal}; diff --git a/src/views/chat.rs b/src/views/chat.rs index 32a9121..d151c80 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -93,7 +93,7 @@ pub fn render_chat( let input_height = if is_subagent_view { SUBAGENT_FOOTER_HEIGHT } else { - input.get_height() + input.get_height_for_width(size.width) }; let help_height = if is_subagent_view { 0 } else { 1 }; let above_status_chunks = Layout::default() diff --git a/src/views/home.rs b/src/views/home.rs index f3c05e1..d674f61 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -86,7 +86,7 @@ pub fn render_home( .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) .split(size); - let input_height = input.get_height(); + let input_height = input.get_height_for_width(size.width); let home_chunks = Layout::default() .direction(Direction::Vertical) .constraints( From bec2732222c1ba5ad624b056feb4b50406413485 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 21:40:33 +0800 Subject: [PATCH 131/226] feat: add terminal notification signals (BEL) for completion events. Introduce `notifications.terminal` config to control terminal BEL emission when an assistant response finishes. Supported modes: auto (Zed-only), enabled, disabled. A condition field gates on terminal focus state. Also: - Track terminal focus via crossterm FocusGained/FocusLost events - Add `agent..maxSteps` as an alias for `steps` - Remove hardcoded DEFAULT_PRINT_MODE_AGENT_MAX_STEPS --- _docs/config/index.mdx | 3 + _docs/config/notifications.mdx | 36 ++++ _docs/config/opencode-compatibility.mdx | 3 +- _docs/config/sounds.mdx | 2 + _docs/gittydocs.jsonc | 1 + _plans/__TODOS.md | 6 +- crabcode.schema.json | 63 +++++++ src/app.rs | 41 ++++- src/config/configuration.rs | 223 +++++++++++++++++++++++- src/config/mod.rs | 5 +- src/main.rs | 25 ++- src/notify.rs | 17 ++ 12 files changed, 403 insertions(+), 22 deletions(-) create mode 100644 _docs/config/notifications.mdx diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx index 36b2ca7..7ed59cb 100644 --- a/_docs/config/index.mdx +++ b/_docs/config/index.mdx @@ -39,6 +39,9 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the "sounds": { "complete": { "enabled": true }, "error": { "enabled": true } + }, + "notifications": { + "terminal": { "complete": "auto", "condition": "unfocused" } } } ``` diff --git a/_docs/config/notifications.mdx b/_docs/config/notifications.mdx new file mode 100644 index 0000000..f38d942 --- /dev/null +++ b/_docs/config/notifications.mdx @@ -0,0 +1,36 @@ +--- +title: Terminal Notifications +description: Configure terminal-level completion signals for editors such as Zed. +--- + +# Completion Signals + +Terminal notifications are crabcode-specific and apply only to `crabcode` config files. They are separate from `sounds..notify`, which sends desktop notifications. + +```jsonc title="crabcode.jsonc" +{ + "notifications": { + "terminal": { + "complete": "auto", + "condition": "unfocused", + }, + }, +} +``` + +`notifications.terminal.complete` controls whether crabcode emits a terminal BEL (`\x07`) when an assistant response finishes. + +| Value | Behavior | +| --- | --- | +| `"auto"` | Emit BEL only in supported terminals. Currently this targets Zed via `ZED_TERM=true` or `TERM_PROGRAM=zed`. | +| `"enabled"` / `true` | Emit BEL in any terminal. | +| `"disabled"` / `false` | Do not emit terminal completion notifications. | + +`notifications.terminal.condition` controls when the signal is emitted. + +| Value | Behavior | +| --- | --- | +| `"unfocused"` | Emit only after the terminal reports focus loss. This is the default. | +| `"always"` | Emit even while the terminal is focused. | + +In Zed, BEL marks the terminal as notified, which lets Zed render the accent dot on the terminal tab or terminal thread entry. Other terminals may play an audible bell, so use `"enabled"` only when you want that behavior outside supported terminals. diff --git a/_docs/config/opencode-compatibility.mdx b/_docs/config/opencode-compatibility.mdx index 0a78065..2b0145b 100644 --- a/_docs/config/opencode-compatibility.mdx +++ b/_docs/config/opencode-compatibility.mdx @@ -7,7 +7,7 @@ description: What OpenCode configuration works in crabcode and what is crabcode- > Don't think CrabCode as another agent config to manage, just treat it like configuring OpenCode. -Most OpenCode docs apply directly: config is JSON/JSONC, project assets live under `.opencode/`, and command files use the OpenCode markdown command format. crabcode adds a small terminal-specific layer for sounds and theme selection. +Most OpenCode docs apply directly: config is JSON/JSONC, project assets live under `.opencode/`, and command files use the OpenCode markdown command format. crabcode adds a small terminal-specific layer for sounds, notifications, and theme selection. ## Compatibility map @@ -33,6 +33,7 @@ Blank cells mean that runtime behavior is not supported by that project today. ` | `provider..options.timeout` | ✅ | partial | Integer milliseconds or `false` to disable timeout. | | `theme` | ✅ | ✅ | In crabcode config files only. OpenCode config `theme` is ignored by crabcode. | | `sounds` | | ✅ | crabcode-specific terminal audio and notifications. | +| `notifications` | | ✅ | crabcode-specific terminal completion signals such as Zed tab dots. | | `mcp` | ✅ | | Accepted at the top level for forward compatibility, not wired to tools yet. | | `permission` | ✅ | | Accepted at the top level, not enforced from config yet. | | `instructions` | ✅ | | Accepted at the top level, but config-driven instruction files are not loaded yet. | diff --git a/_docs/config/sounds.mdx b/_docs/config/sounds.mdx index a241af3..a9937ff 100644 --- a/_docs/config/sounds.mdx +++ b/_docs/config/sounds.mdx @@ -39,3 +39,5 @@ Sounds are crabcode-specific and apply only to `crabcode` config files. - `complete` / `error` default to enabled and use bundled sounds when no `file` is set. - `permission` / `question` default to disabled. - `notify` is only per-event (`sounds.complete.notify`, etc.); `sounds.notify` is not supported. On macOS, notifications use Notification Center through `osascript`; on Linux, they use the available desktop notification backend. + +For terminal-level completion signals such as Zed tab dots, use [`notifications.terminal`](/config/notifications) instead of `sounds`. diff --git a/_docs/gittydocs.jsonc b/_docs/gittydocs.jsonc index e51833d..cc30e40 100644 --- a/_docs/gittydocs.jsonc +++ b/_docs/gittydocs.jsonc @@ -25,6 +25,7 @@ "items": [ { "label": "Overview", "path": "/config" }, { "label": "OpenCode Compatibility", "path": "/config/opencode-compatibility" }, + { "label": "Terminal Notifications", "path": "/config/notifications" }, { "label": "Sounds", "path": "/config/sounds" }, { "label": "Theme", "path": "/config/theme" }, ], diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index e9dfd4b..146436a 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -129,7 +129,7 @@ Replaced at line 239 - [x] Don't prevent scroll when there's a permission required dialog. -- [ ] Proper textwrapping of input for the input chatbox. I can paste a long string (that doesnt compact), or type a long sentence, and it won't wrap to the next line. It just has horizontal scrolling. I dont want horizontal scrolling. +- [x] Proper textwrapping of input for the input chatbox. I can paste a long string (that doesnt compact), or type a long sentence, and it won't wrap to the next line. It just has horizontal scrolling. I dont want horizontal scrolling. - [ ] Codex's "update plan" tool sometimes has a weird premble before the actual checklist shows... Is this relevant for crabcode? Should we update our tool? Can we do it too? @@ -139,3 +139,7 @@ Replaced at line 239 - Uncollapse - [ ] The footer note for the current cwd/workspace. It trims out the very start. i.e. `...ects/_gamedev/my-game:main`. Instead of this, please show the "between" truncation ?? Just maybe, but maybe not. + +- [ ] Make tool calls be AS PERMISSIVE, as codex. Meaning won't have to ask me to "read" sometimes. + +- [ ] Mouse hover on "chat messages". So that when I click it, it opens the "timeline view" > enter option kinda thing. So it shows either the "Copy", "Fork", "Undo" actions, just like opencode. diff --git a/crabcode.schema.json b/crabcode.schema.json index da0ed31..c1422ef 100644 --- a/crabcode.schema.json +++ b/crabcode.schema.json @@ -82,6 +82,59 @@ } }, "type": "object" + }, + "TerminalNotificationMode": { + "anyOf": [ + { + "enum": [ + "auto", + "enabled", + "disabled", + "on", + "off", + "true", + "false" + ], + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "TerminalNotificationsConfigFile": { + "additionalProperties": false, + "properties": { + "complete": { + "$ref": "#/$defs/TerminalNotificationMode", + "default": "auto" + }, + "condition": { + "default": "unfocused", + "enum": [ + "unfocused", + "always" + ], + "type": "string" + } + }, + "type": "object" + }, + "NotificationsConfigFile": { + "additionalProperties": false, + "properties": { + "terminal": { + "anyOf": [ + { + "$ref": "#/$defs/TerminalNotificationsConfigFile" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" } }, "$id": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json", @@ -109,6 +162,16 @@ "null" ] }, + "notifications": { + "anyOf": [ + { + "$ref": "#/$defs/NotificationsConfigFile" + }, + { + "type": "null" + } + ] + }, "permission": true, "provider": true, "sounds": { diff --git a/src/app.rs b/src/app.rs index eb3d14d..9ba8cee 100644 --- a/src/app.rs +++ b/src/app.rs @@ -227,6 +227,8 @@ pub struct App { pub current_theme_index: usize, pub dark_mode: bool, pub sounds: crate::sound::ResolvedSoundsConfig, + pub notifications: crate::config::NotificationsConfig, + terminal_focused: bool, pub tool_permissions: crate::tools::ToolPermissions, pub skills_dirs: Vec, pub is_streaming: bool, @@ -435,6 +437,8 @@ impl App { current_theme_index, dark_mode: true, sounds: resolved_sounds, + notifications: loaded_config.merged_config.notifications, + terminal_focused: true, tool_permissions, skills_dirs: loaded_config.inventory.opencode_skills_dirs, // Note: skills_dirs is legacy; skill loading is now handled by src/skill/mod.rs @@ -458,6 +462,10 @@ impl App { self.play_sound_event_with_notification_detail(event, None); } + pub fn set_terminal_focused(&mut self, focused: bool) { + self.terminal_focused = focused; + } + fn play_sound_event_with_notification_detail( &self, event: crate::sound::SoundEvent, @@ -472,6 +480,25 @@ impl App { } } + fn notify_terminal_complete(&self) { + use crate::config::{TerminalNotificationCondition, TerminalNotificationMode}; + + let terminal = self.notifications.terminal; + if terminal.condition == TerminalNotificationCondition::Unfocused && self.terminal_focused { + return; + } + + let should_emit = match terminal.complete { + TerminalNotificationMode::Auto => crate::notify::terminal_bell_supported(), + TerminalNotificationMode::Enabled => true, + TerminalNotificationMode::Disabled => false, + }; + + if should_emit { + crate::notify::notify_terminal_bell(); + } + } + fn completion_notification_stats(&self) -> Option { let message = self.chat_state.chat.messages.iter().rev().find(|msg| { msg.role == crate::session::types::MessageRole::Assistant && msg.is_complete @@ -1948,7 +1975,7 @@ impl App { .direction(ratatui::layout::Direction::Vertical) .constraints([ratatui::layout::Constraint::Min(0)].as_ref()) .split(self.last_frame_size); - let input_height = self.input.get_height(); + let input_height = self.input.get_height_for_width(self.last_frame_size.width); let input_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints( @@ -2079,7 +2106,8 @@ impl App { self.overlay_focus = OverlayFocus::None; } } else if self.overlay_focus == OverlayFocus::PermissionDialog { - let handled = handle_permission_dialog_mouse_event(&mut self.permission_dialog_state, mouse); + let handled = + handle_permission_dialog_mouse_event(&mut self.permission_dialog_state, mouse); if !handled && matches!( mouse.kind, @@ -2099,7 +2127,7 @@ impl App { .as_ref(), ) .split(size); - let input_height = self.input.get_height() as u16; + let input_height = self.input.get_height_for_width(size.width); let input_height = if self.is_subagent_session_active() { SUBAGENT_FOOTER_HEIGHT } else { @@ -2315,7 +2343,7 @@ impl App { .as_ref(), ) .split(size); - let input_height = self.input.get_height() as u16; + let input_height = self.input.get_height_for_width(size.width); let input_height = if self.is_subagent_session_active() { SUBAGENT_FOOTER_HEIGHT } else { @@ -2363,7 +2391,7 @@ impl App { .as_ref(), ) .split(size); - let input_height = self.input.get_height() as u16; + let input_height = self.input.get_height_for_width(size.width); let input_height = if self.is_subagent_session_active() { SUBAGENT_FOOTER_HEIGHT } else { @@ -4528,6 +4556,7 @@ impl App { crate::sound::SoundEvent::Complete, completion_stats.as_deref(), ); + self.notify_terminal_complete(); } fn finalize_and_persist_streamed_messages( @@ -5298,6 +5327,8 @@ mod tests { current_theme_index: 0, dark_mode: true, sounds: crate::sound::ResolvedSoundsConfig::default(), + notifications: crate::config::NotificationsConfig::default(), + terminal_focused: true, tool_permissions: crate::tools::ToolPermissions::new(".".to_string()), skills_dirs: Vec::new(), is_streaming: false, diff --git a/src/config/configuration.rs b/src/config/configuration.rs index f01779a..f42150b 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -170,6 +170,39 @@ impl Default for SoundsConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalNotificationMode { + Auto, + Enabled, + Disabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalNotificationCondition { + Unfocused, + Always, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TerminalNotificationsConfig { + pub complete: TerminalNotificationMode, + pub condition: TerminalNotificationCondition, +} + +impl Default for TerminalNotificationsConfig { + fn default() -> Self { + Self { + complete: TerminalNotificationMode::Auto, + condition: TerminalNotificationCondition::Unfocused, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct NotificationsConfig { + pub terminal: TerminalNotificationsConfig, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderTimeout { Millis(u64), @@ -186,6 +219,7 @@ pub struct MergedConfig { pub agent_steps: HashMap, pub provider_timeouts: HashMap, pub sounds: SoundsConfig, + pub notifications: NotificationsConfig, } #[derive(Debug, Clone)] @@ -680,6 +714,7 @@ fn crabcode_allowed_keys() -> BTreeSet<&'static str> { let mut out = opencode_allowed_keys(); out.insert("theme"); out.insert("sounds"); + out.insert("notifications"); out } @@ -928,6 +963,7 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics); out.sounds = parse_sounds(obj.get("sounds"), diagnostics); + out.notifications = parse_notifications(obj.get("notifications"), diagnostics); out } @@ -998,14 +1034,7 @@ fn parse_agent_steps( continue; }; - if agent_obj.contains_key("maxSteps") { - diagnostics.warnings.push(format!( - "agent.{}.maxSteps is not supported by crabcode; use agent.{}.steps instead", - name, name - )); - } - - let Some(raw) = agent_obj.get("steps") else { + let Some(raw) = agent_obj.get("steps").or_else(|| agent_obj.get("maxSteps")) else { continue; }; @@ -1186,6 +1215,98 @@ fn apply_sound_event( } } +fn parse_notifications( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> NotificationsConfig { + let mut notifications = NotificationsConfig::default(); + let Some(Value::Object(map)) = value else { + return notifications; + }; + + let Some(terminal) = map.get("terminal") else { + return notifications; + }; + + let Value::Object(terminal_map) = terminal else { + diagnostics + .warnings + .push("notifications.terminal must be an object".to_string()); + return notifications; + }; + + if let Some(complete) = terminal_map.get("complete") { + notifications.terminal.complete = parse_terminal_notification_mode( + complete, + "notifications.terminal.complete", + diagnostics, + ); + } + + if let Some(condition) = terminal_map.get("condition") { + notifications.terminal.condition = parse_terminal_notification_condition( + condition, + "notifications.terminal.condition", + diagnostics, + ); + } + + notifications +} + +fn parse_terminal_notification_mode( + value: &Value, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) -> TerminalNotificationMode { + match value { + Value::Bool(true) => TerminalNotificationMode::Enabled, + Value::Bool(false) => TerminalNotificationMode::Disabled, + Value::String(s) => match s.trim().to_ascii_lowercase().as_str() { + "auto" => TerminalNotificationMode::Auto, + "enabled" | "on" | "true" => TerminalNotificationMode::Enabled, + "disabled" | "off" | "false" => TerminalNotificationMode::Disabled, + _ => { + diagnostics.warnings.push(format!( + "{}: expected auto, enabled, disabled, true, or false", + key + )); + TerminalNotificationMode::Auto + } + }, + _ => { + diagnostics + .warnings + .push(format!("{}: expected string or boolean", key)); + TerminalNotificationMode::Auto + } + } +} + +fn parse_terminal_notification_condition( + value: &Value, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) -> TerminalNotificationCondition { + let Some(s) = value.as_str() else { + diagnostics + .warnings + .push(format!("{}: expected unfocused or always", key)); + return TerminalNotificationCondition::Unfocused; + }; + + match s.trim().to_ascii_lowercase().as_str() { + "unfocused" => TerminalNotificationCondition::Unfocused, + "always" => TerminalNotificationCondition::Always, + _ => { + diagnostics + .warnings + .push(format!("{}: expected unfocused or always", key)); + TerminalNotificationCondition::Unfocused + } + } +} + fn collect_unimplemented_keys(merged: &Value) -> Vec { let Some(obj) = merged.as_object() else { return Vec::new(); @@ -1200,6 +1321,7 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec { "command", "agent", "provider", + "notifications", ] .into_iter() .collect(); @@ -1217,3 +1339,88 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec { keys.sort(); keys } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_terminal_notifications() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "notifications": { + "terminal": { + "complete": "enabled", + "condition": "always" + } + } + }), + &mut diagnostics, + ); + + assert_eq!( + config.notifications.terminal.complete, + TerminalNotificationMode::Enabled + ); + assert_eq!( + config.notifications.terminal.condition, + TerminalNotificationCondition::Always + ); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn terminal_notifications_default_to_auto_unfocused() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config(&json!({}), &mut diagnostics); + + assert_eq!( + config.notifications.terminal.complete, + TerminalNotificationMode::Auto + ); + assert_eq!( + config.notifications.terminal.condition, + TerminalNotificationCondition::Unfocused + ); + } + + #[test] + fn terminal_notification_boolean_complete_is_supported() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "notifications": { + "terminal": { + "complete": false + } + } + }), + &mut diagnostics, + ); + + assert_eq!( + config.notifications.terminal.complete, + TerminalNotificationMode::Disabled + ); + } + + #[test] + fn agent_max_steps_alias_is_supported() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "agent": { + "build": { + "maxSteps": 42 + } + } + }), + &mut diagnostics, + ); + + assert_eq!(config.agent_steps.get("build"), Some(&42)); + assert!(diagnostics.warnings.is_empty()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 5e25467..e185b34 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,8 +1,9 @@ pub mod configuration; pub use configuration::{ - ConfigDiagnostics, ConfigInventory, ConfigLoader, LoadedConfig, MergedConfig, ProviderTimeout, - SoundEffectConfig, SoundsConfig, + ConfigDiagnostics, ConfigInventory, ConfigLoader, LoadedConfig, MergedConfig, + NotificationsConfig, ProviderTimeout, SoundEffectConfig, SoundsConfig, + TerminalNotificationCondition, TerminalNotificationMode, }; pub use configuration::discover_themes; diff --git a/src/main.rs b/src/main.rs index 740a844..117d6cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,8 +29,9 @@ use app::App; use clap::Parser; use ratatui::crossterm::{ event::{ - self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, terminal::{ @@ -44,7 +45,6 @@ use std::sync::Mutex; use std::time::Duration; const POST_CLOSE_LOGO: &str = include_str!("../crabcode-logo.txt"); -const DEFAULT_PRINT_MODE_AGENT_MAX_STEPS: usize = 16; lazy_static::lazy_static! { static ref STARTUP_DIAGNOSTICS: Mutex> = Mutex::new(Vec::new()); @@ -168,8 +168,7 @@ async fn run_print_mode( .merged_config .agent_steps .get(&agent_mode.to_ascii_lowercase()) - .copied() - .or(Some(DEFAULT_PRINT_MODE_AGENT_MAX_STEPS)); + .copied(); let provider_name_clone = provider_name.clone(); let model_clone = model_id.clone(); @@ -315,6 +314,7 @@ async fn main() -> Result<()> { stdout, EnterAlternateScreen, EnableMouseCapture, + EnableFocusChange, PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES), EnableBracketedPaste )?; @@ -323,6 +323,7 @@ async fn main() -> Result<()> { stdout, EnterAlternateScreen, EnableMouseCapture, + EnableFocusChange, EnableBracketedPaste )?; } @@ -353,6 +354,7 @@ async fn main() -> Result<()> { terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, + DisableFocusChange, PopKeyboardEnhancementFlags, DisableBracketedPaste )?; @@ -361,6 +363,7 @@ async fn main() -> Result<()> { terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, + DisableFocusChange, DisableBracketedPaste )?; } @@ -463,6 +466,12 @@ async fn run_event_loop( event::Event::Paste(text) => { app.handle_paste(text); } + event::Event::FocusGained => { + app.set_terminal_focused(true); + } + event::Event::FocusLost => { + app.set_terminal_focused(false); + } _ => {} } } @@ -484,6 +493,12 @@ async fn run_event_loop( app.handle_paste(text); needs_redraw = true; } + event::Event::FocusGained => { + app.set_terminal_focused(true); + } + event::Event::FocusLost => { + app.set_terminal_focused(false); + } _ => {} } } diff --git a/src/notify.rs b/src/notify.rs index 1408f0a..d526286 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -1,3 +1,4 @@ +use std::io::{self, Write}; use std::process::{Command, Stdio}; pub fn is_supported() -> bool { @@ -76,6 +77,22 @@ pub fn notify_event(event: crate::sound::SoundEvent, detail: Option<&str>) { } } +pub fn notify_terminal_bell() { + let mut stdout = io::stdout(); + let _ = stdout.write_all(b"\x07"); + let _ = stdout.flush(); +} + +pub fn terminal_bell_supported() -> bool { + env_eq("ZED_TERM", "true") || env_eq("TERM_PROGRAM", "zed") +} + +fn env_eq(key: &str, expected: &str) -> bool { + std::env::var(key) + .map(|value| value.eq_ignore_ascii_case(expected)) + .unwrap_or(false) +} + fn notification_content( event: crate::sound::SoundEvent, detail: Option<&str>, From ecfa273f1fa135215ab77197fc2bbf4569f2a6ef Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 22:19:53 +0800 Subject: [PATCH 132/226] feat: add premature-completion diagnostics and relax read-tool permissions. - Add session_id, call_id, and agent_mode to AISDK tool, subagent, and task logs - Disable permission prompts for read/search tools on sensitive/external paths - Document premature-complete bug analysis in _plans/PREMATURE_COMPLETE_BUG.md --- _plans/PREMATURE_COMPLETE_BUG.md | 120 +++++++++++++++++++++++++++++++ _plans/__TODOS.md | 2 +- src/agent/subagent.rs | 38 +++++++++- src/llm/client.rs | 14 +++- src/tools/aisdk_bridge.rs | 116 ++++++++++++++++++++++-------- src/tools/permission.rs | 44 +++++++----- src/tools/task.rs | 52 ++++++++++++-- 7 files changed, 329 insertions(+), 57 deletions(-) create mode 100644 _plans/PREMATURE_COMPLETE_BUG.md diff --git a/_plans/PREMATURE_COMPLETE_BUG.md b/_plans/PREMATURE_COMPLETE_BUG.md new file mode 100644 index 0000000..a6f8978 --- /dev/null +++ b/_plans/PREMATURE_COMPLETE_BUG.md @@ -0,0 +1,120 @@ +# Premature Complete Bug + +This is the running memory for dogfooding reports where crabcode ends a turn before the task is actually done, compared against Codex behavior. + +## Protocol + +1. Dogfood crabcode on crabcode. +2. If crabcode completes prematurely, capture the visible chat history and `app.log`. +3. Use Codex to inspect the history/logs, add a focused fix or diagnostic, and append the findings here. +4. Treat this file as the durable thread across repeated incidents. + +## 2026-05-21 Incident + +### User-Visible Symptom + +Crabcode was asked to make tool calls more permissive like Codex. It started the work, made partial edits, then ended with an intermediary-style message: + +> I’ll remove noisy comments and keep the policy readable. + +From the user's perspective this was not a final answer: the plan still had unfinished validation/wrap-up work. + +### `app.log` Evidence + +Relevant sequence: + +- `21:50:55`: `edit` succeeded in `src/tools/permission.rs`. +- `21:50:55`: provider step 21 started with 42 messages. +- `21:50:57-21:50:58`: text chunks streamed for the message above. +- `21:50:58`: metadata said `assistant_message_phase=final_answer`. +- `21:50:58`: metadata said `response.completed end_turn=None`. +- `21:50:58`: AISDK logged `provider_step_finish step=21 has_tool_call=false end_turn=None last_phase=final_answer assistant_text_chars=58 action=finish preview="I’ll remove noisy comments and keep the policy readable."` +- `21:50:58`: relay exhausted and crabcode marked the stream completed: + - `outcome=Exhausted` + - `effective_outcome=Finished` + - `stop_reason=Some(Finish)` + +Important secondary signal: tool execution logs continued after the primary stream was already marked complete: + +- `21:51:16`: `write` created `TOOL_PERMISSIONS_CHANGES.md`. +- `21:51:46`: `write` created `PERMISSIVE_TOOL_CALLS_SUMMARY.md`. +- `21:52:05`: `task` returned a result. +- `21:52:12`: another `task` started and failed with `Provider stream ended without a terminal completion event`. +- `21:52:18+`: more `read`, `edit`, and `bash` attempts were logged. + +The existing logs do not include enough session/tool-call identity on those late tool logs, so we cannot yet prove whether they came from the same stream, a subagent, or another active/background stream. + +### Current Working Theory + +There are likely two overlapping issues: + +1. The model/provider classified an intermediary update as `final_answer` with `end_turn=None`. `aisdk::stream_with_tools` treats `final_answer + no tool call + end_turn != false` as a real finish. +2. The tool lifecycle logs can outlive the visible primary completion, but they currently lack `session_id`, `call_id`, `agent_mode`, and subagent parent/child context. That makes post-completion tool execution hard to attribute. + +Codex reference behavior in `.devrefs/references/openai/codex/codex-rs/core/src/session/turn.rs` treats a closed stream before `response.completed` as an error. Crabcode already has a similar guard inside `aisdk/src/response.rs` for provider streams without a terminal completion event. This incident is different: the provider did emit `response.completed`, but the text looked like progress-update content, not a genuine final response. + +## Changes Made So Far + +### Permission-Policy Changes From Dogfooding Run + +These were already modified by crabcode before the premature completion: + +- `src/tools/permission.rs` + - Read/search style operations no longer prompt for sensitive paths or paths outside the working directory. + - Write/edit operations still check sensitive paths, external paths, and gitignored writes. + - Bash permission prompting was removed in the current dirty worktree. + - Added/updated permission tests for permissive reads and write-based allow-always behavior. +- `src/tools/bash.rs` + - Dangerous command pattern checks were removed in the current dirty worktree. + +These changes are related to the original task, but they are not the premature-complete fix. They should be reviewed separately for safety before landing. + +### Diagnostics Added For Premature Completion + +Added narrow lifecycle logging to make the next recurrence attributable. + +- `src/llm/client.rs` + - `GOING TO STREAM` now logs `session_id`, provider, model, agent mode, max steps, and input message count. + - `Stream completed` now logs `session_id`. + - `session_id` is cloned before passing into AISDK tool conversion so it remains available for completion logging. + +- `src/tools/aisdk_bridge.rs` + - Tool logs now include `tool`, generated `call_id`, `session_id`, `message_id`, `agent_mode`, sender presence, duration, and output/error bytes. + - UI send failures for `ToolCalls` and `ToolResult` now log as `ui_send_failed`. + - This should reveal whether late tool calls are attached to the completed primary session, a child session, or a different stream. + +- `src/tools/task.rs` + - Task tool now logs `[TASK] start`, `[TASK] finish`, and `[TASK] error` with parent session, child session, subagent type, duration, output bytes, and child tool-call count. + - Child-session forwarding now logs start and close. + +- `src/agent/subagent.rs` + - Subagent streams now log `[SUBAGENT] stream_start`, `[SUBAGENT] stream_finish`, and `[SUBAGENT] stream_failed`. + - Subagent metadata is mirrored into `app.log` as `[SUBAGENT_METADATA]`. + - Fixed a borrow-after-move compile issue by cloning `session_id` when passing it into AISDK tool conversion. + +## Verification State + +- `cargo fmt --check` currently reports a formatting diff in `src/tools/permission.rs` around the newly added permissive-read test. That file was already dirty from the dogfooding run. +- `cargo check` initially failed with `E0382` in `src/agent/subagent.rs` because `session_id` was moved into tool conversion and later reused. A clone fix was applied. Rerun is still pending. +- Earlier dogfooding history showed `cargo test permission::tests` passing after the permission changes, but that was before the later diagnostic edits and before the current formatting issue was resolved. + +## Next Debugging Targets + +1. Run `cargo fmt` or minimally format `src/tools/permission.rs`, then rerun `cargo check`. +2. Dogfood the same class of task again and inspect new log fields: + - Match `[AISDK_TOOL] call/result/error` by `session_id` and `call_id`. + - Check whether any tool call occurs after `Stream completed` for the same `session_id`. + - Check `[TASK]` and `[SUBAGENT]` lines to identify child-session activity. +3. If the same session emits post-completion tools, fix the stream/task lifecycle so completion waits for all owned work or cancels orphan work. +4. If the provider marks progress text as `final_answer`, consider a guard that continues instead of finishing when: + - final text is short, + - there is an active/incomplete plan, + - recent text looks like a progress update, + - or the last tool/result indicates more work remains. + +## Open Questions + +- Were the post-`21:50:58` tool calls from the same primary stream, a subagent, or another concurrent/background stream? +- Does the UI mark a turn complete solely when the relay exhausts, even if task/subagent senders still exist? +- Should `final_answer + end_turn=None` be trusted for ChatGPT OAuth/Codex transport, or should `end_turn=true` be required for final completion when tools are enabled? +- Should active plan state influence whether a final-looking response is allowed to complete the turn? diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 146436a..da9fe89 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -140,6 +140,6 @@ Replaced at line 239 - [ ] The footer note for the current cwd/workspace. It trims out the very start. i.e. `...ects/_gamedev/my-game:main`. Instead of this, please show the "between" truncation ?? Just maybe, but maybe not. -- [ ] Make tool calls be AS PERMISSIVE, as codex. Meaning won't have to ask me to "read" sometimes. +- [x] Make tool calls be AS PERMISSIVE, as codex. Meaning won't have to ask me to "read" sometimes. - [ ] Mouse hover on "chat messages". So that when I click it, it opens the "timeline view" > enter option kinda thing. So it shows either the "Copy", "Fork", "Undo" actions, just like opencode. diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index c0eb459..083ecb1 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -154,7 +154,7 @@ pub async fn run_subagent( sender.clone(), "build".to_string(), permissions, - Some(session_id), + Some(session_id.clone()), None, ) .await; @@ -171,6 +171,16 @@ pub async fn run_subagent( ]; let headers = HashMap::new(); + let stream_started_at = std::time::Instant::now(); + let _ = crate::logging::log(&format!( + "[SUBAGENT] stream_start session_id={} subagent_type={} tools={} description_bytes={} prompt_bytes={} sender_present={}", + session_id, + subagent_type.name(), + aisdk_tools.len(), + description.len(), + prompt.len(), + sender.is_some() + )); let mut response: StreamTextResponse = match session.provider_kind { ProviderKind::OpenAICompatible => { @@ -250,6 +260,13 @@ pub async fn run_subagent( tool_call_count = tool_call_count.saturating_add(calls); } ChunkType::Failed(err) => { + let _ = crate::logging::log(&format!( + "[SUBAGENT] stream_failed session_id={} subagent_type={} duration_ms={} error={}", + session_id, + subagent_type.name(), + stream_started_at.elapsed().as_millis(), + err + )); if let Some(sender) = sender.as_ref() { let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); } @@ -261,10 +278,29 @@ pub async fn run_subagent( ChunkType::ResponseCompleted { .. } => { break; } + ChunkType::Metadata(message) => { + let _ = crate::logging::log(&format!( + "[SUBAGENT_METADATA] session_id={} subagent_type={} {}", + session_id, + subagent_type.name(), + message + )); + } _ => {} } } + let stop_reason = response.stop_reason().await; + let _ = crate::logging::log(&format!( + "[SUBAGENT] stream_finish session_id={} subagent_type={} duration_ms={} stop_reason={:?} text_bytes={} tool_call_count={}", + session_id, + subagent_type.name(), + stream_started_at.elapsed().as_millis(), + stop_reason, + collected_text.len(), + tool_call_count + )); + Ok(SubAgentRunResult { output: normalize_subagent_output(collected_text), tool_call_count, diff --git a/src/llm/client.rs b/src/llm/client.rs index 9f028c6..b67a96b 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -365,7 +365,15 @@ pub async fn stream_llm_with_cancellation( messages: Vec, sender: crate::llm::ChunkSender, ) -> Result<(), DynError> { - let _ = log("GOING TO STREAM"); + let _ = log(&format!( + "GOING TO STREAM session_id={} provider={} model={} agent_mode={} agent_max_steps={:?} input_messages={}", + session_id, + provider_name, + model, + agent_mode, + agent_max_steps, + messages.len() + )); let request_config = prepare_request_config(&provider_name, model, reasoning_effort, &sender).await?; @@ -394,7 +402,7 @@ pub async fn stream_llm_with_cancellation( Some(sender.clone()), agent_mode, tool_permissions, - Some(session_id), + Some(session_id.clone()), None, ) .await; @@ -452,7 +460,7 @@ pub async fn stream_llm_with_cancellation( let stream_outcome = relay_result.outcome; let primary_outcome_label = stream_outcome_label(stream_outcome, stop_reason.as_ref()); let _ = log(&format!( - "Stream completed: outcome={stream_outcome:?}, effective_outcome={primary_outcome_label}, stop_reason={stop_reason:?}, agent_max_steps={agent_max_steps:?}", + "Stream completed: session_id={session_id} outcome={stream_outcome:?}, effective_outcome={primary_outcome_label}, stop_reason={stop_reason:?}, agent_max_steps={agent_max_steps:?}", )); log_stream_summary( primary_log_context, diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 4a6264d..1641f72 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -4,6 +4,7 @@ use aisdk::core::Tool; use schemars::Schema; use serde_json::Value; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; use crate::llm::ChunkSender; @@ -55,24 +56,42 @@ pub async fn convert_to_aisdk_tools( async move { let call_seq = TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1; let call_id = format!("call_{call_seq}"); + let started_at = Instant::now(); + let session_id_label = session_id.as_deref().unwrap_or("session"); + let message_id_label = message_id.as_deref().unwrap_or("message"); + let sender_present = sender.is_some(); if let Some(ref sender) = sender { let args = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()); - let _ = sender.send(crate::llm::ChunkMessage::ToolCalls(vec![ - crate::llm::ToolCall { - id: call_id.clone(), - call_type: "function".to_string(), - function: crate::llm::FunctionCall { - name: tool_id.clone(), - arguments: args, + if sender + .send(crate::llm::ChunkMessage::ToolCalls(vec![ + crate::llm::ToolCall { + id: call_id.clone(), + call_type: "function".to_string(), + function: crate::llm::FunctionCall { + name: tool_id.clone(), + arguments: args, + }, }, - }, - ])); + ])) + .is_err() + { + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] ui_send_failed phase=tool_call tool={} call_id={} session_id={} message_id={} agent_mode={}", + tool_id, call_id, session_id_label, message_id_label, agent_mode + )); + } } let _ = crate::logging::log(&format!( - "[AISDK_TOOL] call {} args={}", - tool_id_for_exec, input + "[AISDK_TOOL] call tool={} call_id={} session_id={} message_id={} agent_mode={} sender_present={} args={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + sender_present, + input )); let handler = registry @@ -84,8 +103,14 @@ pub async fn convert_to_aisdk_tools( Err(err) => { send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); let _ = crate::logging::log(&format!( - "[AISDK_TOOL] error {} {}", - tool_id_for_exec, err + "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), + err )); return Err(err); } @@ -95,8 +120,14 @@ pub async fn convert_to_aisdk_tools( let err = format!("Validation error: {}", e); send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); let _ = crate::logging::log(&format!( - "[AISDK_TOOL] error {} {}", - tool_id_for_exec, err + "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), + err )); return Err(err); } @@ -108,16 +139,22 @@ pub async fn convert_to_aisdk_tools( let err = format!("{}", e); send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); let _ = crate::logging::log(&format!( - "[AISDK_TOOL] error {} {}", - tool_id_for_exec, err + "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), + err )); return Err(err); } let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); let ctx = ToolContext::new( - session_id.unwrap_or_else(|| "session".to_string()), - message_id.unwrap_or_else(|| "message".to_string()), + session_id.clone().unwrap_or_else(|| "session".to_string()), + message_id.clone().unwrap_or_else(|| "message".to_string()), agent_mode.clone(), abort_rx, ) @@ -132,16 +169,27 @@ pub async fn convert_to_aisdk_tools( Err(err) => { send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); let _ = crate::logging::log(&format!( - "[AISDK_TOOL] error {} {}", - tool_id_for_exec, err + "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), + err )); return Err(err); } }; let _ = crate::logging::log(&format!( - "[AISDK_TOOL] result {} bytes={}", + "[AISDK_TOOL] result tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} output_bytes={}", tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), tool_result.output.len() )); @@ -168,14 +216,22 @@ pub async fn convert_to_aisdk_tools( }) .to_string(); - let _ = sender.send(crate::llm::ChunkMessage::ToolResult( - crate::llm::ToolCallResult { - tool_call_id: call_id.clone(), - role: "tool".to_string(), - name: tool_id_for_ui.clone(), - content: payload, - }, - )); + if sender + .send(crate::llm::ChunkMessage::ToolResult( + crate::llm::ToolCallResult { + tool_call_id: call_id.clone(), + role: "tool".to_string(), + name: tool_id_for_ui.clone(), + content: payload, + }, + )) + .is_err() + { + let _ = crate::logging::log(&format!( + "[AISDK_TOOL] ui_send_failed phase=tool_result tool={} call_id={} session_id={} message_id={} agent_mode={}", + tool_id_for_ui, call_id, session_id_label, message_id_label, agent_mode + )); + } } Ok(model_output) diff --git a/src/tools/permission.rs b/src/tools/permission.rs index fdaa1d0..cdeb2ec 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -250,10 +250,9 @@ impl ToolPermissions { action: PermissionAction, path: Option<&Path>, ) -> Option { - if matches!( - action, - PermissionAction::Read | PermissionAction::Write | PermissionAction::Edit - ) { + // Read/search tools are sandbox-style discovery operations; only mutating + // filesystem tools require approval for protected path classes. + if matches!(action, PermissionAction::Write | PermissionAction::Edit) { if let Some(path) = path { if is_sensitive_path(path) { return Some(PermissionReasonKind::SensitivePath); @@ -261,15 +260,7 @@ impl ToolPermissions { } } - if matches!( - action, - PermissionAction::Read - | PermissionAction::Write - | PermissionAction::Edit - | PermissionAction::List - | PermissionAction::Glob - | PermissionAction::Grep - ) { + if matches!(action, PermissionAction::Write | PermissionAction::Edit) { if let Some(path) = path { if is_outside_workdir(path, &self.workdir) { return Some(PermissionReasonKind::ExternalPath); @@ -478,7 +469,7 @@ mod tests { let tx_for_task = tx.clone(); let first = tokio::spawn(async move { perms_for_task - .preflight("build", "read", ¶ms_for_task, Some(&tx_for_task)) + .preflight("build", "write", ¶ms_for_task, Some(&tx_for_task)) .await }); @@ -491,18 +482,39 @@ mod tests { let first_result = first.await.expect("task should complete"); assert!(first_result.is_ok()); - let second = perms.preflight("build", "read", ¶ms, Some(&tx)).await; + let second = perms.preflight("build", "write", ¶ms, Some(&tx)).await; assert!(second.is_ok()); assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn read_and_search_tools_do_not_prompt_for_sensitive_or_external_paths() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let sensitive = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + let external = serde_json::json!({ "path": "/tmp/elsewhere" }); + + let read_result = perms + .preflight("build", "read", &sensitive, Some(&tx)) + .await; + let list_result = perms.preflight("build", "list", &external, Some(&tx)).await; + let glob_result = perms.preflight("build", "glob", &external, Some(&tx)).await; + let grep_result = perms.preflight("build", "grep", &external, Some(&tx)).await; + + assert!(read_result.is_ok()); + assert!(list_result.is_ok()); + assert!(glob_result.is_ok()); + assert!(grep_result.is_ok()); + assert!(rx.try_recv().is_err()); + } + #[tokio::test] async fn dangerous_skip_bypasses_permission_prompts() { let perms = ToolPermissions::new("/tmp/workspace").dangerously_skip_permissions(true); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let params = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); - let result = perms.preflight("build", "read", ¶ms, Some(&tx)).await; + let result = perms.preflight("build", "write", ¶ms, Some(&tx)).await; assert!(result.is_ok()); assert!(rx.try_recv().is_err()); diff --git a/src/tools/task.rs b/src/tools/task.rs index c188db8..73bd129 100644 --- a/src/tools/task.rs +++ b/src/tools/task.rs @@ -93,6 +93,17 @@ impl ToolHandler for TaskTool { subagent_type.name() ); + let _ = crate::logging::log(&format!( + "[TASK] start parent_session_id={} child_session_id={} subagent_type={} title={:?} description_bytes={} prompt_bytes={} sender_present={}", + ctx.session_id, + child_session_id, + subagent_type.name(), + title, + description.len(), + prompt.len(), + self.sender.is_some() + )); + let child_sender = self.start_child_session_stream( ctx.session_id.clone(), child_session_id.clone(), @@ -103,7 +114,7 @@ impl ToolHandler for TaskTool { ); let started_at = std::time::Instant::now(); - let result = subagent::run_subagent( + let result = match subagent::run_subagent( subagent_type.clone(), &description, &prompt, @@ -112,18 +123,39 @@ impl ToolHandler for TaskTool { child_session_id.clone(), ) .await - .map_err(|e| { - if let Some(sender) = child_sender.as_ref() { - let _ = sender.send(crate::llm::ChunkMessage::Failed(e.clone())); + { + Ok(result) => result, + Err(e) => { + let _ = crate::logging::log(&format!( + "[TASK] error parent_session_id={} child_session_id={} subagent_type={} duration_ms={} error={}", + ctx.session_id, + child_session_id, + subagent_type.name(), + started_at.elapsed().as_millis(), + e + )); + if let Some(sender) = child_sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Failed(e.clone())); + } + return Err(ToolError::Execution(format!("Subagent error: {}", e))); } - ToolError::Execution(format!("Subagent error: {}", e)) - })?; + }; if let Some(sender) = child_sender.as_ref() { let _ = sender.send(crate::llm::ChunkMessage::End); } let duration_ms = started_at.elapsed().as_millis() as u64; + let _ = crate::logging::log(&format!( + "[TASK] finish parent_session_id={} child_session_id={} subagent_type={} duration_ms={} output_bytes={} child_tool_call_count={}", + ctx.session_id, + child_session_id, + subagent_type.name(), + duration_ms, + result.output.len(), + result.tool_call_count + )); + Ok(ToolResult::new( format!("Subagent ({}) result", subagent_type.name()), result.output, @@ -162,12 +194,20 @@ impl TaskTool { }); tokio::spawn(async move { + let _ = crate::logging::log(&format!( + "[TASK] child_forwarder_start session_id={}", + session_id + )); while let Some(chunk) = child_rx.recv().await { let _ = ui_sender.send(crate::llm::ChunkMessage::SubagentChunk { session_id: session_id.clone(), chunk: Box::new(chunk), }); } + let _ = crate::logging::log(&format!( + "[TASK] child_forwarder_closed session_id={}", + session_id + )); }); Some(child_tx) From be7c8592e398d419184538b175e8d6414f2d7f52 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 21 May 2026 23:50:04 +0800 Subject: [PATCH 133/226] fix(premature-complete): defer finish when tool messages still running. The stream completion now checks for in-flight tool messages before finalizing. If running tool messages exist, completion is deferred until their results arrive. Also updates the Codex prompt to tell models to reserve final answers for completed work and treat progress as interim commentary. --- _plans/PREMATURE_COMPLETE_BUG.md | 44 +++++-- _plans/__TODOS.md | 7 +- src/app.rs | 192 +++++++++++++++++++++++++++++-- src/prompt/mod.rs | 13 +++ 4 files changed, 234 insertions(+), 22 deletions(-) diff --git a/_plans/PREMATURE_COMPLETE_BUG.md b/_plans/PREMATURE_COMPLETE_BUG.md index a6f8978..b7ba59c 100644 --- a/_plans/PREMATURE_COMPLETE_BUG.md +++ b/_plans/PREMATURE_COMPLETE_BUG.md @@ -55,6 +55,29 @@ Codex reference behavior in `.devrefs/references/openai/codex/codex-rs/core/src/ ## Changes Made So Far +### Runtime Fix Applied 2026-05-21 + +The attempted `update_plan`-state guard was rejected in favor of stricter reference parity. + +- Codex continues from structured stream/tool lifecycle state: tool output needing follow-up, pending input, and `response.completed end_turn == Some(false)`. +- opencode exits from persisted assistant finish state only when there are no unresolved tool-call parts; it does not inspect assistant prose or todo/plan wording. +- Neither reference uses `update_plan` or natural-language progress phrasing as a completion gate. + +Applied two reference-shaped fixes: + +- `src/app.rs` now defers session completion if an `End` arrives while tool messages from the current streaming boundary are still `running`. Completion resumes after the pending tool result resolves. This mirrors opencode's unresolved tool-part exit condition and Codex's in-flight tool drain boundary. +- `src/prompt/mod.rs` now tells Codex-style models to treat preambles/progress updates as interim commentary and reserve final answers for completed work. This is a prompt/protocol correction, not an assistant-text keyword matcher. + +AISDK remains limited to reference-style stream signals: tool calls, `end_turn=false`, phase/lifecycle events, terminal-event enforcement, and bounded max-step handling. It still does not special-case `update_plan` inside argument parsing. + +Validation: + +- `cargo fmt --check` +- `cargo test -p aisdk` +- `cargo test stream_finish_waits_for_running_tool_result` +- `cargo test codex_prompt_separates_progress_from_final_answers` +- `cargo check` + ### Permission-Policy Changes From Dogfooding Run These were already modified by crabcode before the premature completion: @@ -94,27 +117,24 @@ Added narrow lifecycle logging to make the next recurrence attributable. ## Verification State -- `cargo fmt --check` currently reports a formatting diff in `src/tools/permission.rs` around the newly added permissive-read test. That file was already dirty from the dogfooding run. -- `cargo check` initially failed with `E0382` in `src/agent/subagent.rs` because `session_id` was moved into tool conversion and later reused. A clone fix was applied. Rerun is still pending. -- Earlier dogfooding history showed `cargo test permission::tests` passing after the permission changes, but that was before the later diagnostic edits and before the current formatting issue was resolved. +- `cargo fmt --check` passes as of the runtime fix. +- `cargo test -p aisdk` passes for the existing reference-style AISDK lifecycle behavior. +- `cargo test stream_finish_waits_for_running_tool_result` passes. +- `cargo test codex_prompt_separates_progress_from_final_answers` passes. +- `cargo check` passes with existing warnings. The permission-policy and diagnostic edits from the earlier dogfooding run remain separate dirty work and should be validated before landing. ## Next Debugging Targets -1. Run `cargo fmt` or minimally format `src/tools/permission.rs`, then rerun `cargo check`. -2. Dogfood the same class of task again and inspect new log fields: +1. Dogfood the same class of task again and inspect new log fields: - Match `[AISDK_TOOL] call/result/error` by `session_id` and `call_id`. - Check whether any tool call occurs after `Stream completed` for the same `session_id`. - Check `[TASK]` and `[SUBAGENT]` lines to identify child-session activity. -3. If the same session emits post-completion tools, fix the stream/task lifecycle so completion waits for all owned work or cancels orphan work. -4. If the provider marks progress text as `final_answer`, consider a guard that continues instead of finishing when: - - final text is short, - - there is an active/incomplete plan, - - recent text looks like a progress update, - - or the last tool/result indicates more work remains. +2. If the same session still emits post-completion tools, check whether those events bypass `ToolCallViewState` and need a lower-level in-flight counter. +3. If provider output still misclassifies progress as `final_answer`, inspect the raw Responses events and prompt text to verify whether the updated final/commentary contract is being sent. ## Open Questions - Were the post-`21:50:58` tool calls from the same primary stream, a subagent, or another concurrent/background stream? - Does the UI mark a turn complete solely when the relay exhausts, even if task/subagent senders still exist? - Should `final_answer + end_turn=None` be trusted for ChatGPT OAuth/Codex transport, or should `end_turn=true` be required for final completion when tools are enabled? -- Should active plan state influence whether a final-looking response is allowed to complete the turn? +- What should be the canonical crabcode pending-work signal that can keep the turn alive without inspecting assistant prose or plan/todo text? diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index da9fe89..9656ba2 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -142,4 +142,9 @@ Replaced at line 239 - [x] Make tool calls be AS PERMISSIVE, as codex. Meaning won't have to ask me to "read" sometimes. -- [ ] Mouse hover on "chat messages". So that when I click it, it opens the "timeline view" > enter option kinda thing. So it shows either the "Copy", "Fork", "Undo" actions, just like opencode. +- [x] Mouse hover on "chat messages". So that when I click it, it opens the "timeline view" > enter option kinda thing. So it shows either the "Copy", "Fork", "Undo" actions, just like opencode. + +- [ ] I have a "complete", "error", "question" (use this in both 'question' and 'permission') sounds. I'd love for them to be bundled in, or at least downloaded by default via fetching from github raw link if it doesnt exist yet. + +- [ ] Like opencode, let's make a command palette via `ctrl+p`. + - [ ] Additionally, since the bottom area takes up too much space with `/ commands ctrl+x shortcuts tab agents ctrl+cc quit`. Let's reduce it to just `ctrl+p`?. diff --git a/src/app.rs b/src/app.rs index 9ba8cee..a71805c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -157,6 +157,7 @@ struct ExternalStreamState { struct ToolCallViewState { tool_call_message_indices: std::collections::HashMap, tool_call_order: Vec, + deferred_finish: bool, } #[derive(Debug)] @@ -813,6 +814,16 @@ impl App { } } + fn chat_for_session(&self, session_id: &str) -> Option<&Chat> { + if self.is_active_session(session_id) { + Some(&self.chat_state.chat) + } else { + self.session_view_states + .get(session_id) + .map(|state| &state.chat) + } + } + fn stream_for_session_mut(&mut self, session_id: &str) -> Option<&mut SessionStreamState> { self.session_view_states .get_mut(session_id) @@ -4285,6 +4296,7 @@ impl App { if let Some(state) = self.session_view_states.get_mut(session_id) { state.stream = None; state.external_stream = None; + state.tool_calls.deferred_finish = false; } if was_active { @@ -4534,6 +4546,10 @@ impl App { } fn finish_streaming_session(&mut self, session_id: &str) { + if self.defer_finish_if_tools_are_running(session_id) { + return; + } + let Some(completion_stats) = self.finalize_and_persist_streamed_messages(session_id, None) else { return; @@ -4559,6 +4575,69 @@ impl App { self.notify_terminal_complete(); } + fn defer_finish_if_tools_are_running(&mut self, session_id: &str) -> bool { + if !self.session_has_running_tool_messages(session_id) { + return false; + } + + if let Some(state) = self.session_view_states.get_mut(session_id) { + state.tool_calls.deferred_finish = true; + } + + let _ = crate::logging::log(&format!( + "[STREAM_DEFERRED] session_id={} reason=running_tool_messages", + session_id + )); + true + } + + fn finish_deferred_streaming_session_if_ready(&mut self, session_id: &str) { + let deferred = self + .session_view_states + .get(session_id) + .is_some_and(|state| state.tool_calls.deferred_finish); + + if !deferred || self.session_has_running_tool_messages(session_id) { + return; + } + + if let Some(state) = self.session_view_states.get_mut(session_id) { + state.tool_calls.deferred_finish = false; + } + + self.finish_streaming_session(session_id); + } + + fn session_has_running_tool_messages(&self, session_id: &str) -> bool { + let Some((start, _, _)) = self.streaming_boundary_for_session(session_id) else { + return false; + }; + let Some(chat) = self.chat_for_session(session_id) else { + return false; + }; + + chat.messages + .iter() + .skip(start) + .any(Self::is_running_tool_message) + } + + fn is_running_tool_message(message: &crate::session::types::Message) -> bool { + if message.role != crate::session::types::MessageRole::Tool { + return false; + } + + serde_json::from_str::(&message.content) + .ok() + .and_then(|value| { + value + .get("status") + .and_then(|status| status.as_str()) + .map(|status| status == "running") + }) + .unwrap_or(true) + } + fn finalize_and_persist_streamed_messages( &mut self, session_id: &str, @@ -4737,6 +4816,8 @@ impl App { .copied() }); + let mut handled = false; + if let Some(chat) = self.chat_for_session_mut(session_id) { if let Some(idx) = target_idx { if let Some(msg) = chat.messages.get_mut(idx) { @@ -4787,19 +4868,23 @@ impl App { msg.content = v.to_string(); chat.mark_render_dirty(); - return; + handled = true; } } - let content = serde_json::json!({ - "id": result.tool_call_id, - "name": result.name, - "status": "ok", - "output_preview": result.content, - }) - .to_string(); - chat.add_message(crate::session::types::Message::tool(content)); + if !handled { + let content = serde_json::json!({ + "id": result.tool_call_id, + "name": result.name, + "status": "ok", + "output_preview": result.content, + }) + .to_string(); + chat.add_message(crate::session::types::Message::tool(content)); + } } + + self.finish_deferred_streaming_session_if_ready(session_id); } fn start_llm_streaming( @@ -5476,6 +5561,95 @@ mod tests { ); } + #[test] + fn stream_finish_waits_for_running_tool_result() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Deferred".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete("Checking.")); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "running", + "args": { "path": "Cargo.toml" }, + }) + .to_string(), + )); + + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + state + .tool_calls + .tool_call_message_indices + .insert("call_1".to_string(), 2); + state.tool_calls.tool_call_order.push("call_1".to_string()); + app.is_streaming = true; + + app.finish_streaming_session(&session_id); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.stream.is_some()); + assert!(state.tool_calls.deferred_finish); + assert!(!app.chat_state.chat.messages[1].is_complete); + assert_eq!( + app.session_manager + .get_session_ref(&session_id) + .unwrap() + .messages + .len(), + 1 + ); + + app.add_tool_result_to_session( + &session_id, + crate::llm::ToolCallResult { + tool_call_id: "call_1".to_string(), + role: "tool".to_string(), + name: "read".to_string(), + content: serde_json::json!({ + "status": "ok", + "title": "Read", + "output_preview": "contents" + }) + .to_string(), + }, + ); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.stream.is_none()); + assert!(!state.tool_calls.deferred_finish); + assert!(app.chat_state.chat.messages[1].is_complete); + + let session_messages = &app + .session_manager + .get_session_ref(&session_id) + .unwrap() + .messages; + assert_eq!(session_messages.len(), 3); + let tool_payload: serde_json::Value = + serde_json::from_str(&session_messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "ok"); + } + #[test] fn chat_only_commands_are_rejected_outside_chat() { let mut app = test_app(); diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 1c0975c..cb649dc 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -203,6 +203,9 @@ Output Philosophy: - Keep tone light, friendly, curious - Exception: Skip preambles for trivial single-file reads - Minimal markdown formatting +- Treat preambles and progress updates as interim commentary before tool calls +- Use final answers only when the requested work is complete +- If work remains, keep using tools instead of sending a final answer Planning: - Use update_plan for non-trivial, multi-phase work @@ -350,4 +353,14 @@ mod tests { ProviderType::Generic ); } + + #[test] + fn codex_prompt_separates_progress_from_final_answers() { + let composer = SystemPromptComposer::new("gpt-5", ".", true, "test"); + let prompt = composer.get_codex_prompt(); + + assert!(prompt.contains("preambles and progress updates as interim commentary")); + assert!(prompt.contains("Use final answers only when the requested work is complete")); + assert!(prompt.contains("keep using tools instead of sending a final answer")); + } } From bcdd883bc02c5ccc5f9fa6374b3bb09162fbee42 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 22 May 2026 00:15:28 +0800 Subject: [PATCH 134/226] fix: premature completion 2. --- src/prompt/mod.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index cb649dc..2f5b486 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -195,7 +195,15 @@ Core Directives: - Fix root cause, not surface patches - Keep changes minimal and focused - Validate work via tests/build -- Only terminate when problem completely solved +- Persist until the task is fully handled end-to-end +- Do not stop at analysis, partial fixes, or incomplete wiring +- Carry changes through implementation, verification, and a clear outcome unless the user explicitly pauses or redirects you + +Autonomy: +- Unless the user explicitly asks for a plan, explanation, or brainstorming, assume they want you to make the needed code changes or run the needed tools +- If you hit a blocker, try to resolve it with available tools before yielding +- Only terminate when you are sure the requested task is solved or you have a concrete blocker to report +- If work remains, keep using tools instead of sending a final answer Output Philosophy: - Group related actions in single preamble @@ -204,8 +212,8 @@ Output Philosophy: - Exception: Skip preambles for trivial single-file reads - Minimal markdown formatting - Treat preambles and progress updates as interim commentary before tool calls -- Use final answers only when the requested work is complete -- If work remains, keep using tools instead of sending a final answer +- Never send a preamble or progress update as the final answer +- Use final answers only when the requested work is complete, verified when practical, and ready to hand back Planning: - Use update_plan for non-trivial, multi-phase work @@ -213,6 +221,9 @@ Planning: - Don't pad with obvious steps - Update plans mid-task if needed with explanation - Mark steps completed before moving forward +- Maintain exactly one in_progress item at a time until all active work is done +- Do not let the plan go stale while coding +- Do not end the turn while any active plan item remains pending or in_progress unless the user pauses, redirects, or you are blocked and explain why File Handling: - Never re-read files after successful edit @@ -362,5 +373,8 @@ mod tests { assert!(prompt.contains("preambles and progress updates as interim commentary")); assert!(prompt.contains("Use final answers only when the requested work is complete")); assert!(prompt.contains("keep using tools instead of sending a final answer")); + assert!(prompt.contains("Persist until the task is fully handled end-to-end")); + assert!(prompt.contains("Do not let the plan go stale while coding")); + assert!(prompt.contains("Do not end the turn while any active plan item remains pending")); } } From 669b1f5e188990422f39452c54dd8c69cc517c9d Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 22 May 2026 00:21:49 +0800 Subject: [PATCH 135/226] feat: add command palette overlay accessible via ctrl+p. Replace the lengthy help bar with a single "ctrl+p commands" hint. The command palette lists core commands, custom commands, and app actions (toggle agent mode, cycle reasoning effort) in a searchable dialog. --- _plans/__TODOS.md | 4 +- src/app.rs | 124 +++++++++- src/command/registry.rs | 4 + src/views/chat.rs | 10 +- src/views/command_palette.rs | 451 +++++++++++++++++++++++++++++++++++ src/views/home.rs | 10 +- src/views/mod.rs | 1 + 7 files changed, 577 insertions(+), 27 deletions(-) create mode 100644 src/views/command_palette.rs diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 9656ba2..edc0b5c 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -146,5 +146,5 @@ Replaced at line 239 - [ ] I have a "complete", "error", "question" (use this in both 'question' and 'permission') sounds. I'd love for them to be bundled in, or at least downloaded by default via fetching from github raw link if it doesnt exist yet. -- [ ] Like opencode, let's make a command palette via `ctrl+p`. - - [ ] Additionally, since the bottom area takes up too much space with `/ commands ctrl+x shortcuts tab agents ctrl+cc quit`. Let's reduce it to just `ctrl+p`?. +- [x] Like opencode, let's make a command palette via `ctrl+p`. + - [x] Additionally, since the bottom area takes up too much space with `/ commands ctrl+x shortcuts tab agents ctrl+cc quit`. Let's reduce it to just `ctrl+p`?. diff --git a/src/app.rs b/src/app.rs index a71805c..9d660a3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,6 +17,10 @@ use crate::utils::git; use crate::views::chat::{ agent_color_for_tab, init_chat, render_chat, SubagentTab, SubagentTabs, SUBAGENT_FOOTER_HEIGHT, }; +use crate::views::command_palette::{ + handle_command_palette_key_event, handle_command_palette_mouse_event, init_command_palette, + render_command_palette, CommandPaletteAction, CommandPaletteAppAction, +}; use crate::views::connect_dialog::{ get_pending_selection, handle_connect_dialog_key_event, handle_connect_dialog_mouse_event, init_connect_dialog, render_connect_dialog, @@ -102,6 +106,7 @@ pub enum OverlayFocus { SkillsDialog, TimelineDialog, MessageActions, + CommandPalette, WhichKey, } @@ -204,6 +209,7 @@ pub struct App { pub permission_dialog_state: PermissionDialogState, pub question_dialog_state: QuestionDialogState, pub skills_dialog_state: crate::views::SkillsDialogState, + pub command_palette_state: crate::views::command_palette::CommandPaletteState, pub which_key_state: crate::views::which_key::WhichKeyState, pub timeline_dialog_state: crate::views::timeline_dialog::TimelineDialogState, pub message_actions_index: Option, @@ -277,6 +283,7 @@ impl App { let skills_dialog_state = crate::views::skills_dialog::init_skills_dialog("Skills", vec![]); let which_key_state = crate::views::which_key::init_which_key(); let timeline_dialog_state = crate::views::timeline_dialog::init_timeline_dialog(); + let command_palette_state = init_command_palette(); let api_key_input = crate::ui::components::api_key_input::ApiKeyInput::new(); let session_manager = SessionManager::new() @@ -416,6 +423,7 @@ impl App { skills_dialog_state, which_key_state, timeline_dialog_state, + command_palette_state, message_actions_index: None, message_actions_dialog: None, api_key_input, @@ -1199,6 +1207,17 @@ impl App { } pub fn handle_keys(&mut self, key: KeyEvent) { + if key.code == KeyCode::Char('p') + && key.modifiers == event::KeyModifiers::CONTROL + && matches!( + self.overlay_focus, + OverlayFocus::None | OverlayFocus::SuggestionsPopup | OverlayFocus::CommandPalette + ) + { + self.open_command_palette(); + return; + } + match key.code { KeyCode::Char('v') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { if self.is_subagent_session_active() @@ -1738,6 +1757,16 @@ impl App { false } } + OverlayFocus::CommandPalette => { + let action = handle_command_palette_key_event(&mut self.command_palette_state, key); + self.handle_command_palette_action(action); + if !self.command_palette_state.dialog.is_visible() + && self.overlay_focus == OverlayFocus::CommandPalette + { + self.overlay_focus = OverlayFocus::None; + } + true + } OverlayFocus::WhichKey => { let action = self.which_key_state.handle_key_event(key); match action { @@ -1867,15 +1896,7 @@ impl App { self.switch_to_parent_session() } KeyCode::Tab => { - if self.agent == "Plan" { - self.agent = "Build".to_string(); - } else { - self.agent = "Plan".to_string(); - } - - let colors = self.get_current_theme_colors(); - let agent_color = crate::theme::agent_color(&self.agent, &colors); - self.chat_state.wave_spinner.set_color(agent_color); + self.toggle_agent_mode(); true } KeyCode::Esc => { @@ -1908,6 +1929,18 @@ impl App { } } + fn toggle_agent_mode(&mut self) { + if self.agent == "Plan" { + self.agent = "Build".to_string(); + } else { + self.agent = "Plan".to_string(); + } + + let colors = self.get_current_theme_colors(); + let agent_color = crate::theme::agent_color(&self.agent, &colors); + self.chat_state.wave_spinner.set_color(agent_color); + } + fn handle_input_and_app_keys(&mut self, key: KeyEvent) { // If chat text is selected and user presses a key, clear the selection // (unless it's Ctrl+C or Escape which are handled earlier) @@ -2314,6 +2347,14 @@ impl App { { self.close_message_actions(); } + } else if self.overlay_focus == OverlayFocus::CommandPalette { + let action = handle_command_palette_mouse_event(&mut self.command_palette_state, mouse); + self.handle_command_palette_action(action); + if !self.command_palette_state.dialog.is_visible() + && self.overlay_focus == OverlayFocus::CommandPalette + { + self.overlay_focus = OverlayFocus::None; + } } else if self.overlay_focus == OverlayFocus::SuggestionsPopup { let anchor_area = self.suggestions_popup_anchor_area(); let action = handle_suggestions_popup_mouse_event( @@ -2633,6 +2674,20 @@ impl App { ); self.skills_dialog_state.dialog.selected_index = 0; } + (_, OverlayFocus::CommandPalette) => { + self.command_palette_state + .dialog + .search_textarea + .insert_str(&text); + self.command_palette_state.dialog.set_search_query( + self.command_palette_state + .dialog + .search_textarea + .lines() + .join(""), + ); + self.command_palette_state.dialog.selected_index = 0; + } (_, OverlayFocus::SessionRenameDialog) => { self.session_rename_dialog_state .input_textarea @@ -2682,6 +2737,50 @@ impl App { self.clear_suggestions_and_blur(); } + fn open_command_palette(&mut self) { + if self.overlay_focus == OverlayFocus::CommandPalette + && self.command_palette_state.dialog.is_visible() + { + self.command_palette_state.dialog.hide(); + self.overlay_focus = OverlayFocus::None; + return; + } + + clear_suggestions(&mut self.suggestions_popup_state); + self.command_palette_state + .refresh_items(&self.command_registry, self.base_focus == BaseFocus::Chat); + self.command_palette_state.show(); + self.overlay_focus = OverlayFocus::CommandPalette; + } + + fn handle_command_palette_action(&mut self, action: CommandPaletteAction) { + match action { + CommandPaletteAction::RunCommand(command) => { + self.overlay_focus = OverlayFocus::None; + let command = format!("/{}", command); + + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_input(&command)); + }); + + self.input.clear(); + self.clear_suggestions_and_blur(); + } + CommandPaletteAction::RunAppAction(action) => { + self.overlay_focus = OverlayFocus::None; + match action { + CommandPaletteAppAction::ToggleAgentMode => self.toggle_agent_mode(), + CommandPaletteAppAction::CycleReasoningEffort => { + let _ = self.cycle_active_reasoning_effort(); + } + } + self.clear_suggestions_and_blur(); + } + CommandPaletteAction::None => {} + } + } + fn clear_suggestions_and_blur(&mut self) { clear_suggestions(&mut self.suggestions_popup_state); if self.overlay_focus == OverlayFocus::SuggestionsPopup { @@ -5303,6 +5402,12 @@ impl App { render_question_dialog(f, &mut self.question_dialog_state, size, colors); } + if self.overlay_focus == OverlayFocus::CommandPalette + && self.command_palette_state.dialog.is_visible() + { + render_command_palette(f, &mut self.command_palette_state, size, colors); + } + if self.overlay_focus == OverlayFocus::WhichKey { crate::views::which_key::render_which_key(f, &self.which_key_state, &colors); } @@ -5388,6 +5493,7 @@ mod tests { permission_dialog_state: init_permission_dialog(), question_dialog_state: init_question_dialog(), skills_dialog_state: crate::views::skills_dialog::init_skills_dialog("Skills", vec![]), + command_palette_state: init_command_palette(), which_key_state: crate::views::which_key::init_which_key(), timeline_dialog_state: crate::views::timeline_dialog::init_timeline_dialog(), message_actions_index: None, diff --git a/src/command/registry.rs b/src/command/registry.rs index 28152d1..4b1ef79 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -83,6 +83,10 @@ impl Registry { self.custom_commands.contains_key(name) } + pub fn custom_command(&self, name: &str) -> Option<&crate::command::custom::CustomCommand> { + self.custom_commands.get(name) + } + pub fn get(&self, name: &str) -> Option<&Command> { if let Some(cmd) = self.commands.get(name) { return Some(cmd); diff --git a/src/views/chat.rs b/src/views/chat.rs index d151c80..d30357d 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -150,14 +150,8 @@ pub fn render_chat( } let help_text = vec![ - Span::styled("/", Style::default().fg(colors.info)), - Span::raw(" commands "), - Span::styled("ctrl+x", Style::default().fg(colors.info)), - Span::raw(" shortcuts "), - Span::styled("tab", Style::default().fg(colors.info)), - Span::raw(" agents "), - Span::styled("ctrl+cc", Style::default().fg(colors.info)), - Span::raw(" quit "), + Span::styled("ctrl+p", Style::default().fg(colors.info)), + Span::raw(" commands"), ]; let help_line = Line::from(help_text); let help_width = help_line.width() as u16; diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs new file mode 100644 index 0000000..5ce8d52 --- /dev/null +++ b/src/views/command_palette.rs @@ -0,0 +1,451 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{layout::Rect, Frame}; + +use crate::command::custom::CustomCommandSource; +use crate::command::registry::Registry; +use crate::theme::ThemeColors; +use crate::ui::components::dialog::{Dialog, DialogAction, DialogItem}; + +const APP_ACTION_PROVIDER: &str = "__command_palette_app_action"; + +#[derive(Debug, Clone, PartialEq)] +pub enum CommandPaletteAction { + RunCommand(String), + RunAppAction(CommandPaletteAppAction), + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandPaletteAppAction { + ToggleAgentMode, + CycleReasoningEffort, +} + +#[derive(Debug)] +pub struct CommandPaletteState { + pub dialog: Dialog, +} + +impl CommandPaletteState { + pub fn new() -> Self { + Self { + dialog: Dialog::with_items("Command Palette", Vec::new()).with_actions(base_actions()), + } + } + + pub fn refresh_items(&mut self, registry: &Registry, is_chat: bool) { + let was_visible = self.dialog.is_visible(); + let search_query = self.dialog.search_query.clone(); + let selected = self + .dialog + .get_selected() + .map(|item| (item.id.clone(), item.provider_id.clone())); + + let mut items = core_palette_items(registry, is_chat); + items.extend(custom_command_items(registry, is_chat)); + + self.dialog = Dialog::with_items("Command Palette", items).with_actions(base_actions()); + self.dialog.set_search_query(search_query); + + if was_visible { + self.dialog.show(); + } + + if let Some((id, provider_id)) = selected { + let _ = self.dialog.select_item_by_key(&id, &provider_id); + } + } + + pub fn show(&mut self) { + self.dialog.show(); + } +} + +impl Default for CommandPaletteState { + fn default() -> Self { + Self::new() + } +} + +pub fn init_command_palette() -> CommandPaletteState { + CommandPaletteState::new() +} + +pub fn render_command_palette( + f: &mut Frame, + state: &mut CommandPaletteState, + area: Rect, + colors: ThemeColors, +) { + state.dialog.render(f, area, colors); +} + +pub fn handle_command_palette_key_event( + state: &mut CommandPaletteState, + event: KeyEvent, +) -> CommandPaletteAction { + if !state.dialog.is_visible() { + return CommandPaletteAction::None; + } + + match event.code { + KeyCode::Enter => { + state.dialog.hide(); + if let Some(selected) = state.dialog.get_selected() { + return action_for_item(selected); + } + } + _ => { + state.dialog.handle_key_event(event); + } + } + + CommandPaletteAction::None +} + +pub fn handle_command_palette_mouse_event( + state: &mut CommandPaletteState, + event: MouseEvent, +) -> CommandPaletteAction { + if !state.dialog.is_visible() { + return CommandPaletteAction::None; + } + + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + state.dialog.item_index_at_position(event.column, event.row) + } else { + None + }; + + state.dialog.handle_mouse_event(event); + + if clicked_item.is_some() && state.dialog.is_visible() { + if let Some(selected) = state.dialog.get_selected() { + let action = action_for_item(selected); + state.dialog.hide(); + return action; + } + } + + CommandPaletteAction::None +} + +fn base_actions() -> Vec { + vec![ + DialogAction { + label: "Run".to_string(), + key: "enter".to_string(), + }, + DialogAction { + label: "Close".to_string(), + key: "esc".to_string(), + }, + ] +} + +fn command_palette_tip(command_name: &str) -> Option { + match command_name { + "models" => Some("ctrl+x m".to_string()), + "themes" => Some("ctrl+x t".to_string()), + "sessions" => Some("ctrl+x l".to_string()), + "new" => Some("ctrl+x n".to_string()), + "exit" => Some("ctrl+x q".to_string()), + _ => None, + } +} + +fn action_for_item(item: &DialogItem) -> CommandPaletteAction { + if item.provider_id == APP_ACTION_PROVIDER { + return match item.id.as_str() { + "toggle-agent-mode" => { + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::ToggleAgentMode) + } + "cycle-reasoning-effort" => { + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::CycleReasoningEffort) + } + _ => CommandPaletteAction::None, + }; + } + + CommandPaletteAction::RunCommand(item.id.clone()) +} + +fn core_palette_items(registry: &Registry, is_chat: bool) -> Vec { + let mut items = Vec::new(); + + for (command, name, group, description) in [ + ("new", "New Session", "Workspace", "Start a blank session"), + ( + "sessions", + "Open Sessions", + "Workspace", + "Browse and switch sessions", + ), + ( + "rename", + "Rename Session", + "Workspace", + "Rename the current session", + ), + ( + "timeline", + "Open Timeline", + "Workspace", + "Jump between messages", + ), + ( + "copy", + "Copy Session Transcript", + "Workspace", + "Copy the current transcript", + ), + ( + "compact", + "Compact Context", + "Workspace", + "Summarize this session to reduce context", + ), + ( + "home", + "Go Home", + "Workspace", + "Return to a blank home screen", + ), + ("models", "Change Model", "Model", "Choose the active model"), + ( + "connect", + "Connect Provider", + "Model", + "Add or update provider credentials", + ), + ( + "refreshmodels", + "Refresh Model Cache", + "Model", + "Refresh models.dev provider data", + ), + ( + "themes", + "Change Theme", + "Appearance", + "Choose a color theme", + ), + ("exit", "Quit Crabcode", "Application", "Exit the app"), + ] { + let Some(registered) = registry.get(command) else { + continue; + }; + if !is_chat && registered.chat_only { + continue; + } + + items.push(DialogItem { + id: command.to_string(), + name: name.to_string(), + group: group.to_string(), + description: description.to_string(), + tip: command_palette_tip(command), + provider_id: String::new(), + }); + } + + items.insert( + 2.min(items.len()), + app_action_item( + "toggle-agent-mode", + "Toggle Agent Mode", + "Workspace", + "Switch between Build and Plan", + Some("tab"), + ), + ); + + items.insert( + items + .iter() + .position(|item| item.group == "Appearance") + .unwrap_or(items.len()), + app_action_item( + "cycle-reasoning-effort", + "Cycle Reasoning Effort", + "Model", + "Switch reasoning effort for the active model", + Some("ctrl+t"), + ), + ); + + items +} + +fn custom_command_items(registry: &Registry, is_chat: bool) -> Vec { + let mut items: Vec = registry + .list_commands() + .into_iter() + .filter(|command| registry.is_custom_command(&command.name)) + .filter(|command| is_chat || !command.chat_only) + .filter(|command| !is_skill_backed_command(registry, &command.name)) + .map(|command| { + let custom = registry.custom_command(&command.name); + DialogItem { + id: command.name.clone(), + name: humanize_command_name(&command.name), + group: "Commands".to_string(), + description: if command.description.trim().is_empty() { + "Run configured command".to_string() + } else { + command.description.clone() + }, + tip: custom.and_then(custom_command_source_tip), + provider_id: String::new(), + } + }) + .collect(); + + items.sort_by(|left, right| left.name.cmp(&right.name)); + items +} + +fn is_skill_backed_command(registry: &Registry, command_name: &str) -> bool { + if registry.is_custom_command(command_name) { + return false; + } + + if command_name == "skills" { + return true; + } + + crate::skill::get_skill_store() + .and_then(|store| store.get(command_name)) + .is_some() +} + +fn custom_command_source_tip(command: &crate::command::custom::CustomCommand) -> Option { + match &command.source { + CustomCommandSource::Config(_) => Some("config".to_string()), + CustomCommandSource::File(_) => Some("file".to_string()), + } +} + +fn app_action_item( + id: &str, + name: &str, + group: &str, + description: &str, + tip: Option<&str>, +) -> DialogItem { + DialogItem { + id: id.to_string(), + name: name.to_string(), + group: group.to_string(), + description: description.to_string(), + tip: tip.map(str::to_string), + provider_id: APP_ACTION_PROVIDER.to_string(), + } +} + +fn humanize_command_name(name: &str) -> String { + let parts: Vec = name + .split(|ch: char| matches!(ch, '-' | '_' | '/' | ':' | '.')) + .filter(|part| !part.is_empty()) + .map(capitalize_ascii) + .collect(); + + if parts.is_empty() { + name.to_string() + } else { + parts.join(" ") + } +} + +fn capitalize_ascii(value: &str) -> String { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + + let mut out = String::new(); + out.extend(first.to_uppercase()); + out.push_str(chars.as_str()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::custom::{CustomCommand, CustomCommandSource}; + use crate::command::handlers::register_all_commands; + use std::path::PathBuf; + + #[test] + fn palette_hides_chat_only_commands_outside_chat() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, false); + + assert!(state.dialog.items.iter().any(|item| item.id == "models")); + assert!(!state.dialog.items.iter().any(|item| item.id == "copy")); + } + + #[test] + fn palette_includes_chat_only_commands_in_chat() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true); + + assert!(state.dialog.items.iter().any(|item| item.id == "copy")); + } + + #[test] + fn palette_uses_command_center_labels_without_slashes() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true); + + assert!(state + .dialog + .items + .iter() + .all(|item| !item.name.starts_with('/'))); + assert!(state + .dialog + .items + .iter() + .any(|item| item.id == "models" && item.name == "Change Model")); + assert!(!state.dialog.items.iter().any(|item| item.id == "skills")); + } + + #[test] + fn palette_includes_config_commands_grouped_as_commands() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + registry.register_custom(CustomCommand { + name: "checkcodex-oauth".to_string(), + description: Some("Check Codex OAuth".to_string()), + agent: None, + model: None, + subtask: None, + template: "check auth".to_string(), + source: CustomCommandSource::Config(PathBuf::from("crabcode.jsonc")), + workdir: PathBuf::from("."), + }); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true); + + let custom = state + .dialog + .items + .iter() + .find(|item| item.id == "checkcodex-oauth") + .expect("custom command should be listed"); + assert_eq!(custom.group, "Commands"); + assert_eq!(custom.name, "Checkcodex Oauth"); + assert_eq!(custom.tip.as_deref(), Some("config")); + } +} diff --git a/src/views/home.rs b/src/views/home.rs index d674f61..2d1d81c 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -208,14 +208,8 @@ pub fn render_home( ); let help_text = vec![ - Span::styled("/", Style::default().fg(colors.info)), - Span::raw(" commands "), - Span::styled("ctrl+x", Style::default().fg(colors.info)), - Span::raw(" shortcuts "), - Span::styled("tab", Style::default().fg(colors.info)), - Span::raw(" agents "), - Span::styled("ctrl+cc", Style::default().fg(colors.info)), - Span::raw(" quit "), + Span::styled("ctrl+p", Style::default().fg(colors.info)), + Span::raw(" commands"), ]; let help_line = Line::from(help_text); let help_width = help_line.width() as u16; diff --git a/src/views/mod.rs b/src/views/mod.rs index dae04eb..0db8374 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,4 +1,5 @@ pub mod chat; +pub mod command_palette; pub mod connect_dialog; pub mod home; pub mod models_dialog; From b81cee43780eadb90bafa365070599172f503ee7 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 22 May 2026 00:38:31 +0800 Subject: [PATCH 136/226] feat: normalize plan status markers and add helper functions. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace unicode status markers (✔/□) with bracket-based format ([x]/[•]/[ ]) in update_plan tool output and refactor formatting into dedicated helpers. Update TUI to render in-progress items with a bullet (•) for visual distinction. Add system prompt instruction to avoid redundant update_plan calls. --- _plans/PREMATURE_COMPLETE_BUG.md | 135 +++++++++++++++++++++++++++++++ src/prompt/mod.rs | 2 + src/tools/update_plan.rs | 55 +++++++++---- src/ui/components/chat.rs | 31 ++++++- 4 files changed, 208 insertions(+), 15 deletions(-) diff --git a/_plans/PREMATURE_COMPLETE_BUG.md b/_plans/PREMATURE_COMPLETE_BUG.md index b7ba59c..fa7d21b 100644 --- a/_plans/PREMATURE_COMPLETE_BUG.md +++ b/_plans/PREMATURE_COMPLETE_BUG.md @@ -138,3 +138,138 @@ Added narrow lifecycle logging to make the next recurrence attributable. - Does the UI mark a turn complete solely when the relay exhausts, even if task/subagent senders still exist? - Should `final_answer + end_turn=None` be trusted for ChatGPT OAuth/Codex transport, or should `end_turn=true` be required for final completion when tools are enabled? - What should be the canonical crabcode pending-work signal that can keep the turn alive without inspecting assistant prose or plan/todo text? + +## 2026-05-22 Recurrence + +### User-Visible Symptom + +Crabcode was asked to add an opencode-style `ctrl+p` command palette and reduce the footer hint text. It completed a long implementation turn early with another preamble-shaped message: + +> I’ll add Ctrl+P handling before other base shortcuts. + +The visible transcript still had unfinished work: the command palette was only partially wired, the footer text had not been changed, and validation had not run. + +The user's immediate follow-up was `Continue`, but that follow-up was cancelled almost immediately. Treat that cancellation as a separate event when reading `app.log`. + +### `app.log` Evidence + +Primary session id: `ypw8yixa4em0rg8v9hldfkl3`. + +Relevant sequence: + +- `23:51:08` through `00:05:08`: the primary turn executed steps 1-29, including reads, searches, plan updates, a new `src/views/command_palette.rs`, and several `src/app.rs` / `src/views/mod.rs` edits. +- `00:05:08`: `edit` call `call_64` succeeded and tool results were added. +- `00:05:08`: provider step 30 started with the same primary session. +- `00:05:10`: metadata said `assistant_message_phase=final_answer`. +- `00:05:10`: metadata said `response.completed end_turn=None`. +- `00:05:10`: AISDK logged `provider_step_finish step=30 has_tool_call=false end_turn=None last_phase=final_answer assistant_text_chars=55 action=finish preview="I’ll add Ctrl+P handling before other base shortcuts."` +- `00:05:10`: crabcode marked the stream complete: + - `outcome=Exhausted` + - `effective_outcome=Finished` + - `stop_reason=Some(Finish)` + +Important separation from the user's cancelled `Continue`: + +- `00:05:15`: a new stream started for the same session with `input_messages=97`. +- `00:05:17`: that new stream was cancelled by the user. +- `00:05:26+`: tool calls `call_65` and later continued after cancellation, with `ui_send_failed`. These belong to the cancelled `Continue` stream, not the original premature-complete stream. + +### Reference Parity Check + +Codex reference behavior in `.devrefs/references/openai/codex/codex-rs/core/src/session/turn.rs` is still structurally similar to crabcode's current loop: + +- A sampling request follows up when completed output includes tool work or `response.completed end_turn == Some(false)`. +- A closed stream before `response.completed` is an error. +- A non-commentary assistant message with no tool call and no `end_turn=false` is treated as a completed model turn. + +opencode reference behavior is also structurally similar: + +- `packages/opencode/src/session/processor.ts` drains the AI SDK `fullStream`, records tool parts, and marks the assistant message completed in cleanup. +- `packages/opencode/src/session/prompt.ts` keeps looping when the last assistant finish is `tool-calls` or when assistant parts still include unresolved non-provider-executed tool calls. +- It does not inspect assistant prose or todo/plan wording to decide whether a turn is complete. + +This means a runtime guard based on text like "I'll ..." or on plan item status would diverge from both references. Requiring `end_turn=true` instead of accepting `None` would also diverge from Codex-style handling, which only treats `Some(false)` as a structured follow-up signal. + +### Current Working Theory + +This recurrence is not the prior "post-completion tools from the same primary stream" suspicion. The new diagnostics show the original primary stream ended cleanly at `00:05:10` on a provider step that had no tool call and no structured follow-up signal. + +The recurrence is best explained as a prompt/protocol parity gap: + +1. The model emitted a progress/preamble sentence as `final_answer`. +2. The provider gave `end_turn=None`, not `false`. +3. Crabcode followed the same structured completion rules as Codex/opencode and finished the turn. +4. The active crabcode Codex prompt is much weaker than the upstream Codex prompt. In particular, upstream Codex's GPT-5.2 prompt explicitly requires persistence until the task is fully handled, maintaining plan status, not leaving the plan stale, and finishing with all plan items complete or explicitly canceled/deferred before ending. Crabcode's local `src/prompt/mod.rs` only has a short "only terminate when solved" / "use final answers only when complete" version. + +### Separate Cancellation Finding + +The cancelled `Continue` run exposed a different issue: cancelling the relay stops UI consumption, but the underlying AISDK tool loop can still execute tools afterward. Evidence is `00:05:26+` tool calls with `ui_send_failed` after `[STREAM_CANCELLED]`. + +That is not the premature-complete recurrence the user asked to ignore, but it should probably become a separate cancellation-abort bug. + +### Next Debugging Targets + +1. Bring `src/prompt/mod.rs` Codex prompt closer to upstream `gpt_5_2_prompt.md`, especially persistence, plan-status, and final-answer criteria. +2. Keep runtime completion gates reference-shaped: tool calls, tool results needing follow-up, `end_turn=false`, commentary phase, and terminal-event enforcement. +3. Do not add natural-language final-answer heuristics or `update_plan` completion gates unless intentionally choosing to diverge from Codex/opencode. +4. Track the cancellation issue separately: cancellation should abort `stream_with_tools` and any in-flight tool execution rather than merely closing the UI sender. + +## 2026-05-22 Plan Loop Regression + +### User-Visible Symptom + +After the premature-completion prompt/protocol fix, crabcode was asked to add syntax highlighting during `Edited` tool calls. Instead of inspecting files, it repeatedly emitted preambles like: + +> I’ll activate the plan and inspect edited-call rendering paths. + +and repeatedly called `update_plan` with the same plan: + +- `Locate edited tool-call rendering path` as `in_progress` +- remaining items as `pending` + +The visible tool result rendered every item as unchecked, so the transcript looked like the active plan never took effect. + +### `app.log` Evidence + +Primary session id: `f8m29e6gfpx6rmj3ydxzdajb`. + +Relevant sequence: + +- `00:28:13`: stream started for the syntax-highlighting request. +- `00:28:18`: the model called `skill ratatui` once. +- `00:28:24` through `00:30:58`: steps 2-23 repeatedly called `update_plan` with the same `in_progress` item and no file-search/read/edit tools. +- `00:31:00`: user cancelled the stream. +- `00:31:07+`: the underlying tool loop still executed additional `update_plan` calls with `ui_send_failed`, matching the separate cancellation-abort issue. + +### Root Cause + +The previous prompt fix made active-plan state more important, but `src/tools/update_plan.rs` returned the same plain-text marker for `in_progress` and `pending`: + +- `in_progress` -> `□` +- `pending` -> `□` + +The model only receives the tool output text, not the UI color styling. It therefore saw its `in_progress` update echoed back as still unchecked, then tried to activate the plan again. The TUI had the same problem in transcript/plain-text captures because active plan rows differed only by color. + +### Fix Applied + +- `src/tools/update_plan.rs` + - Tool output now uses distinct markers: + - `in_progress` -> `[•]` + - `pending` -> `[ ]` + - `completed` -> `[x]` + - Added `format_plan_output_preserves_in_progress_status`. +- `src/ui/components/chat.rs` + - Plan rendering now shows `•` for active rows, so plain-text transcripts preserve active status. + - Added `test_updated_plan_renders_in_progress_distinctly`. +- `src/prompt/mod.rs` + - Added a planning rule that after `update_plan` succeeds, the model should proceed with concrete tool work and not repeat the same plan unless content or statuses changed. + +Validation: + +- `cargo test -q format_plan_output_preserves_in_progress_status` +- `cargo test -q test_updated_plan_renders_in_progress_distinctly` +- `cargo test -q codex_prompt_separates_progress_from_final_answers` + +### Follow-up + +The cancellation-abort issue remains separate: after `[STREAM_CANCELLED]`, `stream_with_tools` can still execute tool calls whose UI sender is already closed. diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 2f5b486..58bfcb2 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -223,6 +223,7 @@ Planning: - Mark steps completed before moving forward - Maintain exactly one in_progress item at a time until all active work is done - Do not let the plan go stale while coding +- After update_plan succeeds, proceed with the next concrete tool call; do not call update_plan again unless the plan content or statuses changed - Do not end the turn while any active plan item remains pending or in_progress unless the user pauses, redirects, or you are blocked and explain why File Handling: @@ -375,6 +376,7 @@ mod tests { assert!(prompt.contains("keep using tools instead of sending a final answer")); assert!(prompt.contains("Persist until the task is fully handled end-to-end")); assert!(prompt.contains("Do not let the plan go stale while coding")); + assert!(prompt.contains("do not call update_plan again unless the plan content")); assert!(prompt.contains("Do not end the turn while any active plan item remains pending")); } } diff --git a/src/tools/update_plan.rs b/src/tools/update_plan.rs index 4be56b4..8d10bc8 100644 --- a/src/tools/update_plan.rs +++ b/src/tools/update_plan.rs @@ -240,6 +240,30 @@ fn validate_plan_items(plan: &[PlanItem]) -> Result<(), ToolError> { Ok(()) } +fn output_marker_for_status(status: &str) -> &'static str { + match status { + "completed" => "[x]", + "in_progress" => "[•]", + _ => "[ ]", + } +} + +fn format_plan_update_output(update: &PlanUpdate) -> String { + let mut output = String::new(); + if let Some(explanation) = update.explanation.as_deref() { + output.push_str(explanation); + output.push('\n'); + } + for item in &update.plan { + output.push_str(&format!( + "{} {}\n", + output_marker_for_status(&item.status), + item.step + )); + } + output +} + #[async_trait] impl ToolHandler for UpdatePlanTool { fn definition(&self) -> Tool { @@ -269,20 +293,7 @@ impl ToolHandler for UpdatePlanTool { async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { let update = parse_update_plan(¶ms)?; - - let mut output = String::new(); - if let Some(explanation) = update.explanation.as_deref() { - output.push_str(explanation); - output.push('\n'); - } - for item in &update.plan { - let marker = match item.status.as_str() { - "completed" => "✔", - "in_progress" => "□", - _ => "□", - }; - output.push_str(&format!("{} {}\n", marker, item.step)); - } + let output = format_plan_update_output(&update); Ok(ToolResult::new("Plan updated", output) .with_metadata("explanation", serde_json::json!(update.explanation)) @@ -343,4 +354,20 @@ mod tests { assert_eq!(update.plan[1].status, "in_progress"); assert_eq!(update.plan[2].status, "completed"); } + + #[test] + fn format_plan_output_preserves_in_progress_status() { + let params = json!({ + "plan": [ + {"step": "Locate renderer", "status": "in_progress"}, + {"step": "Validate", "status": "pending"}, + {"step": "Ship fix", "status": "completed"} + ] + }); + + let update = parse_update_plan(¶ms).unwrap(); + let output = format_plan_update_output(&update); + + assert_eq!(output, "[•] Locate renderer\n[ ] Validate\n[x] Ship fix\n"); + } } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 35cd903..8a1b883 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -404,6 +404,8 @@ fn parse_plan_checkbox_line(line: &str) -> Option { (PlanStepStatus::Completed, rest) } else if let Some(rest) = line.strip_prefix("[•]") { (PlanStepStatus::InProgress, rest) + } else if let Some(rest) = line.strip_prefix("•") { + (PlanStepStatus::InProgress, rest) } else if let Some(rest) = line.strip_prefix("□") { (PlanStepStatus::Pending, rest) } else { @@ -2318,7 +2320,7 @@ impl Chat { .add_modifier(Modifier::DIM | Modifier::CROSSED_OUT), ), PlanStepStatus::InProgress => ( - "□ ", + "• ", Style::default() .fg(colors.accent) .add_modifier(Modifier::BOLD), @@ -3695,6 +3697,33 @@ mod tests { ); } + #[test] + fn test_updated_plan_renders_in_progress_distinctly() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "update_plan", + "status": "ok", + "output_preview": "[ ] Locate renderer\n[•] Implement highlighting\n[x] Validate\n", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬢ Updated Plan", + " └ □ Locate renderer", + " • Implement highlighting", + " ✔ Validate", + "", + ] + ); + } + #[test] fn test_short_updated_plan_content_renders_at_top() { use ratatui::{backend::TestBackend, Terminal}; From 206f8aeb5e64b4538ca0122b741e046d603dfcdf Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 22 May 2026 00:42:16 +0800 Subject: [PATCH 137/226] fix(chat): preserve explicit newlines in user messages. Newlines in user input were being stripped during the word-wrapping step, causing multi-line messages (e.g. lists, paragraphs) to collapse into a single line. Split the content on `\n` before wrapping each line individually to retain intentional line breaks. --- _plans/__TODOS.md | 15 +++++++++++++++ src/ui/components/chat.rs | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index edc0b5c..e98b22e 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -148,3 +148,18 @@ Replaced at line 239 - [x] Like opencode, let's make a command palette via `ctrl+p`. - [x] Additionally, since the bottom area takes up too much space with `/ commands ctrl+x shortcuts tab agents ctrl+cc quit`. Let's reduce it to just `ctrl+p`?. + +- [x] linebreaks aren't really reserved when I finally send the message in the chat UI. For instance I send, + +``` +I want +- [x] To do this + +But I dont want to do this. +``` + +I get + +``` +I want - [x] To do this But I dont want to do this +``` diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 8a1b883..7441417 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1869,12 +1869,18 @@ impl Chat { ]) }; - let styled_content = Line::from(spans_with_image_placeholders( - &content, - text_style, - image_style, - )); - let wrapped_lines = wrap_styled_line(&styled_content, WrapOptions::new(wrap_width)); + let wrapped_lines = content + .split('\n') + .flat_map(|content_line| { + let content_line = content_line.strip_suffix('\r').unwrap_or(content_line); + let styled_content = Line::from(spans_with_image_placeholders( + content_line, + text_style, + image_style, + )); + wrap_styled_line(&styled_content, WrapOptions::new(wrap_width)) + }) + .collect::>(); lines.push(padding_line()); @@ -3479,6 +3485,25 @@ mod tests { ); } + #[test] + fn test_user_message_preserves_explicit_linebreaks() { + let chat = Chat::new(); + let msg = Message::user("I want\n- [ ] To do this\n\nBut I dont want to do this."); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert!(rendered.iter().any(|line| line.contains("I want"))); + assert!(rendered + .iter() + .any(|line| line.contains("- [ ] To do this"))); + assert!(rendered.iter().any(|line| line.trim().is_empty())); + assert!(rendered + .iter() + .any(|line| line.contains("But I dont want to do this."))); + } + #[test] fn test_user_message_image_placeholders_use_markdown_image_color() { let chat = Chat::new(); From 8beb439260f6ab7930ca8a93484d4dd8b14b2bbe Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 22 May 2026 01:52:23 +0800 Subject: [PATCH 138/226] fix: address premature completion recurrence through prompt parity and structured tool history. Apply structured tool-call/tool-output message types at the AISDK boundary and replay persisted tool history as structured pairs instead of flattened observation text. Rework the Codex prompt toward the reference shape with stronger persistence, progress-update, and plan-completion instructions. Change `update_plan` to return a simple acknowledgment with structured metadata. Include tool-call arguments in compaction previews and transcript exports. --- _plans/PREMATURE_COMPLETE_BUG.md | 93 ++++++++++++++++++++++ aisdk/src/message.rs | 46 +++++++++++ aisdk/src/providers/anthropic.rs | 19 +++++ aisdk/src/providers/compatible.rs | 18 +++++ aisdk/src/providers/openai.rs | 41 +++++++++- aisdk/src/response.rs | 123 +++++++++++++++++------------- src/app.rs | 8 +- src/llm/client.rs | 106 ++++++++++++++++++++++++- src/prompt/mod.rs | 109 +++++++++++++------------- src/session/compaction.rs | 33 +++++++- src/tools/update_plan.rs | 49 ++++-------- 11 files changed, 502 insertions(+), 143 deletions(-) diff --git a/_plans/PREMATURE_COMPLETE_BUG.md b/_plans/PREMATURE_COMPLETE_BUG.md index fa7d21b..5fb4e0f 100644 --- a/_plans/PREMATURE_COMPLETE_BUG.md +++ b/_plans/PREMATURE_COMPLETE_BUG.md @@ -273,3 +273,96 @@ Validation: ### Follow-up The cancellation-abort issue remains separate: after `[STREAM_CANCELLED]`, `stream_with_tools` can still execute tool calls whose UI sender is already closed. + +## 2026-05-22 Active Plan Premature Final Recurrence + +### User-Visible Symptom + +Crabcode was asked whether Codex-style `update_plan` preamble rendering was relevant and whether crabcode should support it. It found the relevant renderer path and made one partial edit, then ended the turn with another progress-update-shaped final answer: + +> Now I’ll add regression coverage for the preamble case. + +The task was visibly incomplete: the regression test had not been added, validation had not run, and the active plan still had unfinished items. + +The partial UI/parser change from that interrupted task was removed from `src/ui/components/chat.rs` during this follow-up because it was unrelated to the premature-completion fix and had not been wired into rendering. + +### `app.log` Evidence + +Primary session id: `q5vx4soz1d46hnliwovqord7`. + +Relevant sequence: + +- `00:43:43`: the model called `update_plan` with one `in_progress` item and pending validation. +- `00:44:38`: the model updated the plan to two completed items, one `in_progress` implementation item, and one pending validation item. +- `00:45:01`: `edit` call `call_19` succeeded in `src/ui/components/chat.rs`. +- `00:45:01`: provider step 11 started after the edit result. +- `00:45:02`: metadata said `assistant_message_phase=final_answer`. +- `00:45:02`: metadata said `response.completed end_turn=None`. +- `00:45:02`: AISDK logged `provider_step_finish step=11 has_tool_call=false end_turn=None last_phase=final_answer assistant_text_chars=57 action=finish preview="Now I’ll add regression coverage for the preamble case."` +- `00:45:02`: crabcode marked the stream complete with `stop_reason=Some(Finish)`. + +Unlike the earlier cancellation finding, this recurrence had no late same-stream tool execution after completion. The model simply emitted a preamble as final output and the runtime accepted it. + +### Root Cause + +The provider emitted a normal final-answer phase with no tool call, so crabcode finished the turn. The Codex reference loop similarly does not use `update_plan` state as a completion gate; it relies on model instructions plus structured stream/tool lifecycle signals. That means the non-parity fix is not to special-case active plan items in AISDK. + +The more direct parity gap found during this follow-up was tool history fidelity. Crabcode stores tool-call arguments in the chat message JSON, but both live follow-up observations and persisted-session replay collapsed tool messages to only the tool result text. For tools like `edit`, the model could see `Replaced at line N` without seeing the original `old_string` / `new_string` it had requested. + +### Superseded Runtime Fix + +An `aisdk/src/response.rs` guard was briefly added to keep the turn alive when the latest `update_plan` / `todowrite` state still had `in_progress` or `pending` items. That prevented this symptom but diverged from the Codex reference loop, which does not use plan status as completion control. The guard and its regression test were removed. + +### Prompt-Parity Fix Applied + +- `src/prompt/mod.rs` + - Reworked the Codex prompt toward the reference prompt shape: Personality, Autonomy and Persistence, Progress Updates and Final Answers, Planning, Task Execution, and Validation. + - Strengthened model-facing instructions to persist through implementation, verification, and outcome reporting. + - Kept the completion semantics in prompt/protocol space instead of runtime plan-state gating. + +### Structured Tool-History Parity Fix Applied + +The follow-up fix moved crabcode toward the Codex reference behavior instead of relying on flattened observation text. Codex keeps function calls and function-call outputs as structured conversation items, including call ids and arguments; crabcode now preserves that shape at the AISDK boundary and when replaying persisted tool history. + +- `aisdk/src/message.rs` + - Added structured `ToolCall` and `ToolOutput` message variants. +- `aisdk/src/response.rs` + - Live tool execution now appends a structured tool-call message before execution and a structured tool-output message after execution. + - OpenAI Responses tool-call accumulation now preserves the Responses `call_id` separately from the response item id, so function-call outputs correlate with the correct call id. +- `aisdk/src/providers/openai.rs` + - Serializes structured tool history as Responses `function_call` and `function_call_output` input items. +- `aisdk/src/providers/compatible.rs` + - Serializes structured tool history as Chat Completions assistant `tool_calls` and `tool` messages. +- `aisdk/src/providers/anthropic.rs` + - Serializes structured tool history as Anthropic `tool_use` and `tool_result` content blocks. +- `src/llm/client.rs` + - Persisted crabcode tool messages now replay to the model as structured tool-call plus tool-output pairs when the stored JSON has call id, name, args, and output. + - The older text observation path remains only as a fallback for malformed or legacy tool records. +- `src/tools/update_plan.rs` + - `update_plan` now returns Codex-style model output text: `Plan updated`. + - The explanation and plan remain available as structured metadata for crabcode's UI. +- `src/session/compaction.rs` + - Compaction is still text-based in crabcode, so it includes tool-call arguments explicitly to avoid losing edit/write context during summary generation. +- `src/app.rs` + - `/copy` transcripts now include tool arguments and label tool output explicitly. This is export/UI fidelity, not agent-loop completion control. + +Validation: + +- `cargo fmt --check` +- `cargo test -q -p aisdk` +- `cargo test -q -p aisdk uses_responses_call_id_for_tool_output_correlation` +- `cargo test -q -p aisdk maps_responses_function_call_item_to_tool_call_shape` +- `cargo test -q -p aisdk serializes_structured_tool_history_for_responses_input` +- `cargo test -q -p aisdk tool_execution_error_is_returned_to_model_without_failing_stream` +- `cargo test -q tool_history_replays_structured_tool_call_and_output` +- `cargo test -q parse_update_plan_accepts_codex_shape` +- `cargo test -q execute_returns_codex_style_ack_with_structured_metadata` +- `cargo check` +- `cargo test -q compaction_prompt_preserves_tool_call_arguments` +- `cargo test -q -p aisdk continues_when_provider_marks_response_as_non_final` +- `cargo test -q -p aisdk` +- `cargo check` + +### Follow-up + +The cancellation-abort issue remains separate and still needs a dedicated fix: cancelling a stream can leave the underlying AISDK tool loop running after the UI receiver closes. diff --git a/aisdk/src/message.rs b/aisdk/src/message.rs index 4fc36f7..6da1c34 100644 --- a/aisdk/src/message.rs +++ b/aisdk/src/message.rs @@ -9,6 +9,10 @@ pub enum Message { User(UserMessage), #[serde(rename = "assistant")] Assistant(AssistantMessage), + #[serde(rename = "tool_call")] + ToolCall(ToolCallMessage), + #[serde(rename = "tool_output")] + ToolOutput(ToolOutputMessage), } impl Message { @@ -37,6 +41,32 @@ impl Message { content: content.into(), }) } + + pub fn tool_call( + call_id: impl Into, + name: impl Into, + arguments: impl Into, + ) -> Self { + Self::ToolCall(ToolCallMessage { + call_id: call_id.into(), + name: name.into(), + arguments: arguments.into(), + }) + } + + pub fn tool_output( + call_id: impl Into, + name: impl Into, + output: impl Into, + is_error: bool, + ) -> Self { + Self::ToolOutput(ToolOutputMessage { + call_id: call_id.into(), + name: name.into(), + output: output.into(), + is_error, + }) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -62,6 +92,22 @@ pub struct AssistantMessage { pub content: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallMessage { + pub call_id: String, + pub name: String, + pub arguments: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolOutputMessage { + pub call_id: String, + pub name: String, + pub output: String, + #[serde(default)] + pub is_error: bool, +} + impl From for SystemMessage { fn from(content: String) -> Self { Self { content } diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs index 87888c5..6a79f98 100644 --- a/aisdk/src/providers/anthropic.rs +++ b/aisdk/src/providers/anthropic.rs @@ -117,6 +117,25 @@ impl Provider for Anthropic { "role": "assistant", "content": a.content, })), + Message::ToolCall(t) => Some(serde_json::json!({ + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": t.call_id, + "name": t.name, + "input": serde_json::from_str::(&t.arguments) + .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())), + }], + })), + Message::ToolOutput(t) => Some(serde_json::json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": t.call_id, + "content": t.output, + "is_error": t.is_error, + }], + })), _ => None, }) .collect(); diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index 81d4997..35ef46b 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -115,6 +115,24 @@ impl Provider for OpenAICompatible { "role": "assistant", "content": a.content, }), + Message::ToolCall(t) => serde_json::json!({ + "role": "assistant", + "content": serde_json::Value::Null, + "tool_calls": [{ + "id": t.call_id, + "type": "function", + "function": { + "name": t.name, + "arguments": t.arguments, + } + }], + }), + Message::ToolOutput(t) => serde_json::json!({ + "role": "tool", + "tool_call_id": t.call_id, + "name": t.name, + "content": t.output, + }), }) .collect(); diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index efa23af..0d502ab 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -769,6 +769,13 @@ fn response_function_call_chunk_base_with_item( ) -> Option> { let mut chunk = response_function_call_chunk_base(value, function)?; + if let Some(call_id) = item.get("call_id").and_then(|v| v.as_str()) { + chunk.insert( + "call_id".to_string(), + serde_json::Value::String(call_id.to_string()), + ); + } + if !chunk.contains_key("id") { if let Some(id) = item .get("id") @@ -804,6 +811,17 @@ fn build_openai_messages(messages: &[Message], strip_system: bool) -> Vec Some(serde_json::json!({ + "type": "function_call", + "call_id": t.call_id, + "name": t.name, + "arguments": t.arguments, + })), + Message::ToolOutput(t) => Some(serde_json::json!({ + "type": "function_call_output", + "call_id": t.call_id, + "output": t.output, + })), } }) .collect() @@ -832,8 +850,9 @@ fn openai_responses_user_content(user: &crate::message::UserMessage) -> serde_js #[cfg(test)] mod tests { - use super::{response_sse_data_to_chunk, responses_function_call_chunk}; + use super::{build_openai_messages, response_sse_data_to_chunk, responses_function_call_chunk}; use crate::chunk::{ChunkType, MessagePhase}; + use crate::message::Message; #[test] fn done_marker_emits_terminal_chunk() { @@ -891,6 +910,7 @@ mod tests { assert_eq!(parsed[0]["index"], 0); assert_eq!(parsed[0]["id"], "fc_123"); + assert_eq!(parsed[0]["call_id"], "call_123"); assert_eq!(parsed[0]["function"]["name"], "read"); } @@ -913,4 +933,23 @@ mod tests { "{\"file_path\":\"Cargo.toml\"}" ); } + + #[test] + fn serializes_structured_tool_history_for_responses_input() { + let input = build_openai_messages( + &[ + Message::tool_call("call_edit", "edit", "{\"file_path\":\"src/lib.rs\"}"), + Message::tool_output("call_edit", "edit", "Replaced at line 7", false), + ], + false, + ); + + assert_eq!(input[0]["type"], "function_call"); + assert_eq!(input[0]["call_id"], "call_edit"); + assert_eq!(input[0]["name"], "edit"); + assert_eq!(input[0]["arguments"], "{\"file_path\":\"src/lib.rs\"}"); + assert_eq!(input[1]["type"], "function_call_output"); + assert_eq!(input[1]["call_id"], "call_edit"); + assert_eq!(input[1]["output"], "Replaced at line 7"); + } } diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 9221659..48e4b0d 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -276,8 +276,14 @@ pub async fn stream_with_tools( let mut tool_results_to_observe = Vec::new(); let mut tool_calls_to_run = Vec::new(); + let mut tool_call_messages = Vec::new(); for (call_id, tool_name, args) in tool_calls_to_execute { + let tool_call_message = + Message::tool_call(call_id.clone(), tool_name.clone(), canonical_json(&args)); + current_messages.push(tool_call_message.clone()); + tool_call_messages.push(tool_call_message); + let cache_key = repeatable_tool_cache_key(&tool_name, &args); if let Some(cached_output) = cache_key .as_ref() @@ -299,6 +305,10 @@ pub async fn stream_with_tools( } } + if !tool_call_messages.is_empty() { + messages_arc.lock().await.extend(tool_call_messages); + } + let tool_results = join_all(tool_calls_to_run.into_iter().map( |(call_id, tool_name, args, cache_key)| { let tool = tools.iter().find(|t| t.name == tool_name).cloned(); @@ -349,7 +359,6 @@ pub async fn stream_with_tools( } if !tool_results_to_observe.is_empty() { - let observation = format_tool_observation(&tool_results_to_observe); let tool_names = tool_results_to_observe .iter() .map(|result| result.tool_name.as_str()) @@ -357,15 +366,25 @@ pub async fn stream_with_tools( .join(","); let tool_result_summary = tool_results_log_summary(&tool_results_to_observe); let _ = tx_loop.send(ChunkType::Metadata(format!( - "tool_results_added count={} names={} {} observation_chars={} next_messages={}", + "tool_results_added count={} names={} {} next_messages={}", tool_results_to_observe.len(), tool_names, tool_result_summary, - observation.len(), - current_messages.len() + 1 + current_messages.len() + tool_results_to_observe.len() ))); - current_messages.push(Message::user(observation.clone())); - messages_arc.lock().await.push(Message::user(observation)); + let tool_output_messages = tool_results_to_observe + .into_iter() + .map(|result| { + Message::tool_output( + result.call_id, + result.tool_name, + result.output, + result.is_error, + ) + }) + .collect::>(); + current_messages.extend(tool_output_messages.clone()); + messages_arc.lock().await.extend(tool_output_messages); } } }); @@ -423,6 +442,7 @@ fn message_log_summary(messages: &[Message]) -> MessageLogSummary { Message::System(_) => summary.system_messages += 1, Message::User(_) => summary.user_messages += 1, Message::Assistant(_) => summary.assistant_messages += 1, + Message::ToolCall(_) | Message::ToolOutput(_) => {} } summary.text_bytes += text_bytes; @@ -445,6 +465,8 @@ fn message_role(message: &Message) -> &'static str { Message::System(_) => "system", Message::User(_) => "user", Message::Assistant(_) => "assistant", + Message::ToolCall(_) => "tool_call", + Message::ToolOutput(_) => "tool_output", } } @@ -453,6 +475,8 @@ fn message_size(message: &Message) -> (usize, usize) { Message::System(message) => (message.content.len(), 0), Message::User(message) => (message.content.len(), message.images.len()), Message::Assistant(message) => (message.content.len(), 0), + Message::ToolCall(message) => (message.arguments.len(), 0), + Message::ToolOutput(message) => (message.output.len(), 0), } } @@ -555,6 +579,7 @@ struct ToolCallAccumulator { struct PendingToolCall { key: String, id: Option, + call_id: Option, name: Option, arguments: String, final_arguments: Option, @@ -604,39 +629,6 @@ fn canonical_json(value: &serde_json::Value) -> String { } } -fn format_tool_observation(results: &[ToolExecutionResult]) -> String { - if let [result] = results { - if result.is_error { - return format!( - "Tool `{}` failed:\n{}\n\nUse this tool error to adjust the next step. Do not repeat the same tool call unchanged unless the underlying file or input has changed.", - result.tool_name, result.output - ); - } - - return format!("Tool `{}` result:\n{}", result.tool_name, result.output); - } - - let failed = results.iter().filter(|result| result.is_error).count(); - let mut observation = format!( - "Tool batch results: {} tool calls returned, {} failed. Use these results to answer the user's request or adjust the next step. Do not repeat the same failing tool calls unchanged.", - results.len(), - failed - ); - - for (idx, result) in results.iter().enumerate() { - observation.push_str(&format!( - "\n\n\n{}\n", - idx + 1, - result.tool_name, - result.call_id, - if result.is_error { "error" } else { "ok" }, - result.output - )); - } - - observation -} - impl ToolCallAccumulator { fn ingest(&mut self, json_str: &str) -> std::result::Result<(), String> { let parsed: serde_json::Value = serde_json::from_str(json_str) @@ -662,7 +654,8 @@ impl ToolCallAccumulator { .filter(|name| !name.is_empty()) .ok_or_else(|| format!("Tool call '{}' missing function name", call.key))?; - let id = call.id.unwrap_or(call.key); + let item_id = call.id.unwrap_or_else(|| call.key.clone()); + let id = call.call_id.unwrap_or(item_id); let args = parse_tool_arguments(&id, &call.arguments, call.final_arguments.as_deref())?; results.push((id, name, args)); @@ -686,6 +679,13 @@ impl ToolCallAccumulator { .filter(|id| !id.is_empty()) .map(ToString::to_string); } + if pending.call_id.is_none() { + pending.call_id = item + .get("call_id") + .and_then(|value| value.as_str()) + .filter(|id| !id.is_empty()) + .map(ToString::to_string); + } if let Some(function) = item.get("function") { if pending.name.is_none() { @@ -741,6 +741,7 @@ impl ToolCallAccumulator { self.calls.push(PendingToolCall { key, id: None, + call_id: None, name: None, arguments: String::new(), final_arguments: None, @@ -1022,7 +1023,7 @@ mod tests { let follow_up = messages .last() .and_then(|message| match message { - Message::User(user) => Some(user.content.clone()), + Message::ToolOutput(output) => Some(output.output.clone()), _ => None, }) .unwrap_or_default(); @@ -1095,15 +1096,13 @@ mod tests { .await .into_iter() .filter_map(|message| match message { - Message::User(user) if user.content.starts_with("Tool batch results:") => { - Some(user.content) - } + Message::ToolOutput(output) if output.name == "wait" => Some(output), _ => None, }) .collect::>(); - assert_eq!(observations.len(), 1); - assert!(observations[0].contains("call_1")); - assert!(observations[0].contains("call_2")); + assert_eq!(observations.len(), 2); + assert!(observations.iter().any(|output| output.call_id == "call_1")); + assert!(observations.iter().any(|output| output.call_id == "call_2")); } #[tokio::test] @@ -1154,8 +1153,10 @@ mod tests { .await .into_iter() .filter_map(|message| match message { - Message::User(user) if user.content.contains("Duplicate task call skipped") => { - Some(user.content) + Message::ToolOutput(output) + if output.output.contains("Duplicate task call skipped") => + { + Some(output.output) } _ => None, }) @@ -1304,9 +1305,8 @@ mod tests { .unwrap() .clone() .expect("provider should receive failed tool observation"); - assert!(follow_up.contains("Tool `edit` failed")); + assert!(follow_up.contains("Tool 'edit' error")); assert!(follow_up.contains("Could not find text to replace")); - assert!(follow_up.contains("Do not repeat the same tool call unchanged")); } #[test] @@ -1330,6 +1330,27 @@ mod tests { assert_eq!(calls[0].2["command"], "ls -la"); } + #[test] + fn uses_responses_call_id_for_tool_output_correlation() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"fc_1","call_id":"call_1","type":"function","function":{"name":"read","arguments":""}}]"#, + ) + .unwrap(); + accumulator + .ingest(r#"[{"index":0,"id":"fc_1","function":{"arguments_done":"{\"file_path\":\"Cargo.toml\"}"}}]"#) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "call_1"); + assert_eq!(calls[0].1, "read"); + assert_eq!(calls[0].2["file_path"], "Cargo.toml"); + } + #[test] fn rejects_incomplete_tool_call_arguments() { let mut accumulator = ToolCallAccumulator::default(); diff --git a/src/app.rs b/src/app.rs index 9d660a3..121b713 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2820,8 +2820,14 @@ impl App { if let Some(name) = v.get("name").and_then(|n| n.as_str()) { transcript.push_str(&format!("**Tool:** {}\n", name)); } + if let Some(args) = v.get("args") { + let args = serde_json::to_string_pretty(args) + .unwrap_or_else(|_| args.to_string()); + transcript + .push_str(&format!("**Arguments:**\n```json\n{}\n```\n", args)); + } if let Some(preview) = v.get("output_preview").and_then(|p| p.as_str()) { - transcript.push_str(&format!("```\n{}\n```\n", preview)); + transcript.push_str(&format!("**Output:**\n```\n{}\n```\n", preview)); } } transcript.push_str("\n---\n\n"); diff --git a/src/llm/client.rs b/src/llm/client.rs index b67a96b..6ceb4f2 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -30,6 +30,8 @@ Response must include: Any attempt to use tools is a critical violation. Respond with text ONLY."#; +const TOOL_HISTORY_ARGUMENTS_MAX_CHARS: usize = 60_000; + type DynError = Box; #[derive(Clone, Debug, Default)] @@ -1155,7 +1157,11 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { - aisdk_messages.push(AisdkMessage::user(tool_message_observation(&msg.content))); + if let Some(tool_messages) = tool_messages_for_model(&msg.content) { + aisdk_messages.extend(tool_messages); + } else { + aisdk_messages.push(AisdkMessage::user(tool_message_observation(&msg.content))); + } } } } @@ -1163,6 +1169,37 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec Option> { + let value = serde_json::from_str::(content).ok()?; + let obj = value.as_object()?; + + let call_id = obj + .get("id") + .or_else(|| obj.get("call_id")) + .and_then(|v| v.as_str()) + .filter(|value| !value.trim().is_empty())?; + let name = obj + .get("name") + .and_then(|v| v.as_str()) + .filter(|value| !value.trim().is_empty())?; + let output = obj + .get("output_preview") + .and_then(|v| v.as_str()) + .filter(|value| !value.trim().is_empty())?; + + let arguments = obj + .get("args") + .map(|args| serde_json::to_string(args).unwrap_or_else(|_| args.to_string())) + .unwrap_or_else(|| "{}".to_string()); + let status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("ok"); + let is_error = status.eq_ignore_ascii_case("error"); + + Some(vec![ + AisdkMessage::tool_call(call_id, name, arguments), + AisdkMessage::tool_output(call_id, name, output, is_error), + ]) +} + fn tool_message_observation(content: &str) -> String { let Ok(value) = serde_json::from_str::(content) else { return format!("Tool result:\n{}", content); @@ -1185,14 +1222,36 @@ fn tool_message_observation(content: &str) -> String { if let Some(title) = title { observation.push_str(&format!(": {}", title)); } + if let Some(args) = obj.get("args") { + push_tool_arguments_for_observation(&mut observation, args); + } if !output.is_empty() { - observation.push_str("\n"); + observation.push_str("\n\nTool output:\n"); observation.push_str(output); } observation } +fn push_tool_arguments_for_observation(out: &mut String, args: &serde_json::Value) { + out.push_str("\n\nTool call arguments:\n```json\n"); + out.push_str(&truncate_for_tool_observation( + &serde_json::to_string_pretty(args).unwrap_or_else(|_| args.to_string()), + TOOL_HISTORY_ARGUMENTS_MAX_CHARS, + )); + out.push_str("\n```"); +} + +fn truncate_for_tool_observation(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{}\n[truncated]", truncated) + } else { + truncated + } +} + fn is_openai_oauth_model_allowed(model: &str) -> bool { let model = model.trim().to_ascii_lowercase(); model.contains("codex") || is_openai_oauth_gpt5_model(&model) @@ -1250,7 +1309,7 @@ fn normalize_anthropic_base_url(base_url: &str) -> String { #[cfg(test)] mod tests { use super::{ - is_openai_oauth_model_allowed, openai_request_instructions, AisdkMessage, + convert_messages, is_openai_oauth_model_allowed, openai_request_instructions, AisdkMessage, OpenAIRequestOptions, }; @@ -1306,4 +1365,45 @@ mod tests { assert!(!is_openai_oauth_model_allowed("gpt-5-chat-latest")); assert!(!is_openai_oauth_model_allowed("gpt-4o")); } + + #[test] + fn tool_history_replays_structured_tool_call_and_output() { + let tool_message = crate::session::types::Message::tool( + serde_json::json!({ + "name": "edit", + "status": "ok", + "id": "call_edit", + "title": "Edit: src/lib.rs", + "args": { + "file_path": "src/lib.rs", + "old_string": "old line", + "new_string": "new line" + }, + "output_preview": "Replaced at line 7" + }) + .to_string(), + ); + + let messages = convert_messages(&[tool_message]); + + assert_eq!(messages.len(), 2); + match &messages[0] { + AisdkMessage::ToolCall(call) => { + assert_eq!(call.call_id, "call_edit"); + assert_eq!(call.name, "edit"); + assert!(call.arguments.contains("\"old_string\":\"old line\"")); + assert!(call.arguments.contains("\"new_string\":\"new line\"")); + } + other => panic!("expected tool call, got {other:?}"), + } + match &messages[1] { + AisdkMessage::ToolOutput(output) => { + assert_eq!(output.call_id, "call_edit"); + assert_eq!(output.name, "edit"); + assert_eq!(output.output, "Replaced at line 7"); + assert!(!output.is_error); + } + other => panic!("expected tool output, got {other:?}"), + } + } } diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 58bfcb2..4e509bf 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -185,59 +185,60 @@ Your output will be displayed on a command line interface. Your responses should } fn get_codex_prompt(&self) -> String { - r#"You are an expert software engineer with a concise, direct, friendly personality. - -Core Directives: -- Keep responses concise, direct, friendly -- Send brief preambles before tool calls (8-12 words) -- Break tasks into meaningful, logically ordered steps -- Don't repeat full plan after update_plan -- Fix root cause, not surface patches -- Keep changes minimal and focused -- Validate work via tests/build -- Persist until the task is fully handled end-to-end -- Do not stop at analysis, partial fixes, or incomplete wiring -- Carry changes through implementation, verification, and a clear outcome unless the user explicitly pauses or redirects you - -Autonomy: -- Unless the user explicitly asks for a plan, explanation, or brainstorming, assume they want you to make the needed code changes or run the needed tools -- If you hit a blocker, try to resolve it with available tools before yielding -- Only terminate when you are sure the requested task is solved or you have a concrete blocker to report -- If work remains, keep using tools instead of sending a final answer - -Output Philosophy: -- Group related actions in single preamble -- Build on prior context for momentum -- Keep tone light, friendly, curious -- Exception: Skip preambles for trivial single-file reads -- Minimal markdown formatting -- Treat preambles and progress updates as interim commentary before tool calls -- Never send a preamble or progress update as the final answer -- Use final answers only when the requested work is complete, verified when practical, and ready to hand back + r#"You are Codex, based on GPT-5. You are running as a coding agent in Crabcode on the user's computer. + +Personality: +- Be concise, direct, and friendly. +- Communicate efficiently and keep the user informed about ongoing actions. +- Prioritize actionable guidance, assumptions, prerequisites, and next steps. +- Avoid unnecessary detail unless the user asks for it. + +Autonomy and Persistence: +- Persist until the task is fully handled end-to-end within the current turn whenever feasible. +- Do not stop at analysis, partial fixes, or incomplete wiring. +- Carry work through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you. +- Unless the user explicitly asks for a plan, asks a question about the code, or is brainstorming, assume they want you to make code changes or run tools to solve the problem. +- If code changes are expected, do not stop at a proposed solution in chat; implement the change. +- If you hit a blocker, try to resolve it with available tools before yielding. +- Only terminate when you are sure the problem is solved or you have a concrete blocker to report. + +Progress Updates and Final Answers: +- Send brief preambles before grouped tool calls. +- Treat preambles and progress updates as interim commentary before tool calls. +- Never send a preamble or progress update as the final answer. +- If work remains, continue with tools instead of sending a final answer. +- Use final answers only when the requested work is complete, verified when practical, and ready to hand back. +- Keep final answers concise and focused on what changed, validation run, and any real blocker. Planning: - Use update_plan for non-trivial, multi-phase work -- Plans should break task into logical dependencies -- Don't pad with obvious steps -- Update plans mid-task if needed with explanation -- Mark steps completed before moving forward -- Maintain exactly one in_progress item at a time until all active work is done -- Do not let the plan go stale while coding -- After update_plan succeeds, proceed with the next concrete tool call; do not call update_plan again unless the plan content or statuses changed -- Do not end the turn while any active plan item remains pending or in_progress unless the user pauses, redirects, or you are blocked and explain why - -File Handling: -- Never re-read files after successful edit -- If the user names exact files, inspect those files directly instead of listing directories first -- Avoid repeating identical reads, listings, searches, or validation commands -- Use git log/blame for history context -- Never add copyright/license headers -- Don't use one-letter variables -- Use file_path format for citations - -Efficiency: -- For small, explicit tasks, make the minimal required edit and stop after one relevant verification -- Do not continue searching for optional improvements once the user's requested change is complete +- Plans should break the task into meaningful, logically ordered steps that are easy to verify. +- Do not pad simple work with filler steps or obvious actions. +- Do not repeat the full plan after update_plan; the UI already displays it. +- Before starting the next planned step, mark the previous step completed. +- Maintain exactly one in_progress item at a time. +- Do not jump an item from pending directly to completed; set it to in_progress first. +- Update the plan if scope changes, steps split/merge/reorder, or you discover new work. +- Do not let the plan go stale while coding. +- Finish with all plan items completed or explicitly canceled/deferred before ending the turn. +- After update_plan succeeds, proceed with the next concrete tool call; do not call update_plan again unless the plan content or statuses changed. + +Task Execution: +- Fix the problem at the root cause rather than applying surface-level patches when possible. +- Keep changes minimal and focused on the user's request. +- Respect the existing codebase style and local patterns. +- Do not fix unrelated bugs or broken tests; mention them if relevant. +- Do not git commit or create branches unless explicitly requested. +- Never add copyright or license headers unless requested. +- Prefer rg/ripgrep for search and targeted file reads for named files. +- Avoid repeating identical reads, searches, or validation commands. +- Do not re-read files solely to confirm a successful edit. + +Validation: +- If tests/builds/formatters exist, use focused validation for the changed area first. +- Add or update tests when the codebase has adjacent test patterns and the behavioral risk warrants it. +- Do not add a test framework or formatter to a codebase that does not already use one. +- If validation fails for unrelated reasons, do not fix unrelated issues; report the residual risk. Your output will be displayed on a command line interface. Your responses should be short and concise (typically < 4 lines, excluding tool calls)."#.to_string() } @@ -373,10 +374,14 @@ mod tests { assert!(prompt.contains("preambles and progress updates as interim commentary")); assert!(prompt.contains("Use final answers only when the requested work is complete")); - assert!(prompt.contains("keep using tools instead of sending a final answer")); + assert!(prompt.contains("continue with tools instead of sending a final answer")); assert!(prompt.contains("Persist until the task is fully handled end-to-end")); + assert!(prompt.contains("Do not stop at analysis, partial fixes, or incomplete wiring")); assert!(prompt.contains("Do not let the plan go stale while coding")); + assert!( + prompt.contains("Finish with all plan items completed or explicitly canceled/deferred") + ); assert!(prompt.contains("do not call update_plan again unless the plan content")); - assert!(prompt.contains("Do not end the turn while any active plan item remains pending")); + assert!(prompt.contains("do not stop at a proposed solution in chat")); } } diff --git a/src/session/compaction.rs b/src/session/compaction.rs index f84f4c6..386f94d 100644 --- a/src/session/compaction.rs +++ b/src/session/compaction.rs @@ -217,12 +217,19 @@ fn tool_content_for_prompt(content: &str) -> String { out.push_str(title); } + if let Some(args) = obj.get("args") { + out.push_str("\n\nTool call arguments:\n```json\n"); + let args = serde_json::to_string_pretty(args).unwrap_or_else(|_| args.to_string()); + out.push_str(&truncate_chars(&args, TOOL_OUTPUT_MAX_CHARS)); + out.push_str("\n```"); + } + if let Some(preview) = obj .get("output_preview") .and_then(|v| v.as_str()) .filter(|s| !s.trim().is_empty()) { - out.push('\n'); + out.push_str("\n\nTool output:\n"); out.push_str(&truncate_chars(preview, TOOL_OUTPUT_MAX_CHARS)); } @@ -315,4 +322,28 @@ mod tests { assert_eq!(stats.reduction_percent(), 97); assert_eq!(format_compaction_stats(stats), "12.0K -> 360, saved 97%"); } + + #[test] + fn compaction_prompt_preserves_tool_call_arguments() { + let tool = Message::tool( + serde_json::json!({ + "name": "edit", + "status": "ok", + "args": { + "file_path": "src/lib.rs", + "old_string": "before", + "new_string": "after" + }, + "output_preview": "Replaced at line 4" + }) + .to_string(), + ); + + let prompt = build_prompt(&[tool]); + + assert!(prompt.contains("Tool call arguments:")); + assert!(prompt.contains("\"old_string\": \"before\"")); + assert!(prompt.contains("\"new_string\": \"after\"")); + assert!(prompt.contains("Tool output:\nReplaced at line 4")); + } } diff --git a/src/tools/update_plan.rs b/src/tools/update_plan.rs index 8d10bc8..a846d74 100644 --- a/src/tools/update_plan.rs +++ b/src/tools/update_plan.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; +const PLAN_UPDATED_MESSAGE: &str = "Plan updated"; + #[derive(Debug, Clone, Deserialize, Serialize)] struct PlanItem { step: String, @@ -240,30 +242,6 @@ fn validate_plan_items(plan: &[PlanItem]) -> Result<(), ToolError> { Ok(()) } -fn output_marker_for_status(status: &str) -> &'static str { - match status { - "completed" => "[x]", - "in_progress" => "[•]", - _ => "[ ]", - } -} - -fn format_plan_update_output(update: &PlanUpdate) -> String { - let mut output = String::new(); - if let Some(explanation) = update.explanation.as_deref() { - output.push_str(explanation); - output.push('\n'); - } - for item in &update.plan { - output.push_str(&format!( - "{} {}\n", - output_marker_for_status(&item.status), - item.step - )); - } - output -} - #[async_trait] impl ToolHandler for UpdatePlanTool { fn definition(&self) -> Tool { @@ -293,9 +271,8 @@ impl ToolHandler for UpdatePlanTool { async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { let update = parse_update_plan(¶ms)?; - let output = format_plan_update_output(&update); - Ok(ToolResult::new("Plan updated", output) + Ok(ToolResult::new("Plan updated", PLAN_UPDATED_MESSAGE) .with_metadata("explanation", serde_json::json!(update.explanation)) .with_metadata("plan", serde_json::json!(update.plan))) } @@ -355,19 +332,23 @@ mod tests { assert_eq!(update.plan[2].status, "completed"); } - #[test] - fn format_plan_output_preserves_in_progress_status() { + #[tokio::test] + async fn execute_returns_codex_style_ack_with_structured_metadata() { let params = json!({ + "explanation": "Now implementing.", "plan": [ - {"step": "Locate renderer", "status": "in_progress"}, - {"step": "Validate", "status": "pending"}, - {"step": "Ship fix", "status": "completed"} + {"step": "Implement rendering", "status": "in_progress"}, + {"step": "Validate", "status": "pending"} ] }); + let (_tx, rx) = tokio::sync::watch::channel(false); + let ctx = ToolContext::new("session", "message", "Build", rx); - let update = parse_update_plan(¶ms).unwrap(); - let output = format_plan_update_output(&update); + let result = UpdatePlanTool::new().execute(params, &ctx).await.unwrap(); - assert_eq!(output, "[•] Locate renderer\n[ ] Validate\n[x] Ship fix\n"); + assert_eq!(result.title, "Plan updated"); + assert_eq!(result.output, PLAN_UPDATED_MESSAGE); + assert!(result.metadata.contains_key("plan")); + assert!(result.metadata.contains_key("explanation")); } } From 8884799ebde02935f399c50ef9dec815f3f9aa56 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 22 May 2026 02:33:11 +0800 Subject: [PATCH 139/226] feat: open message actions on direct chat message click. Clicking a chat message (user or AI response) now opens the message actions dialog immediately, with a clean return to chat on close (rather than always returning to the timeline dialog). Extracts the repeated chat area layout calculation into `current_chat_area()`. Adds `message_index_at_position()` to Chat for hit-testing messages by visible row. --- _plans/__TODOS.md | 8 ++ src/app.rs | 292 +++++++++++++++++++++++++++----------- src/ui/components/chat.rs | 109 ++++++++++++++ 3 files changed, 329 insertions(+), 80 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index e98b22e..acc4562 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -96,6 +96,12 @@ - [x] better timeline highlighting of each "message" +- [ ] Timeline highlighting of each message is not very accurate. It's accurate for "my messages". but for the ai responses, ai can seem to only highlight, even via `ctrl+x g`, the first few messages before a tool call happens. This is the same with the mouse hover effects. Expectations: + - I hover/timelinehighlight my message, it encapsulates the entire message box (met) + - I hover/timelinehighlight an ai response's message, it encapsulates the entire block, including tool calls, including the thinking, etc. (not met). + - Essentially, I was imagining kinda the same as having a 'copy' button under each "message" record in the "messages: []" array in vercel ai sdk. That's kinda the point here. But for the limitations of TUIs, I want to just use a click on the entire message block (mine or the AI response, and open a dialog -- which is mostly the current behavior now) + - UI bonus: the hover/timelinehighlight on ai response messages are more subtle, shouldnt use the primary color -- it looks TOO strong. + - [x] IN /models, can we use the ❤︎ icon, but colored pink. instead of the long heart + favorite indicator. - [x] Reasoning effort adjustment in /models. Or a hotkey? In opencode it's ctrl-t. @@ -163,3 +169,5 @@ I get ``` I want - [x] To do this But I dont want to do this ``` + +- [ ] Make the "bash" permission parity to codex. Also I currently dont see the command that it wants to run, so I'm kinda blind on what to run here. diff --git a/src/app.rs b/src/app.rs index 121b713..825f7a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,6 @@ -use ratatui::crossterm::event::{self, KeyCode, KeyEvent, MouseEvent}; +use ratatui::crossterm::event::{ + self, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use crate::autocomplete::AutoComplete; use crate::command::handlers::register_all_commands; @@ -214,6 +216,8 @@ pub struct App { pub timeline_dialog_state: crate::views::timeline_dialog::TimelineDialogState, pub message_actions_index: Option, pub message_actions_dialog: Option, + message_actions_return_focus: OverlayFocus, + pending_chat_message_click: Option, pub api_key_input: crate::ui::components::api_key_input::ApiKeyInput, openai_oauth_receiver: Option>, openai_oauth_in_progress: bool, @@ -421,11 +425,13 @@ impl App { permission_dialog_state, question_dialog_state, skills_dialog_state, + command_palette_state, which_key_state, timeline_dialog_state, - command_palette_state, message_actions_index: None, message_actions_dialog: None, + message_actions_return_focus: OverlayFocus::TimelineDialog, + pending_chat_message_click: None, api_key_input, openai_oauth_receiver: None, openai_oauth_in_progress: false, @@ -1206,6 +1212,46 @@ impl App { } } + fn current_chat_area(&self) -> ratatui::layout::Rect { + let size = self.last_frame_size; + let main_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(1), + ] + .as_ref(), + ) + .split(size); + let input_height = if self.is_subagent_session_active() { + SUBAGENT_FOOTER_HEIGHT + } else { + self.input.get_height_for_width(size.width) + }; + let help_height = if self.is_subagent_session_active() { + 0 + } else { + 1 + }; + let above_status_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Length(0), + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(0), + ratatui::layout::Constraint::Length(input_height), + ratatui::layout::Constraint::Length(help_height), + ratatui::layout::Constraint::Length(1), + ] + .as_ref(), + ) + .split(main_chunks[0]); + + above_status_chunks[1] + } + pub fn handle_keys(&mut self, key: KeyEvent) { if key.code == KeyCode::Char('p') && key.modifiers == event::KeyModifiers::CONTROL @@ -1725,7 +1771,7 @@ impl App { crate::views::timeline_dialog::TimelineDialogAction::Select(idx) => { self.chat_state.chat.scroll_to_message_index(idx); self.chat_state.chat.set_highlighted_message(Some(idx)); - self.show_message_actions(idx); + self.show_message_actions_from(idx, OverlayFocus::TimelineDialog); true } crate::views::timeline_dialog::TimelineDialogAction::Navigate(idx) => { @@ -2082,6 +2128,7 @@ impl App { { self.copy_chat_selection(); self.chat_state.chat.selection.clear(); + self.pending_chat_message_click = None; return; } @@ -2312,7 +2359,7 @@ impl App { crate::views::timeline_dialog::TimelineDialogAction::Select(idx) => { self.chat_state.chat.scroll_to_message_index(idx); self.chat_state.chat.set_highlighted_message(Some(idx)); - self.show_message_actions(idx); + self.show_message_actions_from(idx, OverlayFocus::TimelineDialog); } crate::views::timeline_dialog::TimelineDialogAction::Navigate(idx) => { self.chat_state.chat.scroll_to_message_index(idx); @@ -2327,8 +2374,13 @@ impl App { } } else if self.overlay_focus == OverlayFocus::MessageActions { let maybe_action = if let Some(ref mut dialog) = self.message_actions_dialog { + let clicked_item = if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + dialog.item_index_at_position(mouse.column, mouse.row) + } else { + None + }; let handled = dialog.handle_mouse_event(mouse); - if handled { + if handled && clicked_item.is_some() { dialog.get_selected().map(|s| s.provider_id.clone()) } else { None @@ -2384,96 +2436,84 @@ impl App { } else if self.overlay_focus == OverlayFocus::None { // If chat has a selection and user clicks outside chat area, clear it if self.chat_state.chat.has_selection() && self.base_focus == BaseFocus::Chat { - let size = self.last_frame_size; - let main_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), - ] - .as_ref(), - ) - .split(size); - let input_height = self.input.get_height_for_width(size.width); - let input_height = if self.is_subagent_session_active() { - SUBAGENT_FOOTER_HEIGHT - } else { - input_height - }; - let help_height = if self.is_subagent_session_active() { - 0 - } else { - 1 - }; - let above_status_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Length(0), // Reserved subagent header removed - ratatui::layout::Constraint::Min(0), // Chat content - ratatui::layout::Constraint::Length(0), // Bottom padding - ratatui::layout::Constraint::Length(input_height), - ratatui::layout::Constraint::Length(help_height), // Help bar - ratatui::layout::Constraint::Length(1), // Blank - ] - .as_ref(), - ) - .split(main_chunks[0]); - let chat_area = above_status_chunks[1]; + let chat_area = self.current_chat_area(); let point = ratatui::layout::Position::new(mouse.column, mouse.row); if !chat_area.contains(point) { // Click outside chat area, copy selection before clearing self.copy_chat_selection(); self.chat_state.chat.selection.clear(); + self.pending_chat_message_click = None; } } // Handle mouse events for chat scrolling/selection when in chat mode if self.base_focus == BaseFocus::Chat { - let size = self.last_frame_size; - let main_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), - ] - .as_ref(), - ) - .split(size); - let input_height = self.input.get_height_for_width(size.width); - let input_height = if self.is_subagent_session_active() { - SUBAGENT_FOOTER_HEIGHT - } else { - input_height - }; - let help_height = if self.is_subagent_session_active() { - 0 - } else { - 1 - }; - let above_status_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Length(0), // Reserved subagent header removed - ratatui::layout::Constraint::Min(0), // Chat content - ratatui::layout::Constraint::Length(0), // Bottom padding - ratatui::layout::Constraint::Length(input_height), - ratatui::layout::Constraint::Length(help_height), // Help bar - ratatui::layout::Constraint::Length(1), // Blank - ] - .as_ref(), - ) - .split(main_chunks[0]); - let chat_area = above_status_chunks[1]; + let chat_area = self.current_chat_area(); + + match mouse.kind { + MouseEventKind::Moved + if !self.chat_state.chat.has_selection() + && !self.chat_state.chat.selection.is_dragging => + { + let hovered = self + .chat_state + .chat + .message_index_at_position(mouse, chat_area); + self.chat_state.chat.set_highlighted_message(hovered); + if hovered.is_some() { + return; + } + } + MouseEventKind::Down(MouseButton::Left) + if mouse.modifiers.is_empty() + && !self.chat_state.chat.has_selection() + && !self.chat_state.chat.selection.is_dragging => + { + self.pending_chat_message_click = self + .chat_state + .chat + .message_index_at_position(mouse, chat_area); + } + MouseEventKind::Drag(MouseButton::Left) => { + self.pending_chat_message_click = None; + } + _ => {} + } let had_selection = self.chat_state.chat.has_selection(); let was_dragging = self.chat_state.chat.selection.is_dragging; + let released_pending_message = + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && !mouse.modifiers.contains(KeyModifiers::SHIFT) + { + self.pending_chat_message_click.and_then(|idx| { + (self + .chat_state + .chat + .message_index_at_position(mouse, chat_area) + == Some(idx)) + .then_some(idx) + }) + } else { + None + }; if self.chat_state.chat.handle_mouse_event(mouse, chat_area) { + if let Some(idx) = released_pending_message { + if !self.chat_state.chat.has_selection() { + self.pending_chat_message_click = None; + self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); + self.show_message_actions_from(idx, OverlayFocus::None); + return; + } + } + + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { + self.pending_chat_message_click = None; + } + // Auto-copy when selection is finalized (mouse up after drag) if !had_selection && self.chat_state.chat.has_selection() { // New selection just started, don't copy yet @@ -3506,10 +3546,20 @@ impl App { } fn show_message_actions(&mut self, idx: usize) { + let return_focus = if self.overlay_focus == OverlayFocus::TimelineDialog { + OverlayFocus::TimelineDialog + } else { + OverlayFocus::None + }; + self.show_message_actions_from(idx, return_focus); + } + + fn show_message_actions_from(&mut self, idx: usize, return_focus: OverlayFocus) { use crate::ui::components::dialog::{Dialog, DialogItem}; let can_undo = self.selected_message_can_undo(idx); self.message_actions_index = Some(idx); + self.message_actions_return_focus = return_focus; let mut items = vec![ DialogItem { @@ -3675,7 +3725,12 @@ impl App { fn close_message_actions(&mut self) { self.message_actions_index = None; self.message_actions_dialog = None; - self.overlay_focus = OverlayFocus::TimelineDialog; + let return_focus = self.message_actions_return_focus; + self.message_actions_return_focus = OverlayFocus::TimelineDialog; + if return_focus == OverlayFocus::None { + self.chat_state.chat.clear_highlighted_message(); + } + self.overlay_focus = return_focus; } fn refresh_models_dialog(&mut self) { @@ -5504,6 +5559,8 @@ mod tests { timeline_dialog_state: crate::views::timeline_dialog::init_timeline_dialog(), message_actions_index: None, message_actions_dialog: None, + message_actions_return_focus: OverlayFocus::TimelineDialog, + pending_chat_message_click: None, api_key_input: crate::ui::components::api_key_input::ApiKeyInput::new(), openai_oauth_receiver: None, openai_oauth_in_progress: false, @@ -5551,6 +5608,81 @@ mod tests { .unwrap_or_default() } + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::empty(), + } + } + + #[test] + fn clicking_chat_message_opens_message_actions() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + let _session_id = app.create_new_session(Some("Chat click".to_string())); + app.base_focus = BaseFocus::Chat; + let message = crate::session::types::Message::user("click me"); + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + let colors = app.get_current_theme_colors(); + let positions = app + .chat_state + .chat + .get_message_line_positions(78, &app.model, &colors); + app.chat_state.chat.message_line_positions = positions; + app.chat_state.chat.content_height = 4; + app.chat_state.chat.viewport_height = 18; + app.chat_state.chat.scroll_offset = 0; + assert_eq!( + app.chat_state.chat.message_index_at_position( + mouse(MouseEventKind::Down(MouseButton::Left), 1, 1), + app.current_chat_area(), + ), + Some(0) + ); + + app.handle_mouse_event(mouse(MouseEventKind::Down(MouseButton::Left), 1, 1)); + app.handle_mouse_event(mouse(MouseEventKind::Up(MouseButton::Left), 1, 1)); + + assert_eq!(app.overlay_focus, OverlayFocus::MessageActions); + assert_eq!(app.message_actions_index, Some(0)); + assert!(message_action_names(&app).contains(&"Undo".to_string())); + } + + #[test] + fn closing_direct_chat_message_actions_returns_to_chat() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + let _session_id = app.create_new_session(Some("Chat click".to_string())); + app.base_focus = BaseFocus::Chat; + let message = crate::session::types::Message::user("click me"); + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + let colors = app.get_current_theme_colors(); + let positions = app + .chat_state + .chat + .get_message_line_positions(78, &app.model, &colors); + app.chat_state.chat.message_line_positions = positions; + app.chat_state.chat.content_height = 4; + app.chat_state.chat.viewport_height = 18; + app.chat_state.chat.scroll_offset = 0; + + app.handle_mouse_event(mouse(MouseEventKind::Down(MouseButton::Left), 1, 1)); + app.handle_mouse_event(mouse(MouseEventKind::Up(MouseButton::Left), 1, 1)); + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert_eq!(app.message_actions_index, None); + assert_eq!(app.chat_state.chat.highlighted_message_index, None); + } + #[test] fn message_actions_include_undo_for_user_messages() { let mut app = test_app(); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 7441417..49891c3 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1141,6 +1141,79 @@ impl Chat { self.highlighted_message_index = None; } + pub fn message_index_at_position(&self, event: MouseEvent, area: Rect) -> Option { + use ratatui::layout::Position; + + let point = Position::new(event.column, event.row); + let content_area = Rect { + x: area.x, + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + }; + + if !content_area.contains(point) || self.message_line_positions.is_empty() { + return None; + } + + let content_line = + (event.row.saturating_sub(content_area.y) as usize).saturating_add(self.scroll_offset); + let content_height = self.content_height.max( + self.message_line_positions + .iter() + .copied() + .max() + .unwrap_or(0) + .saturating_add(1), + ); + self.message_index_at_content_line(content_line, content_height) + } + + fn message_index_at_content_line( + &self, + content_line: usize, + content_height: usize, + ) -> Option { + if content_line >= content_height { + return None; + } + + for (idx, message) in self.messages.iter().enumerate() { + if !matches!(message.role, MessageRole::User | MessageRole::Assistant) + || crate::session::compaction::is_compaction_summary(message) + { + continue; + } + + let Some(&start) = self.message_line_positions.get(idx) else { + continue; + }; + let mut end = self + .message_line_positions + .iter() + .copied() + .skip(idx + 1) + .find(|&next_start| next_start > start) + .unwrap_or(content_height); + + while end > start + && self + .cached_lines + .get(end - 1) + .map(line_is_blank) + .unwrap_or(false) + { + end -= 1; + } + + if content_line >= start && content_line < end { + return Some(idx); + } + } + + None + } + fn update_scrollbar(&mut self) { let max_offset = self.content_height.saturating_sub(self.viewport_height); let content_length = max_offset.saturating_add(1).max(1); @@ -3189,6 +3262,42 @@ mod tests { assert_eq!(chat.messages[2].content, " assistant"); } + #[test] + fn click_hit_test_maps_visible_row_to_message_index() { + let mut chat = Chat::with_messages(vec![Message::user("hello"), Message::assistant("hi")]); + let colors = test_colors(); + let positions = chat.get_message_line_positions(40, "model", &colors); + chat.message_line_positions = positions; + chat.content_height = chat.build_all_lines(40, "model", &colors).len(); + chat.viewport_height = 8; + chat.scroll_offset = 0; + + assert_eq!( + chat.message_index_at_position( + mouse( + MouseEventKind::Down(MouseButton::Left), + 1, + 1, + KeyModifiers::empty() + ), + Rect::new(0, 0, 40, 8), + ), + Some(0) + ); + assert_eq!( + chat.message_index_at_position( + mouse( + MouseEventKind::Down(MouseButton::Left), + 1, + 4, + KeyModifiers::empty() + ), + Rect::new(0, 0, 40, 8), + ), + Some(1) + ); + } + #[test] fn test_render_fingerprint_changes_for_same_length_content_mutation() { let mut chat = Chat::new(); From eb1fb4d8c7b56098506af285ddc2e24e8bb395d9 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 22 May 2026 15:02:05 +0800 Subject: [PATCH 140/226] feat: gate app.log logging behind `--emit-logs` flag. Replace all `let _ = log(&format!(...))` calls with `emit_log!()` macro that checks `AtomicBool` guard before writing. Add `--emit-logs` CLI arg (default off) and `set_enabled()`/`enabled()` to `logging.rs`. Add early returns to `log_stream_request` / `log_stream_summary` so they skip building format strings entirely when logging is disabled. --- _plans/__TODOS.md | 7 ++- src/agent/subagent.rs | 16 +++---- src/app.rs | 8 ++-- src/llm/client.rs | 91 +++++++++++++++++++++------------------ src/logging.rs | 29 +++++++++++++ src/main.rs | 6 ++- src/tools/aisdk_bridge.rs | 48 +++++++++++---------- src/tools/question.rs | 4 +- src/tools/task.rs | 22 ++++------ 9 files changed, 135 insertions(+), 96 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index acc4562..e78dc19 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -131,7 +131,7 @@ Replaced at line 239 - [x] During delete in "sessions dialog" can we color the current "to-be-deleted" list item with red instead of the primary color. And since we're showing "Confirm ctrl+d" after pressing ctrl+d the first time, can we also "esc" to cancel (instead of close the session dialog?) -- [ ] Don't log to app.log with logging.rs in the future, but in the future, add a custom env build flag so that when I `cargo install --path` with this flag, I include the "development release build" - so I can use the fast compiled version while having logs. And the normal cargo install --path, will still just be like a production build. +- [x] Don't log to app.log with logging.rs in the future, but in the future, add a custom env build flag so that when I `cargo install --path` with this flag, I include the "development release build" - so I can use the fast compiled version while having logs. And the normal cargo install --path, will still just be like a production build. - [x] Don't prevent scroll when there's a permission required dialog. @@ -171,3 +171,8 @@ I want - [x] To do this But I dont want to do this ``` - [ ] Make the "bash" permission parity to codex. Also I currently dont see the command that it wants to run, so I'm kinda blind on what to run here. + +- [ ] When pasting images and it creates this [Image #1] tag, make it hoverable (just change the color, not the background), then once clicked, goes to the preferred editor of the user. + - Multiple paths here: + - Should it be configurable? + - Autodetected depending on the tool used: i.e. if Wezterm, other terminals "open w/ Finder on mac, or native image opener". If inside Zed, open image with Zed. If inside VSCode/Cursor, open with that IDE. (Ambitious but idk if possible) diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 083ecb1..bf8d14a 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -172,7 +172,7 @@ pub async fn run_subagent( let headers = HashMap::new(); let stream_started_at = std::time::Instant::now(); - let _ = crate::logging::log(&format!( + crate::emit_log!( "[SUBAGENT] stream_start session_id={} subagent_type={} tools={} description_bytes={} prompt_bytes={} sender_present={}", session_id, subagent_type.name(), @@ -180,7 +180,7 @@ pub async fn run_subagent( description.len(), prompt.len(), sender.is_some() - )); + ); let mut response: StreamTextResponse = match session.provider_kind { ProviderKind::OpenAICompatible => { @@ -260,13 +260,13 @@ pub async fn run_subagent( tool_call_count = tool_call_count.saturating_add(calls); } ChunkType::Failed(err) => { - let _ = crate::logging::log(&format!( + crate::emit_log!( "[SUBAGENT] stream_failed session_id={} subagent_type={} duration_ms={} error={}", session_id, subagent_type.name(), stream_started_at.elapsed().as_millis(), err - )); + ); if let Some(sender) = sender.as_ref() { let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); } @@ -279,19 +279,19 @@ pub async fn run_subagent( break; } ChunkType::Metadata(message) => { - let _ = crate::logging::log(&format!( + crate::emit_log!( "[SUBAGENT_METADATA] session_id={} subagent_type={} {}", session_id, subagent_type.name(), message - )); + ); } _ => {} } } let stop_reason = response.stop_reason().await; - let _ = crate::logging::log(&format!( + crate::emit_log!( "[SUBAGENT] stream_finish session_id={} subagent_type={} duration_ms={} stop_reason={:?} text_bytes={} tool_call_count={}", session_id, subagent_type.name(), @@ -299,7 +299,7 @@ pub async fn run_subagent( stop_reason, collected_text.len(), tool_call_count - )); + ); Ok(SubAgentRunResult { output: normalize_subagent_output(collected_text), diff --git a/src/app.rs b/src/app.rs index 825f7a6..c208c1c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2107,7 +2107,7 @@ impl App { pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() { - let _ = crate::logging::log(&format!( + crate::emit_log!( "Handle mouse: kind={:?} modifiers={:?} col={} row={} base={:?} overlay={:?}", mouse.kind, mouse.modifiers, @@ -2115,7 +2115,7 @@ impl App { mouse.row, self.base_focus, self.overlay_focus - )); + ); } // If text is selected and user clicks on an overlay, clear selection instead @@ -4744,10 +4744,10 @@ impl App { state.tool_calls.deferred_finish = true; } - let _ = crate::logging::log(&format!( + crate::emit_log!( "[STREAM_DEFERRED] session_id={} reason=running_tool_messages", session_id - )); + ); true } diff --git a/src/llm/client.rs b/src/llm/client.rs index 6ceb4f2..964d340 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -10,7 +10,6 @@ use futures::StreamExt; use std::{collections::HashMap, time::Instant}; use tokio_util::sync::CancellationToken; -use crate::logging::log; use crate::tools::aisdk_bridge::convert_to_aisdk_tools; const MAX_STEPS_REACHED_PROMPT: &str = r#"CRITICAL - MAXIMUM STEPS REACHED @@ -367,7 +366,7 @@ pub async fn stream_llm_with_cancellation( messages: Vec, sender: crate::llm::ChunkSender, ) -> Result<(), DynError> { - let _ = log(&format!( + crate::emit_log!( "GOING TO STREAM session_id={} provider={} model={} agent_mode={} agent_max_steps={:?} input_messages={}", session_id, provider_name, @@ -375,7 +374,7 @@ pub async fn stream_llm_with_cancellation( agent_mode, agent_max_steps, messages.len() - )); + ); let request_config = prepare_request_config(&provider_name, model, reasoning_effort, &sender).await?; @@ -461,9 +460,9 @@ pub async fn stream_llm_with_cancellation( let stop_reason = response.stop_reason().await; let stream_outcome = relay_result.outcome; let primary_outcome_label = stream_outcome_label(stream_outcome, stop_reason.as_ref()); - let _ = log(&format!( + crate::emit_log!( "Stream completed: session_id={session_id} outcome={stream_outcome:?}, effective_outcome={primary_outcome_label}, stop_reason={stop_reason:?}, agent_max_steps={agent_max_steps:?}", - )); + ); log_stream_summary( primary_log_context, primary_outcome_label, @@ -630,10 +629,13 @@ async fn prepare_request_config( ); } - let _ = log(&format!( + crate::emit_log!( "Provider: {}, NPM: {}, Base URL: {}, Model: {}", - provider_name, provider.npm, request_config.base_url, request_config.model_name - )); + provider_name, + provider.npm, + request_config.base_url, + request_config.model_name + ); Ok(request_config) } @@ -733,7 +735,7 @@ async fn maybe_apply_openai_oauth_overrides( .insert("ChatGPT-Account-Id".to_string(), account_id); } - let _ = log("Configured OpenAI OAuth Codex transport"); + crate::emit_log!("Configured OpenAI OAuth Codex transport"); if !is_openai_oauth_model_allowed(&request_config.model_name) { let fallback_model = "gpt-5.3-codex".to_string(); @@ -864,6 +866,10 @@ fn openai_request_instructions( } fn log_stream_request(context: StreamLogContext<'_>, config: &ProviderRequestConfig) { + if !crate::logging::enabled() { + return; + } + let reasoning_effort = config .reasoning_effort .map(|effort| effort.as_str()) @@ -875,7 +881,7 @@ fn log_stream_request(context: StreamLogContext<'_>, config: &ProviderRequestCon .map(String::as_str) .collect::>(); header_names.sort_unstable(); - let _ = log(&format!( + crate::emit_log!( "[STREAM_REQUEST] {} reasoning_effort={} responses_path={:?} force_store_false={} disallow_system_messages={} force_tool_strict_false={} extra_header_names=[{}]", context.describe(), reasoning_effort, @@ -884,7 +890,7 @@ fn log_stream_request(context: StreamLogContext<'_>, config: &ProviderRequestCon config.openai_options.disallow_system_messages, config.openai_options.force_tool_strict_false, header_names.join(","), - )); + ); } fn log_stream_summary( @@ -896,13 +902,17 @@ fn log_stream_summary( stats: Option<&RelayStats>, error: Option<&str>, ) { + if !crate::logging::enabled() { + return; + } + let stats = stats .map(|stats| stats.describe_at(Some(elapsed_ms))) .unwrap_or_else(|| "chunks=unavailable".to_string()); let error = error .map(|err| format!(" error={}", err)) .unwrap_or_default(); - let _ = log(&format!( + crate::emit_log!( "[STREAM_SUMMARY] {} relay_result={} stop_reason={:?} token_estimate={} elapsed_ms={} {}{}", context.describe(), relay_result, @@ -911,7 +921,7 @@ fn log_stream_summary( elapsed_ms, stats, error, - )); + ); } fn is_transport_or_request_error(err: &str) -> bool { @@ -936,22 +946,22 @@ async fn relay_stream_to_sender( context: StreamLogContext<'_>, ) -> Result { let mut stats = RelayStats::default(); - let _ = log(&format!( + crate::emit_log!( "[RELAY] relay_stream_to_sender started {}", context.describe() - )); + ); loop { let chunk = tokio::select! { _ = cancel_token.cancelled() => { let elapsed_ms = start_time.elapsed().as_millis(); let _ = sender.send(crate::llm::ChunkMessage::Cancelled); - let _ = log(&format!( + crate::emit_log!( "[STREAM_CANCELLED] {} elapsed_ms={} token_estimate={} {}", context.describe(), elapsed_ms, *token_count, stats.describe_at(Some(elapsed_ms)), - )); + ); return Err(anyhow::anyhow!("Streaming cancelled by user").into()); } chunk = stream.next() => chunk, @@ -970,7 +980,7 @@ async fn relay_stream_to_sender( stats.text_chars += text.len(); stats.record_text(text.len(), elapsed_ms); *token_count += estimate_tokens(&text); - let _ = log(&format!("[RELAY] Text chunk ({} chars)", text.len())); + crate::emit_log!("[RELAY] Text chunk ({} chars)", text.len()); let _ = sender.send(crate::llm::ChunkMessage::Text(text)); } ChunkType::Reasoning(reasoning) => { @@ -979,10 +989,7 @@ async fn relay_stream_to_sender( stats.reasoning_chunks += 1; stats.reasoning_chars += reasoning.len(); *token_count += estimate_tokens(&reasoning); - let _ = log(&format!( - "[RELAY] Reasoning chunk ({} chars)", - reasoning.len() - )); + crate::emit_log!("[RELAY] Reasoning chunk ({} chars)", reasoning.len()); let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } ChunkType::ToolCall(tool_call) => { @@ -992,22 +999,22 @@ async fn relay_stream_to_sender( stats.tool_call_bytes += tool_call.len(); let info = tool_call_log_info(&tool_call); stats.record_tool_call(&info, elapsed_ms); - let _ = log(&format!( + crate::emit_log!( "[RELAY] ToolCall chunk received names={} ids={} arg_chars={} arg_done_chars={} bytes={}", info.names_label(), info.ids_label(), info.argument_chars, info.arguments_done_chars, tool_call.len(), - )); + ); } ChunkType::End(_msg) => { let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("End", elapsed_ms); - let _ = log(&format!( + crate::emit_log!( "[RELAY] End chunk — returning Ended {}", stats.describe_at(Some(elapsed_ms)) - )); + ); let duration_ms = elapsed_ms as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { token_count: *token_count, @@ -1023,10 +1030,10 @@ async fn relay_stream_to_sender( let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("ResponseCompleted", elapsed_ms); stats.response_completed_chunks += 1; - let _ = log(&format!( + crate::emit_log!( "[RELAY] ResponseCompleted chunk end_turn={end_turn:?} — returning Ended {}", stats.describe_at(Some(elapsed_ms)) - )); + ); let duration_ms = elapsed_ms as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { token_count: *token_count, @@ -1042,37 +1049,35 @@ async fn relay_stream_to_sender( let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("AssistantMessagePhase", elapsed_ms); stats.record_assistant_phase(phase); - let _ = log(&format!( - "[RELAY] AssistantMessagePhase chunk phase={phase:?}" - )); + crate::emit_log!("[RELAY] AssistantMessagePhase chunk phase={phase:?}"); } ChunkType::Metadata(message) => { let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("Metadata", elapsed_ms); stats.record_metadata(&message); - let _ = log(&format!("[RELAY] Metadata {}", message)); + crate::emit_log!("[RELAY] Metadata {}", message); } ChunkType::Start => { let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("Start", elapsed_ms); stats.start_chunks += 1; - let _ = log("[RELAY] Start chunk received"); + crate::emit_log!("[RELAY] Start chunk received"); } ChunkType::Failed(err) => { let elapsed_ms = start_time.elapsed().as_millis(); stats.record_failed_chunk(); let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); - let _ = log(&format!("Stream Chunk Failed {}", err)); - let _ = log(&format!( + crate::emit_log!("Stream Chunk Failed {}", err); + crate::emit_log!( "[STREAM_ERROR] {} elapsed_ms={} token_estimate={} {} error={}", context.describe(), elapsed_ms, *token_count, stats.describe_at(Some(elapsed_ms)), err, - )); + ); if is_transport_or_request_error(&err) { - let _ = log("[STREAM_ERROR_HINT] Request/stream transport failure. This happened below the model layer while sending or reading provider HTTP data; if it repeats, compare network/proxy/VPN state and provider status with the request and provider_step context above."); + crate::emit_log!("[STREAM_ERROR_HINT] Request/stream transport failure. This happened below the model layer while sending or reading provider HTTP data; if it repeats, compare network/proxy/VPN state and provider status with the request and provider_step context above."); } return Err(anyhow::anyhow!("Streaming failed: {}", err).into()); } @@ -1080,24 +1085,24 @@ async fn relay_stream_to_sender( let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("Incomplete", elapsed_ms); stats.incomplete_chunks += 1; - let _ = log(&format!("[RELAY] Incomplete chunk received: {}", msg)); + crate::emit_log!("[RELAY] Incomplete chunk received: {}", msg); } ChunkType::NotSupported(msg) => { let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("NotSupported", elapsed_ms); stats.not_supported_chunks += 1; - let _ = log(&format!("[RELAY] NotSupported chunk received: {}", msg)); + crate::emit_log!("[RELAY] NotSupported chunk received: {}", msg); } } } let elapsed_ms = start_time.elapsed().as_millis(); - let _ = log(&format!( + crate::emit_log!( "[RELAY] stream exhausted — returning Exhausted {} token_estimate={} {}", context.describe(), *token_count, stats.describe_at(Some(elapsed_ms)), - )); + ); Ok(StreamRelayResult { outcome: StreamRelayOutcome::Exhausted, stats, @@ -1135,11 +1140,11 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { - let _ = log(&format!( + crate::emit_log!( "failed to attach image {}: {}", path.display(), err - )); + ); None } } diff --git a/src/logging.rs b/src/logging.rs index 4d99355..5dabd3e 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -2,9 +2,24 @@ use anyhow::Result; use chrono::Local; use std::fs::OpenOptions; use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; + +static LOGGING_ENABLED: AtomicBool = AtomicBool::new(false); + +pub fn set_enabled(enabled: bool) { + LOGGING_ENABLED.store(enabled, Ordering::Relaxed); +} + +pub fn enabled() -> bool { + LOGGING_ENABLED.load(Ordering::Relaxed) +} #[allow(unused_must_use)] pub fn log(message: &str) -> Result<()> { + if !enabled() { + return Ok(()); + } + let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S"); let log_line = format!("[{}] {}\n", timestamp, message); @@ -16,3 +31,17 @@ pub fn log(message: &str) -> Result<()> { file.write_all(log_line.as_bytes())?; Ok(()) } + +#[macro_export] +macro_rules! emit_log { + ($message:expr) => {{ + if $crate::logging::enabled() { + let _ = $crate::logging::log($message); + } + }}; + ($fmt:expr, $($arg:tt)*) => {{ + if $crate::logging::enabled() { + let _ = $crate::logging::log(&format!($fmt, $($arg)*)); + } + }}; +} diff --git a/src/main.rs b/src/main.rs index 117d6cd..a152a5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -268,6 +268,9 @@ struct Args { #[arg(long = "dangerously-skip-permissions")] dangerously_skip_permissions: bool, + #[arg(long = "emit-logs", hide = true)] + emit_logs: bool, + /// The prompt to run (positional, used in print mode) prompt: Vec, } @@ -275,6 +278,7 @@ struct Args { #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); + crate::logging::set_enabled(args.emit_logs); if args.print_mode { let prompt = args.prompt.join(" "); @@ -420,7 +424,7 @@ async fn run_event_loop( if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() { if let event::Event::Mouse(mouse) = &event { - let _ = crate::logging::log(&format!("Mouse event: {:?}", mouse)); + crate::emit_log!("Mouse event: {:?}", mouse); } } diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 1641f72..c087779 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -26,10 +26,11 @@ pub async fn convert_to_aisdk_tools( for tool_def in tools { if !permissions.is_tool_allowed_for_agent(&agent_mode, &tool_def.id) { - let _ = crate::logging::log(&format!( + crate::emit_log!( "[AISDK_TOOLS] Skipping '{}': not allowed in {} mode", - tool_def.id, agent_mode - )); + tool_def.id, + agent_mode + ); continue; } @@ -76,14 +77,14 @@ pub async fn convert_to_aisdk_tools( ])) .is_err() { - let _ = crate::logging::log(&format!( + crate::emit_log!( "[AISDK_TOOL] ui_send_failed phase=tool_call tool={} call_id={} session_id={} message_id={} agent_mode={}", tool_id, call_id, session_id_label, message_id_label, agent_mode - )); + ); } } - let _ = crate::logging::log(&format!( + crate::emit_log!( "[AISDK_TOOL] call tool={} call_id={} session_id={} message_id={} agent_mode={} sender_present={} args={}", tool_id_for_exec, call_id, @@ -92,7 +93,7 @@ pub async fn convert_to_aisdk_tools( agent_mode, sender_present, input - )); + ); let handler = registry .get(&tool_id_for_exec) @@ -102,7 +103,7 @@ pub async fn convert_to_aisdk_tools( Ok(handler) => handler, Err(err) => { send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); - let _ = crate::logging::log(&format!( + crate::emit_log!( "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", tool_id_for_exec, call_id, @@ -111,7 +112,7 @@ pub async fn convert_to_aisdk_tools( agent_mode, started_at.elapsed().as_millis(), err - )); + ); return Err(err); } }; @@ -119,7 +120,7 @@ pub async fn convert_to_aisdk_tools( if let Err(e) = handler.validate(&input) { let err = format!("Validation error: {}", e); send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); - let _ = crate::logging::log(&format!( + crate::emit_log!( "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", tool_id_for_exec, call_id, @@ -128,7 +129,7 @@ pub async fn convert_to_aisdk_tools( agent_mode, started_at.elapsed().as_millis(), err - )); + ); return Err(err); } @@ -138,7 +139,7 @@ pub async fn convert_to_aisdk_tools( { let err = format!("{}", e); send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); - let _ = crate::logging::log(&format!( + crate::emit_log!( "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", tool_id_for_exec, call_id, @@ -147,7 +148,7 @@ pub async fn convert_to_aisdk_tools( agent_mode, started_at.elapsed().as_millis(), err - )); + ); return Err(err); } @@ -168,7 +169,7 @@ pub async fn convert_to_aisdk_tools( Ok(tool_result) => tool_result, Err(err) => { send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); - let _ = crate::logging::log(&format!( + crate::emit_log!( "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", tool_id_for_exec, call_id, @@ -177,12 +178,12 @@ pub async fn convert_to_aisdk_tools( agent_mode, started_at.elapsed().as_millis(), err - )); + ); return Err(err); } }; - let _ = crate::logging::log(&format!( + crate::emit_log!( "[AISDK_TOOL] result tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} output_bytes={}", tool_id_for_exec, call_id, @@ -191,7 +192,7 @@ pub async fn convert_to_aisdk_tools( agent_mode, started_at.elapsed().as_millis(), tool_result.output.len() - )); + ); let model_output = truncate_tool_output(&tool_result.output, TOOL_MODEL_OUTPUT_LIMIT); @@ -227,10 +228,10 @@ pub async fn convert_to_aisdk_tools( )) .is_err() { - let _ = crate::logging::log(&format!( + crate::emit_log!( "[AISDK_TOOL] ui_send_failed phase=tool_result tool={} call_id={} session_id={} message_id={} agent_mode={}", tool_id_for_ui, call_id, session_id_label, message_id_label, agent_mode - )); + ); } } @@ -259,10 +260,11 @@ pub async fn convert_to_aisdk_tools( let schema: Schema = match serde_json::from_value(input_schema_json) { Ok(s) => s, Err(e) => { - let _ = crate::logging::log(&format!( + crate::emit_log!( "Error creating schema for tool {}: {} (falling back to any schema)", - tool_def.id, e - )); + tool_def.id, + e + ); Schema::from(true) } }; @@ -276,7 +278,7 @@ pub async fn convert_to_aisdk_tools( { Ok(t) => t, Err(e) => { - let _ = crate::logging::log(&format!("Error building tool {}: {}", tool_def.id, e)); + crate::emit_log!("Error building tool {}: {}", tool_def.id, e); continue; } }; diff --git a/src/tools/question.rs b/src/tools/question.rs index da629f2..9ec639e 100644 --- a/src/tools/question.rs +++ b/src/tools/question.rs @@ -370,10 +370,10 @@ impl ToolHandler for QuestionTool { let questions = parse_questions_param(¶ms)?; let generated_count = generated_options_count(&questions); if generated_count > 0 { - let _ = crate::logging::log(&format!( + crate::emit_log!( "[QUESTION_TOOL] added fallback options to {} optionless question(s)", generated_count - )); + ); } let sender = self.sender.as_ref().ok_or_else(|| { diff --git a/src/tools/task.rs b/src/tools/task.rs index 73bd129..bd56f90 100644 --- a/src/tools/task.rs +++ b/src/tools/task.rs @@ -93,7 +93,7 @@ impl ToolHandler for TaskTool { subagent_type.name() ); - let _ = crate::logging::log(&format!( + crate::emit_log!( "[TASK] start parent_session_id={} child_session_id={} subagent_type={} title={:?} description_bytes={} prompt_bytes={} sender_present={}", ctx.session_id, child_session_id, @@ -102,7 +102,7 @@ impl ToolHandler for TaskTool { description.len(), prompt.len(), self.sender.is_some() - )); + ); let child_sender = self.start_child_session_stream( ctx.session_id.clone(), @@ -126,14 +126,14 @@ impl ToolHandler for TaskTool { { Ok(result) => result, Err(e) => { - let _ = crate::logging::log(&format!( + crate::emit_log!( "[TASK] error parent_session_id={} child_session_id={} subagent_type={} duration_ms={} error={}", ctx.session_id, child_session_id, subagent_type.name(), started_at.elapsed().as_millis(), e - )); + ); if let Some(sender) = child_sender.as_ref() { let _ = sender.send(crate::llm::ChunkMessage::Failed(e.clone())); } @@ -146,7 +146,7 @@ impl ToolHandler for TaskTool { } let duration_ms = started_at.elapsed().as_millis() as u64; - let _ = crate::logging::log(&format!( + crate::emit_log!( "[TASK] finish parent_session_id={} child_session_id={} subagent_type={} duration_ms={} output_bytes={} child_tool_call_count={}", ctx.session_id, child_session_id, @@ -154,7 +154,7 @@ impl ToolHandler for TaskTool { duration_ms, result.output.len(), result.tool_call_count - )); + ); Ok(ToolResult::new( format!("Subagent ({}) result", subagent_type.name()), @@ -194,20 +194,14 @@ impl TaskTool { }); tokio::spawn(async move { - let _ = crate::logging::log(&format!( - "[TASK] child_forwarder_start session_id={}", - session_id - )); + crate::emit_log!("[TASK] child_forwarder_start session_id={}", session_id); while let Some(chunk) = child_rx.recv().await { let _ = ui_sender.send(crate::llm::ChunkMessage::SubagentChunk { session_id: session_id.clone(), chunk: Box::new(chunk), }); } - let _ = crate::logging::log(&format!( - "[TASK] child_forwarder_closed session_id={}", - session_id - )); + crate::emit_log!("[TASK] child_forwarder_closed session_id={}", session_id); }); Some(child_tx) From 0699b39312bfb28e5d8a11d97e2395e03a5ca50b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 25 May 2026 18:52:59 +0800 Subject: [PATCH 141/226] feat(aisdk): add OpenAI Responses WebSocket transport with incremental delta. Replace HTTP streaming with persistent WebSocket connection for OpenAI Responses API. Track previous response state to send only delta input on subsequent requests, reducing round-trip overhead. - Add tokio-tungstenite dependency - Introduce `responses_websocket` builder flag on OpenAI provider - Implement `stream_text_websocket` with reconnection and state management - Add `item_id` field to `ToolCallMessage` for proper Responses API item identity tracking - Refactor tool call accumulation to use `CompletedToolCall` struct instead of anonymous tuples - Fall back to HTTP streaming when WebSocket transport fails --- Cargo.lock | 63 ++++ _plans/__TODOS.md | 2 +- aisdk/Cargo.toml | 1 + aisdk/src/message.rs | 17 ++ aisdk/src/providers/openai.rs | 535 ++++++++++++++++++++++++++++++---- aisdk/src/response.rs | 75 +++-- src/llm/client.rs | 3 + 7 files changed, 619 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb93ac4..fee43f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", + "tokio-tungstenite", ] [[package]] @@ -250,6 +251,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -688,6 +695,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deranged" version = "0.5.5" @@ -2917,6 +2930,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3397,6 +3421,20 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3550,6 +3588,25 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3644,6 +3701,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index e78dc19..65f6c06 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -170,7 +170,7 @@ I get I want - [x] To do this But I dont want to do this ``` -- [ ] Make the "bash" permission parity to codex. Also I currently dont see the command that it wants to run, so I'm kinda blind on what to run here. +- [x] Make the "bash" permission parity to codex. Also I currently dont see the command that it wants to run, so I'm kinda blind on what to run here. - [ ] When pasting images and it creates this [Image #1] tag, make it hoverable (just change the color, not the background), then once clicked, goes to the preferred editor of the user. - Multiple paths here: diff --git a/aisdk/Cargo.toml b/aisdk/Cargo.toml index 58a3cda..6eba0fc 100644 --- a/aisdk/Cargo.toml +++ b/aisdk/Cargo.toml @@ -11,6 +11,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" schemars = "1.0" tokio = { version = "1.40", features = ["sync", "rt", "macros"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } futures = "0.3" thiserror = "1.0" eventsource-stream = "0.2" diff --git a/aisdk/src/message.rs b/aisdk/src/message.rs index 6da1c34..9363345 100644 --- a/aisdk/src/message.rs +++ b/aisdk/src/message.rs @@ -48,6 +48,21 @@ impl Message { arguments: impl Into, ) -> Self { Self::ToolCall(ToolCallMessage { + item_id: None, + call_id: call_id.into(), + name: name.into(), + arguments: arguments.into(), + }) + } + + pub fn tool_call_with_item_id( + item_id: impl Into, + call_id: impl Into, + name: impl Into, + arguments: impl Into, + ) -> Self { + Self::ToolCall(ToolCallMessage { + item_id: Some(item_id.into()), call_id: call_id.into(), name: name.into(), arguments: arguments.into(), @@ -94,6 +109,8 @@ pub struct AssistantMessage { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCallMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub item_id: Option, pub call_id: String, pub name: String, pub arguments: String, diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index 0d502ab..0e0d485 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -5,12 +5,21 @@ use crate::provider::{Provider, ProviderStream}; use crate::tool::Tool; use async_trait::async_trait; use eventsource_stream::{EventStreamError, Eventsource}; -use futures::StreamExt; +use futures::{SinkExt, StreamExt}; use std::collections::HashMap; use std::error::Error as StdError; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, Mutex}; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; const OPENAI_STREAM_CONNECT_TIMEOUT_SECS: u64 = 30; const OPENAI_ERROR_BODY_MAX_CHARS: usize = 2048; +const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; +const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; #[derive(Debug, Clone)] pub struct OpenAI { @@ -25,6 +34,8 @@ pub struct OpenAI { tool_strict_override: Option, default_instructions: Option, reasoning_effort: Option, + responses_websocket: bool, + websocket_state: Arc>, } impl OpenAI { @@ -46,6 +57,7 @@ pub struct OpenAIBuilder { tool_strict_override: Option, default_instructions: Option, reasoning_effort: Option, + responses_websocket: bool, } impl OpenAIBuilder { @@ -104,6 +116,11 @@ impl OpenAIBuilder { self } + pub fn responses_websocket(mut self, enabled: bool) -> Self { + self.responses_websocket = enabled; + self + } + pub fn build(self) -> Result { let base_url = self .base_url @@ -137,10 +154,32 @@ impl OpenAIBuilder { tool_strict_override: self.tool_strict_override, default_instructions: self.default_instructions, reasoning_effort: self.reasoning_effort, + responses_websocket: self.responses_websocket, + websocket_state: Arc::new(Mutex::new(OpenAIWebsocketState::default())), }) } } +#[derive(Debug, Default)] +struct OpenAIWebsocketState { + disabled: bool, + connection: Option>>, + last_request: Option, + last_response: Option, +} + +#[derive(Debug, Clone)] +struct OpenAIRequestSnapshot { + body_without_input: serde_json::Value, + input: Vec, +} + +#[derive(Debug, Clone)] +struct OpenAIResponseSnapshot { + response_id: String, + items_added: Vec, +} + #[async_trait] impl Provider for OpenAI { fn name(&self) -> &str { @@ -203,52 +242,26 @@ impl Provider for OpenAI { } let input = build_openai_messages(messages, self.strip_system_and_developer_messages); - - let tool_params: Vec = tools - .iter() - .map(|t| { - let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); - let mut tool = serde_json::json!({ - "type": "function", - "name": t.name, - "description": t.description, - "parameters": schema, - }); - - if let Some(strict) = self.tool_strict_override { - tool = serde_json::json!({ - "type": "function", - "name": t.name, - "strict": strict, - "parameters": schema, - "description": t.description, - }); + let body = self.build_responses_body(input.clone(), tools); + + if self.responses_websocket { + match self + .stream_text_websocket(body.clone(), &request_headers) + .await + { + Ok(stream) => return Ok(stream), + Err(err) => { + let mut state = self.websocket_state.lock().await; + state.disabled = true; + state.last_request = None; + state.last_response = None; + drop(state); + eprintln!( + "[AISDK_OPENAI] websocket transport failed; falling back to HTTP Responses: {}", + err + ); } - - tool - }) - .collect(); - - let mut body = serde_json::json!({ - "model": self.model_name, - "input": input, - "stream": true, - }); - - if !tool_params.is_empty() { - body["tools"] = serde_json::Value::Array(tool_params); - } - - if let Some(instructions) = &self.default_instructions { - body["instructions"] = serde_json::Value::String(instructions.clone()); - } - - if let Some(store) = self.store_override { - body["store"] = serde_json::Value::Bool(store); - } - - if let Some(effort) = &self.reasoning_effort { - body["reasoning"] = serde_json::json!({ "effort": effort }); + } } let request_diagnostics = @@ -308,6 +321,161 @@ impl Provider for OpenAI { } } +impl OpenAI { + fn build_responses_body( + &self, + input: Vec, + tools: &[Tool], + ) -> serde_json::Value { + let tool_params: Vec = tools + .iter() + .map(|t| { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + let mut tool = serde_json::json!({ + "type": "function", + "name": t.name, + "description": t.description, + "parameters": schema, + }); + + if let Some(strict) = self.tool_strict_override { + tool = serde_json::json!({ + "type": "function", + "name": t.name, + "strict": strict, + "parameters": schema, + "description": t.description, + }); + } + + tool + }) + .collect(); + + let mut body = serde_json::json!({ + "model": self.model_name, + "input": input, + "stream": true, + "tool_choice": "auto", + "parallel_tool_calls": true, + "include": [], + }); + + if !tool_params.is_empty() { + body["tools"] = serde_json::Value::Array(tool_params); + } + + if let Some(instructions) = &self.default_instructions { + body["instructions"] = serde_json::Value::String(instructions.clone()); + } + + if let Some(store) = self.store_override { + body["store"] = serde_json::Value::Bool(store); + } + + if let Some(effort) = &self.reasoning_effort { + body["reasoning"] = serde_json::json!({ "effort": effort }); + } + + body + } + + async fn stream_text_websocket( + &self, + full_body: serde_json::Value, + headers: &reqwest::header::HeaderMap, + ) -> Result { + let ws_url = websocket_url(self.base_url.trim_end_matches('/'), &self.responses_path)?; + let (request_body, mut ws) = { + let mut state = self.websocket_state.lock().await; + if state.disabled { + return Err(Error::Provider("websocket transport disabled".to_string())); + } + let request_body = websocket_request_body_from_state(&state, &full_body); + if let Some(ws) = state.connection.take() { + (request_body, ws) + } else { + drop(state); + let ws = connect_openai_websocket(ws_url, headers).await?; + (request_body, ws) + } + }; + + let request_text = serde_json::to_string(&request_body) + .map_err(|err| Error::Provider(format!("failed to encode websocket request: {err}")))?; + ws.send(WsMessage::Text(request_text)) + .await + .map_err(|err| Error::Provider(format!("websocket send failed: {err}")))?; + + let (tx, rx) = mpsc::unbounded_channel(); + let _ = tx.send(Ok(ChunkType::Metadata(format!( + "openai_transport=responses_websocket previous_response_id={} input_items={}", + request_body.get("previous_response_id").is_some(), + request_body + .get("input") + .and_then(|value| value.as_array()) + .map(|items| items.len()) + .unwrap_or(0) + )))); + let websocket_state = Arc::clone(&self.websocket_state); + let request_snapshot = request_snapshot_from_body(&full_body); + tokio::spawn(async move { + let mut response_id = None; + let mut items_added = Vec::new(); + + while let Some(message) = ws.next().await { + match message { + Ok(WsMessage::Text(text)) => { + collect_websocket_response_state(&text, &mut response_id, &mut items_added); + if let Some(chunk) = response_sse_data_to_chunk(&text) { + let is_completed = + matches!(chunk, Ok(ChunkType::ResponseCompleted { .. })); + if tx.send(chunk).is_err() { + return; + } + if is_completed { + if let Some(response_id) = response_id { + let mut state = websocket_state.lock().await; + state.connection = Some(ws); + state.last_request = Some(request_snapshot); + state.last_response = Some(OpenAIResponseSnapshot { + response_id, + items_added, + }); + } + return; + } + } + } + Ok(WsMessage::Ping(_)) | Ok(WsMessage::Pong(_)) => {} + Ok(WsMessage::Close(_)) => { + let _ = tx.send(Ok(ChunkType::Failed( + "websocket closed before response.completed".to_string(), + ))); + return; + } + Ok(WsMessage::Binary(_)) | Ok(WsMessage::Frame(_)) => {} + Err(err) => { + let _ = tx.send(Ok(ChunkType::Failed(format!( + "websocket stream error: {}", + err + )))); + return; + } + } + } + + let _ = tx.send(Ok(ChunkType::Failed( + "websocket stream ended before response.completed".to_string(), + ))); + }); + + Ok(Box::pin(futures::stream::unfold(rx, |mut rx| async { + rx.recv().await.map(|item| (item, rx)) + }))) + } +} + fn format_openai_sse_error(err: &EventStreamError, request_url: &str) -> String { match err { EventStreamError::Transport(source) => { @@ -327,6 +495,169 @@ fn format_openai_sse_error(err: &EventStreamError, request_url: } } +async fn connect_openai_websocket( + ws_url: reqwest::Url, + headers: &reqwest::header::HeaderMap, +) -> Result>> { + let mut request = ws_url + .as_str() + .into_client_request() + .map_err(|err| Error::Provider(format!("failed to build websocket request: {err}")))?; + request.headers_mut().extend(headers.clone()); + request.headers_mut().insert( + OPENAI_BETA_HEADER, + RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE + .parse() + .map_err(|err| Error::Provider(format!("invalid websocket beta header: {err}")))?, + ); + + connect_async(request) + .await + .map(|(ws, _)| ws) + .map_err(|err| Error::Provider(format!("websocket connect failed: {err}"))) +} + +fn websocket_url(base_url: &str, responses_path: &str) -> Result { + let mut url = reqwest::Url::parse(&format!("{base_url}{responses_path}")) + .map_err(|err| Error::Provider(format!("failed to build websocket URL: {err}")))?; + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + "ws" | "wss" => return Ok(url), + other => { + return Err(Error::Provider(format!( + "unsupported websocket URL scheme: {other}" + ))); + } + }; + url.set_scheme(scheme) + .map_err(|_| Error::Provider("failed to set websocket URL scheme".to_string()))?; + Ok(url) +} + +fn websocket_request_body_from_state( + state: &OpenAIWebsocketState, + full_body: &serde_json::Value, +) -> serde_json::Value { + let input = full_body + .get("input") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + let body_without_input = body_without_input(full_body); + + let incremental_input = state + .last_request + .as_ref() + .zip(state.last_response.as_ref()) + .and_then(|(last_request, last_response)| { + if last_request.body_without_input != body_without_input { + return None; + } + + let mut baseline = last_request.input.clone(); + baseline.extend(last_response.items_added.clone()); + if input_starts_with(&input, &baseline) && baseline.len() < input.len() { + Some(( + last_response.response_id.clone(), + input[baseline.len()..].to_vec(), + )) + } else { + None + } + }); + + let mut request_body = full_body.clone(); + if let Some((previous_response_id, delta_input)) = incremental_input { + request_body["previous_response_id"] = serde_json::Value::String(previous_response_id); + request_body["input"] = serde_json::Value::Array(delta_input); + } + request_body["type"] = serde_json::Value::String("response.create".to_string()); + request_body +} + +fn body_without_input(body: &serde_json::Value) -> serde_json::Value { + let mut body = body.clone(); + if let Some(obj) = body.as_object_mut() { + obj.remove("input"); + obj.remove("previous_response_id"); + obj.remove("type"); + } + body +} + +fn request_snapshot_from_body(body: &serde_json::Value) -> OpenAIRequestSnapshot { + OpenAIRequestSnapshot { + body_without_input: body_without_input(body), + input: body + .get("input") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(), + } +} + +fn input_starts_with(input: &[serde_json::Value], baseline: &[serde_json::Value]) -> bool { + input.len() >= baseline.len() + && input + .iter() + .zip(baseline.iter()) + .all(|(left, right)| input_items_equivalent(left, right)) +} + +fn input_items_equivalent(left: &serde_json::Value, right: &serde_json::Value) -> bool { + normalize_input_item_for_prefix(left) == normalize_input_item_for_prefix(right) +} + +fn normalize_input_item_for_prefix(item: &serde_json::Value) -> serde_json::Value { + let mut item = item.clone(); + if item.get("type").and_then(|value| value.as_str()) == Some("function_call") { + if let Some(obj) = item.as_object_mut() { + obj.remove("id"); + obj.remove("status"); + } + } + item +} + +fn collect_websocket_response_state( + text: &str, + response_id: &mut Option, + items_added: &mut Vec, +) { + let Ok(value) = serde_json::from_str::(text) else { + return; + }; + match value.get("type").and_then(|value| value.as_str()) { + Some("response.created") => { + if let Some(id) = value + .get("response") + .and_then(|response| response.get("id")) + .and_then(|id| id.as_str()) + { + *response_id = Some(id.to_string()); + } + } + Some("response.output_item.done") => { + if let Some(item) = value.get("item") { + items_added.push(item.clone()); + } + } + Some("response.completed") => { + if response_id.is_none() { + if let Some(id) = value + .get("response") + .and_then(|response| response.get("id")) + .and_then(|id| id.as_str()) + { + *response_id = Some(id.to_string()); + } + } + } + _ => {} + } +} + fn format_openai_request_error( stage: &str, request_url: &str, @@ -811,12 +1142,18 @@ fn build_openai_messages(messages: &[Message], strip_system: bool) -> Vec Some(serde_json::json!({ - "type": "function_call", - "call_id": t.call_id, - "name": t.name, - "arguments": t.arguments, - })), + Message::ToolCall(t) => { + let mut item = serde_json::json!({ + "type": "function_call", + "call_id": t.call_id, + "name": t.name, + "arguments": t.arguments, + }); + if let Some(item_id) = &t.item_id { + item["id"] = serde_json::Value::String(item_id.clone()); + } + Some(item) + } Message::ToolOutput(t) => Some(serde_json::json!({ "type": "function_call_output", "call_id": t.call_id, @@ -850,7 +1187,11 @@ fn openai_responses_user_content(user: &crate::message::UserMessage) -> serde_js #[cfg(test)] mod tests { - use super::{build_openai_messages, response_sse_data_to_chunk, responses_function_call_chunk}; + use super::{ + build_openai_messages, request_snapshot_from_body, response_sse_data_to_chunk, + responses_function_call_chunk, websocket_request_body_from_state, OpenAI, + OpenAIResponseSnapshot, + }; use crate::chunk::{ChunkType, MessagePhase}; use crate::message::Message; @@ -938,13 +1279,19 @@ mod tests { fn serializes_structured_tool_history_for_responses_input() { let input = build_openai_messages( &[ - Message::tool_call("call_edit", "edit", "{\"file_path\":\"src/lib.rs\"}"), + Message::tool_call_with_item_id( + "fc_edit", + "call_edit", + "edit", + "{\"file_path\":\"src/lib.rs\"}", + ), Message::tool_output("call_edit", "edit", "Replaced at line 7", false), ], false, ); assert_eq!(input[0]["type"], "function_call"); + assert_eq!(input[0]["id"], "fc_edit"); assert_eq!(input[0]["call_id"], "call_edit"); assert_eq!(input[0]["name"], "edit"); assert_eq!(input[0]["arguments"], "{\"file_path\":\"src/lib.rs\"}"); @@ -952,4 +1299,84 @@ mod tests { assert_eq!(input[1]["call_id"], "call_edit"); assert_eq!(input[1]["output"], "Replaced at line 7"); } + + #[tokio::test] + async fn websocket_request_uses_previous_response_id_for_append_only_delta() { + let provider = OpenAI::builder() + .base_url("https://chatgpt.com") + .api_key("") + .model_name("gpt-test") + .build() + .unwrap(); + let previous_input = vec![serde_json::json!({ + "role": "user", + "content": "read the file" + })]; + let previous_body = provider.build_responses_body(previous_input.clone(), &[]); + let function_call = serde_json::json!({ + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "read", + "arguments": "{\"file_path\":\"Cargo.toml\"}" + }); + let function_output = serde_json::json!({ + "type": "function_call_output", + "call_id": "call_1", + "output": "00001| [package]" + }); + + { + let mut state = provider.websocket_state.lock().await; + state.last_request = Some(request_snapshot_from_body(&previous_body)); + state.last_response = Some(OpenAIResponseSnapshot { + response_id: "resp_1".to_string(), + items_added: vec![function_call.clone()], + }); + } + + let mut next_input = previous_input; + next_input.push(function_call); + next_input.push(function_output.clone()); + let next_body = provider.build_responses_body(next_input, &[]); + + let state = provider.websocket_state.lock().await; + let ws_body = websocket_request_body_from_state(&state, &next_body); + + assert_eq!(ws_body["type"], "response.create"); + assert_eq!(ws_body["previous_response_id"], "resp_1"); + assert_eq!(ws_body["input"], serde_json::json!([function_output])); + } + + #[tokio::test] + async fn websocket_request_uses_full_input_when_not_append_only() { + let provider = OpenAI::builder() + .base_url("https://chatgpt.com") + .api_key("") + .model_name("gpt-test") + .build() + .unwrap(); + let previous_body = provider.build_responses_body( + vec![serde_json::json!({"role": "user", "content": "first"})], + &[], + ); + { + let mut state = provider.websocket_state.lock().await; + state.last_request = Some(request_snapshot_from_body(&previous_body)); + state.last_response = Some(OpenAIResponseSnapshot { + response_id: "resp_1".to_string(), + items_added: vec![], + }); + } + let next_body = provider.build_responses_body( + vec![serde_json::json!({"role": "user", "content": "different"})], + &[], + ); + + let state = provider.websocket_state.lock().await; + let ws_body = websocket_request_body_from_state(&state, &next_body); + + assert!(ws_body.get("previous_response_id").is_none()); + assert_eq!(ws_body["input"], next_body["input"]); + } } diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 48e4b0d..79003ce 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -278,9 +278,21 @@ pub async fn stream_with_tools( let mut tool_calls_to_run = Vec::new(); let mut tool_call_messages = Vec::new(); - for (call_id, tool_name, args) in tool_calls_to_execute { - let tool_call_message = - Message::tool_call(call_id.clone(), tool_name.clone(), canonical_json(&args)); + for tool_call in tool_calls_to_execute { + let call_id = tool_call.call_id; + let tool_name = tool_call.name; + let args = tool_call.arguments; + let arguments = canonical_json(&args); + let tool_call_message = if let Some(item_id) = tool_call.item_id { + Message::tool_call_with_item_id( + item_id, + call_id.clone(), + tool_name.clone(), + arguments, + ) + } else { + Message::tool_call(call_id.clone(), tool_name.clone(), arguments) + }; current_messages.push(tool_call_message.clone()); tool_call_messages.push(tool_call_message); @@ -586,6 +598,14 @@ struct PendingToolCall { saw_arguments: bool, } +#[derive(Debug)] +struct CompletedToolCall { + item_id: Option, + call_id: String, + name: String, + arguments: serde_json::Value, +} + #[derive(Debug)] struct ToolExecutionResult { call_id: String, @@ -645,7 +665,7 @@ impl ToolCallAccumulator { Ok(()) } - fn finish(self) -> std::result::Result, String> { + fn finish(self) -> std::result::Result, String> { let mut results = Vec::new(); for call in self.calls { @@ -654,11 +674,21 @@ impl ToolCallAccumulator { .filter(|name| !name.is_empty()) .ok_or_else(|| format!("Tool call '{}' missing function name", call.key))?; - let item_id = call.id.unwrap_or_else(|| call.key.clone()); - let id = call.call_id.unwrap_or(item_id); - let args = parse_tool_arguments(&id, &call.arguments, call.final_arguments.as_deref())?; - - results.push((id, name, args)); + let item_id = call.id.or_else(|| Some(call.key.clone())); + let call_id = call + .call_id + .clone() + .or_else(|| item_id.clone()) + .unwrap_or_else(|| call.key.clone()); + let args = + parse_tool_arguments(&call_id, &call.arguments, call.final_arguments.as_deref())?; + + results.push(CompletedToolCall { + item_id, + call_id, + name, + arguments: args, + }); } Ok(results) @@ -1325,9 +1355,9 @@ mod tests { let calls = accumulator.finish().unwrap(); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].0, "call_1"); - assert_eq!(calls[0].1, "bash"); - assert_eq!(calls[0].2["command"], "ls -la"); + assert_eq!(calls[0].call_id, "call_1"); + assert_eq!(calls[0].name, "bash"); + assert_eq!(calls[0].arguments["command"], "ls -la"); } #[test] @@ -1346,9 +1376,10 @@ mod tests { let calls = accumulator.finish().unwrap(); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].0, "call_1"); - assert_eq!(calls[0].1, "read"); - assert_eq!(calls[0].2["file_path"], "Cargo.toml"); + assert_eq!(calls[0].call_id, "call_1"); + assert_eq!(calls[0].item_id.as_deref(), Some("fc_1")); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].arguments["file_path"], "Cargo.toml"); } #[test] @@ -1384,10 +1415,10 @@ mod tests { let calls = accumulator.finish().unwrap(); assert_eq!(calls.len(), 2); - assert_eq!(calls[0].1, "read"); - assert_eq!(calls[0].2["file_path"], "Cargo.toml"); - assert_eq!(calls[1].1, "bash"); - assert_eq!(calls[1].2["command"], "cargo test"); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].arguments["file_path"], "Cargo.toml"); + assert_eq!(calls[1].name, "bash"); + assert_eq!(calls[1].arguments["command"], "cargo test"); } #[test] @@ -1402,7 +1433,7 @@ mod tests { let calls = accumulator.finish().unwrap(); - assert_eq!(calls[0].2, serde_json::json!({})); + assert_eq!(calls[0].arguments, serde_json::json!({})); } #[test] @@ -1421,7 +1452,7 @@ mod tests { let calls = accumulator.finish().unwrap(); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].1, "read"); - assert_eq!(calls[0].2["file_path"], "Cargo.toml"); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].arguments["file_path"], "Cargo.toml"); } } diff --git a/src/llm/client.rs b/src/llm/client.rs index 964d340..61798f1 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -824,6 +824,9 @@ async fn stream_provider_request( if config.openai_options.force_tool_strict_false { builder = builder.tool_strict_override(false); } + if config.openai_options.disallow_system_messages { + builder = builder.responses_websocket(true); + } if !config.openai_options.additional_headers.is_empty() { builder = builder.headers(config.openai_options.additional_headers.clone()); } From 6e6a74dbabc957a57a982362c77623db0c118986 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 25 May 2026 19:40:18 +0800 Subject: [PATCH 142/226] fix: handle stale websocket connections and normalize assistant message shapes. - Discard idle websocket connections after 60s of inactivity - Retry on websocket send failure with a fresh connection - Clear cached connection state on close/error - Normalize Responses API assistant message items for prefix comparison - Allow empty deltas when previous_response_id is available --- _plans/PREMATURE_COMPLETE_BUG.md | 49 +++++++++ _plans/__TODOS.md | 4 +- aisdk/src/providers/openai.rs | 170 ++++++++++++++++++++++++++++--- 3 files changed, 208 insertions(+), 15 deletions(-) diff --git a/_plans/PREMATURE_COMPLETE_BUG.md b/_plans/PREMATURE_COMPLETE_BUG.md index 5fb4e0f..635ddae 100644 --- a/_plans/PREMATURE_COMPLETE_BUG.md +++ b/_plans/PREMATURE_COMPLETE_BUG.md @@ -366,3 +366,52 @@ Validation: ### Follow-up The cancellation-abort issue remains separate and still needs a dedicated fix: cancelling a stream can leave the underlying AISDK tool loop running after the UI receiver closes. + +## 2026-05-25 Long-Running Turn Cost / Websocket Idle Finding + +### User-Visible Symptom + +While dogfooding an image-tag opener feature, the turn ran for a long time after a delayed permission approval. The user saw high token/cost usage and then a stream failure: + +> websocket closed before response.completed + +This was not a premature-completion recurrence. It was a long-running turn / transport recovery problem. + +### `app.log` Evidence + +Primary session id: `npa3foyel6u2co8n721sxtwv`. + +Relevant sequence: + +- `19:28:16`: the primary stream failed with `websocket closed before response.completed`. +- The failed stream had `elapsed_ms=1552634`, `response_completed=0`, and `agent_max_steps=None`. +- The last metadata included `openai_transport=responses_websocket previous_response_id=false input_items=277`, indicating a full-history websocket request rather than a compact delta. +- `19:28:23`: the follow-up stream restarted with `input_messages=156`, `messages=279`, and `previous_response_id=false input_items=278`. + +### Root Cause + +Two issues amplified the cost: + +1. Crabcode reused cached websocket connections without considering long idle gaps between provider steps. A permission prompt or long tool execution can leave the physical websocket stale before the next request. +2. The websocket delta cache missed append-only continuations too often. Provider response message items use Responses API shapes such as `{"type":"message","role":"assistant","content":[...]}`, while crabcode's local history serializes assistant messages as `{"role":"assistant","content":"..."}`. Prefix comparison treated these equivalent assistant messages as different and fell back to sending full input. It also rejected empty deltas even though Codex allows them when `previous_response_id` is available. + +### Fix Applied + +- `aisdk/src/providers/openai.rs` + - Track when a cached websocket was last successfully used. + - Discard idle cached websocket connections before sending another request, while preserving `last_response` history so `previous_response_id` can still be used on a fresh socket. + - If sending on a reused websocket fails, clear the cached connection, reconnect once, and resend the same request on the fresh websocket. + - Clear cached physical websocket state on runtime close/error before `response.completed`. + - Normalize Responses assistant message items to crabcode's local assistant message shape during prefix comparison. + - Allow empty websocket deltas with `previous_response_id`, matching Codex's `allow_empty_delta` behavior. + +### Validation + +- `cargo fmt --check` +- `cargo test -p aisdk websocket` +- `cargo test -p aisdk` +- `cargo check` + +### Follow-up + +This does not yet add a full Codex-style sampling retry loop around partially streamed websocket failures. The next cost-control target is a bounded stream retry/fallback policy plus sane default `agent_max_steps` for normal Build turns. diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 65f6c06..c8780fe 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -116,7 +116,7 @@ - [x] When a message is sent, the [Image #1] or [Image #2] tags, become just white, not the unique color we have for them in the chat input box. -- [ ] Syntax highlighting during "Edited" tool calls. Check how Codex does it, because it has syntax highlighting for some reason--It's very clean. +- [ ] Syntax highlighting during "Edited" tool calls for diffs. Check how Codex does it, because it has syntax highlighting for some reason--It's very clean. - [ ] I also think the /copy transcript should show "Edit" tool call results no? Right now it looks as simple as: **Tool Result** @@ -172,7 +172,7 @@ I want - [x] To do this But I dont want to do this - [x] Make the "bash" permission parity to codex. Also I currently dont see the command that it wants to run, so I'm kinda blind on what to run here. -- [ ] When pasting images and it creates this [Image #1] tag, make it hoverable (just change the color, not the background), then once clicked, goes to the preferred editor of the user. +- [x] When pasting images and it creates this [Image #1] tag, make it hoverable (just change the color, not the background), then once clicked, goes to the preferred editor of the user. - Multiple paths here: - Should it be configurable? - Autodetected depending on the tool used: i.e. if Wezterm, other terminals "open w/ Finder on mac, or native image opener". If inside Zed, open image with Zed. If inside VSCode/Cursor, open with that IDE. (Ambitious but idk if possible) diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index 0e0d485..cc5a80a 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -9,6 +9,7 @@ use futures::{SinkExt, StreamExt}; use std::collections::HashMap; use std::error::Error as StdError; use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio::net::TcpStream; use tokio::sync::{mpsc, Mutex}; use tokio_tungstenite::connect_async; @@ -20,6 +21,7 @@ const OPENAI_STREAM_CONNECT_TIMEOUT_SECS: u64 = 30; const OPENAI_ERROR_BODY_MAX_CHARS: usize = 2048; const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +const OPENAI_WEBSOCKET_IDLE_MAX: Duration = Duration::from_secs(60); #[derive(Debug, Clone)] pub struct OpenAI { @@ -164,10 +166,29 @@ impl OpenAIBuilder { struct OpenAIWebsocketState { disabled: bool, connection: Option>>, + last_used_at: Option, last_request: Option, last_response: Option, } +impl OpenAIWebsocketState { + fn discard_idle_connection(&mut self) { + let is_idle = self + .last_used_at + .map(|last_used_at| last_used_at.elapsed() > OPENAI_WEBSOCKET_IDLE_MAX) + .unwrap_or(false); + if is_idle { + self.connection = None; + self.last_used_at = None; + } + } + + fn clear_connection(&mut self) { + self.connection = None; + self.last_used_at = None; + } +} + #[derive(Debug, Clone)] struct OpenAIRequestSnapshot { body_without_input: serde_json::Value, @@ -386,26 +407,42 @@ impl OpenAI { headers: &reqwest::header::HeaderMap, ) -> Result { let ws_url = websocket_url(self.base_url.trim_end_matches('/'), &self.responses_path)?; - let (request_body, mut ws) = { + let (request_body, mut ws, reused_connection) = { let mut state = self.websocket_state.lock().await; if state.disabled { return Err(Error::Provider("websocket transport disabled".to_string())); } + state.discard_idle_connection(); let request_body = websocket_request_body_from_state(&state, &full_body); if let Some(ws) = state.connection.take() { - (request_body, ws) + state.last_used_at = None; + (request_body, ws, true) } else { drop(state); - let ws = connect_openai_websocket(ws_url, headers).await?; - (request_body, ws) + let ws = connect_openai_websocket(ws_url.clone(), headers).await?; + (request_body, ws, false) } }; let request_text = serde_json::to_string(&request_body) .map_err(|err| Error::Provider(format!("failed to encode websocket request: {err}")))?; - ws.send(WsMessage::Text(request_text)) - .await - .map_err(|err| Error::Provider(format!("websocket send failed: {err}")))?; + if let Err(err) = ws.send(WsMessage::Text(request_text.clone())).await { + if !reused_connection { + return Err(Error::Provider(format!("websocket send failed: {err}"))); + } + + { + let mut state = self.websocket_state.lock().await; + state.clear_connection(); + } + + let mut fresh_ws = connect_openai_websocket(ws_url, headers).await?; + fresh_ws + .send(WsMessage::Text(request_text)) + .await + .map_err(|err| Error::Provider(format!("websocket send failed: {err}")))?; + ws = fresh_ws; + } let (tx, rx) = mpsc::unbounded_channel(); let _ = tx.send(Ok(ChunkType::Metadata(format!( @@ -437,6 +474,7 @@ impl OpenAI { if let Some(response_id) = response_id { let mut state = websocket_state.lock().await; state.connection = Some(ws); + state.last_used_at = Some(Instant::now()); state.last_request = Some(request_snapshot); state.last_response = Some(OpenAIResponseSnapshot { response_id, @@ -449,6 +487,7 @@ impl OpenAI { } Ok(WsMessage::Ping(_)) | Ok(WsMessage::Pong(_)) => {} Ok(WsMessage::Close(_)) => { + websocket_state.lock().await.clear_connection(); let _ = tx.send(Ok(ChunkType::Failed( "websocket closed before response.completed".to_string(), ))); @@ -456,6 +495,7 @@ impl OpenAI { } Ok(WsMessage::Binary(_)) | Ok(WsMessage::Frame(_)) => {} Err(err) => { + websocket_state.lock().await.clear_connection(); let _ = tx.send(Ok(ChunkType::Failed(format!( "websocket stream error: {}", err @@ -465,6 +505,7 @@ impl OpenAI { } } + websocket_state.lock().await.clear_connection(); let _ = tx.send(Ok(ChunkType::Failed( "websocket stream ended before response.completed".to_string(), ))); @@ -557,7 +598,7 @@ fn websocket_request_body_from_state( let mut baseline = last_request.input.clone(); baseline.extend(last_response.items_added.clone()); - if input_starts_with(&input, &baseline) && baseline.len() < input.len() { + if input_starts_with(&input, &baseline) { Some(( last_response.response_id.clone(), input[baseline.len()..].to_vec(), @@ -610,14 +651,47 @@ fn input_items_equivalent(left: &serde_json::Value, right: &serde_json::Value) - } fn normalize_input_item_for_prefix(item: &serde_json::Value) -> serde_json::Value { - let mut item = item.clone(); - if item.get("type").and_then(|value| value.as_str()) == Some("function_call") { - if let Some(obj) = item.as_object_mut() { + if item.get("type").and_then(|value| value.as_str()) == Some("message") { + if let Some(role) = item.get("role").and_then(|value| value.as_str()) { + if let Some(content) = response_message_content_as_text(item.get("content")) { + return serde_json::json!({ + "role": role, + "content": content, + }); + } + } + } + + let mut normalized = item.clone(); + if normalized.get("type").and_then(|value| value.as_str()) == Some("function_call") { + if let Some(obj) = normalized.as_object_mut() { obj.remove("id"); obj.remove("status"); } } - item + normalized +} + +fn response_message_content_as_text(content: Option<&serde_json::Value>) -> Option { + match content? { + serde_json::Value::String(text) => Some(text.clone()), + serde_json::Value::Array(parts) => { + let mut text = String::new(); + for part in parts { + let part_type = part.get("type").and_then(|value| value.as_str()); + if matches!( + part_type, + Some("output_text") | Some("text") | Some("input_text") + ) { + if let Some(part_text) = part.get("text").and_then(|value| value.as_str()) { + text.push_str(part_text); + } + } + } + Some(text) + } + _ => None, + } } fn collect_websocket_response_state( @@ -1190,10 +1264,11 @@ mod tests { use super::{ build_openai_messages, request_snapshot_from_body, response_sse_data_to_chunk, responses_function_call_chunk, websocket_request_body_from_state, OpenAI, - OpenAIResponseSnapshot, + OpenAIResponseSnapshot, OpenAIWebsocketState, }; use crate::chunk::{ChunkType, MessagePhase}; use crate::message::Message; + use std::time::{Duration, Instant}; #[test] fn done_marker_emits_terminal_chunk() { @@ -1348,6 +1423,75 @@ mod tests { assert_eq!(ws_body["input"], serde_json::json!([function_output])); } + #[tokio::test] + async fn websocket_request_uses_previous_response_id_for_assistant_message_shape_delta() { + let provider = OpenAI::builder() + .base_url("https://chatgpt.com") + .api_key("") + .model_name("gpt-test") + .build() + .unwrap(); + let previous_input = vec![serde_json::json!({ + "role": "user", + "content": "inspect the code" + })]; + let previous_body = provider.build_responses_body(previous_input.clone(), &[]); + let response_assistant_message = serde_json::json!({ + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [ + { "type": "output_text", "text": "I'll inspect the code." } + ] + }); + + { + let mut state = provider.websocket_state.lock().await; + state.last_request = Some(request_snapshot_from_body(&previous_body)); + state.last_response = Some(OpenAIResponseSnapshot { + response_id: "resp_1".to_string(), + items_added: vec![response_assistant_message], + }); + } + + let mut next_input = previous_input; + next_input.push(serde_json::json!({ + "role": "assistant", + "content": "I'll inspect the code." + })); + let next_body = provider.build_responses_body(next_input, &[]); + + let state = provider.websocket_state.lock().await; + let ws_body = websocket_request_body_from_state(&state, &next_body); + + assert_eq!(ws_body["previous_response_id"], "resp_1"); + assert_eq!(ws_body["input"], serde_json::json!([])); + } + + #[test] + fn websocket_connection_clear_preserves_response_history() { + let mut state = OpenAIWebsocketState { + last_used_at: Some(Instant::now() - Duration::from_secs(120)), + last_response: Some(OpenAIResponseSnapshot { + response_id: "resp_1".to_string(), + items_added: vec![], + }), + ..OpenAIWebsocketState::default() + }; + + state.clear_connection(); + + assert!(state.last_used_at.is_none()); + assert_eq!( + state + .last_response + .as_ref() + .map(|response| response.response_id.as_str()), + Some("resp_1") + ); + } + #[tokio::test] async fn websocket_request_uses_full_input_when_not_append_only() { let provider = OpenAI::builder() From ce55ddb52c6219e06cd848b8f39c19e74e280383 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 25 May 2026 23:15:11 +0800 Subject: [PATCH 143/226] feat: add configurable image opening and improve stream interruption handling. Add `images.openWith` config supporting auto, system, editor, and custom commands for opening attached images. Implement image hover & click interactions in chat and input components. Detect stream receiver disconnection to fail sessions gracefully instead of hanging. Persist `was_interrupted` flag across sessions so interrupted assistant messages show an "interrupted" label in the UI on restore. --- _docs/config/images.mdx | 61 ++++++ _docs/config/index.mdx | 5 + _docs/gittydocs.jsonc | 1 + _plans/PREMATURE_COMPLETE_BUG.md | 26 +++ crabcode.schema.json | 60 ++++++ src/app.rs | 354 +++++++++++++++++++++++++++++-- src/config/configuration.rs | 169 +++++++++++++++ src/config/mod.rs | 6 +- src/persistence/conversions.rs | 31 +++ src/session/types.rs | 7 + src/ui/components/chat.rs | 270 +++++++++++++++++++++-- src/ui/components/dialog.rs | 8 + src/ui/components/input.rs | 136 +++++++++++- src/utils/image_attachment.rs | 221 ++++++++++++++++++- 14 files changed, 1308 insertions(+), 47 deletions(-) create mode 100644 _docs/config/images.mdx diff --git a/_docs/config/images.mdx b/_docs/config/images.mdx new file mode 100644 index 0000000..442dcb3 --- /dev/null +++ b/_docs/config/images.mdx @@ -0,0 +1,61 @@ +--- +title: Images +description: Configure how pasted image attachments open from the terminal UI. +--- + +# Image Attachments + +When you paste or autocomplete an image path, crabcode inserts a placeholder such as `[Image #1]`. The placeholder is rendered as interactive text: hovering changes the text color only, and clicking it opens the image. + +By default, image opening uses automatic detection: + +```jsonc title="crabcode.jsonc" +{ + "images": { + "openWith": "auto", + }, +} +``` + +## Open target + +`images.openWith` controls where clicked image placeholders open. + +| Value | Behavior | +| --- | --- | +| `"auto"` | Use an editor opener when crabcode detects a Zed, VSCode, or Cursor integrated terminal. Otherwise use the OS default opener. This is the default. | +| `"system"` | Always use the OS default opener (`open` on macOS, `xdg-open` on Linux, `start` on Windows). | +| `"editor"` | Prefer the detected editor opener, then `$VISUAL` or `$EDITOR`; falls back to the OS opener if no editor command is available. | +| command object | Run a custom command. Use `{path}` anywhere in `args` to insert the image path. | + +`auto` treats standalone terminals such as WezTerm, iTerm, Terminal.app, Alacritty, and Kitty as normal terminals, so they open images with the OS-level file opener. + +## Custom command + +Use a command object when you want complete control over the opener: + +```jsonc title="crabcode.jsonc" +{ + "images": { + "openWith": { + "command": "zed", + "args": ["{path}"], + }, + }, +} +``` + +Other examples: + +```jsonc title="crabcode.jsonc" +{ + "images": { + "openWith": { + "command": "open", + "args": ["-a", "Preview", "{path}"], + }, + }, +} +``` + +If `args` is omitted, crabcode uses `["{path}"]`. diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx index 7ed59cb..e867486 100644 --- a/_docs/config/index.mdx +++ b/_docs/config/index.mdx @@ -42,6 +42,9 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the }, "notifications": { "terminal": { "complete": "auto", "condition": "unfocused" } + }, + "images": { + "openWith": "auto" } } ``` @@ -56,4 +59,6 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the | crabcode-specific command overrides | `.crabcode/commands/` | | Personal defaults | `~/.config/crabcode/crabcode.jsonc` | +See [Image Attachments](/config/images) for configuring where clicked `[Image #1]` placeholders open. + For command syntax, frontmatter, arguments, shell output, and file references, use the [OpenCode commands docs](https://opencode.ai/docs/commands/). crabcode reads the same markdown command format. diff --git a/_docs/gittydocs.jsonc b/_docs/gittydocs.jsonc index cc30e40..85a70fb 100644 --- a/_docs/gittydocs.jsonc +++ b/_docs/gittydocs.jsonc @@ -26,6 +26,7 @@ { "label": "Overview", "path": "/config" }, { "label": "OpenCode Compatibility", "path": "/config/opencode-compatibility" }, { "label": "Terminal Notifications", "path": "/config/notifications" }, + { "label": "Images", "path": "/config/images" }, { "label": "Sounds", "path": "/config/sounds" }, { "label": "Theme", "path": "/config/theme" }, ], diff --git a/_plans/PREMATURE_COMPLETE_BUG.md b/_plans/PREMATURE_COMPLETE_BUG.md index 635ddae..499c003 100644 --- a/_plans/PREMATURE_COMPLETE_BUG.md +++ b/_plans/PREMATURE_COMPLETE_BUG.md @@ -415,3 +415,29 @@ Two issues amplified the cost: ### Follow-up This does not yet add a full Codex-style sampling retry loop around partially streamed websocket failures. The next cost-control target is a bounded stream retry/fallback policy plus sane default `agent_max_steps` for normal Build turns. + +## 2026-05-25 Stuck InProgress After Permission Delay + +### User-Visible Symptom + +After a permission prompt had been open for a while, approving it let the tool finish, but the UI could keep showing the turn as `InProgress` forever. + +### Root Cause + +`App::process_streaming_chunks` drained available stream chunks with `while let Ok(chunk) = receiver.try_recv()`, but ignored `TryRecvError::Disconnected`. + +If the async stream task exited without delivering a terminal `End`, `Failed`, or `Cancelled` chunk, the session's `stream` field stayed populated. That left `is_streaming` true and could leave running tool messages active, even though no producer remained to send the final lifecycle event. + +### Fix Applied + +- `src/app.rs` + - `process_streaming_chunks` now distinguishes `Empty` from `Disconnected`. + - It processes any queued chunks first, then if the receiver is disconnected and the stream is still registered, it logs `[STREAM_DISCONNECTED]` and fails the streaming session with `Stream task ended before sending a completion event`. + - This reuses the existing failure path, which marks still-running tool messages as `error`, persists streamed messages, clears stream state, and resets the active streaming flag. + +### Validation + +- `cargo test disconnected_stream_receiver` +- `cargo test stream_finish_waits_for_running_tool_result` +- `cargo fmt --check` +- `cargo check` diff --git a/crabcode.schema.json b/crabcode.schema.json index c1422ef..fb017ac 100644 --- a/crabcode.schema.json +++ b/crabcode.schema.json @@ -83,6 +83,56 @@ }, "type": "object" }, + "ImageOpenCommandConfigFile": { + "additionalProperties": false, + "properties": { + "args": { + "default": [ + "{path}" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "ImageOpenWith": { + "anyOf": [ + { + "enum": [ + "auto", + "system", + "editor" + ], + "type": "string" + }, + { + "$ref": "#/$defs/ImageOpenCommandConfigFile" + } + ] + }, + "ImagesConfigFile": { + "additionalProperties": false, + "properties": { + "openWith": { + "$ref": "#/$defs/ImageOpenWith", + "default": "auto" + }, + "open_with": { + "$ref": "#/$defs/ImageOpenWith", + "default": "auto" + } + }, + "type": "object" + }, "TerminalNotificationMode": { "anyOf": [ { @@ -155,6 +205,16 @@ "enabled_providers": true, "formatter": true, "instructions": true, + "images": { + "anyOf": [ + { + "$ref": "#/$defs/ImagesConfigFile" + }, + { + "type": "null" + } + ] + }, "mcp": true, "model": { "type": [ diff --git a/src/app.rs b/src/app.rs index c208c1c..0d57a9b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use crate::session::manager::SessionManager; use crate::push_toast; use crate::toast::{self, Toast, ToastLevel}; -use crate::ui::components::chat::Chat; +use crate::ui::components::chat::{Chat, ChatImageTarget}; use crate::ui::components::input::Input; use crate::ui::components::popup::Popup; use crate::utils::git; @@ -239,6 +239,7 @@ pub struct App { pub dark_mode: bool, pub sounds: crate::sound::ResolvedSoundsConfig, pub notifications: crate::config::NotificationsConfig, + pub images: crate::config::ImagesConfig, terminal_focused: bool, pub tool_permissions: crate::tools::ToolPermissions, pub skills_dirs: Vec, @@ -303,6 +304,7 @@ impl App { }; let loaded_config = crate::config::ConfigLoader::load()?; + input.set_image_open_config(loaded_config.merged_config.images.clone()); if !loaded_config.diagnostics.info.is_empty() { for msg in &loaded_config.diagnostics.info { crate::startup_diag!("Config: {}", msg); @@ -453,6 +455,7 @@ impl App { dark_mode: true, sounds: resolved_sounds, notifications: loaded_config.merged_config.notifications, + images: loaded_config.merged_config.images, terminal_focused: true, tool_permissions, skills_dirs: loaded_config.inventory.opencode_skills_dirs, @@ -2085,6 +2088,10 @@ impl App { return false; } + if matches!(mouse.kind, MouseEventKind::Moved) && !self.input.contains_mouse(mouse) { + self.input.clear_hover(); + } + if !self.input.handle_mouse_event(mouse) { return false; } @@ -2105,6 +2112,22 @@ impl App { true } + fn open_chat_image_target(&self, target: &ChatImageTarget) { + let path = std::path::Path::new(&target.path); + match crate::utils::image_attachment::open_path(path, &self.images) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", target.placeholder), + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open image: {}", err), + ToastLevel::Error, + None, + )), + } + } + pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() { crate::emit_log!( @@ -2118,6 +2141,14 @@ impl App { ); } + if matches!(mouse.kind, MouseEventKind::Moved) && !self.input.contains_mouse(mouse) { + self.input.clear_hover(); + } + + if matches!(mouse.kind, MouseEventKind::Moved) && self.base_focus != BaseFocus::Chat { + self.chat_state.chat.clear_hovered_image(); + } + // If text is selected and user clicks on an overlay, clear selection instead if self.overlay_focus != OverlayFocus::None && self.chat_state.chat.has_selection() @@ -2373,8 +2404,30 @@ impl App { self.overlay_focus = OverlayFocus::None; } } else if self.overlay_focus == OverlayFocus::MessageActions { + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + ) { + let chat_area = self.current_chat_area(); + let click_inside_popup = self + .message_actions_dialog + .as_ref() + .map(|dialog| dialog.contains_position(mouse.column, mouse.row)) + .unwrap_or(false); + if !click_inside_popup { + if let Some(target) = self.chat_state.chat.image_at_position(mouse, chat_area) { + self.chat_state.chat.set_hovered_image(Some(target.clone())); + self.pending_chat_message_click = None; + self.close_message_actions(); + self.open_chat_image_target(&target); + return; + } + } + } + let maybe_action = if let Some(ref mut dialog) = self.message_actions_dialog { - let clicked_item = if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + let clicked_item = if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + { dialog.item_index_at_position(mouse.column, mouse.row) } else { None @@ -2456,12 +2509,17 @@ impl App { if !self.chat_state.chat.has_selection() && !self.chat_state.chat.selection.is_dragging => { - let hovered = self + let hovered_image = + self.chat_state.chat.image_at_position(mouse, chat_area); + let hovered_message = self .chat_state .chat .message_index_at_position(mouse, chat_area); - self.chat_state.chat.set_highlighted_message(hovered); - if hovered.is_some() { + self.chat_state.chat.set_hovered_image(hovered_image); + self.chat_state + .chat + .set_highlighted_message(hovered_message); + if hovered_message.is_some() { return; } } @@ -2470,6 +2528,15 @@ impl App { && !self.chat_state.chat.has_selection() && !self.chat_state.chat.selection.is_dragging => { + if let Some(target) = + self.chat_state.chat.image_at_position(mouse, chat_area) + { + self.chat_state.chat.set_hovered_image(Some(target.clone())); + self.pending_chat_message_click = None; + self.open_chat_image_target(&target); + return; + } + self.pending_chat_message_click = self .chat_state .chat @@ -2526,6 +2593,7 @@ impl App { } // Handle mouse events for the main input when no overlay is focused + self.chat_state.chat.clear_hovered_image(); self.handle_input_mouse_event(mouse); } } @@ -4526,16 +4594,40 @@ impl App { for session_id in streaming_ids { let mut chunks = Vec::new(); + let mut disconnected = false; if let Some(stream) = self.stream_for_session_mut(&session_id) { - while let Ok(chunk) = stream.chunk_receiver.try_recv() { - chunks.push(chunk); + loop { + match stream.chunk_receiver.try_recv() { + Ok(chunk) => chunks.push(chunk), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } } } for chunk in chunks { self.process_streaming_chunk_for_session(&session_id, chunk); } + + if disconnected + && self + .session_view_states + .get(&session_id) + .is_some_and(|state| state.stream.is_some()) + { + crate::emit_log!( + "[STREAM_DISCONNECTED] session_id={} reason=stream_receiver_disconnected_without_terminal_chunk", + session_id + ); + self.fail_streaming_session( + &session_id, + "Stream task ended before sending a completion event".to_string(), + ); + } } self.sync_active_streaming_flag(); @@ -4870,6 +4962,22 @@ impl App { } } + fn mark_streamed_assistant_interrupted(&mut self, session_id: &str) { + let Some((start, _, _)) = self.streaming_boundary_for_session(session_id) else { + return; + }; + + if let Some(chat) = self.chat_for_session_mut(session_id) { + for idx in (start..chat.messages.len()).rev() { + if chat.messages[idx].role == crate::session::types::MessageRole::Assistant { + chat.messages[idx].mark_interrupted(); + chat.mark_render_dirty(); + return; + } + } + } + } + fn fail_streaming_session(&mut self, session_id: &str, error: String) { if self .finalize_and_persist_streamed_messages(session_id, Some(&error)) @@ -4894,15 +5002,13 @@ impl App { } fn cancelled_streaming_session(&mut self, session_id: &str) { - let start = self - .streaming_boundary_for_session(session_id) - .map(|(start, _, _)| start) - .unwrap_or(0); + self.mark_streamed_assistant_interrupted(session_id); - if let Some(chat) = self.chat_for_session_mut(session_id) { - chat.mark_streaming_end(); - chat.finalize_streaming_metrics(); - chat.truncate_messages(start); + if self + .finalize_and_persist_streamed_messages(session_id, Some("Streaming cancelled by user")) + .is_none() + { + return; } let _ = self.session_manager.set_session_status( @@ -5536,7 +5642,11 @@ mod tests { App { running: true, version: "test".to_string(), - input: Input::new(), + input: { + let mut input = Input::new(); + input.set_image_open_config(crate::config::ImagesConfig::default()); + input + }, command_registry: registry, session_manager: SessionManager::new(), home_state: init_home(), @@ -5582,6 +5692,7 @@ mod tests { dark_mode: true, sounds: crate::sound::ResolvedSoundsConfig::default(), notifications: crate::config::NotificationsConfig::default(), + images: crate::config::ImagesConfig::default(), terminal_focused: true, tool_permissions: crate::tools::ToolPermissions::new(".".to_string()), skills_dirs: Vec::new(), @@ -5805,6 +5916,77 @@ mod tests { ); } + #[test] + fn interrupted_stream_persists_partial_messages() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Interrupted".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete( + "Partial answer.", + )); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "running", + "args": { "path": "Cargo.toml" }, + }) + .to_string(), + )); + + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.session_view_states.get_mut(&session_id).unwrap().stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + app.is_streaming = true; + + app.cancelled_streaming_session(&session_id); + + assert_eq!(app.chat_state.chat.messages.len(), 3); + + let session = app.session_manager.get_session_ref(&session_id).unwrap(); + assert_eq!( + session.status, + crate::session::types::SessionStatus::Interrupted + ); + assert_eq!(session.messages.len(), 3); + assert_eq!( + session.messages[1].role, + crate::session::types::MessageRole::Assistant + ); + assert_eq!(session.messages[1].content, "Partial answer."); + assert!(session.messages[1].is_complete); + assert!(session.messages[1].was_interrupted); + assert_eq!(session.messages[1].model.as_deref(), Some("test-model")); + assert_eq!( + session.messages[1].provider.as_deref(), + Some("test-provider") + ); + + let tool_payload: serde_json::Value = + serde_json::from_str(&session.messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "error"); + assert_eq!( + tool_payload["output_preview"], + "Streaming cancelled by user" + ); + } + #[test] fn stream_finish_waits_for_running_tool_result() { let mut app = test_app(); @@ -5894,6 +6076,146 @@ mod tests { assert_eq!(tool_payload["status"], "ok"); } + #[test] + fn disconnected_stream_receiver_marks_running_tools_failed() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Disconnected".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete("Working.")); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "bash", + "status": "running", + "args": { "command": "cargo test" }, + }) + .to_string(), + )); + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + drop(sender); + + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + state + .tool_calls + .tool_call_message_indices + .insert("call_1".to_string(), 2); + state.tool_calls.tool_call_order.push("call_1".to_string()); + app.is_streaming = true; + + app.process_streaming_chunks(); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.stream.is_none()); + assert!(!app.is_streaming); + + let session = app.session_manager.get_session_ref(&session_id).unwrap(); + assert_eq!(session.status, crate::session::types::SessionStatus::Failed); + assert_eq!(session.messages.len(), 3); + + let tool_payload: serde_json::Value = + serde_json::from_str(&session.messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "error"); + assert_eq!( + tool_payload["output_preview"], + "Stream task ended before sending a completion event" + ); + } + + #[test] + fn disconnected_stream_receiver_processes_queued_tool_result_before_failing() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Tool result then disconnect".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete("Working.")); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "bash", + "status": "running", + "args": { "command": "cargo test" }, + }) + .to_string(), + )); + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + sender + .send(crate::llm::ChunkMessage::ToolResult( + crate::llm::ToolCallResult { + tool_call_id: "call_1".to_string(), + role: "tool".to_string(), + name: "bash".to_string(), + content: serde_json::json!({ + "status": "ok", + "title": "Bash", + "output_preview": "tests passed" + }) + .to_string(), + }, + )) + .unwrap(); + drop(sender); + + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + state + .tool_calls + .tool_call_message_indices + .insert("call_1".to_string(), 2); + state.tool_calls.tool_call_order.push("call_1".to_string()); + app.is_streaming = true; + + app.process_streaming_chunks(); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.stream.is_none()); + assert!(!app.is_streaming); + + let session = app.session_manager.get_session_ref(&session_id).unwrap(); + assert_eq!(session.status, crate::session::types::SessionStatus::Failed); + assert_eq!(session.messages.len(), 3); + + let tool_payload: serde_json::Value = + serde_json::from_str(&session.messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "ok"); + assert_eq!(tool_payload["output_preview"], "tests passed"); + } + #[test] fn chat_only_commands_are_rejected_outside_chat() { let mut app = test_app(); diff --git a/src/config/configuration.rs b/src/config/configuration.rs index f42150b..e19f1f4 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -203,6 +203,33 @@ pub struct NotificationsConfig { pub terminal: TerminalNotificationsConfig, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageOpenCommandConfig { + pub command: String, + pub args: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImageOpenWith { + Auto, + System, + Editor, + Command(ImageOpenCommandConfig), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImagesConfig { + pub open_with: ImageOpenWith, +} + +impl Default for ImagesConfig { + fn default() -> Self { + Self { + open_with: ImageOpenWith::Auto, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderTimeout { Millis(u64), @@ -220,6 +247,7 @@ pub struct MergedConfig { pub provider_timeouts: HashMap, pub sounds: SoundsConfig, pub notifications: NotificationsConfig, + pub images: ImagesConfig, } #[derive(Debug, Clone)] @@ -715,6 +743,7 @@ fn crabcode_allowed_keys() -> BTreeSet<&'static str> { out.insert("theme"); out.insert("sounds"); out.insert("notifications"); + out.insert("images"); out } @@ -964,6 +993,7 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M out.sounds = parse_sounds(obj.get("sounds"), diagnostics); out.notifications = parse_notifications(obj.get("notifications"), diagnostics); + out.images = parse_images(obj.get("images"), diagnostics); out } @@ -1132,6 +1162,94 @@ fn parse_provider_timeouts( out } +fn parse_images(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> ImagesConfig { + let mut images = ImagesConfig::default(); + let Some(value) = value else { + return images; + }; + if value.is_null() { + return images; + } + let Value::Object(map) = value else { + diagnostics + .warnings + .push("images must be an object".to_string()); + return images; + }; + + let Some(open_with) = map.get("openWith").or_else(|| map.get("open_with")) else { + return images; + }; + + images.open_with = parse_image_open_with(open_with, "images.openWith", diagnostics); + images +} + +fn parse_image_open_with( + value: &Value, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) -> ImageOpenWith { + match value { + Value::String(s) => match s.trim().to_ascii_lowercase().as_str() { + "auto" => ImageOpenWith::Auto, + "system" => ImageOpenWith::System, + "editor" => ImageOpenWith::Editor, + _ => { + diagnostics.warnings.push(format!( + "{}: expected auto, system, editor, or a command object", + key + )); + ImageOpenWith::Auto + } + }, + Value::Object(map) => { + let command = match map.get("command").and_then(Value::as_str) { + Some(command) if !command.trim().is_empty() => command.trim().to_string(), + _ => { + diagnostics + .warnings + .push(format!("{}.command must be a non-empty string", key)); + return ImageOpenWith::Auto; + } + }; + + let args = match map.get("args") { + Some(Value::Array(raw_args)) => { + let mut args = Vec::new(); + for arg in raw_args { + if let Some(arg) = arg.as_str() { + args.push(arg.to_string()); + } else { + diagnostics + .warnings + .push(format!("{}.args must contain only strings", key)); + return ImageOpenWith::Auto; + } + } + args + } + Some(_) => { + diagnostics + .warnings + .push(format!("{}.args must be an array of strings", key)); + return ImageOpenWith::Auto; + } + None => vec!["{path}".to_string()], + }; + + ImageOpenWith::Command(ImageOpenCommandConfig { command, args }) + } + _ => { + diagnostics.warnings.push(format!( + "{}: expected auto, system, editor, or a command object", + key + )); + ImageOpenWith::Auto + } + } +} + fn parse_sounds(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> SoundsConfig { let mut sounds = SoundsConfig::default(); let Some(Value::Object(map)) = value else { @@ -1322,6 +1440,7 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec { "agent", "provider", "notifications", + "images", ] .into_iter() .collect(); @@ -1406,6 +1525,56 @@ mod tests { ); } + #[test] + fn images_open_with_defaults_to_auto() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config(&json!({}), &mut diagnostics); + + assert_eq!(config.images.open_with, ImageOpenWith::Auto); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_images_open_with_string() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "images": { + "openWith": "system" + } + }), + &mut diagnostics, + ); + + assert_eq!(config.images.open_with, ImageOpenWith::System); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_images_open_with_command() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "images": { + "openWith": { + "command": "zed", + "args": ["{path}"] + } + } + }), + &mut diagnostics, + ); + + assert_eq!( + config.images.open_with, + ImageOpenWith::Command(ImageOpenCommandConfig { + command: "zed".to_string(), + args: vec!["{path}".to_string()], + }) + ); + assert!(diagnostics.warnings.is_empty()); + } + #[test] fn agent_max_steps_alias_is_supported() { let mut diagnostics = ConfigDiagnostics::default(); diff --git a/src/config/mod.rs b/src/config/mod.rs index e185b34..5fe8bca 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,9 +1,9 @@ pub mod configuration; pub use configuration::{ - ConfigDiagnostics, ConfigInventory, ConfigLoader, LoadedConfig, MergedConfig, - NotificationsConfig, ProviderTimeout, SoundEffectConfig, SoundsConfig, - TerminalNotificationCondition, TerminalNotificationMode, + ConfigDiagnostics, ConfigInventory, ConfigLoader, ImageOpenCommandConfig, ImageOpenWith, + ImagesConfig, LoadedConfig, MergedConfig, NotificationsConfig, ProviderTimeout, + SoundEffectConfig, SoundsConfig, TerminalNotificationCondition, TerminalNotificationMode, }; pub use configuration::discover_themes; diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index 29ef371..4bf8839 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -34,6 +34,13 @@ impl From for Message { } } + if msg.was_interrupted { + parts.push(MessagePart { + part_type: "status".to_string(), + data: serde_json::json!({ "state": "interrupted" }), + }); + } + Message { id: cuid2::create_id(), session_id: 0, @@ -107,6 +114,14 @@ impl TryFrom for SessionMessage { .find(|p| p.part_type == "compaction_stats") .and_then(|p| serde_json::from_value::(p.data.clone()).ok()); + let was_interrupted = msg.parts.iter().any(|p| { + p.part_type == "status" + && p.data + .get("state") + .and_then(|value| value.as_str()) + .is_some_and(|state| state == "interrupted") + }); + let role = match msg.role.as_str() { "user" => MessageRole::User, "assistant" => MessageRole::Assistant, @@ -148,6 +163,7 @@ impl TryFrom for SessionMessage { provider: msg.provider.clone(), local_image_paths, compaction_stats, + was_interrupted, }) } } @@ -193,4 +209,19 @@ mod tests { let restored = SessionMessage::try_from(persistence_message).unwrap(); assert_eq!(restored.compaction_stats, Some(stats)); } + + #[test] + fn interrupted_status_round_trips_through_message_parts() { + let mut session_message = SessionMessage::assistant("partial"); + session_message.mark_interrupted(); + + let persistence_message: Message = session_message.into(); + assert!(persistence_message.parts.iter().any(|part| { + part.part_type == "status" + && part.data.get("state").and_then(|value| value.as_str()) == Some("interrupted") + })); + + let restored = SessionMessage::try_from(persistence_message).unwrap(); + assert!(restored.was_interrupted); + } } diff --git a/src/session/types.rs b/src/session/types.rs index fedf986..238e796 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -86,6 +86,7 @@ pub struct Message { pub provider: Option, pub local_image_paths: Vec, pub compaction_stats: Option, + pub was_interrupted: bool, } impl Message { @@ -107,6 +108,7 @@ impl Message { provider: None, local_image_paths: Vec::new(), compaction_stats: None, + was_interrupted: false, } } @@ -144,6 +146,7 @@ impl Message { provider: None, local_image_paths: Vec::new(), compaction_stats: None, + was_interrupted: false, } } @@ -162,6 +165,10 @@ impl Message { pub fn mark_complete(&mut self) { self.is_complete = true; } + + pub fn mark_interrupted(&mut self) { + self.was_interrupted = true; + } } #[derive(Debug, Clone, PartialEq)] diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 49891c3..25317eb 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -69,6 +69,15 @@ pub struct Chat { cached_colors_hash: u64, cached_fingerprint: u64, tool_marker_animation_phase: bool, + hovered_image: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatImageTarget { + pub message_index: usize, + pub image_index: usize, + pub placeholder: String, + pub path: String, } // Minimum elapsed time before showing tokens/s (250ms) @@ -587,6 +596,7 @@ impl Chat { cached_colors_hash: 0, cached_fingerprint: 0, tool_marker_animation_phase: false, + hovered_image: None, } } @@ -627,6 +637,7 @@ impl Chat { cached_colors_hash: 0, cached_fingerprint: 0, tool_marker_animation_phase: false, + hovered_image: None, } } @@ -778,6 +789,7 @@ impl Chat { self.streaming_token_counter = None; self.selection.reset(); self.pending_click_anchor = None; + self.hovered_image = None; self.cached_lines.clear(); self.cached_positions.clear(); self.cached_revision = 0; @@ -823,6 +835,7 @@ impl Chat { msg.model.hash(&mut h); msg.provider.hash(&mut h); msg.compaction_stats.hash(&mut h); + msg.was_interrupted.hash(&mut h); } max_width.hash(&mut h); h.finish() @@ -1137,20 +1150,70 @@ impl Chat { self.highlighted_message_index = idx; } - pub fn clear_highlighted_message(&mut self) { - self.highlighted_message_index = None; + pub fn set_hovered_image(&mut self, target: Option) -> bool { + if self.hovered_image == target { + return false; + } + self.hovered_image = target; + self.cached_revision = 0; + true } - pub fn message_index_at_position(&self, event: MouseEvent, area: Rect) -> Option { + pub fn clear_hovered_image(&mut self) -> bool { + self.set_hovered_image(None) + } + + pub fn image_at_position(&self, event: MouseEvent, area: Rect) -> Option { use ratatui::layout::Position; let point = Position::new(event.column, event.row); - let content_area = Rect { + let content_area = Self::content_area_for(area); + + if !content_area.contains(point) || self.cached_lines.is_empty() { + return None; + } + + let content_line = + (event.row.saturating_sub(content_area.y) as usize).saturating_add(self.scroll_offset); + let content_col = event.column.saturating_sub(content_area.x) as usize; + let message_index = + self.message_index_at_content_line(content_line, self.content_height)?; + let line = self.cached_lines.get(content_line)?; + let placeholder = placeholder_at_line_col(line, content_col)?; + let image_index = image_index_from_placeholder(&placeholder)?; + let path = self + .messages + .get(message_index)? + .local_image_paths + .get(image_index)? + .clone(); + + Some(ChatImageTarget { + message_index, + image_index, + placeholder, + path, + }) + } + + pub fn clear_highlighted_message(&mut self) { + self.highlighted_message_index = None; + } + + fn content_area_for(area: Rect) -> Rect { + Rect { x: area.x, y: area.y, width: area.width.saturating_sub(2), height: area.height, - }; + } + } + + pub fn message_index_at_position(&self, event: MouseEvent, area: Rect) -> Option { + use ratatui::layout::Position; + + let point = Position::new(event.column, event.row); + let content_area = Self::content_area_for(area); if !content_area.contains(point) || self.message_line_positions.is_empty() { return None; @@ -1290,12 +1353,7 @@ impl Chat { } // Calculate the content area (exclude scrollbar column) - let content_area = Rect { - x: area.x, - y: area.y, - width: area.width.saturating_sub(2), - height: area.height, - }; + let content_area = Self::content_area_for(area); let rendered_content_area = Rect { x: content_area.x, y: content_area.y, @@ -1927,7 +1985,16 @@ impl Chat { let border_style = Style::default().fg(border_color); let pad_style = Style::default().bg(bg); let text_style = Style::default().fg(colors.text).bg(bg); - let image_style = Style::default().fg(colors.markdown_image).bg(bg); + let image_style = |placeholder: &str| { + let is_hovered = self.hovered_image.as_ref().is_some_and(|target| { + target.message_index == idx && target.placeholder == placeholder + }); + if is_hovered { + Style::default().fg(colors.markdown_image_text).bg(bg) + } else { + Style::default().fg(colors.markdown_image).bg(bg) + } + }; let content = message.content.clone(); let horizontal_padding = 2usize; let right_padding = 2usize; @@ -1949,7 +2016,7 @@ impl Chat { let styled_content = Line::from(spans_with_image_placeholders( content_line, text_style, - image_style, + &image_style, )); wrap_styled_line(&styled_content, WrapOptions::new(wrap_width)) }) @@ -2041,16 +2108,22 @@ impl Chat { } if !emitted_anything { + if message.is_complete && message.was_interrupted { + let metadata = self.format_metadata(message, model, colors); + lines.push(Line::from(metadata)); + lines.push(Line::from("")); + } return lines; } // Add empty line before metadata for spacing let next_role = self.messages.get(idx + 1).map(|m| m.role.clone()); let show_metadata = message.is_complete - && !matches!( - next_role, - Some(MessageRole::Tool) | Some(MessageRole::Assistant) - ); + && (message.was_interrupted + || !matches!( + next_role, + Some(MessageRole::Tool) | Some(MessageRole::Assistant) + )); if show_metadata { lines.push(Line::from("")); @@ -2886,6 +2959,15 @@ impl Chat { } } + if message.was_interrupted { + spans.push(Span::styled( + " • interrupted", + Style::default() + .fg(colors.warning) + .add_modifier(Modifier::BOLD), + )); + } + spans } @@ -3056,11 +3138,14 @@ fn line_uses_background(line: &Line<'_>, bg: Color) -> bool { line.spans.iter().any(|span| span.style.bg == Some(bg)) } -fn spans_with_image_placeholders( +fn spans_with_image_placeholders( text: &str, text_style: Style, - image_style: Style, -) -> Vec> { + image_style: &F, +) -> Vec> +where + F: Fn(&str) -> Style, +{ let mut spans = Vec::new(); let mut remaining = text; @@ -3081,7 +3166,10 @@ fn spans_with_image_placeholders( .chars() .all(|ch| ch.is_ascii_digit()) { - spans.push(Span::styled(placeholder.to_string(), image_style)); + spans.push(Span::styled( + placeholder.to_string(), + image_style(placeholder), + )); } else { spans.push(Span::styled(placeholder.to_string(), text_style)); } @@ -3096,6 +3184,47 @@ fn spans_with_image_placeholders( spans } +fn placeholder_at_line_col(line: &Line<'_>, target_col: usize) -> Option { + let mut col = 0usize; + for span in &line.spans { + let text = span.content.as_ref(); + let width = UnicodeWidthStr::width(text); + if target_col >= col && target_col < col.saturating_add(width) { + return image_placeholder_in_text_at_display_col(text, target_col - col); + } + col = col.saturating_add(width); + } + None +} + +fn image_placeholder_in_text_at_display_col(text: &str, target_col: usize) -> Option { + let mut search_from = 0usize; + while let Some(relative_start) = text[search_from..].find("[Image #") { + let start = search_from + relative_start; + let placeholder_start = &text[start..]; + let Some(end_offset) = placeholder_start.find(']') else { + return None; + }; + let end = start + end_offset + 1; + let placeholder = &text[start..end]; + if image_index_from_placeholder(placeholder).is_some() { + let start_col = UnicodeWidthStr::width(&text[..start]); + let end_col = start_col + UnicodeWidthStr::width(placeholder); + if target_col >= start_col && target_col < end_col { + return Some(placeholder.to_string()); + } + } + search_from = end; + } + None +} + +fn image_index_from_placeholder(placeholder: &str) -> Option { + let raw_number = placeholder.strip_prefix("[Image #")?.strip_suffix(']')?; + let one_based = raw_number.parse::().ok()?; + one_based.checked_sub(1) +} + fn line_to_static(line: Line<'_>) -> Line<'static> { Line { spans: line @@ -3642,6 +3771,51 @@ mod tests { .all(|span| span.style.bg == Some(colors.background_element))); } + #[test] + fn test_user_message_image_hit_test_finds_placeholder() { + let mut msg = Message::user("see [Image #1] please"); + msg.local_image_paths = vec!["/tmp/example.png".to_string()]; + let mut chat = Chat::with_messages(vec![msg]); + let colors = test_colors(); + let area = Rect::new(0, 0, 80, 10); + let content_width = area.width.saturating_sub(2) as usize; + let (lines, positions) = + chat.build_all_lines_with_positions(content_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = area.height as usize; + chat.scroll_offset = 0; + + let (line_idx, col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("[Image #1]").map(|col| (line_idx, col as u16)) + }) + .expect("image placeholder position"); + + let target = chat + .image_at_position( + mouse( + MouseEventKind::Moved, + col, + line_idx as u16, + KeyModifiers::empty(), + ), + area, + ) + .expect("image target"); + + assert_eq!(target.message_index, 0); + assert_eq!(target.image_index, 0); + assert_eq!(target.placeholder, "[Image #1]"); + assert_eq!(target.path, "/tmp/example.png"); + } + #[test] fn test_compaction_summary_renders_marker() { let mut msg = Message::user(format!( @@ -3941,6 +4115,60 @@ mod tests { assert!(lines.is_empty()); } + #[test] + fn interrupted_assistant_metadata_shows_status_label() { + let mut chat = Chat::new(); + let mut msg = Message::assistant("Partial answer."); + msg.t0_ms = Some(1_000); + msg.t1_ms = Some(1_200); + msg.tn_ms = Some(2_000); + msg.output_tokens = Some(40); + msg.mark_interrupted(); + chat.add_message(msg); + chat.add_message(Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "error", + "output_preview": "Streaming cancelled by user", + }) + .to_string(), + )); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + + assert!(lines + .iter() + .map(line_text) + .any(|line| line.contains("interrupted"))); + } + + #[test] + fn interrupted_empty_assistant_metadata_still_shows_status_label() { + let mut chat = Chat::new(); + let mut msg = Message::assistant(""); + msg.mark_interrupted(); + chat.add_message(msg); + chat.add_message(Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "error", + "output_preview": "Streaming cancelled by user", + }) + .to_string(), + )); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + + assert!(lines + .iter() + .map(line_text) + .any(|line| line.contains("interrupted"))); + } + #[test] fn test_streaming_pause_excluded_from_decode_duration() { use std::time::Duration; diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 3f477ab..8f62df7 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -1233,6 +1233,14 @@ impl Dialog { } } + pub fn contains_position(&self, column: u16, row: u16) -> bool { + if !self.visible { + return false; + } + use ratatui::layout::Position; + self.dialog_area.contains(Position::new(column, row)) + } + pub fn item_index_at_position(&self, column: u16, row: u16) -> Option { if !self.visible { return None; diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index b7d348b..103e5a7 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -59,6 +59,8 @@ pub struct Input { draft_text: Option, local_images: Vec, pending_pastes: Vec, + image_open_config: crate::config::ImagesConfig, + hovered_image_placeholder: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -100,6 +102,8 @@ impl Input { draft_text: None, local_images: Vec::new(), pending_pastes: Vec::new(), + image_open_config: crate::config::ImagesConfig::default(), + hovered_image_placeholder: None, } } @@ -108,6 +112,22 @@ impl Input { self } + pub fn set_image_open_config(&mut self, config: crate::config::ImagesConfig) { + self.image_open_config = config; + } + + pub fn contains_mouse(&self, mouse: MouseEvent) -> bool { + let Some(area) = self.textarea_area else { + return false; + }; + let point = ratatui::layout::Position::new(mouse.column, mouse.row); + area.contains(point) + } + + pub fn clear_hover(&mut self) { + self.hovered_image_placeholder = None; + } + pub fn render( &mut self, frame: &mut ratatui::Frame, @@ -392,10 +412,20 @@ impl Input { && mouse_y < textarea_area.y + textarea_area.height; if !within_textarea { + if matches!(mouse.kind, MouseEventKind::Moved) { + self.hovered_image_placeholder = None; + } return false; } match mouse.kind { + MouseEventKind::Moved => { + let previous_hover = self.hovered_image_placeholder.clone(); + self.hovered_image_placeholder = self + .image_at_mouse_position(textarea_area, mouse) + .map(|image| image.placeholder); + previous_hover != self.hovered_image_placeholder + } MouseEventKind::ScrollDown => { self.move_cursor_visual(1); true @@ -414,7 +444,7 @@ impl Input { { let offset = self.flat_offset_for_position(target_row, target_col); if let Some(image) = self.image_at_offset(offset) { - match image_attachment::open_path(&image.path) { + match image_attachment::open_path(&image.path, &self.image_open_config) { Ok(()) => push_toast(Toast::new( format!("Opened {}", image.placeholder), ToastLevel::Info, @@ -714,6 +744,7 @@ impl Input { .move_cursor(CursorMove::Jump(row as u16, col as u16)); self.viewport_top = 0; self.preferred_visual_col = None; + self.hovered_image_placeholder = None; } fn image_placeholder(number: usize) -> String { @@ -1039,7 +1070,6 @@ impl Input { return; } - let placeholder_style = Style::default().fg(colors.markdown_image); let lines = self.textarea.lines(); for (screen_row, visual_line) in visual_lines @@ -1054,6 +1084,13 @@ impl Input { let y = area.y + screen_row as u16; for image in &self.local_images { + let placeholder_style = if self.hovered_image_placeholder.as_deref() + == Some(image.placeholder.as_str()) + { + Style::default().fg(colors.markdown_image_text) + } else { + Style::default().fg(colors.markdown_image) + }; for (start, _) in line.match_indices(&image.placeholder) { Self::style_line_byte_range( buffer, @@ -1068,6 +1105,7 @@ impl Input { } for paste in &self.pending_pastes { + let placeholder_style = Style::default().fg(colors.markdown_image); for (start, _) in line.match_indices(&paste.placeholder) { Self::style_line_byte_range( buffer, @@ -1118,7 +1156,11 @@ impl Input { } let x = area.x + x_offset as u16; if let Some(cell) = buffer.cell_mut((x, y)) { - cell.set_style(style); + if let Some(fg) = style.fg { + cell.set_fg(fg); + } else { + cell.set_style(style); + } } x_offset += UnicodeWidthChar::width(ch).unwrap_or(0); } @@ -1304,6 +1346,19 @@ impl Input { }) } + fn image_at_mouse_position( + &self, + textarea_area: Rect, + mouse: MouseEvent, + ) -> Option { + let relative_x = mouse.column.saturating_sub(textarea_area.x); + let relative_y = mouse.row.saturating_sub(textarea_area.y); + let (target_row, target_col) = + self.cursor_for_screen_position(textarea_area, relative_x, relative_y)?; + let offset = self.flat_offset_for_position(target_row, target_col); + self.image_at_offset(offset) + } + pub fn attach_image(&mut self, path: PathBuf) { let placeholder = Self::image_placeholder(self.local_images.len() + 1); self.preferred_visual_col = None; @@ -1353,6 +1408,15 @@ impl Input { } self.local_images = kept; + if let Some(hovered) = self.hovered_image_placeholder.as_deref() { + if !self + .local_images + .iter() + .any(|image| image.placeholder == hovered) + { + self.hovered_image_placeholder = None; + } + } self.set_text_preserving_images(&text, cursor); } @@ -1436,6 +1500,7 @@ impl Input { self.draft_text = None; self.local_images.clear(); self.pending_pastes.clear(); + self.hovered_image_placeholder = None; if let Some(ref mut history) = self.prompt_history { history.reset_navigation(); } @@ -1465,6 +1530,7 @@ impl Input { self.preferred_visual_col = None; self.local_images.clear(); self.pending_pastes.clear(); + self.hovered_image_placeholder = None; } pub fn insert_char(&mut self, c: char) { @@ -1604,6 +1670,15 @@ mod tests { } } + fn mouse_event_at(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::empty(), + } + } + fn buffer_row_text(buffer: &ratatui::buffer::Buffer, width: u16, y: u16) -> String { (0..width) .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string())) @@ -1924,4 +1999,59 @@ mod tests { Some(colors.markdown_image) ); } + + #[test] + fn test_hovered_image_placeholder_changes_foreground_only() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + input.attach_image(PathBuf::from("/tmp/example.png")); + + let colors = test_colors(); + let backend = TestBackend::new(40, 6); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 40, 6), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let image_pos = find_buffer_text(buffer, 40, 6, "[Image #1]").expect("image placeholder"); + let before_style = buffer.cell(image_pos).expect("image cell").style(); + assert_eq!(before_style.fg, Some(colors.markdown_image)); + + assert!(input.handle_mouse_event(mouse_event_at( + MouseEventKind::Moved, + image_pos.0, + image_pos.1 + ))); + + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 40, 6), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let after_style = buffer.cell(image_pos).expect("image cell").style(); + assert_eq!(after_style.fg, Some(colors.markdown_image_text)); + assert_eq!(after_style.bg, before_style.bg); + } } diff --git a/src/utils/image_attachment.rs b/src/utils/image_attachment.rs index 4683fd8..6b10ca4 100644 --- a/src/utils/image_attachment.rs +++ b/src/utils/image_attachment.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose, Engine as _}; use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; +use std::process::Command; const SUPPORTED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; @@ -128,14 +129,226 @@ pub fn paste_image_to_temp_png() -> Result { Ok(path) } -pub fn open_path(path: &Path) -> Result<()> { +pub fn open_path(path: &Path, config: &crate::config::ImagesConfig) -> Result<()> { if !path.exists() { return Err(anyhow!("image no longer exists: {}", path.display())); } + match &config.open_with { + crate::config::ImageOpenWith::Auto => open_auto(path), + crate::config::ImageOpenWith::System => open_system(path), + crate::config::ImageOpenWith::Editor => open_editor(path).or_else(|_| open_system(path)), + crate::config::ImageOpenWith::Command(command) => open_custom_command(path, command), + } +} + +fn open_auto(path: &Path) -> Result<()> { + if let Some(command) = detected_editor_command() { + if spawn_command(&command, &[path.to_string_lossy().into_owned()]).is_ok() { + return Ok(()); + } + } + + open_system(path) +} + +fn open_editor(path: &Path) -> Result<()> { + if let Some(command) = detected_editor_command() { + return spawn_command(&command, &[path.to_string_lossy().into_owned()]); + } + + for var in ["VISUAL", "EDITOR"] { + if let Ok(value) = std::env::var(var) { + if !value.trim().is_empty() { + return spawn_shell_command(&value, path); + } + } + } + + Err(anyhow!("no editor command detected")) +} + +fn detected_editor_command() -> Option { + if is_zed_terminal() { + return Some("zed".to_string()); + } + + if has_cursor_env() { + return Some("cursor".to_string()); + } + + if let Some(app) = std::env::var_os("TERM_PROGRAM") + .and_then(|value| value.into_string().ok()) + .map(|value| value.to_ascii_lowercase()) + { + if app.contains("cursor") { + return Some("cursor".to_string()); + } + } + + if let Some(command) = detected_editor_from_process_tree() { + return Some(command); + } + + if let Some(app) = std::env::var_os("TERM_PROGRAM") + .and_then(|value| value.into_string().ok()) + .map(|value| value.to_ascii_lowercase()) + { + if app.contains("vscode") || app == "code" { + return Some("code".to_string()); + } + } + + if std::env::var_os("VSCODE_IPC_HOOK_CLI").is_some() + || std::env::var_os("VSCODE_INJECTION").is_some() + || std::env::var_os("VSCODE_CWD").is_some() + { + return Some("code".to_string()); + } + + None +} + +fn has_cursor_env() -> bool { + std::env::var_os("CURSOR_TRACE_ID").is_some() + || std::env::var_os("CURSOR_AGENT").is_some() + || std::env::var_os("CURSOR_CLI").is_some() +} + +fn editor_command_from_process_name(name: &str) -> Option<&'static str> { + let normalized = name.to_ascii_lowercase(); + if normalized.contains("cursor") { + Some("cursor") + } else if normalized.contains("zed") { + Some("zed") + } else if normalized.contains("visual studio code") + || normalized.contains("vscode") + || normalized.contains("code helper") + || normalized.ends_with("/code") + || normalized == "code" + { + Some("code") + } else { + None + } +} + +#[cfg(unix)] +fn detected_editor_from_process_tree() -> Option { + let mut pid = std::process::id(); + for _ in 0..32 { + let parent = parent_pid(pid)?; + if parent == 0 || parent == pid { + return None; + } + + if let Some(command) = process_command(parent).and_then(|name| { + editor_command_from_process_name(&name).map(std::string::ToString::to_string) + }) { + return Some(command); + } + + pid = parent; + } + None +} + +#[cfg(not(unix))] +fn detected_editor_from_process_tree() -> Option { + None +} + +#[cfg(unix)] +fn parent_pid(pid: u32) -> Option { + let output = Command::new("ps") + .args(["-o", "ppid=", "-p", &pid.to_string()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .ok() +} + +#[cfg(unix)] +fn process_command(pid: u32) -> Option { + let output = Command::new("ps") + .args(["-o", "comm=", "-p", &pid.to_string()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let command = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!command.is_empty()).then_some(command) +} + +fn is_zed_terminal() -> bool { + env_eq("ZED_TERM", "true") + || std::env::var("TERM_PROGRAM") + .map(|value| value.eq_ignore_ascii_case("zed")) + .unwrap_or(false) +} + +fn env_eq(key: &str, expected: &str) -> bool { + std::env::var(key) + .map(|value| value.eq_ignore_ascii_case(expected)) + .unwrap_or(false) +} + +fn open_custom_command(path: &Path, command: &crate::config::ImageOpenCommandConfig) -> Result<()> { + let path_arg = path.to_string_lossy(); + let mut args = command + .args + .iter() + .map(|arg| arg.replace("{path}", &path_arg)) + .collect::>(); + if args.is_empty() { + args.push(path_arg.into_owned()); + } + + spawn_command(&command.command, &args) +} + +fn spawn_command(command: &str, args: &[String]) -> Result<()> { + Command::new(command) + .args(args) + .spawn() + .with_context(|| format!("failed to run image opener command `{}`", command))?; + Ok(()) +} + +fn spawn_shell_command(command: &str, path: &Path) -> Result<()> { + let path_text = path.to_string_lossy(); + let quoted_path = shlex::try_quote(&path_text) + .map_err(|err| anyhow!("failed to quote image path {}: {}", path.display(), err))?; + let shell_command = format!("{} {}", command, quoted_path); + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", &shell_command]) + .spawn() + .with_context(|| format!("failed to run image opener command `{}`", command))?; + Ok(()) + } + + #[cfg(not(target_os = "windows"))] + { + Command::new("sh") + .args(["-c", &shell_command]) + .spawn() + .with_context(|| format!("failed to run image opener command `{}`", command))?; + Ok(()) + } +} + +fn open_system(path: &Path) -> Result<()> { #[cfg(target_os = "macos")] { - std::process::Command::new("open") + Command::new("open") .arg(path) .spawn() .with_context(|| format!("failed to open {}", path.display()))?; @@ -144,7 +357,7 @@ pub fn open_path(path: &Path) -> Result<()> { #[cfg(target_os = "windows")] { - std::process::Command::new("cmd") + Command::new("cmd") .args(["/C", "start", ""]) .arg(path) .spawn() @@ -154,7 +367,7 @@ pub fn open_path(path: &Path) -> Result<()> { #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] { - std::process::Command::new("xdg-open") + Command::new("xdg-open") .arg(path) .spawn() .with_context(|| format!("failed to open {}", path.display()))?; From f168dc87485f0e7b14ca202ebf52480ebd7d9ba5 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 25 May 2026 23:45:25 +0800 Subject: [PATCH 144/226] feat: add syntax-highlighted diffs via syntect Introduce a new `syntax` module using `syntect` that highlights code based on file extension. Update the unified diff renderer to accept optional syntax-highlighted spans per line, and wire it into the chat diff display by passing the file path. --- Cargo.lock | 1 + Cargo.toml | 1 + src/ui/components/chat.rs | 6 +- src/ui/diff.rs | 175 ++++++++++++++++++++++++++++++++++++-- src/ui/mod.rs | 1 + src/ui/syntax.rs | 143 +++++++++++++++++++++++++++++++ 6 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 src/ui/syntax.rs diff --git a/Cargo.lock b/Cargo.lock index fee43f6..72cd95f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,7 @@ dependencies = [ "sha2", "shlex", "strsim", + "syntect", "tempfile", "textwrap", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index 81c5064..f3cecf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", tempfile = "3.13" url = "2.5" shlex = "1.3" +syntect = "5.3" [dev-dependencies] tokio-test = "0.4" diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 25317eb..261e977 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -2783,7 +2783,7 @@ impl Chat { Span::raw(" "), Span::styled(verb.to_string(), title_style), Span::raw(" "), - Span::styled(file_path, target_style), + Span::styled(file_path.clone(), target_style), Span::raw(" ("), Span::styled(format!("+{}", stats.added), add_style), Span::raw(" "), @@ -2800,8 +2800,8 @@ impl Chat { .unwrap_or(1); if !old_str.is_empty() || !new_str.is_empty() { - let diff_lines = crate::ui::diff::format_edit_diff_with_start( - old_str, new_str, start_line, max_width, colors, " ", + let diff_lines = crate::ui::diff::format_edit_diff_for_path_with_start( + old_str, new_str, start_line, max_width, colors, " ", &file_path, ); out.extend(diff_lines); } diff --git a/src/ui/diff.rs b/src/ui/diff.rs index ef5b0d8..ce30b05 100644 --- a/src/ui/diff.rs +++ b/src/ui/diff.rs @@ -1,10 +1,11 @@ use crate::theme::ThemeColors; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use unicode_width::UnicodeWidthStr; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; const MAX_DIFF_LINES: usize = 40; const CONTEXT_LINES: usize = 3; +const TAB_WIDTH: usize = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffLineType { @@ -159,6 +160,18 @@ pub fn render_unified_diff_with_indent( max_width: usize, colors: &ThemeColors, indent: &str, +) -> Vec> { + render_unified_diff_with_indent_and_syntax(diff_lines, max_width, colors, indent, None, None, 1) +} + +fn render_unified_diff_with_indent_and_syntax( + diff_lines: &[DiffLine], + max_width: usize, + colors: &ThemeColors, + indent: &str, + old_syntax_lines: Option<&[Vec>]>, + new_syntax_lines: Option<&[Vec>]>, + start_line: usize, ) -> Vec> { let max_line_number = diff_lines .iter() @@ -221,9 +234,31 @@ pub fn render_unified_diff_with_indent( continue; } - // Wrap content if needed - let wrapped = textwrap::wrap(&diff_line.text, content_width); - for (chunk_idx, chunk) in wrapped.iter().enumerate() { + let syntax_spans = + syntax_spans_for_diff_line(diff_line, start_line, old_syntax_lines, new_syntax_lines); + let wrapped_syntax_spans = syntax_spans.map(|spans| { + let styled = spans + .iter() + .map(|span| { + let mut style = content_style.patch(span.style); + if matches!(diff_line.line_type, DiffLineType::Remove) { + style = style.add_modifier(Modifier::DIM); + } + Span::styled(span.content.clone().into_owned(), style) + }) + .collect::>(); + wrap_styled_spans(&styled, content_width) + }); + let wrapped_plain = wrapped_syntax_spans + .is_none() + .then(|| textwrap::wrap(&diff_line.text, content_width)); + let chunk_count = wrapped_syntax_spans + .as_ref() + .map(|chunks| chunks.len()) + .or_else(|| wrapped_plain.as_ref().map(|chunks| chunks.len())) + .unwrap_or(0); + + for chunk_idx in 0..chunk_count { let number_text = if chunk_idx == 0 { diff_line .line_number @@ -241,8 +276,12 @@ pub fn render_unified_diff_with_indent( Span::styled(indent.to_string(), indent_style), Span::styled(number_text, gutter_style), Span::styled(sign_text, sign_style), - Span::styled(chunk.to_string(), content_style), ]; + if let Some(chunks) = wrapped_syntax_spans.as_ref() { + spans.extend(chunks[chunk_idx].clone()); + } else if let Some(chunks) = wrapped_plain.as_ref() { + spans.push(Span::styled(chunks[chunk_idx].to_string(), content_style)); + } // Pad to full width so the background spans the entire row let visible_width: usize = spans .iter() @@ -261,6 +300,78 @@ pub fn render_unified_diff_with_indent( lines } +fn syntax_spans_for_diff_line<'a>( + diff_line: &DiffLine, + start_line: usize, + old_syntax_lines: Option<&'a [Vec>]>, + new_syntax_lines: Option<&'a [Vec>]>, +) -> Option<&'a [Span<'static>]> { + let line_number = diff_line.line_number?; + let index = line_number.checked_sub(start_line)?; + match diff_line.line_type { + DiffLineType::Remove => old_syntax_lines, + DiffLineType::Add | DiffLineType::Context => new_syntax_lines, + } + .and_then(|lines| lines.get(index)) + .map(Vec::as_slice) +} + +fn wrap_styled_spans(spans: &[Span<'static>], max_cols: usize) -> Vec>> { + let mut result: Vec>> = Vec::new(); + let mut current_line: Vec> = Vec::new(); + let mut col: usize = 0; + + for span in spans { + let style = span.style; + let mut remaining = span.content.as_ref(); + + while !remaining.is_empty() { + let mut byte_end = 0; + let mut chars_col = 0; + + for ch in remaining.chars() { + let width = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 0 }); + if col + chars_col + width > max_cols { + break; + } + byte_end += ch.len_utf8(); + chars_col += width; + } + + if byte_end == 0 { + if !current_line.is_empty() { + result.push(std::mem::take(&mut current_line)); + col = 0; + } + let Some(ch) = remaining.chars().next() else { + break; + }; + let ch_len = ch.len_utf8(); + current_line.push(Span::styled(remaining[..ch_len].to_string(), style)); + col = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 1 }); + remaining = &remaining[ch_len..]; + continue; + } + + let (chunk, rest) = remaining.split_at(byte_end); + current_line.push(Span::styled(chunk.to_string(), style)); + col += chars_col; + remaining = rest; + + if col >= max_cols { + result.push(std::mem::take(&mut current_line)); + col = 0; + } + } + } + + if !current_line.is_empty() || result.is_empty() { + result.push(current_line); + } + + result +} + /// Convenience: compute and render a unified diff in one call. pub fn format_edit_diff( old_string: &str, @@ -284,11 +395,42 @@ pub fn format_edit_diff_with_start( render_unified_diff_with_indent(&diff_lines, max_width, colors, indent) } +pub fn format_edit_diff_for_path_with_start( + old_string: &str, + new_string: &str, + start_line: usize, + max_width: usize, + colors: &ThemeColors, + indent: &str, + path: &str, +) -> Vec> { + let diff_lines = + compute_unified_diff_with_start(old_string, new_string, start_line, start_line); + let old_syntax_lines = crate::ui::syntax::highlight_code_for_path(old_string, path, colors); + let new_syntax_lines = crate::ui::syntax::highlight_code_for_path(new_string, path, colors); + render_unified_diff_with_indent_and_syntax( + &diff_lines, + max_width, + colors, + indent, + old_syntax_lines.as_deref(), + new_syntax_lines.as_deref(), + start_line, + ) +} + #[cfg(test)] mod tests { use super::*; use ratatui::style::Color; + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + fn test_colors() -> ThemeColors { ThemeColors { primary: Color::Reset, @@ -401,4 +543,27 @@ mod tests { // With width 3, returns empty assert!(lines.is_empty()); } + + #[test] + fn test_render_unified_diff_highlights_known_file_extension() { + let colors = test_colors(); + let old = "fn value() -> u8 {\n 1\n}"; + let new = "fn value() -> u8 {\n 2\n}"; + + let lines = + format_edit_diff_for_path_with_start(old, new, 1, 80, &colors, "", "src/lib.rs"); + + let context_line = lines + .iter() + .find(|line| line_text(line).contains("fn value")) + .expect("expected context line"); + assert!( + context_line.spans.iter().any(|span| { + span.content.as_ref().contains("fn") + && span.style.fg.is_some() + && span.style.fg != Some(colors.text_weak) + }), + "expected syntax-colored Rust keyword span" + ); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5ea6228..234d4df 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,4 +4,5 @@ pub mod layout; pub mod markdown; pub mod scrollbar; pub mod selection; +pub mod syntax; pub mod wrapping; diff --git a/src/ui/syntax.rs b/src/ui/syntax.rs new file mode 100644 index 0000000..1fd86f3 --- /dev/null +++ b/src/ui/syntax.rs @@ -0,0 +1,143 @@ +use crate::theme::ThemeColors; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; +use std::path::Path; +use std::sync::OnceLock; +use syntect::easy::HighlightLines; +use syntect::highlighting::{ + Color as SyntectColor, FontStyle, Style as SyntectStyle, Theme, ThemeSet, +}; +use syntect::parsing::{SyntaxReference, SyntaxSet}; +use syntect::util::LinesWithEndings; + +const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; +const MAX_HIGHLIGHT_LINES: usize = 10_000; + +static SYNTAX_SET: OnceLock = OnceLock::new(); +static THEME_SET: OnceLock = OnceLock::new(); + +fn syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines) +} + +fn theme_set() -> &'static ThemeSet { + THEME_SET.get_or_init(ThemeSet::load_defaults) +} + +pub fn highlight_code_for_path( + code: &str, + path: &str, + colors: &ThemeColors, +) -> Option>>> { + let lang = detect_lang_for_path(path)?; + highlight_code(code, &lang, colors) +} + +fn highlight_code(code: &str, lang: &str, colors: &ThemeColors) -> Option>>> { + if code.is_empty() + || code.len() > MAX_HIGHLIGHT_BYTES + || code.lines().count() > MAX_HIGHLIGHT_LINES + { + return None; + } + + let syntax = find_syntax(lang)?; + let theme = theme_for_colors(colors)?; + let mut highlighter = HighlightLines::new(syntax, theme); + let mut lines = Vec::new(); + + for line in LinesWithEndings::from(code) { + let ranges = highlighter.highlight_line(line, syntax_set()).ok()?; + let mut spans = Vec::new(); + for (style, text) in ranges { + let text = text.trim_end_matches(['\n', '\r']); + if text.is_empty() { + continue; + } + spans.push(Span::styled(text.to_string(), convert_style(style))); + } + if spans.is_empty() { + spans.push(Span::raw("")); + } + lines.push(spans); + } + + Some(lines) +} + +fn detect_lang_for_path(path: &str) -> Option { + let path = Path::new(path); + if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) { + return Some(ext.to_string()); + } + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_ascii_lowercase()) +} + +fn find_syntax(lang: &str) -> Option<&'static SyntaxReference> { + let syntaxes = syntax_set(); + let lower = lang.to_ascii_lowercase(); + let normalized = match lower.as_str() { + "csharp" | "c-sharp" => "cs", + "golang" => "go", + "python3" => "python", + "shell" | "sh" => "bash", + _ => lower.as_str(), + }; + + syntaxes + .find_syntax_by_token(normalized) + .or_else(|| syntaxes.find_syntax_by_extension(normalized)) + .or_else(|| syntaxes.find_syntax_by_name(normalized)) + .or_else(|| { + syntaxes + .syntaxes() + .iter() + .find(|syntax| syntax.name.eq_ignore_ascii_case(normalized)) + }) +} + +fn theme_for_colors(colors: &ThemeColors) -> Option<&'static Theme> { + let themes = &theme_set().themes; + let theme_name = if is_light(colors.background) { + "InspiredGitHub" + } else { + "base16-ocean.dark" + }; + + themes + .get(theme_name) + .or_else(|| themes.get("base16-ocean.dark")) + .or_else(|| themes.values().next()) +} + +fn is_light(color: Color) -> bool { + let Color::Rgb(r, g, b) = color else { + return false; + }; + let luminance = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; + luminance > 160.0 +} + +fn convert_style(syn_style: SyntectStyle) -> Style { + let mut style = Style::default(); + + if let Some(fg) = convert_color(syn_style.foreground) { + style = style.fg(fg); + } + + if syn_style.font_style.contains(FontStyle::BOLD) { + style = style.add_modifier(Modifier::BOLD); + } + + style +} + +fn convert_color(color: SyntectColor) -> Option { + match color.a { + 0x00 => Some(Color::Indexed(color.r)), + 0x01 => None, + _ => Some(Color::Rgb(color.r, color.g, color.b)), + } +} From 63656d8ef0aef69d25eb6adc9fe584ee76e4f83e Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 00:27:10 +0800 Subject: [PATCH 145/226] feat: add storage dialog, refactor permissions, improve syntax highlighting. - Add Storage dialog showing disk usage for pasted images, data.db, and models.dev cache - Add command palette entry to open storage dialog - Refactor permission system: prompt for read/search on sensitive/external paths, remove dangerous command checks, add doom loop detection for repeated tool calls - Replace default syntect with `two-face` for syntax highlighting in diffs - Blend diff gutter background for softer appearance while preserving syntax colors - Update permission dialog to show bash command and workdir details --- Cargo.lock | 12 + Cargo.toml | 1 + _plans/__TODOS.md | 2 +- src/app.rs | 146 ++++++++- src/tools/bash.rs | 28 +- src/tools/permission.rs | 281 ++++++++++++++--- src/ui/diff.rs | 122 ++++++- src/ui/syntax.rs | 2 +- src/utils/mod.rs | 1 + src/utils/storage.rs | 200 ++++++++++++ src/views/command_palette.rs | 18 ++ src/views/mod.rs | 2 + src/views/permission_dialog.rs | 155 ++++++--- src/views/storage_dialog.rs | 558 +++++++++++++++++++++++++++++++++ 14 files changed, 1398 insertions(+), 130 deletions(-) create mode 100644 src/utils/storage.rs create mode 100644 src/views/storage_dialog.rs diff --git a/Cargo.lock b/Cargo.lock index 72cd95f..f17f82e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -522,6 +522,7 @@ dependencies = [ "tokio-util", "tui-markdown", "tui-textarea", + "two-face", "unicode-width 0.1.14", "url", ] @@ -3608,6 +3609,17 @@ dependencies = [ "utf-8", ] +[[package]] +name = "two-face" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b285c51f8a6ade109ed4566d33ac4fb289fb5d6cf87ed70908a5eaf65e948e34" +dependencies = [ + "serde", + "serde_derive", + "syntect", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index f3cecf4..09cc86f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ tempfile = "3.13" url = "2.5" shlex = "1.3" syntect = "5.3" +two-face = { version = "0.5", default-features = false, features = ["syntect-default-onig"] } [dev-dependencies] tokio-test = "0.4" diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index c8780fe..51419b7 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -116,7 +116,7 @@ - [x] When a message is sent, the [Image #1] or [Image #2] tags, become just white, not the unique color we have for them in the chat input box. -- [ ] Syntax highlighting during "Edited" tool calls for diffs. Check how Codex does it, because it has syntax highlighting for some reason--It's very clean. +- [x] Syntax highlighting during "Edited" tool calls for diffs. Check how Codex does it, because it has syntax highlighting for some reason--It's very clean. - [ ] I also think the /copy transcript should show "Edit" tool call results no? Right now it looks as simple as: **Tool Result** diff --git a/src/app.rs b/src/app.rs index 0d57a9b..5311474 100644 --- a/src/app.rs +++ b/src/app.rs @@ -52,6 +52,10 @@ use crate::views::sessions_dialog::{ handle_sessions_dialog_key_event, handle_sessions_dialog_mouse_event, init_sessions_dialog, render_sessions_dialog, SessionsDialogAction, SessionsDialogFilter, }; +use crate::views::storage_dialog::{ + handle_storage_dialog_key_event, handle_storage_dialog_mouse_event, init_storage_dialog, + render_storage_dialog, StorageDialogAction, +}; use crate::views::suggestions_popup::{ clear_suggestions, get_selected_suggestion, handle_suggestions_popup_key_event, handle_suggestions_popup_mouse_event, init_suggestions_popup, is_suggestions_visible, @@ -64,7 +68,7 @@ use crate::views::themes_dialog::{ use crate::views::{ ChatState, ConnectDialogState, HomeState, ModelsDialogState, OpenAIOAuthFlowState, PermissionDialogState, QuestionDialogState, SessionRenameDialogState, SessionsDialogState, - SuggestionsPopupState, ThemesDialogState, + StorageDialogState, SuggestionsPopupState, ThemesDialogState, }; use crate::{ @@ -109,6 +113,7 @@ pub enum OverlayFocus { TimelineDialog, MessageActions, CommandPalette, + StorageDialog, WhichKey, } @@ -138,6 +143,11 @@ enum CompactionTaskMessage { }, } +#[derive(Debug)] +enum StorageTaskMessage { + Loaded(crate::utils::storage::StorageReport), +} + #[derive(Debug, Clone)] struct CompactionPending { session_id: String, @@ -212,6 +222,7 @@ pub struct App { pub question_dialog_state: QuestionDialogState, pub skills_dialog_state: crate::views::SkillsDialogState, pub command_palette_state: crate::views::command_palette::CommandPaletteState, + pub storage_dialog_state: StorageDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, pub timeline_dialog_state: crate::views::timeline_dialog::TimelineDialogState, pub message_actions_index: Option, @@ -223,6 +234,7 @@ pub struct App { openai_oauth_in_progress: bool, compaction_receiver: Option>, compaction_pending: Option, + storage_receiver: Option>, pub prefs_dao: Option, pub agent: String, pub agent_steps: std::collections::HashMap, @@ -289,6 +301,7 @@ impl App { let which_key_state = crate::views::which_key::init_which_key(); let timeline_dialog_state = crate::views::timeline_dialog::init_timeline_dialog(); let command_palette_state = init_command_palette(); + let storage_dialog_state = init_storage_dialog(); let api_key_input = crate::ui::components::api_key_input::ApiKeyInput::new(); let session_manager = SessionManager::new() @@ -428,6 +441,7 @@ impl App { question_dialog_state, skills_dialog_state, command_palette_state, + storage_dialog_state, which_key_state, timeline_dialog_state, message_actions_index: None, @@ -439,6 +453,7 @@ impl App { openai_oauth_in_progress: false, compaction_receiver: None, compaction_pending: None, + storage_receiver: None, prefs_dao, agent, agent_steps, @@ -1816,6 +1831,16 @@ impl App { } true } + OverlayFocus::StorageDialog => { + let action = handle_storage_dialog_key_event(&mut self.storage_dialog_state, key); + self.handle_storage_dialog_action(action); + if !self.storage_dialog_state.is_visible() + && self.overlay_focus == OverlayFocus::StorageDialog + { + self.overlay_focus = OverlayFocus::None; + } + true + } OverlayFocus::WhichKey => { let action = self.which_key_state.handle_key_event(key); match action { @@ -2460,6 +2485,14 @@ impl App { { self.overlay_focus = OverlayFocus::None; } + } else if self.overlay_focus == OverlayFocus::StorageDialog { + let action = handle_storage_dialog_mouse_event(&mut self.storage_dialog_state, mouse); + self.handle_storage_dialog_action(action); + if !self.storage_dialog_state.is_visible() + && self.overlay_focus == OverlayFocus::StorageDialog + { + self.overlay_focus = OverlayFocus::None; + } } else if self.overlay_focus == OverlayFocus::SuggestionsPopup { let anchor_area = self.suggestions_popup_anchor_area(); let action = handle_suggestions_popup_mouse_event( @@ -2861,6 +2894,70 @@ impl App { self.overlay_focus = OverlayFocus::CommandPalette; } + fn open_storage_dialog(&mut self) { + clear_suggestions(&mut self.suggestions_popup_state); + self.storage_dialog_state.show(); + self.overlay_focus = OverlayFocus::StorageDialog; + + if !self.storage_dialog_state.has_report() && !self.storage_dialog_state.is_checking() { + self.start_storage_refresh(); + } + } + + fn start_storage_refresh(&mut self) { + if self.storage_receiver.is_some() { + return; + } + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); + self.storage_receiver = Some(receiver); + self.storage_dialog_state.start_checking(); + + tokio::task::spawn_blocking(move || { + let report = crate::utils::storage::collect_storage_report(); + let _ = sender.send(StorageTaskMessage::Loaded(report)); + }); + } + + fn handle_storage_dialog_action(&mut self, action: StorageDialogAction) { + match action { + StorageDialogAction::None => {} + StorageDialogAction::Close => { + self.overlay_focus = OverlayFocus::None; + } + StorageDialogAction::Refresh => { + self.start_storage_refresh(); + } + StorageDialogAction::Open(category) => { + self.open_storage_category(category); + } + } + } + + fn open_storage_category(&mut self, category: crate::utils::storage::StorageCategory) { + let Some(path) = self.storage_dialog_state.open_path_for(category) else { + push_toast(Toast::new( + "Storage location is not available yet", + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + return; + }; + + match crate::utils::storage::open_folder(&path) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", path.display()), + ToastLevel::Info, + Some(std::time::Duration::from_secs(2)), + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open storage folder: {}", err), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )), + } + } + fn handle_command_palette_action(&mut self, action: CommandPaletteAction) { match action { CommandPaletteAction::RunCommand(command) => { @@ -2882,6 +2979,7 @@ impl App { CommandPaletteAppAction::CycleReasoningEffort => { let _ = self.cycle_active_reasoning_effort(); } + CommandPaletteAppAction::OpenStorage => self.open_storage_dialog(), } self.clear_suggestions_and_blur(); } @@ -4512,6 +4610,42 @@ impl App { self.sync_active_streaming_flag(); } + fn process_storage_events(&mut self) { + let mut events = Vec::new(); + let mut disconnected = false; + + if let Some(receiver) = &mut self.storage_receiver { + loop { + match receiver.try_recv() { + Ok(event) => events.push(event), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + if disconnected || !events.is_empty() { + self.storage_receiver = None; + } + + if disconnected && events.is_empty() { + self.storage_dialog_state + .set_error("storage check ended before returning results"); + return; + } + + for event in events { + match event { + StorageTaskMessage::Loaded(report) => { + self.storage_dialog_state.set_report(report); + } + } + } + } + fn cleanup_streaming(&mut self) { if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { self.cleanup_streaming_for_session(&session_id); @@ -4574,6 +4708,7 @@ impl App { || self.is_streaming || self.chat_state.chat.has_active_tool_messages() || self.compaction_receiver.is_some() + || self.storage_receiver.is_some() || self .session_view_states .values() @@ -4585,6 +4720,7 @@ impl App { pub fn process_streaming_chunks(&mut self) { self.process_openai_oauth_events(); self.process_compaction_events(); + self.process_storage_events(); let streaming_ids: Vec = self .session_view_states @@ -5575,6 +5711,12 @@ impl App { render_command_palette(f, &mut self.command_palette_state, size, colors); } + if self.overlay_focus == OverlayFocus::StorageDialog + && self.storage_dialog_state.is_visible() + { + render_storage_dialog(f, &mut self.storage_dialog_state, size, colors); + } + if self.overlay_focus == OverlayFocus::WhichKey { crate::views::which_key::render_which_key(f, &self.which_key_state, &colors); } @@ -5665,6 +5807,7 @@ mod tests { question_dialog_state: init_question_dialog(), skills_dialog_state: crate::views::skills_dialog::init_skills_dialog("Skills", vec![]), command_palette_state: init_command_palette(), + storage_dialog_state: init_storage_dialog(), which_key_state: crate::views::which_key::init_which_key(), timeline_dialog_state: crate::views::timeline_dialog::init_timeline_dialog(), message_actions_index: None, @@ -5676,6 +5819,7 @@ mod tests { openai_oauth_in_progress: false, compaction_receiver: None, compaction_pending: None, + storage_receiver: None, prefs_dao: None, agent: "Build".to_string(), agent_steps: std::collections::HashMap::new(), diff --git a/src/tools/bash.rs b/src/tools/bash.rs index fa305ad..9461c06 100644 --- a/src/tools/bash.rs +++ b/src/tools/bash.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_bool_param, get_integer_param, get_string_param, validate_required, ParameterSchema, - ParameterType, Tool, ToolContext, ToolError, ToolHandler, ToolResult, + get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -19,26 +19,6 @@ impl BashTool { pub fn new() -> Self { Self } - - fn is_dangerous(command: &str) -> Option { - let dangerous_patterns = [ - "rm -rf /", - "rm -rf /*", - ":(){ :|: & };:", - "> /dev/sda", - "mkfs", - "dd if=/dev/zero", - "chmod -R 777 /", - ]; - - for pattern in &dangerous_patterns { - if command.contains(pattern) { - return Some(format!("Command contains dangerous pattern: {}", pattern)); - } - } - - None - } } #[async_trait] @@ -100,10 +80,6 @@ impl ToolHandler for BashTool { let description = get_string_param(¶ms, "description").unwrap_or_else(|| command_str.clone()); - if let Some(reason) = Self::is_dangerous(&command_str) { - return Err(ToolError::Permission(reason)); - } - let mut cmd = Command::new("bash"); cmd.arg("-c").arg(&command_str); diff --git a/src/tools/permission.rs b/src/tools/permission.rs index cdeb2ec..acb67e3 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -7,6 +7,8 @@ use std::process::Command; use std::sync::Arc; use tokio::sync::RwLock; +const DOOM_LOOP_THRESHOLD: usize = 3; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PermissionAction { Read, @@ -46,6 +48,8 @@ pub struct PermissionPrompt { pub tool_id: String, pub action: PermissionAction, pub target: Option, + pub command: Option, + pub workdir: Option, pub reason: String, pub response_tx: tokio::sync::oneshot::Sender, } @@ -54,8 +58,7 @@ pub struct PermissionPrompt { enum PermissionReasonKind { SensitivePath, ExternalPath, - GitignoredWrite, - BashCommand, + DoomLoop, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -67,6 +70,12 @@ struct PermissionFingerprint { reason: PermissionReasonKind, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ToolCallFingerprint { + tool_id: String, + params: String, +} + #[derive(Debug, Clone)] pub struct AgentToolPolicies { custom: HashMap>, @@ -107,8 +116,9 @@ impl AgentToolPolicies { } if mode == "plan" { - // Plan mode: deny file modifications and bash; allow everything else (read, search, web, etc.) - return !matches!(tool.as_str(), "write" | "edit" | "bash"); + // OpenCode plan mode denies file modifications, but keeps other + // tools available under the normal permission policy. + return !matches!(tool.as_str(), "write" | "edit"); } if mode == "build" { @@ -130,6 +140,7 @@ impl Default for AgentToolPolicies { pub struct ToolPermissions { workdir: PathBuf, always_grants: Arc>>, + call_counts: Arc>>, agent_policies: Arc, dangerously_skip_permissions: bool, } @@ -139,6 +150,7 @@ impl ToolPermissions { Self { workdir: normalize_path(&workdir.into()), always_grants: Arc::new(RwLock::new(HashSet::new())), + call_counts: Arc::new(RwLock::new(HashMap::new())), agent_policies: Arc::new(AgentToolPolicies::default()), dangerously_skip_permissions: false, } @@ -189,6 +201,10 @@ impl ToolPermissions { }; let reason = self.evaluate_reason(action, path.as_deref()); + let reason = match reason { + Some(reason) => Some(reason), + None => self.evaluate_doom_loop(tool_id, params).await, + }; let Some(reason_kind) = reason else { return Ok(()); @@ -198,12 +214,22 @@ impl ToolPermissions { .as_ref() .map(|p| p.display().to_string()) .or_else(|| command.clone()); + let prompt_target = if action == PermissionAction::Bash { + command.clone().or_else(|| target.clone()) + } else { + target.clone() + }; + let workdir = if action == PermissionAction::Bash { + path.as_ref().map(|p| p.display().to_string()) + } else { + None + }; let fingerprint = PermissionFingerprint { tool_id: tool_id.to_string(), action, target: target.clone(), - command, + command: command.clone(), reason: reason_kind, }; @@ -221,7 +247,9 @@ impl ToolPermissions { let prompt = PermissionPrompt { tool_id: tool_id.to_string(), action, - target, + target: prompt_target, + command, + workdir, reason: reason_text, response_tx, }; @@ -250,9 +278,7 @@ impl ToolPermissions { action: PermissionAction, path: Option<&Path>, ) -> Option { - // Read/search tools are sandbox-style discovery operations; only mutating - // filesystem tools require approval for protected path classes. - if matches!(action, PermissionAction::Write | PermissionAction::Edit) { + if action == PermissionAction::Read { if let Some(path) = path { if is_sensitive_path(path) { return Some(PermissionReasonKind::SensitivePath); @@ -260,7 +286,16 @@ impl ToolPermissions { } } - if matches!(action, PermissionAction::Write | PermissionAction::Edit) { + if matches!( + action, + PermissionAction::Read + | PermissionAction::Write + | PermissionAction::Edit + | PermissionAction::List + | PermissionAction::Glob + | PermissionAction::Grep + | PermissionAction::Bash + ) { if let Some(path) = path { if is_outside_workdir(path, &self.workdir) { return Some(PermissionReasonKind::ExternalPath); @@ -268,19 +303,28 @@ impl ToolPermissions { } } - if matches!(action, PermissionAction::Write | PermissionAction::Edit) { - if let Some(path) = path { - if is_gitignored(path, &self.workdir) { - return Some(PermissionReasonKind::GitignoredWrite); - } - } - } + None + } - if action == PermissionAction::Bash { - return Some(PermissionReasonKind::BashCommand); - } + async fn evaluate_doom_loop( + &self, + tool_id: &str, + params: &Value, + ) -> Option { + let key = ToolCallFingerprint { + tool_id: tool_id.to_string(), + params: serde_json::to_string(params).unwrap_or_else(|_| params.to_string()), + }; - None + let mut call_counts = self.call_counts.write().await; + let count = call_counts.entry(key).or_insert(0); + *count += 1; + + if *count >= DOOM_LOOP_THRESHOLD { + Some(PermissionReasonKind::DoomLoop) + } else { + None + } } } @@ -306,16 +350,16 @@ fn reason_text(reason: PermissionReasonKind, tool_id: &str, target: Option<&str> tool_id ), }, - PermissionReasonKind::GitignoredWrite => match target { + PermissionReasonKind::DoomLoop => match target { Some(target) => format!( - "Tool '{}' wants to modify gitignored path: {}", + "Tool '{}' repeated the same request for {}; explicit approval required", tool_id, target ), - None => format!("Tool '{}' wants to modify a gitignored path", tool_id), + None => format!( + "Tool '{}' repeated the same request; explicit approval required", + tool_id + ), }, - PermissionReasonKind::BashCommand => { - "Bash command execution requires permission".to_string() - } } } @@ -421,9 +465,9 @@ mod tests { let policies = AgentToolPolicies::default(); assert!(policies.is_allowed("plan", "read")); assert!(policies.is_allowed("plan", "glob")); + assert!(policies.is_allowed("plan", "bash")); assert!(!policies.is_allowed("plan", "write")); assert!(!policies.is_allowed("plan", "edit")); - assert!(!policies.is_allowed("plan", "bash")); } #[test] @@ -462,14 +506,14 @@ mod tests { async fn allow_always_persists_for_same_request_fingerprint() { let perms = ToolPermissions::new("/tmp/workspace"); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let params = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + let params = serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }); let perms_for_task = perms.clone(); let params_for_task = params.clone(); let tx_for_task = tx.clone(); let first = tokio::spawn(async move { perms_for_task - .preflight("build", "write", ¶ms_for_task, Some(&tx_for_task)) + .preflight("build", "read", ¶ms_for_task, Some(&tx_for_task)) .await }); @@ -482,39 +526,184 @@ mod tests { let first_result = first.await.expect("task should complete"); assert!(first_result.is_ok()); - let second = perms.preflight("build", "write", ¶ms, Some(&tx)).await; + let second = perms.preflight("build", "read", ¶ms, Some(&tx)).await; assert!(second.is_ok()); assert!(rx.try_recv().is_err()); } #[tokio::test] - async fn read_and_search_tools_do_not_prompt_for_sensitive_or_external_paths() { + async fn sensitive_writes_are_allowed_by_default() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + + let write_result = perms.preflight("build", "write", ¶ms, Some(&tx)).await; + let edit_result = perms.preflight("build", "edit", ¶ms, Some(&tx)).await; + + assert!(write_result.is_ok()); + assert!(edit_result.is_ok()); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn read_tool_prompts_for_sensitive_path() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { perms.preflight("build", "read", ¶ms, Some(&tx)).await } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "read"); + assert_eq!(prompt.action, PermissionAction::Read); + assert_eq!(prompt.target.as_deref(), Some("/tmp/workspace/.env")); + assert!(prompt.reason.contains("sensitive file")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn read_tool_prompts_for_external_path() { let perms = ToolPermissions::new("/tmp/workspace"); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let sensitive = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + let params = serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { perms.preflight("build", "read", ¶ms, Some(&tx)).await } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "read"); + assert_eq!(prompt.action, PermissionAction::Read); + assert_eq!(prompt.target.as_deref(), Some("/tmp/elsewhere/file.txt")); + assert!(prompt.reason.contains("outside working directory")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn search_tools_prompt_for_external_paths() { + let perms = ToolPermissions::new("/tmp/workspace"); let external = serde_json::json!({ "path": "/tmp/elsewhere" }); - let read_result = perms - .preflight("build", "read", &sensitive, Some(&tx)) - .await; - let list_result = perms.preflight("build", "list", &external, Some(&tx)).await; - let glob_result = perms.preflight("build", "glob", &external, Some(&tx)).await; - let grep_result = perms.preflight("build", "grep", &external, Some(&tx)).await; - - assert!(read_result.is_ok()); - assert!(list_result.is_ok()); - assert!(glob_result.is_ok()); - assert!(grep_result.is_ok()); + let list_result = perms.preflight("build", "list", &external, None).await; + let glob_result = perms.preflight("build", "glob", &external, None).await; + let grep_result = perms.preflight("build", "grep", &external, None).await; + + assert!(list_result.is_err()); + assert!(glob_result.is_err()); + assert!(grep_result.is_err()); + } + + #[tokio::test] + async fn bash_is_allowed_by_default_inside_workspace() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ + "command": "pwd", + "workdir": "/tmp/workspace", + }); + + let result = perms.preflight("build", "bash", ¶ms, Some(&tx)).await; + + assert!(result.is_ok()); assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn bash_external_workdir_prompt_separates_command_from_workdir() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ + "command": "pwd", + "workdir": "/tmp/elsewhere", + }); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { perms.preflight("build", "bash", ¶ms, Some(&tx)).await } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.target.as_deref(), Some("pwd")); + assert_eq!(prompt.command.as_deref(), Some("pwd")); + assert_eq!(prompt.workdir.as_deref(), Some("/tmp/elsewhere")); + assert!(prompt.reason.contains("outside working directory")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let _ = pending.await.expect("preflight task should complete"); + } + + #[tokio::test] + async fn repeated_allowed_call_prompts_for_doom_loop() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ + "command": "pwd", + "workdir": "/tmp/workspace", + }); + + let first = perms.preflight("build", "bash", ¶ms, Some(&tx)).await; + let second = perms.preflight("build", "bash", ¶ms, Some(&tx)).await; + assert!(first.is_ok()); + assert!(second.is_ok()); + assert!(rx.try_recv().is_err()); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { perms.preflight("build", "bash", ¶ms, Some(&tx)).await } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "bash"); + assert_eq!(prompt.action, PermissionAction::Bash); + assert_eq!(prompt.target.as_deref(), Some("pwd")); + assert!(prompt.reason.contains("repeated the same request")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + #[tokio::test] async fn dangerous_skip_bypasses_permission_prompts() { let perms = ToolPermissions::new("/tmp/workspace").dangerously_skip_permissions(true); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let params = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + let params = serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }); - let result = perms.preflight("build", "write", ¶ms, Some(&tx)).await; + let result = perms.preflight("build", "read", ¶ms, Some(&tx)).await; assert!(result.is_ok()); assert!(rx.try_recv().is_err()); diff --git a/src/ui/diff.rs b/src/ui/diff.rs index ce30b05..4d12fcb 100644 --- a/src/ui/diff.rs +++ b/src/ui/diff.rs @@ -1,11 +1,12 @@ use crate::theme::ThemeColors; -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; const MAX_DIFF_LINES: usize = 40; const CONTEXT_LINES: usize = 3; const TAB_WIDTH: usize = 4; +const GUTTER_DIFF_BG_ALPHA: f32 = 0.55; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffLineType { @@ -195,13 +196,14 @@ fn render_unified_diff_with_indent_and_syntax( DiffLineType::Add => ('+', colors.diff_add, colors.diff_add_bg), DiffLineType::Context => (' ', colors.text_weak, colors.background), }; + let gutter_bg = diff_gutter_bg(diff_line.line_type, bg, colors.background); - let indent_style = Style::default().bg(bg); + let indent_style = Style::default().bg(gutter_bg); let gutter_style = Style::default() .fg(colors.diff_gutter) - .bg(bg) + .bg(gutter_bg) .add_modifier(Modifier::DIM); - let sign_style = Style::default().fg(fg).bg(bg); + let sign_style = Style::default().fg(fg).bg(gutter_bg); let content_style = Style::default().fg(fg).bg(bg); let pad_style = Style::default().bg(bg); @@ -240,7 +242,10 @@ fn render_unified_diff_with_indent_and_syntax( let styled = spans .iter() .map(|span| { - let mut style = content_style.patch(span.style); + let mut style = span.style.bg(bg); + if style.fg.is_none() { + style = style.fg(colors.text); + } if matches!(diff_line.line_type, DiffLineType::Remove) { style = style.add_modifier(Modifier::DIM); } @@ -300,6 +305,29 @@ fn render_unified_diff_with_indent_and_syntax( lines } +fn diff_gutter_bg(line_type: DiffLineType, diff_bg: Color, base_bg: Color) -> Color { + match line_type { + DiffLineType::Add | DiffLineType::Remove => { + blend_colors(diff_bg, base_bg, GUTTER_DIFF_BG_ALPHA) + } + DiffLineType::Context => diff_bg, + } +} + +fn blend_colors(foreground: Color, background: Color, alpha: f32) -> Color { + let (Color::Rgb(fr, fg, fb), Color::Rgb(br, bg, bb)) = (foreground, background) else { + return foreground; + }; + + let mix = |front: u8, back: u8| { + (front as f32 * alpha + back as f32 * (1.0 - alpha)) + .round() + .clamp(0.0, 255.0) as u8 + }; + + Color::Rgb(mix(fr, br), mix(fg, bg), mix(fb, bb)) +} + fn syntax_spans_for_diff_line<'a>( diff_line: &DiffLine, start_line: usize, @@ -566,4 +594,88 @@ mod tests { "expected syntax-colored Rust keyword span" ); } + + #[test] + fn test_render_unified_diff_keeps_syntax_foreground_after_diff_signs() { + let colors = test_colors(); + let old = "let value = false;\n"; + let new = "let value = true;\n"; + + let lines = + format_edit_diff_for_path_with_start(old, new, 1, 80, &colors, "", "src/lib.rs"); + let removed_line = lines + .iter() + .find(|line| line_text(line).contains("-let value")) + .expect("expected removed line"); + let added_line = lines + .iter() + .find(|line| line_text(line).contains("+let value")) + .expect("expected added line"); + + let removed_identifier = removed_line + .spans + .iter() + .find(|span| span.content.as_ref().contains("value")) + .expect("expected removed identifier span"); + let added_identifier = added_line + .spans + .iter() + .find(|span| span.content.as_ref().contains("value")) + .expect("expected added identifier span"); + + assert_ne!(removed_identifier.style.fg, Some(colors.diff_remove)); + assert_eq!(removed_identifier.style.bg, Some(colors.diff_remove_bg)); + assert!(removed_identifier + .style + .add_modifier + .contains(Modifier::DIM)); + assert_ne!(added_identifier.style.fg, Some(colors.diff_add)); + assert_eq!(added_identifier.style.bg, Some(colors.diff_add_bg)); + } + + #[test] + fn test_render_unified_diff_highlights_typescript_additions() { + let colors = test_colors(); + let new = "import { argv } from 'node:process'\n\nconsole.log(`hello ${argv[2]}`)\n"; + + let lines = + format_edit_diff_for_path_with_start("", new, 1, 100, &colors, "", "scripts/script.ts"); + let import_line = lines + .iter() + .find(|line| line_text(line).contains("+import")) + .expect("expected TypeScript import line"); + let import_span = import_line + .spans + .iter() + .find(|span| span.content.as_ref().contains("import")) + .expect("expected import content span"); + + assert_ne!(import_span.style.fg, Some(colors.diff_add)); + assert_eq!(import_span.style.bg, Some(colors.diff_add_bg)); + } + + #[test] + fn test_render_unified_diff_uses_softer_gutter_background_for_changes() { + let mut colors = test_colors(); + colors.background = Color::Rgb(10, 10, 10); + colors.diff_add_bg = Color::Rgb(10, 70, 20); + + let lines = format_edit_diff_for_path_with_start( + "", + "let value = true;\n", + 1, + 80, + &colors, + "", + "src/lib.rs", + ); + let added_line = lines + .iter() + .find(|line| line_text(line).contains("+let value")) + .expect("expected added line"); + + assert_eq!(added_line.spans[1].style.bg, Some(Color::Rgb(10, 43, 16))); + assert_eq!(added_line.spans[2].style.bg, Some(Color::Rgb(10, 43, 16))); + assert_eq!(added_line.spans[3].style.bg, Some(colors.diff_add_bg)); + } } diff --git a/src/ui/syntax.rs b/src/ui/syntax.rs index 1fd86f3..5550d25 100644 --- a/src/ui/syntax.rs +++ b/src/ui/syntax.rs @@ -17,7 +17,7 @@ static SYNTAX_SET: OnceLock = OnceLock::new(); static THEME_SET: OnceLock = OnceLock::new(); fn syntax_set() -> &'static SyntaxSet { - SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines) + SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) } fn theme_set() -> &'static ThemeSet { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 857c192..5de3f3b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -4,5 +4,6 @@ pub mod frecency; pub mod git; pub mod ignore; pub mod image_attachment; +pub mod storage; pub mod time; pub mod token_counter; diff --git a/src/utils/storage.rs b/src/utils/storage.rs new file mode 100644 index 0000000..bd54db0 --- /dev/null +++ b/src/utils/storage.rs @@ -0,0 +1,200 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + +const PASTED_IMAGE_PREFIX: &str = "crabcode-clipboard-"; +const PASTED_IMAGE_SUFFIX: &str = ".png"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StorageCategory { + PastedImages, + DataDb, + ModelsDevCache, +} + +#[derive(Debug, Clone)] +pub struct StorageRow { + pub category: StorageCategory, + pub label: String, + pub detail: String, + pub bytes: u64, + pub item_count: usize, + pub open_path: Option, +} + +#[derive(Debug, Clone)] +pub struct StorageReport { + pub rows: Vec, + pub total_bytes: u64, + pub checked_at: SystemTime, +} + +pub fn collect_storage_report() -> StorageReport { + let rows = vec![ + collect_pasted_images_in_dir(&std::env::temp_dir()), + collect_file_row( + StorageCategory::DataDb, + "Data.db", + "sessions, preferences, prompt history", + crate::persistence::get_data_dir().join("data.db"), + ), + collect_file_row( + StorageCategory::ModelsDevCache, + "Models.dev Cache", + "models_dev_cache.json", + crate::persistence::get_cache_dir().join("models_dev_cache.json"), + ), + ]; + let total_bytes = rows.iter().map(|row| row.bytes).sum(); + + StorageReport { + rows, + total_bytes, + checked_at: SystemTime::now(), + } +} + +fn collect_pasted_images_in_dir(dir: &Path) -> StorageRow { + let mut bytes = 0u64; + let mut item_count = 0usize; + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !is_pasted_image_file(&path) { + continue; + } + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + bytes = bytes.saturating_add(metadata.len()); + item_count = item_count.saturating_add(1); + } + } + } + } + + StorageRow { + category: StorageCategory::PastedImages, + label: "Pasted Images".to_string(), + detail: format!( + "{} PNG {}", + item_count, + if item_count == 1 { "file" } else { "files" } + ), + bytes, + item_count, + open_path: dir.is_dir().then(|| dir.to_path_buf()), + } +} + +fn collect_file_row( + category: StorageCategory, + label: &str, + detail: &str, + path: PathBuf, +) -> StorageRow { + let bytes = path + .metadata() + .ok() + .filter(|metadata| metadata.is_file()) + .map(|metadata| metadata.len()) + .unwrap_or(0); + + StorageRow { + category, + label: label.to_string(), + detail: detail.to_string(), + bytes, + item_count: usize::from(bytes > 0), + open_path: path.parent().map(Path::to_path_buf), + } +} + +fn is_pasted_image_file(path: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + file_name.starts_with(PASTED_IMAGE_PREFIX) && file_name.ends_with(PASTED_IMAGE_SUFFIX) +} + +pub fn format_bytes(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + + let bytes_f = bytes as f64; + if bytes == 0 { + "0 B".to_string() + } else if bytes_f < KB { + format!("{} B", bytes) + } else if bytes_f < MB { + format!("{:.1} KB", bytes_f / KB) + } else if bytes_f < GB { + format!("{:.1} MB", bytes_f / MB) + } else { + format!("{:.2} GB", bytes_f / GB) + } +} + +pub fn open_folder(path: &Path) -> Result<()> { + if !path.is_dir() { + return Err(anyhow::anyhow!("folder does not exist: {}", path.display())); + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + return Ok(()); + } + + #[cfg(target_os = "windows")] + { + Command::new("explorer") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + return Ok(()); + } + + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + Command::new("xdg-open") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_bytes_uses_readable_units() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(512), "512 B"); + assert_eq!(format_bytes(1536), "1.5 KB"); + assert_eq!(format_bytes(2 * 1024 * 1024), "2.0 MB"); + } + + #[test] + fn pasted_images_scan_counts_matching_png_files_only() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("crabcode-clipboard-a.png"), [1u8; 4]).unwrap(); + std::fs::write(dir.path().join("crabcode-clipboard-b.png"), [1u8; 6]).unwrap(); + std::fs::write(dir.path().join("crabcode-clipboard-c.jpg"), [1u8; 8]).unwrap(); + std::fs::write(dir.path().join("other.png"), [1u8; 10]).unwrap(); + + let row = collect_pasted_images_in_dir(dir.path()); + + assert_eq!(row.item_count, 2); + assert_eq!(row.bytes, 10); + assert_eq!(row.open_path.as_deref(), Some(dir.path())); + } +} diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index 5ce8d52..6d77eab 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -19,6 +19,7 @@ pub enum CommandPaletteAction { pub enum CommandPaletteAppAction { ToggleAgentMode, CycleReasoningEffort, + OpenStorage, } #[derive(Debug)] @@ -163,6 +164,9 @@ fn action_for_item(item: &DialogItem) -> CommandPaletteAction { "cycle-reasoning-effort" => { CommandPaletteAction::RunAppAction(CommandPaletteAppAction::CycleReasoningEffort) } + "open-storage" => { + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::OpenStorage) + } _ => CommandPaletteAction::None, }; } @@ -274,6 +278,20 @@ fn core_palette_items(registry: &Registry, is_chat: bool) -> Vec { ), ); + items.insert( + items + .iter() + .position(|item| item.group == "Application") + .unwrap_or(items.len()), + app_action_item( + "open-storage", + "Storage", + "Application", + "Inspect Crabcode disk usage", + None, + ), + ); + items } diff --git a/src/views/mod.rs b/src/views/mod.rs index 0db8374..aa07e5a 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -9,6 +9,7 @@ pub mod question_dialog; pub mod session_rename_dialog; pub mod sessions_dialog; pub mod skills_dialog; +pub mod storage_dialog; pub mod suggestions_popup; pub mod themes_dialog; pub mod timeline_dialog; @@ -24,6 +25,7 @@ pub use question_dialog::QuestionDialogState; pub use session_rename_dialog::SessionRenameDialogState; pub use sessions_dialog::SessionsDialogState; pub use skills_dialog::SkillsDialogState; +pub use storage_dialog::StorageDialogState; pub use suggestions_popup::SuggestionsPopupState; pub use themes_dialog::ThemesDialogState; pub use timeline_dialog::TimelineDialogState; diff --git a/src/views/permission_dialog.rs b/src/views/permission_dialog.rs index 46496d0..6837a93 100644 --- a/src/views/permission_dialog.rs +++ b/src/views/permission_dialog.rs @@ -1,5 +1,5 @@ use crate::theme::{contrast_text, ThemeColors}; -use crate::tools::{PermissionPrompt, PermissionResponse}; +use crate::tools::{PermissionAction, PermissionPrompt, PermissionResponse}; use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -138,6 +138,56 @@ pub fn handle_permission_dialog_mouse_event( false } +fn permission_detail_lines(prompt: &PermissionPrompt, colors: ThemeColors) -> Vec> { + let is_bash = prompt.action == PermissionAction::Bash || prompt.tool_id == "bash"; + let label_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let value_style = Style::default().fg(colors.text); + let mut details = vec![Line::from(vec![ + Span::styled("Tool ", label_style), + Span::styled( + prompt.tool_id.clone(), + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" • ", label_style), + Span::styled(prompt.reason.clone(), label_style), + ])]; + + if is_bash { + let command = prompt + .command + .as_deref() + .or(prompt.target.as_deref()) + .unwrap_or("(none)"); + details.push(Line::from(vec![ + Span::styled("Command ", label_style), + Span::styled(command.to_string(), value_style), + ])); + + if let Some(workdir) = prompt.workdir.as_deref() { + details.push(Line::from(vec![ + Span::styled("Workdir ", label_style), + Span::styled(workdir.to_string(), value_style), + ])); + } + } else { + let target = prompt + .target + .as_deref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "(none)".to_string()); + details.push(Line::from(vec![ + Span::styled("Target ", label_style), + Span::styled(target, value_style), + ])); + } + + details +} + pub fn render_permission_dialog( f: &mut Frame, state: &mut PermissionDialogState, @@ -148,9 +198,10 @@ pub fn render_permission_dialog( return; }; - let min_height = area.height.min(6); - let desired_height = area.height.min(8); - let panel_height = desired_height.max(min_height); + let details = permission_detail_lines(prompt, colors); + let detail_line_count = details.len() as u16; + let desired_height = (detail_line_count + 5).clamp(8, 10); + let panel_height = area.height.min(desired_height); let dialog_area = Rect { x: area.x, y: area.y + area.height.saturating_sub(panel_height), @@ -180,7 +231,6 @@ pub fn render_permission_dialog( let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), @@ -221,48 +271,10 @@ pub fn render_permission_dialog( header_chunks[1], ); - let target = prompt - .target - .as_deref() - .map(|s| s.to_string()) - .unwrap_or_else(|| "(none)".to_string()); - let summary = Line::from(vec![ - Span::styled( - "Tool ", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), - Span::styled( - prompt.tool_id.clone(), - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " • ", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), - Span::styled( - "Target ", - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ), - Span::styled(target, Style::default().fg(colors.text)), - ]); - f.render_widget(Paragraph::new(summary), chunks[1]); - - let reason = Paragraph::new(prompt.reason.clone()) - .style( - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ) + let detail_block = Paragraph::new(details) + .style(Style::default().bg(colors.dialog_background)) .wrap(Wrap { trim: true }); - f.render_widget(reason, chunks[2]); + f.render_widget(detail_block, chunks[1]); let actions = [ (1usize, "Allow once", "1"), @@ -311,21 +323,64 @@ pub fn render_permission_dialog( let actions_line = Paragraph::new(Line::from(action_spans)).alignment(Alignment::Left); let help_width = help.width() as u16; - let can_render_help = chunks[3].width > 42; + let can_render_help = chunks[2].width > 42; if can_render_help { let footer_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Min(0), - Constraint::Length(help_width.min(chunks[3].width.saturating_sub(20))), + Constraint::Length(help_width.min(chunks[2].width.saturating_sub(20))), ]) - .split(chunks[3]); + .split(chunks[2]); f.render_widget(actions_line, footer_chunks[0]); f.render_widget( Paragraph::new(help).alignment(Alignment::Right), footer_chunks[1], ); } else { - f.render_widget(actions_line, chunks[3]); + f.render_widget(actions_line, chunks[2]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Theme; + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn bash_detail_lines_show_command_and_workdir() { + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let prompt = PermissionPrompt { + tool_id: "bash".to_string(), + action: PermissionAction::Bash, + target: Some("cargo test".to_string()), + command: Some("cargo test".to_string()), + workdir: Some("/tmp/workspace".to_string()), + reason: "Bash command execution requires permission".to_string(), + response_tx, + }; + let colors = Theme::load_builtin_default().get_colors(true); + + let rendered = permission_detail_lines(&prompt, colors) + .iter() + .map(line_text) + .collect::>(); + + assert_eq!( + rendered, + vec![ + "Tool bash • Bash command execution requires permission", + "Command cargo test", + "Workdir /tmp/workspace" + ] + ); + assert!(!rendered.iter().any(|line| line.contains("Target"))); } } diff --git a/src/views/storage_dialog.rs b/src/views/storage_dialog.rs new file mode 100644 index 0000000..460976f --- /dev/null +++ b/src/views/storage_dialog.rs @@ -0,0 +1,558 @@ +use crate::theme::{contrast_text, ThemeColors}; +use crate::utils::storage::{format_bytes, StorageCategory, StorageReport, StorageRow}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Position, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; +use unicode_width::UnicodeWidthStr; + +const STORAGE_CATEGORIES: [StorageCategory; 3] = [ + StorageCategory::PastedImages, + StorageCategory::DataDb, + StorageCategory::ModelsDevCache, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StorageDialogAction { + None, + Close, + Refresh, + Open(StorageCategory), +} + +#[derive(Debug)] +pub struct StorageDialogState { + visible: bool, + selected_index: usize, + checking: bool, + report: Option, + error: Option, + dialog_area: Rect, + rows_area: Rect, +} + +impl StorageDialogState { + pub fn new() -> Self { + Self { + visible: false, + selected_index: 0, + checking: false, + report: None, + error: None, + dialog_area: Rect::default(), + rows_area: Rect::default(), + } + } + + pub fn show(&mut self) { + self.visible = true; + self.selected_index = self + .selected_index + .min(STORAGE_CATEGORIES.len().saturating_sub(1)); + } + + pub fn hide(&mut self) { + self.visible = false; + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn has_report(&self) -> bool { + self.report.is_some() + } + + pub fn is_checking(&self) -> bool { + self.checking + } + + pub fn start_checking(&mut self) { + self.checking = true; + self.error = None; + } + + pub fn set_report(&mut self, report: StorageReport) { + self.report = Some(report); + self.checking = false; + self.error = None; + } + + pub fn set_error(&mut self, error: impl Into) { + self.checking = false; + self.error = Some(error.into()); + } + + pub fn open_path_for(&self, category: StorageCategory) -> Option { + self.report + .as_ref()? + .rows + .iter() + .find(|row| row.category == category) + .and_then(|row| row.open_path.clone()) + } + + fn selected_category(&self) -> StorageCategory { + STORAGE_CATEGORIES[self + .selected_index + .min(STORAGE_CATEGORIES.len().saturating_sub(1))] + } + + fn next(&mut self) { + if self.selected_index + 1 < STORAGE_CATEGORIES.len() { + self.selected_index += 1; + } + } + + fn previous(&mut self) { + self.selected_index = self.selected_index.saturating_sub(1); + } + + fn select_row_at(&mut self, col: u16, row: u16) -> Option { + if !self.rows_area.contains(Position::new(col, row)) { + return None; + } + + let index = row.saturating_sub(self.rows_area.y) as usize / 2; + if index >= STORAGE_CATEGORIES.len() { + return None; + } + + self.selected_index = index; + Some(STORAGE_CATEGORIES[index]) + } +} + +impl Default for StorageDialogState { + fn default() -> Self { + Self::new() + } +} + +pub fn init_storage_dialog() -> StorageDialogState { + StorageDialogState::new() +} + +pub fn handle_storage_dialog_key_event( + state: &mut StorageDialogState, + event: KeyEvent, +) -> StorageDialogAction { + if !state.is_visible() { + return StorageDialogAction::None; + } + + match event.code { + KeyCode::Esc => { + state.hide(); + StorageDialogAction::Close + } + KeyCode::Enter => StorageDialogAction::Open(state.selected_category()), + KeyCode::Up => { + state.previous(); + StorageDialogAction::None + } + KeyCode::Down => { + state.next(); + StorageDialogAction::None + } + KeyCode::Char('r') | KeyCode::Char('R') => StorageDialogAction::Refresh, + _ => StorageDialogAction::None, + } +} + +pub fn handle_storage_dialog_mouse_event( + state: &mut StorageDialogState, + event: MouseEvent, +) -> StorageDialogAction { + if !state.is_visible() { + return StorageDialogAction::None; + } + + match event.kind { + MouseEventKind::ScrollUp => { + state.previous(); + StorageDialogAction::None + } + MouseEventKind::ScrollDown => { + state.next(); + StorageDialogAction::None + } + MouseEventKind::Down(MouseButton::Left) => state + .select_row_at(event.column, event.row) + .map(StorageDialogAction::Open) + .unwrap_or(StorageDialogAction::None), + _ => StorageDialogAction::None, + } +} + +pub fn render_storage_dialog( + f: &mut Frame, + state: &mut StorageDialogState, + area: Rect, + colors: ThemeColors, +) { + if !state.is_visible() { + return; + } + + let dialog_width = area.width.min(80); + let dialog_height = area.height.min(16); + state.dialog_area = Rect { + x: area.x + area.width.saturating_sub(dialog_width) / 2, + y: area.y + area.height.saturating_sub(dialog_height) / 2, + width: dialog_width, + height: dialog_height, + }; + + f.render_widget(Clear, state.dialog_area); + f.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + state.dialog_area, + ); + + let content = Rect { + x: state.dialog_area.x + 3, + y: state.dialog_area.y + 1, + width: state.dialog_area.width.saturating_sub(6), + height: state.dialog_area.height.saturating_sub(2), + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(6), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(content); + + render_header(f, chunks[0], colors); + render_summary(f, state, chunks[2], colors); + + state.rows_area = chunks[3]; + render_rows(f, state, chunks[3], colors); + render_footer(f, chunks[5], colors); +} + +fn render_header(f: &mut Frame, area: Rect, colors: ThemeColors) { + let esc_width = 4; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(esc_width)]) + .split(area); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "Storage", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])), + chunks[0], + ); + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "esc", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Right), + chunks[1], + ); +} + +fn render_summary(f: &mut Frame, state: &StorageDialogState, area: Rect, colors: ThemeColors) { + let text = if let Some(report) = &state.report { + let mut text = format!("Total {}", format_bytes(report.total_bytes)); + if state.checking { + text.push_str(" refreshing..."); + } else { + text.push_str(&format!(" {}", checked_age(report.checked_at))); + } + text + } else if state.checking { + "Total checking...".to_string() + } else if let Some(error) = &state.error { + format!("Total unavailable: {}", error) + } else { + "Total not checked".to_string() + }; + + let style = if state.error.is_some() && state.report.is_none() { + Style::default().fg(colors.error) + } else { + Style::default().fg(colors.text_weak) + }; + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled(text, style)])), + area, + ); +} + +fn render_rows(f: &mut Frame, state: &StorageDialogState, area: Rect, colors: ThemeColors) { + let mut lines = Vec::new(); + + for (index, category) in STORAGE_CATEGORIES.iter().enumerate() { + let row = state + .report + .as_ref() + .and_then(|report| report.rows.iter().find(|row| row.category == *category)); + lines.extend(storage_row_lines( + index, + label_for_category(*category), + row, + state + .report + .as_ref() + .map(|report| report.total_bytes) + .unwrap_or(0), + state.checking && state.report.is_none(), + index == state.selected_index, + area.width as usize, + colors, + )); + } + + f.render_widget(Paragraph::new(lines), area); +} + +fn storage_row_lines( + index: usize, + fallback_label: &str, + row: Option<&StorageRow>, + total_bytes: u64, + checking: bool, + selected: bool, + width: usize, + colors: ThemeColors, +) -> Vec> { + let label = row + .map(|row| row.label.clone()) + .unwrap_or_else(|| fallback_label.to_string()); + let detail = row + .map(|row| row.detail.clone()) + .unwrap_or_else(|| "waiting for storage check".to_string()); + let size = row + .map(|row| format_bytes(row.bytes)) + .unwrap_or_else(|| "-".to_string()); + let percent = row + .map(|row| percent_of(row.bytes, total_bytes)) + .map(|percent| format!("{percent:>3}%")) + .unwrap_or_else(|| { + if checking { + "...".to_string() + } else { + "--%".to_string() + } + }); + let meter = if let Some(row) = row { + meter_text(row.bytes, total_bytes, 22) + } else if checking { + placeholder_meter_text("checking", 22) + } else { + placeholder_meter_text("not checked", 22) + }; + + let marker = if selected { ">" } else { " " }; + let right = format!("{} {}", percent, pad_left(&size, 10)); + let left_budget = width.saturating_sub(right.width() + 2); + let left = truncate(&format!("{marker} {label}"), left_budget); + let first_gap = width.saturating_sub(left.width() + right.width()); + + let detail_prefix = " "; + let detail_budget = width.saturating_sub(meter.width() + detail_prefix.width() + 2); + let detail = truncate(&detail, detail_budget); + let second_left = format!("{detail_prefix}{detail}"); + let second_gap = width.saturating_sub(second_left.width() + meter.width()); + + vec![ + styled_storage_line( + vec![ + Span::styled(left, Style::default().fg(colors.text)), + Span::raw(" ".repeat(first_gap)), + Span::styled( + right, + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + ], + selected, + index, + colors, + ), + styled_storage_line( + vec![ + Span::styled( + second_left, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::raw(" ".repeat(second_gap)), + Span::styled( + meter, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ], + selected, + index, + colors, + ), + ] +} + +fn styled_storage_line( + mut spans: Vec>, + selected: bool, + index: usize, + colors: ThemeColors, +) -> Line<'static> { + if selected { + let fg = contrast_text(colors.primary); + for span in &mut spans { + span.style = span.style.fg(fg).bg(colors.primary); + } + } else if index % 2 == 1 { + for span in &mut spans { + span.style = span.style.bg(colors.background_element); + } + } + + Line::from(spans) +} + +fn percent_of(bytes: u64, total_bytes: u64) -> u16 { + if total_bytes == 0 { + 0 + } else { + ((bytes as f64 / total_bytes as f64) * 100.0).round() as u16 + } +} + +fn meter_text(bytes: u64, total_bytes: u64, width: usize) -> String { + let percent = percent_of(bytes, total_bytes); + let bar_width = width.saturating_sub(2).max(1); + let filled = ((percent as usize * bar_width) + 50) / 100; + let empty = bar_width.saturating_sub(filled.min(bar_width)); + format!( + "[{}{}]", + "#".repeat(filled.min(bar_width)), + "-".repeat(empty) + ) +} + +fn placeholder_meter_text(label: &str, width: usize) -> String { + let inner_width = width.saturating_sub(2).max(1); + let label = truncate(label, inner_width); + let padding = inner_width.saturating_sub(label.width()); + format!("[{}{}]", label, "-".repeat(padding)) +} + +fn checked_age(checked_at: std::time::SystemTime) -> String { + let elapsed = checked_at.elapsed().unwrap_or_default(); + if elapsed.as_secs() < 60 { + "cached now".to_string() + } else if elapsed.as_secs() < 3600 { + format!("cached {}m ago", elapsed.as_secs() / 60) + } else { + format!("cached {}h ago", elapsed.as_secs() / 3600) + } +} + +fn render_footer(f: &mut Frame, area: Rect, colors: ThemeColors) { + let spans = vec![ + Span::styled("Open", Style::default().fg(colors.text_weak)), + Span::raw(" "), + Span::styled( + "enter", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("Refresh", Style::default().fg(colors.text_weak)), + Span::raw(" "), + Span::styled( + "r", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + ]; + + f.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn label_for_category(category: StorageCategory) -> &'static str { + match category { + StorageCategory::PastedImages => "Pasted Images", + StorageCategory::DataDb => "Data.db", + StorageCategory::ModelsDevCache => "Models.dev Cache", + } +} + +fn truncate(text: &str, max_width: usize) -> String { + if text.width() <= max_width { + return text.to_string(); + } + if max_width <= 3 { + return ".".repeat(max_width); + } + + let mut out = String::new(); + let mut width = 0usize; + for ch in text.chars() { + let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if width + char_width > max_width - 3 { + break; + } + out.push(ch); + width += char_width; + } + out.push_str("..."); + out +} + +fn pad_left(text: &str, width: usize) -> String { + let text_width = text.width(); + if text_width >= width { + text.to_string() + } else { + format!("{}{}", " ".repeat(width - text_width), text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn meter_is_relative_to_total_tracked_bytes() { + assert_eq!(meter_text(50, 200, 14), "[###---------]"); + } + + #[test] + fn storage_dialog_keeps_cached_report_while_refreshing() { + let mut state = StorageDialogState::new(); + state.set_report(StorageReport { + rows: Vec::new(), + total_bytes: 12, + checked_at: std::time::SystemTime::now(), + }); + state.start_checking(); + + assert!(state.has_report()); + assert!(state.is_checking()); + } +} From eff0507aafae174edf20032150102f2bb6d52743 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 00:46:34 +0800 Subject: [PATCH 146/226] feat: group assistant turn parts into logical message blocks for clipboard, fork, click, and highlight. --- _plans/__TODOS.md | 4 +- src/app.rs | 88 +++++++++++++++++-- src/session/types.rs | 69 +++++++++++++++ src/ui/components/chat.rs | 179 +++++++++++++++++++++++++++++++------- 4 files changed, 302 insertions(+), 38 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 51419b7..a66c559 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -96,7 +96,7 @@ - [x] better timeline highlighting of each "message" -- [ ] Timeline highlighting of each message is not very accurate. It's accurate for "my messages". but for the ai responses, ai can seem to only highlight, even via `ctrl+x g`, the first few messages before a tool call happens. This is the same with the mouse hover effects. Expectations: +- [x] Timeline highlighting of each message is not very accurate. It's accurate for "my messages". but for the ai responses, ai can seem to only highlight, even via `ctrl+x g`, the first few messages before a tool call happens. This is the same with the mouse hover effects. Expectations: - I hover/timelinehighlight my message, it encapsulates the entire message box (met) - I hover/timelinehighlight an ai response's message, it encapsulates the entire block, including tool calls, including the thinking, etc. (not met). - Essentially, I was imagining kinda the same as having a 'copy' button under each "message" record in the "messages: []" array in vercel ai sdk. That's kinda the point here. But for the limitations of TUIs, I want to just use a click on the entire message block (mine or the AI response, and open a dialog -- which is mostly the current behavior now) @@ -176,3 +176,5 @@ I want - [x] To do this But I dont want to do this - Multiple paths here: - Should it be configurable? - Autodetected depending on the tool used: i.e. if Wezterm, other terminals "open w/ Finder on mac, or native image opener". If inside Zed, open image with Zed. If inside VSCode/Cursor, open with that IDE. (Ambitious but idk if possible) + +- [ ] Make the permissions, config-driven customizable behavior. Make it like OpenCode, so we just link the docs for it in OpenCode. diff --git a/src/app.rs b/src/app.rs index 5311474..a2149ad 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3783,18 +3783,31 @@ impl App { match action { "copy" => { - if let Some(session) = self.session_manager.get_current_session() { - if let Some(msg) = session.messages.get(idx) { - let _ = crate::utils::clipboard::copy_text(&msg.content); - push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); - } + let copy_text = self + .session_manager + .get_current_session() + .and_then(|session| { + crate::session::types::logical_message_block_range(&session.messages, idx) + .map(|range| message_block_clipboard_text(&session.messages, range)) + }); + + if let Some(text) = copy_text { + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); } self.close_message_actions(); } "fork" => { let messages_to_fork: Vec = { if let Some(session) = self.session_manager.get_current_session() { - session.messages.iter().take(idx + 1).cloned().collect() + let end = crate::session::types::logical_message_block_range( + &session.messages, + idx, + ) + .map(|range| range.end) + .unwrap_or_else(|| idx.saturating_add(1).min(session.messages.len())); + + session.messages.iter().take(end).cloned().collect() } else { return; } @@ -5725,6 +5738,44 @@ impl App { } } +fn message_block_clipboard_text( + messages: &[crate::session::types::Message], + range: std::ops::Range, +) -> String { + messages + .get(range) + .unwrap_or(&[]) + .iter() + .flat_map(message_clipboard_sections) + .collect::>() + .join("\n\n") +} + +fn message_clipboard_sections(message: &crate::session::types::Message) -> Vec { + let mut sections = Vec::new(); + + if let Some(reasoning) = message.reasoning.as_deref().map(str::trim) { + if !reasoning.is_empty() { + sections.push(format!("Thinking:\n{}", reasoning)); + } + } + + let content = message.content.trim(); + if !content.is_empty() { + if matches!(message.role, crate::session::types::MessageRole::Tool) { + let content = serde_json::from_str::(content) + .ok() + .and_then(|value| serde_json::to_string_pretty(&value).ok()) + .unwrap_or_else(|| content.to_string()); + sections.push(format!("Tool:\n{}", content)); + } else { + sections.push(message.content.clone()); + } + } + + sections +} + fn append_usage_suffix(mut text: String, suffix: String) -> String { if text.is_empty() { suffix @@ -5863,6 +5914,31 @@ mod tests { .unwrap_or_default() } + #[test] + fn message_block_clipboard_text_includes_assistant_turn_parts() { + let mut assistant = crate::session::types::Message::assistant("Final answer"); + assistant.reasoning = Some("Check files".to_string()); + let messages = vec![ + crate::session::types::Message::user("Prompt"), + assistant, + crate::session::types::Message::tool( + serde_json::json!({ + "name": "read", + "status": "ok", + "output_preview": "contents", + }) + .to_string(), + ), + ]; + + let text = message_block_clipboard_text(&messages, 1..3); + + assert!(text.contains("Thinking:\nCheck files")); + assert!(text.contains("Final answer")); + assert!(text.contains("Tool:\n{")); + assert!(text.contains("\"output_preview\": \"contents\"")); + } + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { MouseEvent { kind, diff --git a/src/session/types.rs b/src/session/types.rs index 238e796..be5e5ed 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::ops::Range; use std::time::SystemTime; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -171,6 +172,49 @@ impl Message { } } +pub fn logical_message_block_start(messages: &[Message], idx: usize) -> Option { + let message = messages.get(idx)?; + + match message.role { + MessageRole::User => Some(idx), + MessageRole::Assistant | MessageRole::System | MessageRole::Tool => { + let segment_start = previous_user_index(messages, idx) + .map(|user_idx| user_idx.saturating_add(1)) + .unwrap_or(0); + + (segment_start..=idx) + .find(|&candidate| matches!(messages[candidate].role, MessageRole::Assistant)) + } + } +} + +pub fn logical_message_block_range(messages: &[Message], idx: usize) -> Option> { + let start = logical_message_block_start(messages, idx)?; + + match messages.get(start)?.role { + MessageRole::User => Some(start..start.saturating_add(1)), + MessageRole::Assistant => { + let end = messages + .iter() + .enumerate() + .skip(start.saturating_add(1)) + .find_map(|(candidate, message)| { + matches!(message.role, MessageRole::User).then_some(candidate) + }) + .unwrap_or(messages.len()); + + Some(start..end) + } + MessageRole::System | MessageRole::Tool => None, + } +} + +fn previous_user_index(messages: &[Message], idx: usize) -> Option { + (0..idx) + .rev() + .find(|&candidate| matches!(messages[candidate].role, MessageRole::User)) +} + #[derive(Debug, Clone, PartialEq)] pub struct Session { pub id: String, @@ -466,6 +510,31 @@ mod tests { assert_ne!(MessageRole::User, MessageRole::Assistant); } + #[test] + fn logical_message_block_range_groups_assistant_turn_parts() { + let messages = vec![ + Message::user("Prompt"), + Message::assistant(""), + Message::tool("tool call"), + Message::assistant("Final answer"), + Message::user("Next prompt"), + ]; + + assert_eq!(logical_message_block_range(&messages, 0), Some(0..1)); + assert_eq!(logical_message_block_range(&messages, 1), Some(1..4)); + assert_eq!(logical_message_block_range(&messages, 2), Some(1..4)); + assert_eq!(logical_message_block_range(&messages, 3), Some(1..4)); + assert_eq!(logical_message_block_range(&messages, 4), Some(4..5)); + } + + #[test] + fn logical_message_block_range_ignores_orphan_tool_rows() { + let messages = vec![Message::tool("orphan"), Message::user("Prompt")]; + + assert_eq!(logical_message_block_range(&messages, 0), None); + assert_eq!(logical_message_block_range(&messages, 1), Some(1..2)); + } + #[test] fn test_message_partial_eq() { let msg1 = Message::user("hello"); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 261e977..efb7cf9 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1,5 +1,5 @@ use crate::session::types::{Message, MessageRole}; -use crate::theme::{contrast_text, ThemeColors}; +use crate::theme::ThemeColors; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; use crate::ui::scrollbar::{ render_scrollbar, scrollbar_grab_offset, scrollbar_offset_from_row_with_grab, ScrollMetrics, @@ -1241,23 +1241,35 @@ impl Chat { return None; } - for (idx, message) in self.messages.iter().enumerate() { - if !matches!(message.role, MessageRole::User | MessageRole::Assistant) - || crate::session::compaction::is_compaction_summary(message) - { + let mut idx = 0usize; + while idx < self.messages.len() { + let Some(message) = self.messages.get(idx) else { + break; + }; + + if crate::session::compaction::is_compaction_summary(message) { + idx = idx.saturating_add(1); continue; } - let Some(&start) = self.message_line_positions.get(idx) else { + let Some(block) = + crate::session::types::logical_message_block_range(&self.messages, idx) + else { + idx = idx.saturating_add(1); + continue; + }; + + if block.start != idx { + idx = idx.saturating_add(1); + continue; + } + + let Some((start, mut end)) = + self.message_block_line_range(idx, &self.message_line_positions, content_height) + else { + idx = block.end.max(idx.saturating_add(1)); continue; }; - let mut end = self - .message_line_positions - .iter() - .copied() - .skip(idx + 1) - .find(|&next_start| next_start > start) - .unwrap_or(content_height); while end > start && self @@ -1272,11 +1284,36 @@ impl Chat { if content_line >= start && content_line < end { return Some(idx); } + + idx = block.end.max(idx.saturating_add(1)); } None } + fn message_block_line_range( + &self, + idx: usize, + positions: &[usize], + content_height: usize, + ) -> Option<(usize, usize)> { + let message = self.messages.get(idx)?; + if crate::session::compaction::is_compaction_summary(message) { + return None; + } + + let block = crate::session::types::logical_message_block_range(&self.messages, idx)?; + let start = positions.get(block.start).copied()?; + let end = positions + .iter() + .copied() + .skip(block.end) + .find(|&next_start| next_start > start) + .unwrap_or(content_height); + + (end > start).then_some((start, end)) + } + fn update_scrollbar(&mut self) { let max_offset = self.content_height.saturating_sub(self.viewport_height); let content_length = max_offset.saturating_add(1).max(1); @@ -1567,28 +1604,26 @@ impl Chat { let visible_start = clamped_scroll.min(content_height); let visible_end = content_height.min(clamped_scroll.saturating_add(viewport)); - let highlight_range = self.highlighted_message_index.and_then(|hl| { - if hl < positions.len() { - let start = positions[hl]; - let end = if hl + 1 < positions.len() { - positions[hl + 1] - } else { - content_height - }; - (end > start).then_some((start, end)) - } else { - None - } - }); + let highlight_range = self + .highlighted_message_index + .and_then(|hl| self.message_block_line_range(hl, positions, content_height)); let visible_highlight_range = trim_trailing_blank_highlight_lines(highlight_range, all_lines); + let highlight_bg = self + .highlighted_message_index + .and_then(|idx| { + crate::session::types::logical_message_block_start(&self.messages, idx) + .and_then(|start| self.messages.get(start)) + }) + .map(|message| timeline_highlight_bg(message, colors)) + .unwrap_or(colors.interactive); let mut content_lines: Vec> = all_lines[visible_start..visible_end].to_vec(); apply_timeline_highlight_to_lines( &mut content_lines, visible_highlight_range, visible_start, - colors.interactive, + highlight_bg, ); let render_area = Rect { @@ -1625,7 +1660,7 @@ impl Chat { width: content_area.width, height, }; - let hl_block = Block::new().style(Style::default().bg(colors.interactive)); + let hl_block = Block::new().style(Style::default().bg(highlight_bg)); f.render_widget(hl_block, hl_area); } } @@ -3079,8 +3114,7 @@ fn apply_timeline_highlight_to_lines( return; }; - let fg = contrast_text(bg); - let highlight_style = Style::default().fg(fg).bg(bg); + let highlight_style = Style::default().bg(bg); for (line_idx, line) in lines.iter_mut().enumerate() { let global_idx = visible_start + line_idx; @@ -3090,11 +3124,33 @@ fn apply_timeline_highlight_to_lines( line.style = line.style.patch(highlight_style); for span in line.spans.iter_mut() { - span.style = span.style.fg(fg).bg(bg); + span.style = span.style.bg(bg); } } } +fn timeline_highlight_bg(message: &Message, colors: &ThemeColors) -> Color { + if matches!(message.role, MessageRole::Assistant) { + return blend_colors(colors.interactive, colors.background, 0.22) + .unwrap_or(colors.background_element); + } + + colors.interactive +} + +fn blend_colors(foreground: Color, background: Color, alpha: f32) -> Option { + let (Color::Rgb(fr, fg, fb), Color::Rgb(br, bg, bb)) = (foreground, background) else { + return None; + }; + + let alpha = alpha.clamp(0.0, 1.0); + let mix = |front: u8, back: u8| { + ((front as f32 * alpha) + (back as f32 * (1.0 - alpha))).round() as u8 + }; + + Some(Color::Rgb(mix(fr, br), mix(fg, bg), mix(fb, bb))) +} + fn trim_trailing_blank_highlight_lines( highlight_range: Option<(usize, usize)>, lines: &[Line<'_>], @@ -3427,6 +3483,67 @@ mod tests { ); } + #[test] + fn click_hit_test_maps_assistant_turn_rows_to_block_start() { + let mut chat = Chat::with_messages(vec![ + Message::user("Prompt"), + Message::assistant("I will check."), + Message::tool( + serde_json::json!({ + "name": "bash", + "status": "ok", + "output_preview": "tests passed", + }) + .to_string(), + ), + Message::assistant("Done."), + Message::user("Next prompt"), + ]); + let colors = test_colors(); + let (lines, positions) = chat.build_all_lines_with_positions(80, "model", &colors); + let content_height = lines.len(); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.message_line_positions = positions.clone(); + chat.content_height = content_height; + chat.viewport_height = 20; + chat.scroll_offset = 0; + + let assistant_range = chat + .message_block_line_range(1, &positions, content_height) + .expect("assistant block range"); + + assert!(assistant_range.0 <= positions[2]); + assert!(positions[3] < assistant_range.1); + assert_eq!( + chat.message_index_at_content_line(positions[2], content_height), + Some(1) + ); + assert_eq!( + chat.message_index_at_content_line(positions[3], content_height), + Some(1) + ); + assert_eq!( + chat.message_index_at_content_line(positions[4], content_height), + Some(4) + ); + } + + #[test] + fn assistant_timeline_highlight_uses_muted_interactive_color() { + let mut colors = test_colors(); + colors.interactive = Color::Rgb(100, 50, 200); + colors.background = Color::Rgb(10, 10, 10); + + assert_eq!( + timeline_highlight_bg(&Message::assistant("Answer"), &colors), + Color::Rgb(30, 19, 52) + ); + assert_eq!( + timeline_highlight_bg(&Message::user("Prompt"), &colors), + colors.interactive + ); + } + #[test] fn test_render_fingerprint_changes_for_same_length_content_mutation() { let mut chat = Chat::new(); From 7bc25721bdf904982da533477aa3c7a87267b29d Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 01:14:02 +0800 Subject: [PATCH 147/226] feat: queue messages sent while streaming and auto-submit after current turn. Messages typed while the assistant is still streaming are now enqueued and automatically submitted once the current stream completes (or after the next tool result). Pressing Esc while messages are queued interrupts the current stream and submits them immediately. Additionally: - Double-Esc with empty input opens the timeline dialog - Input cursor color matches the agent color - Fix input background rendering for transparent themes - Remove duplicate src/themes from theme discovery --- _plans/__TODOS.md | 8 +- src/app.rs | 524 +++++++++++++++++++++++++++++++++--- src/config/configuration.rs | 1 - src/ui/components/input.rs | 92 ++++++- src/views/chat.rs | 193 ++++++++++++- 5 files changed, 759 insertions(+), 59 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index a66c559..e608186 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -42,7 +42,7 @@ - [x] Bug: Timeline livescroll and actual chat UI consistency - make them the same. -- [ ] Parity: Like opencode, I wanna be able to queue messages. By sending some message even though it's still streaming, won't stop the agent, will just keep going. +- [x] Parity: Like opencode, I wanna be able to queue messages. By sending some message even though it's still streaming, won't stop the agent, will just keep going. - [x] Markdown: Proper Table rendering. @@ -110,15 +110,15 @@ - [x] Read my <> (ask for permission), deny. The chat doesn't get persisted, just gone. Please save everything before errors. So we can easily say "continue" -- [ ] wysiwyg double escape to G +- [x] wysiwyg double escape to G -- [ ] Compaction logic is a little broken. I did /compact, and the context compacted is ALWAYS at the bottom. instead of just at the part where it tried to compact the messages. Can we study how codex and opencode do it? +- [ ] Compaction logic is a little broken. I did /compact, and the context compacted is ALWAYS at the bottom. instead of just at the part where it tried to compact the messages. Can we study how codex and opencode do it? meaning if I send a new message after compacting. The "compacted" label is still at the bottom of that most recent message - [x] When a message is sent, the [Image #1] or [Image #2] tags, become just white, not the unique color we have for them in the chat input box. - [x] Syntax highlighting during "Edited" tool calls for diffs. Check how Codex does it, because it has syntax highlighting for some reason--It's very clean. -- [ ] I also think the /copy transcript should show "Edit" tool call results no? Right now it looks as simple as: +- [x] I also think the /copy transcript should show "Edit" tool call results no? Right now it looks as simple as: **Tool Result** **Tool:** edit diff --git a/src/app.rs b/src/app.rs index a2149ad..3c0fc43 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,7 +17,8 @@ use crate::ui::components::popup::Popup; use crate::utils::git; use crate::views::chat::{ - agent_color_for_tab, init_chat, render_chat, SubagentTab, SubagentTabs, SUBAGENT_FOOTER_HEIGHT, + agent_color_for_tab, init_chat, queued_messages_height, render_chat, SubagentTab, SubagentTabs, + SUBAGENT_FOOTER_HEIGHT, }; use crate::views::command_palette::{ handle_command_palette_key_event, handle_command_palette_mouse_event, init_command_palette, @@ -184,9 +185,16 @@ struct ClientSessionState { stream: Option, external_stream: Option, tool_calls: ToolCallViewState, + queued_messages: std::collections::VecDeque, unread_completed: bool, } +#[derive(Debug, Clone)] +struct QueuedUserMessage { + text: String, + image_paths: Vec, +} + impl ClientSessionState { fn with_messages(messages: Vec) -> Self { Self { @@ -195,6 +203,7 @@ impl ClientSessionState { stream: None, external_stream: None, tool_calls: ToolCallViewState::default(), + queued_messages: std::collections::VecDeque::new(), unread_completed: false, } } @@ -225,6 +234,7 @@ pub struct App { pub storage_dialog_state: StorageDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, pub timeline_dialog_state: crate::views::timeline_dialog::TimelineDialogState, + esc_timeline_primed: bool, pub message_actions_index: Option, pub message_actions_dialog: Option, message_actions_return_focus: OverlayFocus, @@ -444,6 +454,7 @@ impl App { storage_dialog_state, which_key_state, timeline_dialog_state, + esc_timeline_primed: false, message_actions_index: None, message_actions_dialog: None, message_actions_return_focus: OverlayFocus::TimelineDialog, @@ -681,6 +692,12 @@ impl App { } else { BaseFocus::Chat }; + if !is_child_session + && self.has_queued_messages_for_session(session_id) + && !self.session_has_active_stream(session_id) + { + self.submit_queued_messages_for_session(session_id); + } true } @@ -862,6 +879,74 @@ impl App { .and_then(|state| state.stream.as_mut()) } + fn session_has_active_stream(&self, session_id: &str) -> bool { + self.session_view_states + .get(session_id) + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()) + } + + fn queued_message_previews_for_current_session(&self) -> Vec { + let Some(session_id) = self.session_manager.get_current_session_id() else { + return Vec::new(); + }; + + self.session_view_states + .get(session_id) + .map(|state| { + state + .queued_messages + .iter() + .map(Self::queued_message_preview) + .collect() + }) + .unwrap_or_default() + } + + fn queued_message_preview(message: &QueuedUserMessage) -> String { + if !message.text.trim().is_empty() { + return message.text.replace('\n', " "); + } + + match message.image_paths.len() { + 0 => String::new(), + 1 => "[Image]".to_string(), + count => format!("[{} images]", count), + } + } + + fn has_queued_messages_for_session(&self, session_id: &str) -> bool { + self.session_view_states + .get(session_id) + .is_some_and(|state| !state.queued_messages.is_empty()) + } + + fn queue_message_for_current_session( + &mut self, + text: String, + image_paths: Vec, + ) -> bool { + let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { + return false; + }; + + self.ensure_session_view_state(&session_id); + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state + .queued_messages + .push_back(QueuedUserMessage { text, image_paths }); + return true; + } + + false + } + + fn drain_queued_messages_for_session(&mut self, session_id: &str) -> Vec { + self.session_view_states + .get_mut(session_id) + .map(|state| state.queued_messages.drain(..).collect()) + .unwrap_or_default() + } + fn streaming_boundary_for_session( &self, session_id: &str, @@ -1252,6 +1337,12 @@ impl App { } else { 1 }; + let queued_messages = self.queued_message_previews_for_current_session(); + let queue_height = if self.is_subagent_session_active() { + 0 + } else { + queued_messages_height(&queued_messages) + }; let above_status_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints( @@ -1259,6 +1350,7 @@ impl App { ratatui::layout::Constraint::Length(0), ratatui::layout::Constraint::Min(0), ratatui::layout::Constraint::Length(0), + ratatui::layout::Constraint::Length(queue_height), ratatui::layout::Constraint::Length(input_height), ratatui::layout::Constraint::Length(help_height), ratatui::layout::Constraint::Length(1), @@ -1271,6 +1363,10 @@ impl App { } pub fn handle_keys(&mut self, key: KeyEvent) { + if key.code != KeyCode::Esc { + self.reset_esc_timeline_state(); + } + if key.code == KeyCode::Char('p') && key.modifiers == event::KeyModifiers::CONTROL && matches!( @@ -1976,19 +2072,28 @@ impl App { KeyCode::Esc => { // If text is selected, clear selection first if self.clear_selection() { + self.reset_esc_timeline_state(); return true; } if self.is_streaming { + self.reset_esc_timeline_state(); + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() + { + if self.interrupt_streaming_to_send_queued_for_session(&session_id) { + return true; + } + } self.cancel_streaming(); return true; } if self.overlay_focus == OverlayFocus::SuggestionsPopup { + self.reset_esc_timeline_state(); self.input.clear(); clear_suggestions(&mut self.suggestions_popup_state); self.overlay_focus = OverlayFocus::None; true } else { - false + self.handle_timeline_esc_key(key) } } KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { @@ -2015,6 +2120,30 @@ impl App { self.chat_state.wave_spinner.set_color(agent_color); } + fn reset_esc_timeline_state(&mut self) { + self.esc_timeline_primed = false; + } + + fn handle_timeline_esc_key(&mut self, key: KeyEvent) -> bool { + if key.modifiers != event::KeyModifiers::NONE + || self.base_focus != BaseFocus::Chat + || !self.input.is_empty() + || self.is_subagent_session_active() + { + self.reset_esc_timeline_state(); + return false; + } + + if self.esc_timeline_primed { + self.reset_esc_timeline_state(); + self.open_timeline_dialog(); + } else { + self.esc_timeline_primed = true; + } + + true + } + fn handle_input_and_app_keys(&mut self, key: KeyEvent) { // If chat text is selected and user presses a key, clear the selection // (unless it's Ctrl+C or Escape which are handled earlier) @@ -2034,10 +2163,6 @@ impl App { use crate::command::parser::parse_input; let input_type = parse_input(&input_text); - if !Self::can_submit_input(&input_type, self.is_streaming) { - return; - } - match input_type { crate::command::parser::InputType::Command(parsed) => { // Don't save commands to prompt history @@ -2051,7 +2176,20 @@ impl App { if image_paths.is_empty() { self.input.save_current_to_history(); } - self.handle_message_input_with_images(msg, image_paths); + let active_session_streaming = self + .session_manager + .get_current_session_id() + .is_some_and(|id| self.session_has_active_stream(id)); + if self.is_streaming && active_session_streaming { + self.queue_message_for_current_session( + msg.to_string(), + image_paths, + ); + } else if !self.is_streaming { + self.handle_message_input_with_images(msg, image_paths); + } else { + return; + } } } @@ -2094,18 +2232,26 @@ impl App { .constraints([ratatui::layout::Constraint::Min(0)].as_ref()) .split(self.last_frame_size); let input_height = self.input.get_height_for_width(self.last_frame_size.width); + let queued_messages = self.queued_message_previews_for_current_session(); + let queue_height = + if self.base_focus == BaseFocus::Chat && !self.is_subagent_session_active() { + queued_messages_height(&queued_messages) + } else { + 0 + }; let input_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints( [ ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(queue_height), ratatui::layout::Constraint::Length(input_height), ] .as_ref(), ) .split(main_chunks[0]); - input_chunks[1] + input_chunks[2] } fn handle_input_mouse_event(&mut self, mouse: MouseEvent) -> bool { @@ -3693,6 +3839,8 @@ impl App { } fn open_timeline_dialog(&mut self) { + self.reset_esc_timeline_state(); + let messages: Vec = match self.session_manager.get_current_session() { Some(s) => s.messages.clone(), @@ -4694,11 +4842,38 @@ impl App { return; }; + self.cancel_streaming_for_session(&session_id); + } + + fn cancel_streaming_for_session(&mut self, session_id: &str) { if let Some(stream) = self.stream_for_session_mut(&session_id) { stream.cancel_token.cancel(); } } + fn interrupt_streaming_to_send_queued_for_session(&mut self, session_id: &str) -> bool { + if !self.is_active_session(session_id) + || !self.has_queued_messages_for_session(session_id) + || !self.session_has_active_stream(session_id) + { + return false; + } + + self.cancel_streaming_for_session(session_id); + self.mark_streamed_assistant_interrupted(session_id); + let _ = self.finalize_and_persist_streamed_messages( + session_id, + Some("Streaming interrupted to send queued messages"), + ); + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Interrupted, + None, + ); + self.cleanup_streaming_for_session(session_id); + self.submit_queued_messages_for_session(session_id) + } + pub fn update_animations(&mut self) { // Only update animations at 20fps (50ms intervals) regardless of render rate const ANIMATION_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); @@ -4758,8 +4933,16 @@ impl App { } } + let mut keep_current_stream = true; for chunk in chunks { - self.process_streaming_chunk_for_session(&session_id, chunk); + if !self.process_streaming_chunk_for_session(&session_id, chunk) { + keep_current_stream = false; + break; + } + } + + if !keep_current_stream { + disconnected = false; } if disconnected @@ -4793,36 +4976,43 @@ impl App { &mut self, session_id: &str, chunk: crate::llm::ChunkMessage, - ) { + ) -> bool { match chunk { crate::llm::ChunkMessage::Text(text) => { if let Some(chat) = self.chat_for_session_mut(session_id) { chat.append_to_last_assistant(&text); } + true } crate::llm::ChunkMessage::Reasoning(reasoning) => { if let Some(chat) = self.chat_for_session_mut(session_id) { chat.append_reasoning_to_last_assistant(&reasoning); } + true } crate::llm::ChunkMessage::Warning(msg) => { push_toast(Toast::new(msg, ToastLevel::Warning, None)); + true } crate::llm::ChunkMessage::End => { self.finish_streaming_session(session_id); + false } crate::llm::ChunkMessage::Failed(error) => { self.fail_streaming_session(session_id, error); + false } crate::llm::ChunkMessage::Cancelled => { self.cancelled_streaming_session(session_id); + false } - crate::llm::ChunkMessage::Metrics { .. } => {} + crate::llm::ChunkMessage::Metrics { .. } => true, crate::llm::ChunkMessage::ToolCalls(tool_calls) => { self.add_tool_calls_to_session(session_id, tool_calls); + true } crate::llm::ChunkMessage::ToolResult(result) => { - self.add_tool_result_to_session(session_id, result); + self.add_tool_result_to_session(session_id, result) } crate::llm::ChunkMessage::SubagentStarted { parent_session_id, @@ -4840,9 +5030,11 @@ impl App { description, prompt, ); + true } crate::llm::ChunkMessage::SubagentChunk { session_id, chunk } => { - self.process_streaming_chunk_for_session(&session_id, *chunk); + let _ = self.process_streaming_chunk_for_session(&session_id, *chunk); + true } crate::llm::ChunkMessage::PermissionRequest(prompt) => { let _ = self.session_manager.set_session_status( @@ -4859,6 +5051,7 @@ impl App { } self.permission_dialog_state.enqueue(prompt); self.overlay_focus = OverlayFocus::PermissionDialog; + true } crate::llm::ChunkMessage::QuestionRequest { questions, @@ -4878,6 +5071,7 @@ impl App { } self.question_dialog_state.enqueue(questions, response_tx); self.overlay_focus = OverlayFocus::QuestionDialog; + true } } } @@ -4969,6 +5163,9 @@ impl App { } self.cleanup_streaming_for_session(session_id); + if self.submit_queued_messages_for_session(session_id) { + return; + } self.play_sound_event_with_notification_detail( crate::sound::SoundEvent::Complete, completion_stats.as_deref(), @@ -4992,14 +5189,14 @@ impl App { true } - fn finish_deferred_streaming_session_if_ready(&mut self, session_id: &str) { + fn finish_deferred_streaming_session_if_ready(&mut self, session_id: &str) -> bool { let deferred = self .session_view_states .get(session_id) .is_some_and(|state| state.tool_calls.deferred_finish); if !deferred || self.session_has_running_tool_messages(session_id) { - return; + return false; } if let Some(state) = self.session_view_states.get_mut(session_id) { @@ -5007,6 +5204,7 @@ impl App { } self.finish_streaming_session(session_id); + true } fn session_has_running_tool_messages(&self, session_id: &str) -> bool { @@ -5148,6 +5346,7 @@ impl App { None, )); self.cleanup_streaming_for_session(session_id); + self.submit_queued_messages_for_session(session_id); } fn cancelled_streaming_session(&mut self, session_id: &str) { @@ -5168,6 +5367,7 @@ impl App { push_toast(Toast::new("Streaming cancelled", ToastLevel::Info, None)); self.cleanup_streaming_for_session(session_id); + self.submit_queued_messages_for_session(session_id); } fn add_tool_calls_to_session( @@ -5222,7 +5422,11 @@ impl App { } } - fn add_tool_result_to_session(&mut self, session_id: &str, result: crate::llm::ToolCallResult) { + fn add_tool_result_to_session( + &mut self, + session_id: &str, + result: crate::llm::ToolCallResult, + ) -> bool { let target_idx = self.session_view_states.get(session_id).and_then(|state| { state .tool_calls @@ -5299,7 +5503,16 @@ impl App { } } - self.finish_deferred_streaming_session_if_ready(session_id); + if self.finish_deferred_streaming_session_if_ready(session_id) { + return false; + } + if self.session_has_active_stream(session_id) + && self.has_queued_messages_for_session(session_id) + && !self.session_has_running_tool_messages(session_id) + { + return !self.interrupt_streaming_to_send_queued_for_session(session_id); + } + true } fn start_llm_streaming( @@ -5442,6 +5655,55 @@ impl App { self.handle_message_input_with_images(msg, Vec::new()); } + fn append_user_message_to_current_session( + &mut self, + msg: String, + image_paths: Vec, + ) { + let mut user_message = crate::session::types::Message::user(&msg); + user_message.local_image_paths = image_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(); + user_message.agent_mode = Some(self.agent.clone()); + user_message.model = Some(self.model.clone()); + user_message.provider = Some(self.provider_name.clone()); + let _ = self + .session_manager + .add_message_to_current_session(&user_message); + self.chat_state.chat.add_message(user_message); + self.cached_usage_check = (usize::MAX, u64::MAX); + } + + fn submit_queued_messages_for_session(&mut self, session_id: &str) -> bool { + if !self.is_active_session(session_id) || self.session_has_active_stream(session_id) { + return false; + } + + let queued_messages = self.drain_queued_messages_for_session(session_id); + if queued_messages.is_empty() { + return false; + } + + self.base_focus = BaseFocus::Chat; + let mut last_text = String::new(); + for queued in queued_messages { + last_text = queued.text.clone(); + self.append_user_message_to_current_session(queued.text, queued.image_paths); + } + + if let Err(e) = self.start_llm_streaming(&last_text) { + push_toast(Toast::new( + format!("LLM error: {}", e), + ToastLevel::Error, + None, + )); + return false; + } + + true + } + fn run_custom_command_prompt( &mut self, prompt: String, @@ -5497,18 +5759,7 @@ impl App { .unwrap_or_else(|| Self::generate_title_from_message(&msg)); self.create_new_session(Some(session_title)); } - let mut user_message = crate::session::types::Message::user(&msg); - user_message.local_image_paths = image_paths - .iter() - .map(|path| path.to_string_lossy().to_string()) - .collect(); - user_message.agent_mode = Some(self.agent.clone()); - user_message.model = Some(self.model.clone()); - user_message.provider = Some(self.provider_name.clone()); - let _ = self - .session_manager - .add_message_to_current_session(&user_message); - self.chat_state.chat.add_message(user_message.clone()); + self.append_user_message_to_current_session(msg.clone(), image_paths); self.base_focus = BaseFocus::Chat; if let Err(e) = self.start_llm_streaming(&msg) { @@ -5523,18 +5774,7 @@ impl App { if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { self.ensure_session_view_state(&session_id); } - let mut user_message = crate::session::types::Message::user(&msg); - user_message.local_image_paths = image_paths - .iter() - .map(|path| path.to_string_lossy().to_string()) - .collect(); - user_message.agent_mode = Some(self.agent.clone()); - user_message.model = Some(self.model.clone()); - user_message.provider = Some(self.provider_name.clone()); - let _ = self - .session_manager - .add_message_to_current_session(&user_message); - self.chat_state.chat.add_message(user_message.clone()); + self.append_user_message_to_current_session(msg.clone(), image_paths); if let Err(e) = self.start_llm_streaming(&msg) { push_toast(Toast::new( @@ -5597,6 +5837,7 @@ impl App { } BaseFocus::Chat => { let subagent_tabs = self.subagent_tabs_for_current_session(); + let queued_messages = self.queued_message_previews_for_current_session(); render_chat( f, &mut self.chat_state, @@ -5613,6 +5854,7 @@ impl App { self.compaction_receiver.is_some(), &usage_text, subagent_tabs, + &queued_messages, ); if is_suggestions_visible(&self.suggestions_popup_state) @@ -5861,6 +6103,7 @@ mod tests { storage_dialog_state: init_storage_dialog(), which_key_state: crate::views::which_key::init_which_key(), timeline_dialog_state: crate::views::timeline_dialog::init_timeline_dialog(), + esc_timeline_primed: false, message_actions_index: None, message_actions_dialog: None, message_actions_return_focus: OverlayFocus::TimelineDialog, @@ -5914,6 +6157,80 @@ mod tests { .unwrap_or_default() } + fn add_current_session_message(app: &mut App, message: crate::session::types::Message) { + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + } + + #[test] + fn double_esc_opens_timeline_at_most_recent_message() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); + add_current_session_message( + &mut app, + crate::session::types::Message::assistant("Answer"), + ); + + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert!(!app.timeline_dialog_state.dialog.is_visible()); + + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.overlay_focus, OverlayFocus::TimelineDialog); + assert!(app.timeline_dialog_state.dialog.is_visible()); + assert_eq!( + app.timeline_dialog_state + .dialog + .get_selected() + .map(|item| item.id.as_str()), + Some("1") + ); + assert_eq!(app.chat_state.chat.highlighted_message_index, Some(1)); + } + + #[test] + fn non_esc_key_clears_pending_double_esc_timeline_open() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); + + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.handle_keys(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + app.input.clear(); + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert!(!app.timeline_dialog_state.dialog.is_visible()); + + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.overlay_focus, OverlayFocus::TimelineDialog); + assert!(app.timeline_dialog_state.dialog.is_visible()); + } + + #[test] + fn esc_with_draft_does_not_prime_timeline_open() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); + + app.input.set_text("draft"); + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.input.clear(); + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert!(!app.timeline_dialog_state.dialog.is_visible()); + } + #[test] fn message_block_clipboard_text_includes_assistant_turn_parts() { let mut assistant = crate::session::types::Message::assistant("Final answer"); @@ -6058,6 +6375,131 @@ mod tests { assert!(App::can_submit_input(&input_type, false)); } + #[test] + fn messages_entered_while_streaming_are_queued() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue".to_string())); + app.base_focus = BaseFocus::Chat; + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.session_view_states.get_mut(&session_id).unwrap().stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 0, + }); + app.is_streaming = true; + app.input.insert_str("Then about riolu"); + + app.handle_keys(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert_eq!(state.queued_messages.len(), 1); + assert_eq!(state.queued_messages[0].text, "Then about riolu"); + assert_eq!( + app.queued_message_previews_for_current_session(), + vec!["Then about riolu".to_string()] + ); + assert!(app.input.is_empty()); + assert!(app.chat_state.chat.messages.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn queued_messages_cancel_stream_after_next_tool_result() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue after tool".to_string())); + app.base_focus = BaseFocus::Chat; + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "bash", + "status": "running", + }) + .to_string(), + )); + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let observed_cancel_token = cancel_token.clone(); + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token, + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 0, + }); + state + .tool_calls + .tool_call_message_indices + .insert("call_1".to_string(), 0); + state.tool_calls.tool_call_order.push("call_1".to_string()); + state.queued_messages.push_back(QueuedUserMessage { + text: "then about pikachu".to_string(), + image_paths: Vec::new(), + }); + app.is_streaming = true; + + app.add_tool_result_to_session( + &session_id, + crate::llm::ToolCallResult { + tool_call_id: "call_1".to_string(), + role: "tool".to_string(), + name: "bash".to_string(), + content: "done".to_string(), + }, + ); + + assert!(observed_cancel_token.is_cancelled()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn esc_with_queued_messages_interrupts_and_submits_immediately() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue esc".to_string())); + app.base_focus = BaseFocus::Chat; + let boundary = app.chat_state.chat.messages.len(); + app.chat_state + .chat + .add_assistant_message("partial response"); + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let observed_cancel_token = cancel_token.clone(); + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token, + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: boundary, + }); + state.queued_messages.push_back(QueuedUserMessage { + text: "Then about riolu".to_string(), + image_paths: Vec::new(), + }); + app.is_streaming = true; + + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(observed_cancel_token.is_cancelled()); + assert!(app + .session_view_states + .get(&session_id) + .unwrap() + .queued_messages + .is_empty()); + assert!(app + .chat_state + .chat + .messages + .iter() + .any( + |message| message.role == crate::session::types::MessageRole::User + && message.content == "Then about riolu" + )); + } + #[test] fn failed_stream_persists_partial_messages() { let mut app = test_app(); diff --git a/src/config/configuration.rs b/src/config/configuration.rs index e19f1f4..8c432a0 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -24,7 +24,6 @@ pub fn discover_themes( if PathBuf::from("src/theme.json").is_file() { built_in.push(PathBuf::from("src/theme.json")); } - built_in.extend(list_json_files(Path::new("src/themes"))); built_in.extend(list_json_files(Path::new("src/generated_themes"))); layers.push(built_in); diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 103e5a7..1f17bdd 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -1,7 +1,7 @@ use crate::autocomplete::{AutoComplete, Suggestion, SuggestionKind}; use crate::persistence::PromptHistoryCache; use crate::push_toast; -use crate::theme::{agent_color, ThemeColors}; +use crate::theme::{agent_color, contrast_text, ThemeColors}; use crate::toast::{Toast, ToastLevel}; use crate::utils::image_attachment; use ratatui::buffer::Buffer; @@ -9,6 +9,7 @@ use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::prelude::{Rect, Style}; +use ratatui::style::{Color, Modifier}; use ratatui::symbols::border; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Paragraph}; @@ -138,6 +139,10 @@ impl Input { reasoning_effort: Option<&str>, colors: &ThemeColors, ) { + if area.width == 0 || area.height == 0 { + return; + } + let agent_color = agent_color(agent, colors); let border_set = border::Set { @@ -187,6 +192,8 @@ impl Input { self.textarea .set_selection_style(Style::default().bg(colors.accent).fg(colors.text)); + self.textarea + .set_cursor_style(input_cursor_style(agent_color)); self.textarea.set_style( Style::default() .fg(colors.text) @@ -227,12 +234,18 @@ impl Input { frame.render_widget(border, area); - let cap_row = Paragraph::new(ratatui::text::Line::from(vec![ - ratatui::text::Span::styled("╹", Style::default().fg(agent_color)), + let cap_fill_width = area.width.saturating_sub(1) as usize; + let cap_fill = if colors.background_element == Color::Reset { + ratatui::text::Span::raw(" ".repeat(cap_fill_width)) + } else { ratatui::text::Span::styled( - "▀".repeat(area.width as usize - 1), + "▀".repeat(cap_fill_width), Style::default().fg(colors.background_element), - ), + ) + }; + let cap_row = Paragraph::new(ratatui::text::Line::from(vec![ + ratatui::text::Span::styled("╹", Style::default().fg(agent_color)), + cap_fill, ])); let cap_row_area = Rect::new(area.x, v_chunks[4].y, area.width, 1); frame.render_widget(cap_row, cap_row_area); @@ -1580,6 +1593,14 @@ impl Input { } } +fn input_cursor_style(color: Color) -> Style { + if color == Color::Reset { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default().bg(color).fg(contrast_text(color)) + } +} + impl Default for Input { fn default() -> Self { Self::new() @@ -1911,6 +1932,67 @@ mod tests { assert!(second_input_row.contains('F')); } + #[test] + fn test_transparent_input_background_does_not_render_cap_strip() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + let mut colors = test_colors(); + colors.background_element = Color::Reset; + + let backend = TestBackend::new(20, 6); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 20, 6), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + assert!(!buffer_row_text(buffer, 20, 4).contains('▀')); + } + + #[test] + fn test_input_cursor_uses_agent_color() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + let mut colors = test_colors(); + colors.secondary = Color::Rgb(238, 121, 72); + + let backend = TestBackend::new(20, 6); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 20, 6), + "Build", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let cursor_cell = buffer.cell((3, 1)).expect("cursor cell").style(); + assert_eq!(cursor_cell.bg, Some(colors.secondary)); + assert_eq!( + cursor_cell.fg, + Some(crate::theme::contrast_text(colors.secondary)) + ); + } + #[test] fn test_wrapped_input_and_paste_increase_height_like_newlines() { let mut newline_input = Input::new(); diff --git a/src/views/chat.rs b/src/views/chat.rs index d30357d..72ed34e 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -1,11 +1,12 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, + style::{Color, Modifier, Style}, symbols::border, - text::{Line, Span}, + text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph}, Frame, }; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::theme::ThemeColors; use crate::ui::components::chat::Chat; @@ -14,6 +15,9 @@ use crate::ui::components::status_bar::StatusBar; use crate::ui::components::wave_spinner::WaveSpinner; pub const SUBAGENT_FOOTER_HEIGHT: u16 = 3; +const QUEUED_MESSAGES_MAX_VISIBLE: usize = 3; +const QUEUED_MESSAGES_TOP_PADDING: u16 = 1; +const QUEUED_MESSAGES_BOTTOM_PADDING: u16 = 1; #[derive(Debug)] pub struct ChatState { @@ -79,6 +83,7 @@ pub fn render_chat( is_compacting: bool, usage_text: &str, subagent_tabs: Option, + queued_messages: &[String], ) { let size = f.area(); let is_subagent_view = subagent_tabs @@ -96,6 +101,11 @@ pub fn render_chat( input.get_height_for_width(size.width) }; let help_height = if is_subagent_view { 0 } else { 1 }; + let queue_height = if is_subagent_view { + 0 + } else { + queued_messages_height(queued_messages) + }; let above_status_chunks = Layout::default() .direction(Direction::Vertical) .constraints( @@ -103,6 +113,7 @@ pub fn render_chat( Constraint::Length(0), // Reserved subagent header removed Constraint::Min(0), // Chat content Constraint::Length(0), // Bottom padding + Constraint::Length(queue_height), Constraint::Length(input_height), Constraint::Length(help_height), Constraint::Length(1), @@ -119,7 +130,7 @@ pub fn render_chat( if let Some(tabs) = subagent_tabs.as_ref() { render_subagent_footer( f, - above_status_chunks[3], + above_status_chunks[4], tabs, usage_text, colors, @@ -129,9 +140,11 @@ pub fn render_chat( ); } } else { + render_queued_messages(f, above_status_chunks[3], queued_messages, &agent, colors); + input.render( f, - above_status_chunks[3], + above_status_chunks[4], &agent, &model, &provider_name, @@ -142,7 +155,7 @@ pub fn render_chat( if is_subagent_view { let blank = Block::default(); - f.render_widget(blank, above_status_chunks[5]); + f.render_widget(blank, above_status_chunks[6]); let status_bar = StatusBar::new(version, cwd, branch, agent, model); status_bar.render(f, main_chunks[1], colors); @@ -155,7 +168,7 @@ pub fn render_chat( ]; let help_line = Line::from(help_text); let help_width = help_line.width() as u16; - let available_width = above_status_chunks[4].width; + let available_width = above_status_chunks[5].width; let help_width = help_width.min(available_width); let usage_width = if !usage_text.is_empty() { @@ -170,7 +183,7 @@ pub fn render_chat( Constraint::Length(usage_width), Constraint::Length(help_width), ]) - .split(above_status_chunks[4]); + .split(above_status_chunks[5]); if is_streaming { let agent_color = crate::theme::agent_color(&agent, colors); @@ -233,12 +246,176 @@ pub fn render_chat( f.render_widget(help, status_chunks[2]); let blank = Block::default(); - f.render_widget(blank, above_status_chunks[5]); + f.render_widget(blank, above_status_chunks[6]); let status_bar = StatusBar::new(version, cwd, branch, agent, model); status_bar.render(f, main_chunks[1], colors); } +pub fn queued_messages_height(messages: &[String]) -> u16 { + if messages.is_empty() { + return 0; + } + + let visible_messages = messages.len().min(QUEUED_MESSAGES_MAX_VISIBLE); + let overflow_line = usize::from(messages.len() > QUEUED_MESSAGES_MAX_VISIBLE); + QUEUED_MESSAGES_TOP_PADDING + + (1 + visible_messages + overflow_line) as u16 + + QUEUED_MESSAGES_BOTTOM_PADDING +} + +fn render_queued_messages( + f: &mut Frame, + area: Rect, + messages: &[String], + agent: &str, + colors: &ThemeColors, +) { + if messages.is_empty() || area.width == 0 || area.height == 0 { + return; + } + + let agent_color = crate::theme::agent_color(agent, colors); + let border_set = border::Set { + vertical_left: "┃", + ..border::PLAIN + }; + let border = Block::new() + .borders(Borders::LEFT) + .border_set(border_set) + .border_style(Style::default().fg(agent_color)); + let inner_area = border.inner(area); + let queue_bg = queued_messages_background(colors); + let bg = Block::default().style(Style::default().bg(queue_bg)); + f.render_widget(bg, area); + f.render_widget(border, area); + + let content_area = Rect { + x: inner_area.x.saturating_add(2), + y: inner_area.y.saturating_add(QUEUED_MESSAGES_TOP_PADDING), + width: inner_area.width.saturating_sub(3), + height: inner_area + .height + .saturating_sub(QUEUED_MESSAGES_TOP_PADDING + QUEUED_MESSAGES_BOTTOM_PADDING), + }; + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let mut lines = Vec::new(); + let hint = "esc to interrupt and send immediately"; + let title = "Messages to submit after next tool call"; + let title_width = 2 + UnicodeWidthStr::width(title); + let hint_width = UnicodeWidthStr::width(hint); + let show_hint = content_area.width as usize >= title_width + hint_width + 4; + + let mut header_spans = vec![ + Span::styled("•", Style::default().fg(agent_color)), + Span::raw(" "), + Span::styled( + title, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::BOLD), + ), + ]; + if show_hint { + let spacer_width = content_area + .width + .saturating_sub((title_width + hint_width) as u16); + header_spans.push(Span::raw(" ".repeat(spacer_width as usize))); + header_spans.push(Span::styled( + hint, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + lines.push(Line::from(header_spans)); + + let message_width = content_area.width.saturating_sub(4) as usize; + for message in messages.iter().take(QUEUED_MESSAGES_MAX_VISIBLE) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("↳", Style::default().fg(colors.text_weak)), + Span::raw(" "), + Span::styled( + truncate_to_width(message, message_width), + Style::default().fg(colors.text_weak), + ), + ])); + } + + if messages.len() > QUEUED_MESSAGES_MAX_VISIBLE { + let more = messages.len() - QUEUED_MESSAGES_MAX_VISIBLE; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("↳", Style::default().fg(colors.text_weak)), + Span::raw(" "), + Span::styled( + format!("+{} more", more), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ])); + } + + f.render_widget( + Paragraph::new(Text::from(lines)).style(Style::default().bg(queue_bg)), + content_area, + ); +} + +fn queued_messages_background(colors: &ThemeColors) -> Color { + match colors.background_element { + Color::Rgb(r, g, b) => { + let luminance = 0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32; + if luminance > 235.0 { + Color::Rgb( + r.saturating_sub(14), + g.saturating_sub(14), + b.saturating_sub(14), + ) + } else { + Color::Rgb( + r.saturating_add(14), + g.saturating_add(14), + b.saturating_add(14), + ) + } + } + _ if colors.dialog_background != colors.background_element => colors.dialog_background, + _ => colors.background, + } +} + +fn truncate_to_width(value: &str, max_width: usize) -> String { + if UnicodeWidthStr::width(value) <= max_width { + return value.to_string(); + } + + let ellipsis = "..."; + let ellipsis_width = UnicodeWidthStr::width(ellipsis); + if max_width <= ellipsis_width { + return ".".repeat(max_width); + } + + let mut rendered = String::new(); + let mut width = 0; + let target_width = max_width - ellipsis_width; + for ch in value.chars() { + let char_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + char_width > target_width { + break; + } + width += char_width; + rendered.push(ch); + } + rendered.push_str(ellipsis); + rendered +} + fn render_subagent_footer( f: &mut Frame, area: ratatui::layout::Rect, From 6703b778c77c6e67b13388c5addfb849aaa07129 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 01:36:25 +0800 Subject: [PATCH 148/226] refactor(main): theme post-close logo and replace startup diagnostics with logging. - Colorize the post-close logo and session info using the configured theme - Replace lazy_static `STARTUP_DIAGNOSTICS` with direct logging calls - Remove `flush_startup_diagnostics()` calls throughout - Fix hover in chat no longer setting timeline highlight - Handle `Resize` events to trigger redraws --- _plans/__TODOS.md | 8 ++-- src/app.rs | 37 ++++++++++++++-- src/main.rs | 105 ++++++++++++++++++++++++++++++++++------------ 3 files changed, 117 insertions(+), 33 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index e608186..3c65c6a 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -36,7 +36,7 @@ - [x] Feature: Rename command `/rename` - parity with opencode. -- [ ] Bug: theme is not persisted? Or is it by config? Just make theme be based on state now, no more config for it. +- [ ] Bug: theme is not persisted? Or is it by config? Just make theme be based on state now, no more config for it.. Actually not a bug.. Just warn that it must be configured, this is not your configured theme, configure it in your config. Or just a VERY MINOR warning that says 'You're only trying out this theme, set it in your theme'.??? - [x] Bug: skill loading on conflict. i.e. duplicate frontend-design skill. Warning: duplicate skill name 'frontend-design' (existing: /Users/carlo/.claude/skills/frontend-design/SKILL.md, duplicate: /Users/carlo/.config/opencode/skill/frontend-design/SKILL.md) @@ -139,12 +139,12 @@ Replaced at line 239 - [ ] Codex's "update plan" tool sometimes has a weird premble before the actual checklist shows... Is this relevant for crabcode? Should we update our tool? Can we do it too? -- [ ] Pressing 'enter' while focusing on a grouplabel header for a "workspace". Make it show a dropdown on the right: - - Archive (can unarchive on new sessions) +- [x] ~Pressing 'enter' while focusing on a grouplabel header for a "workspace". Make it show a dropdown on the right + - Archive (can unarchive on new sessions)~ - dont do anymore - Collapse - Uncollapse -- [ ] The footer note for the current cwd/workspace. It trims out the very start. i.e. `...ects/_gamedev/my-game:main`. Instead of this, please show the "between" truncation ?? Just maybe, but maybe not. +- [x] ~The footer note for the current cwd/workspace. It trims out the very start. i.e. `...ects/_gamedev/my-game:main`. Instead of this, please show the "between" truncation ??~ Just maybe, but maybe not. - [x] Make tool calls be AS PERMISSIVE, as codex. Meaning won't have to ask me to "read" sometimes. diff --git a/src/app.rs b/src/app.rs index 3c0fc43..7c5587d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2695,9 +2695,6 @@ impl App { .chat .message_index_at_position(mouse, chat_area); self.chat_state.chat.set_hovered_image(hovered_image); - self.chat_state - .chat - .set_highlighted_message(hovered_message); if hovered_message.is_some() { return; } @@ -6301,6 +6298,40 @@ mod tests { assert!(message_action_names(&app).contains(&"Undo".to_string())); } + #[test] + fn hovering_chat_message_does_not_set_timeline_highlight() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + let _session_id = app.create_new_session(Some("Chat hover".to_string())); + app.base_focus = BaseFocus::Chat; + let message = crate::session::types::Message::assistant("hover me"); + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + let colors = app.get_current_theme_colors(); + let positions = app + .chat_state + .chat + .get_message_line_positions(78, &app.model, &colors); + app.chat_state.chat.message_line_positions = positions; + app.chat_state.chat.content_height = 4; + app.chat_state.chat.viewport_height = 18; + app.chat_state.chat.scroll_offset = 0; + assert_eq!( + app.chat_state.chat.message_index_at_position( + mouse(MouseEventKind::Moved, 1, 1), + app.current_chat_area(), + ), + Some(0) + ); + + app.handle_mouse_event(mouse(MouseEventKind::Moved, 1, 1)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert_eq!(app.chat_state.chat.highlighted_message_index, None); + } + #[test] fn closing_direct_chat_message_actions_returns_to_chat() { let mut app = test_app(); diff --git a/src/main.rs b/src/main.rs index a152a5f..a55a172 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,19 +39,19 @@ use ratatui::crossterm::{ LeaveAlternateScreen, }, }; -use ratatui::{backend::CrosstermBackend, Terminal}; +use ratatui::{backend::CrosstermBackend, style::Color, Terminal}; use std::io; use std::sync::Mutex; use std::time::Duration; const POST_CLOSE_LOGO: &str = include_str!("../crabcode-logo.txt"); - -lazy_static::lazy_static! { - static ref STARTUP_DIAGNOSTICS: Mutex> = Mutex::new(Vec::new()); -} +const ANSI_RESET: &str = "\x1b[0m"; +const ANSI_DIM: &str = "\x1b[2m"; pub fn push_startup_diag(msg: String) { - STARTUP_DIAGNOSTICS.lock().unwrap().push(msg); + if crate::logging::enabled() { + let _ = crate::logging::log(&msg); + } } #[macro_export] @@ -61,32 +61,82 @@ macro_rules! startup_diag { }; } -fn flush_startup_diagnostics() { - let diags = std::mem::take(&mut *STARTUP_DIAGNOSTICS.lock().unwrap()); - for msg in diags { - eprintln!("{}", msg); - } -} - struct PostCloseInfo { session_id: String, session_title: String, } -fn format_post_close_message(info: Option<&PostCloseInfo>) -> String { +fn ansi_fg(color: Color) -> String { + match color { + Color::Black => "\x1b[30m".to_string(), + Color::Red => "\x1b[31m".to_string(), + Color::Green => "\x1b[32m".to_string(), + Color::Yellow => "\x1b[33m".to_string(), + Color::Blue => "\x1b[34m".to_string(), + Color::Magenta => "\x1b[35m".to_string(), + Color::Cyan => "\x1b[36m".to_string(), + Color::Gray => "\x1b[37m".to_string(), + Color::DarkGray => "\x1b[90m".to_string(), + Color::LightRed => "\x1b[91m".to_string(), + Color::LightGreen => "\x1b[92m".to_string(), + Color::LightYellow => "\x1b[93m".to_string(), + Color::LightBlue => "\x1b[94m".to_string(), + Color::LightMagenta => "\x1b[95m".to_string(), + Color::LightCyan => "\x1b[96m".to_string(), + Color::White => "\x1b[97m".to_string(), + Color::Indexed(index) => format!("\x1b[38;5;{}m", index), + Color::Rgb(r, g, b) => format!("\x1b[38;2;{};{};{}m", r, g, b), + Color::Reset => String::new(), + } +} + +fn push_colored_logo_line(msg: &mut String, line: &str, primary: &str, secondary: &str) { + let split = line.chars().count() / 2; + + msg.push_str(primary); + for (idx, ch) in line.chars().enumerate() { + if idx == split { + msg.push_str(secondary); + } + msg.push(ch); + } + msg.push_str(ANSI_RESET); + msg.push('\n'); +} + +fn format_post_close_message( + info: Option<&PostCloseInfo>, + colors: &crate::theme::ThemeColors, +) -> String { let mut msg = String::new(); + let logo_primary = format!("{}{}", ANSI_DIM, ansi_fg(colors.text_weak)); + let logo_secondary = ansi_fg(colors.primary); + let label_color = ansi_fg(colors.text_weak); + let value_color = ansi_fg(colors.text); for line in POST_CLOSE_LOGO.lines() { - msg.push_str(line); - msg.push('\n'); + push_colored_logo_line(&mut msg, line, &logo_primary, &logo_secondary); } if let Some(info) = info { msg.push('\n'); - msg.push_str(&format!(" {:<10}{}\n", "Session", info.session_title)); msg.push_str(&format!( - " {:<10}crabcode -s {}\n", - "Continue", info.session_id + " {dim}{label_color}{:<10}{reset}{value_color}{}{reset}\n", + "Session", + info.session_title, + dim = ANSI_DIM, + label_color = label_color, + value_color = value_color, + reset = ANSI_RESET, + )); + msg.push_str(&format!( + " {dim}{label_color}{:<10}{reset}{value_color}crabcode -s {}{reset}\n", + "Continue", + info.session_id, + dim = ANSI_DIM, + label_color = label_color, + value_color = value_color, + reset = ANSI_RESET, )); } @@ -228,7 +278,6 @@ async fn run_print_mode( } } - flush_startup_diagnostics(); let _ = no_session_persistence; Ok(()) } @@ -283,7 +332,6 @@ async fn main() -> Result<()> { if args.print_mode { let prompt = args.prompt.join(" "); if prompt.trim().is_empty() { - flush_startup_diagnostics(); eprintln!("Error: No prompt provided for print mode."); eprintln!("Usage: crabcode -p \"\""); std::process::exit(1); @@ -352,6 +400,8 @@ async fn main() -> Result<()> { } }; + let post_close_colors = app.get_current_theme_colors(); + disable_raw_mode()?; if supports_keyboard_enhancement().unwrap_or(false) { execute!( @@ -373,9 +423,10 @@ async fn main() -> Result<()> { } terminal.show_cursor()?; - flush_startup_diagnostics(); - - print!("{}", format_post_close_message(close_info.as_ref())); + print!( + "{}", + format_post_close_message(close_info.as_ref(), &post_close_colors) + ); result } @@ -476,7 +527,7 @@ async fn run_event_loop( event::Event::FocusLost => { app.set_terminal_focused(false); } - _ => {} + event::Event::Resize(_, _) => {} } } @@ -503,7 +554,9 @@ async fn run_event_loop( event::Event::FocusLost => { app.set_terminal_focused(false); } - _ => {} + event::Event::Resize(_, _) => { + needs_redraw = true; + } } } } From eb641ec3508114a88f7763a83cab71bdf9a6d938 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 01:38:31 +0800 Subject: [PATCH 149/226] refactor: simplify post-close logo styling with single-color lines. Replace the two-tone split-color logo line rendering with a single-color per-line approach, darkening the primary color for the bottom logo line. --- src/main.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index a55a172..cfa7e02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,16 +90,9 @@ fn ansi_fg(color: Color) -> String { } } -fn push_colored_logo_line(msg: &mut String, line: &str, primary: &str, secondary: &str) { - let split = line.chars().count() / 2; - - msg.push_str(primary); - for (idx, ch) in line.chars().enumerate() { - if idx == split { - msg.push_str(secondary); - } - msg.push(ch); - } +fn push_styled_line(msg: &mut String, line: &str, style: &str) { + msg.push_str(style); + msg.push_str(line); msg.push_str(ANSI_RESET); msg.push('\n'); } @@ -109,13 +102,14 @@ fn format_post_close_message( colors: &crate::theme::ThemeColors, ) -> String { let mut msg = String::new(); - let logo_primary = format!("{}{}", ANSI_DIM, ansi_fg(colors.text_weak)); - let logo_secondary = ansi_fg(colors.primary); + let logo_primary = ansi_fg(colors.primary); + let logo_bottom = ansi_fg(crate::theme::darken_color(colors.primary, 0.7)); let label_color = ansi_fg(colors.text_weak); let value_color = ansi_fg(colors.text); - for line in POST_CLOSE_LOGO.lines() { - push_colored_logo_line(&mut msg, line, &logo_primary, &logo_secondary); + for (i, line) in POST_CLOSE_LOGO.lines().enumerate() { + let logo_color = if i == 2 { &logo_bottom } else { &logo_primary }; + push_styled_line(&mut msg, line, logo_color); } if let Some(info) = info { From fa8ed06e0fad6f1f5d3b7f494a6c60679e1702bd Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 01:40:47 +0800 Subject: [PATCH 150/226] fix(ui): apply background color at line level for inline code rows. Previously, inline code backgrounds were set only via spans, leaving the rest of the row unfilled. Setting `line.style.bg` ensures the background color fills the full terminal row width. --- src/ui/components/chat.rs | 50 ++++++++++++++++++++++++++++++++---- src/ui/markdown/streaming.rs | 4 ++- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index efb7cf9..8cea004 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -2038,10 +2038,12 @@ impl Chat { .max(1); let padding_line = || { - Line::from(vec![ + let mut line = Line::from(vec![ Span::styled("▌", border_style), Span::styled(" ".repeat(max_width.saturating_sub(1)), pad_style), - ]) + ]); + line.style = Style::default().bg(bg); + line }; let wrapped_lines = content @@ -2069,7 +2071,9 @@ impl Chat { spans.extend(line.spans); spans.push(Span::styled(trailing_padding, pad_style)); - lines.push(Line::from(spans)); + let mut panel_line = Line::from(spans); + panel_line.style = Style::default().bg(bg); + lines.push(panel_line); } lines.push(padding_line()); @@ -2639,6 +2643,7 @@ impl Chat { panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); for line in &mut panel_lines { line.spans.insert(0, Span::styled(" ", pad_style)); + line.style = Style::default().bg(bg); } out.extend(panel_lines); @@ -3191,7 +3196,7 @@ fn render_background_run( } fn line_uses_background(line: &Line<'_>, bg: Color) -> bool { - line.spans.iter().any(|span| span.style.bg == Some(bg)) + line.style.bg == Some(bg) } fn spans_with_image_placeholders( @@ -3291,7 +3296,7 @@ fn line_to_static(line: Line<'_>) -> Line<'static> { style: span.style, }) .collect(), - style: Style::default(), + style: line.style, alignment: line.alignment, } } @@ -4219,6 +4224,41 @@ mod tests { assert_ne!(buffer[(1, 3)].bg, colors.background_element); } + #[test] + fn test_inline_code_background_does_not_fill_full_row() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut colors = test_colors(); + colors.background_element = Color::Indexed(236); + colors.markdown_text = Color::White; + colors.markdown_code = Color::Green; + + let mut chat = Chat::new(); + chat.add_assistant_message("before `ThemeColors` after"); + + let backend = TestBackend::new(50, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| chat.render(f, Rect::new(0, 0, 50, 8), "Plan", "model", &colors)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let (y, row) = (0..8) + .map(|y| (y, buffer_row_text(buffer, 48, y))) + .find(|(_, row)| row.contains("ThemeColors")) + .expect("rendered inline code row"); + let before_start = row.find("before").expect("rendered leading text") as u16; + let code_start = row.find("ThemeColors").expect("rendered inline code") as u16; + let code_end = code_start + "ThemeColors".len() as u16; + let after_start = row.find("after").expect("rendered trailing text") as u16; + + assert_ne!(buffer[(before_start, y)].bg, colors.background_element); + assert_eq!(buffer[(code_start, y)].bg, colors.background_element); + assert_eq!(buffer[(code_end - 1, y)].bg, colors.background_element); + assert_ne!(buffer[(after_start, y)].bg, colors.background_element); + assert_ne!(buffer[(47, y)].bg, colors.background_element); + } + #[test] fn test_synthetic_tool_result_assistant_text_is_hidden() { let chat = Chat::new(); diff --git a/src/ui/markdown/streaming.rs b/src/ui/markdown/streaming.rs index 4ac6d3f..b3bae78 100644 --- a/src/ui/markdown/streaming.rs +++ b/src/ui/markdown/streaming.rs @@ -316,7 +316,9 @@ fn convert_line(line: ratatui_core::text::Line<'_>) -> Line<'static> { }) .collect(); - Line::from(spans) + let mut line = Line::from(spans); + line.style = line_style; + line } /// Convert ratatui-core Style to our ratatui Style From ac4b116bb74d1b74d5173d4f0fb6f98e4a69467e Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 02:42:35 +0800 Subject: [PATCH 151/226] fix: exclude diff gutters from selection copy and highlighting. Diff gutter spans are now tagged with a non-selectable modifier so they are skipped during clipboard extraction and selection highlighting. This ensures copying selected diff text yields only the content, not structural markers. Additionally, the "Copy" action now derives its max-width from the actual chat area rather than a hardcoded constant or `last_frame_size`, and the copy-text pipeline falls back to cached rendered lines when the selection falls entirely within them, preventing text reflow at a different width from corrupting the copied selection. --- src/app.rs | 9 +- src/ui/components/chat.rs | 88 +++++++++++++++++++ src/ui/diff.rs | 66 +++++++++++++-- src/ui/selection.rs | 173 ++++++++++++++++++++++++++------------ 4 files changed, 270 insertions(+), 66 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7c5587d..3c3f532 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1253,8 +1253,8 @@ impl App { if self.chat_state.chat.has_selection() { let colors = self.get_current_theme_colors(); let model = self.model.clone(); - // Use a default max_width for text extraction - let max_width = 80; + let chat_area = self.current_chat_area(); + let max_width = chat_area.width.saturating_sub(2) as usize; if let Some(text) = self .chat_state .chat @@ -1302,11 +1302,12 @@ impl App { } let colors = self.get_current_theme_colors(); let model = self.model.clone(); - let max_width = self.last_frame_size.width.saturating_sub(4) as usize; + let chat_area = self.current_chat_area(); + let max_width = chat_area.width.saturating_sub(2) as usize; if let Some(text) = self.chat_state .chat - .get_selected_text(max_width.max(40), &model, &colors) + .get_selected_text(max_width.max(1), &model, &colors) { if !text.trim().is_empty() { let _ = crate::utils::clipboard::copy_text(&text); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 8cea004..3428c40 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1335,6 +1335,15 @@ impl Chat { if !self.selection.active { return None; } + + let ((s_line, _), (e_line, _)) = self.selection.range(); + if s_line < self.cached_lines.len() && e_line < self.cached_lines.len() { + return crate::ui::selection::extract_selected_text( + &self.cached_lines, + &self.selection, + ); + } + let lines = self.render_visible_messages_without_selection_styling(max_width, model, colors); crate::ui::selection::extract_selected_text(&lines, &self.selection) @@ -3938,6 +3947,85 @@ mod tests { assert_eq!(target.path, "/tmp/example.png"); } + #[test] + fn selected_text_uses_render_cached_lines_when_copy_width_differs() { + let colors = test_colors(); + let content = "Intro line that wraps differently when copy uses the wrong width.\n\nSo the flow would be:\n```sh\ncode\n```"; + let mut chat = Chat::with_messages(vec![Message::assistant(content)]); + let rendered_width = 42; + let (lines, positions) = + chat.build_all_lines_with_positions(rendered_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = 20; + chat.scroll_offset = 0; + + let (line_idx, start_col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("So the flow").map(|start| (line_idx, start)) + }) + .expect("rendered target line"); + + chat.selection.active = true; + chat.selection.start_line = line_idx; + chat.selection.end_line = line_idx; + chat.selection.start_col = start_col; + chat.selection.end_col = start_col + "So the flow".len(); + + assert_eq!( + chat.get_selected_text(120, "model", &colors).as_deref(), + Some("So the flow") + ); + } + + #[test] + fn selected_text_inside_fenced_code_uses_render_cached_lines_when_copy_width_differs() { + let colors = test_colors(); + let content = r#"Before text that is intentionally long enough to wrap at the rendered width. + +```sh +codex exec --skip-git-repo-check \ + "Use the imagegen skill to generate: ... Save the final image to ./assets/foo.png." +```"#; + let mut chat = Chat::with_messages(vec![Message::assistant(content)]); + let rendered_width = 64; + let (lines, positions) = + chat.build_all_lines_with_positions(rendered_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = 20; + chat.scroll_offset = 0; + + let (line_idx, start_col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("imagegen skill").map(|start| (line_idx, start)) + }) + .expect("rendered fenced-code target"); + + chat.selection.active = true; + chat.selection.start_line = line_idx; + chat.selection.end_line = line_idx; + chat.selection.start_col = start_col; + chat.selection.end_col = start_col + "imagegen skill".len(); + + assert_eq!( + chat.get_selected_text(120, "model", &colors).as_deref(), + Some("imagegen skill") + ); + } + #[test] fn test_compaction_summary_renders_marker() { let mut msg = Message::user(format!( diff --git a/src/ui/diff.rs b/src/ui/diff.rs index 4d12fcb..2467a54 100644 --- a/src/ui/diff.rs +++ b/src/ui/diff.rs @@ -1,4 +1,5 @@ use crate::theme::ThemeColors; +use crate::ui::selection::non_selectable_style; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -198,12 +199,14 @@ fn render_unified_diff_with_indent_and_syntax( }; let gutter_bg = diff_gutter_bg(diff_line.line_type, bg, colors.background); - let indent_style = Style::default().bg(gutter_bg); - let gutter_style = Style::default() - .fg(colors.diff_gutter) - .bg(gutter_bg) - .add_modifier(Modifier::DIM); - let sign_style = Style::default().fg(fg).bg(gutter_bg); + let indent_style = non_selectable_style(Style::default().bg(gutter_bg)); + let gutter_style = non_selectable_style( + Style::default() + .fg(colors.diff_gutter) + .bg(gutter_bg) + .add_modifier(Modifier::DIM), + ); + let sign_style = non_selectable_style(Style::default().fg(fg).bg(gutter_bg)); let content_style = Style::default().fg(fg).bg(bg); let pad_style = Style::default().bg(bg); @@ -229,7 +232,7 @@ fn render_unified_diff_with_indent_and_syntax( if visible_width < max_width { spans.push(Span::styled( " ".repeat(max_width - visible_width), - pad_style, + non_selectable_style(pad_style), )); } lines.push(Line::from(spans)); @@ -295,7 +298,7 @@ fn render_unified_diff_with_indent_and_syntax( if visible_width < max_width { spans.push(Span::styled( " ".repeat(max_width - visible_width), - pad_style, + non_selectable_style(pad_style), )); } lines.push(Line::from(spans)); @@ -654,6 +657,53 @@ mod tests { assert_eq!(import_span.style.bg, Some(colors.diff_add_bg)); } + #[test] + fn test_render_unified_diff_gutter_is_not_selection_highlighted_or_copied() { + let colors = test_colors(); + let lines = format_edit_diff("old", "new", 40, &colors); + let added_idx = lines + .iter() + .position(|line| line_text(line).contains("+new")) + .expect("expected added line"); + let selection = crate::ui::selection::Selection { + active: true, + start_line: added_idx, + start_col: 0, + end_line: added_idx, + end_col: 8, + is_dragging: false, + anchor: None, + }; + + let copied = crate::ui::selection::extract_selected_text(&lines, &selection) + .expect("expected copied content"); + assert_eq!(copied, "new"); + + let selected_lines = crate::ui::selection::apply_selection_to_lines( + lines.clone(), + &selection, + Color::Rgb(128, 0, 255), + ); + let selected_line = &selected_lines[added_idx]; + assert_ne!( + selected_line.spans[0].style.bg, + Some(Color::Rgb(128, 0, 255)) + ); + assert_ne!( + selected_line.spans[1].style.bg, + Some(Color::Rgb(128, 0, 255)) + ); + assert_ne!( + selected_line.spans[2].style.bg, + Some(Color::Rgb(128, 0, 255)) + ); + assert!(selected_line + .spans + .iter() + .any(|span| span.content.as_ref().contains("new") + && span.style.bg == Some(Color::Rgb(128, 0, 255)))); + } + #[test] fn test_render_unified_diff_uses_softer_gutter_background_for_changes() { let mut colors = test_colors(); diff --git a/src/ui/selection.rs b/src/ui/selection.rs index 15f9f2f..feb2d76 100644 --- a/src/ui/selection.rs +++ b/src/ui/selection.rs @@ -4,7 +4,42 @@ use ratatui::{ text::Span, }; -/// Represents a text selection range in the chat content. +/// Internal marker for spans that should render normally but be ignored by +/// selection highlighting and clipboard extraction (for example diff gutters). +pub const NON_SELECTABLE_SPAN_MODIFIER: Modifier = Modifier::HIDDEN; + +pub fn non_selectable_style(mut style: Style) -> Style { + style.add_modifier.insert(NON_SELECTABLE_SPAN_MODIFIER); + style.sub_modifier.remove(NON_SELECTABLE_SPAN_MODIFIER); + style +} + +fn is_selectable_span(span: &Span<'_>) -> bool { + !span + .style + .add_modifier + .contains(NON_SELECTABLE_SPAN_MODIFIER) +} + +fn visible_style(mut style: Style) -> Style { + style.add_modifier.remove(NON_SELECTABLE_SPAN_MODIFIER); + style.sub_modifier.remove(NON_SELECTABLE_SPAN_MODIFIER); + style +} + +fn visible_span<'a>(span: Span<'a>) -> Span<'a> { + Span::styled(span.content, visible_style(span.style)) +} + +fn strip_non_selectable_markers<'a>(line: ratatui::text::Line<'a>) -> ratatui::text::Line<'a> { + let spans = line.spans.into_iter().map(visible_span).collect(); + ratatui::text::Line { + spans, + style: line.style, + alignment: line.alignment, + } +} + /// Coordinates are in rendered-content space (line index, column within line). #[derive(Debug, Clone, Default)] pub struct Selection { @@ -192,7 +227,10 @@ pub fn apply_selection_to_lines_with_offset<'a>( line_offset: usize, ) -> Vec> { if !selection.active { - return lines; + return lines + .into_iter() + .map(strip_non_selectable_markers) + .collect(); } let ((s_line, _s_col), (e_line, _e_col)) = selection.range(); @@ -202,7 +240,7 @@ pub fn apply_selection_to_lines_with_offset<'a>( .map(|(visible_idx, line)| { let line_idx = line_offset + visible_idx; if line_idx < s_line || line_idx > e_line { - return line; + return strip_non_selectable_markers(line); } let line_width: usize = line .spans @@ -216,7 +254,13 @@ pub fn apply_selection_to_lines_with_offset<'a>( let styled_spans: Vec = line .spans .into_iter() - .map(|s| selection_span_style(&s, accent)) + .map(|s| { + if is_selectable_span(&s) { + selection_span_style(&s, accent) + } else { + visible_span(s) + } + }) .collect(); return ratatui::text::Line::from(styled_spans); } @@ -225,13 +269,14 @@ pub fn apply_selection_to_lines_with_offset<'a>( let mut col = 0usize; let mut styled_spans = Vec::new(); for span in line.spans { - let new_spans = split_and_style_span(&span, col, accent, sel_range); - // Track column advance before extending - col += new_spans - .iter() - .map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref())) - .sum::(); - styled_spans.extend(new_spans); + let span_width = unicode_width::UnicodeWidthStr::width(span.content.as_ref()); + if is_selectable_span(&span) { + let new_spans = split_and_style_span(&span, col, accent, sel_range); + styled_spans.extend(new_spans); + } else { + styled_spans.push(visible_span(span)); + } + col = col.saturating_add(span_width); } ratatui::text::Line::from(styled_spans) }) @@ -246,60 +291,50 @@ pub fn extract_selected_text( if !selection.active { return None; } - let ((s_line, s_col), (e_line, e_col)) = selection.range(); + let ((s_line, _), (e_line, _)) = selection.range(); let mut result = String::new(); for (line_idx, line) in lines.iter().enumerate() { if line_idx < s_line || line_idx > e_line { continue; } - let full_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); - let line_width = unicode_width::UnicodeWidthStr::width(full_text.as_str()); - - let start = if line_idx == s_line { s_col } else { 0 }; - let end = if line_idx == e_line { - e_col - } else { - line_width - }; - if start >= end || start > full_text.len() { + let line_width: usize = line + .spans + .iter() + .map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + let Some((start, end)) = selection.selection_range_in_line(line_idx, line_width) else { continue; - } + }; - // Convert display-width start/end to character indices - let chars: Vec = full_text.chars().collect(); - let mut char_start = 0; - let mut display_pos = 0; - for (i, c) in chars.iter().enumerate() { - if display_pos >= start { - char_start = i; - break; + let mut line_part = String::new(); + let mut col = 0usize; + for span in &line.spans { + let span_width = unicode_width::UnicodeWidthStr::width(span.content.as_ref()); + let span_end = col.saturating_add(span_width); + + if is_selectable_span(span) && start < span_end && end > col { + let overlap_start = start.saturating_sub(col); + let overlap_end = end.saturating_sub(col).min(span_width); + line_part.push_str(slice_by_display_width( + span.content.as_ref(), + overlap_start, + overlap_end, + )); } - display_pos += unicode_width::UnicodeWidthChar::width(*c).unwrap_or(1); - char_start = i + 1; - } - let mut char_end = char_start; - display_pos = start; - for (i, c) in chars[char_start..].iter().enumerate() { - if display_pos >= end { - char_end = char_start + i; - break; - } - display_pos += unicode_width::UnicodeWidthChar::width(*c).unwrap_or(1); - char_end = char_start + i + 1; + col = span_end; } - let end_idx = char_end.min(chars.len()); - let start_idx = char_start.min(end_idx); - - let selected_part: String = chars[start_idx..end_idx].iter().collect(); + if line_part.is_empty() { + continue; + } if !result.is_empty() { result.push('\n'); } - result.push_str(&selected_part); + result.push_str(&line_part); } if result.is_empty() { @@ -309,6 +344,36 @@ pub fn extract_selected_text( } } +fn slice_by_display_width(text: &str, start: usize, end: usize) -> &str { + if start >= end { + return ""; + } + + let mut byte_start = text.len(); + let mut byte_end = text.len(); + let mut display_pos = 0usize; + + for (byte_idx, ch) in text.char_indices() { + if display_pos >= start && byte_start == text.len() { + byte_start = byte_idx; + } + if display_pos >= end { + byte_end = byte_idx; + break; + } + display_pos += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1); + } + + if byte_start == text.len() && display_pos >= start { + byte_start = text.len(); + } + if display_pos < end { + byte_end = text.len(); + } + + &text[byte_start.min(byte_end)..byte_end] +} + /// Apply a selection highlight style to a span. /// Uses the accent color as background with inverted text for visibility. fn selection_span_style<'a>(span: &Span<'a>, accent: Color) -> Span<'a> { @@ -335,12 +400,12 @@ fn split_and_style_span<'a>( let (sel_start, sel_end) = match selection_range { Some((s, e)) => (s, e), - None => return vec![span.clone()], + None => return vec![visible_span(span.clone())], }; // Check if this span overlaps with the selection if sel_end <= col_offset || sel_start >= span_end { - return vec![span.clone()]; + return vec![visible_span(span.clone())]; } // Calculate the overlap boundaries in display-width positions relative to the span @@ -348,7 +413,7 @@ fn split_and_style_span<'a>( let overlap_end = sel_end.saturating_sub(col_offset).min(width); if overlap_start >= overlap_end { - return vec![span.clone()]; + return vec![visible_span(span.clone())]; } // Convert display-width positions back to character indices @@ -374,7 +439,7 @@ fn split_and_style_span<'a>( let char_end = char_idx; if char_start >= char_end { - return vec![span.clone()]; + return vec![visible_span(span.clone())]; } let before: String = chars[..char_start].iter().collect(); @@ -384,7 +449,7 @@ fn split_and_style_span<'a>( let mut result = Vec::new(); if !before.is_empty() { - result.push(Span::styled(before, span.style)); + result.push(Span::styled(before, visible_style(span.style))); } result.push(Span::styled( @@ -396,7 +461,7 @@ fn split_and_style_span<'a>( )); if !after.is_empty() { - result.push(Span::styled(after, span.style)); + result.push(Span::styled(after, visible_style(span.style))); } result From 99f212f5793dfaca5bed7285a56d3807d356f22d Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 02:43:38 +0800 Subject: [PATCH 152/226] feat(skills): add codex-imagegen skill (exampleonly). Introduces a skill that delegates raster image generation and editing to the user-installed Codex CLI via `codex exec`. Includes authentication rules, output path policy, batch generation guidance, and failure handling. --- skills/codex-imagegen/SKILL.md | 213 +++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 skills/codex-imagegen/SKILL.md diff --git a/skills/codex-imagegen/SKILL.md b/skills/codex-imagegen/SKILL.md new file mode 100644 index 0000000..ce2b0e2 --- /dev/null +++ b/skills/codex-imagegen/SKILL.md @@ -0,0 +1,213 @@ +--- +name: "codex-imagegen" +description: "Generate or edit raster images by delegating to the user-installed official Codex CLI with `codex exec`. Use when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, product mockups, wireframes, transparent-style cutouts, or repo assets, and the output should be a PNG/JPEG/WebP file rather than code, SVG, CSS, or canvas. Requires Codex CLI to be installed and authenticated with `codex login`." +--- + +# Codex Image Generation Skill + +Generates or edits raster images for the current project by delegating to the official OpenAI Codex CLI via `codex exec`. + +This skill is intentionally a subprocess bridge. Crabcode must not access Codex credentials directly, copy Codex auth headers, read `~/.codex/auth.json`, or call private Codex/ChatGPT backend endpoints itself. + +## Top-level mode + +This skill has exactly one mode: + +- **Codex CLI delegation mode:** call the user-installed `codex` binary with `codex exec` and instruct Codex to use its own `imagegen` skill/tool. Codex handles authentication, model/tool access, image generation, and any subscription/quota accounting. + +There is no API-key fallback in this skill. + +## Authentication rule + +Before attempting generation, assume the user must have already run: + +```sh +codex login +``` + +If `codex exec` fails because Codex is missing, unauthenticated, expired, or otherwise unable to access its account, respond exactly: + +```text +You must authenticate w/ `codex login` +``` + +Do not suggest `OPENAI_API_KEY` for this skill. Do not try to inspect or repair Codex credentials. + +## When to use + +Use this skill when the user asks Crabcode to create, edit, transform, or derive bitmap assets, including: + +- website or app hero images +- product mockups +- UI mockups or wireframes as images +- photos or photorealistic renders +- illustrations +- textures +- game sprites +- thumbnails +- icons that should be raster assets +- transparent-background or cutout-style PNGs +- variants based on an existing reference image + +Do not use this skill when the task is better solved by: + +- editing existing SVG/vector assets +- creating repo-native HTML/CSS/canvas +- extending an established logo/icon system in vector form +- making textual diagrams or Mermaid diagrams +- generating code instead of a bitmap file + +## Safety and boundary rules + +- Always use the installed `codex` CLI as the actor that talks to OpenAI. +- Never read `~/.codex/auth.json` or any Codex token store. +- Never spoof Codex headers, user agents, account IDs, or private endpoints. +- Never call `https://chatgpt.com/backend-api/codex` directly from Crabcode. +- Never use `--dangerously-bypass-approvals-and-sandbox` by default. +- Prefer `--skip-git-repo-check` because image output may be requested from temporary or asset folders. +- Keep the delegated prompt tightly scoped to image generation/editing and saving the output file. +- Instruct Codex not to modify unrelated files. +- After `codex exec` returns, verify the expected output file exists before reporting success. + +## Output path policy + +Always establish a concrete destination path before invoking `codex exec`. + +Path precedence: + +1. If the user names a destination file, use that path. +2. If the image is intended for the current project but no path is specified, choose an appropriate path under the workspace, such as: + - `assets/.png` + - `public/images/.png` + - `src/assets/.png` + - `output/imagegen/.png` +3. If the image is only for brainstorming or preview, use `output/imagegen/.png` in the current workspace. + +Prefer `.png` unless the user explicitly requests JPEG or WebP. + +Create parent directories locally before calling Codex when practical. + +## Basic command shape + +Use this pattern: + +```sh +mkdir -p "$(dirname "$OUTPUT_PATH")" +codex exec --skip-git-repo-check \ + "Use your imagegen skill to generate: $IMAGE_REQUEST + +Save or copy the final image exactly to: $ABSOLUTE_OUTPUT_PATH +Do not modify anything else. +If you are not authenticated, respond exactly: You must authenticate w/ \`codex login\`. +When done, reply with only the absolute saved path." +``` + +After the command finishes: + +```sh +test -f "$OUTPUT_PATH" +``` + +If the file exists, report the saved path to the user. If it does not exist, inspect Codex stdout/stderr enough to determine whether this is an auth failure, CLI failure, or generation failure. + +## Prompting Codex + +The delegated prompt should include: + +- the exact user image request +- style, composition, aspect ratio, size, background, and format constraints from the user +- any reference image paths, if provided +- the exact absolute output path +- an instruction to not modify unrelated files +- the exact auth failure response +- an instruction to reply only with the saved path + +Example delegated prompt: + +```text +Use your imagegen skill to generate a square PNG illustration of a cozy crab-shaped coding robot at a terminal, warm desk lamp, dark background, polished but playful. + +Save or copy the final image exactly to: /absolute/path/to/public/images/crab-robot.png +Do not modify anything else. +If you are not authenticated, respond exactly: You must authenticate w/ `codex login`. +When done, reply with only the absolute saved path. +``` + +## Existing image editing + +If the user provides one or more reference images, pass their absolute paths in the delegated prompt and clearly describe how Codex should use them. + +Example: + +```text +Use your imagegen skill to edit the reference image at /absolute/path/to/input.png. +Change the background to a clean studio gradient, preserve the object shape and colors, and export a PNG. + +Save or copy the final image exactly to: /absolute/path/to/output/product-studio.png +Do not modify anything else. +If you are not authenticated, respond exactly: You must authenticate w/ `codex login`. +When done, reply with only the absolute saved path. +``` + +Do not embed large image files in the shell command. Prefer passing local file paths. + +## Transparent-background requests + +For transparent-background or cutout requests, stay in Codex CLI delegation mode. Ask Codex to use its own imagegen workflow and save the final PNG to the requested path. + +Example phrasing: + +```text +Use your imagegen skill to create a PNG cutout with a transparent background if your imagegen workflow supports it. If not, use the best supported workflow to produce a clean removable-background PNG. +``` + +Do not implement local chroma-key removal in this skill unless the user explicitly asks for local post-processing after generation. + +## Batch generation + +For multiple assets, prefer one `codex exec` call per final asset unless the user explicitly asks for a batch in one call. + +Each requested image should have a distinct output path. Verify every expected file exists. + +Example paths: + +```text +output/imagegen/icon-idle.png +output/imagegen/icon-hover.png +output/imagegen/icon-active.png +``` + +## Failure handling + +If `codex` is not installed, unavailable on `PATH`, unauthenticated, or returns an auth/account error, respond exactly: + +```text +You must authenticate w/ `codex login` +``` + +If Codex succeeds but the expected file is missing: + +- report that Codex did not create the expected output file +- include the expected path +- do not claim success +- optionally suggest rerunning with a simpler prompt or explicit output filename + +If Codex creates a file at a different path and reports it clearly, move or copy it to the requested output path only if that is safe and unambiguous. Then verify the requested path exists. + +## Completion response + +On success, keep the final response short: + +```text +Generated image saved to ``. +``` + +If multiple files were generated: + +```text +Generated images: +- `` +- `` +``` + +Do not include Codex internals, credentials, account details, or raw base64 image data. From b65eac1a3d41c1b6dca1dcfd568f80a685b0b796 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 02:46:30 +0800 Subject: [PATCH 153/226] docs: fix config.mdx references, add remote-usage plan, enable theme and sounds. - Update `__PARITY.md` to reference `_docs/config/index.mdx` instead of the old path - Add internal planning notes for remote access scenarios (SSH, backend service, browser frontend) - Enable "vercel" theme and add `permission`/`error` sound config entries --- _docs/__PARITY.md | 4 +- _docs/__remote-usage-plan.md | 317 +++++++++++++++++++++++++++++++++++ crabcode.jsonc | 10 +- 3 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 _docs/__remote-usage-plan.md diff --git a/_docs/__PARITY.md b/_docs/__PARITY.md index affc1fb..df0901c 100644 --- a/_docs/__PARITY.md +++ b/_docs/__PARITY.md @@ -84,7 +84,7 @@ Scope: core harness behavior only: agent loop, system prompt, subagents, tool ca - Add discovery for `.opencode/commands/.md`, frontmatter parsing for `description`, `agent`, `model`, and `subtask`, template expansion for `$ARGUMENTS` and positional args, command-substitution injection with permission checks, file-reference expansion, and Task routing for subtask commands. 3. Permission system is not OpenCode-compatible. - - Files: `src/tools/permission.rs`, `src/config/configuration.rs`, `crabcode.schema.json`, `_docs/config.mdx`. + - Files: `src/tools/permission.rs`, `src/config/configuration.rs`, `crabcode.schema.json`, `_docs/config/index.mdx`. - Add config-driven `allow`, `deny`, and `ask` rules; wildcard tool matching; ordered bash command patterns; per-agent override merging; task permissions; skill permissions; and durable approvals where appropriate. 4. First-class agent registry/config is missing. @@ -124,7 +124,7 @@ Scope: core harness behavior only: agent loop, system prompt, subagents, tool ca - Wire the top-level cancellation token into the per-tool abort channel so bash, webfetch, and subagent execution stop promptly on user interruption. 4. Max-step compatibility should accept OpenCode aliases. - - Files: `src/config/configuration.rs`, `crabcode.schema.json`, `_docs/config.mdx`. + - Files: `src/config/configuration.rs`, `crabcode.schema.json`, `_docs/config/index.mdx`. - Accept `max_steps` and deprecated `maxSteps` as aliases for `steps`, with a warning only if necessary. ### LOW diff --git a/_docs/__remote-usage-plan.md b/_docs/__remote-usage-plan.md new file mode 100644 index 0000000..9f5473d --- /dev/null +++ b/_docs/__remote-usage-plan.md @@ -0,0 +1,317 @@ +# Remote access planning notes + +Internal planning note. This is not public user guidance yet, and it should stay out of `gittydocs.jsonc` navigation until we decide what is safe and stable enough to document. + +Last updated: 2026-05-21. + +## Product Goal + +Make crabcode usable when the machine that owns the workspace is not the machine in the user's hands. + +The important cases are: + +- A developer uses crabcode installed on a VPS, desktop, homelab box, or laptop from another computer. +- A developer controls crabcode from a phone while away from the keyboard. +- A developer uses an iPad or other tablet to SSH into a VPS/Mac mini, starts crabcode remote access, then uses the tablet browser to control crabcode and view the app being developed on the remote machine. +- A developer starts a long agent run, disconnects, and later resumes from another device without losing stream state. +- A developer can use this safely without exposing a write-capable coding agent directly to the public internet. + +This should stay terminal-first. Remote access is about reaching the same terminal-native agent from more places, not turning crabcode into a hosted cloud product. + +Scope decision: this is personal-device and personal-VPS access for now. Team/shared access, workspace collaboration, and multi-user permission models are later problems. + +## Recommendation + +Use three lanes: + +1. Support what already works: SSH into the remote machine, run `crabcode` inside `tmux` or `zellij`, and document Tailscale as the preferred private network path. +2. Turn crabcode into a backend service the first time `crabcode` is run. The TUI becomes one client of that backend, and active generations can outlive any one TUI client. +3. After the backend exists, add a visible `crabcode serve` command for phone and browser access, bound privately by default. The browser surface should be a minimal touch-native frontend backed by crabcode's service API. + +Do not build a separate native app yet. A separate app adds release, auth, pairing, mobile UX, and protocol maintenance before we even know if the runtime protocol is correct. If mobile browser limitations become the real blocker, revisit a native app after the web companion exists. + +Recommend Tailscale, but do not require it. The default docs should say "use SSH over Tailscale if you can; plain SSH with key auth is also fine on a hardened VPS." Tailscale is one example of a private overlay network: a way to make selected personal devices able to reach each other without exposing services to the whole internet. Similar options include WireGuard-based setups, ZeroTier, NetBird, and Cloudflare Access/Tunnel-style SSH access. Tailscale is the easiest default to explain, but crabcode should only require ordinary network reachability plus its own auth. + +For browser access, prefer a private overlay network or localhost tunnel. Treat public exposure as out of scope for write-capable crabcode access unless we later add strong auth, clear warnings, and a narrow sharing mode. + +## Current State + +crabcode is currently a local TUI process: + +- `src/main.rs` owns raw terminal setup, alternate screen, crossterm events, and the main event loop. +- `src/app.rs` owns the active TUI state, session state, streaming state, dialogs, permissions, and model selection. +- `src/persistence/history.rs` persists workspaces, sessions, and messages to SQLite. +- `crabcode -s ` resumes an existing session after process restart. +- `crabcode -p ""` supports non-interactive print mode. +- There is no HTTP server, websocket server, or remote client protocol. +- There is no durable active-generation owner. If the TUI process dies, the active stream dies with it. + +The existing multiworkspace plan already points at the architectural prerequisite: split durable session state from TUI state and add a runtime that owns active generations. + +## Usage Modes + +### Mode A: SSH + terminal multiplexer + +This should be the first documented remote usage because it requires almost no crabcode changes. + +Expected workflow: + +```bash +tailscale ssh devbox +cd ~/code/project +tmux new -A -s crabcode +crabcode +``` + +Or with normal SSH: + +```bash +ssh devbox +cd ~/code/project +tmux new -A -s crabcode +crabcode +``` + +This works for another PC and can work from a phone using a mobile SSH client. The phone experience will be constrained by terminal input, keyboard shortcuts, screen size, and copy/paste, but it is the safest first answer. + +Immediate polish work for this mode: + +- Make `/connect` fully usable on headless machines. Browser OAuth needs a copyable URL and code path, and API-key auth needs to be comfortable over SSH. +- Make terminal resize behavior reliable on small screens. +- Keep `crabcode -s ` prominent after exit. +- Consider a short "remote terminal checklist" in public docs later: install, authenticate, use `tmux`, use Tailscale or hardened SSH, avoid running as root. +- Decide whether sounds and desktop notifications should auto-disable or degrade cleanly when running over SSH. + +### Mode B: Local backend service + +This is the real product foundation. + +The first time `crabcode` runs, it should ensure a local crabcode backend service exists, then attach a TUI client to it. The backend owns active generations, persists events, and lets clients attach and detach. It does not have to be a user-managed service. + +Expected shape: + +- Runtime socket under the crabcode state dir, such as `~/.local/state/crabcode/runtime.sock`. +- Runtime starts on demand and exits after an idle timeout once there are no connected clients and no active generations. +- TUI, browser, and future clients send commands: + - `ListWorkspaces` + - `ListSessions` + - `CreateSession` + - `LoadSession` + - `StartGeneration` + - `CancelGeneration` + - `ApprovePermission` + - `AnswerQuestion` + - `SubscribeSession` +- Runtime writes durable state: + - user messages immediately + - generation rows for active turns + - throttled assistant/tool snapshots during streaming + - explicit events for status changes, permission waits, question waits, tool calls, tool results, errors, cancellation, and completion +- Clients can disconnect without killing active generations. +- Multiple devices can control the same session. +- Permission/question prompts can be answered from any connected controlling device. The backend must make approval state idempotent so the first answer wins and later duplicate answers become no-ops. +- Connected controlling clients should show presence/activity for the same session, such as "phone attached", "desktop attached", "Carlo approved bash", or "desktop is typing". + +This also fixes local multi-terminal usage, not just remote usage. + +### Mode C: Minimal Browser Frontend + +Only build this after Mode B exists. + +The first real phone surface should be a small web frontend that talks to the crabcode backend API. Do not adapt the current TUI directly for the browser: crabcode's current TUI is keyboard-driven, while the phone use case is touch, mobile text entry, and quick glance/control while away from the keyboard. + +The server can be in the same `crabcode` binary: + +```bash +crabcode serve --bind 127.0.0.1:8421 +``` + +Potential Tailscale workflow: + +```bash +crabcode serve --bind 127.0.0.1:8421 +tailscale serve --bg http://127.0.0.1:8421 +``` + +Minimal useful slice: + +- Authenticate/pair the browser. +- Show workspace/session list. +- Create a new session/conversation. +- Load a session transcript with live streaming updates. +- Type and send a new prompt from the phone. +- Stop/cancel an active generation. +- Approve/deny permission prompts. +- Answer model questions. +- Show or remember externally reachable dev preview URLs, such as a Tailscale host URL, SSH-forwarded URL, or another tunnel URL. +- Show current workspace, model, agent mode, remote host, and connected devices. +- Show simple presence/activity for other controlling clients. + +Why this is a good first slice: + +- It gives touch-native controls for the actions a phone user actually needs. +- It uses the same durable backend protocol the TUI needs anyway. +- It avoids making users learn terminal gestures or keyboard shortcuts on glass. +- It can grow toward CLI-equivalent control without pretending the phone is a terminal. + +Risks: + +- It requires designing and maintaining a small frontend. +- Every CLI feature we expose needs a backend API shape. +- We need to be careful not to accidentally build a full web IDE. + +This frontend should be intentionally narrow: it is a remote controller for crabcode sessions, not a replacement IDE. + +### Mode D: Remote dev previews + +The iPad/VPS workflow should be possible: + +1. SSH into the VPS or Mac mini from an iPad terminal app. +2. Start the project and crabcode remote access from the remote shell. +3. Open the paired crabcode browser UI on the iPad. +4. Use crabcode from the browser and preview the app running on the remote machine. + +Important network detail: `localhost:3000` in the iPad browser means the iPad, not the VPS/Mac mini. To view a dev server running on the remote host, the browser needs one of these: + +- Private-network direct access to the remote host and port, such as `http://devbox:3000`, with the dev server bound to a reachable interface. +- SSH local port forwarding from the iPad SSH client, if that client supports it reliably. +- A separate tunnel or proxy tool intended for app previews. + +Product boundary: crabcode should not own serving arbitrary dev-server ports. + +crabcode can help by: + +- Documenting the common options: Tailscale/private-network direct access, SSH local forwarding, or external tunnel tools. +- Letting the user save or pin preview URLs in the remote UI. +- Detecting likely dev-server URLs from command output when practical and presenting them as links. +- Warning when a preview URL points at the local browser's `localhost`, because that usually is not the remote devbox. + +This keeps the security boundary clearer. crabcode controls crabcode sessions; network/tunnel tools expose dev servers. + +## Security Defaults + +Remote crabcode is write-capable by design, so defaults must be conservative. + +- Bind localhost only unless the user explicitly passes a non-local bind address. +- Never recommend opening a crabcode HTTP port directly to the public internet. +- Require authentication for any HTTP/browser access, even on a tailnet. +- Use a short-lived pairing code for new browser clients. +- Store trusted browser clients separately from provider credentials. +- Include CSRF and Origin checks for browser routes. +- Use backend API routes for the mobile frontend. Do not expose a browser terminal. +- Do not expose arbitrary remote ports. crabcode should not become a general-purpose open proxy. +- Show remote host, cwd, model, agent, and pending command/file changes in permission prompts. +- Keep provider credentials on the machine running crabcode. Do not sync `auth.json` between devices in the first design. +- Log remote approvals and denials into the session event stream. +- Allow multiple controlling devices for the same session, but make state-changing operations idempotent and auditable. +- Show presence/activity for connected controlling devices. +- Shut the backend down after an idle timeout when there are no clients and no active generations. +- Treat public internet sharing as a non-goal for now. A private overlay network is acceptable; a public URL to a write-capable coding agent is not the default shape. + +## Private Network Position + +Recommend Tailscale as the easiest private-network path, especially for phone and homelab/VPS use. It should be framed as one recommended option, not a hard dependency. + +Good default wording for public docs later: + +> For personal remote access, use SSH over Tailscale or another private network when possible. It keeps crabcode reachable only from your selected devices. A normal SSH setup with key auth is also fine on a hardened VPS. + +Docs we should reference later: + +- Tailscale SSH: https://tailscale.com/docs/features/tailscale-ssh +- Tailscale Serve and Funnel CLI: https://tailscale.com/docs/reference/tailscale-cli/funnel +- Tailscale access controls/grants: https://tailscale.com/kb/1018/acls +- WireGuard quick start: https://www.wireguard.com/quickstart/ +- ZeroTier remote access docs: https://docs.zerotier.com/remotedesktop/ +- NetBird SSH docs: https://docs.netbird.io/how-to/ssh +- Cloudflare browser SSH docs: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/use-cases/ssh/ssh-browser-rendering/ + +Implementation implication: crabcode does not need to integrate with Tailscale or any private-network provider APIs for v1. We only need to avoid fighting them by binding cleanly to localhost or a chosen address and by documenting the safe path. + +## Implementation Plan + +### Phase 0: Internal planning + +- Keep this document internal. +- Target both remote-computer access and phone control. +- Treat phone access as a full remote control surface, not read-only monitoring. +- Keep the scope personal-device/personal-VPS for now. +- Make `crabcode serve` visible in help. +- Use a minimal touch-native frontend as the first browser slice. +- Do not pursue a browser terminal path for this plan. + +### Phase 1: Document and polish SSH usage + +- Public docs page later: "Remote usage". +- Recommended path: Tailscale plus SSH plus `tmux`. +- Plain SSH path for VPS users. +- Phone path using mobile Tailscale plus a mobile SSH client. +- Add headless auth notes. +- Add a warning that credentials and filesystem access live on the remote host. + +Code polish candidates: + +- Better remote/headless `/connect`. +- Better small-screen layout behavior. +- Better post-exit resume instructions. +- Clearer behavior for sounds, notifications, and clipboard over SSH. + +### Phase 2: Runtime architecture + +- Introduce a local backend process and IPC protocol. +- Start or connect to the backend on the first normal `crabcode` run. +- Persist generation status and event stream in SQLite. +- Move active stream ownership out of `App`. +- Make the TUI subscribe to session events. +- Preserve per-session client view state in the TUI. +- Support detach/reconnect for active generations. +- Support multiple controlling clients on the same session. +- Emit presence/activity events for attached clients. +- Exit the backend after an idle timeout. + +This phase should reuse the multiworkspace plan rather than becoming a parallel architecture. + +### Phase 3: Remote API + +- Add visible `crabcode serve` CLI help. +- Start with localhost binding only. +- Add token/pairing auth. +- Add WebSocket or SSE event streaming for sessions/generations. +- Add backend API routes for session list/create/load, prompt submit, cancel, approve/deny, answer question, model/agent metadata, and presence. +- Add tests for auth, disconnect/reconnect, idle shutdown, multiple controlling clients, permission idempotency, and event replay. +- Add explicit command-line warnings when binding to non-local addresses. + +### Phase 4: Minimal Browser/PWA client + +- Serve a small static web client from the binary or bundled assets. +- Optimize for phone/tablet first: session list, new session, transcript, input, approvals, questions, stop, and saved external preview links. +- Grow toward CLI-equivalent control from the browser. +- Keep advanced TUI-only workflows in SSH only as temporary gaps. +- Add installable PWA metadata only if the mobile browser experience is good. + +### Phase 5: Revisit native app + +Only consider a separate app if: + +- Mobile browser input is not good enough. +- Notifications/background behavior matters enough to justify native code. +- Pairing and secure storage are stable. +- The runtime protocol has stopped changing quickly. + +## Open Questions + +- Do remote approvals need a stricter permission mode than local approvals? +- Should remote browser clients be allowed to run every slash command, or should some commands stay TUI-only at first? +- What idle timeout should the backend use? +- What is the smallest complete mobile command surface after sessions, prompt input, cancel, approvals, questions, and transcript? +- Should crabcode auto-detect dev-server URLs from terminal output, or only let users manually add/pin them? + +## Non-Goals For Now + +- Hosted crabcode cloud. +- Public internet sharing. +- Collaborative editing. +- Syncing provider credentials across devices. +- A separate native mobile app. +- A desktop app. +- Multi-user team permissions. diff --git a/crabcode.jsonc b/crabcode.jsonc index 2ae2492..4fe7fb5 100644 --- a/crabcode.jsonc +++ b/crabcode.jsonc @@ -1,12 +1,20 @@ { "$schema": "crabcode.schema.json", // Crabcode theme id (see src/generated_themes/carbonfox.json) - // "theme": "vercel", + "theme": "vercel", "sounds": { "complete": { "enabled": true, "notify": true, "file": "/Users/carlo/Desktop/Projects/crabcode/sounds/complete.wav", }, + "permission": { + "enabled": true, + "file": "/Users/carlo/Desktop/Projects/crabcode/sounds/question.mp3", + }, + "error": { + "enabled": true, + "file": "/Users/carlo/Desktop/Projects/crabcode/sounds/error.mp3", + }, }, } From 5f7040157433dbd98e047ba3053f77f442a793c1 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 03:28:01 +0800 Subject: [PATCH 154/226] chore: remove aisdk_debug.log. --- .gitignore | 1 + aisdk_debug.log | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 aisdk_debug.log diff --git a/.gitignore b/.gitignore index 21c6b02..a2c76b7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ src/themes/ !src/theme.json !src/theme.json app.log +aisdk_debug.log sounds/complete.wav _dev_reference1 diff --git a/aisdk_debug.log b/aisdk_debug.log deleted file mode 100644 index 3511178..0000000 --- a/aisdk_debug.log +++ /dev/null @@ -1 +0,0 @@ -spawned task done, dropping tx From 57f166c44025fba8e125b0364c7088d6babde6ac Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 04:32:51 +0800 Subject: [PATCH 155/226] feat: add view_image tool for local image inspection. Introduce a new `view_image` filesystem tool that allows the model to inspect local image files. The tool reads an image from disk, processes it via the existing image attachment utility (with optional high/original detail), and returns the result as a data URL along with width, height, and media type metadata. - Add `ToolResultImage` struct and `images` field to `ToolResult` with builder method `with_image()`. - Pipe tool-result images through to the AI SDK bridge via `ToolOutput::with_images()`. - Register `view_image` in the tool registry; classify its permission action as `Read`. - Update the `read` tool description to clarify it is for text files only. - Refactor `skill.rs` to use the `ToolResult::new()` builder pattern. --- _plans/__TODOS.md | 7 ++ src/llm/client.rs | 59 ++++++++++-- src/tools/aisdk_bridge.rs | 17 +++- src/tools/fs/mod.rs | 2 + src/tools/fs/read.rs | 2 +- src/tools/fs/view_image.rs | 148 +++++++++++++++++++++++++++++ src/tools/init.rs | 3 +- src/tools/permission.rs | 6 +- src/tools/skill.rs | 22 ++--- src/tools/types.rs | 21 +++++ src/utils/image_attachment.rs | 172 ++++++++++++++++++++++++++++++++++ 11 files changed, 428 insertions(+), 31 deletions(-) create mode 100644 src/tools/fs/view_image.rs diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 3c65c6a..39cfeb2 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -178,3 +178,10 @@ I want - [x] To do this But I dont want to do this - Autodetected depending on the tool used: i.e. if Wezterm, other terminals "open w/ Finder on mac, or native image opener". If inside Zed, open image with Zed. If inside VSCode/Cursor, open with that IDE. (Ambitious but idk if possible) - [ ] Make the permissions, config-driven customizable behavior. Make it like OpenCode, so we just link the docs for it in OpenCode. + +- [x] View image locally tool, instead of read image. +- [x] Clickable paths. + +- [ ] When in another workspace and there are existing sessions in there and I opened /sessions, make that "workspace" the focus especially since the first page is at home.rs. + +- [ ] I want to make a SPECIAL integration w/ ollama, specifically the local ollama cli. Maybe `ollama ls` can be cached at runtime? and refreshed with refreshmodels? And a special provider place where I can do /connect on it. And it won't require any API keys? I wanna put it somewhere clean though... So that it doesn't really bother with the models.dev stuff, but just fits in cleanly. A /connect provider called 'Ollama (Local)' would be cool. diff --git a/src/llm/client.rs b/src/llm/client.rs index 61798f1..b8aa2d2 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1134,13 +1134,10 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec Some(ImageContent { - data_url, - media_type: crate::utils::image_attachment::mime_type_for_path( - path, - ) - .to_string(), + match crate::utils::image_attachment::prompt_image_for_path(path, false) { + Ok(image) => Some(ImageContent { + data_url: image.data_url, + media_type: image.media_type, }), Err(err) => { crate::emit_log!( @@ -1202,12 +1199,58 @@ fn tool_messages_for_model(content: &str) -> Option> { let status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("ok"); let is_error = status.eq_ignore_ascii_case("error"); + let images = if name == "view_image" && !is_error { + view_image_tool_images(obj) + } else { + Vec::new() + }; + Some(vec![ AisdkMessage::tool_call(call_id, name, arguments), - AisdkMessage::tool_output(call_id, name, output, is_error), + AisdkMessage::tool_output_with_images(call_id, name, output, images, is_error), ]) } +fn view_image_tool_images(obj: &serde_json::Map) -> Vec { + let path = obj + .get("metadata") + .and_then(|metadata| metadata.get("path")) + .and_then(|value| value.as_str()) + .or_else(|| { + obj.get("args") + .and_then(|args| args.get("path")) + .and_then(|value| value.as_str()) + }); + let Some(path) = path else { + return Vec::new(); + }; + + let preserve_original = obj + .get("metadata") + .and_then(|metadata| metadata.get("detail")) + .and_then(|value| value.as_str()) + .map(|detail| detail == "original") + .unwrap_or(false); + + match crate::utils::image_attachment::prompt_image_for_path( + std::path::Path::new(path), + preserve_original, + ) { + Ok(image) => vec![ImageContent { + data_url: image.data_url, + media_type: image.media_type, + }], + Err(err) => { + crate::emit_log!( + "failed to reattach viewed image {} from tool history: {}", + path, + err + ); + Vec::new() + } + } +} + fn tool_message_observation(content: &str) -> String { let Ok(value) = serde_json::from_str::(content) else { return format!("Tool result:\n{}", content); diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index c087779..afbb54a 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -1,5 +1,5 @@ use crate::tools::{ToolContext, ToolRegistry}; -use aisdk::core::tools::ToolExecute; +use aisdk::core::tools::{ToolExecute, ToolOutput}; use aisdk::core::Tool; use schemars::Schema; use serde_json::Value; @@ -194,8 +194,19 @@ pub async fn convert_to_aisdk_tools( tool_result.output.len() ); - let model_output = - truncate_tool_output(&tool_result.output, TOOL_MODEL_OUTPUT_LIMIT); + let model_images = tool_result + .images + .iter() + .map(|image| aisdk::message::ImageContent { + data_url: image.data_url.clone(), + media_type: image.media_type.clone(), + }) + .collect::>(); + let model_output = ToolOutput::new(truncate_tool_output( + &tool_result.output, + TOOL_MODEL_OUTPUT_LIMIT, + )) + .with_images(model_images); if let Some(ref sender) = sender { let preview = truncate_tool_output(&tool_result.output, TOOL_UI_PREVIEW_LIMIT); diff --git a/src/tools/fs/mod.rs b/src/tools/fs/mod.rs index 881020e..3e1971f 100644 --- a/src/tools/fs/mod.rs +++ b/src/tools/fs/mod.rs @@ -2,10 +2,12 @@ pub mod glob; pub mod grep; pub mod list; pub mod read; +pub mod view_image; pub mod write; pub use glob::GlobTool; pub use grep::GrepTool; pub use list::ListTool; pub use read::ReadTool; +pub use view_image::ViewImageTool; pub use write::WriteTool; diff --git a/src/tools/fs/read.rs b/src/tools/fs/read.rs index 6870c2d..668bed8 100644 --- a/src/tools/fs/read.rs +++ b/src/tools/fs/read.rs @@ -81,7 +81,7 @@ impl ToolHandler for ReadTool { fn definition(&self) -> Tool { Tool { id: "read".to_string(), - description: "Read file or directory contents with pagination. Detects binary files automatically." + description: "Read text file or directory contents with pagination. Detects binary files automatically. For local image files, use view_image instead." .to_string(), parameters: vec![ ParameterSchema { diff --git a/src/tools/fs/view_image.rs b/src/tools/fs/view_image.rs new file mode 100644 index 0000000..afb556c --- /dev/null +++ b/src/tools/fs/view_image.rs @@ -0,0 +1,148 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::Path; + +const MAX_IMAGE_FILE_SIZE: u64 = 50 * 1024 * 1024; + +pub struct ViewImageTool; + +impl ViewImageTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ToolHandler for ViewImageTool { + fn definition(&self) -> Tool { + Tool { + id: "view_image".to_string(), + description: "View a local image from the filesystem. Use this when the user asks what a local image file looks like, or when visual inspection of a local image is needed." + .to_string(), + parameters: vec![ + ParameterSchema { + name: "path".to_string(), + description: "Local filesystem path to an image file".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "detail".to_string(), + description: "Optional detail override: high or original. Omit for high resized behavior." + .to_string(), + required: false, + param_type: ParameterType::String, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["path"])?; + match get_string_param(params, "detail").as_deref() { + None | Some("high") | Some("original") => Ok(()), + Some(detail) => Err(ToolError::Validation(format!( + "detail only supports 'high' or 'original', got '{}'", + detail + ))), + } + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let path = get_string_param(¶ms, "path") + .ok_or_else(|| ToolError::Validation("path is required".to_string()))?; + let preserve_original = matches!( + get_string_param(¶ms, "detail").as_deref(), + Some("original") + ); + let path_ref = Path::new(&path); + + if !path_ref.exists() { + return Err(ToolError::NotFound(format!("Image not found: {}", path))); + } + if !path_ref.is_file() { + return Err(ToolError::Validation(format!( + "Image path is not a file: {}", + path + ))); + } + + let metadata = std::fs::metadata(path_ref) + .map_err(|err| ToolError::Execution(format!("Failed to read image metadata: {err}")))?; + if metadata.len() > MAX_IMAGE_FILE_SIZE { + return Err(ToolError::Execution(format!( + "Image is too large ({}MB > {}MB limit)", + metadata.len() / (1024 * 1024), + MAX_IMAGE_FILE_SIZE / (1024 * 1024) + ))); + } + + let image = + crate::utils::image_attachment::prompt_image_for_path(path_ref, preserve_original) + .map_err(|err| ToolError::Execution(format!("Failed to process image: {err}")))?; + + let detail = if preserve_original { + "original" + } else { + "high" + }; + let output = format!( + "Viewed image {} ({}x{}, {})", + path, image.width, image.height, image.media_type + ); + + Ok(ToolResult::new(format!("Viewed Image: {}", path), output) + .with_metadata("path", serde_json::json!(path)) + .with_metadata("width", serde_json::json!(image.width)) + .with_metadata("height", serde_json::json!(image.height)) + .with_metadata("media_type", serde_json::json!(image.media_type.clone())) + .with_metadata("detail", serde_json::json!(detail)) + .with_image(image.data_url, image.media_type)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; + use std::io::Cursor; + + fn test_context() -> ToolContext { + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); + ToolContext::new("session", "message", "build", abort_rx) + } + + #[tokio::test] + async fn view_image_returns_model_image_content() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("example.png"); + let image = RgbaImage::from_pixel(2, 1, Rgba([255, 0, 0, 255])); + let mut encoded = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("encode png"); + std::fs::write(&path, encoded.into_inner()).expect("write png"); + + let result = ViewImageTool::new() + .execute( + serde_json::json!({ + "path": path.to_string_lossy(), + }), + &test_context(), + ) + .await + .expect("view image"); + + assert_eq!(result.images.len(), 1); + assert_eq!(result.images[0].media_type, "image/png"); + assert!(result.images[0] + .data_url + .starts_with("data:image/png;base64,")); + assert_eq!(result.metadata["width"], serde_json::json!(2)); + assert_eq!(result.metadata["height"], serde_json::json!(1)); + } +} diff --git a/src/tools/init.rs b/src/tools/init.rs index 36c4582..342c2b1 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,5 +1,5 @@ use crate::tools::{ - fs::{GlobTool, GrepTool, ListTool, ReadTool, WriteTool}, + fs::{GlobTool, GrepTool, ListTool, ReadTool, ViewImageTool, WriteTool}, BashTool, EditTool, QuestionTool, SkillTool, TaskTool, ToolRegistry, UpdatePlanTool, WebfetchTool, }; @@ -12,6 +12,7 @@ pub async fn initialize_tool_registry() -> ToolRegistry { registry.register(Arc::new(GrepTool::new())).await; registry.register(Arc::new(ListTool::new())).await; registry.register(Arc::new(ReadTool::new())).await; + registry.register(Arc::new(ViewImageTool::new())).await; registry.register(Arc::new(WriteTool::new())).await; registry.register(Arc::new(BashTool::new())).await; registry.register(Arc::new(EditTool::new())).await; diff --git a/src/tools/permission.rs b/src/tools/permission.rs index acb67e3..1797760 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -24,7 +24,7 @@ pub enum PermissionAction { impl PermissionAction { pub fn from_tool_id(tool_id: &str) -> Self { match tool_id { - "read" => Self::Read, + "read" | "view_image" => Self::Read, "write" => Self::Write, "edit" => Self::Edit, "list" => Self::List, @@ -377,7 +377,9 @@ fn extract_primary_path( ) -> Option { let raw = match action { PermissionAction::Read | PermissionAction::Write | PermissionAction::Edit => { - get_string(params, "file_path").or_else(|| get_string(params, "filePath")) + get_string(params, "file_path") + .or_else(|| get_string(params, "filePath")) + .or_else(|| get_string(params, "path")) } PermissionAction::List | PermissionAction::Glob | PermissionAction::Grep => { get_string(params, "path").or_else(|| Some(".".to_string())) diff --git a/src/tools/skill.rs b/src/tools/skill.rs index dabdb98..b7c3def 100644 --- a/src/tools/skill.rs +++ b/src/tools/skill.rs @@ -124,22 +124,12 @@ impl ToolHandler for SkillTool { files = file_list, ); - Ok(ToolResult { - title: format!("Loaded skill: {}", name), - output, - metadata: { - let mut m = std::collections::HashMap::new(); - m.insert( - "name".to_string(), - serde_json::Value::String(info.name.clone()), - ); - m.insert( - "dir".to_string(), - serde_json::Value::String(dir.to_string_lossy().to_string()), - ); - m - }, - }) + Ok(ToolResult::new(format!("Loaded skill: {}", name), output) + .with_metadata("name", serde_json::Value::String(info.name.clone())) + .with_metadata( + "dir", + serde_json::Value::String(dir.to_string_lossy().to_string()), + )) } } diff --git a/src/tools/types.rs b/src/tools/types.rs index e30815e..de76b65 100644 --- a/src/tools/types.rs +++ b/src/tools/types.rs @@ -33,6 +33,14 @@ pub struct ToolResult { pub title: String, pub output: String, pub metadata: HashMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolResultImage { + pub data_url: String, + pub media_type: String, } #[derive(Debug, thiserror::Error)] @@ -106,6 +114,7 @@ impl ToolResult { title: title.into(), output: output.into(), metadata: HashMap::new(), + images: Vec::new(), } } @@ -113,4 +122,16 @@ impl ToolResult { self.metadata.insert(key.into(), value); self } + + pub fn with_image( + mut self, + data_url: impl Into, + media_type: impl Into, + ) -> Self { + self.images.push(ToolResultImage { + data_url: data_url.into(), + media_type: media_type.into(), + }); + self + } } diff --git a/src/utils/image_attachment.rs b/src/utils/image_attachment.rs index 6b10ca4..7f37540 100644 --- a/src/utils/image_attachment.rs +++ b/src/utils/image_attachment.rs @@ -1,10 +1,24 @@ use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose, Engine as _}; +use image::codecs::jpeg::JpegEncoder; +use image::codecs::png::PngEncoder; +use image::codecs::webp::WebPEncoder; +use image::imageops::FilterType; +use image::{ColorType, DynamicImage, GenericImageView, ImageEncoder, ImageFormat}; use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; use std::process::Command; const SUPPORTED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; +const MAX_PROMPT_IMAGE_DIMENSION: u32 = 2048; + +#[derive(Debug, Clone)] +pub struct PromptImage { + pub data_url: String, + pub media_type: String, + pub width: u32, + pub height: u32, +} pub fn is_supported_image_path(path: &Path) -> bool { if !path.is_file() { @@ -46,6 +60,118 @@ pub fn data_url_for_path(path: &Path) -> Result { Ok(format!("data:{mime_type};base64,{encoded}")) } +pub fn prompt_image_for_path(path: &Path, preserve_original: bool) -> Result { + let bytes = + std::fs::read(path).with_context(|| format!("failed to read image {}", path.display()))?; + prompt_image_from_bytes(path, bytes, preserve_original) +} + +fn prompt_image_from_bytes( + path: &Path, + bytes: Vec, + preserve_original: bool, +) -> Result { + let source_format = image::guess_format(&bytes).ok().and_then(|format| { + matches!( + format, + ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::Gif | ImageFormat::WebP + ) + .then_some(format) + }); + + let image = image::load_from_memory(&bytes) + .with_context(|| format!("failed to decode image {}", path.display()))?; + let (width, height) = image.dimensions(); + let can_keep_original = preserve_original + || (width <= MAX_PROMPT_IMAGE_DIMENSION && height <= MAX_PROMPT_IMAGE_DIMENSION); + + let (output_bytes, output_format, output_width, output_height) = if can_keep_original { + if let Some(format) = source_format.filter(|format| can_preserve_source_bytes(*format)) { + (bytes, format, width, height) + } else { + let output_format = ImageFormat::Png; + let output_bytes = encode_image(&image, output_format) + .with_context(|| format!("failed to encode image {}", path.display()))?; + (output_bytes, output_format, width, height) + } + } else { + let resized = image.resize( + MAX_PROMPT_IMAGE_DIMENSION, + MAX_PROMPT_IMAGE_DIMENSION, + FilterType::Triangle, + ); + let output_format = source_format + .filter(|format| can_preserve_source_bytes(*format)) + .unwrap_or(ImageFormat::Png); + let output_bytes = encode_image(&resized, output_format) + .with_context(|| format!("failed to encode image {}", path.display()))?; + ( + output_bytes, + output_format, + resized.width(), + resized.height(), + ) + }; + + let media_type = format_to_mime(output_format).to_string(); + let encoded = general_purpose::STANDARD.encode(output_bytes); + Ok(PromptImage { + data_url: format!("data:{media_type};base64,{encoded}"), + media_type, + width: output_width, + height: output_height, + }) +} + +fn can_preserve_source_bytes(format: ImageFormat) -> bool { + matches!( + format, + ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP + ) +} + +fn encode_image(image: &DynamicImage, format: ImageFormat) -> Result> { + let mut buffer = Vec::new(); + + match format { + ImageFormat::Jpeg => { + let mut encoder = JpegEncoder::new_with_quality(&mut buffer, 85); + encoder.encode_image(image)?; + } + ImageFormat::WebP => { + let rgba = image.to_rgba8(); + let encoder = WebPEncoder::new_lossless(&mut buffer); + encoder.write_image( + rgba.as_raw(), + image.width(), + image.height(), + ColorType::Rgba8.into(), + )?; + } + _ => { + let rgba = image.to_rgba8(); + let encoder = PngEncoder::new(&mut buffer); + encoder.write_image( + rgba.as_raw(), + image.width(), + image.height(), + ColorType::Rgba8.into(), + )?; + } + } + + Ok(buffer) +} + +fn format_to_mime(format: ImageFormat) -> &'static str { + match format { + ImageFormat::Jpeg => "image/jpeg", + ImageFormat::Gif => "image/gif", + ImageFormat::WebP => "image/webp", + _ => "image/png", + } +} + pub fn normalize_pasted_path(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -142,6 +268,23 @@ pub fn open_path(path: &Path, config: &crate::config::ImagesConfig) -> Result<() } } +pub fn open_file_path(path: &Path) -> Result<()> { + if !path.exists() { + return Err(anyhow!("file no longer exists: {}", path.display())); + } + + open_editor(path).or_else(|_| open_system(path)) +} + +pub fn open_url(url: &str) -> Result<()> { + let parsed = url::Url::parse(url).with_context(|| format!("invalid url: {url}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(anyhow!("unsupported url scheme: {}", parsed.scheme())); + } + + open_system_url(parsed.as_str()) +} + fn open_auto(path: &Path) -> Result<()> { if let Some(command) = detected_editor_command() { if spawn_command(&command, &[path.to_string_lossy().into_owned()]).is_ok() { @@ -375,6 +518,35 @@ fn open_system(path: &Path) -> Result<()> { } } +fn open_system_url(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(url) + .spawn() + .with_context(|| format!("failed to open {url}"))?; + return Ok(()); + } + + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .with_context(|| format!("failed to open {url}"))?; + return Ok(()); + } + + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + Command::new("xdg-open") + .arg(url) + .spawn() + .with_context(|| format!("failed to open {url}"))?; + Ok(()) + } +} + fn unwrap_quotes(value: &str) -> &str { let bytes = value.as_bytes(); if bytes.len() >= 2 From c02e6d37b50d2002e4f5cb00a0c482a2ff92ed47 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 04:34:25 +0800 Subject: [PATCH 156/226] feat: add tool image output support and chat hyperlinks. Introduce `ToolOutput` struct with `text` + `images` fields, propagate images through tool output messages and all providers (Anthropic, OpenAI, compatible), and add clickable hyperlinks (file paths and URLs) in the TUI chat with `view_image` tool display. --- aisdk/src/lib.rs | 2 +- aisdk/src/message.rs | 13 + aisdk/src/providers/anthropic.rs | 34 ++- aisdk/src/providers/compatible.rs | 70 ++++- aisdk/src/providers/openai.rs | 47 +++- aisdk/src/response.rs | 30 +- aisdk/src/tool.rs | 55 +++- src/app.rs | 63 ++++- src/ui/components/chat.rs | 350 +++++++++++++++++++++++ src/ui/hyperlink.rs | 447 ++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + 11 files changed, 1072 insertions(+), 40 deletions(-) create mode 100644 src/ui/hyperlink.rs diff --git a/aisdk/src/lib.rs b/aisdk/src/lib.rs index 3c65055..e4b7dd5 100644 --- a/aisdk/src/lib.rs +++ b/aisdk/src/lib.rs @@ -32,7 +32,7 @@ pub mod core { } pub mod tools { - pub use crate::tool::ToolExecute; + pub use crate::tool::{ToolExecute, ToolOutput}; } pub mod chunk { diff --git a/aisdk/src/message.rs b/aisdk/src/message.rs index 9363345..a25c81c 100644 --- a/aisdk/src/message.rs +++ b/aisdk/src/message.rs @@ -74,11 +74,22 @@ impl Message { name: impl Into, output: impl Into, is_error: bool, + ) -> Self { + Self::tool_output_with_images(call_id, name, output, Vec::new(), is_error) + } + + pub fn tool_output_with_images( + call_id: impl Into, + name: impl Into, + output: impl Into, + images: Vec, + is_error: bool, ) -> Self { Self::ToolOutput(ToolOutputMessage { call_id: call_id.into(), name: name.into(), output: output.into(), + images, is_error, }) } @@ -121,6 +132,8 @@ pub struct ToolOutputMessage { pub call_id: String, pub name: String, pub output: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec, #[serde(default)] pub is_error: bool, } diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs index 6a79f98..9b20d68 100644 --- a/aisdk/src/providers/anthropic.rs +++ b/aisdk/src/providers/anthropic.rs @@ -132,7 +132,7 @@ impl Provider for Anthropic { "content": [{ "type": "tool_result", "tool_use_id": t.call_id, - "content": t.output, + "content": anthropic_tool_output_content(t), "is_error": t.is_error, }], })), @@ -300,3 +300,35 @@ fn anthropic_user_content(user: &crate::message::UserMessage) -> serde_json::Val serde_json::Value::Array(parts) } + +fn anthropic_tool_output_content(tool: &crate::message::ToolOutputMessage) -> serde_json::Value { + if tool.images.is_empty() { + return serde_json::json!(tool.output); + } + + let mut parts = Vec::new(); + if !tool.output.is_empty() { + parts.push(serde_json::json!({ + "type": "text", + "text": tool.output, + })); + } + + parts.extend(tool.images.iter().map(|image| { + let data = image + .data_url + .split_once(',') + .map(|(_, data)| data) + .unwrap_or(image.data_url.as_str()); + serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": image.media_type, + "data": data, + }, + }) + })); + + serde_json::Value::Array(parts) +} diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index 35ef46b..c66ca2f 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -102,20 +102,20 @@ impl Provider for OpenAICompatible { let chat_messages: Vec = messages .iter() - .map(|m| match m { - Message::System(s) => serde_json::json!({ + .flat_map(|m| match m { + Message::System(s) => vec![serde_json::json!({ "role": "system", "content": s.content, - }), - Message::User(u) => serde_json::json!({ + })], + Message::User(u) => vec![serde_json::json!({ "role": "user", "content": openai_compatible_user_content(u), - }), - Message::Assistant(a) => serde_json::json!({ + })], + Message::Assistant(a) => vec![serde_json::json!({ "role": "assistant", "content": a.content, - }), - Message::ToolCall(t) => serde_json::json!({ + })], + Message::ToolCall(t) => vec![serde_json::json!({ "role": "assistant", "content": serde_json::Value::Null, "tool_calls": [{ @@ -126,13 +126,8 @@ impl Provider for OpenAICompatible { "arguments": t.arguments, } }], - }), - Message::ToolOutput(t) => serde_json::json!({ - "role": "tool", - "tool_call_id": t.call_id, - "name": t.name, - "content": t.output, - }), + })], + Message::ToolOutput(t) => openai_compatible_tool_output_messages(t), }) .collect(); @@ -233,6 +228,51 @@ fn openai_compatible_user_content(user: &crate::message::UserMessage) -> serde_j serde_json::Value::Array(parts) } +fn openai_compatible_tool_output_messages( + tool: &crate::message::ToolOutputMessage, +) -> Vec { + let mut messages = vec![serde_json::json!({ + "role": "tool", + "tool_call_id": tool.call_id, + "name": tool.name, + "content": tool.output, + })]; + + if !tool.images.is_empty() { + messages.push(serde_json::json!({ + "role": "user", + "content": openai_compatible_image_content( + &format!("Image returned by tool `{}`.", tool.name), + &tool.images, + ), + })); + } + + messages +} + +fn openai_compatible_image_content( + text: &str, + images: &[crate::message::ImageContent], +) -> serde_json::Value { + let mut parts = Vec::new(); + if !text.is_empty() { + parts.push(serde_json::json!({ + "type": "text", + "text": text, + })); + } + parts.extend(images.iter().map(|image| { + serde_json::json!({ + "type": "image_url", + "image_url": { + "url": image.data_url, + }, + }) + })); + serde_json::Value::Array(parts) +} + fn debug_log(msg: &str) { let _ = std::fs::OpenOptions::new() .create(true) diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index cc5a80a..a4669a1 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -1231,7 +1231,7 @@ fn build_openai_messages(messages: &[Message], strip_system: bool) -> Vec Some(serde_json::json!({ "type": "function_call_output", "call_id": t.call_id, - "output": t.output, + "output": openai_tool_output_content(t), })), } }) @@ -1259,6 +1259,27 @@ fn openai_responses_user_content(user: &crate::message::UserMessage) -> serde_js serde_json::Value::Array(parts) } +fn openai_tool_output_content(tool: &crate::message::ToolOutputMessage) -> serde_json::Value { + if tool.images.is_empty() { + return serde_json::json!(tool.output); + } + + let mut parts = Vec::new(); + if !tool.output.is_empty() { + parts.push(serde_json::json!({ + "type": "input_text", + "text": tool.output, + })); + } + parts.extend(tool.images.iter().map(|image| { + serde_json::json!({ + "type": "input_image", + "image_url": image.data_url, + }) + })); + serde_json::Value::Array(parts) +} + #[cfg(test)] mod tests { use super::{ @@ -1375,6 +1396,30 @@ mod tests { assert_eq!(input[1]["output"], "Replaced at line 7"); } + #[test] + fn serializes_tool_image_output_for_responses_input() { + let input = build_openai_messages( + &[Message::tool_output_with_images( + "call_image", + "view_image", + "Viewed image assets/screenshot_1.png", + vec![crate::message::ImageContent { + data_url: "data:image/png;base64,AAA".to_string(), + media_type: "image/png".to_string(), + }], + false, + )], + false, + ); + + assert_eq!(input[0]["type"], "function_call_output"); + assert_eq!(input[0]["call_id"], "call_image"); + let output = input[0]["output"].as_array().expect("content items"); + assert_eq!(output[0]["type"], "input_text"); + assert_eq!(output[1]["type"], "input_image"); + assert_eq!(output[1]["image_url"], "data:image/png;base64,AAA"); + } + #[tokio::test] async fn websocket_request_uses_previous_response_id_for_append_only_delta() { let provider = OpenAI::builder() diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 79003ce..3a56e2f 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -3,7 +3,7 @@ use crate::error::Result; use crate::message::Message; use crate::provider::Provider; use crate::stop::{StopReason, StopWhenFn}; -use crate::tool::Tool; +use crate::tool::{Tool, ToolOutput}; use futures::{future::join_all, StreamExt}; use std::collections::{BTreeMap, HashMap}; use std::pin::Pin; @@ -82,7 +82,7 @@ pub async fn stream_with_tools( let mut current_messages = messages; let mut step_idx: usize = 0; let max_steps = max_steps.unwrap_or(usize::MAX); - let mut cached_repeatable_tool_results: HashMap = HashMap::new(); + let mut cached_repeatable_tool_results: HashMap = HashMap::new(); loop { step_idx += 1; @@ -305,10 +305,10 @@ pub async fn stream_with_tools( tool_results_to_observe.push(ToolExecutionResult { call_id, tool_name, - output: format!( + output: ToolOutput::new(format!( "Duplicate task call skipped; reusing the prior result from this response.\n\n{}", - cached_output - ), + cached_output.text + )), cache_key: None, is_error: false, }); @@ -338,7 +338,10 @@ pub async fn stream_with_tools( Err(err) => ToolExecutionResult { call_id, tool_name: tool_name.clone(), - output: format!("Tool '{}' error: {}", tool_name, err), + output: ToolOutput::new(format!( + "Tool '{}' error: {}", + tool_name, err + )), cache_key: None, is_error: true, }, @@ -346,7 +349,7 @@ pub async fn stream_with_tools( None => ToolExecutionResult { call_id, tool_name: tool_name.clone(), - output: format!("Tool not found: {}", tool_name), + output: ToolOutput::new(format!("Tool not found: {}", tool_name)), cache_key: None, is_error: true, }, @@ -387,10 +390,11 @@ pub async fn stream_with_tools( let tool_output_messages = tool_results_to_observe .into_iter() .map(|result| { - Message::tool_output( + Message::tool_output_with_images( result.call_id, result.tool_name, - result.output, + result.output.text, + result.output.images, result.is_error, ) }) @@ -488,7 +492,7 @@ fn message_size(message: &Message) -> (usize, usize) { Message::User(message) => (message.content.len(), message.images.len()), Message::Assistant(message) => (message.content.len(), 0), Message::ToolCall(message) => (message.arguments.len(), 0), - Message::ToolOutput(message) => (message.output.len(), 0), + Message::ToolOutput(message) => (message.output.len(), message.images.len()), } } @@ -610,7 +614,7 @@ struct CompletedToolCall { struct ToolExecutionResult { call_id: String, tool_name: String, - output: String, + output: ToolOutput, cache_key: Option, is_error: bool, } @@ -1299,7 +1303,9 @@ mod tests { .description("edit files") .input_schema(Schema::from(true)) .execute(ToolExecute::new(move |_input| async move { - Err("Execution error: Not found: Could not find text to replace".to_string()) + Err::( + "Execution error: Not found: Could not find text to replace".to_string(), + ) })) .build() .unwrap(); diff --git a/aisdk/src/tool.rs b/aisdk/src/tool.rs index 2aea3f1..ca89d79 100644 --- a/aisdk/src/tool.rs +++ b/aisdk/src/tool.rs @@ -1,31 +1,76 @@ +use crate::message::ImageContent; use schemars::Schema; use std::future::Future; use std::pin::Pin; use std::sync::Arc; pub type AsyncToolFn = Arc< - dyn Fn(serde_json::Value) -> Pin> + Send>> + dyn Fn(serde_json::Value) -> Pin> + Send>> + Send + Sync, >; +#[derive(Debug, Clone, Default)] +pub struct ToolOutput { + pub text: String, + pub images: Vec, +} + +impl ToolOutput { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + images: Vec::new(), + } + } + + pub fn with_images(mut self, images: Vec) -> Self { + self.images = images; + self + } + + pub fn len(&self) -> usize { + self.text.len() + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() && self.images.is_empty() + } +} + +impl From for ToolOutput { + fn from(text: String) -> Self { + Self::new(text) + } +} + +impl From<&str> for ToolOutput { + fn from(text: &str) -> Self { + Self::new(text) + } +} + #[derive(Clone)] pub struct ToolExecute { inner: AsyncToolFn, } impl ToolExecute { - pub fn new(f: F) -> Self + pub fn new(f: F) -> Self where F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, + Fut: Future> + Send + 'static, + O: Into + Send + 'static, { Self { - inner: Arc::new(move |v: serde_json::Value| Box::pin(f(v))), + inner: Arc::new(move |v: serde_json::Value| { + let fut = f(v); + Box::pin(async move { fut.await.map(Into::into) }) + }), } } - pub async fn call(&self, input: serde_json::Value) -> Result { + pub async fn call(&self, input: serde_json::Value) -> Result { (self.inner)(input).await } } diff --git a/src/app.rs b/src/app.rs index 3c3f532..f916766 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,6 +14,7 @@ use crate::toast::{self, Toast, ToastLevel}; use crate::ui::components::chat::{Chat, ChatImageTarget}; use crate::ui::components::input::Input; use crate::ui::components::popup::Popup; +use crate::ui::hyperlink::HyperlinkTarget; use crate::utils::git; use crate::views::chat::{ @@ -2300,6 +2301,37 @@ impl App { } } + fn open_chat_hyperlink_target(&self, target: &HyperlinkTarget) { + match target { + HyperlinkTarget::File(path) => { + match crate::utils::image_attachment::open_file_path(path) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", path.display()), + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open file: {}", err), + ToastLevel::Error, + None, + )), + } + } + HyperlinkTarget::Url(url) => match crate::utils::image_attachment::open_url(url) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", url), + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open link: {}", err), + ToastLevel::Error, + None, + )), + }, + } + } + pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() { crate::emit_log!( @@ -2594,6 +2626,15 @@ impl App { self.open_chat_image_target(&target); return; } + + if let Some(target) = + self.chat_state.chat.hyperlink_at_position(mouse, chat_area) + { + self.pending_chat_message_click = None; + self.close_message_actions(); + self.open_chat_hyperlink_target(&target); + return; + } } } @@ -2701,7 +2742,9 @@ impl App { } } MouseEventKind::Down(MouseButton::Left) - if mouse.modifiers.is_empty() + if (mouse.modifiers.is_empty() + || mouse.modifiers.contains(KeyModifiers::SUPER) + || mouse.modifiers.contains(KeyModifiers::META)) && !self.chat_state.chat.has_selection() && !self.chat_state.chat.selection.is_dragging => { @@ -2714,10 +2757,20 @@ impl App { return; } - self.pending_chat_message_click = self - .chat_state - .chat - .message_index_at_position(mouse, chat_area); + if let Some(target) = + self.chat_state.chat.hyperlink_at_position(mouse, chat_area) + { + self.pending_chat_message_click = None; + self.open_chat_hyperlink_target(&target); + return; + } + + if mouse.modifiers.is_empty() { + self.pending_chat_message_click = self + .chat_state + .chat + .message_index_at_position(mouse, chat_area); + } } MouseEventKind::Drag(MouseButton::Left) => { self.pending_chat_message_click = None; diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 3428c40..71dfc50 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -218,6 +218,101 @@ fn display_path(raw: &str, basename_only: bool) -> String { trimmed.to_string() } +fn tool_path_candidates(message: &Message) -> Vec { + if message.role != MessageRole::Tool { + return Vec::new(); + } + + let Some(info) = parse_tool_message(&message.content) else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + let mut push_candidate = |value: Option<&str>| { + if let Some(path) = value.and_then(path_candidate_from_value) { + if !candidates.iter().any(|candidate| candidate == &path) { + candidates.push(path); + } + } + }; + + let args_obj = info.args.as_ref().and_then(|value| value.as_object()); + let metadata_obj = info.metadata.as_ref().and_then(|value| value.as_object()); + for key in ["path", "file_path", "filePath"] { + push_candidate(arg_string(args_obj, &[key])); + push_candidate(arg_string(metadata_obj, &[key])); + } + + if let Some(title) = info.title.as_deref() { + push_candidate(title.split_once(':').map(|(_, path)| path.trim())); + } + + candidates +} + +fn matching_tool_path(message: &Message, display: &str) -> Option { + tool_path_candidates(message) + .into_iter() + .find(|path| path_matches_display(path, display)) +} + +fn path_candidate_from_value(value: &str) -> Option { + let path_text = value.trim(); + if path_text.is_empty() { + return None; + } + + if path_text.starts_with("file://") { + return url::Url::parse(path_text).ok()?.to_file_path().ok(); + } + + if let Some(rest) = path_text.strip_prefix("~/") { + return dirs::home_dir().map(|home| home.join(rest)); + } + + let path = std::path::PathBuf::from(path_text); + if path.is_absolute() { + Some(path) + } else { + std::env::current_dir().ok().map(|cwd| cwd.join(path)) + } +} + +fn path_matches_display(path: &std::path::Path, display: &str) -> bool { + if display.is_empty() { + return false; + } + + let path_text = path.to_string_lossy(); + let candidates = [ + path_text.into_owned(), + display_path(&path.to_string_lossy(), false), + display_path(&path.to_string_lossy(), true), + ]; + + candidates + .iter() + .any(|candidate| display_matches_candidate(display, candidate)) +} + +fn display_matches_candidate(display: &str, candidate: &str) -> bool { + display == candidate + || display + .strip_prefix(candidate) + .is_some_and(is_display_location_suffix) +} + +fn is_display_location_suffix(suffix: &str) -> bool { + let Some(rest) = suffix.strip_prefix(':') else { + return false; + }; + + !rest.is_empty() + && rest + .chars() + .all(|ch| ch.is_ascii_digit() || matches!(ch, ':' | '-')) +} + fn search_target( args_obj: Option<&serde_json::Map>, title: Option<&str>, @@ -1196,6 +1291,82 @@ impl Chat { }) } + pub fn hyperlink_at_position( + &self, + event: MouseEvent, + area: Rect, + ) -> Option { + use ratatui::layout::Position; + + let point = Position::new(event.column, event.row); + let content_area = Self::content_area_for(area); + + if !content_area.contains(point) || self.cached_lines.is_empty() { + return None; + } + + let content_line = + (event.row.saturating_sub(content_area.y) as usize).saturating_add(self.scroll_offset); + let content_col = event.column.saturating_sub(content_area.x) as usize; + let line = self.cached_lines.get(content_line)?; + let range = crate::ui::hyperlink::hyperlink_range_at_line_col(line, content_col)?; + + self.resolve_hyperlink_target(content_line, &range) + .or_else(|| Some(range.target)) + } + + fn resolve_hyperlink_target( + &self, + content_line: usize, + range: &crate::ui::hyperlink::HyperlinkRange, + ) -> Option { + if !matches!(range.target, crate::ui::hyperlink::HyperlinkTarget::File(_)) { + return None; + } + + let display = range.text.trim(); + let message_index = self + .message_index_at_content_line(content_line, self.content_height) + .or_else(|| self.raw_message_index_at_content_line(content_line, self.content_height)); + + if let Some(target) = message_index + .and_then(|idx| self.messages.get(idx)) + .and_then(|message| matching_tool_path(message, display)) + { + return Some(crate::ui::hyperlink::HyperlinkTarget::File(target)); + } + + self.messages + .iter() + .find_map(|message| matching_tool_path(message, display)) + .map(crate::ui::hyperlink::HyperlinkTarget::File) + } + + fn raw_message_index_at_content_line( + &self, + content_line: usize, + content_height: usize, + ) -> Option { + if content_line >= content_height { + return None; + } + + self.message_line_positions + .iter() + .copied() + .enumerate() + .find_map(|(idx, start)| { + let end = self + .message_line_positions + .iter() + .copied() + .skip(idx + 1) + .find(|&next_start| next_start > start) + .unwrap_or(content_height); + (content_line >= start && content_line < end).then_some(idx) + }) + } + pub fn clear_highlighted_message(&mut self) { self.highlighted_message_index = None; } @@ -1685,6 +1856,11 @@ impl Chat { let paragraph = Paragraph::new(Text::from(content_lines)); f.render_widget(paragraph, render_area); + crate::ui::hyperlink::mark_detected_hyperlinks( + f.buffer_mut(), + render_area, + &all_lines[visible_start..visible_end], + ); self.content_height = content_height; self.message_line_positions = positions.to_vec(); @@ -2465,6 +2641,7 @@ impl Chat { "question" => "Question", "task" => "Task", "webfetch" => "Webfetch", + "view_image" => "Viewed Image", "skill" => "Skill", other => other, }; @@ -2656,6 +2833,64 @@ impl Chat { } out.extend(panel_lines); + } else if name == "view_image" { + let active = matches!(status.as_str(), "running" | "pending"); + let path = metadata + .as_ref() + .and_then(|m| m.get("path")) + .and_then(|v| v.as_str()) + .or_else(|| { + args_obj + .and_then(|o| o.get("path")) + .and_then(|v| v.as_str()) + }) + .or_else(|| strip_tool_title(title.as_deref(), "Viewed Image")) + .unwrap_or("image"); + let marker_style = Style::default() + .fg(if status == "error" { + colors.error + } else if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(if status == "error" { + colors.error + } else { + colors.text + }) + .add_modifier(Modifier::BOLD); + let gutter_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let path_style = Style::default().fg(colors.text_weak); + let heading = if active { + "Viewing Image" + } else { + "Viewed Image" + }; + + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(heading.to_string(), title_style), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(" └ ".to_string(), gutter_style), + Span::styled(display_path(path, true), path_style), + ]), + max_width, + Line::from(Span::styled(" ", gutter_style)), + ); } else if name == "webfetch" { let active = matches!(status.as_str(), "running" | "pending"); let url = metadata @@ -3947,6 +4182,121 @@ mod tests { assert_eq!(target.path, "/tmp/example.png"); } + #[test] + fn test_hyperlink_hit_test_finds_file_path() { + let mut chat = Chat::with_messages(vec![Message::assistant("open src/ui/hyperlink.rs:12")]); + let colors = test_colors(); + let area = Rect::new(0, 0, 80, 10); + let content_width = area.width.saturating_sub(2) as usize; + let (lines, positions) = + chat.build_all_lines_with_positions(content_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = area.height as usize; + chat.scroll_offset = 0; + + let (line_idx, col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("src/ui/hyperlink.rs") + .map(|col| (line_idx, col as u16)) + }) + .expect("path position"); + + let target = chat + .hyperlink_at_position( + mouse( + MouseEventKind::Down(MouseButton::Left), + col, + line_idx as u16, + KeyModifiers::empty(), + ), + area, + ) + .expect("hyperlink target"); + + match target { + crate::ui::hyperlink::HyperlinkTarget::File(path) => { + assert!(path.ends_with("src/ui/hyperlink.rs")); + } + crate::ui::hyperlink::HyperlinkTarget::Url(url) => { + panic!("expected file target, got {url}"); + } + } + } + + #[test] + fn test_hyperlink_hit_test_uses_tool_metadata_for_short_path() { + let full_path = std::env::current_dir() + .unwrap() + .join("fixtures/not-real/screenshot_1.png"); + let message = Message::tool( + serde_json::json!({ + "name": "view_image", + "status": "ok", + "metadata": { "path": full_path.to_string_lossy().to_string() }, + "title": format!("Viewed Image: {}", full_path.display()), + }) + .to_string(), + ); + let mut chat = Chat::with_messages(vec![message]); + let colors = test_colors(); + let area = Rect::new(0, 0, 80, 10); + assert_eq!( + tool_path_candidates(&chat.messages[0]), + vec![full_path.clone()] + ); + let content_width = area.width.saturating_sub(2) as usize; + let (lines, positions) = + chat.build_all_lines_with_positions(content_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = area.height as usize; + chat.scroll_offset = 0; + + let (line_idx, col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("screenshot_1.png") + .map(|col| (line_idx, col as u16)) + }) + .expect("short path position"); + assert_eq!( + chat.raw_message_index_at_content_line(line_idx, chat.content_height), + Some(0) + ); + assert!(path_matches_display(&full_path, "screenshot_1.png")); + + let target = chat + .hyperlink_at_position( + mouse( + MouseEventKind::Down(MouseButton::Left), + col, + line_idx as u16, + KeyModifiers::empty(), + ), + area, + ) + .expect("hyperlink target"); + + match target { + crate::ui::hyperlink::HyperlinkTarget::File(path) => assert_eq!(path, full_path), + crate::ui::hyperlink::HyperlinkTarget::Url(url) => { + panic!("expected file target, got {url}"); + } + } + } + #[test] fn selected_text_uses_render_cached_lines_when_copy_width_differs() { let colors = test_colors(); diff --git a/src/ui/hyperlink.rs b/src/ui/hyperlink.rs new file mode 100644 index 0000000..d30bae0 --- /dev/null +++ b/src/ui/hyperlink.rs @@ -0,0 +1,447 @@ +use ratatui::{buffer::Buffer, layout::Rect, style::Modifier, text::Line}; +use std::path::PathBuf; +use std::sync::LazyLock; +use unicode_width::UnicodeWidthStr; +use url::Url; + +static LOCATION_SUFFIX_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r":\d+(?::\d+)?(?:-\d+(?::\d+)?)?$").unwrap()); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HyperlinkTarget { + Url(String), + File(PathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HyperlinkRange { + pub start_col: usize, + pub end_col: usize, + pub text: String, + pub target: HyperlinkTarget, +} + +/// Mark URL-like and local-path-like text in rendered buffer cells. +pub fn mark_detected_hyperlinks(buf: &mut Buffer, area: Rect, lines: &[Line<'_>]) { + if area.width == 0 || area.height == 0 { + return; + } + + for (line_idx, line) in lines.iter().take(area.height as usize).enumerate() { + let text = line_to_string(line); + let ranges = detect_hyperlinks(&text); + let y = area.y.saturating_add(line_idx as u16); + + for range in ranges { + mark_range(buf, area, y, &range); + } + } +} + +pub fn hyperlink_at_line_col(line: &Line<'_>, col: usize) -> Option { + hyperlink_range_at_line_col(line, col).map(|range| range.target) +} + +pub fn hyperlink_range_at_line_col(line: &Line<'_>, col: usize) -> Option { + let text = line_to_string(line); + detect_hyperlinks(&text) + .into_iter() + .find(|range| col >= range.start_col && col < range.end_col) +} + +fn mark_range(buf: &mut Buffer, area: Rect, y: u16, range: &HyperlinkRange) { + if range.start_col >= range.end_col { + return; + } + + let start = range.start_col.min(area.width as usize); + let end = range.end_col.min(area.width as usize); + + for col in start..end { + let x = area.x.saturating_add(col as u16); + let cell = &mut buf[(x, y)]; + let symbol = cell.symbol().to_string(); + if symbol.trim().is_empty() { + continue; + } + + cell.modifier.insert(Modifier::UNDERLINED); + } +} + +fn detect_hyperlinks(text: &str) -> Vec { + candidate_tokens(text) + .filter_map(|(start, end, token)| { + let target = hyperlink_target_for_token(token)?; + Some(HyperlinkRange { + start_col: UnicodeWidthStr::width(&text[..start]), + end_col: UnicodeWidthStr::width(&text[..end]), + text: token.to_string(), + target, + }) + }) + .collect() +} + +fn candidate_tokens(text: &str) -> impl Iterator { + let mut raw_tokens = Vec::new(); + let mut start = None; + + for (idx, ch) in text.char_indices() { + if ch.is_whitespace() { + if let Some(token_start) = start.take() { + raw_tokens.push((token_start, idx)); + } + } else if start.is_none() { + start = Some(idx); + } + } + + if let Some(token_start) = start { + raw_tokens.push((token_start, text.len())); + } + + raw_tokens.into_iter().filter_map(move |(start, end)| { + let (start, end) = trim_token_bounds(text, start, end); + (start < end).then(|| (start, end, &text[start..end])) + }) +} + +fn trim_token_bounds(text: &str, mut start: usize, mut end: usize) -> (usize, usize) { + while start < end { + let Some(ch) = text[start..end].chars().next() else { + break; + }; + if is_token_prefix_delimiter(ch) { + start += ch.len_utf8(); + } else { + break; + } + } + + while start < end { + let Some(ch) = text[start..end].chars().next_back() else { + break; + }; + if is_token_suffix_delimiter(ch) { + end -= ch.len_utf8(); + } else { + break; + } + } + + (start, end) +} + +fn is_token_prefix_delimiter(ch: char) -> bool { + matches!(ch, '"' | '\'' | '`' | '(' | '[' | '{' | '<') +} + +fn is_token_suffix_delimiter(ch: char) -> bool { + matches!( + ch, + '"' | '\'' | '`' | ')' | ']' | '}' | '>' | ',' | ';' | ':' | '.' | '!' | '?' + ) +} + +fn hyperlink_target_for_token(token: &str) -> Option { + if token.starts_with("http://") || token.starts_with("https://") { + return Some(HyperlinkTarget::Url(token.to_string())); + } + + if token.starts_with("file://") { + return file_target_for_file_url_token(token).map(HyperlinkTarget::File); + } + + file_target_for_local_path_token(token).map(HyperlinkTarget::File) +} + +fn file_target_for_file_url_token(token: &str) -> Option { + let url = Url::parse(token).ok()?; + let path = url.to_file_path().ok()?; + let path_text = path.to_string_lossy(); + let path_text = strip_location_suffix(&path_text); + Some(PathBuf::from(path_text)) +} + +fn file_target_for_local_path_token(token: &str) -> Option { + let path_text = strip_location_suffix(token); + if !is_local_path_like(path_text) { + return None; + } + + expand_local_path(path_text) +} + +fn strip_location_suffix(token: &str) -> &str { + let without_hash = token + .rsplit_once('#') + .filter(|(_, fragment)| is_hash_location_suffix(fragment)) + .map(|(path, _)| path) + .unwrap_or(token); + + LOCATION_SUFFIX_RE + .find(without_hash) + .filter(|matched| matched.end() == without_hash.len()) + .map(|matched| &without_hash[..matched.start()]) + .unwrap_or(without_hash) +} + +fn is_hash_location_suffix(fragment: &str) -> bool { + let mut chars = fragment.chars(); + matches!(chars.next(), Some('L')) + && chars.any(|ch| ch.is_ascii_digit()) + && fragment + .chars() + .all(|ch| ch == 'L' || ch == 'C' || ch == '-' || ch.is_ascii_digit()) +} + +fn expand_local_path(path_text: &str) -> Option { + if let Some(rest) = path_text.strip_prefix("~/") { + return dirs::home_dir().map(|home| home.join(rest)); + } + + let path = PathBuf::from(path_text); + if path.is_absolute() { + Some(path) + } else { + std::env::current_dir().ok().map(|cwd| cwd.join(path)) + } +} + +fn is_local_path_like(path_text: &str) -> bool { + if path_text.is_empty() + || path_text.contains("://") + || path_text.chars().any(is_forbidden_path_char) + { + return false; + } + + if path_text.starts_with('/') + || path_text.starts_with("~/") + || path_text.starts_with("./") + || path_text.starts_with("../") + { + return path_text.len() > 1; + } + + if path_text.contains('/') { + return is_relative_slash_path(path_text); + } + + is_known_local_filename(path_text) || has_known_extension(path_text) +} + +fn is_forbidden_path_char(ch: char) -> bool { + matches!(ch, '=' | '|' | '*' | '?' | '<' | '>' | '@') +} + +fn is_relative_slash_path(path_text: &str) -> bool { + let segments = path_text + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + let syntactically_path_like = segments.len() >= 2 + && segments + .first() + .and_then(|segment| segment.chars().next()) + .is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(ch, '.' | '_')) + && segments.iter().all(|segment| { + *segment != "." && *segment != ".." && segment.chars().all(is_path_segment_char) + }); + + if !syntactically_path_like { + return false; + } + + if has_known_extension(path_text) + || segments + .last() + .is_some_and(|segment| is_known_local_filename(segment)) + { + return true; + } + + expand_local_path(path_text).is_some_and(|path| path.exists()) +} + +fn is_path_segment_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '+') +} + +fn is_known_local_filename(path_text: &str) -> bool { + matches!( + path_text.to_ascii_lowercase().as_str(), + ".env" + | ".gitignore" + | ".gitattributes" + | "agents.md" + | "cargo.lock" + | "cargo.toml" + | "dockerfile" + | "justfile" + | "license" + | "makefile" + | "package.json" + | "pnpm-lock.yaml" + | "readme.md" + ) +} + +fn has_known_extension(path_text: &str) -> bool { + let Some(ext) = path_text.rsplit('.').next() else { + return false; + }; + if ext == path_text || ext.is_empty() || ext.len() > 8 { + return false; + } + + matches!( + ext.to_ascii_lowercase().as_str(), + "c" | "cc" + | "cpp" + | "css" + | "go" + | "h" + | "hpp" + | "html" + | "java" + | "js" + | "json" + | "jsonc" + | "jsx" + | "kt" + | "lock" + | "lua" + | "m" + | "md" + | "mdx" + | "mm" + | "gif" + | "jpeg" + | "jpg" + | "pdf" + | "png" + | "py" + | "rb" + | "rs" + | "sh" + | "sql" + | "svg" + | "swift" + | "toml" + | "ts" + | "tsx" + | "txt" + | "vue" + | "webp" + | "xml" + | "yaml" + | "yml" + | "zig" + ) +} + +fn line_to_string(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{ + layout::Rect, + text::Line, + widgets::{Paragraph, Widget}, + }; + + fn detected_texts(text: &str) -> Vec { + detect_hyperlinks(text) + .into_iter() + .map(|range| range.text) + .collect() + } + + #[test] + fn detects_relative_and_basename_paths() { + let text = "Read README.md, AGENTS.md and src/ui/components/chat.rs:12."; + assert_eq!( + detected_texts(text), + vec!["README.md", "AGENTS.md", "src/ui/components/chat.rs:12"] + ); + } + + #[test] + fn avoids_common_non_path_slash_tokens() { + assert!( + detect_hyperlinks("streaming at 42t/s, ratio=1/2, non-selectable/unhighlighted") + .is_empty() + ); + } + + #[test] + fn strips_location_suffix_from_file_url_target() { + let text = "See src/main.rs:42"; + let links = detect_hyperlinks(text); + assert_eq!(links.len(), 1); + match &links[0].target { + HyperlinkTarget::File(path) => { + assert!(path.ends_with("src/main.rs")); + assert!(!path.to_string_lossy().ends_with(":42")); + } + HyperlinkTarget::Url(url) => panic!("expected file target, got {url}"), + } + } + + #[test] + fn strips_location_suffix_from_file_scheme_target() { + let file_url = Url::from_file_path(std::env::current_dir().unwrap().join("src/main.rs")) + .unwrap() + .to_string(); + let target = file_target_for_file_url_token(&format!("{file_url}:42")).unwrap(); + + assert!(target.ends_with("src/main.rs")); + assert!(!target.to_string_lossy().ends_with(":42")); + } + + #[test] + fn marks_rendered_cells_without_changing_symbols() { + let area = Rect::new(0, 0, 80, 1); + let line = Line::from("Added src/new.rs (+1 -0)"); + let mut buf = Buffer::empty(area); + + Paragraph::new(line.clone()).render(area, &mut buf); + mark_detected_hyperlinks(&mut buf, area, &[line]); + + let linked = (0..area.width) + .filter_map(|x| { + buf[(x, 0)] + .modifier + .contains(Modifier::UNDERLINED) + .then_some(buf[(x, 0)].symbol().to_string()) + }) + .collect::>(); + + assert_eq!(linked.len(), "src/new.rs".len()); + assert!(linked[0].contains('s')); + assert!(!linked.iter().any(|symbol| symbol.contains("\x1B]8;;"))); + assert!(buf[("Added ".len() as u16, 0)] + .modifier + .contains(Modifier::UNDERLINED)); + } + + #[test] + fn returns_target_at_line_column() { + let line = Line::from("Open src/ui/hyperlink.rs:12"); + let target = hyperlink_at_line_col(&line, "Open src".len()).unwrap(); + + match target { + HyperlinkTarget::File(path) => assert!(path.ends_with("src/ui/hyperlink.rs")), + HyperlinkTarget::Url(url) => panic!("expected file target, got {url}"), + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 234d4df..fa3fdb6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod components; pub mod diff; +pub mod hyperlink; pub mod layout; pub mod markdown; pub mod scrollbar; From 3193d9e75bc7d6a2ee776fd341cd72e4bfd2fb8b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 04:38:45 +0800 Subject: [PATCH 157/226] fix: avoid false positive hyperlinks for single-segment absolute paths. `/connect` and similar single-segment absolute paths are no longer treated as local paths unless the path actually exists on the filesystem. --- src/ui/hyperlink.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/ui/hyperlink.rs b/src/ui/hyperlink.rs index d30bae0..941d74d 100644 --- a/src/ui/hyperlink.rs +++ b/src/ui/hyperlink.rs @@ -217,11 +217,11 @@ fn is_local_path_like(path_text: &str) -> bool { return false; } - if path_text.starts_with('/') - || path_text.starts_with("~/") - || path_text.starts_with("./") - || path_text.starts_with("../") - { + if path_text.starts_with('/') { + return is_absolute_path_like(path_text); + } + + if path_text.starts_with("~/") || path_text.starts_with("./") || path_text.starts_with("../") { return path_text.len() > 1; } @@ -236,6 +236,15 @@ fn is_forbidden_path_char(ch: char) -> bool { matches!(ch, '=' | '|' | '*' | '?' | '<' | '>' | '@') } +fn is_absolute_path_like(path_text: &str) -> bool { + let segments = path_text + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + segments.len() >= 2 || std::path::Path::new(path_text).exists() +} + fn is_relative_slash_path(path_text: &str) -> bool { let segments = path_text .split('/') @@ -377,10 +386,10 @@ mod tests { #[test] fn avoids_common_non_path_slash_tokens() { - assert!( - detect_hyperlinks("streaming at 42t/s, ratio=1/2, non-selectable/unhighlighted") - .is_empty() - ); + assert!(detect_hyperlinks( + "streaming at 42t/s, ratio=1/2, non-selectable/unhighlighted, /connect" + ) + .is_empty()); } #[test] From 4cee971b42a5d4fef0f7e958bd727ac02068929e Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 05:19:12 +0800 Subject: [PATCH 158/226] feat: add local Ollama provider integration with optional API keys. - Add Ollama (Local) provider with `ollama ls` CLI integration and runtime caching - Make API key optional for OpenAI, Anthropic, and compatible. --- _plans/__TODOS.md | 6 +- aisdk/src/providers/anthropic.rs | 6 +- aisdk/src/providers/compatible.rs | 14 +- aisdk/src/providers/openai.rs | 14 +- src/app.rs | 224 ++++++++++---- src/command/handlers.rs | 484 +++++++++++++++++++----------- src/llm/client.rs | 19 +- src/model/discovery.rs | 40 ++- src/model/mod.rs | 1 + src/model/ollama.rs | 250 +++++++++++++++ src/persistence/auth.rs | 3 + src/persistence/providers.rs | 1 + src/ui/components/chat.rs | 121 +++++++- src/views/sessions_dialog.rs | 12 + 14 files changed, 924 insertions(+), 271 deletions(-) create mode 100644 src/model/ollama.rs diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 39cfeb2..d136088 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -184,4 +184,8 @@ I want - [x] To do this But I dont want to do this - [ ] When in another workspace and there are existing sessions in there and I opened /sessions, make that "workspace" the focus especially since the first page is at home.rs. -- [ ] I want to make a SPECIAL integration w/ ollama, specifically the local ollama cli. Maybe `ollama ls` can be cached at runtime? and refreshed with refreshmodels? And a special provider place where I can do /connect on it. And it won't require any API keys? I wanna put it somewhere clean though... So that it doesn't really bother with the models.dev stuff, but just fits in cleanly. A /connect provider called 'Ollama (Local)' would be cool. +- [x] I want to make a SPECIAL integration w/ ollama, specifically the local ollama cli. Maybe `ollama ls` can be cached at runtime? and refreshed with refreshmodels? And a special provider place where I can do /connect on it. And it won't require any API keys? I wanna put it somewhere clean though... So that it doesn't really bother with the models.dev stuff, but just fits in cleanly. A /connect provider called 'Ollama (Local)' would be cool. API key-less should be possible too! + +- [ ] When clicking, it opens message actions.. Special case for UX: don't change the scroll value when it comes from "clicking a message".. But the other /timeline and ctrl+x g paths should be just fine. + +- [ ] Zed alert circle thing when asking permission or question, please emit it. Currently it's only on completions by default I think. diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs index 9b20d68..a100bad 100644 --- a/aisdk/src/providers/anthropic.rs +++ b/aisdk/src/providers/anthropic.rs @@ -65,7 +65,7 @@ impl AnthropicBuilder { base_url: self .base_url .ok_or(Error::MissingField("base_url".into()))?, - api_key: self.api_key.ok_or(Error::MissingField("api_key".into()))?, + api_key: self.api_key.unwrap_or_default(), model_name: self .model_name .ok_or(Error::MissingField("model_name".into()))?, @@ -176,7 +176,9 @@ impl Provider for Anthropic { reqwest::header::CONTENT_TYPE, "application/json".parse().unwrap(), ); - request_headers.insert("x-api-key", self.api_key.parse().unwrap()); + if !self.api_key.is_empty() { + request_headers.insert("x-api-key", self.api_key.parse().unwrap()); + } request_headers.insert("anthropic-version", "2023-06-01".parse().unwrap()); let client = reqwest::Client::builder() diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index c66ca2f..cc08859 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -65,7 +65,7 @@ impl OpenAICompatibleBuilder { base_url: self .base_url .ok_or(Error::MissingField("base_url".into()))?, - api_key: self.api_key.ok_or(Error::MissingField("api_key".into()))?, + api_key: self.api_key.unwrap_or_default(), model_name: self .model_name .ok_or(Error::MissingField("model_name".into()))?, @@ -424,6 +424,18 @@ mod tests { .collect() } + #[test] + fn builder_allows_missing_api_key() { + let provider = OpenAICompatible::builder() + .base_url("http://localhost:11434/v1") + .model_name("llama3.2:latest") + .provider_name("ollama") + .build() + .expect("api key should be optional"); + + assert!(provider.api_key.is_empty()); + } + #[test] fn emits_tool_call_delta_without_finish_reason() { let data = r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"id":"tool-1","index":0,"type":"function","function":{"name":"question","arguments":"{\"questions\":[{\"header\":\"Hobbies\",\"options\":[]}]}"}}]}}]}"#; diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index a4669a1..caecd88 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -127,7 +127,7 @@ impl OpenAIBuilder { let base_url = self .base_url .ok_or(Error::MissingField("base_url".into()))?; - let api_key = self.api_key.ok_or(Error::MissingField("api_key".into()))?; + let api_key = self.api_key.unwrap_or_default(); let model_name = self .model_name .ok_or(Error::MissingField("model_name".into()))?; @@ -1291,6 +1291,18 @@ mod tests { use crate::message::Message; use std::time::{Duration, Instant}; + #[test] + fn builder_allows_missing_api_key() { + let provider = OpenAI::builder() + .base_url("http://localhost:11434/v1") + .model_name("local-model") + .provider_name("local-openai") + .build() + .expect("api key should be optional"); + + assert!(provider.api_key.is_empty()); + } + #[test] fn done_marker_emits_terminal_chunk() { let chunk = response_sse_data_to_chunk("[DONE]").expect("expected terminal chunk"); diff --git a/src/app.rs b/src/app.rs index f916766..850ca9b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2351,6 +2351,7 @@ impl App { if matches!(mouse.kind, MouseEventKind::Moved) && self.base_focus != BaseFocus::Chat { self.chat_state.chat.clear_hovered_image(); + self.chat_state.chat.clear_hovered_hyperlink(); } // If text is selected and user clicks on an overlay, clear selection instead @@ -2732,11 +2733,21 @@ impl App { { let hovered_image = self.chat_state.chat.image_at_position(mouse, chat_area); + let hovered_hyperlink = if hovered_image.is_none() { + self.chat_state + .chat + .hyperlink_hover_at_position(mouse, chat_area) + } else { + None + }; let hovered_message = self .chat_state .chat .message_index_at_position(mouse, chat_area); self.chat_state.chat.set_hovered_image(hovered_image); + self.chat_state + .chat + .set_hovered_hyperlink(hovered_hyperlink); if hovered_message.is_some() { return; } @@ -2824,6 +2835,7 @@ impl App { // Handle mouse events for the main input when no overlay is focused self.chat_state.chat.clear_hovered_image(); + self.chat_state.chat.clear_hovered_hyperlink(); self.handle_input_mouse_event(mouse); } } @@ -3560,19 +3572,7 @@ impl App { self.connect_dialog_state.dialog.show(); self.overlay_focus = OverlayFocus::ConnectDialog; } else if title == "Sessions" { - let dialog_items: Vec = - items - .into_iter() - .map(|item| crate::ui::components::dialog::DialogItem { - id: item.id, - name: item.name, - group: item.group, - description: item.description, - tip: item.tip, - provider_id: item.provider_id.clone(), - }) - .collect(); - self.show_sessions_dialog(title, dialog_items); + self.open_sessions_dialog(); } else { let dialog_items: Vec = items @@ -3761,18 +3761,7 @@ impl App { self.connect_dialog_state.dialog.show(); self.overlay_focus = OverlayFocus::ConnectDialog; } else if title == "Sessions" { - let dialog_items: Vec = items - .into_iter() - .map(|item| crate::ui::components::dialog::DialogItem { - id: item.id, - name: item.name, - group: item.group, - description: item.description, - tip: item.tip, - provider_id: item.provider_id.clone(), - }) - .collect(); - self.show_sessions_dialog(title, dialog_items); + self.open_sessions_dialog(); } else { let dialog_items: Vec = items .into_iter() @@ -4126,21 +4115,59 @@ impl App { Err(_) => return, }; - if connected_providers.is_empty() { + let include_ollama = connected_providers.contains_key(crate::model::ollama::PROVIDER_ID) + || self.provider_name == crate::model::ollama::PROVIDER_ID + || self + .models_dialog_state + .dialog + .items + .iter() + .any(|item| item.provider_id == crate::model::ollama::PROVIDER_ID); + + if connected_providers.is_empty() && !include_ollama { return; } - let discovery = match Discovery::new() { - Ok(d) => d, - Err(_) => return, - }; + let has_non_ollama = connected_providers + .keys() + .any(|provider_id| !crate::model::ollama::is_ollama_provider(provider_id)); - let models = match tokio::task::block_in_place(|| { - let rt = tokio::runtime::Handle::current(); - rt.block_on(discovery.fetch_models()) - }) { - Ok(models) => models, - Err(_) => return, + let models = if has_non_ollama { + match Discovery::new() { + Ok(discovery) => match tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(discovery.fetch_models()) + }) { + Ok(models) => models, + Err(err) if include_ollama => { + let ollama_models = tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(crate::model::ollama::models_for_dialog_cached_or_empty()) + }); + if ollama_models.is_empty() { + push_toast(Toast::new( + format!("Failed to refresh models: {}", err), + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + } + ollama_models + } + Err(_) => return, + }, + Err(_) if include_ollama => tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(crate::model::ollama::models_for_dialog_cached_or_empty()) + }), + Err(_) => return, + } + } else if include_ollama { + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(crate::model::ollama::models_for_dialog_cached_or_empty()) + }) + } else { + return; }; let prefs = self @@ -4152,7 +4179,9 @@ impl App { std::collections::HashMap::new(); for model in &models { - if connected_providers.contains_key(&model.provider_id) { + if connected_providers.contains_key(&model.provider_id) + || crate::model::ollama::is_ollama_provider(&model.provider_id) + { model_lookup.insert((model.provider_id.clone(), model.id.clone()), model.clone()); } } @@ -4256,7 +4285,9 @@ impl App { continue; } - if connected_providers.contains_key(&model.provider_id) { + if connected_providers.contains_key(&model.provider_id) + || crate::model::ollama::is_ollama_provider(&model.provider_id) + { provider_models .entry(model.provider_name.clone()) .or_default() @@ -4320,34 +4351,26 @@ impl App { self.overlay_focus = OverlayFocus::ModelsDialog; } - fn show_sessions_dialog( - &mut self, - title: impl Into, - items: Vec, - ) { - self.sessions_dialog_state = init_sessions_dialog(title, items); - - let current_session_id = self.session_manager.get_current_session_id().cloned(); - if let Some(session_id) = current_session_id { - let _ = self + fn focus_current_session_or_workspace_in_sessions_dialog(&mut self) { + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { + if self .sessions_dialog_state .dialog - .select_item_by_id(&session_id); + .select_item_by_id(&session_id) + { + return; + } } - self.sessions_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::SessionsDialog; + let current_workspace_id = self.session_manager.current_workspace_id(); + let _ = self + .sessions_dialog_state + .focus_workspace(current_workspace_id); } fn open_sessions_dialog(&mut self) { self.refresh_sessions_dialog(); - - if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { - let _ = self - .sessions_dialog_state - .dialog - .select_item_by_id(&session_id); - } + self.focus_current_session_or_workspace_in_sessions_dialog(); self.sessions_dialog_state.dialog.show(); self.overlay_focus = OverlayFocus::SessionsDialog; @@ -4558,6 +4581,11 @@ impl App { ) { match self.connect_dialog_mode { ConnectDialogMode::ProviderSelection => { + if crate::model::ollama::is_ollama_provider(&selected_item.id) { + self.connect_local_ollama(); + return; + } + if selected_item.id == "openai" { self.show_openai_connect_methods(); return; @@ -4585,6 +4613,52 @@ impl App { } } + fn connect_local_ollama(&mut self) { + let models_result = tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(crate::model::ollama::refresh_model_cache()) + }); + + let models = match models_result { + Ok(models) => models, + Err(err) => { + push_toast(Toast::new( + format!("Failed to connect Ollama: {}", err), + ToastLevel::Error, + Some(std::time::Duration::from_secs(5)), + )); + self.overlay_focus = OverlayFocus::None; + return; + } + }; + + match crate::persistence::AuthDAO::new().and_then(|dao| { + dao.set_provider( + crate::model::ollama::PROVIDER_ID.to_string(), + crate::persistence::AuthConfig::Local, + ) + }) { + Ok(()) => { + push_toast(Toast::new( + format!("Connected Ollama ({} local models)", models.len()), + ToastLevel::Success, + None, + )); + self.connect_dialog_state = init_connect_dialog(); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + } + Err(err) => { + push_toast(Toast::new( + format!("Failed to save Ollama connection: {}", err), + ToastLevel::Error, + None, + )); + } + } + + self.overlay_focus = OverlayFocus::None; + } + fn begin_openai_oauth_browser(&mut self) { if self.openai_oauth_in_progress { push_toast(Toast::new( @@ -7092,6 +7166,40 @@ mod tests { .any(|item| item.id == other_id && item.group == "other-workspace")); } + #[test] + fn sessions_dialog_focuses_current_workspace_from_home_without_current_session() { + let mut app = test_app(); + let current_id = app.create_new_session(Some("Current".to_string())); + let other_id = app.create_new_session(Some("Other".to_string())); + let other_session = app.session_manager.get_session(&other_id).unwrap(); + other_session.workspace_id = -1; + other_session.workspace_path = "/tmp/other-workspace".to_string(); + other_session.workspace_name = "other-workspace".to_string(); + + app.start_blank_session(None); + app.open_sessions_dialog(); + + assert_eq!(app.base_focus, BaseFocus::Home); + assert!(app.session_manager.get_current_session_id().is_none()); + assert_eq!(app.sessions_dialog_state.filter, SessionsDialogFilter::All); + assert_eq!( + app.sessions_dialog_state.dialog.get_focused_group_header(), + Some(app.session_manager.current_workspace_name()) + ); + assert!(app + .sessions_dialog_state + .dialog + .items + .iter() + .any(|item| item.id == current_id)); + assert!(app + .sessions_dialog_state + .dialog + .items + .iter() + .any(|item| item.id == other_id)); + } + #[test] fn status_workspace_path_follows_active_session() { let mut app = test_app(); diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 3115b24..af02312 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -100,6 +100,10 @@ pub fn handle_connect<'a>( ("anthropic", "Anthropic"), ("openai", "OpenAI"), ("google", "Google"), + ( + crate::model::ollama::PROVIDER_ID, + crate::model::ollama::PROVIDER_NAME, + ), ] { out.insert( id.to_string(), @@ -117,13 +121,14 @@ pub fn handle_connect<'a>( out } - let providers_map = match crate::model::discovery::Discovery::new() { + let mut providers_map = match crate::model::discovery::Discovery::new() { Ok(discovery) => match discovery.fetch_providers().await { Ok(p) => p, Err(_) => fallback_providers(), }, Err(_) => fallback_providers(), }; + crate::model::ollama::inject_provider(&mut providers_map); const POPULAR_PROVIDERS: &[&str] = &[ "opencode", @@ -136,7 +141,9 @@ pub fn handle_connect<'a>( let mut items: Vec = providers_map .into_iter() .map(|(id, provider)| { - let group = if POPULAR_PROVIDERS.contains(&id.as_str()) { + let group = if id == crate::model::ollama::PROVIDER_ID { + "Local" + } else if POPULAR_PROVIDERS.contains(&id.as_str()) { "Popular" } else { "Other" @@ -146,7 +153,11 @@ pub fn handle_connect<'a>( id: id.clone(), name: provider.name.clone(), group: group.to_string(), - description: id.clone(), + description: if id == crate::model::ollama::PROVIDER_ID { + "Local Ollama CLI".to_string() + } else { + id.clone() + }, tip: if is_connected { Some("🟢 Connected".to_string()) } else { @@ -203,197 +214,247 @@ pub fn handle_models<'a>( Err(e) => return CommandResult::Error(format!("Failed to load providers: {}", e)), }; - if connected_providers.is_empty() { - return CommandResult::Error( - "No models available. Please connect a provider first using /connect".to_string(), - ); - } - - let discovery = Discovery::new(); + let provider_filter_matches_ollama = provider_filter.as_deref().map_or(false, |filter| { + let filter = filter.to_ascii_lowercase(); + crate::model::ollama::PROVIDER_ID.contains(&filter) + || crate::model::ollama::PROVIDER_NAME + .to_ascii_lowercase() + .contains(&filter) + }); - match discovery { - Ok(d) => match d.fetch_models().await { - Ok(models) => { - let prefs = prefs_data; - - let mut model_lookup: std::collections::HashMap<(String, String), ModelType> = - std::collections::HashMap::new(); - - for model in &models { - if connected_providers.contains_key(&model.provider_id) - && if let Some(filter) = &provider_filter { - model.provider_id.contains(filter) - || model.provider_name.to_lowercase().contains(filter) - } else { - true - } - { - model_lookup.insert( - (model.provider_id.clone(), model.id.clone()), - model.clone(), - ); - } - } + let has_ollama = connected_providers.contains_key(crate::model::ollama::PROVIDER_ID) + || (connected_providers.is_empty() && provider_filter.is_none()) + || provider_filter_matches_ollama; + let has_non_ollama = connected_providers + .keys() + .any(|provider_id| !crate::model::ollama::is_ollama_provider(provider_id)); - let favorites_set = prefs - .as_ref() - .map(|p| { - p.favorite - .iter() - .map(|m| (m.provider_id.clone(), m.model_id.clone())) - .collect::>() - }) - .unwrap_or_default(); - - let recent_set = prefs - .as_ref() - .map(|p| { - p.recent - .iter() - .map(|m| (m.provider_id.clone(), m.model_id.clone())) - .collect::>() + let discovery = Discovery::new(); + let mut models: Vec = if has_non_ollama { + match discovery { + Ok(d) => match d.fetch_models().await { + Ok(models) => models + .into_iter() + .filter(|model| { + !crate::model::ollama::is_ollama_provider(&model.provider_id) }) - .unwrap_or_default(); - - let mut items: Vec = Vec::new(); - - let add_model_item = - |items: &mut Vec, model: &ModelType, group: &str| { - let is_active = active_model_id.as_ref() == Some(&model.id); - let is_favorite = favorites_set - .contains(&(model.provider_id.clone(), model.id.clone())); - - let tip = if is_active { - Some("Active".to_string()) - } else if is_favorite { - Some("❤︎".to_string()) - } else { - None - }; - - let description = model.provider_name.clone(); - - items.push(DialogItem { - id: model.id.clone(), - name: model.name.clone(), - group: group.to_string(), - description, - tip, - provider_id: model.provider_id.clone(), - }); - }; - - let favorites_list = prefs - .as_ref() - .map(|p| p.favorite.clone()) - .unwrap_or_default(); - - let mut favorite_models = Vec::new(); - for fav in &favorites_list { - if let Some(model) = - model_lookup.get(&(fav.provider_id.clone(), fav.model_id.clone())) - { - favorite_models.push(model.clone()); + .collect(), + Err(e) => { + if has_ollama { + push_toast(Toast::new( + format!("Skipped models.dev models: {}", e), + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + Vec::new() + } else { + return CommandResult::Error(format!("Failed to fetch models: {}", e)); } } - - for model in &favorite_models { - add_model_item(&mut items, model, "Favorite"); + }, + Err(e) => { + if has_ollama { + push_toast(Toast::new( + format!("Skipped models.dev models: {}", e), + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + Vec::new() + } else { + return CommandResult::Error(format!( + "Failed to initialize model discovery: {}", + e + )); } + } + } + } else { + Vec::new() + }; - let recent_list = prefs.as_ref().map(|p| p.recent.clone()).unwrap_or_default(); + let mut ollama_error = None; + if has_ollama { + match crate::model::ollama::models_for_dialog_cached().await { + Ok(ollama_models) => models.extend(ollama_models), + Err(err) => ollama_error = Some(err.to_string()), + } + } - let mut recent_models = Vec::new(); - for recent in &recent_list { - if favorites_set - .contains(&(recent.provider_id.clone(), recent.model_id.clone())) - { - continue; - } - if let Some(model) = - model_lookup.get(&(recent.provider_id.clone(), recent.model_id.clone())) - { - recent_models.push(model.clone()); - } - } + let prefs = prefs_data; - for model in &recent_models { - add_model_item(&mut items, model, "Recent"); - } + let mut model_lookup: std::collections::HashMap<(String, String), ModelType> = + std::collections::HashMap::new(); - let mut provider_models: std::collections::HashMap> = - std::collections::HashMap::new(); + for model in &models { + if (connected_providers.contains_key(&model.provider_id) + || crate::model::ollama::is_ollama_provider(&model.provider_id)) + && if let Some(filter) = &provider_filter { + model.provider_id.contains(filter) + || model.provider_name.to_lowercase().contains(filter) + } else { + true + } + { + model_lookup.insert((model.provider_id.clone(), model.id.clone()), model.clone()); + } + } - for model in models { - let model_key = (model.provider_id.clone(), model.id.clone()); - if favorites_set.contains(&model_key) || recent_set.contains(&model_key) { - continue; - } + let favorites_set = prefs + .as_ref() + .map(|p| { + p.favorite + .iter() + .map(|m| (m.provider_id.clone(), m.model_id.clone())) + .collect::>() + }) + .unwrap_or_default(); - if connected_providers.contains_key(&model.provider_id) - && if let Some(filter) = &provider_filter { - model.provider_id.contains(filter) - || model.provider_name.to_lowercase().contains(filter) - } else { - true - } - { - provider_models - .entry(model.provider_name.clone()) - .or_default() - .push(model); - } - } + let recent_set = prefs + .as_ref() + .map(|p| { + p.recent + .iter() + .map(|m| (m.provider_id.clone(), m.model_id.clone())) + .collect::>() + }) + .unwrap_or_default(); - for (provider_name, models_list) in provider_models { - for model in &models_list { - add_model_item(&mut items, model, &provider_name); - } - } + let mut items: Vec = Vec::new(); - items.sort_by(|a, b| { - let is_a_special = a.group == "Favorite" || a.group == "Recent"; - let is_b_special = b.group == "Favorite" || b.group == "Recent"; + let add_model_item = |items: &mut Vec, model: &ModelType, group: &str| { + let is_active = active_model_id.as_ref() == Some(&model.id); + let is_favorite = + favorites_set.contains(&(model.provider_id.clone(), model.id.clone())); - if is_a_special && !is_b_special { - return std::cmp::Ordering::Less; - } - if !is_a_special && is_b_special { - return std::cmp::Ordering::Greater; - } + let tip = if is_active { + Some("Active".to_string()) + } else if is_favorite { + Some("❤︎".to_string()) + } else { + None + }; - if is_a_special && is_b_special { - if a.group == "Favorite" && b.group != "Favorite" { - return std::cmp::Ordering::Less; - } - if a.group != "Favorite" && b.group == "Favorite" { - return std::cmp::Ordering::Greater; - } - return std::cmp::Ordering::Equal; - } + let description = model.provider_name.clone(); - a.group.cmp(&b.group).then(a.name.cmp(&b.name)) - }); + items.push(DialogItem { + id: model.id.clone(), + name: model.name.clone(), + group: group.to_string(), + description, + tip, + provider_id: model.provider_id.clone(), + }); + }; - if items.is_empty() { - if let Some(filter) = provider_filter { - CommandResult::Error(format!( - "No models found for provider: {}", - filter - )) - } else { - CommandResult::Error("No models available".to_string()) - } - } else { - CommandResult::ShowDialog { - title: "Available Models".to_string(), - items, - } - } + let favorites_list = prefs + .as_ref() + .map(|p| p.favorite.clone()) + .unwrap_or_default(); + + let mut favorite_models = Vec::new(); + for fav in &favorites_list { + if let Some(model) = model_lookup.get(&(fav.provider_id.clone(), fav.model_id.clone())) + { + favorite_models.push(model.clone()); + } + } + + for model in &favorite_models { + add_model_item(&mut items, model, "Favorite"); + } + + let recent_list = prefs.as_ref().map(|p| p.recent.clone()).unwrap_or_default(); + + let mut recent_models = Vec::new(); + for recent in &recent_list { + if favorites_set.contains(&(recent.provider_id.clone(), recent.model_id.clone())) { + continue; + } + if let Some(model) = + model_lookup.get(&(recent.provider_id.clone(), recent.model_id.clone())) + { + recent_models.push(model.clone()); + } + } + + for model in &recent_models { + add_model_item(&mut items, model, "Recent"); + } + + let mut provider_models: std::collections::HashMap> = + std::collections::HashMap::new(); + + for model in models { + let model_key = (model.provider_id.clone(), model.id.clone()); + if favorites_set.contains(&model_key) || recent_set.contains(&model_key) { + continue; + } + + if (connected_providers.contains_key(&model.provider_id) + || crate::model::ollama::is_ollama_provider(&model.provider_id)) + && if let Some(filter) = &provider_filter { + model.provider_id.contains(filter) + || model.provider_name.to_lowercase().contains(filter) + } else { + true } - Err(e) => CommandResult::Error(format!("Failed to fetch models: {}", e)), - }, - Err(e) => CommandResult::Error(format!("Failed to initialize model discovery: {}", e)), + { + provider_models + .entry(model.provider_name.clone()) + .or_default() + .push(model); + } + } + + for (provider_name, models_list) in provider_models { + for model in &models_list { + add_model_item(&mut items, model, &provider_name); + } + } + + items.sort_by(|a, b| { + let is_a_special = a.group == "Favorite" || a.group == "Recent"; + let is_b_special = b.group == "Favorite" || b.group == "Recent"; + + if is_a_special && !is_b_special { + return std::cmp::Ordering::Less; + } + if !is_a_special && is_b_special { + return std::cmp::Ordering::Greater; + } + + if is_a_special && is_b_special { + if a.group == "Favorite" && b.group != "Favorite" { + return std::cmp::Ordering::Less; + } + if a.group != "Favorite" && b.group == "Favorite" { + return std::cmp::Ordering::Greater; + } + return std::cmp::Ordering::Equal; + } + + a.group.cmp(&b.group).then(a.name.cmp(&b.name)) + }); + + if items.is_empty() { + let filter_matches_ollama = provider_filter_matches_ollama || provider_filter.is_none(); + + if has_ollama && filter_matches_ollama { + if let Some(err) = ollama_error { + return CommandResult::Error(format!("Failed to fetch Ollama models: {}", err)); + } + } + + if let Some(filter) = provider_filter { + CommandResult::Error(format!("No models found for provider: {}", filter)) + } else { + CommandResult::Error("No models available".to_string()) + } + } else { + CommandResult::ShowDialog { + title: "Available Models".to_string(), + items, + } } }) } @@ -545,20 +606,44 @@ pub fn handle_refreshmodels<'a>( } }; - let providers = match discovery.refresh_cache().await { + let (providers_result, ollama_result) = tokio::join!( + discovery.refresh_cache(), + crate::model::ollama::refresh_model_cache() + ); + + let mut providers = match providers_result { Ok(p) => p, Err(e) => { push_toast(Toast::new( - format!("Failed to refresh models cache: {}", e), - ToastLevel::Error, + format!("Skipped models.dev refresh: {}", e), + ToastLevel::Warning, Some(std::time::Duration::from_secs(3)), )); - return CommandResult::Success(String::new()); + std::collections::HashMap::new() } }; + let ollama_model_count = match ollama_result { + Ok(models) => models.len(), + Err(err) => { + push_toast(Toast::new( + format!("Skipped Ollama refresh: {}", err), + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + 0 + } + }; + + crate::model::ollama::inject_provider(&mut providers); + let provider_count = providers.len(); - let model_count: usize = providers.values().map(|p| p.models.len()).sum(); + let model_count: usize = providers + .values() + .filter(|p| !crate::model::ollama::is_ollama_provider(&p.id)) + .map(|p| p.models.len()) + .sum::() + + ollama_model_count; push_toast(Toast::new( format!( @@ -858,7 +943,11 @@ mod tests { match result { CommandResult::ShowDialog { title, items } => { assert_eq!(title, "Connect a provider"); - assert!(!items.is_empty()); + assert!(items.iter().any(|item| { + item.id == crate::model::ollama::PROVIDER_ID + && item.name == crate::model::ollama::PROVIDER_NAME + && item.group == "Local" + })); if items.len() >= 4 { assert!(items.iter().any(|item| item.id == "anthropic" || item.id == "openai" @@ -917,6 +1006,39 @@ mod tests { let _ = crate::model::discovery::Discovery::cleanup_test(); } + #[tokio::test] + async fn test_handle_models_shows_ollama_without_connection() { + let _ = crate::persistence::AuthDAO::cleanup_test(); + crate::model::ollama::set_cached_models_for_test(vec![crate::model::ollama::OllamaModel { + id: "llama3.2:latest".to_string(), + name: "llama3.2:latest".to_string(), + }]); + + let parsed = ParsedCommand { + name: "models".to_string(), + args: vec![], + raw: "/models".to_string(), + prefs_dao: None, + active_model_id: None, + }; + let mut session_manager = SessionManager::new(); + let result = handle_models(&parsed, &mut session_manager).await; + + match result { + CommandResult::ShowDialog { title, items } => { + assert_eq!(title, "Available Models"); + assert!(items.iter().any(|item| { + item.id == "llama3.2:latest" + && item.provider_id == crate::model::ollama::PROVIDER_ID + })); + } + other => panic!("Expected Ollama models dialog, got {:?}", other), + } + + crate::model::ollama::clear_cache_for_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); + } + #[tokio::test] async fn test_handle_models_with_filter() { let _ = crate::model::discovery::Discovery::cleanup_test(); diff --git a/src/llm/client.rs b/src/llm/client.rs index b8aa2d2..4aa46d2 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -593,12 +593,17 @@ async fn prepare_request_config( let auth_dao = crate::persistence::AuthDAO::new()?; let auth_config = auth_dao.get_provider(provider_name)?; - let discovery = crate::model::discovery::Discovery::new()?; - let providers = discovery.fetch_providers().await?; + let provider = if crate::model::ollama::is_ollama_provider(provider_name) { + crate::model::ollama::provider() + } else { + let discovery = crate::model::discovery::Discovery::new()?; + let providers = discovery.fetch_providers().await?; - let provider = providers - .get(provider_name) - .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", provider_name))?; + providers + .get(provider_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", provider_name))? + }; let provider_kind = ProviderKind::from_provider(provider_name, &provider.npm); let mut request_config = ProviderRequestConfig::new( @@ -619,7 +624,8 @@ async fn prepare_request_config( ) .await; - if request_config.api_key.is_none() { + if request_config.api_key.is_none() && !crate::model::ollama::is_ollama_provider(provider_name) + { send_warning( sender, format!( @@ -643,6 +649,7 @@ async fn prepare_request_config( fn configured_api_key(auth_config: Option<&crate::persistence::AuthConfig>) -> Option { auth_config.and_then(|config| match config { crate::persistence::AuthConfig::Api { key } => Some(key.clone()), + crate::persistence::AuthConfig::Local => None, crate::persistence::AuthConfig::OAuth { access, .. } => Some(access.clone()), }) } diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 3da072f..3a63b54 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -205,16 +205,14 @@ impl Discovery { } pub async fn fetch_providers(&self) -> Result> { - if let Some(cached) = self.load_from_cache()? { - return Ok(cached); - } - - // In test mode, avoid hard network dependency so unit tests are reliable. - if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { + let mut providers = if let Some(cached) = self.load_from_cache()? { + cached + } else if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { + // In test mode, avoid hard network dependency so unit tests are reliable. match self.fetch_from_api().await { Ok(providers) => { let _ = self.save_to_cache(&providers); - return Ok(providers); + providers } Err(_) => { let mut providers: HashMap = HashMap::new(); @@ -237,30 +235,40 @@ impl Discovery { }, ); } - return Ok(providers); + providers } } - } - - let providers = self.fetch_from_api().await?; + } else { + let providers = self.fetch_from_api().await?; + self.save_to_cache(&providers)?; + providers + }; - self.save_to_cache(&providers)?; + crate::model::ollama::inject_provider(&mut providers); Ok(providers) } pub async fn refresh_cache(&self) -> Result> { - let providers = self.fetch_from_api().await?; + let mut providers = self.fetch_from_api().await?; self.save_to_cache(&providers)?; + crate::model::ollama::inject_provider(&mut providers); Ok(providers) } pub async fn fetch_models(&self) -> Result> { - let providers = self.fetch_providers().await?; - - let mut models = Vec::new(); + let mut models = crate::model::ollama::models_from_runtime_cache(); + let providers = match self.fetch_providers().await { + Ok(providers) => providers, + Err(_err) if !models.is_empty() => return Ok(models), + Err(err) => return Err(err), + }; for (provider_id, provider) in providers { + if crate::model::ollama::is_ollama_provider(&provider_id) { + continue; + } + for (model_id, model) in provider.models { let mut capabilities = Vec::new(); diff --git a/src/model/mod.rs b/src/model/mod.rs index a9df33b..c90950e 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,3 +1,4 @@ pub mod discovery; +pub mod ollama; pub mod reasoning; pub mod types; diff --git a/src/model/ollama.rs b/src/model/ollama.rs new file mode 100644 index 0000000..17b9051 --- /dev/null +++ b/src/model/ollama.rs @@ -0,0 +1,250 @@ +use anyhow::{Context, Result}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +pub const PROVIDER_ID: &str = "ollama"; +pub const PROVIDER_NAME: &str = "Ollama (Local)"; +pub const BASE_URL: &str = "http://localhost:11434/v1"; +pub const NPM_PACKAGE: &str = "@ai-sdk/openai-compatible"; + +const OLLAMA_LS_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OllamaModel { + pub id: String, + pub name: String, +} + +static MODEL_CACHE: OnceLock>>> = OnceLock::new(); + +fn cache() -> &'static Mutex>> { + MODEL_CACHE.get_or_init(|| Mutex::new(None)) +} + +pub fn is_ollama_provider(provider_id: &str) -> bool { + provider_id == PROVIDER_ID +} + +pub fn provider() -> crate::model::discovery::Provider { + crate::model::discovery::Provider { + id: PROVIDER_ID.to_string(), + name: PROVIDER_NAME.to_string(), + api: BASE_URL.to_string(), + doc: "https://ollama.com".to_string(), + env: Vec::new(), + npm: NPM_PACKAGE.to_string(), + models: cached_discovery_models().unwrap_or_default(), + } +} + +pub fn inject_provider( + providers: &mut std::collections::HashMap, +) { + providers.insert(PROVIDER_ID.to_string(), provider()); +} + +pub async fn list_models_cached() -> Result> { + if let Some(models) = cache().lock().ok().and_then(|guard| guard.clone()) { + return Ok(models); + } + + refresh_model_cache().await +} + +pub async fn refresh_model_cache() -> Result> { + let models = list_models_from_cli().await?; + if let Ok(mut guard) = cache().lock() { + *guard = Some(models.clone()); + } + Ok(models) +} + +pub fn models_from_runtime_cache() -> Vec { + cache() + .lock() + .ok() + .and_then(|guard| guard.clone()) + .unwrap_or_default() + .into_iter() + .map(model_for_dialog) + .collect() +} + +pub async fn models_for_dialog_cached() -> Result> { + Ok(list_models_cached() + .await? + .into_iter() + .map(model_for_dialog) + .collect()) +} + +pub async fn models_for_dialog_cached_or_empty() -> Vec { + models_for_dialog_cached().await.unwrap_or_default() +} + +pub fn model_for_dialog(model: OllamaModel) -> crate::model::types::Model { + crate::model::types::Model { + family: model_family(&model.id), + provider_id: PROVIDER_ID.to_string(), + provider_name: PROVIDER_NAME.to_string(), + capabilities: vec!["local".to_string()], + reasoning: false, + id: model.id, + name: model.name, + } +} + +fn cached_discovery_models( +) -> Option> { + let models = cache().lock().ok().and_then(|guard| guard.clone())?; + Some( + models + .into_iter() + .map(|model| { + let id = model.id; + let family = model_family(&id); + ( + id.clone(), + crate::model::discovery::Model { + id: id.clone(), + name: model.name, + family, + attachment: false, + reasoning: false, + tool_call: true, + structured_output: false, + temperature: true, + knowledge: String::new(), + release_date: String::new(), + last_updated: String::new(), + modalities: Some(crate::model::discovery::Modalities { + input: vec!["text".to_string()], + output: vec!["text".to_string()], + }), + open_weights: true, + cost: None, + limit: None, + }, + ) + }) + .collect(), + ) +} + +async fn list_models_from_cli() -> Result> { + let output = tokio::time::timeout( + OLLAMA_LS_TIMEOUT, + tokio::process::Command::new("ollama").arg("ls").output(), + ) + .await + .context("timed out running `ollama ls`")? + .context("failed to run `ollama ls`")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + format!("`ollama ls` exited with status {}", output.status) + } else { + format!( + "`ollama ls` exited with status {}: {}", + output.status, stderr + ) + }; + return Err(anyhow::anyhow!(message)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_ollama_ls_output(&stdout)) +} + +pub fn parse_ollama_ls_output(output: &str) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut models = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let Some(name) = line.split_whitespace().next() else { + continue; + }; + + if name.eq_ignore_ascii_case("name") || !seen.insert(name.to_string()) { + continue; + } + + models.push(OllamaModel { + id: name.to_string(), + name: name.to_string(), + }); + } + + models.sort_by(|a, b| a.name.cmp(&b.name)); + models +} + +fn model_family(model_id: &str) -> String { + model_id + .split([':', '/']) + .next() + .filter(|family| !family.trim().is_empty()) + .unwrap_or(model_id) + .to_string() +} + +#[cfg(test)] +pub fn set_cached_models_for_test(models: Vec) { + if let Ok(mut guard) = cache().lock() { + *guard = Some(models); + } +} + +#[cfg(test)] +pub fn clear_cache_for_test() { + if let Ok(mut guard) = cache().lock() { + *guard = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ollama_ls_output() { + let output = "NAME ID SIZE MODIFIED\nllama3.2:latest a80c4f17acd5 2.0 GB 3 weeks ago\nqwen2.5-coder:7b 2b0496514337 4.7 GB 2 days ago\n"; + + let models = parse_ollama_ls_output(output); + + assert_eq!( + models, + vec![ + OllamaModel { + id: "llama3.2:latest".to_string(), + name: "llama3.2:latest".to_string(), + }, + OllamaModel { + id: "qwen2.5-coder:7b".to_string(), + name: "qwen2.5-coder:7b".to_string(), + }, + ] + ); + } + + #[test] + fn provider_uses_cached_models_without_running_cli() { + set_cached_models_for_test(vec![OllamaModel { + id: "llama3.2:latest".to_string(), + name: "llama3.2:latest".to_string(), + }]); + + let provider = provider(); + + assert_eq!(provider.id, PROVIDER_ID); + assert_eq!(provider.name, PROVIDER_NAME); + assert!(provider.models.contains_key("llama3.2:latest")); + clear_cache_for_test(); + } +} diff --git a/src/persistence/auth.rs b/src/persistence/auth.rs index 8ee083d..b873793 100644 --- a/src/persistence/auth.rs +++ b/src/persistence/auth.rs @@ -11,6 +11,8 @@ use super::{ensure_data_dir, get_data_dir}; pub enum AuthConfig { #[serde(rename = "api")] Api { key: String }, + #[serde(rename = "local")] + Local, #[serde(rename = "oauth")] OAuth { refresh: String, @@ -140,6 +142,7 @@ impl AuthDAO { let providers = self.load()?; Ok(providers.get(name).and_then(|c| match c { AuthConfig::Api { key } => Some(key.clone()), + AuthConfig::Local => None, AuthConfig::OAuth { access, .. } => Some(access.clone()), })) } diff --git a/src/persistence/providers.rs b/src/persistence/providers.rs index 5037fb9..b27d474 100644 --- a/src/persistence/providers.rs +++ b/src/persistence/providers.rs @@ -109,6 +109,7 @@ impl ProviderDAO { if let Some(auth_config) = configured_auth.get(&provider.id) { let auth_type = match auth_config { super::auth::AuthConfig::Api { .. } => "api", + super::auth::AuthConfig::Local => "local", super::auth::AuthConfig::OAuth { .. } => "oauth", }; diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 71dfc50..6fad1e0 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -70,6 +70,7 @@ pub struct Chat { cached_fingerprint: u64, tool_marker_animation_phase: bool, hovered_image: Option, + hovered_hyperlink: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -80,6 +81,12 @@ pub struct ChatImageTarget { pub path: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatHyperlinkHover { + content_line: usize, + range: crate::ui::hyperlink::HyperlinkRange, +} + // Minimum elapsed time before showing tokens/s (250ms) const MIN_TOKENS_PER_SECOND_ELAPSED_MS: u128 = 250; const TOOL_RESULT_MAX_SCREEN_LINES: usize = 8; @@ -692,6 +699,7 @@ impl Chat { cached_fingerprint: 0, tool_marker_animation_phase: false, hovered_image: None, + hovered_hyperlink: None, } } @@ -733,6 +741,7 @@ impl Chat { cached_fingerprint: 0, tool_marker_animation_phase: false, hovered_image: None, + hovered_hyperlink: None, } } @@ -885,6 +894,7 @@ impl Chat { self.selection.reset(); self.pending_click_anchor = None; self.hovered_image = None; + self.hovered_hyperlink = None; self.cached_lines.clear(); self.cached_positions.clear(); self.cached_revision = 0; @@ -1258,6 +1268,18 @@ impl Chat { self.set_hovered_image(None) } + pub fn set_hovered_hyperlink(&mut self, target: Option) -> bool { + if self.hovered_hyperlink == target { + return false; + } + self.hovered_hyperlink = target; + true + } + + pub fn clear_hovered_hyperlink(&mut self) -> bool { + self.set_hovered_hyperlink(None) + } + pub fn image_at_position(&self, event: MouseEvent, area: Rect) -> Option { use ratatui::layout::Position; @@ -1315,6 +1337,37 @@ impl Chat { .or_else(|| Some(range.target)) } + pub fn hyperlink_hover_at_position( + &self, + event: MouseEvent, + area: Rect, + ) -> Option { + use ratatui::layout::Position; + + let point = Position::new(event.column, event.row); + let content_area = Self::content_area_for(area); + + if !content_area.contains(point) || self.cached_lines.is_empty() { + return None; + } + + let content_line = + (event.row.saturating_sub(content_area.y) as usize).saturating_add(self.scroll_offset); + let content_col = event.column.saturating_sub(content_area.x) as usize; + let line = self.cached_lines.get(content_line)?; + let range = crate::ui::hyperlink::hyperlink_range_at_line_col(line, content_col)?; + + let clickable = self + .resolve_hyperlink_target(content_line, &range) + .or_else(|| Some(range.target.clone())) + .is_some(); + + clickable.then_some(ChatHyperlinkHover { + content_line, + range, + }) + } + fn resolve_hyperlink_target( &self, content_line: usize, @@ -1856,11 +1909,16 @@ impl Chat { let paragraph = Paragraph::new(Text::from(content_lines)); f.render_widget(paragraph, render_area); - crate::ui::hyperlink::mark_detected_hyperlinks( - f.buffer_mut(), - render_area, - &all_lines[visible_start..visible_end], - ); + if let Some(hovered) = &self.hovered_hyperlink { + if hovered.content_line >= visible_start && hovered.content_line < visible_end { + crate::ui::hyperlink::mark_hyperlink_range( + f.buffer_mut(), + render_area, + hovered.content_line - visible_start, + &hovered.range, + ); + } + } self.content_height = content_height; self.message_line_positions = positions.to_vec(); @@ -4297,6 +4355,59 @@ mod tests { } } + #[test] + fn test_hyperlink_underline_only_renders_on_hover() { + use ratatui::{backend::TestBackend, Terminal}; + + let colors = test_colors(); + let mut chat = Chat::with_messages(vec![Message::assistant("open src/ui/hyperlink.rs")]); + let area = Rect::new(0, 0, 80, 10); + let backend = TestBackend::new(area.width, area.height); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| chat.render(f, area, "Plan", "model", &colors)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!(!(0..area.height).any(|y| { + (0..area.width).any(|x| buffer[(x, y)].modifier.contains(Modifier::UNDERLINED)) + })); + + let (line_idx, col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("src/ui/hyperlink.rs") + .map(|col| (line_idx, col as u16)) + }) + .expect("path position"); + let hover = chat + .hyperlink_hover_at_position( + mouse( + MouseEventKind::Moved, + col, + line_idx as u16, + KeyModifiers::empty(), + ), + area, + ) + .expect("hyperlink hover"); + chat.set_hovered_hyperlink(Some(hover)); + + terminal + .draw(|f| chat.render(f, area, "Plan", "model", &colors)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let underlined = (0..area.height) + .flat_map(|y| (0..area.width).map(move |x| (x, y))) + .filter(|&(x, y)| buffer[(x, y)].modifier.contains(Modifier::UNDERLINED)) + .count(); + + assert_eq!(underlined, "src/ui/hyperlink.rs".len()); + } + #[test] fn selected_text_uses_render_cached_lines_when_copy_width_differs() { let colors = test_colors(); diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index a0ce322..55576c8 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -102,6 +102,18 @@ impl SessionsDialogState { self.workspace_group_ids = group_ids; } + pub fn focus_workspace(&mut self, workspace_id: i64) -> bool { + let Some(group) = self + .workspace_group_ids + .iter() + .find_map(|(group, id)| (*id == workspace_id).then(|| group.clone())) + else { + return false; + }; + + self.dialog.focus_group_header(&group) + } + fn focused_workspace_group(&self) -> Option<(String, i64)> { let group = self.dialog.get_focused_group_header()?.to_string(); let workspace_id = self.workspace_group_ids.get(&group).copied()?; From 60091ed514858df25aa043e6aea0a5e66711b13c Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 05:19:20 +0800 Subject: [PATCH 159/226] fix: hyperlink on hover only. --- src/ui/hyperlink.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/hyperlink.rs b/src/ui/hyperlink.rs index 941d74d..a4810f1 100644 --- a/src/ui/hyperlink.rs +++ b/src/ui/hyperlink.rs @@ -38,6 +38,15 @@ pub fn mark_detected_hyperlinks(buf: &mut Buffer, area: Rect, lines: &[Line<'_>] } } +pub fn mark_hyperlink_range(buf: &mut Buffer, area: Rect, line_idx: usize, range: &HyperlinkRange) { + if line_idx >= area.height as usize { + return; + } + + let y = area.y.saturating_add(line_idx as u16); + mark_range(buf, area, y, range); +} + pub fn hyperlink_at_line_col(line: &Line<'_>, col: usize) -> Option { hyperlink_range_at_line_col(line, col).map(|range| range.target) } From cbbb980861a4ef7975e8ca260b94dd3f8452dc0a Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 05:33:00 +0800 Subject: [PATCH 160/226] feat: emit terminal BEL on permission/question events, fix scroll-on-click. Add `notifications.terminal.permission` and `notifications.terminal.question` settings with `auto`/`enabled`/`disabled` modes. Refactor `notify_terminal_complete` into the generic `notify_terminal_event` and wire it up to permission and question prompts. Fix a bug where clicking a message would prematurely scroll to that message before showing the action overlay. --- _docs/config/index.mdx | 7 ++++- _docs/config/notifications.mdx | 10 ++++--- _docs/config/opencode-compatibility.mdx | 2 +- _docs/config/sounds.mdx | 2 +- _plans/__TODOS.md | 6 ++-- crabcode.schema.json | 8 ++++++ src/app.rs | 25 ++++++++++++---- src/config/configuration.rs | 38 +++++++++++++++++++++++++ 8 files changed, 82 insertions(+), 16 deletions(-) diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx index e867486..2e6b6f2 100644 --- a/_docs/config/index.mdx +++ b/_docs/config/index.mdx @@ -41,7 +41,12 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the "error": { "enabled": true } }, "notifications": { - "terminal": { "complete": "auto", "condition": "unfocused" } + "terminal": { + "complete": "auto", + "permission": "auto", + "question": "auto", + "condition": "unfocused" + } }, "images": { "openWith": "auto" diff --git a/_docs/config/notifications.mdx b/_docs/config/notifications.mdx index f38d942..42ca6af 100644 --- a/_docs/config/notifications.mdx +++ b/_docs/config/notifications.mdx @@ -1,9 +1,9 @@ --- title: Terminal Notifications -description: Configure terminal-level completion signals for editors such as Zed. +description: Configure terminal-level prompt and completion signals for editors such as Zed. --- -# Completion Signals +# Editor Alert Signals Terminal notifications are crabcode-specific and apply only to `crabcode` config files. They are separate from `sounds..notify`, which sends desktop notifications. @@ -12,19 +12,21 @@ Terminal notifications are crabcode-specific and apply only to `crabcode` config "notifications": { "terminal": { "complete": "auto", + "permission": "auto", + "question": "auto", "condition": "unfocused", }, }, } ``` -`notifications.terminal.complete` controls whether crabcode emits a terminal BEL (`\x07`) when an assistant response finishes. +`notifications.terminal.complete`, `notifications.terminal.permission`, and `notifications.terminal.question` control whether crabcode emits a terminal BEL (`\x07`) when an assistant response finishes, a permission prompt opens, or a question prompt opens. | Value | Behavior | | --- | --- | | `"auto"` | Emit BEL only in supported terminals. Currently this targets Zed via `ZED_TERM=true` or `TERM_PROGRAM=zed`. | | `"enabled"` / `true` | Emit BEL in any terminal. | -| `"disabled"` / `false` | Do not emit terminal completion notifications. | +| `"disabled"` / `false` | Do not emit terminal notifications for that event. | `notifications.terminal.condition` controls when the signal is emitted. diff --git a/_docs/config/opencode-compatibility.mdx b/_docs/config/opencode-compatibility.mdx index 2b0145b..7c45183 100644 --- a/_docs/config/opencode-compatibility.mdx +++ b/_docs/config/opencode-compatibility.mdx @@ -33,7 +33,7 @@ Blank cells mean that runtime behavior is not supported by that project today. ` | `provider..options.timeout` | ✅ | partial | Integer milliseconds or `false` to disable timeout. | | `theme` | ✅ | ✅ | In crabcode config files only. OpenCode config `theme` is ignored by crabcode. | | `sounds` | | ✅ | crabcode-specific terminal audio and notifications. | -| `notifications` | | ✅ | crabcode-specific terminal completion signals such as Zed tab dots. | +| `notifications` | | ✅ | crabcode-specific terminal alert signals such as Zed tab dots. | | `mcp` | ✅ | | Accepted at the top level for forward compatibility, not wired to tools yet. | | `permission` | ✅ | | Accepted at the top level, not enforced from config yet. | | `instructions` | ✅ | | Accepted at the top level, but config-driven instruction files are not loaded yet. | diff --git a/_docs/config/sounds.mdx b/_docs/config/sounds.mdx index a9937ff..475e13a 100644 --- a/_docs/config/sounds.mdx +++ b/_docs/config/sounds.mdx @@ -40,4 +40,4 @@ Sounds are crabcode-specific and apply only to `crabcode` config files. - `permission` / `question` default to disabled. - `notify` is only per-event (`sounds.complete.notify`, etc.); `sounds.notify` is not supported. On macOS, notifications use Notification Center through `osascript`; on Linux, they use the available desktop notification backend. -For terminal-level completion signals such as Zed tab dots, use [`notifications.terminal`](/config/notifications) instead of `sounds`. +For terminal-level editor alert signals such as Zed tab dots, use [`notifications.terminal`](/config/notifications) instead of `sounds`. diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index d136088..ff4acfe 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -182,10 +182,10 @@ I want - [x] To do this But I dont want to do this - [x] View image locally tool, instead of read image. - [x] Clickable paths. -- [ ] When in another workspace and there are existing sessions in there and I opened /sessions, make that "workspace" the focus especially since the first page is at home.rs. +- [x] When in another workspace and there are existing sessions in there and I opened /sessions, make that "workspace" the focus especially since the first page is at home.rs. - [x] I want to make a SPECIAL integration w/ ollama, specifically the local ollama cli. Maybe `ollama ls` can be cached at runtime? and refreshed with refreshmodels? And a special provider place where I can do /connect on it. And it won't require any API keys? I wanna put it somewhere clean though... So that it doesn't really bother with the models.dev stuff, but just fits in cleanly. A /connect provider called 'Ollama (Local)' would be cool. API key-less should be possible too! -- [ ] When clicking, it opens message actions.. Special case for UX: don't change the scroll value when it comes from "clicking a message".. But the other /timeline and ctrl+x g paths should be just fine. +- [x] When clicking, it opens message actions.. Special case for UX: don't change the scroll value when it comes from "clicking a message".. But the other /timeline and ctrl+x g paths should be just fine. -- [ ] Zed alert circle thing when asking permission or question, please emit it. Currently it's only on completions by default I think. +- [x] Zed alert circle thing when asking permission or question, please emit it. Currently it's only on completions by default I think. diff --git a/crabcode.schema.json b/crabcode.schema.json index fb017ac..ee8b197 100644 --- a/crabcode.schema.json +++ b/crabcode.schema.json @@ -159,6 +159,14 @@ "$ref": "#/$defs/TerminalNotificationMode", "default": "auto" }, + "permission": { + "$ref": "#/$defs/TerminalNotificationMode", + "default": "auto" + }, + "question": { + "$ref": "#/$defs/TerminalNotificationMode", + "default": "auto" + }, "condition": { "default": "unfocused", "enum": [ diff --git a/src/app.rs b/src/app.rs index 850ca9b..6ffede7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -525,7 +525,7 @@ impl App { } } - fn notify_terminal_complete(&self) { + fn notify_terminal_event(&self, event: crate::sound::SoundEvent) { use crate::config::{TerminalNotificationCondition, TerminalNotificationMode}; let terminal = self.notifications.terminal; @@ -533,7 +533,14 @@ impl App { return; } - let should_emit = match terminal.complete { + let mode = match event { + crate::sound::SoundEvent::Complete => terminal.complete, + crate::sound::SoundEvent::Permission => terminal.permission, + crate::sound::SoundEvent::Question => terminal.question, + crate::sound::SoundEvent::Error => TerminalNotificationMode::Disabled, + }; + + let should_emit = match mode { TerminalNotificationMode::Auto => crate::notify::terminal_bell_supported(), TerminalNotificationMode::Enabled => true, TerminalNotificationMode::Disabled => false, @@ -2811,7 +2818,6 @@ impl App { if let Some(idx) = released_pending_message { if !self.chat_state.chat.has_selection() { self.pending_chat_message_click = None; - self.chat_state.chat.scroll_to_message_index(idx); self.chat_state.chat.set_highlighted_message(Some(idx)); self.show_message_actions_from(idx, OverlayFocus::None); return; @@ -5171,6 +5177,7 @@ impl App { let _ = self.switch_to_session(session_id); } self.play_sound_event(crate::sound::SoundEvent::Permission); + self.notify_terminal_event(crate::sound::SoundEvent::Permission); if let Some(chat) = self.chat_for_session_mut(session_id) { chat.pause_streaming_tps_timer(); } @@ -5191,6 +5198,7 @@ impl App { let _ = self.switch_to_session(session_id); } self.play_sound_event(crate::sound::SoundEvent::Question); + self.notify_terminal_event(crate::sound::SoundEvent::Question); if let Some(chat) = self.chat_for_session_mut(session_id) { chat.pause_streaming_tps_timer(); } @@ -5295,7 +5303,7 @@ impl App { crate::sound::SoundEvent::Complete, completion_stats.as_deref(), ); - self.notify_terminal_complete(); + self.notify_terminal_event(crate::sound::SoundEvent::Complete); } fn defer_finish_if_tools_are_running(&mut self, session_id: &str) -> bool { @@ -6407,9 +6415,10 @@ mod tests { .chat .get_message_line_positions(78, &app.model, &colors); app.chat_state.chat.message_line_positions = positions; - app.chat_state.chat.content_height = 4; + app.chat_state.chat.content_height = 25; app.chat_state.chat.viewport_height = 18; - app.chat_state.chat.scroll_offset = 0; + app.chat_state.chat.scroll_offset = 3; + let scroll_offset_before_click = app.chat_state.chat.scroll_offset; assert_eq!( app.chat_state.chat.message_index_at_position( mouse(MouseEventKind::Down(MouseButton::Left), 1, 1), @@ -6423,6 +6432,10 @@ mod tests { assert_eq!(app.overlay_focus, OverlayFocus::MessageActions); assert_eq!(app.message_actions_index, Some(0)); + assert_eq!( + app.chat_state.chat.scroll_offset, + scroll_offset_before_click + ); assert!(message_action_names(&app).contains(&"Undo".to_string())); } diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 8c432a0..8852e98 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -185,6 +185,8 @@ pub enum TerminalNotificationCondition { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TerminalNotificationsConfig { pub complete: TerminalNotificationMode, + pub permission: TerminalNotificationMode, + pub question: TerminalNotificationMode, pub condition: TerminalNotificationCondition, } @@ -192,6 +194,8 @@ impl Default for TerminalNotificationsConfig { fn default() -> Self { Self { complete: TerminalNotificationMode::Auto, + permission: TerminalNotificationMode::Auto, + question: TerminalNotificationMode::Auto, condition: TerminalNotificationCondition::Unfocused, } } @@ -1360,6 +1364,22 @@ fn parse_notifications( ); } + if let Some(permission) = terminal_map.get("permission") { + notifications.terminal.permission = parse_terminal_notification_mode( + permission, + "notifications.terminal.permission", + diagnostics, + ); + } + + if let Some(question) = terminal_map.get("question") { + notifications.terminal.question = parse_terminal_notification_mode( + question, + "notifications.terminal.question", + diagnostics, + ); + } + if let Some(condition) = terminal_map.get("condition") { notifications.terminal.condition = parse_terminal_notification_condition( condition, @@ -1471,6 +1491,8 @@ mod tests { "notifications": { "terminal": { "complete": "enabled", + "permission": "enabled", + "question": "disabled", "condition": "always" } } @@ -1482,6 +1504,14 @@ mod tests { config.notifications.terminal.complete, TerminalNotificationMode::Enabled ); + assert_eq!( + config.notifications.terminal.permission, + TerminalNotificationMode::Enabled + ); + assert_eq!( + config.notifications.terminal.question, + TerminalNotificationMode::Disabled + ); assert_eq!( config.notifications.terminal.condition, TerminalNotificationCondition::Always @@ -1498,6 +1528,14 @@ mod tests { config.notifications.terminal.complete, TerminalNotificationMode::Auto ); + assert_eq!( + config.notifications.terminal.permission, + TerminalNotificationMode::Auto + ); + assert_eq!( + config.notifications.terminal.question, + TerminalNotificationMode::Auto + ); assert_eq!( config.notifications.terminal.condition, TerminalNotificationCondition::Unfocused From 55c183266e90fb088605db45f224edfea07aef62 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 13:15:19 +0800 Subject: [PATCH 161/226] feat: add "Skills" command palette entry to open skills dialog. Insert a "Skills" item in the Model group of the command palette that dispatches to `show_skills_dialog`, and add the corresponding `OpenSkillsDialog` action variant. --- src/app.rs | 1 + src/views/command_palette.rs | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 6ffede7..5d9a0bf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3195,6 +3195,7 @@ impl App { let _ = self.cycle_active_reasoning_effort(); } CommandPaletteAppAction::OpenStorage => self.open_storage_dialog(), + CommandPaletteAppAction::OpenSkillsDialog => self.show_skills_dialog(), } self.clear_suggestions_and_blur(); } diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index 6d77eab..57b4bfb 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -20,6 +20,7 @@ pub enum CommandPaletteAppAction { ToggleAgentMode, CycleReasoningEffort, OpenStorage, + OpenSkillsDialog, } #[derive(Debug)] @@ -43,7 +44,21 @@ impl CommandPaletteState { .map(|item| (item.id.clone(), item.provider_id.clone())); let mut items = core_palette_items(registry, is_chat); - items.extend(custom_command_items(registry, is_chat)); + items.insert( + items + .iter() + .position(|item| item.group == "Model") + .unwrap_or(items.len()), + app_action_item( + "open-skills-dialog", + "Skills", + "Model", + "View and select available skills", + None, + ), + ); + + items.extend(custom_command_items(registry, is_chat)); self.dialog = Dialog::with_items("Command Palette", items).with_actions(base_actions()); self.dialog.set_search_query(search_query); @@ -167,6 +182,9 @@ fn action_for_item(item: &DialogItem) -> CommandPaletteAction { "open-storage" => { CommandPaletteAction::RunAppAction(CommandPaletteAppAction::OpenStorage) } + "open-skills-dialog" => { + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::OpenSkillsDialog) + } _ => CommandPaletteAction::None, }; } From 644af4d22199492bc4fd28bdcc6fa0129f632f76 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 14:38:09 +0800 Subject: [PATCH 162/226] docs: add formatting reminder to AGENTS.md. Include instruction to always run fmt at the end of changes. --- AGENTS.md | 2 + src/app.rs | 23 ++++++++--- src/main.rs | 77 +++++++++++++++++++++++++++++++++++- src/views/command_palette.rs | 28 ++++++------- 4 files changed, 108 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e3c834d..c7d1c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ This file contains important information about the codebase that the AI agent sh Before adding/changing scripts, make sure to check `justfile` for existing recipes (this repo uses `just` and typically runs scripts via `bun`). +Always run fmt at the end of changes. + ## File Locations ### Configuration Docs diff --git a/src/app.rs b/src/app.rs index 5d9a0bf..f058794 100644 --- a/src/app.rs +++ b/src/app.rs @@ -283,6 +283,10 @@ pub struct App { impl App { pub fn new() -> Result { + Self::new_with_model_override(None) + } + + pub fn new_with_model_override(model_override: Option<&str>) -> Result { let mut registry = Registry::new(); register_all_commands(&mut registry); @@ -369,13 +373,16 @@ impl App { } } - let active_model_info = if let Some(ref dao) = prefs_dao { - dao.get_active_model().ok().flatten() + let model_override = model_override.map(parse_model_ref); + let active_model_info = if model_override.is_none() { + prefs_dao + .as_ref() + .and_then(|dao| dao.get_active_model().ok().flatten()) } else { None }; - if active_model_info.is_none() { + if model_override.is_none() && active_model_info.is_none() { if let (Some(ref dao), Some(model_str)) = ( prefs_dao.as_ref(), loaded_config.merged_config.model.clone(), @@ -385,14 +392,18 @@ impl App { } } - let active_model_info = if let Some(ref dao) = prefs_dao { - dao.get_active_model().ok().flatten() + let active_model_info = if model_override.is_none() { + prefs_dao + .as_ref() + .and_then(|dao| dao.get_active_model().ok().flatten()) } else { None }; let (active_model, active_provider_name) = - if let Some((provider_id, model_id)) = active_model_info { + if let Some((provider_id, model_id)) = model_override { + (model_id, provider_id) + } else if let Some((provider_id, model_id)) = active_model_info { (model_id.clone(), provider_id.clone()) } else if let Some(model_str) = loaded_config.merged_config.model.clone() { let (provider_id, model_id) = parse_model_ref(&model_str); diff --git a/src/main.rs b/src/main.rs index cfa7e02..7dd5a5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -139,6 +139,7 @@ fn format_post_close_message( async fn run_print_mode( prompt: &str, + model_override: Option<&str>, no_session_persistence: bool, dangerously_skip_permissions: bool, ) -> Result<()> { @@ -154,7 +155,10 @@ async fn run_print_mode( let active = prefs_dao .as_ref() .and_then(|d| d.get_active_model().ok().flatten()); - if let Some((pid, mid)) = active { + if let Some(model) = model_override { + let (pid, mid) = crate::app::parse_model_ref(model); + (pid, mid) + } else if let Some((pid, mid)) = active { (pid, mid) } else if let Some(m) = loaded_config.merged_config.model.clone() { let (pid, mid) = crate::app::parse_model_ref(&m); @@ -307,6 +311,10 @@ struct Args { #[arg(long = "no-session-persistence")] no_session_persistence: bool, + /// Model to use for this invocation, formatted as provider/model + #[arg(short = 'm', long = "model")] + model: Option, + /// Skip permission prompts in print mode. Intended for isolated benchmark/CI workspaces. #[arg(long = "dangerously-skip-permissions")] dangerously_skip_permissions: bool, @@ -332,13 +340,14 @@ async fn main() -> Result<()> { } return run_print_mode( &prompt, + args.model.as_deref(), args.no_session_persistence, args.dangerously_skip_permissions, ) .await; } - let mut app = App::new()?; + let mut app = App::new_with_model_override(args.model.as_deref())?; if let Some(ref session_id) = args.session { app.session_manager.switch_session(session_id); @@ -425,6 +434,70 @@ async fn main() -> Result<()> { result } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_model_after_print_prompt() { + let args = Args::try_parse_from([ + "crabcode", + "-p", + "hi", + "--model", + "opencode-go/deepseek-v4-flash", + ]) + .unwrap(); + + assert_eq!(args.prompt, vec!["hi"]); + assert_eq!(args.model.as_deref(), Some("opencode-go/deepseek-v4-flash")); + } + + #[test] + fn parses_model_with_no_session_persistence_after_print_prompt() { + let args = Args::try_parse_from([ + "crabcode", + "-p", + "hi", + "--no-session-persistence", + "--model", + "opencode-go/kimi-k2.5", + ]) + .unwrap(); + + assert_eq!(args.prompt, vec!["hi"]); + assert!(args.no_session_persistence); + assert_eq!(args.model.as_deref(), Some("opencode-go/kimi-k2.5")); + } + + #[test] + fn parses_short_model_alias() { + let args = Args::try_parse_from(["crabcode", "-p", "hi", "-m", "openai/gpt-5.2"]).unwrap(); + + assert_eq!(args.prompt, vec!["hi"]); + assert_eq!(args.model.as_deref(), Some("openai/gpt-5.2")); + } + + #[test] + fn double_dash_keeps_model_like_tokens_in_prompt() { + let args = Args::try_parse_from([ + "crabcode", + "-p", + "hi", + "--", + "--model", + "opencode-go/deepseek-v4-flash", + ]) + .unwrap(); + + assert_eq!( + args.prompt, + vec!["hi", "--model", "opencode-go/deepseek-v4-flash"] + ); + assert_eq!(args.model, None); + } +} + async fn run_event_loop( terminal: &mut Terminal>, app: &mut App, diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index 57b4bfb..c2ddd4a 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -45,20 +45,20 @@ impl CommandPaletteState { let mut items = core_palette_items(registry, is_chat); items.insert( - items - .iter() - .position(|item| item.group == "Model") - .unwrap_or(items.len()), - app_action_item( - "open-skills-dialog", - "Skills", - "Model", - "View and select available skills", - None, - ), - ); - - items.extend(custom_command_items(registry, is_chat)); + items + .iter() + .position(|item| item.group == "Model") + .unwrap_or(items.len()), + app_action_item( + "open-skills-dialog", + "Skills", + "Model", + "View and select available skills", + None, + ), + ); + + items.extend(custom_command_items(registry, is_chat)); self.dialog = Dialog::with_items("Command Palette", items).with_actions(base_actions()); self.dialog.set_search_query(search_query); From ad6bf93aae9c6637e256204cf962b03e026a8aa2 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 17:20:51 +0800 Subject: [PATCH 163/226] feat: proper context compaction. --- _plans/__TODOS.md | 2 +- src/app.rs | 4 +-- src/llm/client.rs | 23 ++++++++++++ src/session/compaction.rs | 72 +++++++++++++++++++++++++++++++++++-- src/ui/components/chat.rs | 75 +++++++++++++++++++++++++-------------- 5 files changed, 144 insertions(+), 32 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index ff4acfe..973aa3f 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -112,7 +112,7 @@ - [x] wysiwyg double escape to G -- [ ] Compaction logic is a little broken. I did /compact, and the context compacted is ALWAYS at the bottom. instead of just at the part where it tried to compact the messages. Can we study how codex and opencode do it? meaning if I send a new message after compacting. The "compacted" label is still at the bottom of that most recent message +- [x] Compaction logic is a little broken. I did /compact, and the context compacted is ALWAYS at the bottom. instead of just at the part where it tried to compact the messages. Can we study how codex and opencode do it? meaning if I send a new message after compacting. The "compacted" label is still at the bottom of that most recent message - [x] When a message is sent, the [Image #1] or [Image #2] tags, become just white, not the unique color we have for them in the chat input box. diff --git a/src/app.rs b/src/app.rs index f058794..db565d0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3397,9 +3397,7 @@ impl App { before_messages, after_messages: messages.len(), }; - if let Some(summary_message) = messages.first_mut() { - summary_message.compaction_stats = Some(stats); - } + crate::session::compaction::append_compaction_marker(&mut messages, stats); (messages, stats) }); diff --git a/src/llm/client.rs b/src/llm/client.rs index 4aa46d2..0314872 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1131,6 +1131,10 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { aisdk_messages.push(AisdkMessage::system(msg.content.clone())); @@ -1464,4 +1468,23 @@ mod tests { other => panic!("expected tool output, got {other:?}"), } } + + #[test] + fn compaction_marker_is_not_sent_to_model() { + let stats = crate::session::types::CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }; + let marker = crate::session::compaction::compaction_marker(stats); + + let messages = convert_messages(&[crate::session::types::Message::user("tail"), marker]); + + assert_eq!(messages.len(), 1); + match &messages[0] { + AisdkMessage::User(message) => assert_eq!(message.content, "tail"), + other => panic!("expected user message, got {other:?}"), + } + } } diff --git a/src/session/compaction.rs b/src/session/compaction.rs index 386f94d..48201b9 100644 --- a/src/session/compaction.rs +++ b/src/session/compaction.rs @@ -2,6 +2,7 @@ use crate::session::types::{CompactionStats, Message, MessageRole}; pub const DEFAULT_TAIL_TURNS: usize = 2; pub const SUMMARY_PREFIX: &str = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:"; +pub const COMPACTION_MARKER_CONTENT: &str = "[crabcode:context-compacted]"; const SUMMARIZATION_PROMPT: &str = r#"You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. @@ -92,6 +93,10 @@ pub fn build_prompt(messages: &[Message]) -> String { prompt.push_str("Summarize the following session transcript.\n\n\n"); for (idx, message) in messages.iter().enumerate() { + if is_compaction_marker(message) { + continue; + } + let content = message_content_for_prompt(message); if content.trim().is_empty() { continue; @@ -123,7 +128,6 @@ pub fn build_compacted_messages( summary_message.provider = provider; summary_message.agent_mode = agent_mode; summary_message.token_count = Some(estimate_tokens(&summary_message.content)); - summary_message.compaction_stats = stats; if let Some(first_tail) = tail_messages.first() { summary_message.timestamp = first_tail .timestamp @@ -133,6 +137,9 @@ pub fn build_compacted_messages( let mut messages = vec![summary_message]; messages.extend(tail_messages); + if let Some(stats) = stats { + append_compaction_marker(&mut messages, stats); + } messages } @@ -141,6 +148,10 @@ pub fn total_context_tokens(messages: &[Message]) -> usize { } pub fn message_context_tokens(message: &Message) -> usize { + if is_compaction_marker(message) { + return 0; + } + message .token_count .unwrap_or_else(|| estimate_tokens(&message.content)) @@ -154,7 +165,38 @@ pub fn latest_compaction_stats(messages: &[Message]) -> Option } pub fn is_compaction_summary(message: &Message) -> bool { - message.compaction_stats.is_some() || message.content.starts_with(SUMMARY_PREFIX) + message.content.starts_with(SUMMARY_PREFIX) +} + +pub fn is_compaction_marker(message: &Message) -> bool { + message.content == COMPACTION_MARKER_CONTENT && message.compaction_stats.is_some() +} + +pub fn is_compaction_display_item(message: &Message) -> bool { + is_compaction_summary(message) || is_compaction_marker(message) +} + +pub fn compaction_marker(stats: CompactionStats) -> Message { + let mut marker = Message::system(COMPACTION_MARKER_CONTENT); + marker.compaction_stats = Some(stats); + marker.token_count = Some(0); + marker +} + +pub fn append_compaction_marker(messages: &mut Vec, stats: CompactionStats) { + let mut marker = compaction_marker(stats); + let now = std::time::SystemTime::now(); + marker.timestamp = messages + .last() + .map(|message| { + if now < message.timestamp { + message.timestamp + } else { + now + } + }) + .unwrap_or(now); + messages.push(marker); } pub fn format_token_count(count: usize) -> String { @@ -309,6 +351,32 @@ mod tests { assert!(compacted[0].timestamp <= compacted[1].timestamp); } + #[test] + fn compaction_marker_is_appended_after_retained_tail() { + let stats = CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }; + + let compacted = build_compacted_messages( + "summary", + vec![Message::user("tail")], + None, + None, + None, + Some(stats), + ); + + assert_eq!(compacted.len(), 3); + assert!(is_compaction_summary(&compacted[0])); + assert_eq!(compacted[1].content, "tail"); + assert!(is_compaction_marker(&compacted[2])); + assert_eq!(compacted[2].compaction_stats, Some(stats)); + assert_eq!(message_context_tokens(&compacted[2]), 0); + } + #[test] fn compaction_stats_formats_reduction() { let stats = CompactionStats { diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 6fad1e0..934813e 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -921,7 +921,7 @@ impl Chat { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // Bump this whenever rendering logic changes (tables, markdown, etc.) - const RENDER_VERSION: u64 = 5; + const RENDER_VERSION: u64 = 6; RENDER_VERSION.hash(&mut h); colors.hash(&mut h); self.messages.len().hash(&mut h); @@ -1471,7 +1471,7 @@ impl Chat { break; }; - if crate::session::compaction::is_compaction_summary(message) { + if crate::session::compaction::is_compaction_display_item(message) { idx = idx.saturating_add(1); continue; } @@ -1522,7 +1522,7 @@ impl Chat { content_height: usize, ) -> Option<(usize, usize)> { let message = self.messages.get(idx)?; - if crate::session::compaction::is_compaction_summary(message) { + if crate::session::compaction::is_compaction_display_item(message) { return None; } @@ -1987,6 +1987,24 @@ impl Chat { } let message = &self.messages[idx]; + if crate::session::compaction::is_compaction_marker(message) + || (crate::session::compaction::is_compaction_summary(message) + && message.compaction_stats.is_some()) + { + all_lines.extend(format_compaction_marker( + message.compaction_stats, + max_width, + colors, + )); + all_lines.push(Line::from("")); + idx += 1; + continue; + } + if crate::session::compaction::is_compaction_summary(message) { + idx += 1; + continue; + } + let attached_to_assistant = idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; let message_lines = self.format_message( @@ -2004,11 +2022,6 @@ impl Chat { idx += 1; } - if let Some(stats) = latest_compaction_marker_stats(&self.messages) { - all_lines.extend(format_compaction_marker(stats, max_width, colors)); - all_lines.push(Line::from("")); - } - (all_lines, positions) } @@ -2252,7 +2265,7 @@ impl Chat { match message.role { MessageRole::User => { - if crate::session::compaction::is_compaction_summary(message) { + if crate::session::compaction::is_compaction_display_item(message) { return lines; } @@ -3334,16 +3347,6 @@ impl Chat { } } -fn latest_compaction_marker_stats( - messages: &[Message], -) -> Option> { - messages - .iter() - .rev() - .find(|message| crate::session::compaction::is_compaction_summary(message)) - .map(|message| message.compaction_stats) -} - fn format_compaction_marker<'a>( stats: Option, max_width: usize, @@ -4488,29 +4491,49 @@ codex exec --skip-git-repo-check \ } #[test] - fn test_compaction_summary_renders_marker() { - let mut msg = Message::user(format!( + fn test_compaction_marker_renders_at_compaction_point() { + let summary = Message::user(format!( "{}\nsummary content that should stay hidden", crate::session::compaction::SUMMARY_PREFIX )); - msg.compaction_stats = Some(crate::session::types::CompactionStats { + let stats = crate::session::types::CompactionStats { before_tokens: 12_000, after_tokens: 360, before_messages: 8, after_messages: 2, - }); - let chat = Chat::with_messages(vec![msg, Message::user("tail")]); + }; + let marker = crate::session::compaction::compaction_marker(stats); + let chat = Chat::with_messages(vec![ + summary, + Message::user("tail"), + marker, + Message::user("after compact"), + ]); let colors = test_colors(); let lines = chat.build_all_lines(80, "model", &colors); let rendered = lines.iter().map(line_text).collect::>(); assert!(!rendered.iter().any(|line| line.contains("summary content"))); - assert!(rendered.iter().any(|line| line.contains("tail"))); + let marker_idx = rendered + .iter() + .position(|line| line.contains("Context compacted")) + .expect("rendered compaction marker"); + let tail_idx = rendered + .iter() + .position(|line| line.contains("tail")) + .expect("rendered retained tail"); + let after_idx = rendered + .iter() + .position(|line| line.contains("after compact")) + .expect("rendered later user message"); + assert_eq!( - rendered.iter().rev().find(|line| !line.is_empty()), + rendered.get(marker_idx), Some(&"• Context compacted (12.0K -> 360, saved 97%)".to_string()) ); + assert!(tail_idx < marker_idx); + assert!(marker_idx < after_idx); } #[test] From f0f4110cd997700127739a22bbabbf6bc877c70a Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 17:29:35 +0800 Subject: [PATCH 164/226] fix: diff bleeding. --- src/ui/diff.rs | 90 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/src/ui/diff.rs b/src/ui/diff.rs index 2467a54..05eb9b5 100644 --- a/src/ui/diff.rs +++ b/src/ui/diff.rs @@ -257,9 +257,13 @@ fn render_unified_diff_with_indent_and_syntax( .collect::>(); wrap_styled_spans(&styled, content_width) }); - let wrapped_plain = wrapped_syntax_spans - .is_none() - .then(|| textwrap::wrap(&diff_line.text, content_width)); + let wrapped_plain = wrapped_syntax_spans.is_none().then(|| { + let display_text = expand_tabs_for_display(&diff_line.text, 0); + textwrap::wrap(&display_text, content_width) + .into_iter() + .map(|chunk| chunk.into_owned()) + .collect::>() + }); let chunk_count = wrapped_syntax_spans .as_ref() .map(|chunks| chunks.len()) @@ -361,7 +365,7 @@ fn wrap_styled_spans(spans: &[Span<'static>], max_cols: usize) -> Vec max_cols { break; } @@ -378,14 +382,17 @@ fn wrap_styled_spans(spans: &[Span<'static>], max_cols: usize) -> Vec], max_cols: usize) -> Vec usize { + if ch == '\t' { + let offset = col % TAB_WIDTH; + return if offset == 0 { + TAB_WIDTH + } else { + TAB_WIDTH - offset + }; + } + + ch.width().unwrap_or(0) +} + +fn expand_tabs_for_display(text: &str, start_col: usize) -> String { + if !text.contains('\t') { + return text.to_string(); + } + + let mut expanded = String::with_capacity(text.len()); + let mut col = start_col; + for ch in text.chars() { + if ch == '\t' { + let width = display_char_width(ch, col).max(1); + expanded.push_str(&" ".repeat(width)); + col += width; + } else { + expanded.push(ch); + col += display_char_width(ch, col); + } + } + + expanded +} + /// Convenience: compute and render a unified diff in one call. pub fn format_edit_diff( old_string: &str, @@ -657,6 +698,41 @@ mod tests { assert_eq!(import_span.style.bg, Some(colors.diff_add_bg)); } + #[test] + fn test_render_unified_diff_expands_plain_tabs() { + let colors = test_colors(); + let lines = format_edit_diff_with_start("", "\talpha", 1, 40, &colors, ""); + let rendered = lines.iter().map(line_text).collect::>(); + + assert!(rendered.iter().all(|line| !line.contains('\t'))); + assert!(rendered.iter().any(|line| line.starts_with("1 + alpha"))); + } + + #[test] + fn test_render_unified_diff_expands_syntax_tabs() { + let colors = test_colors(); + let new = "\t\tcarfront = {\n\t\t\tinterval = 8.5,\n"; + + let lines = format_edit_diff_for_path_with_start( + "", + new, + 16, + 80, + &colors, + " ", + "arcade/core/levels.lua", + ); + let rendered = lines.iter().map(line_text).collect::>(); + + assert!(rendered.iter().all(|line| !line.contains('\t'))); + assert!(rendered + .iter() + .any(|line| line.starts_with(" 16 + carfront = {"))); + assert!(rendered + .iter() + .any(|line| line.starts_with(" 17 + interval = 8.5,"))); + } + #[test] fn test_render_unified_diff_gutter_is_not_selection_highlighted_or_copied() { let colors = test_colors(); From d8a88dcf8d8936aa40b2f0e890e0e816a91c35dd Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 26 May 2026 20:26:06 +0800 Subject: [PATCH 165/226] feat(tools): enforce at most one in_progress item in update_plan. Add validation in `validate_plan_items` to reject plans with multiple `in_progress` items and update the tool description/parameter schema to reflect this constraint. Add corresponding unit test. --- _plans/__TODOS.md | 2 +- src/tools/update_plan.rs | 30 ++++++++++++++++++++++++++++-- src/ui/components/chat.rs | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 973aa3f..b3a9f02 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -137,7 +137,7 @@ Replaced at line 239 - [x] Proper textwrapping of input for the input chatbox. I can paste a long string (that doesnt compact), or type a long sentence, and it won't wrap to the next line. It just has horizontal scrolling. I dont want horizontal scrolling. -- [ ] Codex's "update plan" tool sometimes has a weird premble before the actual checklist shows... Is this relevant for crabcode? Should we update our tool? Can we do it too? +- [x] Codex's "update plan" tool sometimes has a weird premble before the actual checklist shows... Is this relevant for crabcode? Should we update our tool? Can we do it too? - [x] ~Pressing 'enter' while focusing on a grouplabel header for a "workspace". Make it show a dropdown on the right - Archive (can unarchive on new sessions)~ - dont do anymore diff --git a/src/tools/update_plan.rs b/src/tools/update_plan.rs index a846d74..be0d9ac 100644 --- a/src/tools/update_plan.rs +++ b/src/tools/update_plan.rs @@ -220,6 +220,8 @@ fn validate_plan_items(plan: &[PlanItem]) -> Result<(), ToolError> { )); } + let mut in_progress_count = 0; + for (idx, item) in plan.iter().enumerate() { if item.step.trim().is_empty() { return Err(ToolError::Validation(format!( @@ -237,6 +239,16 @@ fn validate_plan_items(plan: &[PlanItem]) -> Result<(), ToolError> { item.step, item.status ))); } + + if item.status == "in_progress" { + in_progress_count += 1; + } + } + + if in_progress_count > 1 { + return Err(ToolError::Validation( + "Plan must contain at most one in_progress item".to_string(), + )); } Ok(()) @@ -247,7 +259,7 @@ impl ToolHandler for UpdatePlanTool { fn definition(&self) -> Tool { Tool { id: "update_plan".to_string(), - description: "Update the current task plan. Use this for non-trivial, multi-step work. Provide an optional explanation and a plan array with step/status items. Status must be pending, in_progress, or completed.".to_string(), + description: "Update the current task plan. Use this for non-trivial, multi-step work. Provide an optional explanation and a plan array with step/status items. Status must be pending, in_progress, or completed. At most one step can be in_progress at a time.".to_string(), parameters: vec![ ParameterSchema { name: "explanation".to_string(), @@ -257,7 +269,7 @@ impl ToolHandler for UpdatePlanTool { }, ParameterSchema { name: "plan".to_string(), - description: "Array of plan items, each with step and status (pending, in_progress, completed)".to_string(), + description: "Array of plan items, each with step and status (pending, in_progress, completed). At most one item may be in_progress.".to_string(), required: true, param_type: ParameterType::Array(Box::new(plan_item_param_type())), }, @@ -332,6 +344,20 @@ mod tests { assert_eq!(update.plan[2].status, "completed"); } + #[test] + fn parse_update_plan_rejects_multiple_in_progress_items() { + let params = json!({ + "plan": [ + {"step": "Implement rendering", "status": "in_progress"}, + {"step": "Validate rendering", "status": "in_progress"} + ] + }); + + let err = parse_update_plan(¶ms).unwrap_err(); + + assert!(err.to_string().contains("at most one in_progress item")); + } + #[tokio::test] async fn execute_returns_codex_style_ack_with_structured_metadata() { let params = json!({ diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 934813e..cc7b3ab 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -4726,6 +4726,42 @@ codex exec --skip-git-repo-check \ ); } + #[test] + fn test_updated_plan_renders_explanation_before_steps() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "update_plan", + "status": "ok", + "metadata": { + "explanation": "Need a short plan before editing.", + "plan": [ + {"step": "Locate renderer", "status": "completed"}, + {"step": "Implement checklist", "status": "in_progress"}, + {"step": "Validate output", "status": "pending"} + ] + }, + "output_preview": "Plan updated", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬢ Updated Plan", + " └ Need a short plan before editing.", + " ✔ Locate renderer", + " • Implement checklist", + " □ Validate output", + "", + ] + ); + } + #[test] fn test_short_updated_plan_content_renders_at_top() { use ratatui::{backend::TestBackend, Terminal}; From 5f0d6f08db063df3ce6ced93a5784a016bd25328 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 27 May 2026 10:19:32 +0800 Subject: [PATCH 166/226] refactor(config): unify sounds and notifications into single notifications config. Merge the top-level `sounds` and `notifications` objects into one `notifications` block where each event (`complete`, `error`, `permission`, `question`) controls terminal alerts, audio, and desktop notifications from a single place: - Replace `sounds..enabled` with `notifications..soundEnabled` - Replace `sounds..notify` with `notifications..desktop` - Replace `sounds..file` with `notifications..soundFile` - Move `notifications.terminal.` to `notifications..terminal` - Move `notifications.terminal.condition` to `notifications.terminalCondition` The old `sounds` and `notifications.terminal` shapes are still parsed with deprecation warnings and migrated at startup. Update docs, schema, defaults, and internal Rust types accordingly. BREAKING CHANGE: Config files must migrate from `sounds` and `notifications.terminal` to the unified `notifications.` structure. --- README.md | 2 +- _docs/config/index.mdx | 17 +- _docs/config/notifications.mdx | 53 ++- _docs/config/opencode-compatibility.mdx | 5 +- _docs/config/sounds.mdx | 43 --- _docs/gittydocs.jsonc | 3 +- _docs/index.mdx | 2 +- _docs/quickstart.mdx | 14 +- crabcode.jsonc | 21 +- crabcode.schema.json | 190 ++++----- defaults/crabcode.jsonc | 42 +- justfile | 3 + npm/README.md | 162 +++++++- src/app.rs | 25 +- src/config/configuration.rs | 488 ++++++++++++++++++------ src/config/mod.rs | 4 +- src/sound.rs | 42 +- 17 files changed, 724 insertions(+), 392 deletions(-) delete mode 100644 _docs/config/sounds.mdx diff --git a/README.md b/README.md index 66ef75a..28119ce 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A purely Rust-based AI CLI coding agent with a beautiful terminal UI for interac ## Features - **Made with Rust** - Uses ratatui, crossterm and nucleo (fuzzy search), all fast tech. -- **Sounds** - I wanted this in opencode, I just made it built in instead of a plugin. +- **Notifications** - Sounds, desktop notifications, and terminal alert signals are built in. - **TPS, TTFT, Latency metrics** - Also wanted this in opencode, just made it built-in. - **Opens instantly** - one of my main motivations why I made this! :D Very lightweight after build. - **Terminal UI (TUI)** - Beautiful, responsive interface built with [ratatui](https://github.com/ratatui-org/ratatui) diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx index 2e6b6f2..c9caa5b 100644 --- a/_docs/config/index.mdx +++ b/_docs/config/index.mdx @@ -36,16 +36,15 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json", "model": "openai/gpt-5.2", "theme": "crabcode-orange", - "sounds": { - "complete": { "enabled": true }, - "error": { "enabled": true } - }, "notifications": { - "terminal": { - "complete": "auto", - "permission": "auto", - "question": "auto", - "condition": "unfocused" + "terminalCondition": "unfocused", + "complete": { + "terminal": "auto", + "soundEnabled": true, + "desktop": true + }, + "error": { + "soundEnabled": true } }, "images": { diff --git a/_docs/config/notifications.mdx b/_docs/config/notifications.mdx index 42ca6af..4736848 100644 --- a/_docs/config/notifications.mdx +++ b/_docs/config/notifications.mdx @@ -1,26 +1,53 @@ --- -title: Terminal Notifications -description: Configure terminal-level prompt and completion signals for editors such as Zed. +title: Notifications +description: Configure event sounds, desktop notifications, and terminal alert signals. --- -# Editor Alert Signals +# Configure alerts -Terminal notifications are crabcode-specific and apply only to `crabcode` config files. They are separate from `sounds..notify`, which sends desktop notifications. +Notifications are crabcode-specific and apply only to `crabcode` config files. Each event can control terminal alert signals, audio, and native desktop notifications from one place. ```jsonc title="crabcode.jsonc" { "notifications": { - "terminal": { - "complete": "auto", - "permission": "auto", - "question": "auto", - "condition": "unfocused", + "terminalCondition": "unfocused", + "complete": { + "terminal": "auto", + "soundEnabled": true, + "soundFile": "", + "desktop": true, + }, + "error": { + "soundEnabled": true, + "soundFile": "", + "desktop": false, + }, + "permission": { + "terminal": "auto", + "soundEnabled": false, + "soundFile": "/absolute/path/to/permission.wav", + "desktop": false, + }, + "question": { + "terminal": "auto", + "soundEnabled": false, + "soundFile": "/absolute/path/to/question.wav", + "desktop": false, }, }, } ``` -`notifications.terminal.complete`, `notifications.terminal.permission`, and `notifications.terminal.question` control whether crabcode emits a terminal BEL (`\x07`) when an assistant response finishes, a permission prompt opens, or a question prompt opens. +Events are `complete`, `error`, `permission`, and `question`. + +| Field | Behavior | +| --- | --- | +| `terminal` | Emits a terminal BEL (`\x07`) for editor alerts such as Zed tab dots. | +| `soundEnabled` | Plays audio for the event. | +| `soundFile` | Optional absolute path to a custom audio file. Use `""`, `null`, or omit it to use defaults. | +| `desktop` | Sends a native desktop notification. | + +`complete` and `error` default to `soundEnabled: true` and use bundled sounds when `soundFile` is unset. `permission` and `question` default to silent unless you enable them and provide a file. | Value | Behavior | | --- | --- | @@ -28,7 +55,7 @@ Terminal notifications are crabcode-specific and apply only to `crabcode` config | `"enabled"` / `true` | Emit BEL in any terminal. | | `"disabled"` / `false` | Do not emit terminal notifications for that event. | -`notifications.terminal.condition` controls when the signal is emitted. +`notifications.terminalCondition` controls when terminal signals are emitted. | Value | Behavior | | --- | --- | @@ -36,3 +63,7 @@ Terminal notifications are crabcode-specific and apply only to `crabcode` config | `"always"` | Emit even while the terminal is focused. | In Zed, BEL marks the terminal as notified, which lets Zed render the accent dot on the terminal tab or terminal thread entry. Other terminals may play an audible bell, so use `"enabled"` only when you want that behavior outside supported terminals. + +Desktop notifications are best-effort. On macOS, crabcode uses Notification Center through `osascript`; on Linux, it uses the available desktop notification backend. + +The old top-level `sounds` config is deprecated. crabcode still reads it with a startup warning, but new configs should use `notifications..soundEnabled`, `notifications..soundFile`, and `notifications..desktop`. diff --git a/_docs/config/opencode-compatibility.mdx b/_docs/config/opencode-compatibility.mdx index 7c45183..37b6e0a 100644 --- a/_docs/config/opencode-compatibility.mdx +++ b/_docs/config/opencode-compatibility.mdx @@ -7,7 +7,7 @@ description: What OpenCode configuration works in crabcode and what is crabcode- > Don't think CrabCode as another agent config to manage, just treat it like configuring OpenCode. -Most OpenCode docs apply directly: config is JSON/JSONC, project assets live under `.opencode/`, and command files use the OpenCode markdown command format. crabcode adds a small terminal-specific layer for sounds, notifications, and theme selection. +Most OpenCode docs apply directly: config is JSON/JSONC, project assets live under `.opencode/`, and command files use the OpenCode markdown command format. crabcode adds a small terminal-specific layer for notifications and theme selection. ## Compatibility map @@ -32,8 +32,7 @@ Blank cells mean that runtime behavior is not supported by that project today. ` | Markdown agent files | ✅ | | Discovered for diagnostics, not applied as runtime agent definitions yet. | | `provider..options.timeout` | ✅ | partial | Integer milliseconds or `false` to disable timeout. | | `theme` | ✅ | ✅ | In crabcode config files only. OpenCode config `theme` is ignored by crabcode. | -| `sounds` | | ✅ | crabcode-specific terminal audio and notifications. | -| `notifications` | | ✅ | crabcode-specific terminal alert signals such as Zed tab dots. | +| `notifications` | | ✅ | crabcode-specific sounds, desktop notifications, and terminal alert signals such as Zed tab dots. | | `mcp` | ✅ | | Accepted at the top level for forward compatibility, not wired to tools yet. | | `permission` | ✅ | | Accepted at the top level, not enforced from config yet. | | `instructions` | ✅ | | Accepted at the top level, but config-driven instruction files are not loaded yet. | diff --git a/_docs/config/sounds.mdx b/_docs/config/sounds.mdx deleted file mode 100644 index 475e13a..0000000 --- a/_docs/config/sounds.mdx +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Sounds -description: Configure crabcode sounds and per-event notifications. ---- - -# Sounds - -Sounds are crabcode-specific and apply only to `crabcode` config files. - -```jsonc title="crabcode.jsonc" -{ - "sounds": { - "complete": { - "enabled": true, - "notify": true, - "file": "/abs/path/to/complete.wav", - }, - "error": { - "enabled": true, - "notify": true, - "file": "/abs/path/to/error.wav", - }, - "permission": { - "enabled": false, - "notify": false, - "file": "/abs/path/to/permission.wav", - }, - "question": { - "enabled": false, - "notify": false, - "file": "/abs/path/to/question.wav", - }, - }, -} -``` - -`file` is required for custom sounds and must be an absolute path. - -- `complete` / `error` default to enabled and use bundled sounds when no `file` is set. -- `permission` / `question` default to disabled. -- `notify` is only per-event (`sounds.complete.notify`, etc.); `sounds.notify` is not supported. On macOS, notifications use Notification Center through `osascript`; on Linux, they use the available desktop notification backend. - -For terminal-level editor alert signals such as Zed tab dots, use [`notifications.terminal`](/config/notifications) instead of `sounds`. diff --git a/_docs/gittydocs.jsonc b/_docs/gittydocs.jsonc index 85a70fb..8d0c719 100644 --- a/_docs/gittydocs.jsonc +++ b/_docs/gittydocs.jsonc @@ -25,9 +25,8 @@ "items": [ { "label": "Overview", "path": "/config" }, { "label": "OpenCode Compatibility", "path": "/config/opencode-compatibility" }, - { "label": "Terminal Notifications", "path": "/config/notifications" }, + { "label": "Notifications", "path": "/config/notifications" }, { "label": "Images", "path": "/config/images" }, - { "label": "Sounds", "path": "/config/sounds" }, { "label": "Theme", "path": "/config/theme" }, ], }, diff --git a/_docs/index.mdx b/_docs/index.mdx index 8ee5067..de77047 100644 --- a/_docs/index.mdx +++ b/_docs/index.mdx @@ -38,5 +38,5 @@ crabcode is a focused, terminal-only coding agent—just the TUI, built in Rust ## Where to next - [Quickstart](/quickstart) – Get up and running in 5 minutes -- [Configuration](/config) – OpenCode-compatible config, sounds, and themes +- [Configuration](/config) – OpenCode-compatible config, notifications, and themes - [GitHub](https://github.com/blankeos/crabcode) – Source code and issues diff --git a/_docs/quickstart.mdx b/_docs/quickstart.mdx index a08df2b..b462dfa 100644 --- a/_docs/quickstart.mdx +++ b/_docs/quickstart.mdx @@ -58,9 +58,15 @@ crabcode uses JSONC (JSON with comments). Create a `crabcode.jsonc` in your proj { "theme": "default", "model": "openai/gpt-5.2", - "sounds": { - "complete": { "enabled": true }, - "error": { "enabled": true }, + "notifications": { + "complete": { + "terminal": "auto", + "soundEnabled": true, + "desktop": true, + }, + "error": { + "soundEnabled": true, + }, }, } ``` @@ -99,4 +105,4 @@ Once you're in crabcode: | Credentials | `~/.local/state/crabcode/auth.json` | `$XDG_STATE_HOME/crabcode/auth.json` | | Preferences and Sessions | `~/.local/state/crabcode/data.db` | `$XDG_STATE_HOME/crabcode/data.db` | | Model cache | `~/.local/state/crabcode/cache/models_dev_cache.json` | `$XDG_STATE_HOME/crabcode/cache/models_dev_cache.json` | -| Sounds | `~/.local/state/crabcode/sounds` | `$XDG_STATE_HOME/crabcode/sounds` | +| Bundled notification sounds | `~/.local/state/crabcode/sounds` | `$XDG_STATE_HOME/crabcode/sounds` | diff --git a/crabcode.jsonc b/crabcode.jsonc index 4fe7fb5..4d2f5cc 100644 --- a/crabcode.jsonc +++ b/crabcode.jsonc @@ -2,19 +2,24 @@ "$schema": "crabcode.schema.json", // Crabcode theme id (see src/generated_themes/carbonfox.json) "theme": "vercel", - "sounds": { + "notifications": { "complete": { - "enabled": true, - "notify": true, - "file": "/Users/carlo/Desktop/Projects/crabcode/sounds/complete.wav", + "terminal": "auto", + "soundEnabled": true, + "desktop": true, + "soundFile": "/Users/carlo/Desktop/Projects/crabcode/sounds/complete.wav", + }, + "question": { + "soundEnabled": true, + "soundFile": "/Users/carlo/Desktop/Projects/crabcode/sounds/question.mp3", }, "permission": { - "enabled": true, - "file": "/Users/carlo/Desktop/Projects/crabcode/sounds/question.mp3", + "soundEnabled": true, + "soundFile": "/Users/carlo/Desktop/Projects/crabcode/sounds/question.mp3", }, "error": { - "enabled": true, - "file": "/Users/carlo/Desktop/Projects/crabcode/sounds/error.mp3", + "soundEnabled": true, + "soundFile": "/Users/carlo/Desktop/Projects/crabcode/sounds/error.mp3", }, }, } diff --git a/crabcode.schema.json b/crabcode.schema.json index ee8b197..e29b3ac 100644 --- a/crabcode.schema.json +++ b/crabcode.schema.json @@ -1,88 +1,5 @@ { "$defs": { - "SoundEffectConfigFile": { - "additionalProperties": false, - "properties": { - "enabled": { - "type": [ - "boolean", - "null" - ] - }, - "file": { - "type": [ - "string", - "null" - ] - }, - "notify": { - "default": false, - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, - "SoundsConfigFile": { - "additionalProperties": false, - "properties": { - "complete": { - "anyOf": [ - { - "$ref": "#/$defs/SoundEffectConfigFile" - }, - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "error": { - "anyOf": [ - { - "$ref": "#/$defs/SoundEffectConfigFile" - }, - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "permission": { - "anyOf": [ - { - "$ref": "#/$defs/SoundEffectConfigFile" - }, - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "question": { - "anyOf": [ - { - "$ref": "#/$defs/SoundEffectConfigFile" - }, - { - "type": "boolean" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "ImageOpenCommandConfigFile": { "additionalProperties": false, "properties": { @@ -152,28 +69,49 @@ } ] }, - "TerminalNotificationsConfigFile": { + "TerminalNotificationCondition": { + "enum": [ + "unfocused", + "always" + ], + "type": "string" + }, + "NotificationEventConfigFile": { "additionalProperties": false, "properties": { - "complete": { - "$ref": "#/$defs/TerminalNotificationMode", - "default": "auto" + "terminal": { + "$ref": "#/$defs/TerminalNotificationMode" }, - "permission": { - "$ref": "#/$defs/TerminalNotificationMode", - "default": "auto" + "soundEnabled": { + "type": [ + "boolean", + "null" + ] }, - "question": { - "$ref": "#/$defs/TerminalNotificationMode", - "default": "auto" + "sound_enabled": { + "type": [ + "boolean", + "null" + ] }, - "condition": { - "default": "unfocused", - "enum": [ - "unfocused", - "always" - ], - "type": "string" + "soundFile": { + "type": [ + "string", + "null" + ] + }, + "sound_file": { + "type": [ + "string", + "null" + ] + }, + "desktop": { + "default": false, + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -181,15 +119,53 @@ "NotificationsConfigFile": { "additionalProperties": false, "properties": { - "terminal": { + "complete": { + "anyOf": [ + { + "$ref": "#/$defs/NotificationEventConfigFile" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/NotificationEventConfigFile" + }, + { + "type": "null" + } + ] + }, + "permission": { + "anyOf": [ + { + "$ref": "#/$defs/NotificationEventConfigFile" + }, + { + "type": "null" + } + ] + }, + "question": { "anyOf": [ { - "$ref": "#/$defs/TerminalNotificationsConfigFile" + "$ref": "#/$defs/NotificationEventConfigFile" }, { "type": "null" } ] + }, + "terminalCondition": { + "$ref": "#/$defs/TerminalNotificationCondition", + "default": "unfocused" + }, + "terminal_condition": { + "$ref": "#/$defs/TerminalNotificationCondition", + "default": "unfocused" } }, "type": "object" @@ -242,16 +218,6 @@ }, "permission": true, "provider": true, - "sounds": { - "anyOf": [ - { - "$ref": "#/$defs/SoundsConfigFile" - }, - { - "type": "null" - } - ] - }, "theme": { "type": [ "string", diff --git a/defaults/crabcode.jsonc b/defaults/crabcode.jsonc index 7f637d5..04e1b28 100644 --- a/defaults/crabcode.jsonc +++ b/defaults/crabcode.jsonc @@ -9,35 +9,41 @@ // Default model (used only when you don't have an active model persisted yet). "model": "openai/gpt-5.2", - // Sounds are optional. - // - `enabled` toggles each event. - // - `notify` toggles desktop notifications per event. - // - `file` must be an absolute path (no `~`, no relative). - // - If `enabled` is true and `file` is omitted: + // Notifications are optional and grouped by event. + // - `terminal` controls editor alert signals such as Zed tab dots. + // - `desktop` toggles native desktop notifications per event. + // - `soundEnabled` toggles each event's audio. + // - `soundFile` must be an absolute path (no `~`, no relative). + // - If `soundEnabled` is true and `soundFile` is omitted: // - `complete` and `error` use bundled defaults // - `permission` and `question` stay silent - "sounds": { + "notifications": { + "terminalCondition": "unfocused", "error": { - "enabled": true, - "notify": false, + "terminal": "disabled", + "soundEnabled": true, + "desktop": false, // Optional override: - // "file": "/absolute/path/to/error.mp3" + // "soundFile": "/absolute/path/to/error.mp3" }, "complete": { - "enabled": true, - "notify": false, + "terminal": "auto", + "soundEnabled": true, + "desktop": false, // Optional override: - // "file": "/absolute/path/to/complete.mp3" + // "soundFile": "/absolute/path/to/complete.mp3" }, "permission": { - "enabled": false, - "notify": false, - // "file": "/absolute/path/to/permission.wav" + "terminal": "auto", + "soundEnabled": false, + "desktop": false, + // "soundFile": "/absolute/path/to/permission.wav" }, "question": { - "enabled": false, - "notify": false, - // "file": "/absolute/path/to/question.wav" + "terminal": "auto", + "soundEnabled": false, + "desktop": false, + // "soundFile": "/absolute/path/to/question.wav" } }, diff --git a/justfile b/justfile index 9e4a307..3dac20b 100644 --- a/justfile +++ b/justfile @@ -22,6 +22,9 @@ devdocs: log: tail -f app.log +sync_readme: + cp README.md npm/README.md + # Release: bump versions, create release commit, and create a git tag. # Usage: just tag [patch|minor|major] diff --git a/npm/README.md b/npm/README.md index d0a0e8d..28119ce 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1,27 +1,161 @@ -# crabcode +# 🦀 crabcode -Cross-platform installer package for the `crabcode` CLI. +> [!WARNING] +> This ambitious project is very very early (like experiment-early) don't expect it to get to OpenCode level anytime soon. +> Like it literally doesn't even work yet. -## Install +A purely Rust-based AI CLI coding agent with a beautiful terminal UI for interactive "agentic engineering". -```bash -npm install -g crabcode +> In the words of the buildwithpi.ai creator, 'There are many coding agents, this one is mine'. +> +> It's OpenCode but in pure Rust 🦀 w/ my personal flavors. +> +> ~ Carlo (Author) + +![Crabcode banner](_docs/crabcode_banner.jpg) + +## Features + +- **Made with Rust** - Uses ratatui, crossterm and nucleo (fuzzy search), all fast tech. +- **Notifications** - Sounds, desktop notifications, and terminal alert signals are built in. +- **TPS, TTFT, Latency metrics** - Also wanted this in opencode, just made it built-in. +- **Opens instantly** - one of my main motivations why I made this! :D Very lightweight after build. +- **Terminal UI (TUI)** - Beautiful, responsive interface built with [ratatui](https://github.com/ratatui-org/ratatui) +- **Built for the OpenCode user** - works out of the box w/ opencode themes, every UX, and some existing configs so you don't need to force your team to use crabcode. + - **Same UX** - carefully ported most of the good UX from OpenCode i.e. shortcuts, etc. + - **Agent System** - Switch between PLAN (read-only analysis) and BUILD (implementation) agents with TAB, and custom agents. + - **Multiple Model Support** - Works w/ the same models.dev support. + - **Command System** - Intuitive commands: `/sessions`, `/new`, `/connect`, `/models`, `/exit` + custom commands. + - **Session Management** - Create and manage multiple chat sessions + - **Streaming Responses** - Real-time streaming of AI responses (w/ [aisdk.rs](https://aisdk.rs)) + +## Installation -# or -pnpm add -g crabcode -# or -bun add -g crabcode +```sh +npm install -g crabcode # npm +bun install -g crabcode # or bun +cargo binstall crabcode # or cargo-binstall (prebuilt binary, faster) +cargo install crabcode # or cargo (build from source) +curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) ``` +## Quick Start + +1. Run crabcode: + + ```bash + crabcode + ``` + +2. Configure your AI model: + + ``` + /connect + ``` + +3. Start coding! Type your questions or requests and press Enter. + ## Usage +### Commands + +| Command | Description | +| ----------- | -------------------------------- | +| `/sessions` | List all sessions | +| `/new` | Create a new session | +| `/connect` | Open the provider connect dialog | +| `/models` | List available models | +| `/exit` | Quit crabcode | + +### Key Bindings + +| Key | Action | +| ---------------- | -------------------------------------- | +| `Ctrl+X` | Open the shortcuts dialog | +| `TAB` | Switch between PLAN and BUILD agents | +| `Enter` | Submit message or execute command | +| `Ctrl+C` (once) | Clear input | +| `Ctrl+C` (twice) | Quit | +| `Esc` | Close popup suggestions | +| `↑/↓` | Navigate in input or suggestions popup | + +### Agent Types + +- **PLAN** - Read-only analysis and planning agent. Best for understanding codebases, architecture questions, and planning changes. +- **BUILD** - Full access implementation agent. Best for writing code, implementing features, and making changes. + +## Configuration + +Your credentials are stored in crabcode's state directory: + +- Default: `~/.local/state/crabcode/auth.json` +- With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/auth.json` + +Read the [configuration docs here](/_docs/config/index.mdx). + +### Supported Providers + +> Will be powered by mostly [aisdk](https://github.com/lazy-hq/aisdk) + [models.dev](https://models.dev) +> So **most of them** will work out of the box. + +I tried crabcode specifically for these providers: + +- [x] **opencode-zen** +- [x] **nano-gpt** +- [x] **zai** +- [x] **minimax** +- [x] **fireworks** +- [x] **baseten** +- [x] **ollama** + +> Feel free to create an issue / add to this list if you tried + +### Known unsupported providers + +> I might work harder to support these in the future. + +- ChatGPT/Codex Subscription (Though they have good-will to support OpenCode, so maybe CrabCode can as well). **might support later**. +- Kimi For Coding Subscription - I keep getting 401 but it works in OpenCode, I may have to contact them first. **might support later** +- Gemini - It's OAuth + also very unsure. So currently no. +- Claude Code Subscription - Known to explicitly not like harnesses. So never will, sorry. + +## Development + +### Run tests + ```bash -crabcode +cargo test ``` -The package downloads the matching release binary from GitHub after install and -executes it on demand, so you do not need Cargo installed on consumer machines. +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Inspiration + +This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/opencode). Also made this project w/ OpenCode btw, so thank you OpenCode! 🙏 + +## Scope and Limits + +- [x] Chat, switch models, agents +- [x] Minimal configurations (I want it to just feel at least like vanilla opencode) +- [x] The cheapest model providers (GLM, etc.) +- [x] A ding sound, my only opencode plugin at the moment. +- [x] No reverse-engineering oauth from big AI (Claude Code, Gemini), at least for now (Don't wanna get in trouble). +- [x] Exception: ChatGPT oauth (because I use it) +- [x] Copy chat contents, copy the chat input +- [x] Image inputs +- [ ] Possibly ralphy? (very far, idk how to do that) +- [ ] ACP w/ Zed? (very far, idk how to do that) +- [x] No Claude Code oauth spoofing. +- [x] No plugin ecosystem (If I think it's worth building, just make it built-in and configurable) +- [x] No desktop app +- [x] No web sharing thing (Might be a dealbreaker for vibecoders w/ tailscale, but I haven't reached these levels yet, when I do, I might) -Repository: +## Why? -https://github.com/Blankeos/crabcode +I'm learning rust :D. Built a few TUIs as practice. Also been making AI chat apps on web, so I wanna work on this. diff --git a/src/app.rs b/src/app.rs index db565d0..d32233d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -365,11 +365,11 @@ impl App { } } - let (resolved_sounds, sound_warnings) = - crate::sound::resolve_effective_sounds(&loaded_config.merged_config.sounds); - if !sound_warnings.is_empty() { - for msg in &sound_warnings { - crate::startup_diag!("Sound warning: {}", msg); + let (resolved_sounds, notification_warnings) = + crate::sound::resolve_effective_sounds(&loaded_config.merged_config.notifications); + if !notification_warnings.is_empty() { + for msg in ¬ification_warnings { + crate::startup_diag!("Notification warning: {}", msg); } } @@ -531,7 +531,7 @@ impl App { crate::sound::play_file(path); } - if self.sounds.notify_for_event(event) { + if self.notifications.desktop_for_event(event) { crate::notify::notify_event(event, detail); } } @@ -539,16 +539,17 @@ impl App { fn notify_terminal_event(&self, event: crate::sound::SoundEvent) { use crate::config::{TerminalNotificationCondition, TerminalNotificationMode}; - let terminal = self.notifications.terminal; - if terminal.condition == TerminalNotificationCondition::Unfocused && self.terminal_focused { + if self.notifications.terminal_condition == TerminalNotificationCondition::Unfocused + && self.terminal_focused + { return; } let mode = match event { - crate::sound::SoundEvent::Complete => terminal.complete, - crate::sound::SoundEvent::Permission => terminal.permission, - crate::sound::SoundEvent::Question => terminal.question, - crate::sound::SoundEvent::Error => TerminalNotificationMode::Disabled, + crate::sound::SoundEvent::Complete => self.notifications.complete.terminal, + crate::sound::SoundEvent::Permission => self.notifications.permission.terminal, + crate::sound::SoundEvent::Question => self.notifications.question.terminal, + crate::sound::SoundEvent::Error => self.notifications.error.terminal, }; let should_emit = match mode { diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 8852e98..f559740 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -127,48 +127,6 @@ pub struct ConfigInventory { pub command_files: Vec, } -#[derive(Debug, Clone, Default)] -pub struct SoundEffectConfig { - pub file: Option, - pub enabled: bool, - pub notify: bool, -} - -#[derive(Debug, Clone)] -pub struct SoundsConfig { - pub error: SoundEffectConfig, - pub complete: SoundEffectConfig, - pub permission: SoundEffectConfig, - pub question: SoundEffectConfig, -} - -impl Default for SoundsConfig { - fn default() -> Self { - Self { - error: SoundEffectConfig { - file: None, - enabled: true, - notify: false, - }, - complete: SoundEffectConfig { - file: None, - enabled: true, - notify: false, - }, - permission: SoundEffectConfig { - file: None, - enabled: false, - notify: false, - }, - question: SoundEffectConfig { - file: None, - enabled: false, - notify: false, - }, - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TerminalNotificationMode { Auto, @@ -182,28 +140,71 @@ pub enum TerminalNotificationCondition { Always, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TerminalNotificationsConfig { - pub complete: TerminalNotificationMode, - pub permission: TerminalNotificationMode, - pub question: TerminalNotificationMode, - pub condition: TerminalNotificationCondition, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotificationEventConfig { + pub terminal: TerminalNotificationMode, + pub sound_enabled: bool, + pub sound_file: Option, + pub desktop: bool, } -impl Default for TerminalNotificationsConfig { - fn default() -> Self { - Self { - complete: TerminalNotificationMode::Auto, - permission: TerminalNotificationMode::Auto, - question: TerminalNotificationMode::Auto, - condition: TerminalNotificationCondition::Unfocused, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotificationsConfig { + pub error: NotificationEventConfig, + pub complete: NotificationEventConfig, + pub permission: NotificationEventConfig, + pub question: NotificationEventConfig, + pub terminal_condition: TerminalNotificationCondition, +} + +impl NotificationsConfig { + pub fn desktop_for_event(&self, event: crate::sound::SoundEvent) -> bool { + match event { + crate::sound::SoundEvent::Error => self.error.desktop, + crate::sound::SoundEvent::Complete => self.complete.desktop, + crate::sound::SoundEvent::Permission => self.permission.desktop, + crate::sound::SoundEvent::Question => self.question.desktop, } } + + pub fn any_desktop_enabled(&self) -> bool { + self.error.desktop + || self.complete.desktop + || self.permission.desktop + || self.question.desktop + } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct NotificationsConfig { - pub terminal: TerminalNotificationsConfig, +impl Default for NotificationsConfig { + fn default() -> Self { + Self { + error: NotificationEventConfig { + terminal: TerminalNotificationMode::Disabled, + sound_enabled: true, + sound_file: None, + desktop: false, + }, + complete: NotificationEventConfig { + terminal: TerminalNotificationMode::Auto, + sound_enabled: true, + sound_file: None, + desktop: false, + }, + permission: NotificationEventConfig { + terminal: TerminalNotificationMode::Auto, + sound_enabled: false, + sound_file: None, + desktop: false, + }, + question: NotificationEventConfig { + terminal: TerminalNotificationMode::Auto, + sound_enabled: false, + sound_file: None, + desktop: false, + }, + terminal_condition: TerminalNotificationCondition::Unfocused, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -248,7 +249,6 @@ pub struct MergedConfig { pub agent_tool_policies: HashMap>, pub agent_steps: HashMap, pub provider_timeouts: HashMap, - pub sounds: SoundsConfig, pub notifications: NotificationsConfig, pub images: ImagesConfig, } @@ -994,8 +994,10 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M out.agent_steps = parse_agent_steps(obj.get("agent"), diagnostics); out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics); - out.sounds = parse_sounds(obj.get("sounds"), diagnostics); - out.notifications = parse_notifications(obj.get("notifications"), diagnostics); + let mut notifications = NotificationsConfig::default(); + apply_legacy_sounds(obj.get("sounds"), &mut notifications, diagnostics); + apply_notifications(obj.get("notifications"), &mut notifications, diagnostics); + out.notifications = notifications; out.images = parse_images(obj.get("images"), diagnostics); out @@ -1253,51 +1255,73 @@ fn parse_image_open_with( } } -fn parse_sounds(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> SoundsConfig { - let mut sounds = SoundsConfig::default(); - let Some(Value::Object(map)) = value else { - return sounds; +fn apply_legacy_sounds( + value: Option<&Value>, + notifications: &mut NotificationsConfig, + diagnostics: &mut ConfigDiagnostics, +) { + let Some(value) = value else { + return; + }; + + if value.is_null() { + return; + } + + let Value::Object(map) = value else { + diagnostics + .warnings + .push("sounds is deprecated and must be an object when used".to_string()); + return; }; + diagnostics.warnings.push( + "sounds is deprecated; move audio settings to notifications..soundEnabled and notifications..soundFile" + .to_string(), + ); + if map.contains_key("notify") { diagnostics.warnings.push( - "sounds.notify is no longer supported; use sounds..notify (for example sounds.complete.notify)" + "sounds.notify is no longer supported; use notifications..desktop instead" .to_string(), ); } - apply_sound_event( - &mut sounds.error, + apply_legacy_sound_event( + &mut notifications.error, map.get("error"), "sounds.error", + "notifications.error", diagnostics, ); - apply_sound_event( - &mut sounds.complete, + apply_legacy_sound_event( + &mut notifications.complete, map.get("complete"), "sounds.complete", + "notifications.complete", diagnostics, ); - apply_sound_event( - &mut sounds.permission, + apply_legacy_sound_event( + &mut notifications.permission, map.get("permission"), "sounds.permission", + "notifications.permission", diagnostics, ); - apply_sound_event( - &mut sounds.question, + apply_legacy_sound_event( + &mut notifications.question, map.get("question"), "sounds.question", + "notifications.question", diagnostics, ); - - sounds } -fn apply_sound_event( - target: &mut SoundEffectConfig, +fn apply_legacy_sound_event( + target: &mut NotificationEventConfig, value: Option<&Value>, - key: &str, + old_key: &str, + new_key: &str, diagnostics: &mut ConfigDiagnostics, ) { let Some(value) = value else { @@ -1305,59 +1329,116 @@ fn apply_sound_event( }; if let Value::Bool(enabled) = value { - target.enabled = *enabled; + target.sound_enabled = *enabled; return; } let Value::Object(map) = value else { + diagnostics + .warnings + .push(format!("{} must be a boolean or object when used", old_key)); return; }; if let Some(Value::Bool(enabled)) = map.get("enabled") { - target.enabled = *enabled; + target.sound_enabled = *enabled; } if let Some(Value::Bool(notify)) = map.get("notify") { - target.notify = *notify; + diagnostics.warnings.push(format!( + "{}.notify is deprecated; use {}.desktop instead", + old_key, new_key + )); + target.desktop = *notify; } if let Some(Value::String(file)) = map.get("file") { - let p = PathBuf::from(file); - if p.is_absolute() { - target.file = Some(p); - } else { - diagnostics.warnings.push(format!( - "{}: sound file must be an absolute path; treating as disabled", - key - )); - target.file = None; - target.enabled = false; - } + apply_sound_file(target, file, &format!("{}.file", old_key), diagnostics); } } -fn parse_notifications( +fn apply_notifications( value: Option<&Value>, + notifications: &mut NotificationsConfig, diagnostics: &mut ConfigDiagnostics, -) -> NotificationsConfig { - let mut notifications = NotificationsConfig::default(); - let Some(Value::Object(map)) = value else { - return notifications; +) { + let Some(value) = value else { + return; }; - let Some(terminal) = map.get("terminal") else { - return notifications; + if value.is_null() { + return; + } + + let Value::Object(map) = value else { + diagnostics + .warnings + .push("notifications must be an object".to_string()); + return; }; - let Value::Object(terminal_map) = terminal else { + apply_legacy_terminal_notifications(map.get("terminal"), notifications, diagnostics); + + if let Some(condition) = map + .get("terminalCondition") + .or_else(|| map.get("terminal_condition")) + { + notifications.terminal_condition = parse_terminal_notification_condition( + condition, + "notifications.terminalCondition", + diagnostics, + ); + } + + apply_notification_event( + &mut notifications.error, + map.get("error"), + "notifications.error", + diagnostics, + ); + apply_notification_event( + &mut notifications.complete, + map.get("complete"), + "notifications.complete", + diagnostics, + ); + apply_notification_event( + &mut notifications.permission, + map.get("permission"), + "notifications.permission", + diagnostics, + ); + apply_notification_event( + &mut notifications.question, + map.get("question"), + "notifications.question", + diagnostics, + ); +} + +fn apply_legacy_terminal_notifications( + value: Option<&Value>, + notifications: &mut NotificationsConfig, + diagnostics: &mut ConfigDiagnostics, +) { + let Some(value) = value else { + return; + }; + + let Value::Object(terminal_map) = value else { diagnostics .warnings .push("notifications.terminal must be an object".to_string()); - return notifications; + return; }; + diagnostics.warnings.push( + "notifications.terminal is deprecated; use notifications..terminal and notifications.terminalCondition instead" + .to_string(), + ); + if let Some(complete) = terminal_map.get("complete") { - notifications.terminal.complete = parse_terminal_notification_mode( + notifications.complete.terminal = parse_terminal_notification_mode( complete, "notifications.terminal.complete", diagnostics, @@ -1365,7 +1446,7 @@ fn parse_notifications( } if let Some(permission) = terminal_map.get("permission") { - notifications.terminal.permission = parse_terminal_notification_mode( + notifications.permission.terminal = parse_terminal_notification_mode( permission, "notifications.terminal.permission", diagnostics, @@ -1373,7 +1454,7 @@ fn parse_notifications( } if let Some(question) = terminal_map.get("question") { - notifications.terminal.question = parse_terminal_notification_mode( + notifications.question.terminal = parse_terminal_notification_mode( question, "notifications.terminal.question", diagnostics, @@ -1381,14 +1462,99 @@ fn parse_notifications( } if let Some(condition) = terminal_map.get("condition") { - notifications.terminal.condition = parse_terminal_notification_condition( + notifications.terminal_condition = parse_terminal_notification_condition( condition, "notifications.terminal.condition", diagnostics, ); } +} + +fn apply_notification_event( + target: &mut NotificationEventConfig, + value: Option<&Value>, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) { + let Some(value) = value else { + return; + }; + + if value.is_null() { + return; + } + + let Value::Object(map) = value else { + diagnostics + .warnings + .push(format!("{} must be an object", key)); + return; + }; + + if let Some(terminal) = map.get("terminal") { + target.terminal = + parse_terminal_notification_mode(terminal, &format!("{}.terminal", key), diagnostics); + } - notifications + if let Some(desktop) = map.get("desktop") { + if let Some(desktop) = desktop.as_bool() { + target.desktop = desktop; + } else if !desktop.is_null() { + diagnostics + .warnings + .push(format!("{}.desktop must be a boolean", key)); + } + } + + if let Some(sound_enabled) = map.get("soundEnabled").or_else(|| map.get("sound_enabled")) { + if let Some(sound_enabled) = sound_enabled.as_bool() { + target.sound_enabled = sound_enabled; + } else if !sound_enabled.is_null() { + diagnostics + .warnings + .push(format!("{}.soundEnabled must be a boolean", key)); + } + } + + if let Some(sound_file) = map.get("soundFile").or_else(|| map.get("sound_file")) { + match sound_file { + Value::String(file) => { + apply_sound_file(target, file, &format!("{}.soundFile", key), diagnostics); + } + Value::Null => { + target.sound_file = None; + } + _ => { + diagnostics + .warnings + .push(format!("{}.soundFile must be a string or null", key)); + } + } + } +} + +fn apply_sound_file( + target: &mut NotificationEventConfig, + file: &str, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) { + if file.trim().is_empty() { + target.sound_file = None; + return; + } + + let p = PathBuf::from(file); + if p.is_absolute() { + target.sound_file = Some(p); + } else { + diagnostics.warnings.push(format!( + "{}: sound file must be an absolute path; treating as disabled", + key + )); + target.sound_file = None; + target.sound_enabled = false; + } } fn parse_terminal_notification_mode( @@ -1484,7 +1650,56 @@ mod tests { use serde_json::json; #[test] - fn parses_terminal_notifications() { + fn parses_event_notifications() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "notifications": { + "terminalCondition": "always", + "complete": { + "terminal": "enabled", + "desktop": true, + "soundEnabled": true, + "soundFile": "/tmp/complete.wav" + }, + "permission": { + "terminal": "enabled" + }, + "question": { + "terminal": "disabled" + } + } + }), + &mut diagnostics, + ); + + assert_eq!( + config.notifications.complete.terminal, + TerminalNotificationMode::Enabled + ); + assert!(config.notifications.complete.desktop); + assert!(config.notifications.complete.sound_enabled); + assert_eq!( + config.notifications.complete.sound_file, + Some(PathBuf::from("/tmp/complete.wav")) + ); + assert_eq!( + config.notifications.permission.terminal, + TerminalNotificationMode::Enabled + ); + assert_eq!( + config.notifications.question.terminal, + TerminalNotificationMode::Disabled + ); + assert_eq!( + config.notifications.terminal_condition, + TerminalNotificationCondition::Always + ); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn legacy_terminal_notifications_are_migrated() { let mut diagnostics = ConfigDiagnostics::default(); let config = parse_merged_config( &json!({ @@ -1501,22 +1716,25 @@ mod tests { ); assert_eq!( - config.notifications.terminal.complete, + config.notifications.complete.terminal, TerminalNotificationMode::Enabled ); assert_eq!( - config.notifications.terminal.permission, + config.notifications.permission.terminal, TerminalNotificationMode::Enabled ); assert_eq!( - config.notifications.terminal.question, + config.notifications.question.terminal, TerminalNotificationMode::Disabled ); assert_eq!( - config.notifications.terminal.condition, + config.notifications.terminal_condition, TerminalNotificationCondition::Always ); - assert!(diagnostics.warnings.is_empty()); + assert!(diagnostics + .warnings + .iter() + .any(|warning| warning.contains("notifications.terminal is deprecated"))); } #[test] @@ -1525,19 +1743,19 @@ mod tests { let config = parse_merged_config(&json!({}), &mut diagnostics); assert_eq!( - config.notifications.terminal.complete, + config.notifications.complete.terminal, TerminalNotificationMode::Auto ); assert_eq!( - config.notifications.terminal.permission, + config.notifications.permission.terminal, TerminalNotificationMode::Auto ); assert_eq!( - config.notifications.terminal.question, + config.notifications.question.terminal, TerminalNotificationMode::Auto ); assert_eq!( - config.notifications.terminal.condition, + config.notifications.terminal_condition, TerminalNotificationCondition::Unfocused ); } @@ -1548,8 +1766,8 @@ mod tests { let config = parse_merged_config( &json!({ "notifications": { - "terminal": { - "complete": false + "complete": { + "terminal": false } } }), @@ -1557,11 +1775,41 @@ mod tests { ); assert_eq!( - config.notifications.terminal.complete, + config.notifications.complete.terminal, TerminalNotificationMode::Disabled ); } + #[test] + fn legacy_sounds_are_migrated_to_notification_events() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "sounds": { + "complete": { + "enabled": true, + "notify": true, + "file": "/tmp/complete.wav" + }, + "question": false + } + }), + &mut diagnostics, + ); + + assert!(config.notifications.complete.sound_enabled); + assert!(config.notifications.complete.desktop); + assert_eq!( + config.notifications.complete.sound_file, + Some(PathBuf::from("/tmp/complete.wav")) + ); + assert!(!config.notifications.question.sound_enabled); + assert!(diagnostics + .warnings + .iter() + .any(|warning| warning.contains("sounds is deprecated"))); + } + #[test] fn images_open_with_defaults_to_auto() { let mut diagnostics = ConfigDiagnostics::default(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 5fe8bca..0ff311b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,8 +2,8 @@ pub mod configuration; pub use configuration::{ ConfigDiagnostics, ConfigInventory, ConfigLoader, ImageOpenCommandConfig, ImageOpenWith, - ImagesConfig, LoadedConfig, MergedConfig, NotificationsConfig, ProviderTimeout, - SoundEffectConfig, SoundsConfig, TerminalNotificationCondition, TerminalNotificationMode, + ImagesConfig, LoadedConfig, MergedConfig, NotificationEventConfig, NotificationsConfig, + ProviderTimeout, TerminalNotificationCondition, TerminalNotificationMode, }; pub use configuration::discover_themes; diff --git a/src/sound.rs b/src/sound.rs index 66db0c8..da648b8 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -16,10 +16,6 @@ pub struct ResolvedSoundsConfig { pub complete: Option, pub permission: Option, pub question: Option, - pub error_notify: bool, - pub complete_notify: bool, - pub permission_notify: bool, - pub question_notify: bool, } impl ResolvedSoundsConfig { @@ -31,15 +27,6 @@ impl ResolvedSoundsConfig { SoundEvent::Question => self.question.as_deref(), } } - - pub fn notify_for_event(&self, event: SoundEvent) -> bool { - match event { - SoundEvent::Error => self.error_notify, - SoundEvent::Complete => self.complete_notify, - SoundEvent::Permission => self.permission_notify, - SoundEvent::Question => self.question_notify, - } - } } #[derive(Debug, Clone, Copy)] @@ -58,54 +45,45 @@ const BUILTIN_ERROR_MP3: &[u8] = include_bytes!("../sounds/error.mp3"); const BUILTIN_COMPLETE_MP3: &[u8] = include_bytes!("../sounds/complete.mp3"); pub fn resolve_effective_sounds( - config: &crate::config::SoundsConfig, + config: &crate::config::NotificationsConfig, ) -> (ResolvedSoundsConfig, Vec) { let mut warnings = Vec::new(); let mut built_in_cache = BuiltInSoundCache::default(); let resolved = ResolvedSoundsConfig { error: resolve_event_path( - "sounds.error", + "notifications.error", &config.error, Some(BuiltInSound::Error), &mut built_in_cache, &mut warnings, ), complete: resolve_event_path( - "sounds.complete", + "notifications.complete", &config.complete, Some(BuiltInSound::Complete), &mut built_in_cache, &mut warnings, ), permission: resolve_event_path( - "sounds.permission", + "notifications.permission", &config.permission, None, &mut built_in_cache, &mut warnings, ), question: resolve_event_path( - "sounds.question", + "notifications.question", &config.question, None, &mut built_in_cache, &mut warnings, ), - error_notify: config.error.notify, - complete_notify: config.complete.notify, - permission_notify: config.permission.notify, - question_notify: config.question.notify, }; - if (resolved.error_notify - || resolved.complete_notify - || resolved.permission_notify - || resolved.question_notify) - && !crate::notify::is_supported() - { + if config.any_desktop_enabled() && !crate::notify::is_supported() { warnings.push( - "Desktop notifications are enabled for sounds, but no supported notification backend is available on this OS" + "Desktop notifications are enabled, but no supported notification backend is available on this OS" .to_string(), ); } @@ -115,16 +93,16 @@ pub fn resolve_effective_sounds( fn resolve_event_path( key: &str, - effect: &crate::config::SoundEffectConfig, + effect: &crate::config::NotificationEventConfig, fallback: Option, built_in_cache: &mut BuiltInSoundCache, warnings: &mut Vec, ) -> Option { - if !effect.enabled { + if !effect.sound_enabled { return None; } - if let Some(path) = effect.file.as_ref() { + if let Some(path) = effect.sound_file.as_ref() { if path.is_file() { return Some(path.clone()); } From a22770c781b0042bd0fd8f076095b797ba27819b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 27 May 2026 23:58:47 +0800 Subject: [PATCH 167/226] chore: just progress mgmt stuff. --- _docs/__PARITY.md | 244 +++++++++++++++++++++++++--------------------- _plans/__TODOS.md | 4 + 2 files changed, 135 insertions(+), 113 deletions(-) diff --git a/_docs/__PARITY.md b/_docs/__PARITY.md index df0901c..399d50b 100644 --- a/_docs/__PARITY.md +++ b/_docs/__PARITY.md @@ -1,142 +1,160 @@ # Crabcode Harness Parity Audit -Checked: 2026-05-19. +Checked: 2026-05-27. -Scope: core harness behavior only: agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands, and permissions. This compares crabcode source against the local opencode reference in `.devrefs/references/anomalyco/opencode` plus the requested opencode behavior. +Scope: core harness behavior only: agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands, and permissions. UX, theming, keybinds, model picker/auth UI, and other non-harness features are intentionally excluded. ## Feature Matrix -| # | Feature | OpenCode | Crabcode | Gap | -|---|---------|----------|----------|-----| -| 1.1 | Multi-step agentic iteration | Streams model responses, accumulates tool calls, executes tools, appends observations, and continues until stop or step limit. | Present. `src/llm/client.rs` calls `stream_with_tools`; `aisdk/src/response.rs` loops over steps, tool calls, and observations. | No major parity gap for the core loop. | -| 1.2 | Cancellation token support | User interruption cancels active generation and agent work. | Present for model streaming. `src/llm/client.rs` relays cancellation and emits `ChunkMessage::Cancelled`; tools get abort channels through the AI SDK bridge. | Tool cancellation is weaker: `src/tools/aisdk_bridge.rs` creates a fresh abort channel instead of wiring the top-level cancellation token through every long-running tool. | -| 1.3 | Step limit and fallback | Enforces configured max steps, then injects a max-steps prompt and performs a text-only completion. | Mostly present. `MAX_STEPS_REACHED_PROMPT` is injected and `src/llm/client.rs` calls the provider again with no tools when the loop reaches the limit. | `maxSteps` alias is explicitly unsupported; behavior is tied to the current `steps` config path. | -| 1.4 | Chunk-based streaming | Emits text, reasoning, tool calls, tool results, errors, metrics, and cancellation chunks. | Present. `src/llm/mod.rs` defines `Text`, `Reasoning`, `ToolCalls`, `ToolResult`, `Failed`, `Metrics`, `Cancelled`, plus permission, question, and subagent chunks. | No major parity gap for the listed chunk types. | -| 1.5 | Plan/Build mode toggle | Plan mode is read-only; build mode can execute write-capable tools. | Present. `src/app.rs` toggles Plan/Build; `src/tools/permission.rs` denies `write`, `edit`, and `bash` in Plan. | This is mode-based policy only, not the full opencode agent-mode registry. | -| 1.6 | Permission preflight during tool execution | Tool calls are preflighted and can surface permission dialogs mid-run. | Present but limited. `src/tools/aisdk_bridge.rs` preflights before execution; `src/tools/permission.rs` emits permission requests; `src/app.rs` handles the dialog. | Policy inputs are hardcoded/in-memory rather than driven by opencode-style config rules. | -| 1.7 | Configurable max steps per agent | Each agent can define max steps; limit injects the max-steps prompt. | Partially present. `src/config/configuration.rs` parses `agent..steps`; app and print paths pass agent-specific step counts into the LLM call. | Only `steps` is supported; broader per-agent config and deprecated `maxSteps` compatibility are missing. | -| 2.1 | Provider-specific prompt header and behavior | Chooses provider/model-specific prompts such as Beast/OpenAI, Anthropic, Gemini, Codex, and other provider variants. | Partial. `src/prompt/mod.rs` has Beast, Anthropic, Gemini, and Codex prompt branches. | Prompt set is simpler than opencode, with fewer provider/model variants and less complete behavioral parity. | -| 2.2 | Environment context block | Includes workdir, git status/repo, platform, and date in the system prompt. | Present. `src/prompt/mod.rs` emits workdir, git repo status, platform, and current date. | Minor wording/content differences only. | -| 2.3 | Tool schemas block | Lists all registered tools as JSON in the system prompt. | Partial. `SystemPromptComposer` can emit tool schemas if built with a tool registry, but runtime app and print composition do not call `.with_tool_registry(...)`. | Actual runtime system prompts do not include the tool schemas block even though provider requests still receive tool schemas through the AI SDK. | -| 2.4 | Custom instructions discovery | Walks up for project instructions and supports global fallback. | Partial. `src/prompt/rules.rs` finds local `AGENTS.md`/`CLAUDE.md` and global crabcode/Claude files. | Does not stop at git worktree boundary, does not include opencode global paths, and does not support config-driven instruction entries. | -| 2.5 | Available skills block | Emits `` with names and descriptions. | Present in interactive mode. `src/prompt/mod.rs` renders skills when `SkillStore` is attached; `src/app.rs` initializes the store. | Print mode does not initialize/attach the skill store, so the block can be absent outside the app path. | -| 2.6 | Available subagents block | Lists subagent names and descriptions so the primary agent can pick a Task target. | Present. `src/prompt/mod.rs` emits ``; `src/agent/subagent.rs` supplies definitions. | Only the currently implemented subagents are listed; missing scout, VLM, and hidden/system agents. | -| 2.7 | Prompt-level subagent selection guidance | Primary agent sees when to use the Task tool and which subagent to choose. | Partial. The prompt lists subagent descriptions and the Task tool schema constrains allowed types. | No task-permission matrix or hidden-agent metadata in the prompt. | -| 3.1 | Task tool | Primary agents spawn subagents through a Task tool. | Present. `src/tools/task.rs` implements Task; `src/tools/init.rs` registers it dynamically. | Missing opencode parameters such as background execution, task IDs, command routing, and task status. | -| 3.2 | `explore` subagent | Fast read-only subagent with glob, grep, read, and list. | Present. `src/agent/subagent.rs` defines `Explore` with those read-only tools. | No major parity gap for the basic explore profile. | -| 3.3 | `general` subagent | Full multi-step subagent, excluding `todowrite`. | Present. `src/agent/subagent.rs` defines `General` with broad tools and excludes `todowrite`. | Permission behavior is still governed by crabcode's simpler policy engine. | -| 3.4 | `scout` subagent | Read-only external research agent that can clone repositories. | Missing. `SubAgentType` only has `Explore` and `General`. | Need scout definition, repo clone/overview tools, and external research permissions. | -| 3.5 | `vlm-agent` | Image-analysis subagent. | Missing. | Need image input plumbing, VLM model selection, and a VLM-capable subagent definition. | -| 3.6 | Hidden/system agents | Compaction, title, and summary agents run automatically and are hidden from user autocomplete. | Missing as an agent system. | Need hidden agent definitions and automatic invocation hooks for compaction, title, and summary flows. | -| 3.7 | Child sessions | Subagent work is represented as child sessions with parent/child navigation. | Partial. `src/session/manager.rs` supports child sessions; `src/tools/task.rs` creates subagent sessions; `src/app.rs` renders subagent events. | Lacks opencode-style background tasks, task status tracking, and richer child-session lifecycle controls. | -| 3.8 | `@mention` subagent invocation | User input can invoke subagents by mention. | Missing. Slash command parsing exists in `src/command/parser.rs`; autocomplete focuses on files/commands, not subagents. | Need parser, autocomplete, and dispatch path for `@subagent` invocation. | -| 3.9 | Agent mode: primary, subagent, all | Agent definitions declare where they are available. | Missing. | Need agent registry fields and enforcement for primary-only, subagent-only, and all-mode agents. | -| 3.10 | Hidden agents from autocomplete | Agents can be invokable but hidden from autocomplete. | Missing. | Requires hidden metadata in agent definitions and autocomplete filtering. | -| 3.11 | Task permissions | Controls which agents can invoke which subagents. | Missing. Task validates only the hardcoded subagent enum. | Need per-agent task permission rules and enforcement before spawning a child agent. | -| 4.1 | `bash` tool | Executes shell commands. | Present. `src/tools/bash.rs`; registered in `src/tools/init.rs`. | Policy granularity differs from opencode. | -| 4.2 | `edit` tool | Exact string replacement in files. | Present. `src/tools/edit.rs`; registered in `src/tools/init.rs`. | No major parity gap for the basic tool. | -| 4.3 | `write` tool | Creates or overwrites files. | Present. `src/tools/fs/write.rs`; registered in `src/tools/init.rs`. | No major parity gap for the basic tool. | -| 4.4 | `read` tool | Reads files with offset/limit pagination and can inspect directories. | Present. `src/tools/fs/read.rs`; registered in `src/tools/init.rs`. | Confirm directory behavior stays aligned with opencode's separate `list` semantics during future changes. | -| 4.5 | `grep` tool | Regex search with include filters. | Present. `src/tools/fs/grep.rs`; registered in `src/tools/init.rs`. | No major parity gap for the basic tool. | -| 4.6 | `glob` tool | File pattern matching. | Present. `src/tools/fs/glob.rs`; registered in `src/tools/init.rs`. | No major parity gap for the basic tool. | -| 4.7 | `list` tool | Deliberate tree-style directory listing, separate from read-directory behavior. | Partial. `src/tools/fs/list.rs` lists direct entries. | Needs recursive/tree-style output parity with opencode's `list`. | -| 4.8 | `skill` tool | Loads `SKILL.md` by name and lists available skills in its description. | Present. `src/tools/skill.rs`; registered in `src/tools/init.rs`. | Availability filtering does not honor skill permission patterns. | -| 4.9 | `task` tool | Spawns subagents. | Present. `src/tools/task.rs`; dynamically registered in `src/tools/init.rs`. | Missing background/status/command/task-permission behavior. | -| 4.10 | `todowrite` tool | Manages structured task lists. | Present. `src/tools/todowrite.rs`; registered in `src/tools/init.rs`. | No major parity gap for registration; behavior should be checked separately if exact todo schema parity is required. | -| 4.11 | `webfetch` tool | Fetches web content and converts it to readable text/markdown. | Present. `src/tools/webfetch.rs`; registered in `src/tools/init.rs`. | Network and markdown-conversion fidelity may differ, but the core tool exists. | -| 4.12 | `websearch` tool | Searches the web through Exa AI. | Missing. | Need search provider integration, schema, permissions, and registration. | -| 4.13 | `question` tool | Asks the user questions during execution. | Present. `src/tools/question.rs`; dynamically registered in `src/tools/init.rs`. | No major parity gap for basic interactive questions. | -| 4.14 | `extract-images` tool | Saves session images to disk. | Missing. | Need session attachment/image storage model and tool registration. | -| 4.15 | `apply_patch` tool | Applies diffs/patches. | Missing. | Need patch application tool, schema, permissions, and safe failure handling. | -| 4.16 | `lsp` tool | Experimental LSP code intelligence. | Missing. | Need LSP client/session management and tool schema. | -| 5.1 | Skill discovery locations | Searches project/global opencode, Claude, and agents skill locations. | Partial. `src/skill/mod.rs` scans crabcode/opencode globals plus project/global `.claude` and `.agents`. | Missing config `skills.paths` and URL-based skills; project `.opencode`/`.crabcode` discovery is rooted, not fully walk-up like `.claude`/`.agents`. | -| 5.2 | Walk-up to git worktree | Walks up project directories until the git worktree boundary. | Partial. `src/skill/mod.rs` walks up for `.claude` and `.agents`. | Walk-up does not stop at the git worktree boundary and is not consistently applied to all project skill roots. | -| 5.3 | Skill frontmatter | Requires YAML frontmatter with `name` and `description`. | Partial. `name` is required, but `description` is optional in `src/skill/mod.rs`. | Enforce required descriptions for opencode compatibility. | -| 5.4 | Pattern-based skill permissions | Supports allow/deny rules such as `internal-* = deny`. | Missing. | Need skill permission parsing, glob matching, and filtering before prompt/tool exposure. | -| 5.5 | Skill tool list in description | Skill tool description lists available skills. | Present. `src/tools/skill.rs` builds a description from `SkillStore::list()`. | Should be filtered by skill permissions once those exist. | -| 6.1 | JSON agent config | Supports agent config in `opencode.json`. | Partial. Crabcode config parses agent tool allowlists and `steps` from JSON/JSONC. | Missing most opencode agent fields and full `opencode.json` agent compatibility. | -| 6.2 | Markdown agent config | Supports `~/.config/opencode/agents/.md` frontmatter. | Missing as runtime config. `src/config/configuration.rs` inventories agent markdown files but does not parse/apply them. | Need markdown frontmatter parser and merge logic. | -| 6.3 | Per-agent execution fields | Supports description, temperature, model, max steps, mode, hidden, color, top_p, permissions, and task permissions. | Mostly missing. `src/agent/config.rs` is global LLM session state; config currently supports only tool policies and `steps`. | Need a first-class agent definition model and enforcement path. | -| 6.4 | Agent creation wizard | `opencode agent create` scaffolds new agent config. | Missing. | Add command/CLI flow to create agent markdown or JSON config entries. | -| 7.1 | User-defined command files | Loads `.opencode/commands/.md`. | Missing. `src/command` only implements built-in slash commands and skill-backed commands. | Need command file discovery, parsing, and registration. | -| 7.2 | Command frontmatter | Supports description, agent, model, and subtask. | Missing. | Need command frontmatter schema and dispatch behavior. | -| 7.3 | Template variables | Expands `$ARGUMENTS`, `$1`, `$2`, and similar variables. | Missing. | Add command template expansion before sending prompts. | -| 7.4 | Shell output injection | Expands command-substitution snippets inside command prompts. | Missing. | Add shell execution path with permission checks and output injection. | -| 7.5 | File references | Expands `@path/to/file` references inside command prompts. | Missing. | Reuse file reference parsing/attachment code or add command-specific resolver. | -| 7.6 | Subtask command routing | Commands can run as subtasks through the Task tool. | Missing. | Add `subtask` handling that routes through Task with the requested agent. | -| 8.1 | Per-tool permissions | Per-tool rules support allow, deny, and ask. | Partial. `src/tools/permission.rs` has Plan/Build defaults and in-memory allow/deny/ask outcomes. | No config-level per-tool allow/deny/ask matrix. | -| 8.2 | Wildcard permission patterns | Supports patterns such as `mymcp_* = deny`. | Missing. | Add wildcard matcher and config schema. | -| 8.3 | Bash command patterns | Supports command-specific bash permissions such as `git push = ask` and `git * = allow`. | Missing. | Replace hardcoded bash ask behavior with ordered pattern-specific rules. | -| 8.4 | Per-agent permission override | Agent config can override global permissions. | Missing. | Requires first-class agent config plus permission merge order. | -| 8.5 | External directory gating | Writes/commands outside workspace are gated. | Present. `src/tools/permission.rs` checks external paths and sensitive paths. | No major parity gap for the basic safety gate, but rule configurability is missing. | -| 8.6 | Doom loop recovery prompts | Detects repeated permission/operation loops and prompts for recovery. | Missing. | Need loop detection in agent execution and recovery prompt injection. | - -## Priority Gaps +| # | Feature | OpenCode | Crabcode | Gap | +| ---- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.1 | Multi-step agentic iteration | Streams model responses, handles tool calls, appends tool results/observations, and continues until model stop or step limit. | Mostly present. `src/llm/client.rs` uses `stream_with_tools`; runtime tool execution is bridged through `src/tools/aisdk_bridge.rs`. | Core loop exists, but harness logic is split between the AI SDK bridge and UI runtime rather than a first-class agent runner; subagents also bypass `stream_llm_with_cancellation`, so cancellation/limits/policy are inconsistent there. | +| 1.2 | Cancellation token support for user interruption | Active model streams and agent work can be interrupted by the user. | Present for primary streams. `src/app.rs` stores `CancellationToken`s and cancels on interruption; `src/llm/client.rs` emits `ChunkMessage::Cancelled`. | Tool/subagent cancellation is incomplete: `convert_to_aisdk_tools` creates a fresh abort channel per tool call, and `TaskTool`/`run_subagent` do not receive the primary cancellation token. | +| 1.3 | Step limit with text-only fallback | Configured max steps stop tool use, inject a max-steps instruction, and ask the model for a text-only summary. | Present for primary agent. `src/llm/client.rs` defines `MAX_STEPS_REACHED_PROMPT`, detects step-limit stop, and performs a second no-tool completion. Config parses `agent..steps` and `maxSteps` in `src/config/configuration.rs`. | Subagents do not apply agent-specific max steps or fallback; `run_subagent` calls `stream_with_tools(..., None, ...)`. | +| 1.4 | Chunk-based streaming | Streams text, reasoning, tool calls, tool results, errors, metrics, and cancelled events. | Present. `src/llm/mod.rs` has `Text`, `Reasoning`, `ToolCalls`, `ToolResult`, `Failed`, `Metrics`, `Cancelled`, plus permission/question/subagent chunks. | Good parity for listed chunk categories. | +| 1.5 | Plan/Build mode toggle | Plan mode is read-only; Build mode permits write-capable tools. | Partial. `src/app.rs` toggles `Plan`/`Build`; `src/tools/permission.rs` policy denies `write`/`edit` in plan mode. | Plan mode still allows `bash` by default (`plan_mode_blocks_mutating_tools` test asserts this), so it is not strictly read-only like OpenCode. | +| 1.6 | Permission preflight during tool execution | Tool calls are preflighted and can show permission dialogs mid-stream. | Partial. `src/tools/aisdk_bridge.rs` preflights before execution; `src/tools/permission.rs` emits `PermissionRequest`; `src/app.rs` handles permission dialogs. | Preflight policy only covers sensitive reads, external paths, agent tool availability, and doom-loop detection; it lacks config-driven allow/deny/ask and bash command pattern matching. | +| 1.7 | Configurable max steps per agent | Per-agent `max_steps` controls loop depth and max-step prompt injection. | Partial. `src/config/configuration.rs` parses `agent..steps`/`maxSteps`; app and print paths pass the active mode limit into `stream_llm_with_cancellation`. | Only active Plan/Build-style modes receive the value; markdown agent files and Task subagents do not get per-agent limits. | +| 2.1 | Provider-specific header and behavior instructions | Has provider/model-specific prompt variants including Beast/OpenAI, Anthropic, Gemini, and Codex behavior. | Mostly present. `src/prompt/mod.rs` selects Beast/OpenAI, Anthropic, Gemini, Codex, or generic prompt branches. | Prompt content is a simplified local implementation and may drift from OpenCode's exact wording/variants. | +| 2.2 | Environment context block | Includes workdir, git status, platform, and current date. | Present. `SystemPromptComposer::get_environment_context` emits `` with working directory, git repo flag, platform, and date. | No material harness gap. | +| 2.3 | Tool schemas block | System prompt lists all registered tools as JSON schemas. | Partial. `SystemPromptComposer::with_tool_registry` can include schemas, and `src/agent/manager.rs` uses it. | Main app/print prompt creation in `src/app.rs` and `src/main.rs` does not pass the tool registry, so primary runtime prompts usually omit the JSON tool schema block. | +| 2.4 | Custom instructions discovery | Walks up for project `AGENTS.md`/`CLAUDE.md` and falls back to global instructions. | Mostly present. `src/prompt/rules.rs` walks up from the working directory for `AGENTS.md`, then `CLAUDE.md`; global fallback checks `$XDG_CONFIG_HOME/crabcode/AGENTS.md` and `~/.claude/CLAUDE.md`. | Does not stop at git worktree root, so it can walk above the project; global fallback does not include all OpenCode-style locations if any exist beyond these. | +| 2.5 | Available skills XML block | System prompt lists discovered skills in ``. | Present. `src/prompt/mod.rs` appends `` from `crate::skill::SkillStore`; `src/tools/skill.rs` also lists them in the tool description. | Depends on `init_skill_store` being called before prompt composition; otherwise no block. | +| 2.6 | Available subagents XML block | System prompt lists subagent names/descriptions so the primary agent can choose the Task tool. | Present for built-ins. `src/prompt/mod.rs` appends `` from `SubAgentDef::all()`. | Only `explore` and `general` are listed; config-defined/hidden/scout/vlm/system agents are absent. | +| 3.1 | Task tool | Primary agents can spawn subagents through a built-in `task` tool. | Present. `src/tools/task.rs` registers `task` dynamically in `register_dynamic_tools`. | Only supports hard-coded `explore` and `general`; no task permission matrix. | +| 3.2 | Explore subagent | Fast read-only subagent with glob/grep/read/list tools. | Present. `SubAgentType::Explore` allows `glob`, `grep`, `read`, `list` in `src/agent/subagent.rs`. | No configurable max steps/cancellation; relies on a fresh ToolPermissions instance. | +| 3.3 | General subagent | Full tool access except todowrite for complex multi-step work. | Partial. `SubAgentType::General` allows `bash`, `edit`, `write`, `read`, `grep`, `glob`, `list`, `skill`, `webfetch`. | Missing `question`, `task`, `update_plan`, `view_image`, and any future tools; the allowed list is not derived from agent config. | +| 3.4 | Scout subagent | Read-only external docs/dependency research agent that can clone repos. | Missing. | Add a `Scout` `SubAgentType`, prompt, scoped permissions, clone-capable tool policy, and prompt listing. | +| 3.5 | vlm-agent | Dedicated image-analysis subagent. | Missing. | `view_image` exists as a tool, but no image-analysis subagent or Task route exists. | +| 3.6 | Hidden/system agents | Internal compaction, title, and summary agents run automatically and/or are hidden from autocomplete. | Partial. Manual compaction exists in `src/session/compaction.rs` and `src/app.rs`; no dedicated agent registry entries. | No title/summary hidden agents, no hidden flag, no system-agent invocation through the same agent config model. | +| 3.7 | Child sessions for subagents | Subagent work is stored as child sessions with parent/child navigation. | Present. `TaskTool` emits `SubagentStarted`; `src/app.rs::start_subagent_session` creates child sessions; `SessionManager` tracks `parent_id`/`children_by_parent`. | Child sessions are UI/session-wired but not backed by a general agent tree/config model. | +| 3.8 | Subagent descriptions in system prompt | Primary agent sees names/descriptions and when to use Task. | Present for built-ins. `src/prompt/mod.rs` and `src/tools/task.rs` list `explore` and `general`. | Missing scout/vlm/custom/hidden agent descriptions and task permission hints. | +| 3.9 | @mention subagent invocation | User can invoke subagents from input with `@agent` mentions. | Missing. Search found autocomplete/token handling but no subagent @mention dispatch. | Add parser/autocomplete/runtime route that converts `@explore ...` or configured aliases into Task/subagent execution. | +| 3.10 | Agent mode primary/subagent/all | Agents declare where they can run. | Missing. | Current `agent` is a string mode (`Plan`/`Build`) plus hard-coded subagent enum; no `primary`/`subagent`/`all` mode semantics. | +| 3.11 | Hidden agents | Hidden agents are omitted from @autocomplete but invokable via Task/system. | Missing. | No agent registry with `hidden` metadata. | +| 3.12 | Task permissions | Agents can restrict which subagents they may invoke. | Missing. | `TaskTool` accepts any hard-coded type from any primary context. | +| 4.1 | Tool: bash | Shell command execution with timeout/output streaming. | Present. `src/tools/bash.rs`, registered in `src/tools/init.rs`. | Permission semantics are incomplete compared with OpenCode bash pattern policies. | +| 4.2 | Tool: edit | Exact string replacement in files. | Present. `src/tools/edit.rs` also supports fuzzy matching and replace-all. | No major parity gap for registration. | +| 4.3 | Tool: write | Create/overwrite files. | Present. `src/tools/fs/write.rs`. | No major parity gap for registration. | +| 4.4 | Tool: read | Read files with offset/limit pagination; also directories. | Present. `src/tools/fs/read.rs`. | No major parity gap for registration. | +| 4.5 | Tool: grep | Regex search with include filters. | Present. `src/tools/fs/grep.rs`. | No major parity gap for registration. | +| 4.6 | Tool: glob | Glob file matching. | Present. `src/tools/fs/glob.rs`. | No major parity gap for registration. | +| 4.7 | Tool: list | Deliberate directory listing/tree tool distinct from read. | Partial. `src/tools/fs/list.rs` lists direct entries with pagination. | OpenCode behavior is tree-style; Crabcode list is direct-entry only. | +| 4.8 | Tool: skill | Load `SKILL.md` by name. | Present. `src/tools/skill.rs`. | Skill permission patterns are missing. | +| 4.9 | Tool: task | Spawn subagents. | Present. `src/tools/task.rs`. | Only hard-coded explore/general and no task permission controls. | +| 4.10 | Tool: todowrite | Manage structured task/todo lists. | Missing as `todowrite`. | Crabcode has `update_plan` (`src/tools/update_plan.rs`) and UI accepts legacy todowrite history, but `todowrite` is not registered. | +| 4.11 | Tool: webfetch | Fetch web content and convert HTML to markdown. | Present. `src/tools/webfetch.rs`. | No major registration gap. | +| 4.12 | Tool: websearch | Web search via Exa AI. | Missing. | No `websearch` module/registration. | +| 4.13 | Tool: question | Ask user questions during execution. | Present dynamically. `src/tools/question.rs`, registered by `register_dynamic_tools`. | Not available to subagents unless explicitly added to their scoped registry. | +| 4.14 | Tool: extract-images | Save session images to disk. | Missing. | Image attachment/viewing exists, but no registered `extract-images` tool. | +| 4.15 | Tool: apply_patch | Apply diffs. | Missing. | No registered patch application tool; edits rely on `edit`/`write`. | +| 4.16 | Tool: lsp | Experimental LSP code intelligence. | Missing. | No LSP tool module/registration. | +| 4.17 | Extra Crabcode tool: update_plan | Not listed as an OpenCode built-in in the requested list; Codex-style planning tool. | Present. `src/tools/update_plan.rs`, registered in `src/tools/init.rs`. | If strict 1:1 OpenCode parity is required, decide whether to keep as extra or alias/align with `todowrite`. | +| 4.18 | Extra Crabcode tool: view_image | Local image inspection tool. | Present. `src/tools/fs/view_image.rs`, registered in `src/tools/init.rs`. | OpenCode covers image extraction/VLM separately; no VLM subagent parity. | +| 5.1 | Skill discovery locations | Searches `.opencode/skills//SKILL.md`, `~/.config/opencode/skills//SKILL.md`, `.claude/skills`, `.agents/skills`, `~/.claude/skills`, `~/.agents/skills`. | Mostly present. `src/skill/mod.rs` scans global/project opencode/crabcode skill dirs plus `.claude`/`.agents` global and walk-up project dirs. | Project `.opencode` scan only checks `project_root`, not walk-up directories to the git root; global `~/.config/opencode` is covered only through `xdg_config_home`. | +| 5.2 | Walk-up project skills | Walks up from project root/current tree for project skill dirs. | Partial. `.claude` and `.agents` skills walk upward; `.opencode`/`.crabcode` project skills only scan `project_root`. | Add walk-up for `.opencode/skills` and `.crabcode/skills` if OpenCode-compatible discovery is required from nested workdirs. | +| 5.3 | YAML frontmatter required `name` and `description` | Skill files require both fields. | Partial. `parse_skill_file` requires `name` but `description` is `Option`. | Enforce required `description` or warn/skip invalid skills for exact parity. | +| 5.4 | Pattern-based skill permissions | Skill allow/deny patterns like `"internal-*": "deny"`. | Missing. | No skill permission config or matcher exists in `src/skill/mod.rs`/`src/tools/skill.rs`. | +| 5.5 | Skill tool lists available skills | Tool description includes available skills. | Present. `SkillTool::build_description` emits ``. | No material gap beyond discovery/permissions. | +| 6.1 | Agent config via JSON | `opencode.json` supports per-agent configuration. | Partial. `src/config/configuration.rs` reads `opencode.json(c)`/`crabcode.json(c)` and parses `default_agent`, `agent..tools`, and `agent..steps`. | Most per-agent fields are ignored/unimplemented. | +| 6.2 | Agent config via markdown files | `~/.config/opencode/agents/.md` with frontmatter defines agents. | Missing. `discover_opencode_inventory` records agent files, but nothing parses/applies them. | Implement markdown agent loader and merge with JSON agent config. | +| 6.3 | Per-agent description | Agent description available for prompt/autocomplete/Task. | Missing except hard-coded subagents. | Add to agent config model and prompt listing. | +| 6.4 | Per-agent temperature/model/top_p | Agent can override sampling/model. | Missing. | Current runtime uses active UI/model config; only custom commands can temporarily set model. | +| 6.5 | Per-agent max_steps | Agent can set max steps. | Partial. Supports `steps`/`maxSteps` in JSON for active modes. | Not available from markdown agent files and not applied to Task subagents. | +| 6.6 | Per-agent mode primary/subagent/all | Agent declares invocation context. | Missing. | Current model has no such field. | +| 6.7 | Per-agent hidden/color | Agents can be hidden and have color metadata. | Missing for config. | UI color is derived from agent name/theme, not config; no hidden semantics. | +| 6.8 | Per-agent permissions/task permissions | Agent overrides global permissions and allowed subagents. | Partial for tools only. `agent..tools` can restrict tool availability. | No allow/deny/ask permission map, no bash patterns, no task permission map. | +| 6.9 | Agent creation wizard | `opencode agent create`. | Missing. | No CLI/command implementation for creating agent files. | +| 7.1 | Custom command files | `.opencode/commands/.md` user-defined slash commands. | Present. `src/command/custom.rs` scans `command` and `commands` dirs under `.opencode`, `.crabcode`, and global dirs. | Discovery is project-root based; no current-directory walk-up beyond resolved project root. | +| 7.2 | Command frontmatter | Supports `description`, `agent`, `model`, `subtask`. | Present. `Frontmatter` in `src/command/custom.rs` includes those fields. | `subtask` is returned but ignored by `run_custom_command_prompt` (`_subtask`), so subtask execution parity is missing. | +| 7.3 | Template variables | Supports `$ARGUMENTS`, positional args, etc. | Partial. `apply_arguments` supports `$ARGUMENTS` and `$1`, `$2`, with last positional consuming rest. | Other OpenCode template variables beyond these are not implemented. | +| 7.4 | Shell output injection | Supports `!\`command\`` injection. | Present. `expand_shell_blocks` runs shell blocks with timeout/truncation. | Shell injection does not go through the normal permission system. | +| 7.5 | File references | Supports `@path/to/file` expansion. | Present. `append_file_references` injects file or directory contents. | Reference reads do not go through normal permissions/external path gates. | +| 8.1 | Per-tool permissions allow/deny/ask | Config can set tool policy to allow, deny, or ask. | Missing. | `ToolPermissions` has hard-coded preflight reasons and `dangerously_skip_permissions`; no config parser/model for allow/deny/ask. | +| 8.2 | Wildcard permission patterns | Tool permission patterns like `mymcp_*`. | Missing. | No wildcard matcher for tool IDs. | +| 8.3 | Bash command permission patterns | Command-specific rules like `git push: ask`, `git *: allow`. | Missing. | Bash preflight extracts command text but never matches it against configured command patterns. | +| 8.4 | Per-agent permission overrides | Agents override global permissions. | Partial. `agent..tools` can restrict which tools are exposed. | Does not model allow/deny/ask or pattern-specific overrides. | +| 8.5 | External directory gating | Access outside workdir prompts/gates. | Present. `is_outside_workdir` and `evaluate_reason` gate read/write/edit/list/glob/grep/bash workdir paths. | Gating is prompt-only and does not integrate with configurable policy modes. | +| 8.6 | Doom loop recovery prompts | Repeated identical tool calls trigger recovery/permission prompt. | Present. `evaluate_doom_loop` prompts after `DOOM_LOOP_THRESHOLD`. | Prompt is generic; no richer OpenCode recovery instruction injection. | + +## Priority-ranked actionable gaps ### CRITICAL -1. Runtime system prompt omits the tool schemas block. - - Files: `src/prompt/mod.rs`, `src/app.rs`, `src/main.rs`, `src/tools/init.rs`. - - `SystemPromptComposer` can render tool schemas, but the app and print paths do not pass a registry with `.with_tool_registry(...)`. Build the static and dynamic tool registries before composing the system prompt, then include `question` and `task` as dynamic schemas. +1. **Make Plan mode truly read-only.** + Files: `src/tools/permission.rs`, tests in the same file. + Implementation notes: update `AgentToolPolicies::is_allowed("plan", ...)` to deny `bash` by default in addition to `write` and `edit` unless a custom agent policy explicitly permits it. Reconcile the comment and `plan_mode_blocks_mutating_tools` test, which currently asserts bash is allowed. -2. OpenCode-compatible custom commands are absent. - - Files: `src/command/parser.rs`, `src/command/registry.rs`, `src/command/handlers.rs`. - - Add discovery for `.opencode/commands/.md`, frontmatter parsing for `description`, `agent`, `model`, and `subtask`, template expansion for `$ARGUMENTS` and positional args, command-substitution injection with permission checks, file-reference expansion, and Task routing for subtask commands. +2. **Load tool schemas into the real primary system prompt.** + Files: `src/app.rs`, `src/main.rs`, `src/prompt/mod.rs`, `src/tools/init.rs`. + Implementation notes: when composing the system prompt in app and print mode, initialize/register the same tool registry (including dynamic tools where possible) and call `.with_tool_registry(...)`. Avoid creating a mismatched registry that advertises tools unavailable at runtime. -3. Permission system is not OpenCode-compatible. - - Files: `src/tools/permission.rs`, `src/config/configuration.rs`, `crabcode.schema.json`, `_docs/config/index.mdx`. - - Add config-driven `allow`, `deny`, and `ask` rules; wildcard tool matching; ordered bash command patterns; per-agent override merging; task permissions; skill permissions; and durable approvals where appropriate. +3. **Implement OpenCode-style permission config.** + Files: `src/config/configuration.rs`, `src/tools/permission.rs`, `src/tools/aisdk_bridge.rs`, `_docs/config.mdx`. + Implementation notes: parse `permission` into per-tool allow/deny/ask rules; support wildcard tool patterns and bash command patterns; merge global and per-agent overrides; preserve current external-path/sensitive/doom-loop checks as default `ask` reasons. -4. First-class agent registry/config is missing. - - Files: `src/agent/config.rs`, `src/agent/subagent.rs`, `src/config/configuration.rs`. - - Introduce an agent definition model covering description, model, temperature, top_p, steps/max_steps aliases, mode, hidden, color, permissions, and task permissions. Parse both JSON config and markdown frontmatter agent files. +4. **Propagate cancellation and step limits into subagents/tools.** + Files: `src/tools/aisdk_bridge.rs`, `src/tools/context.rs`, `src/tools/task.rs`, `src/agent/subagent.rs`, `src/llm/client.rs`. + Implementation notes: pass the primary `CancellationToken` into tool contexts and child subagents; stop creating inert per-tool abort channels; apply configured `max_steps` and text-only fallback to `run_subagent` or route subagents through a shared agent runner. ### HIGH -1. Missing subagent set beyond `explore` and `general`. - - Files: `src/agent/subagent.rs`, `src/tools/task.rs`, `src/tools/init.rs`. - - Add `scout`, `vlm-agent`, and hidden `compaction`, `title`, and `summary` agents. Scout also needs repo clone/overview tools and external research permissions; VLM needs image input/model routing. +5. **Replace hard-coded subagents with a real agent registry.** + Files: `src/agent/config.rs`, `src/agent/subagent.rs`, `src/prompt/mod.rs`, `src/tools/task.rs`, `src/config/configuration.rs`. + Implementation notes: define an `AgentDefinition` model with name, description, mode (`primary`/`subagent`/`all`), hidden, model, temperature, top_p, max_steps, tools, permissions, and task permissions. Use it for prompt listing, Task validation, and runtime tool scoping. -2. Task tool lacks background/status/command parity. - - Files: `src/tools/task.rs`, `src/session/manager.rs`, `src/app.rs`. - - Add `task_id`, `background`, `command`, and task-status support. Enforce task permissions before child session creation and expose background task lifecycle events. +6. **Add missing OpenCode subagents: `scout` and `vlm-agent`.** + Files: `src/agent/subagent.rs`, `src/tools/task.rs`, `src/prompt/mod.rs`. + Implementation notes: add prompts, descriptions, tool scopes, and Task validation. `scout` should remain read-only but allow external research/clone operations; `vlm-agent` should accept image context and use image-capable tooling/model paths. -3. Missing or partial built-in tools. - - Files: `src/tools/init.rs`, `src/tools/fs/list.rs`, new tool modules. - - Add `websearch`, `extract-images`, `apply_patch`, and `lsp`. Update `list` to produce opencode-style tree output rather than only direct directory entries. +7. **Parse markdown agent files.** + Files: `src/config/configuration.rs`, new module under `src/agent/`. + Implementation notes: the config loader already discovers `~/.config/opencode/agents/*.md` and `.opencode/agents/*.md`; add frontmatter parsing and merge the body as agent instructions/system prompt content. -4. Instruction discovery is incomplete. - - Files: `src/prompt/rules.rs`, `src/config/configuration.rs`, `src/main.rs`. - - Stop walk-up at the git worktree boundary, include opencode global instruction paths, support config-provided instruction entries, and attach `SkillStore` in print mode so available skills appear consistently. +8. **Implement task permissions and hidden-agent semantics.** + Files: `src/tools/task.rs`, `src/agent/subagent.rs`, future agent registry. + Implementation notes: validate whether the calling agent may invoke the target subagent; list non-hidden agents in prompts/autocomplete while allowing hidden agents for system/Task use if permitted. + +9. **Add missing built-in tools required for 1:1 parity.** + Files: `src/tools/init.rs`, new modules under `src/tools/`. + Implementation notes: implement/register `websearch`, `extract-images`, `apply_patch`, and `lsp`; add a `todowrite` compatibility tool or alias to `update_plan` if OpenCode-compatible prompts expect that name. ### MEDIUM -1. Skill loader needs compatibility hardening. - - Files: `src/skill/mod.rs`, `src/tools/skill.rs`, `src/config/configuration.rs`. - - Add config `skills.paths` and URL skills, enforce required descriptions, apply permission-pattern filtering, and bound walk-up discovery by the git worktree. +10. **Make `list` tree-style.** + Files: `src/tools/fs/list.rs`. + Implementation notes: keep pagination but output a deliberate directory-tree listing rather than only direct entries. Match OpenCode semantics while preserving direct listing if needed behind a depth option. + +11. **Complete skill discovery and validation parity.** + Files: `src/skill/mod.rs`, `src/tools/skill.rs`, config parser. + Implementation notes: walk up for `.opencode/skills`/`.crabcode/skills`, enforce required `description`, and add pattern-based skill allow/deny rules such as `internal-* = deny`. + +12. **Stop prompt rule walk-up at the project/git boundary.** + Files: `src/prompt/rules.rs`, `src/config/configuration.rs`. + Implementation notes: pass the discovered project root into rule resolution or detect the git worktree root; avoid reading parent-directory `AGENTS.md` outside the intended workspace. -2. `@mention` subagent invocation is missing. - - Files: `src/command/parser.rs`, `src/autocomplete`, `src/app.rs`. - - Add parser/autocomplete support for subagent mentions and route mentioned subagents into the Task flow with the rest of the message as the prompt. +13. **Honor custom command `subtask`.** + Files: `src/command/custom.rs`, `src/command/registry.rs`, `src/app.rs`, `src/tools/task.rs`. + Implementation notes: if `subtask: true`, execute the rendered prompt through Task/subagent flow instead of directly appending it as a primary chat message; apply command `agent` as the target agent. -3. Tool-call cancellation should propagate into tools. - - Files: `src/llm/client.rs`, `src/tools/aisdk_bridge.rs`, tool implementations that can block. - - Wire the top-level cancellation token into the per-tool abort channel so bash, webfetch, and subagent execution stop promptly on user interruption. +14. **Route command shell/file expansions through permissions.** + Files: `src/command/custom.rs`, `src/tools/permission.rs`, command execution path in `src/app.rs`. + Implementation notes: `!\`...\``and`@file`currently bypass tool preflight. Reuse`ToolPermissions` before shell execution or external/sensitive file reads. -4. Max-step compatibility should accept OpenCode aliases. - - Files: `src/config/configuration.rs`, `crabcode.schema.json`, `_docs/config/index.mdx`. - - Accept `max_steps` and deprecated `maxSteps` as aliases for `steps`, with a warning only if necessary. +15. **Support more OpenCode command template variables.** + Files: `src/command/custom.rs`. + Implementation notes: inventory OpenCode's full variable set and extend `apply_arguments`/render context beyond `$ARGUMENTS` and `$N`. ### LOW -1. Agent creation wizard is missing. - - Files: `src/command/handlers.rs`, `src/main.rs`. - - Add `crabcode agent create` or a slash-command equivalent after the agent definition format is implemented. +16. **Add @mention subagent invocation.** + Files: `src/ui/components/input.rs`, autocomplete modules, `src/app.rs`, future agent registry. + Implementation notes: detect `@` at message start or in a supported invocation form, validate against visible subagent/all agents, and dispatch through Task/child session flow. -2. Provider prompt set is simplified. - - Files: `src/prompt/mod.rs`. - - Add remaining opencode provider/model prompt variants only after the core config, command, permission, and tool gaps are closed. +17. **Add agent creation wizard/command.** + Files: `src/command/handlers.rs`, `src/command/registry.rs`, future agent config writer. + Implementation notes: implement a command/CLI flow equivalent to `opencode agent create` that writes markdown frontmatter files under the appropriate agents directory. -3. Per-agent visual metadata can wait. - - Files: `src/agent/config.rs`, UI consumers later. - - `color` is part of opencode agent config, but it does not affect harness execution and should be implemented after execution semantics are compatible. +18. **Unify hidden compaction/title/summary agents with agent registry.** + Files: `src/session/compaction.rs`, `src/app.rs`, future `src/agent/` registry. + Implementation notes: current compaction is a bespoke summarization path. Model compaction/title/summary as hidden system agents so they share provider config, permissions, and invocation semantics. diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index b3a9f02..f24af85 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -189,3 +189,7 @@ I want - [x] To do this But I dont want to do this - [x] When clicking, it opens message actions.. Special case for UX: don't change the scroll value when it comes from "clicking a message".. But the other /timeline and ctrl+x g paths should be just fine. - [x] Zed alert circle thing when asking permission or question, please emit it. Currently it's only on completions by default I think. + +- [ ] Let's refactor highlights so that "highlighting" doesn't copy immediately. But rather, show a little dropdown like this so that I have control if I wanna copy or not. I want this because there are some parts that are kinda bothersome especially for users with clipboard history, it just quickly bloats it. + +- [ ] Minor bug.. Whatever I typed... and then pressed up multiple times, meaning I got to the "previously submitted chats i made", I go back down with down, and I got just an empty chat lol. So it looks like the chat I sent is gone. From a544af424a0955857650f041626f6672393e2f72 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 00:53:47 +0800 Subject: [PATCH 168/226] docs: add WebSocket reset bug investigation to premature complete notes. Document transport failure observed during highlight refactor where a WebSocket connection reset interrupted a provider step before response.completed, causing mid-task stoppage. Includes log evidence, root cause analysis, and follow-up items for retry/resume handling. --- src/app.rs | 485 +++++++++++++++++++++++++++++++++----- src/ui/components/chat.rs | 48 +++- src/ui/wrapping.rs | 24 +- 3 files changed, 496 insertions(+), 61 deletions(-) diff --git a/src/app.rs b/src/app.rs index d32233d..634d3d4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,12 @@ use ratatui::crossterm::event::{ self, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, +}; use crate::autocomplete::AutoComplete; use crate::command::handlers::register_all_commands; @@ -179,6 +185,24 @@ struct ToolCallViewState { deferred_finish: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SelectionActionTarget { + Chat, + Input, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SelectionActionBarState { + target: SelectionActionTarget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SelectionAction { + Annotate, + Copy, + Dismiss, +} + #[derive(Debug)] struct ClientSessionState { chat: Chat, @@ -239,6 +263,7 @@ pub struct App { pub message_actions_index: Option, pub message_actions_dialog: Option, message_actions_return_focus: OverlayFocus, + selection_action_bar: Option, pending_chat_message_click: Option, pub api_key_input: crate::ui::components::api_key_input::ApiKeyInput, openai_oauth_receiver: Option>, @@ -470,6 +495,7 @@ impl App { message_actions_index: None, message_actions_dialog: None, message_actions_return_focus: OverlayFocus::TimelineDialog, + selection_action_bar: None, pending_chat_message_click: None, api_key_input, openai_oauth_receiver: None, @@ -1244,6 +1270,20 @@ impl App { .unwrap_or_else(|| self.cwd.clone()) } + fn current_selection_action_bar_area(&self) -> Option { + self.selection_action_bar.map(|state| match state.target { + SelectionActionTarget::Chat => chat_selection_action_bar_area( + self.current_chat_area(), + self.chat_state.chat.scroll_offset, + &self.chat_state.chat.selection, + ), + SelectionActionTarget::Input => input_selection_action_bar_area( + self.last_frame_size, + self.suggestions_popup_anchor_area(), + ), + }) + } + fn current_git_branch(&mut self, cwd: &str) -> Option { const GIT_BRANCH_REFRESH: std::time::Duration = std::time::Duration::from_secs(2); @@ -1269,37 +1309,25 @@ impl App { } fn try_copy_selection(&mut self) -> bool { - // Check chat selection if self.chat_state.chat.has_selection() { - let colors = self.get_current_theme_colors(); - let model = self.model.clone(); - let chat_area = self.current_chat_area(); - let max_width = chat_area.width.saturating_sub(2) as usize; - if let Some(text) = self - .chat_state - .chat - .get_selected_text(max_width, &model, &colors) - { - let _ = crate::utils::clipboard::copy_text(&text); - push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); - } + let _ = self.copy_chat_selection(); self.chat_state.chat.selection.clear(); + self.selection_action_bar = None; return true; } - // Check input selection + if self.input.has_selection() { - let text = self.input.get_selected_text(); - if !text.is_empty() { - let _ = crate::utils::clipboard::copy_text(&text); - push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); - } + let _ = self.copy_input_selection(); self.input.clear_selection(); + self.selection_action_bar = None; return true; } + false } fn clear_selection(&mut self) -> bool { + self.selection_action_bar = None; if self.chat_state.chat.has_selection() { self.chat_state.chat.selection.clear(); return true; @@ -1311,33 +1339,122 @@ impl App { false } - fn copy_chat_selection(&mut self) { + fn copy_input_selection(&mut self) -> bool { + if !self.input.has_selection() { + return false; + } + + let text = self.input.get_selected_text(); + if text.is_empty() { + return false; + } + + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + true + } + + fn selected_chat_text(&self) -> Option { if !self.chat_state.chat.has_selection() { - return; + return None; } - // Don't copy zero-width selections (e.g., single click without drag) + let ((s_line, s_col), (e_line, e_col)) = self.chat_state.chat.selection.range(); if s_line == e_line && s_col == e_col { - return; + return None; } + let colors = self.get_current_theme_colors(); let model = self.model.clone(); let chat_area = self.current_chat_area(); let max_width = chat_area.width.saturating_sub(2) as usize; - if let Some(text) = - self.chat_state - .chat - .get_selected_text(max_width.max(1), &model, &colors) - { - if !text.trim().is_empty() { - let _ = crate::utils::clipboard::copy_text(&text); - push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + self.chat_state + .chat + .get_selected_text(max_width.max(1), &model, &colors) + .filter(|text| !text.trim().is_empty()) + } + + fn selected_text_for_action(&self, target: SelectionActionTarget) -> Option { + match target { + SelectionActionTarget::Chat => self.selected_chat_text(), + SelectionActionTarget::Input => self + .input + .has_selection() + .then(|| self.input.get_selected_text()) + .filter(|text| !text.is_empty()), + } + } + + fn show_selection_action_bar_for(&mut self, target: SelectionActionTarget) { + self.selection_action_bar = self + .selected_text_for_action(target) + .map(|_| SelectionActionBarState { target }); + } + + fn dismiss_selection_actions(&mut self) -> bool { + let had_selection = self.clear_selection(); + self.pending_chat_message_click = None; + had_selection + } + + fn annotate_selection(&mut self, target: SelectionActionTarget) -> bool { + if target != SelectionActionTarget::Chat { + return false; + } + + let Some(text) = self.selected_text_for_action(target) else { + return self.dismiss_selection_actions(); + }; + + if !self.input.is_empty() { + self.input.insert_str("\n"); + } + self.input.insert_str(&format_selection_annotation(&text)); + self.dismiss_selection_actions(); + push_toast(Toast::new( + "Annotated selection in prompt", + ToastLevel::Info, + None, + )); + true + } + + fn handle_selection_action_key(&mut self, key: KeyEvent) -> bool { + let Some(state) = self.selection_action_bar else { + return false; + }; + + match key.code { + KeyCode::Char('y') if key.modifiers == event::KeyModifiers::NONE => { + let _ = self.try_copy_selection(); + true + } + KeyCode::Char('i') + if key.modifiers == event::KeyModifiers::NONE + && state.target == SelectionActionTarget::Chat => + { + self.annotate_selection(state.target) } + KeyCode::Esc if key.modifiers == event::KeyModifiers::NONE => { + self.dismiss_selection_actions(); + self.reset_esc_timeline_state(); + true + } + _ => false, } } - fn current_chat_area(&self) -> ratatui::layout::Rect { - let size = self.last_frame_size; + fn copy_chat_selection(&mut self) -> bool { + let Some(text) = self.selected_chat_text() else { + return false; + }; + + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + true + } + + fn chat_area_for_size(&self, size: Rect) -> Rect { let main_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints( @@ -1383,6 +1500,10 @@ impl App { above_status_chunks[1] } + fn current_chat_area(&self) -> Rect { + self.chat_area_for_size(self.last_frame_size) + } + pub fn handle_keys(&mut self, key: KeyEvent) { if key.code != KeyCode::Esc { self.reset_esc_timeline_state(); @@ -1399,6 +1520,10 @@ impl App { return; } + if self.handle_selection_action_key(key) { + return; + } + match key.code { KeyCode::Char('v') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { if self.is_subagent_session_active() @@ -1413,7 +1538,6 @@ impl App { return; } KeyCode::Char('c') if key.modifiers == event::KeyModifiers::CONTROL => { - // If text is selected (chat or input), copy to clipboard first if self.try_copy_selection() { return; } @@ -2166,9 +2290,11 @@ impl App { } fn handle_input_and_app_keys(&mut self, key: KeyEvent) { - // If chat text is selected and user presses a key, clear the selection - // (unless it's Ctrl+C or Escape which are handled earlier) - self.chat_state.chat.selection.clear(); + if self.selection_action_bar.is_some() { + self.dismiss_selection_actions(); + } else { + self.chat_state.chat.selection.clear(); + } if self.is_subagent_session_active() { clear_suggestions(&mut self.suggestions_popup_state); @@ -2275,11 +2401,45 @@ impl App { input_chunks[2] } + fn handle_selection_action_mouse(&mut self, mouse: MouseEvent) -> bool { + let Some(state) = self.selection_action_bar else { + return false; + }; + let Some(area) = self.current_selection_action_bar_area() else { + return false; + }; + + let point = ratatui::layout::Position::new(mouse.column, mouse.row); + if !area.contains(point) { + return false; + } + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => true, + MouseEventKind::Up(MouseButton::Left) => { + let rel = mouse.column.saturating_sub(area.x) as usize; + match selection_action_for_column(state.target, rel) { + SelectionAction::Annotate => self.annotate_selection(state.target), + SelectionAction::Copy => { + let _ = self.try_copy_selection(); + true + } + SelectionAction::Dismiss => self.dismiss_selection_actions(), + } + } + _ => true, + } + } + fn handle_input_mouse_event(&mut self, mouse: MouseEvent) -> bool { if self.is_subagent_session_active() { return false; } + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + self.selection_action_bar = None; + } + if matches!(mouse.kind, MouseEventKind::Moved) && !self.input.contains_mouse(mouse) { self.input.clear_hover(); } @@ -2294,10 +2454,10 @@ impl App { ratatui::crossterm::event::MouseButton::Left ) ) { - let text = self.input.get_selected_text(); - if !text.is_empty() { - let _ = crate::utils::clipboard::copy_text(&text); - push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + if self.input.has_selection() && !self.input.get_selected_text().is_empty() { + self.show_selection_action_bar_for(SelectionActionTarget::Input); + } else { + self.selection_action_bar = None; } } self.update_suggestions(); @@ -2368,6 +2528,17 @@ impl App { self.input.clear_hover(); } + if self.handle_selection_action_mouse(mouse) { + return; + } + + if self.selection_action_bar.is_some() + && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + { + self.dismiss_selection_actions(); + return; + } + if matches!(mouse.kind, MouseEventKind::Moved) && self.base_focus != BaseFocus::Chat { self.chat_state.chat.clear_hovered_image(); self.chat_state.chat.clear_hovered_hyperlink(); @@ -2375,15 +2546,14 @@ impl App { // If text is selected and user clicks on an overlay, clear selection instead if self.overlay_focus != OverlayFocus::None - && self.chat_state.chat.has_selection() + && (self.chat_state.chat.has_selection() || self.input.has_selection()) + && self.selection_action_bar.is_none() && matches!( mouse.kind, ratatui::crossterm::event::MouseEventKind::Down(_) ) { - self.copy_chat_selection(); - self.chat_state.chat.selection.clear(); - self.pending_chat_message_click = None; + self.dismiss_selection_actions(); return; } @@ -2734,10 +2904,7 @@ impl App { let point = ratatui::layout::Position::new(mouse.column, mouse.row); if !chat_area.contains(point) { - // Click outside chat area, copy selection before clearing - self.copy_chat_selection(); - self.chat_state.chat.selection.clear(); - self.pending_chat_message_click = None; + self.dismiss_selection_actions(); } } @@ -2827,6 +2994,10 @@ impl App { }; if self.chat_state.chat.handle_mouse_event(mouse, chat_area) { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + self.selection_action_bar = None; + } + if let Some(idx) = released_pending_message { if !self.chat_state.chat.has_selection() { self.pending_chat_message_click = None; @@ -2840,12 +3011,11 @@ impl App { self.pending_chat_message_click = None; } - // Auto-copy when selection is finalized (mouse up after drag) + // Show copy/annotate actions when selection is finalized (mouse up after drag) if !had_selection && self.chat_state.chat.has_selection() { - // New selection just started, don't copy yet + // New selection just started, don't show actions yet } else if was_dragging && !self.chat_state.chat.selection.is_dragging { - // Selection was just finalized (mouse up) - self.copy_chat_selection(); + self.show_selection_action_bar_for(SelectionActionTarget::Chat); } return; } @@ -6120,10 +6290,167 @@ impl App { crate::views::which_key::render_which_key(f, &self.which_key_state, &colors); } + if let Some(state) = self.selection_action_bar { + let area = match state.target { + SelectionActionTarget::Chat => chat_selection_action_bar_area( + self.chat_area_for_size(size), + self.chat_state.chat.scroll_offset, + &self.chat_state.chat.selection, + ), + SelectionActionTarget::Input => { + input_selection_action_bar_area(size, self.suggestions_popup_anchor_area()) + } + }; + render_selection_action_bar(f, area, state.target, &colors); + } + toast::render_toasts(f, &get_toast_manager().lock().unwrap(), &colors); } } +fn format_selection_annotation(text: &str) -> String { + let text = text.trim(); + if text.lines().count() <= 1 { + format!("`{}`", text) + } else { + format!("```\n{}\n```", text) + } +} + +const SELECTION_ACTION_BAR_WIDTH: u16 = 24; +const CHAT_SELECTION_ACTION_COPY_COL: usize = 12; +const CHAT_SELECTION_ACTION_ESC_COL: usize = 19; +const INPUT_SELECTION_ACTION_ESC_COL: usize = 8; + +fn selection_action_for_column(target: SelectionActionTarget, column: usize) -> SelectionAction { + match target { + SelectionActionTarget::Chat if column < CHAT_SELECTION_ACTION_COPY_COL => { + SelectionAction::Annotate + } + SelectionActionTarget::Chat if column < CHAT_SELECTION_ACTION_ESC_COL => { + SelectionAction::Copy + } + SelectionActionTarget::Chat => SelectionAction::Dismiss, + SelectionActionTarget::Input if column < INPUT_SELECTION_ACTION_ESC_COL => { + SelectionAction::Copy + } + SelectionActionTarget::Input => SelectionAction::Dismiss, + } +} + +fn chat_selection_action_bar_area( + chat_area: Rect, + scroll_offset: usize, + selection: &crate::ui::selection::Selection, +) -> Rect { + let content_area = Rect { + x: chat_area.x, + y: chat_area.y, + width: chat_area.width.saturating_sub(2), + height: chat_area.height, + }; + let ((start_line, start_col), (end_line, _)) = selection.range(); + selection_action_bar_area_for_anchor( + content_area, + scroll_offset, + start_line, + end_line, + start_col, + SELECTION_ACTION_BAR_WIDTH, + ) +} + +fn input_selection_action_bar_area(frame_area: Rect, input_area: Rect) -> Rect { + let y = input_area.y.saturating_sub(1); + let x = input_area.x.saturating_add(1); + clamp_action_bar_area( + frame_area, + Rect::new(x, y, SELECTION_ACTION_BAR_WIDTH.min(frame_area.width), 1), + ) +} + +fn selection_action_bar_area_for_anchor( + area: Rect, + scroll_offset: usize, + start_line: usize, + end_line: usize, + start_col: usize, + width: u16, +) -> Rect { + let visible_start_line = start_line.saturating_sub(scroll_offset); + let visible_end_line = end_line.saturating_sub(scroll_offset); + let y = if visible_start_line > 0 { + area.y.saturating_add(visible_start_line as u16 - 1) + } else { + area.y.saturating_add( + (visible_end_line + 1).min(area.height.saturating_sub(1) as usize) as u16, + ) + }; + let x = area.x.saturating_add(start_col as u16).min( + area.x + .saturating_add(area.width.saturating_sub(width.max(1))), + ); + + clamp_action_bar_area(area, Rect::new(x, y, width.min(area.width), 1)) +} + +fn clamp_action_bar_area(container: Rect, mut area: Rect) -> Rect { + area.width = area.width.min(container.width); + if area.width == 0 || container.width == 0 || container.height == 0 { + return Rect::new(container.x, container.y, 0, 0); + } + + let max_x = container + .x + .saturating_add(container.width.saturating_sub(area.width)); + area.x = area.x.clamp(container.x, max_x); + let max_y = container + .y + .saturating_add(container.height.saturating_sub(1)); + area.y = area.y.clamp(container.y, max_y); + area.height = 1; + area +} + +fn render_selection_action_bar( + f: &mut ratatui::Frame, + area: Rect, + target: SelectionActionTarget, + colors: &theme::ThemeColors, +) { + if area.width == 0 || area.height == 0 { + return; + } + + f.render_widget(Clear, area); + let bg = colors.dialog_background; + let key_style = Style::default() + .fg(colors.text_strong) + .bg(bg) + .add_modifier(Modifier::BOLD); + let label_style = Style::default().fg(colors.text_weak).bg(bg); + let line = if target == SelectionActionTarget::Chat { + Line::from(vec![ + Span::raw(" "), + Span::styled("i", key_style), + Span::styled(" annotate ", label_style), + Span::styled("y", key_style), + Span::styled(" copy ", label_style), + Span::styled("esc", key_style), + Span::raw(" "), + ]) + } else { + Line::from(vec![ + Span::raw(" "), + Span::styled("y", key_style), + Span::styled(" copy ", label_style), + Span::styled("esc", key_style), + Span::raw(" "), + ]) + }; + f.render_widget(Paragraph::new(line).style(Style::default().bg(bg)), area); +} + fn message_block_clipboard_text( messages: &[crate::session::types::Message], range: std::ops::Range, @@ -6251,6 +6578,7 @@ mod tests { message_actions_index: None, message_actions_dialog: None, message_actions_return_focus: OverlayFocus::TimelineDialog, + selection_action_bar: None, pending_chat_message_click: None, api_key_input: crate::ui::components::api_key_input::ApiKeyInput::new(), openai_oauth_receiver: None, @@ -6409,6 +6737,57 @@ mod tests { } } + #[test] + fn selection_action_bar_column_mapping_matches_rendered_labels() { + assert_eq!( + selection_action_for_column(SelectionActionTarget::Chat, 1), + SelectionAction::Annotate + ); + assert_eq!( + selection_action_for_column(SelectionActionTarget::Chat, 12), + SelectionAction::Copy + ); + assert_eq!( + selection_action_for_column(SelectionActionTarget::Chat, 19), + SelectionAction::Dismiss + ); + assert_eq!( + selection_action_for_column(SelectionActionTarget::Input, 1), + SelectionAction::Copy + ); + assert_eq!( + selection_action_for_column(SelectionActionTarget::Input, 8), + SelectionAction::Dismiss + ); + } + + #[test] + fn chat_selection_action_i_annotates_and_dismisses_selection() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + app.base_focus = BaseFocus::Chat; + app.chat_state + .chat + .add_message(crate::session::types::Message::assistant("alpha beta")); + app.chat_state.chat.selection.active = true; + app.chat_state.chat.selection.start_line = 0; + app.chat_state.chat.selection.start_col = 0; + app.chat_state.chat.selection.end_line = 0; + app.chat_state.chat.selection.end_col = "alpha".len(); + + app.show_selection_action_bar_for(SelectionActionTarget::Chat); + assert_eq!( + app.selection_action_bar.map(|state| state.target), + Some(SelectionActionTarget::Chat) + ); + + app.handle_keys(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert_eq!(app.input.get_text(), "`alpha`"); + assert!(app.selection_action_bar.is_none()); + assert!(!app.chat_state.chat.has_selection()); + } + #[test] fn clicking_chat_message_opens_message_actions() { let mut app = test_app(); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index cc7b3ab..573bdcf 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -4,7 +4,7 @@ use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; use crate::ui::scrollbar::{ render_scrollbar, scrollbar_grab_offset, scrollbar_offset_from_row_with_grab, ScrollMetrics, }; -use crate::ui::selection::Selection; +use crate::ui::selection::{non_selectable_style, Selection}; use crate::ui::wrapping::{wrap_styled_line, WrapOptions}; use crate::utils::token_counter::StreamingTokenCounter; use ratatui::{ @@ -2273,8 +2273,8 @@ impl Chat { let border_color = crate::theme::agent_mode_color(message.agent_mode.as_deref(), colors); let bg = colors.background_element; - let border_style = Style::default().fg(border_color); - let pad_style = Style::default().bg(bg); + let border_style = non_selectable_style(Style::default().fg(border_color)); + let pad_style = non_selectable_style(Style::default().bg(bg)); let text_style = Style::default().fg(colors.text).bg(bg); let image_style = |placeholder: &str| { let is_hovered = self.hovered_image.as_ref().is_some_and(|target| { @@ -4490,6 +4490,48 @@ codex exec --skip-git-repo-check \ ); } + #[test] + fn selected_user_message_text_excludes_panel_gutter_and_padding() { + let colors = test_colors(); + let mut chat = + Chat::with_messages(vec![Message::user("control if\njust quickly bloats it.")]); + let rendered_width = 40; + let (lines, positions) = + chat.build_all_lines_with_positions(rendered_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = 20; + chat.scroll_offset = 0; + + let first_line = chat + .cached_lines + .iter() + .position(|line| line_text(line).contains("control if")) + .expect("first user text line"); + let second_line = chat + .cached_lines + .iter() + .position(|line| line_text(line).contains("just quickly bloats it.")) + .expect("second user text line"); + let second_line_width = + UnicodeWidthStr::width(line_text(&chat.cached_lines[second_line]).as_str()); + + chat.selection.active = true; + chat.selection.start_line = first_line; + chat.selection.start_col = 0; + chat.selection.end_line = second_line; + chat.selection.end_col = second_line_width; + + let selected = chat + .get_selected_text(rendered_width, "model", &colors) + .expect("selected text"); + + assert_eq!(selected, "control if\njust quickly bloats it."); + assert!(!selected.contains('▌')); + } + #[test] fn test_compaction_marker_renders_at_compaction_point() { let summary = Message::user(format!( diff --git a/src/ui/wrapping.rs b/src/ui/wrapping.rs index 31b5ccd..30f2e5f 100644 --- a/src/ui/wrapping.rs +++ b/src/ui/wrapping.rs @@ -1,5 +1,6 @@ use std::ops::Range; +use crate::ui::selection::NON_SELECTABLE_SPAN_MODIFIER; use ratatui::{ style::Style, text::{Line, Span}, @@ -247,7 +248,7 @@ fn line_from_range( let content = original.spans[idx].content.as_ref(); spans.push(Span::styled( content[local_start..local_end].to_string(), - original.style.patch(*span_style), + merge_non_selectable_marker(original.style, *span_style), )); } @@ -262,14 +263,27 @@ fn clone_spans(spans: &[Span<'_>], base_style: Style) -> Vec> { spans .iter() .map(|span| { - Span::styled( - span.content.as_ref().to_string(), - base_style.patch(span.style), - ) + let style = merge_non_selectable_marker(base_style, span.style); + Span::styled(span.content.as_ref().to_string(), style) }) .collect() } +fn merge_non_selectable_marker(base_style: Style, span_style: Style) -> Style { + let mut style = base_style.patch(span_style); + if base_style + .add_modifier + .contains(NON_SELECTABLE_SPAN_MODIFIER) + || span_style + .add_modifier + .contains(NON_SELECTABLE_SPAN_MODIFIER) + { + style.add_modifier.insert(NON_SELECTABLE_SPAN_MODIFIER); + style.sub_modifier.remove(NON_SELECTABLE_SPAN_MODIFIER); + } + style +} + #[cfg(test)] mod tests { use super::*; From 4b38b1e885820616ef77197ee735946e5884de6f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 01:01:20 +0800 Subject: [PATCH 169/226] fix(openai): add bounded retry for websocket stream disconnects. Adds one retry attempt for OpenAI Responses websocket read failures that occur before response.completed, reconnecting and resending the same request only if no text, reasoning, or tool-call chunks have been emitted. Emits retry metadata as openai_transport=responses_websocket_retry for log diagnosis and includes tests for the WebsocketStreamProgress tracker. --- _plans/PREMATURE_COMPLETE_BUG.md | 35 ++++++ aisdk/src/providers/openai.rs | 185 ++++++++++++++++++++++++------- 2 files changed, 177 insertions(+), 43 deletions(-) diff --git a/_plans/PREMATURE_COMPLETE_BUG.md b/_plans/PREMATURE_COMPLETE_BUG.md index 499c003..b1b4e7d 100644 --- a/_plans/PREMATURE_COMPLETE_BUG.md +++ b/_plans/PREMATURE_COMPLETE_BUG.md @@ -441,3 +441,38 @@ If the async stream task exited without delivering a terminal `End`, `Failed`, o - `cargo test stream_finish_waits_for_running_tool_result` - `cargo fmt --check` - `cargo check` + +## 2026-05-28 WebSocket Reset During Highlight Refactor + +### User-Visible Symptom + +While refactoring text selection so highlighting shows explicit actions instead of copying immediately, crabcode stopped mid-task after several successful edits. + +### `app.log` Evidence + +Primary session id: `ocesi62w1f7b7pr7g5n9j7o2`. + +Relevant sequence: + +- `00:39:37`: an edit to `src/ui/selection.rs` completed successfully. +- `00:39:37`: provider step 139 started with `previous_response_id=true`. +- `00:39:41`: the stream failed with `WebSocket protocol error: Connection reset without closing handshake`. +- The stream summary had `response_completed=0`, `relay_result=Error`, `stop_reason=Some(Error(...))`, and `current_phase=commentary`. + +### Root Cause + +This was not premature final-answer completion. It was a transport failure before a terminal `response.completed` event. + +The disconnected-receiver handling correctly treats this as a failed stream, but crabcode still does not have a retry/resume path for a partially streamed provider step. The interrupted feature work had to be resumed manually from the dirty tree and `app.log` context. + +### Fix Applied + +- `aisdk/src/providers/openai.rs` + - Added one bounded retry for Responses websocket read failures before `response.completed`. + - Retries reconnect on a fresh websocket and resends the same request only if the failed attempt has not emitted text, reasoning, or tool-call chunks. + - Keeps text/tool retries conservative to avoid duplicated visible output or duplicate tool execution. + - Emits retry metadata as `openai_transport=responses_websocket_retry ...` for future log diagnosis. + +### Follow-up + +- This still does not retry after partial text, reasoning, or tool-call output has already been emitted. Supporting that safely would require resumable provider responses or UI/model de-duplication of replayed deltas. diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index caecd88..40ad6c5 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -22,6 +22,7 @@ const OPENAI_ERROR_BODY_MAX_CHARS: usize = 2048; const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; const OPENAI_WEBSOCKET_IDLE_MAX: Duration = Duration::from_secs(60); +const OPENAI_WEBSOCKET_STREAM_RETRIES: usize = 1; #[derive(Debug, Clone)] pub struct OpenAI { @@ -201,6 +202,26 @@ struct OpenAIResponseSnapshot { items_added: Vec, } +#[derive(Debug, Default)] +struct WebsocketStreamProgress { + emitted_non_replayable_output: bool, +} + +impl WebsocketStreamProgress { + fn record_chunk(&mut self, chunk: &ChunkType) { + if matches!( + chunk, + ChunkType::Text(_) | ChunkType::Reasoning(_) | ChunkType::ToolCall(_) + ) { + self.emitted_non_replayable_output = true; + } + } + + fn can_retry_without_duplicate_output(&self) -> bool { + !self.emitted_non_replayable_output + } +} + #[async_trait] impl Provider for OpenAI { fn name(&self) -> &str { @@ -436,9 +457,9 @@ impl OpenAI { state.clear_connection(); } - let mut fresh_ws = connect_openai_websocket(ws_url, headers).await?; + let mut fresh_ws = connect_openai_websocket(ws_url.clone(), headers).await?; fresh_ws - .send(WsMessage::Text(request_text)) + .send(WsMessage::Text(request_text.clone())) .await .map_err(|err| Error::Provider(format!("websocket send failed: {err}")))?; ws = fresh_ws; @@ -456,59 +477,109 @@ impl OpenAI { )))); let websocket_state = Arc::clone(&self.websocket_state); let request_snapshot = request_snapshot_from_body(&full_body); + let retry_ws_url = ws_url.clone(); + let retry_headers = headers.clone(); tokio::spawn(async move { - let mut response_id = None; - let mut items_added = Vec::new(); - - while let Some(message) = ws.next().await { - match message { - Ok(WsMessage::Text(text)) => { - collect_websocket_response_state(&text, &mut response_id, &mut items_added); - if let Some(chunk) = response_sse_data_to_chunk(&text) { - let is_completed = - matches!(chunk, Ok(ChunkType::ResponseCompleted { .. })); - if tx.send(chunk).is_err() { - return; - } - if is_completed { - if let Some(response_id) = response_id { - let mut state = websocket_state.lock().await; - state.connection = Some(ws); - state.last_used_at = Some(Instant::now()); - state.last_request = Some(request_snapshot); - state.last_response = Some(OpenAIResponseSnapshot { - response_id, - items_added, - }); + let mut retry_count = 0usize; + + loop { + let mut response_id = None; + let mut items_added = Vec::new(); + let mut progress = WebsocketStreamProgress::default(); + + let failure = loop { + match ws.next().await { + Some(Ok(WsMessage::Text(text))) => { + collect_websocket_response_state( + &text, + &mut response_id, + &mut items_added, + ); + if let Some(chunk) = response_sse_data_to_chunk(&text) { + let is_completed = + matches!(chunk, Ok(ChunkType::ResponseCompleted { .. })); + if let Ok(ref chunk) = chunk { + progress.record_chunk(chunk); + } + if tx.send(chunk).is_err() { + return; + } + if is_completed { + if let Some(response_id) = response_id { + let mut state = websocket_state.lock().await; + state.connection = Some(ws); + state.last_used_at = Some(Instant::now()); + state.last_request = Some(request_snapshot.clone()); + state.last_response = Some(OpenAIResponseSnapshot { + response_id, + items_added, + }); + } + return; } - return; } } + Some(Ok(WsMessage::Ping(_))) | Some(Ok(WsMessage::Pong(_))) => {} + Some(Ok(WsMessage::Close(_))) => { + break "websocket closed before response.completed".to_string(); + } + Some(Ok(WsMessage::Binary(_))) | Some(Ok(WsMessage::Frame(_))) => {} + Some(Err(err)) => { + break format!("websocket stream error: {}", err); + } + None => { + break "websocket stream ended before response.completed".to_string(); + } } - Ok(WsMessage::Ping(_)) | Ok(WsMessage::Pong(_)) => {} - Ok(WsMessage::Close(_)) => { - websocket_state.lock().await.clear_connection(); - let _ = tx.send(Ok(ChunkType::Failed( - "websocket closed before response.completed".to_string(), - ))); + }; + + websocket_state.lock().await.clear_connection(); + + if retry_count < OPENAI_WEBSOCKET_STREAM_RETRIES + && progress.can_retry_without_duplicate_output() + { + retry_count += 1; + if tx + .send(Ok(ChunkType::Metadata(format!( + "openai_transport=responses_websocket_retry attempt={} reason={}", + retry_count, failure + )))) + .is_err() + { return; } - Ok(WsMessage::Binary(_)) | Ok(WsMessage::Frame(_)) => {} - Err(err) => { - websocket_state.lock().await.clear_connection(); + + let mut fresh_ws = match connect_openai_websocket( + retry_ws_url.clone(), + &retry_headers, + ) + .await + { + Ok(ws) => ws, + Err(err) => { + let _ = tx.send(Ok(ChunkType::Failed(format!( + "{}; websocket retry connect failed: {}", + failure, err + )))); + return; + } + }; + + if let Err(err) = fresh_ws.send(WsMessage::Text(request_text.clone())).await { let _ = tx.send(Ok(ChunkType::Failed(format!( - "websocket stream error: {}", - err + "{}; websocket retry send failed: {}", + failure, err )))); return; } + + ws = fresh_ws; + continue; } - } - websocket_state.lock().await.clear_connection(); - let _ = tx.send(Ok(ChunkType::Failed( - "websocket stream ended before response.completed".to_string(), - ))); + let _ = tx.send(Ok(ChunkType::Failed(failure))); + return; + } }); Ok(Box::pin(futures::stream::unfold(rx, |mut rx| async { @@ -1285,7 +1356,7 @@ mod tests { use super::{ build_openai_messages, request_snapshot_from_body, response_sse_data_to_chunk, responses_function_call_chunk, websocket_request_body_from_state, OpenAI, - OpenAIResponseSnapshot, OpenAIWebsocketState, + OpenAIResponseSnapshot, OpenAIWebsocketState, WebsocketStreamProgress, }; use crate::chunk::{ChunkType, MessagePhase}; use crate::message::Message; @@ -1325,6 +1396,34 @@ mod tests { )); } + #[test] + fn websocket_stream_progress_allows_retry_before_output() { + let mut progress = WebsocketStreamProgress::default(); + + progress.record_chunk(&ChunkType::Metadata( + "openai_transport=responses_websocket".to_string(), + )); + progress.record_chunk(&ChunkType::AssistantMessagePhase { + phase: Some(MessagePhase::Commentary), + }); + + assert!(progress.can_retry_without_duplicate_output()); + } + + #[test] + fn websocket_stream_progress_blocks_retry_after_replay_unsafe_chunks() { + for chunk in [ + ChunkType::Text("partial".to_string()), + ChunkType::Reasoning("thinking".to_string()), + ChunkType::ToolCall(r#"[{"id":"call_1"}]"#.to_string()), + ] { + let mut progress = WebsocketStreamProgress::default(); + progress.record_chunk(&chunk); + + assert!(!progress.can_retry_without_duplicate_output()); + } + } + #[test] fn maps_responses_assistant_message_phase() { let chunk = response_sse_data_to_chunk( From fb684d7b70f2e7549a9e279d323e494870c38cfc Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 01:01:33 +0800 Subject: [PATCH 170/226] refactor: changed from 'annotate' to 'add to prompt'. --- src/app.rs | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/app.rs b/src/app.rs index 634d3d4..8c90df7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -198,7 +198,7 @@ struct SelectionActionBarState { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SelectionAction { - Annotate, + AddToPrompt, Copy, Dismiss, } @@ -1397,7 +1397,7 @@ impl App { had_selection } - fn annotate_selection(&mut self, target: SelectionActionTarget) -> bool { + fn add_selection_to_prompt(&mut self, target: SelectionActionTarget) -> bool { if target != SelectionActionTarget::Chat { return false; } @@ -1409,10 +1409,11 @@ impl App { if !self.input.is_empty() { self.input.insert_str("\n"); } - self.input.insert_str(&format_selection_annotation(&text)); + self.input + .insert_str(&format_selection_prompt_addition(&text)); self.dismiss_selection_actions(); push_toast(Toast::new( - "Annotated selection in prompt", + "Added selection to prompt", ToastLevel::Info, None, )); @@ -1433,7 +1434,7 @@ impl App { if key.modifiers == event::KeyModifiers::NONE && state.target == SelectionActionTarget::Chat => { - self.annotate_selection(state.target) + self.add_selection_to_prompt(state.target) } KeyCode::Esc if key.modifiers == event::KeyModifiers::NONE => { self.dismiss_selection_actions(); @@ -2419,7 +2420,7 @@ impl App { MouseEventKind::Up(MouseButton::Left) => { let rel = mouse.column.saturating_sub(area.x) as usize; match selection_action_for_column(state.target, rel) { - SelectionAction::Annotate => self.annotate_selection(state.target), + SelectionAction::AddToPrompt => self.add_selection_to_prompt(state.target), SelectionAction::Copy => { let _ = self.try_copy_selection(); true @@ -3011,7 +3012,7 @@ impl App { self.pending_chat_message_click = None; } - // Show copy/annotate actions when selection is finalized (mouse up after drag) + // Show copy/add-to-prompt actions when selection is finalized (mouse up after drag) if !had_selection && self.chat_state.chat.has_selection() { // New selection just started, don't show actions yet } else if was_dragging && !self.chat_state.chat.selection.is_dragging { @@ -6308,7 +6309,7 @@ impl App { } } -fn format_selection_annotation(text: &str) -> String { +fn format_selection_prompt_addition(text: &str) -> String { let text = text.trim(); if text.lines().count() <= 1 { format!("`{}`", text) @@ -6317,15 +6318,15 @@ fn format_selection_annotation(text: &str) -> String { } } -const SELECTION_ACTION_BAR_WIDTH: u16 = 24; -const CHAT_SELECTION_ACTION_COPY_COL: usize = 12; -const CHAT_SELECTION_ACTION_ESC_COL: usize = 19; +const SELECTION_ACTION_BAR_WIDTH: u16 = 28; +const CHAT_SELECTION_ACTION_COPY_COL: usize = 16; +const CHAT_SELECTION_ACTION_ESC_COL: usize = 23; const INPUT_SELECTION_ACTION_ESC_COL: usize = 8; fn selection_action_for_column(target: SelectionActionTarget, column: usize) -> SelectionAction { match target { SelectionActionTarget::Chat if column < CHAT_SELECTION_ACTION_COPY_COL => { - SelectionAction::Annotate + SelectionAction::AddToPrompt } SelectionActionTarget::Chat if column < CHAT_SELECTION_ACTION_ESC_COL => { SelectionAction::Copy @@ -6433,7 +6434,7 @@ fn render_selection_action_bar( Line::from(vec![ Span::raw(" "), Span::styled("i", key_style), - Span::styled(" annotate ", label_style), + Span::styled(" add to prompt ", label_style), Span::styled("y", key_style), Span::styled(" copy ", label_style), Span::styled("esc", key_style), @@ -6741,14 +6742,14 @@ mod tests { fn selection_action_bar_column_mapping_matches_rendered_labels() { assert_eq!( selection_action_for_column(SelectionActionTarget::Chat, 1), - SelectionAction::Annotate + SelectionAction::AddToPrompt ); assert_eq!( - selection_action_for_column(SelectionActionTarget::Chat, 12), + selection_action_for_column(SelectionActionTarget::Chat, 16), SelectionAction::Copy ); assert_eq!( - selection_action_for_column(SelectionActionTarget::Chat, 19), + selection_action_for_column(SelectionActionTarget::Chat, 23), SelectionAction::Dismiss ); assert_eq!( @@ -6762,7 +6763,7 @@ mod tests { } #[test] - fn chat_selection_action_i_annotates_and_dismisses_selection() { + fn chat_selection_action_i_adds_to_prompt_and_dismisses_selection() { let mut app = test_app(); app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); app.base_focus = BaseFocus::Chat; From c4cd65d2e8ba2a29f0ef44d7df8b5d077fac20fa Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 01:16:19 +0800 Subject: [PATCH 171/226] fix: outside input box not triggering tooltip for copy. --- src/app.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/ui/components/input.rs | 5 +++++ 2 files changed, 43 insertions(+) diff --git a/src/app.rs b/src/app.rs index 8c90df7..e3fe6e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2446,6 +2446,15 @@ impl App { } if !self.input.handle_mouse_event(mouse) { + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && self.input.has_selection() + && !self.input.get_selected_text().is_empty() + { + self.show_selection_action_bar_for(SelectionActionTarget::Input); + self.update_suggestions(); + return true; + } + return false; } @@ -6789,6 +6798,35 @@ mod tests { assert!(!app.chat_state.chat.has_selection()); } + #[test] + fn input_selection_action_bar_shows_when_drag_releases_outside_input() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + app.input.set_text("alpha beta"); + app.input + .set_textarea_area_for_test(ratatui::layout::Rect::new(2, 20, 20, 1)); + + assert!(app.handle_input_mouse_event(mouse( + MouseEventKind::Down(MouseButton::Left), + 2, + 20 + ))); + assert!(app.handle_input_mouse_event(mouse( + MouseEventKind::Drag(MouseButton::Left), + 7, + 20 + ))); + assert!(app.input.has_selection()); + assert_eq!(app.input.get_selected_text(), "alpha"); + + assert!(app.handle_input_mouse_event(mouse(MouseEventKind::Up(MouseButton::Left), 7, 19))); + + assert_eq!( + app.selection_action_bar.map(|state| state.target), + Some(SelectionActionTarget::Input) + ); + } + #[test] fn clicking_chat_message_opens_message_actions() { let mut app = test_app(); diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 1f17bdd..bd8a16e 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -554,6 +554,11 @@ impl Input { result } + #[cfg(test)] + pub(crate) fn set_textarea_area_for_test(&mut self, area: Rect) { + self.textarea_area = Some(area); + } + pub fn clear_selection(&mut self) { self.textarea.cancel_selection(); } From 47ced6e1699746d2f1c250f0c79cbde651136782 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 01:32:59 +0800 Subject: [PATCH 172/226] fix: anthropic-style for qwen 3.7 max. --- src/llm/client.rs | 116 ++++++++++++++++++++++++++++++++++++++--- src/model/discovery.rs | 46 +++++++++++++++- src/model/ollama.rs | 1 + 3 files changed, 156 insertions(+), 7 deletions(-) diff --git a/src/llm/client.rs b/src/llm/client.rs index 0314872..b124e12 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -605,12 +605,13 @@ async fn prepare_request_config( .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", provider_name))? }; - let provider_kind = ProviderKind::from_provider(provider_name, &provider.npm); + let model_route = resolve_model_route(&provider, model); + let provider_kind = ProviderKind::from_provider(provider_name, &model_route.npm_package); let mut request_config = ProviderRequestConfig::new( provider_kind, provider.name.clone(), - provider_kind.normalize_base_url(&provider.api), - model, + provider_kind.normalize_base_url(&model_route.api), + model_route.model_name, configured_api_key(auth_config.as_ref()), reasoning_effort, ); @@ -638,7 +639,7 @@ async fn prepare_request_config( crate::emit_log!( "Provider: {}, NPM: {}, Base URL: {}, Model: {}", provider_name, - provider.npm, + model_route.npm_package, request_config.base_url, request_config.model_name ); @@ -646,6 +647,46 @@ async fn prepare_request_config( Ok(request_config) } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ResolvedModelRoute { + npm_package: String, + api: String, + model_name: String, +} + +fn resolve_model_route( + provider: &crate::model::discovery::Provider, + requested_model: String, +) -> ResolvedModelRoute { + let model = provider.models.get(&requested_model); + + let npm_package = model + .and_then(|model| model.provider.as_ref()) + .and_then(|provider| provider.npm.as_deref()) + .filter(|npm| !npm.trim().is_empty()) + .unwrap_or(provider.npm.as_str()) + .to_string(); + + let api = model + .and_then(|model| model.provider.as_ref()) + .and_then(|provider| provider.api.as_deref()) + .filter(|api| !api.trim().is_empty()) + .unwrap_or(provider.api.as_str()) + .to_string(); + + let model_name = model + .map(|model| model.id.as_str()) + .filter(|id| !id.trim().is_empty()) + .unwrap_or(requested_model.as_str()) + .to_string(); + + ResolvedModelRoute { + npm_package, + api, + model_name, + } +} + fn configured_api_key(auth_config: Option<&crate::persistence::AuthConfig>) -> Option { auth_config.and_then(|config| match config { crate::persistence::AuthConfig::Api { key } => Some(key.clone()), @@ -1371,8 +1412,8 @@ fn normalize_anthropic_base_url(base_url: &str) -> String { #[cfg(test)] mod tests { use super::{ - convert_messages, is_openai_oauth_model_allowed, openai_request_instructions, AisdkMessage, - OpenAIRequestOptions, + convert_messages, is_openai_oauth_model_allowed, openai_request_instructions, + resolve_model_route, AisdkMessage, OpenAIRequestOptions, ProviderKind, }; #[test] @@ -1428,6 +1469,69 @@ mod tests { assert!(!is_openai_oauth_model_allowed("gpt-4o")); } + #[test] + fn model_provider_override_selects_anthropic_route() { + let provider: crate::model::discovery::Provider = + serde_json::from_value(serde_json::json!({ + "id": "opencode-go", + "name": "OpenCode Go", + "api": "https://opencode.ai/zen/go/v1", + "npm": "@ai-sdk/openai-compatible", + "env": ["OPENCODE_API_KEY"], + "models": { + "qwen3.7-max": { + "id": "qwen3.7-max", + "name": "Qwen3.7 Max", + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "provider": { + "npm": "@ai-sdk/anthropic" + } + } + } + })) + .unwrap(); + + let route = resolve_model_route(&provider, "qwen3.7-max".to_string()); + assert_eq!(route.npm_package, "@ai-sdk/anthropic"); + assert_eq!(route.api, "https://opencode.ai/zen/go/v1"); + assert_eq!(route.model_name, "qwen3.7-max"); + assert_eq!( + ProviderKind::from_provider("opencode-go", &route.npm_package), + ProviderKind::Anthropic + ); + assert_eq!( + ProviderKind::Anthropic.normalize_base_url(&route.api), + "https://opencode.ai/zen/go" + ); + } + + #[test] + fn model_route_falls_back_to_provider_transport() { + let provider: crate::model::discovery::Provider = + serde_json::from_value(serde_json::json!({ + "id": "opencode-go", + "name": "OpenCode Go", + "api": "https://opencode.ai/zen/go/v1", + "npm": "@ai-sdk/openai-compatible", + "env": ["OPENCODE_API_KEY"], + "models": { + "kimi-k2.6": { + "id": "kimi-k2.6", + "name": "Kimi K2.6", + "release_date": "2026-04-21", + "last_updated": "2026-04-21" + } + } + })) + .unwrap(); + + let route = resolve_model_route(&provider, "kimi-k2.6".to_string()); + assert_eq!(route.npm_package, "@ai-sdk/openai-compatible"); + assert_eq!(route.api, "https://opencode.ai/zen/go/v1"); + assert_eq!(route.model_name, "kimi-k2.6"); + } + #[test] fn tool_history_replays_structured_tool_call_and_output() { let tool_message = crate::session::types::Message::tool( diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 3a63b54..dc1f8af 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -9,6 +9,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; const MODELS_DEV_API_URL: &str = "https://models.dev/api.json"; const CACHE_TTL_SECONDS: u64 = 24 * 60 * 60; +const CACHE_SCHEMA_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Provider { @@ -56,6 +57,16 @@ pub struct Model { pub cost: Option, #[serde(default)] pub limit: Option, + #[serde(default)] + pub provider: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelProvider { + #[serde(default)] + pub npm: Option, + #[serde(default)] + pub api: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -90,6 +101,8 @@ pub struct Limit { struct CacheEntry { data: HashMap, timestamp: u64, + #[serde(default)] + schema_version: u32, } pub struct Discovery { @@ -171,6 +184,10 @@ impl Discovery { let entry: CacheEntry = serde_json::from_str(&cached_json).context("Failed to parse cache file")?; + if entry.schema_version < CACHE_SCHEMA_VERSION { + return Ok(None); + } + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .context("System time is before UNIX epoch")? @@ -194,6 +211,7 @@ impl Discovery { let entry = CacheEntry { data: data.clone(), timestamp: now, + schema_version: CACHE_SCHEMA_VERSION, }; let serialized = @@ -343,9 +361,15 @@ impl Discovery { let entry: CacheEntry = serde_json::from_str(&cached_json).ok()?; let provider = entry.data.get(provider_id)?; let model = provider.models.get(model_id)?; + let provider_npm = model + .provider + .as_ref() + .and_then(|provider| provider.npm.as_deref()) + .filter(|npm| !npm.trim().is_empty()) + .unwrap_or(provider.npm.as_str()); Some(crate::model::reasoning::capability_for_model( provider_id, - &provider.npm, + provider_npm, model_id, &model.id, &model.name, @@ -516,6 +540,7 @@ mod tests { let entry = CacheEntry { data: providers.clone(), timestamp: 123456, + schema_version: CACHE_SCHEMA_VERSION, }; let serialized = serde_json::to_string(&entry).unwrap(); @@ -523,6 +548,25 @@ mod tests { assert_eq!(deserialized.data.len(), 1); assert_eq!(deserialized.timestamp, 123456); + assert_eq!(deserialized.schema_version, CACHE_SCHEMA_VERSION); + } + + #[test] + fn test_model_provider_override_deserialization() { + let model: Model = serde_json::from_value(serde_json::json!({ + "id": "qwen3.7-max", + "name": "Qwen3.7 Max", + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "provider": { + "npm": "@ai-sdk/anthropic" + } + })) + .unwrap(); + + let provider = model.provider.expect("provider override"); + assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/anthropic")); + assert_eq!(provider.api, None); } #[tokio::test] diff --git a/src/model/ollama.rs b/src/model/ollama.rs index 17b9051..16b760b 100644 --- a/src/model/ollama.rs +++ b/src/model/ollama.rs @@ -124,6 +124,7 @@ fn cached_discovery_models( open_weights: true, cost: None, limit: None, + provider: None, }, ) }) From a4dc2b2c63e9f22edaaca43ce3839c3b9de66908 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 01:50:46 +0800 Subject: [PATCH 173/226] refactor(anthropic): extract SSE stream parsing into composable helpers. Decompose the monolithic `filter_map` closure into dedicated functions for each event type, enabling OpenAI-style tool call delta emission and proper handling of `message_stop` and `stop_reason` signals. --- aisdk/src/providers/anthropic.rs | 317 +++++++++++++++++++++++++------ 1 file changed, 263 insertions(+), 54 deletions(-) diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs index a100bad..d909853 100644 --- a/aisdk/src/providers/anthropic.rs +++ b/aisdk/src/providers/anthropic.rs @@ -206,64 +206,29 @@ impl Provider for Anthropic { let stream = response .bytes_stream() .eventsource() - .filter_map(|ev| { - match ev { - Ok(event) => { - let event_type = event.event.as_str(); - let data = &event.data; - - if data.is_empty() { - return futures::future::ready(None); - } + .filter_map(|ev| match ev { + Ok(event) => { + let event_type = event.event.as_str(); + let data = &event.data; - match serde_json::from_str::(data) { - Ok(value) => match event_type { - "content_block_delta" => { - let delta = &value["delta"]; - match delta["type"].as_str() { - Some("text_delta") => futures::future::ready( - delta["text"] - .as_str() - .map(|t| Ok(ChunkType::Text(t.to_string()))), - ), - Some("thinking_delta") => futures::future::ready( - delta["thinking"] - .as_str() - .map(|t| Ok(ChunkType::Reasoning(t.to_string()))), - ), - Some("input_json_delta") => futures::future::ready( - delta["partial_json"] - .as_str() - .map(|j| Ok(ChunkType::ToolCall(j.to_string()))), - ), - _ => futures::future::ready(None), - } - } - "message_delta" => { - // Stream exhausts naturally after message_stop - futures::future::ready(None) - } - "error" => { - let error_msg = value["error"]["message"] - .as_str() - .unwrap_or("Unknown error"); - futures::future::ready(Some(Ok(ChunkType::Failed( - error_msg.to_string(), - )))) - } - _ => futures::future::ready(None), - }, - Err(e) => futures::future::ready(Some(Ok(ChunkType::Failed(format!( - "Invalid SSE data: {}", - e - ))))), - } + if data.is_empty() { + return futures::future::ready(None); } - Err(e) => { - let err = format!("SSE error: {}", e); - futures::future::ready(Some(Ok(ChunkType::Failed(err)))) + + match serde_json::from_str::(data) { + Ok(value) => { + futures::future::ready(anthropic_stream_chunk(event_type, &value)) + } + Err(e) => futures::future::ready(Some(Ok(ChunkType::Failed(format!( + "Invalid SSE data: {}", + e + ))))), } } + Err(e) => { + let err = format!("SSE error: {}", e); + futures::future::ready(Some(Ok(ChunkType::Failed(err)))) + } }) .boxed(); @@ -271,6 +236,154 @@ impl Provider for Anthropic { } } +fn anthropic_stream_chunk( + event_type: &str, + value: &serde_json::Value, +) -> Option> { + match event_type { + "content_block_start" => anthropic_tool_call_start(value) + .map(ChunkType::ToolCall) + .map(Ok), + "content_block_delta" => anthropic_content_block_delta(value).map(Ok), + "message_delta" => anthropic_message_delta(value).map(Ok), + "message_stop" => Some(Ok(ChunkType::End(String::new()))), + "error" => { + let error_msg = value["error"]["message"] + .as_str() + .unwrap_or("Unknown error"); + Some(Ok(ChunkType::Failed(error_msg.to_string()))) + } + _ => None, + } +} + +fn anthropic_content_block_delta(value: &serde_json::Value) -> Option { + let delta = value.get("delta")?; + + match delta.get("type").and_then(|delta_type| delta_type.as_str()) { + Some("text_delta") => delta + .get("text") + .and_then(|text| text.as_str()) + .filter(|text| !text.is_empty()) + .map(|text| ChunkType::Text(text.to_string())), + Some("thinking_delta") => delta + .get("thinking") + .and_then(|thinking| thinking.as_str()) + .filter(|thinking| !thinking.is_empty()) + .map(|thinking| ChunkType::Reasoning(thinking.to_string())), + Some("input_json_delta") => { + anthropic_tool_call_arguments_delta(value).map(ChunkType::ToolCall) + } + _ => None, + } +} + +fn anthropic_message_delta(value: &serde_json::Value) -> Option { + let stop_reason = value + .get("delta") + .and_then(|delta| delta.get("stop_reason")) + .and_then(|stop_reason| stop_reason.as_str())?; + + match stop_reason { + "max_tokens" => Some(ChunkType::Incomplete("stop_reason=max_tokens".to_string())), + "refusal" => Some(ChunkType::Failed("stop_reason=refusal".to_string())), + _ => None, + } +} + +fn anthropic_tool_call_start(value: &serde_json::Value) -> Option { + let content_block = value.get("content_block")?; + if content_block + .get("type") + .and_then(|block_type| block_type.as_str()) + != Some("tool_use") + { + return None; + } + + let mut function = serde_json::Map::new(); + if let Some(name) = content_block + .get("name") + .and_then(|name| name.as_str()) + .filter(|name| !name.is_empty()) + { + function.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + } + + if let Some(input) = content_block + .get("input") + .filter(|input| !anthropic_tool_input_is_empty(input)) + { + function.insert( + "arguments_done".to_string(), + serde_json::Value::String(input.to_string()), + ); + } + + let mut item = anthropic_tool_call_item_base(value, function); + if let Some(id) = content_block + .get("id") + .and_then(|id| id.as_str()) + .filter(|id| !id.is_empty()) + { + item.insert("id".to_string(), serde_json::Value::String(id.to_string())); + } + item.insert( + "type".to_string(), + serde_json::Value::String("function".to_string()), + ); + + serde_json::to_string(&vec![serde_json::Value::Object(item)]).ok() +} + +fn anthropic_tool_call_arguments_delta(value: &serde_json::Value) -> Option { + let partial_json = value + .get("delta") + .and_then(|delta| delta.get("partial_json")) + .and_then(|partial_json| partial_json.as_str()) + .filter(|partial_json| !partial_json.is_empty())?; + + let mut function = serde_json::Map::new(); + function.insert( + "arguments".to_string(), + serde_json::Value::String(partial_json.to_string()), + ); + + serde_json::to_string(&vec![serde_json::Value::Object( + anthropic_tool_call_item_base(value, function), + )]) + .ok() +} + +fn anthropic_tool_call_item_base( + value: &serde_json::Value, + function: serde_json::Map, +) -> serde_json::Map { + let mut item = serde_json::Map::new(); + + if let Some(index) = value.get("index").and_then(|index| index.as_u64()) { + item.insert( + "index".to_string(), + serde_json::Value::Number(serde_json::Number::from(index)), + ); + } + + item.insert("function".to_string(), serde_json::Value::Object(function)); + item +} + +fn anthropic_tool_input_is_empty(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Null => true, + serde_json::Value::Object(map) => map.is_empty(), + serde_json::Value::String(text) => text.trim().is_empty(), + _ => false, + } +} + fn anthropic_user_content(user: &crate::message::UserMessage) -> serde_json::Value { if user.images.is_empty() { return serde_json::json!(user.content); @@ -303,6 +416,102 @@ fn anthropic_user_content(user: &crate::message::UserMessage) -> serde_json::Val serde_json::Value::Array(parts) } +#[cfg(test)] +mod tests { + use super::*; + + fn tool_call_json(event_type: &str, value: serde_json::Value) -> serde_json::Value { + let chunk = anthropic_stream_chunk(event_type, &value) + .expect("event should produce a chunk") + .expect("chunk should parse"); + + let ChunkType::ToolCall(json) = chunk else { + panic!("expected tool call chunk"); + }; + + serde_json::from_str::(&json).expect("tool call should be json") + } + + #[test] + fn emits_tool_call_start_as_openai_style_delta() { + let json = tool_call_json( + "content_block_start", + serde_json::json!({ + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": "read", + "input": {}, + }, + }), + ); + + assert_eq!(json[0]["index"], 1); + assert_eq!(json[0]["id"], "toolu_1"); + assert_eq!(json[0]["type"], "function"); + assert_eq!(json[0]["function"]["name"], "read"); + assert!(json[0]["function"].get("arguments").is_none()); + } + + #[test] + fn emits_tool_input_delta_as_openai_style_delta() { + let json = tool_call_json( + "content_block_delta", + serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": "{\"file_path\"", + }, + }), + ); + + assert_eq!(json[0]["index"], 0); + assert_eq!(json[0]["function"]["arguments"], "{\"file_path\""); + } + + #[test] + fn ignores_empty_tool_input_delta() { + let value = serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": "", + }, + }); + + assert!(anthropic_stream_chunk("content_block_delta", &value).is_none()); + } + + #[test] + fn message_stop_emits_terminal_chunk() { + let chunk = anthropic_stream_chunk("message_stop", &serde_json::json!({})) + .expect("event should produce a chunk") + .expect("chunk should parse"); + + assert!(matches!(chunk, ChunkType::End(_))); + } + + #[test] + fn max_tokens_stop_reason_emits_incomplete_chunk() { + let value = serde_json::json!({ + "type": "message_delta", + "delta": { + "stop_reason": "max_tokens", + }, + }); + let chunk = anthropic_stream_chunk("message_delta", &value) + .expect("event should produce a chunk") + .expect("chunk should parse"); + + assert!(matches!(chunk, ChunkType::Incomplete(_))); + } +} + fn anthropic_tool_output_content(tool: &crate::message::ToolOutputMessage) -> serde_json::Value { if tool.images.is_empty() { return serde_json::json!(tool.output); From db7b9dd8cb243c2e611f31d8c5d385f28e0bcd34 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 01:52:45 +0800 Subject: [PATCH 174/226] feat(ui): add edge scrolling for text selection drag. When dragging a text selection to the top or bottom edge of the chat or input area, the view now auto-scrolls to continue extending the selection beyond the visible area. Introduces `SelectionEdgeScroll` tracking and `EdgeScrollDirection` in the selection module. --- _plans/__TODOS.md | 4 +- src/app.rs | 26 ++++- src/ui/components/chat.rs | 191 +++++++++++++++++++++++++++++++++---- src/ui/components/input.rs | 168 +++++++++++++++++++++++++++++--- src/ui/selection.rs | 6 ++ 5 files changed, 360 insertions(+), 35 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index f24af85..d5afc7f 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -190,6 +190,6 @@ I want - [x] To do this But I dont want to do this - [x] Zed alert circle thing when asking permission or question, please emit it. Currently it's only on completions by default I think. -- [ ] Let's refactor highlights so that "highlighting" doesn't copy immediately. But rather, show a little dropdown like this so that I have control if I wanna copy or not. I want this because there are some parts that are kinda bothersome especially for users with clipboard history, it just quickly bloats it. +- [x] Let's refactor highlights so that "highlighting" doesn't copy immediately. But rather, show a little dropdown like this so that I have control if I wanna copy or not. I want this because there are some parts that are kinda bothersome especially for users with clipboard history, it just quickly bloats it. -- [ ] Minor bug.. Whatever I typed... and then pressed up multiple times, meaning I got to the "previously submitted chats i made", I go back down with down, and I got just an empty chat lol. So it looks like the chat I sent is gone. +- [x] Mouse scroll ux just like opencode, when highlighting. Needs to scroll when I reach edges as I drag and click. diff --git a/src/app.rs b/src/app.rs index e3fe6e1..f7161c1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2908,8 +2908,13 @@ impl App { } } } else if self.overlay_focus == OverlayFocus::None { - // If chat has a selection and user clicks outside chat area, clear it - if self.chat_state.chat.has_selection() && self.base_focus == BaseFocus::Chat { + // If chat has a selection and user clicks outside chat area, clear it. + // Dragging is handled by the chat component so edge scrolling can continue. + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && self.chat_state.chat.has_selection() + && !self.chat_state.chat.selection.is_dragging + && self.base_focus == BaseFocus::Chat + { let chat_area = self.current_chat_area(); let point = ratatui::layout::Position::new(mouse.column, mouse.row); @@ -5204,6 +5209,11 @@ impl App { if self.last_animation_update.elapsed() >= ANIMATION_INTERVAL { self.chat_state.wave_spinner.update(); self.home_state.tick(); + if self.tick_selection_edge_scroll() { + self.selection_action_bar = None; + self.pending_chat_message_click = None; + self.update_suggestions(); + } self.last_animation_update = std::time::Instant::now(); } @@ -5215,6 +5225,7 @@ impl App { pub fn is_animation_running(&self) -> bool { self.base_focus == BaseFocus::Home + || self.has_active_selection_edge_scroll() || self.is_streaming || self.chat_state.chat.has_active_tool_messages() || self.compaction_receiver.is_some() @@ -5227,6 +5238,17 @@ impl App { && self.sessions_dialog_state.dialog.is_visible()) } + fn has_active_selection_edge_scroll(&self) -> bool { + self.input.has_active_selection_edge_scroll() + || self.chat_state.chat.has_active_selection_edge_scroll() + } + + fn tick_selection_edge_scroll(&mut self) -> bool { + let input_scrolled = self.input.tick_selection_edge_scroll(); + let chat_scrolled = self.chat_state.chat.tick_selection_edge_scroll(); + input_scrolled || chat_scrolled + } + pub fn process_streaming_chunks(&mut self) { self.process_openai_oauth_events(); self.process_compaction_events(); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 573bdcf..4dfd6d3 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -4,7 +4,7 @@ use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; use crate::ui::scrollbar::{ render_scrollbar, scrollbar_grab_offset, scrollbar_offset_from_row_with_grab, ScrollMetrics, }; -use crate::ui::selection::{non_selectable_style, Selection}; +use crate::ui::selection::{non_selectable_style, EdgeScrollDirection, Selection}; use crate::ui::wrapping::{wrap_styled_line, WrapOptions}; use crate::utils::token_counter::StreamingTokenCounter; use ratatui::{ @@ -55,6 +55,7 @@ pub struct Chat { pub message_line_positions: Vec, /// Text selection state for copy-on-select pub selection: Selection, + selection_edge_scroll: Option, /// Anchor that existed before the current mouse click started. pending_click_anchor: Option<(usize, usize)>, /// Index of the message highlighted by timeline navigation (None = no highlight) @@ -73,6 +74,12 @@ pub struct Chat { hovered_hyperlink: Option, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SelectionEdgeScroll { + direction: EdgeScrollDirection, + column: usize, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChatImageTarget { pub message_index: usize, @@ -688,6 +695,7 @@ impl Chat { streaming_message_idx: None, message_line_positions: Vec::new(), selection: Selection::new(), + selection_edge_scroll: None, pending_click_anchor: None, highlighted_message_index: None, render_revision: 1, @@ -730,6 +738,7 @@ impl Chat { streaming_message_idx: None, message_line_positions: Vec::new(), selection: Selection::new(), + selection_edge_scroll: None, pending_click_anchor: None, highlighted_message_index: None, render_revision: 1, @@ -1546,6 +1555,99 @@ impl Chat { self.scrollbar_state = self.scrollbar_state.position(position); } + pub fn has_active_selection_edge_scroll(&self) -> bool { + self.selection_edge_scroll.is_some() + } + + pub fn tick_selection_edge_scroll(&mut self) -> bool { + let Some(edge_scroll) = self.selection_edge_scroll else { + return false; + }; + if !self.selection.is_dragging { + self.selection_edge_scroll = None; + return false; + } + + let before = self.scroll_offset; + match edge_scroll.direction { + EdgeScrollDirection::Up => self.scroll_up(1), + EdgeScrollDirection::Down => self.scroll_down(1), + } + + if self.scroll_offset == before { + self.selection_edge_scroll = None; + return false; + } + + let line = match edge_scroll.direction { + EdgeScrollDirection::Up => self.scroll_offset, + EdgeScrollDirection::Down => self + .scroll_offset + .saturating_add(self.viewport_height.saturating_sub(1)) + .min(self.content_height.saturating_sub(1)), + }; + self.selection.extend(line, edge_scroll.column); + true + } + + fn clear_selection_edge_scroll(&mut self) { + self.selection_edge_scroll = None; + } + + fn edge_scroll_direction(area: Rect, row: u16) -> Option { + if area.height == 0 { + return None; + } + let bottom = area.y.saturating_add(area.height.saturating_sub(1)); + if row <= area.y { + Some(EdgeScrollDirection::Up) + } else if row >= bottom { + Some(EdgeScrollDirection::Down) + } else { + None + } + } + + fn clamped_content_column(content_area: Rect, column: u16) -> usize { + if content_area.width == 0 { + return 0; + } + column + .saturating_sub(content_area.x) + .min(content_area.width.saturating_sub(1)) as usize + } + + fn clamped_content_row(content_area: Rect, row: u16) -> u16 { + if content_area.height == 0 { + return 0; + } + row.saturating_sub(content_area.y) + .min(content_area.height.saturating_sub(1)) + } + + fn update_selection_edge_scroll(&mut self, content_area: Rect, event: MouseEvent) { + if !self.selection.is_dragging || content_area.width == 0 || content_area.height == 0 { + self.clear_selection_edge_scroll(); + return; + } + + self.selection_edge_scroll = + Self::edge_scroll_direction(content_area, event.row).map(|direction| { + SelectionEdgeScroll { + direction, + column: Self::clamped_content_column(content_area, event.column), + } + }); + } + + fn drag_selection_to_position(&mut self, content_area: Rect, event: MouseEvent) { + let content_line = (Self::clamped_content_row(content_area, event.row) as usize + + self.scroll_offset) + .min(self.content_height.saturating_sub(1)); + let content_col = Self::clamped_content_column(content_area, event.column); + self.selection.extend(content_line, content_col); + } + pub fn has_selection(&self) -> bool { self.selection.active } @@ -1594,6 +1696,13 @@ impl Chat { width: 1, height: area.height, }; + let content_area = Self::content_area_for(area); + let rendered_content_area = Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width, + height: content_area.height, + }; if self.is_dragging_scrollbar { match event.kind { @@ -1615,22 +1724,26 @@ impl Chat { self.scrollbar_drag_offset = None; // If dragging selection outside area, finalize it if self.selection.is_dragging { - self.selection.finish(); - self.pending_click_anchor = None; - // Copy will be handled by app.rs on mouse up + match event.kind { + MouseEventKind::Drag(MouseButton::Left) => { + self.drag_selection_to_position(rendered_content_area, event); + self.update_selection_edge_scroll(rendered_content_area, event); + let _ = self.tick_selection_edge_scroll(); + return true; + } + MouseEventKind::Up(_) => { + self.selection.finish(); + self.clear_selection_edge_scroll(); + self.pending_click_anchor = None; + // Copy will be handled by app.rs on mouse up + return true; + } + _ => {} + } } return false; } - // Calculate the content area (exclude scrollbar column) - let content_area = Self::content_area_for(area); - let rendered_content_area = Rect { - x: content_area.x, - y: content_area.y, - width: content_area.width, - height: content_area.height, - }; - let is_on_scrollbar = scrollbar_area.contains(point); let is_in_content = rendered_content_area.contains(point); @@ -1671,10 +1784,12 @@ impl Chat { .selection .start_from_anchor_to(content_line, content_col) { + self.clear_selection_edge_scroll(); true } else { // Start text selection and record this normal click as the anchor. self.selection.start(content_line, content_col); + self.clear_selection_edge_scroll(); true } } else { @@ -1687,10 +1802,9 @@ impl Chat { true } else if is_in_content && self.selection.is_dragging { // Extend text selection - let content_line = (event.row.saturating_sub(rendered_content_area.y) as usize) - .saturating_add(self.scroll_offset); - let content_col = event.column.saturating_sub(rendered_content_area.x) as usize; - self.selection.extend(content_line, content_col); + self.drag_selection_to_position(rendered_content_area, event); + self.update_selection_edge_scroll(rendered_content_area, event); + let _ = self.tick_selection_edge_scroll(); true } else { false @@ -1723,6 +1837,7 @@ impl Chat { // Finalize text selection self.selection.finish(); + self.clear_selection_edge_scroll(); self.pending_click_anchor = None; // If selection is zero-width (click without drag), clear it let ((s_line, s_col), (e_line, e_col)) = self.selection.range(); @@ -1738,6 +1853,7 @@ impl Chat { // Right-click clears selection if self.selection.active { self.selection.clear(); + self.clear_selection_edge_scroll(); self.pending_click_anchor = None; true } else { @@ -5252,6 +5368,47 @@ codex exec --skip-git-repo-check \ assert_eq!(chat.scroll_offset, 0); } + #[test] + fn test_mouse_drag_at_bottom_edge_scrolls_chat_selection() { + let mut chat = chat_with_content_height(20); + chat.viewport_height = 5; + let area = Rect::new(0, 0, 40, 5); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 2, + 2, + KeyModifiers::NONE, + ), + area, + )); + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Drag(MouseButton::Left), + 2, + 4, + KeyModifiers::NONE, + ), + area, + )); + + assert_eq!(chat.scroll_offset, 1); + assert!(chat.has_active_selection_edge_scroll()); + assert_eq!(chat.selection.range(), ((2, 2), (5, 2))); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 2, + 4, + KeyModifiers::NONE, + ), + area, + )); + assert!(!chat.has_active_selection_edge_scroll()); + } + #[test] fn test_chat_scroll_to_bottom() { let mut chat = Chat::new(); diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index bd8a16e..7326899 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -3,6 +3,7 @@ use crate::persistence::PromptHistoryCache; use crate::push_toast; use crate::theme::{agent_color, contrast_text, ThemeColors}; use crate::toast::{Toast, ToastLevel}; +use crate::ui::selection::EdgeScrollDirection; use crate::utils::image_attachment; use ratatui::buffer::Buffer; use ratatui::crossterm::event::{ @@ -50,12 +51,20 @@ struct VisualLine { end_col: usize, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SelectionEdgeScroll { + direction: EdgeScrollDirection, + column: u16, +} + pub struct Input { textarea: TextArea<'static>, pub autocomplete: Option, textarea_area: Option, viewport_top: usize, preferred_visual_col: Option, + selection_drag_active: bool, + selection_edge_scroll: Option, prompt_history: Option, draft_text: Option, local_images: Vec, @@ -99,6 +108,8 @@ impl Input { textarea_area: None, viewport_top: 0, preferred_visual_col: None, + selection_drag_active: false, + selection_edge_scroll: None, prompt_history, draft_text: None, local_images: Vec::new(), @@ -129,6 +140,95 @@ impl Input { self.hovered_image_placeholder = None; } + pub fn has_active_selection_edge_scroll(&self) -> bool { + self.selection_edge_scroll.is_some() + } + + pub fn tick_selection_edge_scroll(&mut self) -> bool { + let Some(edge_scroll) = self.selection_edge_scroll else { + return false; + }; + if !self.selection_drag_active { + self.selection_edge_scroll = None; + return false; + } + + self.preferred_visual_col = Some(edge_scroll.column as usize); + let moved = match edge_scroll.direction { + EdgeScrollDirection::Up => self.move_cursor_visual(-1), + EdgeScrollDirection::Down => self.move_cursor_visual(1), + }; + if !moved { + self.selection_edge_scroll = None; + } + moved + } + + fn clear_selection_drag_state(&mut self) { + self.selection_drag_active = false; + self.selection_edge_scroll = None; + self.preferred_visual_col = None; + } + + fn clamped_relative_x(area: Rect, column: u16) -> u16 { + if area.width == 0 { + return 0; + } + column + .saturating_sub(area.x) + .min(area.width.saturating_sub(1)) + } + + fn clamped_relative_y(area: Rect, row: u16) -> u16 { + if area.height == 0 { + return 0; + } + row.saturating_sub(area.y) + .min(area.height.saturating_sub(1)) + } + + fn edge_scroll_direction(area: Rect, row: u16) -> Option { + if area.height == 0 { + return None; + } + let bottom = area.y.saturating_add(area.height.saturating_sub(1)); + if row <= area.y { + Some(EdgeScrollDirection::Up) + } else if row >= bottom { + Some(EdgeScrollDirection::Down) + } else { + None + } + } + + fn update_selection_edge_scroll(&mut self, area: Rect, mouse: MouseEvent) { + if !self.selection_drag_active || area.width == 0 || area.height == 0 { + self.selection_edge_scroll = None; + return; + } + + self.selection_edge_scroll = + Self::edge_scroll_direction(area, mouse.row).map(|direction| SelectionEdgeScroll { + direction, + column: Self::clamped_relative_x(area, mouse.column), + }); + } + + fn move_selection_to_mouse_position(&mut self, area: Rect, mouse: MouseEvent) -> bool { + let relative_x = Self::clamped_relative_x(area, mouse.column); + let relative_y = Self::clamped_relative_y(area, mouse.row); + + let Some((target_row, target_col)) = + self.cursor_for_screen_position(area, relative_x, relative_y) + else { + return false; + }; + + self.textarea + .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); + true + } + pub fn render( &mut self, frame: &mut ratatui::Frame, @@ -425,8 +525,22 @@ impl Input { && mouse_y < textarea_area.y + textarea_area.height; if !within_textarea { - if matches!(mouse.kind, MouseEventKind::Moved) { - self.hovered_image_placeholder = None; + match mouse.kind { + MouseEventKind::Drag(MouseButton::Left) if self.selection_drag_active => { + self.preferred_visual_col = None; + self.move_selection_to_mouse_position(textarea_area, mouse); + self.update_selection_edge_scroll(textarea_area, mouse); + let _ = self.tick_selection_edge_scroll(); + return true; + } + MouseEventKind::Up(MouseButton::Left) if self.selection_drag_active => { + self.clear_selection_drag_state(); + return false; + } + MouseEventKind::Moved => { + self.hovered_image_placeholder = None; + } + _ => {} } return false; } @@ -475,6 +589,8 @@ impl Input { self.textarea .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); self.textarea.start_selection(); + self.selection_drag_active = true; + self.selection_edge_scroll = None; } else { let lines = self.textarea.lines(); let last_row = lines.len().saturating_sub(1); @@ -482,32 +598,32 @@ impl Input { self.textarea .move_cursor(CursorMove::Jump(last_row as u16, last_col as u16)); self.textarea.start_selection(); + self.selection_drag_active = true; + self.selection_edge_scroll = None; } true } MouseEventKind::Drag(MouseButton::Left) => { - self.preferred_visual_col = None; - // Extend the ongoing selection - let relative_x = mouse_x.saturating_sub(textarea_area.x); - let relative_y = mouse_y.saturating_sub(textarea_area.y); - - if let Some((target_row, target_col)) = - self.cursor_for_screen_position(textarea_area, relative_x, relative_y) - { - // Since start_selection() was called and is_selecting() is true, - // move_cursor extends the selection - self.textarea - .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); + if !self.selection_drag_active { + return false; } + self.preferred_visual_col = None; + // Since start_selection() was called and is_selecting() is true, + // move_cursor extends the selection. + self.move_selection_to_mouse_position(textarea_area, mouse); + self.update_selection_edge_scroll(textarea_area, mouse); + let _ = self.tick_selection_edge_scroll(); true } MouseEventKind::Up(MouseButton::Left) => { // Selection finalized (cursor was moved during drag) + self.clear_selection_drag_state(); true } MouseEventKind::Up(MouseButton::Right) => { // Right-click clears selection self.textarea.cancel_selection(); + self.clear_selection_drag_state(); true } _ => false, @@ -561,6 +677,7 @@ impl Input { pub fn clear_selection(&mut self) { self.textarea.cancel_selection(); + self.clear_selection_drag_state(); } /// Delete the word before the cursor. Handles multi-byte emoji correctly @@ -751,6 +868,7 @@ impl Input { .bg(ratatui::style::Color::Rgb(255, 140, 0)) .fg(ratatui::style::Color::Reset), ); + self.clear_selection_drag_state(); } fn set_text_preserving_images(&mut self, text: &str, cursor_offset: usize) { @@ -2046,6 +2164,28 @@ mod tests { assert_eq!(input.textarea.cursor(), (0, 0)); } + #[test] + fn test_mouse_drag_at_bottom_edge_scrolls_wrapped_input_selection() { + let mut input = Input::new(); + input.insert_str("0123456789ABCDEFGHIJ"); + input.textarea_area = Some(Rect::new(0, 0, 5, 2)); + + assert!(input.handle_mouse_event(mouse_event_at( + MouseEventKind::Down(MouseButton::Left), + 0, + 0, + ))); + assert!(input.handle_mouse_event(mouse_event_at( + MouseEventKind::Drag(MouseButton::Left), + 4, + 1, + ))); + + assert!(input.has_active_selection_edge_scroll()); + assert_eq!(input.textarea.cursor(), (0, 14)); + assert_eq!(input.get_selected_text(), "0123456789ABCD"); + } + #[test] fn test_image_and_large_paste_placeholders_render_with_same_color() { use ratatui::{backend::TestBackend, Terminal}; diff --git a/src/ui/selection.rs b/src/ui/selection.rs index feb2d76..a0ef888 100644 --- a/src/ui/selection.rs +++ b/src/ui/selection.rs @@ -4,6 +4,12 @@ use ratatui::{ text::Span, }; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EdgeScrollDirection { + Up, + Down, +} + /// Internal marker for spans that should render normally but be ignored by /// selection highlighting and clipboard extraction (for example diff gutters). pub const NON_SELECTABLE_SPAN_MODIFIER: Modifier = Modifier::HIDDEN; From c9021bf07919ba008ab3cf90c08e4aadf157086e Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 02:46:46 +0800 Subject: [PATCH 175/226] fix: more fixes on premature complete esp for other models, qwen 3.7 max. --- _plans/PREMATURE_COMPLETE_BUG.md | 55 +++++++ aisdk/src/chunk.rs | 61 +++++++- aisdk/src/providers/anthropic.rs | 30 +++- aisdk/src/providers/compatible.rs | 22 ++- aisdk/src/providers/openai.rs | 4 +- aisdk/src/response.rs | 244 ++++++++++++++++++++++++++++-- src/agent/subagent.rs | 2 +- src/llm/client.rs | 12 +- 8 files changed, 400 insertions(+), 30 deletions(-) diff --git a/_plans/PREMATURE_COMPLETE_BUG.md b/_plans/PREMATURE_COMPLETE_BUG.md index b1b4e7d..272dd8a 100644 --- a/_plans/PREMATURE_COMPLETE_BUG.md +++ b/_plans/PREMATURE_COMPLETE_BUG.md @@ -476,3 +476,58 @@ The disconnected-receiver handling correctly treats this as a failed stream, but ### Follow-up - This still does not retry after partial text, reasoning, or tool-call output has already been emitted. Supporting that safely would require resumable provider responses or UI/model de-duplication of replayed deltas. + +## 2026-05-28 Phase-Less Interim Text Recurrence + +### User-Visible Symptom + +During a Sheetpilot landing-page build fix, crabcode stopped after a failed `bun run build` with another progress-update-shaped response: + +> There's a version conflict with `@universal-deploy/node` expecting a newer Vite API. Let me check the dependency tree. + +The task was not complete: the model had just stated the next investigation step and had not inspected the dependency tree. + +### `app.log` Evidence + +Primary session id: `f6ce3q379uwmtmz4jf3dq6i5`. + +Relevant sequence: + +- `02:00:50`: `bash` call `call_130` ran `bun run build` and returned a failed build output. +- `02:00:50`: provider step 40 started with `provider_kind=Anthropic`, `base_url=https://opencode.ai/zen/go`, and `agent_max_steps=None`. +- `02:00:54`: text chunks streamed the progress update above. +- `02:00:54`: AISDK logged `provider_step_finish step=40 has_tool_call=false end_turn=None last_phase=unknown assistant_text_chars=118 action=finish`. +- `02:00:54`: relay summary had `response_completed=0`, `assistant_phase=0`, and all assistant text counted as `unphased`. +- `02:00:54`: crabcode marked the stream complete as `outcome=Exhausted`, `effective_outcome=Finished`, `stop_reason=Some(Finish)`. + +### Root Cause + +This was not the earlier OpenAI Responses case where a preamble was incorrectly emitted in `final_answer`. The Anthropic-compatible transport did not expose Codex-style `assistant_message_phase` or Responses `end_turn`, and crabcode also discarded the provider's native stop/finish reason. That meant AISDK collapsed a phase-less no-tool terminal step into `StopReason::Finish` with no structured way to tell whether this was a final assistant answer or merely a provider message boundary. + +Codex avoids this class when using Responses because completion is anchored on `response.completed` plus message phase/end-turn signals. Opencode keeps finish reasons in its message state instead of collapsing all provider terminal events to the same shape. Crabcode had no equivalent finish-reason preservation for phase-less providers. + +### Fix Applied + +- `aisdk/src/response.rs` + - Removed the prose-based interim-progress classifier. + - Tracks provider finish reasons from terminal chunks and logs `provider_finish_reason=...`. + - Continues once for phase-less no-tool output when tools are available and the terminal reason is not an explicit final-answer stop. This is a structured fallback for providers that lack Codex-style message phases. + - Treats OpenAI-compatible `finish_reason=stop` / `stop_sequence` as explicit final stops, while Anthropic `end_turn` is treated as a provider message boundary unless accompanied by a Codex-style final phase. + - The guard remains bounded to one consecutive follow-up and resets after an actual tool-call step. +- `aisdk/src/chunk.rs` + - Added normalized `FinishReason` values. +- `aisdk/src/providers/anthropic.rs` + - Preserves Anthropic `message_delta.stop_reason` instead of discarding non-error reasons such as `end_turn` and `tool_use`. +- `aisdk/src/providers/compatible.rs` + - Preserves OpenAI-compatible `finish_reason` on terminal chunks. + +### Validation + +- `cargo test -q -p aisdk continues_once_after_phase_less_end_turn_without_final_phase` +- `cargo test -q -p aisdk phase_less_final_text_still_finishes` +- `cargo test -q -p aisdk end_turn_stop_reason_emits_terminal_reason` +- `cargo test -q -p aisdk finish_reason_emits_terminal_chunk` +- `cargo test -q -p aisdk` +- `cargo check` +- `cargo fmt --check` +- `git diff --check` diff --git a/aisdk/src/chunk.rs b/aisdk/src/chunk.rs index 8d08608..21e5155 100644 --- a/aisdk/src/chunk.rs +++ b/aisdk/src/chunk.rs @@ -7,7 +7,7 @@ pub enum ChunkType { AssistantMessagePhase { phase: Option }, ResponseCompleted { end_turn: Option }, Metadata(String), - End(String), + End { reason: Option }, Failed(String), Incomplete(String), NotSupported(String), @@ -18,3 +18,62 @@ pub enum MessagePhase { Commentary, FinalAnswer, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FinishReason { + Stop, + ToolCalls, + Length, + ContentFilter, + Refusal, + EndTurn, + StopSequence, + PauseTurn, + Unknown(String), +} + +impl FinishReason { + pub fn from_openai_compatible(reason: &str) -> Self { + match reason { + "stop" => Self::Stop, + "tool_calls" | "function_call" => Self::ToolCalls, + "length" => Self::Length, + "content_filter" => Self::ContentFilter, + other => Self::Unknown(other.to_string()), + } + } + + pub fn from_anthropic(reason: &str) -> Self { + match reason { + "end_turn" => Self::EndTurn, + "tool_use" => Self::ToolCalls, + "max_tokens" => Self::Length, + "stop_sequence" => Self::StopSequence, + "pause_turn" => Self::PauseTurn, + "refusal" => Self::Refusal, + other => Self::Unknown(other.to_string()), + } + } + + pub fn label(&self) -> &str { + match self { + Self::Stop => "stop", + Self::ToolCalls => "tool_calls", + Self::Length => "length", + Self::ContentFilter => "content_filter", + Self::Refusal => "refusal", + Self::EndTurn => "end_turn", + Self::StopSequence => "stop_sequence", + Self::PauseTurn => "pause_turn", + Self::Unknown(reason) => reason.as_str(), + } + } + + /// True when a phase-less provider gave a stop reason that is strong + /// enough to accept as a final assistant response without another agent + /// loop step. Anthropic `end_turn` is intentionally excluded: it marks the + /// provider message boundary, not a Codex-style final-answer phase. + pub fn is_final_assistant_stop(&self) -> bool { + matches!(self, Self::Stop | Self::StopSequence) + } +} diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs index d909853..48595a1 100644 --- a/aisdk/src/providers/anthropic.rs +++ b/aisdk/src/providers/anthropic.rs @@ -1,4 +1,4 @@ -use crate::chunk::ChunkType; +use crate::chunk::{ChunkType, FinishReason}; use crate::error::{Error, Result}; use crate::message::Message; use crate::provider::{Provider, ProviderStream}; @@ -246,7 +246,7 @@ fn anthropic_stream_chunk( .map(Ok), "content_block_delta" => anthropic_content_block_delta(value).map(Ok), "message_delta" => anthropic_message_delta(value).map(Ok), - "message_stop" => Some(Ok(ChunkType::End(String::new()))), + "message_stop" => Some(Ok(ChunkType::End { reason: None })), "error" => { let error_msg = value["error"]["message"] .as_str() @@ -287,7 +287,9 @@ fn anthropic_message_delta(value: &serde_json::Value) -> Option { match stop_reason { "max_tokens" => Some(ChunkType::Incomplete("stop_reason=max_tokens".to_string())), "refusal" => Some(ChunkType::Failed("stop_reason=refusal".to_string())), - _ => None, + reason => Some(ChunkType::End { + reason: Some(FinishReason::from_anthropic(reason)), + }), } } @@ -493,7 +495,7 @@ mod tests { .expect("event should produce a chunk") .expect("chunk should parse"); - assert!(matches!(chunk, ChunkType::End(_))); + assert!(matches!(chunk, ChunkType::End { reason: None })); } #[test] @@ -510,6 +512,26 @@ mod tests { assert!(matches!(chunk, ChunkType::Incomplete(_))); } + + #[test] + fn end_turn_stop_reason_emits_terminal_reason() { + let value = serde_json::json!({ + "type": "message_delta", + "delta": { + "stop_reason": "end_turn", + }, + }); + let chunk = anthropic_stream_chunk("message_delta", &value) + .expect("event should produce a chunk") + .expect("chunk should parse"); + + assert!(matches!( + chunk, + ChunkType::End { + reason: Some(FinishReason::EndTurn) + } + )); + } } fn anthropic_tool_output_content(tool: &crate::message::ToolOutputMessage) -> serde_json::Value { diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index cc08859..291c65b 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -1,4 +1,4 @@ -use crate::chunk::ChunkType; +use crate::chunk::{ChunkType, FinishReason}; use crate::error::{Error, Result}; use crate::message::Message; use crate::provider::{Provider, ProviderStream}; @@ -289,7 +289,7 @@ fn process_sse_data(data: &str) -> Vec> { if data == "[DONE]" { debug_log("[SSE] Terminal: [DONE]"); - return vec![Ok(ChunkType::End(String::new()))]; + return vec![Ok(ChunkType::End { reason: None })]; } if data.is_empty() || is_sse_metadata_line(data) { @@ -397,7 +397,9 @@ fn process_sse_data(data: &str) -> Vec> { "content_filter" => chunks.push(Ok(ChunkType::Failed( "finish_reason=content_filter".to_string(), ))), - _ => chunks.push(Ok(ChunkType::End(String::new()))), + _ => chunks.push(Ok(ChunkType::End { + reason: Some(FinishReason::from_openai_compatible(finish_reason)), + })), } if chunks.is_empty() { @@ -459,7 +461,10 @@ mod tests { fn done_marker_emits_terminal_chunk() { let chunks = process_sse_data("[DONE]"); - assert!(matches!(chunks.as_slice(), [Ok(ChunkType::End(_))])); + assert!(matches!( + chunks.as_slice(), + [Ok(ChunkType::End { reason: None })] + )); } #[test] @@ -468,9 +473,12 @@ mod tests { let chunks = process_sse_data(data); - assert!(chunks - .iter() - .any(|chunk| matches!(chunk, Ok(ChunkType::End(_))))); + assert!(chunks.iter().any(|chunk| matches!( + chunk, + Ok(ChunkType::End { + reason: Some(FinishReason::Stop) + }) + ))); } #[test] diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs index 40ad6c5..50536e0 100644 --- a/aisdk/src/providers/openai.rs +++ b/aisdk/src/providers/openai.rs @@ -1055,7 +1055,7 @@ fn error_source_chain(err: &(dyn StdError + 'static)) -> String { fn response_sse_data_to_chunk(data: &str) -> Option> { if data == "[DONE]" { - return Some(Ok(ChunkType::End(String::new()))); + return Some(Ok(ChunkType::End { reason: None })); } if data.is_empty() { return None; @@ -1378,7 +1378,7 @@ mod tests { fn done_marker_emits_terminal_chunk() { let chunk = response_sse_data_to_chunk("[DONE]").expect("expected terminal chunk"); - assert!(matches!(chunk, Ok(ChunkType::End(_)))); + assert!(matches!(chunk, Ok(ChunkType::End { .. }))); } #[test] diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 3a56e2f..981283b 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -1,4 +1,4 @@ -use crate::chunk::{ChunkType, MessagePhase}; +use crate::chunk::{ChunkType, FinishReason, MessagePhase}; use crate::error::Result; use crate::message::Message; use crate::provider::Provider; @@ -10,6 +10,8 @@ use std::pin::Pin; use std::sync::Arc; use tokio::sync::mpsc; +const PHASELESS_AMBIGUOUS_FOLLOW_UP_LIMIT: usize = 1; + pub struct StreamTextResponse { pub stream: LanguageModelStream, stop_reason: Arc>>, @@ -83,6 +85,7 @@ pub async fn stream_with_tools( let mut step_idx: usize = 0; let max_steps = max_steps.unwrap_or(usize::MAX); let mut cached_repeatable_tool_results: HashMap = HashMap::new(); + let mut phase_less_ambiguous_follow_ups = 0usize; loop { step_idx += 1; @@ -135,6 +138,7 @@ pub async fn stream_with_tools( let mut accumulated_text = String::new(); let mut saw_terminal_event = false; let mut response_end_turn = None; + let mut provider_finish_reason = None; let mut last_assistant_message_phase = None; let mut current_assistant_message_phase = None; @@ -172,12 +176,19 @@ pub async fn stream_with_tools( return; } } - Ok(ChunkType::End(_content)) => { + Ok(ChunkType::End { reason }) => { // Processed internally — NOT forwarded to tx_loop. // Forwarding End would cause relay_stream_to_sender // to return Ended prematurely, dropping the channel // before tool execution / subsequent steps. saw_terminal_event = true; + if let Some(reason) = reason { + let label = reason.label().to_string(); + provider_finish_reason = Some(reason); + let _ = tx_loop.send(ChunkType::Metadata(format!( + "provider_finish_reason={label}" + ))); + } } Ok(ChunkType::Metadata(msg)) => { let _ = tx_loop.send(ChunkType::Metadata(msg)); @@ -227,16 +238,29 @@ pub async fn stream_with_tools( let end_turn_requires_follow_up = matches!(response_end_turn, Some(false)); let commentary_requires_follow_up = matches!(last_assistant_message_phase, Some(MessagePhase::Commentary)); - let needs_follow_up = end_turn_requires_follow_up || commentary_requires_follow_up; + let phase_less_ambiguous_requires_follow_up = !tools.is_empty() + && response_end_turn.is_none() + && last_assistant_message_phase.is_none() + && phase_less_ambiguous_follow_ups < PHASELESS_AMBIGUOUS_FOLLOW_UP_LIMIT + && provider_finish_reason + .as_ref() + .is_none_or(|reason| !reason.is_final_assistant_stop()); + let needs_follow_up = end_turn_requires_follow_up + || commentary_requires_follow_up + || phase_less_ambiguous_requires_follow_up; let action = if needs_follow_up { "continue" } else { "finish" }; let _ = tx_loop.send(ChunkType::Metadata(format!( - "provider_step_finish step={} has_tool_call=false end_turn={:?} last_phase={} assistant_text_chars={} action={} preview={:?}", + "provider_step_finish step={} has_tool_call=false end_turn={:?} provider_finish_reason={} last_phase={} assistant_text_chars={} action={} preview={:?}", step_idx, response_end_turn, + provider_finish_reason + .as_ref() + .map(FinishReason::label) + .unwrap_or("unknown"), message_phase_label(last_assistant_message_phase), assistant_text.len(), action, @@ -246,6 +270,9 @@ pub async fn stream_with_tools( if needs_follow_up { let reason = if end_turn_requires_follow_up { "end_turn=false" + } else if phase_less_ambiguous_requires_follow_up { + phase_less_ambiguous_follow_ups += 1; + "phase_less_terminal_without_final_signal" } else { "assistant_message_phase=commentary" }; @@ -259,6 +286,8 @@ pub async fn stream_with_tools( break; } + phase_less_ambiguous_follow_ups = 0; + let tool_calls_to_execute = match tool_call_accumulator.finish() { Ok(tool_calls) if !tool_calls.is_empty() => tool_calls, Ok(_) => { @@ -852,7 +881,7 @@ fn tool_call_key(item: &serde_json::Value, array_index: usize) -> String { #[cfg(test)] mod tests { use super::{stream_with_tools, ToolCallAccumulator}; - use crate::chunk::{ChunkType, MessagePhase}; + use crate::chunk::{ChunkType, FinishReason, MessagePhase}; use crate::message::Message; use crate::provider::{Provider, ProviderStream}; use crate::stop::StopReason; @@ -886,6 +915,16 @@ mod tests { requests: Arc, } + #[derive(Debug, Clone)] + struct PhaselessAmbiguousProvider { + requests: Arc, + } + + #[derive(Debug, Clone)] + struct PhaselessFinalProvider { + requests: Arc, + } + #[derive(Debug, Clone)] struct RecoveringToolFailureProvider { requests: Arc, @@ -915,12 +954,16 @@ mod tests { r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"wait","arguments":"{\"id\":1}"}},{"index":1,"id":"call_2","type":"function","function":{"name":"wait","arguments":"{\"id\":2}"}}]"# .to_string(), )), - Ok(ChunkType::End(String::new())), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), ] } else { vec![ Ok(ChunkType::Text("done".to_string())), - Ok(ChunkType::End(String::new())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), ] }; @@ -951,11 +994,15 @@ mod tests { r#"[{"index":0,"id":"call_repeat","type":"function","function":{"name":"task","arguments":"{\"description\":\"Write haiku\",\"prompt\":\"Write a haiku\",\"subagent_type\":\"general\"}"}}]"# .to_string(), )), - Ok(ChunkType::End(String::new())), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), ], _ => vec![ Ok(ChunkType::Text("done".to_string())), - Ok(ChunkType::End(String::new())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), ], }; @@ -1028,6 +1075,77 @@ mod tests { } } + #[async_trait] + impl Provider for PhaselessAmbiguousProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap, + ) -> crate::error::Result { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = match request { + 0 => vec![ + Ok(ChunkType::Text("Dependency conflict found.".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::EndTurn), + }), + ], + 1 => vec![ + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_list","type":"function","function":{"name":"list","arguments":"{\"path\":\".\"}"}}]"# + .to_string(), + )), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), + ], + _ => vec![ + Ok(ChunkType::Text("Done.".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), + ], + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + + #[async_trait] + impl Provider for PhaselessFinalProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap, + ) -> crate::error::Result { + self.requests.fetch_add(1, Ordering::SeqCst); + Ok(Box::pin(futures::stream::iter(vec![ + Ok(ChunkType::Text("Done. Build now passes.".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), + ]))) + } + } + #[async_trait] impl Provider for RecoveringToolFailureProvider { fn name(&self) -> &str { @@ -1051,7 +1169,9 @@ mod tests { r#"[{"index":0,"id":"call_edit","type":"function","function":{"name":"edit","arguments":"{\"file_path\":\"src/lib.rs\",\"old_string\":\"missing\",\"new_string\":\"replacement\"}"}}]"# .to_string(), )), - Ok(ChunkType::End(String::new())), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), ] } else { let follow_up = messages @@ -1065,7 +1185,9 @@ mod tests { vec![ Ok(ChunkType::Text("recovered".to_string())), - Ok(ChunkType::End(String::new())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), ] }; @@ -1257,6 +1379,106 @@ mod tests { assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); } + #[tokio::test] + async fn continues_once_after_phase_less_end_turn_without_final_phase() { + let provider = PhaselessAmbiguousProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + let executions = Arc::new(AtomicUsize::new(0)); + + let list_executions = executions.clone(); + let list_tool = Tool::builder() + .name("list") + .description("list files") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(move |_input| { + let list_executions = list_executions.clone(); + async move { + list_executions.fetch_add(1, Ordering::SeqCst); + Ok("package.json\nbun.lock".to_string()) + } + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("fix the build")], + vec![list_tool], + Some(5), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + let mut continuation_logged = false; + let mut finish_reason_logged = false; + while let Some(chunk) = response.stream.next().await { + match chunk { + ChunkType::Text(delta) => text.push_str(&delta), + ChunkType::Metadata(message) + if message.contains("phase_less_terminal_without_final_signal") => + { + continuation_logged = true; + } + ChunkType::Metadata(message) + if message.contains("provider_finish_reason=end_turn") => + { + finish_reason_logged = true; + } + _ => {} + } + } + + assert_eq!(text, "Dependency conflict found.Done."); + assert!(continuation_logged); + assert!(finish_reason_logged); + assert_eq!(provider.requests.load(Ordering::SeqCst), 3); + assert_eq!(executions.load(Ordering::SeqCst), 1); + assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); + } + + #[tokio::test] + async fn phase_less_final_text_still_finishes() { + let provider = PhaselessFinalProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + + let noop_tool = Tool::builder() + .name("noop") + .description("noop") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new( + |_input| async move { Ok("ok".to_string()) }, + )) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("fix the build")], + vec![noop_tool], + Some(5), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + while let Some(chunk) = response.stream.next().await { + if let ChunkType::Text(delta) = chunk { + text.push_str(&delta); + } + } + + assert_eq!(text, "Done. Build now passes."); + assert_eq!(provider.requests.load(Ordering::SeqCst), 1); + assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); + } + #[tokio::test] async fn max_steps_allows_exact_configured_step_count() { let provider = FollowUpProvider { diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index bf8d14a..dcc2799 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -272,7 +272,7 @@ pub async fn run_subagent( } return Err(format!("Subagent streaming failed: {}", err)); } - ChunkType::End(_) => { + ChunkType::End { .. } => { break; } ChunkType::ResponseCompleted { .. } => { diff --git a/src/llm/client.rs b/src/llm/client.rs index b124e12..a49e8a7 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -567,7 +567,7 @@ pub async fn summarize_for_compaction( } ChunkType::Reasoning(_) | ChunkType::ToolCall(_) - | ChunkType::End(_) + | ChunkType::End { .. } | ChunkType::AssistantMessagePhase { .. } | ChunkType::ResponseCompleted { .. } | ChunkType::Metadata(_) @@ -1059,12 +1059,16 @@ async fn relay_stream_to_sender( tool_call.len(), ); } - ChunkType::End(_msg) => { + ChunkType::End { reason } => { let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("End", elapsed_ms); + let reason = reason + .as_ref() + .map(|reason| reason.label()) + .unwrap_or("unknown"); crate::emit_log!( - "[RELAY] End chunk — returning Ended {}", - stats.describe_at(Some(elapsed_ms)) + "[RELAY] End chunk reason={reason} — returning Ended {}", + stats.describe_at(Some(elapsed_ms)), ); let duration_ms = elapsed_ms as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { From 551ab6762ca24fe0604f4f4e2bdf8c2e94d1f7ea Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 02:59:26 +0800 Subject: [PATCH 176/226] feat: handle text-only models when images are attached. Propagate `supports_image_input` flag through LLM config, client, and tool bridge to detect model image capabilities via modalities or attachment fields. When a model doesn't support image input, convert image attachments to text error notes instead of failing silently, and skip empty assistant messages.feat(llm): support text-only models by converting images to error notes Add `supports_image_input` flag to LLM session config and propagate it through the client and tool bridge. The flag is determined by checking model modalities or attachment fields from discovery data. When a model doesn't support image input, image attachments and view_image tool outputs are converted to text error notes instead of being sent as images. Also filters out empty assistant messages before sending to the provider. --- src/agent/config.rs | 1 + src/agent/subagent.rs | 1 + src/llm/client.rs | 203 ++++++++++++++++++++++++++++++++++++-- src/tools/aisdk_bridge.rs | 23 ++++- 4 files changed, 215 insertions(+), 13 deletions(-) diff --git a/src/agent/config.rs b/src/agent/config.rs index 2a6b844..60b9e97 100644 --- a/src/agent/config.rs +++ b/src/agent/config.rs @@ -8,6 +8,7 @@ pub struct LlmSessionConfig { pub provider_kind: ProviderKind, pub base_url: String, pub reasoning_effort: Option, + pub supports_image_input: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index dcc2799..6f8d4cb 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -156,6 +156,7 @@ pub async fn run_subagent( permissions, Some(session_id.clone()), None, + session.supports_image_input, ) .await; diff --git a/src/llm/client.rs b/src/llm/client.rs index a49e8a7..68025de 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -51,6 +51,7 @@ struct ProviderRequestConfig { model_name: String, api_key: Option, reasoning_effort: Option, + supports_image_input: bool, openai_options: OpenAIRequestOptions, } @@ -62,6 +63,7 @@ impl ProviderRequestConfig { model_name: String, api_key: Option, reasoning_effort: Option, + supports_image_input: bool, ) -> Self { Self { kind, @@ -70,6 +72,7 @@ impl ProviderRequestConfig { model_name, api_key, reasoning_effort, + supports_image_input, openai_options: OpenAIRequestOptions::default(), } } @@ -378,7 +381,7 @@ pub async fn stream_llm_with_cancellation( let request_config = prepare_request_config(&provider_name, model, reasoning_effort, &sender).await?; - let aisdk_messages = convert_messages(&messages); + let aisdk_messages = convert_messages_for_model(&messages, request_config.supports_image_input); let tool_registry = crate::tools::initialize_tool_registry().await; @@ -396,6 +399,7 @@ pub async fn stream_llm_with_cancellation( }, base_url: request_config.base_url.clone(), reasoning_effort: request_config.reasoning_effort, + supports_image_input: request_config.supports_image_input, }); let aisdk_tools = convert_to_aisdk_tools( @@ -405,6 +409,7 @@ pub async fn stream_llm_with_cancellation( tool_permissions, Some(session_id.clone()), None, + request_config.supports_image_input, ) .await; @@ -605,6 +610,7 @@ async fn prepare_request_config( .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", provider_name))? }; + let supports_image_input = model_supports_image_input(provider.models.get(&model)); let model_route = resolve_model_route(&provider, model); let provider_kind = ProviderKind::from_provider(provider_name, &model_route.npm_package); let mut request_config = ProviderRequestConfig::new( @@ -614,6 +620,7 @@ async fn prepare_request_config( model_route.model_name, configured_api_key(auth_config.as_ref()), reasoning_effort, + supports_image_input, ); maybe_apply_openai_oauth_overrides( @@ -637,11 +644,16 @@ async fn prepare_request_config( } crate::emit_log!( - "Provider: {}, NPM: {}, Base URL: {}, Model: {}", + "Provider: {}, NPM: {}, Base URL: {}, Model: {}, Image Input: {}", provider_name, model_route.npm_package, request_config.base_url, - request_config.model_name + request_config.model_name, + if request_config.supports_image_input { + "supported" + } else { + "unsupported" + } ); Ok(request_config) @@ -687,6 +699,18 @@ fn resolve_model_route( } } +fn model_supports_image_input(model: Option<&crate::model::discovery::Model>) -> bool { + let Some(model) = model else { + return true; + }; + + if let Some(modalities) = model.modalities.as_ref() { + return modalities.input.iter().any(|item| item == "image"); + } + + model.attachment +} + fn configured_api_key(auth_config: Option<&crate::persistence::AuthConfig>) -> Option { auth_config.and_then(|config| match config { crate::persistence::AuthConfig::Api { key } => Some(key.clone()), @@ -1173,6 +1197,13 @@ fn estimate_tokens(content: &str) -> usize { } fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { + convert_messages_for_model(messages, true) +} + +fn convert_messages_for_model( + messages: &[crate::session::types::Message], + supports_image_input: bool, +) -> Vec { let mut aisdk_messages = Vec::new(); for msg in messages { @@ -1185,6 +1216,14 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { + if !supports_image_input && !msg.local_image_paths.is_empty() { + aisdk_messages.push(AisdkMessage::user(content_with_unsupported_image_note( + &msg.content, + msg.local_image_paths.len(), + ))); + continue; + } + let images = msg .local_image_paths .iter() @@ -1215,10 +1254,15 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { + if msg.content.trim().is_empty() { + continue; + } aisdk_messages.push(AisdkMessage::assistant(msg.content.clone())); } crate::session::types::MessageRole::Tool => { - if let Some(tool_messages) = tool_messages_for_model(&msg.content) { + if let Some(tool_messages) = + tool_messages_for_model(&msg.content, supports_image_input) + { aisdk_messages.extend(tool_messages); } else { aisdk_messages.push(AisdkMessage::user(tool_message_observation(&msg.content))); @@ -1230,7 +1274,7 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec Option> { +fn tool_messages_for_model(content: &str, supports_image_input: bool) -> Option> { let value = serde_json::from_str::(content).ok()?; let obj = value.as_object()?; @@ -1255,11 +1299,16 @@ fn tool_messages_for_model(content: &str) -> Option> { let status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("ok"); let is_error = status.eq_ignore_ascii_case("error"); - let images = if name == "view_image" && !is_error { + let images = if name == "view_image" && !is_error && supports_image_input { view_image_tool_images(obj) } else { Vec::new() }; + let output = if name == "view_image" && !is_error && !supports_image_input { + content_with_unsupported_image_note(output, 1) + } else { + output.to_string() + }; Some(vec![ AisdkMessage::tool_call(call_id, name, arguments), @@ -1267,6 +1316,19 @@ fn tool_messages_for_model(content: &str) -> Option> { ]) } +fn content_with_unsupported_image_note(content: &str, image_count: usize) -> String { + let image_label = if image_count == 1 { "image" } else { "images" }; + let note = format!( + "ERROR: Cannot read {image_label} (this model does not support image input). Inform the user." + ); + + if content.trim().is_empty() { + note + } else { + format!("{content}\n\n{note}") + } +} + fn view_image_tool_images(obj: &serde_json::Map) -> Vec { let path = obj .get("metadata") @@ -1416,8 +1478,9 @@ fn normalize_anthropic_base_url(base_url: &str) -> String { #[cfg(test)] mod tests { use super::{ - convert_messages, is_openai_oauth_model_allowed, openai_request_instructions, - resolve_model_route, AisdkMessage, OpenAIRequestOptions, ProviderKind, + convert_messages, convert_messages_for_model, is_openai_oauth_model_allowed, + model_supports_image_input, openai_request_instructions, resolve_model_route, AisdkMessage, + OpenAIRequestOptions, ProviderKind, }; #[test] @@ -1577,6 +1640,130 @@ mod tests { } } + #[test] + fn empty_assistant_messages_are_not_sent_to_provider() { + let messages = convert_messages(&[ + crate::session::types::Message::system("system"), + crate::session::types::Message::user("prompt"), + crate::session::types::Message::assistant(""), + crate::session::types::Message::assistant(" \n\t"), + crate::session::types::Message::assistant("answer"), + ]); + + assert_eq!(messages.len(), 3); + match &messages[0] { + AisdkMessage::System(message) => assert_eq!(message.content, "system"), + other => panic!("expected system message, got {other:?}"), + } + match &messages[1] { + AisdkMessage::User(message) => assert_eq!(message.content, "prompt"), + other => panic!("expected user message, got {other:?}"), + } + match &messages[2] { + AisdkMessage::Assistant(message) => assert_eq!(message.content, "answer"), + other => panic!("expected assistant message, got {other:?}"), + } + } + + #[test] + fn user_images_become_text_note_for_text_only_model() { + let mut user_message = crate::session::types::Message::user("what is in this?"); + user_message.local_image_paths = vec!["/tmp/example.png".to_string()]; + + let messages = convert_messages_for_model(&[user_message], false); + + assert_eq!(messages.len(), 1); + match &messages[0] { + AisdkMessage::User(message) => { + assert!(message.images.is_empty()); + assert!(message.content.contains("what is in this?")); + assert!(message + .content + .contains("this model does not support image input")); + } + other => panic!("expected user message, got {other:?}"), + } + } + + #[test] + fn view_image_tool_history_becomes_text_note_for_text_only_model() { + let tool_message = crate::session::types::Message::tool( + serde_json::json!({ + "name": "view_image", + "status": "ok", + "id": "call_view_image", + "args": { + "path": "/tmp/example.png" + }, + "metadata": { + "path": "/tmp/example.png" + }, + "output_preview": "Viewed image /tmp/example.png (2x1, image/png)" + }) + .to_string(), + ); + + let messages = convert_messages_for_model(&[tool_message], false); + + assert_eq!(messages.len(), 2); + match &messages[1] { + AisdkMessage::ToolOutput(output) => { + assert!(output.images.is_empty()); + assert!(output.output.contains("Viewed image /tmp/example.png")); + assert!(output + .output + .contains("this model does not support image input")); + } + other => panic!("expected tool output, got {other:?}"), + } + } + + #[test] + fn model_image_input_support_uses_modalities_then_attachment() { + let image_model: crate::model::discovery::Model = + serde_json::from_value(serde_json::json!({ + "id": "vision", + "name": "Vision", + "attachment": false, + "modalities": { + "input": ["text", "image"], + "output": ["text"] + } + })) + .unwrap(); + let text_model: crate::model::discovery::Model = + serde_json::from_value(serde_json::json!({ + "id": "text", + "name": "Text", + "attachment": true, + "modalities": { + "input": ["text"], + "output": ["text"] + } + })) + .unwrap(); + let attachment_model: crate::model::discovery::Model = + serde_json::from_value(serde_json::json!({ + "id": "legacy-vision", + "name": "Legacy Vision", + "attachment": true + })) + .unwrap(); + let no_attachment_model: crate::model::discovery::Model = + serde_json::from_value(serde_json::json!({ + "id": "legacy-text", + "name": "Legacy Text", + "attachment": false + })) + .unwrap(); + + assert!(model_supports_image_input(Some(&image_model))); + assert!(!model_supports_image_input(Some(&text_model))); + assert!(model_supports_image_input(Some(&attachment_model))); + assert!(!model_supports_image_input(Some(&no_attachment_model))); + assert!(model_supports_image_input(None)); + } + #[test] fn compaction_marker_is_not_sent_to_model() { let stats = crate::session::types::CompactionStats { diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index afbb54a..bad39e7 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -20,6 +20,7 @@ pub async fn convert_to_aisdk_tools( permissions: crate::tools::ToolPermissions, session_id: Option, message_id: Option, + supports_image_input: bool, ) -> Vec { let mut aisdk_tools = Vec::new(); let tools = registry.list().await; @@ -53,6 +54,7 @@ pub async fn convert_to_aisdk_tools( let permissions = permissions.clone(); let session_id = session_id.clone(); let message_id = message_id.clone(); + let supports_image_input = supports_image_input; async move { let call_seq = TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1; @@ -202,11 +204,15 @@ pub async fn convert_to_aisdk_tools( media_type: image.media_type.clone(), }) .collect::>(); - let model_output = ToolOutput::new(truncate_tool_output( - &tool_result.output, - TOOL_MODEL_OUTPUT_LIMIT, - )) - .with_images(model_images); + let mut model_output_text = + truncate_tool_output(&tool_result.output, TOOL_MODEL_OUTPUT_LIMIT); + let model_output = if supports_image_input || model_images.is_empty() { + ToolOutput::new(model_output_text).with_images(model_images) + } else { + model_output_text.push_str("\n\n"); + model_output_text.push_str(&unsupported_image_input_note(model_images.len())); + ToolOutput::new(model_output_text) + }; if let Some(ref sender) = sender { let preview = truncate_tool_output(&tool_result.output, TOOL_UI_PREVIEW_LIMIT); @@ -314,6 +320,13 @@ fn truncate_tool_output(output: &str, limit: usize) -> String { truncated } +fn unsupported_image_input_note(image_count: usize) -> String { + let image_label = if image_count == 1 { "image" } else { "images" }; + format!( + "ERROR: Cannot read {image_label} (this model does not support image input). Inform the user." + ) +} + fn send_tool_error_result( sender: Option<&ChunkSender>, call_id: &str, From 2cb074c3e44533080364ec9f540169175b6731eb Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 03:42:46 +0800 Subject: [PATCH 177/226] feat(aisdk): add reasoning_content support to tool call messages. Adds a `reasoning_content` field to `ToolCallMessage` and introduces new constructor variants (`tool_call_with_reasoning`, `tool_call_with_item_id_and_reasoning`) to propagate reasoning text from streaming chunks into tool call messages. The OpenAI-compatible provider serializes this field and injects an empty `reasoning_content` string for providers like Kimi/Moonshot that require it.feat(aisdk): add reasoning_content support to tool call messages Adds a `reasoning_content` field to `ToolCallMessage` and introduces new constructor variants (`tool_call_with_reasoning`, `tool_call_with_item_id_and_reasoning`) to propagate reasoning text from streaming chunks into tool call messages. The OpenAI-compatible provider serializes this field and injects an empty `reasoning_content` string for providers like Kimi/Moonshot that require it. --- aisdk/src/message.rs | 35 ++++++++++ aisdk/src/providers/compatible.rs | 90 ++++++++++++++++++++---- aisdk/src/response.rs | 110 +++++++++++++++++++++++++++++- 3 files changed, 220 insertions(+), 15 deletions(-) diff --git a/aisdk/src/message.rs b/aisdk/src/message.rs index a25c81c..b23e393 100644 --- a/aisdk/src/message.rs +++ b/aisdk/src/message.rs @@ -52,6 +52,7 @@ impl Message { call_id: call_id.into(), name: name.into(), arguments: arguments.into(), + reasoning_content: None, }) } @@ -66,6 +67,38 @@ impl Message { call_id: call_id.into(), name: name.into(), arguments: arguments.into(), + reasoning_content: None, + }) + } + + pub fn tool_call_with_reasoning( + call_id: impl Into, + name: impl Into, + arguments: impl Into, + reasoning_content: impl Into, + ) -> Self { + Self::ToolCall(ToolCallMessage { + item_id: None, + call_id: call_id.into(), + name: name.into(), + arguments: arguments.into(), + reasoning_content: Some(reasoning_content.into()), + }) + } + + pub fn tool_call_with_item_id_and_reasoning( + item_id: impl Into, + call_id: impl Into, + name: impl Into, + arguments: impl Into, + reasoning_content: impl Into, + ) -> Self { + Self::ToolCall(ToolCallMessage { + item_id: Some(item_id.into()), + call_id: call_id.into(), + name: name.into(), + arguments: arguments.into(), + reasoning_content: Some(reasoning_content.into()), }) } @@ -125,6 +158,8 @@ pub struct ToolCallMessage { pub call_id: String, pub name: String, pub arguments: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index 291c65b..79e7ced 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -100,6 +100,8 @@ impl Provider for OpenAICompatible { format!("{}/v1/chat/completions", base) }; + let include_empty_tool_call_reasoning = + openai_compatible_requires_tool_call_reasoning_content(self); let chat_messages: Vec = messages .iter() .flat_map(|m| match m { @@ -115,18 +117,10 @@ impl Provider for OpenAICompatible { "role": "assistant", "content": a.content, })], - Message::ToolCall(t) => vec![serde_json::json!({ - "role": "assistant", - "content": serde_json::Value::Null, - "tool_calls": [{ - "id": t.call_id, - "type": "function", - "function": { - "name": t.name, - "arguments": t.arguments, - } - }], - })], + Message::ToolCall(t) => vec![openai_compatible_tool_call_message( + t, + include_empty_tool_call_reasoning, + )], Message::ToolOutput(t) => openai_compatible_tool_output_messages(t), }) .collect(); @@ -228,6 +222,40 @@ fn openai_compatible_user_content(user: &crate::message::UserMessage) -> serde_j serde_json::Value::Array(parts) } +fn openai_compatible_tool_call_message( + tool: &crate::message::ToolCallMessage, + include_empty_reasoning_content: bool, +) -> serde_json::Value { + let mut message = serde_json::json!({ + "role": "assistant", + "content": serde_json::Value::Null, + "tool_calls": [{ + "id": tool.call_id, + "type": "function", + "function": { + "name": tool.name, + "arguments": tool.arguments, + } + }], + }); + + if let Some(reasoning_content) = &tool.reasoning_content { + message["reasoning_content"] = serde_json::Value::String(reasoning_content.clone()); + } else if include_empty_reasoning_content { + message["reasoning_content"] = serde_json::Value::String(String::new()); + } + + message +} + +fn openai_compatible_requires_tool_call_reasoning_content(provider: &OpenAICompatible) -> bool { + let model = provider.model_name.to_ascii_lowercase(); + let provider_name = provider.provider_name.to_ascii_lowercase(); + let base_url = provider.base_url.to_ascii_lowercase(); + + model.contains("kimi") || provider_name.contains("moonshot") || base_url.contains("moonshot") +} + fn openai_compatible_tool_output_messages( tool: &crate::message::ToolOutputMessage, ) -> Vec { @@ -457,6 +485,44 @@ mod tests { assert!(chunks.is_empty()); } + #[test] + fn tool_call_message_preserves_reasoning_content() { + let message = Message::tool_call_with_reasoning( + "call_1", + "read", + r#"{"file_path":"src/lib.rs"}"#, + "plan", + ); + let Message::ToolCall(tool) = message else { + panic!("expected tool call message"); + }; + + let payload = openai_compatible_tool_call_message(&tool, false); + + assert_eq!(payload["reasoning_content"], "plan"); + } + + #[test] + fn kimi_tool_call_message_includes_empty_reasoning_content_fallback() { + let provider = OpenAICompatible::builder() + .base_url("https://opencode.ai/zen/go/v1") + .model_name("kimi-k2.6") + .provider_name("OpenCode Go") + .build() + .unwrap(); + let message = Message::tool_call("call_1", "read", r#"{"file_path":"src/lib.rs"}"#); + let Message::ToolCall(tool) = message else { + panic!("expected tool call message"); + }; + + let payload = openai_compatible_tool_call_message( + &tool, + openai_compatible_requires_tool_call_reasoning_content(&provider), + ); + + assert_eq!(payload["reasoning_content"], ""); + } + #[test] fn done_marker_emits_terminal_chunk() { let chunks = process_sse_data("[DONE]"); diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs index 981283b..45ced0d 100644 --- a/aisdk/src/response.rs +++ b/aisdk/src/response.rs @@ -136,6 +136,7 @@ pub async fn stream_with_tools( let mut has_tool_call = false; let mut tool_call_accumulator = ToolCallAccumulator::default(); let mut accumulated_text = String::new(); + let mut accumulated_reasoning = String::new(); let mut saw_terminal_event = false; let mut response_end_turn = None; let mut provider_finish_reason = None; @@ -165,6 +166,7 @@ pub async fn stream_with_tools( let _ = tx_loop.send(ChunkType::Text(text)); } Ok(ChunkType::Reasoning(reasoning)) => { + accumulated_reasoning.push_str(&reasoning); let _ = tx_loop.send(ChunkType::Reasoning(reasoning)); } Ok(ChunkType::ToolCall(json_str)) => { @@ -312,15 +314,32 @@ pub async fn stream_with_tools( let tool_name = tool_call.name; let args = tool_call.arguments; let arguments = canonical_json(&args); - let tool_call_message = if let Some(item_id) = tool_call.item_id { - Message::tool_call_with_item_id( + let tool_call_message = if accumulated_reasoning.is_empty() { + if let Some(item_id) = tool_call.item_id { + Message::tool_call_with_item_id( + item_id, + call_id.clone(), + tool_name.clone(), + arguments, + ) + } else { + Message::tool_call(call_id.clone(), tool_name.clone(), arguments) + } + } else if let Some(item_id) = tool_call.item_id { + Message::tool_call_with_item_id_and_reasoning( item_id, call_id.clone(), tool_name.clone(), arguments, + accumulated_reasoning.clone(), ) } else { - Message::tool_call(call_id.clone(), tool_name.clone(), arguments) + Message::tool_call_with_reasoning( + call_id.clone(), + tool_name.clone(), + arguments, + accumulated_reasoning.clone(), + ) }; current_messages.push(tool_call_message.clone()); tool_call_messages.push(tool_call_message); @@ -902,6 +921,11 @@ mod tests { requests: Arc, } + #[derive(Debug, Clone)] + struct ReasoningToolCallProvider { + requests: Arc, + } + #[derive(Debug, Clone)] struct RepeatingTaskProvider { requests: Arc, @@ -971,6 +995,47 @@ mod tests { } } + #[async_trait] + impl Provider for ReasoningToolCallProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap, + ) -> crate::error::Result { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = if request == 0 { + vec![ + Ok(ChunkType::Reasoning("inspect the file".to_string())), + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_read","type":"function","function":{"name":"read","arguments":"{\"file_path\":\"src/lib.rs\"}"}}]"# + .to_string(), + )), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), + ] + } else { + vec![ + Ok(ChunkType::Text("done".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), + ] + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + #[async_trait] impl Provider for RepeatingTaskProvider { fn name(&self) -> &str { @@ -1261,6 +1326,45 @@ mod tests { assert!(observations.iter().any(|output| output.call_id == "call_2")); } + #[tokio::test] + async fn preserves_reasoning_content_on_tool_call_history() { + let provider = ReasoningToolCallProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + let read_tool = Tool::builder() + .name("read") + .description("read a file") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(|_input| async move { + Ok("file contents".to_string()) + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider, + vec![Message::user("inspect")], + vec![read_tool], + Some(3), + None, + HashMap::new(), + ) + .await + .unwrap(); + + while response.stream.next().await.is_some() {} + + let tool_call_reasoning = response.messages().await.into_iter().find_map(|message| { + if let Message::ToolCall(tool_call) = message { + tool_call.reasoning_content + } else { + None + } + }); + + assert_eq!(tool_call_reasoning.as_deref(), Some("inspect the file")); + } + #[tokio::test] async fn skips_exact_repeated_task_call_in_same_response() { let provider = RepeatingTaskProvider { From 9bac1eb4d5765feb7bf8856bff46f78b6adea70f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 10:48:47 +0800 Subject: [PATCH 178/226] refactor(aisdk): group adjacent tool calls into single assistant message. Extract message serialization into openai_compatible_messages() which collapses consecutive ToolCall messages into a single assistant message with multiple tool_calls, matching the OpenAI API spec.refactor(aisdk): group adjacent tool calls into single assistant message. --- Cargo.lock | 1 + Cargo.toml | 2 +- _docs/__PARITY.md | 247 +++++++------- _docs/config/index.mdx | 45 +++ src/agent/subagent.rs | 214 ++++++++---- src/app.rs | 30 +- src/config/configuration.rs | 218 +++++++++++++ src/llm/client.rs | 14 +- src/main.rs | 48 ++- src/tools/aisdk_bridge.rs | 11 +- src/tools/context.rs | 22 +- src/tools/init.rs | 78 ++++- src/tools/mod.rs | 7 +- src/tools/permission.rs | 627 +++++++++++++++++++++++++++++++++++- src/tools/task.rs | 35 ++ 15 files changed, 1364 insertions(+), 235 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f17f82e..bea326d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2900,6 +2900,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index 09cc86f..ddcf30f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ tui-textarea = { version = "0.7", features = ["ratatui"] } tokio = { version = "1.40", features = ["full"] } reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["preserve_order"] } json5 = "0.4" schemars = "1.0" anyhow = "1.0" diff --git a/_docs/__PARITY.md b/_docs/__PARITY.md index 399d50b..e496a28 100644 --- a/_docs/__PARITY.md +++ b/_docs/__PARITY.md @@ -1,160 +1,151 @@ # Crabcode Harness Parity Audit -Checked: 2026-05-27. +Checked: 2026-05-28. -Scope: core harness behavior only: agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands, and permissions. UX, theming, keybinds, model picker/auth UI, and other non-harness features are intentionally excluded. +Scope: core harness functionality only: agent loop, system prompt, subagents, tool calling, skill loading, agent config, custom commands, and permissions. UX, theming, keybinds, model picker/auth UI, and other non-harness features are intentionally excluded. ## Feature Matrix -| # | Feature | OpenCode | Crabcode | Gap | -| ---- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1.1 | Multi-step agentic iteration | Streams model responses, handles tool calls, appends tool results/observations, and continues until model stop or step limit. | Mostly present. `src/llm/client.rs` uses `stream_with_tools`; runtime tool execution is bridged through `src/tools/aisdk_bridge.rs`. | Core loop exists, but harness logic is split between the AI SDK bridge and UI runtime rather than a first-class agent runner; subagents also bypass `stream_llm_with_cancellation`, so cancellation/limits/policy are inconsistent there. | -| 1.2 | Cancellation token support for user interruption | Active model streams and agent work can be interrupted by the user. | Present for primary streams. `src/app.rs` stores `CancellationToken`s and cancels on interruption; `src/llm/client.rs` emits `ChunkMessage::Cancelled`. | Tool/subagent cancellation is incomplete: `convert_to_aisdk_tools` creates a fresh abort channel per tool call, and `TaskTool`/`run_subagent` do not receive the primary cancellation token. | -| 1.3 | Step limit with text-only fallback | Configured max steps stop tool use, inject a max-steps instruction, and ask the model for a text-only summary. | Present for primary agent. `src/llm/client.rs` defines `MAX_STEPS_REACHED_PROMPT`, detects step-limit stop, and performs a second no-tool completion. Config parses `agent..steps` and `maxSteps` in `src/config/configuration.rs`. | Subagents do not apply agent-specific max steps or fallback; `run_subagent` calls `stream_with_tools(..., None, ...)`. | -| 1.4 | Chunk-based streaming | Streams text, reasoning, tool calls, tool results, errors, metrics, and cancelled events. | Present. `src/llm/mod.rs` has `Text`, `Reasoning`, `ToolCalls`, `ToolResult`, `Failed`, `Metrics`, `Cancelled`, plus permission/question/subagent chunks. | Good parity for listed chunk categories. | -| 1.5 | Plan/Build mode toggle | Plan mode is read-only; Build mode permits write-capable tools. | Partial. `src/app.rs` toggles `Plan`/`Build`; `src/tools/permission.rs` policy denies `write`/`edit` in plan mode. | Plan mode still allows `bash` by default (`plan_mode_blocks_mutating_tools` test asserts this), so it is not strictly read-only like OpenCode. | -| 1.6 | Permission preflight during tool execution | Tool calls are preflighted and can show permission dialogs mid-stream. | Partial. `src/tools/aisdk_bridge.rs` preflights before execution; `src/tools/permission.rs` emits `PermissionRequest`; `src/app.rs` handles permission dialogs. | Preflight policy only covers sensitive reads, external paths, agent tool availability, and doom-loop detection; it lacks config-driven allow/deny/ask and bash command pattern matching. | -| 1.7 | Configurable max steps per agent | Per-agent `max_steps` controls loop depth and max-step prompt injection. | Partial. `src/config/configuration.rs` parses `agent..steps`/`maxSteps`; app and print paths pass the active mode limit into `stream_llm_with_cancellation`. | Only active Plan/Build-style modes receive the value; markdown agent files and Task subagents do not get per-agent limits. | -| 2.1 | Provider-specific header and behavior instructions | Has provider/model-specific prompt variants including Beast/OpenAI, Anthropic, Gemini, and Codex behavior. | Mostly present. `src/prompt/mod.rs` selects Beast/OpenAI, Anthropic, Gemini, Codex, or generic prompt branches. | Prompt content is a simplified local implementation and may drift from OpenCode's exact wording/variants. | -| 2.2 | Environment context block | Includes workdir, git status, platform, and current date. | Present. `SystemPromptComposer::get_environment_context` emits `` with working directory, git repo flag, platform, and date. | No material harness gap. | -| 2.3 | Tool schemas block | System prompt lists all registered tools as JSON schemas. | Partial. `SystemPromptComposer::with_tool_registry` can include schemas, and `src/agent/manager.rs` uses it. | Main app/print prompt creation in `src/app.rs` and `src/main.rs` does not pass the tool registry, so primary runtime prompts usually omit the JSON tool schema block. | -| 2.4 | Custom instructions discovery | Walks up for project `AGENTS.md`/`CLAUDE.md` and falls back to global instructions. | Mostly present. `src/prompt/rules.rs` walks up from the working directory for `AGENTS.md`, then `CLAUDE.md`; global fallback checks `$XDG_CONFIG_HOME/crabcode/AGENTS.md` and `~/.claude/CLAUDE.md`. | Does not stop at git worktree root, so it can walk above the project; global fallback does not include all OpenCode-style locations if any exist beyond these. | -| 2.5 | Available skills XML block | System prompt lists discovered skills in ``. | Present. `src/prompt/mod.rs` appends `` from `crate::skill::SkillStore`; `src/tools/skill.rs` also lists them in the tool description. | Depends on `init_skill_store` being called before prompt composition; otherwise no block. | -| 2.6 | Available subagents XML block | System prompt lists subagent names/descriptions so the primary agent can choose the Task tool. | Present for built-ins. `src/prompt/mod.rs` appends `` from `SubAgentDef::all()`. | Only `explore` and `general` are listed; config-defined/hidden/scout/vlm/system agents are absent. | -| 3.1 | Task tool | Primary agents can spawn subagents through a built-in `task` tool. | Present. `src/tools/task.rs` registers `task` dynamically in `register_dynamic_tools`. | Only supports hard-coded `explore` and `general`; no task permission matrix. | -| 3.2 | Explore subagent | Fast read-only subagent with glob/grep/read/list tools. | Present. `SubAgentType::Explore` allows `glob`, `grep`, `read`, `list` in `src/agent/subagent.rs`. | No configurable max steps/cancellation; relies on a fresh ToolPermissions instance. | -| 3.3 | General subagent | Full tool access except todowrite for complex multi-step work. | Partial. `SubAgentType::General` allows `bash`, `edit`, `write`, `read`, `grep`, `glob`, `list`, `skill`, `webfetch`. | Missing `question`, `task`, `update_plan`, `view_image`, and any future tools; the allowed list is not derived from agent config. | -| 3.4 | Scout subagent | Read-only external docs/dependency research agent that can clone repos. | Missing. | Add a `Scout` `SubAgentType`, prompt, scoped permissions, clone-capable tool policy, and prompt listing. | -| 3.5 | vlm-agent | Dedicated image-analysis subagent. | Missing. | `view_image` exists as a tool, but no image-analysis subagent or Task route exists. | -| 3.6 | Hidden/system agents | Internal compaction, title, and summary agents run automatically and/or are hidden from autocomplete. | Partial. Manual compaction exists in `src/session/compaction.rs` and `src/app.rs`; no dedicated agent registry entries. | No title/summary hidden agents, no hidden flag, no system-agent invocation through the same agent config model. | -| 3.7 | Child sessions for subagents | Subagent work is stored as child sessions with parent/child navigation. | Present. `TaskTool` emits `SubagentStarted`; `src/app.rs::start_subagent_session` creates child sessions; `SessionManager` tracks `parent_id`/`children_by_parent`. | Child sessions are UI/session-wired but not backed by a general agent tree/config model. | -| 3.8 | Subagent descriptions in system prompt | Primary agent sees names/descriptions and when to use Task. | Present for built-ins. `src/prompt/mod.rs` and `src/tools/task.rs` list `explore` and `general`. | Missing scout/vlm/custom/hidden agent descriptions and task permission hints. | -| 3.9 | @mention subagent invocation | User can invoke subagents from input with `@agent` mentions. | Missing. Search found autocomplete/token handling but no subagent @mention dispatch. | Add parser/autocomplete/runtime route that converts `@explore ...` or configured aliases into Task/subagent execution. | -| 3.10 | Agent mode primary/subagent/all | Agents declare where they can run. | Missing. | Current `agent` is a string mode (`Plan`/`Build`) plus hard-coded subagent enum; no `primary`/`subagent`/`all` mode semantics. | -| 3.11 | Hidden agents | Hidden agents are omitted from @autocomplete but invokable via Task/system. | Missing. | No agent registry with `hidden` metadata. | -| 3.12 | Task permissions | Agents can restrict which subagents they may invoke. | Missing. | `TaskTool` accepts any hard-coded type from any primary context. | -| 4.1 | Tool: bash | Shell command execution with timeout/output streaming. | Present. `src/tools/bash.rs`, registered in `src/tools/init.rs`. | Permission semantics are incomplete compared with OpenCode bash pattern policies. | -| 4.2 | Tool: edit | Exact string replacement in files. | Present. `src/tools/edit.rs` also supports fuzzy matching and replace-all. | No major parity gap for registration. | -| 4.3 | Tool: write | Create/overwrite files. | Present. `src/tools/fs/write.rs`. | No major parity gap for registration. | -| 4.4 | Tool: read | Read files with offset/limit pagination; also directories. | Present. `src/tools/fs/read.rs`. | No major parity gap for registration. | -| 4.5 | Tool: grep | Regex search with include filters. | Present. `src/tools/fs/grep.rs`. | No major parity gap for registration. | -| 4.6 | Tool: glob | Glob file matching. | Present. `src/tools/fs/glob.rs`. | No major parity gap for registration. | -| 4.7 | Tool: list | Deliberate directory listing/tree tool distinct from read. | Partial. `src/tools/fs/list.rs` lists direct entries with pagination. | OpenCode behavior is tree-style; Crabcode list is direct-entry only. | -| 4.8 | Tool: skill | Load `SKILL.md` by name. | Present. `src/tools/skill.rs`. | Skill permission patterns are missing. | -| 4.9 | Tool: task | Spawn subagents. | Present. `src/tools/task.rs`. | Only hard-coded explore/general and no task permission controls. | -| 4.10 | Tool: todowrite | Manage structured task/todo lists. | Missing as `todowrite`. | Crabcode has `update_plan` (`src/tools/update_plan.rs`) and UI accepts legacy todowrite history, but `todowrite` is not registered. | -| 4.11 | Tool: webfetch | Fetch web content and convert HTML to markdown. | Present. `src/tools/webfetch.rs`. | No major registration gap. | -| 4.12 | Tool: websearch | Web search via Exa AI. | Missing. | No `websearch` module/registration. | -| 4.13 | Tool: question | Ask user questions during execution. | Present dynamically. `src/tools/question.rs`, registered by `register_dynamic_tools`. | Not available to subagents unless explicitly added to their scoped registry. | -| 4.14 | Tool: extract-images | Save session images to disk. | Missing. | Image attachment/viewing exists, but no registered `extract-images` tool. | -| 4.15 | Tool: apply_patch | Apply diffs. | Missing. | No registered patch application tool; edits rely on `edit`/`write`. | -| 4.16 | Tool: lsp | Experimental LSP code intelligence. | Missing. | No LSP tool module/registration. | -| 4.17 | Extra Crabcode tool: update_plan | Not listed as an OpenCode built-in in the requested list; Codex-style planning tool. | Present. `src/tools/update_plan.rs`, registered in `src/tools/init.rs`. | If strict 1:1 OpenCode parity is required, decide whether to keep as extra or alias/align with `todowrite`. | -| 4.18 | Extra Crabcode tool: view_image | Local image inspection tool. | Present. `src/tools/fs/view_image.rs`, registered in `src/tools/init.rs`. | OpenCode covers image extraction/VLM separately; no VLM subagent parity. | -| 5.1 | Skill discovery locations | Searches `.opencode/skills//SKILL.md`, `~/.config/opencode/skills//SKILL.md`, `.claude/skills`, `.agents/skills`, `~/.claude/skills`, `~/.agents/skills`. | Mostly present. `src/skill/mod.rs` scans global/project opencode/crabcode skill dirs plus `.claude`/`.agents` global and walk-up project dirs. | Project `.opencode` scan only checks `project_root`, not walk-up directories to the git root; global `~/.config/opencode` is covered only through `xdg_config_home`. | -| 5.2 | Walk-up project skills | Walks up from project root/current tree for project skill dirs. | Partial. `.claude` and `.agents` skills walk upward; `.opencode`/`.crabcode` project skills only scan `project_root`. | Add walk-up for `.opencode/skills` and `.crabcode/skills` if OpenCode-compatible discovery is required from nested workdirs. | -| 5.3 | YAML frontmatter required `name` and `description` | Skill files require both fields. | Partial. `parse_skill_file` requires `name` but `description` is `Option`. | Enforce required `description` or warn/skip invalid skills for exact parity. | -| 5.4 | Pattern-based skill permissions | Skill allow/deny patterns like `"internal-*": "deny"`. | Missing. | No skill permission config or matcher exists in `src/skill/mod.rs`/`src/tools/skill.rs`. | -| 5.5 | Skill tool lists available skills | Tool description includes available skills. | Present. `SkillTool::build_description` emits ``. | No material gap beyond discovery/permissions. | -| 6.1 | Agent config via JSON | `opencode.json` supports per-agent configuration. | Partial. `src/config/configuration.rs` reads `opencode.json(c)`/`crabcode.json(c)` and parses `default_agent`, `agent..tools`, and `agent..steps`. | Most per-agent fields are ignored/unimplemented. | -| 6.2 | Agent config via markdown files | `~/.config/opencode/agents/.md` with frontmatter defines agents. | Missing. `discover_opencode_inventory` records agent files, but nothing parses/applies them. | Implement markdown agent loader and merge with JSON agent config. | -| 6.3 | Per-agent description | Agent description available for prompt/autocomplete/Task. | Missing except hard-coded subagents. | Add to agent config model and prompt listing. | -| 6.4 | Per-agent temperature/model/top_p | Agent can override sampling/model. | Missing. | Current runtime uses active UI/model config; only custom commands can temporarily set model. | -| 6.5 | Per-agent max_steps | Agent can set max steps. | Partial. Supports `steps`/`maxSteps` in JSON for active modes. | Not available from markdown agent files and not applied to Task subagents. | -| 6.6 | Per-agent mode primary/subagent/all | Agent declares invocation context. | Missing. | Current model has no such field. | -| 6.7 | Per-agent hidden/color | Agents can be hidden and have color metadata. | Missing for config. | UI color is derived from agent name/theme, not config; no hidden semantics. | -| 6.8 | Per-agent permissions/task permissions | Agent overrides global permissions and allowed subagents. | Partial for tools only. `agent..tools` can restrict tool availability. | No allow/deny/ask permission map, no bash patterns, no task permission map. | -| 6.9 | Agent creation wizard | `opencode agent create`. | Missing. | No CLI/command implementation for creating agent files. | -| 7.1 | Custom command files | `.opencode/commands/.md` user-defined slash commands. | Present. `src/command/custom.rs` scans `command` and `commands` dirs under `.opencode`, `.crabcode`, and global dirs. | Discovery is project-root based; no current-directory walk-up beyond resolved project root. | -| 7.2 | Command frontmatter | Supports `description`, `agent`, `model`, `subtask`. | Present. `Frontmatter` in `src/command/custom.rs` includes those fields. | `subtask` is returned but ignored by `run_custom_command_prompt` (`_subtask`), so subtask execution parity is missing. | -| 7.3 | Template variables | Supports `$ARGUMENTS`, positional args, etc. | Partial. `apply_arguments` supports `$ARGUMENTS` and `$1`, `$2`, with last positional consuming rest. | Other OpenCode template variables beyond these are not implemented. | -| 7.4 | Shell output injection | Supports `!\`command\`` injection. | Present. `expand_shell_blocks` runs shell blocks with timeout/truncation. | Shell injection does not go through the normal permission system. | -| 7.5 | File references | Supports `@path/to/file` expansion. | Present. `append_file_references` injects file or directory contents. | Reference reads do not go through normal permissions/external path gates. | -| 8.1 | Per-tool permissions allow/deny/ask | Config can set tool policy to allow, deny, or ask. | Missing. | `ToolPermissions` has hard-coded preflight reasons and `dangerously_skip_permissions`; no config parser/model for allow/deny/ask. | -| 8.2 | Wildcard permission patterns | Tool permission patterns like `mymcp_*`. | Missing. | No wildcard matcher for tool IDs. | -| 8.3 | Bash command permission patterns | Command-specific rules like `git push: ask`, `git *: allow`. | Missing. | Bash preflight extracts command text but never matches it against configured command patterns. | -| 8.4 | Per-agent permission overrides | Agents override global permissions. | Partial. `agent..tools` can restrict which tools are exposed. | Does not model allow/deny/ask or pattern-specific overrides. | -| 8.5 | External directory gating | Access outside workdir prompts/gates. | Present. `is_outside_workdir` and `evaluate_reason` gate read/write/edit/list/glob/grep/bash workdir paths. | Gating is prompt-only and does not integrate with configurable policy modes. | -| 8.6 | Doom loop recovery prompts | Repeated identical tool calls trigger recovery/permission prompt. | Present. `evaluate_doom_loop` prompts after `DOOM_LOOP_THRESHOLD`. | Prompt is generic; no richer OpenCode recovery instruction injection. | +| # | Feature | OpenCode | Crabcode | Gap | +| ---- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.1 | Multi-step agentic iteration | LLM streaming loop continues across tool calls until model stop or step limit. | **Present.** `src/llm/client.rs` uses AI SDK `stream_with_tools`; `src/tools/aisdk_bridge.rs` executes tools and returns tool outputs to the model. | Harness is functional, but orchestration is split across `llm/client.rs`, the AI SDK bridge, and app state rather than a single reusable agent runner shared by primary and subagents. | +| 1.2 | Cancellation token support | User interruption cancels active model/tool work. | **Mostly present.** `src/app.rs` stores `CancellationToken`s; `relay_stream_to_sender` emits `ChunkMessage::Cancelled`; `ToolContext` carries the token; `TaskTool`/subagents receive it. | Long-running tools only cancel if they check `ctx.is_aborted`; `webfetch` and most sync filesystem tools do not poll mid-operation. | +| 1.3 | Step limit enforcement with text-only fallback | Configured max steps stops tools and injects a max-steps text-only summary prompt. | **Present.** `MAX_STEPS_REACHED_PROMPT` and fallback completion exist in `src/llm/client.rs`; subagents have equivalent fallback in `src/agent/subagent.rs`. | Config supports `steps` and `maxSteps`, but not OpenCode's snake-case `max_steps`; step-limit handling should be centralized in the shared runner. | +| 1.4 | Chunk-based streaming | Streams text, reasoning, tool calls, tool results, errors, metrics, and cancellation. | **Present.** `src/llm/mod.rs` defines `Text`, `Reasoning`, `ToolCalls`, `ToolResult`, `Failed`, `Metrics`, `Cancelled`, plus permission/question/subagent events. | Provider raw/partial `ToolCall` chunks are logged in `relay_stream_to_sender` but UI tool-call chunks are emitted from the execution bridge once execution starts, so partial tool-call argument streaming is not exposed. | +| 1.5 | Plan/Build mode toggle | Plan mode is read-only; Build mode permits mutating tools. | **Partial.** `src/app.rs` toggles `Plan`/`Build`; `AgentToolPolicies` hides `bash`, `write`, and `edit` in plan mode. | Plan mode still exposes `task`; a Plan agent can spawn the `general` subagent, and `run_subagent` scopes it as `build`, allowing `bash`/`write`/`edit`. This breaks read-only parity. | +| 1.6 | Permission preflight during tool execution | Tool calls are checked before execution and may trigger mid-stream dialogs. | **Present.** `src/tools/aisdk_bridge.rs` calls `ToolPermissions::preflight`; `PermissionRequest` is handled by `src/app.rs`. | Custom command shell/file expansions bypass this preflight path. | +| 1.7 | Configurable max steps per agent | Per-agent `max_steps` controls agent loop depth. | **Partial.** `src/config/configuration.rs` parses `agent..steps` and `maxSteps`; app/print/task paths pass limits by current agent/subagent name. | Missing `max_steps` alias and full OpenCode agent config integration. | +| 2.1 | Provider-specific header and behavior instructions | Provider/model-specific prompt variants for Beast/OpenAI, Anthropic, Gemini, and Codex. | **Mostly present.** `src/prompt/mod.rs` has OpenAI, Anthropic, Gemini, Codex, and Generic prompt branches. | Selection is based on model-id string heuristics, not resolved provider kind/model metadata, so OpenAI-compatible or renamed models can get the wrong prompt. | +| 2.2 | Environment context block | Includes workdir, git status, platform, and date. | **Present.** `SystemPromptComposer::get_environment_context` emits `` with working directory, git-repo flag, platform, and date. | No material harness gap. | +| 2.3 | Tool schemas block | System prompt lists registered tool schemas as JSON. | **Present.** `SystemPromptComposer::with_tool_registry` emits JSON schemas; app and print mode compose prompts with scoped dynamic registries. | Schemas are scoped to visible tools for the current mode, not literally every registered tool; registry ordering is not deterministic because it is backed by a `HashMap`. | +| 2.4 | Custom instructions discovery | Walk-up discovery for `AGENTS.md`/`CLAUDE.md` plus global fallback. | **Partial.** `src/prompt/rules.rs` walks upward for nearest local `AGENTS.md` or `CLAUDE.md`; global fallback checks `$XDG_CONFIG_HOME/crabcode/AGENTS.md` and `~/.claude/CLAUDE.md`. | Walk-up does not stop at the git/project root; it does not load OpenCode global instruction locations; it returns the first local match instead of a layered instruction stack. | +| 2.5 | Available skills XML block | Prompt lists discovered skills as ``. | **Present.** `src/prompt/mod.rs` appends skills from `SkillStore`; `src/tools/skill.rs` repeats them in the tool description. | No material gap beyond discovery/permission gaps listed below. | +| 2.6 | Available subagents XML block | Prompt lists subagent names/descriptions so the primary agent can use Task. | **Partial.** `src/prompt/mod.rs` emits `` from `SubAgentDef::all()`. | Only hard-coded `explore` and `general` are listed; no `scout`, `vlm-agent`, hidden agents, or config-defined agents. | +| 3.1 | Task tool | Primary agents spawn subagents through a built-in `task` tool. | **Present.** `src/tools/task.rs` is registered dynamically by `register_dynamic_tools`. | Task targets are hard-coded and not backed by an OpenCode-style agent registry. | +| 3.2 | `explore` subagent | Fast read-only subagent with `glob`, `grep`, `read`, and `list`. | **Present.** `SubAgentType::Explore` and `build_scoped_registry` restrict it to those tools. | System prompt is bespoke and does not use the shared prompt composer/environment/custom-instruction pipeline. | +| 3.3 | `general` subagent | Full tool access minus `todowrite`. | **Partial.** `SubAgentType::General` allows `bash`, `edit`, `write`, `read`, `grep`, `glob`, `list`, `skill`, and `webfetch`. | Missing several available tools (`question`, `task`, `update_plan`, `view_image`) and future OpenCode tools; tool access is a hard-coded allowlist. | +| 3.4 | `scout` subagent | Read-only external-docs/dependency research agent that can clone repos. | **Missing.** No `Scout` variant or prompt exists in `src/agent/subagent.rs`. | Add a scout definition, clone-safe tool policy, Task validation, prompt listing, and permission defaults. | +| 3.5 | `vlm-agent` | Dedicated image-analysis subagent. | **Missing.** `view_image` exists, but no image-analysis subagent or Task route exists. | Add VLM agent definition and image/context passing behavior. | +| 3.6 | Child sessions and session tree | Subagent work is stored as child sessions with parent/child navigation. | **Present.** `TaskTool` emits `SubagentStarted`; `src/app.rs` creates child sessions; `SessionManager` tracks parent/children and persistence stores parent identifiers. | Child sessions are wired specifically for Task, not through a general agent/session tree abstraction. | +| 3.7 | Subagent descriptions in prompt | Primary prompt describes available subagents. | **Partial.** Built-in descriptions for `explore` and `general` are included. | Missing descriptions for `scout`, `vlm-agent`, custom agents, hidden agents, and task permission hints. | +| 3.8 | `@mention` subagent invocation | User input can invoke subagents via `@agent`. | **Missing.** No parser/autocomplete/runtime path dispatches `@explore` or `@general` to Task. | Add mention parsing and dispatch into Task/child-session flow. | +| 3.9 | Agent mode: primary vs subagent vs all | Agents declare invocation context. | **Missing.** Crabcode has a string `agent` mode for Plan/Build and a hard-coded `SubAgentType` enum. | Introduce agent definitions with `mode = primary`, `subagent`, or `all`. | +| 3.10 | Hidden agents | Hidden agents are omitted from autocomplete but invokable internally or via Task if allowed. | **Missing.** No agent registry contains `hidden` metadata. | Needed for hidden/system agents and exact autocomplete/Task behavior. | +| 3.11 | Hidden system agents: compaction, title, summary | Internal agents run automatically. | **Partial.** Compaction exists as a bespoke summarization path in `src/session/compaction.rs`; title generation is heuristic; no summary/title agents exist. | Unify compaction/title/summary as hidden agent definitions with shared model/config behavior. | +| 3.12 | Task permissions | Config controls which agents may invoke which subagents. | **Partial.** Generic permission rules can target the `task` tool with the subagent name as pattern. | No first-class `task`/`task_permissions` agent field; Task validation does not use an agent registry. | +| 4.1 | Tool: `bash` | Shell command execution. | **Present.** `src/tools/bash.rs`, registered in `src/tools/init.rs`. | Registration parity OK. | +| 4.2 | Tool: `edit` | Exact string replacement in files. | **Present.** `src/tools/edit.rs`; includes fuzzy/trimmed matching and replace-all. | Behavior is broader than exact replacement, which may surprise if strict OpenCode semantics are required. | +| 4.3 | Tool: `write` | Create/overwrite files. | **Present.** `src/tools/fs/write.rs`. | Registration parity OK. | +| 4.4 | Tool: `read` | Read files with offset/limit and directories. | **Present.** `src/tools/fs/read.rs`. | Registration parity OK. | +| 4.5 | Tool: `grep` | Regex search with include filters. | **Present.** `src/tools/fs/grep.rs`. | Registration parity OK. | +| 4.6 | Tool: `glob` | File pattern matching. | **Present.** `src/tools/fs/glob.rs`. | Registration parity OK. | +| 4.7 | Tool: `list` | Deliberate tree-style directory listing, distinct from read. | **Partial.** `src/tools/fs/list.rs` lists direct directory entries with pagination. | Not tree-style; does not recurse/render a directory tree. | +| 4.8 | Tool: `skill` | Load `SKILL.md` by name. | **Present.** `src/tools/skill.rs`. | Registration parity OK; discovery/config gaps below. | +| 4.9 | Tool: `task` | Spawn subagents. | **Present.** `src/tools/task.rs`, dynamic registration. | Hard-coded `explore`/`general`; no real agent registry/task permissions. | +| 4.10 | Tool: `todowrite` | Manage structured task lists. | **Missing as named tool.** Crabcode has `update_plan` in `src/tools/update_plan.rs`. | Add `todowrite` or an alias if OpenCode prompts/tools expect that name. | +| 4.11 | Tool: `webfetch` | Fetch web content and convert HTML to markdown. | **Present.** `src/tools/webfetch.rs`. | Registration parity OK. | +| 4.12 | Tool: `websearch` | Web search via Exa AI. | **Missing.** No registered `websearch` tool. | Implement module, config/auth path, and registration. | +| 4.13 | Tool: `question` | Ask user questions during execution. | **Present.** `src/tools/question.rs`, registered dynamically. | Not available to `general` subagent because its allowlist excludes it. | +| 4.14 | Tool: `extract-images` | Save session images to disk. | **Missing.** No registered extraction tool. | Add session-image extraction/storage behavior. | +| 4.15 | Tool: `apply_patch` | Apply diffs. | **Missing.** No registered patch tool. | Implement diff/patch application with permission checks and validation. | +| 4.16 | Tool: `lsp` | Experimental LSP code intelligence. | **Missing.** No LSP tool module/registration. | Implement or intentionally gate behind experimental config. | +| 4.17 | Extra Crabcode tool: `update_plan` | Not in the requested OpenCode built-in list. | **Present.** Codex-style planning tool registered statically. | Decide whether to keep as extra only or alias to `todowrite`. | +| 4.18 | Extra Crabcode tool: `view_image` | OpenCode uses image extraction/VLM agent concepts. | **Present.** `src/tools/fs/view_image.rs` sends local images to image-capable models. | Does not replace `extract-images` or `vlm-agent`. | +| 5.1 | Skill discovery locations | Project/global `.opencode/skills`, `.claude/skills`, `.agents/skills`, and home/global variants. | **Mostly present.** `src/skill/mod.rs` scans XDG opencode/crabcode skill dirs, project `.opencode`/`.crabcode`, global/project `.claude`, and global/project `.agents`. | Does not scan nested project `.opencode`/`.crabcode` skill dirs between cwd and git root; scans `.claude`/`.agents` upward from project root past the workspace. | +| 5.2 | Walk-up project skills | Project skills are discovered while walking up to the worktree. | **Partial.** `.claude` and `.agents` are walked upward; `.opencode` and `.crabcode` are only checked at `project_root`. | Add cwd-to-git-root walk-up for `.opencode/skills` and `.crabcode/skills`; stop external walk-up at the worktree root. | +| 5.3 | YAML frontmatter | `name` and `description` are required. | **Partial.** Parser requires `name`; `description` is optional. | Enforce or warn/skip missing `description` for exact parity. | +| 5.4 | Pattern-based skill permissions | Rules such as `internal-* = deny`. | **Mostly present via generic permissions.** `permission.skill` rules can match the skill name before `SkillTool` executes. | No dedicated skill permission config surface or diagnostics in `src/skill/mod.rs`; document the generic `permission.skill` form. | +| 5.5 | Skill tool lists available skills | Tool description enumerates available skills. | **Present.** `SkillTool::build_description` emits ``. | No material gap. | +| 6.1 | Agent config via `opencode.json` | JSON config defines agents. | **Partial.** `src/config/configuration.rs` loads global/local `opencode.json(c)` and parses `agent..tools`, `permission`, and `steps`/`maxSteps`. | Most OpenCode per-agent fields are ignored. | +| 6.2 | Agent config via markdown files | `~/.config/opencode/agents/.md` with frontmatter defines agents. | **Missing.** `discover_opencode_inventory` records agent files, but no loader parses/applies them. | Implement markdown agent parser and merge with JSON agent config. | +| 6.3 | Per-agent description/model/temperature/top_p | Agents can define description and model/sampling overrides. | **Missing.** Runtime model is global/current UI model; custom commands can temporarily override model. | Add fields to agent definitions and request config. | +| 6.4 | Per-agent max_steps | Agents can define max steps. | **Partial.** JSON `steps`/`maxSteps` works for current mode/subagent names. | Missing `max_steps` alias and markdown-agent support. | +| 6.5 | Per-agent mode/hidden/color | Agents can declare mode, visibility, and color. | **Missing.** No config-backed mode/hidden/color model. | Add metadata to the agent registry; keep theme-derived fallback colors. | +| 6.6 | Per-agent permissions and task permissions | Agents override tool permissions and allowed subagents. | **Partial.** `agent..permission` and `agent..tools` are parsed; generic `task` rules can target subagent names. | No dedicated OpenCode task-permission field and no registry-driven Task validation. | +| 6.7 | Agent creation wizard | `opencode agent create`. | **Missing.** CLI args in `src/main.rs` have no agent subcommand; slash commands have no creation flow. | Add CLI/command wizard that writes markdown agent files. | +| 7.1 | Custom command files | `.opencode/commands/.md` user slash commands. | **Present.** `src/command/custom.rs` scans `command` and `commands` dirs under global/project opencode/crabcode dirs. | Discovery is project-root based, not cwd-to-root walk-up. | +| 7.2 | Command frontmatter | Supports `description`, `agent`, `model`, and `subtask`. | **Present in parsing.** `Frontmatter` includes all four fields. | `subtask` is parsed but ignored at execution time. | +| 7.3 | Template variables | Supports OpenCode command variables. | **Partial.** `apply_arguments` supports `$ARGUMENTS` and `$1`, `$2`, etc.; the last positional consumes remaining args. | Other OpenCode variables are not implemented. | +| 7.4 | Shell output injection | Supports `!\`command\`` injection. | **Present.** `expand_shell_blocks` runs shell snippets with timeout/truncation. | Does not run through `ToolPermissions` or permission dialogs. | +| 7.5 | File references | Supports `@path/to/file` expansion. | **Present.** `append_file_references` injects file or directory contents. | Does not run through `ToolPermissions`, sensitive-file gating, or external-directory gating. | +| 7.6 | Command `subtask` execution | Command can run as a subtask/subagent. | **Missing behavior.** `run_custom_command_prompt` accepts `_subtask` but ignores it. | Implement subtask dispatch via Task/child session when `subtask: true`. | +| 8.1 | Per-tool permissions: allow, deny, ask | Config controls each tool. | **Present.** `parse_permission_rules` and `ToolPermissions::preflight` support allow/deny/ask decisions. | Document exact config contract; ensure every non-AI-SDK execution path also preflights. | +| 8.2 | Wildcard permission patterns | Rules can match wildcard tools and targets. | **Present.** `evaluate_permission_rules` uses `wildcard_match`; config tests cover `mcp_*`. | No material core gap. | +| 8.3 | Bash command patterns | Rules like `git push = ask` and `git * = allow`. | **Present.** `permission_patterns_for_tool` matches bash commands; tests cover `git *` and `git push *`. | Exact OpenCode precedence should be verified; Crabcode currently uses last matching rule. | +| 8.4 | Per-agent permission overrides | Agent-specific permissions override global rules. | **Present.** `agent_permission_rules` are parsed and evaluated after global rules. | Depends on string agent names, not full agent definitions/modes. | +| 8.5 | External directory gating | Access outside workdir is gated. | **Present.** `evaluate_reason` gates external paths; `external_directory` rules can override. | Command `@file` and `!\`shell\`` expansions bypass this system. | +| 8.6 | Doom loop recovery prompts | Repeated identical tool calls trigger recovery/permission flow. | **Partial.** Repeated calls after `DOOM_LOOP_THRESHOLD` trigger a permission prompt. | No richer OpenCode-style model recovery prompt/instruction injection beyond the permission prompt. | ## Priority-ranked actionable gaps ### CRITICAL -1. **Make Plan mode truly read-only.** - Files: `src/tools/permission.rs`, tests in the same file. - Implementation notes: update `AgentToolPolicies::is_allowed("plan", ...)` to deny `bash` by default in addition to `write` and `edit` unless a custom agent policy explicitly permits it. Reconcile the comment and `plan_mode_blocks_mutating_tools` test, which currently asserts bash is allowed. +1. **Close the Plan-mode Task escape hatch.** + Files: `src/tools/permission.rs`, `src/tools/task.rs`, `src/agent/subagent.rs`, `src/tools/init.rs`. + Implementation notes: either hide `task` in Plan mode by default or enforce target-agent capabilities before execution. If Plan may call Task, restrict it to read-only subagents such as `explore`; do not run subagents with hard-coded `agent_mode = "build"` when called from Plan. Add tests for `Plan -> task(general)` denying `bash`/`write`/`edit`. -2. **Load tool schemas into the real primary system prompt.** - Files: `src/app.rs`, `src/main.rs`, `src/prompt/mod.rs`, `src/tools/init.rs`. - Implementation notes: when composing the system prompt in app and print mode, initialize/register the same tool registry (including dynamic tools where possible) and call `.with_tool_registry(...)`. Avoid creating a mismatched registry that advertises tools unavailable at runtime. +2. **Introduce a real OpenCode-compatible agent registry.** + Files: new `src/agent/definition.rs` or equivalent, `src/agent/subagent.rs`, `src/config/configuration.rs`, `src/prompt/mod.rs`, `src/tools/task.rs`, `src/app.rs`. + Implementation notes: define `AgentDefinition { name, description, mode, hidden, model, temperature, top_p, max_steps, tools, permissions, task_permissions, instructions }`. Use this single registry for primary modes, subagents, prompt listings, Task validation, tool scoping, and max-step lookup. -3. **Implement OpenCode-style permission config.** - Files: `src/config/configuration.rs`, `src/tools/permission.rs`, `src/tools/aisdk_bridge.rs`, `_docs/config.mdx`. - Implementation notes: parse `permission` into per-tool allow/deny/ask rules; support wildcard tool patterns and bash command patterns; merge global and per-agent overrides; preserve current external-path/sensitive/doom-loop checks as default `ask` reasons. - -4. **Propagate cancellation and step limits into subagents/tools.** - Files: `src/tools/aisdk_bridge.rs`, `src/tools/context.rs`, `src/tools/task.rs`, `src/agent/subagent.rs`, `src/llm/client.rs`. - Implementation notes: pass the primary `CancellationToken` into tool contexts and child subagents; stop creating inert per-tool abort channels; apply configured `max_steps` and text-only fallback to `run_subagent` or route subagents through a shared agent runner. +3. **Parse and apply markdown agent files.** + Files: `src/config/configuration.rs`, new loader under `src/agent/`, `_docs/config.mdx`. + Implementation notes: `discover_opencode_inventory` already finds `.opencode/agents` and XDG opencode agent files. Add YAML frontmatter parsing, body-as-instructions support, merge precedence with JSON config, diagnostics, and tests for `mode`, `hidden`, permissions, and max steps. ### HIGH -5. **Replace hard-coded subagents with a real agent registry.** - Files: `src/agent/config.rs`, `src/agent/subagent.rs`, `src/prompt/mod.rs`, `src/tools/task.rs`, `src/config/configuration.rs`. - Implementation notes: define an `AgentDefinition` model with name, description, mode (`primary`/`subagent`/`all`), hidden, model, temperature, top_p, max_steps, tools, permissions, and task permissions. Use it for prompt listing, Task validation, and runtime tool scoping. +4. **Add missing OpenCode subagents and hidden system agents.** + Files: `src/agent/subagent.rs`, future agent registry, `src/session/compaction.rs`, `src/app.rs`, `src/tools/task.rs`. + Implementation notes: add `scout` and `vlm-agent`; model `compaction`, `title`, and `summary` as hidden agents. Ensure hidden agents can be invoked internally or via Task only when permitted and are omitted from visible autocomplete/prompt listings when appropriate. -6. **Add missing OpenCode subagents: `scout` and `vlm-agent`.** - Files: `src/agent/subagent.rs`, `src/tools/task.rs`, `src/prompt/mod.rs`. - Implementation notes: add prompts, descriptions, tool scopes, and Task validation. `scout` should remain read-only but allow external research/clone operations; `vlm-agent` should accept image context and use image-capable tooling/model paths. +5. **Register missing OpenCode built-in tools.** + Files: `src/tools/init.rs`, new modules under `src/tools/`, `src/tools/aisdk_bridge.rs`. + Implementation notes: implement/register `websearch`, `extract-images`, `apply_patch`, and `lsp`; add a `todowrite` compatibility tool or alias to `update_plan`. Wire permissions, schemas, tests, and subagent allowlists. -7. **Parse markdown agent files.** - Files: `src/config/configuration.rs`, new module under `src/agent/`. - Implementation notes: the config loader already discovers `~/.config/opencode/agents/*.md` and `.opencode/agents/*.md`; add frontmatter parsing and merge the body as agent instructions/system prompt content. +6. **Honor custom command `subtask`.** + Files: `src/command/custom.rs`, `src/command/registry.rs`, `src/app.rs`, `src/tools/task.rs`. + Implementation notes: when `subtask: true`, execute the rendered prompt through Task/subagent flow instead of appending it as a primary user message. Treat command `agent` as the target agent and persist output in a child session. -8. **Implement task permissions and hidden-agent semantics.** - Files: `src/tools/task.rs`, `src/agent/subagent.rs`, future agent registry. - Implementation notes: validate whether the calling agent may invoke the target subagent; list non-hidden agents in prompts/autocomplete while allowing hidden agents for system/Task use if permitted. +7. **Permission-gate custom command shell and file expansions.** + Files: `src/command/custom.rs`, `src/app.rs`, `src/tools/permission.rs`. + Implementation notes: run `!\`...\``through bash permission preflight and run`@file`/`@directory` references through read/list external/sensitive gating. In non-interactive contexts, deny or require explicit skip-permissions behavior. -9. **Add missing built-in tools required for 1:1 parity.** - Files: `src/tools/init.rs`, new modules under `src/tools/`. - Implementation notes: implement/register `websearch`, `extract-images`, `apply_patch`, and `lsp`; add a `todowrite` compatibility tool or alias to `update_plan` if OpenCode-compatible prompts expect that name. +8. **Use shared prompt composition for subagents.** + Files: `src/agent/subagent.rs`, `src/prompt/mod.rs`, future agent registry. + Implementation notes: compose subagent prompts with environment context, scoped tool schemas, custom instructions, available skills, and allowed subagents as appropriate, plus the subagent-specific instruction body. ### MEDIUM -10. **Make `list` tree-style.** - Files: `src/tools/fs/list.rs`. - Implementation notes: keep pagination but output a deliberate directory-tree listing rather than only direct entries. Match OpenCode semantics while preserving direct listing if needed behind a depth option. - -11. **Complete skill discovery and validation parity.** - Files: `src/skill/mod.rs`, `src/tools/skill.rs`, config parser. - Implementation notes: walk up for `.opencode/skills`/`.crabcode/skills`, enforce required `description`, and add pattern-based skill allow/deny rules such as `internal-* = deny`. +9. **Complete skill discovery parity.** + Files: `src/skill/mod.rs`, config docs/tests. + Implementation notes: walk cwd-to-git-root for `.opencode/skills` and `.crabcode/skills`; stop `.claude`/`.agents` walk-up at the worktree root; enforce or clearly warn on missing `description`; document `permission.skill` wildcard patterns. -12. **Stop prompt rule walk-up at the project/git boundary.** - Files: `src/prompt/rules.rs`, `src/config/configuration.rs`. - Implementation notes: pass the discovered project root into rule resolution or detect the git worktree root; avoid reading parent-directory `AGENTS.md` outside the intended workspace. +10. **Make `list` tree-style.** + Files: `src/tools/fs/list.rs`. + Implementation notes: preserve pagination but support OpenCode's deliberate directory-tree output. Consider a depth limit and clear truncation metadata. -13. **Honor custom command `subtask`.** - Files: `src/command/custom.rs`, `src/command/registry.rs`, `src/app.rs`, `src/tools/task.rs`. - Implementation notes: if `subtask: true`, execute the rendered prompt through Task/subagent flow instead of directly appending it as a primary chat message; apply command `agent` as the target agent. +11. **Improve custom instruction discovery.** + Files: `src/prompt/rules.rs`, `src/config/configuration.rs`. + Implementation notes: stop at the git/project root, add OpenCode-compatible global instruction locations, and decide whether to layer multiple walk-up files or keep nearest-only behavior with docs/tests. -14. **Route command shell/file expansions through permissions.** - Files: `src/command/custom.rs`, `src/tools/permission.rs`, command execution path in `src/app.rs`. - Implementation notes: `!\`...\``and`@file`currently bypass tool preflight. Reuse`ToolPermissions` before shell execution or external/sensitive file reads. +12. **Switch provider prompt selection from string heuristics to provider/model metadata.** + Files: `src/prompt/mod.rs`, `src/llm/client.rs`, `src/model/discovery.rs`. + Implementation notes: pass resolved provider kind/family into `SystemPromptComposer`; avoid misclassifying OpenAI-compatible, renamed, or provider-routed models. -15. **Support more OpenCode command template variables.** - Files: `src/command/custom.rs`. - Implementation notes: inventory OpenCode's full variable set and extend `apply_arguments`/render context beyond `$ARGUMENTS` and `$N`. +13. **Support OpenCode's full command variable set.** + Files: `src/command/custom.rs`. + Implementation notes: inventory OpenCode variables, add a render context, and test escaping/unknown variable behavior. Keep existing `$ARGUMENTS` and positional compatibility. ### LOW -16. **Add @mention subagent invocation.** - Files: `src/ui/components/input.rs`, autocomplete modules, `src/app.rs`, future agent registry. - Implementation notes: detect `@` at message start or in a supported invocation form, validate against visible subagent/all agents, and dispatch through Task/child session flow. +14. **Add `@mention` agent invocation.** + Files: `src/command/parser.rs`, `src/autocomplete/`, `src/app.rs`, future agent registry. + Implementation notes: parse supported `@agent` forms, validate against visible `subagent`/`all` agents, and route to Task/child session execution. -17. **Add agent creation wizard/command.** - Files: `src/command/handlers.rs`, `src/command/registry.rs`, future agent config writer. - Implementation notes: implement a command/CLI flow equivalent to `opencode agent create` that writes markdown frontmatter files under the appropriate agents directory. +15. **Add an agent creation wizard/command.** + Files: `src/main.rs`, `src/command/handlers.rs`, `src/command/registry.rs`, future agent config writer. + Implementation notes: implement a CLI or slash-command equivalent of `opencode agent create` that writes markdown agent files with valid frontmatter. -18. **Unify hidden compaction/title/summary agents with agent registry.** - Files: `src/session/compaction.rs`, `src/app.rs`, future `src/agent/` registry. - Implementation notes: current compaction is a bespoke summarization path. Model compaction/title/summary as hidden system agents so they share provider config, permissions, and invocation semantics. +16. **Document intentional extras and aliases.** + Files: `_docs/config.mdx`, `src/tools/init.rs`, tool docs if added. + Implementation notes: clarify `update_plan` and `view_image` as Crabcode/Codex extensions, and document any compatibility aliases such as `todowrite -> update_plan`. diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx index c9caa5b..ecf0297 100644 --- a/_docs/config/index.mdx +++ b/_docs/config/index.mdx @@ -53,6 +53,51 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the } ``` +## Permissions + +crabcode reads the OpenCode-compatible `permission` field. Rules resolve to `allow`, `ask`, or `deny`, with later matching rules taking precedence. + +```jsonc title="crabcode.jsonc" +{ + "permission": { + "*": "ask", + "bash": { + "*": "ask", + "git *": "allow", + "git push *": "deny" + }, + "edit": "ask", + "external_directory": { + "~/projects/reference/**": "allow" + } + } +} +``` + +Permission keys can be concrete tool names, wildcard tool patterns like `mcp_*`, or built-in guard keys such as `external_directory` and `doom_loop`. Bash rules match the command string, so patterns like `git *`, `npm run *`, or `rm *` work as command gates. Path patterns support `~` and `$HOME` at the start. + +You can also override permissions per agent. Agent rules are merged after global rules, so they win when both match. + +```jsonc title="crabcode.jsonc" +{ + "permission": { + "bash": "ask" + }, + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git status *": "allow" + } + } + } + } +} +``` + +Plan mode is read-only by default and does not expose `bash`, `write`, or `edit` unless an explicit agent tool policy enables them. The existing safety prompts for sensitive reads, external paths, and repeated identical tool calls remain active by default. + ## What belongs where | Need | Put it in | diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 6f8d4cb..c7887af 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -133,21 +133,19 @@ pub async fn run_subagent( full_registry: &ToolRegistry, sender: Option, session_id: String, + cancel_token: tokio_util::sync::CancellationToken, + permissions: crate::tools::ToolPermissions, + max_steps: Option, ) -> Result { use aisdk::core::{ - chunk::ChunkType, - response::{stream_with_tools, StreamTextResponse}, - Message as AisdkMessage, + chunk::ChunkType, response::StreamTextResponse, stop::StopReason, Message as AisdkMessage, }; - use aisdk::{Anthropic, OpenAI, OpenAICompatible}; use futures::StreamExt; use std::collections::HashMap; let session = get_llm_session().ok_or("LLM session not configured")?; - let cwd = crate::utils::cwd::current_dir_or_dot(); let scoped_registry = build_scoped_registry(full_registry, &subagent_type).await; - let permissions = crate::tools::ToolPermissions::new(cwd.clone()); let aisdk_tools = crate::tools::aisdk_bridge::convert_to_aisdk_tools( &scoped_registry, @@ -157,6 +155,7 @@ pub async fn run_subagent( Some(session_id.clone()), None, session.supports_image_input, + cancel_token.clone(), ) .await; @@ -174,73 +173,37 @@ pub async fn run_subagent( let headers = HashMap::new(); let stream_started_at = std::time::Instant::now(); crate::emit_log!( - "[SUBAGENT] stream_start session_id={} subagent_type={} tools={} description_bytes={} prompt_bytes={} sender_present={}", + "[SUBAGENT] stream_start session_id={} subagent_type={} tools={} description_bytes={} prompt_bytes={} max_steps={:?} sender_present={}", session_id, subagent_type.name(), aisdk_tools.len(), description.len(), prompt.len(), + max_steps, sender.is_some() ); - let mut response: StreamTextResponse = match session.provider_kind { - ProviderKind::OpenAICompatible => { - let mut builder = OpenAICompatible::builder() - .base_url(&session.base_url) - .model_name(&session.model) - .provider_name(&session.provider_name) - .api_key(session.api_key.as_deref().unwrap_or("")); - if let Some(effort) = session.reasoning_effort { - builder = builder.reasoning_effort(effort.as_str()); - } - let provider = builder - .build() - .map_err(|e| format!("Failed to build OpenAICompatible provider: {}", e))?; + let mut response: StreamTextResponse = + start_subagent_stream(&session, messages, aisdk_tools, max_steps, headers).await?; - stream_with_tools(provider, messages, aisdk_tools, None, None, headers) - .await - .map_err(|e| format!("Stream error: {}", e))? - } - ProviderKind::Anthropic => { - let mut builder = Anthropic::builder() - .base_url(&session.base_url) - .model_name(&session.model) - .provider_name(&session.provider_name) - .api_key(session.api_key.as_deref().unwrap_or("")); - if let Some(effort) = session.reasoning_effort { - builder = builder.reasoning_effort(effort.as_str()); - } - let provider = builder - .build() - .map_err(|e| format!("Failed to build Anthropic provider: {}", e))?; + let mut collected_text = String::new(); + let mut tool_call_count = 0usize; - stream_with_tools(provider, messages, aisdk_tools, None, None, headers) - .await - .map_err(|e| format!("Stream error: {}", e))? - } - ProviderKind::OpenAI => { - let mut builder = OpenAI::builder() - .base_url(&session.base_url) - .model_name(&session.model) - .provider_name(&session.provider_name) - .api_key(session.api_key.as_deref().unwrap_or("")); - if let Some(effort) = session.reasoning_effort { - builder = builder.reasoning_effort(effort.as_str()); + loop { + let chunk = tokio::select! { + _ = cancel_token.cancelled() => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Cancelled); + } + return Err("Subagent cancelled".to_string()); } - let provider = builder - .build() - .map_err(|e| format!("Failed to build OpenAI provider: {}", e))?; + chunk = response.stream.next() => chunk, + }; - stream_with_tools(provider, messages, aisdk_tools, None, None, headers) - .await - .map_err(|e| format!("Stream error: {}", e))? - } - }; - - let mut collected_text = String::new(); - let mut tool_call_count = 0usize; + let Some(chunk) = chunk else { + break; + }; - while let Some(chunk) = response.stream.next().await { match chunk { ChunkType::Text(text) => { collected_text.push_str(&text); @@ -292,6 +255,72 @@ pub async fn run_subagent( } let stop_reason = response.stop_reason().await; + if max_steps.is_some() && matches!(stop_reason, Some(StopReason::Hook)) { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Warning( + "Maximum configured steps reached. Sending text-only subagent summary.".to_string(), + )); + } + + let mut follow_up_messages = response.messages().await; + follow_up_messages.push(AisdkMessage::assistant( + crate::llm::client::MAX_STEPS_REACHED_PROMPT, + )); + let mut summary_response = start_subagent_stream( + &session, + follow_up_messages, + Vec::new(), + None, + HashMap::new(), + ) + .await?; + + loop { + let chunk = tokio::select! { + _ = cancel_token.cancelled() => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Cancelled); + } + return Err("Subagent cancelled".to_string()); + } + chunk = summary_response.stream.next() => chunk, + }; + + let Some(chunk) = chunk else { + break; + }; + + match chunk { + ChunkType::Text(text) => { + collected_text.push_str(&text); + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Text(text)); + } + } + ChunkType::Reasoning(reasoning) => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); + } + } + ChunkType::Failed(err) => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); + } + return Err(format!("Subagent max-step summary failed: {}", err)); + } + ChunkType::End { .. } | ChunkType::ResponseCompleted { .. } => break, + ChunkType::Metadata(message) => { + crate::emit_log!( + "[SUBAGENT_METADATA] session_id={} subagent_type={} {}", + session_id, + subagent_type.name(), + message + ); + } + _ => {} + } + } + } crate::emit_log!( "[SUBAGENT] stream_finish session_id={} subagent_type={} duration_ms={} stop_reason={:?} text_bytes={} tool_call_count={}", session_id, @@ -308,6 +337,71 @@ pub async fn run_subagent( }) } +async fn start_subagent_stream( + session: &crate::agent::config::LlmSessionConfig, + messages: Vec, + tools: Vec, + max_steps: Option, + headers: std::collections::HashMap, +) -> Result { + use aisdk::core::response::stream_with_tools; + use aisdk::{Anthropic, OpenAI, OpenAICompatible}; + + match session.provider_kind { + ProviderKind::OpenAICompatible => { + let mut builder = OpenAICompatible::builder() + .base_url(&session.base_url) + .model_name(&session.model) + .provider_name(&session.provider_name) + .api_key(session.api_key.as_deref().unwrap_or("")); + if let Some(effort) = session.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + let provider = builder + .build() + .map_err(|e| format!("Failed to build OpenAICompatible provider: {}", e))?; + + stream_with_tools(provider, messages, tools, max_steps, None, headers) + .await + .map_err(|e| format!("Stream error: {}", e)) + } + ProviderKind::Anthropic => { + let mut builder = Anthropic::builder() + .base_url(&session.base_url) + .model_name(&session.model) + .provider_name(&session.provider_name) + .api_key(session.api_key.as_deref().unwrap_or("")); + if let Some(effort) = session.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + let provider = builder + .build() + .map_err(|e| format!("Failed to build Anthropic provider: {}", e))?; + + stream_with_tools(provider, messages, tools, max_steps, None, headers) + .await + .map_err(|e| format!("Stream error: {}", e)) + } + ProviderKind::OpenAI => { + let mut builder = OpenAI::builder() + .base_url(&session.base_url) + .model_name(&session.model) + .provider_name(&session.provider_name) + .api_key(session.api_key.as_deref().unwrap_or("")); + if let Some(effort) = session.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + let provider = builder + .build() + .map_err(|e| format!("Failed to build OpenAI provider: {}", e))?; + + stream_with_tools(provider, messages, tools, max_steps, None, headers) + .await + .map_err(|e| format!("Stream error: {}", e)) + } + } +} + fn normalize_subagent_output(output: String) -> String { if output.trim().is_empty() { "Subagent completed without a final text response.".to_string() diff --git a/src/app.rs b/src/app.rs index f7161c1..d260a4f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -460,7 +460,11 @@ impl App { agent_policies = agent_policies.with_custom_tools(mode.clone(), tools.clone()); } let tool_permissions = crate::tools::ToolPermissions::new(cwd_path.clone()) - .with_agent_policies(agent_policies); + .with_agent_policies(agent_policies) + .with_permission_rules(loaded_config.merged_config.permission_rules.clone()) + .with_agent_permission_rules( + loaded_config.merged_config.agent_permission_rules.clone(), + ); let discovery = crate::model::discovery::Discovery::new().ok(); let cached_git_branch = git::get_branch_for_path(&cwd); @@ -5932,6 +5936,7 @@ impl App { .get(&self.agent.to_ascii_lowercase()) .copied(); let tool_permissions = self.tool_permissions.clone(); + let agent_steps = self.agent_steps.clone(); let cwd = self.cwd.clone(); let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); @@ -5944,14 +5949,32 @@ impl App { .any(|m| m.role == crate::session::types::MessageRole::System); if !has_system { + let prompt_registry = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let registry = crate::tools::initialize_tool_registry_with_dynamic( + Some(sender.clone()), + tool_permissions.clone(), + agent_steps.clone(), + cancel_token.clone(), + ) + .await; + crate::tools::scope_tool_registry_for_agent( + ®istry, + &tool_permissions, + &agent_mode, + ) + .await + }) + }); + // Create system prompt with tools let composer = crate::prompt::SystemPromptComposer::new( &model, &cwd, is_git_repo, std::env::consts::OS, - ); - + ) + .with_tool_registry(prompt_registry); let system_prompt = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { composer.compose().await }) }); @@ -5968,6 +5991,7 @@ impl App { reasoning_effort, agent_mode, agent_max_steps, + agent_steps, tool_permissions, messages, sender_clone.clone(), diff --git a/src/config/configuration.rs b/src/config/configuration.rs index f559740..e42117b 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -1,3 +1,6 @@ +use crate::tools::{ + expand_permission_pattern, PermissionPolicyAction, PermissionRule, PermissionRules, +}; use anyhow::{anyhow, Context, Result}; use regex::Regex; use serde_json::Value; @@ -247,6 +250,8 @@ pub struct MergedConfig { pub default_agent: Option, pub commands: Vec, pub agent_tool_policies: HashMap>, + pub permission_rules: PermissionRules, + pub agent_permission_rules: HashMap, pub agent_steps: HashMap, pub provider_timeouts: HashMap, pub notifications: NotificationsConfig, @@ -990,7 +995,9 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M } } + out.permission_rules = parse_permission_rules(obj.get("permission"), diagnostics, "permission"); out.agent_tool_policies = parse_agent_tool_policies(obj.get("agent"), diagnostics); + out.agent_permission_rules = parse_agent_permission_rules(obj.get("agent"), diagnostics); out.agent_steps = parse_agent_steps(obj.get("agent"), diagnostics); out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics); @@ -1055,6 +1062,147 @@ fn parse_agent_tool_policies( out } +fn parse_agent_permission_rules( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> HashMap { + let mut out = HashMap::new(); + let Some(Value::Object(agents)) = value else { + return out; + }; + + for (name, val) in agents { + let Some(agent_obj) = val.as_object() else { + continue; + }; + + let Some(permission) = agent_obj.get("permission") else { + continue; + }; + + let key = name.trim().to_ascii_lowercase(); + if key.is_empty() { + continue; + } + + let rules = parse_permission_rules( + Some(permission), + diagnostics, + &format!("agent.{}.permission", name), + ); + if !rules.is_empty() { + out.insert(key, rules); + } + } + + out +} + +fn parse_permission_rules( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, + context: &str, +) -> PermissionRules { + let mut out = Vec::new(); + let Some(value) = value else { + return out; + }; + + if value.is_null() { + return out; + } + + if let Some(action) = value.as_str() { + match PermissionPolicyAction::parse(action) { + Some(action) => out.push(PermissionRule { + permission: "*".to_string(), + pattern: "*".to_string(), + action, + }), + None => diagnostics.warnings.push(format!( + "{} must be one of allow, ask, or deny; got '{}'", + context, action + )), + } + return out; + } + + let Some(map) = value.as_object() else { + diagnostics + .warnings + .push(format!("{} must be a string or object", context)); + return out; + }; + + for (permission, value) in map { + let permission = permission.trim().to_ascii_lowercase(); + if permission.is_empty() { + diagnostics + .warnings + .push(format!("{} contains an empty permission key", context)); + continue; + } + + if let Some(action) = value.as_str() { + match PermissionPolicyAction::parse(action) { + Some(action) => out.push(PermissionRule { + permission, + pattern: "*".to_string(), + action, + }), + None => diagnostics.warnings.push(format!( + "{}.{} must be one of allow, ask, or deny; got '{}'", + context, permission, action + )), + } + continue; + } + + let Some(patterns) = value.as_object() else { + diagnostics.warnings.push(format!( + "{}.{} must be one of allow, ask, deny, or an object of pattern rules", + context, permission + )); + continue; + }; + + for (pattern, action_value) in patterns { + let Some(action_text) = action_value.as_str() else { + diagnostics.warnings.push(format!( + "{}.{}.{} must be one of allow, ask, or deny", + context, permission, pattern + )); + continue; + }; + + let Some(action) = PermissionPolicyAction::parse(action_text) else { + diagnostics.warnings.push(format!( + "{}.{}.{} must be one of allow, ask, or deny; got '{}'", + context, permission, pattern, action_text + )); + continue; + }; + + let pattern = expand_permission_pattern(pattern); + if pattern.trim().is_empty() { + diagnostics.warnings.push(format!( + "{}.{} contains an empty permission pattern", + context, permission + )); + continue; + } + + out.push(PermissionRule { + permission: permission.clone(), + pattern, + action, + }); + } + } + + out +} + fn parse_agent_steps( value: Option<&Value>, diagnostics: &mut ConfigDiagnostics, @@ -1860,6 +2008,76 @@ mod tests { assert!(diagnostics.warnings.is_empty()); } + #[test] + fn parses_global_permission_rules_in_order() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "git push *": "deny" + }, + "mcp_*": "deny" + } + }), + &mut diagnostics, + ); + + assert_eq!(config.permission_rules.len(), 4); + assert_eq!(config.permission_rules[0].permission, "bash"); + assert_eq!(config.permission_rules[0].pattern, "*"); + assert_eq!( + config.permission_rules[0].action, + PermissionPolicyAction::Ask + ); + assert_eq!(config.permission_rules[3].permission, "mcp_*"); + assert_eq!( + config.permission_rules[3].action, + PermissionPolicyAction::Deny + ); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_agent_permission_overrides() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "permission": "ask", + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git status *": "allow" + }, + "edit": "deny" + } + } + } + }), + &mut diagnostics, + ); + + assert_eq!(config.permission_rules.len(), 1); + assert_eq!(config.permission_rules[0].permission, "*"); + assert_eq!( + config.permission_rules[0].action, + PermissionPolicyAction::Ask + ); + + let build_rules = config + .agent_permission_rules + .get("build") + .expect("build agent permission rules"); + assert_eq!(build_rules.len(), 3); + assert_eq!(build_rules[2].permission, "edit"); + assert_eq!(build_rules[2].action, PermissionPolicyAction::Deny); + assert!(diagnostics.warnings.is_empty()); + } + #[test] fn agent_max_steps_alias_is_supported() { let mut diagnostics = ConfigDiagnostics::default(); diff --git a/src/llm/client.rs b/src/llm/client.rs index 68025de..bde980a 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -12,7 +12,7 @@ use tokio_util::sync::CancellationToken; use crate::tools::aisdk_bridge::convert_to_aisdk_tools; -const MAX_STEPS_REACHED_PROMPT: &str = r#"CRITICAL - MAXIMUM STEPS REACHED +pub(crate) const MAX_STEPS_REACHED_PROMPT: &str = r#"CRITICAL - MAXIMUM STEPS REACHED The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. @@ -365,6 +365,7 @@ pub async fn stream_llm_with_cancellation( reasoning_effort: Option, agent_mode: String, agent_max_steps: Option, + agent_steps: HashMap, tool_permissions: crate::tools::ToolPermissions, messages: Vec, sender: crate::llm::ChunkSender, @@ -383,9 +384,13 @@ pub async fn stream_llm_with_cancellation( let aisdk_messages = convert_messages_for_model(&messages, request_config.supports_image_input); - let tool_registry = crate::tools::initialize_tool_registry().await; - - crate::tools::register_dynamic_tools(&tool_registry, Some(sender.clone())).await; + let tool_registry = crate::tools::initialize_tool_registry_with_dynamic( + Some(sender.clone()), + tool_permissions.clone(), + agent_steps, + cancel_token.clone(), + ) + .await; // Set LLM session config for subagent use crate::agent::config::set_llm_session(crate::agent::config::LlmSessionConfig { @@ -410,6 +415,7 @@ pub async fn stream_llm_with_cancellation( Some(session_id.clone()), None, request_config.supports_image_input, + cancel_token.clone(), ) .await; diff --git a/src/main.rs b/src/main.rs index 7dd5a5a..7bce1c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -149,6 +149,7 @@ async fn run_print_mode( // Load config and model preferences let loaded_config = crate::config::ConfigLoader::load()?; + crate::skill::init_skill_store(&loaded_config.xdg_config_home, &loaded_config.project_root); let prefs_dao = crate::persistence::PrefsDAO::new().ok(); let (provider_name, model_id) = { @@ -197,33 +198,55 @@ async fn run_print_mode( let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); - // Build messages with system prompt - let composer = crate::prompt::SystemPromptComposer::new( - &model_id, - &cwd, - is_git_repo, - std::env::consts::OS, - ); - let system_prompt = composer.compose().await; - let messages = vec![Message::system(system_prompt), Message::user(prompt)]; - let (sender, mut receiver) = mpsc::unbounded_channel(); + let mut agent_policies = crate::tools::AgentToolPolicies::default(); + for (mode, tools) in &loaded_config.merged_config.agent_tool_policies { + agent_policies = agent_policies.with_custom_tools(mode.clone(), tools.clone()); + } let tool_permissions = crate::tools::ToolPermissions::new(std::path::PathBuf::from(&cwd)) + .with_agent_policies(agent_policies) + .with_permission_rules(loaded_config.merged_config.permission_rules.clone()) + .with_agent_permission_rules(loaded_config.merged_config.agent_permission_rules.clone()) .dangerously_skip_permissions(dangerously_skip_permissions); - + let agent_steps = loaded_config.merged_config.agent_steps.clone(); let agent_max_steps = loaded_config .merged_config .agent_steps .get(&agent_mode.to_ascii_lowercase()) .copied(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + + let prompt_registry = crate::tools::initialize_tool_registry_with_dynamic( + Some(sender.clone()), + tool_permissions.clone(), + agent_steps.clone(), + cancel_token.clone(), + ) + .await; + let prompt_registry = crate::tools::scope_tool_registry_for_agent( + &prompt_registry, + &tool_permissions, + &agent_mode, + ) + .await; + + // Build messages with system prompt + let composer = crate::prompt::SystemPromptComposer::new( + &model_id, + &cwd, + is_git_repo, + std::env::consts::OS, + ) + .with_tool_registry(prompt_registry); + let system_prompt = composer.compose().await; + let messages = vec![Message::system(system_prompt), Message::user(prompt)]; let provider_name_clone = provider_name.clone(); let model_clone = model_id.clone(); let completion_sender = sender.clone(); tokio::spawn(async move { - let cancel_token = tokio_util::sync::CancellationToken::new(); if let Err(err) = stream_llm_with_cancellation( cancel_token, cuid2::create_id(), @@ -232,6 +255,7 @@ async fn run_print_mode( reasoning_effort, agent_mode.clone(), agent_max_steps, + agent_steps, tool_permissions, messages, sender, diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index bad39e7..93e064d 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -5,6 +5,7 @@ use schemars::Schema; use serde_json::Value; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Instant; +use tokio_util::sync::CancellationToken; use crate::llm::ChunkSender; @@ -21,12 +22,13 @@ pub async fn convert_to_aisdk_tools( session_id: Option, message_id: Option, supports_image_input: bool, + cancel_token: CancellationToken, ) -> Vec { let mut aisdk_tools = Vec::new(); let tools = registry.list().await; for tool_def in tools { - if !permissions.is_tool_allowed_for_agent(&agent_mode, &tool_def.id) { + if !permissions.is_tool_visible_for_agent(&agent_mode, &tool_def.id) { crate::emit_log!( "[AISDK_TOOLS] Skipping '{}': not allowed in {} mode", tool_def.id, @@ -42,6 +44,7 @@ pub async fn convert_to_aisdk_tools( let permissions = permissions.clone(); let session_id = session_id.clone(); let message_id = message_id.clone(); + let cancel_token = cancel_token.clone(); let execute = ToolExecute::new(move |input: Value| { let tool_id = tool_id.clone(); @@ -54,6 +57,7 @@ pub async fn convert_to_aisdk_tools( let permissions = permissions.clone(); let session_id = session_id.clone(); let message_id = message_id.clone(); + let cancel_token = cancel_token.clone(); let supports_image_input = supports_image_input; async move { @@ -154,12 +158,11 @@ pub async fn convert_to_aisdk_tools( return Err(err); } - let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); - let ctx = ToolContext::new( + let ctx = ToolContext::from_cancel_token( session_id.clone().unwrap_or_else(|| "session".to_string()), message_id.clone().unwrap_or_else(|| "message".to_string()), agent_mode.clone(), - abort_rx, + cancel_token.clone(), ) .with_call_id(call_id.clone()); diff --git a/src/tools/context.rs b/src/tools/context.rs index 20b14de..64d225e 100644 --- a/src/tools/context.rs +++ b/src/tools/context.rs @@ -3,6 +3,7 @@ pub struct ToolContext { pub message_id: String, pub agent: String, pub abort: tokio::sync::watch::Receiver, + pub cancel_token: tokio_util::sync::CancellationToken, pub call_id: Option, pub extra: Option, } @@ -19,6 +20,25 @@ impl ToolContext { message_id: message_id.into(), agent: agent.into(), abort, + cancel_token: tokio_util::sync::CancellationToken::new(), + call_id: None, + extra: None, + } + } + + pub fn from_cancel_token( + session_id: impl Into, + message_id: impl Into, + agent: impl Into, + cancel_token: tokio_util::sync::CancellationToken, + ) -> Self { + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); + Self { + session_id: session_id.into(), + message_id: message_id.into(), + agent: agent.into(), + abort: abort_rx, + cancel_token, call_id: None, extra: None, } @@ -35,6 +55,6 @@ impl ToolContext { } pub fn is_aborted(&self) -> bool { - *self.abort.borrow() + self.cancel_token.is_cancelled() || *self.abort.borrow() } } diff --git a/src/tools/init.rs b/src/tools/init.rs index 342c2b1..fc64ad5 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,9 +1,11 @@ use crate::tools::{ fs::{GlobTool, GrepTool, ListTool, ReadTool, ViewImageTool, WriteTool}, - BashTool, EditTool, QuestionTool, SkillTool, TaskTool, ToolRegistry, UpdatePlanTool, - WebfetchTool, + BashTool, EditTool, QuestionTool, SkillTool, TaskTool, ToolPermissions, ToolRegistry, + UpdatePlanTool, WebfetchTool, }; +use std::collections::HashMap; use std::sync::Arc; +use tokio_util::sync::CancellationToken; pub async fn initialize_tool_registry() -> ToolRegistry { let registry = ToolRegistry::new(); @@ -26,6 +28,9 @@ pub async fn initialize_tool_registry() -> ToolRegistry { pub async fn register_dynamic_tools( registry: &ToolRegistry, sender: Option, + permissions: ToolPermissions, + agent_steps: HashMap, + cancel_token: CancellationToken, ) { registry .register(Arc::new( @@ -35,7 +40,74 @@ pub async fn register_dynamic_tools( registry .register(Arc::new( - TaskTool::new(registry.clone()).with_sender_opt(sender), + TaskTool::new(registry.clone()) + .with_sender_opt(sender) + .with_runtime_options(permissions, agent_steps, cancel_token), )) .await; } + +pub async fn initialize_tool_registry_with_dynamic( + sender: Option, + permissions: ToolPermissions, + agent_steps: HashMap, + cancel_token: CancellationToken, +) -> ToolRegistry { + let registry = initialize_tool_registry().await; + register_dynamic_tools(®istry, sender, permissions, agent_steps, cancel_token).await; + registry +} + +pub async fn scope_tool_registry_for_agent( + registry: &ToolRegistry, + permissions: &ToolPermissions, + agent_mode: &str, +) -> ToolRegistry { + let scoped = ToolRegistry::new(); + for tool in registry.list().await { + if permissions.is_tool_visible_for_agent(agent_mode, &tool.id) { + if let Some(handler) = registry.get(&tool.id).await { + scoped.register(handler).await; + } + } + } + scoped +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn dynamic_registry_contains_runtime_tools() { + let registry = initialize_tool_registry_with_dynamic( + None, + ToolPermissions::new("."), + HashMap::new(), + CancellationToken::new(), + ) + .await; + + assert!(registry.get("question").await.is_some()); + assert!(registry.get("task").await.is_some()); + } + + #[tokio::test] + async fn scoped_plan_registry_hides_mutating_tools() { + let permissions = ToolPermissions::new("."); + let registry = initialize_tool_registry_with_dynamic( + None, + permissions.clone(), + HashMap::new(), + CancellationToken::new(), + ) + .await; + let scoped = scope_tool_registry_for_agent(®istry, &permissions, "plan").await; + + assert!(scoped.get("read").await.is_some()); + assert!(scoped.get("task").await.is_some()); + assert!(scoped.get("bash").await.is_none()); + assert!(scoped.get("write").await.is_none()); + assert!(scoped.get("edit").await.is_none()); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index b1a6d10..8ec305c 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -19,9 +19,12 @@ pub mod webfetch; pub use bash::BashTool; pub use context::ToolContext; pub use edit::EditTool; -pub use init::{initialize_tool_registry, register_dynamic_tools}; +pub use init::{ + initialize_tool_registry, initialize_tool_registry_with_dynamic, scope_tool_registry_for_agent, +}; pub use permission::{ - AgentToolPolicies, PermissionAction, PermissionPrompt, PermissionResponse, ToolPermissions, + expand_permission_pattern, AgentToolPolicies, PermissionAction, PermissionPolicyAction, + PermissionPrompt, PermissionResponse, PermissionRule, PermissionRules, ToolPermissions, }; pub use question::QuestionTool; pub use registry::ToolRegistry; diff --git a/src/tools/permission.rs b/src/tools/permission.rs index 1797760..1efbcf8 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -1,5 +1,6 @@ use crate::llm::{ChunkMessage, ChunkSender}; use crate::tools::ToolError; +use regex::Regex; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; @@ -43,6 +44,33 @@ pub enum PermissionResponse { AllowAlways, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PermissionPolicyAction { + Allow, + Deny, + Ask, +} + +impl PermissionPolicyAction { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "allow" => Some(Self::Allow), + "deny" => Some(Self::Deny), + "ask" => Some(Self::Ask), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PermissionRule { + pub permission: String, + pub pattern: String, + pub action: PermissionPolicyAction, +} + +pub type PermissionRules = Vec; + #[derive(Debug)] pub struct PermissionPrompt { pub tool_id: String, @@ -59,6 +87,7 @@ enum PermissionReasonKind { SensitivePath, ExternalPath, DoomLoop, + ConfiguredAsk, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -116,9 +145,9 @@ impl AgentToolPolicies { } if mode == "plan" { - // OpenCode plan mode denies file modifications, but keeps other - // tools available under the normal permission policy. - return !matches!(tool.as_str(), "write" | "edit"); + // OpenCode plan mode is read-only by default. Custom agent tool + // policies above can still opt specific tools back in. + return !matches!(tool.as_str(), "bash" | "write" | "edit"); } if mode == "build" { @@ -142,6 +171,8 @@ pub struct ToolPermissions { always_grants: Arc>>, call_counts: Arc>>, agent_policies: Arc, + permission_rules: Arc, + agent_permission_rules: Arc>, dangerously_skip_permissions: bool, } @@ -152,6 +183,8 @@ impl ToolPermissions { always_grants: Arc::new(RwLock::new(HashSet::new())), call_counts: Arc::new(RwLock::new(HashMap::new())), agent_policies: Arc::new(AgentToolPolicies::default()), + permission_rules: Arc::new(Vec::new()), + agent_permission_rules: Arc::new(HashMap::new()), dangerously_skip_permissions: false, } } @@ -161,6 +194,21 @@ impl ToolPermissions { self } + pub fn with_permission_rules(mut self, rules: PermissionRules) -> Self { + self.permission_rules = Arc::new(rules); + self + } + + pub fn with_agent_permission_rules(mut self, rules: HashMap) -> Self { + let normalized = rules + .into_iter() + .map(|(agent, rules)| (agent.trim().to_ascii_lowercase(), rules)) + .filter(|(agent, _)| !agent.is_empty()) + .collect(); + self.agent_permission_rules = Arc::new(normalized); + self + } + pub fn dangerously_skip_permissions(mut self, enabled: bool) -> Self { self.dangerously_skip_permissions = enabled; self @@ -174,6 +222,19 @@ impl ToolPermissions { self.agent_policies.is_allowed(agent_mode, tool_id) } + pub fn is_tool_visible_for_agent(&self, agent_mode: &str, tool_id: &str) -> bool { + if !self.is_tool_allowed_for_agent(agent_mode, tool_id) { + return false; + } + + let permission_key = permission_key_for_tool_id(tool_id); + let patterns = vec!["*".to_string()]; + !matches!( + self.evaluate_config_decision(agent_mode, &permission_key, tool_id, &patterns), + Some(PermissionPolicyAction::Deny) + ) + } + pub async fn preflight( &self, agent_mode: &str, @@ -188,10 +249,6 @@ impl ToolPermissions { ))); } - if self.dangerously_skip_permissions { - return Ok(()); - } - let action = PermissionAction::from_tool_id(tool_id); let path = extract_primary_path(action, params, &self.workdir); let command = if action == PermissionAction::Bash { @@ -199,19 +256,121 @@ impl ToolPermissions { } else { None }; + let permission_key = permission_key_for_tool_id(tool_id); + let patterns = permission_patterns_for_tool( + tool_id, + action, + params, + path.as_deref(), + command.as_deref(), + &self.workdir, + ); + + match self.evaluate_config_decision(agent_mode, &permission_key, tool_id, &patterns) { + Some(PermissionPolicyAction::Deny) => { + return Err(ToolError::Permission(configured_deny_text( + tool_id, &patterns, + ))); + } + Some(PermissionPolicyAction::Ask) if !self.dangerously_skip_permissions => { + return self + .ask_permission( + tool_id, + action, + PermissionReasonKind::ConfiguredAsk, + path.as_deref(), + command.clone(), + sender, + ) + .await; + } + _ => {} + } - let reason = self.evaluate_reason(action, path.as_deref()); - let reason = match reason { - Some(reason) => Some(reason), - None => self.evaluate_doom_loop(tool_id, params).await, - }; + let mut reason = self.evaluate_reason(action, path.as_deref()); + if let Some(reason_kind) = reason { + match self.evaluate_guard_decision( + agent_mode, + tool_id, + reason_kind, + path.as_deref(), + &patterns, + ) { + Some(PermissionPolicyAction::Deny) => { + return Err(ToolError::Permission(guard_deny_text( + reason_kind, + tool_id, + path.as_deref(), + ))); + } + Some(PermissionPolicyAction::Allow) => { + reason = None; + } + _ => {} + } + } - let Some(reason_kind) = reason else { + if self.dangerously_skip_permissions { return Ok(()); - }; + } + if let Some(reason_kind) = reason { + return self + .ask_permission( + tool_id, + action, + reason_kind, + path.as_deref(), + command.clone(), + sender, + ) + .await; + } + + if let Some(reason_kind) = self.evaluate_doom_loop(tool_id, params).await { + match self.evaluate_guard_decision( + agent_mode, + tool_id, + reason_kind, + path.as_deref(), + &patterns, + ) { + Some(PermissionPolicyAction::Deny) => { + return Err(ToolError::Permission(guard_deny_text( + reason_kind, + tool_id, + path.as_deref(), + ))); + } + Some(PermissionPolicyAction::Allow) => return Ok(()), + _ => { + return self + .ask_permission( + tool_id, + action, + reason_kind, + path.as_deref(), + command, + sender, + ) + .await; + } + } + } + + Ok(()) + } + + async fn ask_permission( + &self, + tool_id: &str, + action: PermissionAction, + reason_kind: PermissionReasonKind, + path: Option<&Path>, + command: Option, + sender: Option<&ChunkSender>, + ) -> Result<(), ToolError> { let target = path - .as_ref() .map(|p| p.display().to_string()) .or_else(|| command.clone()); let prompt_target = if action == PermissionAction::Bash { @@ -220,7 +379,7 @@ impl ToolPermissions { target.clone() }; let workdir = if action == PermissionAction::Bash { - path.as_ref().map(|p| p.display().to_string()) + path.map(|p| p.display().to_string()) } else { None }; @@ -273,6 +432,56 @@ impl ToolPermissions { } } + fn evaluate_config_decision( + &self, + agent_mode: &str, + permission_key: &str, + tool_id: &str, + patterns: &[String], + ) -> Option { + let agent_key = agent_mode.trim().to_ascii_lowercase(); + let empty: &[PermissionRule] = &[]; + let agent_rules = self + .agent_permission_rules + .get(&agent_key) + .map(Vec::as_slice) + .unwrap_or(empty); + evaluate_permission_rules( + permission_key, + tool_id, + patterns, + &[self.permission_rules.as_slice(), agent_rules], + ) + } + + fn evaluate_guard_decision( + &self, + agent_mode: &str, + tool_id: &str, + reason: PermissionReasonKind, + path: Option<&Path>, + fallback_patterns: &[String], + ) -> Option { + let (permission_key, patterns) = match reason { + PermissionReasonKind::ExternalPath => ( + "external_directory".to_string(), + path.map(|path| path_patterns(path, &self.workdir)) + .filter(|patterns| !patterns.is_empty()) + .unwrap_or_else(|| fallback_patterns.to_vec()), + ), + PermissionReasonKind::DoomLoop => ( + "doom_loop".to_string(), + vec![tool_id.to_string(), "*".to_string()], + ), + PermissionReasonKind::SensitivePath | PermissionReasonKind::ConfiguredAsk => ( + permission_key_for_tool_id(tool_id), + fallback_patterns.to_vec(), + ), + }; + + self.evaluate_config_decision(agent_mode, &permission_key, tool_id, &patterns) + } + fn evaluate_reason( &self, action: PermissionAction, @@ -360,6 +569,61 @@ fn reason_text(reason: PermissionReasonKind, tool_id: &str, target: Option<&str> tool_id ), }, + PermissionReasonKind::ConfiguredAsk => match target { + Some(target) => format!( + "Permission config requires approval before tool '{}' can access '{}'", + tool_id, target + ), + None => format!( + "Permission config requires approval before running tool '{}'", + tool_id + ), + }, + } +} + +fn configured_deny_text(tool_id: &str, patterns: &[String]) -> String { + let target = patterns + .iter() + .find(|pattern| pattern.as_str() != "*") + .map(String::as_str) + .unwrap_or("*"); + format!( + "Permission config denies tool '{}' for pattern '{}'", + tool_id, target + ) +} + +fn guard_deny_text(reason: PermissionReasonKind, tool_id: &str, path: Option<&Path>) -> String { + let target = path.map(|p| p.display().to_string()); + match reason { + PermissionReasonKind::SensitivePath => match target { + Some(target) => format!( + "Permission config denies tool '{}' access to sensitive file '{}'", + tool_id, target + ), + None => format!( + "Permission config denies tool '{}' access to sensitive files", + tool_id + ), + }, + PermissionReasonKind::ExternalPath => match target { + Some(target) => format!( + "Permission config denies tool '{}' access outside the working directory: {}", + tool_id, target + ), + None => format!( + "Permission config denies tool '{}' access outside the working directory", + tool_id + ), + }, + PermissionReasonKind::DoomLoop => { + format!( + "Permission config denies repeated identical tool calls for '{}'", + tool_id + ) + } + PermissionReasonKind::ConfiguredAsk => configured_deny_text(tool_id, &[]), } } @@ -370,6 +634,199 @@ fn get_string(params: &Value, key: &str) -> Option { .map(|s| s.to_string()) } +fn permission_key_for_tool_id(tool_id: &str) -> String { + match tool_id.trim().to_ascii_lowercase().as_str() { + "write" | "edit" => "edit".to_string(), + "read" | "view_image" => "read".to_string(), + other => other.to_string(), + } +} + +fn permission_patterns_for_tool( + tool_id: &str, + action: PermissionAction, + params: &Value, + path: Option<&Path>, + command: Option<&str>, + workdir: &Path, +) -> Vec { + let mut patterns = Vec::new(); + + match tool_id { + "bash" => { + if let Some(command) = command { + push_nonempty(&mut patterns, command); + } + } + "glob" => { + if let Some(pattern) = get_string(params, "pattern") { + push_nonempty(&mut patterns, &pattern); + } + } + "grep" => { + if let Some(pattern) = get_string(params, "pattern") { + push_nonempty(&mut patterns, &pattern); + } + } + "skill" => { + if let Some(name) = get_string(params, "name") { + push_nonempty(&mut patterns, &name); + } + } + "task" => { + if let Some(subagent) = get_string(params, "subagent_type") { + push_nonempty(&mut patterns, &subagent); + } + } + "webfetch" => { + if let Some(url) = get_string(params, "url") { + push_nonempty(&mut patterns, &url); + } + } + "question" | "update_plan" => patterns.push("*".to_string()), + _ => {} + } + + if patterns.is_empty() { + if let Some(path) = path { + patterns.extend(path_patterns(path, workdir)); + } + } + + if patterns.is_empty() { + if matches!( + action, + PermissionAction::Unknown + | PermissionAction::Bash + | PermissionAction::Glob + | PermissionAction::Grep + ) { + patterns.push("*".to_string()); + } + } + + patterns +} + +fn push_nonempty(patterns: &mut Vec, value: &str) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + patterns.push(trimmed.to_string()); + } +} + +fn path_patterns(path: &Path, workdir: &Path) -> Vec { + let mut out = Vec::new(); + let absolute = normalize_path(path); + + if let Ok(relative) = absolute.strip_prefix(workdir) { + let relative = normalize_pattern_path(relative); + if !relative.is_empty() { + out.push(relative); + } + } + + let absolute = normalize_pattern_path(&absolute); + if !absolute.is_empty() && !out.iter().any(|existing| existing == &absolute) { + out.push(absolute); + } + + if out.is_empty() { + out.push("*".to_string()); + } + + out +} + +fn normalize_pattern_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn evaluate_permission_rules( + permission_key: &str, + tool_id: &str, + patterns: &[String], + rulesets: &[&[PermissionRule]], +) -> Option { + let permission_key = permission_key.trim().to_ascii_lowercase(); + let tool_id = tool_id.trim().to_ascii_lowercase(); + let patterns = if patterns.is_empty() { + vec!["*".to_string()] + } else { + patterns.to_vec() + }; + + let mut decision = None; + for ruleset in rulesets { + for rule in *ruleset { + if !wildcard_match(&permission_key, &rule.permission) + && !wildcard_match(&tool_id, &rule.permission) + { + continue; + } + + if patterns + .iter() + .any(|pattern| wildcard_match(pattern, &rule.pattern)) + { + decision = Some(rule.action); + } + } + } + + decision +} + +pub fn expand_permission_pattern(pattern: &str) -> String { + let trimmed = pattern.trim(); + let Some(home) = dirs::home_dir() else { + return trimmed.to_string(); + }; + let home = home.to_string_lossy(); + + if trimmed == "~" { + return home.to_string(); + } + if let Some(rest) = trimmed.strip_prefix("~/") { + return format!("{}/{}", home, rest); + } + if trimmed == "$HOME" { + return home.to_string(); + } + if let Some(rest) = trimmed.strip_prefix("$HOME/") { + return format!("{}/{}", home, rest); + } + trimmed.to_string() +} + +fn wildcard_match(input: &str, pattern: &str) -> bool { + let input = input.replace('\\', "/"); + let pattern = pattern.replace('\\', "/"); + let mut escaped = String::new(); + + for ch in pattern.chars() { + match ch { + '*' => escaped.push_str(".*"), + '?' => escaped.push('.'), + '.' | '+' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '\\' => { + escaped.push('\\'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + + if escaped.ends_with(" .*") { + escaped.truncate(escaped.len() - 3); + escaped.push_str("( .*)?"); + } + + let pattern = format!("(?s)^{}$", escaped); + Regex::new(&pattern) + .map(|regex| regex.is_match(&input)) + .unwrap_or(false) +} + fn extract_primary_path( action: PermissionAction, params: &Value, @@ -467,11 +924,20 @@ mod tests { let policies = AgentToolPolicies::default(); assert!(policies.is_allowed("plan", "read")); assert!(policies.is_allowed("plan", "glob")); - assert!(policies.is_allowed("plan", "bash")); + assert!(!policies.is_allowed("plan", "bash")); assert!(!policies.is_allowed("plan", "write")); assert!(!policies.is_allowed("plan", "edit")); } + #[test] + fn custom_plan_policy_can_explicitly_allow_bash() { + let policies = AgentToolPolicies::default() + .with_custom_tools("plan", vec!["read".to_string(), "bash".to_string()]); + + assert!(policies.is_allowed("plan", "bash")); + assert!(!policies.is_allowed("plan", "write")); + } + #[test] fn sensitive_path_detection_matches_env_patterns() { assert!(is_sensitive_path(Path::new(".env"))); @@ -632,6 +1098,133 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn configured_bash_patterns_use_last_matching_rule() { + let perms = ToolPermissions::new("/tmp/workspace").with_permission_rules(vec![ + PermissionRule { + permission: "bash".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Ask, + }, + PermissionRule { + permission: "bash".to_string(), + pattern: "git *".to_string(), + action: PermissionPolicyAction::Allow, + }, + PermissionRule { + permission: "bash".to_string(), + pattern: "git push *".to_string(), + action: PermissionPolicyAction::Deny, + }, + ]); + + let allowed = serde_json::json!({ + "command": "git status --short", + "workdir": "/tmp/workspace", + }); + let denied = serde_json::json!({ + "command": "git push origin main", + "workdir": "/tmp/workspace", + }); + + assert!(perms + .preflight("build", "bash", &allowed, None) + .await + .is_ok()); + assert!(perms + .preflight("build", "bash", &denied, None) + .await + .is_err()); + } + + #[tokio::test] + async fn configured_ask_prompts_for_matching_tool_pattern() { + let perms = + ToolPermissions::new("/tmp/workspace").with_permission_rules(vec![PermissionRule { + permission: "mcp_*".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Ask, + }]); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({}); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { + perms + .preflight("build", "mcp_lookup", ¶ms, Some(&tx)) + .await + } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "mcp_lookup"); + assert!(prompt + .reason + .contains("Permission config requires approval")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn agent_permission_rules_override_global_rules() { + let mut agent_rules = HashMap::new(); + agent_rules.insert( + "build".to_string(), + vec![PermissionRule { + permission: "bash".to_string(), + pattern: "git *".to_string(), + action: PermissionPolicyAction::Allow, + }], + ); + + let perms = ToolPermissions::new("/tmp/workspace") + .with_permission_rules(vec![PermissionRule { + permission: "bash".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Deny, + }]) + .with_agent_permission_rules(agent_rules); + let params = serde_json::json!({ + "command": "git status", + "workdir": "/tmp/workspace", + }); + + assert!(perms + .preflight("build", "bash", ¶ms, None) + .await + .is_ok()); + assert!(perms + .preflight("plan", "bash", ¶ms, None) + .await + .is_err()); + } + + #[tokio::test] + async fn external_directory_allow_bypasses_default_prompt() { + let perms = + ToolPermissions::new("/tmp/workspace").with_permission_rules(vec![PermissionRule { + permission: "external_directory".to_string(), + pattern: "/tmp/elsewhere/*".to_string(), + action: PermissionPolicyAction::Allow, + }]); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }); + + let result = perms.preflight("build", "read", ¶ms, Some(&tx)).await; + + assert!(result.is_ok()); + assert!(rx.try_recv().is_err()); + } + #[tokio::test] async fn bash_external_workdir_prompt_separates_command_from_workdir() { let perms = ToolPermissions::new("/tmp/workspace"); diff --git a/src/tools/task.rs b/src/tools/task.rs index bd56f90..dd05215 100644 --- a/src/tools/task.rs +++ b/src/tools/task.rs @@ -5,11 +5,16 @@ use crate::tools::{ }; use async_trait::async_trait; use serde_json::Value; +use std::collections::HashMap; use std::sync::Arc; +use tokio_util::sync::CancellationToken; pub struct TaskTool { tool_registry: Arc, sender: Option, + permissions: Option, + agent_steps: HashMap, + cancel_token: CancellationToken, } impl TaskTool { @@ -17,6 +22,9 @@ impl TaskTool { Self { tool_registry: Arc::new(tool_registry), sender: None, + permissions: None, + agent_steps: HashMap::new(), + cancel_token: CancellationToken::new(), } } @@ -24,6 +32,18 @@ impl TaskTool { self.sender = sender; self } + + pub fn with_runtime_options( + mut self, + permissions: crate::tools::ToolPermissions, + agent_steps: HashMap, + cancel_token: CancellationToken, + ) -> Self { + self.permissions = Some(permissions); + self.agent_steps = agent_steps; + self.cancel_token = cancel_token; + self + } } #[async_trait] @@ -81,6 +101,18 @@ impl ToolHandler for TaskTool { if ctx.is_aborted() { return Err(ToolError::Execution("Subagent cancelled".to_string())); } + let subagent_cancel_token = ctx.cancel_token.clone(); + let permissions = self.permissions.clone().unwrap_or_else(|| { + crate::tools::ToolPermissions::new(crate::utils::cwd::current_dir_or_dot()) + }); + let max_steps = self + .agent_steps + .get(subagent_type.name()) + .or_else(|| { + self.agent_steps + .get(&subagent_type.name().to_ascii_lowercase()) + }) + .copied(); let child_session_id = cuid2::create_id(); let title = format!( @@ -121,6 +153,9 @@ impl ToolHandler for TaskTool { &self.tool_registry, child_sender.clone(), child_session_id.clone(), + subagent_cancel_token, + permissions, + max_steps, ) .await { From 1f07c7d5fae7a80597f3a2771e4fda6d5cef11b1 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 10:49:02 +0800 Subject: [PATCH 179/226] fix: kimi k2.6 fixes. --- aisdk/src/providers/compatible.rs | 144 ++++++++++++++++++++++-------- 1 file changed, 107 insertions(+), 37 deletions(-) diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs index 79e7ced..8b0ebcd 100644 --- a/aisdk/src/providers/compatible.rs +++ b/aisdk/src/providers/compatible.rs @@ -102,28 +102,7 @@ impl Provider for OpenAICompatible { let include_empty_tool_call_reasoning = openai_compatible_requires_tool_call_reasoning_content(self); - let chat_messages: Vec = messages - .iter() - .flat_map(|m| match m { - Message::System(s) => vec![serde_json::json!({ - "role": "system", - "content": s.content, - })], - Message::User(u) => vec![serde_json::json!({ - "role": "user", - "content": openai_compatible_user_content(u), - })], - Message::Assistant(a) => vec![serde_json::json!({ - "role": "assistant", - "content": a.content, - })], - Message::ToolCall(t) => vec![openai_compatible_tool_call_message( - t, - include_empty_tool_call_reasoning, - )], - Message::ToolOutput(t) => openai_compatible_tool_output_messages(t), - }) - .collect(); + let chat_messages = openai_compatible_messages(messages, include_empty_tool_call_reasoning); let tool_params: Vec = tools .iter() @@ -222,25 +201,88 @@ fn openai_compatible_user_content(user: &crate::message::UserMessage) -> serde_j serde_json::Value::Array(parts) } -fn openai_compatible_tool_call_message( - tool: &crate::message::ToolCallMessage, +fn openai_compatible_messages( + messages: &[Message], + include_empty_tool_call_reasoning: bool, +) -> Vec { + let mut chat_messages = Vec::new(); + let mut index = 0; + + while index < messages.len() { + match &messages[index] { + Message::System(s) => { + chat_messages.push(serde_json::json!({ + "role": "system", + "content": s.content, + })); + index += 1; + } + Message::User(u) => { + chat_messages.push(serde_json::json!({ + "role": "user", + "content": openai_compatible_user_content(u), + })); + index += 1; + } + Message::Assistant(a) => { + chat_messages.push(serde_json::json!({ + "role": "assistant", + "content": a.content, + })); + index += 1; + } + Message::ToolCall(_) => { + let mut tool_calls = Vec::new(); + let mut reasoning_content = None; + + while let Some(Message::ToolCall(tool)) = messages.get(index) { + if reasoning_content.is_none() { + reasoning_content = tool.reasoning_content.clone(); + } + tool_calls.push(openai_compatible_tool_call(tool)); + index += 1; + } + + chat_messages.push(openai_compatible_tool_call_message_from_calls( + tool_calls, + reasoning_content, + include_empty_tool_call_reasoning, + )); + } + Message::ToolOutput(t) => { + chat_messages.extend(openai_compatible_tool_output_messages(t)); + index += 1; + } + } + } + + chat_messages +} + +fn openai_compatible_tool_call(tool: &crate::message::ToolCallMessage) -> serde_json::Value { + serde_json::json!({ + "id": tool.call_id, + "type": "function", + "function": { + "name": tool.name, + "arguments": tool.arguments, + } + }) +} + +fn openai_compatible_tool_call_message_from_calls( + tool_calls: Vec, + reasoning_content: Option, include_empty_reasoning_content: bool, ) -> serde_json::Value { let mut message = serde_json::json!({ "role": "assistant", "content": serde_json::Value::Null, - "tool_calls": [{ - "id": tool.call_id, - "type": "function", - "function": { - "name": tool.name, - "arguments": tool.arguments, - } - }], + "tool_calls": tool_calls, }); - if let Some(reasoning_content) = &tool.reasoning_content { - message["reasoning_content"] = serde_json::Value::String(reasoning_content.clone()); + if let Some(reasoning_content) = reasoning_content { + message["reasoning_content"] = serde_json::Value::String(reasoning_content); } else if include_empty_reasoning_content { message["reasoning_content"] = serde_json::Value::String(String::new()); } @@ -497,7 +539,11 @@ mod tests { panic!("expected tool call message"); }; - let payload = openai_compatible_tool_call_message(&tool, false); + let payload = openai_compatible_tool_call_message_from_calls( + vec![openai_compatible_tool_call(&tool)], + tool.reasoning_content.clone(), + false, + ); assert_eq!(payload["reasoning_content"], "plan"); } @@ -515,14 +561,38 @@ mod tests { panic!("expected tool call message"); }; - let payload = openai_compatible_tool_call_message( - &tool, + let payload = openai_compatible_tool_call_message_from_calls( + vec![openai_compatible_tool_call(&tool)], + tool.reasoning_content.clone(), openai_compatible_requires_tool_call_reasoning_content(&provider), ); assert_eq!(payload["reasoning_content"], ""); } + #[test] + fn groups_adjacent_tool_calls_before_tool_outputs() { + let messages = vec![ + Message::system("system"), + Message::user("user"), + Message::tool_call("glob:0", "glob", r#"{"pattern":"**/*.jpg"}"#), + Message::tool_call("glob:1", "glob", r#"{"pattern":"**/*.png"}"#), + Message::tool_output("glob:0", "glob", "jpg result", false), + Message::tool_output("glob:1", "glob", "png result", false), + ]; + + let payload = openai_compatible_messages(&messages, false); + + assert_eq!(payload.len(), 5); + assert_eq!(payload[2]["role"], "assistant"); + assert_eq!(payload[2]["tool_calls"][0]["id"], "glob:0"); + assert_eq!(payload[2]["tool_calls"][1]["id"], "glob:1"); + assert_eq!(payload[3]["role"], "tool"); + assert_eq!(payload[3]["tool_call_id"], "glob:0"); + assert_eq!(payload[4]["role"], "tool"); + assert_eq!(payload[4]["tool_call_id"], "glob:1"); + } + #[test] fn done_marker_emits_terminal_chunk() { let chunks = process_sse_data("[DONE]"); From 20c088db733d1a42d08abe9015981995ee81b743 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 11:02:29 +0800 Subject: [PATCH 180/226] refactor: (annoying) prevent opening message actions when clicking assistant messagesfix: prevent opening message actions when clicking assistant messages. The `message_actions_index_at_position` method now filters out assistant messages, so clicking on assistant responses no longer triggers the message action overlay. --- src/app.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index d260a4f..7ccf726 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2982,10 +2982,8 @@ impl App { } if mouse.modifiers.is_empty() { - self.pending_chat_message_click = self - .chat_state - .chat - .message_index_at_position(mouse, chat_area); + self.pending_chat_message_click = + self.message_actions_index_at_position(mouse, chat_area); } } MouseEventKind::Drag(MouseButton::Left) => { @@ -4114,6 +4112,25 @@ impl App { self.show_message_actions_from(idx, return_focus); } + fn message_actions_index_at_position( + &self, + mouse: MouseEvent, + chat_area: Rect, + ) -> Option { + self.chat_state + .chat + .message_index_at_position(mouse, chat_area) + .filter(|idx| { + self.chat_state + .chat + .messages + .get(*idx) + .is_some_and(|message| { + message.role != crate::session::types::MessageRole::Assistant + }) + }) + } + fn show_message_actions_from(&mut self, idx: usize, return_focus: OverlayFocus) { use crate::ui::components::dialog::{Dialog, DialogItem}; @@ -6914,6 +6931,42 @@ mod tests { assert!(message_action_names(&app).contains(&"Undo".to_string())); } + #[test] + fn clicking_assistant_chat_message_does_not_open_message_actions() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + let _session_id = app.create_new_session(Some("Chat click".to_string())); + app.base_focus = BaseFocus::Chat; + let message = crate::session::types::Message::assistant("click me"); + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + let colors = app.get_current_theme_colors(); + let positions = app + .chat_state + .chat + .get_message_line_positions(78, &app.model, &colors); + app.chat_state.chat.message_line_positions = positions; + app.chat_state.chat.content_height = 4; + app.chat_state.chat.viewport_height = 18; + app.chat_state.chat.scroll_offset = 0; + assert_eq!( + app.chat_state.chat.message_index_at_position( + mouse(MouseEventKind::Down(MouseButton::Left), 1, 1), + app.current_chat_area(), + ), + Some(0) + ); + + app.handle_mouse_event(mouse(MouseEventKind::Down(MouseButton::Left), 1, 1)); + app.handle_mouse_event(mouse(MouseEventKind::Up(MouseButton::Left), 1, 1)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert_eq!(app.message_actions_index, None); + assert_eq!(app.chat_state.chat.highlighted_message_index, None); + } + #[test] fn hovering_chat_message_does_not_set_timeline_highlight() { let mut app = test_app(); From 4e2645a44c42a3a1dada0191ead7c8caf65bc82f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 14:18:16 +0800 Subject: [PATCH 181/226] feat(agent): add OpenCode-compatible agent registry with @mentions and markdown agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a centralized `AgentRegistry` that unifies built-in agents (`build`, `plan`, `explore`, `general`) with JSON-configured and OpenCode markdown agent definitions. - Add `AgentDefinition` and `AgentRegistry` in `src/agent/definition.rs` with support for `mode` (primary/subagent/all), `hidden`, `tools`, `permission`, `task_permissions`, `model`, `temperature`, `top_p`, and max steps. - Parse OpenCode markdown agent files (`.opencode/agents/*.md`) with YAML frontmatter and body-as-instructions, merging over JSON config. - Implement first-token `@agent` invocation with autocomplete, validation, and Task/child-session routing. - Enforce Plan-mode safety via registry: `plan` task permissions deny all subagents except `explore`; subagents execute under their target agent name instead of hard-coded `build`. - Apply subagent `model` overrides during Task/`@agent` execution and add live `▣ Agent • model` stream indicators in chat UI. - Update parity audit docs to reflect closed gaps. --- .opencode/commands/checkparity-opencode.md | 4 +- _docs/__PARITY.md | 64 +- _docs/config/index.mdx | 52 ++ _docs/config/opencode-compatibility.mdx | 12 +- _plans/__TODOS.md | 20 + src/agent/definition.rs | 985 +++++++++++++++++++++ src/agent/mod.rs | 1 + src/agent/subagent.rs | 170 ++-- src/app.rs | 395 ++++++++- src/autocomplete/command.rs | 13 + src/autocomplete/mod.rs | 7 + src/command/parser.rs | 61 +- src/config/configuration.rs | 37 +- src/llm/client.rs | 39 +- src/llm/mod.rs | 2 + src/main.rs | 23 +- src/prompt/mod.rs | 16 +- src/theme.rs | 6 +- src/tools/init.rs | 13 +- src/tools/permission.rs | 2 +- src/tools/task.rs | 185 +++- src/ui/components/chat.rs | 115 ++- src/ui/components/input.rs | 59 +- src/views/chat.rs | 85 +- 24 files changed, 2107 insertions(+), 259 deletions(-) create mode 100644 src/agent/definition.rs diff --git a/.opencode/commands/checkparity-opencode.md b/.opencode/commands/checkparity-opencode.md index 8402582..e77d396 100644 --- a/.opencode/commands/checkparity-opencode.md +++ b/.opencode/commands/checkparity-opencode.md @@ -5,6 +5,8 @@ agent: build Audit the crabcode codebase (this Rust project) against the opencode AI coding agent for 1:1 feature parity. Focus ONLY on core harness functionality (agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands). Do NOT audit UX, theming, keybinds, or non-harness features. +Before changing `_docs/__PARITY.md`, read the existing file and preserve any recently completed items. Add or update a short "Recent implementation notes" section that says which prior gaps have been closed, then adjust the matrix and priority list so completed work is not still presented as an open gap. + ## What to Audit For each area below, read the relevant crabcode source files, compare against how opencode does it (I will provide opencode's behavior inline), and produce a table row: Feature | Crabcode Status | Gap @@ -111,4 +113,4 @@ Produce a markdown table with these columns: Then a separate section with PRIORITY-ranked actionable gaps (CRITICAL/HIGH/MEDIUM/LOW) with specific file locations and implementation notes. -Write it in _docs/__PARITY.md \ No newline at end of file +Write it in _docs/__PARITY.md diff --git a/_docs/__PARITY.md b/_docs/__PARITY.md index e496a28..73a97f8 100644 --- a/_docs/__PARITY.md +++ b/_docs/__PARITY.md @@ -4,35 +4,43 @@ Checked: 2026-05-28. Scope: core harness functionality only: agent loop, system prompt, subagents, tool calling, skill loading, agent config, custom commands, and permissions. UX, theming, keybinds, model picker/auth UI, and other non-harness features are intentionally excluded. +Recent implementation notes: + +- Closed the Plan-mode Task escape hatch: Plan can still see `task`, but default task permissions only allow `explore`; subagents now execute under their target agent name instead of hard-coded `build`. +- Added a central `AgentRegistry` with built-in `build`, `plan`, `explore`, and `general` definitions, plus JSON/markdown agent definitions, visible subagent prompt listings, tool scoping, Task validation, and max-step lookup. +- Added OpenCode markdown agent parsing from discovered `.opencode/agents`/`agent` files with YAML frontmatter, body-as-instructions, `mode`, `hidden`, permissions, task permissions, and `steps`/`maxSteps`/`max_steps`. +- Added first-token `@agent` invocation for visible subagents, with autocomplete and direct Task/child-session execution. +- Applied subagent `model` overrides for Task/`@agent` execution and added live `▣ Agent • model` stream indicators for primary and child sessions. + ## Feature Matrix | # | Feature | OpenCode | Crabcode | Gap | | ---- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1.1 | Multi-step agentic iteration | LLM streaming loop continues across tool calls until model stop or step limit. | **Present.** `src/llm/client.rs` uses AI SDK `stream_with_tools`; `src/tools/aisdk_bridge.rs` executes tools and returns tool outputs to the model. | Harness is functional, but orchestration is split across `llm/client.rs`, the AI SDK bridge, and app state rather than a single reusable agent runner shared by primary and subagents. | | 1.2 | Cancellation token support | User interruption cancels active model/tool work. | **Mostly present.** `src/app.rs` stores `CancellationToken`s; `relay_stream_to_sender` emits `ChunkMessage::Cancelled`; `ToolContext` carries the token; `TaskTool`/subagents receive it. | Long-running tools only cancel if they check `ctx.is_aborted`; `webfetch` and most sync filesystem tools do not poll mid-operation. | -| 1.3 | Step limit enforcement with text-only fallback | Configured max steps stops tools and injects a max-steps text-only summary prompt. | **Present.** `MAX_STEPS_REACHED_PROMPT` and fallback completion exist in `src/llm/client.rs`; subagents have equivalent fallback in `src/agent/subagent.rs`. | Config supports `steps` and `maxSteps`, but not OpenCode's snake-case `max_steps`; step-limit handling should be centralized in the shared runner. | +| 1.3 | Step limit enforcement with text-only fallback | Configured max steps stops tools and injects a max-steps text-only summary prompt. | **Present.** `MAX_STEPS_REACHED_PROMPT` and fallback completion exist in `src/llm/client.rs`; subagents have equivalent fallback in `src/agent/subagent.rs`. | Step-limit handling is still duplicated between primary streams and subagents instead of centralized in a shared runner. | | 1.4 | Chunk-based streaming | Streams text, reasoning, tool calls, tool results, errors, metrics, and cancellation. | **Present.** `src/llm/mod.rs` defines `Text`, `Reasoning`, `ToolCalls`, `ToolResult`, `Failed`, `Metrics`, `Cancelled`, plus permission/question/subagent events. | Provider raw/partial `ToolCall` chunks are logged in `relay_stream_to_sender` but UI tool-call chunks are emitted from the execution bridge once execution starts, so partial tool-call argument streaming is not exposed. | -| 1.5 | Plan/Build mode toggle | Plan mode is read-only; Build mode permits mutating tools. | **Partial.** `src/app.rs` toggles `Plan`/`Build`; `AgentToolPolicies` hides `bash`, `write`, and `edit` in plan mode. | Plan mode still exposes `task`; a Plan agent can spawn the `general` subagent, and `run_subagent` scopes it as `build`, allowing `bash`/`write`/`edit`. This breaks read-only parity. | +| 1.5 | Plan/Build mode toggle | Plan mode is read-only; Build mode permits mutating tools. | **Mostly present.** `src/app.rs` toggles `Plan`/`Build`; registry tool policy keeps Plan read-only and permits Task only to allowed read-only subagents. | OpenCode's exact Plan exceptions for writing plan files are not modeled; Plan safety now holds for Task/general. | | 1.6 | Permission preflight during tool execution | Tool calls are checked before execution and may trigger mid-stream dialogs. | **Present.** `src/tools/aisdk_bridge.rs` calls `ToolPermissions::preflight`; `PermissionRequest` is handled by `src/app.rs`. | Custom command shell/file expansions bypass this preflight path. | -| 1.7 | Configurable max steps per agent | Per-agent `max_steps` controls agent loop depth. | **Partial.** `src/config/configuration.rs` parses `agent..steps` and `maxSteps`; app/print/task paths pass limits by current agent/subagent name. | Missing `max_steps` alias and full OpenCode agent config integration. | +| 1.7 | Configurable max steps per agent | Per-agent `max_steps` controls agent loop depth. | **Present.** Agent registry supports `steps`, `maxSteps`, and `max_steps`; app/print/task paths read max steps from registry definitions. | No material max-step config gap beyond the duplicated runner logic noted in 1.3. | | 2.1 | Provider-specific header and behavior instructions | Provider/model-specific prompt variants for Beast/OpenAI, Anthropic, Gemini, and Codex. | **Mostly present.** `src/prompt/mod.rs` has OpenAI, Anthropic, Gemini, Codex, and Generic prompt branches. | Selection is based on model-id string heuristics, not resolved provider kind/model metadata, so OpenAI-compatible or renamed models can get the wrong prompt. | | 2.2 | Environment context block | Includes workdir, git status, platform, and date. | **Present.** `SystemPromptComposer::get_environment_context` emits `` with working directory, git-repo flag, platform, and date. | No material harness gap. | | 2.3 | Tool schemas block | System prompt lists registered tool schemas as JSON. | **Present.** `SystemPromptComposer::with_tool_registry` emits JSON schemas; app and print mode compose prompts with scoped dynamic registries. | Schemas are scoped to visible tools for the current mode, not literally every registered tool; registry ordering is not deterministic because it is backed by a `HashMap`. | | 2.4 | Custom instructions discovery | Walk-up discovery for `AGENTS.md`/`CLAUDE.md` plus global fallback. | **Partial.** `src/prompt/rules.rs` walks upward for nearest local `AGENTS.md` or `CLAUDE.md`; global fallback checks `$XDG_CONFIG_HOME/crabcode/AGENTS.md` and `~/.claude/CLAUDE.md`. | Walk-up does not stop at the git/project root; it does not load OpenCode global instruction locations; it returns the first local match instead of a layered instruction stack. | | 2.5 | Available skills XML block | Prompt lists discovered skills as ``. | **Present.** `src/prompt/mod.rs` appends skills from `SkillStore`; `src/tools/skill.rs` repeats them in the tool description. | No material gap beyond discovery/permission gaps listed below. | -| 2.6 | Available subagents XML block | Prompt lists subagent names/descriptions so the primary agent can use Task. | **Partial.** `src/prompt/mod.rs` emits `` from `SubAgentDef::all()`. | Only hard-coded `explore` and `general` are listed; no `scout`, `vlm-agent`, hidden agents, or config-defined agents. | -| 3.1 | Task tool | Primary agents spawn subagents through a built-in `task` tool. | **Present.** `src/tools/task.rs` is registered dynamically by `register_dynamic_tools`. | Task targets are hard-coded and not backed by an OpenCode-style agent registry. | -| 3.2 | `explore` subagent | Fast read-only subagent with `glob`, `grep`, `read`, and `list`. | **Present.** `SubAgentType::Explore` and `build_scoped_registry` restrict it to those tools. | System prompt is bespoke and does not use the shared prompt composer/environment/custom-instruction pipeline. | -| 3.3 | `general` subagent | Full tool access minus `todowrite`. | **Partial.** `SubAgentType::General` allows `bash`, `edit`, `write`, `read`, `grep`, `glob`, `list`, `skill`, and `webfetch`. | Missing several available tools (`question`, `task`, `update_plan`, `view_image`) and future OpenCode tools; tool access is a hard-coded allowlist. | +| 2.6 | Available subagents XML block | Prompt lists subagent names/descriptions so the primary agent can use Task. | **Mostly present.** `src/prompt/mod.rs` emits visible subagent definitions from `AgentRegistry`, including config-defined agents. | Missing OpenCode built-in `scout` and `vlm-agent`; hidden agents are intentionally omitted from prompt listings. | +| 3.1 | Task tool | Primary agents spawn subagents through a built-in `task` tool. | **Present.** `src/tools/task.rs` is registered dynamically and validates targets through `AgentRegistry`. | Background/resumable task features are not implemented. | +| 3.2 | `explore` subagent | Fast read-only subagent with `glob`, `grep`, `read`, and `list`. | **Present.** Registry-defined `explore` is read-only and `build_scoped_registry` restricts tools from the target definition. | System prompt is still bespoke and does not use the shared prompt composer/environment/custom-instruction pipeline. | +| 3.3 | `general` subagent | Full tool access minus `todowrite`. | **Mostly present.** Registry-defined `general` can use all registered tools, and subagent execution uses the `general` agent policy. | Crabcode still lacks `todowrite` and other missing OpenCode tools listed below, so "full tool access" is limited to Crabcode's current tool set. | | 3.4 | `scout` subagent | Read-only external-docs/dependency research agent that can clone repos. | **Missing.** No `Scout` variant or prompt exists in `src/agent/subagent.rs`. | Add a scout definition, clone-safe tool policy, Task validation, prompt listing, and permission defaults. | | 3.5 | `vlm-agent` | Dedicated image-analysis subagent. | **Missing.** `view_image` exists, but no image-analysis subagent or Task route exists. | Add VLM agent definition and image/context passing behavior. | | 3.6 | Child sessions and session tree | Subagent work is stored as child sessions with parent/child navigation. | **Present.** `TaskTool` emits `SubagentStarted`; `src/app.rs` creates child sessions; `SessionManager` tracks parent/children and persistence stores parent identifiers. | Child sessions are wired specifically for Task, not through a general agent/session tree abstraction. | -| 3.7 | Subagent descriptions in prompt | Primary prompt describes available subagents. | **Partial.** Built-in descriptions for `explore` and `general` are included. | Missing descriptions for `scout`, `vlm-agent`, custom agents, hidden agents, and task permission hints. | -| 3.8 | `@mention` subagent invocation | User input can invoke subagents via `@agent`. | **Missing.** No parser/autocomplete/runtime path dispatches `@explore` or `@general` to Task. | Add mention parsing and dispatch into Task/child-session flow. | -| 3.9 | Agent mode: primary vs subagent vs all | Agents declare invocation context. | **Missing.** Crabcode has a string `agent` mode for Plan/Build and a hard-coded `SubAgentType` enum. | Introduce agent definitions with `mode = primary`, `subagent`, or `all`. | -| 3.10 | Hidden agents | Hidden agents are omitted from autocomplete but invokable internally or via Task if allowed. | **Missing.** No agent registry contains `hidden` metadata. | Needed for hidden/system agents and exact autocomplete/Task behavior. | +| 3.7 | Subagent descriptions in prompt | Primary prompt describes available subagents. | **Mostly present.** Visible registry subagents, including custom JSON/markdown agents, are listed with descriptions. | Missing built-in `scout`/`vlm-agent`, hidden/internal agents, and task permission hints. | +| 3.8 | `@mention` subagent invocation | User input can invoke subagents via `@agent`. | **Present.** First-token `@agent` input is parsed, autocompleted from visible subagents, validated, and routed through Task/child-session execution. | Inline mentions and image-context forwarding are not implemented. | +| 3.9 | Agent mode: primary vs subagent vs all | Agents declare invocation context. | **Present.** `AgentDefinition.mode` supports `primary`, `subagent`, and `all`, and Task/autocomplete use that mode. | Internal system agents are still not modeled through the registry. | +| 3.10 | Hidden agents | Hidden agents are omitted from autocomplete but invokable internally or via Task if allowed. | **Partial.** `hidden` is parsed and hidden agents are omitted from prompt/autocomplete listings. | Hidden system agents (`compaction`, `title`, `summary`) are not yet unified as registry agents. | | 3.11 | Hidden system agents: compaction, title, summary | Internal agents run automatically. | **Partial.** Compaction exists as a bespoke summarization path in `src/session/compaction.rs`; title generation is heuristic; no summary/title agents exist. | Unify compaction/title/summary as hidden agent definitions with shared model/config behavior. | -| 3.12 | Task permissions | Config controls which agents may invoke which subagents. | **Partial.** Generic permission rules can target the `task` tool with the subagent name as pattern. | No first-class `task`/`task_permissions` agent field; Task validation does not use an agent registry. | +| 3.12 | Task permissions | Config controls which agents may invoke which subagents. | **Present.** Agents support `task_permissions`; `permission.task` also works as a fallback, and Task validates parent-to-target access through the registry. | `ask` task permissions do not open a dedicated prompt yet; they behave as non-deny validation. | | 4.1 | Tool: `bash` | Shell command execution. | **Present.** `src/tools/bash.rs`, registered in `src/tools/init.rs`. | Registration parity OK. | | 4.2 | Tool: `edit` | Exact string replacement in files. | **Present.** `src/tools/edit.rs`; includes fuzzy/trimmed matching and replace-all. | Behavior is broader than exact replacement, which may surprise if strict OpenCode semantics are required. | | 4.3 | Tool: `write` | Create/overwrite files. | **Present.** `src/tools/fs/write.rs`. | Registration parity OK. | @@ -56,12 +64,12 @@ Scope: core harness functionality only: agent loop, system prompt, subagents, to | 5.3 | YAML frontmatter | `name` and `description` are required. | **Partial.** Parser requires `name`; `description` is optional. | Enforce or warn/skip missing `description` for exact parity. | | 5.4 | Pattern-based skill permissions | Rules such as `internal-* = deny`. | **Mostly present via generic permissions.** `permission.skill` rules can match the skill name before `SkillTool` executes. | No dedicated skill permission config surface or diagnostics in `src/skill/mod.rs`; document the generic `permission.skill` form. | | 5.5 | Skill tool lists available skills | Tool description enumerates available skills. | **Present.** `SkillTool::build_description` emits ``. | No material gap. | -| 6.1 | Agent config via `opencode.json` | JSON config defines agents. | **Partial.** `src/config/configuration.rs` loads global/local `opencode.json(c)` and parses `agent..tools`, `permission`, and `steps`/`maxSteps`. | Most OpenCode per-agent fields are ignored. | -| 6.2 | Agent config via markdown files | `~/.config/opencode/agents/.md` with frontmatter defines agents. | **Missing.** `discover_opencode_inventory` records agent files, but no loader parses/applies them. | Implement markdown agent parser and merge with JSON agent config. | -| 6.3 | Per-agent description/model/temperature/top_p | Agents can define description and model/sampling overrides. | **Missing.** Runtime model is global/current UI model; custom commands can temporarily override model. | Add fields to agent definitions and request config. | -| 6.4 | Per-agent max_steps | Agents can define max steps. | **Partial.** JSON `steps`/`maxSteps` works for current mode/subagent names. | Missing `max_steps` alias and markdown-agent support. | -| 6.5 | Per-agent mode/hidden/color | Agents can declare mode, visibility, and color. | **Missing.** No config-backed mode/hidden/color model. | Add metadata to the agent registry; keep theme-derived fallback colors. | -| 6.6 | Per-agent permissions and task permissions | Agents override tool permissions and allowed subagents. | **Partial.** `agent..permission` and `agent..tools` are parsed; generic `task` rules can target subagent names. | No dedicated OpenCode task-permission field and no registry-driven Task validation. | +| 6.1 | Agent config via `opencode.json` | JSON config defines agents. | **Mostly present.** `src/config/configuration.rs` parses JSON agents into `AgentRegistry`, including tools, permissions, task permissions, descriptions, mode, hidden, model, sampling, and max steps. | Subagent model overrides are applied; sampling fields are parsed but not yet applied to provider requests. | +| 6.2 | Agent config via markdown files | `~/.config/opencode/agents/.md` with frontmatter defines agents. | **Present.** Discovered OpenCode markdown agent files are parsed with YAML frontmatter and body-as-instructions, then merged before JSON agent config. | Markdown loading is limited to already-discovered global/project OpenCode agent paths. | +| 6.3 | Per-agent description/model/temperature/top_p | Agents can define description and model/sampling overrides. | **Partial.** Agent definitions parse description, model, temperature, and top_p; subagent model overrides are applied during Task/`@agent` execution. | Primary-agent model overrides and per-agent sampling overrides are not yet applied. | +| 6.4 | Per-agent max_steps | Agents can define max steps. | **Present.** JSON and markdown agents support `steps`, `maxSteps`, and `max_steps`; primary and subagent paths read from the registry. | No material gap. | +| 6.5 | Per-agent mode/hidden/color | Agents can declare mode, visibility, and color. | **Partial.** `mode` and `hidden` are parsed and applied to prompt listings, autocomplete, and Task validation. | `color` is not parsed/applied; hidden system agents are not modeled. | +| 6.6 | Per-agent permissions and task permissions | Agents override tool permissions and allowed subagents. | **Present.** Registry-derived tool policies, permission overrides, and task permissions feed tool scoping, `ToolPermissions`, and Task validation. | `ask` task permissions do not currently produce an interactive task-approval dialog. | | 6.7 | Agent creation wizard | `opencode agent create`. | **Missing.** CLI args in `src/main.rs` have no agent subcommand; slash commands have no creation flow. | Add CLI/command wizard that writes markdown agent files. | | 7.1 | Custom command files | `.opencode/commands/.md` user slash commands. | **Present.** `src/command/custom.rs` scans `command` and `commands` dirs under global/project opencode/crabcode dirs. | Discovery is project-root based, not cwd-to-root walk-up. | | 7.2 | Command frontmatter | Supports `description`, `agent`, `model`, and `subtask`. | **Present in parsing.** `Frontmatter` includes all four fields. | `subtask` is parsed but ignored at execution time. | @@ -80,17 +88,7 @@ Scope: core harness functionality only: agent loop, system prompt, subagents, to ### CRITICAL -1. **Close the Plan-mode Task escape hatch.** - Files: `src/tools/permission.rs`, `src/tools/task.rs`, `src/agent/subagent.rs`, `src/tools/init.rs`. - Implementation notes: either hide `task` in Plan mode by default or enforce target-agent capabilities before execution. If Plan may call Task, restrict it to read-only subagents such as `explore`; do not run subagents with hard-coded `agent_mode = "build"` when called from Plan. Add tests for `Plan -> task(general)` denying `bash`/`write`/`edit`. - -2. **Introduce a real OpenCode-compatible agent registry.** - Files: new `src/agent/definition.rs` or equivalent, `src/agent/subagent.rs`, `src/config/configuration.rs`, `src/prompt/mod.rs`, `src/tools/task.rs`, `src/app.rs`. - Implementation notes: define `AgentDefinition { name, description, mode, hidden, model, temperature, top_p, max_steps, tools, permissions, task_permissions, instructions }`. Use this single registry for primary modes, subagents, prompt listings, Task validation, tool scoping, and max-step lookup. - -3. **Parse and apply markdown agent files.** - Files: `src/config/configuration.rs`, new loader under `src/agent/`, `_docs/config.mdx`. - Implementation notes: `discover_opencode_inventory` already finds `.opencode/agents` and XDG opencode agent files. Add YAML frontmatter parsing, body-as-instructions support, merge precedence with JSON config, diagnostics, and tests for `mode`, `hidden`, permissions, and max steps. +No critical harness parity gaps remain from the previous audit batch. Items 1-3 were closed by the agent registry, Plan/Task permission enforcement, and markdown agent loader work noted above. ### HIGH @@ -138,14 +136,10 @@ Scope: core harness functionality only: agent loop, system prompt, subagents, to ### LOW -14. **Add `@mention` agent invocation.** - Files: `src/command/parser.rs`, `src/autocomplete/`, `src/app.rs`, future agent registry. - Implementation notes: parse supported `@agent` forms, validate against visible `subagent`/`all` agents, and route to Task/child session execution. - -15. **Add an agent creation wizard/command.** +14. **Add an agent creation wizard/command.** Files: `src/main.rs`, `src/command/handlers.rs`, `src/command/registry.rs`, future agent config writer. Implementation notes: implement a CLI or slash-command equivalent of `opencode agent create` that writes markdown agent files with valid frontmatter. -16. **Document intentional extras and aliases.** +15. **Document intentional extras and aliases.** Files: `_docs/config.mdx`, `src/tools/init.rs`, tool docs if added. Implementation notes: clarify `update_plan` and `view_image` as Crabcode/Codex extensions, and document any compatibility aliases such as `todowrite -> update_plan`. diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx index ecf0297..cdbe3cf 100644 --- a/_docs/config/index.mdx +++ b/_docs/config/index.mdx @@ -98,6 +98,58 @@ You can also override permissions per agent. Agent rules are merged after global Plan mode is read-only by default and does not expose `bash`, `write`, or `edit` unless an explicit agent tool policy enables them. The existing safety prompts for sensitive reads, external paths, and repeated identical tool calls remain active by default. +## Agents + +crabcode builds one runtime agent registry from built-in agents, OpenCode markdown agent files, and JSON config. Built-ins include `build`, `plan`, `explore`, and `general`. + +Markdown agents live in `.opencode/agents/` or `~/.config/opencode/agents/`. YAML frontmatter configures the agent, and the markdown body becomes that agent's instructions. + +```md title=".opencode/agents/reviewer.md" +--- +description: Review code without making edits +mode: subagent +hidden: false +tools: + - read + - grep + - glob +steps: 8 +permission: + edit: deny +task_permissions: + explore: allow +--- + +Review the requested code and return findings with file paths and line numbers. +``` + +JSON agents use the same registry and override markdown definitions when names match. + +```jsonc title="crabcode.jsonc" +{ + "agent": { + "reviewer": { + "description": "Review code without making edits", + "mode": "subagent", + "hidden": false, + "max_steps": 8, + "tools": ["read", "grep", "glob"], + "permission": { + "edit": "deny" + }, + "task_permissions": { + "explore": "allow", + "general": "deny" + } + } + } +} +``` + +Supported agent fields are `name`, `description`, `mode` (`primary`, `subagent`, or `all`), `hidden`, `model`, `temperature`, `top_p`, `steps`, `maxSteps`, `max_steps`, `tools`, `permission`, `task_permissions`, and `instructions`/`prompt`. Subagent `model` overrides are applied when the agent is invoked through Task or `@agent`; `temperature` and `top_p` are parsed for compatibility but are not yet applied to runtime requests. + +Direct `@agent` invocation is available for visible `subagent` and `all` agents. For example, `@explore trace config loading` starts a child session through the Task flow. + ## What belongs where | Need | Put it in | diff --git a/_docs/config/opencode-compatibility.mdx b/_docs/config/opencode-compatibility.mdx index 37b6e0a..4da4460 100644 --- a/_docs/config/opencode-compatibility.mdx +++ b/_docs/config/opencode-compatibility.mdx @@ -27,14 +27,18 @@ Blank cells mean that runtime behavior is not supported by that project today. ` | `~/.config/crabcode/AGENTS.md` | | ✅ | crabcode global instructions. | | Claude Code fallback rules | ✅ | ✅ | `CLAUDE.md` and `~/.claude/CLAUDE.md` are read unless disabled by crabcode environment flags. | | Skills | ✅ | ✅ | Reads `SKILL.md` files from OpenCode, crabcode, Claude, and `.agents` skill roots. | -| `agent..tools` | ✅ | partial | crabcode supports tool allowlists for configured agents. | -| `agent..steps` | ✅ | partial | crabcode supports `steps`; `maxSteps` is not supported. | -| Markdown agent files | ✅ | | Discovered for diagnostics, not applied as runtime agent definitions yet. | +| `agent..tools` | ✅ | ✅ | Tool allowlists feed the runtime agent registry. | +| `agent..permission` | ✅ | ✅ | Agent-specific permission rules override global rules. | +| `agent..task_permissions` | ✅ | ✅ | Controls which subagents an agent may invoke through Task. | +| `agent..steps` / `maxSteps` / `max_steps` | ✅ | ✅ | Registry max-step values apply to primary agents and subagents. | +| `agent..mode` / `hidden` | ✅ | ✅ | Modes control primary/subagent use; hidden agents are omitted from prompt and autocomplete listings. | +| `agent..model` / `temperature` / `top_p` | ✅ | partial | Subagent `model` overrides are applied for Task/`@agent`; sampling settings are parsed but not yet applied. | +| Markdown agent files | ✅ | ✅ | `.opencode/agents/*.md` frontmatter is parsed; body content becomes agent instructions. | | `provider..options.timeout` | ✅ | partial | Integer milliseconds or `false` to disable timeout. | | `theme` | ✅ | ✅ | In crabcode config files only. OpenCode config `theme` is ignored by crabcode. | | `notifications` | | ✅ | crabcode-specific sounds, desktop notifications, and terminal alert signals such as Zed tab dots. | | `mcp` | ✅ | | Accepted at the top level for forward compatibility, not wired to tools yet. | -| `permission` | ✅ | | Accepted at the top level, not enforced from config yet. | +| `permission` | ✅ | ✅ | Global tool permission rules are enforced during AI SDK tool execution. | | `instructions` | ✅ | | Accepted at the top level, but config-driven instruction files are not loaded yet. | | `tools` | ✅ | | Accepted at the top level, not used as global tool config yet. | | `compaction` | ✅ | | Accepted at the top level, not used as config yet. | diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index d5afc7f..180ed6b 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -193,3 +193,23 @@ I want - [x] To do this But I dont want to do this - [x] Let's refactor highlights so that "highlighting" doesn't copy immediately. But rather, show a little dropdown like this so that I have control if I wanna copy or not. I want this because there are some parts that are kinda bothersome especially for users with clipboard history, it just quickly bloats it. - [x] Mouse scroll ux just like opencode, when highlighting. Needs to scroll when I reach edges as I drag and click. + +- [ ] Sometimes list items that have "bold" characters on them kinda break a new line between the number enum and the actual sentence i.e. + - 1.
**Replaced old indicator**. + - Even though when I copy it looks like + + ``` + 1. **Replaced the old loading indicator** (`SheetCopilot.tsx:757`) with a new shimmer bar that shows unconditionally whenever `loading()` is true. Text reads "Generating Response..." with an animated sweep across a 1px track. + + 2. **Removed the `draftPatch` label** (`SheetCopilot.tsx:1273`) from the tool-call topline — the card now renders without the external label. +G + 3. **Added shimmer CSS** (`sheetpilot.css:1165`) with `@keyframes sheetpilot-shimmer-sweep` and the `.sheetpilot-generating*` layout. + + Build it with your usual `pnpm dev` / `pnpm build` to see the changes. + ``` + +- [ ] Make "▼ 💭 Thinking" rendered like this. And an accordion, so if I click it with my mouse, or with a special hotkey + command palette command. It can be toggled on and off. + +- [ ] Subagent UI view is not rendering the full table it seems like.. I always see this.. just the top. + - `┌─────────────────────────┬────────────────────────────────────────────────────────────────────────────` - never the full table + - Thouh I think the table does have content. I think it's just being weird. \ No newline at end of file diff --git a/src/agent/definition.rs b/src/agent/definition.rs new file mode 100644 index 0000000..b0dfa8a --- /dev/null +++ b/src/agent/definition.rs @@ -0,0 +1,985 @@ +use crate::tools::{ + expand_permission_pattern, PermissionPolicyAction, PermissionRule, PermissionRules, +}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; + +const EXPLORE_SYSTEM_PROMPT: &str = r#"You are a fast, read-only code exploration agent. Your job is to search codebases, find files, and answer questions about code structure. + +TOOLS AVAILABLE: +- glob: Find files by pattern matching +- grep: Search file contents using regex +- read: Read file contents with pagination +- list: List directory contents + +IMPORTANT RULES: +- Only use the tools listed above (glob, grep, read, list) +- Search in parallel when possible (use multiple tool calls at once) +- Be thorough - search patterns, naming conventions, and related files +- Return a single comprehensive message with all findings +- Focus on precise code locations (file paths and line numbers) +- If you can't find something after thorough searching, report that clearly +- Do NOT use bash, write, edit, or any other tools + +You will receive a detailed task description from the primary agent. Complete it and return your findings in a single message."#; + +const GENERAL_SYSTEM_PROMPT: &str = r#"You are a general-purpose subagent that can use all available tools to complete complex multi-step tasks autonomously. + +IMPORTANT RULES: +- Your entire response will be returned to the primary agent as a single tool result +- Complete ALL steps autonomously before returning +- Be thorough and verify your work using available tools +- Return a single comprehensive message with your results +- Do NOT ask questions back to the user - just complete the task +- Do NOT use the update_plan tool + +You will receive a detailed task description from the primary agent. Complete it and return your findings in a single comprehensive message."#; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentMode { + Primary, + Subagent, + All, +} + +impl AgentMode { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "primary" => Some(Self::Primary), + "subagent" => Some(Self::Subagent), + "all" => Some(Self::All), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Primary => "primary", + Self::Subagent => "subagent", + Self::All => "all", + } + } + + pub fn can_run_as_subagent(self) -> bool { + matches!(self, Self::Subagent | Self::All) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgentDefinition { + pub name: String, + pub description: String, + pub mode: AgentMode, + mode_explicit: bool, + pub hidden: bool, + hidden_explicit: bool, + pub model: Option, + pub temperature: Option, + pub top_p: Option, + pub max_steps: Option, + pub tools: Option>, + pub permissions: PermissionRules, + pub task_permissions: PermissionRules, + pub instructions: Option, +} + +impl AgentDefinition { + pub fn normalized_name(name: &str) -> String { + name.trim().to_ascii_lowercase() + } + + pub fn visible_subagent(&self) -> bool { + self.mode.can_run_as_subagent() && !self.hidden + } + + pub fn can_invoke(&self, target: &str) -> bool { + let rules = if self.task_permissions.is_empty() { + self.permissions + .iter() + .filter(|rule| rule.permission == "task" || rule.permission == "*") + .cloned() + .collect::>() + } else { + self.task_permissions.clone() + }; + + if rules.is_empty() { + return true; + } + + let target = target.trim().to_ascii_lowercase(); + let mut decision = None; + for rule in rules { + if !matches!(rule.permission.as_str(), "task" | "*") { + continue; + } + if crate::tools::permission::wildcard_match(&target, &rule.pattern) + || crate::tools::permission::wildcard_match("*", &rule.pattern) + { + decision = Some(rule.action); + } + } + + !matches!(decision, Some(PermissionPolicyAction::Deny)) + } + + fn merge(mut self, overlay: AgentDefinition) -> Self { + if !overlay.description.is_empty() { + self.description = overlay.description; + } + if overlay.mode_explicit { + self.mode = overlay.mode; + } + if overlay.hidden_explicit { + self.hidden = overlay.hidden; + } + self.model = overlay.model.or(self.model); + self.temperature = overlay.temperature.or(self.temperature); + self.top_p = overlay.top_p.or(self.top_p); + self.max_steps = overlay.max_steps.or(self.max_steps); + self.tools = overlay.tools.or(self.tools); + if !overlay.permissions.is_empty() { + self.permissions = overlay.permissions; + } + if !overlay.task_permissions.is_empty() { + self.task_permissions = overlay.task_permissions; + } + self.instructions = overlay.instructions.or(self.instructions); + self + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgentRegistry { + agents: BTreeMap, + default_agent: String, +} + +impl Default for AgentRegistry { + fn default() -> Self { + Self::builtin(None) + } +} + +impl AgentRegistry { + pub fn builtin(default_agent: Option<&str>) -> Self { + let mut registry = Self { + agents: BTreeMap::new(), + default_agent: normalize_agent_ref(default_agent.unwrap_or("build")), + }; + + for agent in builtin_agents() { + registry.upsert(agent); + } + + if !registry.agents.contains_key(®istry.default_agent) { + registry.default_agent = "build".to_string(); + } + registry + } + + pub fn with_definitions( + default_agent: Option<&str>, + definitions: impl IntoIterator, + ) -> Self { + let mut registry = Self::builtin(default_agent); + for definition in definitions { + registry.upsert(definition); + } + if !registry.agents.contains_key(®istry.default_agent) { + registry.default_agent = "build".to_string(); + } + registry + } + + pub fn upsert(&mut self, mut definition: AgentDefinition) { + let key = AgentDefinition::normalized_name(&definition.name); + if key.is_empty() { + return; + } + definition.name = key.clone(); + if let Some(existing) = self.agents.remove(&key) { + self.agents.insert(key, existing.merge(definition)); + } else { + self.agents.insert(key, definition); + } + } + + pub fn get(&self, name: &str) -> Option<&AgentDefinition> { + self.agents.get(&AgentDefinition::normalized_name(name)) + } + + pub fn default_agent(&self) -> &str { + &self.default_agent + } + + pub fn primary_agent(&self, name: &str) -> Option<&AgentDefinition> { + self.get(name) + .filter(|agent| matches!(agent.mode, AgentMode::Primary | AgentMode::All)) + } + + pub fn task_target(&self, name: &str) -> Option<&AgentDefinition> { + self.get(name) + .filter(|agent| agent.mode.can_run_as_subagent()) + } + + pub fn can_agent_invoke(&self, parent: &str, target: &str) -> bool { + let Some(target_agent) = self.task_target(target) else { + return false; + }; + let parent_agent = self.get(parent); + parent_agent.is_none_or(|agent| agent.can_invoke(&target_agent.name)) + } + + pub fn visible_subagents(&self) -> Vec<&AgentDefinition> { + self.agents + .values() + .filter(|agent| agent.visible_subagent()) + .collect() + } + + pub fn visible_agent_names_for_mentions(&self) -> Vec { + self.visible_subagents() + .into_iter() + .map(|agent| agent.name.clone()) + .collect() + } + + pub fn tool_policy_map(&self) -> HashMap> { + self.agents + .iter() + .filter_map(|(name, agent)| agent.tools.clone().map(|tools| (name.clone(), tools))) + .collect() + } + + pub fn permission_rules_map(&self) -> HashMap { + self.agents + .iter() + .filter(|(_, agent)| !agent.permissions.is_empty()) + .map(|(name, agent)| (name.clone(), agent.permissions.clone())) + .collect() + } + + pub fn max_steps_map(&self) -> HashMap { + self.agents + .iter() + .filter_map(|(name, agent)| agent.max_steps.map(|steps| (name.clone(), steps))) + .collect() + } +} + +pub fn parse_agent_definitions_from_config( + value: Option<&Value>, + warnings: &mut Vec, +) -> Vec { + let Some(Value::Object(agents)) = value else { + return Vec::new(); + }; + + let mut out = Vec::new(); + for (name, value) in agents { + match parse_agent_definition(name, value, None, warnings, &format!("agent.{}", name)) { + Some(agent) => out.push(agent), + None => continue, + } + } + out +} + +pub fn load_markdown_agent_definitions( + paths: &[PathBuf], + warnings: &mut Vec, +) -> Vec { + let mut out = Vec::new(); + for path in paths { + match load_markdown_agent_definition(path, warnings) { + Some(agent) => out.push(agent), + None => continue, + } + } + out +} + +fn builtin_agents() -> Vec { + vec![ + AgentDefinition { + name: "build".to_string(), + description: "The default agent. Executes tools based on configured permissions." + .to_string(), + mode: AgentMode::Primary, + mode_explicit: true, + hidden: false, + hidden_explicit: true, + model: None, + temperature: None, + top_p: None, + max_steps: None, + tools: Some(vec!["*".to_string()]), + permissions: Vec::new(), + task_permissions: Vec::new(), + instructions: None, + }, + AgentDefinition { + name: "plan".to_string(), + description: "Plan mode. Read-only by default, with Task limited to read-only agents." + .to_string(), + mode: AgentMode::Primary, + mode_explicit: true, + hidden: false, + hidden_explicit: true, + model: None, + temperature: None, + top_p: None, + max_steps: None, + tools: Some(vec![ + "glob".to_string(), + "grep".to_string(), + "list".to_string(), + "read".to_string(), + "view_image".to_string(), + "skill".to_string(), + "webfetch".to_string(), + "question".to_string(), + "update_plan".to_string(), + "task".to_string(), + ]), + permissions: Vec::new(), + task_permissions: vec![ + PermissionRule { + permission: "task".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Deny, + }, + PermissionRule { + permission: "task".to_string(), + pattern: "explore".to_string(), + action: PermissionPolicyAction::Allow, + }, + ], + instructions: None, + }, + AgentDefinition { + name: "general".to_string(), + description: "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.".to_string(), + mode: AgentMode::Subagent, + mode_explicit: true, + hidden: false, + hidden_explicit: true, + model: None, + temperature: None, + top_p: None, + max_steps: None, + tools: Some(vec!["*".to_string()]), + permissions: Vec::new(), + task_permissions: Vec::new(), + instructions: Some(GENERAL_SYSTEM_PROMPT.to_string()), + }, + AgentDefinition { + name: "explore".to_string(), + description: "Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. This agent is read-only and fast.".to_string(), + mode: AgentMode::Subagent, + mode_explicit: true, + hidden: false, + hidden_explicit: true, + model: None, + temperature: None, + top_p: None, + max_steps: None, + tools: Some(vec![ + "glob".to_string(), + "grep".to_string(), + "read".to_string(), + "list".to_string(), + ]), + permissions: Vec::new(), + task_permissions: Vec::new(), + instructions: Some(EXPLORE_SYSTEM_PROMPT.to_string()), + }, + ] +} + +fn load_markdown_agent_definition( + path: &Path, + warnings: &mut Vec, +) -> Option { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(err) => { + warnings.push(format!( + "Failed to read OpenCode agent file {}: {}", + path.display(), + err + )); + return None; + } + }; + + let (frontmatter, body) = split_frontmatter(&content); + let data = match frontmatter { + Some(raw) if !raw.trim().is_empty() => match serde_yaml::from_str::(raw) + { + Ok(value) => serde_json::to_value(value).unwrap_or(Value::Null), + Err(err) => { + warnings.push(format!( + "{}: failed to parse YAML frontmatter: {}", + path.display(), + err + )); + return None; + } + }, + _ => Value::Object(serde_json::Map::new()), + }; + + let fallback_name = agent_name_from_path(path); + parse_agent_definition( + &fallback_name, + &data, + Some(body.trim().to_string()), + warnings, + &format!("agent file {}", path.display()), + ) +} + +fn split_frontmatter(content: &str) -> (Option<&str>, &str) { + let Some(rest) = content.strip_prefix("---") else { + return (None, content); + }; + let Some(rest) = rest + .strip_prefix('\n') + .or_else(|| rest.strip_prefix("\r\n")) + else { + return (None, content); + }; + + let frontmatter_start = content.len() - rest.len(); + let mut offset = frontmatter_start; + for line in rest.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\n', '\r']); + if trimmed == "---" { + let body_start = offset + line.len(); + return ( + Some(&content[frontmatter_start..offset]), + &content[body_start..], + ); + } + offset += line.len(); + } + + (None, content) +} + +fn agent_name_from_path(path: &Path) -> String { + path.file_stem() + .and_then(|s| s.to_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "agent".to_string()) +} + +fn parse_agent_definition( + fallback_name: &str, + value: &Value, + instructions: Option, + warnings: &mut Vec, + context: &str, +) -> Option { + let obj = match value { + Value::Object(obj) => obj, + Value::Null => return None, + _ => { + warnings.push(format!("{} must be an object", context)); + return None; + } + }; + + if obj + .get("disable") + .or_else(|| obj.get("disabled")) + .and_then(Value::as_bool) + == Some(true) + { + return None; + } + + let name = obj + .get("name") + .and_then(Value::as_str) + .unwrap_or(fallback_name) + .trim(); + if name.is_empty() { + warnings.push(format!("{} has an empty agent name", context)); + return None; + } + + let mode_value = obj.get("mode").and_then(Value::as_str); + let parsed_mode = mode_value.and_then(AgentMode::parse); + let mode = parsed_mode.unwrap_or_else(|| default_mode_for_agent(name)); + let mode_explicit = parsed_mode.is_some(); + if obj.get("mode").is_some() && parsed_mode.is_none() { + warnings.push(format!( + "{}.mode must be primary, subagent, or all", + context + )); + } + + let description = obj + .get("description") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let hidden_value = obj.get("hidden"); + let hidden = hidden_value.and_then(Value::as_bool).unwrap_or(false); + let hidden_explicit = hidden_value.and_then(Value::as_bool).is_some(); + let model = string_field(obj.get("model")); + let temperature = number_field( + obj.get("temperature"), + warnings, + &format!("{}.temperature", context), + ); + let top_p = number_field(obj.get("top_p"), warnings, &format!("{}.top_p", context)); + let max_steps = parse_steps( + obj.get("steps") + .or_else(|| obj.get("maxSteps")) + .or_else(|| obj.get("max_steps")), + warnings, + context, + ); + let tools = parse_tools(obj.get("tools"), warnings, context); + let permissions = parse_permission_rules( + obj.get("permission"), + warnings, + &format!("{}.permission", context), + ); + let task_permissions = parse_task_permission_rules( + obj.get("task_permissions") + .or_else(|| obj.get("taskPermissions")) + .or_else(|| obj.get("task")), + warnings, + &format!("{}.task_permissions", context), + ); + let instructions = instructions + .filter(|s| !s.trim().is_empty()) + .or_else(|| string_field(obj.get("instructions"))) + .or_else(|| string_field(obj.get("prompt"))); + + Some(AgentDefinition { + name: name.to_string(), + description, + mode, + mode_explicit, + hidden, + hidden_explicit, + model, + temperature, + top_p, + max_steps, + tools, + permissions, + task_permissions, + instructions, + }) +} + +fn string_field(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) +} + +fn number_field(value: Option<&Value>, warnings: &mut Vec, context: &str) -> Option { + match value { + None | Some(Value::Null) => None, + Some(Value::Number(n)) => n.as_f64(), + Some(_) => { + warnings.push(format!("{} must be a number", context)); + None + } + } +} + +fn parse_steps(value: Option<&Value>, warnings: &mut Vec, context: &str) -> Option { + let Some(value) = value else { + return None; + }; + let Some(num) = value.as_u64() else { + warnings.push(format!("{}.steps must be a positive integer", context)); + return None; + }; + if num == 0 { + warnings.push(format!("{}.steps must be greater than 0", context)); + return None; + } + if num > usize::MAX as u64 { + warnings.push(format!( + "{}.steps is too large for this platform; ignoring value {}", + context, num + )); + return None; + } + Some(num as usize) +} + +fn parse_tools( + value: Option<&Value>, + warnings: &mut Vec, + context: &str, +) -> Option> { + let Some(value) = value else { + return None; + }; + + let mut tools = Vec::new(); + match value { + Value::Array(arr) => { + for item in arr { + if let Some(tool) = item.as_str() { + push_tool(&mut tools, tool); + } + } + } + Value::String(tool) => push_tool(&mut tools, tool), + Value::Object(map) => { + for (tool, enabled) in map { + if enabled.as_bool().unwrap_or(false) { + push_tool(&mut tools, tool); + } + } + } + _ => warnings.push(format!( + "{}.tools must be a string, array of strings, or object of booleans", + context + )), + } + + (!tools.is_empty()).then_some(tools) +} + +fn push_tool(tools: &mut Vec, tool: &str) { + let tool = tool.trim().to_ascii_lowercase(); + if !tool.is_empty() && !tools.iter().any(|existing| existing == &tool) { + tools.push(tool); + } +} + +fn parse_task_permission_rules( + value: Option<&Value>, + warnings: &mut Vec, + context: &str, +) -> PermissionRules { + let Some(value) = value else { + return Vec::new(); + }; + + if let Some(action_text) = value.as_str() { + return PermissionPolicyAction::parse(action_text) + .map(|action| { + vec![PermissionRule { + permission: "task".to_string(), + pattern: "*".to_string(), + action, + }] + }) + .unwrap_or_else(|| { + warnings.push(format!( + "{} must be one of allow, ask, or deny; got '{}'", + context, action_text + )); + Vec::new() + }); + } + + if let Value::Array(arr) = value { + let mut rules = vec![PermissionRule { + permission: "task".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Deny, + }]; + for item in arr { + if let Some(agent) = item.as_str() { + let pattern = agent.trim(); + if !pattern.is_empty() { + rules.push(PermissionRule { + permission: "task".to_string(), + pattern: pattern.to_ascii_lowercase(), + action: PermissionPolicyAction::Allow, + }); + } + } + } + return rules; + } + + let Some(map) = value.as_object() else { + warnings.push(format!( + "{} must be an action, agent array, or object of agent rules", + context + )); + return Vec::new(); + }; + + let mut out = Vec::new(); + for (pattern, action_value) in map { + let Some(action_text) = action_value.as_str() else { + warnings.push(format!( + "{}.{} must be one of allow, ask, or deny", + context, pattern + )); + continue; + }; + let Some(action) = PermissionPolicyAction::parse(action_text) else { + warnings.push(format!( + "{}.{} must be one of allow, ask, or deny; got '{}'", + context, pattern, action_text + )); + continue; + }; + out.push(PermissionRule { + permission: "task".to_string(), + pattern: expand_permission_pattern(pattern).to_ascii_lowercase(), + action, + }); + } + out +} + +fn parse_permission_rules( + value: Option<&Value>, + warnings: &mut Vec, + context: &str, +) -> PermissionRules { + let mut out = Vec::new(); + let Some(value) = value else { + return out; + }; + if value.is_null() { + return out; + } + + if let Some(action_text) = value.as_str() { + match PermissionPolicyAction::parse(action_text) { + Some(action) => out.push(PermissionRule { + permission: "*".to_string(), + pattern: "*".to_string(), + action, + }), + None => warnings.push(format!( + "{} must be one of allow, ask, or deny; got '{}'", + context, action_text + )), + } + return out; + } + + let Some(map) = value.as_object() else { + warnings.push(format!("{} must be a string or object", context)); + return out; + }; + + for (permission, value) in map { + let permission = permission.trim().to_ascii_lowercase(); + if permission.is_empty() { + warnings.push(format!("{} contains an empty permission key", context)); + continue; + } + + if let Some(action_text) = value.as_str() { + match PermissionPolicyAction::parse(action_text) { + Some(action) => out.push(PermissionRule { + permission, + pattern: "*".to_string(), + action, + }), + None => warnings.push(format!( + "{}.{} must be one of allow, ask, or deny; got '{}'", + context, permission, action_text + )), + } + continue; + } + + let Some(patterns) = value.as_object() else { + warnings.push(format!( + "{}.{} must be one of allow, ask, deny, or an object of pattern rules", + context, permission + )); + continue; + }; + + for (pattern, action_value) in patterns { + let Some(action_text) = action_value.as_str() else { + warnings.push(format!( + "{}.{}.{} must be one of allow, ask, or deny", + context, permission, pattern + )); + continue; + }; + let Some(action) = PermissionPolicyAction::parse(action_text) else { + warnings.push(format!( + "{}.{}.{} must be one of allow, ask, or deny; got '{}'", + context, permission, pattern, action_text + )); + continue; + }; + out.push(PermissionRule { + permission: permission.clone(), + pattern: expand_permission_pattern(pattern), + action, + }); + } + } + + out +} + +fn normalize_agent_ref(name: &str) -> String { + AgentDefinition::normalized_name(name) +} + +fn default_mode_for_agent(name: &str) -> AgentMode { + match AgentDefinition::normalized_name(name).as_str() { + "build" | "plan" => AgentMode::Primary, + "general" | "explore" => AgentMode::Subagent, + _ => AgentMode::All, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn plan_can_only_task_explore_by_default() { + let registry = AgentRegistry::default(); + + assert!(registry.can_agent_invoke("Plan", "explore")); + assert!(!registry.can_agent_invoke("Plan", "general")); + } + + #[test] + fn parses_json_agent_fields() { + let mut warnings = Vec::new(); + let defs = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "description": "Review code", + "mode": "subagent", + "hidden": true, + "model": "openai/gpt-5", + "temperature": 0.2, + "top_p": 0.9, + "max_steps": 7, + "tools": ["read", "grep"], + "permission": { "edit": "deny" }, + "task_permissions": ["explore"], + "prompt": "Read only." + } + })), + &mut warnings, + ); + + assert!(warnings.is_empty()); + assert_eq!(defs.len(), 1); + let def = &defs[0]; + assert_eq!(def.name, "reviewer"); + assert_eq!(def.mode, AgentMode::Subagent); + assert!(def.hidden); + assert_eq!(def.max_steps, Some(7)); + assert_eq!( + def.tools.as_deref(), + Some(&["read".to_string(), "grep".to_string()][..]) + ); + assert_eq!(def.instructions.as_deref(), Some("Read only.")); + assert_eq!(def.task_permissions.len(), 2); + } + + #[test] + fn markdown_body_becomes_instructions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("reviewer.md"); + std::fs::write( + &path, + "---\ndescription: Review code\nmode: subagent\nhidden: true\nsteps: 3\npermission:\n edit: deny\n---\nBe strict.\n", + ) + .unwrap(); + + let mut warnings = Vec::new(); + let defs = load_markdown_agent_definitions(&[path], &mut warnings); + + assert!(warnings.is_empty()); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].name, "reviewer"); + assert_eq!(defs[0].mode, AgentMode::Subagent); + assert!(defs[0].hidden); + assert_eq!(defs[0].max_steps, Some(3)); + assert_eq!(defs[0].permissions[0].permission, "edit"); + assert_eq!(defs[0].instructions.as_deref(), Some("Be strict.")); + } + + #[test] + fn later_agent_definitions_override_earlier_ones() { + let mut warnings = Vec::new(); + let markdown = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "description": "Markdown description", + "mode": "subagent", + "hidden": true, + "steps": 3 + } + })), + &mut warnings, + ); + let json_defs = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "description": "JSON description", + "mode": "subagent", + "max_steps": 5 + } + })), + &mut warnings, + ); + let registry = AgentRegistry::with_definitions(None, markdown.into_iter().chain(json_defs)); + let reviewer = registry.get("reviewer").unwrap(); + + assert!(warnings.is_empty()); + assert_eq!(reviewer.description, "JSON description"); + assert_eq!(reviewer.mode, AgentMode::Subagent); + assert!(reviewer.hidden); + assert_eq!(reviewer.max_steps, Some(5)); + } + + #[test] + fn explicit_all_mode_and_hidden_false_override_prior_definition() { + let mut warnings = Vec::new(); + let markdown = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "mode": "subagent", + "hidden": true + } + })), + &mut warnings, + ); + let json_defs = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "mode": "all", + "hidden": false + } + })), + &mut warnings, + ); + let registry = AgentRegistry::with_definitions(None, markdown.into_iter().chain(json_defs)); + let reviewer = registry.get("reviewer").unwrap(); + + assert!(warnings.is_empty()); + assert_eq!(reviewer.mode, AgentMode::All); + assert!(!reviewer.hidden); + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 4a6dc5a..973b0d3 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,5 +1,6 @@ pub mod build; pub mod config; +pub mod definition; pub mod manager; pub mod plan; pub mod subagent; diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index c7887af..7706410 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -1,122 +1,25 @@ use crate::agent::config::{get_llm_session, ProviderKind}; +use crate::agent::definition::AgentDefinition; use crate::tools::ToolRegistry; -const EXPLORE_SYSTEM_PROMPT: &str = r#"You are a fast, read-only code exploration agent. Your job is to search codebases, find files, and answer questions about code structure. - -TOOLS AVAILABLE: -- glob: Find files by pattern matching -- grep: Search file contents using regex -- read: Read file contents with pagination -- list: List directory contents - -IMPORTANT RULES: -- Only use the tools listed above (glob, grep, read, list) -- Search in parallel when possible (use multiple tool calls at once) -- Be thorough - search patterns, naming conventions, and related files -- Return a single comprehensive message with all findings -- Focus on precise code locations (file paths and line numbers) -- If you can't find something after thorough searching, report that clearly -- Do NOT use bash, write, edit, or any other tools - -You will receive a detailed task description from the primary agent. Complete it and return your findings in a single message."#; - -const GENERAL_SYSTEM_PROMPT: &str = r#"You are a general-purpose subagent that can use all available tools to complete complex multi-step tasks autonomously. - -IMPORTANT RULES: -- Your entire response will be returned to the primary agent as a single tool result -- Complete ALL steps autonomously before returning -- Be thorough and verify your work using available tools -- Return a single comprehensive message with your results -- Do NOT ask questions back to the user - just complete the task -- Do NOT use the update_plan tool - -You will receive a detailed task description from the primary agent. Complete it and return your findings in a single comprehensive message."#; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SubAgentType { - Explore, - General, -} - -impl SubAgentType { - pub fn from_str(s: &str) -> Option { - match s.to_lowercase().as_str() { - "explore" => Some(Self::Explore), - "general" => Some(Self::General), - _ => None, - } - } - - pub fn name(&self) -> &'static str { - match self { - Self::Explore => "explore", - Self::General => "general", - } - } - - pub fn description(&self) -> &'static str { - match self { - Self::Explore => "Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. This agent is read-only and fast.", - Self::General => "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel, generate and run complex scripts, or research unfamiliar code.", - } - } - - pub fn system_prompt(&self) -> &'static str { - match self { - Self::Explore => EXPLORE_SYSTEM_PROMPT, - Self::General => GENERAL_SYSTEM_PROMPT, - } - } - - pub fn allowed_tools(&self) -> Vec<&'static str> { - match self { - Self::Explore => vec!["glob", "grep", "read", "list"], - Self::General => vec![ - "bash", "edit", "write", "read", "grep", "glob", "list", "skill", "webfetch", - ], - } - } -} - -pub struct SubAgentDef { - pub subagent_type: SubAgentType, - pub name: String, - pub description: String, -} - pub struct SubAgentRunResult { pub output: String, pub tool_call_count: usize, } -impl SubAgentDef { - pub fn all() -> Vec { - vec![ - SubAgentDef { - subagent_type: SubAgentType::Explore, - name: SubAgentType::Explore.name().to_string(), - description: SubAgentType::Explore.description().to_string(), - }, - SubAgentDef { - subagent_type: SubAgentType::General, - name: SubAgentType::General.name().to_string(), - description: SubAgentType::General.description().to_string(), - }, - ] - } -} - pub async fn build_scoped_registry( full_registry: &ToolRegistry, - subagent_type: &SubAgentType, + agent: &AgentDefinition, ) -> ToolRegistry { let scoped = ToolRegistry::new(); - let allowed = subagent_type.allowed_tools(); + let allowed = agent.tools.as_ref(); let full_tools = full_registry.list().await; for tool_def in &full_tools { - if allowed.contains(&tool_def.id.as_str()) { + let tool_allowed = allowed + .is_none_or(|tools| tools.iter().any(|tool| tool == "*" || tool == &tool_def.id)); + if tool_allowed { if let Some(handler) = full_registry.get(&tool_def.id).await { scoped.register(handler).await; } @@ -127,7 +30,7 @@ pub async fn build_scoped_registry( } pub async fn run_subagent( - subagent_type: SubAgentType, + agent: AgentDefinition, description: &str, prompt: &str, full_registry: &ToolRegistry, @@ -143,14 +46,15 @@ pub async fn run_subagent( use futures::StreamExt; use std::collections::HashMap; - let session = get_llm_session().ok_or("LLM session not configured")?; + let parent_session = get_llm_session().ok_or("LLM session not configured")?; + let session = resolve_subagent_session(&agent, parent_session, sender.as_ref()).await?; - let scoped_registry = build_scoped_registry(full_registry, &subagent_type).await; + let scoped_registry = build_scoped_registry(full_registry, &agent).await; let aisdk_tools = crate::tools::aisdk_bridge::convert_to_aisdk_tools( &scoped_registry, sender.clone(), - "build".to_string(), + agent.name.clone(), permissions, Some(session_id.clone()), None, @@ -159,7 +63,10 @@ pub async fn run_subagent( ) .await; - let system_prompt = subagent_type.system_prompt(); + let system_prompt = agent + .instructions + .as_deref() + .unwrap_or("Complete the delegated task and return a concise, comprehensive result."); let user_content = format!( "## Task Description\n{}\n\n## Task Prompt\n{}", description, prompt @@ -175,7 +82,7 @@ pub async fn run_subagent( crate::emit_log!( "[SUBAGENT] stream_start session_id={} subagent_type={} tools={} description_bytes={} prompt_bytes={} max_steps={:?} sender_present={}", session_id, - subagent_type.name(), + agent.name, aisdk_tools.len(), description.len(), prompt.len(), @@ -227,7 +134,7 @@ pub async fn run_subagent( crate::emit_log!( "[SUBAGENT] stream_failed session_id={} subagent_type={} duration_ms={} error={}", session_id, - subagent_type.name(), + agent.name, stream_started_at.elapsed().as_millis(), err ); @@ -246,7 +153,7 @@ pub async fn run_subagent( crate::emit_log!( "[SUBAGENT_METADATA] session_id={} subagent_type={} {}", session_id, - subagent_type.name(), + agent.name, message ); } @@ -313,7 +220,7 @@ pub async fn run_subagent( crate::emit_log!( "[SUBAGENT_METADATA] session_id={} subagent_type={} {}", session_id, - subagent_type.name(), + agent.name, message ); } @@ -324,7 +231,7 @@ pub async fn run_subagent( crate::emit_log!( "[SUBAGENT] stream_finish session_id={} subagent_type={} duration_ms={} stop_reason={:?} text_bytes={} tool_call_count={}", session_id, - subagent_type.name(), + agent.name, stream_started_at.elapsed().as_millis(), stop_reason, collected_text.len(), @@ -402,6 +309,43 @@ async fn start_subagent_stream( } } +async fn resolve_subagent_session( + agent: &AgentDefinition, + parent_session: crate::agent::config::LlmSessionConfig, + sender: Option<&crate::llm::ChunkSender>, +) -> Result { + let Some(model_ref) = agent.model.as_deref() else { + return Ok(parent_session); + }; + + let model_ref = model_ref.trim(); + if model_ref.is_empty() { + return Ok(parent_session); + } + + let Some((provider, model)) = model_ref.split_once('/') else { + let mut session = parent_session; + session.model = model_ref.to_string(); + return Ok(session); + }; + let provider = provider.trim(); + let model = model.trim(); + if provider.is_empty() || model.is_empty() { + return Ok(parent_session); + } + + let (fallback_sender, _fallback_rx) = tokio::sync::mpsc::unbounded_channel(); + let sender = sender.unwrap_or(&fallback_sender); + crate::llm::client::build_subagent_llm_session( + provider, + model.to_string(), + parent_session.reasoning_effort, + sender, + ) + .await + .map_err(|err| err.to_string()) +} + fn normalize_subagent_output(output: String) -> String { if output.trim().is_empty() { "Subagent completed without a final text response.".to_string() diff --git a/src/app.rs b/src/app.rs index 7ccf726..f7d31f4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,6 +14,7 @@ use crate::command::parser::InputType; use crate::command::registry::Registry; use crate::llm::client::stream_llm_with_cancellation; use crate::session::manager::SessionManager; +use crate::tools::ToolHandler; use crate::push_toast; use crate::toast::{self, Toast, ToastLevel}; @@ -273,6 +274,7 @@ pub struct App { storage_receiver: Option>, pub prefs_dao: Option, pub agent: String, + pub agent_registry: crate::agent::definition::AgentRegistry, pub agent_steps: std::collections::HashMap, pub provider_timeouts: std::collections::HashMap, pub model: String, @@ -380,9 +382,21 @@ impl App { registry.register_custom(command); } crate::command::handlers::register_skill_commands(&mut registry); - input.autocomplete = Some(AutoComplete::new(crate::autocomplete::CommandAuto::new( - ®istry, - ))); + let agent_registry = loaded_config.merged_config.agent_registry.clone(); + let agent_suggestions = agent_registry + .visible_subagents() + .into_iter() + .map(|agent| { + crate::autocomplete::Suggestion::agent( + agent.name.clone(), + agent.description.clone(), + ) + }) + .collect(); + input.autocomplete = Some( + AutoComplete::new(crate::autocomplete::CommandAuto::new(®istry)) + .with_agents(agent_suggestions), + ); if let Some(default_agent) = loaded_config.merged_config.default_agent.clone() { if !default_agent.trim().is_empty() { @@ -443,7 +457,7 @@ impl App { &loaded_config.cwd, loaded_config.merged_config.theme.as_deref(), ); - let agent_steps = loaded_config.merged_config.agent_steps.clone(); + let agent_steps = agent_registry.max_steps_map(); let provider_timeouts = loaded_config.merged_config.provider_timeouts.clone(); let theme_for_colors = themes @@ -456,15 +470,13 @@ impl App { let chat_state = init_chat(chat, &agent, &colors); let session_rename_dialog_state = init_session_rename_dialog(colors); let mut agent_policies = crate::tools::AgentToolPolicies::default(); - for (mode, tools) in &loaded_config.merged_config.agent_tool_policies { + for (mode, tools) in agent_registry.tool_policy_map() { agent_policies = agent_policies.with_custom_tools(mode.clone(), tools.clone()); } let tool_permissions = crate::tools::ToolPermissions::new(cwd_path.clone()) .with_agent_policies(agent_policies) .with_permission_rules(loaded_config.merged_config.permission_rules.clone()) - .with_agent_permission_rules( - loaded_config.merged_config.agent_permission_rules.clone(), - ); + .with_agent_permission_rules(agent_registry.permission_rules_map()); let discovery = crate::model::discovery::Discovery::new().ok(); let cached_git_branch = git::get_branch_for_path(&cwd); @@ -509,6 +521,7 @@ impl App { storage_receiver: None, prefs_dao, agent, + agent_registry, agent_steps, provider_timeouts, model: active_model, @@ -833,8 +846,14 @@ impl App { } let mut tabs = Vec::with_capacity(children.len() + 1); + let root_agent = self.agent.clone(); + let root_model = self + .session_active_stream_model(&root_id) + .unwrap_or_else(|| self.model.clone()); tabs.push(SubagentTab { label: "main".to_string(), + agent: root_agent, + model: root_model, active: current_id == root_id, running: root.status.is_active() || self @@ -847,6 +866,8 @@ impl App { let colors = self.get_current_theme_colors(); for (idx, child) in children.into_iter().enumerate() { let label = subagent_tab_label(&child.title, &child.id); + let (agent, model) = + self.session_agent_model_for_display(&child.id, "Subagent", &self.model); let running = child.status.is_active() || self .session_view_states @@ -854,6 +875,8 @@ impl App { .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); tabs.push(SubagentTab { label, + agent, + model, active: current_id == child.id, running, color: agent_color_for_tab(idx, &colors), @@ -866,6 +889,72 @@ impl App { }) } + fn current_session_agent_model_for_display(&self) -> (String, String) { + let Some(session_id) = self.session_manager.get_current_session_id() else { + return (self.agent.clone(), self.model.clone()); + }; + if self.session_manager.parent_id_of(session_id).is_none() { + return ( + self.agent.clone(), + self.session_active_stream_model(session_id) + .unwrap_or_else(|| self.model.clone()), + ); + } + self.session_agent_model_for_display(session_id, &self.agent, &self.model) + } + + fn session_agent_model_for_display( + &self, + session_id: &str, + fallback_agent: &str, + fallback_model: &str, + ) -> (String, String) { + let agent = self + .session_view_states + .get(session_id) + .and_then(|state| first_agent_mode(&state.chat.messages)) + .or_else(|| { + self.session_manager + .get_session_ref(session_id) + .and_then(|session| first_agent_mode(&session.messages)) + }) + .unwrap_or_else(|| fallback_agent.to_string()); + + let model = self.session_model_for_display(session_id, fallback_model); + + (agent, model) + } + + fn session_model_for_display(&self, session_id: &str, fallback_model: &str) -> String { + self.session_active_stream_model(session_id) + .or_else(|| { + self.session_view_states + .get(session_id) + .and_then(|state| latest_message_model(&state.chat.messages)) + }) + .or_else(|| { + self.session_manager + .get_session_ref(session_id) + .and_then(|session| latest_message_model(&session.messages)) + }) + .unwrap_or_else(|| fallback_model.to_string()) + } + + fn session_active_stream_model(&self, session_id: &str) -> Option { + self.session_view_states.get(session_id).and_then(|state| { + state + .stream + .as_ref() + .and_then(|stream| stream.streaming_model.clone()) + .or_else(|| { + state + .external_stream + .as_ref() + .and_then(|stream| stream.streaming_model.clone()) + }) + }) + } + fn start_blank_session(&mut self, title: Option) { self.save_active_session_view_state(); self.pending_session_title = title.and_then(|title| { @@ -2259,7 +2348,7 @@ impl App { } fn toggle_agent_mode(&mut self) { - if self.agent == "Plan" { + if self.agent.eq_ignore_ascii_case("plan") { self.agent = "Build".to_string(); } else { self.agent = "Plan".to_string(); @@ -2323,6 +2412,16 @@ impl App { rt.block_on(self.process_command_input(parsed)); }); } + crate::command::parser::InputType::AgentMention(mention) => { + if image_paths.is_empty() { + self.input.save_current_to_history(); + } + if !self.is_streaming { + self.handle_agent_mention_input(mention, image_paths); + } else { + return; + } + } crate::command::parser::InputType::Message(msg) => { // Only save messages (not commands) to prompt history if image_paths.is_empty() { @@ -3283,6 +3382,10 @@ impl App { self.input.clear(); } + crate::autocomplete::SuggestionKind::Agent => { + self.input.apply_suggestion(&selected); + self.update_suggestions(); + } crate::autocomplete::SuggestionKind::File => { self.input.apply_suggestion(&selected); self.update_suggestions(); @@ -3798,6 +3901,9 @@ impl App { InputType::Message(msg) => { self.handle_message_input(msg); } + InputType::AgentMention(mention) => { + self.handle_agent_mention_input(mention, Vec::new()); + } } } @@ -5384,6 +5490,8 @@ impl App { session_id, title, subagent_type, + model, + provider, description, prompt, } => { @@ -5392,6 +5500,8 @@ impl App { session_id, title, subagent_type, + model, + provider, description, prompt, ); @@ -5449,6 +5559,8 @@ impl App { session_id: String, title: String, subagent_type: String, + model: Option, + provider: Option, description: String, prompt: String, ) { @@ -5469,6 +5581,8 @@ impl App { let mut user_message = crate::session::types::Message::user(&user_content); user_message.agent_mode = Some(subagent_type.clone()); + user_message.model = model.clone(); + user_message.provider = provider.clone(); let mut persist_user = false; if let Some(state) = self.session_view_states.get_mut(&session_id) { @@ -5479,12 +5593,14 @@ impl App { if let Some(last_msg) = state.chat.messages.last_mut() { last_msg.is_complete = false; last_msg.agent_mode = Some(subagent_type); + last_msg.model = model.clone(); + last_msg.provider = provider.clone(); } state.chat.mark_render_dirty(); state.chat.begin_streaming_turn(); state.external_stream = Some(ExternalStreamState { - streaming_model: Some(self.model.clone()), - streaming_provider: Some(self.provider_name.clone()), + streaming_model: model.or_else(|| Some(self.model.clone())), + streaming_provider: provider.or_else(|| Some(self.provider_name.clone())), chat_len_before_assistant: 1, }); state.unread_completed = true; @@ -5953,7 +6069,7 @@ impl App { .get(&self.agent.to_ascii_lowercase()) .copied(); let tool_permissions = self.tool_permissions.clone(); - let agent_steps = self.agent_steps.clone(); + let agent_registry = self.agent_registry.clone(); let cwd = self.cwd.clone(); let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); @@ -5971,7 +6087,7 @@ impl App { let registry = crate::tools::initialize_tool_registry_with_dynamic( Some(sender.clone()), tool_permissions.clone(), - agent_steps.clone(), + agent_registry.clone(), cancel_token.clone(), ) .await; @@ -5991,7 +6107,8 @@ impl App { is_git_repo, std::env::consts::OS, ) - .with_tool_registry(prompt_registry); + .with_tool_registry(prompt_registry) + .with_agent_registry(agent_registry.clone()); let system_prompt = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { composer.compose().await }) }); @@ -6008,7 +6125,7 @@ impl App { reasoning_effort, agent_mode, agent_max_steps, - agent_steps, + agent_registry, tool_permissions, messages, sender_clone.clone(), @@ -6042,6 +6159,202 @@ impl App { self.handle_message_input_with_images(msg, Vec::new()); } + fn handle_agent_mention_input( + &mut self, + mention: crate::command::parser::ParsedAgentMention, + image_paths: Vec, + ) { + if image_paths.is_empty() && mention.prompt.trim().is_empty() { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("Usage: @{} ", mention.agent), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + let Some(agent) = self.agent_registry.task_target(&mention.agent).cloned() else { + self.play_sound_event(crate::sound::SoundEvent::Error); + let available = self + .agent_registry + .visible_agent_names_for_mentions() + .join(", "); + let suffix = if available.is_empty() { + String::new() + } else { + format!(" Available agents: {}", available) + }; + push_toast(Toast::new( + format!("Unknown agent: @{}.{}", mention.agent, suffix), + ToastLevel::Error, + Some(std::time::Duration::from_secs(4)), + )); + return; + }; + + if !agent.visible_subagent() { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!( + "Agent @{} is not available for direct mention", + mention.agent + ), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + if !self + .agent_registry + .can_agent_invoke(&self.agent, &agent.name) + { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("{} cannot invoke @{}", self.agent, agent.name), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + if self.base_focus == BaseFocus::Home + && self.session_manager.get_current_session_id().is_none() + { + let session_title = self + .pending_session_title + .take() + .unwrap_or_else(|| Self::generate_title_from_message(&mention.raw)); + self.create_new_session(Some(session_title)); + } + + if self.session_manager.get_current_session_id().is_none() { + self.create_new_session(Some(Self::generate_title_from_message(&mention.raw))); + } + + self.append_user_message_to_current_session(mention.raw.clone(), image_paths); + self.base_focus = BaseFocus::Chat; + + if let Err(err) = self.start_agent_mention_task(agent.name, mention.prompt) { + push_toast(Toast::new( + format!("Agent error: {}", err), + ToastLevel::Error, + None, + )); + } + } + + fn start_agent_mention_task( + &mut self, + agent_name: String, + prompt: String, + ) -> Result<(), Box> { + use tokio::sync::mpsc; + + let session_id = self + .session_manager + .get_current_session_id() + .cloned() + .ok_or_else(|| "No active session".to_string())?; + self.ensure_session_view_state(&session_id); + + let (sender, receiver) = mpsc::unbounded_channel(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + self.is_streaming = true; + + let chat_len_before_assistant = self.chat_state.chat.messages.len(); + let streaming_model = Some(self.model.clone()); + let streaming_provider = Some(self.provider_name.clone()); + self.chat_state + .chat + .prepare_streaming_token_counter(&self.model); + self.chat_state.chat.add_assistant_message(""); + if let Some(last_msg) = self.chat_state.chat.messages.last_mut() { + last_msg.is_complete = false; + } + self.chat_state.chat.mark_render_dirty(); + self.chat_state.chat.begin_streaming_turn(); + + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: cancel_token.clone(), + streaming_model, + streaming_provider, + chat_len_before_assistant, + }); + state.tool_calls = ToolCallViewState::default(); + state.unread_completed = false; + } + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + + let provider_name = self.provider_name.clone(); + let model = self.model.clone(); + let reasoning_effort = self.active_reasoning_effort(); + let parent_agent = self.agent.clone(); + let tool_permissions = self.tool_permissions.clone(); + let agent_registry = self.agent_registry.clone(); + let task_description = format!("{} mention", agent_name); + let sender_for_error = sender.clone(); + + tokio::spawn(async move { + let result = async { + crate::llm::client::configure_subagent_llm_session( + &provider_name, + model, + reasoning_effort, + &sender, + ) + .await + .map_err(|err| err.to_string())?; + + let registry = crate::tools::initialize_tool_registry_with_dynamic( + Some(sender.clone()), + tool_permissions.clone(), + agent_registry.clone(), + cancel_token.clone(), + ) + .await; + let task = crate::tools::TaskTool::new(registry) + .with_sender_opt(Some(sender.clone())) + .with_runtime_options(tool_permissions, agent_registry, cancel_token.clone()); + let params = serde_json::json!({ + "subagent_type": agent_name, + "description": task_description, + "prompt": prompt, + }); + let ctx = crate::tools::ToolContext::from_cancel_token( + session_id.clone(), + "agent-mention", + parent_agent, + cancel_token, + ); + + task.execute(params, &ctx) + .await + .map_err(|err| err.to_string()) + } + .await; + + match result { + Ok(tool_result) => { + let _ = sender.send(crate::llm::ChunkMessage::Text(tool_result.output)); + let _ = sender.send(crate::llm::ChunkMessage::End); + } + Err(err) => { + let _ = sender_for_error.send(crate::llm::ChunkMessage::Failed(err)); + } + } + }); + + Ok(()) + } + fn append_user_message_to_current_session( &mut self, msg: String, @@ -6225,6 +6538,7 @@ impl App { BaseFocus::Chat => { let subagent_tabs = self.subagent_tabs_for_current_session(); let queued_messages = self.queued_message_previews_for_current_session(); + let (display_agent, display_model) = self.current_session_agent_model_for_display(); render_chat( f, &mut self.chat_state, @@ -6232,8 +6546,8 @@ impl App { self.version.clone(), status_cwd.clone(), branch, - self.agent.clone(), - self.model.clone(), + display_agent, + display_model, self.provider_name.clone(), reasoning_effort, &colors, @@ -6592,12 +6906,40 @@ fn subagent_tab_label(title: &str, fallback: &str) -> String { } } +fn first_agent_mode(messages: &[crate::session::types::Message]) -> Option { + messages + .iter() + .find_map(|message| message.agent_mode.as_deref()) + .map(str::trim) + .filter(|agent| !agent.is_empty()) + .map(ToOwned::to_owned) +} + +fn latest_message_model(messages: &[crate::session::types::Message]) -> Option { + messages + .iter() + .rev() + .find_map(|message| message.model.as_deref()) + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(ToOwned::to_owned) +} + fn titlecase_ascii(value: &str) -> String { - let mut chars = value.chars(); - match chars.next() { - Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), - None => String::new(), + let mut out = String::new(); + let mut word_start = true; + for ch in value.trim().chars() { + if matches!(ch, '-' | '_' | ' ') { + out.push(ch); + word_start = true; + } else if word_start { + out.push(ch.to_ascii_uppercase()); + word_start = false; + } else { + out.push(ch); + } } + out } impl Default for App { @@ -6661,6 +7003,7 @@ mod tests { storage_receiver: None, prefs_dao: None, agent: "Build".to_string(), + agent_registry: crate::agent::definition::AgentRegistry::default(), agent_steps: std::collections::HashMap::new(), provider_timeouts: std::collections::HashMap::new(), model: "test-model".to_string(), @@ -7884,6 +8227,8 @@ mod tests { "child-a".to_string(), "Explore task (@explore subagent)".to_string(), "explore".to_string(), + None, + None, "Explore task".to_string(), "Find files".to_string(), ); @@ -7892,6 +8237,8 @@ mod tests { "child-b".to_string(), "General task (@general subagent)".to_string(), "general".to_string(), + None, + None, "General task".to_string(), "Check implementation".to_string(), ); @@ -7942,6 +8289,8 @@ mod tests { "child-a".to_string(), "General task (@general subagent)".to_string(), "general".to_string(), + Some("sub-model".to_string()), + Some("sub-provider".to_string()), "General task".to_string(), "Check implementation".to_string(), ); @@ -7959,6 +8308,10 @@ mod tests { subagent_tab_label("Find files (@explore subagent)", "fallback"), "Explore" ); + assert_eq!( + subagent_tab_label("Analyze image (@vlm-agent subagent)", "fallback"), + "Vlm-Agent" + ); assert_eq!(subagent_tab_label("", "fallback"), "fallback"); } } diff --git a/src/autocomplete/command.rs b/src/autocomplete/command.rs index 0684be0..f97199c 100644 --- a/src/autocomplete/command.rs +++ b/src/autocomplete/command.rs @@ -4,6 +4,7 @@ use std::collections::HashSet; #[derive(Clone, Debug, PartialEq, Eq)] pub enum SuggestionKind { Command, + Agent, File, } @@ -43,9 +44,21 @@ impl Suggestion { } } + pub fn agent(name: impl Into, description: impl Into) -> Self { + let name = name.into(); + Self { + replacement: name.clone(), + name, + description: description.into(), + kind: SuggestionKind::Agent, + is_directory: false, + } + } + pub fn display_prefix(&self) -> &'static str { match self.kind { SuggestionKind::Command => "/", + SuggestionKind::Agent => "@", SuggestionKind::File => "", } } diff --git a/src/autocomplete/mod.rs b/src/autocomplete/mod.rs index 60de27e..63a5869 100644 --- a/src/autocomplete/mod.rs +++ b/src/autocomplete/mod.rs @@ -12,6 +12,7 @@ pub enum AutoCompleteMode { pub struct AutoComplete { pub command_auto: CommandAuto, pub file_auto: FileAuto, + pub agents: Vec, pub mode: AutoCompleteMode, } @@ -20,10 +21,16 @@ impl AutoComplete { Self { command_auto, file_auto: FileAuto::new(), + agents: Vec::new(), mode: AutoCompleteMode::Command, } } + pub fn with_agents(mut self, agents: Vec) -> Self { + self.agents = agents; + self + } + pub fn get_suggestions(&self, input: &str, is_chat: bool) -> Vec { match &self.mode { AutoCompleteMode::Command => self.command_auto.get_suggestions(input, is_chat), diff --git a/src/command/parser.rs b/src/command/parser.rs index d29db7a..19f70ba 100644 --- a/src/command/parser.rs +++ b/src/command/parser.rs @@ -7,6 +7,13 @@ pub struct ParsedCommand<'a> { pub active_model_id: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedAgentMention { + pub agent: String, + pub prompt: String, + pub raw: String, +} + impl<'a> ParsedCommand<'a> { pub fn raw_args(&self) -> &str { let Some(without_slash) = self.raw.trim().strip_prefix('/') else { @@ -28,10 +35,11 @@ impl<'a> PartialEq for ParsedCommand<'a> { #[derive(Debug, Clone, PartialEq)] pub enum InputType<'a> { Command(ParsedCommand<'a>), + AgentMention(ParsedAgentMention), Message(String), } -pub fn parse_input(input: &str) -> InputType { +pub fn parse_input(input: &str) -> InputType<'_> { let trimmed = input.trim(); if trimmed.starts_with('/') { @@ -40,10 +48,38 @@ pub fn parse_input(input: &str) -> InputType { } } + if trimmed.starts_with('@') { + if let Some(parsed) = parse_agent_mention(trimmed) { + return InputType::AgentMention(parsed); + } + } + InputType::Message(trimmed.to_string()) } -fn parse_command(input: &str) -> Option { +fn parse_agent_mention(input: &str) -> Option { + let rest = input.strip_prefix('@')?; + let (agent, prompt) = rest + .split_once(char::is_whitespace) + .map(|(agent, prompt)| (agent, prompt.trim_start())) + .unwrap_or((rest, "")); + + if agent.is_empty() + || !agent + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + return None; + } + + Some(ParsedAgentMention { + agent: agent.to_ascii_lowercase(), + prompt: prompt.to_string(), + raw: input.to_string(), + }) +} + +fn parse_command(input: &str) -> Option> { let without_slash = input.strip_prefix('/')?; let parts = shlex::split(without_slash).unwrap_or_else(|| { without_slash @@ -177,6 +213,27 @@ mod tests { ); } + #[test] + fn test_parse_input_agent_mention() { + let input = "@explore find parser tests"; + let result = parse_input(input); + assert_eq!( + result, + InputType::AgentMention(ParsedAgentMention { + agent: "explore".to_string(), + prompt: "find parser tests".to_string(), + raw: input.to_string(), + }) + ); + } + + #[test] + fn test_parse_input_invalid_agent_mention_is_message() { + let input = "@../file"; + let result = parse_input(input); + assert_eq!(result, InputType::Message("@../file".to_string())); + } + #[test] fn test_parse_input_message() { let input = "hello world"; diff --git a/src/config/configuration.rs b/src/config/configuration.rs index e42117b..f4e760b 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -248,6 +248,7 @@ pub struct MergedConfig { pub theme: Option, pub model: Option, pub default_agent: Option, + pub agent_registry: crate::agent::definition::AgentRegistry, pub commands: Vec, pub agent_tool_policies: HashMap>, pub permission_rules: PermissionRules, @@ -332,6 +333,22 @@ impl ConfigLoader { &mut diagnostics, ); let mut merged_config = parse_merged_config(&merged, &mut diagnostics); + let mut agent_definitions = crate::agent::definition::load_markdown_agent_definitions( + &inventory.opencode_agents, + &mut diagnostics.warnings, + ); + let mut ignored_agent_warnings = Vec::new(); + agent_definitions.extend( + crate::agent::definition::parse_agent_definitions_from_config( + merged.get("agent"), + &mut ignored_agent_warnings, + ), + ); + merged_config.agent_registry = crate::agent::definition::AgentRegistry::with_definitions( + merged_config.default_agent.as_deref(), + agent_definitions, + ); + merged_config.sync_agent_derived_fields(); merged_config.commands = commands; diagnostics.unimplemented_keys = collect_unimplemented_keys(&merged); @@ -996,9 +1013,15 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M } out.permission_rules = parse_permission_rules(obj.get("permission"), diagnostics, "permission"); - out.agent_tool_policies = parse_agent_tool_policies(obj.get("agent"), diagnostics); - out.agent_permission_rules = parse_agent_permission_rules(obj.get("agent"), diagnostics); - out.agent_steps = parse_agent_steps(obj.get("agent"), diagnostics); + let json_agents = crate::agent::definition::parse_agent_definitions_from_config( + obj.get("agent"), + &mut diagnostics.warnings, + ); + out.agent_registry = crate::agent::definition::AgentRegistry::with_definitions( + out.default_agent.as_deref(), + json_agents, + ); + out.sync_agent_derived_fields(); out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics); let mut notifications = NotificationsConfig::default(); @@ -1010,6 +1033,14 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M out } +impl MergedConfig { + fn sync_agent_derived_fields(&mut self) { + self.agent_tool_policies = self.agent_registry.tool_policy_map(); + self.agent_permission_rules = self.agent_registry.permission_rules_map(); + self.agent_steps = self.agent_registry.max_steps_map(); + } +} + fn parse_agent_tool_policies( value: Option<&Value>, diagnostics: &mut ConfigDiagnostics, diff --git a/src/llm/client.rs b/src/llm/client.rs index bde980a..c69b851 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -365,7 +365,7 @@ pub async fn stream_llm_with_cancellation( reasoning_effort: Option, agent_mode: String, agent_max_steps: Option, - agent_steps: HashMap, + agent_registry: crate::agent::definition::AgentRegistry, tool_permissions: crate::tools::ToolPermissions, messages: Vec, sender: crate::llm::ChunkSender, @@ -387,7 +387,7 @@ pub async fn stream_llm_with_cancellation( let tool_registry = crate::tools::initialize_tool_registry_with_dynamic( Some(sender.clone()), tool_permissions.clone(), - agent_steps, + agent_registry, cancel_token.clone(), ) .await; @@ -554,6 +554,41 @@ pub async fn stream_llm_with_cancellation( Ok(()) } +pub async fn configure_subagent_llm_session( + provider_name: &str, + model: String, + reasoning_effort: Option, + sender: &crate::llm::ChunkSender, +) -> Result<(), DynError> { + let session = + build_subagent_llm_session(provider_name, model, reasoning_effort, sender).await?; + crate::agent::config::set_llm_session(session); + Ok(()) +} + +pub async fn build_subagent_llm_session( + provider_name: &str, + model: String, + reasoning_effort: Option, + sender: &crate::llm::ChunkSender, +) -> Result { + let request_config = + prepare_request_config(provider_name, model, reasoning_effort, sender).await?; + Ok(crate::agent::config::LlmSessionConfig { + provider_name: request_config.provider_name, + model: request_config.model_name, + api_key: request_config.api_key, + provider_kind: match request_config.kind { + ProviderKind::OpenAI => crate::agent::config::ProviderKind::OpenAI, + ProviderKind::OpenAICompatible => crate::agent::config::ProviderKind::OpenAICompatible, + ProviderKind::Anthropic => crate::agent::config::ProviderKind::Anthropic, + }, + base_url: request_config.base_url, + reasoning_effort: request_config.reasoning_effort, + supports_image_input: request_config.supports_image_input, + }) +} + pub async fn summarize_for_compaction( provider_name: String, model: String, diff --git a/src/llm/mod.rs b/src/llm/mod.rs index b289c87..a385304 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -17,6 +17,8 @@ pub enum ChunkMessage { session_id: String, title: String, subagent_type: String, + model: Option, + provider: Option, description: String, prompt: String, }, diff --git a/src/main.rs b/src/main.rs index 7bce1c6..c0cd4b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -200,27 +200,25 @@ async fn run_print_mode( let (sender, mut receiver) = mpsc::unbounded_channel(); + let agent_registry = loaded_config.merged_config.agent_registry.clone(); let mut agent_policies = crate::tools::AgentToolPolicies::default(); - for (mode, tools) in &loaded_config.merged_config.agent_tool_policies { - agent_policies = agent_policies.with_custom_tools(mode.clone(), tools.clone()); + for (mode, tools) in agent_registry.tool_policy_map() { + agent_policies = agent_policies.with_custom_tools(mode, tools); } let tool_permissions = crate::tools::ToolPermissions::new(std::path::PathBuf::from(&cwd)) .with_agent_policies(agent_policies) .with_permission_rules(loaded_config.merged_config.permission_rules.clone()) - .with_agent_permission_rules(loaded_config.merged_config.agent_permission_rules.clone()) + .with_agent_permission_rules(agent_registry.permission_rules_map()) .dangerously_skip_permissions(dangerously_skip_permissions); - let agent_steps = loaded_config.merged_config.agent_steps.clone(); - let agent_max_steps = loaded_config - .merged_config - .agent_steps - .get(&agent_mode.to_ascii_lowercase()) - .copied(); + let agent_max_steps = agent_registry + .get(&agent_mode) + .and_then(|agent| agent.max_steps); let cancel_token = tokio_util::sync::CancellationToken::new(); let prompt_registry = crate::tools::initialize_tool_registry_with_dynamic( Some(sender.clone()), tool_permissions.clone(), - agent_steps.clone(), + agent_registry.clone(), cancel_token.clone(), ) .await; @@ -238,7 +236,8 @@ async fn run_print_mode( is_git_repo, std::env::consts::OS, ) - .with_tool_registry(prompt_registry); + .with_tool_registry(prompt_registry) + .with_agent_registry(agent_registry.clone()); let system_prompt = composer.compose().await; let messages = vec![Message::system(system_prompt), Message::user(prompt)]; @@ -255,7 +254,7 @@ async fn run_print_mode( reasoning_effort, agent_mode.clone(), agent_max_steps, - agent_steps, + agent_registry, tool_permissions, messages, sender, diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 4e509bf..6b38501 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -35,6 +35,7 @@ pub struct SystemPromptComposer { is_git_repo: bool, platform: String, tool_registry: Option, + agent_registry: Option, } impl SystemPromptComposer { @@ -50,6 +51,7 @@ impl SystemPromptComposer { is_git_repo, platform: platform.into(), tool_registry: None, + agent_registry: None, } } @@ -58,6 +60,14 @@ impl SystemPromptComposer { self } + pub fn with_agent_registry( + mut self, + registry: crate::agent::definition::AgentRegistry, + ) -> Self { + self.agent_registry = Some(registry); + self + } + pub async fn compose(&self) -> String { let mut parts = Vec::new(); @@ -317,7 +327,11 @@ Tool use: } // Add available subagents listing - let subagents = crate::agent::subagent::SubAgentDef::all(); + let registry = self + .agent_registry + .clone() + .unwrap_or_else(crate::agent::definition::AgentRegistry::default); + let subagents = registry.visible_subagents(); if !subagents.is_empty() { let subagents_xml = subagents .iter() diff --git a/src/theme.rs b/src/theme.rs index 595f616..1b38340 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -135,12 +135,12 @@ pub fn contrast_text(background: ratatui::style::Color) -> ratatui::style::Color } pub fn agent_color(agent: &str, colors: &ThemeColors) -> ratatui::style::Color { - match agent { + match agent.to_ascii_lowercase().as_str() { // Match OpenCode primary agent colors: // - Build: secondary // - Plan: accent - "Build" => colors.secondary, - "Plan" => colors.accent, + "build" => colors.secondary, + "plan" => colors.accent, _ => colors.primary, } } diff --git a/src/tools/init.rs b/src/tools/init.rs index fc64ad5..d112076 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -3,7 +3,6 @@ use crate::tools::{ BashTool, EditTool, QuestionTool, SkillTool, TaskTool, ToolPermissions, ToolRegistry, UpdatePlanTool, WebfetchTool, }; -use std::collections::HashMap; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -29,7 +28,7 @@ pub async fn register_dynamic_tools( registry: &ToolRegistry, sender: Option, permissions: ToolPermissions, - agent_steps: HashMap, + agent_registry: crate::agent::definition::AgentRegistry, cancel_token: CancellationToken, ) { registry @@ -42,7 +41,7 @@ pub async fn register_dynamic_tools( .register(Arc::new( TaskTool::new(registry.clone()) .with_sender_opt(sender) - .with_runtime_options(permissions, agent_steps, cancel_token), + .with_runtime_options(permissions, agent_registry, cancel_token), )) .await; } @@ -50,11 +49,11 @@ pub async fn register_dynamic_tools( pub async fn initialize_tool_registry_with_dynamic( sender: Option, permissions: ToolPermissions, - agent_steps: HashMap, + agent_registry: crate::agent::definition::AgentRegistry, cancel_token: CancellationToken, ) -> ToolRegistry { let registry = initialize_tool_registry().await; - register_dynamic_tools(®istry, sender, permissions, agent_steps, cancel_token).await; + register_dynamic_tools(®istry, sender, permissions, agent_registry, cancel_token).await; registry } @@ -83,7 +82,7 @@ mod tests { let registry = initialize_tool_registry_with_dynamic( None, ToolPermissions::new("."), - HashMap::new(), + crate::agent::definition::AgentRegistry::default(), CancellationToken::new(), ) .await; @@ -98,7 +97,7 @@ mod tests { let registry = initialize_tool_registry_with_dynamic( None, permissions.clone(), - HashMap::new(), + crate::agent::definition::AgentRegistry::default(), CancellationToken::new(), ) .await; diff --git a/src/tools/permission.rs b/src/tools/permission.rs index 1efbcf8..6ef2134 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -799,7 +799,7 @@ pub fn expand_permission_pattern(pattern: &str) -> String { trimmed.to_string() } -fn wildcard_match(input: &str, pattern: &str) -> bool { +pub(crate) fn wildcard_match(input: &str, pattern: &str) -> bool { let input = input.replace('\\', "/"); let pattern = pattern.replace('\\', "/"); let mut escaped = String::new(); diff --git a/src/tools/task.rs b/src/tools/task.rs index dd05215..cde7a9c 100644 --- a/src/tools/task.rs +++ b/src/tools/task.rs @@ -1,11 +1,11 @@ -use crate::agent::subagent::{self, SubAgentType}; +use crate::agent::definition::AgentRegistry; +use crate::agent::subagent; use crate::tools::{ get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, ToolError, ToolHandler, ToolRegistry, ToolResult, }; use async_trait::async_trait; use serde_json::Value; -use std::collections::HashMap; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -13,17 +13,88 @@ pub struct TaskTool { tool_registry: Arc, sender: Option, permissions: Option, - agent_steps: HashMap, + agent_registry: AgentRegistry, cancel_token: CancellationToken, } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_parent_cannot_invoke_general_subagent() { + let task = TaskTool::new(ToolRegistry::new()).with_runtime_options( + crate::tools::ToolPermissions::new("."), + AgentRegistry::default(), + CancellationToken::new(), + ); + let params = serde_json::json!({ + "subagent_type": "general", + "description": "test", + "prompt": "try to write" + }); + let ctx = + ToolContext::from_cancel_token("session", "message", "Plan", CancellationToken::new()); + + let result = tokio_test::block_on(task.execute(params, &ctx)); + assert!(matches!(result, Err(ToolError::Permission(_)))); + } + + #[test] + fn explore_subagent_policy_denies_mutating_tools() { + let registry = AgentRegistry::default(); + let mut policies = crate::tools::AgentToolPolicies::default(); + for (agent, tools) in registry.tool_policy_map() { + policies = policies.with_custom_tools(agent, tools); + } + let permissions = crate::tools::ToolPermissions::new(".").with_agent_policies(policies); + + assert!(permissions.is_tool_allowed_for_agent("explore", "read")); + assert!(!permissions.is_tool_allowed_for_agent("explore", "bash")); + assert!(!permissions.is_tool_allowed_for_agent("explore", "write")); + assert!(!permissions.is_tool_allowed_for_agent("explore", "edit")); + } + + #[test] + fn subagent_display_model_prefers_agent_model_override() { + crate::agent::config::set_llm_session(crate::agent::config::LlmSessionConfig { + provider_name: "parent-provider".to_string(), + model: "parent-model".to_string(), + api_key: None, + provider_kind: crate::agent::config::ProviderKind::OpenAICompatible, + base_url: "https://example.test".to_string(), + reasoning_effort: None, + supports_image_input: false, + }); + + let mut warnings = Vec::new(); + let defs = crate::agent::definition::parse_agent_definitions_from_config( + Some(&serde_json::json!({ + "vlm-agent": { + "mode": "subagent", + "model": "opencode-go/kimi-k2.6" + } + })), + &mut warnings, + ); + let agent = defs.first().expect("agent definition"); + + assert!(warnings.is_empty()); + assert_eq!( + subagent_display_provider(agent).as_deref(), + Some("opencode-go") + ); + assert_eq!(subagent_display_model(agent).as_deref(), Some("kimi-k2.6")); + } +} + impl TaskTool { pub fn new(tool_registry: ToolRegistry) -> Self { Self { tool_registry: Arc::new(tool_registry), sender: None, permissions: None, - agent_steps: HashMap::new(), + agent_registry: AgentRegistry::default(), cancel_token: CancellationToken::new(), } } @@ -36,11 +107,11 @@ impl TaskTool { pub fn with_runtime_options( mut self, permissions: crate::tools::ToolPermissions, - agent_steps: HashMap, + agent_registry: AgentRegistry, cancel_token: CancellationToken, ) -> Self { self.permissions = Some(permissions); - self.agent_steps = agent_steps; + self.agent_registry = agent_registry; self.cancel_token = cancel_token; self } @@ -49,13 +120,26 @@ impl TaskTool { #[async_trait] impl ToolHandler for TaskTool { fn definition(&self) -> Tool { + let available = self + .agent_registry + .visible_subagents() + .into_iter() + .map(|agent| format!("- {}: {}", agent.name, agent.description)) + .collect::>() + .join("\n"); + let available = if available.is_empty() { + "No visible subagent types are currently configured.".to_string() + } else { + available + }; + Tool { id: "task".to_string(), - description: "Launch a new agent to handle complex, multistep tasks autonomously.\n\nWhen using the Task tool, you must specify a subagent_type parameter to select which agent type to use.\n\nWhen to use the Task tool:\n- When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt.\n\nWhen NOT to use the Task tool:\n- If you want to read a specific file path, use the Read or Glob tool instead\n- If you are searching for a specific class definition, use the Glob tool instead\n- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead\n- Other tasks that are not related to the agent descriptions above\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; do that by using multiple tool calls in a single message\n2. When the agent is done, it will return a single message back to you. The result is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation starts with a fresh context\n4. The agent's outputs should generally be trusted\n5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)\n\nAvailable subagent types:\n- explore: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase.\n- general: General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.".to_string(), + description: format!("Launch a new agent to handle complex, multistep tasks autonomously.\n\nWhen using the Task tool, you must specify a subagent_type parameter to select which agent type to use.\n\nWhen to use the Task tool:\n- When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt.\n\nWhen NOT to use the Task tool:\n- If you want to read a specific file path, use the Read or Glob tool instead\n- If you are searching for a specific class definition, use the Glob tool instead\n- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead\n- Other tasks that are not related to the agent descriptions above\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; do that by using multiple tool calls in a single message\n2. When the agent is done, it will return a single message back to you. The result is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation starts with a fresh context\n4. The agent's outputs should generally be trusted\n5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)\n\nAvailable subagent types:\n{}", available), parameters: vec![ ParameterSchema { name: "subagent_type".to_string(), - description: "The type of specialized agent to use for this task (explore or general)".to_string(), + description: "The type of specialized agent to use for this task".to_string(), required: true, param_type: ParameterType::String, }, @@ -79,9 +163,9 @@ impl ToolHandler for TaskTool { validate_required(params, &["subagent_type", "description", "prompt"])?; let subagent_type = get_string_param(params, "subagent_type").unwrap_or_default(); - if SubAgentType::from_str(&subagent_type).is_none() { + if self.agent_registry.task_target(&subagent_type).is_none() { return Err(ToolError::Validation(format!( - "Invalid subagent_type: '{}'. Must be 'explore' or 'general'", + "Invalid subagent_type: '{}'. Must be a configured subagent", subagent_type ))); } @@ -94,9 +178,23 @@ impl ToolHandler for TaskTool { let description = get_string_param(¶ms, "description").unwrap_or_default(); let prompt = get_string_param(¶ms, "prompt").unwrap_or_default(); - let subagent_type = SubAgentType::from_str(&subagent_type_str).ok_or_else(|| { - ToolError::Validation(format!("Unknown subagent type: {}", subagent_type_str)) - })?; + let subagent = self + .agent_registry + .task_target(&subagent_type_str) + .cloned() + .ok_or_else(|| { + ToolError::Validation(format!("Unknown subagent type: {}", subagent_type_str)) + })?; + + if !self + .agent_registry + .can_agent_invoke(&ctx.agent, &subagent.name) + { + return Err(ToolError::Permission(format!( + "Agent '{}' is not allowed to invoke subagent '{}'", + ctx.agent, subagent.name + ))); + } if ctx.is_aborted() { return Err(ToolError::Execution("Subagent cancelled".to_string())); @@ -105,14 +203,7 @@ impl ToolHandler for TaskTool { let permissions = self.permissions.clone().unwrap_or_else(|| { crate::tools::ToolPermissions::new(crate::utils::cwd::current_dir_or_dot()) }); - let max_steps = self - .agent_steps - .get(subagent_type.name()) - .or_else(|| { - self.agent_steps - .get(&subagent_type.name().to_ascii_lowercase()) - }) - .copied(); + let max_steps = subagent.max_steps; let child_session_id = cuid2::create_id(); let title = format!( @@ -122,14 +213,14 @@ impl ToolHandler for TaskTool { } else { description.trim() }, - subagent_type.name() + subagent.name ); crate::emit_log!( "[TASK] start parent_session_id={} child_session_id={} subagent_type={} title={:?} description_bytes={} prompt_bytes={} sender_present={}", ctx.session_id, child_session_id, - subagent_type.name(), + subagent.name, title, description.len(), prompt.len(), @@ -140,14 +231,16 @@ impl ToolHandler for TaskTool { ctx.session_id.clone(), child_session_id.clone(), title.clone(), - subagent_type.name().to_string(), + subagent.name.clone(), + subagent_display_provider(&subagent), + subagent_display_model(&subagent), description.clone(), prompt.clone(), ); let started_at = std::time::Instant::now(); let result = match subagent::run_subagent( - subagent_type.clone(), + subagent.clone(), &description, &prompt, &self.tool_registry, @@ -165,7 +258,7 @@ impl ToolHandler for TaskTool { "[TASK] error parent_session_id={} child_session_id={} subagent_type={} duration_ms={} error={}", ctx.session_id, child_session_id, - subagent_type.name(), + subagent.name, started_at.elapsed().as_millis(), e ); @@ -185,17 +278,17 @@ impl ToolHandler for TaskTool { "[TASK] finish parent_session_id={} child_session_id={} subagent_type={} duration_ms={} output_bytes={} child_tool_call_count={}", ctx.session_id, child_session_id, - subagent_type.name(), + subagent.name, duration_ms, result.output.len(), result.tool_call_count ); Ok(ToolResult::new( - format!("Subagent ({}) result", subagent_type.name()), + format!("Subagent ({}) result", subagent.name), result.output, ) - .with_metadata("subagent_type", serde_json::json!(subagent_type.name())) + .with_metadata("subagent_type", serde_json::json!(subagent.name)) .with_metadata("child_session_id", serde_json::json!(child_session_id)) .with_metadata("child_session_title", serde_json::json!(title)) .with_metadata( @@ -213,6 +306,8 @@ impl TaskTool { session_id: String, title: String, subagent_type: String, + provider: Option, + model: Option, description: String, prompt: String, ) -> Option { @@ -224,6 +319,8 @@ impl TaskTool { session_id: session_id.clone(), title, subagent_type, + model, + provider, description, prompt, }); @@ -242,3 +339,33 @@ impl TaskTool { Some(child_tx) } } + +fn subagent_display_model(agent: &crate::agent::definition::AgentDefinition) -> Option { + agent + .model + .as_deref() + .map(str::trim) + .filter(|model_ref| !model_ref.is_empty()) + .map(|model_ref| { + model_ref + .split_once('/') + .map(|(_, model)| model.trim()) + .unwrap_or(model_ref) + .to_string() + }) + .or_else(|| crate::agent::config::get_llm_session().map(|session| session.model)) +} + +fn subagent_display_provider(agent: &crate::agent::definition::AgentDefinition) -> Option { + agent + .model + .as_deref() + .map(str::trim) + .filter(|model_ref| !model_ref.is_empty()) + .and_then(|model_ref| { + model_ref + .split_once('/') + .map(|(provider, _)| provider.trim().to_string()) + }) + .or_else(|| crate::agent::config::get_llm_session().map(|session| session.provider_name)) +} diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 4dfd6d3..ac427b8 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -2519,8 +2519,8 @@ impl Chat { } if !emitted_anything { - if message.is_complete && message.was_interrupted { - let metadata = self.format_metadata(message, model, colors); + if is_streaming || (message.is_complete && message.was_interrupted) { + let metadata = self.format_metadata(message, model, colors, !is_streaming); lines.push(Line::from(metadata)); lines.push(Line::from("")); } @@ -2529,16 +2529,17 @@ impl Chat { // Add empty line before metadata for spacing let next_role = self.messages.get(idx + 1).map(|m| m.role.clone()); - let show_metadata = message.is_complete - && (message.was_interrupted - || !matches!( - next_role, - Some(MessageRole::Tool) | Some(MessageRole::Assistant) - )); + let show_metadata = is_streaming + || (message.is_complete + && (message.was_interrupted + || !matches!( + next_role, + Some(MessageRole::Tool) | Some(MessageRole::Assistant) + ))); if show_metadata { lines.push(Line::from("")); - let metadata = self.format_metadata(message, model, colors); + let metadata = self.format_metadata(message, model, colors, !is_streaming); lines.push(Line::from(metadata)); lines.push(Line::from("")); } else { @@ -3346,7 +3347,13 @@ impl Chat { out } - fn format_metadata(&self, message: &Message, _model: &str, colors: &ThemeColors) -> Vec { + fn format_metadata( + &self, + message: &Message, + model: &str, + colors: &ThemeColors, + include_metrics: bool, + ) -> Vec> { let mut spans = Vec::new(); // Get agent mode from previous user message or default to "Plan" @@ -3363,7 +3370,7 @@ impl Chat { // Agent type spans.push(Span::styled( - agent_mode, + display_agent_name(&agent_mode), Style::default() .fg(agent_color) .add_modifier(Modifier::BOLD), @@ -3373,14 +3380,14 @@ impl Chat { spans.push(Span::styled(" • ", Style::default().fg(colors.text_weak))); // Model ID - use persisted model from message, fallback to current model - let model_display = message.model.as_deref().unwrap_or(_model); + let model_display = message.model.as_deref().unwrap_or(model); spans.push(Span::styled( model_display.to_string(), Style::default().fg(colors.text), )); - // Timing + throughput metrics (only show for completed messages) - if message.is_complete { + // Timing + throughput metrics are shown only once the stream is done. + if include_metrics { if let (Some(t0), Some(t1), Some(tn)) = (message.t0_ms, message.t1_ms, message.tn_ms) { let output_tokens = message.output_tokens.or(message.token_count).unwrap_or(0); @@ -3495,6 +3502,23 @@ fn is_synthetic_tool_result_text(content: &str) -> bool { content.trim_start().starts_with("[tool result:") } +fn display_agent_name(agent: &str) -> String { + let mut out = String::new(); + let mut word_start = true; + for ch in agent.trim().chars() { + if matches!(ch, '-' | '_' | ' ') { + out.push(ch); + word_start = true; + } else if word_start { + out.push(ch.to_ascii_uppercase()); + word_start = false; + } else { + out.push(ch); + } + } + out +} + fn render_line_backgrounds( f: &mut Frame, area: Rect, @@ -3731,6 +3755,13 @@ mod tests { use ratatui::layout::Rect; use ratatui::style::Color; + #[test] + fn display_agent_name_title_cases_agent_words() { + assert_eq!(display_agent_name("build"), "Build"); + assert_eq!(display_agent_name("vlm-agent"), "Vlm-Agent"); + assert_eq!(display_agent_name("general_reviewer"), "General_Reviewer"); + } + fn test_colors() -> ThemeColors { ThemeColors { primary: Color::Reset, @@ -5038,6 +5069,62 @@ codex exec --skip-git-repo-check \ assert!(lines.is_empty()); } + #[test] + fn streaming_assistant_metadata_shows_agent_model_without_metrics() { + let mut chat = Chat::new(); + let mut user = Message::user("Prompt"); + user.agent_mode = Some("build".to_string()); + chat.add_message(user); + + let mut msg = Message::incomplete("Streaming answer."); + msg.model = Some("glm-4.7".to_string()); + msg.t0_ms = Some(1_000); + msg.t1_ms = Some(1_200); + msg.tn_ms = Some(2_000); + msg.output_tokens = Some(40); + chat.add_message(msg); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "fallback-model", &colors); + let metadata = lines + .iter() + .map(line_text) + .find(|line| line.contains("Build • glm-4.7")) + .expect("streaming metadata line"); + + assert!(!metadata.contains("ttft")); + assert!(!metadata.contains("t/s")); + assert!(!metadata.contains("1.0s")); + } + + #[test] + fn completed_assistant_metadata_includes_latency_metrics() { + let mut chat = Chat::new(); + let mut user = Message::user("Prompt"); + user.agent_mode = Some("build".to_string()); + chat.add_message(user); + + let mut msg = Message::assistant("Done."); + msg.model = Some("glm-4.7".to_string()); + msg.t0_ms = Some(1_000); + msg.t1_ms = Some(1_200); + msg.tn_ms = Some(2_000); + msg.output_tokens = Some(40); + chat.add_message(msg); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "fallback-model", &colors); + let metadata = lines + .iter() + .map(line_text) + .find(|line| line.contains("Build • glm-4.7")) + .expect("completed metadata line"); + + assert!(metadata.contains("1.0s")); + assert!(metadata.contains("ttft 0.2s")); + assert!(metadata.contains("50t/s")); + } + #[test] fn interrupted_assistant_metadata_shows_status_label() { let mut chat = Chat::new(); diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 7326899..7504f36 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -1563,6 +1563,13 @@ impl Input { let text = self.get_text(); self.replace_range(0..text.len(), &replacement); } + SuggestionKind::Agent => { + let Some(token) = self.current_at_token(true) else { + return; + }; + let replacement = format!("@{} ", suggestion.replacement); + self.replace_range(token.range, &replacement); + } SuggestionKind::File => { let Some(token) = self.current_at_token(true) else { return; @@ -1606,7 +1613,21 @@ impl Input { let suggestions = if let Some(filter) = self.command_query() { autocomplete.command_auto.get_suggestions(&filter, is_chat) } else if let Some(token) = self.current_at_token(true) { - autocomplete.file_auto.get_suggestions(&token.query) + let mut suggestions = Vec::new(); + suggestions.extend( + autocomplete + .agents + .iter() + .filter(|agent| { + agent + .name + .to_ascii_lowercase() + .starts_with(&token.query.to_ascii_lowercase()) + }) + .cloned(), + ); + suggestions.extend(autocomplete.file_auto.get_suggestions(&token.query)); + suggestions } else { Vec::new() }; @@ -1709,7 +1730,21 @@ impl Input { return autocomplete.command_auto.get_suggestions(&filter, is_chat); } if let Some(token) = self.current_at_token(true) { - return autocomplete.file_auto.get_suggestions(&token.query); + let mut suggestions = Vec::new(); + suggestions.extend( + autocomplete + .agents + .iter() + .filter(|agent| { + agent + .name + .to_ascii_lowercase() + .starts_with(&token.query.to_ascii_lowercase()) + }) + .cloned(), + ); + suggestions.extend(autocomplete.file_auto.get_suggestions(&token.query)); + return suggestions; } } Vec::new() @@ -2116,6 +2151,26 @@ mod tests { ); } + #[test] + fn test_agent_autocomplete_is_available_outside_chat() { + let mut input = Input::new().with_autocomplete( + AutoComplete::new(crate::autocomplete::CommandAuto::default()).with_agents(vec![ + Suggestion::agent("explore", "Explore code"), + Suggestion::agent("general", "General task"), + ]), + ); + input.insert_str("@"); + + let suggestions = input.get_autocomplete_suggestions(false); + + assert!(suggestions + .iter() + .any(|s| s.kind == SuggestionKind::Agent && s.name == "explore")); + assert!(suggestions + .iter() + .any(|s| s.kind == SuggestionKind::Agent && s.name == "general")); + } + #[test] fn test_wrapped_input_and_paste_increase_height_like_newlines() { let mut newline_input = Input::new(); diff --git a/src/views/chat.rs b/src/views/chat.rs index 72ed34e..fb4aa2c 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -28,6 +28,8 @@ pub struct ChatState { #[derive(Debug, Clone)] pub struct SubagentTab { pub label: String, + pub agent: String, + pub model: String, pub active: bool, pub running: bool, pub color: ratatui::style::Color, @@ -442,6 +444,10 @@ fn render_subagent_footer( .unwrap_or("Subagent"); let running = active_tab.is_some_and(|tab| tab.running); let active_color = active_tab.map(|tab| tab.color).unwrap_or(colors.primary); + let active_agent = active_tab + .map(|tab| tab.agent.as_str()) + .unwrap_or("Subagent"); + let active_model = active_tab.map(|tab| tab.model.as_str()).unwrap_or(""); let border_set = border::Set { vertical_left: "┃", @@ -462,15 +468,15 @@ fn render_subagent_footer( return; } - let mut left_spans = vec![ - Span::styled( - label.to_string(), - Style::default() - .fg(colors.text) - .add_modifier(Modifier::BOLD), - ), - Span::raw(format!(" ({} of {})", active_index + 1, total)), - ]; + let mut left_spans = + agent_model_spans_with_color(active_agent, active_model, active_color, colors); + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + format!("{} ({} of {})", label, active_index + 1, total), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); if running { left_spans.push(Span::raw(" ")); @@ -549,6 +555,55 @@ fn render_subagent_footer( ); } +fn agent_model_spans_with_color( + agent: &str, + model: &str, + agent_color: Color, + colors: &ThemeColors, +) -> Vec> { + let mut spans = vec![ + Span::styled( + "▣ ", + Style::default() + .fg(agent_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + display_agent_name(agent), + Style::default() + .fg(agent_color) + .add_modifier(Modifier::BOLD), + ), + ]; + + if !model.trim().is_empty() { + spans.push(Span::styled(" • ", Style::default().fg(colors.text_weak))); + spans.push(Span::styled( + model.trim().to_string(), + Style::default().fg(colors.text), + )); + } + + spans +} + +fn display_agent_name(agent: &str) -> String { + let mut out = String::new(); + let mut word_start = true; + for ch in agent.trim().chars() { + if matches!(ch, '-' | '_' | ' ') { + out.push(ch); + word_start = true; + } else if word_start { + out.push(ch.to_ascii_uppercase()); + word_start = false; + } else { + out.push(ch); + } + } + out +} + fn centered_subagent_footer_content(area: Rect) -> Rect { if area.width <= 3 || area.height == 0 { return Rect::new(area.x, area.y, area.width, area.height.min(1)); @@ -561,3 +616,15 @@ fn centered_subagent_footer_content(area: Rect) -> Rect { height: 1, } } + +#[cfg(test)] +mod tests { + use super::display_agent_name; + + #[test] + fn display_agent_name_title_cases_agent_words() { + assert_eq!(display_agent_name("build"), "Build"); + assert_eq!(display_agent_name("vlm-agent"), "Vlm-Agent"); + assert_eq!(display_agent_name("general_reviewer"), "General_Reviewer"); + } +} From bb235b2cb8c5672f30db7dc6d9048b2aba5c14ec Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 14:25:58 +0800 Subject: [PATCH 182/226] fix(ui): keep list markers inline with item text when wrapping. List markers were getting separated from their content and rendered on standalone lines during word wrap. This affects both ordered ("1.", "2.") and unordered ("-", "*", "+") lists. - Join detached marker-only lines back with the following content line in the markdown streaming renderer - Prevent the word wrapper from breaking after a bare list marker - Bump RENDER_VERSION to invalidate cached message heights --- _plans/__TODOS.md | 20 ++++----- src/ui/components/chat.rs | 2 +- src/ui/markdown/streaming.rs | 84 +++++++++++++++++++++++++++++++++++- src/ui/wrapping.rs | 58 ++++++++++++++++++++++++- 4 files changed, 150 insertions(+), 14 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 180ed6b..b467dcf 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -194,22 +194,22 @@ I want - [x] To do this But I dont want to do this - [x] Mouse scroll ux just like opencode, when highlighting. Needs to scroll when I reach edges as I drag and click. -- [ ] Sometimes list items that have "bold" characters on them kinda break a new line between the number enum and the actual sentence i.e. +- [x] Sometimes list items that have "bold" characters on them kinda break a new line between the number enum and the actual sentence i.e. - 1.
**Replaced old indicator**. - Even though when I copy it looks like - ``` - 1. **Replaced the old loading indicator** (`SheetCopilot.tsx:757`) with a new shimmer bar that shows unconditionally whenever `loading()` is true. Text reads "Generating Response..." with an animated sweep across a 1px track. + ``` + 1. **Replaced the old loading indicator** (`SheetCopilot.tsx:757`) with a new shimmer bar that shows unconditionally whenever `loading()` is true. Text reads "Generating Response..." with an animated sweep across a 1px track. - 2. **Removed the `draftPatch` label** (`SheetCopilot.tsx:1273`) from the tool-call topline — the card now renders without the external label. -G - 3. **Added shimmer CSS** (`sheetpilot.css:1165`) with `@keyframes sheetpilot-shimmer-sweep` and the `.sheetpilot-generating*` layout. + 2. **Removed the `draftPatch` label** (`SheetCopilot.tsx:1273`) from the tool-call topline — the card now renders without the external label. - Build it with your usual `pnpm dev` / `pnpm build` to see the changes. - ``` + G 3. **Added shimmer CSS** (`sheetpilot.css:1165`) with `@keyframes sheetpilot-shimmer-sweep` and the `.sheetpilot-generating*` layout. + + Build it with your usual `pnpm dev` / `pnpm build` to see the changes. + ``` - [ ] Make "▼ 💭 Thinking" rendered like this. And an accordion, so if I click it with my mouse, or with a special hotkey + command palette command. It can be toggled on and off. - [ ] Subagent UI view is not rendering the full table it seems like.. I always see this.. just the top. - - `┌─────────────────────────┬────────────────────────────────────────────────────────────────────────────` - never the full table - - Thouh I think the table does have content. I think it's just being weird. \ No newline at end of file + - `┌─────────────────────────┬────────────────────────────────────────────────────────────────────────────` - never the full table + - Thouh I think the table does have content. I think it's just being weird. diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index ac427b8..84a6ea5 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -930,7 +930,7 @@ impl Chat { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // Bump this whenever rendering logic changes (tables, markdown, etc.) - const RENDER_VERSION: u64 = 6; + const RENDER_VERSION: u64 = 7; RENDER_VERSION.hash(&mut h); colors.hash(&mut h); self.messages.len().hash(&mut h); diff --git a/src/ui/markdown/streaming.rs b/src/ui/markdown/streaming.rs index b3bae78..941eedd 100644 --- a/src/ui/markdown/streaming.rs +++ b/src/ui/markdown/streaming.rs @@ -147,14 +147,18 @@ pub fn render_markdown( let text = tui_markdown::from_str_with_options(&processed, &options); // Convert to our ratatui version's Line type and wrap to max_width - let mut result = Vec::new(); + let mut themed_lines = Vec::new(); let mut in_code_block = false; for line in text.lines { // Convert ratatui-core Line to our ratatui Line let mut converted_line = convert_line(line); apply_markdown_theme(&mut converted_line, &mut in_code_block, colors); + themed_lines.push(converted_line); + } + let mut result = Vec::new(); + for converted_line in join_detached_list_markers(themed_lines) { // Check if line needs wrapping let line_str = line_to_string(&converted_line); let line_width = unicode_width::UnicodeWidthStr::width(line_str.as_str()); @@ -179,6 +183,54 @@ pub fn render_markdown( result } +fn join_detached_list_markers(lines: Vec>) -> Vec> { + let mut result = Vec::with_capacity(lines.len()); + let mut iter = lines.into_iter().peekable(); + + while let Some(mut line) = iter.next() { + if is_detached_list_marker_line(&line) { + if let Some(next) = iter.peek() { + let next_text = line_to_string(next); + if !next_text.trim().is_empty() && !is_detached_list_marker_text(&next_text) { + let next = iter.next().expect("peeked next line"); + ensure_trailing_marker_space(&mut line); + line.spans.extend(next.spans); + result.push(line); + continue; + } + } + } + + result.push(line); + } + + result +} + +fn is_detached_list_marker_line(line: &Line<'_>) -> bool { + let text = line_to_string(line); + text.ends_with(' ') && is_detached_list_marker_text(&text) +} + +fn is_detached_list_marker_text(text: &str) -> bool { + let marker = text.trim_end().trim_start(); + + matches!(marker, "-" | "*" | "+") + || marker.strip_suffix('.').is_some_and(|digits| { + !digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit()) + }) +} + +fn ensure_trailing_marker_space(line: &mut Line<'static>) { + let Some(last_span) = line.spans.last_mut() else { + return; + }; + if last_span.content.ends_with(' ') { + return; + } + last_span.content.to_mut().push(' '); +} + fn is_preprocessed_table_line(line: &str) -> bool { line.contains('│') || line.contains('┌') @@ -528,6 +580,36 @@ mod tests { assert!(lines.len() > 1); } + #[test] + fn test_ordered_list_marker_stays_with_bold_item_text() { + let colors = test_colors(); + let lines = render_markdown( + "1. **Replaced the old loading indicator** (`SheetCopilot.tsx:757`) with a new shimmer bar.", + 10, + &colors, + ); + let rendered: Vec = lines.iter().map(line_to_string).collect(); + + assert!(rendered[0].starts_with("1. Replace")); + assert!(!rendered.iter().any(|line| line.trim_end() == "1.")); + } + + #[test] + fn test_multiple_ordered_list_items_keep_markers_inline() { + let colors = test_colors(); + let lines = render_markdown( + "1. **Removed the label** from the topline.\n\n2. **Added shimmer CSS** with keyframes.", + 80, + &colors, + ); + let rendered: Vec = lines.iter().map(line_to_string).collect(); + + assert!(rendered.iter().any(|line| line.starts_with("1. Removed"))); + assert!(rendered.iter().any(|line| line.starts_with("2. Added"))); + assert!(!rendered.iter().any(|line| line.trim_end() == "1.")); + assert!(!rendered.iter().any(|line| line.trim_end() == "2.")); + } + #[test] fn test_render_markdown_with_table() { let colors = test_colors(); diff --git a/src/ui/wrapping.rs b/src/ui/wrapping.rs index 30f2e5f..242512c 100644 --- a/src/ui/wrapping.rs +++ b/src/ui/wrapping.rs @@ -152,8 +152,22 @@ fn wrap_ranges(text: &str, first_width: usize, subsequent_width: usize) -> Vec Vec bool { + if break_end <= start || break_end > text.len() { + return false; + } + + let prefix = &text[start..break_end]; + if !prefix.ends_with(' ') { + return false; + } + + let marker = prefix.trim_end().trim_start(); + let is_marker = matches!(marker, "-" | "*" | "+") + || marker.strip_suffix('.').is_some_and(|digits| { + !digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit()) + }); + + is_marker && break_start + 1 == break_end +} + fn skip_breaking_whitespace(text: &str, mut byte_idx: usize) -> usize { while byte_idx < text.len() { let Some(ch) = text[byte_idx..].chars().next() else { @@ -334,6 +372,22 @@ mod tests { assert_eq!(line_text(&wrapped[2]), " four"); } + #[test] + fn keeps_ordered_list_marker_with_first_word_when_wrapping() { + let wrapped = wrap_styled_line(&Line::from("1. Replaced old indicator"), 10); + + assert_eq!(line_text(&wrapped[0]), "1. Replace"); + assert_ne!(line_text(&wrapped[0]), "1."); + } + + #[test] + fn keeps_unordered_list_marker_with_first_word_when_wrapping() { + let wrapped = wrap_styled_line(&Line::from("- Replaced old indicator"), 8); + + assert_eq!(line_text(&wrapped[0]), "- Replac"); + assert_ne!(line_text(&wrapped[0]), "-"); + } + #[test] fn wraps_unicode_on_char_boundaries() { let line = Line::from("cool 😄 emoji wraps"); From 94036faf9aca9273b249ce2b0fd70c3c943b8f61 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 28 May 2026 16:21:33 +0800 Subject: [PATCH 183/226] fix(ui/markdown): preserve table line breaks in rendered markdown tables. The markdown streaming parser was collapsing multi-line rendered tables into a single line because the Unicode box-drawing output contained bare newlines that Markdown treated as soft line breaks. Added trailing double spaces before each newline so the lines remain separate after re-parsing. Bumped RENDER_VERSION to invalidate cached layout. Fixes subagent UI tables that previously only showed the top border row. --- _plans/__TODOS.md | 2 +- src/ui/components/chat.rs | 2 +- src/ui/markdown/streaming.rs | 29 ++++++++++++++++++----------- src/ui/markdown/table.rs | 16 +++++++++++++++- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index b467dcf..83454fd 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -210,6 +210,6 @@ I want - [x] To do this But I dont want to do this - [ ] Make "▼ 💭 Thinking" rendered like this. And an accordion, so if I click it with my mouse, or with a special hotkey + command palette command. It can be toggled on and off. -- [ ] Subagent UI view is not rendering the full table it seems like.. I always see this.. just the top. +- [x] Subagent UI view is not rendering the full table it seems like.. I always see this.. just the top. - `┌─────────────────────────┬────────────────────────────────────────────────────────────────────────────` - never the full table - Thouh I think the table does have content. I think it's just being weird. diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 84a6ea5..0e81f0c 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -930,7 +930,7 @@ impl Chat { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // Bump this whenever rendering logic changes (tables, markdown, etc.) - const RENDER_VERSION: u64 = 7; + const RENDER_VERSION: u64 = 8; RENDER_VERSION.hash(&mut h); colors.hash(&mut h); self.messages.len().hash(&mut h); diff --git a/src/ui/markdown/streaming.rs b/src/ui/markdown/streaming.rs index 941eedd..cd3a7ad 100644 --- a/src/ui/markdown/streaming.rs +++ b/src/ui/markdown/streaming.rs @@ -617,18 +617,12 @@ mod tests { let lines = render_markdown(input, 80, &colors); // Convert lines to string for inspection - let output: String = lines + let line_strings: Vec = lines.iter().map(line_to_string).collect(); + let output = line_strings.join("\n"); + let table_lines: Vec<_> = line_strings .iter() - .map(|line| { - line.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n"); - - eprintln!("render_markdown output:\n{}", output); + .filter(|line| is_preprocessed_table_line(line)) + .collect(); // Should contain our Unicode box-drawing corners, not raw markdown assert!( @@ -645,6 +639,19 @@ mod tests { !output.contains("| A |"), "Raw markdown table should be replaced" ); + assert_eq!( + table_lines.len(), + 5, + "Rendered table rows should remain separate lines:\n{}", + output + ); + for line in table_lines { + assert!( + unicode_width::UnicodeWidthStr::width(line.as_str()) <= 80, + "Rendered table line should fit the viewport: {}", + line + ); + } } #[test] diff --git a/src/ui/markdown/table.rs b/src/ui/markdown/table.rs index 376fa8c..4eaad24 100644 --- a/src/ui/markdown/table.rs +++ b/src/ui/markdown/table.rs @@ -29,7 +29,7 @@ pub fn preprocess_tables(content: &str, max_width: usize) -> String { in_table = false; last_end = range.end; let rendered = render_table(&rows, &table_alignments, max_width); - result.push_str(&rendered); + result.push_str(&preserve_table_line_breaks(&rendered)); rows.clear(); } Event::Start(Tag::TableHead) => { @@ -81,6 +81,20 @@ pub fn preprocess_tables(content: &str, max_width: usize) -> String { result } +fn preserve_table_line_breaks(rendered: &str) -> String { + let mut result = String::with_capacity(rendered.len()); + let mut lines = rendered.split('\n').peekable(); + + while let Some(line) = lines.next() { + result.push_str(line); + if lines.peek().is_some() { + result.push_str(" \n"); + } + } + + result +} + fn render_table(rows: &[Vec], alignments: &[Alignment], max_width: usize) -> String { if rows.is_empty() { return String::new(); From 2d7f5cfcd75ae31affd40d3d651f6c5cb59a85c2 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 29 May 2026 03:09:50 +0800 Subject: [PATCH 184/226] feat: optimization. --- _plans/RENDER_OPTIMIZATION.md | 58 +++++++++++++++++++++++++++++++++++ src/ui/components/chat.rs | 48 +++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 _plans/RENDER_OPTIMIZATION.md diff --git a/_plans/RENDER_OPTIMIZATION.md b/_plans/RENDER_OPTIMIZATION.md new file mode 100644 index 0000000..1f17cc9 --- /dev/null +++ b/_plans/RENDER_OPTIMIZATION.md @@ -0,0 +1,58 @@ +# Render Optimization Plan + +## Goal + +Keep typing, scrolling, hover, and streaming responsive during long chat sessions. + +## Done + +- Cache the active-tool scan by chat render revision so repeated frames do not repeatedly parse tool JSON. +- Move `message_line_positions` mirroring into chat cache rebuilds instead of cloning positions on every render. + +## Next + +1. Add lightweight performance tracing behind `CRABCODE_PERF_TRACE=1`. + - Track total frame render time. + - Track chat render time. + - Track chat cache rebuild time. + - Track markdown render time. + - Include message count, rendered line count, visible line range, cache hit/miss, and active overlay. + +2. Replace linear line-to-message hit testing with indexed lookups. + - Build message line ranges when chat lines are cached. + - Use binary search for `message_index_at_content_line`. + - Reuse the same ranges for hover, image/link lookup, message actions, timeline highlight, and selection action placement. + +3. Add per-message render caching. + - Key cached message lines by message revision, width, theme hash, hover state where needed, and streaming state. + - Re-render only changed messages when a stream chunk arrives. + - Preserve logical grouping for task/exploration tool rows. + +4. Virtualize transcript rendering. + - Maintain per-message rendered heights and prefix sums. + - Resolve visible messages from scroll offset and viewport height. + - Render only visible messages plus a small buffer. + - Keep full-copy selection behavior using cached rendered lines or on-demand range rendering. + +5. Coalesce streaming updates. + - Drain text chunks into one append per event-loop tick. + - Invalidate render once per tick instead of once per chunk. + - Make `SimpleStreamingRenderer` append incrementally instead of reset-and-copying the full streaming message. + +6. Decouple active tool animation from full transcript cache invalidation. + - Store marker state separately from message lines, or render marker spans in a lightweight visible-line pass. + - Avoid full cache rebuilds every animation phase. + +7. Cache input wrapping. + - Add an input text revision and width-keyed `visual_lines` cache. + - Recompute wrapping only when input text, cursor-affecting layout, or width changes. + +8. Reduce autocomplete cloning. + - Avoid cloning the complete file autocomplete entry cache for each suggestion query. + - Score against borrowed entries while holding the cache lock, or store the cache behind `Arc<[FileEntry]>`. + +9. Add regression benchmarks. + - Synthetic long transcript render benchmark. + - Streaming append benchmark with a large prior transcript. + - Mouse move and scroll hit-test benchmark. + - Long prompt input typing benchmark. diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 0e81f0c..3498635 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -69,6 +69,8 @@ pub struct Chat { cached_width: usize, cached_colors_hash: u64, cached_fingerprint: u64, + cached_active_tools_revision: std::cell::Cell, + cached_has_active_tools: std::cell::Cell, tool_marker_animation_phase: bool, hovered_image: Option, hovered_hyperlink: Option, @@ -705,6 +707,8 @@ impl Chat { cached_width: 0, cached_colors_hash: 0, cached_fingerprint: 0, + cached_active_tools_revision: std::cell::Cell::new(0), + cached_has_active_tools: std::cell::Cell::new(false), tool_marker_animation_phase: false, hovered_image: None, hovered_hyperlink: None, @@ -748,6 +752,8 @@ impl Chat { cached_width: 0, cached_colors_hash: 0, cached_fingerprint: 0, + cached_active_tools_revision: std::cell::Cell::new(0), + cached_has_active_tools: std::cell::Cell::new(false), tool_marker_animation_phase: false, hovered_image: None, hovered_hyperlink: None, @@ -910,6 +916,8 @@ impl Chat { self.cached_width = 0; self.cached_colors_hash = 0; self.cached_fingerprint = 0; + self.cached_active_tools_revision.set(0); + self.cached_has_active_tools.set(false); self.tool_marker_animation_phase = false; self.invalidate_cache(); } @@ -917,6 +925,7 @@ impl Chat { fn invalidate_cache(&mut self) { self.render_revision = self.render_revision.wrapping_add(1).max(1); self.cached_fingerprint = 0; + self.cached_active_tools_revision.set(0); } fn cache_colors_hash(colors: &ThemeColors) -> u64 { @@ -1105,12 +1114,20 @@ impl Chat { } pub(crate) fn has_active_tool_messages(&self) -> bool { - self.messages.iter().rev().any(|message| { + if self.cached_active_tools_revision.get() == self.render_revision { + return self.cached_has_active_tools.get(); + } + + let has_active_tools = self.messages.iter().rev().any(|message| { message.role == MessageRole::Tool && parse_tool_message(&message.content) .map(|info| matches!(info.status.as_str(), "running" | "pending")) .unwrap_or(false) - }) + }); + + self.cached_has_active_tools.set(has_active_tools); + self.cached_active_tools_revision.set(self.render_revision); + has_active_tools } pub fn prepare_streaming_token_counter(&mut self, model: &str) { @@ -1930,6 +1947,7 @@ impl Chat { let (message_lines, message_positions) = self.build_all_lines_with_positions(max_width, model, colors); self.cached_lines = message_lines.into_iter().map(line_to_static).collect(); + self.message_line_positions = message_positions.clone(); self.cached_positions = message_positions; self.cached_revision = self.render_revision; self.cached_width = max_width; @@ -2037,7 +2055,6 @@ impl Chat { } self.content_height = content_height; - self.message_line_positions = positions.to_vec(); self.scroll_offset = clamped_scroll; self.update_scrollbar(); @@ -4109,6 +4126,31 @@ mod tests { assert_eq!(second_frame[0], "⬢ Webfetch https://example.com"); } + #[test] + fn test_active_tool_scan_cache_recomputes_after_render_dirty() { + let mut chat = Chat::new(); + let content = serde_json::json!({ + "name": "bash", + "status": "running", + "args": { "command": "printf hello" }, + }) + .to_string(); + + chat.add_message(Message::tool(content)); + assert!(chat.has_active_tool_messages()); + + chat.messages[0].content = serde_json::json!({ + "name": "bash", + "status": "ok", + "args": { "command": "printf hello" }, + "output_preview": "hello", + }) + .to_string(); + chat.mark_render_dirty(); + + assert!(!chat.has_active_tool_messages()); + } + #[test] fn test_bash_tool_renders_ran_command_preview() { let chat = Chat::new(); From 6cd4a72274a42814732e960fbd1f539a4be65ae9 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 29 May 2026 03:16:01 +0800 Subject: [PATCH 185/226] fix: stdin pipe for -p mode. --- src/main.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index c0cd4b1..b19cecf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,7 @@ use ratatui::crossterm::{ }, }; use ratatui::{backend::CrosstermBackend, style::Color, Terminal}; -use std::io; +use std::io::{self, IsTerminal, Read}; use std::sync::Mutex; use std::time::Duration; @@ -349,6 +349,36 @@ struct Args { prompt: Vec, } +fn merge_prompt_with_stdin(prompt: &str, stdin: &str) -> String { + if stdin.trim().is_empty() { + return prompt.to_string(); + } + + let mut merged = String::with_capacity(prompt.len() + stdin.len() + 24); + merged.push_str(prompt); + merged.push_str("\n\n\n"); + merged.push_str(stdin); + if !stdin.ends_with('\n') { + merged.push('\n'); + } + merged.push_str(""); + merged +} + +fn read_print_mode_prompt(prompt: &str) -> Result { + let mut stdin = io::stdin(); + if stdin.is_terminal() { + return Ok(prompt.to_string()); + } + + let mut stdin_content = Vec::new(); + stdin.read_to_end(&mut stdin_content)?; + Ok(merge_prompt_with_stdin( + prompt, + &String::from_utf8_lossy(&stdin_content), + )) +} + #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); @@ -361,6 +391,7 @@ async fn main() -> Result<()> { eprintln!("Usage: crabcode -p \"\""); std::process::exit(1); } + let prompt = read_print_mode_prompt(&prompt)?; return run_print_mode( &prompt, args.model.as_deref(), @@ -519,6 +550,22 @@ mod tests { ); assert_eq!(args.model, None); } + + #[test] + fn merge_prompt_with_stdin_ignores_empty_input() { + assert_eq!( + merge_prompt_with_stdin("Generate a commit message.", "\n \t\n"), + "Generate a commit message." + ); + } + + #[test] + fn merge_prompt_with_stdin_wraps_piped_input() { + assert_eq!( + merge_prompt_with_stdin("Examine the diff.", "diff --git a/a b/a\n+change"), + "Examine the diff.\n\n\ndiff --git a/a b/a\n+change\n" + ); + } } async fn run_event_loop( From ae847d47a038e75a50b17436894da1b417b48e9b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 30 May 2026 00:53:18 +0800 Subject: [PATCH 186/226] feat(app): restore undo attachments and update terminal title state. - Restore local image attachments when undoing a user message so pasted images remain selectable in input. - Add terminal-title status updates for workspace name, attention-required overlays, and in-progress streaming with spinner animation. - Add terminal title sanitization/truncation plus exit cleanup for terminal title restoration. - Add tests for terminal title behavior and undo image attachment restoration. --- _plans/__TODOS.md | 4 + src/app.rs | 180 +++++++++++++++++++++++++++++++++++-- src/main.rs | 2 + src/notify.rs | 99 +++++++++++++++++++- src/ui/components/input.rs | 41 +++++++++ 5 files changed, 320 insertions(+), 6 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 83454fd..3f41592 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -213,3 +213,7 @@ I want - [x] To do this But I dont want to do this - [x] Subagent UI view is not rendering the full table it seems like.. I always see this.. just the top. - `┌─────────────────────────┬────────────────────────────────────────────────────────────────────────────` - never the full table - Thouh I think the table does have content. I think it's just being weird. + +- [x] When I do "Undo" on a message that had an attachment / image. It goes back to my input, but it isn't highlighted anymore, meaning that image is probably not visible anymore right? Is there a way to persist that? + +- [x] Emit the same Loading stuff that codex does. So that Zed knows when the agent is "in progress". diff --git a/src/app.rs b/src/app.rs index f7d31f4..7903d8e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -204,6 +204,10 @@ enum SelectionAction { Dismiss, } +const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] = + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const TERMINAL_TITLE_SPINNER_INTERVAL_MS: u128 = 100; + #[derive(Debug)] struct ClientSessionState { chat: Chat, @@ -306,6 +310,9 @@ pub struct App { discovery: Option, cached_usage_text: String, cached_usage_check: (usize, u64), + terminal_title_enabled: bool, + terminal_title_last: Option, + terminal_title_animation_origin: std::time::Instant, } impl App { @@ -554,6 +561,9 @@ impl App { discovery, cached_usage_text: String::new(), cached_usage_check: (0, 0), + terminal_title_enabled: crate::notify::terminal_title_supported(), + terminal_title_last: None, + terminal_title_animation_origin: now, }) } @@ -606,6 +616,96 @@ impl App { } } + pub fn update_terminal_title_signal(&mut self) { + if !self.terminal_title_enabled { + return; + } + + let title = self.terminal_title_text(); + if self.terminal_title_last.as_deref() == Some(title.as_str()) { + return; + } + + if crate::notify::set_terminal_title(&title).is_ok() { + self.terminal_title_last = Some(title); + } + } + + pub fn clear_terminal_title_signal(&mut self) { + if self.terminal_title_last.take().is_some() { + let _ = crate::notify::clear_terminal_title(); + } + } + + fn terminal_title_text(&self) -> String { + let project = self.terminal_title_project_name(); + + if self.terminal_title_requires_action() { + return format!("[!] {}", project); + } + + if self.terminal_title_has_active_progress() { + return format!("{} {}", self.terminal_title_spinner_frame(), project); + } + + project + } + + fn terminal_title_project_name(&self) -> String { + let workspace = self.active_workspace_path(); + let name = std::path::Path::new(&workspace) + .file_name() + .and_then(|name| name.to_str()) + .map(str::trim) + .filter(|name| !name.is_empty() && *name != "." && *name != "..") + .unwrap_or("crabcode"); + + Self::truncate_terminal_title_part(name, 48) + } + + fn terminal_title_requires_action(&self) -> bool { + if matches!( + self.overlay_focus, + OverlayFocus::PermissionDialog | OverlayFocus::QuestionDialog + ) { + return true; + } + + self.session_manager + .get_current_session_id() + .and_then(|id| self.session_manager.get_session_ref(id)) + .is_some_and(|session| session.status == crate::session::types::SessionStatus::Waiting) + } + + fn terminal_title_has_active_progress(&self) -> bool { + self.compaction_receiver.is_some() + || self + .session_view_states + .values() + .any(|state| state.stream.is_some() || state.external_stream.is_some()) + } + + fn terminal_title_spinner_frame(&self) -> &'static str { + let frame_index = self.terminal_title_animation_origin.elapsed().as_millis() + / TERMINAL_TITLE_SPINNER_INTERVAL_MS; + TERMINAL_TITLE_SPINNER_FRAMES[frame_index as usize % TERMINAL_TITLE_SPINNER_FRAMES.len()] + } + + fn truncate_terminal_title_part(value: &str, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); + } + + let head = value.chars().take(max_chars).collect::(); + if value.chars().count() <= max_chars || max_chars <= 3 { + return head; + } + + let mut truncated = head.chars().take(max_chars - 3).collect::(); + truncated.push_str("..."); + truncated + } + fn completion_notification_stats(&self) -> Option { let message = self.chat_state.chat.messages.iter().rev().find(|msg| { msg.role == crate::session::types::MessageRole::Assistant && msg.is_complete @@ -4374,11 +4474,11 @@ impl App { return; } - let undone_content: Option = { + let undone_message: Option = { if let Some(session) = self.session_manager.get_current_session() { - let content = session.messages.get(idx).map(|m| m.content.clone()); + let message = session.messages.get(idx).cloned(); session.messages.truncate(idx); - content + message } else { return; } @@ -4396,8 +4496,14 @@ impl App { self.chat_state.chat.scroll_offset = usize::MAX; self.chat_state.chat.clear_highlighted_message(); - if let Some(content) = undone_content { - self.input.set_text(&content); + if let Some(message) = undone_message { + let image_paths = message + .local_image_paths + .iter() + .map(std::path::PathBuf::from) + .collect(); + self.input + .set_text_with_local_images(&message.content, image_paths); } push_toast(Toast::new( @@ -7035,6 +7141,9 @@ mod tests { discovery: None, cached_usage_text: String::new(), cached_usage_check: (0, 0), + terminal_title_enabled: false, + terminal_title_last: None, + terminal_title_animation_origin: std::time::Instant::now(), } } @@ -7045,6 +7154,44 @@ mod tests { .unwrap_or_default() } + #[test] + fn terminal_title_uses_workspace_leaf_when_idle() { + let mut app = test_app(); + app.cwd = "/tmp/sheetpilot".to_string(); + + assert_eq!(app.terminal_title_text(), "sheetpilot"); + } + + #[test] + fn terminal_title_prefixes_spinner_while_streaming() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("test".to_string())); + if let Some(session) = app.session_manager.sessions.get_mut(&session_id) { + session.workspace_path = "/tmp/sheetpilot".to_string(); + } + if let Some(state) = app.session_view_states.get_mut(&session_id) { + state.external_stream = Some(ExternalStreamState { + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 0, + }); + } + + let title = app.terminal_title_text(); + assert!(TERMINAL_TITLE_SPINNER_FRAMES + .iter() + .any(|frame| title == format!("{frame} sheetpilot"))); + } + + #[test] + fn terminal_title_marks_action_required() { + let mut app = test_app(); + app.cwd = "/tmp/sheetpilot".to_string(); + app.overlay_focus = OverlayFocus::PermissionDialog; + + assert_eq!(app.terminal_title_text(), "[!] sheetpilot"); + } + fn add_current_session_message(app: &mut App, message: crate::session::types::Message) { app.chat_state.chat.add_message(message.clone()); app.session_manager @@ -7387,6 +7534,29 @@ mod tests { assert!(message_action_names(&app).contains(&"Undo".to_string())); } + #[test] + fn undo_user_message_restores_local_image_attachments_to_input() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + let mut user_message = crate::session::types::Message::user("see [Image #1]"); + user_message.local_image_paths = vec!["/tmp/example.png".to_string()]; + add_current_session_message(&mut app, user_message); + add_current_session_message( + &mut app, + crate::session::types::Message::assistant("Answer"), + ); + app.message_actions_index = Some(0); + + app.execute_message_action("undo"); + + assert_eq!(app.input.get_text(), "see [Image #1]"); + assert_eq!( + app.input.local_image_paths_for_submission(), + vec![std::path::PathBuf::from("/tmp/example.png")] + ); + assert!(app.chat_state.chat.messages.is_empty()); + } + #[test] fn message_actions_omit_undo_for_agent_messages() { let mut app = test_app(); diff --git a/src/main.rs b/src/main.rs index b19cecf..955cddc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -458,6 +458,7 @@ async fn main() -> Result<()> { }; let post_close_colors = app.get_current_theme_colors(); + app.clear_terminal_title_signal(); disable_raw_mode()?; if supports_keyboard_enhancement().unwrap_or(false) { @@ -586,6 +587,7 @@ async fn run_event_loop( app.process_streaming_chunks(); app.update_animations(); + app.update_terminal_title_signal(); remove_expired_toasts(); if needs_redraw || animation_needed { terminal.draw(|f| app.render(f))?; diff --git a/src/notify.rs b/src/notify.rs index d526286..78ec0b0 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -1,6 +1,8 @@ -use std::io::{self, Write}; +use std::io::{self, IsTerminal, Write}; use std::process::{Command, Stdio}; +const MAX_TERMINAL_TITLE_CHARS: usize = 240; + pub fn is_supported() -> bool { #[cfg(target_os = "macos")] { @@ -87,6 +89,80 @@ pub fn terminal_bell_supported() -> bool { env_eq("ZED_TERM", "true") || env_eq("TERM_PROGRAM", "zed") } +pub fn terminal_title_supported() -> bool { + terminal_bell_supported() +} + +pub fn set_terminal_title(title: &str) -> io::Result<()> { + if !io::stdout().is_terminal() { + return Ok(()); + } + + write_terminal_title(&sanitize_terminal_title(title)) +} + +pub fn clear_terminal_title() -> io::Result<()> { + if !io::stdout().is_terminal() { + return Ok(()); + } + + write_terminal_title("") +} + +fn write_terminal_title(title: &str) -> io::Result<()> { + let mut stdout = io::stdout(); + write!(stdout, "\x1b]0;{}\x07", title)?; + stdout.flush() +} + +fn sanitize_terminal_title(title: &str) -> String { + let mut sanitized = String::new(); + let mut chars_written = 0; + let mut pending_space = false; + + for ch in title.chars() { + if ch.is_whitespace() { + pending_space = !sanitized.is_empty(); + continue; + } + + if is_disallowed_terminal_title_char(ch) { + continue; + } + + if pending_space { + let remaining = MAX_TERMINAL_TITLE_CHARS.saturating_sub(chars_written); + if remaining > 1 { + sanitized.push(' '); + chars_written += 1; + } + pending_space = false; + } + + if chars_written >= MAX_TERMINAL_TITLE_CHARS { + break; + } + + sanitized.push(ch); + chars_written += 1; + } + + sanitized +} + +fn is_disallowed_terminal_title_char(ch: char) -> bool { + matches!( + ch, + '\u{0000}'..='\u{001F}' + | '\u{007F}'..='\u{009F}' + | '\u{061C}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' + | '\u{FEFF}' + ) +} + fn env_eq(key: &str, expected: &str) -> bool { std::env::var(key) .map(|value| value.eq_ignore_ascii_case(expected)) @@ -202,3 +278,24 @@ fn escape_xml(value: &str) -> String { .replace('"', """) .replace('\'', "'") } + +#[cfg(test)] +mod tests { + use super::sanitize_terminal_title; + use super::MAX_TERMINAL_TITLE_CHARS; + + #[test] + fn terminal_title_sanitizer_strips_controls_and_collapses_space() { + let sanitized = sanitize_terminal_title(" crab\tcode\n\x1b\x07\u{202E} running "); + + assert_eq!(sanitized, "crab code running"); + } + + #[test] + fn terminal_title_sanitizer_truncates_long_titles() { + let title = "x".repeat(MAX_TERMINAL_TITLE_CHARS + 10); + let sanitized = sanitize_terminal_title(&title); + + assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS); + } +} diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 7504f36..74c5c95 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -1690,6 +1690,33 @@ impl Input { self.hovered_image_placeholder = None; } + pub fn set_text_with_local_images(&mut self, text: &str, image_paths: Vec) { + let mut text = text.to_string(); + let local_images = image_paths + .into_iter() + .enumerate() + .map(|(idx, path)| { + let placeholder = Self::image_placeholder(idx + 1); + if !text.contains(&placeholder) { + if !text.is_empty() && !text.chars().last().is_some_and(char::is_whitespace) { + text.push(' '); + } + text.push_str(&placeholder); + } + LocalImageAttachment { placeholder, path } + }) + .collect::>(); + + self.reset_textarea(); + self.textarea.insert_str(&text); + self.viewport_top = 0; + self.preferred_visual_col = None; + self.local_images = local_images; + self.pending_pastes.clear(); + self.hovered_image_placeholder = None; + self.sync_image_placeholders(); + } + pub fn insert_char(&mut self, c: char) { self.preferred_visual_col = None; self.textarea.insert_str(c.to_string().as_str()); @@ -1952,6 +1979,20 @@ mod tests { assert_eq!(input.local_image_paths_for_submission(), vec![path]); } + #[test] + fn test_set_text_with_local_images_restores_attachment_state() { + let mut input = Input::new(); + let paths = vec![ + PathBuf::from("/tmp/example-1.png"), + PathBuf::from("/tmp/example-2.png"), + ]; + + input.set_text_with_local_images("see [Image #1] and [Image #2]", paths.clone()); + + assert_eq!(input.get_text(), "see [Image #1] and [Image #2]"); + assert_eq!(input.local_image_paths_for_submission(), paths); + } + #[test] fn test_backspace_removes_image_placeholder() { let mut input = Input::new(); From 42a664f677d254fc03d05c823a64d0ac8b4e3bba Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 30 May 2026 01:25:12 +0800 Subject: [PATCH 187/226] feat(benchmark): extract bench-agents into modular benchmarking package. Move the previous monolithic agent benchmark script into a new `benchmarking/` module with typed core pieces for agents, CLI parsing, task registry, checks, formatting, workspace/report utilities, and static fixture serving. Add dedicated benchmark tasks for JS, Rust, site-fetch, and TypeScript scenarios plus a contributor README with usage and task authoring guidance. Keep `scripts/bench-agents.ts` as a compatibility entrypoint that delegates to the new runner. --- benchmarking/README.md | 66 ++ benchmarking/bench-agents.ts | 494 +++++++++ benchmarking/src/agents.ts | 69 ++ benchmarking/src/checks.ts | 66 ++ benchmarking/src/cli.ts | 145 +++ benchmarking/src/defaults.ts | 12 + benchmarking/src/format.ts | 41 + benchmarking/src/report.ts | 131 +++ benchmarking/src/static-server.ts | 99 ++ benchmarking/src/tasks/basic.ts | 61 ++ benchmarking/src/tasks/define.ts | 6 + benchmarking/src/tasks/index.ts | 7 + benchmarking/src/tasks/rust.ts | 48 + benchmarking/src/tasks/site.ts | 54 + benchmarking/src/tasks/typescript.ts | 231 +++++ benchmarking/src/types.ts | 47 + benchmarking/src/workspace.ts | 53 + scripts/bench-agents.ts | 1376 +------------------------- 18 files changed, 1631 insertions(+), 1375 deletions(-) create mode 100644 benchmarking/README.md create mode 100644 benchmarking/bench-agents.ts create mode 100644 benchmarking/src/agents.ts create mode 100644 benchmarking/src/checks.ts create mode 100644 benchmarking/src/cli.ts create mode 100644 benchmarking/src/defaults.ts create mode 100644 benchmarking/src/format.ts create mode 100644 benchmarking/src/report.ts create mode 100644 benchmarking/src/static-server.ts create mode 100644 benchmarking/src/tasks/basic.ts create mode 100644 benchmarking/src/tasks/define.ts create mode 100644 benchmarking/src/tasks/index.ts create mode 100644 benchmarking/src/tasks/rust.ts create mode 100644 benchmarking/src/tasks/site.ts create mode 100644 benchmarking/src/tasks/typescript.ts create mode 100644 benchmarking/src/types.ts create mode 100644 benchmarking/src/workspace.ts diff --git a/benchmarking/README.md b/benchmarking/README.md new file mode 100644 index 0000000..d17d6e9 --- /dev/null +++ b/benchmarking/README.md @@ -0,0 +1,66 @@ +# Benchmarking + +The agent benchmark suite compares `crabcode`, `opencode`, and `codex` on small deterministic coding tasks. + +The developer UX stays anchored on the existing recipe: + +```sh +just bench-agents +``` + +Useful filters: + +```sh +just bench-agents --list-tasks +just bench-agents --tasks workflow-planner-ts +just bench-agents --tags typescript,hidden-tests +just bench-agents --difficulty hard +just bench-agents --estimate --agents crabcode,codex +``` + +## Layout + +```text +benchmarking/ + bench-agents.ts CLI entrypoint and run orchestration + src/ + agents.ts Agent command templates and prompt wrapping + checks.ts Reusable deterministic check helpers + cli.ts Args, env overrides, task filtering, help text + defaults.ts Default model, prices, paths, agents + format.ts Formatting, shell quoting, rough token/cost estimates + report.ts Markdown summary report writer + static-server.ts Per-run localhost fixture server + workspace.ts Fixtures, run directories, logs, cleanup + tasks/ Benchmark task registry +``` + +## Adding A Benchmark + +Add a task file under `benchmarking/src/tasks/` or extend an existing topic file, then export it from `benchmarking/src/tasks/index.ts`. + +Tasks should be self-contained: fixture files, the exact user prompt, and deterministic checks all live with the task definition. + +```ts +import { defineTask } from './define.ts' + +export const myTasks = [ + defineTask({ + id: 'hard-refactor-example', + title: 'Refactor a small module without changing behavior', + difficulty: 'hard', + tags: ['typescript', 'refactor'], + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/example.ts': `export function example() { return 1 }\n`, + }, + prompt: `Refactor src/example.ts and keep behavior unchanged. Do not add dependencies.`, + check: (cwd) => [ + // Prefer reusable helpers from ../checks.ts when possible. + ], + }), +] +``` + +Keep prompts direct and checks deterministic. For harder tasks, prefer visible tests plus hidden tests injected by `bunTestWithHiddenFileCheck`. + diff --git a/benchmarking/bench-agents.ts b/benchmarking/bench-agents.ts new file mode 100644 index 0000000..ba5f907 --- /dev/null +++ b/benchmarking/bench-agents.ts @@ -0,0 +1,494 @@ +// Make-shift benchmark for comparing crabcode, opencode, and codex on tiny agent tasks. +// Run via: `just bench-agents` + +// @ts-nocheck + +import { spawn } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { benchmarkPrompt, commandFor, displayAgent, modelForAgent, resolveTaskPrompt } from './src/agents.ts' +import { parseAgents, parseArgs, printHelp, printTaskList, selectTasks } from './src/cli.ts' +import { + DEFAULT_AGENTS, + DEFAULT_INPUT_USD_PER_MTOK, + DEFAULT_MODEL, + DEFAULT_OUTPUT_USD_PER_MTOK, + DEFAULT_REPORT_DIR, + DEFAULT_RUNS, + DEFAULT_TIMEOUT_MS, +} from './src/defaults.ts' +import { estimateCost, estimateTokens, formatDuration, formatUsd, tailText } from './src/format.ts' +import { writeMarkdownReport, summaryRows } from './src/report.ts' +import { runChecks } from './src/checks.ts' +import { startStaticServer } from './src/static-server.ts' +import { TASKS } from './src/tasks/index.ts' +import { + cleanupWorkspace, + cleanupWorkspaceChildren, + createRunRoot, + timestampForPath, + writeFixture, + writeRunArtifacts, +} from './src/workspace.ts' +import type { AgentName, BenchmarkTask, RunResult } from './src/types.ts' + +const activeChildren = new Set() +const activeWorkspaces = new Set() +let activeRunRoot: string | null = null +let shutdownRequested = false + +process.once('SIGINT', () => requestShutdown('SIGINT')) +process.once('SIGTERM', () => requestShutdown('SIGTERM')) + +const args = parseArgs(process.argv.slice(2)) + +if (args.help) { + printHelp(TASKS) + process.exit(0) +} + +if (args['list-tasks']) { + printTaskList(TASKS) + process.exit(0) +} + +const agents = parseAgents(String(args.agents ?? process.env.BENCH_AGENTS ?? DEFAULT_AGENTS.join(','))) +const selectedTasks = selectTasks( + TASKS, + args.tasks ?? process.env.BENCH_TASKS, + args.tags ?? process.env.BENCH_TAGS, + args.difficulty ?? process.env.BENCH_DIFFICULTY, +) +const model = String(args.model ?? process.env.BENCH_MODEL ?? DEFAULT_MODEL) +const timeoutMs = Number(args['timeout-ms'] ?? process.env.BENCH_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS) +const runs = Number(args.runs ?? process.env.BENCH_RUNS ?? DEFAULT_RUNS) +const keep = Boolean(args.keep) +const estimateOnly = Boolean(args.estimate) +const inputPrice = Number(args['input-price'] ?? process.env.BENCH_INPUT_USD_PER_MTOK ?? DEFAULT_INPUT_USD_PER_MTOK) +const outputPrice = Number(args['output-price'] ?? process.env.BENCH_OUTPUT_USD_PER_MTOK ?? DEFAULT_OUTPUT_USD_PER_MTOK) +const outputPath = args.out ? resolve(String(args.out)) : null +const runId = timestampForPath() +const runRoot = createRunRoot(args.dir ?? process.env.BENCH_DIR, runId) +const workspacesRoot = join(runRoot, 'workspaces') +const logsRoot = join(runRoot, 'logs') +mkdirSync(workspacesRoot, { recursive: true }) +mkdirSync(logsRoot, { recursive: true }) +const reportPath = args['no-report'] + ? null + : args.report && args.report !== true + ? resolve(String(args.report)) + : join(resolve(String(args['report-dir'] ?? process.env.BENCH_REPORT_DIR ?? DEFAULT_REPORT_DIR)), `agent-benchmark-${runId}.md`) + +if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('--timeout-ms must be a positive number') +} + +if (!Number.isFinite(runs) || runs <= 0) { + throw new Error('--runs must be a positive number') +} + +const plannedPrompts = selectedTasks.length * agents.length * runs +const estimatedInputTokens = selectedTasks.reduce((sum, task) => sum + estimateTokens(benchmarkPrompt(task.prompt)), 0) * agents.length * runs +const plannedCost = estimateCost(estimatedInputTokens, 0, inputPrice, outputPrice) + +printIntro() + +if (estimateOnly) { + process.exit(0) +} + +activeRunRoot = runRoot +printPaths() + +const results: RunResult[] = [] +writeCurrentMarkdownReport() + +try { + let runNumber = 0 + + runLoop: for (let runIndex = 0; runIndex < runs; runIndex++) { + for (const task of selectedTasks) { + for (const agent of agents) { + if (shutdownRequested) break runLoop + runNumber += 1 + const result = await safeRunBenchmark(agent, task, runIndex, runNumber, plannedPrompts) + results.push(result) + writeCurrentMarkdownReport() + printResult(result) + } + } + } + + printSummary(results) + + if (reportPath) { + writeCurrentMarkdownReport() + console.log(`\nWrote Markdown report to ${reportPath}`) + } + + if (shutdownRequested) { + process.exitCode = 130 + } + + if (outputPath) { + writeFileSync( + outputPath, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + model, + agents, + tasks: selectedTasks.map((task) => task.id), + runs, + runId, + runRoot, + workspacesRoot, + logsRoot, + markdownReport: reportPath, + agentModels: Object.fromEntries(agents.map((agent) => [agent, modelForAgent(agent, model)])), + pricing: { + inputUsdPerMillionTokens: inputPrice, + outputUsdPerMillionTokens: outputPrice, + }, + results, + }, + null, + 2, + ) + '\n', + ) + console.log(`\nWrote JSON results to ${outputPath}`) + } +} finally { + writeCurrentMarkdownReport() + if (keep) { + console.log(`\nKept benchmark workspaces in ${workspacesRoot}`) + } else { + cleanupWorkspaceChildren(workspacesRoot) + } + activeRunRoot = null +} + +function writeCurrentMarkdownReport() { + if (!reportPath || estimateOnly) return + + writeMarkdownReport(reportPath, { + runId, + runRoot, + workspacesRoot, + logsRoot, + model, + agents, + tasks: selectedTasks, + runs, + plannedPrompts, + timeoutMs, + keep, + inputPrice, + outputPrice, + results, + stopped: shutdownRequested, + }) +} + +async function safeRunBenchmark( + agent: AgentName, + task: BenchmarkTask, + runIndex: number, + runNumber: number, + totalRuns: number, +): Promise { + try { + return await runBenchmark(agent, task, runIndex, runNumber, totalRuns) + } catch (err) { + return { + agent, + task: task.id, + ok: false, + passedChecks: 0, + totalChecks: 0, + elapsedMs: 0, + estimatedInputTokens: 0, + estimatedOutputTokens: 0, + estimatedCostUsd: 0, + exitCode: null, + timedOut: false, + error: `benchmark runner crashed: ${err instanceof Error ? err.message : String(err)}`, + } + } +} + +async function runBenchmark( + agent: AgentName, + task: BenchmarkTask, + runIndex: number, + runNumber: number, + totalRuns: number, +): Promise { + const runLabel = `${String(runIndex + 1).padStart(2, '0')}-${agent}-${task.id}` + const workspace = join(workspacesRoot, runLabel) + const runTimeoutMs = Number(task.timeoutMs ?? timeoutMs) + mkdirSync(workspace, { recursive: true }) + activeWorkspaces.add(workspace) + let staticServer: Awaited> | null = null + + try { + writeFixture(workspace, task) + + if (model) { + writeFileSync(join(workspace, 'crabcode.jsonc'), JSON.stringify({ model }, null, 2) + '\n') + } + + printRunStart(runNumber, totalRuns, agent, task.id, workspace) + + if (task.site) { + try { + staticServer = await startStaticServer(join(workspace, task.site.root)) + } catch (err) { + const checks = runChecks(task, workspace) + const passedChecks = checks.filter((check) => check.pass).length + return { + agent, + task: task.id, + ok: false, + passedChecks, + totalChecks: checks.length, + elapsedMs: 0, + estimatedInputTokens: 0, + estimatedOutputTokens: 0, + estimatedCostUsd: 0, + exitCode: null, + timedOut: false, + error: `failed to start local static server: ${err instanceof Error ? err.message : String(err)}`, + workspace, + } + } + } + + const prompt = benchmarkPrompt(resolveTaskPrompt(task, staticServer?.url)) + const command = commandFor(agent, prompt, model) + const started = performance.now() + const proc = await runShell(command, workspace, runTimeoutMs) + const elapsedMs = Math.round(performance.now() - started) + const checks = runChecks(task, workspace) + const passedChecks = checks.filter((check) => check.pass).length + const output = `${proc.stdout}\n${proc.stderr}`.trim() + const artifacts = writeRunArtifacts(logsRoot, runLabel, command, proc.stdout, proc.stderr) + const estimatedInputTokens = estimateTokens(prompt) + const estimatedOutputTokens = estimateTokens(output) + const ok = !shutdownRequested && !proc.timedOut && proc.exitCode === 0 && passedChecks === checks.length + const errors = [ + proc.timedOut ? `timed out after ${runTimeoutMs}ms` : '', + proc.exitCode !== 0 && proc.exitCode !== null ? `exit code ${proc.exitCode}` : '', + ...checks + .filter((check) => !check.pass) + .map((check) => `${check.name}${check.detail ? `: ${check.detail}` : ''}`), + proc.error ?? '', + ].filter(Boolean) + + return { + agent, + task: task.id, + ok, + passedChecks, + totalChecks: checks.length, + elapsedMs, + estimatedInputTokens, + estimatedOutputTokens, + estimatedCostUsd: estimateCost(estimatedInputTokens, estimatedOutputTokens, inputPrice, outputPrice), + exitCode: proc.exitCode, + timedOut: proc.timedOut, + error: errors.join('; ') || undefined, + workspace, + stdoutPath: artifacts.stdoutPath, + stderrPath: artifacts.stderrPath, + commandPath: artifacts.commandPath, + stdoutTail: tailText(proc.stdout), + stderrTail: tailText(proc.stderr), + } + } finally { + await staticServer?.close() + activeWorkspaces.delete(workspace) + } +} + +function runShell(command: string, cwd: string, timeoutMs: number) { + return new Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; error?: string }>( + (resolveRun) => { + const child = spawn(command, { + cwd, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + env: { + ...process.env, + NO_COLOR: '1', + CI: '1', + }, + }) + + let stdout = '' + let stderr = '' + let timedOut = false + let settled = false + activeChildren.add(child) + + const timer = setTimeout(() => { + timedOut = true + terminateChild(child, 'SIGTERM') + setTimeout(() => terminateChild(child, 'SIGKILL'), 2_000).unref() + }, timeoutMs) + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.on('error', (err) => { + if (settled) return + settled = true + activeChildren.delete(child) + clearTimeout(timer) + resolveRun({ stdout, stderr, exitCode: null, timedOut, error: err.message }) + }) + child.on('close', (code) => { + if (settled) return + settled = true + activeChildren.delete(child) + clearTimeout(timer) + resolveRun({ stdout, stderr, exitCode: code, timedOut }) + }) + }, + ) +} + +function requestShutdown(signal: string) { + if (shutdownRequested) { + writeCurrentMarkdownReport() + cleanupActiveWorkspaces() + process.exit(signal === 'SIGINT' ? 130 : 143) + } + + shutdownRequested = true + console.error(`\nReceived ${signal}; stopping active agent processes...`) + writeCurrentMarkdownReport() + + for (const child of activeChildren) { + terminateChild(child, 'SIGTERM') + } + + setTimeout(() => { + for (const child of activeChildren) { + terminateChild(child, 'SIGKILL') + } + writeCurrentMarkdownReport() + cleanupActiveWorkspaces() + process.exit(signal === 'SIGINT' ? 130 : 143) + }, 2_500).unref() +} + +function terminateChild(child: any, signal: NodeJS.Signals) { + if (!child?.pid) return + + try { + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }) + return + } + + process.kill(-child.pid, signal) + } catch { + try { + child.kill(signal) + } catch {} + } +} + +function cleanupActiveWorkspaces() { + if (keep) return + for (const workspace of activeWorkspaces) { + cleanupWorkspace(workspace) + } + activeWorkspaces.clear() + if (activeRunRoot) { + cleanupWorkspace(activeRunRoot) + } +} + +function printIntro() { + console.log('Agent benchmark') + console.log('') + console.log('Config') + console.log(` model: ${model}`) + console.log(` agents: ${agents.map(displayAgent).join(', ')}`) + console.log(` tasks: ${selectedTasks.map((task) => task.id).join(', ')}`) + console.log(` runs: ${runs}`) + console.log(` prompts: ${plannedPrompts}`) + console.log(` timeout: ${formatDuration(timeoutMs)}`) + console.log(` prompt cost: ${formatUsd(plannedCost)} estimated`) + console.log('') + console.log('Agent model args') + for (const agent of agents) { + console.log(` ${displayAgent(agent).padEnd(12)} ${modelForAgent(agent, model)}`) + } + console.log('') +} + +function printPaths() { + console.log('Paths') + console.log(` run: ${runRoot}`) + console.log(` workspaces: ${workspacesRoot}`) + console.log(` logs: ${logsRoot}`) + if (reportPath) { + console.log(` report: ${reportPath}`) + } + console.log('') + console.log('Notes') + console.log(' Permission-gated actions are auto-approved for opencode and codex in isolated workspaces.') + console.log(' Crabcode print mode is run with --dangerously-skip-permissions in isolated workspaces.') + console.log(' Site-fetch tasks use a per-run 127.0.0.1 static server; they do not hit the public internet.') + if (!keep) { + console.log(' Workspaces are removed at exit. Pass --keep to preserve them.') + } + console.log('') +} + +function printRunStart(runNumber: number, totalRuns: number, agent: AgentName, taskId: string, workspace: string) { + console.log(`Run ${runNumber}/${totalRuns}: ${displayAgent(agent)} / ${taskId}`) + console.log(` workspace: ${workspace}`) +} + +function printResult(result: RunResult) { + const status = result.ok ? 'PASS' : 'FAIL' + const checks = `${result.passedChecks}/${result.totalChecks}` + console.log(` result: ${status}`) + console.log(` checks: ${checks}`) + console.log(` time: ${formatDuration(result.elapsedMs)}`) + console.log(` cost: ${formatUsd(result.estimatedCostUsd)} estimated`) + if (result.error) { + console.log(' reason:') + for (const line of result.error.split('; ')) { + console.log(` - ${line}`) + } + } + if (result.stdoutPath || result.stderrPath) { + console.log(' output:') + if (result.stdoutPath) console.log(` stdout: ${result.stdoutPath}`) + if (result.stderrPath) console.log(` stderr: ${result.stderrPath}`) + } + console.log('') +} + +function printSummary(results: RunResult[]) { + console.log('\nSummary') + console.log('| Agent | Score | Checks | Avg time | Est. tokens | Est. cost |') + console.log('|---|---:|---:|---:|---:|---:|') + + for (const row of summaryRows(results, agents)) { + console.log(`| ${displayAgent(row.agent)} | ${row.score} | ${row.checks} | ${row.avgTime} | ${row.tokens} | ${row.cost} |`) + } + + console.log('\nMetric: Score is the percent of task runs where the command exited successfully and every deterministic check passed.') + console.log('Cost is an estimate from prompt/output text tokens only; provider dashboards are the source of truth.') +} + diff --git a/benchmarking/src/agents.ts b/benchmarking/src/agents.ts new file mode 100644 index 0000000..7d01594 --- /dev/null +++ b/benchmarking/src/agents.ts @@ -0,0 +1,69 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { DEFAULT_AGENTS, REPO_ROOT } from './defaults.ts' +import { shellQuote } from './format.ts' +import type { AgentName, BenchmarkTask } from './types.ts' + +export const AGENT_LABELS: Record = { + crabcode: '🦀 crabcode', + opencode: '🔲 opencode', + codex: '⚛️ codex', +} + +export function displayAgent(agent: AgentName) { + return AGENT_LABELS[agent] ?? agent +} + +export function commandFor(agent: AgentName, prompt: string, model: string) { + const defaults: Record = { + crabcode: defaultCrabcodeCommand(), + opencode: 'opencode run --dangerously-skip-permissions -m {model} {prompt}', + codex: 'codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}', + } + const envName = `BENCH_${agent.toUpperCase()}_CMD` + const template = process.env[envName] || defaults[agent] + const agentModel = modelForAgent(agent, model) + return template + .replaceAll('{repo}', shellQuote(REPO_ROOT)) + .replaceAll('{model}', shellQuote(agentModel)) + .replaceAll('{prompt}', shellQuote(prompt)) +} + +export function benchmarkPrompt(prompt: string) { + return [ + 'You are running inside an isolated benchmark fixture.', + 'Modify files in the current working directory directly. Do not only describe the change.', + 'Keep the change minimal. When the task is complete, stop.', + 'If the task names exact file paths, inspect those paths directly instead of listing directories first.', + 'Do not repeat identical tool calls or run optional extra checks after the requested change is complete.', + '', + `Task: ${prompt}`, + ].join('\n') +} + +export function resolveTaskPrompt(task: BenchmarkTask, siteUrl?: string) { + return task.prompt.replaceAll('{siteUrl}', siteUrl ?? '') +} + +export function modelForAgent(agent: AgentName, modelRef: string) { + if (agent === 'codex') { + return modelRef.replace(/^openai\//, '') + } + + return modelRef +} + +function defaultCrabcodeCommand() { + const binary = join(REPO_ROOT, 'target', 'debug', 'crabcode') + if (existsSync(binary)) { + return `${shellQuote(binary)} -p --no-session-persistence --dangerously-skip-permissions {prompt}` + } + return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- -p --no-session-persistence --dangerously-skip-permissions {prompt}` +} + +export function assertAgentName(value: string): asserts value is AgentName { + if (!DEFAULT_AGENTS.includes(value as AgentName)) { + throw new Error(`Unknown agent: ${value}. Expected one of ${DEFAULT_AGENTS.join(', ')}`) + } +} + diff --git a/benchmarking/src/checks.ts b/benchmarking/src/checks.ts new file mode 100644 index 0000000..b0c970b --- /dev/null +++ b/benchmarking/src/checks.ts @@ -0,0 +1,66 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { tailText } from './format.ts' +import type { BenchmarkTask, CheckResult } from './types.ts' + +export function runChecks(task: BenchmarkTask, workspace: string): CheckResult[] { + try { + return task.check(workspace) + } catch (err) { + return [ + { + name: 'checks completed', + pass: false, + detail: err instanceof Error ? err.message : String(err), + }, + ] + } +} + +export function bunTestCheck(cwd: string): CheckResult { + const result = runCheckCommand(cwd, process.execPath, ['test']) + return { + name: 'bun test passes', + pass: result.ok, + detail: result.detail, + } +} + +export function bunTestWithHiddenFileCheck(cwd: string, name: string, path: string, content: string): CheckResult { + const fullPath = join(cwd, path) + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content) + + const result = runCheckCommand(cwd, process.execPath, ['test']) + return { + name, + pass: result.ok, + detail: result.detail, + } +} + +export function runCheckCommand(cwd: string, command: string, args: string[]) { + const proc = spawnSync(command, args, { + cwd, + encoding: 'utf8', + timeout: 15_000, + env: { + ...process.env, + NO_COLOR: '1', + CI: '1', + }, + }) + const output = `${proc.stdout ?? ''}\n${proc.stderr ?? ''}`.trim() + const detail = proc.error + ? proc.error.message + : proc.status === 0 + ? undefined + : tailText(output, 600) || `exit code ${proc.status}` + + return { + ok: proc.status === 0, + detail, + } +} + diff --git a/benchmarking/src/cli.ts b/benchmarking/src/cli.ts new file mode 100644 index 0000000..dc8aa82 --- /dev/null +++ b/benchmarking/src/cli.ts @@ -0,0 +1,145 @@ +import { + DEFAULT_AGENTS, + DEFAULT_BENCHMARK_DIR, + DEFAULT_INPUT_USD_PER_MTOK, + DEFAULT_MODEL, + DEFAULT_OUTPUT_USD_PER_MTOK, + DEFAULT_REPORT_DIR, + DEFAULT_RUNS, + DEFAULT_TIMEOUT_MS, +} from './defaults.ts' +import { assertAgentName } from './agents.ts' +import type { AgentName, BenchmarkTask, ParsedArgs } from './types.ts' + +export function parseArgs(raw: string[]): ParsedArgs { + const parsed: ParsedArgs = {} + for (let i = 0; i < raw.length; i++) { + const arg = raw[i] + if (!arg.startsWith('--')) continue + const body = arg.slice(2) + const [key, inlineValue] = body.split('=', 2) + if (inlineValue !== undefined) { + parsed[key] = inlineValue + continue + } + const next = raw[i + 1] + if (next && !next.startsWith('--')) { + parsed[key] = next + i++ + } else { + parsed[key] = true + } + } + return parsed +} + +export function parseAgents(value: string): AgentName[] { + const agents = value + .split(',') + .map((agent) => agent.trim()) + .filter(Boolean) + + for (const agent of agents) { + assertAgentName(agent) + } + + return agents as AgentName[] +} + +export function selectTasks(tasks: BenchmarkTask[], value?: string | boolean, tags?: string | boolean, difficulty?: string | boolean): BenchmarkTask[] { + let selected = parseTaskIds(tasks, value) + + if (tags && tags !== true) { + const requiredTags = splitCsv(String(tags)) + selected = selected.filter((task) => requiredTags.every((tag) => task.tags?.includes(tag))) + } + + if (difficulty && difficulty !== true) { + selected = selected.filter((task) => task.difficulty === difficulty) + } + + if (!selected.length) { + throw new Error('No benchmark tasks matched the requested filters') + } + + return selected +} + +export function printTaskList(tasks: BenchmarkTask[]) { + console.log('Benchmark tasks') + for (const task of tasks) { + const difficulty = task.difficulty ?? 'medium' + const tags = task.tags?.length ? ` [${task.tags.join(', ')}]` : '' + console.log(` ${task.id.padEnd(24)} ${difficulty.padEnd(6)} ${task.title}${tags}`) + } +} + +export function printHelp(tasks: BenchmarkTask[]) { + console.log(`Usage: bun run scripts/bench-agents.ts [options] + +Options: + --model provider/model Model passed to each agent. + --agents crabcode,opencode,codex Agents to run. + --tasks id-a,id-b Task IDs to run. + --tags typescript,hidden-tests Run tasks containing every listed tag. + --difficulty hard Run tasks by difficulty: smoke, medium, hard. + --list-tasks Print available tasks and exit. + --runs 1 Repetitions per agent/task. + --timeout-ms 45000 Timeout per run. + --estimate Print planned prompt count and prompt-only cost, then exit. + --input-price 1.25 Input USD per 1M tokens for rough cost estimates. + --output-price 10 Output USD per 1M tokens for rough cost estimates. + --out bench-results.json Write machine-readable JSON results. + --report benchmark.md Write Markdown report at an exact path. + --report-dir benchmark-reports Directory for default Markdown reports. + --no-report Disable Markdown report generation. + --dir .benchmarks Parent directory for benchmark runs. + --keep Keep temporary workspaces for inspection. + +Default params: + model: ${DEFAULT_MODEL} + agents: ${DEFAULT_AGENTS.join(',')} + tasks: ${tasks.map((task) => task.id).join(',')} + runs: ${DEFAULT_RUNS} + timeout-ms: ${DEFAULT_TIMEOUT_MS} + input-price: ${DEFAULT_INPUT_USD_PER_MTOK} + output-price: ${DEFAULT_OUTPUT_USD_PER_MTOK} + dir: ${DEFAULT_BENCHMARK_DIR} + report-dir: ${DEFAULT_REPORT_DIR} + +Environment overrides: + BENCH_MODEL, BENCH_AGENTS, BENCH_TASKS, BENCH_TAGS, BENCH_DIFFICULTY, + BENCH_RUNS, BENCH_TIMEOUT_MS, BENCH_INPUT_USD_PER_MTOK, + BENCH_OUTPUT_USD_PER_MTOK, BENCH_DIR, BENCH_REPORT_DIR + +Stop behavior: + Ctrl+C stops the active agent process tree and removes temporary workspaces unless --keep is set. + +Command overrides: + BENCH_CRABCODE_CMD='crabcode -p --no-session-persistence --dangerously-skip-permissions {prompt}' + BENCH_OPENCODE_CMD='opencode run --dangerously-skip-permissions -m {model} {prompt}' + BENCH_CODEX_CMD='codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}' + +Template tokens: {prompt}, {model}, {repo} +Note: {model} is agent-aware; codex strips a leading openai/ provider prefix. +`) +} + +function parseTaskIds(tasks: BenchmarkTask[], value?: string | boolean): BenchmarkTask[] { + if (!value || value === true) return tasks + const ids = splitCsv(String(value)) + return ids.map((id) => { + const task = tasks.find((candidate) => candidate.id === id) + if (!task) { + throw new Error(`Unknown task: ${id}. Expected one of ${tasks.map((task) => task.id).join(', ')}`) + } + return task + }) +} + +function splitCsv(value: string) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} diff --git a/benchmarking/src/defaults.ts b/benchmarking/src/defaults.ts new file mode 100644 index 0000000..c4f43d7 --- /dev/null +++ b/benchmarking/src/defaults.ts @@ -0,0 +1,12 @@ +import { join, resolve } from 'node:path' +import type { AgentName } from './types.ts' + +export const REPO_ROOT = resolve(import.meta.dir, '..', '..') +export const DEFAULT_MODEL = 'openai/gpt-5.3-codex' +export const DEFAULT_TIMEOUT_MS = 45_000 +export const DEFAULT_RUNS = 1 +export const DEFAULT_INPUT_USD_PER_MTOK = 1.25 +export const DEFAULT_OUTPUT_USD_PER_MTOK = 10 +export const DEFAULT_BENCHMARK_DIR = join(REPO_ROOT, '.benchmarks') +export const DEFAULT_REPORT_DIR = join(REPO_ROOT, 'benchmark-reports') +export const DEFAULT_AGENTS: AgentName[] = ['crabcode', 'opencode', 'codex'] diff --git a/benchmarking/src/format.ts b/benchmarking/src/format.ts new file mode 100644 index 0000000..979e7af --- /dev/null +++ b/benchmarking/src/format.ts @@ -0,0 +1,41 @@ +export function shellQuote(value: string) { + if (!value) return "''" + return `'${value.replaceAll("'", `'\\''`)}'` +} + +export function sanitizePathPart(value: string) { + return value.replace(/[^a-zA-Z0-9._-]+/g, '-') +} + +export function tailText(value: string, maxChars = 2_000) { + if (!value.trim()) return '' + if (value.length <= maxChars) return value.trim() + return `... truncated ...\n${value.slice(value.length - maxChars).trim()}` +} + +export function formatDuration(ms: number) { + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +export function estimateTokens(text: string) { + return Math.ceil(text.length / 4) +} + +export function estimateCost(inputTokens: number, outputTokens: number, inputUsdPerMillion: number, outputUsdPerMillion: number) { + return (inputTokens / 1_000_000) * inputUsdPerMillion + (outputTokens / 1_000_000) * outputUsdPerMillion +} + +export function formatUsd(value: number) { + if (!value) return '$0.0000' + return `$${value.toFixed(4)}` +} + +export function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +export function escapeMarkdownTable(value: string) { + return value.replaceAll('|', '\\|').replaceAll('\n', '
') +} + diff --git a/benchmarking/src/report.ts b/benchmarking/src/report.ts new file mode 100644 index 0000000..a65d699 --- /dev/null +++ b/benchmarking/src/report.ts @@ -0,0 +1,131 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { displayAgent, modelForAgent } from './agents.ts' +import { escapeMarkdownTable, formatDuration, formatUsd, sum } from './format.ts' +import type { AgentName, BenchmarkTask, RunResult } from './types.ts' + +export function summaryRows(results: RunResult[], agents: AgentName[]) { + return agents.map((agent) => { + const items = results.filter((result) => result.agent === agent) + const passCount = items.filter((result) => result.ok).length + const totalChecks = sum(items.map((item) => item.totalChecks)) + const passedChecks = sum(items.map((item) => item.passedChecks)) + const avgMs = items.length ? sum(items.map((item) => item.elapsedMs)) / items.length : 0 + const tokens = sum(items.map((item) => item.estimatedInputTokens + item.estimatedOutputTokens)) + const cost = sum(items.map((item) => item.estimatedCostUsd)) + return { + agent, + score: items.length ? `${Math.round((passCount / items.length) * 100)}%` : '0%', + checks: `${passedChecks}/${totalChecks}`, + avgTime: `${(avgMs / 1000).toFixed(1)}s`, + tokens, + cost: formatUsd(cost), + } + }) +} + +export function writeMarkdownReport( + path: string, + report: { + runId: string + runRoot: string + workspacesRoot: string + logsRoot: string + model: string + agents: AgentName[] + tasks: BenchmarkTask[] + runs: number + plannedPrompts: number + timeoutMs: number + keep: boolean + inputPrice: number + outputPrice: number + results: RunResult[] + stopped: boolean + }, +) { + mkdirSync(dirname(path), { recursive: true }) + const lines: string[] = [] + + lines.push(`# Agent Benchmark Report`) + lines.push('') + lines.push(`Generated: ${new Date().toISOString()}`) + lines.push(`Run ID: \`${report.runId}\``) + lines.push(`Model: \`${report.model || '(agent defaults)'}\``) + lines.push(`Agent model args: ${report.agents.map((agent) => `\`${displayAgent(agent)}=${modelForAgent(agent, report.model)}\``).join(', ')}`) + lines.push(`Agents: ${report.agents.map((agent) => `\`${displayAgent(agent)}\``).join(', ')}`) + lines.push(`Tasks: ${report.tasks.map((task) => `\`${task.id}\``).join(', ')}`) + lines.push(`Runs per agent/task: ${report.runs}`) + lines.push(`Completed runs: ${report.results.length}/${report.plannedPrompts}`) + lines.push(`Timeout per run: ${report.timeoutMs}ms`) + lines.push(`Benchmark run directory: \`${report.runRoot}\``) + lines.push(`Agents ran in: \`${report.workspacesRoot}\``) + lines.push(`Logs: \`${report.logsRoot}\``) + lines.push(`Workspaces kept after run: ${report.keep ? 'yes' : 'no'}`) + lines.push(`Stopped early: ${report.stopped ? 'yes' : 'no'}`) + lines.push('') + lines.push(`Permission-gated actions are auto-approved for benchmark agent commands in isolated workspaces.`) + lines.push(`Site-fetch tasks use a per-run 127.0.0.1 static server and do not hit the public internet.`) + lines.push(`Cost is a rough estimate from prompt/output text tokens only; provider dashboards are the source of truth.`) + lines.push('') + + lines.push(`## Summary`) + lines.push('') + lines.push('| Agent | Score | Checks | Avg time | Est. tokens | Est. cost |') + lines.push('|---|---:|---:|---:|---:|---:|') + for (const row of summaryRows(report.results, report.agents)) { + lines.push(`| ${displayAgent(row.agent)} | ${row.score} | ${row.checks} | ${row.avgTime} | ${row.tokens} | ${row.cost} |`) + } + lines.push('') + + lines.push(`## Runs`) + lines.push('') + lines.push('| Status | Agent | Task | Checks | Time | Est. tokens | Est. cost | Workspace | Stdout | Stderr | Error |') + lines.push('|---|---|---|---:|---:|---:|---:|---|---|---|---|') + for (const result of report.results) { + const status = result.ok ? 'PASS' : 'FAIL' + const tokens = result.estimatedInputTokens + result.estimatedOutputTokens + lines.push( + `| ${status} | ${displayAgent(result.agent)} | ${result.task} | ${result.passedChecks}/${result.totalChecks} | ${formatDuration(result.elapsedMs)} | ${tokens} | ${formatUsd(result.estimatedCostUsd)} | \`${result.workspace ?? ''}\` | \`${result.stdoutPath ?? ''}\` | \`${result.stderrPath ?? ''}\` | ${escapeMarkdownTable(result.error ?? '')} |`, + ) + } + lines.push('') + + lines.push(`## Output Tails`) + lines.push('') + for (const result of report.results) { + if (!result.stdoutTail && !result.stderrTail) continue + lines.push(`### ${displayAgent(result.agent)} / ${result.task}`) + lines.push('') + if (result.stdoutTail) { + lines.push('stdout:') + lines.push('```text') + lines.push(result.stdoutTail) + lines.push('```') + lines.push('') + } + if (result.stderrTail) { + lines.push('stderr:') + lines.push('```text') + lines.push(result.stderrTail) + lines.push('```') + lines.push('') + } + } + + lines.push(`## Tasks`) + lines.push('') + for (const task of report.tasks) { + lines.push(`### ${task.id}`) + lines.push('') + lines.push(task.title) + lines.push('') + lines.push('```text') + lines.push(task.prompt) + lines.push('```') + lines.push('') + } + + writeFileSync(path, lines.join('\n') + '\n') +} + diff --git a/benchmarking/src/static-server.ts b/benchmarking/src/static-server.ts new file mode 100644 index 0000000..10c05d7 --- /dev/null +++ b/benchmarking/src/static-server.ts @@ -0,0 +1,99 @@ +import { createServer } from 'node:http' +import { existsSync, readFileSync, statSync } from 'node:fs' +import { extname, resolve, sep } from 'node:path' + +export async function startStaticServer(root: string) { + const absoluteRoot = resolve(root) + let lastError: Error | null = null + + for (let attempt = 0; attempt < 20; attempt++) { + const port = 41_000 + Math.floor(Math.random() * 20_000) + try { + return await listenStaticServer(absoluteRoot, port) + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)) + } + } + + throw lastError ?? new Error('failed to start static server') +} + +function listenStaticServer(absoluteRoot: string, port: number) { + const server = createServer((request, response) => { + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.writeHead(405, { allow: 'GET, HEAD' }) + response.end('Method not allowed') + return + } + + let requestPath = 'index.html' + try { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + requestPath = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'index.html' + } catch { + response.writeHead(400) + response.end('Bad request') + return + } + + const filePath = resolve(absoluteRoot, requestPath) + if (filePath !== absoluteRoot && !filePath.startsWith(absoluteRoot + sep)) { + response.writeHead(403) + response.end('Forbidden') + return + } + + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + response.writeHead(404) + response.end('Not found') + return + } + + response.writeHead(200, { 'content-type': contentTypeFor(filePath) }) + if (request.method === 'HEAD') { + response.end() + return + } + response.end(readFileSync(filePath)) + }) + + return new Promise<{ url: string; close: () => Promise }>((resolveStart, rejectStart) => { + let settled = false + const onError = (err: Error) => { + if (settled) return + settled = true + rejectStart(err) + } + server.once('error', onError) + try { + server.listen(port, '127.0.0.1', () => { + if (settled) return + settled = true + server.off('error', onError) + resolveStart({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolveClose) => { + server.close(() => resolveClose()) + }), + }) + }) + } catch (err) { + onError(err instanceof Error ? err : new Error(String(err))) + } + }) +} + +function contentTypeFor(path: string) { + switch (extname(path)) { + case '.json': + return 'application/json; charset=utf-8' + case '.md': + return 'text/markdown; charset=utf-8' + case '.txt': + return 'text/plain; charset=utf-8' + default: + return 'application/octet-stream' + } +} + diff --git a/benchmarking/src/tasks/basic.ts b/benchmarking/src/tasks/basic.ts new file mode 100644 index 0000000..6edee79 --- /dev/null +++ b/benchmarking/src/tasks/basic.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { DEFAULT_MODEL } from '../defaults.ts' +import { defineTask } from './define.ts' + +export const basicTasks = [ + defineTask({ + id: 'bugfix-js', + title: 'Fix a small JavaScript bug', + difficulty: 'smoke', + tags: ['javascript', 'bugfix', 'small'], + files: { + 'package.json': JSON.stringify({ type: 'module' }, null, 2) + '\n', + 'stats.js': `export function average(nums) { + if (nums.length === 0) return 0 + return nums.reduce((sum, n) => sum + n, 0) +} +`, + }, + prompt: `Fix stats.js. average([2, 4, 6]) should return 4, average([10]) should return 10, and average([]) should keep returning 0. Keep the change minimal.`, + check: (cwd) => { + const stats = readFileSync(join(cwd, 'stats.js'), 'utf8') + const hasDivide = /\/\s*nums\.length/.test(stats) + const stillHandlesEmpty = /length\s*={2,3}\s*0/.test(stats) && /return\s+0/.test(stats) + return [ + { name: 'divides by length', pass: hasDivide }, + { name: 'keeps empty-array guard', pass: stillHandlesEmpty }, + ] + }, + }), + defineTask({ + id: 'config-doc-sync', + title: 'Synchronize tiny config docs', + difficulty: 'smoke', + tags: ['docs', 'json', 'sync'], + files: { + 'config.json': JSON.stringify( + { + model: DEFAULT_MODEL, + agent: { build: { steps: 20 } }, + }, + null, + 2, + ) + '\n', + 'README.md': `# Fixture + +Default model: openai/gpt-5.3-codex +Build steps: 12 +`, + }, + prompt: `Update README.md so the documented Build steps value matches config.json. Do not change config.json.`, + check: (cwd) => { + const config = readFileSync(join(cwd, 'config.json'), 'utf8') + const readme = readFileSync(join(cwd, 'README.md'), 'utf8') + return [ + { name: 'README documents 20 steps', pass: /Build steps:\s*20/.test(readme) }, + { name: 'config remains unchanged', pass: config.includes('"steps": 20') }, + ] + }, + }), +] diff --git a/benchmarking/src/tasks/define.ts b/benchmarking/src/tasks/define.ts new file mode 100644 index 0000000..8d94933 --- /dev/null +++ b/benchmarking/src/tasks/define.ts @@ -0,0 +1,6 @@ +import type { BenchmarkTask } from '../types.ts' + +export function defineTask(task: BenchmarkTask): BenchmarkTask { + return task +} + diff --git a/benchmarking/src/tasks/index.ts b/benchmarking/src/tasks/index.ts new file mode 100644 index 0000000..9f7c1ea --- /dev/null +++ b/benchmarking/src/tasks/index.ts @@ -0,0 +1,7 @@ +import { basicTasks } from './basic.ts' +import { rustTasks } from './rust.ts' +import { siteTasks } from './site.ts' +import { typescriptTasks } from './typescript.ts' + +export const TASKS = [...basicTasks, ...rustTasks, ...siteTasks, ...typescriptTasks] + diff --git a/benchmarking/src/tasks/rust.ts b/benchmarking/src/tasks/rust.ts new file mode 100644 index 0000000..a81be18 --- /dev/null +++ b/benchmarking/src/tasks/rust.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { defineTask } from './define.ts' + +export const rustTasks = [ + defineTask({ + id: 'add-rust-test', + title: 'Add a focused Rust test', + difficulty: 'smoke', + tags: ['rust', 'tests', 'small'], + files: { + 'src/lib.rs': `pub fn slugify(input: &str) -> String { + input + .trim() + .to_lowercase() + .split_whitespace() + .collect::>() + .join("-") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugifies_basic_text() { + assert_eq!(slugify("Hello Crab Code"), "hello-crab-code"); + } +} +`, + 'Cargo.toml': `[package] +name = "bench-fixture" +version = "0.0.0" +edition = "2021" +`, + }, + prompt: `Add one focused test in src/lib.rs for slugify that covers leading/trailing whitespace and repeated internal whitespace. Do not change the slugify implementation.`, + check: (cwd) => { + const lib = readFileSync(join(cwd, 'src/lib.rs'), 'utf8') + return [ + { name: 'adds a second test', pass: (lib.match(/#\[test\]/g) ?? []).length >= 2 }, + { name: 'covers whitespace case', pass: /\\t|\\n| {2,}|leading|trailing|whitespace/i.test(lib) }, + { name: 'does not change implementation shape', pass: lib.includes('.split_whitespace()') }, + ] + }, + }), +] + diff --git a/benchmarking/src/tasks/site.ts b/benchmarking/src/tasks/site.ts new file mode 100644 index 0000000..943c95e --- /dev/null +++ b/benchmarking/src/tasks/site.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { defineTask } from './define.ts' + +export const siteTasks = [ + defineTask({ + id: 'local-site-fetch', + title: 'Fetch local site data and update docs', + difficulty: 'medium', + tags: ['webfetch', 'docs', 'json'], + site: { + root: 'site', + }, + files: { + 'site/api/releases.json': JSON.stringify( + { + releases: [ + { + version: '1.8.0-beta.1', + channel: 'beta', + recommended: false, + migrationNote: 'Beta users should keep the experimental flag enabled.', + }, + { + version: '1.7.4', + channel: 'stable', + recommended: true, + migrationNote: 'Set `snapshotMode` to `sparse` before rollout.', + }, + ], + }, + null, + 2, + ) + '\n', + 'docs/release.md': `# Release Notes + +Recommended stable: 1.6.2 +Migration note: TBD +`, + }, + prompt: `Fetch {siteUrl}/api/releases.json, find the recommended stable release, and update docs/release.md with its version and migrationNote. Do not change files under site/.`, + check: (cwd) => { + const doc = readFileSync(join(cwd, 'docs/release.md'), 'utf8') + const siteData = readFileSync(join(cwd, 'site/api/releases.json'), 'utf8') + return [ + { name: 'documents recommended stable version', pass: /1\.7\.4/.test(doc) }, + { name: 'copies fetched migration note', pass: /snapshotMode/.test(doc) && /sparse/.test(doc) }, + { name: 'removes placeholder note', pass: !/TBD/.test(doc) }, + { name: 'keeps served fixture intact', pass: siteData.includes('"version": "1.7.4"') }, + ] + }, + }), +] + diff --git a/benchmarking/src/tasks/typescript.ts b/benchmarking/src/tasks/typescript.ts new file mode 100644 index 0000000..59c7885 --- /dev/null +++ b/benchmarking/src/tasks/typescript.ts @@ -0,0 +1,231 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { bunTestCheck, bunTestWithHiddenFileCheck } from '../checks.ts' +import { defineTask } from './define.ts' + +export const typescriptTasks = [ + defineTask({ + id: 'invoice-ts-fix', + title: 'Fix a cross-file TypeScript invoice bug', + difficulty: 'medium', + tags: ['typescript', 'bugfix', 'tests'], + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/invoice.ts': `export type InvoiceLine = { + sku: string + unitCents: number + quantity: number +} + +export function invoiceTotalCents(lines: InvoiceLine[], discountPercent = 0, taxRate = 0): number { + const subtotal = lines.reduce((sum, line) => sum + line.unitCents, 0) + const discounted = subtotal - Math.round(subtotal * discountPercent) + return Math.round(discounted * (1 + taxRate)) +} +`, + 'tests/invoice.test.ts': `import { expect, test } from 'bun:test' +import { invoiceTotalCents } from '../src/invoice' + +test('counts quantities before discount and tax', () => { + const total = invoiceTotalCents( + [ + { sku: 'seat', unitCents: 1000, quantity: 2 }, + { sku: 'addon', unitCents: 500, quantity: 1 }, + ], + 10, + 0.08, + ) + + expect(total).toBe(2430) +}) + +test('handles quantity-only totals', () => { + expect(invoiceTotalCents([{ sku: 'usage', unitCents: 333, quantity: 3 }])).toBe(999) +}) +`, + }, + prompt: `Fix src/invoice.ts so invoiceTotalCents counts line quantities, treats discountPercent as a whole percent where 10 means 10%, and keeps taxRate as a decimal. Do not change the tests or add dependencies.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/invoice.test.ts'), 'utf8') + return [ + { name: 'keeps invoice behavior tests', pass: testFile.includes('toBe(2430)') && testFile.includes('quantity: 3') }, + bunTestCheck(cwd), + ] + }, + }), + defineTask({ + id: 'jsonc-config-parser', + title: 'Add tiny JSONC config parser support', + difficulty: 'medium', + tags: ['typescript', 'parser', 'jsonc'], + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/config.ts': `export type AppConfig = { + model: string + limits: { + maxTurns: number + } + features: string[] +} + +export function parseConfig(text: string): AppConfig { + return JSON.parse(text) +} +`, + 'tests/config.test.ts': `import { expect, test } from 'bun:test' +import { parseConfig } from '../src/config' + +test('parses line comments and trailing commas', () => { + const config = parseConfig(\`{ + // default benchmark model + "model": "openai/gpt-5.3-codex", + "limits": { + "maxTurns": 8, + }, + "features": [ + "shell", + "edit", + ], + }\`) + + expect(config).toEqual({ + model: 'openai/gpt-5.3-codex', + limits: { maxTurns: 8 }, + features: ['shell', 'edit'], + }) +}) +`, + }, + prompt: `Update src/config.ts so parseConfig accepts JSONC-style // line comments and trailing commas in objects/arrays. Keep the public API the same, keep the existing test, and do not add dependencies.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/config.test.ts'), 'utf8') + return [ + { + name: 'keeps JSONC coverage', + pass: testFile.includes('// default benchmark model') && testFile.includes('"maxTurns": 8,') && testFile.includes('"edit",'), + }, + bunTestCheck(cwd), + ] + }, + }), + defineTask({ + id: 'workflow-planner-ts', + title: 'Implement dependency-aware workflow planning', + difficulty: 'hard', + tags: ['typescript', 'algorithm', 'hidden-tests'], + timeoutMs: 60_000, + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/planner.ts': `export type WorkflowStep = { + id: string + dependsOn?: string[] + estimatedSeconds?: number +} + +export type ExecutionStage = { + parallel: string[] +} + +export function createExecutionPlan(steps: WorkflowStep[]): ExecutionStage[] { + return steps.map((step) => ({ parallel: [step.id] })) +} +`, + 'tests/planner.test.ts': `import { expect, test } from 'bun:test' +import { createExecutionPlan, type WorkflowStep } from '../src/planner' + +test('groups ready steps into stable dependency stages', () => { + const steps: WorkflowStep[] = [ + { id: 'checkout' }, + { id: 'lint', dependsOn: ['checkout'] }, + { id: 'docs', dependsOn: ['checkout'] }, + { id: 'test', dependsOn: ['lint'] }, + { id: 'package', dependsOn: ['docs', 'test'] }, + ] + + expect(createExecutionPlan(steps)).toEqual([ + { parallel: ['checkout'] }, + { parallel: ['lint', 'docs'] }, + { parallel: ['test'] }, + { parallel: ['package'] }, + ]) +}) + +test('rejects missing dependencies with useful context', () => { + expect(() => + createExecutionPlan([ + { id: 'deploy', dependsOn: ['package'] }, + ]), + ).toThrow(/package.*deploy|deploy.*package/) +}) + +test('rejects dependency cycles', () => { + expect(() => + createExecutionPlan([ + { id: 'a', dependsOn: ['b'] }, + { id: 'b', dependsOn: ['a'] }, + ]), + ).toThrow(/cycle/i) +}) +`, + }, + prompt: `Implement createExecutionPlan in src/planner.ts. Return execution stages where every step in a stage can run after all previous stages, and keep the original input order inside each stage. The function must handle input that is not already sorted, throw helpful errors for duplicate step ids, unknown dependencies, and dependency cycles, and it must not mutate the input. Do not change the tests or add dependencies.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/planner.test.ts'), 'utf8') + return [ + { + name: 'keeps visible planner coverage', + pass: + testFile.includes('groups ready steps into stable dependency stages') && + testFile.includes('rejects missing dependencies') && + testFile.includes('rejects dependency cycles'), + }, + bunTestWithHiddenFileCheck( + cwd, + 'hidden workflow planner tests pass', + 'tests/__bench_hidden_planner.test.ts', + `import { expect, test } from 'bun:test' +import { createExecutionPlan, type WorkflowStep } from '../src/planner' + +test('plans unsorted dependency input without mutating it', () => { + const steps: WorkflowStep[] = [ + { id: 'deploy', dependsOn: ['build', 'migrate'], estimatedSeconds: 30 }, + { id: 'lint', estimatedSeconds: 10 }, + { id: 'build', dependsOn: ['lint'], estimatedSeconds: 40 }, + { id: 'migrate', dependsOn: ['lint'], estimatedSeconds: 15 }, + { id: 'notify', dependsOn: ['deploy'], estimatedSeconds: 5 }, + ] + const original = structuredClone(steps) + + expect(createExecutionPlan(steps)).toEqual([ + { parallel: ['lint'] }, + { parallel: ['build', 'migrate'] }, + { parallel: ['deploy'] }, + { parallel: ['notify'] }, + ]) + expect(steps).toEqual(original) +}) + +test('rejects duplicate step ids', () => { + expect(() => + createExecutionPlan([ + { id: 'build' }, + { id: 'build', dependsOn: ['build'] }, + ]), + ).toThrow(/duplicate|build/i) +}) + +test('detects cycles even when independent work is present', () => { + expect(() => + createExecutionPlan([ + { id: 'setup' }, + { id: 'a', dependsOn: ['b'] }, + { id: 'b', dependsOn: ['a'] }, + ]), + ).toThrow(/cycle/i) +}) +`, + ), + ] + }, + }), +] diff --git a/benchmarking/src/types.ts b/benchmarking/src/types.ts new file mode 100644 index 0000000..99c5def --- /dev/null +++ b/benchmarking/src/types.ts @@ -0,0 +1,47 @@ +export type AgentName = 'crabcode' | 'opencode' | 'codex' + +export type BenchmarkDifficulty = 'smoke' | 'medium' | 'hard' + +export type BenchmarkTask = { + id: string + title: string + prompt: string + files: Record + difficulty?: BenchmarkDifficulty + tags?: string[] + timeoutMs?: number + site?: { + root: string + } + check: (cwd: string) => CheckResult[] +} + +export type CheckResult = { + name: string + pass: boolean + detail?: string +} + +export type RunResult = { + agent: AgentName + task: string + ok: boolean + passedChecks: number + totalChecks: number + elapsedMs: number + estimatedInputTokens: number + estimatedOutputTokens: number + estimatedCostUsd: number + exitCode: number | null + timedOut: boolean + error?: string + workspace?: string + stdoutPath?: string + stderrPath?: string + commandPath?: string + stdoutTail?: string + stderrTail?: string +} + +export type ParsedArgs = Record + diff --git a/benchmarking/src/workspace.ts b/benchmarking/src/workspace.ts new file mode 100644 index 0000000..31372b0 --- /dev/null +++ b/benchmarking/src/workspace.ts @@ -0,0 +1,53 @@ +import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { DEFAULT_BENCHMARK_DIR } from './defaults.ts' +import { sanitizePathPart } from './format.ts' +import type { BenchmarkTask } from './types.ts' + +export function createRunRoot(dir: string | boolean | undefined, runId: string) { + const parent = dir && dir !== true ? resolve(String(dir)) : DEFAULT_BENCHMARK_DIR + mkdirSync(parent, { recursive: true }) + const root = join(parent, runId) + mkdirSync(root, { recursive: true }) + return root +} + +export function timestampForPath() { + return new Date().toISOString().replaceAll(':', '').replaceAll('.', '-') +} + +export function writeFixture(workspace: string, task: BenchmarkTask) { + for (const [path, content] of Object.entries(task.files)) { + const fullPath = join(workspace, path) + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content) + } +} + +export function writeRunArtifacts(logsRoot: string, runLabel: string, command: string, stdout: string, stderr: string) { + const safeLabel = sanitizePathPart(runLabel) + const commandPath = join(logsRoot, `${safeLabel}.command.txt`) + const stdoutPath = join(logsRoot, `${safeLabel}.stdout.txt`) + const stderrPath = join(logsRoot, `${safeLabel}.stderr.txt`) + + writeFileSync(commandPath, command + '\n') + writeFileSync(stdoutPath, stdout) + writeFileSync(stderrPath, stderr) + + return { commandPath, stdoutPath, stderrPath } +} + +export function cleanupWorkspace(workspace: string) { + try { + rmSync(workspace, { recursive: true, force: true }) + } catch {} +} + +export function cleanupWorkspaceChildren(workspace: string) { + try { + for (const entry of readdirSync(workspace)) { + cleanupWorkspace(join(workspace, entry)) + } + } catch {} +} + diff --git a/scripts/bench-agents.ts b/scripts/bench-agents.ts index fa1f302..cc120b6 100644 --- a/scripts/bench-agents.ts +++ b/scripts/bench-agents.ts @@ -1,1376 +1,2 @@ -// Make-shift benchmark for comparing crabcode, opencode, and codex on tiny agent tasks. -// Run via: `bun run scripts/bench-agents.ts` +import '../benchmarking/bench-agents.ts' -// @ts-nocheck - -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { dirname, extname, join, resolve, sep } from 'node:path' -import { spawn, spawnSync } from 'node:child_process' -import { createServer } from 'node:http' - -type AgentName = 'crabcode' | 'opencode' | 'codex' - -type Task = { - id: string - title: string - prompt: string - files: Record - site?: { - root: string - } - check: (cwd: string) => CheckResult[] -} - -type CheckResult = { - name: string - pass: boolean - detail?: string -} - -type RunResult = { - agent: AgentName - task: string - ok: boolean - passedChecks: number - totalChecks: number - elapsedMs: number - estimatedInputTokens: number - estimatedOutputTokens: number - estimatedCostUsd: number - exitCode: number | null - timedOut: boolean - error?: string - workspace?: string - stdoutPath?: string - stderrPath?: string - commandPath?: string - stdoutTail?: string - stderrTail?: string -} - -const REPO_ROOT = resolve(import.meta.dir, '..') -const DEFAULT_MODEL = 'openai/gpt-5.3-codex-spark' -const DEFAULT_TIMEOUT_MS = 45_000 -const DEFAULT_RUNS = 1 -const DEFAULT_INPUT_USD_PER_MTOK = 1.25 -const DEFAULT_OUTPUT_USD_PER_MTOK = 10 -const DEFAULT_BENCHMARK_DIR = join(REPO_ROOT, '.benchmarks') -const DEFAULT_REPORT_DIR = join(REPO_ROOT, 'benchmark-reports') -const DEFAULT_AGENTS: AgentName[] = ['crabcode', 'opencode', 'codex'] -const AGENT_LABELS: Record = { - crabcode: '🦀 crabcode', - opencode: '🔲 opencode', - codex: '⚛️ codex', -} -const activeChildren = new Set() -const activeWorkspaces = new Set() -let activeRunRoot: string | null = null -let shutdownRequested = false - -process.once('SIGINT', () => requestShutdown('SIGINT')) -process.once('SIGTERM', () => requestShutdown('SIGTERM')) - -const TASKS: Task[] = [ - { - id: 'bugfix-js', - title: 'Fix a small JavaScript bug', - files: { - 'package.json': JSON.stringify({ type: 'module' }, null, 2) + '\n', - 'stats.js': `export function average(nums) { - if (nums.length === 0) return 0 - return nums.reduce((sum, n) => sum + n, 0) -} -`, - }, - prompt: `Fix stats.js. average([2, 4, 6]) should return 4, average([10]) should return 10, and average([]) should keep returning 0. Keep the change minimal.`, - check: (cwd) => { - const stats = readFileSync(join(cwd, 'stats.js'), 'utf8') - const hasDivide = /\/\s*nums\.length/.test(stats) - const stillHandlesEmpty = /length\s*={2,3}\s*0/.test(stats) && /return\s+0/.test(stats) - return [ - { name: 'divides by length', pass: hasDivide }, - { name: 'keeps empty-array guard', pass: stillHandlesEmpty }, - ] - }, - }, - { - id: 'add-rust-test', - title: 'Add a focused Rust test', - files: { - 'src/lib.rs': `pub fn slugify(input: &str) -> String { - input - .trim() - .to_lowercase() - .split_whitespace() - .collect::>() - .join("-") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slugifies_basic_text() { - assert_eq!(slugify("Hello Crab Code"), "hello-crab-code"); - } -} -`, - 'Cargo.toml': `[package] -name = "bench-fixture" -version = "0.0.0" -edition = "2021" -`, - }, - prompt: `Add one focused test in src/lib.rs for slugify that covers leading/trailing whitespace and repeated internal whitespace. Do not change the slugify implementation.`, - check: (cwd) => { - const lib = readFileSync(join(cwd, 'src/lib.rs'), 'utf8') - return [ - { name: 'adds a second test', pass: (lib.match(/#\[test\]/g) ?? []).length >= 2 }, - { name: 'covers whitespace case', pass: /\\t|\\n| {2,}|leading|trailing|whitespace/i.test(lib) }, - { name: 'does not change implementation shape', pass: lib.includes('.split_whitespace()') }, - ] - }, - }, - { - id: 'config-doc-sync', - title: 'Synchronize tiny config docs', - files: { - 'config.json': JSON.stringify( - { - model: DEFAULT_MODEL, - agent: { build: { steps: 20 } }, - }, - null, - 2, - ) + '\n', - 'README.md': `# Fixture - -Default model: openai/gpt-5.3-codex-spark -Build steps: 12 -`, - }, - prompt: `Update README.md so the documented Build steps value matches config.json. Do not change config.json.`, - check: (cwd) => { - const config = readFileSync(join(cwd, 'config.json'), 'utf8') - const readme = readFileSync(join(cwd, 'README.md'), 'utf8') - return [ - { name: 'README documents 20 steps', pass: /Build steps:\s*20/.test(readme) }, - { name: 'config remains unchanged', pass: config.includes('"steps": 20') }, - ] - }, - }, - { - id: 'local-site-fetch', - title: 'Fetch local site data and update docs', - site: { - root: 'site', - }, - files: { - 'site/api/releases.json': JSON.stringify( - { - releases: [ - { - version: '1.8.0-beta.1', - channel: 'beta', - recommended: false, - migrationNote: 'Beta users should keep the experimental flag enabled.', - }, - { - version: '1.7.4', - channel: 'stable', - recommended: true, - migrationNote: 'Set `snapshotMode` to `sparse` before rollout.', - }, - ], - }, - null, - 2, - ) + '\n', - 'docs/release.md': `# Release Notes - -Recommended stable: 1.6.2 -Migration note: TBD -`, - }, - prompt: `Fetch {siteUrl}/api/releases.json, find the recommended stable release, and update docs/release.md with its version and migrationNote. Do not change files under site/.`, - check: (cwd) => { - const doc = readFileSync(join(cwd, 'docs/release.md'), 'utf8') - const siteData = readFileSync(join(cwd, 'site/api/releases.json'), 'utf8') - return [ - { name: 'documents recommended stable version', pass: /1\.7\.4/.test(doc) }, - { name: 'copies fetched migration note', pass: /snapshotMode/.test(doc) && /sparse/.test(doc) }, - { name: 'removes placeholder note', pass: !/TBD/.test(doc) }, - { name: 'keeps served fixture intact', pass: siteData.includes('"version": "1.7.4"') }, - ] - }, - }, - { - id: 'invoice-ts-fix', - title: 'Fix a cross-file TypeScript invoice bug', - files: { - 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', - 'src/invoice.ts': `export type InvoiceLine = { - sku: string - unitCents: number - quantity: number -} - -export function invoiceTotalCents(lines: InvoiceLine[], discountPercent = 0, taxRate = 0): number { - const subtotal = lines.reduce((sum, line) => sum + line.unitCents, 0) - const discounted = subtotal - Math.round(subtotal * discountPercent) - return Math.round(discounted * (1 + taxRate)) -} -`, - 'tests/invoice.test.ts': `import { expect, test } from 'bun:test' -import { invoiceTotalCents } from '../src/invoice' - -test('counts quantities before discount and tax', () => { - const total = invoiceTotalCents( - [ - { sku: 'seat', unitCents: 1000, quantity: 2 }, - { sku: 'addon', unitCents: 500, quantity: 1 }, - ], - 10, - 0.08, - ) - - expect(total).toBe(2430) -}) - -test('handles quantity-only totals', () => { - expect(invoiceTotalCents([{ sku: 'usage', unitCents: 333, quantity: 3 }])).toBe(999) -}) -`, - }, - prompt: `Fix src/invoice.ts so invoiceTotalCents counts line quantities, treats discountPercent as a whole percent where 10 means 10%, and keeps taxRate as a decimal. Do not change the tests or add dependencies.`, - check: (cwd) => { - const testFile = readFileSync(join(cwd, 'tests/invoice.test.ts'), 'utf8') - return [ - { name: 'keeps invoice behavior tests', pass: testFile.includes('toBe(2430)') && testFile.includes('quantity: 3') }, - bunTestCheck(cwd), - ] - }, - }, - { - id: 'jsonc-config-parser', - title: 'Add tiny JSONC config parser support', - files: { - 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', - 'src/config.ts': `export type AppConfig = { - model: string - limits: { - maxTurns: number - } - features: string[] -} - -export function parseConfig(text: string): AppConfig { - return JSON.parse(text) -} -`, - 'tests/config.test.ts': `import { expect, test } from 'bun:test' -import { parseConfig } from '../src/config' - -test('parses line comments and trailing commas', () => { - const config = parseConfig(\`{ - // default benchmark model - "model": "openai/gpt-5.3-codex-spark", - "limits": { - "maxTurns": 8, - }, - "features": [ - "shell", - "edit", - ], - }\`) - - expect(config).toEqual({ - model: 'openai/gpt-5.3-codex-spark', - limits: { maxTurns: 8 }, - features: ['shell', 'edit'], - }) -}) -`, - }, - prompt: `Update src/config.ts so parseConfig accepts JSONC-style // line comments and trailing commas in objects/arrays. Keep the public API the same, keep the existing test, and do not add dependencies.`, - check: (cwd) => { - const testFile = readFileSync(join(cwd, 'tests/config.test.ts'), 'utf8') - return [ - { - name: 'keeps JSONC coverage', - pass: testFile.includes('// default benchmark model') && testFile.includes('"maxTurns": 8,') && testFile.includes('"edit",'), - }, - bunTestCheck(cwd), - ] - }, - }, - { - id: 'workflow-planner-ts', - title: 'Implement dependency-aware workflow planning', - files: { - 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', - 'src/planner.ts': `export type WorkflowStep = { - id: string - dependsOn?: string[] - estimatedSeconds?: number -} - -export type ExecutionStage = { - parallel: string[] -} - -export function createExecutionPlan(steps: WorkflowStep[]): ExecutionStage[] { - return steps.map((step) => ({ parallel: [step.id] })) -} -`, - 'tests/planner.test.ts': `import { expect, test } from 'bun:test' -import { createExecutionPlan, type WorkflowStep } from '../src/planner' - -test('groups ready steps into stable dependency stages', () => { - const steps: WorkflowStep[] = [ - { id: 'checkout' }, - { id: 'lint', dependsOn: ['checkout'] }, - { id: 'docs', dependsOn: ['checkout'] }, - { id: 'test', dependsOn: ['lint'] }, - { id: 'package', dependsOn: ['docs', 'test'] }, - ] - - expect(createExecutionPlan(steps)).toEqual([ - { parallel: ['checkout'] }, - { parallel: ['lint', 'docs'] }, - { parallel: ['test'] }, - { parallel: ['package'] }, - ]) -}) - -test('rejects missing dependencies with useful context', () => { - expect(() => - createExecutionPlan([ - { id: 'deploy', dependsOn: ['package'] }, - ]), - ).toThrow(/package.*deploy|deploy.*package/) -}) - -test('rejects dependency cycles', () => { - expect(() => - createExecutionPlan([ - { id: 'a', dependsOn: ['b'] }, - { id: 'b', dependsOn: ['a'] }, - ]), - ).toThrow(/cycle/i) -}) -`, - }, - prompt: `Implement createExecutionPlan in src/planner.ts. Return execution stages where every step in a stage can run after all previous stages, and keep the original input order inside each stage. The function must handle input that is not already sorted, throw helpful errors for duplicate step ids, unknown dependencies, and dependency cycles, and it must not mutate the input. Do not change the tests or add dependencies.`, - check: (cwd) => { - const testFile = readFileSync(join(cwd, 'tests/planner.test.ts'), 'utf8') - return [ - { - name: 'keeps visible planner coverage', - pass: - testFile.includes('groups ready steps into stable dependency stages') && - testFile.includes('rejects missing dependencies') && - testFile.includes('rejects dependency cycles'), - }, - bunTestWithHiddenFileCheck( - cwd, - 'hidden workflow planner tests pass', - 'tests/__bench_hidden_planner.test.ts', - `import { expect, test } from 'bun:test' -import { createExecutionPlan, type WorkflowStep } from '../src/planner' - -test('plans unsorted dependency input without mutating it', () => { - const steps: WorkflowStep[] = [ - { id: 'deploy', dependsOn: ['build', 'migrate'], estimatedSeconds: 30 }, - { id: 'lint', estimatedSeconds: 10 }, - { id: 'build', dependsOn: ['lint'], estimatedSeconds: 40 }, - { id: 'migrate', dependsOn: ['lint'], estimatedSeconds: 15 }, - { id: 'notify', dependsOn: ['deploy'], estimatedSeconds: 5 }, - ] - const original = structuredClone(steps) - - expect(createExecutionPlan(steps)).toEqual([ - { parallel: ['lint'] }, - { parallel: ['build', 'migrate'] }, - { parallel: ['deploy'] }, - { parallel: ['notify'] }, - ]) - expect(steps).toEqual(original) -}) - -test('rejects duplicate step ids', () => { - expect(() => - createExecutionPlan([ - { id: 'build' }, - { id: 'build', dependsOn: ['build'] }, - ]), - ).toThrow(/duplicate|build/i) -}) - -test('detects cycles even when independent work is present', () => { - expect(() => - createExecutionPlan([ - { id: 'setup' }, - { id: 'a', dependsOn: ['b'] }, - { id: 'b', dependsOn: ['a'] }, - ]), - ).toThrow(/cycle/i) -}) -`, - ), - ] - }, - }, -] - -const args = parseArgs(process.argv.slice(2)) -const agents = parseAgents(args.agents ?? process.env.BENCH_AGENTS ?? DEFAULT_AGENTS.join(',')) -const selectedTasks = parseTasks(args.tasks ?? process.env.BENCH_TASKS) -const model = String(args.model ?? process.env.BENCH_MODEL ?? DEFAULT_MODEL) -const timeoutMs = Number(args['timeout-ms'] ?? process.env.BENCH_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS) -const runs = Number(args.runs ?? process.env.BENCH_RUNS ?? DEFAULT_RUNS) -const keep = Boolean(args.keep) -const estimateOnly = Boolean(args.estimate) -const inputPrice = Number(args['input-price'] ?? process.env.BENCH_INPUT_USD_PER_MTOK ?? DEFAULT_INPUT_USD_PER_MTOK) -const outputPrice = Number(args['output-price'] ?? process.env.BENCH_OUTPUT_USD_PER_MTOK ?? DEFAULT_OUTPUT_USD_PER_MTOK) -const outputPath = args.out ? resolve(String(args.out)) : null -const runId = timestampForPath() -const runRoot = createRunRoot(args.dir ?? process.env.BENCH_DIR, runId) -const workspacesRoot = join(runRoot, 'workspaces') -const logsRoot = join(runRoot, 'logs') -mkdirSync(workspacesRoot, { recursive: true }) -mkdirSync(logsRoot, { recursive: true }) -const reportPath = args['no-report'] - ? null - : args.report && args.report !== true - ? resolve(String(args.report)) - : join(resolve(String(args['report-dir'] ?? process.env.BENCH_REPORT_DIR ?? DEFAULT_REPORT_DIR)), `agent-benchmark-${runId}.md`) - -if (args.help) { - printHelp() - process.exit(0) -} - -if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error('--timeout-ms must be a positive number') -} - -if (!Number.isFinite(runs) || runs <= 0) { - throw new Error('--runs must be a positive number') -} - -const plannedPrompts = selectedTasks.length * agents.length * runs -const estimatedInputTokens = selectedTasks.reduce((sum, task) => sum + estimateTokens(benchmarkPrompt(task.prompt)), 0) * agents.length * runs -const plannedCost = estimateCost(estimatedInputTokens, 0, inputPrice, outputPrice) - -printIntro() - -if (estimateOnly) { - process.exit(0) -} - -activeRunRoot = runRoot -printPaths() - -const results: RunResult[] = [] -writeCurrentMarkdownReport() - -try { - let runNumber = 0 - - runLoop: for (let runIndex = 0; runIndex < runs; runIndex++) { - for (const task of selectedTasks) { - for (const agent of agents) { - if (shutdownRequested) break runLoop - runNumber += 1 - const result = await safeRunBenchmark(agent, task, runIndex, runNumber, plannedPrompts) - results.push(result) - writeCurrentMarkdownReport() - printResult(result) - } - } - } - - printSummary(results) - - if (reportPath) { - writeCurrentMarkdownReport() - console.log(`\nWrote Markdown report to ${reportPath}`) - } - - if (shutdownRequested) { - process.exitCode = 130 - } - - if (outputPath) { - writeFileSync( - outputPath, - JSON.stringify( - { - generatedAt: new Date().toISOString(), - model, - agents, - tasks: selectedTasks.map((task) => task.id), - runs, - runId, - runRoot, - workspacesRoot, - logsRoot, - markdownReport: reportPath, - agentModels: Object.fromEntries(agents.map((agent) => [agent, modelForAgent(agent, model)])), - pricing: { - inputUsdPerMillionTokens: inputPrice, - outputUsdPerMillionTokens: outputPrice, - }, - results, - }, - null, - 2, - ) + '\n', - ) - console.log(`\nWrote JSON results to ${outputPath}`) - } -} finally { - writeCurrentMarkdownReport() - if (keep) { - console.log(`\nKept benchmark workspaces in ${workspacesRoot}`) - } else { - cleanupWorkspaceChildren(workspacesRoot) - } - activeRunRoot = null -} - -function writeCurrentMarkdownReport() { - if (!reportPath || estimateOnly) return - - writeMarkdownReport(reportPath, { - runId, - runRoot, - workspacesRoot, - logsRoot, - model, - agents, - tasks: selectedTasks, - runs, - plannedPrompts, - timeoutMs, - keep, - inputPrice, - outputPrice, - results, - stopped: shutdownRequested, - }) -} - -async function safeRunBenchmark( - agent: AgentName, - task: Task, - runIndex: number, - runNumber: number, - totalRuns: number, -): Promise { - try { - return await runBenchmark(agent, task, runIndex, runNumber, totalRuns) - } catch (err) { - return { - agent, - task: task.id, - ok: false, - passedChecks: 0, - totalChecks: 0, - elapsedMs: 0, - estimatedInputTokens: 0, - estimatedOutputTokens: 0, - estimatedCostUsd: 0, - exitCode: null, - timedOut: false, - error: `benchmark runner crashed: ${err instanceof Error ? err.message : String(err)}`, - } - } -} - -async function runBenchmark( - agent: AgentName, - task: Task, - runIndex: number, - runNumber: number, - totalRuns: number, -): Promise { - const runLabel = `${String(runIndex + 1).padStart(2, '0')}-${agent}-${task.id}` - const workspace = join(workspacesRoot, runLabel) - mkdirSync(workspace, { recursive: true }) - activeWorkspaces.add(workspace) - let staticServer: Awaited> | null = null - - try { - writeFixture(workspace, task) - - if (model) { - writeFileSync(join(workspace, 'crabcode.jsonc'), JSON.stringify({ model }, null, 2) + '\n') - } - - printRunStart(runNumber, totalRuns, agent, task.id, workspace) - - if (task.site) { - try { - staticServer = await startStaticServer(join(workspace, task.site.root)) - } catch (err) { - const checks = runChecks(task, workspace) - const passedChecks = checks.filter((check) => check.pass).length - return { - agent, - task: task.id, - ok: false, - passedChecks, - totalChecks: checks.length, - elapsedMs: 0, - estimatedInputTokens: 0, - estimatedOutputTokens: 0, - estimatedCostUsd: 0, - exitCode: null, - timedOut: false, - error: `failed to start local static server: ${err instanceof Error ? err.message : String(err)}`, - workspace, - } - } - } - - const prompt = benchmarkPrompt(resolveTaskPrompt(task, staticServer?.url)) - const command = commandFor(agent, prompt) - const started = performance.now() - const proc = await runShell(command, workspace, timeoutMs) - const elapsedMs = Math.round(performance.now() - started) - const checks = runChecks(task, workspace) - const passedChecks = checks.filter((check) => check.pass).length - const output = `${proc.stdout}\n${proc.stderr}`.trim() - const artifacts = writeRunArtifacts(runLabel, command, proc.stdout, proc.stderr) - const estimatedInputTokens = estimateTokens(prompt) - const estimatedOutputTokens = estimateTokens(output) - const ok = !shutdownRequested && !proc.timedOut && proc.exitCode === 0 && passedChecks === checks.length - const errors = [ - proc.timedOut ? `timed out after ${timeoutMs}ms` : '', - proc.exitCode !== 0 && proc.exitCode !== null ? `exit code ${proc.exitCode}` : '', - ...checks - .filter((check) => !check.pass) - .map((check) => `${check.name}${check.detail ? `: ${check.detail}` : ''}`), - proc.error ?? '', - ].filter(Boolean) - - return { - agent, - task: task.id, - ok, - passedChecks, - totalChecks: checks.length, - elapsedMs, - estimatedInputTokens, - estimatedOutputTokens, - estimatedCostUsd: estimateCost(estimatedInputTokens, estimatedOutputTokens, inputPrice, outputPrice), - exitCode: proc.exitCode, - timedOut: proc.timedOut, - error: errors.join('; ') || undefined, - workspace, - stdoutPath: artifacts.stdoutPath, - stderrPath: artifacts.stderrPath, - commandPath: artifacts.commandPath, - stdoutTail: tailText(proc.stdout), - stderrTail: tailText(proc.stderr), - } - } finally { - await staticServer?.close() - activeWorkspaces.delete(workspace) - } -} - -function createRunRoot(dir: string | boolean | undefined, runId: string) { - const parent = dir && dir !== true ? resolve(String(dir)) : DEFAULT_BENCHMARK_DIR - mkdirSync(parent, { recursive: true }) - const root = join(parent, runId) - mkdirSync(root, { recursive: true }) - return root -} - -function timestampForPath() { - return new Date().toISOString().replaceAll(':', '').replaceAll('.', '-') -} - -function commandFor(agent: AgentName, prompt: string) { - const defaults: Record = { - crabcode: defaultCrabcodeCommand(), - opencode: 'opencode run --dangerously-skip-permissions -m {model} {prompt}', - codex: 'codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}', - } - const envName = `BENCH_${agent.toUpperCase()}_CMD` - const template = process.env[envName] || defaults[agent] - const agentModel = modelForAgent(agent, model) - return template - .replaceAll('{repo}', shellQuote(REPO_ROOT)) - .replaceAll('{model}', shellQuote(agentModel)) - .replaceAll('{prompt}', shellQuote(prompt)) -} - -function benchmarkPrompt(prompt: string) { - return [ - 'You are running inside an isolated benchmark fixture.', - 'Modify files in the current working directory directly. Do not only describe the change.', - 'Keep the change minimal. When the task is complete, stop.', - 'If the task names exact file paths, inspect those paths directly instead of listing directories first.', - 'Do not repeat identical tool calls or run optional extra checks after the requested change is complete.', - '', - `Task: ${prompt}`, - ].join('\n') -} - -function resolveTaskPrompt(task: Task, siteUrl?: string) { - return task.prompt.replaceAll('{siteUrl}', siteUrl ?? '') -} - -function modelForAgent(agent: AgentName, modelRef: string) { - if (agent === 'codex') { - return modelRef.replace(/^openai\//, '') - } - - return modelRef -} - -function defaultCrabcodeCommand() { - const binary = join(REPO_ROOT, 'target', 'debug', 'crabcode') - if (existsSync(binary)) { - return `${shellQuote(binary)} -p --no-session-persistence --dangerously-skip-permissions {prompt}` - } - return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- -p --no-session-persistence --dangerously-skip-permissions {prompt}` -} - -function writeFixture(workspace: string, task: Task) { - for (const [path, content] of Object.entries(task.files)) { - const fullPath = join(workspace, path) - mkdirSync(dirname(fullPath), { recursive: true }) - writeFileSync(fullPath, content) - } -} - -function runShell(command: string, cwd: string, timeoutMs: number) { - return new Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; error?: string }>( - (resolveRun) => { - const child = spawn(command, { - cwd, - shell: true, - stdio: ['ignore', 'pipe', 'pipe'], - detached: process.platform !== 'win32', - env: { - ...process.env, - NO_COLOR: '1', - CI: '1', - }, - }) - - let stdout = '' - let stderr = '' - let timedOut = false - let settled = false - activeChildren.add(child) - - const timer = setTimeout(() => { - timedOut = true - terminateChild(child, 'SIGTERM') - setTimeout(() => terminateChild(child, 'SIGKILL'), 2_000).unref() - }, timeoutMs) - - child.stdout.on('data', (chunk) => { - stdout += chunk.toString() - }) - child.stderr.on('data', (chunk) => { - stderr += chunk.toString() - }) - child.on('error', (err) => { - if (settled) return - settled = true - activeChildren.delete(child) - clearTimeout(timer) - resolveRun({ stdout, stderr, exitCode: null, timedOut, error: err.message }) - }) - child.on('close', (code) => { - if (settled) return - settled = true - activeChildren.delete(child) - clearTimeout(timer) - resolveRun({ stdout, stderr, exitCode: code, timedOut }) - }) - }, - ) -} - -function runChecks(task: Task, workspace: string): CheckResult[] { - try { - return task.check(workspace) - } catch (err) { - return [ - { - name: 'checks completed', - pass: false, - detail: err instanceof Error ? err.message : String(err), - }, - ] - } -} - -function bunTestCheck(cwd: string): CheckResult { - const result = runCheckCommand(cwd, process.execPath, ['test']) - return { - name: 'bun test passes', - pass: result.ok, - detail: result.detail, - } -} - -function bunTestWithHiddenFileCheck(cwd: string, name: string, path: string, content: string): CheckResult { - const fullPath = join(cwd, path) - mkdirSync(dirname(fullPath), { recursive: true }) - writeFileSync(fullPath, content) - - const result = runCheckCommand(cwd, process.execPath, ['test']) - return { - name, - pass: result.ok, - detail: result.detail, - } -} - -function runCheckCommand(cwd: string, command: string, args: string[]) { - const proc = spawnSync(command, args, { - cwd, - encoding: 'utf8', - timeout: 15_000, - env: { - ...process.env, - NO_COLOR: '1', - CI: '1', - }, - }) - const output = `${proc.stdout ?? ''}\n${proc.stderr ?? ''}`.trim() - const detail = proc.error - ? proc.error.message - : proc.status === 0 - ? undefined - : tailText(output, 600) || `exit code ${proc.status}` - - return { - ok: proc.status === 0, - detail, - } -} - -async function startStaticServer(root: string) { - const absoluteRoot = resolve(root) - let lastError: Error | null = null - - for (let attempt = 0; attempt < 20; attempt++) { - const port = 41_000 + Math.floor(Math.random() * 20_000) - try { - return await listenStaticServer(absoluteRoot, port) - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)) - } - } - - throw lastError ?? new Error('failed to start static server') -} - -function listenStaticServer(absoluteRoot: string, port: number) { - const server = createServer((request, response) => { - if (request.method !== 'GET' && request.method !== 'HEAD') { - response.writeHead(405, { allow: 'GET, HEAD' }) - response.end('Method not allowed') - return - } - - let requestPath = 'index.html' - try { - const url = new URL(request.url ?? '/', 'http://127.0.0.1') - requestPath = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'index.html' - } catch { - response.writeHead(400) - response.end('Bad request') - return - } - - const filePath = resolve(absoluteRoot, requestPath) - if (filePath !== absoluteRoot && !filePath.startsWith(absoluteRoot + sep)) { - response.writeHead(403) - response.end('Forbidden') - return - } - - if (!existsSync(filePath) || statSync(filePath).isDirectory()) { - response.writeHead(404) - response.end('Not found') - return - } - - response.writeHead(200, { 'content-type': contentTypeFor(filePath) }) - if (request.method === 'HEAD') { - response.end() - return - } - response.end(readFileSync(filePath)) - }) - - return new Promise<{ url: string; close: () => Promise }>((resolveStart, rejectStart) => { - let settled = false - const onError = (err: Error) => { - if (settled) return - settled = true - rejectStart(err) - } - server.once('error', onError) - try { - server.listen(port, '127.0.0.1', () => { - if (settled) return - settled = true - server.off('error', onError) - resolveStart({ - url: `http://127.0.0.1:${port}`, - close: () => - new Promise((resolveClose) => { - server.close(() => resolveClose()) - }), - }) - }) - } catch (err) { - onError(err instanceof Error ? err : new Error(String(err))) - } - }) -} - -function contentTypeFor(path: string) { - switch (extname(path)) { - case '.json': - return 'application/json; charset=utf-8' - case '.md': - return 'text/markdown; charset=utf-8' - case '.txt': - return 'text/plain; charset=utf-8' - default: - return 'application/octet-stream' - } -} - -function requestShutdown(signal: string) { - if (shutdownRequested) { - writeCurrentMarkdownReport() - cleanupActiveWorkspaces() - process.exit(signal === 'SIGINT' ? 130 : 143) - } - - shutdownRequested = true - console.error(`\nReceived ${signal}; stopping active agent processes...`) - writeCurrentMarkdownReport() - - for (const child of activeChildren) { - terminateChild(child, 'SIGTERM') - } - - setTimeout(() => { - for (const child of activeChildren) { - terminateChild(child, 'SIGKILL') - } - writeCurrentMarkdownReport() - cleanupActiveWorkspaces() - process.exit(signal === 'SIGINT' ? 130 : 143) - }, 2_500).unref() -} - -function terminateChild(child: any, signal: NodeJS.Signals) { - if (!child?.pid) return - - try { - if (process.platform === 'win32') { - spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }) - return - } - - process.kill(-child.pid, signal) - } catch { - try { - child.kill(signal) - } catch {} - } -} - -function cleanupActiveWorkspaces() { - if (keep) return - for (const workspace of activeWorkspaces) { - cleanupWorkspace(workspace) - } - activeWorkspaces.clear() - if (activeRunRoot) { - cleanupWorkspace(activeRunRoot) - } -} - -function cleanupWorkspace(workspace: string) { - try { - rmSync(workspace, { recursive: true, force: true }) - } catch {} -} - -function cleanupWorkspaceChildren(workspace: string) { - try { - for (const entry of readdirSync(workspace)) { - cleanupWorkspace(join(workspace, entry)) - } - } catch {} -} - -function printIntro() { - console.log('Agent benchmark') - console.log('') - console.log('Config') - console.log(` model: ${model}`) - console.log(` agents: ${agents.map(displayAgent).join(', ')}`) - console.log(` tasks: ${selectedTasks.map((task) => task.id).join(', ')}`) - console.log(` runs: ${runs}`) - console.log(` prompts: ${plannedPrompts}`) - console.log(` timeout: ${formatDuration(timeoutMs)}`) - console.log(` prompt cost: ${formatUsd(plannedCost)} estimated`) - console.log('') - console.log('Agent model args') - for (const agent of agents) { - console.log(` ${displayAgent(agent).padEnd(12)} ${modelForAgent(agent, model)}`) - } - console.log('') -} - -function printPaths() { - console.log('Paths') - console.log(` run: ${runRoot}`) - console.log(` workspaces: ${workspacesRoot}`) - console.log(` logs: ${logsRoot}`) - if (reportPath) { - console.log(` report: ${reportPath}`) - } - console.log('') - console.log('Notes') - console.log(' Permission-gated actions are auto-approved for opencode and codex in isolated workspaces.') - console.log(' Crabcode print mode is run with --dangerously-skip-permissions in isolated workspaces.') - console.log(' Site-fetch tasks use a per-run 127.0.0.1 static server; they do not hit the public internet.') - if (!keep) { - console.log(' Workspaces are removed at exit. Pass --keep to preserve them.') - } - console.log('') -} - -function printRunStart(runNumber: number, totalRuns: number, agent: AgentName, taskId: string, workspace: string) { - console.log(`Run ${runNumber}/${totalRuns}: ${displayAgent(agent)} / ${taskId}`) - console.log(` workspace: ${workspace}`) -} - -function printResult(result: RunResult) { - const status = result.ok ? 'PASS' : 'FAIL' - const checks = `${result.passedChecks}/${result.totalChecks}` - console.log(` result: ${status}`) - console.log(` checks: ${checks}`) - console.log(` time: ${formatDuration(result.elapsedMs)}`) - console.log(` cost: ${formatUsd(result.estimatedCostUsd)} estimated`) - if (result.error) { - console.log(' reason:') - for (const line of result.error.split('; ')) { - console.log(` - ${line}`) - } - } - if (result.stdoutPath || result.stderrPath) { - console.log(' output:') - if (result.stdoutPath) console.log(` stdout: ${result.stdoutPath}`) - if (result.stderrPath) console.log(` stderr: ${result.stderrPath}`) - } - console.log('') -} - -function printSummary(results: RunResult[]) { - console.log('\nSummary') - console.log('| Agent | Score | Checks | Avg time | Est. tokens | Est. cost |') - console.log('|---|---:|---:|---:|---:|---:|') - - for (const row of summaryRows(results)) { - console.log(`| ${displayAgent(row.agent)} | ${row.score} | ${row.checks} | ${row.avgTime} | ${row.tokens} | ${row.cost} |`) - } - - console.log('\nMetric: Score is the percent of task runs where the command exited successfully and every deterministic check passed.') - console.log('Cost is an estimate from prompt/output text tokens only; provider dashboards are the source of truth.') -} - -function summaryRows(results: RunResult[]) { - return agents.map((agent) => { - const items = results.filter((result) => result.agent === agent) - const passCount = items.filter((result) => result.ok).length - const totalChecks = sum(items.map((item) => item.totalChecks)) - const passedChecks = sum(items.map((item) => item.passedChecks)) - const avgMs = items.length ? sum(items.map((item) => item.elapsedMs)) / items.length : 0 - const tokens = sum(items.map((item) => item.estimatedInputTokens + item.estimatedOutputTokens)) - const cost = sum(items.map((item) => item.estimatedCostUsd)) - return { - agent, - score: items.length ? `${Math.round((passCount / items.length) * 100)}%` : '0%', - checks: `${passedChecks}/${totalChecks}`, - avgTime: `${(avgMs / 1000).toFixed(1)}s`, - tokens, - cost: formatUsd(cost), - } - }) -} - -function writeMarkdownReport( - path: string, - report: { - runId: string - runRoot: string - workspacesRoot: string - logsRoot: string - model: string - agents: AgentName[] - tasks: Task[] - runs: number - plannedPrompts: number - timeoutMs: number - keep: boolean - inputPrice: number - outputPrice: number - results: RunResult[] - stopped: boolean - }, -) { - mkdirSync(dirname(path), { recursive: true }) - const lines: string[] = [] - - lines.push(`# Agent Benchmark Report`) - lines.push('') - lines.push(`Generated: ${new Date().toISOString()}`) - lines.push(`Run ID: \`${report.runId}\``) - lines.push(`Model: \`${report.model || '(agent defaults)'}\``) - lines.push(`Agent model args: ${report.agents.map((agent) => `\`${displayAgent(agent)}=${modelForAgent(agent, report.model)}\``).join(', ')}`) - lines.push(`Agents: ${report.agents.map((agent) => `\`${displayAgent(agent)}\``).join(', ')}`) - lines.push(`Tasks: ${report.tasks.map((task) => `\`${task.id}\``).join(', ')}`) - lines.push(`Runs per agent/task: ${report.runs}`) - lines.push(`Completed runs: ${report.results.length}/${report.plannedPrompts}`) - lines.push(`Timeout per run: ${report.timeoutMs}ms`) - lines.push(`Benchmark run directory: \`${report.runRoot}\``) - lines.push(`Agents ran in: \`${report.workspacesRoot}\``) - lines.push(`Logs: \`${report.logsRoot}\``) - lines.push(`Workspaces kept after run: ${report.keep ? 'yes' : 'no'}`) - lines.push(`Stopped early: ${report.stopped ? 'yes' : 'no'}`) - lines.push('') - lines.push(`Permission-gated actions are auto-approved for benchmark agent commands in isolated workspaces.`) - lines.push(`Site-fetch tasks use a per-run 127.0.0.1 static server and do not hit the public internet.`) - lines.push(`Cost is a rough estimate from prompt/output text tokens only; provider dashboards are the source of truth.`) - lines.push('') - - lines.push(`## Summary`) - lines.push('') - lines.push('| Agent | Score | Checks | Avg time | Est. tokens | Est. cost |') - lines.push('|---|---:|---:|---:|---:|---:|') - for (const row of summaryRows(report.results)) { - lines.push(`| ${displayAgent(row.agent)} | ${row.score} | ${row.checks} | ${row.avgTime} | ${row.tokens} | ${row.cost} |`) - } - lines.push('') - - lines.push(`## Runs`) - lines.push('') - lines.push('| Status | Agent | Task | Checks | Time | Est. tokens | Est. cost | Workspace | Stdout | Stderr | Error |') - lines.push('|---|---|---|---:|---:|---:|---:|---|---|---|---|') - for (const result of report.results) { - const status = result.ok ? 'PASS' : 'FAIL' - const tokens = result.estimatedInputTokens + result.estimatedOutputTokens - lines.push( - `| ${status} | ${displayAgent(result.agent)} | ${result.task} | ${result.passedChecks}/${result.totalChecks} | ${formatDuration(result.elapsedMs)} | ${tokens} | ${formatUsd(result.estimatedCostUsd)} | \`${result.workspace ?? ''}\` | \`${result.stdoutPath ?? ''}\` | \`${result.stderrPath ?? ''}\` | ${escapeMarkdownTable(result.error ?? '')} |`, - ) - } - lines.push('') - - lines.push(`## Output Tails`) - lines.push('') - for (const result of report.results) { - if (!result.stdoutTail && !result.stderrTail) continue - lines.push(`### ${displayAgent(result.agent)} / ${result.task}`) - lines.push('') - if (result.stdoutTail) { - lines.push('stdout:') - lines.push('```text') - lines.push(result.stdoutTail) - lines.push('```') - lines.push('') - } - if (result.stderrTail) { - lines.push('stderr:') - lines.push('```text') - lines.push(result.stderrTail) - lines.push('```') - lines.push('') - } - } - - lines.push(`## Tasks`) - lines.push('') - for (const task of report.tasks) { - lines.push(`### ${task.id}`) - lines.push('') - lines.push(task.title) - lines.push('') - lines.push('```text') - lines.push(task.prompt) - lines.push('```') - lines.push('') - } - - writeFileSync(path, lines.join('\n') + '\n') -} - -function escapeMarkdownTable(value: string) { - return value.replaceAll('|', '\\|').replaceAll('\n', '
') -} - -function displayAgent(agent: AgentName) { - return AGENT_LABELS[agent] ?? agent -} - -function writeRunArtifacts(runLabel: string, command: string, stdout: string, stderr: string) { - const safeLabel = sanitizePathPart(runLabel) - const commandPath = join(logsRoot, `${safeLabel}.command.txt`) - const stdoutPath = join(logsRoot, `${safeLabel}.stdout.txt`) - const stderrPath = join(logsRoot, `${safeLabel}.stderr.txt`) - - writeFileSync(commandPath, command + '\n') - writeFileSync(stdoutPath, stdout) - writeFileSync(stderrPath, stderr) - - return { commandPath, stdoutPath, stderrPath } -} - -function sanitizePathPart(value: string) { - return value.replace(/[^a-zA-Z0-9._-]+/g, '-') -} - -function tailText(value: string, maxChars = 2_000) { - if (!value.trim()) return '' - if (value.length <= maxChars) return value.trim() - return `... truncated ...\n${value.slice(value.length - maxChars).trim()}` -} - -function formatDuration(ms: number) { - if (ms < 1000) return `${Math.round(ms)}ms` - return `${(ms / 1000).toFixed(1)}s` -} - -function estimateTokens(text: string) { - return Math.ceil(text.length / 4) -} - -function estimateCost(inputTokens: number, outputTokens: number, inputUsdPerMillion: number, outputUsdPerMillion: number) { - return (inputTokens / 1_000_000) * inputUsdPerMillion + (outputTokens / 1_000_000) * outputUsdPerMillion -} - -function formatUsd(value: number) { - if (!value) return '$0.0000' - return `$${value.toFixed(4)}` -} - -function sum(values: number[]) { - return values.reduce((total, value) => total + value, 0) -} - -function parseArgs(raw: string[]) { - const parsed: Record = {} - for (let i = 0; i < raw.length; i++) { - const arg = raw[i] - if (!arg.startsWith('--')) continue - const body = arg.slice(2) - const [key, inlineValue] = body.split('=', 2) - if (inlineValue !== undefined) { - parsed[key] = inlineValue - continue - } - const next = raw[i + 1] - if (next && !next.startsWith('--')) { - parsed[key] = next - i++ - } else { - parsed[key] = true - } - } - return parsed -} - -function parseAgents(value: string): AgentName[] { - const agents = value.split(',').map((agent) => agent.trim()).filter(Boolean) - const valid = new Set(DEFAULT_AGENTS) - for (const agent of agents) { - if (!valid.has(agent as AgentName)) { - throw new Error(`Unknown agent: ${agent}. Expected one of ${DEFAULT_AGENTS.join(', ')}`) - } - } - return agents as AgentName[] -} - -function parseTasks(value?: string | boolean): Task[] { - if (!value || value === true) return TASKS - const ids = String(value).split(',').map((task) => task.trim()).filter(Boolean) - return ids.map((id) => { - const task = TASKS.find((candidate) => candidate.id === id) - if (!task) { - throw new Error(`Unknown task: ${id}. Expected one of ${TASKS.map((task) => task.id).join(', ')}`) - } - return task - }) -} - -function shellQuote(value: string) { - if (!value) return "''" - return `'${value.replaceAll("'", `'\\''`)}'` -} - -function printHelp() { - console.log(`Usage: bun run scripts/bench-agents.ts [options] - -Options: - --model provider/model Model passed to each agent. - --agents crabcode,opencode,codex Agents to run. - --tasks id-a,id-b Task IDs to run. - --runs 1 Repetitions per agent/task. - --timeout-ms 45000 Timeout per run. - --estimate Print planned prompt count and prompt-only cost, then exit. - --input-price 1.25 Input USD per 1M tokens for rough cost estimates. - --output-price 10 Output USD per 1M tokens for rough cost estimates. - --out bench-results.json Write machine-readable JSON results. - --report benchmark.md Write Markdown report at an exact path. - --report-dir benchmark-reports Directory for default Markdown reports. - --no-report Disable Markdown report generation. - --dir .benchmarks Parent directory for benchmark runs. - --keep Keep temporary workspaces for inspection. - -Default params: - model: ${DEFAULT_MODEL} - agents: ${DEFAULT_AGENTS.join(',')} - tasks: ${TASKS.map((task) => task.id).join(',')} - runs: ${DEFAULT_RUNS} - timeout-ms: ${DEFAULT_TIMEOUT_MS} - input-price: ${DEFAULT_INPUT_USD_PER_MTOK} - output-price: ${DEFAULT_OUTPUT_USD_PER_MTOK} - dir: ${DEFAULT_BENCHMARK_DIR} - report-dir: ${DEFAULT_REPORT_DIR} - -Environment overrides: - BENCH_MODEL, BENCH_AGENTS, BENCH_TASKS, BENCH_RUNS, BENCH_TIMEOUT_MS, - BENCH_INPUT_USD_PER_MTOK, BENCH_OUTPUT_USD_PER_MTOK, BENCH_DIR, BENCH_REPORT_DIR - -Stop behavior: - Ctrl+C stops the active agent process tree and removes temporary workspaces unless --keep is set. - -Command overrides: - BENCH_CRABCODE_CMD='crabcode -p --no-session-persistence --dangerously-skip-permissions {prompt}' - BENCH_OPENCODE_CMD='opencode run --dangerously-skip-permissions -m {model} {prompt}' - BENCH_CODEX_CMD='codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}' - -Template tokens: {prompt}, {model}, {repo} -Note: {model} is agent-aware; codex strips a leading openai/ provider prefix. -`) -} From 715edd4dfb62cb6103a86cd947fd9d5e320b22b3 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 30 May 2026 03:25:42 +0800 Subject: [PATCH 188/226] feat(benchmarking): add issue triage pipeline benchmark task. Add a new hard TypeScript multi-file benchmark fixture for issue triage (types, scoring/grouping pipeline, Markdown report, CLI, visible and hidden tests) and register it in the task index. Update benchmarking docs with the new task invocation example, and improve `bench-agents` intro output to show per-run timeout vs. max task timeout. --- benchmarking/README.md | 2 +- benchmarking/bench-agents.ts | 6 +- benchmarking/src/tasks/index.ts | 4 +- benchmarking/src/tasks/triage.ts | 416 +++++++++++++++++++++++++++++++ 4 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 benchmarking/src/tasks/triage.ts diff --git a/benchmarking/README.md b/benchmarking/README.md index d17d6e9..c77f2a7 100644 --- a/benchmarking/README.md +++ b/benchmarking/README.md @@ -13,6 +13,7 @@ Useful filters: ```sh just bench-agents --list-tasks just bench-agents --tasks workflow-planner-ts +just bench-agents --tasks issue-triage-pipeline-ts --agents crabcode,opencode,codex just bench-agents --tags typescript,hidden-tests just bench-agents --difficulty hard just bench-agents --estimate --agents crabcode,codex @@ -63,4 +64,3 @@ export const myTasks = [ ``` Keep prompts direct and checks deterministic. For harder tasks, prefer visible tests plus hidden tests injected by `bunTestWithHiddenFileCheck`. - diff --git a/benchmarking/bench-agents.ts b/benchmarking/bench-agents.ts index ba5f907..31c02be 100644 --- a/benchmarking/bench-agents.ts +++ b/benchmarking/bench-agents.ts @@ -90,6 +90,7 @@ if (!Number.isFinite(runs) || runs <= 0) { const plannedPrompts = selectedTasks.length * agents.length * runs const estimatedInputTokens = selectedTasks.reduce((sum, task) => sum + estimateTokens(benchmarkPrompt(task.prompt)), 0) * agents.length * runs const plannedCost = estimateCost(estimatedInputTokens, 0, inputPrice, outputPrice) +const maxRunTimeoutMs = Math.max(timeoutMs, ...selectedTasks.map((task) => Number(task.timeoutMs ?? timeoutMs))) printIntro() @@ -424,7 +425,9 @@ function printIntro() { console.log(` tasks: ${selectedTasks.map((task) => task.id).join(', ')}`) console.log(` runs: ${runs}`) console.log(` prompts: ${plannedPrompts}`) - console.log(` timeout: ${formatDuration(timeoutMs)}`) + console.log( + ` timeout: ${formatDuration(timeoutMs)}${maxRunTimeoutMs === timeoutMs ? '' : ` default, ${formatDuration(maxRunTimeoutMs)} max`}`, + ) console.log(` prompt cost: ${formatUsd(plannedCost)} estimated`) console.log('') console.log('Agent model args') @@ -491,4 +494,3 @@ function printSummary(results: RunResult[]) { console.log('\nMetric: Score is the percent of task runs where the command exited successfully and every deterministic check passed.') console.log('Cost is an estimate from prompt/output text tokens only; provider dashboards are the source of truth.') } - diff --git a/benchmarking/src/tasks/index.ts b/benchmarking/src/tasks/index.ts index 9f7c1ea..f922935 100644 --- a/benchmarking/src/tasks/index.ts +++ b/benchmarking/src/tasks/index.ts @@ -1,7 +1,7 @@ import { basicTasks } from './basic.ts' import { rustTasks } from './rust.ts' import { siteTasks } from './site.ts' +import { triageTasks } from './triage.ts' import { typescriptTasks } from './typescript.ts' -export const TASKS = [...basicTasks, ...rustTasks, ...siteTasks, ...typescriptTasks] - +export const TASKS = [...basicTasks, ...rustTasks, ...siteTasks, ...typescriptTasks, ...triageTasks] diff --git a/benchmarking/src/tasks/triage.ts b/benchmarking/src/tasks/triage.ts new file mode 100644 index 0000000..07832c4 --- /dev/null +++ b/benchmarking/src/tasks/triage.ts @@ -0,0 +1,416 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { bunTestWithHiddenFileCheck } from '../checks.ts' +import { defineTask } from './define.ts' + +export const triageTasks = [ + defineTask({ + id: 'issue-triage-pipeline-ts', + title: 'Implement a multi-file issue triage pipeline', + difficulty: 'hard', + tags: ['typescript', 'cli', 'multi-file', 'hidden-tests'], + timeoutMs: 120_000, + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'README.md': `# Triage Pipeline Fixture + +Implement the triage pipeline without adding dependencies. + +Scoring: +- p0/p1/p2/p3 severities are worth 100/70/40/10. +- security and data-loss labels add 25 points each. +- customer adds 15, regression adds 10, and low-priority subtracts 15. +- stale days are the full days between updatedAt and asOf, capped at 30. +- blocked marks an issue as blocked; blocked issues sort after ready issues in the same owner group. + +Sorting: +- Groups sort by total score descending, then owner alphabetically, with unassigned last. +- Issues sort by blocked status, score descending, severity, updatedAt oldest first, then id. + +Markdown: +- Start with "# Issue Triage - YYYY-MM-DD". +- Include "Open issues: N" and "Top issue: ID (score)". +- Each owner section is "## owner (N issues, score S)". +- Each issue line is "- [score] ID severity ready|blocked - title". +- Include a following " Labels: label, label" line when labels exist. +`, + 'src/types.ts': `export type Severity = 'p0' | 'p1' | 'p2' | 'p3' + +export type IssueInput = { + id: string + title: string + severity: string + labels?: string[] + owner?: string | null + status?: string + createdAt?: string + updatedAt: string +} + +export type RankedIssue = { + id: string + title: string + severity: Severity + labels: string[] + owner: string + status: 'open' + createdAt?: string + updatedAt: string + score: number + blocked: boolean +} + +export type TriageGroup = { + owner: string + issues: RankedIssue[] + totalScore: number +} + +export type TriagePlan = { + asOf: string + totalOpen: number + groups: TriageGroup[] + topIssue: RankedIssue | null +} + +export type PlanOptions = { + asOf?: string +} +`, + 'src/scoring.ts': `import type { IssueInput, Severity } from './types' + +export const severityOrder: Record = { + p0: 0, + p1: 1, + p2: 2, + p3: 3, +} + +export function normalizeSeverity(value: string): Severity { + const normalized = value.toLowerCase() + if (normalized === 'p0' || normalized === 'p1' || normalized === 'p2' || normalized === 'p3') { + return normalized + } + return 'p3' +} + +export function scoreIssue(issue: Pick, asOf: string): number { + return 0 +} +`, + 'src/triage.ts': `import { normalizeSeverity, scoreIssue, severityOrder } from './scoring' +import type { IssueInput, PlanOptions, RankedIssue, TriagePlan } from './types' + +export function normalizeIssues(issues: IssueInput[]): RankedIssue[] { + return issues + .filter((issue) => issue.status !== 'closed') + .map((issue) => ({ + id: issue.id, + title: issue.title, + severity: normalizeSeverity(issue.severity), + labels: issue.labels ?? [], + owner: issue.owner ?? 'unassigned', + status: 'open', + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + score: 0, + blocked: false, + })) +} + +export function createTriagePlan(issues: IssueInput[], options: PlanOptions = {}): TriagePlan { + const asOf = options.asOf ?? new Date().toISOString().slice(0, 10) + const normalized = normalizeIssues(issues).map((issue) => ({ + ...issue, + score: scoreIssue(issue, asOf), + blocked: issue.labels.includes('blocked'), + })) + + return { + asOf, + totalOpen: normalized.length, + groups: [], + topIssue: normalized[0] ?? null, + } +} + +export { severityOrder } +`, + 'src/report.ts': `import type { TriagePlan } from './types' + +export function renderTriageMarkdown(plan: TriagePlan): string { + return JSON.stringify(plan, null, 2) +} +`, + 'src/cli.ts': `import { readFileSync } from 'node:fs' +import { createTriagePlan } from './triage' +import { renderTriageMarkdown } from './report' +import type { IssueInput } from './types' + +const [, , filePath, ...args] = process.argv +const asOfIndex = args.indexOf('--as-of') +const asOf = asOfIndex >= 0 ? args[asOfIndex + 1] : undefined + +if (!filePath) { + console.error('Usage: bun src/cli.ts issues.json [--as-of YYYY-MM-DD]') + process.exit(1) +} + +const issues = JSON.parse(readFileSync(filePath, 'utf8')) as IssueInput[] +console.log(renderTriageMarkdown(createTriagePlan(issues, { asOf }))) +`, + 'src/index.ts': `export * from './types' +export * from './scoring' +export * from './triage' +export * from './report' +`, + 'tests/triage.test.ts': `import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { expect, test } from 'bun:test' +import { createTriagePlan, normalizeIssues, renderTriageMarkdown, scoreIssue, type IssueInput } from '../src/index' + +const sampleIssues: IssueInput[] = [ + { + id: 'PAY-9', + title: ' Card failures ', + severity: 'P1', + labels: ['Customer', 'Regression'], + owner: 'Payments', + status: 'open', + updatedAt: '2026-05-10', + }, + { + id: 'PAY-9', + title: 'Old duplicate', + severity: 'p3', + labels: [], + owner: 'payments', + status: 'open', + updatedAt: '2026-05-01', + }, + { + id: 'PAY-7', + title: 'Blocked webhook', + severity: 'p1', + labels: ['blocked'], + owner: 'payments', + status: 'open', + updatedAt: '2026-05-01', + }, + { + id: 'AUTH-1', + title: 'Token leak report', + severity: 'p2', + labels: ['security'], + owner: 'auth', + status: 'open', + updatedAt: '2026-05-18', + }, + { + id: 'DOC-4', + title: ' docs typo ', + severity: 'p3', + labels: ['LOW-PRIORITY'], + status: 'open', + updatedAt: '2026-04-01', + }, + { + id: 'DONE-1', + title: 'Already shipped', + severity: 'p0', + labels: ['customer'], + owner: 'payments', + status: 'closed', + updatedAt: '2026-05-19', + }, +] + +test('normalizes open issues and keeps the latest duplicate', () => { + const normalized = normalizeIssues(sampleIssues).map(({ id, title, severity, labels, owner, status, updatedAt, score, blocked }) => ({ + id, + title, + severity, + labels, + owner, + status, + updatedAt, + score, + blocked, + })) + + expect(normalized).toEqual([ + { + id: 'PAY-9', + title: 'Card failures', + severity: 'p1', + labels: ['customer', 'regression'], + owner: 'payments', + status: 'open', + updatedAt: '2026-05-10', + score: 0, + blocked: false, + }, + { + id: 'PAY-7', + title: 'Blocked webhook', + severity: 'p1', + labels: ['blocked'], + owner: 'payments', + status: 'open', + updatedAt: '2026-05-01', + score: 0, + blocked: false, + }, + { + id: 'AUTH-1', + title: 'Token leak report', + severity: 'p2', + labels: ['security'], + owner: 'auth', + status: 'open', + updatedAt: '2026-05-18', + score: 0, + blocked: false, + }, + { + id: 'DOC-4', + title: 'docs typo', + severity: 'p3', + labels: ['low-priority'], + owner: 'unassigned', + status: 'open', + updatedAt: '2026-04-01', + score: 0, + blocked: false, + }, + ]) +}) + +test('scores, groups, and orders issues for triage', () => { + expect(scoreIssue(sampleIssues[0], '2026-05-20')).toBe(105) + + const plan = createTriagePlan(sampleIssues, { asOf: '2026-05-20' }) + + expect(plan.totalOpen).toBe(4) + expect(plan.groups.map((group) => group.owner)).toEqual(['payments', 'auth', 'unassigned']) + expect(plan.groups[0].totalScore).toBe(194) + expect(plan.groups[0].issues.map((issue) => \`\${issue.id}:\${issue.score}:\${issue.blocked}\`)).toEqual([ + 'PAY-9:105:false', + 'PAY-7:89:true', + ]) + expect(plan.topIssue?.id).toBe('PAY-9') +}) + +test('renders the deterministic markdown report', () => { + const markdown = renderTriageMarkdown(createTriagePlan(sampleIssues, { asOf: '2026-05-20' })) + + expect(markdown).toContain('# Issue Triage - 2026-05-20') + expect(markdown).toContain('Open issues: 4') + expect(markdown).toContain('Top issue: PAY-9 (105)') + expect(markdown).toContain('## payments (2 issues, score 194)') + expect(markdown).toContain('- [105] PAY-9 p1 ready - Card failures') + expect(markdown).toContain(' Labels: customer, regression') + expect(markdown).toContain('- [89] PAY-7 p1 blocked - Blocked webhook') +}) + +test('CLI reads a JSON file and renders markdown', () => { + const dir = mkdtempSync(join(tmpdir(), 'triage-bench-')) + const input = join(dir, 'issues.json') + writeFileSync(input, JSON.stringify(sampleIssues)) + + const result = spawnSync(process.execPath, ['src/cli.ts', input, '--as-of', '2026-05-20'], { + cwd: process.cwd(), + encoding: 'utf8', + }) + + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('# Issue Triage - 2026-05-20') + expect(result.stdout).toContain('PAY-9') +}) +`, + }, + prompt: `Implement the issue triage pipeline described in README.md. You will need to update the TypeScript modules under src/ so normalization, scoring, grouping, Markdown rendering, and the CLI all work together. + +Requirements: +- Do not change tests or add dependencies. +- Deduplicate issues by id before filtering; when duplicates exist, keep the issue with the latest updatedAt. +- Treat missing or non-open status as open, but remove issues whose latest status is closed. +- Normalize titles by trimming whitespace; normalize owners and labels to lowercase; default missing owner to unassigned. +- Score with the README rules, using full stale days from updatedAt to asOf and a 30 day cap. +- Build owner groups with total scores, sorted as described in README.md. +- Mark blocked issues from the blocked label and sort blocked issues after ready issues in the same group. +- Set topIssue to the highest-priority ready issue across the full plan, falling back to the highest-priority blocked issue only when every issue is blocked. +- renderTriageMarkdown must follow the README Markdown format and include "No open issues" for an empty plan. +- src/cli.ts must read the JSON input file, support --as-of YYYY-MM-DD, print the Markdown report, and exit with a non-zero code plus a useful error on invalid input.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/triage.test.ts'), 'utf8') + return [ + { + name: 'keeps visible triage coverage', + pass: + testFile.includes('normalizes open issues') && + testFile.includes('scores, groups, and orders issues') && + testFile.includes('CLI reads a JSON file'), + }, + bunTestWithHiddenFileCheck( + cwd, + 'hidden triage pipeline tests pass', + 'tests/__bench_hidden_triage.test.ts', + `import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { expect, test } from 'bun:test' +import { createTriagePlan, normalizeIssues, renderTriageMarkdown, type IssueInput } from '../src/index' + +test('latest closed duplicate removes the issue from the plan', () => { + const issues: IssueInput[] = [ + { id: 'A', title: 'open first', severity: 'p0', status: 'open', updatedAt: '2026-05-01' }, + { id: 'A', title: 'closed later', severity: 'p0', status: 'closed', updatedAt: '2026-05-02' }, + { id: 'B', title: 'still open', severity: 'p2', labels: ['customer'], updatedAt: '2026-05-03' }, + ] + + expect(normalizeIssues(issues).map((issue) => issue.id)).toEqual(['B']) + expect(createTriagePlan(issues, { asOf: '2026-05-10' }).totalOpen).toBe(1) +}) + +test('ready issues sort before blocked issues even when blocked scores higher', () => { + const plan = createTriagePlan( + [ + { id: 'B', title: 'blocked critical', severity: 'p0', labels: ['blocked', 'security'], owner: 'Ops', updatedAt: '2026-05-01' }, + { id: 'C', title: 'ready same score', severity: 'p2', owner: 'ops', updatedAt: '2026-05-09' }, + { id: 'A', title: 'ready same score', severity: 'p2', owner: 'ops', updatedAt: '2026-05-09' }, + ], + { asOf: '2026-05-10' }, + ) + + expect(plan.groups[0].owner).toBe('ops') + expect(plan.groups[0].issues.map((issue) => issue.id)).toEqual(['A', 'C', 'B']) + expect(plan.topIssue?.id).toBe('A') +}) + +test('empty reports stay useful and the CLI reports invalid JSON', () => { + const markdown = renderTriageMarkdown(createTriagePlan([], { asOf: '2026-05-20' })) + expect(markdown).toContain('Open issues: 0') + expect(markdown).toContain('No open issues') + + const dir = mkdtempSync(join(tmpdir(), 'triage-hidden-')) + const input = join(dir, 'broken.json') + writeFileSync(input, '{not json') + const result = spawnSync(process.execPath, ['src/cli.ts', input], { + cwd: process.cwd(), + encoding: 'utf8', + }) + + expect(result.status).not.toBe(0) + expect(result.stderr).toMatch(/invalid|json|parse/i) +}) +`, + ), + ] + }, + }), +] From 92f524eb831d7402643c298e74d3a5b24a8c8fff Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 30 May 2026 18:50:27 +0800 Subject: [PATCH 189/226] feat: add non-interactive print mode and batched file write support. - Add print-mode runtime behavior that disables interactive workflows (`question`, `update_plan`), updates system prompt context, and auto-denies question prompts while streaming. - Introduce a new `write_files` tool for replacing complete file sets atomically, plus registry wiring, permission/policy integration, and dedicated permission-path tests. - Enhance benchmarking to pass model selection through default crabcode commands, honor configured binaries, improve timeout reporting with override visibility, and add related tests. --- _plans/__TODOS.md | 2 + benchmarking/src/agents.test.ts | 46 ++++++++ benchmarking/src/agents.ts | 38 ++++++- benchmarking/src/cli.ts | 4 +- benchmarking/src/report.test.ts | 43 ++++++++ benchmarking/src/report.ts | 9 +- benchmarking/src/tasks/triage.ts | 2 +- src/main.rs | 42 +++++++- src/prompt/mod.rs | 32 ++++++ src/tools/fs/mod.rs | 2 +- src/tools/fs/write.rs | 179 ++++++++++++++++++++++++++----- src/tools/init.rs | 3 +- src/tools/permission.rs | 151 ++++++++++++++++++++++++-- 13 files changed, 506 insertions(+), 47 deletions(-) create mode 100644 benchmarking/src/agents.test.ts create mode 100644 benchmarking/src/report.test.ts diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 3f41592..4f530b7 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -217,3 +217,5 @@ I want - [x] To do this But I dont want to do this - [x] When I do "Undo" on a message that had an attachment / image. It goes back to my input, but it isn't highlighted anymore, meaning that image is probably not visible anymore right? Is there a way to persist that? - [x] Emit the same Loading stuff that codex does. So that Zed knows when the agent is "in progress". + +- [ ] During /compact, i can't queue a message, the same way I can usually queue messages while streaming. Btw except in compact, compaction has to be completely done before it registers my queued message until it's fully processed. diff --git a/benchmarking/src/agents.test.ts b/benchmarking/src/agents.test.ts new file mode 100644 index 0000000..edd8512 --- /dev/null +++ b/benchmarking/src/agents.test.ts @@ -0,0 +1,46 @@ +import { afterEach, expect, test } from 'bun:test' +import { benchmarkPrompt, commandFor } from './agents.ts' + +const originalCrabcodeCommand = process.env.BENCH_CRABCODE_CMD +const originalCrabcodeBin = process.env.BENCH_CRABCODE_BIN + +afterEach(() => { + if (originalCrabcodeCommand === undefined) { + delete process.env.BENCH_CRABCODE_CMD + } else { + process.env.BENCH_CRABCODE_CMD = originalCrabcodeCommand + } + if (originalCrabcodeBin === undefined) { + delete process.env.BENCH_CRABCODE_BIN + } else { + process.env.BENCH_CRABCODE_BIN = originalCrabcodeBin + } +}) + +test('default crabcode benchmark command pins the requested model', () => { + delete process.env.BENCH_CRABCODE_CMD + delete process.env.BENCH_CRABCODE_BIN + + const command = commandFor('crabcode', 'fix the fixture', 'openai/gpt-5.5') + + expect(command).toContain("-m 'openai/gpt-5.5'") + expect(command).toContain("'fix the fixture'") +}) + +test('crabcode benchmark command supports an explicit optimized binary', () => { + delete process.env.BENCH_CRABCODE_CMD + process.env.BENCH_CRABCODE_BIN = '/tmp/crabcode-release' + + const command = commandFor('crabcode', 'fix the fixture', 'openai/gpt-5.5') + + expect(command).toContain("'/tmp/crabcode-release'") + expect(command).toContain("-m 'openai/gpt-5.5'") +}) + +test('benchmark prompt asks agents to stop after concise validation summary', () => { + const prompt = benchmarkPrompt('Fix the bug.') + + expect(prompt).toContain('When the task is complete, stop.') + expect(prompt).toContain('After verification, give a final answer in at most two short lines') + expect(prompt).toContain('Do not enumerate every edited file') +}) diff --git a/benchmarking/src/agents.ts b/benchmarking/src/agents.ts index 7d01594..71c434f 100644 --- a/benchmarking/src/agents.ts +++ b/benchmarking/src/agents.ts @@ -1,5 +1,5 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' +import { accessSync, constants, existsSync } from 'node:fs' +import { delimiter, join } from 'node:path' import { DEFAULT_AGENTS, REPO_ROOT } from './defaults.ts' import { shellQuote } from './format.ts' import type { AgentName, BenchmarkTask } from './types.ts' @@ -36,6 +36,8 @@ export function benchmarkPrompt(prompt: string) { 'Keep the change minimal. When the task is complete, stop.', 'If the task names exact file paths, inspect those paths directly instead of listing directories first.', 'Do not repeat identical tool calls or run optional extra checks after the requested change is complete.', + 'After verification, give a final answer in at most two short lines: what changed and what validation ran.', + 'Do not enumerate every edited file or continue explaining once the task is complete.', '', `Task: ${prompt}`, ].join('\n') @@ -54,11 +56,38 @@ export function modelForAgent(agent: AgentName, modelRef: string) { } function defaultCrabcodeCommand() { + const configuredBinary = process.env.BENCH_CRABCODE_BIN?.trim() + if (configuredBinary) { + return `${shellQuote(configuredBinary)} -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` + } + + const installedBinary = findExecutableOnPath('crabcode') + if (installedBinary) { + return `${shellQuote(installedBinary)} -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` + } + + const releaseBinary = join(REPO_ROOT, 'target', 'release', 'crabcode') + if (existsSync(releaseBinary)) { + return `${shellQuote(releaseBinary)} -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` + } + const binary = join(REPO_ROOT, 'target', 'debug', 'crabcode') if (existsSync(binary)) { - return `${shellQuote(binary)} -p --no-session-persistence --dangerously-skip-permissions {prompt}` + return `${shellQuote(binary)} -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` } - return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- -p --no-session-persistence --dangerously-skip-permissions {prompt}` + return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` +} + +function findExecutableOnPath(name: string) { + const pathValue = process.env.PATH ?? '' + for (const dir of pathValue.split(delimiter).filter(Boolean)) { + const candidate = join(dir, name) + try { + accessSync(candidate, constants.X_OK) + return candidate + } catch {} + } + return null } export function assertAgentName(value: string): asserts value is AgentName { @@ -66,4 +95,3 @@ export function assertAgentName(value: string): asserts value is AgentName { throw new Error(`Unknown agent: ${value}. Expected one of ${DEFAULT_AGENTS.join(', ')}`) } } - diff --git a/benchmarking/src/cli.ts b/benchmarking/src/cli.ts index dc8aa82..2278bc7 100644 --- a/benchmarking/src/cli.ts +++ b/benchmarking/src/cli.ts @@ -85,7 +85,7 @@ Options: --difficulty hard Run tasks by difficulty: smoke, medium, hard. --list-tasks Print available tasks and exit. --runs 1 Repetitions per agent/task. - --timeout-ms 45000 Timeout per run. + --timeout-ms ${DEFAULT_TIMEOUT_MS} Default timeout per run. --estimate Print planned prompt count and prompt-only cost, then exit. --input-price 1.25 Input USD per 1M tokens for rough cost estimates. --output-price 10 Output USD per 1M tokens for rough cost estimates. @@ -116,7 +116,7 @@ Stop behavior: Ctrl+C stops the active agent process tree and removes temporary workspaces unless --keep is set. Command overrides: - BENCH_CRABCODE_CMD='crabcode -p --no-session-persistence --dangerously-skip-permissions {prompt}' + BENCH_CRABCODE_CMD='crabcode -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}' BENCH_OPENCODE_CMD='opencode run --dangerously-skip-permissions -m {model} {prompt}' BENCH_CODEX_CMD='codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}' diff --git a/benchmarking/src/report.test.ts b/benchmarking/src/report.test.ts new file mode 100644 index 0000000..06e0654 --- /dev/null +++ b/benchmarking/src/report.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'bun:test' +import { writeMarkdownReport } from './report.ts' +import type { BenchmarkTask } from './types.ts' + +test('markdown report shows task timeout overrides separately from the default timeout', () => { + const dir = mkdtempSync(join(tmpdir(), 'crabcode-bench-report-')) + const reportPath = join(dir, 'report.md') + const tasks: BenchmarkTask[] = [ + { + id: 'issue-triage-pipeline-ts', + title: 'Implement triage', + prompt: 'Implement triage.', + timeoutMs: 180_000, + files: {}, + check: () => [], + }, + ] + + writeMarkdownReport(reportPath, { + runId: 'test-run', + runRoot: dir, + workspacesRoot: join(dir, 'workspaces'), + logsRoot: join(dir, 'logs'), + model: 'openai/gpt-5.5', + agents: ['crabcode'], + tasks, + runs: 1, + plannedPrompts: 1, + timeoutMs: 45_000, + keep: false, + inputPrice: 1.25, + outputPrice: 10, + results: [], + stopped: false, + }) + + const markdown = readFileSync(reportPath, 'utf8') + expect(markdown).toContain('Default timeout per run: 45000ms') + expect(markdown).toContain('Task timeout overrides: `issue-triage-pipeline-ts=180000ms`') +}) diff --git a/benchmarking/src/report.ts b/benchmarking/src/report.ts index a65d699..fd96034 100644 --- a/benchmarking/src/report.ts +++ b/benchmarking/src/report.ts @@ -57,7 +57,13 @@ export function writeMarkdownReport( lines.push(`Tasks: ${report.tasks.map((task) => `\`${task.id}\``).join(', ')}`) lines.push(`Runs per agent/task: ${report.runs}`) lines.push(`Completed runs: ${report.results.length}/${report.plannedPrompts}`) - lines.push(`Timeout per run: ${report.timeoutMs}ms`) + lines.push(`Default timeout per run: ${report.timeoutMs}ms`) + const timeoutOverrides = report.tasks + .filter((task) => task.timeoutMs !== undefined && task.timeoutMs !== report.timeoutMs) + .map((task) => `${task.id}=${task.timeoutMs}ms`) + if (timeoutOverrides.length) { + lines.push(`Task timeout overrides: ${timeoutOverrides.map((override) => `\`${override}\``).join(', ')}`) + } lines.push(`Benchmark run directory: \`${report.runRoot}\``) lines.push(`Agents ran in: \`${report.workspacesRoot}\``) lines.push(`Logs: \`${report.logsRoot}\``) @@ -128,4 +134,3 @@ export function writeMarkdownReport( writeFileSync(path, lines.join('\n') + '\n') } - diff --git a/benchmarking/src/tasks/triage.ts b/benchmarking/src/tasks/triage.ts index 07832c4..66a8cdc 100644 --- a/benchmarking/src/tasks/triage.ts +++ b/benchmarking/src/tasks/triage.ts @@ -9,7 +9,7 @@ export const triageTasks = [ title: 'Implement a multi-file issue triage pipeline', difficulty: 'hard', tags: ['typescript', 'cli', 'multi-file', 'hidden-tests'], - timeoutMs: 120_000, + timeoutMs: 240_000, files: { 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', 'README.md': `# Triage Pipeline Fixture diff --git a/src/main.rs b/src/main.rs index 955cddc..8112518 100644 --- a/src/main.rs +++ b/src/main.rs @@ -205,9 +205,11 @@ async fn run_print_mode( for (mode, tools) in agent_registry.tool_policy_map() { agent_policies = agent_policies.with_custom_tools(mode, tools); } + let permission_rules = + print_mode_permission_rules(loaded_config.merged_config.permission_rules.clone()); let tool_permissions = crate::tools::ToolPermissions::new(std::path::PathBuf::from(&cwd)) .with_agent_policies(agent_policies) - .with_permission_rules(loaded_config.merged_config.permission_rules.clone()) + .with_permission_rules(permission_rules) .with_agent_permission_rules(agent_registry.permission_rules_map()) .dangerously_skip_permissions(dangerously_skip_permissions); let agent_max_steps = agent_registry @@ -237,7 +239,8 @@ async fn run_print_mode( std::env::consts::OS, ) .with_tool_registry(prompt_registry) - .with_agent_registry(agent_registry.clone()); + .with_agent_registry(agent_registry.clone()) + .with_print_mode(true); let system_prompt = composer.compose().await; let messages = vec![Message::system(system_prompt), Message::user(prompt)]; @@ -295,6 +298,12 @@ async fn run_print_mode( .response_tx .send(crate::tools::PermissionResponse::Deny); } + crate::llm::ChunkMessage::QuestionRequest { response_tx, .. } => { + let _ = response_tx.send(serde_json::json!({ + "skipped": true, + "reason": "Question prompts are unavailable in non-interactive print mode" + })); + } _ => {} } } @@ -303,6 +312,19 @@ async fn run_print_mode( Ok(()) } +fn print_mode_permission_rules( + mut rules: crate::tools::PermissionRules, +) -> crate::tools::PermissionRules { + for tool_id in ["question", "update_plan"] { + rules.push(crate::tools::PermissionRule { + permission: tool_id.to_string(), + pattern: "*".to_string(), + action: crate::tools::PermissionPolicyAction::Deny, + }); + } + rules +} + lazy_static::lazy_static! { static ref TOAST_MANAGER: Mutex = Mutex::new(ToastManager::new()); } @@ -567,6 +589,22 @@ mod tests { "Examine the diff.\n\n\ndiff --git a/a b/a\n+change\n" ); } + + #[test] + fn print_mode_denies_interactive_tools() { + let rules = print_mode_permission_rules(Vec::new()); + + assert!(rules.iter().any(|rule| { + rule.permission == "question" + && rule.pattern == "*" + && rule.action == crate::tools::PermissionPolicyAction::Deny + })); + assert!(rules.iter().any(|rule| { + rule.permission == "update_plan" + && rule.pattern == "*" + && rule.action == crate::tools::PermissionPolicyAction::Deny + })); + } } async fn run_event_loop( diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 6b38501..a9058b4 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -34,6 +34,7 @@ pub struct SystemPromptComposer { working_directory: String, is_git_repo: bool, platform: String, + print_mode: bool, tool_registry: Option, agent_registry: Option, } @@ -50,6 +51,7 @@ impl SystemPromptComposer { working_directory: working_directory.into(), is_git_repo, platform: platform.into(), + print_mode: false, tool_registry: None, agent_registry: None, } @@ -68,11 +70,19 @@ impl SystemPromptComposer { self } + pub fn with_print_mode(mut self, print_mode: bool) -> Self { + self.print_mode = print_mode; + self + } + pub async fn compose(&self) -> String { let mut parts = Vec::new(); parts.push(self.get_header()); parts.push(self.get_core_prompt()); + if self.print_mode { + parts.push(self.get_print_mode_context()); + } parts.push(self.get_environment_context()); if let Some(ref registry) = self.tool_registry { @@ -219,6 +229,8 @@ Progress Updates and Final Answers: - If work remains, continue with tools instead of sending a final answer. - Use final answers only when the requested work is complete, verified when practical, and ready to hand back. - Keep final answers concise and focused on what changed, validation run, and any real blocker. +- For routine code changes, prefer one or two compact sentences plus validation; do not list every edited file unless that detail is needed. +- Once the final answer is complete, stop instead of continuing with extra explanation. Planning: - Use update_plan for non-trivial, multi-phase work @@ -268,6 +280,15 @@ Your output will be displayed on a command line interface. Your responses should ) } + fn get_print_mode_context(&self) -> String { + r#"Non-Interactive Print Mode: +- Keep planning internal; do not call update_plan. +- Do not ask the user questions or wait for interactive input. +- Prefer direct read/edit/write/bash tool use, and prefer write_files when replacing complete contents of multiple files. +- After requested validation passes, send a compact final answer and stop."# + .to_string() + } + async fn get_tools_context(&self, registry: &ToolRegistry) -> String { let schemas = registry.list_schemas().await; @@ -398,4 +419,15 @@ mod tests { assert!(prompt.contains("do not call update_plan again unless the plan content")); assert!(prompt.contains("do not stop at a proposed solution in chat")); } + + #[test] + fn print_mode_context_disables_interactive_planning() { + let composer = SystemPromptComposer::new("gpt-5", ".", true, "test").with_print_mode(true); + let context = composer.get_print_mode_context(); + + assert!(context.contains("do not call update_plan")); + assert!(context.contains("Do not ask the user questions")); + assert!(context.contains("write_files")); + assert!(context.contains("stop")); + } } diff --git a/src/tools/fs/mod.rs b/src/tools/fs/mod.rs index 3e1971f..6173b67 100644 --- a/src/tools/fs/mod.rs +++ b/src/tools/fs/mod.rs @@ -10,4 +10,4 @@ pub use grep::GrepTool; pub use list::ListTool; pub use read::ReadTool; pub use view_image::ViewImageTool; -pub use write::WriteTool; +pub use write::{WriteFilesTool, WriteTool}; diff --git a/src/tools/fs/write.rs b/src/tools/fs/write.rs index 7c8d42e..31ffd01 100644 --- a/src/tools/fs/write.rs +++ b/src/tools/fs/write.rs @@ -4,9 +4,11 @@ use crate::tools::{ }; use async_trait::async_trait; use serde_json::Value; +use std::collections::HashMap; use std::path::Path; pub struct WriteTool; +pub struct WriteFilesTool; impl WriteTool { pub fn new() -> Self { @@ -14,6 +16,36 @@ impl WriteTool { } } +impl WriteFilesTool { + pub fn new() -> Self { + Self + } +} + +fn write_one_file(file_path: &str, content: &str) -> Result<(bool, u64), ToolError> { + let path = Path::new(file_path); + let is_new = !path.exists(); + + if let Some(parent) = path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).map_err(|e| { + ToolError::Execution(format!("Failed to create directories: {}", e)) + })?; + } + } + + let temp_path = path.with_extension("tmp"); + + std::fs::write(&temp_path, content) + .map_err(|e| ToolError::Execution(format!("Failed to write temp file: {}", e)))?; + + std::fs::rename(&temp_path, path) + .map_err(|e| ToolError::Execution(format!("Failed to rename file: {}", e)))?; + + let bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + Ok((is_new, bytes)) +} + #[async_trait] impl ToolHandler for WriteTool { fn definition(&self) -> Tool { @@ -49,38 +81,135 @@ impl ToolHandler for WriteTool { let content = get_string_param(¶ms, "content") .ok_or_else(|| ToolError::Validation("content is required".to_string()))?; - let path = Path::new(&file_path); - let is_new = !path.exists(); + let (is_new, bytes) = write_one_file(&file_path, &content)?; + + Ok(ToolResult::new( + format!("Write: {}", file_path), + if is_new { + format!("Created file with {} bytes", bytes) + } else { + format!("Updated file with {} bytes", bytes) + }, + )) + } +} + +#[async_trait] +impl ToolHandler for WriteFilesTool { + fn definition(&self) -> Tool { + let mut file_props = HashMap::new(); + file_props.insert("file_path".to_string(), ParameterType::String); + file_props.insert("content".to_string(), ParameterType::String); - if let Some(parent) = path.parent() { - if !parent.exists() { - std::fs::create_dir_all(parent).map_err(|e| { - ToolError::Execution(format!("Failed to create directories: {}", e)) - })?; + Tool { + id: "write_files".to_string(), + description: "Create or overwrite multiple files in one call. Prefer this over repeated write calls when replacing complete contents of 2 or more files.".to_string(), + parameters: vec![ParameterSchema { + name: "files".to_string(), + description: "Array of files to write, each with file_path and content.".to_string(), + required: true, + param_type: ParameterType::Array(Box::new(ParameterType::Object(file_props))), + }], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["files"])?; + let files = params + .get("files") + .and_then(Value::as_array) + .ok_or_else(|| ToolError::Validation("files must be an array".to_string()))?; + + if files.is_empty() { + return Err(ToolError::Validation( + "files must include at least one file".to_string(), + )); + } + + for (index, file) in files.iter().enumerate() { + let Some(obj) = file.as_object() else { + return Err(ToolError::Validation(format!( + "files[{index}] must be an object" + ))); + }; + if !obj.get("file_path").is_some_and(Value::is_string) { + return Err(ToolError::Validation(format!( + "files[{index}].file_path is required" + ))); + } + if !obj.get("content").is_some_and(Value::is_string) { + return Err(ToolError::Validation(format!( + "files[{index}].content is required" + ))); } } - let temp_path = path.with_extension("tmp"); + Ok(()) + } - std::fs::write(&temp_path, content) - .map_err(|e| ToolError::Execution(format!("Failed to write temp file: {}", e)))?; + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let files = params + .get("files") + .and_then(Value::as_array) + .ok_or_else(|| ToolError::Validation("files must be an array".to_string()))?; - std::fs::rename(&temp_path, path) - .map_err(|e| ToolError::Execution(format!("Failed to rename file: {}", e)))?; + let mut summaries = Vec::with_capacity(files.len()); + for file in files { + let file_path = file + .get("file_path") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::Validation("file_path is required".to_string()))?; + let content = file + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::Validation("content is required".to_string()))?; + let (is_new, bytes) = write_one_file(file_path, content)?; + let action = if is_new { "created" } else { "updated" }; + summaries.push(format!("{file_path}: {action} {bytes} bytes")); + } Ok(ToolResult::new( - format!("Write: {}", file_path), - if is_new { - format!( - "Created file with {} bytes", - std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) - ) - } else { - format!( - "Updated file with {} bytes", - std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) - ) - }, - )) + format!("Write files: {}", summaries.len()), + summaries.join("\n"), + ) + .with_metadata("file_count", serde_json::json!(summaries.len()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolHandler; + + fn test_context() -> ToolContext { + let (_tx, rx) = tokio::sync::watch::channel(false); + ToolContext::new("session", "message", "build", rx) + } + + #[tokio::test] + async fn write_files_creates_and_updates_multiple_files() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("a.txt"); + let second = dir.path().join("nested/b.txt"); + std::fs::write(&first, "old").unwrap(); + + let result = WriteFilesTool::new() + .execute( + serde_json::json!({ + "files": [ + { "file_path": first.to_string_lossy(), "content": "new" }, + { "file_path": second.to_string_lossy(), "content": "created" } + ] + }), + &test_context(), + ) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(first).unwrap(), "new"); + assert_eq!(std::fs::read_to_string(second).unwrap(), "created"); + assert!(result.output.contains("updated 3 bytes")); + assert!(result.output.contains("created 7 bytes")); + assert_eq!(result.metadata["file_count"], serde_json::json!(2)); } } diff --git a/src/tools/init.rs b/src/tools/init.rs index d112076..382435f 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,5 +1,5 @@ use crate::tools::{ - fs::{GlobTool, GrepTool, ListTool, ReadTool, ViewImageTool, WriteTool}, + fs::{GlobTool, GrepTool, ListTool, ReadTool, ViewImageTool, WriteFilesTool, WriteTool}, BashTool, EditTool, QuestionTool, SkillTool, TaskTool, ToolPermissions, ToolRegistry, UpdatePlanTool, WebfetchTool, }; @@ -15,6 +15,7 @@ pub async fn initialize_tool_registry() -> ToolRegistry { registry.register(Arc::new(ReadTool::new())).await; registry.register(Arc::new(ViewImageTool::new())).await; registry.register(Arc::new(WriteTool::new())).await; + registry.register(Arc::new(WriteFilesTool::new())).await; registry.register(Arc::new(BashTool::new())).await; registry.register(Arc::new(EditTool::new())).await; registry.register(Arc::new(SkillTool::new())).await; diff --git a/src/tools/permission.rs b/src/tools/permission.rs index 6ef2134..128378c 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -26,7 +26,7 @@ impl PermissionAction { pub fn from_tool_id(tool_id: &str) -> Self { match tool_id { "read" | "view_image" => Self::Read, - "write" => Self::Write, + "write" | "write_files" => Self::Write, "edit" => Self::Edit, "list" => Self::List, "glob" => Self::Glob, @@ -147,7 +147,7 @@ impl AgentToolPolicies { if mode == "plan" { // OpenCode plan mode is read-only by default. Custom agent tool // policies above can still opt specific tools back in. - return !matches!(tool.as_str(), "bash" | "write" | "edit"); + return !matches!(tool.as_str(), "bash" | "write" | "write_files" | "edit"); } if mode == "build" { @@ -250,7 +250,8 @@ impl ToolPermissions { } let action = PermissionAction::from_tool_id(tool_id); - let path = extract_primary_path(action, params, &self.workdir); + let paths = extract_primary_paths(tool_id, action, params, &self.workdir); + let path = paths.first().cloned(); let command = if action == PermissionAction::Bash { get_string(params, "command").map(|s| s.trim().to_string()) } else { @@ -287,24 +288,26 @@ impl ToolPermissions { _ => {} } - let mut reason = self.evaluate_reason(action, path.as_deref()); + let (mut reason, mut reason_path) = self.evaluate_reasons(action, &paths); + let prompt_path = reason_path.as_deref().or(path.as_deref()); if let Some(reason_kind) = reason { match self.evaluate_guard_decision( agent_mode, tool_id, reason_kind, - path.as_deref(), + prompt_path, &patterns, ) { Some(PermissionPolicyAction::Deny) => { return Err(ToolError::Permission(guard_deny_text( reason_kind, tool_id, - path.as_deref(), + prompt_path, ))); } Some(PermissionPolicyAction::Allow) => { reason = None; + reason_path = None; } _ => {} } @@ -320,7 +323,7 @@ impl ToolPermissions { tool_id, action, reason_kind, - path.as_deref(), + reason_path.as_deref().or(path.as_deref()), command.clone(), sender, ) @@ -515,6 +518,19 @@ impl ToolPermissions { None } + fn evaluate_reasons( + &self, + action: PermissionAction, + paths: &[PathBuf], + ) -> (Option, Option) { + for path in paths { + if let Some(reason) = self.evaluate_reason(action, Some(path.as_path())) { + return (Some(reason), Some(path.clone())); + } + } + (None, None) + } + async fn evaluate_doom_loop( &self, tool_id: &str, @@ -636,7 +652,7 @@ fn get_string(params: &Value, key: &str) -> Option { fn permission_key_for_tool_id(tool_id: &str) -> String { match tool_id.trim().to_ascii_lowercase().as_str() { - "write" | "edit" => "edit".to_string(), + "write" | "write_files" | "edit" => "edit".to_string(), "read" | "view_image" => "read".to_string(), other => other.to_string(), } @@ -684,6 +700,18 @@ fn permission_patterns_for_tool( } } "question" | "update_plan" => patterns.push("*".to_string()), + "write_files" => { + if let Some(files) = params.get("files").and_then(Value::as_array) { + for file in files { + if let Some(path) = get_string(file, "file_path") + .or_else(|| get_string(file, "filePath")) + .or_else(|| get_string(file, "path")) + { + push_nonempty(&mut patterns, &path); + } + } + } + } _ => {} } @@ -837,6 +865,7 @@ fn extract_primary_path( get_string(params, "file_path") .or_else(|| get_string(params, "filePath")) .or_else(|| get_string(params, "path")) + .or_else(|| first_write_files_path(params)) } PermissionAction::List | PermissionAction::Glob | PermissionAction::Grep => { get_string(params, "path").or_else(|| Some(".".to_string())) @@ -850,6 +879,53 @@ fn extract_primary_path( Some(resolve_path(&raw, workdir)) } +fn extract_primary_paths( + tool_id: &str, + action: PermissionAction, + params: &Value, + workdir: &Path, +) -> Vec { + if tool_id == "write_files" { + return write_files_paths(params) + .into_iter() + .map(|path| resolve_path(&path, workdir)) + .collect(); + } + + extract_primary_path(action, params, workdir) + .into_iter() + .collect() +} + +fn write_files_paths(params: &Value) -> Vec { + params + .get("files") + .and_then(Value::as_array) + .map(|files| { + files + .iter() + .filter_map(|file| { + get_string(file, "file_path") + .or_else(|| get_string(file, "filePath")) + .or_else(|| get_string(file, "path")) + }) + .collect() + }) + .unwrap_or_default() +} + +fn first_write_files_path(params: &Value) -> Option { + params + .get("files") + .and_then(Value::as_array) + .and_then(|files| files.first()) + .and_then(|file| { + get_string(file, "file_path") + .or_else(|| get_string(file, "filePath")) + .or_else(|| get_string(file, "path")) + }) +} + pub fn resolve_path(raw: &str, workdir: &Path) -> PathBuf { let p = PathBuf::from(raw); if p.is_absolute() { @@ -926,6 +1002,7 @@ mod tests { assert!(policies.is_allowed("plan", "glob")); assert!(!policies.is_allowed("plan", "bash")); assert!(!policies.is_allowed("plan", "write")); + assert!(!policies.is_allowed("plan", "write_files")); assert!(!policies.is_allowed("plan", "edit")); } @@ -970,6 +1047,27 @@ mod tests { assert_eq!(extracted, PathBuf::from("/tmp/workspace/.env")); } + #[test] + fn extract_primary_paths_collects_all_write_files_paths() { + let wd = PathBuf::from("/tmp/workspace"); + let params = serde_json::json!({ + "files": [ + { "file_path": "src/a.ts", "content": "a" }, + { "file_path": "/tmp/elsewhere/b.ts", "content": "b" } + ] + }); + + let extracted = extract_primary_paths("write_files", PermissionAction::Write, ¶ms, &wd); + + assert_eq!( + extracted, + vec![ + PathBuf::from("/tmp/workspace/src/a.ts"), + PathBuf::from("/tmp/elsewhere/b.ts") + ] + ); + } + #[tokio::test] async fn allow_always_persists_for_same_request_fingerprint() { let perms = ToolPermissions::new("/tmp/workspace"); @@ -1069,6 +1167,43 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn write_files_prompts_for_external_secondary_path() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ + "files": [ + { "file_path": "/tmp/workspace/src/a.ts", "content": "a" }, + { "file_path": "/tmp/elsewhere/b.ts", "content": "b" } + ] + }); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { + perms + .preflight("build", "write_files", ¶ms, Some(&tx)) + .await + } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "write_files"); + assert_eq!(prompt.action, PermissionAction::Write); + assert_eq!(prompt.target.as_deref(), Some("/tmp/elsewhere/b.ts")); + assert!(prompt.reason.contains("outside working directory")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + #[tokio::test] async fn search_tools_prompt_for_external_paths() { let perms = ToolPermissions::new("/tmp/workspace"); From 91924db6d124f2c2fd02e3496bafc3b5ecc6d1bc Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 30 May 2026 19:12:23 +0800 Subject: [PATCH 190/226] feat(tools,benchmarking): add reasoning-effort override and apply_patch tool support. - Add `--reasoning-effort` support (with env-driven benchmark override) and thread it through benchmark command generation and print-mode model reasoning resolution. - Introduce and register a new `apply_patch` tool, including permissions/path extraction updates and registry ordering guarantees. - Update benchmark prompt/docs and add tests for new reasoning handling, patch-tool behavior, and tool-policy constraints. --- benchmarking/src/agents.test.ts | 19 ++ benchmarking/src/agents.ts | 13 +- benchmarking/src/cli.ts | 5 +- src/main.rs | 43 ++- src/prompt/mod.rs | 6 +- src/tools/init.rs | 6 +- src/tools/mod.rs | 2 + src/tools/patch.rs | 528 ++++++++++++++++++++++++++++++++ src/tools/permission.rs | 40 ++- src/tools/registry.rs | 94 +++++- src/tools/task.rs | 1 + 11 files changed, 732 insertions(+), 25 deletions(-) create mode 100644 src/tools/patch.rs diff --git a/benchmarking/src/agents.test.ts b/benchmarking/src/agents.test.ts index edd8512..7f49c12 100644 --- a/benchmarking/src/agents.test.ts +++ b/benchmarking/src/agents.test.ts @@ -3,6 +3,7 @@ import { benchmarkPrompt, commandFor } from './agents.ts' const originalCrabcodeCommand = process.env.BENCH_CRABCODE_CMD const originalCrabcodeBin = process.env.BENCH_CRABCODE_BIN +const originalCrabcodeReasoning = process.env.BENCH_CRABCODE_REASONING afterEach(() => { if (originalCrabcodeCommand === undefined) { @@ -15,6 +16,11 @@ afterEach(() => { } else { process.env.BENCH_CRABCODE_BIN = originalCrabcodeBin } + if (originalCrabcodeReasoning === undefined) { + delete process.env.BENCH_CRABCODE_REASONING + } else { + process.env.BENCH_CRABCODE_REASONING = originalCrabcodeReasoning + } }) test('default crabcode benchmark command pins the requested model', () => { @@ -24,6 +30,7 @@ test('default crabcode benchmark command pins the requested model', () => { const command = commandFor('crabcode', 'fix the fixture', 'openai/gpt-5.5') expect(command).toContain("-m 'openai/gpt-5.5'") + expect(command).toContain("--reasoning-effort 'medium'") expect(command).toContain("'fix the fixture'") }) @@ -35,12 +42,24 @@ test('crabcode benchmark command supports an explicit optimized binary', () => { expect(command).toContain("'/tmp/crabcode-release'") expect(command).toContain("-m 'openai/gpt-5.5'") + expect(command).toContain("--reasoning-effort 'medium'") +}) + +test('crabcode benchmark command supports a reasoning override', () => { + delete process.env.BENCH_CRABCODE_CMD + delete process.env.BENCH_CRABCODE_BIN + process.env.BENCH_CRABCODE_REASONING = 'low' + + const command = commandFor('crabcode', 'fix the fixture', 'openai/gpt-5.5') + + expect(command).toContain("--reasoning-effort 'low'") }) test('benchmark prompt asks agents to stop after concise validation summary', () => { const prompt = benchmarkPrompt('Fix the bug.') expect(prompt).toContain('When the task is complete, stop.') + expect(prompt).toContain('Do not invoke package managers or one-off formatter installs') expect(prompt).toContain('After verification, give a final answer in at most two short lines') expect(prompt).toContain('Do not enumerate every edited file') }) diff --git a/benchmarking/src/agents.ts b/benchmarking/src/agents.ts index 71c434f..8ffe88a 100644 --- a/benchmarking/src/agents.ts +++ b/benchmarking/src/agents.ts @@ -36,6 +36,7 @@ export function benchmarkPrompt(prompt: string) { 'Keep the change minimal. When the task is complete, stop.', 'If the task names exact file paths, inspect those paths directly instead of listing directories first.', 'Do not repeat identical tool calls or run optional extra checks after the requested change is complete.', + 'Do not invoke package managers or one-off formatter installs; use existing project scripts only.', 'After verification, give a final answer in at most two short lines: what changed and what validation ran.', 'Do not enumerate every edited file or continue explaining once the task is complete.', '', @@ -56,26 +57,28 @@ export function modelForAgent(agent: AgentName, modelRef: string) { } function defaultCrabcodeCommand() { + const reasoning = shellQuote(process.env.BENCH_CRABCODE_REASONING?.trim() || 'medium') + const args = `-p -m {model} --reasoning-effort ${reasoning} --no-session-persistence --dangerously-skip-permissions {prompt}` const configuredBinary = process.env.BENCH_CRABCODE_BIN?.trim() if (configuredBinary) { - return `${shellQuote(configuredBinary)} -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` + return `${shellQuote(configuredBinary)} ${args}` } const installedBinary = findExecutableOnPath('crabcode') if (installedBinary) { - return `${shellQuote(installedBinary)} -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` + return `${shellQuote(installedBinary)} ${args}` } const releaseBinary = join(REPO_ROOT, 'target', 'release', 'crabcode') if (existsSync(releaseBinary)) { - return `${shellQuote(releaseBinary)} -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` + return `${shellQuote(releaseBinary)} ${args}` } const binary = join(REPO_ROOT, 'target', 'debug', 'crabcode') if (existsSync(binary)) { - return `${shellQuote(binary)} -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` + return `${shellQuote(binary)} ${args}` } - return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}` + return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- ${args}` } function findExecutableOnPath(name: string) { diff --git a/benchmarking/src/cli.ts b/benchmarking/src/cli.ts index 2278bc7..60c8020 100644 --- a/benchmarking/src/cli.ts +++ b/benchmarking/src/cli.ts @@ -110,13 +110,14 @@ Default params: Environment overrides: BENCH_MODEL, BENCH_AGENTS, BENCH_TASKS, BENCH_TAGS, BENCH_DIFFICULTY, BENCH_RUNS, BENCH_TIMEOUT_MS, BENCH_INPUT_USD_PER_MTOK, - BENCH_OUTPUT_USD_PER_MTOK, BENCH_DIR, BENCH_REPORT_DIR + BENCH_OUTPUT_USD_PER_MTOK, BENCH_DIR, BENCH_REPORT_DIR, + BENCH_CRABCODE_REASONING Stop behavior: Ctrl+C stops the active agent process tree and removes temporary workspaces unless --keep is set. Command overrides: - BENCH_CRABCODE_CMD='crabcode -p -m {model} --no-session-persistence --dangerously-skip-permissions {prompt}' + BENCH_CRABCODE_CMD='crabcode -p -m {model} --reasoning-effort medium --no-session-persistence --dangerously-skip-permissions {prompt}' BENCH_OPENCODE_CMD='opencode run --dangerously-skip-permissions -m {model} {prompt}' BENCH_CODEX_CMD='codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}' diff --git a/src/main.rs b/src/main.rs index 8112518..8d96c12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -140,6 +140,7 @@ fn format_post_close_message( async fn run_print_mode( prompt: &str, model_override: Option<&str>, + reasoning_override: Option, no_session_persistence: bool, dangerously_skip_permissions: bool, ) -> Result<()> { @@ -175,18 +176,19 @@ async fn run_print_mode( .clone() .unwrap_or_else(|| "Build".to_string()); + let saved_reasoning = prefs_dao + .as_ref() + .and_then(|dao| { + dao.get_model_reasoning_effort(&provider_name, &model_id) + .ok() + }) + .flatten(); + let requested_reasoning = reasoning_override.or(saved_reasoning); let reasoning_effort = crate::model::discovery::Discovery::new() .ok() .and_then(|discovery| discovery.get_model_reasoning_capability(&provider_name, &model_id)) .and_then(|capability| { - let saved = prefs_dao - .as_ref() - .and_then(|dao| { - dao.get_model_reasoning_effort(&provider_name, &model_id) - .ok() - }) - .flatten()?; - let resolved = capability.resolve(Some(saved))?; + let resolved = capability.resolve(requested_reasoning)?; if resolved == crate::model::reasoning::ReasoningEffort::None { None } else { @@ -325,6 +327,15 @@ fn print_mode_permission_rules( rules } +fn parse_reasoning_effort_arg( + value: &str, +) -> Result { + value.parse().map_err(|_| { + "reasoning effort must be one of none, minimal, low, medium, high, xhigh, or max" + .to_string() + }) +} + lazy_static::lazy_static! { static ref TOAST_MANAGER: Mutex = Mutex::new(ToastManager::new()); } @@ -360,6 +371,10 @@ struct Args { #[arg(short = 'm', long = "model")] model: Option, + /// Reasoning effort to use for this invocation: none, minimal, low, medium, high, xhigh, or max + #[arg(long = "reasoning-effort", value_parser = parse_reasoning_effort_arg)] + reasoning_effort: Option, + /// Skip permission prompts in print mode. Intended for isolated benchmark/CI workspaces. #[arg(long = "dangerously-skip-permissions")] dangerously_skip_permissions: bool, @@ -417,6 +432,7 @@ async fn main() -> Result<()> { return run_print_mode( &prompt, args.model.as_deref(), + args.reasoning_effort, args.no_session_persistence, args.dangerously_skip_permissions, ) @@ -555,6 +571,17 @@ mod tests { assert_eq!(args.model.as_deref(), Some("openai/gpt-5.2")); } + #[test] + fn parses_print_reasoning_effort_override() { + let args = + Args::try_parse_from(["crabcode", "-p", "hi", "--reasoning-effort", "medium"]).unwrap(); + + assert_eq!( + args.reasoning_effort, + Some(crate::model::reasoning::ReasoningEffort::Medium) + ); + } + #[test] fn double_dash_keeps_model_like_tokens_in_prompt() { let args = Args::try_parse_from([ diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index a9058b4..1a8a24d 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -284,7 +284,9 @@ Your output will be displayed on a command line interface. Your responses should r#"Non-Interactive Print Mode: - Keep planning internal; do not call update_plan. - Do not ask the user questions or wait for interactive input. -- Prefer direct read/edit/write/bash tool use, and prefer write_files when replacing complete contents of multiple files. +- Prefer direct read/apply_patch/edit/bash tool use. +- For existing-file edits, prefer apply_patch or edit over rewriting whole files; use write_files mainly for new files or true full rewrites. +- After tests pass, do not run optional one-off formatters or package-manager commands unless the project has an explicit formatter script or the user asked for it. - After requested validation passes, send a compact final answer and stop."# .to_string() } @@ -427,7 +429,9 @@ mod tests { assert!(context.contains("do not call update_plan")); assert!(context.contains("Do not ask the user questions")); + assert!(context.contains("apply_patch")); assert!(context.contains("write_files")); + assert!(context.contains("one-off formatters")); assert!(context.contains("stop")); } } diff --git a/src/tools/init.rs b/src/tools/init.rs index 382435f..628fc8b 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,7 +1,7 @@ use crate::tools::{ fs::{GlobTool, GrepTool, ListTool, ReadTool, ViewImageTool, WriteFilesTool, WriteTool}, - BashTool, EditTool, QuestionTool, SkillTool, TaskTool, ToolPermissions, ToolRegistry, - UpdatePlanTool, WebfetchTool, + ApplyPatchTool, BashTool, EditTool, QuestionTool, SkillTool, TaskTool, ToolPermissions, + ToolRegistry, UpdatePlanTool, WebfetchTool, }; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -14,6 +14,7 @@ pub async fn initialize_tool_registry() -> ToolRegistry { registry.register(Arc::new(ListTool::new())).await; registry.register(Arc::new(ReadTool::new())).await; registry.register(Arc::new(ViewImageTool::new())).await; + registry.register(Arc::new(ApplyPatchTool::new())).await; registry.register(Arc::new(WriteTool::new())).await; registry.register(Arc::new(WriteFilesTool::new())).await; registry.register(Arc::new(BashTool::new())).await; @@ -107,6 +108,7 @@ mod tests { assert!(scoped.get("read").await.is_some()); assert!(scoped.get("task").await.is_some()); assert!(scoped.get("bash").await.is_none()); + assert!(scoped.get("apply_patch").await.is_none()); assert!(scoped.get("write").await.is_none()); assert!(scoped.get("edit").await.is_none()); } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 8ec305c..da00052 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -7,6 +7,7 @@ pub mod context; pub mod edit; pub mod fs; pub mod init; +pub mod patch; pub mod permission; pub mod question; pub mod registry; @@ -22,6 +23,7 @@ pub use edit::EditTool; pub use init::{ initialize_tool_registry, initialize_tool_registry_with_dynamic, scope_tool_registry_for_agent, }; +pub use patch::ApplyPatchTool; pub use permission::{ expand_permission_pattern, AgentToolPolicies, PermissionAction, PermissionPolicyAction, PermissionPrompt, PermissionResponse, PermissionRule, PermissionRules, ToolPermissions, diff --git a/src/tools/patch.rs b/src/tools/patch.rs new file mode 100644 index 0000000..cdb0d03 --- /dev/null +++ b/src/tools/patch.rs @@ -0,0 +1,528 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +pub struct ApplyPatchTool; + +#[derive(Default)] +struct PatchSummary { + added: usize, + updated: usize, + deleted: usize, + moved: usize, +} + +impl PatchSummary { + fn touched(&self) -> usize { + self.added + self.updated + self.deleted + self.moved + } + + fn describe(&self) -> String { + let mut parts = Vec::new(); + if self.added > 0 { + parts.push(format!("added {}", self.added)); + } + if self.updated > 0 { + parts.push(format!("updated {}", self.updated)); + } + if self.deleted > 0 { + parts.push(format!("deleted {}", self.deleted)); + } + if self.moved > 0 { + parts.push(format!("moved {}", self.moved)); + } + if parts.is_empty() { + "no files changed".to_string() + } else { + parts.join(", ") + } + } +} + +impl ApplyPatchTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ToolHandler for ApplyPatchTool { + fn definition(&self) -> Tool { + Tool { + id: "apply_patch".to_string(), + description: "Apply a compact multi-file patch. Prefer this for edits to existing files, especially multi-file changes, because a unified diff is much shorter than rewriting whole files. Accepts standard unified diffs and Codex-style patches beginning with *** Begin Patch.".to_string(), + parameters: vec![ParameterSchema { + name: "patch".to_string(), + description: "Patch text to apply. Use standard unified diff format with ---/+++/@@ hunks, or Codex-style *** Begin Patch format.".to_string(), + required: true, + param_type: ParameterType::String, + }], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["patch"])?; + if !params.get("patch").is_some_and(Value::is_string) { + return Err(ToolError::Validation("patch must be a string".to_string())); + } + Ok(()) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let patch = get_string_param(¶ms, "patch") + .ok_or_else(|| ToolError::Validation("patch is required".to_string()))?; + let patch = clean_patch_input(&patch); + let summary = if patch.trim_start().starts_with("*** Begin Patch") { + apply_codex_patch(&patch)? + } else { + apply_unified_patch(&patch)? + }; + + Ok(ToolResult::new( + "Apply patch", + format!("Applied patch: {}", summary.describe()), + ) + .with_metadata("file_count", serde_json::json!(summary.touched()))) + } +} + +pub(crate) fn patch_paths_from_params(params: &Value) -> Vec { + params + .get("patch") + .and_then(Value::as_str) + .map(extract_patch_paths) + .unwrap_or_default() +} + +pub(crate) fn extract_patch_paths(patch: &str) -> Vec { + let patch = clean_patch_input(patch); + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + + for line in patch.lines() { + let candidates: Vec = if let Some(path) = line.strip_prefix("*** Update File: ") { + vec![path.to_string()] + } else if let Some(path) = line.strip_prefix("*** Add File: ") { + vec![path.to_string()] + } else if let Some(path) = line.strip_prefix("*** Delete File: ") { + vec![path.to_string()] + } else if let Some(path) = line.strip_prefix("*** Move to: ") { + vec![path.to_string()] + } else if let Some(path) = line.strip_prefix("--- ") { + vec![normalize_diff_path(path)] + } else if let Some(path) = line.strip_prefix("+++ ") { + vec![normalize_diff_path(path)] + } else if let Some(rest) = line.strip_prefix("diff --git ") { + rest.split_whitespace().map(normalize_diff_path).collect() + } else { + Vec::new() + }; + + for path in candidates { + let path = path.trim(); + if path.is_empty() || path == "/dev/null" { + continue; + } + if seen.insert(path.to_string()) { + paths.push(path.to_string()); + } + } + } + + paths +} + +pub(crate) fn patch_paths_as_pathbufs(params: &Value, workdir: &Path) -> Vec { + patch_paths_from_params(params) + .into_iter() + .map(|path| { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + workdir.join(path) + } + }) + .collect() +} + +fn clean_patch_input(raw: &str) -> String { + let trimmed = raw.trim(); + let mut lines: Vec<&str> = trimmed.lines().collect(); + if lines + .first() + .is_some_and(|line| line.trim_start().starts_with("```")) + { + lines.remove(0); + if lines + .last() + .is_some_and(|line| line.trim_start().starts_with("```")) + { + lines.pop(); + } + } + lines.join("\n") +} + +fn normalize_diff_path(raw: &str) -> String { + let path = raw + .trim() + .split_whitespace() + .next() + .unwrap_or("") + .trim_matches('"'); + + path.strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path) + .to_string() +} + +fn apply_unified_patch(patch: &str) -> Result { + let lines: Vec<&str> = patch.lines().collect(); + let mut index = 0; + let mut summary = PatchSummary::default(); + + while index < lines.len() { + if !lines[index].starts_with("--- ") { + index += 1; + continue; + } + + let old_path = normalize_diff_path( + lines[index] + .strip_prefix("--- ") + .expect("line prefix already checked"), + ); + index += 1; + + if index >= lines.len() || !lines[index].starts_with("+++ ") { + return Err(ToolError::Validation( + "Unified diff file header must include a +++ path".to_string(), + )); + } + let new_path = normalize_diff_path( + lines[index] + .strip_prefix("+++ ") + .expect("line prefix already checked"), + ); + index += 1; + + let target_path = if new_path == "/dev/null" { + old_path.as_str() + } else { + new_path.as_str() + }; + let mut content = if old_path == "/dev/null" { + String::new() + } else { + read_file(target_path)? + }; + let mut applied_hunks = 0usize; + + while index < lines.len() + && !lines[index].starts_with("--- ") + && !lines[index].starts_with("diff --git ") + { + if !lines[index].starts_with("@@") { + index += 1; + continue; + } + index += 1; + let (old_text, new_text, next_index) = collect_hunk(&lines, index); + content = replace_hunk(&content, &old_text, &new_text)?; + applied_hunks += 1; + index = next_index; + } + + if new_path == "/dev/null" { + std::fs::remove_file(target_path) + .map_err(|e| ToolError::Execution(format!("Failed to delete file: {}", e)))?; + summary.deleted += 1; + } else { + write_atomic(target_path, &content)?; + if old_path == "/dev/null" { + summary.added += 1; + } else if applied_hunks > 0 { + summary.updated += 1; + } + } + } + + if summary.touched() == 0 { + return Err(ToolError::Validation( + "Patch did not contain any file changes".to_string(), + )); + } + + Ok(summary) +} + +fn collect_hunk(lines: &[&str], mut index: usize) -> (String, String, usize) { + let mut old_lines = Vec::new(); + let mut new_lines = Vec::new(); + + while index < lines.len() + && !lines[index].starts_with("@@") + && !lines[index].starts_with("--- ") + && !lines[index].starts_with("diff --git ") + && !lines[index].starts_with("*** ") + { + let line = lines[index]; + if line == r"\ No newline at end of file" { + index += 1; + continue; + } + let (prefix, rest) = line.split_at(line.len().min(1)); + match prefix { + " " => { + old_lines.push(rest.to_string()); + new_lines.push(rest.to_string()); + } + "-" => old_lines.push(rest.to_string()), + "+" => new_lines.push(rest.to_string()), + _ => {} + } + index += 1; + } + + ( + join_hunk_lines(&old_lines), + join_hunk_lines(&new_lines), + index, + ) +} + +fn join_hunk_lines(lines: &[String]) -> String { + if lines.is_empty() { + String::new() + } else { + let mut out = lines.join("\n"); + out.push('\n'); + out + } +} + +fn replace_hunk(content: &str, old_text: &str, new_text: &str) -> Result { + if old_text.is_empty() { + let mut out = content.to_string(); + out.push_str(new_text); + return Ok(out); + } + + if let Some(pos) = content.find(old_text) { + let mut out = String::with_capacity(content.len() - old_text.len() + new_text.len()); + out.push_str(&content[..pos]); + out.push_str(new_text); + out.push_str(&content[pos + old_text.len()..]); + return Ok(out); + } + + if old_text.ends_with('\n') { + let old_trimmed = old_text.trim_end_matches('\n'); + if let Some(pos) = content.find(old_trimmed) { + let new_trimmed = new_text.trim_end_matches('\n'); + let mut out = + String::with_capacity(content.len() - old_trimmed.len() + new_trimmed.len()); + out.push_str(&content[..pos]); + out.push_str(new_trimmed); + out.push_str(&content[pos + old_trimmed.len()..]); + return Ok(out); + } + } + + Err(ToolError::NotFound( + "Could not apply patch hunk: context was not found".to_string(), + )) +} + +fn apply_codex_patch(patch: &str) -> Result { + let lines: Vec<&str> = patch.lines().collect(); + let mut index = 0; + let mut summary = PatchSummary::default(); + + if lines.get(index).map(|line| line.trim()) != Some("*** Begin Patch") { + return Err(ToolError::Validation( + "Codex patch must start with *** Begin Patch".to_string(), + )); + } + index += 1; + + while index < lines.len() { + let line = lines[index].trim(); + if line == "*** End Patch" { + break; + } + + if let Some(path) = line.strip_prefix("*** Add File: ") { + index += 1; + let mut file_lines = Vec::new(); + while index < lines.len() && !lines[index].starts_with("*** ") { + let Some(content) = lines[index].strip_prefix('+') else { + return Err(ToolError::Validation( + "Add File lines must start with +".to_string(), + )); + }; + file_lines.push(content.to_string()); + index += 1; + } + write_atomic(path, &join_hunk_lines(&file_lines))?; + summary.added += 1; + continue; + } + + if let Some(path) = line.strip_prefix("*** Delete File: ") { + std::fs::remove_file(path) + .map_err(|e| ToolError::Execution(format!("Failed to delete file: {}", e)))?; + summary.deleted += 1; + index += 1; + continue; + } + + if let Some(path) = line.strip_prefix("*** Update File: ") { + index += 1; + let mut move_to = None; + if let Some(target) = lines + .get(index) + .and_then(|line| line.trim().strip_prefix("*** Move to: ")) + { + move_to = Some(target.to_string()); + index += 1; + } + + let mut content = read_file(path)?; + let mut hunk_count = 0usize; + while index < lines.len() && !lines[index].starts_with("*** ") { + if !lines[index].starts_with("@@") { + index += 1; + continue; + } + index += 1; + let (old_text, new_text, next_index) = collect_hunk(&lines, index); + content = replace_hunk(&content, &old_text, &new_text)?; + hunk_count += 1; + index = next_index; + } + + let target = move_to.as_deref().unwrap_or(path); + write_atomic(target, &content)?; + if let Some(target) = move_to { + if target != path { + let _ = std::fs::remove_file(path); + summary.moved += 1; + } else if hunk_count > 0 { + summary.updated += 1; + } + } else if hunk_count > 0 { + summary.updated += 1; + } + continue; + } + + return Err(ToolError::Validation(format!( + "Unsupported patch directive: {}", + line + ))); + } + + if summary.touched() == 0 { + return Err(ToolError::Validation( + "Patch did not contain any file changes".to_string(), + )); + } + + Ok(summary) +} + +fn read_file(path: &str) -> Result { + std::fs::read_to_string(path) + .map_err(|e| ToolError::Execution(format!("Failed to read file '{}': {}", path, e))) +} + +fn write_atomic(path: &str, content: &str) -> Result<(), ToolError> { + let path = Path::new(path); + if let Some(parent) = path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).map_err(|e| { + ToolError::Execution(format!("Failed to create directories: {}", e)) + })?; + } + } + + let temp_path = path.with_extension("tmp"); + std::fs::write(&temp_path, content) + .map_err(|e| ToolError::Execution(format!("Failed to write temp file: {}", e)))?; + std::fs::rename(&temp_path, path) + .map_err(|e| ToolError::Execution(format!("Failed to rename file: {}", e)))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolHandler; + + fn test_context() -> ToolContext { + let (_tx, rx) = tokio::sync::watch::channel(false); + ToolContext::new("session", "message", "build", rx) + } + + #[tokio::test] + async fn apply_patch_updates_multiple_files_from_unified_diff() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("a.txt"); + let second = dir.path().join("b.txt"); + std::fs::write(&first, "one\ntwo\n").unwrap(); + std::fs::write(&second, "alpha\nbeta\n").unwrap(); + + let patch = format!( + "--- {}\n+++ {}\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n--- {}\n+++ {}\n@@ -1,2 +1,2 @@\n alpha\n-beta\n+gamma\n", + first.display(), + first.display(), + second.display(), + second.display() + ); + + let result = ApplyPatchTool::new() + .execute(serde_json::json!({ "patch": patch }), &test_context()) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(first).unwrap(), "one\nthree\n"); + assert_eq!(std::fs::read_to_string(second).unwrap(), "alpha\ngamma\n"); + assert!(result.output.contains("updated 2")); + assert_eq!(result.metadata["file_count"], serde_json::json!(2)); + } + + #[tokio::test] + async fn apply_patch_supports_codex_patch_format() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("a.txt"); + std::fs::write(&file, "one\ntwo\n").unwrap(); + let patch = format!( + "*** Begin Patch\n*** Update File: {}\n@@\n one\n-two\n+three\n*** End Patch\n", + file.display() + ); + + ApplyPatchTool::new() + .execute(serde_json::json!({ "patch": patch }), &test_context()) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(file).unwrap(), "one\nthree\n"); + } + + #[test] + fn extract_patch_paths_finds_unified_and_codex_paths() { + let patch = "*** Begin Patch\n*** Update File: src/a.ts\n*** Move to: src/b.ts\n*** End Patch\n--- a/src/c.ts\n+++ b/src/c.ts\n"; + assert_eq!( + extract_patch_paths(patch), + vec!["src/a.ts", "src/b.ts", "src/c.ts"] + ); + } +} diff --git a/src/tools/permission.rs b/src/tools/permission.rs index 128378c..fcd0cda 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -27,7 +27,7 @@ impl PermissionAction { match tool_id { "read" | "view_image" => Self::Read, "write" | "write_files" => Self::Write, - "edit" => Self::Edit, + "edit" | "apply_patch" => Self::Edit, "list" => Self::List, "glob" => Self::Glob, "grep" => Self::Grep, @@ -147,7 +147,10 @@ impl AgentToolPolicies { if mode == "plan" { // OpenCode plan mode is read-only by default. Custom agent tool // policies above can still opt specific tools back in. - return !matches!(tool.as_str(), "bash" | "write" | "write_files" | "edit"); + return !matches!( + tool.as_str(), + "bash" | "write" | "write_files" | "edit" | "apply_patch" + ); } if mode == "build" { @@ -652,7 +655,7 @@ fn get_string(params: &Value, key: &str) -> Option { fn permission_key_for_tool_id(tool_id: &str) -> String { match tool_id.trim().to_ascii_lowercase().as_str() { - "write" | "write_files" | "edit" => "edit".to_string(), + "write" | "write_files" | "edit" | "apply_patch" => "edit".to_string(), "read" | "view_image" => "read".to_string(), other => other.to_string(), } @@ -712,6 +715,11 @@ fn permission_patterns_for_tool( } } } + "apply_patch" => { + for path in crate::tools::patch::patch_paths_from_params(params) { + push_nonempty(&mut patterns, &path); + } + } _ => {} } @@ -892,6 +900,13 @@ fn extract_primary_paths( .collect(); } + if tool_id == "apply_patch" { + return crate::tools::patch::patch_paths_as_pathbufs(params, workdir) + .into_iter() + .map(|path| normalize_path(&path)) + .collect(); + } + extract_primary_path(action, params, workdir) .into_iter() .collect() @@ -1004,6 +1019,7 @@ mod tests { assert!(!policies.is_allowed("plan", "write")); assert!(!policies.is_allowed("plan", "write_files")); assert!(!policies.is_allowed("plan", "edit")); + assert!(!policies.is_allowed("plan", "apply_patch")); } #[test] @@ -1068,6 +1084,24 @@ mod tests { ); } + #[test] + fn extract_primary_paths_collects_all_apply_patch_paths() { + let wd = PathBuf::from("/tmp/workspace"); + let params = serde_json::json!({ + "patch": "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n--- /dev/null\n+++ b/src/b.ts\n@@ -0,0 +1 @@\n+new\n" + }); + + let extracted = extract_primary_paths("apply_patch", PermissionAction::Edit, ¶ms, &wd); + + assert_eq!( + extracted, + vec![ + PathBuf::from("/tmp/workspace/src/a.ts"), + PathBuf::from("/tmp/workspace/src/b.ts") + ] + ); + } + #[tokio::test] async fn allow_always_persists_for_same_request_fingerprint() { let perms = ToolPermissions::new("/tmp/workspace"); diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 62e0c4e..3810593 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -7,18 +7,23 @@ use tokio::sync::RwLock; #[derive(Clone)] pub struct ToolRegistry { tools: Arc>>>, + order: Arc>>, } impl ToolRegistry { pub fn new() -> Self { Self { tools: Arc::new(RwLock::new(HashMap::new())), + order: Arc::new(RwLock::new(Vec::new())), } } pub async fn register(&self, tool: Arc) { let definition = tool.definition(); let mut tools = self.tools.write().await; + if !tools.contains_key(&definition.id) { + self.order.write().await.push(definition.id.clone()); + } tools.insert(definition.id.clone(), tool); } @@ -29,14 +34,21 @@ impl ToolRegistry { pub async fn list(&self) -> Vec { let tools = self.tools.read().await; - tools.values().map(|t| t.definition()).collect() + let order = self.order.read().await; + order + .iter() + .filter_map(|id| tools.get(id)) + .map(|tool| tool.definition()) + .collect() } pub async fn list_schemas(&self) -> Vec { let tools = self.tools.read().await; - tools - .values() - .map(|t| t.definition().to_openai_schema()) + let order = self.order.read().await; + order + .iter() + .filter_map(|id| tools.get(id)) + .map(|tool| tool.definition().to_openai_schema()) .collect() } } @@ -46,3 +58,77 @@ impl Default for ToolRegistry { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::{ + fs::WriteTool, ParameterSchema, ParameterType, ToolContext, ToolError, ToolResult, + }; + use async_trait::async_trait; + use serde_json::Value; + + struct TestTool(&'static str); + + #[async_trait] + impl ToolHandler for TestTool { + fn definition(&self) -> Tool { + Tool { + id: self.0.to_string(), + description: "test tool".to_string(), + parameters: vec![ParameterSchema { + name: "value".to_string(), + description: "value".to_string(), + required: false, + param_type: ParameterType::String, + }], + } + } + + fn validate(&self, _params: &Value) -> Result<(), ToolError> { + Ok(()) + } + + async fn execute( + &self, + _params: Value, + _ctx: &ToolContext, + ) -> Result { + Ok(ToolResult::new("test", "ok")) + } + } + + #[tokio::test] + async fn lists_tools_in_registration_order() { + let registry = ToolRegistry::new(); + registry.register(Arc::new(TestTool("first"))).await; + registry.register(Arc::new(TestTool("second"))).await; + registry.register(Arc::new(WriteTool::new())).await; + + let ids: Vec<_> = registry + .list() + .await + .into_iter() + .map(|tool| tool.id) + .collect(); + + assert_eq!(ids, vec!["first", "second", "write"]); + } + + #[tokio::test] + async fn reregister_keeps_original_order() { + let registry = ToolRegistry::new(); + registry.register(Arc::new(TestTool("first"))).await; + registry.register(Arc::new(TestTool("second"))).await; + registry.register(Arc::new(TestTool("first"))).await; + + let ids: Vec<_> = registry + .list() + .await + .into_iter() + .map(|tool| tool.id) + .collect(); + + assert_eq!(ids, vec!["first", "second"]); + } +} diff --git a/src/tools/task.rs b/src/tools/task.rs index cde7a9c..1831508 100644 --- a/src/tools/task.rs +++ b/src/tools/task.rs @@ -51,6 +51,7 @@ mod tests { assert!(permissions.is_tool_allowed_for_agent("explore", "read")); assert!(!permissions.is_tool_allowed_for_agent("explore", "bash")); + assert!(!permissions.is_tool_allowed_for_agent("explore", "apply_patch")); assert!(!permissions.is_tool_allowed_for_agent("explore", "write")); assert!(!permissions.is_tool_allowed_for_agent("explore", "edit")); } From 5923cf6a5371aa0b648635b2c29f6891523dc2bb Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 31 May 2026 19:48:46 +0800 Subject: [PATCH 191/226] fix(autocomplete): hide registered "skills" from autocomplete suggestions (they're not commands). Prevent commands marked as hidden from appearing in command and hidden-token autocomplete results, add registry support for tracking hidden command names, and register skill commands as hidden so they are excluded. Add coverage for hidden-command filtering behavior. --- src/autocomplete/command.rs | 12 ++++++++++++ src/command/handlers.rs | 1 + src/command/registry.rs | 10 ++++++++++ 3 files changed, 23 insertions(+) diff --git a/src/autocomplete/command.rs b/src/autocomplete/command.rs index f97199c..2f10e53 100644 --- a/src/autocomplete/command.rs +++ b/src/autocomplete/command.rs @@ -76,12 +76,14 @@ impl CommandAuto { let commands: Vec = registry .list_commands() .iter() + .filter(|cmd| !registry.is_hidden_from_autocomplete(&cmd.name)) .map(|cmd| Suggestion::command(cmd.name.clone(), cmd.description.clone())) .collect(); let hidden_token_map: Vec<(String, String)> = registry .list_commands() .iter() + .filter(|cmd| !registry.is_hidden_from_autocomplete(&cmd.name)) .flat_map(|cmd| { cmd.hidden_tokens .iter() @@ -217,6 +219,16 @@ mod tests { assert!(chat_suggestions.iter().any(|s| s.name == "compact")); } + #[test] + fn test_hidden_from_autocomplete_command_is_not_suggested() { + let mut registry = setup_registry(); + registry.hide_from_autocomplete("sessions"); + let auto = CommandAuto::new(®istry); + + assert!(auto.get_suggestions("s", true).is_empty()); + assert!(auto.get_suggestions("res", true).is_empty()); + } + #[test] fn test_get_suggestions_partial() { let registry = setup_registry(); diff --git a/src/command/handlers.rs b/src/command/handlers.rs index af02312..70be8a4 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -556,6 +556,7 @@ pub fn register_skill_commands(registry: &mut Registry) { hidden_tokens: vec![], chat_only: false, }); + registry.hide_from_autocomplete(skill.name.clone()); } } } diff --git a/src/command/registry.rs b/src/command/registry.rs index 4b1ef79..863ffa0 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -47,6 +47,7 @@ pub struct DialogItem { pub struct Registry { commands: HashMap, custom_commands: HashMap, + hidden_from_autocomplete: std::collections::HashSet, } impl Registry { @@ -54,6 +55,7 @@ impl Registry { Self { commands: HashMap::new(), custom_commands: HashMap::new(), + hidden_from_autocomplete: std::collections::HashSet::new(), } } @@ -87,6 +89,14 @@ impl Registry { self.custom_commands.get(name) } + pub fn hide_from_autocomplete(&mut self, name: impl Into) { + self.hidden_from_autocomplete.insert(name.into()); + } + + pub fn is_hidden_from_autocomplete(&self, name: &str) -> bool { + self.hidden_from_autocomplete.contains(name) + } + pub fn get(&self, name: &str) -> Option<&Command> { if let Some(cmd) = self.commands.get(name) { return Some(cmd); From 9a195ba6ab3923ef88c6d85dfe4aad45df9874c5 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 31 May 2026 19:58:49 +0800 Subject: [PATCH 192/226] feat(commands): add /fork command with /branch alias for session cloning. Implement a chat-only `/fork` command (with hidden `/branch` alias) that clones the current session transcript into a new session, including shared fork helpers and message-action/command handling integration. Add toast/error handling and palette visibility updates, plus test coverage for registration, chat-only behavior, palette items, and fork action results. --- _plans/__TODOS.md | 2 + src/app.rs | 226 +++++++++++++++++++++++++++-------- src/command/handlers.rs | 30 ++++- src/views/command_palette.rs | 8 ++ 4 files changed, 214 insertions(+), 52 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 4f530b7..2e78f87 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -219,3 +219,5 @@ I want - [x] To do this But I dont want to do this - [x] Emit the same Loading stuff that codex does. So that Zed knows when the agent is "in progress". - [ ] During /compact, i can't queue a message, the same way I can usually queue messages while streaming. Btw except in compact, compaction has to be completely done before it registers my queued message until it's fully processed. + +- [x] /fork command like codex. diff --git a/src/app.rs b/src/app.rs index 7903d8e..c1635bc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3691,6 +3691,30 @@ impl App { true } + fn command_matches(&self, command_name: &str, canonical_name: &str) -> bool { + self.command_registry + .get(command_name) + .is_some_and(|command| command.name.as_str() == canonical_name) + } + + fn push_command_error(&mut self, message: impl Into) { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + message.into(), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + + fn handle_fork_command(&mut self, args: &[String]) { + if !args.is_empty() { + self.push_command_error("Usage: /fork"); + return; + } + + let _ = self.fork_current_session(None); + } + async fn compact_current_session(&mut self) { if self.compaction_receiver.is_some() { push_toast(Toast::new( @@ -3900,6 +3924,11 @@ impl App { } return; } + if self.command_matches(&parsed.name, "fork") && self.base_focus == BaseFocus::Chat + { + self.handle_fork_command(&parsed.args); + return; + } if self.reject_chat_only_command_outside_chat(&parsed.name) { return; } @@ -4096,6 +4125,10 @@ impl App { } return; } + if self.command_matches(&parsed.name, "fork") && self.base_focus == BaseFocus::Chat { + self.handle_fork_command(&parsed.args); + return; + } if self.reject_chat_only_command_outside_chat(&parsed.name) { return; } @@ -4392,6 +4425,53 @@ impl App { .unwrap_or(false) } + fn current_session_messages_to_fork( + &mut self, + through_idx: Option, + ) -> Option> { + let session = self.session_manager.get_current_session()?; + let end = through_idx + .map(|idx| { + crate::session::types::logical_message_block_range(&session.messages, idx) + .map(|range| range.end) + .unwrap_or_else(|| idx.saturating_add(1).min(session.messages.len())) + }) + .unwrap_or(session.messages.len()); + + Some(session.messages.iter().take(end).cloned().collect()) + } + + fn fork_current_session(&mut self, through_idx: Option) -> bool { + let Some(messages_to_fork) = self.current_session_messages_to_fork(through_idx) else { + self.push_command_error("No active session to fork"); + return false; + }; + + if messages_to_fork.is_empty() { + self.push_command_error("Nothing to fork"); + return false; + } + + let fork_title = fork_title_from_messages(&messages_to_fork); + + let _ = self.create_new_session(Some(fork_title)); + for msg in &messages_to_fork { + let _ = self.session_manager.add_message_to_current_session(msg); + } + + self.chat_state.chat.clear(); + self.chat_state.chat.replace_messages(messages_to_fork); + self.chat_state.chat.scroll_offset = usize::MAX; + self.chat_state.chat.clear_highlighted_message(); + self.base_focus = BaseFocus::Chat; + + let toast = through_idx + .map(|idx| format!("Forked session from message {}", idx + 1)) + .unwrap_or_else(|| "Forked session".to_string()); + push_toast(Toast::new(toast, ToastLevel::Info, None)); + true + } + fn execute_message_action(&mut self, action: &str) { let idx = match self.message_actions_index { Some(i) => i, @@ -4415,58 +4495,11 @@ impl App { self.close_message_actions(); } "fork" => { - let messages_to_fork: Vec = { - if let Some(session) = self.session_manager.get_current_session() { - let end = crate::session::types::logical_message_block_range( - &session.messages, - idx, - ) - .map(|range| range.end) - .unwrap_or_else(|| idx.saturating_add(1).min(session.messages.len())); - - session.messages.iter().take(end).cloned().collect() - } else { - return; - } - }; - - let fork_title = messages_to_fork - .last() - .map(|msg| { - let preview = msg - .content - .lines() - .find(|line| !line.trim().is_empty()) - .unwrap_or("fork"); - let truncated: String = preview.chars().take(40).collect(); - if truncated.len() < preview.len() { - format!("{}...", truncated) - } else { - truncated - } - }) - .unwrap_or_default(); - - let _ = self.create_new_session(Some(fork_title)); - for msg in &messages_to_fork { - let _ = self.session_manager.add_message_to_current_session(msg); + if self.fork_current_session(Some(idx)) { + self.close_message_actions(); + self.timeline_dialog_state.hide(); + self.overlay_focus = OverlayFocus::None; } - - self.chat_state.chat.clear(); - self.chat_state.chat.replace_messages(messages_to_fork); - self.chat_state.chat.scroll_offset = usize::MAX; - self.chat_state.chat.clear_highlighted_message(); - self.base_focus = BaseFocus::Chat; - - push_toast(Toast::new( - format!("Forked session from message {}", idx + 1), - ToastLevel::Info, - None, - )); - - self.close_message_actions(); - self.timeline_dialog_state.hide(); - self.overlay_focus = OverlayFocus::None; } "undo" => { if !self.selected_message_can_undo(idx) { @@ -6982,6 +7015,25 @@ fn message_clipboard_sections(message: &crate::session::types::Message) -> Vec String { + messages + .last() + .map(|msg| { + let preview = msg + .content + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or("fork"); + let truncated: String = preview.chars().take(40).collect(); + if truncated.len() < preview.len() { + format!("{}...", truncated) + } else { + truncated + } + }) + .unwrap_or_default() +} + fn append_usage_suffix(mut text: String, suffix: String) -> String { if text.is_empty() { suffix @@ -7573,6 +7625,76 @@ mod tests { assert!(!message_action_names(&app).contains(&"Undo".to_string())); } + #[tokio::test(flavor = "multi_thread")] + async fn fork_command_clones_current_session() { + let mut app = test_app(); + let original_id = app.create_new_session(Some("Original".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); + add_current_session_message( + &mut app, + crate::session::types::Message::assistant("Answer"), + ); + + app.process_input("/fork").await; + + let forked_id = app + .session_manager + .get_current_session_id() + .cloned() + .expect("forked session should be active"); + assert_ne!(forked_id, original_id); + assert_eq!(app.base_focus, BaseFocus::Chat); + assert_eq!(app.chat_state.chat.messages.len(), 2); + assert_eq!(app.chat_state.chat.messages[0].content, "Prompt"); + assert_eq!(app.chat_state.chat.messages[1].content, "Answer"); + assert_eq!( + app.session_manager + .get_session_ref(&original_id) + .unwrap() + .messages + .len(), + 2 + ); + assert_eq!( + app.session_manager + .get_session_ref(&forked_id) + .unwrap() + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + vec!["Prompt", "Answer"] + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn branch_alias_forks_current_session() { + let mut app = test_app(); + let original_id = app.create_new_session(Some("Original".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); + + app.process_input("/branch").await; + + let forked_id = app + .session_manager + .get_current_session_id() + .cloned() + .expect("forked session should be active"); + assert_ne!(forked_id, original_id); + assert_eq!( + app.session_manager + .get_session_ref(&forked_id) + .unwrap() + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + vec!["Prompt"] + ); + } + #[test] fn commands_can_submit_while_streaming() { let input_type = parse_input("/models"); @@ -8096,9 +8218,11 @@ mod tests { let mut app = test_app(); assert!(app.reject_chat_only_command_outside_chat("compact")); + assert!(app.reject_chat_only_command_outside_chat("branch")); app.base_focus = BaseFocus::Chat; assert!(!app.reject_chat_only_command_outside_chat("compact")); + assert!(!app.reject_chat_only_command_outside_chat("branch")); } #[test] diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 70be8a4..3c69897 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -508,6 +508,22 @@ pub fn handle_compact<'a>( }) } +pub fn handle_fork<'a>( + parsed: &'a ParsedCommand<'a>, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error("Usage: /fork".to_string()); + } + + // The app intercepts /fork because it needs access to chat view state. + CommandResult::Success(String::new()) + }) +} + pub fn handle_skills<'a>( parsed: &'a ParsedCommand<'a>, _sm: &'a mut SessionManager, @@ -756,6 +772,14 @@ pub fn register_all_commands(registry: &mut Registry) { chat_only: true, }); + registry.register(Command { + name: "fork".to_string(), + description: "Fork the current session".to_string(), + handler: handle_fork, + hidden_tokens: vec!["branch".to_string()], + chat_only: true, + }); + registry.register(Command { name: "skills".to_string(), description: "List available skills".to_string(), @@ -1108,7 +1132,7 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 13); + assert_eq!(names.len(), 14); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); @@ -1119,8 +1143,12 @@ mod tests { assert!(names.contains(&"refreshmodels".to_string())); assert!(names.contains(&"timeline".to_string())); assert!(names.contains(&"compact".to_string())); + assert!(names.contains(&"fork".to_string())); assert!(names.contains(&"skills".to_string())); assert!(registry.is_chat_only("compact")); + assert!(registry.is_chat_only("fork")); + assert!(registry.is_chat_only("branch")); + assert_eq!(registry.get("branch").unwrap().name, "fork"); } #[tokio::test] diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index c2ddd4a..1afb90f 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -227,6 +227,12 @@ fn core_palette_items(registry: &Registry, is_chat: bool) -> Vec { "Workspace", "Summarize this session to reduce context", ), + ( + "fork", + "Fork Session", + "Workspace", + "Create a new session from this transcript", + ), ( "home", "Go Home", @@ -422,6 +428,7 @@ mod tests { assert!(state.dialog.items.iter().any(|item| item.id == "models")); assert!(!state.dialog.items.iter().any(|item| item.id == "copy")); + assert!(!state.dialog.items.iter().any(|item| item.id == "fork")); } #[test] @@ -433,6 +440,7 @@ mod tests { state.refresh_items(®istry, true); assert!(state.dialog.items.iter().any(|item| item.id == "copy")); + assert!(state.dialog.items.iter().any(|item| item.id == "fork")); } #[test] From f41dc16150c7d60982137d1812a9f8e026432967 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 31 May 2026 20:28:28 +0800 Subject: [PATCH 193/226] feat(app): queue and batch user messages during active compaction. Allow queued messages to be accepted while compaction is running and flush them as a single combined submission once compaction completes. The new flow coalesces text with newlines, preserves image ordering with placeholder renumbering across merged messages, and now automatically submits queued messages after successful/failed/disconnected compaction events. - Add compaction-aware queue eligibility check for Enter handling - Submit merged queued batch via one user record instead of multiple append calls - Add tests for compaction queuing, batch message submission, and image placeholder renumbering --- _plans/__TODOS.md | 4 +- src/app.rs | 276 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 270 insertions(+), 10 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 2e78f87..aed377c 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -218,6 +218,8 @@ I want - [x] To do this But I dont want to do this - [x] Emit the same Loading stuff that codex does. So that Zed knows when the agent is "in progress". -- [ ] During /compact, i can't queue a message, the same way I can usually queue messages while streaming. Btw except in compact, compaction has to be completely done before it registers my queued message until it's fully processed. +- [x] During /compact, i can't queue a message, the same way I can usually queue messages while streaming. Btw except in compact, compaction has to be completely done before it registers my queued message until it's fully processed. + +- [x] If I queue multiple messages for example 3x of nice. Let's make them a single message. - [x] /fork command like codex. diff --git a/src/app.rs b/src/app.rs index c1635bc..07021c0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1124,6 +1124,14 @@ impl App { .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()) } + fn session_has_active_compaction(&self, session_id: &str) -> bool { + self.compaction_receiver.is_some() + && self + .compaction_pending + .as_ref() + .is_some_and(|pending| pending.session_id == session_id) + } + fn queued_message_previews_for_current_session(&self) -> Vec { let Some(session_id) = self.session_manager.get_current_session_id() else { return Vec::new(); @@ -1186,6 +1194,85 @@ impl App { .unwrap_or_default() } + fn combine_queued_messages(queued_messages: Vec) -> QueuedUserMessage { + let mut text_parts = Vec::with_capacity(queued_messages.len()); + let mut image_paths = Vec::new(); + + for queued in queued_messages { + let image_offset = image_paths.len(); + let image_count = queued.image_paths.len(); + let text = Self::queued_message_text_for_combined_submission( + &queued.text, + image_offset, + image_count, + ); + + if !text.is_empty() { + text_parts.push(text); + } + image_paths.extend(queued.image_paths); + } + + QueuedUserMessage { + text: text_parts.join("\n"), + image_paths, + } + } + + fn queued_message_text_for_combined_submission( + text: &str, + image_offset: usize, + image_count: usize, + ) -> String { + let text = Self::renumber_image_placeholders(text, image_offset, image_count); + if !text.trim().is_empty() || image_count == 0 { + return text; + } + + (0..image_count) + .map(|idx| format!("[Image #{}]", image_offset + idx + 1)) + .collect::>() + .join(" ") + } + + fn renumber_image_placeholders(text: &str, image_offset: usize, image_count: usize) -> String { + if image_offset == 0 || image_count == 0 || !text.contains("[Image #") { + return text.to_string(); + } + + let mut output = String::with_capacity(text.len()); + let mut remaining = text; + + while let Some(start) = remaining.find("[Image #") { + output.push_str(&remaining[..start]); + + let placeholder_start = &remaining[start..]; + let Some(end_offset) = placeholder_start.find(']') else { + output.push_str(placeholder_start); + return output; + }; + let end = start + end_offset + 1; + let placeholder = &remaining[start..end]; + + let image_number = placeholder + .strip_prefix("[Image #") + .and_then(|value| value.strip_suffix(']')) + .and_then(|value| value.parse::().ok()); + + match image_number { + Some(number) if (1..=image_count).contains(&number) => { + output.push_str(&format!("[Image #{}]", image_offset + number)); + } + _ => output.push_str(placeholder), + } + + remaining = &remaining[end..]; + } + + output.push_str(remaining); + output + } + fn streaming_boundary_for_session( &self, session_id: &str, @@ -2527,11 +2614,14 @@ impl App { if image_paths.is_empty() { self.input.save_current_to_history(); } - let active_session_streaming = self + let active_session_can_queue = self .session_manager .get_current_session_id() - .is_some_and(|id| self.session_has_active_stream(id)); - if self.is_streaming && active_session_streaming { + .is_some_and(|id| { + self.session_has_active_stream(id) + || self.session_has_active_compaction(id) + }); + if self.is_streaming && active_session_can_queue { self.queue_message_for_current_session( msg.to_string(), image_paths, @@ -5264,6 +5354,11 @@ impl App { fn process_compaction_events(&mut self) { let mut events = Vec::new(); let mut disconnected = false; + let disconnected_session_id = self + .compaction_pending + .as_ref() + .filter(|_| self.compaction_receiver.is_some()) + .map(|pending| pending.session_id.clone()); if let Some(receiver) = &mut self.compaction_receiver { loop { @@ -5284,6 +5379,8 @@ impl App { self.cached_usage_check = (usize::MAX, u64::MAX); } + let mut completed_compaction_sessions = Vec::new(); + for event in events { match event { CompactionTaskMessage::Success { @@ -5291,6 +5388,7 @@ impl App { messages, stats, } => { + let completed_session_id = session_id.clone(); match self .session_manager .replace_session_messages(&session_id, messages.clone()) @@ -5344,8 +5442,10 @@ impl App { )); } } + completed_compaction_sessions.push(completed_session_id); } CompactionTaskMessage::Failed { session_id, error } => { + let completed_session_id = session_id.clone(); let _ = self.session_manager.set_session_status( &session_id, crate::session::types::SessionStatus::Idle, @@ -5357,10 +5457,21 @@ impl App { ToastLevel::Error, Some(std::time::Duration::from_secs(3)), )); + completed_compaction_sessions.push(completed_session_id); } } } + if disconnected && completed_compaction_sessions.is_empty() { + if let Some(session_id) = disconnected_session_id { + completed_compaction_sessions.push(session_id); + } + } + + for session_id in completed_compaction_sessions { + self.submit_queued_messages_for_session(&session_id); + } + self.sync_active_streaming_flag(); } @@ -6525,13 +6636,11 @@ impl App { } self.base_focus = BaseFocus::Chat; - let mut last_text = String::new(); - for queued in queued_messages { - last_text = queued.text.clone(); - self.append_user_message_to_current_session(queued.text, queued.image_paths); - } + let queued = Self::combine_queued_messages(queued_messages); + let prompt = queued.text.clone(); + self.append_user_message_to_current_session(queued.text, queued.image_paths); - if let Err(e) = self.start_llm_streaming(&last_text) { + if let Err(e) = self.start_llm_streaming(&prompt) { push_toast(Toast::new( format!("LLM error: {}", e), ToastLevel::Error, @@ -7739,6 +7848,33 @@ mod tests { assert!(app.chat_state.chat.messages.is_empty()); } + #[test] + fn messages_entered_while_compacting_are_queued() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Compact queue".to_string())); + app.base_focus = BaseFocus::Chat; + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.compaction_receiver = Some(receiver); + app.compaction_pending = Some(CompactionPending { + session_id: session_id.clone(), + before_tokens: 1_000, + }); + app.sync_active_streaming_flag(); + app.input.insert_str("Then about eevee"); + + app.handle_keys(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert_eq!(state.queued_messages.len(), 1); + assert_eq!(state.queued_messages[0].text, "Then about eevee"); + assert_eq!( + app.queued_message_previews_for_current_session(), + vec!["Then about eevee".to_string()] + ); + assert!(app.input.is_empty()); + assert!(app.chat_state.chat.messages.is_empty()); + } + #[tokio::test(flavor = "multi_thread")] async fn queued_messages_cancel_stream_after_next_tool_result() { let mut app = test_app(); @@ -7835,6 +7971,74 @@ mod tests { )); } + #[tokio::test(flavor = "multi_thread")] + async fn queued_messages_submit_as_single_user_record_with_line_breaks() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue batch".to_string())); + app.base_focus = BaseFocus::Chat; + let state = app.session_view_states.get_mut(&session_id).unwrap(); + for text in ["nice", "nice", "nice"] { + state.queued_messages.push_back(QueuedUserMessage { + text: text.to_string(), + image_paths: Vec::new(), + }); + } + + assert!(app.submit_queued_messages_for_session(&session_id)); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.queued_messages.is_empty()); + assert!(state.stream.is_some()); + assert_eq!( + app.chat_state + .chat + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + vec!["nice\nnice\nnice", ""] + ); + + let persisted_messages = &app + .session_manager + .get_session_ref(&session_id) + .unwrap() + .messages; + assert_eq!(persisted_messages.len(), 1); + assert_eq!(persisted_messages[0].content, "nice\nnice\nnice"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn queued_image_messages_submit_as_single_record_with_renumbered_placeholders() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue images".to_string())); + app.base_focus = BaseFocus::Chat; + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.queued_messages.push_back(QueuedUserMessage { + text: "first [Image #1]".to_string(), + image_paths: vec![std::path::PathBuf::from("/tmp/first.png")], + }); + state.queued_messages.push_back(QueuedUserMessage { + text: "second [Image #1]".to_string(), + image_paths: vec![std::path::PathBuf::from("/tmp/second.png")], + }); + + assert!(app.submit_queued_messages_for_session(&session_id)); + + let user_message = app + .chat_state + .chat + .messages + .iter() + .find(|message| message.role == crate::session::types::MessageRole::User) + .unwrap(); + assert_eq!(user_message.content, "first [Image #1]\nsecond [Image #2]"); + assert_eq!( + user_message.local_image_paths, + vec!["/tmp/first.png".to_string(), "/tmp/second.png".to_string()] + ); + } + #[test] fn failed_stream_persists_partial_messages() { let mut app = test_app(); @@ -8270,6 +8474,60 @@ mod tests { ); } + #[tokio::test(flavor = "multi_thread")] + async fn queued_messages_submit_after_compaction_result() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Compact queue submit".to_string())); + app.base_focus = BaseFocus::Chat; + app.session_view_states + .get_mut(&session_id) + .unwrap() + .queued_messages + .push_back(QueuedUserMessage { + text: "Then about jolteon".to_string(), + image_paths: Vec::new(), + }); + + let stats = crate::session::types::CompactionStats { + before_tokens: 1_000, + after_tokens: 120, + before_messages: 5, + after_messages: 1, + }; + let compacted_messages = vec![crate::session::types::Message::assistant("summary")]; + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + sender + .send(CompactionTaskMessage::Success { + session_id: session_id.clone(), + messages: compacted_messages, + stats, + }) + .unwrap(); + drop(sender); + app.compaction_receiver = Some(receiver); + app.compaction_pending = Some(CompactionPending { + session_id: session_id.clone(), + before_tokens: stats.before_tokens, + }); + app.is_streaming = true; + + app.process_compaction_events(); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.queued_messages.is_empty()); + assert!(state.stream.is_some()); + assert!(app.is_streaming); + assert_eq!( + app.chat_state + .chat + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + vec!["summary", "Then about jolteon", ""] + ); + } + #[test] fn session_usage_text_includes_compaction_stats() { let mut app = test_app(); From d7c765a6581066422f880bce83f2ab6ae3982fde Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 31 May 2026 20:28:34 +0800 Subject: [PATCH 194/226] chore: todo --- _plans/__TODOS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index aed377c..3db772e 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -177,7 +177,7 @@ I want - [x] To do this But I dont want to do this - Should it be configurable? - Autodetected depending on the tool used: i.e. if Wezterm, other terminals "open w/ Finder on mac, or native image opener". If inside Zed, open image with Zed. If inside VSCode/Cursor, open with that IDE. (Ambitious but idk if possible) -- [ ] Make the permissions, config-driven customizable behavior. Make it like OpenCode, so we just link the docs for it in OpenCode. +- [x] Make the permissions, config-driven customizable behavior. Make it like OpenCode, so we just link the docs for it in OpenCode. - [x] View image locally tool, instead of read image. - [x] Clickable paths. From f5df7a919ac558ad14e90070153b5c582ca14249 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 31 May 2026 20:53:56 +0800 Subject: [PATCH 195/226] feat(theme): persist theme selection as state fallback Use persisted theme preference when config theme is unset, persist theme changes on preview/commit/rotate actions, and add active theme preference storage with tests so selected themes are retained across sessions. --- _plans/__TODOS.md | 2 +- src/app.rs | 100 ++++++++++++++++++++++----------------- src/persistence/prefs.rs | 26 ++++++++++ 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 3db772e..33ecba4 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -36,7 +36,7 @@ - [x] Feature: Rename command `/rename` - parity with opencode. -- [ ] Bug: theme is not persisted? Or is it by config? Just make theme be based on state now, no more config for it.. Actually not a bug.. Just warn that it must be configured, this is not your configured theme, configure it in your config. Or just a VERY MINOR warning that says 'You're only trying out this theme, set it in your theme'.??? +- [x] Let's make the 'theme' selection persisted somewhere in the 'state' (outside the config). So whatever I select, it gets selected. But this 'state' is the 2nd source of theme data, so it becomes a fallback. The primary is the config.. If the config is set, don't get the data from the persisted theme data state. But if it's not configured. Whatever is set, in persisted theme, that's what we use. - [x] Bug: skill loading on conflict. i.e. duplicate frontend-design skill. Warning: duplicate skill name 'frontend-design' (existing: /Users/carlo/.claude/skills/frontend-design/SKILL.md, duplicate: /Users/carlo/.config/opencode/skill/frontend-design/SKILL.md) diff --git a/src/app.rs b/src/app.rs index 07021c0..ed195e2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -458,11 +458,20 @@ impl App { ("big-pickle".to_string(), "opencode".to_string()) }; + let configured_theme_id = loaded_config.merged_config.theme.as_deref(); + let persisted_theme_id = if configured_theme_id.is_none() { + prefs_dao + .as_ref() + .and_then(|dao| dao.get_active_theme().ok().flatten()) + } else { + None + }; + let selected_theme_id = configured_theme_id.or(persisted_theme_id.as_deref()); let (themes, current_theme_index) = crate::config::discover_themes( &loaded_config.xdg_config_home, &loaded_config.project_root, &loaded_config.cwd, - loaded_config.merged_config.theme.as_deref(), + selected_theme_id, ); let agent_steps = agent_registry.max_steps_map(); let provider_timeouts = loaded_config.merged_config.provider_timeouts.clone(); @@ -1581,6 +1590,46 @@ impl App { pub fn cycle_theme(&mut self) { if !self.themes.is_empty() { self.current_theme_index = (self.current_theme_index + 1) % self.themes.len(); + if let Some(theme_id) = self + .themes + .get(self.current_theme_index) + .map(|theme| theme.id.clone()) + { + self.persist_theme_selection(&theme_id); + } + } + } + + fn preview_theme_by_id(&mut self, theme_id: &str) { + if let Some((idx, _)) = self + .themes + .iter() + .enumerate() + .find(|(_, theme)| theme.id == theme_id) + { + self.current_theme_index = idx; + } + } + + fn commit_theme_by_id(&mut self, theme_id: &str) -> Option { + let (idx, selected_theme_id) = self + .themes + .iter() + .enumerate() + .find(|(_, theme)| theme.id == theme_id) + .map(|(idx, theme)| (idx, theme.id.clone()))?; + + self.current_theme_index = idx; + self.themes_dialog_committed = true; + self.persist_theme_selection(&selected_theme_id); + Some(selected_theme_id) + } + + fn persist_theme_selection(&self, theme_id: &str) { + if let Some(ref dao) = self.prefs_dao { + if let Err(e) = dao.set_active_theme(theme_id.to_string()) { + eprintln!("Failed to save active theme: {}", e); + } } } @@ -1944,26 +1993,12 @@ impl App { match action { crate::views::themes_dialog::ThemesDialogAction::PreviewTheme { theme_id } => { - if let Some((idx, _)) = self - .themes - .iter() - .enumerate() - .find(|(_, t)| t.id == theme_id) - { - self.current_theme_index = idx; - } + self.preview_theme_by_id(&theme_id); } crate::views::themes_dialog::ThemesDialogAction::SelectTheme { theme_id } => { - if let Some((idx, theme)) = self - .themes - .iter() - .enumerate() - .find(|(_, t)| t.id == theme_id) - { - self.current_theme_index = idx; - self.themes_dialog_committed = true; + if let Some(selected_theme_id) = self.commit_theme_by_id(&theme_id) { push_toast(Toast::new( - format!("Theme: {}", theme.id), + format!("Theme: {}", selected_theme_id), ToastLevel::Info, None, )); @@ -2981,26 +3016,12 @@ impl App { match action { crate::views::themes_dialog::ThemesDialogAction::PreviewTheme { theme_id } => { - if let Some((idx, _)) = self - .themes - .iter() - .enumerate() - .find(|(_, t)| t.id == theme_id) - { - self.current_theme_index = idx; - } + self.preview_theme_by_id(&theme_id); } crate::views::themes_dialog::ThemesDialogAction::SelectTheme { theme_id } => { - if let Some((idx, theme)) = self - .themes - .iter() - .enumerate() - .find(|(_, t)| t.id == theme_id) - { - self.current_theme_index = idx; - self.themes_dialog_committed = true; + if let Some(selected_theme_id) = self.commit_theme_by_id(&theme_id) { push_toast(Toast::new( - format!("Theme: {}", theme.id), + format!("Theme: {}", selected_theme_id), ToastLevel::Info, None, )); @@ -3466,14 +3487,7 @@ impl App { .get_selected() .map(|it| it.id.clone()) { - if let Some((idx, _)) = self - .themes - .iter() - .enumerate() - .find(|(_, t)| t.id == theme_id) - { - self.current_theme_index = idx; - } + self.preview_theme_by_id(&theme_id); } } (_, OverlayFocus::ConnectDialog) => { diff --git a/src/persistence/prefs.rs b/src/persistence/prefs.rs index 1ba0d4a..1976031 100644 --- a/src/persistence/prefs.rs +++ b/src/persistence/prefs.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use super::{ensure_data_dir, get_data_dir}; const MODEL_PREFS_KEY: &str = "model_preferences"; +const ACTIVE_THEME_KEY: &str = "active_theme"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelRef { @@ -191,6 +192,17 @@ impl PrefsDAO { self.set_model_preferences(&prefs) } + pub fn get_active_theme(&self) -> Result> { + Ok(self + .get_pref(ACTIVE_THEME_KEY)? + .map(|theme| theme.trim().to_string()) + .filter(|theme| !theme.is_empty())) + } + + pub fn set_active_theme(&self, theme_id: String) -> Result<()> { + self.set_pref(ACTIVE_THEME_KEY, theme_id.trim()) + } + pub fn toggle_favorite(&self, provider_id: String, model_id: String) -> Result { let mut prefs = self.get_model_preferences()?; let was_favorite = prefs.is_favorite(&provider_id, &model_id); @@ -317,4 +329,18 @@ mod tests { assert_eq!(ref1, ref2); assert_ne!(ref1, ref3); } + + #[test] + fn test_active_theme_round_trip() { + let dao = setup_test_dao(); + + assert_eq!(dao.get_active_theme().unwrap(), None); + + dao.set_active_theme("tokyonight".to_string()).unwrap(); + + assert_eq!( + dao.get_active_theme().unwrap(), + Some("tokyonight".to_string()) + ); + } } From 381befe62a17de7ab260df31c081321131d11094 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 31 May 2026 20:57:14 +0800 Subject: [PATCH 196/226] fix(command-palette): match hidden command tokens during search. Populate `provider_id` with `registered.hidden_tokens` (joined as a single string) so command palette search can match hidden/alias tokens, and add a regression test ensuring "branch" still finds `fork` via its hidden token without relying on visible label text. --- src/views/command_palette.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index 1afb90f..bc8c425 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -273,7 +273,7 @@ fn core_palette_items(registry: &Registry, is_chat: bool) -> Vec { group: group.to_string(), description: description.to_string(), tip: command_palette_tip(command), - provider_id: String::new(), + provider_id: registered.hidden_tokens.join(" "), }); } @@ -443,6 +443,27 @@ mod tests { assert!(state.dialog.items.iter().any(|item| item.id == "fork")); } + #[test] + fn palette_search_matches_hidden_command_tokens() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true); + state.dialog.set_search_query("branch"); + + let matches = state + .dialog + .filtered_items + .iter() + .flat_map(|(_, items)| items.iter()) + .map(|item| (item.id.as_str(), item.name.as_str())) + .collect::>(); + + assert!(matches.contains(&("fork", "Fork Session"))); + assert!(!matches.iter().any(|(_, name)| name.contains("branch"))); + } + #[test] fn palette_uses_command_center_labels_without_slashes() { let mut registry = Registry::new(); From 191506d42159330f705df45ea9e9fdefe7b81e79 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 31 May 2026 21:14:23 +0800 Subject: [PATCH 197/226] feat(ui): add command palette toggle for assistant thinking visibility Add chat state to track whether assistant reasoning text is expanded, wire command palette actions to set visibility, and update reasoning rendering/tests so reasoning can be collapsed/expanded in-place. --- _plans/__TODOS.md | 2 +- src/app.rs | 11 ++- src/ui/components/chat.rs | 86 +++++++++++++--- src/views/command_palette.rs | 187 +++++++++++++++++++++++++++++++++-- 4 files changed, 257 insertions(+), 29 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 33ecba4..35bde41 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -208,7 +208,7 @@ I want - [x] To do this But I dont want to do this Build it with your usual `pnpm dev` / `pnpm build` to see the changes. ``` -- [ ] Make "▼ 💭 Thinking" rendered like this. And an accordion, so if I click it with my mouse, or with a special hotkey + command palette command. It can be toggled on and off. +- [x] Make "▼ 💭 Thinking" rendered like this. And an accordion, so if I click it with my mouse, or with a special hotkey + command palette command. It can be toggled on and off. - [x] Subagent UI view is not rendering the full table it seems like.. I always see this.. just the top. - `┌─────────────────────────┬────────────────────────────────────────────────────────────────────────────` - never the full table diff --git a/src/app.rs b/src/app.rs index ed195e2..1c0d4b9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3609,8 +3609,12 @@ impl App { } clear_suggestions(&mut self.suggestions_popup_state); - self.command_palette_state - .refresh_items(&self.command_registry, self.base_focus == BaseFocus::Chat); + let thinking_visible = self.chat_state.chat.thinking_visible(); + self.command_palette_state.refresh_items( + &self.command_registry, + self.base_focus == BaseFocus::Chat, + thinking_visible, + ); self.command_palette_state.show(); self.overlay_focus = OverlayFocus::CommandPalette; } @@ -3697,6 +3701,9 @@ impl App { self.overlay_focus = OverlayFocus::None; match action { CommandPaletteAppAction::ToggleAgentMode => self.toggle_agent_mode(), + CommandPaletteAppAction::SetThinkingVisible(visible) => { + self.chat_state.chat.set_thinking_visible(visible); + } CommandPaletteAppAction::CycleReasoningEffort => { let _ = self.cycle_active_reasoning_effort(); } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 3498635..aa9e4c9 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -51,6 +51,8 @@ pub struct Chat { streaming_renderer: Option, /// Index of the message currently being rendered by streaming_renderer streaming_message_idx: Option, + /// Whether assistant reasoning/thinking text is expanded in chat. + thinking_visible: bool, /// Starting line positions for each message in the rendered content pub message_line_positions: Vec, /// Text selection state for copy-on-select @@ -695,6 +697,7 @@ impl Chat { last_tps_calculated: None, streaming_renderer: None, streaming_message_idx: None, + thinking_visible: true, message_line_positions: Vec::new(), selection: Selection::new(), selection_edge_scroll: None, @@ -740,6 +743,7 @@ impl Chat { last_tps_calculated: None, streaming_renderer: None, streaming_message_idx: None, + thinking_visible: true, message_line_positions: Vec::new(), selection: Selection::new(), selection_edge_scroll: None, @@ -789,6 +793,19 @@ impl Chat { self.render_revision } + pub fn thinking_visible(&self) -> bool { + self.thinking_visible + } + + pub fn set_thinking_visible(&mut self, visible: bool) { + if self.thinking_visible == visible { + return; + } + + self.thinking_visible = visible; + self.invalidate_cache(); + } + fn should_autoscroll(&self) -> bool { self.autoscroll_enabled && !self.user_scrolled_up } @@ -939,9 +956,10 @@ impl Chat { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // Bump this whenever rendering logic changes (tables, markdown, etc.) - const RENDER_VERSION: u64 = 8; + const RENDER_VERSION: u64 = 9; RENDER_VERSION.hash(&mut h); colors.hash(&mut h); + self.thinking_visible.hash(&mut h); self.messages.len().hash(&mut h); for msg in &self.messages { std::mem::discriminant(&msg.role).hash(&mut h); @@ -2484,25 +2502,29 @@ impl Chat { let reasoning_trimmed = reasoning.trim(); if !reasoning_trimmed.is_empty() { emitted_anything = true; - let reasoning_prefix = "💭 Thinking..."; - lines.push(Line::from(vec![Span::styled( - reasoning_prefix, - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::ITALIC), - )])); - let reasoning_style = Style::default() .fg(colors.text_weak) .add_modifier(Modifier::ITALIC); - let reasoning_line = Line::from(Span::styled( - reasoning_trimmed.to_string(), + let reasoning_prefix = if self.thinking_visible { + "💭 Thinking..." + } else { + "💭 Thinking collapsed" + }; + lines.push(Line::from(vec![Span::styled( + reasoning_prefix, reasoning_style, - )); - lines.extend(wrap_styled_line( - &reasoning_line, - WrapOptions::new(max_width.max(1)), - )); + )])); + + if self.thinking_visible { + let reasoning_line = Line::from(Span::styled( + reasoning_trimmed.to_string(), + reasoning_style, + )); + lines.extend(wrap_styled_line( + &reasoning_line, + WrapOptions::new(max_width.max(1)), + )); + } // Add separator between reasoning and content (only if there's content) if has_visible_content { @@ -3875,6 +3897,38 @@ mod tests { assert_eq!(chat.messages.len(), 2); assert_eq!(chat.messages[0].content, "hello"); assert_eq!(chat.messages[1].content, "hi there"); + assert!(chat.thinking_visible()); + } + + #[test] + fn assistant_reasoning_can_be_collapsed() { + let mut assistant = Message::assistant("Final answer"); + assistant.reasoning = Some("Private reasoning".to_string()); + let mut chat = Chat::with_messages(vec![assistant]); + let colors = test_colors(); + + let expanded = chat + .build_all_lines(100, "model", &colors) + .iter() + .map(line_text) + .collect::>(); + assert!(expanded + .iter() + .any(|line| line.contains("Private reasoning"))); + + chat.set_thinking_visible(false); + let collapsed = chat + .build_all_lines(100, "model", &colors) + .iter() + .map(line_text) + .collect::>(); + + assert!(collapsed + .iter() + .any(|line| line.contains("Thinking collapsed"))); + assert!(!collapsed + .iter() + .any(|line| line.contains("Private reasoning"))); } #[test] diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index bc8c425..c11b364 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -18,6 +18,7 @@ pub enum CommandPaletteAction { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandPaletteAppAction { ToggleAgentMode, + SetThinkingVisible(bool), CycleReasoningEffort, OpenStorage, OpenSkillsDialog, @@ -35,7 +36,7 @@ impl CommandPaletteState { } } - pub fn refresh_items(&mut self, registry: &Registry, is_chat: bool) { + pub fn refresh_items(&mut self, registry: &Registry, is_chat: bool, thinking_visible: bool) { let was_visible = self.dialog.is_visible(); let search_query = self.dialog.search_query.clone(); let selected = self @@ -43,7 +44,7 @@ impl CommandPaletteState { .get_selected() .map(|item| (item.id.clone(), item.provider_id.clone())); - let mut items = core_palette_items(registry, is_chat); + let mut items = core_palette_items(registry, is_chat, thinking_visible); items.insert( items .iter() @@ -55,6 +56,7 @@ impl CommandPaletteState { "Model", "View and select available skills", None, + &[], ), ); @@ -171,11 +173,17 @@ fn command_palette_tip(command_name: &str) -> Option { } fn action_for_item(item: &DialogItem) -> CommandPaletteAction { - if item.provider_id == APP_ACTION_PROVIDER { + if is_app_action(item) { return match item.id.as_str() { "toggle-agent-mode" => { CommandPaletteAction::RunAppAction(CommandPaletteAppAction::ToggleAgentMode) } + "collapse-thinking" => CommandPaletteAction::RunAppAction( + CommandPaletteAppAction::SetThinkingVisible(false), + ), + "expand-thinking" => CommandPaletteAction::RunAppAction( + CommandPaletteAppAction::SetThinkingVisible(true), + ), "cycle-reasoning-effort" => { CommandPaletteAction::RunAppAction(CommandPaletteAppAction::CycleReasoningEffort) } @@ -192,7 +200,18 @@ fn action_for_item(item: &DialogItem) -> CommandPaletteAction { CommandPaletteAction::RunCommand(item.id.clone()) } -fn core_palette_items(registry: &Registry, is_chat: bool) -> Vec { +fn is_app_action(item: &DialogItem) -> bool { + item.provider_id + .split_whitespace() + .next() + .is_some_and(|provider_id| provider_id == APP_ACTION_PROVIDER) +} + +fn core_palette_items( + registry: &Registry, + is_chat: bool, + thinking_visible: bool, +) -> Vec { let mut items = Vec::new(); for (command, name, group, description) in [ @@ -285,9 +304,36 @@ fn core_palette_items(registry: &Registry, is_chat: bool) -> Vec { "Workspace", "Switch between Build and Plan", Some("tab"), + &[], ), ); + if is_chat { + let (id, name, description, hidden_tokens) = if thinking_visible { + ( + "collapse-thinking", + "Collapse Thinking", + "Collapse assistant reasoning details", + ["Hide thinking"], + ) + } else { + ( + "expand-thinking", + "Expand Thinking", + "Expand assistant reasoning details", + ["Show thinking"], + ) + }; + + items.insert( + items + .iter() + .position(|item| item.group == "Appearance") + .unwrap_or(items.len()), + app_action_item(id, name, "Appearance", description, None, &hidden_tokens), + ); + } + items.insert( items .iter() @@ -299,6 +345,7 @@ fn core_palette_items(registry: &Registry, is_chat: bool) -> Vec { "Model", "Switch reasoning effort for the active model", Some("ctrl+t"), + &[], ), ); @@ -313,6 +360,7 @@ fn core_palette_items(registry: &Registry, is_chat: bool) -> Vec { "Application", "Inspect Crabcode disk usage", None, + &[], ), ); @@ -374,14 +422,20 @@ fn app_action_item( group: &str, description: &str, tip: Option<&str>, + hidden_tokens: &[&str], ) -> DialogItem { + let provider_id = std::iter::once(APP_ACTION_PROVIDER) + .chain(hidden_tokens.iter().copied()) + .collect::>() + .join(" "); + DialogItem { id: id.to_string(), name: name.to_string(), group: group.to_string(), description: description.to_string(), tip: tip.map(str::to_string), - provider_id: APP_ACTION_PROVIDER.to_string(), + provider_id, } } @@ -424,11 +478,16 @@ mod tests { register_all_commands(&mut registry); let mut state = init_command_palette(); - state.refresh_items(®istry, false); + state.refresh_items(®istry, false, true); assert!(state.dialog.items.iter().any(|item| item.id == "models")); assert!(!state.dialog.items.iter().any(|item| item.id == "copy")); assert!(!state.dialog.items.iter().any(|item| item.id == "fork")); + assert!(!state + .dialog + .items + .iter() + .any(|item| item.id == "collapse-thinking" || item.id == "expand-thinking")); } #[test] @@ -437,7 +496,7 @@ mod tests { register_all_commands(&mut registry); let mut state = init_command_palette(); - state.refresh_items(®istry, true); + state.refresh_items(®istry, true, true); assert!(state.dialog.items.iter().any(|item| item.id == "copy")); assert!(state.dialog.items.iter().any(|item| item.id == "fork")); @@ -449,7 +508,7 @@ mod tests { register_all_commands(&mut registry); let mut state = init_command_palette(); - state.refresh_items(®istry, true); + state.refresh_items(®istry, true, true); state.dialog.set_search_query("branch"); let matches = state @@ -464,13 +523,121 @@ mod tests { assert!(!matches.iter().any(|(_, name)| name.contains("branch"))); } + #[test] + fn palette_shows_collapse_thinking_when_thinking_is_visible() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, true); + + assert!(state + .dialog + .items + .iter() + .any(|item| item.id == "collapse-thinking" && item.name == "Collapse Thinking")); + assert!(!state + .dialog + .items + .iter() + .any(|item| item.id == "expand-thinking")); + } + + #[test] + fn palette_shows_expand_thinking_when_thinking_is_hidden() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, false); + + assert!(state + .dialog + .items + .iter() + .any(|item| item.id == "expand-thinking" && item.name == "Expand Thinking")); + assert!(!state + .dialog + .items + .iter() + .any(|item| item.id == "collapse-thinking")); + } + + #[test] + fn palette_search_matches_hidden_thinking_tokens() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, false); + state.dialog.set_search_query("show thinking"); + + let matches = state + .dialog + .filtered_items + .iter() + .flat_map(|(_, items)| items.iter()) + .map(|item| (item.id.as_str(), item.name.as_str())) + .collect::>(); + + assert!(matches.contains(&("expand-thinking", "Expand Thinking"))); + assert!(!matches + .iter() + .any(|(_, name)| name.contains("Show thinking"))); + + state.refresh_items(®istry, true, true); + state.dialog.set_search_query("hide thinking"); + + let matches = state + .dialog + .filtered_items + .iter() + .flat_map(|(_, items)| items.iter()) + .map(|item| (item.id.as_str(), item.name.as_str())) + .collect::>(); + + assert!(matches.contains(&("collapse-thinking", "Collapse Thinking"))); + assert!(!matches + .iter() + .any(|(_, name)| name.contains("Hide thinking"))); + } + + #[test] + fn palette_thinking_items_map_to_visibility_actions() { + let collapse = app_action_item( + "collapse-thinking", + "Collapse Thinking", + "Appearance", + "Collapse assistant reasoning details", + None, + &["Hide thinking"], + ); + let expand = app_action_item( + "expand-thinking", + "Expand Thinking", + "Appearance", + "Expand assistant reasoning details", + None, + &["Show thinking"], + ); + + assert_eq!( + action_for_item(&collapse), + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::SetThinkingVisible(false)) + ); + assert_eq!( + action_for_item(&expand), + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::SetThinkingVisible(true)) + ); + } + #[test] fn palette_uses_command_center_labels_without_slashes() { let mut registry = Registry::new(); register_all_commands(&mut registry); let mut state = init_command_palette(); - state.refresh_items(®istry, true); + state.refresh_items(®istry, true, true); assert!(state .dialog @@ -501,7 +668,7 @@ mod tests { }); let mut state = init_command_palette(); - state.refresh_items(®istry, true); + state.refresh_items(®istry, true, true); let custom = state .dialog From dcf2d1e32c9ce9f8569f2a493685a8d62f306b78 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 31 May 2026 23:49:15 +0800 Subject: [PATCH 198/226] chore: remote usage plan. --- _docs/__remote-usage-plan.md | 424 ++++++++++++++++++++++++++++------- 1 file changed, 341 insertions(+), 83 deletions(-) diff --git a/_docs/__remote-usage-plan.md b/_docs/__remote-usage-plan.md index 9f5473d..ddc5cc2 100644 --- a/_docs/__remote-usage-plan.md +++ b/_docs/__remote-usage-plan.md @@ -2,18 +2,23 @@ Internal planning note. This is not public user guidance yet, and it should stay out of `gittydocs.jsonc` navigation until we decide what is safe and stable enough to document. -Last updated: 2026-05-21. +Last updated: 2026-05-31. ## Product Goal Make crabcode usable when the machine that owns the workspace is not the machine in the user's hands. +The product model should be simple enough to remember: + +> One machine hosts the workspace. Every other device attaches. + The important cases are: - A developer uses crabcode installed on a VPS, desktop, homelab box, or laptop from another computer. - A developer controls crabcode from a phone while away from the keyboard. - A developer uses an iPad or other tablet to SSH into a VPS/Mac mini, starts crabcode remote access, then uses the tablet browser to control crabcode and view the app being developed on the remote machine. - A developer starts a long agent run, disconnects, and later resumes from another device without losing stream state. +- A developer starts crabcode on a host, then can use either a phone browser, another laptop TUI, or a quick non-interactive prompt against the same host URL. - A developer can use this safely without exposing a write-capable coding agent directly to the public internet. This should stay terminal-first. Remote access is about reaching the same terminal-native agent from more places, not turning crabcode into a hosted cloud product. @@ -22,11 +27,44 @@ Scope decision: this is personal-device and personal-VPS access for now. Team/sh ## Recommendation -Use three lanes: +Make remote access a first-class host/client shape: + +```bash +# Host machine / VPS +crabcode serve + +# Phone / tablet +# Open the printed browser URL. + +# Another laptop +crabcode attach + +# Script, launcher, or quick remote prompt +crabcode -p --attach "continue the refactor" +``` + +`crabcode serve` is the primitive. It owns the workspace, credentials, session history, active generations, permission prompts, and pairing. Browser access, `crabcode attach`, and `crabcode -p --attach` are clients of the same service protocol. + +Support what already works first: SSH into the remote machine, run `crabcode` inside `tmux` or `zellij`, and document Tailscale as the preferred private network path. But the target product should not stop at SSH. The ten-out-of-ten version is a host URL that can be used from a phone browser, a remote terminal client, or a non-interactive CLI prompt. + +The first polished host output should look like: + +```text +crabcode host ready + +Workspace: /home/carlo/project +Browser: http://devbox:8421 +Attach: crabcode attach http://devbox:8421 +Prompt: crabcode -p --attach http://devbox:8421 "..." +Pair: 482-119 expires in 10 minutes +``` -1. Support what already works: SSH into the remote machine, run `crabcode` inside `tmux` or `zellij`, and document Tailscale as the preferred private network path. -2. Turn crabcode into a backend service the first time `crabcode` is run. The TUI becomes one client of that backend, and active generations can outlive any one TUI client. -3. After the backend exists, add a visible `crabcode serve` command for phone and browser access, bound privately by default. The browser surface should be a minimal touch-native frontend backed by crabcode's service API. +The browser UI and `attach` TUI must not become separate implementations. Build one authenticated host API and event stream, then put thin clients on top: + +- Phone browser: touch-first prompt, approve, cancel, and preview-link control. +- `crabcode attach `: terminal-native remote TUI that feels like local crabcode, with clear remote host/cwd/model status. +- `crabcode -p --attach `: non-interactive remote prompt for scripts, aliases, launchers, and quick follow-ups. +- Future clients: native app, desktop app, shortcuts, or automation can reuse the same protocol if they become worth building. Do not build a separate native app yet. A separate app adds release, auth, pairing, mobile UX, and protocol maintenance before we even know if the runtime protocol is correct. If mobile browser limitations become the real blocker, revisit a native app after the web companion exists. @@ -43,10 +81,11 @@ crabcode is currently a local TUI process: - `src/persistence/history.rs` persists workspaces, sessions, and messages to SQLite. - `crabcode -s ` resumes an existing session after process restart. - `crabcode -p ""` supports non-interactive print mode. +- There is no `crabcode serve`, `crabcode attach `, or `crabcode -p --attach `. - There is no HTTP server, websocket server, or remote client protocol. - There is no durable active-generation owner. If the TUI process dies, the active stream dies with it. -The existing multiworkspace plan already points at the architectural prerequisite: split durable session state from TUI state and add a runtime that owns active generations. +The existing multiworkspace plan already points at the architectural prerequisite: split durable session state from TUI state and add a runtime that owns active generations. The remote plan should reuse that split instead of building a parallel web-only architecture. ## Usage Modes @@ -82,56 +121,74 @@ Immediate polish work for this mode: - Consider a short "remote terminal checklist" in public docs later: install, authenticate, use `tmux`, use Tailscale or hardened SSH, avoid running as root. - Decide whether sounds and desktop notifications should auto-disable or degrade cleanly when running over SSH. -### Mode B: Local backend service +### Mode B: Host service This is the real product foundation. -The first time `crabcode` runs, it should ensure a local crabcode backend service exists, then attach a TUI client to it. The backend owns active generations, persists events, and lets clients attach and detach. It does not have to be a user-managed service. +`crabcode serve` starts the host runtime for the current workspace. The host owns active generations, persists events, serves the phone UI, accepts terminal attaches, and lets clients disconnect/reconnect without killing work. -Expected shape: +Expected workflow: -- Runtime socket under the crabcode state dir, such as `~/.local/state/crabcode/runtime.sock`. -- Runtime starts on demand and exits after an idle timeout once there are no connected clients and no active generations. -- TUI, browser, and future clients send commands: - - `ListWorkspaces` - - `ListSessions` - - `CreateSession` - - `LoadSession` - - `StartGeneration` - - `CancelGeneration` - - `ApprovePermission` - - `AnswerQuestion` - - `SubscribeSession` -- Runtime writes durable state: - - user messages immediately - - generation rows for active turns - - throttled assistant/tool snapshots during streaming - - explicit events for status changes, permission waits, question waits, tool calls, tool results, errors, cancellation, and completion -- Clients can disconnect without killing active generations. -- Multiple devices can control the same session. -- Permission/question prompts can be answered from any connected controlling device. The backend must make approval state idempotent so the first answer wins and later duplicate answers become no-ops. -- Connected controlling clients should show presence/activity for the same session, such as "phone attached", "desktop attached", "Carlo approved bash", or "desktop is typing". +```bash +cd ~/code/project +crabcode serve +``` -This also fixes local multi-terminal usage, not just remote usage. +Expected output: -### Mode C: Minimal Browser Frontend +```text +crabcode host ready -Only build this after Mode B exists. +Workspace: /home/carlo/project +Browser: http://devbox:8421 +Attach: crabcode attach http://devbox:8421 +Prompt: crabcode -p --attach http://devbox:8421 "..." +Pair: 482-119 expires in 10 minutes +``` -The first real phone surface should be a small web frontend that talks to the crabcode backend API. Do not adapt the current TUI directly for the browser: crabcode's current TUI is keyboard-driven, while the phone use case is touch, mobile text entry, and quick glance/control while away from the keyboard. +Expected shape: -The server can be in the same `crabcode` binary: +- Runtime socket under the crabcode state dir for local clients, such as `~/.local/state/crabcode/runtime.sock`. +- HTTP API and event stream for browser/remote clients. +- Host starts explicitly with `crabcode serve`; a later local daemon can also start on demand when normal `crabcode` runs. +- Host exits only when explicitly stopped, or after a configured idle timeout if daemon mode is added later. +- Host keeps provider credentials on the machine running crabcode. +- Host exposes no general-purpose terminal and no arbitrary port proxy. +- Host emits durable session events and status snapshots so clients can replay state after reconnecting. +- Host prints a browser URL, attach command, print-mode attach command, short-lived pairing code, and ideally a terminal QR code. + +Host commands accepted from clients: + +- `ListWorkspaces` +- `ListSessions` +- `CreateSession` +- `LoadSession` +- `StartGeneration` +- `CancelGeneration` +- `ApprovePermission` +- `DenyPermission` +- `AnswerQuestion` +- `SubscribeSession` +- `ListPreviewLinks` +- `SavePreviewLink` +- `ListClients` + +Host-written durable state: + +- user messages immediately +- generation rows for active turns +- throttled assistant/tool snapshots during streaming +- explicit events for status changes, permission waits, question waits, tool calls, tool results, errors, cancellation, completion, preview-link changes, and client attach/detach -```bash -crabcode serve --bind 127.0.0.1:8421 -``` +This also fixes local multi-terminal usage, not just remote usage. -Potential Tailscale workflow: +### Mode C: Remote clients -```bash -crabcode serve --bind 127.0.0.1:8421 -tailscale serve --bg http://127.0.0.1:8421 -``` +Build browser, terminal attach, and print-mode attach against the same host protocol. + +#### Phone browser + +The phone surface should be a small touch-native frontend. Do not adapt the current TUI directly for the browser: crabcode's current TUI is keyboard-driven, while the phone use case is mobile text entry, quick glance/control, and approvals while away from the keyboard. Minimal useful slice: @@ -143,24 +200,53 @@ Minimal useful slice: - Stop/cancel an active generation. - Approve/deny permission prompts. - Answer model questions. -- Show or remember externally reachable dev preview URLs, such as a Tailscale host URL, SSH-forwarded URL, or another tunnel URL. - Show current workspace, model, agent mode, remote host, and connected devices. - Show simple presence/activity for other controlling clients. +- Show and pin external dev preview URLs. + +The phone client should default toward a prompt/approve role, not a full IDE role. It can grow later, but v1 should make the common away-from-keyboard path excellent. + +#### Terminal attach -Why this is a good first slice: +`crabcode attach ` should launch a remote TUI client for another laptop or terminal-capable tablet. -- It gives touch-native controls for the actions a phone user actually needs. -- It uses the same durable backend protocol the TUI needs anyway. -- It avoids making users learn terminal gestures or keyboard shortcuts on glass. -- It can grow toward CLI-equivalent control without pretending the phone is a terminal. +Expected workflow: + +```bash +crabcode attach http://devbox:8421 +``` + +It should feel like local crabcode, with persistent remote context in the UI: + +```text +remote: devbox cwd: /home/carlo/project model: opencode/big-pickle +``` -Risks: +Attach should support the same core actions as the local TUI: session list, transcript, prompt input, cancel, approvals, questions, model/agent metadata, and preview links. Advanced local-only workflows can remain SSH-only temporarily, but the attach path should be treated as a first-class client rather than a debugging convenience. -- It requires designing and maintaining a small frontend. -- Every CLI feature we expose needs a backend API shape. -- We need to be careful not to accidentally build a full web IDE. +#### Print-mode attach -This frontend should be intentionally narrow: it is a remote controller for crabcode sessions, not a replacement IDE. +`crabcode -p --attach "..."` should submit a prompt to the host and stream the result to stdout. + +Expected workflow: + +```bash +crabcode -p --attach http://devbox:8421 "continue the next step" +``` + +This is useful for scripts, aliases, launchers, phone shortcuts, and quick prompts from a second laptop. It is also the simplest client and should be implemented early as a protocol proving ground before the full remote TUI. + +#### Remembered hosts + +After a successful pairing, users should be able to name and reuse hosts: + +```bash +crabcode hosts +crabcode attach devbox +crabcode -p --attach devbox "summarize the current state" +``` + +Remembered hosts should store host URL, display name, trust token, last-used time, and possibly a friendly workspace label. They must not store provider credentials. ### Mode D: Remote dev previews @@ -185,27 +271,120 @@ crabcode can help by: - Letting the user save or pin preview URLs in the remote UI. - Detecting likely dev-server URLs from command output when practical and presenting them as links. - Warning when a preview URL points at the local browser's `localhost`, because that usually is not the remote devbox. +- Offering a preview-link panel in the browser and attach TUI, scoped to the active workspace/session. +- Supporting simple localhost-to-remote hints, such as "the host printed `localhost:3000`; try `http://devbox:3000` or your Tailscale host URL." This keeps the security boundary clearer. crabcode controls crabcode sessions; network/tunnel tools expose dev servers. +## Product Details That Make This Feel Native + +### Pairing + +Pairing should be fast and obvious: + +- Print the browser URL. +- Print the attach command. +- Print the `-p --attach` command shape. +- Print a short pairing code with an expiry timer. +- Print a terminal QR code when the terminal can reasonably display it. +- Remember trusted devices after pairing. +- Let the user revoke trusted devices from the host. + +### Device roles + +Clients should have explicit roles: + +- `phone`: prompt, cancel, approve/deny, answer questions, and manage preview links. +- `attach-tui`: full terminal control where implemented. +- `print`: submit one prompt and stream one answer. +- `monitor`: read-only transcript/event stream, later if needed. + +The role should be shown in presence/activity and approval audit logs. The phone role should be the safe default for browser clients because it matches the "continue prompting from my phone" use case without implying a full browser IDE. + +### Presence and audit trail + +Connected clients should be visible in the session: + +- "phone attached" +- "desktop attached" +- "Carlo approved bash from phone" +- "desktop submitted prompt" +- "phone disconnected" + +Presence is not collaboration yet. It is there to reduce ambiguity when multiple personal devices are controlling one write-capable agent. + +### Host aliases + +The first pairing can optionally save a friendly host alias: + +```bash +crabcode attach devbox +crabcode -p --attach devbox "what is currently running?" +``` + +Aliases should be local to the attaching device and map to a URL plus trust token. They should be easy to list, rename, and forget. + +## Protocol Shape + +Use one event/API contract for the browser UI, `crabcode attach`, and `crabcode -p --attach`. + +Session event envelope: + +```text +SessionEvent { + seq + session_id + generation_id? + actor_client_id? + kind + payload + created_at +} +``` + +Important event kinds: + +- `client_attached` +- `client_detached` +- `user_message_added` +- `generation_started` +- `assistant_delta` +- `assistant_snapshot` +- `assistant_completed` +- `tool_started` +- `tool_updated` +- `tool_completed` +- `permission_requested` +- `permission_answered` +- `question_requested` +- `question_answered` +- `generation_cancelled` +- `generation_failed` +- `preview_link_saved` +- `preview_link_removed` + +Replay rule: a client should be able to load the latest session snapshot, subscribe from a known `seq`, and recover cleanly after browser sleep, SSH drop, laptop close, or mobile network changes. + ## Security Defaults Remote crabcode is write-capable by design, so defaults must be conservative. - Bind localhost only unless the user explicitly passes a non-local bind address. - Never recommend opening a crabcode HTTP port directly to the public internet. -- Require authentication for any HTTP/browser access, even on a tailnet. -- Use a short-lived pairing code for new browser clients. -- Store trusted browser clients separately from provider credentials. +- Require authentication for browser, attach, and print-mode attach clients, even on a tailnet. +- Use a short-lived pairing code for new clients. +- Store trusted client tokens separately from provider credentials. - Include CSRF and Origin checks for browser routes. - Use backend API routes for the mobile frontend. Do not expose a browser terminal. - Do not expose arbitrary remote ports. crabcode should not become a general-purpose open proxy. - Show remote host, cwd, model, agent, and pending command/file changes in permission prompts. +- Show the requesting/approving device role in permission prompts and audit events. +- Let the host restrict client roles, such as phone prompt/approve only, attach TUI full control, or monitor read-only. - Keep provider credentials on the machine running crabcode. Do not sync `auth.json` between devices in the first design. - Log remote approvals and denials into the session event stream. - Allow multiple controlling devices for the same session, but make state-changing operations idempotent and auditable. - Show presence/activity for connected controlling devices. -- Shut the backend down after an idle timeout when there are no clients and no active generations. +- If daemon mode is enabled later, shut the backend down after an idle timeout when there are no clients and no active generations. - Treat public internet sharing as a non-goal for now. A private overlay network is acceptable; a public URL to a write-capable coding agent is not the default shape. ## Private Network Position @@ -228,15 +407,26 @@ Docs we should reference later: Implementation implication: crabcode does not need to integrate with Tailscale or any private-network provider APIs for v1. We only need to avoid fighting them by binding cleanly to localhost or a chosen address and by documenting the safe path. +Possible convenience later: + +```bash +crabcode serve --bind 127.0.0.1:8421 +crabcode serve --bind 100.x.y.z:8421 +crabcode serve --bind tailnet +``` + +`--bind tailnet` should only exist if we can make the behavior predictable. Otherwise, keep the primitive explicit and let users pass the address they want. + ## Implementation Plan ### Phase 0: Internal planning - Keep this document internal. -- Target both remote-computer access and phone control. +- Target the host/client model: one machine hosts, every other device attaches. +- Remote v1 should include `crabcode serve`, phone browser access, `crabcode -p --attach`, and `crabcode attach`. - Treat phone access as a full remote control surface, not read-only monitoring. - Keep the scope personal-device/personal-VPS for now. -- Make `crabcode serve` visible in help. +- Make `crabcode serve`, `crabcode attach`, and `crabcode -p --attach` visible in help once implemented. - Use a minimal touch-native frontend as the first browser slice. - Do not pursue a browser terminal path for this plan. @@ -256,40 +446,91 @@ Code polish candidates: - Better post-exit resume instructions. - Clearer behavior for sounds, notifications, and clipboard over SSH. -### Phase 2: Runtime architecture +### Phase 2: Shared protocol boundary + +Define the host/client contract before building individual clients. + +- Add typed command and event enums for the host API. +- Add a session event envelope with monotonic `seq`. +- Add session snapshot/replay semantics. +- Add client identity, client role, and trusted-client token types. +- Add preview-link data types. +- Add idempotency keys for approvals, questions, prompt submits, and cancellation. +- Keep the protocol transport-agnostic enough that local socket, HTTP/SSE, WebSocket, and future native clients can share the same command/event model. -- Introduce a local backend process and IPC protocol. -- Start or connect to the backend on the first normal `crabcode` run. +This phase should be mostly internal Rust types and adapters. It is the guardrail that keeps browser, attach TUI, and print attach from becoming three separate products. + +### Phase 3: Host runtime and `crabcode serve` + +- Add visible `crabcode serve`. +- Start with localhost binding by default. +- Add explicit command-line warnings when binding to non-local addresses. +- Print browser URL, attach command, print-mode attach command, pairing code, and QR code if feasible. +- Add token/pairing auth. +- Add HTTP routes for session list/create/load, prompt submit, cancel, approve/deny, answer question, model/agent metadata, client presence, and preview links. +- Add SSE or WebSocket event streaming for sessions/generations. +- Move active stream ownership out of `App` and into the host runtime enough for remote clients to control it. - Persist generation status and event stream in SQLite. -- Move active stream ownership out of `App`. -- Make the TUI subscribe to session events. -- Preserve per-session client view state in the TUI. -- Support detach/reconnect for active generations. -- Support multiple controlling clients on the same session. +- Make permission/question prompts answerable from any connected controlling device. +- Make approvals/questions idempotent so the first answer wins and duplicate answers become no-ops. - Emit presence/activity events for attached clients. -- Exit the backend after an idle timeout. -This phase should reuse the multiworkspace plan rather than becoming a parallel architecture. +This phase should reuse the multiworkspace/session persistence work rather than becoming a parallel remote-only runtime. -### Phase 3: Remote API +### Phase 4: Print-mode attach -- Add visible `crabcode serve` CLI help. -- Start with localhost binding only. -- Add token/pairing auth. -- Add WebSocket or SSE event streaming for sessions/generations. -- Add backend API routes for session list/create/load, prompt submit, cancel, approve/deny, answer question, model/agent metadata, and presence. -- Add tests for auth, disconnect/reconnect, idle shutdown, multiple controlling clients, permission idempotency, and event replay. -- Add explicit command-line warnings when binding to non-local addresses. +Implement the simplest non-browser client first: + +```bash +crabcode -p --attach http://devbox:8421 "continue the next step" +``` + +- Resolve host aliases as well as raw URLs. +- Pair if the host does not already trust this client. +- Submit one prompt to the selected/default session. +- Stream assistant output to stdout. +- Surface permission/question waits clearly, with a compact approval path if interactive stdin is available. +- Exit with useful status codes for cancelled, failed, denied, and completed turns. -### Phase 4: Minimal Browser/PWA client +This is the fastest way to prove the host protocol from outside the original TUI. + +### Phase 5: Minimal Browser/PWA client - Serve a small static web client from the binary or bundled assets. - Optimize for phone/tablet first: session list, new session, transcript, input, approvals, questions, stop, and saved external preview links. -- Grow toward CLI-equivalent control from the browser. +- Default browser clients to a phone-style role: prompt, cancel, approve/deny, answer questions, and preview links. +- Show connected devices and recent remote activity. +- Show host, cwd, model, agent, and pending command/file details prominently around approval prompts. +- Grow toward CLI-equivalent control from the browser only where it helps the phone/tablet workflow. - Keep advanced TUI-only workflows in SSH only as temporary gaps. - Add installable PWA metadata only if the mobile browser experience is good. -### Phase 5: Revisit native app +### Phase 6: Terminal attach + +Implement the richer terminal client: + +```bash +crabcode attach http://devbox:8421 +crabcode attach devbox +``` + +- Render a TUI client backed by host snapshots and session events. +- Keep remote context visible in the status line: host, cwd, model, agent, and attached client role. +- Support session list, transcript, input, streaming, cancel, approvals, questions, model/agent metadata, and preview links. +- Preserve local UI state on the attaching machine where appropriate, but keep canonical session/generation state on the host. +- Handle reconnects after SSH drops, laptop sleep, or network changes by replaying from the last seen event `seq`. + +### Phase 7: Local daemon mode + +Once the explicit host/client shape works, consider making normal local `crabcode` attach to an on-demand local backend too. + +- Start or connect to a local backend on normal `crabcode` runs. +- Use a runtime socket under the crabcode state dir, such as `~/.local/state/crabcode/runtime.sock`. +- Let local TUI clients detach/reconnect without killing active generations. +- Exit the daemon after an idle timeout when there are no clients and no active generations. +- Keep this as an evolution of `crabcode serve`, not a separate architecture. + +### Phase 8: Revisit native app Only consider a separate app if: @@ -298,13 +539,30 @@ Only consider a separate app if: - Pairing and secure storage are stable. - The runtime protocol has stopped changing quickly. +## Product Completeness Criteria + +The plan is only a 10/10 if the first complete remote story feels like this: + +- A host can run `crabcode serve` and immediately see the browser URL, attach command, print-mode attach command, and pairing code. +- A phone can pair from the printed URL or QR code and submit a prompt without touching SSH. +- A phone can approve/deny a permission request with enough context to understand the host, cwd, command/file target, model, and requesting device. +- Another laptop can run `crabcode -p --attach "..."` and stream a remote answer. +- Another laptop can run `crabcode attach ` and get a real remote TUI client. +- Closing the phone browser, losing SSH, or sleeping the attaching laptop does not kill the active generation. +- Reattaching shows the current transcript and generation state without duplicated approvals or corrupted history. +- Preview URLs are visible and pinnable, but crabcode does not proxy arbitrary dev-server ports. +- Remembered hosts make repeat use short: `crabcode attach devbox` and `crabcode -p --attach devbox "..."`. + ## Open Questions - Do remote approvals need a stricter permission mode than local approvals? -- Should remote browser clients be allowed to run every slash command, or should some commands stay TUI-only at first? -- What idle timeout should the backend use? +- Which client roles should be available in v1, and should browser clients default to `phone` rather than full control? +- Should remote browser clients be allowed to run every slash command, or should some commands stay attach/SSH-only at first? +- What idle timeout should daemon mode use, if explicit `crabcode serve` does not auto-exit? - What is the smallest complete mobile command surface after sessions, prompt input, cancel, approvals, questions, and transcript? - Should crabcode auto-detect dev-server URLs from terminal output, or only let users manually add/pin them? +- Should event streaming use SSE first, WebSocket first, or both behind the same internal event API? +- How should host aliases be named when one host serves multiple workspaces over time? ## Non-Goals For Now From 069fab546902459644897f35e2010668cec5061f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 3 Jun 2026 04:40:30 +0800 Subject: [PATCH 199/226] feat(remote): BIG add remote mode support with client UI and release plumbing Add a full remote workflow by introducing new `src/remote` backend integration, wiring it through command/session/model/persistence flows, and adding the `remote-client` frontend for project browsing, messages, and attachments. Update CI/release config, docs, and settings/schema to support remote usage and distribution, including new setup/build files and usage documentation. --- .github/dist-build-setup.yml | 11 + .github/workflows/release.yml | 11 + .gitignore | 3 + Cargo.lock | 7 + Cargo.toml | 17 +- README.md | 13 +- _docs/gittydocs.jsonc | 1 + _docs/remote-usage.mdx | 257 + _plans/__TODOS.md | 4 + {_docs => _plans}/__remote-usage-plan.md | 0 build.rs | 90 + crabcode.jsonc | 2 +- dist-workspace.toml | 2 + justfile | 7 + remote-client/.gitignore | 2 + remote-client/bun.lock | 683 +++ remote-client/package.json | 27 + remote-client/src/assets/icons/index.ts | 12 + remote-client/src/assets/icons/ph-brain.tsx | 5 + .../icons/vscode-icons-default-file.tsx | 5 + .../icons/vscode-icons-file-type-css.tsx | 5 + .../icons/vscode-icons-file-type-html.tsx | 5 + .../icons/vscode-icons-file-type-js.tsx | 5 + .../icons/vscode-icons-file-type-json.tsx | 5 + .../icons/vscode-icons-file-type-markdown.tsx | 5 + .../icons/vscode-icons-file-type-reactts.tsx | 5 + .../icons/vscode-icons-file-type-rust.tsx | 5 + .../icons/vscode-icons-file-type-toml.tsx | 5 + .../vscode-icons-file-type-typescript.tsx | 5 + .../icons/vscode-icons-file-type-yaml.tsx | 5 + .../components/ai-elements/attachments.tsx | 220 + .../src/components/ai-elements/message.tsx | 98 + .../src/components/ai-elements/shimmer.tsx | 36 + .../components/remote/collapsible-panel.tsx | 57 + .../components/remote/faded-edge-effect.tsx | 49 + .../src/components/remote/project-favicon.tsx | 65 + .../src/components/remote/project-list.tsx | 239 + .../src/components/ui/context-menu.tsx | 74 + remote-client/src/components/ui/popover.tsx | 28 + remote-client/src/icons.tsx | 223 + remote-client/src/lib/cx.ts | 3 + remote-client/src/pages/+Layout.tsx | 29 + remote-client/src/pages/+config.ts | 8 + remote-client/src/pages/index/+Page.tsx | 4539 +++++++++++++++++ remote-client/src/remote-api.ts | 250 + remote-client/src/styles/app.css | 153 + remote-client/tsconfig.json | 18 + remote-client/vite.config.ts | 9 + src/app.rs | 597 ++- src/command/handlers.rs | 70 +- src/command/parser.rs | 30 +- src/command/registry.rs | 18 +- src/llm/client.rs | 60 +- src/main.rs | 120 +- src/model/reasoning.rs | 17 + src/persistence/history.rs | 98 +- src/remote/mod.rs | 3312 ++++++++++++ src/session/manager.rs | 150 +- src/tools/permission.rs | 5 + src/views/home.rs | 77 +- src/views/permission_dialog.rs | 58 + src/views/question_dialog.rs | 82 + 62 files changed, 11864 insertions(+), 137 deletions(-) create mode 100644 .github/dist-build-setup.yml create mode 100644 _docs/remote-usage.mdx rename {_docs => _plans}/__remote-usage-plan.md (100%) create mode 100644 build.rs create mode 100644 remote-client/.gitignore create mode 100644 remote-client/bun.lock create mode 100644 remote-client/package.json create mode 100644 remote-client/src/assets/icons/index.ts create mode 100644 remote-client/src/assets/icons/ph-brain.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-default-file.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-css.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-html.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-js.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-json.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-markdown.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-reactts.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-rust.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-toml.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-typescript.tsx create mode 100644 remote-client/src/assets/icons/vscode-icons-file-type-yaml.tsx create mode 100644 remote-client/src/components/ai-elements/attachments.tsx create mode 100644 remote-client/src/components/ai-elements/message.tsx create mode 100644 remote-client/src/components/ai-elements/shimmer.tsx create mode 100644 remote-client/src/components/remote/collapsible-panel.tsx create mode 100644 remote-client/src/components/remote/faded-edge-effect.tsx create mode 100644 remote-client/src/components/remote/project-favicon.tsx create mode 100644 remote-client/src/components/remote/project-list.tsx create mode 100644 remote-client/src/components/ui/context-menu.tsx create mode 100644 remote-client/src/components/ui/popover.tsx create mode 100644 remote-client/src/icons.tsx create mode 100644 remote-client/src/lib/cx.ts create mode 100644 remote-client/src/pages/+Layout.tsx create mode 100644 remote-client/src/pages/+config.ts create mode 100644 remote-client/src/pages/index/+Page.tsx create mode 100644 remote-client/src/remote-api.ts create mode 100644 remote-client/src/styles/app.css create mode 100644 remote-client/tsconfig.json create mode 100644 remote-client/vite.config.ts create mode 100644 src/remote/mod.rs diff --git a/.github/dist-build-setup.yml b/.github/dist-build-setup.yml new file mode 100644 index 0000000..cdb33b5 --- /dev/null +++ b/.github/dist-build-setup.yml @@ -0,0 +1,11 @@ +- name: Install Bun + uses: oven-sh/setup-bun@v2 +- name: Build remote client + shell: bash + run: | + cd remote-client + bun install --frozen-lockfile + bun run build +- name: Verify remote client assets + shell: bash + run: test -s remote-client/dist/client/index.html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c59af5..3a769ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,6 +127,17 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y echo "$HOME/.cargo/bin" >> $GITHUB_PATH fi + - name: "Install Bun" + uses: "oven-sh/setup-bun@v2" + - name: "Build remote client" + run: | + cd remote-client + bun install --frozen-lockfile + bun run build + shell: "bash" + - name: "Verify remote client assets" + run: "test -s remote-client/dist/client/index.html" + shell: "bash" - name: Install dist run: ${{ matrix.install_dist.run }} # Get the dist-manifest diff --git a/.gitignore b/.gitignore index a2c76b7..de74e2c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ _dev_reference1 _dev_reference2 .env node_modules +remote-client/dist/assets.json +remote-client/dist/client/.vite/ +remote-client/dist/server/ .devrefs/references .benchmarks/ benchmark-reports/ diff --git a/Cargo.lock b/Cargo.lock index bea326d..f0f2aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,6 +499,7 @@ dependencies = [ "lazy_static", "nucleo-matcher", "pulldown-cmark", + "qrcode", "rand", "ratatui", "ratatui-core", @@ -2407,6 +2408,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index ddcf30f..ad112b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,20 @@ readme = "README.md" keywords = ["ai", "cli", "coding", "agent", "tui"] categories = ["command-line-utilities", "development-tools", "api-bindings"] authors = ["Carlo Taleon "] +include = [ + "/Cargo.toml", + "/Cargo.lock", + "/build.rs", + "/src/**/*.rs", + "/src/**/*.json", + "/README.md", + "/LICENSE", + "/crabcode-logo.txt", + "/mascot.txt", + "/sounds/complete.mp3", + "/sounds/error.mp3", + "/remote-client/dist/client/**", +] [dependencies] ratatui = "0.29" @@ -35,7 +49,7 @@ nucleo-matcher = "0.3" rusqlite = { version = "0.31", features = ["bundled"] } cuid2 = "0.1" chrono = { version = "0.4", features = ["serde"] } -aisdk = { path = "aisdk" } +aisdk = { path = "aisdk", version = "0.1.0" } tokio-util = "0.7" glob = "0.3" strsim = "0.11" @@ -45,6 +59,7 @@ textwrap = "0.16" unicode-width = "0.1" tui-markdown = "0.3" pulldown-cmark = "0.13" +qrcode = { version = "0.14", default-features = false } ratatui-core = "0.1" tiktoken-rs = "0.9.1" base64 = "0.22" diff --git a/README.md b/README.md index 28119ce..a35edac 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,8 @@ # 🦀 crabcode -> [!WARNING] -> This ambitious project is very very early (like experiment-early) don't expect it to get to OpenCode level anytime soon. -> Like it literally doesn't even work yet. - A purely Rust-based AI CLI coding agent with a beautiful terminal UI for interactive "agentic engineering". -> In the words of the buildwithpi.ai creator, 'There are many coding agents, this one is mine'. +> In the words of the buildwithpi.ai creators, 'There are many coding agents, this one is mine'. > > It's OpenCode but in pure Rust 🦀 w/ my personal flavors. > @@ -100,6 +96,7 @@ Read the [configuration docs here](/_docs/config/index.mdx). I tried crabcode specifically for these providers: +- [x] **openai** (both API key and OAuth, thank you OpenAI for supporting harnesses!) - [x] **opencode-zen** - [x] **nano-gpt** - [x] **zai** @@ -114,7 +111,6 @@ I tried crabcode specifically for these providers: > I might work harder to support these in the future. -- ChatGPT/Codex Subscription (Though they have good-will to support OpenCode, so maybe CrabCode can as well). **might support later**. - Kimi For Coding Subscription - I keep getting 401 but it works in OpenCode, I may have to contact them first. **might support later** - Gemini - It's OAuth + also very unsure. So currently no. - Claude Code Subscription - Known to explicitly not like harnesses. So never will, sorry. @@ -149,12 +145,11 @@ This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/o - [x] Exception: ChatGPT oauth (because I use it) - [x] Copy chat contents, copy the chat input - [x] Image inputs -- [ ] Possibly ralphy? (very far, idk how to do that) +- [x] Personal remote usage + Browser client equivalent. - [ ] ACP w/ Zed? (very far, idk how to do that) - [x] No Claude Code oauth spoofing. -- [x] No plugin ecosystem (If I think it's worth building, just make it built-in and configurable) +- [x] No plugin ecosystem (If I think it's worth building, just make it built-in and configurable i.e. sounds) - [x] No desktop app -- [x] No web sharing thing (Might be a dealbreaker for vibecoders w/ tailscale, but I haven't reached these levels yet, when I do, I might) ## Why? diff --git a/_docs/gittydocs.jsonc b/_docs/gittydocs.jsonc index 8d0c719..ed78002 100644 --- a/_docs/gittydocs.jsonc +++ b/_docs/gittydocs.jsonc @@ -18,6 +18,7 @@ "items": [ { "label": "Overview", "path": "/" }, { "label": "Quickstart", "path": "/quickstart" }, + { "label": "Remote Usage", "path": "/remote-usage" }, ], }, { diff --git a/_docs/remote-usage.mdx b/_docs/remote-usage.mdx new file mode 100644 index 0000000..a19ab57 --- /dev/null +++ b/_docs/remote-usage.mdx @@ -0,0 +1,257 @@ +--- +title: Remote Usage +description: Use crabcode from another phone, laptop, browser, or SSH session. +--- + +# Use crabcode away from your keyboard + +crabcode can run on one machine while you control it from another. The machine with the project checkout, credentials, tools, and session history is the host. Your phone, tablet, or second laptop is a client. + +That shape covers two common cases: + +| Use case | What you want | +| --- | --- | +| Local network | Keep prompting from your phone while you are in the kitchen, on the couch, or away from your desk. | +| External network | Keep prompting while you are out for lunch, travelling, or using a different laptop. | + +The main idea is: + +```bash +# Host machine +crabcode serve + +# Browser client +# Open the URL printed by the host. + +# Terminal client +crabcode attach + +# One-shot remote prompt +crabcode -p --attach "continue the refactor" +``` + +For any URL that another device can reach, use pairing: + +```bash +crabcode serve --paircode random +``` + +The host prints a short pair code. Enter it once from a browser or `crabcode attach`; the client stores a trusted token in its crabcode state directory so future attaches can use the remembered host alias. + +## How remote usage works + +`crabcode serve` starts a small HTTP host for the current workspace. The host owns the important things: + +| Host-owned state | Why it stays on the host | +| --- | --- | +| Workspace files | Tools run against the project checkout on the host machine. | +| Provider credentials | API keys and OAuth tokens stay in the host's `auth.json`. | +| Session history | Conversations are stored in the host's crabcode state database. | +| Active agent run | Browser and terminal clients can disconnect without moving the project or credentials. | + +Clients are thin control surfaces: + +| Client | Use it for | +| --- | --- | +| Phone or laptop browser | Prompting, reading the transcript, switching sessions, changing model or agent mode, approving permissions, answering agent questions, and cancelling a run. | +| `crabcode attach ` | Full terminal-style control from another laptop or terminal-capable device. | +| `crabcode -p --attach "..."` | Scripts, launchers, shortcuts, and quick follow-up prompts. | +| SSH | The classic remote terminal path when you do not want to run `crabcode serve`. | + +crabcode does not expose a general remote shell or proxy arbitrary development ports. It exposes crabcode sessions. If your app also runs a dev server on the host, expose that separately through your LAN, Tailscale, SSH forwarding, or another preview tunnel. + +## Same machine + +The default bind address is local-only: + +```bash +crabcode serve --paircode random +``` + +Open the printed browser URL on the same machine, usually: + +```text +http://127.0.0.1:8421 +``` + +You can also attach from another terminal on the same machine: + +```bash +crabcode attach http://127.0.0.1:8421 +``` + +## Local network + +To reach crabcode from a phone or another laptop on the same Wi-Fi or Ethernet network, bind the host to a reachable address. + +On the machine with the project: + +```bash +cd ~/code/my-project +crabcode serve --bind 0.0.0.0:8421 --paircode random +``` + +The host will print a phone URL when it can detect one. If it prints only `127.0.0.1`, replace that address with the host machine's LAN IP, for example: + +```text +http://192.168.1.42:8421 +``` + +From a phone or laptop browser: + +1. Open the LAN URL. +2. Enter the pair code printed by `crabcode serve`. +3. Prompt, monitor, cancel, or switch sessions from the browser. + +From another laptop terminal: + +```bash +crabcode attach http://192.168.1.42:8421 +``` + +After pairing, `crabcode attach` remembers the host. List remembered hosts with: + +```bash +crabcode hosts +``` + +Then attach by alias: + +```bash +crabcode attach my-project +``` + +`--bind 0.0.0.0:8421` listens on every network interface. Use it only on networks you trust, keep the pair code enabled, and stop the host with `Ctrl+C` when you are done. + +## External network with Tailscale + +Tailscale works well with crabcode because it gives your devices private network reachability. crabcode does not need a Tailscale integration; it only needs to bind to an address your tailnet devices can reach. + +Recommended shape: + +1. Install Tailscale on the host machine and the client device. +2. Sign both devices into the same tailnet. +3. Start crabcode on the host's Tailscale IP or hostname. +4. Open the tailnet URL from your phone browser or attach from another laptop. + +On the host: + +```bash +cd ~/code/my-project +TAILNET_IP=$(tailscale ip -4) +crabcode serve --bind "$TAILNET_IP:8421" --paircode random +``` + +From a phone browser: + +```text +http://:8421 +``` + +If you use MagicDNS, the URL can be a machine name instead: + +```text +http://devbox:8421 +``` + +From another laptop: + +```bash +crabcode attach http://devbox:8421 +``` + +For a one-shot prompt: + +```bash +crabcode -p --attach http://devbox:8421 "summarize the current state and keep going" +``` + +This is the "off grid for lunch" workflow: the project stays on your laptop, Mac mini, homelab box, or VPS, while your phone reaches it over Tailscale. Your provider credentials stay on the host. + +If binding directly to the Tailscale IP is awkward on your machine, this also works: + +```bash +crabcode serve --bind 0.0.0.0:8421 --paircode random +``` + +Then use the Tailscale IP or MagicDNS name from your clients. Binding to `0.0.0.0` also listens on LAN interfaces, so prefer the specific tailnet IP when you can. + +## Choosing a server + +The server is simply the device that owns the checkout and runs `crabcode serve`. + +| Server | Good for | Notes | +| --- | --- | --- | +| Your laptop | Continuing a local coding run from your phone. | The laptop must stay awake and online. | +| Mac mini or desktop | Always-on home or office workstation. | Best when paired with Tailscale or another private network. | +| VPS | Remote development from anywhere. | Use key-based SSH, a firewall, and a private network if possible. | + +Do not expose a write-capable crabcode host directly to the public internet. Put it behind Tailscale, another private network, SSH forwarding, or a hardened access layer. + +## Remote app previews + +If crabcode starts or edits an app that runs on `localhost:3000`, remember that `localhost` means different things on different devices. + +On the host machine: + +```text +http://localhost:3000 +``` + +On your phone, that same URL points at the phone, not the host. Use one of these instead: + +| Preview path | Example | +| --- | --- | +| LAN URL | `http://192.168.1.42:3000` | +| Tailscale URL | `http://devbox:3000` | +| SSH local forwarding | Forward remote `3000` to local `3000` in your SSH client. | +| Tunnel tool | Use the tunnel your project already supports. | + +crabcode remote access controls crabcode. It does not automatically expose every dev server running on the host. + +## Classic SSH remote usage + +You do not need `crabcode serve` for the classic terminal workflow. This is how you would use many terminal coding agents remotely: SSH into the machine that has the project, start a persistent terminal session, and run crabcode there. + +From another laptop: + +```bash +ssh devbox +cd ~/code/my-project +tmux new -A -s crabcode +crabcode +``` + +If the SSH connection drops, reconnect and run the same `tmux` command: + +```bash +ssh devbox +cd ~/code/my-project +tmux new -A -s crabcode +``` + +From an iPhone with Termius: + +1. Add the host in Termius. Use a Tailscale IP, Tailscale hostname, or normal SSH hostname. +2. Connect over SSH. +3. Run `cd ~/code/my-project`. +4. Run `tmux new -A -s crabcode`. +5. Run `crabcode`. +6. Reconnect later and run `tmux new -A -s crabcode` again to return to the same terminal session. + +This path has more friction on a phone because you are using a terminal UI through a mobile keyboard. It is still useful when you want the safest possible setup, when the browser client is not enough for a particular workflow, or when you already live in SSH. + +## Current limits + +Remote usage is personal-device oriented. It is not a shared team workspace or public hosted service. + +Browser access is best for prompts, transcript review, session switching, model or agent changes, permission approvals, agent questions, and cancellation. For full TUI behavior from another laptop, use `crabcode attach `. For the most conservative remote setup, use SSH with `tmux` and run normal `crabcode`. + +Keep these defaults in mind: + +| Default | Meaning | +| --- | --- | +| `crabcode serve` binds `127.0.0.1:8421` | Same-machine only. Use `--bind` for phone or laptop access. | +| Pairing is enabled only when you pass `--paircode` | Use `--paircode random` for any non-local bind. | +| Trusted clients are local to each client device | `crabcode hosts` shows aliases remembered by that device. | +| The host process must stay alive | Stop it with `Ctrl+C` when you no longer want remote access. | diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index 35bde41..84ed3b5 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -60,6 +60,8 @@ - [x] A single AI response, is considered 1 message. So combine all its parts into a single message record. Not that every message part becomes a separate message in the timeline dialog. +- [ ] Message model refactor: persist one logical assistant response as one assistant message with ordered parts (`reasoning`, `text`, `tool_call`, `tool_result`) instead of protocol-shaped `assistant/tool/assistant` rows. Keep provider replay as a flattening step, and make interrupted/error turns durable while streaming. + - [x] Allow me to paste images i.e. [Image #1] [Image #2] [Image #3]. When I click on them, the image would be opened with my Finder (OS-specific) - [x] Let's make the 'questions' a bit more mouse-driven. @@ -223,3 +225,5 @@ I want - [x] To do this But I dont want to do this - [x] If I queue multiple messages for example 3x of nice. Let's make them a single message. - [x] /fork command like codex. + +- [ ] TUI: When very last item in /models. If the very last item is a "Thinking" model, then I can't really see the "currently selected/focused" item (the last item), because the thinking left and right key covers it. diff --git a/_docs/__remote-usage-plan.md b/_plans/__remote-usage-plan.md similarity index 100% rename from _docs/__remote-usage-plan.md rename to _plans/__remote-usage-plan.md diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..16d4788 --- /dev/null +++ b/build.rs @@ -0,0 +1,90 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + println!("cargo:rerun-if-changed=remote-client/dist/client"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let dist_dir = manifest_dir.join("remote-client/dist/client"); + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("remote_assets.rs"); + + let mut files = Vec::new(); + collect_files(&dist_dir, &dist_dir, &mut files); + files.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut output = String::new(); + output.push_str( + "pub struct RemoteAsset {\n pub content_type: &'static str,\n pub body: &'static [u8],\n}\n\n", + ); + output.push_str("pub fn remote_asset(path: &str) -> Option {\n"); + output.push_str(" match path {\n"); + + if files.is_empty() { + output.push_str(" _ => None,\n"); + } else { + for (route, file_path) in files { + let route = route.replace('\\', "/"); + let content_type = content_type_for_path(&route); + let path_literal = file_path.display().to_string().replace('\\', "\\\\"); + output.push_str(&format!( + " {:?} => Some(RemoteAsset {{ content_type: {:?}, body: include_bytes!({:?}) }}),\n", + route, content_type, path_literal + )); + + if route == "/index.html" { + output.push_str(&format!( + " \"/\" => Some(RemoteAsset {{ content_type: {:?}, body: include_bytes!({:?}) }}),\n", + content_type, path_literal + )); + } + } + output.push_str(" _ => None,\n"); + } + + output.push_str(" }\n}\n"); + + fs::write(out_path, output).unwrap(); +} + +fn collect_files(root: &Path, dir: &Path, files: &mut Vec<(String, PathBuf)>) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with('.')) + { + continue; + } + + if path.is_dir() { + collect_files(root, &path, files); + } else if path.is_file() { + let Ok(relative) = path.strip_prefix(root) else { + continue; + }; + let route = format!("/{}", relative.display()); + files.push((route, path)); + } + } +} + +fn content_type_for_path(path: &str) -> &'static str { + match Path::new(path).extension().and_then(|ext| ext.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("json") => "application/json; charset=utf-8", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + } +} diff --git a/crabcode.jsonc b/crabcode.jsonc index 4d2f5cc..995babc 100644 --- a/crabcode.jsonc +++ b/crabcode.jsonc @@ -1,7 +1,7 @@ { "$schema": "crabcode.schema.json", // Crabcode theme id (see src/generated_themes/carbonfox.json) - "theme": "vercel", + // "theme": "vercel", "notifications": { "complete": { "terminal": "auto", diff --git a/dist-workspace.toml b/dist-workspace.toml index 92c4095..1736ae1 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -7,6 +7,8 @@ members = ["cargo:."] cargo-dist-version = "0.30.3" # CI backends to support ci = "github" +# Build the embedded browser client before cargo-dist compiles the Rust binary. +github-build-setup = "../dist-build-setup.yml" # The installers to generate for each app installers = [] # Target platforms to build apps for (Rust target-triple syntax) diff --git a/justfile b/justfile index 3dac20b..40d9d3b 100644 --- a/justfile +++ b/justfile @@ -4,6 +4,13 @@ default: dev: cargo r +remote-client-build: + cd remote-client && bun install && bun run build + +dist-build *args: + just remote-client-build + dist build {{ args }} + preview: ./target/release/crabcode diff --git a/remote-client/.gitignore b/remote-client/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/remote-client/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/remote-client/bun.lock b/remote-client/bun.lock new file mode 100644 index 0000000..26cb524 --- /dev/null +++ b/remote-client/bun.lock @@ -0,0 +1,683 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "crabcode-remote-client", + "dependencies": { + "@kobalte/core": "^0.13.11", + "@tailwindcss/vite": "^4.1.18", + "bagon-hooks": "^0.0.6", + "cmdk-solid": "^1.1.2", + "solid-js": "1.9.10", + "solid-sonner": "^0.3.1", + "solid-streamdown": "^1.0.1", + "vike": "^0.4.252", + "vike-metadata-solid": "^1.0.5", + "vike-solid": "^0.7.19", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.0.4", + }, + "devDependencies": { + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@brillout/import": ["@brillout/import@0.2.6", "", {}, "sha512-1GUTmADc8trUC1YSW2lp9r6PmwluMoEyHajnE1kxVdbKGD0wJOlq/DvTWMUqLtBDCnQR+n//qgMtz6HwA/lotA=="], + + "@brillout/json-serializer": ["@brillout/json-serializer@0.5.23", "", {}, "sha512-nM7okvu4UaoxYshKB+903s3/UpIBdkJl++iDT3gOLnHaSqY6HbhAXTsUTxrroAAcIt6RKQfShaKjCBlzR2A1Gg=="], + + "@brillout/picocolors": ["@brillout/picocolors@1.0.31", "", {}, "sha512-xFCPBefU/AevF8XkwXSd/jIHA0fWw+mZ/gd5x2WOc5ysG7keHnU8m0gwY2Bvq5lvTyjwT4tiyow6fnMoYdmSqw=="], + + "@brillout/vite-plugin-server-entry": ["@brillout/vite-plugin-server-entry@0.7.18", "", { "dependencies": { "@brillout/import": "^0.2.6", "@brillout/picocolors": "^1.0.26" } }, "sha512-j3neG+vaIZ2AbP2/vGgaIyJwrFIxlK3xd3Ey2EGBswCvAGeI4QSSfXGbb7R3b3H8223PgTTsWOZuZH0Y8Ope2w=="], + + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="], + + "@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="], + + "@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@solid-primitives/deep": ["@solid-primitives/deep@0.2.10", "", { "dependencies": { "@solid-primitives/memo": "^1.3.10" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uzhXr/cMABsGkTcV3yuviZjTDmj44wb2JetuJ5O8NCugz4Ug/6XlPjT8ISMPYV7eCbC/gFhgt7e60+TRpl2Upg=="], + + "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="], + + "@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="], + + "@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="], + + "@solid-primitives/media": ["@solid-primitives/media@2.3.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LX9fB5WDaK87FMDtUB1qokBOfT2et9Uobv/zZaKLH9caFSz4+P70MBKEIBHcZQy+9MV5M2XvGYLTbLskjkzMjA=="], + + "@solid-primitives/memo": ["@solid-primitives/memo@1.4.5", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-dMfFShNsyX5virETyDv/Uoy2HP+PL4k8cUTTLb2r4TfoqJb010KIaOuURqp/Qbdznp4ZkDuP57b28d45kaOueQ=="], + + "@solid-primitives/mutation-observer": ["@solid-primitives/mutation-observer@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-p69O/zwvR7fCT87KGN3hkDfsZyWAJH+bsCZrdmWHcM5/8lg1Ndh7NjxXX1G1Ugk6ru/Bx6MvSppbbfkdBlwzgQ=="], + + "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="], + + "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="], + + "@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw=="], + + "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], + + "@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="], + + "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ=="], + + "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="], + + "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], + + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], + + "@universal-deploy/netlify": ["@universal-deploy/netlify@0.2.2", "", { "dependencies": { "@universal-deploy/store": "^0.2.1" }, "peerDependencies": { "vite": ">=7.1" } }, "sha512-10JY+1z0aun66IegHhVdOBTgXioI0+hgA0IVc0zgZvm2C7g2eQWpv48wtqCZZsXyUxajKcIlxiYxIuhuRIdfrQ=="], + + "@universal-deploy/node": ["@universal-deploy/node@0.1.7", "", { "dependencies": { "@universal-deploy/store": "^0.2.1", "magic-string": "^0.30.21", "srvx": "^0.11.9" }, "peerDependencies": { "vite": ">=7.1" }, "optionalPeers": ["vite"] }, "sha512-HxSiVZ2CYGX0M9RFl7cxf7Pfaz+vAA3ZfHJ6Yvflv04Gtyfyus2Z6xGSOluy3c8OxAyVu8fvhoXinU0ziHmaGA=="], + + "@universal-deploy/store": ["@universal-deploy/store@0.2.1", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "*" } }, "sha512-9CYaStacvufXAaVmaf8dxEVptqpcX5m9+vz1PIlN4gjYKlXfOdbZTuhv2xLwp3mj4jBR2/8VYdF5Vviw9cBYEA=="], + + "@universal-deploy/vite": ["@universal-deploy/vite@0.1.10", "", { "dependencies": { "@universal-deploy/netlify": "^0.2.2", "@universal-deploy/node": "^0.1.7", "@universal-deploy/store": "^0.2.1", "@universal-middleware/express": "^0.4.26", "magic-string": "^0.30.21", "rou3": "^0.8.1" }, "peerDependencies": { "vite": ">=7.1" }, "optionalPeers": ["vite"] }, "sha512-fORNv7+cTgWT1iS2ywUvwaxVkn7QCUHwXwZjfEEv9tL//MoTdwmosq7z2vB4tyN8ERGgWKU7MaQN+YNxfg/68g=="], + + "@universal-middleware/core": ["@universal-middleware/core@0.4.17", "", { "dependencies": { "regexparam": "^3.0.0", "tough-cookie": "^6.0.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260302.0", "@hattip/core": "^0.0.49", "@types/express": "^4 || ^5", "@webroute/route": "^0.8.0", "elysia": "^1.4.25", "fastify": "^5.7.4", "h3": "^1.15.5", "hono": "^4.11.9", "srvx": ">=0.8" }, "optionalPeers": ["@cloudflare/workers-types", "@hattip/core", "@types/express", "@webroute/route", "elysia", "fastify", "h3", "hono", "srvx"] }, "sha512-q+/nXW9DQ94RtmlghC57DhwEvjrqxX57EtU40iaM3U+eYTKc+FVnEdlpdrYX8kCAdEU7zVBLBlFgJre+VrXoUg=="], + + "@universal-middleware/express": ["@universal-middleware/express@0.4.27", "", { "dependencies": { "@universal-middleware/core": "^0.4.17", "@universal-middleware/node": "^0.2.0" } }, "sha512-leJeb617DqDmwVqTFtPg9YISlYiln6B6Srq8GFo01aNmwGqt5rqA6f0nP4TU9lFdKekUAMZ932I3n6BZWkOItg=="], + + "@universal-middleware/node": ["@universal-middleware/node@0.1.0", "", { "dependencies": { "@universal-middleware/core": "^0.4.17" } }, "sha512-I03mkOhw0Ka28MtALkpoGJYE8YYSJxmq/iambaqKGXxlFXgLI/VXlw0LmX9iansUzbolNq4hWFMfHyNp2xb4jA=="], + + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], + + "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], + + "bagon-hooks": ["bagon-hooks@0.0.6", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-LPFaRCz89A6I1F/jFi+c+Uku2v07bx5pSGJmwHXJGY/VSLhePsewWmE2sPAIU0751HEpSXDaPCGgGLr5usOegg=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "cmdk-solid": ["cmdk-solid@1.1.2", "", { "dependencies": { "@kobalte/core": "^0.12.4", "@kobalte/utils": "^0.9.0", "@solid-primitives/deep": "^0.2.7", "@solid-primitives/mutation-observer": "^1.1.17" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-E0g6bwr2Z92Ib1K2WHwGsq6xsrkVVSN1oe5YTg8LCV3iIwtyZORwmR+8n0xrecqycdhgAar8vWUjtB1OJt9tiA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "convert-route": ["convert-route@1.1.1", "", {}, "sha512-FENJ90K52uyE/qpbjy0i0gbglgKaL50BweqXx2OPaSG/pby2woRrnAdSVcGCdFFVvb/u/UTGqKybohiqcFWCMQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="], + + "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], + + "isbot-fast": ["isbot-fast@1.2.0", "", {}, "sha512-twjuQzy2gKMDVfKGQyQqrx6Uy4opu/fiVUTTpdqtFsd7OQijIp5oXvb27n5EemYXaijh5fomndJt/SPRLsEdSg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="], + + "seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], + + "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + + "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="], + + "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + + "solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="], + + "solid-streamdown": ["solid-streamdown@1.0.1", "", { "dependencies": { "hast-util-to-jsx-runtime": "^2.3.6", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", "unified": "^11.0.5" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-2A6rOngyjFwcTfvAzPrRXFHxNm6UVlC6sqCmyv43QB0682AGiohzf7a4wbNAa4Z11jZWZ0Xvuoauf1TUGeajVQ=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tldts": ["tldts@7.4.2", "", { "dependencies": { "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw=="], + + "tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vike": ["vike@0.4.259", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@brillout/import": "^0.2.6", "@brillout/json-serializer": "^0.5.23", "@brillout/picocolors": "^1.0.30", "@brillout/vite-plugin-server-entry": "0.7.18", "@universal-deploy/store": "^0.2.1", "@universal-deploy/vite": "^0.1.9", "@universal-middleware/core": "^0.4.17", "@universal-middleware/node": "^0.1.0", "cac": "^6.0.0", "convert-route": "^1.1.1", "es-module-lexer": "^1.0.0", "esbuild": ">=0.19.0", "json5": "^2.0.0", "magic-string": "^0.30.17", "picomatch": "^4.0.4", "semver": "^7.7.4", "sirv": "^3.0.2", "source-map-support": "^0.5.0", "tinyglobby": "^0.2.16", "vite": ">=6.3.0", "vite-plugin-wrapper": "^0.1.0" }, "peerDependencies": { "react-streaming": ">=0.3.42" }, "optionalPeers": ["react-streaming"], "bin": { "vike": "bin.js" } }, "sha512-5S+rgfLYfozCqXTlyZzr6P4FXOIoPcsSbqYO32keBK2Jun4AKR5BI/sQiP3w09Zq/WZkywEu0ofHFAAxtaIdSg=="], + + "vike-metadata-solid": ["vike-metadata-solid@1.0.5", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-PxbEA45S/6dZQ6rNt22UXgs6PXHUjW9P/oX0MEdrZ7WrCsPc3T9owQwBVGfDRVRQh5nFdDyXEWgAbnAK7oapsw=="], + + "vike-solid": ["vike-solid@0.7.20", "", { "dependencies": { "isbot-fast": "^1.2.0", "vite-plugin-solid": "^2.11.10" }, "peerDependencies": { "solid-js": "^1.8.7", "vike": ">=0.4.250" } }, "sha512-T2m95J1xzxUk3NY8wod6/6kFCAyucpLQY5eDMvFuB9id1BPr5x8jj+NQLFvfZ03UGSjp1iGLyqTEaled4dodEQ=="], + + "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + + "vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], + + "vite-plugin-wrapper": ["vite-plugin-wrapper@0.1.0", "", { "peerDependencies": { "vite": ">=7" } }, "sha512-orELI9PzoYKFRsI8TP4pTt05rL0oS68u8kJSANpJLZWdYdkqEsjEPrTLG1U/7x5PlACxIebmkbiBPaCg/oPSsw=="], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@universal-middleware/express/@universal-middleware/node": ["@universal-middleware/node@0.2.0", "", { "dependencies": { "@universal-middleware/core": "^0.4.17" } }, "sha512-Q7ppCHGYw0wtlF5oW5pDaIimDbnSvbPGWcseXsRWDUvzqM1fI0B6G1r43+Bv7qgCKigj580JKjs+eR3GgzCBrw=="], + + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "cmdk-solid/@kobalte/core": ["@kobalte/core@0.12.6", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.0", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-+Ta2o2wEqZ2fCqLMkvjT40VHNmcFKdGe8TNDVQbbMPk66qoU6g/DDRFR/Ib7eAjb+C95VoIyk6zaafos2VOo0w=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + } +} diff --git a/remote-client/package.json b/remote-client/package.json new file mode 100644 index 0000000..49bfa75 --- /dev/null +++ b/remote-client/package.json @@ -0,0 +1,27 @@ +{ + "name": "crabcode-remote-client", + "private": true, + "type": "module", + "scripts": { + "dev": "bunx --bun vike dev --host 127.0.0.1 --port 4271", + "build": "bunx --bun vike build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@kobalte/core": "^0.13.11", + "@tailwindcss/vite": "^4.1.18", + "bagon-hooks": "^0.0.6", + "cmdk-solid": "^1.1.2", + "solid-js": "1.9.10", + "solid-sonner": "^0.3.1", + "solid-streamdown": "^1.0.1", + "vike": "^0.4.252", + "vike-metadata-solid": "^1.0.5", + "vike-solid": "^0.7.19", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.0.4" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/remote-client/src/assets/icons/index.ts b/remote-client/src/assets/icons/index.ts new file mode 100644 index 0000000..16a8ed7 --- /dev/null +++ b/remote-client/src/assets/icons/index.ts @@ -0,0 +1,12 @@ +export { default as IconBrainGlyph } from "./ph-brain" +export { default as IconIconFileRust } from "./vscode-icons-file-type-rust" +export { default as IconIconFileTs } from "./vscode-icons-file-type-typescript" +export { default as IconIconFileTsx } from "./vscode-icons-file-type-reactts" +export { default as IconIconFileJs } from "./vscode-icons-file-type-js" +export { default as IconIconFileJson } from "./vscode-icons-file-type-json" +export { default as IconIconFileMarkdown } from "./vscode-icons-file-type-markdown" +export { default as IconIconFileToml } from "./vscode-icons-file-type-toml" +export { default as IconIconFileYaml } from "./vscode-icons-file-type-yaml" +export { default as IconIconFileCss } from "./vscode-icons-file-type-css" +export { default as IconIconFileHtml } from "./vscode-icons-file-type-html" +export { default as IconIconFileDefault } from "./vscode-icons-default-file" diff --git a/remote-client/src/assets/icons/ph-brain.tsx b/remote-client/src/assets/icons/ph-brain.tsx new file mode 100644 index 0000000..20b8101 --- /dev/null +++ b/remote-client/src/assets/icons/ph-brain.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-default-file.tsx b/remote-client/src/assets/icons/vscode-icons-default-file.tsx new file mode 100644 index 0000000..0e79224 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-default-file.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-css.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-css.tsx new file mode 100644 index 0000000..10babe8 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-css.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-html.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-html.tsx new file mode 100644 index 0000000..a814c78 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-html.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-js.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-js.tsx new file mode 100644 index 0000000..36f4c3b --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-js.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-json.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-json.tsx new file mode 100644 index 0000000..68ba1c8 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-json.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-markdown.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-markdown.tsx new file mode 100644 index 0000000..7cf4f6c --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-markdown.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-reactts.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-reactts.tsx new file mode 100644 index 0000000..88e6853 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-reactts.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-rust.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-rust.tsx new file mode 100644 index 0000000..9cdefda --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-rust.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-toml.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-toml.tsx new file mode 100644 index 0000000..14ef8b1 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-toml.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-typescript.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-typescript.tsx new file mode 100644 index 0000000..007859f --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-typescript.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-yaml.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-yaml.tsx new file mode 100644 index 0000000..7039223 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-yaml.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes) { + return (); +} \ No newline at end of file diff --git a/remote-client/src/components/ai-elements/attachments.tsx b/remote-client/src/components/ai-elements/attachments.tsx new file mode 100644 index 0000000..36a6c96 --- /dev/null +++ b/remote-client/src/components/ai-elements/attachments.tsx @@ -0,0 +1,220 @@ +import { createContext, Show, splitProps, useContext, type ComponentProps, type JSX } from "solid-js" +import { IconFileText, IconImage, IconX } from "../../icons" +import { cx } from "../../lib/cx" + +export type AttachmentVariant = "grid" | "inline" | "list" + +export type AttachmentData = { + id: string + url: string + filename?: string + mediaType?: string + size?: number +} + +type AttachmentsProps = ComponentProps<"div"> & { + variant?: AttachmentVariant + children: JSX.Element +} + +type AttachmentProps = ComponentProps<"div"> & { + data: AttachmentData + onRemove?: () => void + children: JSX.Element +} + +type AttachmentPreviewProps = ComponentProps<"div"> & { + fallbackIcon?: JSX.Element +} + +type AttachmentInfoProps = ComponentProps<"div"> & { + showMediaType?: boolean +} + +type AttachmentRemoveProps = ComponentProps<"button"> & { + label?: string +} + +type AttachmentsContextValue = { + variant: AttachmentVariant +} + +type AttachmentContextValue = { + data: AttachmentData + onRemove?: () => void +} + +const AttachmentsContext = createContext() +const AttachmentContext = createContext() + +export function Attachments(props: AttachmentsProps) { + const [local, others] = splitProps(props, ["variant", "class", "children"]) + const variant = () => local.variant ?? "grid" + + return ( + +
+ {local.children} +
+
+ ) +} + +export function Attachment(props: AttachmentProps) { + const attachments = useAttachmentsContext() + const [local, others] = splitProps(props, ["data", "onRemove", "class", "children"]) + const variant = () => attachments.variant + + return ( + +
+ {local.children} +
+
+ ) +} + +export function AttachmentPreview(props: AttachmentPreviewProps) { + const attachments = useAttachmentsContext() + const attachment = useAttachmentContext() + const [local, others] = splitProps(props, ["class", "fallbackIcon"]) + const variant = () => attachments.variant + const isImage = () => getMediaCategory(attachment.data) === "image" + + return ( +
+ } + > + {`Image: + +
+ ) +} + +export function AttachmentInfo(props: AttachmentInfoProps) { + const attachment = useAttachmentContext() + const [local, others] = splitProps(props, ["class", "showMediaType"]) + const meta = () => + [local.showMediaType ? attachment.data.mediaType : null, attachment.data.size ? formatBytes(attachment.data.size) : null] + .filter(Boolean) + .join(" · ") + + return ( +
+
+ {getAttachmentLabel(attachment.data)} +
+
+ {meta()} +
+
+ ) +} + +export function AttachmentRemove(props: AttachmentRemoveProps) { + const attachment = useAttachmentContext() + const [local, others] = splitProps(props, ["class", "children", "label"]) + + return ( + + ) +} + +export function AttachmentEmpty(props: ComponentProps<"div">) { + const [local, others] = splitProps(props, ["class", "children"]) + return ( +
+ {local.children ?? "No attachments"} +
+ ) +} + +export function getMediaCategory(data: AttachmentData) { + const mediaType = data.mediaType?.toLowerCase() ?? "" + if (mediaType.startsWith("image/")) return "image" + if (mediaType.startsWith("video/")) return "video" + if (mediaType.startsWith("audio/")) return "audio" + if (mediaType) return "document" + return "unknown" +} + +export function getAttachmentLabel(data: AttachmentData) { + return data.filename?.trim() || (getMediaCategory(data) === "image" ? "Image" : "Attachment") +} + +function AttachmentPreviewIcon(props: { class?: string; fallbackIcon?: JSX.Element }) { + if (props.fallbackIcon) return <>{props.fallbackIcon} + return getMediaCategory(useAttachmentContext().data) === "image" ? ( + + ) : ( + + ) +} + +function useAttachmentsContext() { + const value = useContext(AttachmentsContext) + if (!value) throw new Error("Attachment components must be used inside ") + return value +} + +function useAttachmentContext() { + const value = useContext(AttachmentContext) + if (!value) throw new Error("Attachment child components must be used inside ") + return value +} + +function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return "" + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} diff --git a/remote-client/src/components/ai-elements/message.tsx b/remote-client/src/components/ai-elements/message.tsx new file mode 100644 index 0000000..b31955f --- /dev/null +++ b/remote-client/src/components/ai-elements/message.tsx @@ -0,0 +1,98 @@ +import type { ComponentProps, JSX } from "solid-js" +import { splitProps } from "solid-js" +import { StreamMarkdown } from "solid-streamdown" +import { cx } from "../../lib/cx" + +type MessageRole = "user" | "assistant" | "system" | "tool" | string + +type MessageProps = ComponentProps<"article"> & { + from?: MessageRole + children: JSX.Element +} + +type MessageContentProps = ComponentProps<"div"> & { + children: JSX.Element +} + +type MessageResponseProps = ComponentProps<"div"> & { + content: string +} + +type MessageActionProps = ComponentProps<"button"> & { + label?: string +} + +export function Message(props: MessageProps) { + const [local, others] = splitProps(props, ["from", "class", "children"]) + const isUser = () => local.from === "user" + + return ( +
+ {local.children} +
+ ) +} + +export function MessageContent(props: MessageContentProps) { + const [local, others] = splitProps(props, ["class", "children"]) + return ( +
+ {local.children} +
+ ) +} + +export function MessageResponse(props: MessageResponseProps) { + const [local, others] = splitProps(props, ["class", "content"]) + return ( + + ) +} + +export function MessageActions(props: MessageContentProps) { + const [local, others] = splitProps(props, ["class", "children"]) + return ( +
+ {local.children} +
+ ) +} + +export function MessageAction(props: MessageActionProps) { + const [local, others] = splitProps(props, ["class", "children", "label"]) + return ( + + ) +} + +export function MessageToolbar(props: MessageContentProps) { + const [local, others] = splitProps(props, ["class", "children"]) + return ( +
+ {local.children} +
+ ) +} diff --git a/remote-client/src/components/ai-elements/shimmer.tsx b/remote-client/src/components/ai-elements/shimmer.tsx new file mode 100644 index 0000000..2948bef --- /dev/null +++ b/remote-client/src/components/ai-elements/shimmer.tsx @@ -0,0 +1,36 @@ +import type { Component, JSX } from "solid-js" +import { Dynamic } from "solid-js/web" +import { cx } from "../../lib/cx" + +type ShimmerProps = { + children: string + as?: keyof JSX.IntrinsicElements + class?: string + duration?: number + spread?: number +} + +export const Shimmer: Component = (props) => { + const duration = () => props.duration ?? 1.6 + const spread = () => (props.children?.length ?? 0) * (props.spread ?? 2) + + return ( + + {props.children} + + ) +} diff --git a/remote-client/src/components/remote/collapsible-panel.tsx b/remote-client/src/components/remote/collapsible-panel.tsx new file mode 100644 index 0000000..75d8917 --- /dev/null +++ b/remote-client/src/components/remote/collapsible-panel.tsx @@ -0,0 +1,57 @@ +import { createEffect, createSignal, type JSX, onCleanup, onMount } from "solid-js" +import { cx } from "../../lib/cx" + +export function CollapsiblePanel(props: { open: boolean; class?: string; children: JSX.Element }) { + const [height, setHeight] = createSignal(0) + const [animateHeight, setAnimateHeight] = createSignal(true) + let innerRef: HTMLDivElement | undefined + let previousOpen = props.open + let restoreFrame: number | undefined + + const measure = (animate: boolean) => { + if (restoreFrame) window.cancelAnimationFrame(restoreFrame) + setAnimateHeight(animate) + setHeight(innerRef?.scrollHeight ?? 0) + if (!animate) { + restoreFrame = window.requestAnimationFrame(() => { + setAnimateHeight(true) + restoreFrame = undefined + }) + } + } + + onMount(() => { + measure(false) + const resizeObserver = new ResizeObserver(() => { + if (props.open) measure(false) + }) + if (innerRef) resizeObserver.observe(innerRef) + onCleanup(() => { + resizeObserver.disconnect() + if (restoreFrame) window.cancelAnimationFrame(restoreFrame) + }) + }) + + createEffect(() => { + const open = props.open + const changed = open !== previousOpen + previousOpen = open + queueMicrotask(() => measure(changed)) + }) + + return ( +
+
+ {props.children} +
+
+ ) +} diff --git a/remote-client/src/components/remote/faded-edge-effect.tsx b/remote-client/src/components/remote/faded-edge-effect.tsx new file mode 100644 index 0000000..008ea56 --- /dev/null +++ b/remote-client/src/components/remote/faded-edge-effect.tsx @@ -0,0 +1,49 @@ +export function FadedEdgeEffect(props: { + color?: string + direction?: "vertical" | "horizontal" | "radial" | "top" | "bottom" + hidden?: boolean + size?: string +}) { + const color = () => props.color ?? "var(--bg)" + const direction = () => props.direction ?? "radial" + const size = () => props.size ?? "5rem" + + const style = () => { + switch (direction()) { + case "horizontal": + return { + background: `linear-gradient(90deg, ${color()} 0%, rgba(0,0,0,0) 5%, rgba(0,0,0,0) 95%, ${color()} 100%)`, + } + case "vertical": + return { + background: `linear-gradient(0deg, ${color()} 0%, rgba(0,0,0,0) 5%, rgba(0,0,0,0) 95%, ${color()} 100%)`, + } + case "top": + return { + background: `linear-gradient(180deg, ${color()} 0%, rgba(0,0,0,0) 100%)`, + height: size(), + bottom: "auto", + } + case "bottom": + return { + background: `linear-gradient(0deg, ${color()} 0%, rgba(0,0,0,0) 100%)`, + height: size(), + top: "auto", + } + default: + return { + background: `radial-gradient(circle, rgba(2,0,36,0) 0%, rgba(232,232,235,0) 76%, ${color()} 100%)`, + } + } + } + + return ( +
+ ) +} diff --git a/remote-client/src/components/remote/project-favicon.tsx b/remote-client/src/components/remote/project-favicon.tsx new file mode 100644 index 0000000..2185c78 --- /dev/null +++ b/remote-client/src/components/remote/project-favicon.tsx @@ -0,0 +1,65 @@ +import { createEffect, createMemo, createSignal } from "solid-js" +import { cx } from "../../lib/cx" + +const loadedProjectFaviconSrcs = new Set() + +type LoadStatus = "loading" | "loaded" | "error" + +export function ProjectFavicon(props: { cwd: string; label: string; token?: string; class?: string }) { + const src = createMemo(() => projectFaviconSrc(props.cwd, props.token)) + const [status, setStatus] = createSignal("loading") + + createEffect(() => { + const next = src() + setStatus(next && loadedProjectFaviconSrcs.has(next) ? "loaded" : "loading") + }) + + return ( + <> + {status() !== "loaded" ? ( + + ) : null} + {src() ? ( + { + const currentSrc = src() + if (!currentSrc) return + loadedProjectFaviconSrcs.add(currentSrc) + setStatus("loaded") + }} + onError={() => setStatus("error")} + /> + ) : null} + + ) +} + +function projectInitial(label: string): string { + return (label.trim()[0] || ".").toLowerCase() +} + +function projectFaviconSrc(cwd: string, token: string | undefined): string | null { + const projectCwd = cwd.trim() + if (!projectCwd) return null + + const params = new URLSearchParams({ cwd: projectCwd }) + const authToken = token?.trim() + if (authToken) params.set("token", authToken) + + return `/api/project-favicon?${params.toString()}` +} diff --git a/remote-client/src/components/remote/project-list.tsx b/remote-client/src/components/remote/project-list.tsx new file mode 100644 index 0000000..a8e1c07 --- /dev/null +++ b/remote-client/src/components/remote/project-list.tsx @@ -0,0 +1,239 @@ +import { type Accessor, createEffect, createSignal, For, Index, onCleanup, onMount } from "solid-js" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu" +import { IconCaretDown, IconPlus } from "../../icons" +import type { RemoteSession } from "../../remote-api" +import { cx } from "../../lib/cx" +import { CollapsiblePanel } from "./collapsible-panel" +import { FadedEdgeEffect } from "./faded-edge-effect" +import { ProjectFavicon } from "./project-favicon" + +export type ProjectGroup = { + name: string + path: string + sessions: RemoteSession[] +} + +type ProjectListProps = { + projects: Accessor + openProjects: Accessor> + activeProjectPath: Accessor + token: Accessor + currentSessionId: Accessor + onToggleProject: (key: string) => void + onNewSession: (workspacePath?: string) => void + onSwitchSession: (id: string) => void + onArchiveSession: (id: string) => void + onArchiveProject: (path: string) => void +} + +export function ProjectList(props: ProjectListProps) { + const [scrollEl, setScrollEl] = createSignal() + const edges = useScrollEdges(scrollEl) + + return ( +
+
+ + {(project) => { + const key = () => project().path || project().name + const open = () => props.openProjects().has(key()) + const active = () => isActiveProject(project().path, props.activeProjectPath()) + return ( +
+
+ + +
+ + +
+
+ + props.onNewSession(project().path)}> + New chat + + + props.onArchiveProject(project().path)} + > + Archive project + + +
+
+ +
+ + {(session) => ( + props.onSwitchSession(session.id)} + onArchive={() => props.onArchiveSession(session.id)} + /> + )} + +
+
+
+ ) + }} +
+
+
+ ) +} + +function isActiveProject(projectPath: string, activeProjectPath: string | null | undefined): boolean { + const project = projectPath.trim() + const active = activeProjectPath?.trim() + return Boolean(project && active && project === active) +} + +function SessionRow(props: { + session: RemoteSession + active: boolean + onClick: () => void + onArchive: () => void +}) { + return ( + + + + + + Open session + + + Archive session + + + + ) +} + +function statusClass(status: string): string { + if (status === "running" || status === "streaming" || status === "pending") return "bg-[var(--blue)]" + if (status === "failed" || status === "error" || status === "interrupted") return "bg-[var(--red)]" + return "bg-[var(--green)]" +} + +function statusLabel(status: string): string { + if (status === "running" || status === "streaming") return "Running" + if (status === "pending") return "Queued" + if (status === "failed" || status === "error") return "Failed" + if (status === "interrupted") return "Interrupted" + return "Ready" +} + +function relativeTime(timestamp: number): string { + if (!timestamp) return "" + const seconds = Math.max(0, Math.floor(Date.now() / 1000 - timestamp)) + if (seconds < 60) return "now" + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo ago` + return `${Math.floor(months / 12)}y ago` +} + +function useScrollEdges(scrollEl: Accessor) { + const [isAtTop, setIsAtTop] = createSignal(true) + const [isAtBottom, setIsAtBottom] = createSignal(true) + + const update = () => { + const el = scrollEl() + if (!el) return + setIsAtTop(el.scrollTop <= 1) + setIsAtBottom(el.scrollTop + el.clientHeight >= el.scrollHeight - 1) + } + + onMount(() => { + createEffect(() => { + const el = scrollEl() + if (!el) return + update() + el.addEventListener("scroll", update, { passive: true }) + const resizeObserver = new ResizeObserver(update) + resizeObserver.observe(el) + onCleanup(() => { + el.removeEventListener("scroll", update) + resizeObserver.disconnect() + }) + }) + }) + + return { isAtTop, isAtBottom, update } +} diff --git a/remote-client/src/components/ui/context-menu.tsx b/remote-client/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..09bf2fc --- /dev/null +++ b/remote-client/src/components/ui/context-menu.tsx @@ -0,0 +1,74 @@ +import * as ContextMenuPrimitive from "@kobalte/core/context-menu" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import type { ValidComponent } from "solid-js" +import { splitProps } from "solid-js" +import { cx } from "../../lib/cx" + +const ContextMenu = ContextMenuPrimitive.Root +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +type ContextMenuContentProps = + ContextMenuPrimitive.ContextMenuContentProps & { + class?: string | undefined + } + +const ContextMenuContent = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ContextMenuContentProps, ["class"]) + return ( + + + + ) +} + +type ContextMenuItemProps = + ContextMenuPrimitive.ContextMenuItemProps & { + class?: string | undefined + } + +const ContextMenuItem = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ContextMenuItemProps, ["class"]) + const danger = local.class?.split(/\s+/).includes("danger") + return ( + + ) +} + +type ContextMenuSeparatorProps = + ContextMenuPrimitive.ContextMenuSeparatorProps & { + class?: string | undefined + } + +const ContextMenuSeparator = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ContextMenuSeparatorProps, ["class"]) + return ( + + ) +} + +export { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } diff --git a/remote-client/src/components/ui/popover.tsx b/remote-client/src/components/ui/popover.tsx new file mode 100644 index 0000000..d6764d6 --- /dev/null +++ b/remote-client/src/components/ui/popover.tsx @@ -0,0 +1,28 @@ +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import * as PopoverPrimitive from "@kobalte/core/popover" +import type { Component, ValidComponent } from "solid-js" +import { splitProps } from "solid-js" + +const PopoverTrigger = PopoverPrimitive.Trigger + +const Popover: Component = (props) => { + return +} + +type PopoverContentProps = + PopoverPrimitive.PopoverContentProps & { + class?: string | undefined + } + +const PopoverContent = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as PopoverContentProps, ["class"]) + return ( + + + + ) +} + +export { Popover, PopoverContent, PopoverTrigger } diff --git a/remote-client/src/icons.tsx b/remote-client/src/icons.tsx new file mode 100644 index 0000000..6400b06 --- /dev/null +++ b/remote-client/src/icons.tsx @@ -0,0 +1,223 @@ +import type { ComponentProps } from "solid-js" + +type IconProps = ComponentProps<"svg"> + +export function IconFolder(props: IconProps) { + return ( + + ) +} + +export function IconCaretDown(props: IconProps) { + return ( + + ) +} + +export function IconSearch(props: IconProps) { + return ( + + ) +} + +export function IconArrowUp(props: IconProps) { + return ( + + ) +} + +export function IconTerminal(props: IconProps) { + return ( + + ) +} + +export function IconPlus(props: IconProps) { + return ( + + ) +} + +export function IconPaperclip(props: IconProps) { + return ( + + ) +} + +export function IconImage(props: IconProps) { + return ( + + ) +} + +export function IconSidebar(props: IconProps) { + return ( + + ) +} + +export function IconServers(props: IconProps) { + return ( + + ) +} + +export function IconCheck(props: IconProps) { + return ( + + ) +} + +export function IconCopy(props: IconProps) { + return ( + + ) +} + +export function IconX(props: IconProps) { + return ( + + ) +} + +export function IconArrowLeft(props: IconProps) { + return ( + + ) +} + +export function IconDots(props: IconProps) { + return ( + + ) +} + +export function IconBrain(props: IconProps) { + return ( + + ) +} + +export function IconGlobe(props: IconProps) { + return ( + + ) +} + +export function IconFileText(props: IconProps) { + return ( + + ) +} + +export function IconPencilSimple(props: IconProps) { + return ( + + ) +} + +export function IconWarningCircle(props: IconProps) { + return ( + + ) +} diff --git a/remote-client/src/lib/cx.ts b/remote-client/src/lib/cx.ts new file mode 100644 index 0000000..94741f0 --- /dev/null +++ b/remote-client/src/lib/cx.ts @@ -0,0 +1,3 @@ +export function cx(...classes: Array) { + return classes.filter(Boolean).join(" ") +} diff --git a/remote-client/src/pages/+Layout.tsx b/remote-client/src/pages/+Layout.tsx new file mode 100644 index 0000000..25c25cb --- /dev/null +++ b/remote-client/src/pages/+Layout.tsx @@ -0,0 +1,29 @@ +import type { JSX } from "solid-js" +import { Toaster } from "solid-sonner" +import { useMetadata } from "vike-metadata-solid" + +useMetadata.setGlobalDefaults({ + title: "CrabCode", +}) + +export default function Layout(props: { children: JSX.Element }) { + useMetadata({ title: "CrabCode" }) + return ( + <> + {props.children} + + + ) +} diff --git a/remote-client/src/pages/+config.ts b/remote-client/src/pages/+config.ts new file mode 100644 index 0000000..6453f71 --- /dev/null +++ b/remote-client/src/pages/+config.ts @@ -0,0 +1,8 @@ +import type { Config } from "vike/types" +import vikeSolid from "vike-solid/config" + +export default { + extends: [vikeSolid], + ssr: false, + prerender: true, +} satisfies Config diff --git a/remote-client/src/pages/index/+Page.tsx b/remote-client/src/pages/index/+Page.tsx new file mode 100644 index 0000000..b277833 --- /dev/null +++ b/remote-client/src/pages/index/+Page.tsx @@ -0,0 +1,4539 @@ +import { useHotkeys } from "bagon-hooks" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "cmdk-solid" +import { type Accessor, createEffect, createMemo, createSignal, For, Index, type JSX, onCleanup, onMount, type Setter, Show } from "solid-js" +import { StreamMarkdown } from "solid-streamdown" +import "solid-streamdown/styles.css" +import { toast } from "solid-sonner" +import crabcodeLogo from "../../../../crabcode-logo.txt?raw" +import mascotArt from "../../../../mascot.txt?raw" +import { + IconBrainGlyph, + IconIconFileCss, + IconIconFileDefault, + IconIconFileHtml, + IconIconFileJs, + IconIconFileJson, + IconIconFileMarkdown, + IconIconFileRust, + IconIconFileToml, + IconIconFileTs, + IconIconFileTsx, + IconIconFileYaml, +} from "../../assets/icons" +import { + Attachment, + AttachmentInfo, + AttachmentPreview, + AttachmentRemove, + Attachments, + type AttachmentData, +} from "../../components/ai-elements/attachments" +import { + Message, + MessageAction, + MessageActions, + MessageContent, + MessageResponse, + MessageToolbar, +} from "../../components/ai-elements/message" +import { Shimmer } from "../../components/ai-elements/shimmer" +import { CollapsiblePanel } from "../../components/remote/collapsible-panel" +import { FadedEdgeEffect } from "../../components/remote/faded-edge-effect" +import { ProjectFavicon } from "../../components/remote/project-favicon" +import { ProjectList, type ProjectGroup } from "../../components/remote/project-list" +import { Popover, PopoverContent, PopoverTrigger } from "../../components/ui/popover" +import { + IconArrowLeft, + IconArrowUp, + IconCaretDown, + IconCheck, + IconCopy, + IconDots, + IconFileText, + IconFolder, + IconGlobe, + IconPaperclip, + IconPencilSimple, + IconPlus, + IconSearch, + IconServers, + IconSidebar, + IconTerminal, + IconWarningCircle, + IconX, +} from "../../icons" +import { cx } from "../../lib/cx" +import { + createRemoteApi, + RemoteApiError, + type RemoteMessage, + type RemoteModel, + type RemotePendingPermission, + type RemotePendingQuestion, + type RemotePromptImage, + type RemoteQuestionItem, + type RemoteSkill, + type RemoteStatus, + type RemoteState, + type RemoteSuggestion, +} from "../../remote-api" +import "../../styles/app.css" + +const TOKEN_KEY = "crabcode.remote.token" +const SERVERS_KEY = "crabcode.remote.servers" +const PROMPT_HISTORY_KEY = "crabcode.remote.promptHistory" +const AGENT_MODES = ["Build", "Plan"] +const MAX_COMPOSER_ATTACHMENTS = 8 +const MAX_COMPOSER_ATTACHMENT_BYTES = 16 * 1024 * 1024 +const IMAGE_FILE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const MAX_PROMPT_HISTORY = 100 +const MENTION_ACCENTS = [ + { text: "#bfa8ff", background: "rgba(177, 143, 255, 0.14)", ring: "rgba(177, 143, 255, 0.24)" }, + { text: "#8edfc0", background: "rgba(96, 185, 148, 0.13)", ring: "rgba(96, 185, 148, 0.22)" }, + { text: "#f0bd7e", background: "rgba(210, 148, 68, 0.13)", ring: "rgba(210, 148, 68, 0.22)" }, + { text: "#8fc9ff", background: "rgba(92, 158, 219, 0.13)", ring: "rgba(92, 158, 219, 0.22)" }, + { text: "#f1a7bc", background: "rgba(214, 101, 128, 0.13)", ring: "rgba(214, 101, 128, 0.22)" }, + { text: "#d5d985", background: "rgba(177, 184, 82, 0.13)", ring: "rgba(177, 184, 82, 0.22)" }, +] +const LOGO_ART = normalizeArt(crabcodeLogo, { trimCommonIndent: true }) +const MASCOT_FRAMES = mascotArt + .trimEnd() + .split(/\n\s*\n/) + .filter((frame) => frame.trim().length > 0) + .map((frame) => normalizeArt(frame)) + +type SavedServer = { + id: string + address: string + name: string + username: string + password: string +} + +type RemotePermissionResponse = "deny" | "allow_once" | "allow_always" + +type ServerPanelTab = "servers" | "skills" | "mcp" | "lsp" | "plugins" + +type CompletionTrigger = { + kind: "slash" | "mention" + query: string + range: [number, number] +} + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } +type JsonObject = { [key: string]: JsonValue } + +type ParsedToolMessage = { + id: string + name: string + status: string + args?: JsonValue + metadata?: JsonValue + outputPreview?: string + title?: string + lineCount?: number +} + +type ToolMessage = { + message: RemoteMessage + parsed: ParsedToolMessage + cwd: string +} + +type ThreadItem = + | { type: "message"; message: RemoteMessage; activityTools: ToolMessage[] } + | { type: "activity"; tools: ToolMessage[] } + | { type: "action"; tool: ToolMessage } + +type ComposerAttachment = { + id: string + name: string + mediaType: string + size: number + dataUrl: string +} + +type ImagePreviewTarget = { + url: string + label: string +} + +type PromptTextPart = { + kind: "text" | "image" | "mention" + text: string +} + +type ImagePlaceholderRange = { + number: number + start: number + end: number +} + +type ToolVisualState = "active" | "complete" | "error" +type ToolIconKind = "brain" | "check" | "file" | "globe" | "pencil" | "search" | "terminal" | "warning" + +type ToolStepDetail = { + label: string + detail?: string + status?: ToolVisualState +} + +type ToolActivityStep = { + key: string + label: string + icon: ToolIconKind + state: ToolVisualState + details: ToolStepDetail[] + preview?: string + defaultOpen?: boolean +} + +type DiffLine = { + kind: "add" | "remove" | "context" + text: string +} + +type ActionDescriptor = { + label: string + description: string + state: ToolVisualState + icon: ToolIconKind + stats?: { added: number; removed: number } + details: ToolStepDetail[] + diffLines: DiffLine[] + preview?: string +} + +const THINKING_TOOL_NAMES = new Set([ + "glob", + "grep", + "list", + "question", + "read", + "skill", + "task", + "todowrite", + "update_plan", + "view_image", + "webfetch", +]) +const EXPLORATION_TOOL_NAMES = new Set(["glob", "grep", "list", "read"]) +const ACTION_TOOL_NAMES = new Set(["apply_patch", "bash", "edit", "write"]) +const PANEL_BASE = + "rounded-xl border border-[var(--line-strong)] bg-[#202020] shadow-[0_1rem_3rem_rgba(0,0,0,0.35)] outline-none" +const POPOVER_ANIMATION = + "origin-[var(--kb-popover-content-transform-origin)] data-[expanded]:animate-flyUpAndScale data-[closed]:animate-flyUpAndScaleExit" +const ICON_BUTTON = + "grid place-items-center rounded-md text-[var(--muted)] transition hover:bg-[#282828] hover:text-[var(--text)] focus-visible:bg-[#282828] focus-visible:text-[var(--text)]" +const MENU_ROW = + "flex min-h-[2.15rem] w-full items-center justify-between gap-5 rounded-[7px] px-2 text-left text-[0.9rem] text-[var(--muted)] transition hover:bg-[#2b2b2b] hover:text-[var(--text)] focus-visible:bg-[#2b2b2b] focus-visible:text-[var(--text)]" +const MENU_ROW_ACTIVE = + "bg-[#2d2d2d] text-[var(--text)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]" +const INPUT_BASE = + "min-w-0 rounded-lg border border-[var(--line)] bg-[#181818] px-3 text-[var(--text)] outline-none" +const COMPOSER_TEXT_CLASS = "px-5 pt-5 pb-2 text-[0.98rem] leading-normal" + +export default function RemoteClient() { + const [token, setToken] = createSignal(localStorage.getItem(TOKEN_KEY) || "") + const api = createMemo(() => createRemoteApi(token)) + + const [state, setState] = createSignal(null) + const [pairRequired, setPairRequired] = createSignal(false) + const [pairCode, setPairCode] = createSignal("") + const [pairError, setPairError] = createSignal("") + const [permissionBusy, setPermissionBusy] = createSignal(false) + const [questionBusy, setQuestionBusy] = createSignal(false) + const [sidebarOpen, setSidebarOpen] = createSignal(false) + const [projectOpen, setProjectOpen] = createSignal>(new Set()) + const [projectsInitialized, setProjectsInitialized] = createSignal(false) + const [projectPickerOpen, setProjectPickerOpen] = createSignal(false) + const [projectPickerAddOpen, setProjectPickerAddOpen] = createSignal(false) + const [newProjectOpen, setNewProjectOpen] = createSignal(false) + const [projectPathInput, setProjectPathInput] = createSignal("") + const [projectPathError, setProjectPathError] = createSignal("") + const [serversOpen, setServersOpen] = createSignal(false) + const [serverPanelTab, setServerPanelTab] = createSignal("servers") + const [serversManageOpen, setServersManageOpen] = createSignal(false) + const [serverAddOpen, setServerAddOpen] = createSignal(false) + const [serverSearch, setServerSearch] = createSignal("") + const [serverAddress, setServerAddress] = createSignal("") + const [serverName, setServerName] = createSignal("") + const [serverUsername, setServerUsername] = createSignal("") + const [serverPassword, setServerPassword] = createSignal("") + const [savedServers, setSavedServers] = createSignal(loadSavedServers()) + const [agentOpen, setAgentOpen] = createSignal(false) + const [reasoningOpen, setReasoningOpen] = createSignal(false) + const [modelOpen, setModelOpen] = createSignal(false) + const [models, setModels] = createSignal([]) + const [skills, setSkills] = createSignal([]) + const [modelQuery, setModelQuery] = createSignal("") + const [modelActiveIndex, setModelActiveIndex] = createSignal(0) + const [agentActiveIndex, setAgentActiveIndex] = createSignal(0) + const [reasoningActiveIndex, setReasoningActiveIndex] = createSignal(0) + const [commandRendered, setCommandRendered] = createSignal(false) + const [commandClosing, setCommandClosing] = createSignal(false) + const [commandQuery, setCommandQuery] = createSignal("") + const [prompt, setPrompt] = createSignal("") + const [composerAttachments, setComposerAttachments] = createSignal([]) + const [imagePreview, setImagePreview] = createSignal(null) + const [browserPromptHistory, setBrowserPromptHistory] = createSignal(loadPromptHistory()) + const [promptHistoryIndex, setPromptHistoryIndex] = createSignal(null) + const [promptHistoryDraft, setPromptHistoryDraft] = createSignal("") + const [composerSuggestions, setComposerSuggestions] = createSignal([]) + const [composerSuggestionIndex, setComposerSuggestionIndex] = createSignal(0) + const [completionTrigger, setCompletionTrigger] = createSignal(null) + const [completionRevision, setCompletionRevision] = createSignal(0) + const [mascotFrame, setMascotFrame] = createSignal(0) + const [threadScrollEl, setThreadScrollEl] = createSignal() + const [threadContentEl, setThreadContentEl] = createSignal() + const threadScroll = useStickToBottom(threadScrollEl, threadContentEl) + + let promptRef: HTMLTextAreaElement | undefined + let promptOverlayRef: HTMLDivElement | undefined + let composerSuggestionsRef: HTMLDivElement | undefined + let imageInputRef: HTMLInputElement | undefined + let commandInputRef: HTMLInputElement | undefined + let projectPathInputRef: HTMLInputElement | undefined + let serverAddressRef: HTMLInputElement | undefined + let modelSearchRef: HTMLInputElement | undefined + let focusPromptAfterControlPopoverClose = false + let closeStateEvents: (() => void) | undefined + let commandCloseTimer: number | undefined + + const openCommandPalette = () => { + if (commandCloseTimer !== undefined) { + window.clearTimeout(commandCloseTimer) + commandCloseTimer = undefined + } + setCommandRendered(true) + setCommandClosing(false) + queueMicrotask(() => commandInputRef?.focus()) + } + + const closeCommandPalette = () => { + if (!commandRendered() || commandClosing()) return + setCommandClosing(true) + commandCloseTimer = window.setTimeout(() => { + setCommandRendered(false) + setCommandClosing(false) + commandCloseTimer = undefined + }, 180) + } + + useHotkeys( + [ + [ + "mod+K", + (event) => { + event.preventDefault() + openCommandPalette() + }, + { preventDefault: true }, + ], + ], + [] + ) + + createEffect(() => { + if (!commandRendered()) return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + event.preventDefault() + closeCommandPalette() + } + + window.addEventListener("keydown", onKeyDown) + onCleanup(() => window.removeEventListener("keydown", onKeyDown)) + }) + + let completionRequestId = 0 + let completionResultsKey = "" + createEffect(() => { + const trigger = completionTrigger() + completionRevision() + if (!trigger) { + setComposerSuggestions([]) + setComposerSuggestionIndex(0) + completionResultsKey = "" + return + } + + const requestId = ++completionRequestId + const resultsKey = `${trigger.kind}:${trigger.range[0]}:${trigger.query}` + const resetSelection = resultsKey !== completionResultsKey + completionResultsKey = resultsKey + void api() + .autocomplete(trigger.kind, trigger.query, Boolean(state()?.current_session_id)) + .then((suggestions) => { + if (requestId !== completionRequestId) return + const next = suggestions.slice(0, 12) + setComposerSuggestions(next) + setComposerSuggestionIndex((index) => (resetSelection ? 0 : Math.min(index, Math.max(next.length - 1, 0)))) + }) + .catch(() => { + if (requestId !== completionRequestId) return + setComposerSuggestions([]) + setComposerSuggestionIndex(0) + }) + }) + + createEffect(() => { + const index = composerSuggestionIndex() + composerSuggestions().length + const list = composerSuggestionsRef + if (!list) return + + queueMicrotask(() => { + const option = list.querySelector(`[data-composer-suggestion-index="${index}"]`) + option?.scrollIntoView({ block: "nearest" }) + }) + }) + + const applyRemoteState = (next: RemoteState) => { + setState(next) + if (!projectsInitialized()) { + setProjectOpen(new Set(projectsFromState(next).map((project) => project.path || project.name))) + setProjectsInitialized(true) + } + } + + const loadStateSnapshot = async () => { + applyRemoteState(await api().state()) + } + + const openStateEvents = () => { + closeStateEvents?.() + closeStateEvents = api().stateEvents( + (next) => { + setPairRequired(false) + setPairError("") + applyRemoteState(next) + }, + () => { + const message = "Live connection interrupted. Reconnecting..." + setPairError(message) + toast.error(message) + } + ) + } + + const connect = async () => { + try { + const status = await api().status() + if (status.auth_required && !token()) { + setPairRequired(true) + return + } + + const next = await api().state() + setPairRequired(false) + applyRemoteState(next) + openStateEvents() + } catch (error) { + if (error instanceof RemoteApiError && error.status === 401) { + localStorage.removeItem(TOKEN_KEY) + setToken("") + closeStateEvents?.() + closeStateEvents = undefined + setPairRequired(true) + return + } + setPairRequired(true) + const message = errorToastMessage(error, "Host unavailable or pairing required.") + setPairError(message) + toast.error(message) + } + } + + onMount(() => { + connect() + const mascotTimer = window.setInterval(() => { + setMascotFrame((current) => (current + 1) % Math.max(MASCOT_FRAMES.length, 1)) + }, 620) + onCleanup(() => { + closeStateEvents?.() + if (commandCloseTimer !== undefined) window.clearTimeout(commandCloseTimer) + window.clearInterval(mascotTimer) + }) + }) + + const projectPath = createMemo(() => state()?.status.cwd || "") + const projectName = createMemo( + () => state()?.status.workspace || basename(projectPath()) || "Project" + ) + const projects = createMemo(() => projectsFromState(state())) + const activeServerUrl = createMemo(() => state()?.status.browser_url || browserOrigin()) + const servers = createMemo(() => { + const activeUrl = activeServerUrl() + const seen = new Set() + const activeServer: SavedServer = { + id: "active", + address: activeUrl, + name: activeUrl.replace(/^https?:\/\//, ""), + username: "", + password: "", + } + return [activeServer, ...savedServers()].filter((server) => { + const key = normalizeServerAddress(server.address) + if (seen.has(key)) return false + seen.add(key) + return true + }) + }) + const filteredServers = createMemo(() => { + const query = serverSearch().trim().toLowerCase() + if (!query) return servers() + return servers().filter((server) => + `${server.name} ${server.address} ${server.username}`.toLowerCase().includes(query) + ) + }) + const reasoningOptions = createMemo(() => state()?.status.reasoning_efforts ?? []) + const reasoningLabel = createMemo(() => state()?.status.reasoning_effort || "off") + const pendingPermission = createMemo(() => state()?.pending_permission ?? null) + const pendingQuestion = createMemo(() => state()?.pending_question ?? null) + + const commandResults = createMemo(() => { + const query = commandQuery().trim().toLowerCase() + const sessions = state()?.sessions ?? [] + if (!query) return sessions.slice(0, 12) + return sessions + .filter((session) => + `${session.workspace} ${session.title} ${session.status}`.toLowerCase().includes(query) + ) + .slice(0, 24) + }) + const projectCommandResults = createMemo(() => { + const query = commandQuery().trim().toLowerCase() + const list = projects() + if (!query) return list.slice(0, 8) + return list + .filter((project) => `${project.name} ${project.path}`.toLowerCase().includes(query)) + .slice(0, 16) + }) + + const filteredModels = createMemo(() => { + const query = modelQuery().trim().toLowerCase() + if (!query) return models() + return models().filter((model) => + `${model.group} ${model.name} ${model.provider_id} ${model.id} ${model.description}` + .toLowerCase() + .includes(query) + ) + }) + + createEffect(() => { + const list = filteredModels() + if (!modelOpen()) return + const active = list.findIndex((model) => model.active) + setModelActiveIndex((index) => Math.max(0, Math.min(index || Math.max(active, 0), Math.max(list.length - 1, 0)))) + }) + + const visibleMessages = createMemo(() => + (state()?.messages ?? []).filter((message) => message.role !== "system") + ) + const promptHistoryEntries = createMemo(() => + mergePromptHistoryEntries(browserPromptHistory(), messagePromptHistoryEntries(visibleMessages())) + ) + const currentSession = createMemo(() => + (state()?.sessions ?? []).find((session) => session.id === state()?.current_session_id) + ) + const threadItems = createMemo(() => buildThreadItems(visibleMessages(), projectPath())) + const isEmptyChat = createMemo(() => threadItems().length === 0 && !state()?.is_streaming) + + createEffect(() => { + state()?.current_session_id + queueMicrotask(() => threadScroll.scrollToBottom(false)) + }) + + const pair = async (event: SubmitEvent) => { + event.preventDefault() + setPairError("") + try { + const response = await api().pair(pairCode()) + localStorage.setItem(TOKEN_KEY, response.token) + setToken(response.token) + setPairCode("") + setPairRequired(false) + await connect() + } catch (error) { + const message = errorToastMessage(error, "Pair code rejected.") + setPairError(message) + toast.error(message) + } + } + + const startNewSession = async (workspacePath?: string) => { + closeCommandPalette() + try { + const next = await api().newSession(workspacePath) + applyRemoteState(next) + setSidebarOpen(false) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not start a new chat.") + } + } + + const selectWorkspace = async (path: string) => { + const nextPath = path.trim() + if (!nextPath) return + + setProjectPathError("") + try { + const next = await api().selectWorkspace(nextPath) + applyRemoteState(next) + setProjectPickerOpen(false) + setNewProjectOpen(false) + setSidebarOpen(false) + promptRef?.focus() + } catch (error) { + const message = errorToastMessage(error, "Could not open folder") + setProjectPathError(message) + toast.error(message) + } + } + + const submitProjectPath = async (event: SubmitEvent) => { + event.preventDefault() + await selectWorkspace(projectPathInput()) + } + + const switchSession = async (id: string) => { + closeCommandPalette() + try { + const next = await api().switchSession(id) + applyRemoteState(next) + setSidebarOpen(false) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not switch chat.") + } + } + + const archiveSession = async (id: string) => { + closeCommandPalette() + try { + const next = await api().archiveSession(id) + applyRemoteState(next) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not archive chat.") + } + } + + const archiveProject = async (path: string) => { + const nextPath = path.trim() + if (!nextPath) return + + closeCommandPalette() + try { + const next = await api().archiveWorkspace(nextPath) + applyRemoteState(next) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not archive project.") + } + } + + const copySessionTranscript = async () => { + const current = currentSession() + const transcript = sessionTranscript(current?.title || "Untitled", state()?.messages ?? []) + if (!transcript.trim()) { + toast.warning("No transcript to copy.") + return + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(transcript) + } else { + fallbackCopyText(transcript) + } + toast.success("Session transcript copied.") + } catch { + try { + fallbackCopyText(transcript) + toast.success("Session transcript copied.") + } catch { + toast.error("Clipboard access was denied.") + } + } + } + + const handleLocalSlashCommand = async (text: string) => { + const parsed = parseSlashCommand(text) + if (!parsed) return false + + if (sameToken(parsed.name, "copy")) { + if (parsed.args.trim()) { + toast.error("Usage: /copy") + } else { + await copySessionTranscript() + } + return true + } + + if (sameToken(parsed.name, "models")) { + setModelQuery(parsed.args.trim()) + setModelOpen(true) + void loadModels() + focusModelSearch() + return true + } + + return false + } + + const composerAttachmentData = createMemo(() => + composerAttachments().map((attachment, index) => ({ + id: attachment.id, + url: attachment.dataUrl, + filename: `[Image #${index + 1}] ${attachment.name}`, + mediaType: attachment.mediaType, + size: attachment.size, + })) + ) + + const appendImagePlaceholders = (startIndex: number, count: number) => { + if (count <= 0) return + resetPromptHistoryNavigation() + const placeholders = Array.from({ length: count }, (_, index) => `[Image #${startIndex + index}]`) + setPrompt((current) => { + const separator = current.length > 0 && !/\s$/.test(current) ? " " : "" + return `${current}${separator}${placeholders.join(" ")} ` + }) + setComposerSuggestions([]) + setCompletionTrigger(null) + queueMicrotask(() => { + promptRef?.focus() + resizePrompt() + }) + } + + const addImageFiles = async (files: File[]) => { + const imageFiles = files.filter((file) => isSupportedImageFile(file)) + if (imageFiles.length === 0) { + toast.warning("Paste or choose PNG, JPEG, GIF, or WebP images.") + return + } + + const available = MAX_COMPOSER_ATTACHMENTS - composerAttachments().length + if (available <= 0) { + toast.warning(`Attach up to ${MAX_COMPOSER_ATTACHMENTS} images.`) + return + } + + const accepted = imageFiles.slice(0, available) + if (imageFiles.length > accepted.length) { + toast.warning(`Only ${MAX_COMPOSER_ATTACHMENTS} images can be attached.`) + } + + const oversized = accepted.find((file) => file.size > MAX_COMPOSER_ATTACHMENT_BYTES) + if (oversized) { + toast.error(`${oversized.name || "Image"} is larger than 16MB.`) + return + } + + try { + const next = await Promise.all(accepted.map(readComposerAttachment)) + const startIndex = composerAttachments().length + 1 + setComposerAttachments((current) => [...current, ...next]) + appendImagePlaceholders(startIndex, next.length) + toast.success(next.length === 1 ? "Image attached." : `${next.length} images attached.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not read image.") + } + } + + const handlePromptPaste = (event: ClipboardEvent & { currentTarget: HTMLTextAreaElement }) => { + const files = filesFromClipboard(event.clipboardData) + if (files.length === 0) return + + event.preventDefault() + void addImageFiles(files) + } + + const handleComposerDrop = (event: DragEvent & { currentTarget: HTMLFormElement }) => { + const files = Array.from(event.dataTransfer?.files ?? []) + if (files.length === 0) return + + event.preventDefault() + void addImageFiles(files) + } + + const resetPromptHistoryNavigation = () => { + setPromptHistoryIndex(null) + setPromptHistoryDraft("") + } + + const addPromptHistoryEntry = (text: string) => { + const entry = normalizePromptHistoryEntry(text) + if (!entry || parseSlashCommand(entry)) return + + setBrowserPromptHistory((current) => { + const next = [entry, ...current.filter((item) => item !== entry)].slice(0, MAX_PROMPT_HISTORY) + savePromptHistory(next) + return next + }) + resetPromptHistoryNavigation() + } + + const applyPromptHistoryEntry = (text: string, cursor: "start" | "end") => { + setPrompt(text) + setComposerSuggestions([]) + setCompletionTrigger(null) + queueMicrotask(() => { + const offset = cursor === "start" ? 0 : text.length + promptRef?.focus() + promptRef?.setSelectionRange(offset, offset) + resizePrompt() + }) + } + + const navigatePromptHistory = ( + direction: "up" | "down", + event: KeyboardEvent & { currentTarget: HTMLTextAreaElement } + ) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false + if (event.currentTarget.selectionStart !== event.currentTarget.selectionEnd) return false + if (composerAttachments().length > 0) return false + + const entries = promptHistoryEntries() + if (entries.length === 0) return false + + const text = prompt() + const cursor = event.currentTarget.selectionStart + + if (direction === "up") { + if (!isCursorOnFirstLogicalLine(text, cursor)) return false + + const currentIndex = promptHistoryIndex() + const nextIndex = currentIndex == null ? 0 : Math.min(currentIndex + 1, entries.length - 1) + if (currentIndex === nextIndex) return false + + event.preventDefault() + if (currentIndex == null) setPromptHistoryDraft(text) + setPromptHistoryIndex(nextIndex) + applyPromptHistoryEntry(entries[nextIndex] ?? "", "start") + return true + } + + if (!isCursorOnLastLogicalLine(text, cursor)) return false + + const currentIndex = promptHistoryIndex() + if (currentIndex == null) return false + + event.preventDefault() + if (currentIndex === 0) { + const draft = promptHistoryDraft() + resetPromptHistoryNavigation() + applyPromptHistoryEntry(draft, "end") + return true + } + + const nextIndex = currentIndex - 1 + setPromptHistoryIndex(nextIndex) + applyPromptHistoryEntry(entries[nextIndex] ?? "", "end") + return true + } + + const removeComposerAttachment = (id: string) => { + const current = composerAttachments() + const index = current.findIndex((attachment) => attachment.id === id) + if (index < 0) return + + removeComposerAttachmentNumbers([index + 1]) + } + + const removeComposerAttachmentNumbers = (numbers: number[], text = prompt()) => { + resetPromptHistoryNavigation() + const numberSet = new Set(numbers) + const current = composerAttachments() + const nextAttachments = current.filter((_, index) => !numberSet.has(index + 1)) + setComposerAttachments(nextAttachments) + setPrompt(renumberImagePlaceholdersAfterRemoval(text, numbers, current.length)) + setComposerSuggestions([]) + setCompletionTrigger(null) + queueMicrotask(resizePrompt) + } + + const syncComposerAttachmentsToText = (text: string) => { + const current = composerAttachments() + if (current.length === 0) return text + + const referenced = new Set( + imagePlaceholderRanges(text) + .map((range) => range.number) + .filter((number) => number >= 1 && number <= current.length) + ) + const removedNumbers = current + .map((_, index) => index + 1) + .filter((number) => !referenced.has(number)) + + if (removedNumbers.length === 0) return text + + const nextAttachments = current.filter((_, index) => !removedNumbers.includes(index + 1)) + setComposerAttachments(nextAttachments) + return renumberImagePlaceholdersAfterRemoval(text, removedNumbers, current.length) + } + + const handlePromptInput = (event: InputEvent & { currentTarget: HTMLTextAreaElement }) => { + resetPromptHistoryNavigation() + const cursor = event.currentTarget.selectionStart + const nextText = syncComposerAttachmentsToText(event.currentTarget.value) + setPrompt(nextText) + setCompletionTrigger(detectCompletionTrigger(nextText, Math.min(cursor, nextText.length))) + setCompletionRevision((revision) => revision + 1) + resizePrompt() + + if (nextText !== event.currentTarget.value) { + const nextCursor = Math.min(cursor, nextText.length) + queueMicrotask(() => { + promptRef?.setSelectionRange(nextCursor, nextCursor) + resizePrompt() + }) + } + } + + const handlePromptScroll = (event: Event & { currentTarget: HTMLTextAreaElement }) => { + if (promptOverlayRef) promptOverlayRef.scrollTop = event.currentTarget.scrollTop + } + + const removePromptImageTagAtCursor = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => { + if (event.key !== "Backspace" && event.key !== "Delete") return false + if (composerAttachments().length === 0) return false + + const textarea = event.currentTarget + const text = textarea.value + const selectionStart = textarea.selectionStart + const selectionEnd = textarea.selectionEnd + const ranges = imagePlaceholderRanges(text) + const targetRanges = + selectionStart !== selectionEnd + ? ranges.filter((range) => rangesIntersect(range.start, range.end, selectionStart, selectionEnd)) + : ranges.filter((range) => + event.key === "Backspace" + ? (selectionStart > range.start && selectionStart <= range.end) || + (selectionStart === range.end + 1 && /\s/.test(text[range.end] ?? "")) + : selectionStart >= range.start && selectionStart < range.end + ) + + if (targetRanges.length === 0) return false + + event.preventDefault() + const removeSelection = selectionStart !== selectionEnd + const removedNumbers = [...new Set(targetRanges.map((range) => range.number))] + const removalRanges = removeSelection + ? [...targetRanges, { number: 0, start: selectionStart, end: selectionEnd }] + : targetRanges + const cursor = Math.min(...removalRanges.map((range) => range.start)) + const nextText = removeRangesFromText(text, removalRanges) + + removeComposerAttachmentNumbers(removedNumbers, nextText) + queueMicrotask(() => { + const nextCursor = Math.min(cursor, prompt().length) + promptRef?.focus() + promptRef?.setSelectionRange(nextCursor, nextCursor) + resizePrompt() + }) + return true + } + + const promptImages = (attachments: ComposerAttachment[]): RemotePromptImage[] => + attachments.map((attachment) => ({ + name: attachment.name, + media_type: attachment.mediaType, + data_url: attachment.dataUrl, + })) + + const openImagePreview = (attachment: AttachmentData) => { + const target = imagePreviewFromAttachment(attachment) + if (target) setImagePreview(target) + } + + const clearComposer = () => { + setPrompt("") + setComposerAttachments([]) + setComposerSuggestions([]) + setCompletionTrigger(null) + resetPromptHistoryNavigation() + resizePrompt() + } + + const submitPromptText = async ( + rawText: string, + restoreOnError = true, + attachments = composerAttachments() + ) => { + const text = promptTextWithAttachmentPlaceholders(rawText, attachments.length).trim() + if (!text && attachments.length === 0) return + if (attachments.length === 0 && await handleLocalSlashCommand(text)) { + clearComposer() + return + } + if (attachments.length > 0 && parseSlashCommand(text)) { + toast.error("Images can only be attached to chat prompts.") + return + } + + clearComposer() + try { + await api().prompt(text, promptImages(attachments)) + addPromptHistoryEntry(text) + await loadStateSnapshot() + } catch (error) { + if (restoreOnError) { + setPrompt(text) + setComposerAttachments(attachments) + resizePrompt() + } + toast.error(error instanceof Error ? error.message : "Prompt failed.") + } + } + + const submitPrompt = async (event: SubmitEvent) => { + event.preventDefault() + + if (state()?.is_streaming) { + try { + await api().cancel() + await loadStateSnapshot() + } catch (error) { + showErrorToast(error, "Could not stop generation.") + } + return + } + + await submitPromptText(prompt(), true) + } + + const answerPermission = async (response: RemotePermissionResponse) => { + if (permissionBusy()) return + setPermissionBusy(true) + try { + const next = await api().answerPermission(response) + applyRemoteState(next) + } catch (error) { + showErrorToast(error, "Could not answer permission request.") + await loadStateSnapshot().catch(() => {}) + } finally { + setPermissionBusy(false) + promptRef?.focus() + } + } + + const answerQuestion = async (answers: string[][]) => { + if (questionBusy()) return + setQuestionBusy(true) + try { + const next = await api().answerQuestion(answers) + applyRemoteState(next) + } catch (error) { + showErrorToast(error, "Could not answer question.") + await loadStateSnapshot().catch(() => {}) + } finally { + setQuestionBusy(false) + promptRef?.focus() + } + } + + const cancelQuestion = async () => { + if (questionBusy()) return + setQuestionBusy(true) + try { + const next = await api().cancelQuestion() + applyRemoteState(next) + } catch (error) { + showErrorToast(error, "Could not cancel question.") + await loadStateSnapshot().catch(() => {}) + } finally { + setQuestionBusy(false) + promptRef?.focus() + } + } + + const loadModels = async () => { + if (models().length > 0) return + try { + setModels(await api().models()) + } catch (error) { + showErrorToast(error, "Could not load models.") + } + } + + const loadSkills = async () => { + if (skills().length > 0) return + try { + setSkills(await api().skills()) + } catch (error) { + showErrorToast(error, "Could not load skills.") + } + } + + const focusModelSearch = () => { + window.setTimeout(() => modelSearchRef?.focus(), 0) + } + + const focusPromptInput = () => { + window.setTimeout(() => promptRef?.focus(), 0) + } + + const requestPromptFocusAfterControlPopoverClose = () => { + focusPromptAfterControlPopoverClose = true + } + + const handleControlPopoverCloseAutoFocus = (event: Event) => { + if (!focusPromptAfterControlPopoverClose) return + event.preventDefault() + focusPromptAfterControlPopoverClose = false + focusPromptInput() + } + + const handleModelOpenChange = (open: boolean) => { + setModelOpen(open) + if (open) { + setModelQuery("") + setModelActiveIndex(Math.max(filteredModels().findIndex((model) => model.active), 0)) + setComposerSuggestions([]) + setCompletionTrigger(null) + void loadModels() + focusModelSearch() + } + } + + const handleModelSearchKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { + const list = filteredModels() + if (event.key === "ArrowDown") { + event.preventDefault() + setModelActiveIndex((index) => (index + 1) % Math.max(list.length, 1)) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + setModelActiveIndex((index) => (index - 1 + Math.max(list.length, 1)) % Math.max(list.length, 1)) + return + } + if (event.key === "Enter") { + event.preventDefault() + const selected = list[modelActiveIndex()] + if (selected) void selectModel(selected) + return + } + if (event.key === "Escape") { + event.preventDefault() + requestPromptFocusAfterControlPopoverClose() + setModelOpen(false) + } + } + + const handleNewProjectOpenChange = (open: boolean) => { + setNewProjectOpen(open) + setProjectPathError("") + if (open) { + setProjectPathInput("") + queueMicrotask(() => projectPathInputRef?.focus()) + } + } + + const handleProjectPickerOpenChange = (open: boolean) => { + setProjectPickerOpen(open) + setProjectPathError("") + if (!open) setProjectPickerAddOpen(false) + } + + const handleAgentOpenChange = (open: boolean) => { + setAgentOpen(open) + if (open) { + setAgentActiveIndex(Math.max(AGENT_MODES.findIndex((agent) => sameToken(agent, state()?.status.agent || "Build")), 0)) + setComposerSuggestions([]) + setCompletionTrigger(null) + } + } + + const handleReasoningOpenChange = (open: boolean) => { + setReasoningOpen(open) + if (open) { + setReasoningActiveIndex( + Math.max(reasoningOptions().findIndex((effort) => sameToken(effort, reasoningLabel())), 0) + ) + setComposerSuggestions([]) + setCompletionTrigger(null) + } + } + + const selectModel = async (model: RemoteModel) => { + try { + await api().selectModel(model.provider_id, model.id) + setModelOpen(false) + setModels([]) + await loadStateSnapshot() + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not select model.") + } + } + + const selectAgentMode = async (agent: string) => { + try { + const next = await api().setAgent(agent) + applyRemoteState(next) + setAgentOpen(false) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not switch agent.") + } + } + + const selectReasoningEffort = async (effort: string) => { + try { + const next = await api().setReasoning(effort === "off" ? null : effort) + applyRemoteState(next) + setReasoningOpen(false) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not set reasoning effort.") + } + } + + const handleAgentMenuKeyDown = (event: KeyboardEvent) => { + handleChoiceMenuKeyDown( + event, + agentOpen(), + setAgentOpen, + AGENT_MODES, + agentActiveIndex(), + setAgentActiveIndex, + selectAgentMode, + requestPromptFocusAfterControlPopoverClose + ) + } + + const handleReasoningMenuKeyDown = (event: KeyboardEvent) => { + handleChoiceMenuKeyDown( + event, + reasoningOpen(), + setReasoningOpen, + reasoningOptions(), + reasoningActiveIndex(), + setReasoningActiveIndex, + selectReasoningEffort, + requestPromptFocusAfterControlPopoverClose + ) + } + + const openServerManager = () => { + setServersOpen(false) + setServersManageOpen(true) + setServerAddOpen(false) + setServerSearch("") + } + + const handleServersOpenChange = (open: boolean) => { + setServersOpen(open) + if (open) { + setServerPanelTab("servers") + void loadSkills() + } + } + + const selectServerPanelTab = (tab: ServerPanelTab) => { + setServerPanelTab(tab) + if (tab === "skills") void loadSkills() + } + + const showAddServer = () => { + setServerAddOpen(true) + setServerAddress("") + setServerName("") + setServerUsername("") + setServerPassword("") + queueMicrotask(() => serverAddressRef?.focus()) + } + + const saveServer = (event: SubmitEvent) => { + event.preventDefault() + const address = normalizeServerAddress(serverAddress()) + if (!address) return + + const nextServer: SavedServer = { + id: cuid(), + address, + name: serverName().trim() || address.replace(/^https?:\/\//, ""), + username: serverUsername().trim(), + password: serverPassword(), + } + const next = [ + nextServer, + ...savedServers().filter( + (server) => normalizeServerAddress(server.address) !== normalizeServerAddress(address) + ), + ] + setSavedServers(next) + saveSavedServers(next) + setServerAddOpen(false) + } + + const openServer = (server: SavedServer) => { + const address = normalizeServerAddress(server.address) + if (!address || isActiveServer(address, activeServerUrl())) return + window.location.href = address + } + + const toggleProject = (key: string) => { + setProjectOpen((current) => { + const next = new Set(current) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + const refreshCompletion = () => { + const trigger = detectCompletionTrigger(prompt(), promptRef?.selectionStart ?? prompt().length) + setCompletionTrigger(trigger) + setCompletionRevision((value) => value + 1) + } + + const applyComposerSuggestion = (suggestion: RemoteSuggestion) => { + const trigger = completionTrigger() + if (!trigger) return + + const text = prompt() + const [start, end] = trigger.range + const replacement = + suggestion.kind === "command" + ? `/${suggestion.replacement} ` + : suggestion.kind === "agent" + ? `@${suggestion.replacement} ` + : `${quoteCompletionPath(suggestion.replacement)} ` + const next = `${text.slice(0, start)}${replacement}${text.slice(end)}` + const cursor = start + replacement.length + resetPromptHistoryNavigation() + setPrompt(next) + setComposerSuggestions([]) + setCompletionTrigger(null) + queueMicrotask(() => { + if (!promptRef) return + promptRef.focus() + promptRef.setSelectionRange(cursor, cursor) + resizePrompt() + }) + } + + const chooseComposerSuggestion = (suggestion: RemoteSuggestion) => { + if (suggestion.kind === "command") { + void submitPromptText(`/${suggestion.replacement || suggestion.name}`, false) + return + } + + applyComposerSuggestion(suggestion) + } + + const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => { + if (composerSuggestions().length > 0) { + if (event.key === "ArrowDown") { + event.preventDefault() + setComposerSuggestionIndex((index) => (index + 1) % composerSuggestions().length) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + setComposerSuggestionIndex((index) => + (index - 1 + composerSuggestions().length) % composerSuggestions().length + ) + return + } + if (event.key === "Tab" || (event.key === "Enter" && !event.shiftKey)) { + event.preventDefault() + const selected = composerSuggestions()[composerSuggestionIndex()] + if (selected) chooseComposerSuggestion(selected) + return + } + if (event.key === "Escape") { + event.preventDefault() + setComposerSuggestions([]) + setCompletionTrigger(null) + return + } + } + + if (removePromptImageTagAtCursor(event)) return + + if (event.key === "ArrowUp" && navigatePromptHistory("up", event)) return + if (event.key === "ArrowDown" && navigatePromptHistory("down", event)) return + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + event.currentTarget.form?.requestSubmit() + } + } + + const modelLabel = createMemo(() => { + const status = state()?.status + if (!status) return "Model" + return `${status.provider}/${status.model}` + }) + + const themeStyle = createMemo( + () => + ({ + "--brand-primary": state()?.status.theme?.primary ?? "#6c8ed8", + "--brand-dim": state()?.status.theme?.primary_dim ?? "#4a639f", + }) as JSX.CSSProperties + ) + + const resizePrompt = () => { + const el = promptRef + if (!el) return + el.style.height = "auto" + el.style.height = `${Math.min(el.scrollHeight, 224)}px` + } + + return ( +
+ +
+
+

Pair device

+

Enter the code printed by crabcode serve.

+
+ setPairCode(event.currentTarget.value)} + autocomplete="one-time-code" + inputmode="numeric" + placeholder="482-119" + /> + +
+
{pairError()}
+
+
+
+ + + + + + + + {projectName()} + + + {projectPath()} + + + + + +
+ Open project + +
+ +
+ setProjectPathInput(event.currentTarget.value)} + placeholder="/Users/carlo/Desktop/Projects/app" + /> + +
+
+
+ + {(project) => ( + + )} + +
+ +
+ {projectPathError()} +
+
+
+
+
+ + + + + + + + + +
+ + + + + +
+ +
+ + {(server) => ( + + )} + +
+ + + } + > +
+ 0} fallback={
No skills loaded.
}> + + {(skill) => ( +
+ + + + {skill.name} + + + {skill.description || skill.location} + + +
+ )} +
+
+
+
+
+
+
+ + +
+
+
+ 0} + fallback={ + + } + > + + {(item) => ( + state()?.status ?? null} + token={token} + onPreviewImage={openImagePreview} + /> + )} + + +
+
+
+ +
+ + {(permission) => ( + + )} + + + {(question) => ( + + )} + +
event.preventDefault()} + onDrop={handleComposerDrop} + > + { + const files = Array.from(event.currentTarget.files ?? []) + event.currentTarget.value = "" + void addImageFiles(files) + }} + /> + 0}> +
+ + + {(attachment) => ( + removeComposerAttachment(attachment.id)} + class="cursor-zoom-in transition hover:border-[rgba(255,255,255,0.16)] hover:bg-[#242424] focus-visible:border-[rgba(157,177,239,0.55)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgba(157,177,239,0.18)]" + role="button" + tabIndex={0} + onClick={() => openImagePreview(attachment)} + onKeyDown={(event) => handleImagePreviewKeyDown(event, () => openImagePreview(attachment))} + > + +
+ + +
+
+ )} +
+
+
+
+
+ +