Improve TUI coherence, small-screen layout, and contrast guardrails
This commit is contained in:
+16
-1
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -726,7 +726,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.21"
|
||||
version = "0.3.22"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+19
-3
@@ -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 <key> <value>`.
|
||||
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.
|
||||
|
||||
+12
-3
@@ -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:
|
||||
|
||||
+45
-86
@@ -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::<Vec<_>>()
|
||||
.join("\n");
|
||||
return CommandResult::message(format!(
|
||||
"Usage: /set <key> <value>\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 <key> <value>");
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
.join("\n");
|
||||
return CommandResult::message(format!(
|
||||
"Usage: /set <key> <value>\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 <key> <value>");
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
+42
-2
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+100
-12
@@ -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 <key> <value>",
|
||||
},
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+15
-7
@@ -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 {
|
||||
|
||||
+14
-2
@@ -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"),
|
||||
|
||||
+94
-10
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<YoloRestoreState>,
|
||||
// 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<String>,
|
||||
/// 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());
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
assert!(command_labels.contains(&"/config"));
|
||||
assert!(command_labels.contains(&"/links"));
|
||||
assert!(!command_labels.contains(&"/set"));
|
||||
assert!(!command_labels.contains(&"/deepseek"));
|
||||
}
|
||||
}
|
||||
|
||||
+142
-6
@@ -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<SessionMetadata>,
|
||||
filtered: Vec<SessionMetadata>,
|
||||
selected: usize,
|
||||
list_scroll: Cell<usize>,
|
||||
list_visible_rows: Cell<usize>,
|
||||
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::<Vec<_>>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+185
-42
@@ -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<String>,
|
||||
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<String>, 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<Line<'static>> = Vec::new();
|
||||
let mut lines: Vec<Line<'static>> = 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<Line<'static>> = Vec::new();
|
||||
let mut lines: Vec<Line<'static>> = 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<Line<'static>> = Vec::new();
|
||||
let mut lines: Vec<Line<'static>> = 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<Line<'static>> = Vec::new();
|
||||
let mut lines: Vec<Line<'static>> = 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 <n>");
|
||||
let header = if queued_compacted {
|
||||
format!("Queued ({queued_count}) [compact] - /queue edit <n>")
|
||||
} else {
|
||||
format!("Queued ({queued_count}) - /queue edit <n>")
|
||||
};
|
||||
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));
|
||||
|
||||
+53
-2
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
)));
|
||||
|
||||
+682
-4
@@ -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<char>,
|
||||
cursor: usize,
|
||||
select_all: bool,
|
||||
scope: ConfigScope,
|
||||
}
|
||||
|
||||
pub struct ConfigView {
|
||||
rows: Vec<ConfigRow>,
|
||||
selected: usize,
|
||||
scroll: usize,
|
||||
editing: Option<ConfigEdit>,
|
||||
status: Option<String>,
|
||||
last_visible_rows: Cell<usize>,
|
||||
}
|
||||
|
||||
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::<String>();
|
||||
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<char> = 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::<String>();
|
||||
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::<String>();
|
||||
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::<String>();
|
||||
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<Line> = 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<Line> = 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<String>)>,
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<String>(), "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"));
|
||||
}
|
||||
}
|
||||
|
||||
+123
-52
@@ -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<Span<'static>> {
|
||||
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<char> = 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<String> {
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user