Improve TUI coherence, small-screen layout, and contrast guardrails

This commit is contained in:
Hunter Bown
2026-02-19 10:09:41 -06:00
parent 6130617b8d
commit b88ce88a42
19 changed files with 1682 additions and 237 deletions
+16 -1
View File
@@ -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
View File
@@ -726,7 +726,7 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.3.21"
version = "0.3.22"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 isnt 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
+17 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
}
+119
View File
@@ -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,
);
}
}