diff --git a/CHANGELOG.md b/CHANGELOG.md index fae0a891..c5b31f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.3.22] - 2026-02-19 + +### Added +- Interactive `/config` editing modal for runtime settings updates. + +### Changed +- Retired user-facing `/set` command path (no longer reachable/discoverable). +- Replaced `/deepseek` command behavior with `/links` (aliases: `dashboard`, `api`). + +### Fixed +- Legacy `/set` and `/deepseek` inputs now return migration guidance instead of generic unknown-command errors. + ## [0.3.21] - 2026-02-19 ### Added @@ -298,7 +312,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.21...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.22...HEAD +[0.3.22]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.21...v0.3.22 [0.3.21]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.17...v0.3.21 [0.3.17]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.16...v0.3.17 [0.3.16]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.14...v0.3.16 diff --git a/Cargo.lock b/Cargo.lock index 1332c3f4..bf612f7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -726,7 +726,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.3.21" +version = "0.3.22" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 2d14f0d8..4abbf1dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deepseek-tui" -version = "0.3.21" +version = "0.3.22" edition = "2024" description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting" license = "MIT" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 512031fa..be221226 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -78,11 +78,11 @@ DeepSeek CLI also stores user preferences in: Notable settings include `auto_compact` (default `true`), which automatically summarizes earlier turns once the conversation grows large. You can inspect or update these from the -TUI with `/settings` and `/set `. +TUI with `/settings` and `/config` (interactive editor). Common settings keys: -- `theme` (default, dark, light) +- `theme` (default, dark, light, whale) - `auto_compact` (on/off) - `show_thinking` (on/off) - `show_tool_details` (on/off) @@ -90,6 +90,22 @@ Common settings keys: - `max_history` (number of input history entries) - `default_model` (model name override) +Readability semantics: + +- Selection uses a unified style across transcript, composer menus, and modals. +- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes. + +### Command Migration Notes + +If you are upgrading from older releases: + +- Old: `/deepseek` + New: `/links` (aliases: `/dashboard`, `/api`) +- Old: `/set model deepseek-reasoner` + New: `/config` and edit the `model` row to `deepseek-reasoner` +- Old: discover `/set` in slash UX/help + New: use `/config` for editing and `/settings` for read-only inspection + ## Key Reference ### Core keys (used by the TUI/engine) @@ -98,7 +114,7 @@ Common settings keys: - `base_url` (string, optional): defaults to `https://api.deepseek.com` (OpenAI-compatible Responses API). - `default_text_model` (string, optional): defaults to `deepseek-reasoner`. Supported IDs are `deepseek-reasoner` and `deepseek-chat`. - `allow_shell` (bool, optional): defaults to `true` (sandboxed). -- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `/set approval_mode` also accepts `on-request` and `untrusted` aliases. +- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases. - `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`. - `managed_config_path` (string, optional): managed config file loaded after user/env config. - `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values. diff --git a/docs/MODES.md b/docs/MODES.md index 019f989a..52b56c24 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -19,15 +19,24 @@ Press `Tab` to cycle: **Plan → Agent → YOLO → Plan**. You can override approval behavior at runtime: ```text -/set approval_mode suggest -/set approval_mode auto -/set approval_mode never +/config +# edit the approval_mode row to: suggest | auto | never ``` +Legacy note: `/set approval_mode ...` was retired in favor of `/config`. + - `suggest` (default): uses the per-mode rules above. - `auto`: auto-approves all tools (similar to YOLO approval behavior, but without forcing YOLO mode). - `never`: blocks any tool that isn’t considered safe/read-only. +## Small-Screen Status Behavior + +When terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible: + +- Loading and queued status rows are budgeted by available height. +- Queued previews collapse to compact summaries when full previews do not fit. +- `/queue` workflows remain available; compact status only affects rendering density. + ## Workspace Boundary and Trust Mode By default, file tools are restricted to the `--workspace` directory. Enable trust mode to allow file access outside the workspace: diff --git a/src/commands/config.rs b/src/commands/config.rs index b55a1a84..21d11989 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,4 +1,4 @@ -//! Config commands: config, set, settings, mode switches, trust, logout +//! Config commands: config, settings, mode switches, trust, logout use super::CommandResult; use crate::config::{COMMON_DEEPSEEK_MODELS, canonical_model_name, clear_api_key}; @@ -7,42 +7,9 @@ use crate::settings::Settings; use crate::tui::app::{App, AppAction, AppMode, OnboardingState, SidebarFocus}; use crate::tui::approval::ApprovalMode; -/// Display current configuration -pub fn show_config(app: &mut App) -> CommandResult { - let has_project_doc = app.project_doc.is_some(); - let config_info = format!( - "Session Configuration:\n\ - ─────────────────────────────\n\ - Mode: {}\n\ - Model: {}\n\ - Workspace: {}\n\ - Shell enabled: {}\n\ - Approval mode: {}\n\ - Max sub-agents: {}\n\ - Trust mode: {}\n\ - Auto-compact: {}\n\ - Sidebar width: {}%\n\ - Sidebar focus: {}\n\ - Total tokens: {}\n\ - Project doc: {}", - app.mode.label(), - app.model, - app.workspace.display(), - if app.allow_shell { "yes" } else { "no" }, - app.approval_mode.label(), - app.max_subagents, - if app.trust_mode { "yes" } else { "no" }, - if app.auto_compact { "yes" } else { "no" }, - app.sidebar_width_percent, - app.sidebar_focus.as_setting(), - app.total_tokens, - if has_project_doc { - "loaded" - } else { - "not found" - }, - ); - CommandResult::message(config_info) +/// Open the interactive config editor modal. +pub fn show_config(_app: &mut App) -> CommandResult { + CommandResult::action(AppAction::OpenConfigView) } /// Show persistent settings @@ -54,36 +21,9 @@ pub fn show_settings(_app: &mut App) -> CommandResult { } /// Modify a setting at runtime -pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { - let Some(args) = args else { - let available = Settings::available_settings() - .iter() - .map(|(k, d)| format!(" {k}: {d}")) - .collect::>() - .join("\n"); - return CommandResult::message(format!( - "Usage: /set \n\n\ - Available settings:\n{available}\n\n\ - Session-only settings:\n \ - model: Current model\n \ - approval_mode: auto | suggest | never\n\n\ - Add --save to persist to settings file." - )); - }; +pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { + let key = key.to_lowercase(); - let parts: Vec<&str> = args.splitn(2, ' ').collect(); - if parts.len() < 2 { - return CommandResult::error("Usage: /set "); - } - - let key = parts[0].to_lowercase(); - let (value, should_save) = if parts[1].ends_with(" --save") { - (parts[1].trim_end_matches(" --save").trim(), true) - } else { - (parts[1].trim(), false) - }; - - // Handle session-only settings first match key.as_str() { "model" => { let Some(model) = canonical_model_name(value) else { @@ -121,10 +61,9 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { _ => {} } - // Load and update persistent settings let mut settings = match Settings::load() { Ok(s) => s, - Err(e) if !should_save => { + Err(e) if !persist => { app.status_message = Some(format!( "Settings unavailable; applying session-only override ({e})" )); @@ -137,7 +76,6 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { return CommandResult::error(format!("{e}")); } - // Apply to current session let mut action = None; match key.as_str() { "auto_compact" | "compact" => { @@ -187,8 +125,7 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { _ => {} } - // Save if requested - let message = if should_save { + let message = if persist { if let Err(e) = settings.save() { return CommandResult::error(format!("Failed to save: {e}")); } @@ -203,6 +140,40 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { } } +/// Modify a setting at runtime +#[allow(dead_code)] +pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { + let Some(args) = args else { + let available = Settings::available_settings() + .iter() + .map(|(k, d)| format!(" {k}: {d}")) + .collect::>() + .join("\n"); + return CommandResult::message(format!( + "Usage: /set \n\n\ + Available settings:\n{available}\n\n\ + Session-only settings:\n \ + model: Current model\n \ + approval_mode: auto | suggest | never\n\n\ + Add --save to persist to settings file." + )); + }; + + let parts: Vec<&str> = args.splitn(2, ' ').collect(); + if parts.len() < 2 { + return CommandResult::error("Usage: /set "); + } + + let key = parts[0].to_lowercase(); + let (value, should_save) = if parts[1].ends_with(" --save") { + (parts[1].trim_end_matches(" --save").trim(), true) + } else { + (parts[1].trim(), false) + }; + + set_config_value(app, &key, value, should_save) +} + /// Enable YOLO mode (shell + trust + auto-approve) pub fn yolo(app: &mut App) -> CommandResult { app.set_mode(AppMode::Yolo); @@ -376,24 +347,12 @@ mod tests { } #[test] - fn test_show_config_displays_all_fields() { + fn test_show_config_opens_config_editor() { let mut app = create_test_app(); app.total_tokens = 1234; let result = show_config(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Session Configuration")); - assert!(msg.contains("Mode:")); - assert!(msg.contains("Model:")); - assert!(msg.contains("Workspace:")); - assert!(msg.contains("Shell enabled:")); - assert!(msg.contains("Approval mode:")); - assert!(msg.contains("Max sub-agents:")); - assert!(msg.contains("Trust mode:")); - assert!(msg.contains("Auto-compact:")); - assert!(msg.contains("Sidebar width:")); - assert!(msg.contains("Total tokens:")); - assert!(msg.contains("Project doc:")); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::OpenConfigView))); } #[test] diff --git a/src/commands/core.rs b/src/commands/core.rs index 2bc6d0ae..5fcf421f 100644 --- a/src/commands/core.rs +++ b/src/commands/core.rs @@ -164,9 +164,12 @@ pub fn home_dashboard(app: &mut App) -> CommandResult { // Quick actions section let _ = writeln!(stats, "\nQuick Actions"); let _ = writeln!(stats, "--------------------------------------------"); - let _ = writeln!(stats, "/deepseek - Dashboard & API links"); + let _ = writeln!(stats, "/links - Dashboard & API links"); let _ = writeln!(stats, "/skills - List available skills"); - let _ = writeln!(stats, "/config - Show current configuration"); + let _ = writeln!( + stats, + "/config - Open interactive configuration editor" + ); let _ = writeln!(stats, "/settings - Show persistent settings"); let _ = writeln!(stats, "/model - Switch or view model"); let _ = writeln!(stats, "/subagents - List sub-agent status"); @@ -246,6 +249,27 @@ mod tests { assert!(msg.contains("Usage: /clear")); } + #[test] + fn test_help_config_topic_uses_interactive_editor_text() { + let mut app = create_test_app(); + let result = help(&mut app, Some("config")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("config")); + assert!(msg.contains("Open interactive configuration editor")); + assert!(msg.contains("Usage: /config")); + } + + #[test] + fn test_help_links_topic_shows_aliases() { + let mut app = create_test_app(); + let result = help(&mut app, Some("links")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("links")); + assert!(msg.contains("Show DeepSeek dashboard and docs links")); + assert!(msg.contains("Usage: /links")); + assert!(msg.contains("Aliases: dashboard, api")); + } + #[test] fn test_help_pushes_overlay() { let mut app = create_test_app(); @@ -418,4 +442,20 @@ mod tests { assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}"); } } + + #[test] + fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() { + let mut app = create_test_app(); + let result = home_dashboard(&mut app); + let msg = result + .message + .expect("home dashboard should return message"); + assert!(msg.contains("/links - Dashboard & API links")); + assert!(msg.contains("/config - Open interactive configuration editor")); + assert!( + !msg.lines() + .any(|line| line.trim_start().starts_with("/set ")) + ); + assert!(!msg.contains("/deepseek")); + } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fdabb626..08d3bedc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -145,10 +145,10 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/subagents", }, CommandInfo { - name: "deepseek", + name: "links", aliases: &["dashboard", "api"], description: "Show DeepSeek dashboard and docs links", - usage: "/deepseek", + usage: "/links", }, CommandInfo { name: "home", @@ -203,15 +203,9 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "config", aliases: &[], - description: "Display current configuration", + description: "Open interactive configuration editor", usage: "/config", }, - CommandInfo { - name: "set", - aliases: &[], - description: "Modify a setting", - usage: "/set ", - }, CommandInfo { name: "yolo", aliases: &[], @@ -336,7 +330,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "models" => core::models(app), "queue" | "queued" => queue::queue(app, arg), "subagents" | "agents" => core::subagents(app), - "deepseek" | "dashboard" | "api" => core::deepseek_links(), + "links" | "dashboard" | "api" => core::deepseek_links(), "home" | "stats" | "overview" => core::home_dashboard(app), "note" => note::note(app, arg), "task" | "tasks" => task::task(app, arg), @@ -351,7 +345,6 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Config commands "config" => config::show_config(app), "settings" => config::show_settings(app), - "set" => config::set_config(app, arg), "yolo" => config::yolo(app), "normal" => config::normal_mode(app), "agent" => config::agent_mode(app), @@ -375,12 +368,25 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "skill" => skills::run_skill(app, arg), "review" => review::review(app, arg), + // Legacy command migrations (kept out of registry/autocomplete intentionally). + "set" => CommandResult::error( + "The /set command was retired. Use /config to edit settings and /settings to inspect current values.", + ), + "deepseek" => CommandResult::error( + "The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).", + ), + _ => CommandResult::error(format!( "Unknown command: /{command}. Type /help for available commands." )), } } +/// Update a configuration value programmatically (used by interactive UI views). +pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { + config::set_config_value(app, key, value, persist) +} + /// Get command info by name or alias pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> { let name = name.strip_prefix('/').unwrap_or(name); @@ -403,5 +409,87 @@ pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> { #[cfg(test)] mod tests { - // No unit tests currently required for command routing. + use super::*; + use crate::config::Config; + use crate::tui::app::{App, AppAction, TuiOptions}; + use std::path::PathBuf; + + fn create_test_app() -> App { + let options = TuiOptions { + model: "deepseek-reasoner".to_string(), + workspace: PathBuf::from("."), + allow_shell: false, + use_alt_screen: true, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }; + App::new(options, &Config::default()) + } + + #[test] + fn command_registry_contains_config_and_links_but_not_set_or_deepseek() { + assert!(COMMANDS.iter().any(|cmd| cmd.name == "config")); + assert!(COMMANDS.iter().any(|cmd| cmd.name == "links")); + assert!(!COMMANDS.iter().any(|cmd| cmd.name == "set")); + assert!(!COMMANDS.iter().any(|cmd| cmd.name == "deepseek")); + } + + #[test] + fn links_command_has_dashboard_and_api_aliases() { + let links = COMMANDS + .iter() + .find(|cmd| cmd.name == "links") + .expect("links command should exist"); + assert_eq!(links.aliases, &["dashboard", "api"]); + } + + #[test] + fn execute_config_opens_config_view_action() { + let mut app = create_test_app(); + let result = execute("/config", &mut app); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::OpenConfigView))); + } + + #[test] + fn execute_links_and_aliases_return_links_message() { + let mut app = create_test_app(); + for cmd in ["/links", "/dashboard", "/api"] { + let result = execute(cmd, &mut app); + let msg = result.message.expect("links commands should return text"); + assert!(msg.contains("https://platform.deepseek.com")); + assert!(result.action.is_none()); + } + } + + #[test] + fn removed_set_and_deepseek_commands_show_migration_hints() { + let mut app = create_test_app(); + let set_result = execute("/set model deepseek-reasoner", &mut app); + let set_msg = set_result + .message + .expect("legacy command should return an error message"); + assert!(set_msg.contains("The /set command was retired")); + assert!(set_msg.contains("/config")); + assert!(set_msg.contains("/settings")); + assert!(set_result.action.is_none()); + + let deepseek_result = execute("/deepseek", &mut app); + let deepseek_msg = deepseek_result + .message + .expect("legacy command should return an error message"); + assert!(deepseek_msg.contains("The /deepseek command was renamed")); + assert!(deepseek_msg.contains("/links")); + assert!(deepseek_msg.contains("/dashboard")); + assert!(deepseek_msg.contains("/api")); + assert!(deepseek_result.action.is_none()); + } } diff --git a/src/palette.rs b/src/palette.rs index 67863023..865ac22f 100644 --- a/src/palette.rs +++ b/src/palette.rs @@ -44,9 +44,17 @@ pub const DEEPSEEK_SLATE: Color = Color::Rgb( pub const DEEPSEEK_RED: Color = Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2); -pub const TEXT_PRIMARY: Color = Color::White; -pub const TEXT_MUTED: Color = Color::Rgb(192, 192, 192); // #C0C0C0 -pub const TEXT_DIM: Color = Color::Rgb(160, 160, 160); // #A0A0A0 +pub const TEXT_BODY: Color = Color::White; +pub const TEXT_SECONDARY: Color = Color::Rgb(192, 192, 192); // #C0C0C0 +pub const TEXT_HINT: Color = Color::Rgb(160, 160, 160); // #A0A0A0 +pub const TEXT_ACCENT: Color = DEEPSEEK_SKY; +pub const FOOTER_HINT: Color = Color::Rgb(180, 190, 208); // #B4BED0 +pub const SELECTION_TEXT: Color = Color::White; + +// Compatibility aliases for existing call sites. +pub const TEXT_PRIMARY: Color = TEXT_BODY; +pub const TEXT_MUTED: Color = TEXT_SECONDARY; +pub const TEXT_DIM: Color = TEXT_HINT; // New semantic colors for UI theming pub const BORDER_COLOR: Color = @@ -54,7 +62,7 @@ pub const BORDER_COLOR: Color = #[allow(dead_code)] pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; // #3578E5 #[allow(dead_code)] -pub const ACCENT_SECONDARY: Color = DEEPSEEK_SKY; // #6AAEF2 +pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; // #6AAEF2 #[allow(dead_code)] pub const BACKGROUND_LIGHT: Color = Color::Rgb(30, 47, 71); // #1E2F47 #[allow(dead_code)] @@ -91,19 +99,19 @@ pub fn ui_theme(name: &str) -> UiTheme { "dark" => UiTheme { name: "dark", composer_bg: DEEPSEEK_INK, - selection_bg: Color::Rgb(30, 52, 92), + selection_bg: SELECTION_BG, header_bg: DEEPSEEK_INK, }, "light" => UiTheme { name: "light", composer_bg: Color::Rgb(26, 38, 58), - selection_bg: Color::Rgb(38, 64, 112), + selection_bg: SELECTION_BG, header_bg: DEEPSEEK_SLATE, }, "whale" => UiTheme { name: "whale", composer_bg: DEEPSEEK_SLATE, - selection_bg: DEEPSEEK_NAVY, + selection_bg: SELECTION_BG, header_bg: DEEPSEEK_INK, }, _ => UiTheme { diff --git a/src/settings.rs b/src/settings.rs index 7863820c..35cfd5f2 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -164,9 +164,20 @@ impl Settings { self.max_input_history = max; } "default_model" | "model" => { - let Some(model) = canonical_model_name(value) else { + let trimmed = value.trim(); + if trimmed.is_empty() + || matches!( + trimmed.to_ascii_lowercase().as_str(), + "none" | "default" | "(default)" + ) + { + self.default_model = None; + return Ok(()); + } + + let Some(model) = canonical_model_name(trimmed) else { anyhow::bail!( - "Failed to update setting: invalid model '{value}'. Expected: deepseek-chat or deepseek-reasoner." + "Failed to update setting: invalid model '{value}'. Expected: deepseek-chat, deepseek-reasoner, or none/default." ); }; self.default_model = Some(model.to_string()); @@ -207,6 +218,7 @@ impl Settings { } /// Get available setting keys and their descriptions + #[allow(dead_code)] pub fn available_settings() -> Vec<(&'static str, &'static str)> { vec![ ("theme", "Color theme: default, dark, light"), diff --git a/src/tui/app.rs b/src/tui/app.rs index a46e4e07..bc4a8979 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -92,6 +92,7 @@ impl SidebarFocus { } #[must_use] + #[allow(dead_code)] pub fn as_setting(self) -> &'static str { match self { Self::Auto => "auto", @@ -230,6 +231,13 @@ pub struct TuiOptions { pub resume_session_id: Option, } +#[derive(Debug, Clone, Copy)] +struct YoloRestoreState { + allow_shell: bool, + trust_mode: bool, + approval_mode: ApprovalMode, +} + /// Global UI state for the TUI. #[allow(clippy::struct_excessive_bools)] pub struct App { @@ -298,6 +306,7 @@ pub struct App { pub hooks: HookExecutor, #[allow(dead_code)] pub yolo: bool, + yolo_restore: Option, // Clipboard handler pub clipboard: ClipboardHandler, // Tool approval session allowlist @@ -310,6 +319,7 @@ pub struct App { /// Trust mode - allow access outside workspace pub trust_mode: bool, /// Project documentation (AGENTS.md or CLAUDE.md) + #[allow(dead_code)] pub project_doc: Option, /// Plan state for tracking tasks pub plan_state: SharedPlanState, @@ -438,7 +448,7 @@ impl App { let TuiOptions { model, workspace, - allow_shell: _allow_shell, + allow_shell, use_alt_screen, max_subagents, skills_dir: global_skills_dir, @@ -481,6 +491,17 @@ impl App { preferred_mode }; + let yolo_restore = if initial_mode == AppMode::Yolo { + Some(YoloRestoreState { + allow_shell: config.allow_shell(), + trust_mode: false, + approval_mode: ApprovalMode::Suggest, + }) + } else { + None + }; + let allow_shell = allow_shell || initial_mode == AppMode::Yolo; + let history = if needs_onboarding { Vec::new() // No welcome message during onboarding } else { @@ -550,7 +571,7 @@ impl App { max_input_history, total_tokens: 0, total_conversation_tokens: 0, - allow_shell: true, + allow_shell, max_subagents, subagent_cache: Vec::new(), ui_theme, @@ -568,6 +589,7 @@ impl App { api_key_cursor: 0, hooks, yolo: initial_mode == AppMode::Yolo, + yolo_restore, clipboard: ClipboardHandler::new(), approval_session_approved: HashSet::new(), approval_mode: if matches!(initial_mode, AppMode::Yolo) { @@ -644,16 +666,30 @@ impl App { return false; } + let entering_yolo = mode == AppMode::Yolo && previous_mode != AppMode::Yolo; + let leaving_yolo = previous_mode == AppMode::Yolo && mode != AppMode::Yolo; + self.mode = mode; self.status_message = Some(format!("Switched to {} mode", mode.label())); - self.allow_shell = true; - self.trust_mode = matches!(mode, AppMode::Yolo); - self.yolo = matches!(mode, AppMode::Yolo); - self.approval_mode = if matches!(mode, AppMode::Yolo) { - ApprovalMode::Auto - } else { - ApprovalMode::Suggest - }; + + if entering_yolo { + self.yolo_restore = Some(YoloRestoreState { + allow_shell: self.allow_shell, + trust_mode: self.trust_mode, + approval_mode: self.approval_mode, + }); + self.allow_shell = true; + self.trust_mode = true; + self.approval_mode = ApprovalMode::Auto; + } else if leaving_yolo { + if let Some(restore) = self.yolo_restore.take() { + self.allow_shell = restore.allow_shell; + self.trust_mode = restore.trust_mode; + self.approval_mode = restore.approval_mode; + } + } + + self.yolo = mode == AppMode::Yolo; if mode != AppMode::Plan { self.plan_prompt_pending = false; self.plan_tool_used_in_turn = false; @@ -1201,6 +1237,7 @@ pub enum AppAction { model: String, workspace: PathBuf, }, + OpenConfigView, SendMessage(String), ListSubAgents, FetchModels, @@ -1372,6 +1409,53 @@ mod tests { assert!(app.allow_shell); } + #[test] + fn app_new_respects_allow_shell_option_when_not_yolo() { + let mut options = test_options(false); + options.allow_shell = false; + options.start_in_agent_mode = true; // avoid coupling to settings.default_mode + let app = App::new(options, &Config::default()); + assert!(!app.allow_shell); + } + + #[test] + fn set_mode_yolo_restores_previous_policies_on_exit() { + let mut options = test_options(false); + options.allow_shell = false; + options.start_in_agent_mode = true; // avoid coupling to settings.default_mode + let mut app = App::new(options, &Config::default()); + app.allow_shell = false; + app.trust_mode = false; + app.approval_mode = ApprovalMode::Never; + + app.set_mode(AppMode::Yolo); + assert!(app.allow_shell); + assert!(app.trust_mode); + assert_eq!(app.approval_mode, ApprovalMode::Auto); + + app.set_mode(AppMode::Agent); + assert!(!app.allow_shell); + assert!(!app.trust_mode); + assert_eq!(app.approval_mode, ApprovalMode::Never); + } + + #[test] + fn leaving_yolo_after_startup_restores_baseline_policies() { + let mut config = Config::default(); + config.allow_shell = Some(false); + + let mut app = App::new(test_options(true), &config); + assert_eq!(app.mode, AppMode::Yolo); + assert!(app.allow_shell); + assert!(app.trust_mode); + assert_eq!(app.approval_mode, ApprovalMode::Auto); + + app.set_mode(AppMode::Agent); + assert!(!app.allow_shell); + assert!(!app.trust_mode); + assert_eq!(app.approval_mode, ApprovalMode::Suggest); + } + #[test] fn test_mark_history_updated() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/src/tui/command_palette.rs b/src/tui/command_palette.rs index 96fc256b..e3c0e481 100644 --- a/src/tui/command_palette.rs +++ b/src/tui/command_palette.rs @@ -474,7 +474,7 @@ impl ModalView for CommandPaletteView { let style = if is_selected { Style::default() - .fg(palette::DEEPSEEK_SKY) + .fg(palette::SELECTION_TEXT) .bg(palette::SELECTION_BG) } else { Style::default().fg(palette::TEXT_PRIMARY) @@ -521,6 +521,7 @@ impl ModalView for CommandPaletteView { #[cfg(test)] mod tests { use super::*; + use std::path::Path; fn palette_entry( section: PaletteSection, @@ -634,4 +635,19 @@ mod tests { assert_eq!(view.filtered.len(), 1); assert_eq!(view.entries[view.filtered[0]].label, "skill:search"); } + + #[test] + fn command_palette_command_entries_include_links_and_config_but_not_removed_commands() { + let entries = build_entries(Path::new("."), Path::new(".")); + let command_labels = entries + .iter() + .filter(|entry| entry.section == PaletteSection::Command) + .map(|entry| entry.label.as_str()) + .collect::>(); + + assert!(command_labels.contains(&"/config")); + assert!(command_labels.contains(&"/links")); + assert!(!command_labels.contains(&"/set")); + assert!(!command_labels.contains(&"/deepseek")); + } } diff --git a/src/tui/session_picker.rs b/src/tui/session_picker.rs index 91d4bf7a..62b3e4fc 100644 --- a/src/tui/session_picker.rs +++ b/src/tui/session_picker.rs @@ -1,5 +1,6 @@ //! Session resume picker view for the TUI. +use std::cell::Cell; use std::collections::HashMap; use chrono::{DateTime, Local}; @@ -42,6 +43,8 @@ pub struct SessionPickerView { sessions: Vec, filtered: Vec, selected: usize, + list_scroll: Cell, + list_visible_rows: Cell, search_input: String, search_mode: bool, sort_mode: SortMode, @@ -61,6 +64,8 @@ impl SessionPickerView { sessions, filtered: Vec::new(), selected: 0, + list_scroll: Cell::new(0), + list_visible_rows: Cell::new(8), search_input: String::new(), search_mode: false, sort_mode: SortMode::Recent, @@ -104,6 +109,7 @@ impl SessionPickerView { if self.selected >= self.filtered.len() { self.selected = 0; } + self.ensure_selected_visible(); self.refresh_preview(); } @@ -116,9 +122,34 @@ impl SessionPickerView { let len = self.filtered.len() as isize; let next = (self.selected as isize + delta).clamp(0, len - 1) as usize; self.selected = next; + self.ensure_selected_visible(); self.refresh_preview(); } + fn update_list_viewport(&self, visible_rows: usize) { + self.list_visible_rows.set(visible_rows.max(1)); + self.ensure_selected_visible(); + } + + fn ensure_selected_visible(&self) { + if self.filtered.is_empty() { + self.list_scroll.set(0); + return; + } + + let visible_rows = self.list_visible_rows.get().max(1); + let max_scroll = self.filtered.len().saturating_sub(visible_rows); + let mut scroll = self.list_scroll.get().min(max_scroll); + + if self.selected < scroll { + scroll = self.selected; + } else if self.selected >= scroll.saturating_add(visible_rows) { + scroll = self.selected.saturating_add(1).saturating_sub(visible_rows); + } + + self.list_scroll.set(scroll.min(max_scroll)); + } + fn selected_session(&self) -> Option<&SessionMetadata> { self.filtered.get(self.selected) } @@ -310,14 +341,29 @@ impl ModalView for SessionPickerView { Clear.render(popup_area, buf); let chunks = Layout::default() - .direction(Direction::Horizontal) + .direction(if popup_area.width < 95 { + Direction::Vertical + } else { + Direction::Horizontal + }) .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) .split(popup_area); + let list_inner = modal_block(" Sessions ").inner(chunks[0]); + let header_rows = 1 + usize::from(self.confirm_delete || self.status.is_some()); + let footer_rows = usize::from(!self.filtered.is_empty()); + let visible_rows = usize::from(list_inner.height) + .saturating_sub(header_rows + footer_rows) + .max(1); + self.update_list_viewport(visible_rows); + let list_scroll = self.list_scroll.get(); + let list_lines = build_list_lines( &self.filtered, self.selected, - popup_area.width, + list_inner.width, + list_scroll, + visible_rows, self.search_mode, &self.search_input, self.sort_label(), @@ -329,10 +375,11 @@ impl ModalView for SessionPickerView { .wrap(Wrap { trim: false }); list.render(chunks[0], buf); + let preview_inner = modal_block(" Preview ").inner(chunks[1]); let preview_lines = format_preview( &self.current_preview, - chunks[1].width.saturating_sub(2), - chunks[1].height as usize, + preview_inner.width, + preview_inner.height as usize, ); let preview = Paragraph::new(preview_lines) @@ -347,6 +394,8 @@ fn build_list_lines( sessions: &[SessionMetadata], selected: usize, width: u16, + scroll: usize, + visible_rows: usize, search_mode: bool, search_input: &str, sort_label: &str, @@ -386,12 +435,12 @@ fn build_list_lines( return lines; } - for (idx, session) in sessions.iter().enumerate() { + for (idx, session) in sessions.iter().enumerate().skip(scroll).take(visible_rows) { let mut line = format_session_line(session); line = truncate(&line, width); let style = if idx == selected { Style::default() - .fg(palette::DEEPSEEK_SKY) + .fg(palette::SELECTION_TEXT) .bg(palette::SELECTION_BG) } else { Style::default().fg(palette::TEXT_PRIMARY) @@ -399,6 +448,18 @@ fn build_list_lines( lines.push(Line::from(Span::styled(line, style))); } + if sessions.len() > visible_rows { + let start = scroll.saturating_add(1); + let end = (scroll + visible_rows).min(sessions.len()); + lines.push(Line::from(Span::styled( + truncate( + &format!("Showing {start}-{end} / {}", sessions.len()), + width, + ), + Style::default().fg(palette::TEXT_DIM), + ))); + } + lines } @@ -530,3 +591,78 @@ fn is_subsequence(needle: &str, haystack: &str) -> bool { } false } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use unicode_width::UnicodeWidthStr; + + fn test_session(idx: usize, title: &str) -> SessionMetadata { + SessionMetadata { + id: format!("session-{idx:02}"), + title: title.to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + message_count: idx + 1, + total_tokens: 100, + model: "deepseek-reasoner".to_string(), + workspace: std::path::PathBuf::from("/tmp"), + mode: Some("agent".to_string()), + } + } + + #[test] + fn build_list_lines_truncates_to_list_pane_width() { + let sessions = vec![test_session( + 1, + "A very long title that should be truncated by the list pane width", + )]; + let width = 24; + let lines = build_list_lines(&sessions, 0, width, 0, 5, false, "", "recent", false, None); + + for line in lines { + let rendered_width: usize = line.spans.iter().map(|span| span.content.width()).sum(); + assert!( + rendered_width <= width as usize, + "line width {} exceeded pane width {}", + rendered_width, + width + ); + } + } + + #[test] + fn ensure_selected_visible_updates_scroll_window() { + let sessions = (0..10) + .map(|idx| test_session(idx, &format!("Session {idx}"))) + .collect::>(); + + let mut view = SessionPickerView { + sessions: sessions.clone(), + filtered: sessions, + selected: 0, + list_scroll: Cell::new(0), + list_visible_rows: Cell::new(3), + search_input: String::new(), + search_mode: false, + sort_mode: SortMode::Recent, + preview_cache: HashMap::new(), + current_preview: Vec::new(), + confirm_delete: false, + status: None, + }; + + view.selected = 6; + view.ensure_selected_visible(); + assert_eq!(view.list_scroll.get(), 4); + + view.selected = 1; + view.ensure_selected_visible(); + assert_eq!(view.list_scroll.get(), 1); + + view.selected = 9; + view.ensure_selected_visible(); + assert_eq!(view.list_scroll.get(), 7); + } +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 2121cd43..75460c52 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -77,7 +77,7 @@ use super::history::{ ToolStatus, ViewImageCell, WebSearchCell, history_cells_from_message, summarize_mcp_output, summarize_tool_args, summarize_tool_output, }; -use super::views::{HelpView, ModalKind, ViewEvent}; +use super::views::{ConfigView, HelpView, ModalKind, ViewEvent}; use super::widgets::{ ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable, slash_completion_hints, }; @@ -85,6 +85,9 @@ use super::widgets::{ // === Constants === const MAX_QUEUED_PREVIEW: usize = 3; +const SLASH_MENU_LIMIT: usize = 6; +const MIN_CHAT_HEIGHT: u16 = 3; +const MIN_COMPOSER_HEIGHT: u16 = 1; const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 85.0; const UI_IDLE_POLL_MS: u64 = 33; @@ -94,6 +97,13 @@ const UI_TYPING_INDICATOR_MS: u64 = 120; const UI_STATUS_ANIMATION_MS: u64 = UI_DEEPSEEK_SQUIGGLE_MS; const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15; +#[derive(Debug, Clone, PartialEq, Eq)] +struct StatusLayoutPlan { + status_height: u16, + queued_preview: Vec, + queued_compacted: bool, +} + /// Run the interactive TUI event loop. /// /// # Examples @@ -1331,6 +1341,11 @@ async fn run_event_loop( .send(Op::SetCompaction { config: compaction }) .await; } + AppAction::OpenConfigView => { + if app.view_stack.top_kind() != Some(ModalKind::Config) { + app.view_stack.push(ConfigView::new_for_app(app)); + } + } AppAction::CompactContext => { app.status_message = Some("Compacting context...".to_string()); @@ -2084,6 +2099,87 @@ async fn handle_plan_choice( Ok(true) } +fn chat_height_floor(body_height: u16) -> u16 { + body_height + .saturating_sub(MIN_COMPOSER_HEIGHT) + .clamp(1, MIN_CHAT_HEIGHT) +} + +fn status_row_budget( + terminal_height: u16, + header_height: u16, + footer_height: u16, + composer_height: u16, +) -> u16 { + let body_height = terminal_height.saturating_sub(header_height + footer_height); + let chat_floor = chat_height_floor(body_height); + body_height.saturating_sub(composer_height.max(MIN_COMPOSER_HEIGHT) + chat_floor) +} + +fn compact_queued_preview(app: &App, preview_rows_budget: usize) -> (Vec, bool) { + if app.queued_message_count() == 0 || preview_rows_budget == 0 { + return (Vec::new(), false); + } + + let preview_rows_budget = preview_rows_budget.min(MAX_QUEUED_PREVIEW); + let queue_count = app.queued_message_count(); + let mut previews = app.queued_message_previews(preview_rows_budget); + if previews.len() > preview_rows_budget { + previews.truncate(preview_rows_budget); + if let Some(last) = previews.last_mut() { + let shown_count = preview_rows_budget.saturating_sub(1); + let hidden_count = queue_count.saturating_sub(shown_count); + *last = format!("+{hidden_count} more"); + } + } + + let shown_count = previews + .iter() + .filter(|line| !line.starts_with('+')) + .count(); + (previews, queue_count > shown_count) +} + +fn compute_status_layout( + app: &App, + terminal_height: u16, + composer_height: u16, +) -> StatusLayoutPlan { + let status_budget = status_row_budget(terminal_height, 1, 1, composer_height); + if status_budget == 0 { + return StatusLayoutPlan { + status_height: 0, + queued_preview: Vec::new(), + queued_compacted: app.queued_message_count() > 0, + }; + } + + let fixed_rows = usize::from(app.is_loading) + usize::from(app.queued_draft.is_some()); + let queue_rows_budget = usize::from(status_budget).saturating_sub(fixed_rows); + + let (queued_preview, preview_compacted) = if queue_rows_budget > 0 { + compact_queued_preview(app, queue_rows_budget.saturating_sub(1)) + } else { + (Vec::new(), app.queued_message_count() > 0) + }; + + let queue_rows = if app.queued_message_count() > 0 && queue_rows_budget > 0 { + 1 + queued_preview.len() + } else { + 0 + }; + let requested_rows = fixed_rows + queue_rows; + let status_height = + u16::try_from(requested_rows.min(usize::from(status_budget))).unwrap_or(status_budget); + let queued_compacted = preview_compacted || (app.queued_message_count() > 0 && queue_rows == 0); + + StatusLayoutPlan { + status_height, + queued_preview, + queued_compacted, + } +} + fn render(f: &mut Frame, app: &mut App) { let size = f.area(); @@ -2099,33 +2195,35 @@ fn render(f: &mut Frame, app: &mut App) { let header_height = 1; let footer_height = 1; - let queued_preview = app.queued_message_previews(MAX_QUEUED_PREVIEW); - let queued_lines = if queued_preview.is_empty() { - 0 - } else { - queued_preview.len() + 1 - }; - let editing_lines = usize::from(app.queued_draft.is_some()); - let status_lines = usize::from(app.is_loading); - let status_height = - u16::try_from(status_lines + queued_lines + editing_lines).unwrap_or(u16::MAX); + let body_height = size.height.saturating_sub(header_height + footer_height); let prompt = prompt_for_mode(app.mode); - let available_height = size - .height - .saturating_sub(header_height + footer_height + status_height); + let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT); + let composer_for_budget = { + let max_composer_height = body_height + .saturating_sub(chat_height_floor(body_height)) + .max(MIN_COMPOSER_HEIGHT); + let composer_widget = + ComposerWidget::new(app, prompt, max_composer_height, &slash_menu_entries); + composer_widget.desired_height(size.width) + }; + let status_layout = compute_status_layout(app, size.height, composer_for_budget); + let composer_max_height = body_height + .saturating_sub(status_layout.status_height + chat_height_floor(body_height)) + .max(MIN_COMPOSER_HEIGHT); let composer_height = { - let composer_widget = ComposerWidget::new(app, prompt, available_height); + let composer_widget = + ComposerWidget::new(app, prompt, composer_max_height, &slash_menu_entries); composer_widget.desired_height(size.width) }; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(header_height), // Header - Constraint::Min(1), // Chat area - Constraint::Length(status_height), // Status indicator - Constraint::Length(composer_height), // Composer - Constraint::Length(footer_height), // Footer + Constraint::Length(header_height), // Header + Constraint::Min(1), // Chat area + Constraint::Length(status_layout.status_height), // Status indicator + Constraint::Length(composer_height), // Composer + Constraint::Length(footer_height), // Footer ]) .split(size); @@ -2177,13 +2275,21 @@ fn render(f: &mut Frame, app: &mut App) { } // Render status - if status_height > 0 { - render_status_indicator(f, chunks[2], app, &queued_preview); + if status_layout.status_height > 0 { + render_status_indicator( + f, + chunks[2], + app, + app.queued_message_count(), + &status_layout.queued_preview, + status_layout.queued_compacted, + ); } // Render composer let cursor_pos = { - let composer_widget = ComposerWidget::new(app, prompt, available_height); + let composer_widget = + ComposerWidget::new(app, prompt, composer_max_height, &slash_menu_entries); let buf = f.buffer_mut(); composer_widget.render(chunks[3], buf); composer_widget.cursor_pos(chunks[3]) @@ -2236,7 +2342,7 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) { } let content_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::new(); + let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); match app.plan_state.try_lock() { Ok(plan) => { @@ -2311,7 +2417,7 @@ fn render_sidebar_todos(f: &mut Frame, area: Rect, app: &App) { } let content_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::new(); + let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); match app.todos.try_lock() { Ok(todos) => { @@ -2380,7 +2486,7 @@ fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) { } let content_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::new(); + let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); if let Some(turn_id) = app.runtime_turn_id.as_ref() { let status = app @@ -2466,7 +2572,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { } let content_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::new(); + let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); if app.subagent_cache.is_empty() { lines.push(Line::from(Span::styled( @@ -2692,6 +2798,33 @@ async fn handle_view_events(app: &mut App, engine_handle: &EngineHandle, events: title )); } + ViewEvent::ConfigUpdated { + key, + value, + persist, + } => { + let result = commands::set_config_value(app, &key, &value, persist); + if let Some(msg) = result.message { + app.add_message(HistoryCell::System { content: msg }); + } + + if let Some(action) = result.action { + match action { + AppAction::UpdateCompaction(compaction) => { + let _ = engine_handle + .send(Op::SetCompaction { config: compaction }) + .await; + } + AppAction::OpenConfigView => {} + _ => {} + } + } + + if app.view_stack.top_kind() == Some(ModalKind::Config) { + app.view_stack.pop(); + app.view_stack.push(ConfigView::new_for_app(app)); + } + } ViewEvent::SubAgentsRefresh => { app.status_message = Some("Refreshing sub-agents...".to_string()); let _ = engine_handle.send(Op::ListSubAgents).await; @@ -2889,8 +3022,15 @@ fn resume_terminal( Ok(()) } -fn render_status_indicator(f: &mut Frame, area: Rect, app: &App, queued: &[String]) { - let mut lines = Vec::new(); +fn render_status_indicator( + f: &mut Frame, + area: Rect, + app: &App, + queued_count: usize, + queued: &[String], + queued_compacted: bool, +) { + let mut lines = Vec::with_capacity(1 + queued.len() + usize::from(app.queued_draft.is_some())); if app.is_loading { let header = if app.show_thinking { @@ -2956,10 +3096,13 @@ fn render_status_indicator(f: &mut Frame, area: Rect, app: &App, queued: &[Strin ])); } - if !queued.is_empty() { + if queued_count > 0 { let available = area.width as usize; - let queued_count = app.queued_message_count(); - let header = format!("Queued ({queued_count}) - /queue edit "); + let header = if queued_compacted { + format!("Queued ({queued_count}) [compact] - /queue edit ") + } else { + format!("Queued ({queued_count}) - /queue edit ") + }; let header = truncate_line_to_width(&header, available.max(1)); lines.push(Line::from(vec![Span::styled( header, @@ -3033,7 +3176,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { { right_extras.push(Span::styled( format!(" {scroll_pct}% "), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(palette::FOOTER_HINT), )); } @@ -3041,7 +3184,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { if app.transcript_selection.is_active() { right_extras.push(Span::styled( " [SEL] ", - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(palette::FOOTER_HINT), )); } @@ -3059,7 +3202,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // 3. Left side content (status toast or standard footer) let active_status = app.active_status_toast(); - let left_spans = if let Some(toast) = active_status { + let left_spans = if let Some(toast) = active_status.as_ref() { let max_left = available_width .saturating_sub(right_width) .saturating_sub(1) @@ -3089,7 +3232,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { if let Some(workspace_name) = app.workspace.file_name() { if let Some(name) = workspace_name.to_str() { let ws = format!("{} ", name); - spans.push(Span::styled(ws, Style::default().fg(palette::TEXT_DIM))); + spans.push(Span::styled(ws, Style::default().fg(palette::FOOTER_HINT))); } } @@ -3099,7 +3242,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { if !context.is_empty() { spans.push(Span::styled( format!("{context} "), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(palette::FOOTER_HINT), )); } } @@ -3108,7 +3251,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { if let Some(ref sid) = app.current_session_id { spans.push(Span::styled( format!("session:{} ", &sid[..8.min(sid.len())]), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(palette::FOOTER_HINT), )); } @@ -3117,14 +3260,14 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { let tokens_k = app.total_conversation_tokens as f64 / 1000.0; spans.push(Span::styled( format!("{tokens_k:.1}k tokens "), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(palette::FOOTER_HINT), )); } // Help hint spans.push(Span::styled( "F1 help", - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(palette::FOOTER_HINT), )); spans @@ -3142,7 +3285,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { all_spans.extend(right_spans); } else { // Fallback for narrow screens - let simple_left = if let Some(toast) = app.active_status_toast() { + let simple_left = if let Some(toast) = active_status.as_ref() { let max_left = available_width.saturating_sub(10).saturating_sub(1).max(1); let truncated = truncate_line_to_width(&toast.text, max_left); vec![Span::styled( @@ -3152,7 +3295,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { } else { vec![Span::styled( "F1 help", - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(palette::FOOTER_HINT), )] }; let bar_filled_narrow = "█".repeat(filled.min(5)); diff --git a/src/tui/ui/tests.rs b/src/tui/ui/tests.rs index a46997ab..63e499c4 100644 --- a/src/tui/ui/tests.rs +++ b/src/tui/ui/tests.rs @@ -295,13 +295,64 @@ fn visible_slash_menu_entries_respects_hide_flag() { assert!(hidden_entries.is_empty()); } +#[test] +fn visible_slash_menu_entries_excludes_removed_commands() { + let mut app = create_test_app(); + app.input = "/".to_string(); + + let entries = visible_slash_menu_entries(&app, 128); + assert!(entries.iter().any(|entry| entry == "/config")); + assert!(entries.iter().any(|entry| entry == "/links")); + assert!(!entries.iter().any(|entry| entry == "/set")); + assert!(!entries.iter().any(|entry| entry == "/deepseek")); +} + #[test] fn apply_slash_menu_selection_appends_space_for_arg_commands() { let mut app = create_test_app(); - let entries = vec!["/set".to_string(), "/settings".to_string()]; + let entries = vec!["/model".to_string(), "/settings".to_string()]; app.slash_menu_selected = 0; assert!(apply_slash_menu_selection(&mut app, &entries, true)); - assert_eq!(app.input, "/set "); + assert_eq!(app.input, "/model "); +} + +#[test] +fn status_layout_budget_preserves_chat_and_composer_on_tiny_heights() { + let mut app = create_test_app(); + app.is_loading = true; + for idx in 0..5 { + app.queue_message(crate::tui::app::QueuedMessage::new( + format!("queued message {idx}"), + None, + )); + } + + let layout = compute_status_layout(&app, 9, 3); + assert_eq!(layout.status_height, 1); + assert!(layout.queued_preview.is_empty()); + assert!(layout.queued_compacted); +} + +#[test] +fn compact_queued_preview_summarizes_hidden_messages() { + let mut app = create_test_app(); + for idx in 0..4 { + app.queue_message(crate::tui::app::QueuedMessage::new( + format!("queued message {idx}"), + None, + )); + } + + let (one_row, compacted_one_row) = compact_queued_preview(&app, 1); + assert_eq!(one_row, vec!["+4 more".to_string()]); + assert!(compacted_one_row); + + let (two_rows, compacted_two_rows) = compact_queued_preview(&app, 2); + assert_eq!( + two_rows, + vec!["queued message 0".to_string(), "+3 more".to_string()] + ); + assert!(compacted_two_rows); } #[test] diff --git a/src/tui/user_input.rs b/src/tui/user_input.rs index 5ddd100e..65cf61af 100644 --- a/src/tui/user_input.rs +++ b/src/tui/user_input.rs @@ -216,7 +216,7 @@ impl ModalView for UserInputView { lines.push(Line::from(Span::styled( content, Style::default() - .fg(palette::DEEPSEEK_SKY) + .fg(palette::SELECTION_TEXT) .bg(palette::SELECTION_BG) .bold(), ))); @@ -245,7 +245,7 @@ impl ModalView for UserInputView { lines.push(Line::from(Span::styled( content, Style::default() - .fg(palette::DEEPSEEK_SKY) + .fg(palette::SELECTION_TEXT) .bg(palette::SELECTION_BG) .bold(), ))); diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index 47bb4daf..3a93cfc4 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -1,14 +1,17 @@ -use crossterm::event::KeyEvent; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{buffer::Buffer, layout::Rect}; +use std::cell::Cell; use std::fmt; use std::path::{Path, PathBuf}; use crate::palette; +use crate::settings::Settings; use crate::tools::UserInputResponse; use crate::tools::spec::ApprovalRequirement; use crate::tools::spec::ToolCapability; use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType}; use crate::tools::{ToolContext, ToolRegistryBuilder}; +use crate::tui::app::App; use crate::tui::approval::{ElevationOption, ReviewDecision}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -22,6 +25,7 @@ pub enum ModalKind { SubAgents, Pager, SessionPicker, + Config, } #[derive(Debug, Clone)] @@ -51,6 +55,11 @@ pub enum ViewEvent { UserInputCancelled { tool_id: String, }, + ConfigUpdated { + key: String, + value: String, + persist: bool, + }, PlanPromptSelected { option: usize, }, @@ -181,10 +190,10 @@ const HELP_COMMAND_SECTION_ORDER: [&str; 7] = [ fn help_command_section(name: &str) -> &'static str { match name { - "help" | "clear" | "exit" | "model" | "models" | "home" | "deepseek" => "Core", + "help" | "clear" | "exit" | "model" | "models" | "home" | "links" => "Core", "normal" | "agent" | "yolo" | "plan" | "trust" | "logout" => "Modes", "save" | "sessions" | "load" | "export" | "compact" | "queue" => "Session", - "config" | "set" | "settings" => "Configuration", + "config" | "settings" => "Configuration", "task" | "skills" | "skill" | "subagents" | "review" => "Workflows", "note" | "cost" | "context" | "system" | "undo" | "retry" => "Planning", "init" => "Debug", @@ -218,6 +227,571 @@ fn grouped_commands( .collect() } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfigScope { + Session, + Saved, +} + +impl ConfigScope { + fn label(self) -> &'static str { + match self { + ConfigScope::Session => "SESSION", + ConfigScope::Saved => "SAVED", + } + } + + fn persist(self) -> bool { + matches!(self, ConfigScope::Saved) + } +} + +#[derive(Debug, Clone)] +struct ConfigRow { + key: String, + value: String, + editable: bool, + scope: ConfigScope, +} + +#[derive(Debug, Clone)] +struct ConfigEdit { + key: String, + original_value: String, + buffer: Vec, + cursor: usize, + select_all: bool, + scope: ConfigScope, +} + +pub struct ConfigView { + rows: Vec, + selected: usize, + scroll: usize, + editing: Option, + status: Option, + last_visible_rows: Cell, +} + +impl ConfigView { + pub fn new_for_app(app: &App) -> Self { + let settings = Settings::load().unwrap_or_else(|_| Settings::default()); + let rows = vec![ + ConfigRow { + key: "model".to_string(), + value: app.model.clone(), + editable: true, + scope: ConfigScope::Session, + }, + ConfigRow { + key: "approval_mode".to_string(), + value: app.approval_mode.label().to_string(), + editable: true, + scope: ConfigScope::Session, + }, + ConfigRow { + key: "auto_compact".to_string(), + value: settings.auto_compact.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "show_thinking".to_string(), + value: settings.show_thinking.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "show_tool_details".to_string(), + value: settings.show_tool_details.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "default_mode".to_string(), + value: settings.default_mode.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "theme".to_string(), + value: settings.theme.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "sidebar_width".to_string(), + value: settings.sidebar_width_percent.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "sidebar_focus".to_string(), + value: settings.sidebar_focus.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "max_history".to_string(), + value: settings.max_input_history.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "default_model".to_string(), + value: settings + .default_model + .as_deref() + .unwrap_or("(default)") + .to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ]; + + Self { + rows, + selected: 0, + scroll: 0, + editing: None, + status: None, + last_visible_rows: Cell::new(0), + } + } + + fn visible_rows_cached(&self) -> usize { + let cached = self.last_visible_rows.get(); + if cached == 0 { 8 } else { cached } + } + + fn adjust_scroll(&mut self, visible_rows: usize) { + if self.rows.is_empty() { + self.selected = 0; + self.scroll = 0; + return; + } + + let max = self.rows.len().saturating_sub(1); + self.selected = self.selected.min(max); + + if self.selected < self.scroll { + self.scroll = self.selected; + } + + let visible_rows = visible_rows.max(1); + if self.selected >= self.scroll + visible_rows { + self.scroll = self.selected.saturating_sub(visible_rows.saturating_sub(1)); + } + } + + fn move_selection(&mut self, delta: isize) { + if self.rows.is_empty() { + return; + } + + let max = self.rows.len().saturating_sub(1); + let next = if delta.is_negative() { + self.selected.saturating_sub(delta.unsigned_abs()) + } else { + (self.selected + delta as usize).min(max) + }; + + self.selected = next; + let visible_rows = self.visible_rows_cached(); + self.adjust_scroll(visible_rows); + } + + fn handle_editing_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Esc => { + self.editing = None; + self.status = Some("Edit cancelled".to_string()); + ViewAction::None + } + KeyCode::Enter => { + let Some(edit) = self.editing.take() else { + return ViewAction::None; + }; + let submitted = edit.buffer.iter().collect::(); + let value = submitted.trim().to_string(); + ViewAction::Emit(ViewEvent::ConfigUpdated { + key: edit.key, + value, + persist: edit.scope.persist(), + }) + } + KeyCode::Backspace => { + if let Some(edit) = self.editing.as_mut() { + if edit.select_all { + edit.buffer.clear(); + edit.cursor = 0; + edit.select_all = false; + } else if edit.cursor > 0 { + edit.cursor = edit.cursor.saturating_sub(1); + edit.buffer.remove(edit.cursor); + } + } + ViewAction::None + } + KeyCode::Delete => { + if let Some(edit) = self.editing.as_mut() { + if edit.select_all { + edit.buffer.clear(); + edit.cursor = 0; + edit.select_all = false; + } else if edit.cursor < edit.buffer.len() { + edit.buffer.remove(edit.cursor); + } + } + ViewAction::None + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(edit) = self.editing.as_mut() { + edit.buffer.clear(); + edit.cursor = 0; + edit.select_all = false; + } + ViewAction::None + } + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(edit) = self.editing.as_mut() { + edit.cursor = edit.buffer.len(); + edit.select_all = true; + } + ViewAction::None + } + KeyCode::Left => { + if let Some(edit) = self.editing.as_mut() { + if edit.select_all { + edit.cursor = 0; + edit.select_all = false; + } else { + edit.cursor = edit.cursor.saturating_sub(1); + } + } + ViewAction::None + } + KeyCode::Right => { + if let Some(edit) = self.editing.as_mut() { + if edit.select_all { + edit.cursor = edit.buffer.len(); + edit.select_all = false; + } else { + edit.cursor = (edit.cursor + 1).min(edit.buffer.len()); + } + } + ViewAction::None + } + KeyCode::Home => { + if let Some(edit) = self.editing.as_mut() { + edit.cursor = 0; + edit.select_all = false; + } + ViewAction::None + } + KeyCode::End => { + if let Some(edit) = self.editing.as_mut() { + edit.cursor = edit.buffer.len(); + edit.select_all = false; + } + ViewAction::None + } + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() => + { + if let Some(edit) = self.editing.as_mut() { + if edit.select_all { + edit.buffer.clear(); + edit.cursor = 0; + edit.select_all = false; + } + edit.buffer.insert(edit.cursor, ch); + edit.cursor += 1; + } + ViewAction::None + } + _ => ViewAction::None, + } + } + + fn start_edit(&mut self) { + let Some(row) = self.rows.get(self.selected) else { + return; + }; + let key = row.key.clone(); + let original_value = row.value.clone(); + let initial_value = if key == "default_model" && original_value == "(default)" { + String::new() + } else { + original_value.clone() + }; + + let buffer: Vec = initial_value.chars().collect(); + self.editing = Some(ConfigEdit { + key, + original_value, + cursor: buffer.len(), + buffer, + select_all: true, + scope: row.scope, + }); + self.status = None; + } +} + +fn config_hint_for_key(key: &str) -> &'static str { + match key { + "model" => { + "deepseek-chat | deepseek-reasoner (aliases: deepseek-v3, deepseek-v3.2, deepseek-r1)" + } + "approval_mode" => "auto | suggest | never", + "auto_compact" | "show_thinking" | "show_tool_details" => "on/off, true/false, yes/no, 1/0", + "default_mode" => "agent | plan | yolo", + "theme" => "default | dark | light | whale", + "sidebar_width" => "10..=50", + "sidebar_focus" => "auto | plan | todos | tasks | agents", + "max_history" => "integer (0 allowed)", + "default_model" => "deepseek-chat | deepseek-reasoner | none/default", + _ => "", + } +} + +fn render_config_editor_value_line(edit: &ConfigEdit) -> ratatui::text::Line<'static> { + use ratatui::{ + prelude::Stylize, + style::Style, + text::{Line, Span}, + }; + + let mut spans = Vec::new(); + spans.push(Span::styled( + "New: ", + Style::default().fg(palette::TEXT_MUTED), + )); + + let cursor_style = Style::default() + .fg(palette::DEEPSEEK_INK) + .bg(palette::DEEPSEEK_SKY) + .bold(); + let selected_style = Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG); + + if edit.select_all && !edit.buffer.is_empty() { + let text = edit.buffer.iter().collect::(); + spans.push(Span::styled(text, selected_style)); + spans.push(Span::styled(" ", cursor_style)); + return Line::from(spans); + } + + let before = edit.buffer.iter().take(edit.cursor).collect::(); + spans.push(Span::raw(before)); + if edit.cursor < edit.buffer.len() { + let ch = edit.buffer[edit.cursor]; + spans.push(Span::styled(ch.to_string(), cursor_style)); + let after = edit + .buffer + .iter() + .skip(edit.cursor.saturating_add(1)) + .collect::(); + spans.push(Span::raw(after)); + } else { + spans.push(Span::styled(" ", cursor_style)); + } + + Line::from(spans) +} + +impl ModalView for ConfigView { + fn kind(&self) -> ModalKind { + ModalKind::Config + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + if self.editing.is_some() { + return self.handle_editing_key(key); + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, + KeyCode::Up | KeyCode::Char('k') => { + self.move_selection(-1); + ViewAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_selection(1); + ViewAction::None + } + KeyCode::PageUp => { + self.move_selection(-5); + ViewAction::None + } + KeyCode::PageDown => { + self.move_selection(5); + ViewAction::None + } + KeyCode::Char('e') | KeyCode::Char('E') | KeyCode::Enter => { + if self.rows.get(self.selected).is_some_and(|row| row.editable) { + self.start_edit(); + } + ViewAction::None + } + _ => ViewAction::None, + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + use ratatui::{ + prelude::Stylize, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, + }; + + let popup_width = 84.min(area.width.saturating_sub(4)); + let popup_height = 22.min(area.height.saturating_sub(4)); + + let popup_area = Rect { + x: (area.width - popup_width) / 2, + y: (area.height - popup_height) / 2, + width: popup_width, + height: popup_height, + }; + + Clear.render(popup_area, buf); + + let base_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)); + + let inner = base_block.inner(popup_area); + let (lines, footer) = if let Some(edit) = self.editing.as_ref() { + let mut lines: Vec = Vec::new(); + lines.push(Line::from(vec![Span::styled( + format!("Edit {}", edit.key), + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("Scope: ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(edit.scope.label()), + ])); + lines.push(Line::from(vec![ + Span::styled("Current: ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(truncate_view_text(&edit.original_value, 60)), + ])); + lines.push(Line::from("")); + lines.push(render_config_editor_value_line(edit)); + lines.push(Line::from("")); + let hint = config_hint_for_key(&edit.key); + if !hint.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Hint: ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(hint), + ])); + } + ( + lines, + " Enter=apply, Esc=cancel, Ctrl+U=clear, Ctrl+A=all, \u{2190}/\u{2192}=move " + .to_string(), + ) + } else { + let content_height = usize::from(inner.height); + let header_lines = 4usize; + let bottom_lines = 1usize; + let visible_rows = content_height + .saturating_sub(header_lines + bottom_lines) + .max(1); + self.last_visible_rows.set(visible_rows); + + let start = self.scroll.min(self.rows.len()); + let end = (start + visible_rows).min(self.rows.len()); + let scrollable = self.rows.len() > visible_rows; + + let mut lines: Vec = vec![ + Line::from(vec![Span::styled( + "Session Configuration", + Style::default().fg(palette::DEEPSEEK_BLUE).bold(), + )]), + Line::from(""), + Line::from(" Key Value Scope"), + Line::from(" ─────────────────────────────────────────────────────────────────"), + ]; + + for (idx, row) in self.rows.iter().enumerate().skip(start).take(visible_rows) { + let selected = idx == self.selected; + let style = if selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let value = truncate_view_text(&row.value, 44); + let mut line = Line::from(format!( + " {:<17} {:<44} {}", + row.key, + value, + row.scope.label() + )); + line.style = style; + lines.push(line); + } + + if self.rows.is_empty() { + lines.push(Line::from(" No settings available.")); + } + + let bottom_text = if let Some(status) = self.status.as_ref() { + status.clone() + } else if scrollable && !self.rows.is_empty() { + format!( + " Showing {}-{} / {}", + self.scroll.saturating_add(1), + end, + self.rows.len() + ) + } else { + String::new() + }; + lines.push(Line::from(Span::styled( + bottom_text, + Style::default().fg(palette::TEXT_MUTED), + ))); + + let footer = if scrollable { + " ↑/↓=select, Enter=edit, PgUp/PgDn=scroll, Esc=close " + } else { + " ↑/↓=select, Enter=edit, Esc=close " + }; + (lines, footer.to_string()) + }; + + let block = Block::default() + .title(Line::from(vec![Span::styled( + " Config ", + Style::default().fg(palette::DEEPSEEK_BLUE).bold(), + )])) + .title_bottom(Line::from(Span::styled( + footer, + Style::default().fg(palette::TEXT_MUTED), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)); + + let inner = block.inner(popup_area); + block.render(popup_area, buf); + Paragraph::new(lines) + .style(Style::default().fg(palette::TEXT_PRIMARY)) + .scroll((0, 0)) + .render(inner, buf); + } +} + pub struct HelpView { scroll: usize, tool_sections: Vec<(String, Vec)>, @@ -852,7 +1426,31 @@ fn truncate_view_text(text: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { - use super::truncate_view_text; + use super::{ConfigView, ModalView, ViewAction, ViewEvent, truncate_view_text}; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::path::PathBuf; + + fn create_test_app() -> App { + let options = TuiOptions { + model: "deepseek-reasoner".to_string(), + workspace: PathBuf::from("."), + allow_shell: false, + use_alt_screen: true, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }; + App::new(options, &Config::default()) + } #[test] fn truncate_view_text_handles_unicode() { @@ -863,4 +1461,84 @@ mod tests { assert_eq!(truncate_view_text(text, 4), "abc😀"); assert_eq!(truncate_view_text(text, 5), "abc😀é"); } + + #[test] + fn config_view_includes_expected_editable_rows() { + let app = create_test_app(); + let view = ConfigView::new_for_app(&app); + let keys = view + .rows + .iter() + .map(|row| row.key.as_str()) + .collect::>(); + assert!(keys.contains(&"model")); + assert!(keys.contains(&"approval_mode")); + assert!(keys.contains(&"auto_compact")); + assert!(view.rows.iter().all(|row| row.editable)); + } + + #[test] + fn config_view_enter_and_ctrl_u_emit_config_updated() { + let app = create_test_app(); + let mut view = ConfigView::new_for_app(&app); + + let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(start, ViewAction::None)); + assert!(view.editing.is_some()); + + let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert!(matches!(clear, ViewAction::None)); + let cleared = view + .editing + .as_ref() + .expect("editing should remain active after Ctrl+U"); + assert!(cleared.buffer.is_empty()); + + for ch in "deepseek-chat".chars() { + let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + } + + let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match submit { + ViewAction::Emit(ViewEvent::ConfigUpdated { + key, + value, + persist, + }) => { + assert_eq!(key, "model"); + assert_eq!(value, "deepseek-chat"); + assert!(!persist); + } + other => panic!("expected config update emit, got {other:?}"), + } + assert!(view.editing.is_none()); + } + + #[test] + fn config_view_typing_replaces_on_first_char() { + let app = create_test_app(); + let mut view = ConfigView::new_for_app(&app); + + let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let edit = view.editing.as_ref().expect("editing should be active"); + assert!(edit.select_all, "editor should start with select-all"); + + let _ = view.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + let edit = view.editing.as_ref().expect("editing should remain active"); + assert_eq!(edit.buffer.iter().collect::(), "x"); + } + + #[test] + fn config_view_escape_cancels_editing() { + let app = create_test_app(); + let mut view = ConfigView::new_for_app(&app); + let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(view.editing.is_some()); + + let cancel = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(cancel, ViewAction::None)); + assert!(view.editing.is_none()); + assert_eq!(view.status.as_deref(), Some("Edit cancelled")); + } } diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index f34b7b4b..40e0ef53 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -97,29 +97,31 @@ pub struct ComposerWidget<'a> { app: &'a App, prompt: &'a str, max_height: u16, + slash_menu_entries: &'a [String], } impl<'a> ComposerWidget<'a> { - pub fn new(app: &'a App, prompt: &'a str, max_height: u16) -> Self { + pub fn new( + app: &'a App, + prompt: &'a str, + max_height: u16, + slash_menu_entries: &'a [String], + ) -> Self { Self { app, prompt, max_height, + slash_menu_entries, } } } impl Renderable for ComposerWidget<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { - let slash_menu_entries = if self.app.slash_menu_hidden { - Vec::new() - } else { - slash_completion_hints(&self.app.input, 6) - }; let prompt_width = self.prompt.width(); let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX); let content_width = usize::from(area.width.saturating_sub(prompt_width_u16).max(1)); - let menu_lines = slash_menu_entries.len(); + let menu_lines = self.slash_menu_entries.len(); let max_height = usize::from(area.height).saturating_sub(menu_lines).max(1); let continuation = " ".repeat(prompt_width); @@ -161,16 +163,16 @@ impl Renderable for ComposerWidget<'_> { } } - if !slash_menu_entries.is_empty() { + if !self.slash_menu_entries.is_empty() { let selected = self .app .slash_menu_selected - .min(slash_menu_entries.len().saturating_sub(1)); - for (idx, entry) in slash_menu_entries.iter().enumerate() { + .min(self.slash_menu_entries.len().saturating_sub(1)); + for (idx, entry) in self.slash_menu_entries.iter().enumerate() { let is_selected = idx == selected; let style = if is_selected { Style::default() - .fg(palette::DEEPSEEK_SKY) + .fg(palette::SELECTION_TEXT) .bg(palette::SELECTION_BG) } else { Style::default().fg(palette::TEXT_MUTED) @@ -190,30 +192,22 @@ impl Renderable for ComposerWidget<'_> { } fn desired_height(&self, width: u16) -> u16 { - let menu_lines = if self.app.slash_menu_hidden { - 0 - } else { - slash_completion_hints(&self.app.input, 6).len() - }; composer_height( &self.app.input, width, self.max_height, self.prompt, - menu_lines, + self.slash_menu_entries.len(), ) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let menu_lines = if self.app.slash_menu_hidden { - 0 - } else { - slash_completion_hints(&self.app.input, 6).len() - }; let prompt_width = self.prompt.width(); let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX); let content_width = usize::from(area.width.saturating_sub(prompt_width_u16).max(1)); - let max_height = usize::from(area.height).saturating_sub(menu_lines).max(1); + let max_height = usize::from(area.height) + .saturating_sub(self.slash_menu_entries.len()) + .max(1); let (_visible_lines, cursor_row, cursor_col) = layout_input( &self.app.input, @@ -334,7 +328,7 @@ impl Renderable for ApprovalWidget<'_> { let is_selected = i == self.selected; let style = if is_selected { Style::default() - .fg(palette::DEEPSEEK_SKY) + .fg(palette::SELECTION_TEXT) .bg(palette::SELECTION_BG) } else { Style::default() @@ -444,7 +438,7 @@ impl Renderable for ElevationWidget<'_> { let is_selected = i == self.selected; let style = if is_selected { Style::default() - .fg(palette::DEEPSEEK_SKY) + .fg(palette::SELECTION_TEXT) .bg(palette::SELECTION_BG) } else { Style::default() @@ -520,7 +514,9 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) { return; }; - let selection_style = Style::default().bg(app.ui_theme.selection_bg); + let selection_style = Style::default() + .bg(app.ui_theme.selection_bg) + .fg(palette::SELECTION_TEXT); for (idx, line) in lines.iter_mut().enumerate() { let line_index = top + idx; @@ -538,8 +534,14 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) { (0, usize::MAX) }; - let new_spans = apply_selection_to_line(line, col_start, col_end, selection_style); - line.spans = new_spans; + if col_start == 0 && col_end == usize::MAX { + for span in &mut line.spans { + span.style = span.style.patch(selection_style); + } + continue; + } + + line.spans = apply_selection_to_line(line, col_start, col_end, selection_style); } } @@ -549,7 +551,7 @@ fn apply_selection_to_line( col_end: usize, selection_style: Style, ) -> Vec> { - let mut result = Vec::new(); + let mut result = Vec::with_capacity(line.spans.len().saturating_add(2)); let mut current_col = 0usize; for span in &line.spans { @@ -565,30 +567,25 @@ fn apply_selection_to_line( span.style.patch(selection_style), )); } else { - let chars: Vec = span_text.chars().collect(); - let mut before = String::new(); - let mut selected = String::new(); - let mut after = String::new(); + let span_sel_start = col_start.saturating_sub(current_col).min(span_len); + let span_sel_end = col_end.saturating_sub(current_col).min(span_len); + let byte_start = byte_index_at_char(span_text, span_sel_start); + let byte_end = byte_index_at_char(span_text, span_sel_end); - for (i, &ch) in chars.iter().enumerate() { - let char_col = current_col + i; - if char_col < col_start { - before.push(ch); - } else if char_col < col_end { - selected.push(ch); - } else { - after.push(ch); - } + if byte_start > 0 { + result.push(Span::styled( + span_text[..byte_start].to_string(), + span.style, + )); } - - if !before.is_empty() { - result.push(Span::styled(before, span.style)); + if byte_end > byte_start { + result.push(Span::styled( + span_text[byte_start..byte_end].to_string(), + span.style.patch(selection_style), + )); } - if !selected.is_empty() { - result.push(Span::styled(selected, span.style.patch(selection_style))); - } - if !after.is_empty() { - result.push(Span::styled(after, span.style)); + if byte_end < span_text.len() { + result.push(Span::styled(span_text[byte_end..].to_string(), span.style)); } } @@ -598,6 +595,16 @@ fn apply_selection_to_line( result } +fn byte_index_at_char(text: &str, char_index: usize) -> usize { + if char_index == 0 { + return 0; + } + text.char_indices() + .nth(char_index) + .map(|(idx, _)| idx) + .unwrap_or(text.len()) +} + fn composer_height( input: &str, width: u16, @@ -780,8 +787,15 @@ fn wrap_text(text: &str, width: usize) -> Vec { #[cfg(test)] mod tests { - use super::{cursor_row_col, pad_lines_to_bottom, wrap_input_lines, wrap_text}; - use ratatui::text::Line; + use super::{ + apply_selection_to_line, composer_height, cursor_row_col, layout_input, + pad_lines_to_bottom, slash_completion_hints, wrap_input_lines, wrap_text, + }; + use crate::palette; + use ratatui::{ + style::Style, + text::{Line, Span}, + }; use unicode_width::UnicodeWidthStr; #[test] @@ -932,4 +946,61 @@ mod tests { ); } } + + #[test] + fn slash_completion_hints_include_links_and_config() { + let hints = slash_completion_hints("/", 128); + assert!(hints.iter().any(|hint| hint == "/config")); + assert!(hints.iter().any(|hint| hint == "/links")); + } + + #[test] + fn slash_completion_hints_exclude_set_and_deepseek_commands() { + let hints = slash_completion_hints("/", 128); + assert!(!hints.iter().any(|hint| hint == "/set")); + assert!(!hints.iter().any(|hint| hint == "/deepseek")); + } + + #[test] + fn selection_style_uses_explicit_selection_text_role() { + let line = Line::from(Span::styled( + "hello world", + Style::default().fg(palette::TEXT_PRIMARY), + )); + let selection_style = Style::default() + .bg(palette::SELECTION_BG) + .fg(palette::SELECTION_TEXT); + + let styled = apply_selection_to_line(&line, 0, 5, selection_style); + assert_eq!(styled.len(), 2); + assert_eq!(styled[0].content.as_ref(), "hello"); + assert_eq!(styled[0].style.fg, Some(palette::SELECTION_TEXT)); + assert_eq!(styled[0].style.bg, Some(palette::SELECTION_BG)); + assert_eq!(styled[1].content.as_ref(), " world"); + } + + #[test] + fn composer_layout_helpers_stay_consistent() { + let input = "line one wraps nicely\nline two wraps as well"; + let prompt = "> "; + let width = 16; + let available_height = 6; + let menu_lines = 2; + + let height = composer_height(input, width, available_height, prompt, menu_lines); + let prompt_width = u16::try_from(prompt.width()).unwrap_or(u16::MAX); + let content_width = usize::from(width.saturating_sub(prompt_width).max(1)); + let input_height_budget = usize::from(height).saturating_sub(menu_lines).max(1); + let (visible, cursor_row, cursor_col) = layout_input( + input, + input.chars().count(), + content_width, + input_height_budget, + ); + + assert!(visible.len().saturating_add(menu_lines) <= usize::from(height)); + assert!(!visible.is_empty()); + assert!(cursor_row < visible.len()); + assert!(cursor_col < content_width.max(1)); + } } diff --git a/tests/palette_audit.rs b/tests/palette_audit.rs index 488999e1..8f34a024 100644 --- a/tests/palette_audit.rs +++ b/tests/palette_audit.rs @@ -7,6 +7,12 @@ use std::fs; use std::path::Path; +use ratatui::style::Color; + +#[path = "../src/palette.rs"] +#[allow(dead_code)] +mod palette; + /// Colors that should not be used directly in TUI code. /// Use semantic aliases (STATUS_SUCCESS, STATUS_WARNING, etc.) instead. const DEPRECATED_DIRECT_COLORS: &[&str] = &["DEEPSEEK_AQUA"]; @@ -14,6 +20,61 @@ const DEPRECATED_DIRECT_COLORS: &[&str] = &["DEEPSEEK_AQUA"]; /// Patterns that indicate proper usage (in palette.rs definitions) const ALLOWED_PATTERNS: &[&str] = &["pub const DEEPSEEK_AQUA", "DEEPSEEK_AQUA_RGB"]; +fn color_to_rgb(color: Color) -> (u8, u8, u8) { + match color { + Color::Rgb(r, g, b) => (r, g, b), + Color::Black => (0, 0, 0), + Color::White => (255, 255, 255), + Color::Gray => (128, 128, 128), + Color::DarkGray => (169, 169, 169), + Color::Red => (255, 0, 0), + Color::LightRed => (255, 102, 102), + Color::Green => (0, 255, 0), + Color::LightGreen => (102, 255, 102), + Color::Yellow => (255, 255, 0), + Color::LightYellow => (255, 255, 153), + Color::Blue => (0, 0, 255), + Color::LightBlue => (102, 153, 255), + Color::Magenta => (255, 0, 255), + Color::LightMagenta => (255, 153, 255), + Color::Cyan => (0, 255, 255), + Color::LightCyan => (153, 255, 255), + _ => panic!("unsupported color variant for contrast test: {:?}", color), + } +} + +fn linearize_srgb(component: u8) -> f64 { + let srgb = f64::from(component) / 255.0; + if srgb <= 0.04045 { + srgb / 12.92 + } else { + ((srgb + 0.055) / 1.055).powf(2.4) + } +} + +fn relative_luminance(color: Color) -> f64 { + let (r, g, b) = color_to_rgb(color); + 0.2126 * linearize_srgb(r) + 0.7152 * linearize_srgb(g) + 0.0722 * linearize_srgb(b) +} + +fn contrast_ratio(foreground: Color, background: Color) -> f64 { + let fg = relative_luminance(foreground); + let bg = relative_luminance(background); + if fg >= bg { + (fg + 0.05) / (bg + 0.05) + } else { + (bg + 0.05) / (fg + 0.05) + } +} + +fn assert_min_contrast(label: &str, foreground: Color, background: Color, min_ratio: f64) { + let ratio = contrast_ratio(foreground, background); + assert!( + ratio >= min_ratio, + "{label} contrast {ratio:.2} is below minimum {min_ratio:.2}" + ); +} + /// Audit a single file for deprecated color usage. fn audit_file(path: &Path, violations: &mut Vec) { let content = match fs::read_to_string(path) { @@ -113,3 +174,61 @@ fn verify_brand_colors_defined() { "DEEPSEEK_RED should be #E25060" ); } + +#[test] +fn contrast_guardrails_for_key_ui_pairs() { + let min_readable = 4.5; + + assert_min_contrast( + "TEXT_BODY on DEEPSEEK_INK", + palette::TEXT_BODY, + palette::DEEPSEEK_INK, + min_readable, + ); + assert_min_contrast( + "TEXT_SECONDARY on DEEPSEEK_INK", + palette::TEXT_SECONDARY, + palette::DEEPSEEK_INK, + min_readable, + ); + assert_min_contrast( + "TEXT_HINT on DEEPSEEK_INK", + palette::TEXT_HINT, + palette::DEEPSEEK_INK, + min_readable, + ); + assert_min_contrast( + "STATUS_WARNING on DEEPSEEK_INK", + palette::STATUS_WARNING, + palette::DEEPSEEK_INK, + min_readable, + ); + assert_min_contrast( + "STATUS_ERROR on DEEPSEEK_INK", + palette::STATUS_ERROR, + palette::DEEPSEEK_INK, + min_readable, + ); + assert_min_contrast( + "FOOTER_HINT on DEEPSEEK_INK", + palette::FOOTER_HINT, + palette::DEEPSEEK_INK, + min_readable, + ); + assert_min_contrast( + "FOOTER_HINT on DEEPSEEK_SLATE", + palette::FOOTER_HINT, + palette::DEEPSEEK_SLATE, + min_readable, + ); + + for theme in ["default", "dark", "light", "whale"] { + let selection_bg = palette::ui_theme(theme).selection_bg; + assert_min_contrast( + &format!("SELECTION_TEXT on {theme} selection"), + palette::SELECTION_TEXT, + selection_bg, + min_readable, + ); + } +}