From b172b8d306703772358d8ceb5a4ac804aed16394 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Thu, 12 Mar 2026 11:32:25 -0500 Subject: [PATCH] feat: remove Normal mode and consolidate to Agent (#4) Keep legacy /normal and settings fallback behavior mapped to Agent, align docs around the three visible modes, and include the current TUI and onboarding refinements in this worktree. --- .trimtab/init-trimtab-protocol.md | 2 +- README.md | 5 +- crates/tui/src/commands/config.rs | 61 +- crates/tui/src/commands/core.rs | 14 +- crates/tui/src/commands/mod.rs | 8 +- crates/tui/src/hooks.rs | 2 +- crates/tui/src/palette.rs | 22 +- crates/tui/src/prompts.rs | 4 +- crates/tui/src/runtime_threads.rs | 1 - crates/tui/src/settings.rs | 82 ++- crates/tui/src/tools/registry.rs | 2 +- crates/tui/src/tui/app.rs | 138 ++-- crates/tui/src/tui/command_palette.rs | 1 - crates/tui/src/tui/history.rs | 851 +++++++++++++++-------- crates/tui/src/tui/onboarding/mod.rs | 71 +- crates/tui/src/tui/onboarding/welcome.rs | 87 +-- crates/tui/src/tui/transcript.rs | 56 +- crates/tui/src/tui/ui.rs | 733 +++++++++---------- crates/tui/src/tui/ui/tests.rs | 87 ++- crates/tui/src/tui/views/mod.rs | 53 +- crates/tui/src/tui/widgets/header.rs | 110 +-- crates/tui/src/tui/widgets/mod.rs | 363 ++++++++-- docs/CONFIGURATION.md | 8 +- docs/MODES.md | 12 +- 24 files changed, 1685 insertions(+), 1088 deletions(-) diff --git a/.trimtab/init-trimtab-protocol.md b/.trimtab/init-trimtab-protocol.md index 48657342..919355f3 100644 --- a/.trimtab/init-trimtab-protocol.md +++ b/.trimtab/init-trimtab-protocol.md @@ -148,4 +148,4 @@ See DEPENDENCY_GRAPH.md for the full dependency graph. - TUI binary still references monolith source (src/) — migration incremental - DeepSeek API: Responses API preferred, chat completions fallback - Sandbox: macOS Seatbelt, Linux Landlock -- Modes: Normal, Plan, Agent, YOLO, RLM, Duo (each gates different tools) +- Modes: Plan, Agent, YOLO (visible). Hidden `/normal` and legacy `default_mode = "normal"` normalize to Agent. diff --git a/README.md b/README.md index 9bdec6aa..a8618eb2 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,10 @@ project. An agent loop with file editing, shell execution, `web.run` browsing, git operations, task tracking, and [MCP](https://modelcontextprotocol.io) server integration. Context-aware memory compaction keeps long sessions on track. `crates/tui` remains the live shipped runtime while the workspace extraction continues. -Four modes (**Tab** / **Shift+Tab** to cycle): +Three visible modes (**Tab** / **Shift+Tab** to cycle): | Mode | Behavior | |------|----------| -| **Normal** | Chat-first mode for questions, explanation, and low-friction steering | | **Plan** | Design-first — proposes before acting | | **Agent** | Multi-step autonomous tool use | | **YOLO** | Full auto-approve, no guardrails | @@ -67,7 +66,7 @@ Four modes (**Tab** / **Shift+Tab** to cycle): 1. Paste your API key in onboarding. 2. Choose a mode for the task in front of you: - `Normal` to ask questions, `Plan` to review a plan first, `Agent` to let the model use tools, `YOLO` only inside a trusted workspace. + `Plan` to review a plan first, `Agent` to let the model use tools, `YOLO` only inside a trusted workspace. 3. Watch the status area while work is running: approvals, queued work, and active sub-agents stay there while the turn is live. 4. Recover work with `Ctrl+R` or `/sessions` if you need to resume an interrupted thread. diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index c7470822..a508c2ae 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -82,6 +82,14 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.auto_compact = settings.auto_compact; action = Some(AppAction::UpdateCompaction(app.compaction_config())); } + "calm_mode" | "calm" => { + app.calm_mode = settings.calm_mode; + app.mark_history_updated(); + } + "low_motion" | "motion" => { + app.low_motion = settings.low_motion; + app.needs_redraw = true; + } "show_thinking" | "thinking" => { app.show_thinking = settings.show_thinking; app.mark_history_updated(); @@ -90,6 +98,16 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.show_tool_details = settings.show_tool_details; app.mark_history_updated(); } + "composer_density" | "composer" => { + app.composer_density = + crate::tui::app::ComposerDensity::from_setting(&settings.composer_density); + app.needs_redraw = true; + } + "transcript_spacing" | "spacing" => { + app.transcript_spacing = + crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing); + app.mark_history_updated(); + } "default_mode" | "mode" => { let mode = AppMode::from_setting(&settings.default_mode); app.set_mode(mode); @@ -120,13 +138,18 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> _ => {} } + let display_value = match key.as_str() { + "default_mode" | "mode" => settings.default_mode.clone(), + _ => value.to_string(), + }; + let message = if persist { if let Err(e) = settings.save() { return CommandResult::error(format!("Failed to save: {e}")); } - format!("{key} = {value} (saved)") + format!("{key} = {display_value} (saved)") } else { - format!("{key} = {value} (session only, add --save to persist)") + format!("{key} = {display_value} (session only, add --save to persist)") }; CommandResult { @@ -175,10 +198,10 @@ pub fn yolo(app: &mut App) -> CommandResult { CommandResult::message("YOLO mode enabled - shell + trust + auto-approve!") } -/// Enable normal mode (read-only chat, suggestions before approvals) +/// Legacy alias for the removed normal mode. pub fn normal_mode(app: &mut App) -> CommandResult { - app.set_mode(AppMode::Normal); - CommandResult::message("Normal mode enabled.") + app.set_mode(AppMode::Agent); + CommandResult::message("Normal mode was removed. Switched to Agent mode.") } /// Enable agent mode (autonomous tool use with approvals) @@ -334,7 +357,7 @@ mod tests { fn test_mode_switch_commands() { let mut app = create_test_app(); let _ = normal_mode(&mut app); - assert_eq!(app.mode, AppMode::Normal); + assert_eq!(app.mode, AppMode::Agent); let _ = agent_mode(&mut app); assert_eq!(app.mode, AppMode::Agent); let _ = plan_mode(&mut app); @@ -403,6 +426,32 @@ mod tests { assert_eq!(app.model, "deepseek-reasoner"); } + #[test] + fn test_set_default_mode_normal_save_reports_normalized_value() { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-default-mode-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let result = set_config(&mut app, Some("default_mode normal --save")); + let msg = result.message.unwrap(); + assert_eq!(msg, "default_mode = agent (saved)"); + assert_eq!(app.mode, AppMode::Agent); + + let settings_path = Settings::path().unwrap(); + let saved = fs::read_to_string(settings_path).unwrap(); + assert!(saved.contains("default_mode = \"agent\"")); + } + #[test] fn test_set_approval_mode_valid_values() { let mut app = create_test_app(); diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 67cef381..419c79eb 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -180,11 +180,12 @@ pub fn home_dashboard(app: &mut App) -> CommandResult { let _ = writeln!(stats, "\nMode Tips"); let _ = writeln!(stats, "--------------------------------------------"); match app.mode { - AppMode::Normal => { - let _ = writeln!(stats, "Normal mode - Chat with the assistant"); - } AppMode::Agent => { let _ = writeln!(stats, "Agent mode - Use tools for autonomous tasks"); + let _ = writeln!( + stats, + " Use Ctrl+X to review in Plan mode before executing" + ); let _ = writeln!(stats, " Type /yolo to enable full tool access"); } AppMode::Yolo => { @@ -444,12 +445,7 @@ mod tests { #[test] fn test_home_dashboard_mode_tips_for_each_mode() { - let modes = [ - AppMode::Normal, - AppMode::Agent, - AppMode::Yolo, - AppMode::Plan, - ]; + let modes = [AppMode::Agent, AppMode::Yolo, AppMode::Plan]; for mode in modes { let mut app = create_test_app(); app.mode = mode; diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 84bf0532..eb7d5492 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -212,12 +212,6 @@ pub const COMMANDS: &[CommandInfo] = &[ description: "Enable YOLO mode (shell + trust + auto-approve)", usage: "/yolo", }, - CommandInfo { - name: "normal", - aliases: &[], - description: "Switch to normal mode (no autonomous tool flow)", - usage: "/normal", - }, CommandInfo { name: "agent", aliases: &[], @@ -346,7 +340,6 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "config" => config::show_config(app), "settings" => config::show_settings(app), "yolo" => config::yolo(app), - "normal" => config::normal_mode(app), "agent" => config::agent_mode(app), "plan" => config::plan_mode(app), "trust" => config::trust(app), @@ -372,6 +365,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "set" => CommandResult::error( "The /set command was retired. Use /config to edit settings and /settings to inspect current values.", ), + "normal" => config::normal_mode(app), "deepseek" => CommandResult::error( "The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).", ), diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index 0e583ae1..cda21e1e 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -35,7 +35,7 @@ pub enum HookEvent { ToolCallBefore, /// Triggered after a tool completes (success or failure) ToolCallAfter, - /// Triggered when the user changes modes (Normal, Edit, Agent, Plan) + /// Triggered when the user changes modes (Plan, Agent, Yolo) ModeChange, /// Triggered when an error occurs OnError, diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index 865ac22f..e58b8f70 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -50,6 +50,7 @@ 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; +pub const TEXT_SOFT: Color = Color::Rgb(214, 223, 235); // #D6DFEB // Compatibility aliases for existing call sites. pub const TEXT_PRIMARY: Color = TEXT_BODY; @@ -69,6 +70,26 @@ pub const BACKGROUND_LIGHT: Color = Color::Rgb(30, 47, 71); // #1E2F47 pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30 #[allow(dead_code)] pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0 +#[allow(dead_code)] +pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); // #152134 +#[allow(dead_code)] +pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); // #1C2A40 +#[allow(dead_code)] +pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); // #362C1A +#[allow(dead_code)] +pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); // #44351C +#[allow(dead_code)] +pub const SURFACE_TOOL: Color = Color::Rgb(24, 39, 60); // #18273C +#[allow(dead_code)] +pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(29, 48, 73); // #1D3049 +#[allow(dead_code)] +pub const SURFACE_SUCCESS: Color = Color::Rgb(22, 56, 63); // #16383F +#[allow(dead_code)] +pub const SURFACE_ERROR: Color = Color::Rgb(63, 27, 36); // #3F1B24 +pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(146, 198, 248); // #92C6F8 +pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); // #85B8EA +pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); // #C08F99 +pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(205, 216, 228); // #CDD8E4 // Legacy status colors - keep for backward compatibility pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY; @@ -78,7 +99,6 @@ pub const STATUS_ERROR: Color = DEEPSEEK_RED; pub const STATUS_INFO: Color = DEEPSEEK_BLUE; // Mode-specific accent colors for mode badges -pub const MODE_NORMAL: Color = Color::Rgb(192, 192, 192); // #C0C0C0 pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index c87a1d15..ed7fb5f6 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -11,6 +11,7 @@ use std::path::Path; // Prompt files loaded at compile time pub const BASE_PROMPT: &str = include_str!("prompts/base.txt"); +#[allow(dead_code)] pub const NORMAL_PROMPT: &str = include_str!("prompts/normal.txt"); pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt"); pub const YOLO_PROMPT: &str = include_str!("prompts/yolo.txt"); @@ -18,7 +19,6 @@ pub const PLAN_PROMPT: &str = include_str!("prompts/plan.txt"); fn mode_prompt(mode: AppMode) -> &'static str { match mode { - AppMode::Normal => NORMAL_PROMPT, AppMode::Agent => AGENT_PROMPT, AppMode::Yolo => YOLO_PROMPT, AppMode::Plan => PLAN_PROMPT, @@ -95,7 +95,7 @@ pub fn base_system_prompt() -> SystemPrompt { } pub fn normal_system_prompt() -> SystemPrompt { - system_prompt_for_mode(AppMode::Normal) + system_prompt_for_mode(AppMode::Agent) } pub fn agent_system_prompt() -> SystemPrompt { diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 58d062af..b18d93e8 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2251,7 +2251,6 @@ fn enforce_lru_capacity( fn parse_mode(mode: &str) -> AppMode { match mode.trim().to_ascii_lowercase().as_str() { - "normal" => AppMode::Normal, "plan" => AppMode::Plan, "yolo" => AppMode::Yolo, _ => AppMode::Agent, diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index c0946e79..cdfb10b5 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -17,11 +17,19 @@ pub struct Settings { pub theme: String, /// Auto-compact conversations when they get long pub auto_compact: bool, + /// Reduce status noise and collapse details more aggressively + pub calm_mode: bool, + /// Reduce animation and redraw churn + pub low_motion: bool, /// Show thinking blocks from the model pub show_thinking: bool, /// Show detailed tool output pub show_tool_details: bool, - /// Default mode: "normal", "agent", "plan", "yolo" + /// Composer layout density: compact, comfortable, spacious + pub composer_density: String, + /// Transcript spacing rhythm: compact, comfortable, spacious + pub transcript_spacing: String, + /// Default mode: "agent", "plan", "yolo" pub default_mode: String, /// Sidebar width as percentage of terminal width pub sidebar_width_percent: u16, @@ -38,8 +46,12 @@ impl Default for Settings { Self { theme: "whale".to_string(), auto_compact: true, + calm_mode: false, + low_motion: false, show_thinking: true, show_tool_details: true, + composer_density: "comfortable".to_string(), + transcript_spacing: "comfortable".to_string(), default_mode: "agent".to_string(), sidebar_width_percent: 28, sidebar_focus: "auto".to_string(), @@ -70,6 +82,10 @@ impl Settings { let mut settings: Settings = toml::from_str(&content) .with_context(|| format!("Failed to parse settings from {}", path.display()))?; settings.default_mode = normalize_mode(&settings.default_mode).to_string(); + settings.composer_density = + normalize_composer_density(&settings.composer_density).to_string(); + settings.transcript_spacing = + normalize_transcript_spacing(&settings.transcript_spacing).to_string(); settings.sidebar_focus = normalize_sidebar_focus(&settings.sidebar_focus).to_string(); settings.default_model = settings .default_model @@ -109,17 +125,41 @@ impl Settings { "auto_compact" | "compact" => { self.auto_compact = parse_bool(value)?; } + "calm_mode" | "calm" => { + self.calm_mode = parse_bool(value)?; + } + "low_motion" | "motion" => { + self.low_motion = parse_bool(value)?; + } "show_thinking" | "thinking" => { self.show_thinking = parse_bool(value)?; } "show_tool_details" | "tool_details" => { self.show_tool_details = parse_bool(value)?; } + "composer_density" | "composer" => { + let normalized = normalize_composer_density(value); + if !["compact", "comfortable", "spacious"].contains(&normalized) { + anyhow::bail!( + "Failed to update setting: invalid composer density '{value}'. Expected: compact, comfortable, spacious." + ); + } + self.composer_density = normalized.to_string(); + } + "transcript_spacing" | "spacing" => { + let normalized = normalize_transcript_spacing(value); + if !["compact", "comfortable", "spacious"].contains(&normalized) { + anyhow::bail!( + "Failed to update setting: invalid transcript spacing '{value}'. Expected: compact, comfortable, spacious." + ); + } + self.transcript_spacing = normalized.to_string(); + } "default_mode" | "mode" => { let normalized = normalize_mode(value); - if !["normal", "agent", "plan", "yolo"].contains(&normalized) { + if !["agent", "plan", "yolo"].contains(&normalized) { anyhow::bail!( - "Failed to update setting: invalid mode '{value}'. Expected: normal, agent, plan, yolo." + "Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo." ); } self.default_mode = normalized.to_string(); @@ -195,8 +235,12 @@ impl Settings { lines.push("─────────────────────────────".to_string()); lines.push(format!(" theme: {}", self.theme)); lines.push(format!(" auto_compact: {}", self.auto_compact)); + lines.push(format!(" calm_mode: {}", self.calm_mode)); + lines.push(format!(" low_motion: {}", self.low_motion)); lines.push(format!(" show_thinking: {}", self.show_thinking)); lines.push(format!(" show_tool_details: {}", self.show_tool_details)); + lines.push(format!(" composer_density: {}", self.composer_density)); + lines.push(format!(" transcript_spacing: {}", self.transcript_spacing)); lines.push(format!(" default_mode: {}", self.default_mode)); lines.push(format!( " sidebar_width: {}%", @@ -222,9 +266,19 @@ impl Settings { vec![ ("theme", "Color theme: default, dark, light"), ("auto_compact", "Auto-compact conversations: on/off"), + ("calm_mode", "Calmer UI defaults: on/off"), + ("low_motion", "Reduce animation and redraw churn: on/off"), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), - ("default_mode", "Default mode: normal, agent, plan, yolo"), + ( + "composer_density", + "Composer density: compact, comfortable, spacious", + ), + ( + "transcript_spacing", + "Transcript spacing: compact, comfortable, spacious", + ), + ("default_mode", "Default mode: agent, plan, yolo"), ("sidebar_width", "Sidebar width percentage: 10-50"), ( "sidebar_focus", @@ -253,7 +307,7 @@ fn parse_bool(value: &str) -> Result { fn normalize_mode(value: &str) -> &str { match value.trim().to_ascii_lowercase().as_str() { "edit" => "agent", - "normal" => "normal", + "normal" => "agent", "agent" => "agent", "plan" => "plan", "yolo" => "yolo", @@ -261,6 +315,24 @@ fn normalize_mode(value: &str) -> &str { } } +fn normalize_composer_density(value: &str) -> &str { + match value.trim().to_ascii_lowercase().as_str() { + "compact" | "tight" => "compact", + "comfortable" | "default" | "normal" => "comfortable", + "spacious" | "loose" => "spacious", + _ => value, + } +} + +fn normalize_transcript_spacing(value: &str) -> &str { + match value.trim().to_ascii_lowercase().as_str() { + "compact" | "tight" => "compact", + "comfortable" | "default" | "normal" => "comfortable", + "spacious" | "loose" => "spacious", + _ => value, + } +} + fn normalize_sidebar_focus(value: &str) -> &str { match value.trim().to_ascii_lowercase().as_str() { "plan" => "plan", diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 8edade69..a21f43ee 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -175,7 +175,7 @@ impl ToolRegistry { .collect() } - /// Get read-only tools (for Normal mode). + /// Get read-only tools. #[must_use] #[allow(dead_code)] pub fn read_only_tools(&self) -> Vec> { diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index bb3179c2..5c1403ac 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1,7 +1,7 @@ //! Application state for the `DeepSeek` TUI. use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::Instant; use ratatui::layout::Rect; @@ -29,28 +29,6 @@ use crate::tui::streaming::StreamingState; use crate::tui::transcript::TranscriptViewCache; use crate::tui::views::ViewStack; -/// Format a nice welcome banner. -fn format_welcome_banner(model: &str, workspace: &Path, yolo: bool) -> String { - let mode_line = if yolo { - "\nYOLO mode — shell + trust + auto-approve enabled\n" - } else { - "" - }; - - format!( - "Start with a workflow instead of a shortcut:\n\ - - Normal asks questions, Agent runs tools, Plan reviews the approach first\n\ - - Watch approvals, queued prompts, and sub-agents in the runtime status area\n\ - - Use /queue to edit pending work and Ctrl+R or /sessions to resume past threads\n\ - - Ctrl+K opens the command palette, F1 opens help, Esc cancels current work\n\ - {mode_line}\ - Directory: {}\n\ - Model: {}", - workspace.display(), - model - ) -} - // === Types === /// State machine for onboarding new users. @@ -66,7 +44,6 @@ pub enum OnboardingState { /// Supported application modes for the TUI. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppMode { - Normal, Agent, Yolo, Plan, @@ -82,6 +59,42 @@ pub enum SidebarFocus { Agents, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComposerDensity { + Compact, + Comfortable, + Spacious, +} + +impl ComposerDensity { + #[must_use] + pub fn from_setting(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "compact" | "tight" => Self::Compact, + "spacious" | "loose" => Self::Spacious, + _ => Self::Comfortable, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TranscriptSpacing { + Compact, + Comfortable, + Spacious, +} + +impl TranscriptSpacing { + #[must_use] + pub fn from_setting(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "compact" | "tight" => Self::Compact, + "spacious" | "loose" => Self::Spacious, + _ => Self::Comfortable, + } + } +} + impl SidebarFocus { #[must_use] pub fn from_setting(value: &str) -> Self { @@ -184,7 +197,6 @@ impl AppMode { #[must_use] pub fn from_setting(value: &str) -> Self { match value.trim().to_ascii_lowercase().as_str() { - "normal" => Self::Normal, "plan" => Self::Plan, "yolo" => Self::Yolo, _ => Self::Agent, @@ -194,7 +206,6 @@ impl AppMode { #[must_use] pub fn as_setting(self) -> &'static str { match self { - Self::Normal => "normal", Self::Agent => "agent", Self::Yolo => "yolo", Self::Plan => "plan", @@ -204,7 +215,6 @@ impl AppMode { /// Short label used in the UI footer. pub fn label(self) -> &'static str { match self { - AppMode::Normal => "NORMAL", AppMode::Agent => "AGENT", AppMode::Yolo => "YOLO", AppMode::Plan => "PLAN", @@ -215,7 +225,6 @@ impl AppMode { /// Description shown in help or onboarding text. pub fn description(self) -> &'static str { match self { - AppMode::Normal => "Chat mode - ask questions, get answers", AppMode::Agent => "Agent mode - autonomous task execution with tools", AppMode::Yolo => "YOLO mode - full tool access without approvals", AppMode::Plan => "Plan mode - design before implementing", @@ -301,8 +310,12 @@ pub struct App { pub input_history: Vec, pub history_index: Option, pub auto_compact: bool, + pub calm_mode: bool, + pub low_motion: bool, pub show_thinking: bool, pub show_tool_details: bool, + pub composer_density: ComposerDensity, + pub transcript_spacing: TranscriptSpacing, pub sidebar_width_percent: u16, pub sidebar_focus: SidebarFocus, /// Slash menu selection index in composer. @@ -409,8 +422,6 @@ pub struct App { pub task_panel: Vec, /// Whether the UI needs to be redrawn. pub needs_redraw: bool, - /// Session start time for elapsed-time display in the footer. - pub session_start: Instant, /// When the current thinking block started (for duration tracking). pub thinking_started_at: Option, /// Whether context compaction is currently in progress. @@ -504,8 +515,12 @@ impl App { let needs_onboarding = !skip_onboarding && (!was_onboarded || needs_api_key); let settings = Settings::load().unwrap_or_else(|_| Settings::default()); let auto_compact = settings.auto_compact; + let calm_mode = settings.calm_mode; + let low_motion = settings.low_motion; let show_thinking = settings.show_thinking; let show_tool_details = settings.show_tool_details; + let composer_density = ComposerDensity::from_setting(&settings.composer_density); + let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing); let sidebar_width_percent = settings.sidebar_width_percent; let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus); let max_input_history = settings.max_input_history; @@ -534,14 +549,6 @@ impl App { }; let allow_shell = allow_shell || initial_mode == AppMode::Yolo; - let history = if needs_onboarding { - Vec::new() // No welcome message during onboarding - } else { - vec![HistoryCell::System { - content: format_welcome_banner(&model, &workspace, yolo), - }] - }; - // Initialize hooks executor from config let hooks_config = config.hooks_config(); let hooks = HookExecutor::new(hooks_config, workspace.clone()); @@ -549,8 +556,6 @@ impl App { // Initialize plan state let plan_state = new_shared_plan_state(); - let history_len = history.len() as u64; - let agents_skills_dir = workspace.join(".agents").join("skills"); let local_skills_dir = workspace.join("skills"); let skills_dir = if agents_skills_dir.exists() { @@ -566,8 +571,8 @@ impl App { input: String::new(), cursor_position: 0, paste_burst: PasteBurst::default(), - history, - history_version: history_len, + history: Vec::new(), + history_version: 0, api_messages: Vec::new(), transcript_scroll: TranscriptScroll::ToBottom, pending_scroll_delta: 0, @@ -593,8 +598,12 @@ impl App { input_history: Vec::new(), history_index: None, auto_compact, + calm_mode, + low_motion, show_thinking, show_tool_details, + composer_density, + transcript_spacing, sidebar_width_percent, sidebar_focus, slash_menu_selected: 0, @@ -665,7 +674,6 @@ impl App { workspace_context_refreshed_at: None, task_panel: Vec::new(), needs_redraw: true, - session_start: Instant::now(), thinking_started_at: None, is_compacting: false, last_send_at: None, @@ -694,9 +702,7 @@ impl App { if let Err(err) = crate::tui::onboarding::mark_onboarded() { self.status_message = Some(format!("Failed to mark onboarding: {err}")); } - self.add_message(HistoryCell::System { - content: format_welcome_banner(&self.model, &self.workspace, self.yolo), - }); + self.needs_redraw = true; } pub fn set_mode(&mut self, mode: AppMode) -> bool { @@ -743,22 +749,20 @@ impl App { true } - /// Cycle through modes: Normal -> Agent -> YOLO -> Plan + /// Cycle through modes: Plan -> Agent -> YOLO pub fn cycle_mode(&mut self) { let next = match self.mode { - AppMode::Normal => AppMode::Agent, + AppMode::Plan => AppMode::Agent, AppMode::Agent => AppMode::Yolo, AppMode::Yolo => AppMode::Plan, - AppMode::Plan => AppMode::Normal, }; let _ = self.set_mode(next); } - /// Cycle through modes in reverse: Plan -> YOLO -> Agent -> Normal + /// Cycle through modes in reverse: YOLO -> Agent -> Plan pub fn cycle_mode_reverse(&mut self) { let next = match self.mode { - AppMode::Normal => AppMode::Plan, - AppMode::Agent => AppMode::Normal, + AppMode::Agent => AppMode::Plan, AppMode::Yolo => AppMode::Agent, AppMode::Plan => AppMode::Yolo, }; @@ -935,6 +939,9 @@ impl App { TranscriptRenderOptions { show_thinking: self.show_thinking, show_tool_details: self.show_tool_details, + calm_mode: self.calm_mode, + low_motion: self.low_motion, + spacing: self.transcript_spacing, } } @@ -1183,24 +1190,6 @@ impl App { self.queued_messages.remove(index) } - pub fn queued_message_previews(&self, max: usize) -> Vec { - if max == 0 { - return Vec::new(); - } - - let mut previews: Vec = self - .queued_messages - .iter() - .take(max) - .map(|msg| msg.display.clone()) - .collect(); - let extra = self.queued_messages.len().saturating_sub(previews.len()); - if extra > 0 { - previews.push(format!("+{extra} more")); - } - previews - } - pub fn queued_message_count(&self) -> usize { self.queued_messages.len() } @@ -1344,6 +1333,13 @@ mod tests { ); } + #[test] + fn app_starts_without_seeded_transcript_messages() { + let app = App::new(test_options(false), &Config::default()); + assert!(app.history.is_empty()); + assert_eq!(app.history_version, 0); + } + #[test] fn clear_todos_resets_plan_state() { let mut app = App::new(test_options(false), &Config::default()); @@ -1390,7 +1386,7 @@ mod tests { app.cycle_mode_reverse(); assert_eq!(app.mode, AppMode::Yolo); - app.mode = AppMode::Normal; + app.mode = AppMode::Agent; app.cycle_mode_reverse(); assert_eq!(app.mode, AppMode::Plan); } diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index 48bd1bb6..9076d705 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -219,7 +219,6 @@ fn command_runs_directly(name: &str) -> bool { | "export" | "config" | "yolo" - | "normal" | "agent" | "plan" | "trust" diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index cf322244..5952ed1a 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -11,16 +11,28 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::models::{ContentBlock, Message}; use crate::palette; use crate::tools::review::ReviewOutput; +use crate::tui::app::TranscriptSpacing; use crate::tui::diff_render; use crate::tui::markdown_render; // === Constants === -const TOOL_COMMAND_LINE_LIMIT: usize = 5; -const TOOL_OUTPUT_LINE_LIMIT: usize = 12; -const TOOL_TEXT_LIMIT: usize = 240; -const TOOL_RUNNING_SYMBOLS: [&str; 3] = ["◌", "◍", "◉"]; -const TOOL_STATUS_SYMBOL_MS: u64 = 900; +const TOOL_COMMAND_LINE_LIMIT: usize = 3; +const TOOL_OUTPUT_LINE_LIMIT: usize = 6; +const TOOL_TEXT_LIMIT: usize = 180; +const TOOL_RUNNING_SYMBOLS: [&str; 4] = ["·", "◦", "•", "◦"]; +const TOOL_STATUS_SYMBOL_MS: u64 = 1_800; +const TOOL_CARD_SUMMARY_LINES: usize = 4; +const THINKING_SUMMARY_LINE_LIMIT: usize = 4; +const TOOL_DONE_SYMBOL: &str = "•"; +const TOOL_FAILED_SYMBOL: &str = "•"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ThinkingVisualState { + Live, + Done, + Idle, +} // === History Cells === @@ -49,6 +61,9 @@ pub enum HistoryCell { pub struct TranscriptRenderOptions { pub show_thinking: bool, pub show_tool_details: bool, + pub calm_mode: bool, + pub low_motion: bool, + pub spacing: TranscriptSpacing, } impl Default for TranscriptRenderOptions { @@ -56,6 +71,9 @@ impl Default for TranscriptRenderOptions { Self { show_thinking: true, show_tool_details: true, + calm_mode: false, + low_motion: false, + spacing: TranscriptSpacing::Comfortable, } } } @@ -64,19 +82,33 @@ impl HistoryCell { /// Render the cell into a set of terminal lines. pub fn lines(&self, width: u16) -> Vec> { match self { - HistoryCell::User { content } => render_message("▸ You", content, user_style(), width), - HistoryCell::Assistant { content, .. } => { - render_message("◆ Answer", content, assistant_style(), width) - } - HistoryCell::System { content } => { - render_message("● System", content, system_style(), width) - } + HistoryCell::User { content } => render_message( + "You", + user_label_style(), + message_body_style(), + content, + width, + ), + HistoryCell::Assistant { content, .. } => render_message( + "Assistant", + assistant_label_style(), + message_body_style(), + content, + width, + ), + HistoryCell::System { content } => render_message( + "Note", + system_label_style(), + system_body_style(), + content, + width, + ), HistoryCell::Thinking { content, streaming, duration_secs, - } => render_thinking(content, width, *streaming, *duration_secs), - HistoryCell::Tool(cell) => cell.lines(width), + } => render_thinking(content, width, *streaming, *duration_secs, false, false), + HistoryCell::Tool(cell) => cell.lines_with_motion(width, false), } } @@ -87,14 +119,37 @@ impl HistoryCell { ) -> Vec> { match self { HistoryCell::Thinking { .. } if !options.show_thinking => Vec::new(), + HistoryCell::Thinking { + content, + streaming, + duration_secs, + } => render_thinking( + content, + width, + *streaming, + *duration_secs, + !*streaming, + options.low_motion, + ), HistoryCell::Tool(cell) if !options.show_tool_details => { - let mut lines = cell.lines(width); + let mut lines = cell.lines_with_motion(width, options.low_motion); if lines.len() > 2 { lines.truncate(2); - lines.push(Line::from(Span::styled( - " … details hidden (show_tool_details=off)", + lines.push(details_affordance_line( + "details hidden", Style::default().fg(palette::TEXT_MUTED).italic(), - ))); + )); + } + lines + } + HistoryCell::Tool(cell) if options.calm_mode => { + let mut lines = cell.lines_with_motion(width, options.low_motion); + if lines.len() > TOOL_CARD_SUMMARY_LINES { + lines.truncate(TOOL_CARD_SUMMARY_LINES); + lines.push(details_affordance_line( + "press v for details", + Style::default().fg(palette::TEXT_MUTED).italic(), + )); } lines } @@ -211,17 +266,21 @@ pub enum ToolCell { impl ToolCell { /// Render the tool cell into lines. pub fn lines(&self, width: u16) -> Vec> { + self.lines_with_motion(width, false) + } + + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { match self { - ToolCell::Exec(cell) => cell.lines(width), - ToolCell::Exploring(cell) => cell.lines(width), - ToolCell::PlanUpdate(cell) => cell.lines(width), - ToolCell::PatchSummary(cell) => cell.lines(width), - ToolCell::Review(cell) => cell.lines(width), - ToolCell::DiffPreview(cell) => cell.lines(width), - ToolCell::Mcp(cell) => cell.lines(width), - ToolCell::ViewImage(cell) => cell.lines(width), - ToolCell::WebSearch(cell) => cell.lines(width), - ToolCell::Generic(cell) => cell.lines(width), + ToolCell::Exec(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::Exploring(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::PlanUpdate(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::PatchSummary(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::Review(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::DiffPreview(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::Mcp(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::ViewImage(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::WebSearch(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::Generic(cell) => cell.lines_with_motion(width, low_motion), } } } @@ -248,24 +307,24 @@ pub struct ExecCell { impl ExecCell { /// Render the execution cell into lines. - pub fn lines(&self, width: u16) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - let (label, color) = match self.status { - ToolStatus::Running => ("Running", palette::STATUS_WARNING), - ToolStatus::Success => match self.source { - ExecSource::User => ("You ran", palette::STATUS_SUCCESS), - ExecSource::Assistant => ("Ran", palette::STATUS_SUCCESS), - }, - ToolStatus::Failed => ("Failed", palette::STATUS_ERROR), - }; - let dot = status_symbol(self.started_at, self.status); - lines.push(Line::from(vec![ - Span::styled(format!("{dot} "), Style::default().fg(color)), - Span::styled( - label, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ), - ])); + lines.push(render_tool_header( + "Shell", + tool_status_label(self.status), + self.status, + self.started_at, + low_motion, + )); + + if self.status == ToolStatus::Success && self.source == ExecSource::User { + lines.extend(render_compact_kv( + "source", + "started by you", + Style::default().fg(palette::TEXT_MUTED), + width, + )); + } if let Some(interaction) = self.interaction.as_ref() { lines.extend(wrap_plain_line( @@ -290,10 +349,12 @@ impl ExecCell { if let Some(duration_ms) = self.duration_ms { let seconds = f64::from(u32::try_from(duration_ms).unwrap_or(u32::MAX)) / 1000.0; - lines.push(Line::from(Span::styled( - format!(" {seconds:.2}s"), - Style::default().fg(palette::TEXT_MUTED), - ))); + lines.extend(render_compact_kv( + "time", + &format!("{seconds:.2}s"), + Style::default().fg(palette::TEXT_DIM), + width, + )); } lines @@ -315,33 +376,37 @@ pub struct ExploringCell { impl ExploringCell { /// Render the exploring cell into lines. - pub fn lines(&self, width: u16) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); let all_done = self .entries .iter() .all(|entry| entry.status != ToolStatus::Running); - let header = if all_done { "Explored" } else { "Exploring" }; - lines.push(Line::from(Span::styled( - header, - Style::default() - .fg(palette::DEEPSEEK_SKY) - .add_modifier(Modifier::BOLD), - ))); + let status = if all_done { + ToolStatus::Success + } else { + ToolStatus::Running + }; + lines.push(render_tool_header( + "Workspace", + if all_done { "done" } else { "running" }, + status, + None, + low_motion, + )); for entry in &self.entries { let prefix = match entry.status { - ToolStatus::Running => "•", - ToolStatus::Success => "ok", - ToolStatus::Failed => "!!", + ToolStatus::Running => "live", + ToolStatus::Success => "done", + ToolStatus::Failed => "issue", }; - let style = match entry.status { - ToolStatus::Running => Style::default().fg(palette::DEEPSEEK_SKY), - ToolStatus::Success => Style::default().fg(palette::STATUS_SUCCESS), - ToolStatus::Failed => Style::default().fg(palette::STATUS_ERROR), - }; - let line = format!(" {} {}", prefix, entry.label); - lines.extend(wrap_plain_line(&line, style, width)); + lines.extend(render_compact_kv( + prefix, + &entry.label, + tool_value_style(), + width, + )); } lines } @@ -371,33 +436,36 @@ pub struct PlanUpdateCell { impl PlanUpdateCell { /// Render the plan update cell into lines. - pub fn lines(&self, width: u16) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - let header = match self.status { - ToolStatus::Running => "Updating Plan", - _ => "Updated Plan", - }; - lines.push(Line::from(Span::styled( - header, - Style::default() - .fg(palette::DEEPSEEK_BLUE) - .add_modifier(Modifier::BOLD), - ))); + lines.push(render_tool_header( + "Plan", + tool_status_label(self.status), + self.status, + None, + low_motion, + )); if let Some(explanation) = self.explanation.as_ref() { - lines.extend(render_message(" ", explanation, system_style(), width)); + lines.extend(render_message( + "", + system_label_style(), + system_body_style(), + explanation, + width, + )); } for step in &self.steps { let marker = match step.status.as_str() { - "completed" => "[x]", - "in_progress" => "[~]", - _ => "[ ]", + "completed" => "done", + "in_progress" => "live", + _ => "next", }; - let line = format!(" {} {}", marker, step.step); - lines.extend(wrap_plain_line( - &line, - Style::default().fg(palette::DEEPSEEK_BLUE), + lines.extend(render_compact_kv( + marker, + &step.step, + tool_value_style(), width, )); } @@ -424,25 +492,19 @@ pub struct PatchSummaryCell { impl PatchSummaryCell { /// Render the patch summary cell into lines. - pub fn lines(&self, width: u16) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - let header = match self.status { - ToolStatus::Running => "Applying Patch", - ToolStatus::Success => "Patch Applied", - ToolStatus::Failed => "Patch Failed", - }; - let color = match self.status { - ToolStatus::Running => palette::STATUS_WARNING, - ToolStatus::Success => palette::STATUS_SUCCESS, - ToolStatus::Failed => palette::STATUS_ERROR, - }; - lines.push(Line::from(Span::styled( - header, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ))); - lines.extend(wrap_plain_line( - &format!(" {}", self.path), - Style::default().fg(palette::TEXT_MUTED), + lines.push(render_tool_header( + "Patch", + tool_status_label(self.status), + self.status, + None, + low_motion, + )); + lines.extend(render_compact_kv( + "file", + &self.path, + tool_value_style(), width, )); lines.extend(render_tool_output( @@ -467,22 +529,21 @@ pub struct ReviewCell { } impl ReviewCell { - pub fn lines(&self, width: u16) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - let (label, color) = match self.status { - ToolStatus::Running => ("Reviewing", palette::STATUS_WARNING), - ToolStatus::Success => ("Review Complete", palette::STATUS_SUCCESS), - ToolStatus::Failed => ("Review Failed", palette::STATUS_ERROR), - }; - lines.push(Line::from(Span::styled( - label, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ))); + lines.push(render_tool_header( + "Review", + tool_status_label(self.status), + self.status, + None, + low_motion, + )); if !self.target.trim().is_empty() { - lines.extend(wrap_plain_line( - &format!(" Target: {}", self.target.trim()), - Style::default().fg(palette::TEXT_MUTED), + lines.extend(render_compact_kv( + "target", + self.target.trim(), + tool_value_style(), width, )); } @@ -592,14 +653,21 @@ pub struct DiffPreviewCell { } impl DiffPreviewCell { - pub fn lines(&self, width: u16) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - lines.push(Line::from(Span::styled( - self.title.clone(), - Style::default() - .fg(palette::DEEPSEEK_BLUE) - .add_modifier(Modifier::BOLD), - ))); + lines.push(render_tool_header( + "Diff", + "done", + ToolStatus::Success, + None, + low_motion, + )); + lines.extend(render_compact_kv( + "title", + &self.title, + tool_value_style(), + width, + )); lines.extend(diff_render::render_diff(&self.diff, width)); lines } @@ -616,27 +684,29 @@ pub struct McpToolCell { impl McpToolCell { /// Render the MCP tool cell into lines. - pub fn lines(&self, width: u16) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - let header = match self.status { - ToolStatus::Running => format!("Calling {}", self.tool), - _ => format!("Called {}", self.tool), - }; - let color = if self.status == ToolStatus::Failed { - palette::STATUS_ERROR - } else { - palette::DEEPSEEK_SKY - }; - lines.push(Line::from(Span::styled( - header, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ))); + lines.push(render_tool_header( + "Tool", + tool_status_label(self.status), + self.status, + None, + low_motion, + )); + lines.extend(render_compact_kv( + "name", + &self.tool, + tool_value_style(), + width, + )); if self.is_image { - lines.push(Line::from(Span::styled( - " (image result)", - Style::default().fg(palette::TEXT_MUTED), - ))); + lines.extend(render_compact_kv( + "result", + "image", + tool_value_style(), + width, + )); } if let Some(content) = self.content.as_ref() { @@ -654,9 +724,21 @@ pub struct ViewImageCell { impl ViewImageCell { /// Render the image view cell into lines. - pub fn lines(&self, width: u16) -> Vec> { - let header = format!("Viewed Image {}", self.path.display()); - wrap_plain_line(&header, Style::default().fg(palette::DEEPSEEK_SKY), width) + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + let mut lines = vec![render_tool_header( + "Image", + "done", + ToolStatus::Success, + None, + low_motion, + )]; + lines.extend(render_compact_kv( + "path", + &self.path.display().to_string(), + tool_value_style(), + width, + )); + lines } } @@ -670,28 +752,26 @@ pub struct WebSearchCell { impl WebSearchCell { /// Render the web search cell into lines. - pub fn lines(&self, width: u16) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - let header = match self.status { - ToolStatus::Running => "Searching", - _ => "Searched", - }; - lines.push(Line::from(Span::styled( - header, - Style::default() - .fg(palette::DEEPSEEK_BLUE) - .add_modifier(Modifier::BOLD), - ))); - lines.extend(wrap_plain_line( - &format!(" {}", self.query), - Style::default().fg(palette::TEXT_MUTED), + lines.push(render_tool_header( + "Search", + tool_status_label(self.status), + self.status, + None, + low_motion, + )); + lines.extend(render_compact_kv( + "query", + &self.query, + tool_value_style(), width, )); if let Some(summary) = self.summary.as_ref() { lines.extend(render_compact_kv( - "result:", + "result", summary, - Style::default().fg(palette::TEXT_MUTED), + tool_value_style(), width, )); } @@ -710,37 +790,37 @@ pub struct GenericToolCell { impl GenericToolCell { /// Render the generic tool cell into lines. - pub fn lines(&self, width: u16) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - let header = match self.status { - ToolStatus::Running => format!("Calling {}", self.name), - _ => format!("Called {}", self.name), - }; - let color = if self.status == ToolStatus::Failed { - palette::STATUS_ERROR - } else { - palette::DEEPSEEK_SKY - }; - lines.push(Line::from(Span::styled( - header, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ))); + lines.push(render_tool_header( + "Tool", + tool_status_label(self.status), + self.status, + None, + low_motion, + )); + lines.extend(render_compact_kv( + "name", + &self.name, + tool_value_style(), + width, + )); let show_args = matches!(self.status, ToolStatus::Running) || self.output.is_none(); if show_args && let Some(summary) = self.input_summary.as_ref() { lines.extend(render_compact_kv( - "args:", + "args", summary, - Style::default().fg(palette::TEXT_MUTED), + tool_value_style(), width, )); } if let Some(output) = self.output.as_ref() { - let style = if self.status == ToolStatus::Failed { - Style::default().fg(palette::STATUS_ERROR) - } else { - Style::default().fg(palette::TEXT_MUTED) - }; - lines.extend(render_compact_kv("result:", output, style, width)); + lines.extend(render_compact_kv( + "result", + output, + tool_value_style(), + width, + )); } lines } @@ -1001,7 +1081,6 @@ pub fn output_is_image(output: &str) -> bool { .any(|ext| lower.contains(ext)) } -#[cfg(test)] #[must_use] pub fn extract_reasoning_summary(text: &str) -> Option { let mut lines = text.lines().peekable(); @@ -1048,90 +1127,102 @@ fn render_thinking( width: u16, streaming: bool, duration_secs: Option, + collapsed: bool, + low_motion: bool, ) -> Vec> { + let state = thinking_visual_state(streaming, duration_secs); let style = thinking_style(); - let border_style = Style::default().fg(palette::STATUS_WARNING); - let w = usize::from(width); - - // Top border: ┌─ THINKING (3.2s) ───────┐ - let label = if streaming { - " THINKING ".to_string() - } else if let Some(dur) = duration_secs { - format!(" THINKING ({dur:.1}s) ") - } else { - " THINKING ".to_string() - }; - let label_width = UnicodeWidthStr::width(label.as_str()); - let fill = w.saturating_sub(2 + label_width); // 2 for ┌ and ┐ - let top_line = format!("┌{}{}{}", "─".repeat(0), label, "─".repeat(fill)); - let top_line = if top_line.chars().count() < w { - format!("{}┐", &top_line) - } else { - truncate_to_width(&top_line, w) - }; - let mut lines = Vec::new(); - lines.push(Line::from(Span::styled(top_line, border_style))); + let mut header_spans = vec![ + Span::styled( + format!("{} ", thinking_symbol(state, low_motion)), + Style::default().fg(thinking_state_accent(state)), + ), + Span::styled("thinking", thinking_title_style()), + ]; + header_spans.push(Span::styled(" ", Style::default())); + header_spans.push(Span::styled( + thinking_status_label(state), + thinking_status_style(state), + )); + if let Some(dur) = duration_secs { + header_spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM))); + header_spans.push(Span::styled(format!("{dur:.1}s"), thinking_meta_style())); + } + lines.push(Line::from(header_spans)); - // Content with │ prefix - let content_width = width.saturating_sub(4).max(1); // 4 for "│ " and " │" - let rendered = markdown_render::render_markdown(content, content_width, style); + let content_width = width.saturating_sub(3).max(1); + let body_text = if collapsed { + extract_reasoning_summary(content).unwrap_or_else(|| content.trim().to_string()) + } else { + content.to_string() + }; + let mut rendered = markdown_render::render_markdown(&body_text, content_width, style); + let mut truncated = false; + if collapsed && rendered.len() > THINKING_SUMMARY_LINE_LIMIT { + rendered.truncate(THINKING_SUMMARY_LINE_LIMIT); + truncated = true; + } if rendered.is_empty() && streaming { - // Show animated placeholder while waiting for first content lines.push(Line::from(vec![ - Span::styled("│ ", border_style), - Span::styled("...", style), + Span::styled("▏ ", Style::default().fg(thinking_state_accent(state))), + Span::styled("reasoning in progress...", style.italic()), ])); } for line in rendered { - let mut spans = vec![Span::styled("│ ", border_style)]; + let mut spans = vec![Span::styled( + "▏ ", + Style::default().fg(thinking_state_accent(state)), + )]; spans.extend(line.spans); lines.push(Line::from(spans)); } - // Bottom border: └────────────────────────┘ - let bottom_fill = w.saturating_sub(2); // 2 for └ and ┘ - let bottom_line = format!("└{}┘", "─".repeat(bottom_fill)); - lines.push(Line::from(Span::styled( - truncate_to_width(&bottom_line, w), - border_style, - ))); + if collapsed && (!streaming && (truncated || body_text.trim() != content.trim())) { + lines.push(Line::from(vec![ + Span::styled("▏ ", Style::default().fg(thinking_state_accent(state))), + Span::styled( + "summary only; press v for details", + Style::default().fg(palette::TEXT_MUTED).italic(), + ), + ])); + } lines } -fn truncate_to_width(s: &str, max_width: usize) -> String { - let mut out = String::new(); - let mut w = 0; - for ch in s.chars() { - let cw = UnicodeWidthChar::width(ch).unwrap_or(0); - if w + cw > max_width { - break; - } - out.push(ch); - w += cw; - } - out -} - -fn render_message(prefix: &str, content: &str, style: Style, width: u16) -> Vec> { +fn render_message( + prefix: &str, + label_style: Style, + body_style: Style, + content: &str, + width: u16, +) -> Vec> { let prefix_width = UnicodeWidthStr::width(prefix); let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX); let content_width = usize::from(width.saturating_sub(prefix_width_u16).max(1)); let mut lines = Vec::new(); - let rendered = markdown_render::render_markdown(content, content_width as u16, style); + let rendered = markdown_render::render_markdown(content, content_width as u16, body_style); for (idx, line) in rendered.into_iter().enumerate() { if idx == 0 { - let mut spans = vec![ - Span::styled(prefix.to_string(), style.add_modifier(Modifier::BOLD)), - Span::raw(" "), - ]; + let mut spans = Vec::new(); + if !prefix.is_empty() { + spans.push(Span::styled( + prefix.to_string(), + label_style.add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + } spans.extend(line.spans); lines.push(Line::from(spans)); } else { - let indent = " ".repeat(prefix_width + 1); + let indent = if prefix.is_empty() { + String::new() + } else { + " ".repeat(prefix_width + 1) + }; let mut spans = vec![Span::raw(indent)]; spans.extend(line.spans); lines.push(Line::from(spans)); @@ -1150,23 +1241,24 @@ fn render_command(command: &str, width: u16) -> Vec> { .enumerate() { if count >= TOOL_COMMAND_LINE_LIMIT { - lines.push(Line::from(Span::styled( - " ...", + lines.push(details_affordance_line( + "command clipped; press v for details", Style::default().fg(palette::TEXT_MUTED), - ))); + )); break; } - lines.push(Line::from(vec![ - Span::styled(" $ ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled(chunk, Style::default().fg(palette::TEXT_PRIMARY)), - ])); + lines.extend(render_card_detail_line( + if count == 0 { Some("command") } else { None }, + chunk.as_str(), + tool_value_style(), + width, + )); } lines } fn render_compact_kv(label: &str, value: &str, style: Style, width: u16) -> Vec> { - let line = format!(" {label} {value}"); - wrap_plain_line(&line, style, width) + render_card_detail_line(Some(label.trim_end_matches(':')), value, style, width) } fn render_tool_output(output: &str, width: u16, line_limit: usize) -> Vec> { @@ -1187,17 +1279,19 @@ fn render_tool_output(output: &str, width: u16, line_limit: usize) -> Vec= line_limit { let omitted = total.saturating_sub(line_limit); if omitted > 0 { - lines.push(Line::from(Span::styled( - format!(" ... +{omitted} lines"), + lines.push(details_affordance_line( + &format!("+{omitted} more lines; press v for details"), Style::default().fg(palette::TEXT_MUTED), - ))); + )); } break; } - lines.push(Line::from(vec![ - Span::styled(" | ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled(line, Style::default().fg(palette::TEXT_MUTED)), - ])); + lines.extend(render_card_detail_line( + if idx == 0 { Some("result") } else { None }, + &line, + tool_value_style(), + width, + )); } lines } @@ -1237,32 +1331,38 @@ fn render_exec_output(output: &str, width: u16, line_limit: usize) -> Vec 2 * line_limit { let omitted = total.saturating_sub(2 * line_limit); - lines.push(Line::from(Span::styled( - format!(" ... +{omitted} lines"), + lines.push(details_affordance_line( + &format!("+{omitted} more lines; press v for details"), Style::default().fg(palette::TEXT_MUTED), - ))); + )); let tail_start = total.saturating_sub(line_limit); for line in &all_lines[tail_start..] { - lines.push(Line::from(vec![ - Span::styled(" | ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled(line.to_string(), Style::default().fg(palette::TEXT_MUTED)), - ])); + lines.extend(render_card_detail_line( + None, + line, + tool_value_style(), + width, + )); } } else if total > head_end { for line in &all_lines[head_end..] { - lines.push(Line::from(vec![ - Span::styled(" | ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled(line.to_string(), Style::default().fg(palette::TEXT_MUTED)), - ])); + lines.extend(render_card_detail_line( + None, + line, + tool_value_style(), + width, + )); } } @@ -1314,10 +1414,20 @@ fn wrap_text(text: &str, width: usize) -> Vec { } } -fn status_symbol(started_at: Option, status: ToolStatus) -> String { +fn status_symbol(started_at: Option, status: ToolStatus, low_motion: bool) -> String { match status { ToolStatus::Running => { - let elapsed_ms = started_at.map_or(0, |t| t.elapsed().as_millis()); + if low_motion { + return TOOL_RUNNING_SYMBOLS[0].to_string(); + } + let elapsed_ms = started_at.map_or_else( + || { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_millis()) + }, + |t| t.elapsed().as_millis(), + ); let cycle = u128::from(TOOL_STATUS_SYMBOL_MS); let idx = if cycle == 0 { 0 @@ -1326,11 +1436,18 @@ fn status_symbol(started_at: Option, status: ToolStatus) -> String { }; TOOL_RUNNING_SYMBOLS[usize::try_from(idx).unwrap_or_default()].to_string() } - ToolStatus::Success => "o".to_string(), - ToolStatus::Failed => "x".to_string(), + ToolStatus::Success => TOOL_DONE_SYMBOL.to_string(), + ToolStatus::Failed => TOOL_FAILED_SYMBOL.to_string(), } } +fn details_affordance_line(text: &str, style: Style) -> Line<'static> { + Line::from(vec![ + Span::styled("▏ ", Style::default().fg(palette::TEXT_DIM)), + Span::styled(format!("{text}"), style), + ]) +} + fn truncate_text(text: &str, max_len: usize) -> String { if text.chars().count() <= max_len { return text.to_string(); @@ -1343,29 +1460,175 @@ fn truncate_text(text: &str, max_len: usize) -> String { out } -fn user_style() -> Style { - Style::default() - .fg(palette::TEXT_PRIMARY) - .add_modifier(Modifier::BOLD) +fn user_label_style() -> Style { + Style::default().fg(palette::TEXT_MUTED) } -fn assistant_style() -> Style { +fn assistant_label_style() -> Style { Style::default().fg(palette::DEEPSEEK_SKY) } -fn system_style() -> Style { +fn system_label_style() -> Style { + Style::default().fg(palette::TEXT_DIM) +} + +fn message_body_style() -> Style { + Style::default().fg(palette::TEXT_PRIMARY) +} + +fn system_body_style() -> Style { Style::default().fg(palette::TEXT_MUTED).italic() } fn thinking_style() -> Style { + Style::default().fg(palette::TEXT_TOOL_OUTPUT) +} + +fn render_tool_header( + title: &str, + state: &str, + status: ToolStatus, + started_at: Option, + low_motion: bool, +) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!("{} ", status_symbol(started_at, status, low_motion)), + Style::default().fg(tool_state_color(status)), + ), + Span::styled(title.to_string(), tool_title_style()), + Span::styled(" ", Style::default()), + Span::styled(state.to_string(), tool_status_style(status)), + ]) +} + +fn render_card_detail_line( + label: Option<&str>, + value: &str, + value_style: Style, + width: u16, +) -> Vec> { + let label_text = label.map(|text| format!("{text}:")); + let prefix_width = UnicodeWidthStr::width("▏ ") + + label_text.as_deref().map_or(0, UnicodeWidthStr::width) + + usize::from(label.is_some()); + let content_width = usize::from(width).saturating_sub(prefix_width).max(1); + + let mut lines = Vec::new(); + for (idx, part) in wrap_text(value, content_width).into_iter().enumerate() { + let mut spans = vec![Span::styled("▏ ", Style::default().fg(palette::TEXT_DIM))]; + if idx == 0 { + if let Some(label_text) = label_text.as_deref() { + spans.push(Span::styled( + label_text.to_string(), + tool_detail_label_style(), + )); + spans.push(Span::raw(" ")); + } + } else if let Some(label_text) = label_text.as_deref() { + spans.push(Span::raw( + " ".repeat(UnicodeWidthStr::width(label_text) + 1), + )); + } + spans.push(Span::styled(part, value_style)); + lines.push(Line::from(spans)); + } + lines +} + +fn tool_title_style() -> Style { Style::default() - .fg(palette::TEXT_PRIMARY) - .add_modifier(Modifier::ITALIC) + .fg(palette::TEXT_SOFT) + .add_modifier(Modifier::BOLD) +} + +fn tool_status_style(status: ToolStatus) -> Style { + Style::default().fg(match status { + ToolStatus::Running => palette::ACCENT_TOOL_LIVE, + ToolStatus::Success => palette::TEXT_DIM, + ToolStatus::Failed => palette::ACCENT_TOOL_ISSUE, + }) +} + +fn tool_detail_label_style() -> Style { + Style::default().fg(palette::TEXT_DIM) +} + +fn tool_state_color(status: ToolStatus) -> Color { + match status { + ToolStatus::Running => palette::ACCENT_TOOL_LIVE, + ToolStatus::Success => palette::TEXT_DIM, + ToolStatus::Failed => palette::ACCENT_TOOL_ISSUE, + } +} + +fn tool_status_label(status: ToolStatus) -> &'static str { + match status { + ToolStatus::Running => "running", + ToolStatus::Success => "done", + ToolStatus::Failed => "issue", + } +} + +fn tool_value_style() -> Style { + Style::default().fg(palette::TEXT_MUTED) +} + +fn thinking_visual_state(streaming: bool, duration_secs: Option) -> ThinkingVisualState { + if streaming { + ThinkingVisualState::Live + } else if duration_secs.is_some() { + ThinkingVisualState::Done + } else { + ThinkingVisualState::Idle + } +} + +fn thinking_status_label(state: ThinkingVisualState) -> &'static str { + match state { + ThinkingVisualState::Live => "live", + ThinkingVisualState::Done => "done", + ThinkingVisualState::Idle => "idle", + } +} + +fn thinking_symbol(state: ThinkingVisualState, low_motion: bool) -> String { + match state { + ThinkingVisualState::Live => status_symbol(None, ToolStatus::Running, low_motion), + ThinkingVisualState::Done => "◦".to_string(), + ThinkingVisualState::Idle => "·".to_string(), + } +} + +fn thinking_title_style() -> Style { + Style::default() + .fg(palette::TEXT_SOFT) + .add_modifier(Modifier::BOLD) +} + +fn thinking_status_style(state: ThinkingVisualState) -> Style { + Style::default().fg(match state { + ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE, + ThinkingVisualState::Done => palette::TEXT_DIM, + ThinkingVisualState::Idle => palette::TEXT_DIM, + }) +} + +fn thinking_meta_style() -> Style { + Style::default().fg(palette::TEXT_DIM) +} + +fn thinking_state_accent(state: ThinkingVisualState) -> Color { + match state { + ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE, + ThinkingVisualState::Done => palette::TEXT_DIM, + ThinkingVisualState::Idle => palette::TEXT_DIM, + } } #[cfg(test)] mod tests { - use super::extract_reasoning_summary; + use super::{extract_reasoning_summary, render_thinking}; #[test] fn extract_reasoning_summary_prefers_summary_block() { @@ -1380,4 +1643,22 @@ mod tests { let summary = extract_reasoning_summary(text).expect("summary should exist"); assert_eq!(summary, "Line one\nLine two"); } + + #[test] + fn render_thinking_collapsed_shows_details_affordance() { + let lines = render_thinking( + "Summary: First line\nSecond line\nThird line\nFourth line\nFifth line", + 80, + false, + Some(2.0), + true, + false, + ); + let text = lines + .iter() + .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref())) + .collect::(); + assert!(text.contains("summary only; press v for details")); + assert!(text.contains("thinking")); + } } diff --git a/crates/tui/src/tui/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs index 4e89941f..6f805759 100644 --- a/crates/tui/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -11,7 +11,7 @@ use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Paragraph, Wrap}, + widgets::{Block, Borders, Padding, Paragraph, Wrap}, }; use crate::palette; @@ -21,8 +21,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let block = Block::default().style(Style::default().bg(palette::DEEPSEEK_INK)); f.render_widget(block, area); - let content_width = 72.min(area.width.saturating_sub(4)); - let content_height = 26.min(area.height.saturating_sub(4)); + let content_width = 76.min(area.width.saturating_sub(4)); + let content_height = 20.min(area.height.saturating_sub(4)); let content_area = Rect { x: (area.width - content_width) / 2, y: (area.height - content_height) / 2, @@ -40,18 +40,27 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { if !lines.is_empty() { let (step, total) = onboarding_step(app); - let mut decorated = vec![ - Line::from(Span::styled( - format!("Step {step}/{total}"), + let panel = Block::default() + .title(Line::from(Span::styled( + " DeepSeek TUI ", + Style::default() + .fg(palette::DEEPSEEK_BLUE) + .add_modifier(Modifier::BOLD), + ))) + .title_bottom(Line::from(Span::styled( + format!(" Step {step}/{total} "), Style::default() .fg(palette::TEXT_MUTED) .add_modifier(Modifier::BOLD), - )), - Line::from(""), - ]; - decorated.extend(lines); - let paragraph = Paragraph::new(decorated).wrap(Wrap { trim: false }); - f.render_widget(paragraph, content_area); + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_SLATE)) + .padding(Padding::new(2, 2, 1, 1)); + let inner = panel.inner(content_area); + f.render_widget(panel, content_area); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + f.render_widget(paragraph, inner); } } @@ -88,45 +97,23 @@ pub fn tips_lines() -> Vec> { vec![ Line::from(Span::styled( - "Start With These Workflows", - Style::default() - .fg(palette::DEEPSEEK_SKY) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::raw(" 1. Pick a mode for the task:")), - Line::from(Span::raw( - " Normal asks questions, Agent runs tools, Plan lets you review the approach first.", - )), - Line::from(Span::raw(" 2. Watch the runtime state while work runs:")), - Line::from(Span::raw( - " approvals, queued prompts, and active sub-agents stay visible in the status area.", - )), - Line::from(Span::raw( - " 3. Use /queue when you want to review or edit queued prompts.", - )), - Line::from(Span::raw( - " 4. Use /subagents or the status strip to inspect agent fan-out.", - )), - Line::from(Span::raw( - " 5. Use Ctrl+R or /sessions to resume interrupted work.", - )), - Line::from(""), - Line::from(Span::styled( - "Controls", + "Start Simple", Style::default() .fg(palette::DEEPSEEK_SKY) .add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::raw( - " - F1 help, Ctrl+K command palette, Esc cancel current work", + "Write the task in plain language. Use /help or Ctrl+K when you want a command.", )), Line::from(Span::raw( - " - Tab cycles modes, Alt+1/2/3/4 switches directly", + "The bottom composer is multi-line: Enter sends, Alt+Enter or Ctrl+J adds a new line.", )), Line::from(Span::raw( - " - Alt+!/@/#/$/) focuses Plan/Todos/Tasks/Agents/Auto", + "Switch modes only when the job changes: Plan for review-first work, Agent for execution, YOLO when you want auto-approval.", + )), + Line::from(Span::raw( + "Ctrl+R resumes earlier sessions, and Esc backs out of the current draft or overlay.", )), Line::from(vec![ Span::styled("Press ", Style::default().fg(palette::TEXT_MUTED)), @@ -137,7 +124,7 @@ pub fn tips_lines() -> Vec> { .add_modifier(Modifier::BOLD), ), Span::styled( - " to start working", + " to open the workspace", Style::default().fg(palette::TEXT_MUTED), ), ]), diff --git a/crates/tui/src/tui/onboarding/welcome.rs b/crates/tui/src/tui/onboarding/welcome.rs index 97253abe..3726c6a6 100644 --- a/crates/tui/src/tui/onboarding/welcome.rs +++ b/crates/tui/src/tui/onboarding/welcome.rs @@ -5,66 +5,39 @@ use ratatui::text::{Line, Span}; use crate::palette; -const LOGO: &str = r" -██████╗ ███████╗███████╗██████╗ ███████╗███████╗███████╗██╗ ██╗ -██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝██╔════╝██╔════╝██║ ██╔╝ -██║ ██║█████╗ █████╗ ██████╔╝███████╗█████╗ █████╗ █████╔╝ -██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ╚════██║██╔══╝ ██╔══╝ ██╔═██╗ -██████╔╝███████╗███████╗██║ ███████║███████╗███████╗██║ ██╗ -╚═════╝ ╚══════╝╚══════╝╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝ -"; - pub fn lines() -> Vec> { - let mut lines = Vec::new(); - - for (i, line) in LOGO.lines().enumerate() { - let color = match i % 3 { - 0 => palette::DEEPSEEK_BLUE, - 1 => palette::DEEPSEEK_SKY, - _ => palette::DEEPSEEK_RED, - }; - lines.push(Line::from(Span::styled( - line, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ))); - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("Welcome to ", Style::default().fg(palette::TEXT_PRIMARY)), - Span::styled( + vec![ + Line::from(Span::styled( "DeepSeek TUI", Style::default() .fg(palette::DEEPSEEK_BLUE) .add_modifier(Modifier::BOLD), - ), - ])); - lines.push(Line::from(Span::styled( - format!("Version {}", env!("CARGO_PKG_VERSION")), - Style::default().fg(palette::TEXT_MUTED), - ))); - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Agent workflows for the DeepSeek API in your terminal", - Style::default().fg(palette::TEXT_MUTED), - ))); - lines.push(Line::from(Span::styled( - "Set up your key, pick a mode, and watch approvals, queue state, and agents as work runs.", - Style::default().fg(palette::TEXT_MUTED), - ))); - lines.push(Line::from(Span::styled( - "Not affiliated with DeepSeek Inc.", - Style::default().fg(palette::TEXT_MUTED), - ))); - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Press Enter to start setup.", - Style::default().fg(palette::TEXT_PRIMARY), - ))); - lines.push(Line::from(Span::styled( - "Ctrl+C exits at any point.", - Style::default().fg(palette::TEXT_MUTED), - ))); - - lines + )), + Line::from(Span::styled( + format!("Version {}", env!("CARGO_PKG_VERSION")), + Style::default().fg(palette::TEXT_MUTED), + )), + Line::from(""), + Line::from(Span::styled( + "A focused terminal workspace for longer model sessions.", + Style::default().fg(palette::TEXT_PRIMARY), + )), + Line::from(Span::styled( + "You'll add an API key, review trust for this directory, and then land in the chat.", + Style::default().fg(palette::TEXT_MUTED), + )), + Line::from(Span::styled( + "The main composer is multi-line, so you can write full prompts instead of squeezing everything into one line.", + Style::default().fg(palette::TEXT_MUTED), + )), + Line::from(""), + Line::from(Span::styled( + "Press Enter to continue.", + Style::default().fg(palette::TEXT_PRIMARY), + )), + Line::from(Span::styled( + "Ctrl+C exits at any point.", + Style::default().fg(palette::TEXT_MUTED), + )), + ] } diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index 4a48aa81..c419b0aa 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -1,9 +1,8 @@ //! Cached transcript rendering for the TUI. -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; +use ratatui::text::Line; -use crate::palette; +use crate::tui::app::TranscriptSpacing; use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; use crate::tui::scrolling::TranscriptLineMeta; @@ -61,20 +60,12 @@ impl TranscriptViewCache { }); } - if cell_index + 1 < cells.len() - && !cell.is_stream_continuation() - && cell.is_conversational() - && cells[cell_index + 1].is_conversational() - { - // Add subtle horizontal separator between messages - let separator = Span::styled( - "─".repeat(usize::from(width)), - Style::default() - .fg(palette::TEXT_MUTED) - .add_modifier(Modifier::DIM), - ); - lines.push(Line::from(separator)); - meta.push(TranscriptLineMeta::Spacer); + if let Some(next_cell) = cells.get(cell_index + 1) { + let spacer_rows = spacer_rows_between(cell, next_cell, options.spacing); + for _ in 0..spacer_rows { + lines.push(Line::from("")); + meta.push(TranscriptLineMeta::Spacer); + } } } @@ -100,3 +91,34 @@ impl TranscriptViewCache { self.lines.len() } } + +fn spacer_rows_between( + current: &HistoryCell, + next: &HistoryCell, + spacing: TranscriptSpacing, +) -> usize { + if current.is_stream_continuation() { + return 0; + } + + let conversational_gap = match spacing { + TranscriptSpacing::Compact => 0, + TranscriptSpacing::Comfortable => 1, + TranscriptSpacing::Spacious => 2, + }; + let secondary_gap = match spacing { + TranscriptSpacing::Compact => 0, + TranscriptSpacing::Comfortable => 1, + TranscriptSpacing::Spacious => 1, + }; + + if current.is_conversational() && next.is_conversational() { + conversational_gap + } else if matches!(current, HistoryCell::System { .. } | HistoryCell::Tool(_)) + || matches!(next, HistoryCell::System { .. } | HistoryCell::Tool(_)) + { + secondary_gap + } else { + 0 + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index ccbe576d..82928b22 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -84,27 +84,21 @@ 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 MIN_COMPOSER_HEIGHT: u16 = 3; const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; -const UI_IDLE_POLL_MS: u64 = 33; -const UI_ACTIVE_POLL_MS: u64 = 16; -const UI_DEEPSEEK_SQUIGGLE_MS: u64 = 120; -const UI_TYPING_INDICATOR_MS: u64 = 120; -const UI_STATUS_ANIMATION_MS: u64 = UI_DEEPSEEK_SQUIGGLE_MS; -const MAX_ACTIVE_AGENT_STATUS_ROWS: usize = 2; +const UI_IDLE_POLL_MS: u64 = 48; +const UI_ACTIVE_POLL_MS: u64 = 24; +const UI_DEEPSEEK_SQUIGGLE_MS: u64 = 320; +const UI_STATUS_ANIMATION_MS: u64 = 360; const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15; const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100; #[derive(Debug, Clone, PartialEq, Eq)] struct StatusLayoutPlan { status_height: u16, - queued_preview: Vec, - queued_compacted: bool, - compact_runtime_summary: bool, } /// Run the interactive TUI event loop. @@ -830,8 +824,12 @@ async fn run_event_loop( let has_running_agents = running_agent_count(app) > 0; if (app.is_loading || has_running_agents || app.is_compacting) - && last_status_frame.elapsed() >= Duration::from_millis(UI_STATUS_ANIMATION_MS) + && last_status_frame.elapsed() + >= Duration::from_millis(status_animation_interval_ms(app)) { + if !app.low_motion && history_has_live_motion(&app.history) { + app.mark_history_updated(); + } app.needs_redraw = true; last_status_frame = Instant::now(); } @@ -852,9 +850,9 @@ async fn run_event_loop( } let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting { - Duration::from_millis(UI_ACTIVE_POLL_MS) + Duration::from_millis(active_poll_ms(app)) } else { - Duration::from_millis(UI_IDLE_POLL_MS) + Duration::from_millis(idle_poll_ms(app)) }; if let Some(until_flush) = app.paste_burst.next_flush_delay(now) { poll_timeout = poll_timeout.min(until_flush); @@ -1097,7 +1095,7 @@ async fn run_event_loop( app.set_sidebar_focus(SidebarFocus::Plan); app.status_message = Some("Sidebar focus: plan".to_string()); } else { - app.set_mode(AppMode::Normal); + app.set_mode(AppMode::Plan); } continue; } @@ -1120,12 +1118,7 @@ async fn run_event_loop( continue; } KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Agents); - app.status_message = Some("Sidebar focus: agents".to_string()); - } else { - app.set_mode(AppMode::Plan); - } + apply_alt_4_shortcut(app, key.modifiers); continue; } KeyCode::Char('!') if key.modifiers.contains(KeyModifiers::ALT) => { @@ -1370,18 +1363,14 @@ async fn run_event_loop( } KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { let new_mode = match app.mode { - AppMode::Agent => AppMode::Normal, - _ => AppMode::Agent, + AppMode::Plan => AppMode::Agent, + _ => AppMode::Plan, }; app.set_mode(new_mode); } KeyCode::Char('v') if is_paste_shortcut(&key) => { app.paste_from_clipboard(); } - KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Normal); - continue; - } KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => { app.set_mode(AppMode::Agent); continue; @@ -1394,10 +1383,6 @@ async fn run_event_loop( app.set_mode(AppMode::Plan); continue; } - KeyCode::Char('N') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Normal); - continue; - } KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => { app.set_mode(AppMode::Agent); continue; @@ -1465,6 +1450,15 @@ fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bool { false } +fn apply_alt_4_shortcut(app: &mut App, modifiers: KeyModifiers) { + if modifiers.contains(KeyModifiers::CONTROL) { + app.set_sidebar_focus(SidebarFocus::Agents); + app.status_message = Some("Sidebar focus: agents".to_string()); + } else { + app.set_mode(AppMode::Plan); + } +} + fn handle_paste_burst_decision( app: &mut App, decision: CharDecision, @@ -2143,7 +2137,7 @@ fn plan_next_step_prompt() -> String { " 1) Accept + implement in Agent mode", " 2) Accept + implement in YOLO mode", " 3) Revise the plan / ask follow-ups", - " 4) Exit Plan mode", + " 4) Return to Agent mode without implementing", "", "Use the plan confirmation popup or type 1-4 and press Enter.", ] @@ -2173,7 +2167,7 @@ fn parse_plan_choice(input: &str) -> Option { "accept" | "approve" | "agent" | "a" => Some(PlanChoice::AcceptAgent), "accept-yolo" | "yolo" | "y" => Some(PlanChoice::AcceptYolo), "revise" | "edit" | "plan" | "stay" => Some(PlanChoice::RevisePlan), - "normal" | "exit" | "cancel" | "back" => Some(PlanChoice::ExitPlan), + "exit" | "cancel" | "back" => Some(PlanChoice::ExitPlan), _ => None, } } @@ -2268,30 +2262,6 @@ fn status_row_budget( body_height.saturating_sub(composer_height.max(MIN_COMPOSER_HEIGHT) + chat_floor) } -fn compact_queued_preview(app: &App, preview_rows_budget: usize) -> (Vec, bool) { - if app.queued_message_count() == 0 || preview_rows_budget == 0 { - return (Vec::new(), false); - } - - let preview_rows_budget = preview_rows_budget.min(MAX_QUEUED_PREVIEW); - let queue_count = app.queued_message_count(); - let mut previews = app.queued_message_previews(preview_rows_budget); - if previews.len() > preview_rows_budget { - previews.truncate(preview_rows_budget); - if let Some(last) = previews.last_mut() { - let shown_count = preview_rows_budget.saturating_sub(1); - let hidden_count = queue_count.saturating_sub(shown_count); - *last = format!("+{hidden_count} more"); - } - } - - let shown_count = previews - .iter() - .filter(|line| !line.starts_with('+')) - .count(); - (previews, queue_count > shown_count) -} - fn running_agent_count(app: &App) -> usize { let mut ids: std::collections::HashSet<&str> = app.agent_progress.keys().map(String::as_str).collect(); @@ -2370,98 +2340,28 @@ fn reconcile_subagent_activity_state(app: &mut App) { } } -fn compact_runtime_parts(app: &App) -> Vec { - let mut parts = Vec::new(); - - let active_agents = running_agent_count(app); - if active_agents > 0 { - parts.push(format!( - "{active_agents} agent{}", - if active_agents == 1 { "" } else { "s" } - )); - } - - let running_tasks = app - .task_panel - .iter() - .filter(|task| task.status == "running") - .count(); - if running_tasks > 0 { - parts.push(format!( - "{running_tasks} task{}", - if running_tasks == 1 { "" } else { "s" } - )); - } - - let queued = app.queued_message_count(); - if queued > 0 { - parts.push(format!("{queued} queued")); - } - if app.queued_draft.is_some() { - parts.push("editing queue".to_string()); - } - - match app.view_stack.top_kind() { - Some(ModalKind::Approval) => parts.push("approval open".to_string()), - Some(ModalKind::Elevation) => parts.push("elevation open".to_string()), - _ => {} - } - - parts -} - fn compute_status_layout( app: &App, terminal_height: u16, - terminal_width: 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, - compact_runtime_summary: false, - }; + return StatusLayoutPlan { status_height: 0 }; } - let active_agents = running_agent_count(app); - let compact_runtime_summary = - terminal_width < SIDEBAR_VISIBLE_MIN_WIDTH && !compact_runtime_parts(app).is_empty(); - let fixed_rows = usize::from(app.is_loading || app.is_compacting) - + usize::from(compact_runtime_summary) + let active_details = usize::from(app.is_loading || app.is_compacting) + usize::from(app.queued_draft.is_some()) - + usize::from(active_agents > 0); - 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 mut requested_rows = fixed_rows + queue_rows; - if active_agents > 0 { - let detail_rows_budget = usize::from(status_budget).saturating_sub(requested_rows); - let detail_rows = detail_rows_budget.min(active_agents.min(MAX_ACTIVE_AGENT_STATUS_ROWS)); - requested_rows += detail_rows; - } + + usize::from(running_agent_count(app) > 0) + + usize::from(matches!( + app.view_stack.top_kind(), + Some(ModalKind::Approval | ModalKind::Elevation) + )); + let requested_rows = 1 + active_details.min(2); 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, - compact_runtime_summary, - } + StatusLayoutPlan { status_height } } fn render(f: &mut Frame, app: &mut App) { @@ -2480,23 +2380,20 @@ fn render(f: &mut Frame, app: &mut App) { let header_height = 1; let footer_height = 1; let body_height = size.height.saturating_sub(header_height + footer_height); - let prompt = prompt_for_mode(app.mode); 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); + let composer_widget = ComposerWidget::new(app, max_composer_height, &slash_menu_entries); composer_widget.desired_height(size.width) }; - let status_layout = compute_status_layout(app, size.height, size.width, composer_for_budget); + 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, composer_max_height, &slash_menu_entries); + let composer_widget = ComposerWidget::new(app, composer_max_height, &slash_menu_entries); composer_widget.desired_height(size.width) }; @@ -2514,14 +2411,25 @@ fn render(f: &mut Frame, app: &mut App) { // Render header { let context_window = crate::models::context_window_for_model(&app.model); - let header_data = - HeaderData::new(app.mode, &app.model, app.is_loading, app.ui_theme.header_bg) - .with_usage( - app.total_conversation_tokens, - context_window, - app.session_cost, - app.last_prompt_tokens, - ); + let workspace_name = app + .workspace + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("workspace"); + let header_data = HeaderData::new( + app.mode, + &app.model, + workspace_name, + app.is_loading, + app.ui_theme.header_bg, + ) + .with_usage( + app.total_conversation_tokens, + context_window, + app.session_cost, + app.last_prompt_tokens, + ); let header_widget = HeaderWidget::new(header_data); let buf = f.buffer_mut(); header_widget.render(chunks[0], buf); @@ -2560,21 +2468,12 @@ fn render(f: &mut Frame, app: &mut App) { // Render status 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, - status_layout.compact_runtime_summary, - ); + render_status_indicator(f, chunks[2], app); } // Render composer let cursor_pos = { - let composer_widget = - ComposerWidget::new(app, prompt, composer_max_height, &slash_menu_entries); + let composer_widget = ComposerWidget::new(app, composer_max_height, &slash_menu_entries); let buf = f.buffer_mut(); composer_widget.render(chunks[3], buf); composer_widget.cursor_pos(chunks[3]) @@ -2861,7 +2760,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { if app.subagent_cache.is_empty() { lines.push(Line::from(Span::styled( - "No sub-agents", + "No agents", Style::default().fg(palette::TEXT_MUTED), ))); } else { @@ -2923,7 +2822,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { } } - render_sidebar_section(f, area, "Sub-Agents", lines); + render_sidebar_section(f, area, "Agents", lines); } fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec>) { @@ -3334,198 +3233,189 @@ fn resume_terminal( Ok(()) } -fn render_status_indicator( - f: &mut Frame, - area: Rect, - app: &App, - queued_count: usize, - queued: &[String], - queued_compacted: bool, - compact_runtime_summary: bool, -) { - let max_rows = usize::from(area.height); - if max_rows == 0 { +fn render_status_indicator(f: &mut Frame, area: Rect, app: &App) { + if area.height == 0 || area.width == 0 { return; } - let mut lines = Vec::with_capacity( - 2 + queued.len() + usize::from(app.queued_draft.is_some()) + MAX_ACTIVE_AGENT_STATUS_ROWS, - ); - if app.is_loading { - let header = if app.show_thinking { - app.reasoning_header.clone() - } else { - None - }; - let elapsed = app.turn_started_at.map(format_elapsed); - // Distinguish thinking streaming from answer streaming. - let is_thinking_streaming = app.streaming_message_index.is_some_and(|idx| { - matches!( - app.history.get(idx), - Some(HistoryCell::Thinking { - streaming: true, - .. - }) - ) - }); - let is_answer_streaming = app.streaming_message_index.is_some() && !is_thinking_streaming; - let spinner = if is_answer_streaming { - typing_indicator(app.turn_started_at) + let mut lines = vec![status_summary_line(app, area.width)]; + let detail_budget = usize::from(area.height.saturating_sub(1)); + lines.extend(status_detail_lines(app, area.width, detail_budget)); + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + f.render_widget(paragraph, area); +} + +fn approval_mode_summary(app: &App) -> &'static str { + match app.approval_mode { + ApprovalMode::Auto => "auto", + ApprovalMode::Suggest => "review", + ApprovalMode::Never => "off", + } +} + +fn workspace_short_name(app: &App) -> String { + app.workspace + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("workspace") + .to_string() +} + +fn current_run_state(app: &App) -> (&'static str, ratatui::style::Color) { + if app.is_compacting { + ("Compacting", palette::STATUS_WARNING) + } else if app.is_loading { + ("Working", palette::DEEPSEEK_SKY) + } else if running_agent_count(app) > 0 { + ("Agents active", palette::DEEPSEEK_SKY) + } else if app.queued_draft.is_some() { + ("Editing queue", palette::STATUS_WARNING) + } else { + ("Ready", palette::TEXT_MUTED) + } +} + +fn status_summary_line(app: &App, width: u16) -> Line<'static> { + let queue = app.queued_message_count(); + let running_tasks = app + .task_panel + .iter() + .filter(|task| task.status == "running") + .count(); + let active_agents = running_agent_count(app); + let (state, state_color) = current_run_state(app); + let mut parts = vec![workspace_short_name(app)]; + if queue > 0 { + parts.push(format!("queue {queue}")); + } + if !matches!(app.approval_mode, ApprovalMode::Suggest) { + parts.push(format!("approvals {}", approval_mode_summary(app))); + } + if running_tasks > 0 { + parts.push(format!( + "{} task{}", + running_tasks, + if running_tasks == 1 { "" } else { "s" } + )); + } + if active_agents > 0 { + parts.push(format!( + "{} agent{}", + active_agents, + if active_agents == 1 { "" } else { "s" } + )); + } + if width >= 100 + && let Some(workspace_context) = app.workspace_context.as_ref() + { + parts.push(workspace_context.to_string()); + } + let text = parts.join(" · "); + + let available_width = if state == "Ready" { + usize::from(width) + } else { + usize::from(width).saturating_sub(state.len() + 3) + }; + let mut spans = vec![Span::styled( + truncate_line_to_width(&text, available_width.max(1)), + Style::default().fg(palette::TEXT_MUTED), + )]; + if state != "Ready" { + spans.push(Span::styled( + " · ", + Style::default().fg(palette::TEXT_DIM), + )); + spans.push(Span::styled( + state.to_string(), + Style::default().fg(state_color), + )); + } + Line::from(spans) +} + +fn status_detail_lines(app: &App, width: u16, budget: usize) -> Vec> { + if budget == 0 { + return Vec::new(); + } + + let mut lines = Vec::new(); + + if app.is_loading && lines.len() < budget { + let header = app + .reasoning_header + .as_deref() + .filter(|header| !header.trim().is_empty()) + .unwrap_or("streaming response"); + let spinner = if app.low_motion { + "·" } else { deepseek_squiggle(app.turn_started_at) }; - let (label, label_color) = if is_answer_streaming { - ("ANSWER", palette::DEEPSEEK_SKY) - } else if app.show_thinking || is_thinking_streaming { - ("THINKING", palette::STATUS_WARNING) + let elapsed = app.turn_started_at.map(format_elapsed).unwrap_or_default(); + let detail = if elapsed.is_empty() { + format!("{spinner} {header} · Esc interrupts") } else { - ("WORKING", palette::STATUS_WARNING) + format!("{spinner} {header} · {elapsed} · Esc interrupts") }; - let mut spans = vec![ - Span::styled(spinner, Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::raw(" "), - Span::styled(label, Style::default().fg(label_color).bold()), - ]; - if !is_answer_streaming && let Some(header) = header { - spans.push(Span::raw(": ")); - spans.push(Span::styled( - header, - Style::default().fg(palette::STATUS_WARNING), - )); - } - - if let Some(elapsed) = elapsed { - spans.push(Span::raw(" | ")); - spans.push(Span::styled( - elapsed, - Style::default().fg(palette::TEXT_MUTED), - )); - } - - spans.push(Span::raw(" | ")); - spans.push(Span::styled( - "Esc/Ctrl+C to interrupt", + lines.push(Line::from(Span::styled( + truncate_line_to_width(&detail, usize::from(width)), Style::default().fg(palette::TEXT_MUTED), - )); - - lines.push(Line::from(spans)); - } else if app.is_compacting { - let spinner = deepseek_squiggle(None); - let spans = vec![ - Span::styled(spinner, Style::default().fg(palette::STATUS_WARNING).bold()), - Span::raw(" "), - Span::styled( - "COMPACTING", - Style::default().fg(palette::STATUS_WARNING).bold(), - ), - Span::raw(" "), - Span::styled( - "summarizing context...", - Style::default().fg(palette::TEXT_MUTED), - ), - ]; - lines.push(Line::from(spans)); + ))); } - if compact_runtime_summary && lines.len() < max_rows { - let summary = compact_runtime_parts(app).join(" | "); - if !summary.is_empty() { - let available = area.width as usize; - lines.push(Line::from(vec![ - Span::styled("STATE ", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled( - truncate_line_to_width(&summary, available.saturating_sub(6).max(1)), - Style::default().fg(palette::TEXT_MUTED), - ), - ])); - } + if app.is_compacting && lines.len() < budget { + lines.push(Line::from(Span::styled( + truncate_line_to_width( + "Compacting context · summarizing older turns · Esc interrupts", + usize::from(width), + ), + Style::default().fg(palette::TEXT_MUTED), + ))); } - let active_agent_total = running_agent_count(app); - if active_agent_total > 0 && lines.len() < max_rows { - let available = area.width as usize; - let spinner_start = app.agent_activity_started_at.or(app.turn_started_at); - let spinner = deepseek_squiggle(spinner_start); - let header = format!("AGENTS {active_agent_total} running | /agents"); - lines.push(Line::from(vec![ - Span::styled(spinner, Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::raw(" "), - Span::styled( - truncate_line_to_width(&header, available.saturating_sub(2).max(1)), - Style::default().fg(palette::DEEPSEEK_SKY).bold(), + if let Some(draft) = app.queued_draft.as_ref() + && lines.len() < budget + { + lines.push(Line::from(Span::styled( + truncate_line_to_width( + &format!("Editing queued draft · {}", draft.display), + usize::from(width), ), - ])); + Style::default().fg(palette::TEXT_MUTED), + ))); + } - let preview_limit = max_rows - .saturating_sub(lines.len()) - .min(MAX_ACTIVE_AGENT_STATUS_ROWS); - let active_rows = active_agent_rows(app, preview_limit); - for (id, status) in &active_rows { - if lines.len() >= max_rows { - break; - } - let id_short = truncate_line_to_width(id, 12); - let status_single_line = status.lines().next().unwrap_or(status.as_str()); - let prefix = format!(" {id_short}: "); - let detail_width = available.saturating_sub(prefix.width()).max(1); - let detail = truncate_line_to_width(status_single_line, detail_width); - lines.push(Line::from(vec![ - Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)), - Span::styled(detail, Style::default().fg(palette::TEXT_DIM)), - ])); - } - - let hidden = active_agent_total.saturating_sub(active_rows.len()); - if hidden > 0 && lines.len() < max_rows { + if running_agent_count(app) > 0 && lines.len() < budget { + let active_rows = active_agent_rows(app, 1); + if let Some((id, status)) = active_rows.first() { lines.push(Line::from(Span::styled( - format!(" +{hidden} more running"), + truncate_line_to_width( + &format!("Agent {id} · {}", status.lines().next().unwrap_or(status)), + usize::from(width), + ), Style::default().fg(palette::TEXT_MUTED), ))); } } - if let Some(draft) = app.queued_draft.as_ref() { - let available = area.width as usize; - let prefix = "Editing queued:"; - let prefix_width = prefix.width() + 1; - let max_len = available.saturating_sub(prefix_width).max(1); - let preview = truncate_line_to_width(&draft.display, max_len); - lines.push(Line::from(vec![ - Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)), - Span::raw(" "), - Span::styled(preview, Style::default().fg(palette::DEEPSEEK_SKY)), - ])); - } - - if queued_count > 0 { - let available = area.width as usize; - let header = if queued_compacted { - format!("Queued ({queued_count}) [compact] - /queue edit ") - } else { - format!("Queued ({queued_count}) - /queue edit ") - }; - let header = truncate_line_to_width(&header, available.max(1)); - lines.push(Line::from(vec![Span::styled( - header, + if matches!( + app.view_stack.top_kind(), + Some(ModalKind::Approval | ModalKind::Elevation) + ) && lines.len() < budget + { + lines.push(Line::from(Span::styled( + truncate_line_to_width( + "Review open request · Esc closes the overlay", + usize::from(width), + ), Style::default().fg(palette::TEXT_MUTED), - )])); - - for (idx, message) in queued.iter().enumerate() { - let label = if message.starts_with('+') { - message.to_string() - } else { - format!("{}. {message}", idx + 1) - }; - let preview = truncate_line_to_width(&label, available.max(1)); - lines.push(Line::from(vec![Span::styled( - preview, - Style::default().fg(palette::TEXT_DIM), - )])); - } + ))); } - let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); - f.render_widget(paragraph, area); + lines } fn status_color(level: StatusToastLevel) -> ratatui::style::Color { @@ -3591,47 +3481,11 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { Style::default().fg(status_color(toast.level)), )] } else { - // Kimi-style: "00:07 yolo agent (model, thinking)" - let mut spans = Vec::new(); - - // Elapsed time HH:MM - let elapsed = app.session_start.elapsed(); - let total_secs = elapsed.as_secs(); - let hours = total_secs / 3600; - let mins = (total_secs % 3600) / 60; - spans.push(Span::styled( - format!("{hours:02}:{mins:02}"), + let hint = footer_hint_text(app); + vec![Span::styled( + hint, Style::default().fg(palette::FOOTER_HINT), - )); - spans.push(Span::raw(" ")); - - // Mode (lowercase, colored) - let (mode_label, mode_color) = footer_mode_style(app); - spans.push(Span::styled( - mode_label.to_string(), - Style::default().fg(mode_color), - )); - spans.push(Span::raw(" ")); - - // "agent" label + model name + status in parens - let model_short = app.model.rsplit('/').next().unwrap_or(&app.model); - let status = if app.is_compacting { - ", compacting" - } else if app.is_loading { - ", thinking" - } else { - "" - }; - spans.push(Span::styled( - "agent ", - Style::default().fg(palette::TEXT_HINT), - )); - spans.push(Span::styled( - format!("({model_short}{status})"), - Style::default().fg(palette::TEXT_HINT), - )); - - spans + )] }; let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum(); @@ -3645,10 +3499,32 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { f.render_widget(footer, area); } +fn footer_hint_text(app: &App) -> String { + let slash_menu_open = !visible_slash_menu_entries(app, 1).is_empty(); + if !app.view_stack.is_empty() { + return "Esc close overlay".to_string(); + } + if app.is_loading || app.is_compacting { + return "Esc interrupt".to_string(); + } + if slash_menu_open { + return "Up/Down move · Tab accept".to_string(); + } + if !app.input.is_empty() { + if app.input.contains('\n') { + return "Enter send · Esc clear".to_string(); + } + return "Enter send · Alt+Enter newline".to_string(); + } + if app.input_history.is_empty() { + return "Ctrl+K commands · F1 help".to_string(); + } + "Ctrl+K commands".to_string() +} + fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) { let label = app.mode.as_setting(); let color = match app.mode { - crate::tui::app::AppMode::Normal => palette::MODE_NORMAL, crate::tui::app::AppMode::Agent => palette::MODE_AGENT, crate::tui::app::AppMode::Yolo => palette::MODE_YOLO, crate::tui::app::AppMode::Plan => palette::MODE_PLAN, @@ -3824,15 +3700,6 @@ fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool false } -fn prompt_for_mode(mode: AppMode) -> &'static str { - match mode { - AppMode::Normal => "> ", - AppMode::Agent => "agent> ", - AppMode::Yolo => "yolo> ", - AppMode::Plan => "plan> ", - } -} - fn estimated_context_tokens(app: &App) -> Option { i64::try_from(estimate_input_tokens_conservative( &app.api_messages, @@ -3917,20 +3784,52 @@ fn format_elapsed(start: Instant) -> String { } fn deepseek_squiggle(start: Option) -> &'static str { - const FRAMES: [&str; 6] = ["◍", "◉", "◌", "◌", "◉", "◍"]; + const FRAMES: [&str; 4] = ["·", "◦", "•", "◦"]; let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis()); let idx = ((elapsed_ms / u128::from(UI_DEEPSEEK_SQUIGGLE_MS)) as usize) % FRAMES.len(); FRAMES[idx] } -/// Braille spinner frames — a prominent rotating circle pattern. -const TYPING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +fn status_animation_interval_ms(app: &App) -> u64 { + if app.low_motion { + 2_400 + } else { + UI_STATUS_ANIMATION_MS + } +} -/// Returns the typing indicator frame based on elapsed time. -fn typing_indicator(start: Option) -> &'static str { - let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis()); - let idx = ((elapsed_ms / u128::from(UI_TYPING_INDICATOR_MS)) as usize) % TYPING_FRAMES.len(); - TYPING_FRAMES[idx] +fn active_poll_ms(app: &App) -> u64 { + if app.low_motion { + 96 + } else { + UI_ACTIVE_POLL_MS + } +} + +fn idle_poll_ms(app: &App) -> u64 { + if app.low_motion { 120 } else { UI_IDLE_POLL_MS } +} + +fn history_has_live_motion(history: &[HistoryCell]) -> bool { + history.iter().any(|cell| match cell { + HistoryCell::Thinking { streaming, .. } => *streaming, + HistoryCell::Tool(tool) => match tool { + ToolCell::Exec(cell) => cell.status == ToolStatus::Running, + ToolCell::Exploring(cell) => cell + .entries + .iter() + .any(|entry| entry.status == ToolStatus::Running), + ToolCell::PlanUpdate(cell) => cell.status == ToolStatus::Running, + ToolCell::PatchSummary(cell) => cell.status == ToolStatus::Running, + ToolCell::Review(cell) => cell.status == ToolStatus::Running, + ToolCell::DiffPreview(_) => false, + ToolCell::Mcp(cell) => cell.status == ToolStatus::Running, + ToolCell::ViewImage(_) => false, + ToolCell::WebSearch(cell) => cell.status == ToolStatus::Running, + ToolCell::Generic(cell) => cell.status == ToolStatus::Running, + }, + _ => false, + }) } fn truncate_line_to_width(text: &str, max_width: usize) -> String { @@ -4140,38 +4039,54 @@ fn open_tool_details_pager(app: &mut App) -> bool { .and_then(|meta| meta.cell_line()) .map(|(cell_index, _)| cell_index) } else { - app.history - .len() - .checked_sub(1) - .filter(|idx| app.tool_details_by_cell.contains_key(idx)) - .or_else(|| app.tool_details_by_cell.keys().copied().max()) + app.history.len().checked_sub(1) }; let Some(cell_index) = target_cell else { return false; }; - let Some(detail) = app.tool_details_by_cell.get(&cell_index) else { - app.status_message = Some("No tool details for selected line".to_string()); + if let Some(detail) = app.tool_details_by_cell.get(&cell_index) { + let input = serde_json::to_string_pretty(&detail.input) + .unwrap_or_else(|_| detail.input.to_string()); + let output = detail.output.as_deref().map_or( + "(not available)".to_string(), + std::string::ToString::to_string, + ); + let content = format!( + "Tool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}", + detail.tool_id, detail.tool_name, input, output + ); + + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + app.view_stack.push(PagerView::from_text( + format!("Tool: {}", detail.tool_name), + &content, + width.saturating_sub(2), + )); + return true; + } + + let Some(cell) = app.history.get(cell_index) else { + app.status_message = Some("No details available for the selected line".to_string()); return false; }; - - let input = - serde_json::to_string_pretty(&detail.input).unwrap_or_else(|_| detail.input.to_string()); - let output = detail.output.as_deref().map_or( - "(not available)".to_string(), - std::string::ToString::to_string, - ); - let content = format!( - "Tool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}", - detail.tool_id, detail.tool_name, input, output - ); - + let title = match cell { + HistoryCell::User { .. } => "You".to_string(), + HistoryCell::Assistant { .. } => "Assistant".to_string(), + HistoryCell::System { .. } => "Note".to_string(), + HistoryCell::Thinking { .. } => "Reasoning".to_string(), + HistoryCell::Tool(_) => "Message".to_string(), + }; let width = app .last_transcript_area .map(|area| area.width) .unwrap_or(80); + let content = history_cell_to_text(cell, width); app.view_stack.push(PagerView::from_text( - format!("Tool: {}", detail.tool_name), + title, &content, width.saturating_sub(2), )); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index c432bbe4..b589201d 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -116,6 +116,29 @@ fn create_test_app() -> App { App::new(options, &Config::default()) } +#[test] +fn alt_4_switches_to_plan_mode() { + let mut app = create_test_app(); + app.mode = AppMode::Agent; + + apply_alt_4_shortcut(&mut app, KeyModifiers::ALT); + + assert_eq!(app.mode, AppMode::Plan); +} + +#[test] +fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() { + let mut app = create_test_app(); + app.mode = AppMode::Agent; + app.sidebar_focus = SidebarFocus::Auto; + + apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL); + + assert_eq!(app.mode, AppMode::Agent); + assert_eq!(app.sidebar_focus, SidebarFocus::Agents); + assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents")); +} + fn make_subagent( id: &str, status: crate::tools::subagent::SubAgentStatus, @@ -166,34 +189,35 @@ fn running_agent_count_unions_cache_and_progress() { } #[test] -fn compute_status_layout_reserves_rows_for_active_agents() { +fn compute_status_layout_reserves_extra_rows_for_active_state() { let app = create_test_app(); - let baseline = compute_status_layout(&app, 30, 120, 3); - assert_eq!(baseline.status_height, 0); + let baseline = compute_status_layout(&app, 30, 3); + assert_eq!(baseline.status_height, 1); let mut with_agents = create_test_app(); with_agents .agent_progress .insert("agent_a".to_string(), "running".to_string()); - let active = compute_status_layout(&with_agents, 30, 120, 3); - assert!(active.status_height >= 1); + let active = compute_status_layout(&with_agents, 30, 3); + assert!(active.status_height > baseline.status_height); } #[test] -fn narrow_layout_adds_compact_runtime_summary_without_sidebar() { +fn status_summary_line_mentions_queue_and_approval_mode() { let mut app = create_test_app(); - app.agent_progress - .insert("agent_a".to_string(), "running".to_string()); + app.approval_mode = crate::tui::approval::ApprovalMode::Auto; app.queue_message(crate::tui::app::QueuedMessage::new( "queued message".to_string(), None, )); - - let narrow = compute_status_layout(&app, 30, 80, 3); - let wide = compute_status_layout(&app, 30, 120, 3); - - assert!(narrow.compact_runtime_summary); - assert!(!wide.compact_runtime_summary); + let summary = status_summary_line(&app, 120); + let summary_text = summary + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(summary_text.contains("queue 1")); + assert!(summary_text.contains("approvals auto")); } #[test] @@ -476,10 +500,8 @@ fn status_layout_budget_preserves_chat_and_composer_on_tiny_heights() { )); } - let layout = compute_status_layout(&app, 9, 80, 3); + 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] @@ -507,25 +529,20 @@ fn api_key_validation_warns_without_blocking_unusual_formats() { } #[test] -fn compact_queued_preview_summarizes_hidden_messages() { +fn status_detail_lines_show_queue_draft_when_editing() { 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); + app.queued_draft = Some(crate::tui::app::QueuedMessage::new( + "refine the queued prompt".to_string(), + None, + )); + let details = status_detail_lines(&app, 120, 2); + assert!(!details.is_empty()); + let text = details[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(text.contains("Editing queued draft")); } #[test] diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index d2ed7165..816125e5 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -199,7 +199,7 @@ const HELP_COMMAND_SECTION_ORDER: [&str; 7] = [ fn help_command_section(name: &str) -> &'static str { match name { "help" | "clear" | "exit" | "model" | "models" | "home" | "links" => "Core", - "normal" | "agent" | "yolo" | "plan" | "trust" | "logout" => "Modes", + "agent" | "yolo" | "plan" | "trust" | "logout" => "Modes", "save" | "sessions" | "load" | "export" | "compact" | "queue" => "Session", "config" | "settings" => "Configuration", "task" | "skills" | "skill" | "subagents" | "review" => "Workflows", @@ -303,6 +303,18 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + key: "calm_mode".to_string(), + value: settings.calm_mode.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "low_motion".to_string(), + value: settings.low_motion.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { key: "show_thinking".to_string(), value: settings.show_thinking.to_string(), @@ -315,6 +327,18 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + key: "composer_density".to_string(), + value: settings.composer_density.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + key: "transcript_spacing".to_string(), + value: settings.transcript_spacing.clone(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { key: "default_mode".to_string(), value: settings.default_mode.clone(), @@ -553,8 +577,11 @@ fn config_hint_for_key(key: &str) -> &'static str { "deepseek-chat | deepseek-reasoner | deepseek-* (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" => "normal | agent | plan | yolo", + "auto_compact" | "calm_mode" | "low_motion" | "show_thinking" | "show_tool_details" => { + "on/off, true/false, yes/no, 1/0" + } + "composer_density" | "transcript_spacing" => "compact | comfortable | spacious", + "default_mode" => "agent | plan | yolo", "theme" => "default | dark | light | whale", "sidebar_width" => "10..=50", "sidebar_focus" => "auto | plan | todos | tasks | agents", @@ -968,19 +995,19 @@ impl ModalView for HelpView { Line::from(" Left / Right - Move cursor"), Line::from(" Ctrl+A / Ctrl+E - Jump to start / end of line"), Line::from(" Backspace / Delete - Delete character before / after cursor"), - Line::from(" Ctrl+U - Clear entire input line"), + Line::from(" Ctrl+U - Clear the current draft"), Line::from(""), Line::from(vec![Span::styled( "=== Multi-line Input ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Ctrl+J / Alt+Enter - Insert newline (without submitting)"), + Line::from(" Ctrl+J / Alt+Enter - Insert a new line in the composer"), Line::from(""), Line::from(vec![Span::styled( "=== Actions ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Enter - Submit message"), + Line::from(" Enter - Send the current draft"), Line::from( " Esc - Close menu, cancel request, discard draft, or clear input", ), @@ -988,7 +1015,7 @@ impl ModalView for HelpView { Line::from(" Ctrl+D - Exit when input is empty"), Line::from(" Ctrl+K - Open command palette"), Line::from(" l - Open pager for last message (when input empty)"), - Line::from(" v - Open tool details (when input empty)"), + Line::from(" v - Open details for the selected tool or message"), Line::from(" Enter (selection) - Open pager for selected text"), Line::from(""), Line::from(vec![Span::styled( @@ -996,11 +1023,11 @@ impl ModalView for HelpView { Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), Line::from(" Tab / Shift+Tab - Complete /command or cycle modes"), - Line::from(" Alt+1/2/3/4 - Directly jump to Normal/Agent/YOLO/Plan"), - Line::from(" Alt+N/A/Y/P - Alternative jump to Normal/Agent/YOLO/Plan"), + Line::from(" Alt+1/2/3 - Directly jump to Plan/Agent/YOLO"), + Line::from(" Alt+P/A/Y - Alternative jump to Plan/Agent/YOLO"), Line::from(" Alt+!/@/#/$/+) - Focus Plan/Todos/Tasks/Agents/Auto sidebar"), - Line::from(" /normal /agent /yolo /plan - Set mode directly"), - Line::from(" Ctrl+X - Toggle between Agent and Normal modes"), + Line::from(" /agent /yolo /plan - Set mode directly"), + Line::from(" Ctrl+X - Toggle between Plan and Agent modes"), Line::from(""), Line::from(vec![Span::styled( "=== Sessions ===", @@ -1032,7 +1059,7 @@ impl ModalView for HelpView { "Mode Cycle:", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Normal → Agent → YOLO → Plan (Tab), reverse with Shift+Tab"), + Line::from(" Plan → Agent → YOLO (Tab), reverse with Shift+Tab"), Line::from(""), Line::from(vec![Span::styled( "Commands:", @@ -1178,7 +1205,7 @@ impl ModalView for SubAgentsView { if self.agents.is_empty() { lines.push(Line::from(Span::styled( - "No sub-agents running.", + "No agents running.", Style::default().fg(palette::TEXT_MUTED), ))); } else { diff --git a/crates/tui/src/tui/widgets/header.rs b/crates/tui/src/tui/widgets/header.rs index 1003a99e..54070442 100644 --- a/crates/tui/src/tui/widgets/header.rs +++ b/crates/tui/src/tui/widgets/header.rs @@ -17,6 +17,7 @@ use super::Renderable; /// Data required to render the header bar. pub struct HeaderData<'a> { pub model: &'a str, + pub workspace_name: &'a str, pub mode: AppMode, pub is_streaming: bool, pub background: ratatui::style::Color, @@ -36,11 +37,13 @@ impl<'a> HeaderData<'a> { pub fn new( mode: AppMode, model: &'a str, + workspace_name: &'a str, is_streaming: bool, background: ratatui::style::Color, ) -> Self { Self { model, + workspace_name, mode, is_streaming, background, @@ -84,33 +87,53 @@ impl<'a> HeaderWidget<'a> { /// Get the color for a mode. fn mode_color(mode: AppMode) -> ratatui::style::Color { match mode { - AppMode::Normal => palette::MODE_NORMAL, AppMode::Agent => palette::MODE_AGENT, AppMode::Yolo => palette::MODE_YOLO, AppMode::Plan => palette::MODE_PLAN, } } - /// Build the mode badge span (no brackets, lowercase, bold). - fn mode_badge(&self) -> Span<'static> { - let label = self.data.mode.label().to_lowercase(); - let color = Self::mode_color(self.data.mode); - Span::styled( - label, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ) + fn mode_name(mode: AppMode) -> &'static str { + match mode { + AppMode::Agent => "Agent", + AppMode::Yolo => "Yolo", + AppMode::Plan => "Plan", + } } - /// Build the model name span (muted, truncated). - fn model_span(&self) -> Span<'static> { - let display_name = if self.data.model.chars().count() > 25 { - let truncated: String = self.data.model.chars().take(22).collect(); - format!("{truncated}...") - } else { - self.data.model.to_string() - }; + fn mode_segments(&self) -> Vec> { + let modes = [AppMode::Plan, AppMode::Agent, AppMode::Yolo]; + let mut spans = Vec::new(); + for (idx, mode) in modes.into_iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(" ")); + } + let is_selected = mode == self.data.mode; + let style = if is_selected { + Style::default() + .fg(self.data.background) + .bg(Self::mode_color(mode)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::TEXT_HINT) + }; + spans.push(Span::styled(format!(" {} ", Self::mode_name(mode)), style)); + } + spans + } - Span::styled(display_name, Style::default().fg(palette::TEXT_HINT)) + fn context_text(&self, max_chars: usize) -> String { + let raw = format!("{} · {}", self.data.workspace_name, self.data.model); + if raw.chars().count() <= max_chars { + raw + } else { + let mut truncated = String::new(); + for ch in raw.chars().take(max_chars.saturating_sub(3)) { + truncated.push(ch); + } + truncated.push_str("..."); + truncated + } } /// Build the streaming indicator span. @@ -134,31 +157,36 @@ impl Renderable for HeaderWidget<'_> { return; } - // Build left section: mode + model - let mode_span = self.mode_badge(); - let model_span = self.model_span(); + let mut left_spans = self.mode_segments(); // Build right section: streaming indicator only. Footer owns context. let streaming_span = self.streaming_indicator(); // Calculate widths - let mode_width = mode_span.content.width(); - let model_width = model_span.content.width(); + let mut left_width: usize = left_spans.iter().map(|span| span.content.width()).sum(); let streaming_width = streaming_span.as_ref().map_or(0, |s| s.content.width()); let right_width = streaming_width; - let left_width = mode_width + 2 + model_width; // mode + " " + model - let available = area.width as usize; // Build final line based on available space let mut spans = Vec::new(); - if available >= left_width + right_width + 2 { - // Full layout: mode model (spacer) ● - spans.push(mode_span); - spans.push(Span::raw(" ")); - spans.push(model_span); + let context_room = available + .saturating_sub(left_width + right_width) + .saturating_sub(2); + if context_room >= 10 { + let context_text = self.context_text(context_room); + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + context_text, + Style::default().fg(palette::TEXT_HINT), + )); + left_width = left_spans.iter().map(|span| span.content.width()).sum(); + } + + if available >= left_width + right_width { + spans.extend(left_spans); // Spacer to push right elements to the end let padding_needed = available.saturating_sub(left_width + right_width); @@ -170,24 +198,14 @@ impl Renderable for HeaderWidget<'_> { if let Some(streaming) = streaming_span { spans.push(streaming); } - } else if available >= mode_width + 2 + model_width.min(10) { - // Compact layout: mode truncated_model - spans.push(mode_span); - spans.push(Span::raw(" ")); - let model_str = self.data.model; - let display_model = if model_str.chars().count() > 10 { - let truncated: String = model_str.chars().take(7).collect(); - format!("{truncated}...") - } else { - model_str.to_string() - }; + } else if available >= 12 { spans.push(Span::styled( - display_model, - Style::default().fg(palette::TEXT_HINT), + format!(" {} ", Self::mode_name(self.data.mode)), + Style::default() + .fg(self.data.background) + .bg(Self::mode_color(self.data.mode)) + .add_modifier(Modifier::BOLD), )); - } else if available >= mode_width { - // Minimal: just mode badge - spans.push(mode_span); } else { // Ultra-minimal: single lowercase char let first_char = self diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 84398505..7509b7a8 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -7,7 +7,7 @@ pub use renderable::Renderable; use std::time::Duration; use crate::palette; -use crate::tui::app::App; +use crate::tui::app::{App, AppMode, ComposerDensity}; use crate::tui::approval::{ApprovalRequest, ElevationOption, ElevationRequest, ToolCategory}; use crate::tui::history::HistoryCell; use crate::tui::scrolling::{TranscriptLineMeta, TranscriptScroll}; @@ -24,6 +24,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; const SEND_FLASH_DURATION: Duration = Duration::from_millis(500); +const COMPOSER_PANEL_HEIGHT: u16 = 2; pub struct ChatWidget { content_area: Rect, @@ -36,6 +37,19 @@ impl ChatWidget { let visible_lines = content_area.height as usize; let render_options = app.transcript_render_options(); + if should_render_empty_state(app) { + let lines = build_empty_state_lines(app, content_area); + app.last_transcript_area = Some(content_area); + app.last_transcript_top = 0; + app.last_transcript_visible = visible_lines; + app.last_transcript_total = 0; + app.last_transcript_padding_top = 0; + return Self { + content_area, + lines, + }; + } + app.transcript_cache.ensure( &app.history, content_area.width.max(1), @@ -74,7 +88,9 @@ impl ChatWidget { }; // Brief flash highlight on the most recently sent user message. - if let Some(send_at) = app.last_send_at { + if !app.low_motion + && let Some(send_at) = app.last_send_at + { if send_at.elapsed() < SEND_FLASH_DURATION { apply_send_flash(&mut lines, top, &app.history, line_meta); } else { @@ -109,74 +125,113 @@ impl Renderable for ChatWidget { 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, - slash_menu_entries: &'a [String], - ) -> Self { + pub fn new(app: &'a App, max_height: u16, slash_menu_entries: &'a [String]) -> Self { Self { app, - prompt, max_height, slash_menu_entries, } } + + fn has_panel(area: Rect) -> bool { + area.height >= 3 && area.width >= 12 + } + + fn inner_area(area: Rect) -> Rect { + if Self::has_panel(area) { + Block::default().borders(Borders::ALL).inner(area) + } else { + area + } + } + + fn mode_color(&self) -> Color { + match self.app.mode { + AppMode::Agent => palette::MODE_AGENT, + AppMode::Yolo => palette::MODE_YOLO, + AppMode::Plan => palette::MODE_PLAN, + } + } + + fn max_height_cap(&self) -> u16 { + composer_max_height(self.app.composer_density) + } } impl Renderable for ComposerWidget<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { - 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 background = Style::default().bg(self.app.ui_theme.composer_bg); + let has_panel = Self::has_panel(area); + let inner_area = Self::inner_area(area); 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); - + let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines); + let content_width = usize::from(inner_area.width.max(1)); let (visible_lines, _cursor_row, _cursor_col) = layout_input( &self.app.input, self.app.cursor_position, content_width, - max_height, + input_rows_budget, ); + let is_draft_mode = self.app.input.contains('\n') || visible_lines.len() > 1; + if has_panel { + let border_color = if self.app.input.trim().is_empty() { + palette::BORDER_COLOR + } else { + self.mode_color() + }; + let hint_line = if self.slash_menu_entries.is_empty() { + None + } else { + Some(Line::from(vec![ + Span::styled(" Up/Down move ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled("Tab accept ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled("Esc close", Style::default().fg(palette::TEXT_MUTED)), + ])) + }; - let background = Style::default().bg(self.app.ui_theme.composer_bg); - let block = Block::default().style(background); - block.render(area, buf); - - let mut lines = Vec::new(); - if self.app.input.is_empty() { - let placeholder = "Type a message or /help for commands..."; - lines.push(Line::from(vec![ - Span::styled( - self.prompt, - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - ), - Span::styled( - placeholder, - Style::default().fg(palette::TEXT_MUTED).italic(), - ), - ])); + let mut block = Block::default() + .title(Line::from(Span::styled( + if is_draft_mode { "Draft" } else { "Composer" }, + Style::default().fg(palette::TEXT_MUTED), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .style(background); + if let Some(hint_line) = hint_line { + block = block.title_bottom(hint_line); + } + block.render(area, buf); } else { - for (idx, line) in visible_lines.iter().enumerate() { - let prefix = if idx == 0 { - self.prompt - } else { - continuation.as_str() - }; - lines.push(Line::from(vec![ - Span::styled(prefix, Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled(line.clone(), Style::default().fg(palette::TEXT_PRIMARY)), - ])); + Block::default().style(background).render(area, buf); + } + + let mut input_lines = Vec::new(); + if self.app.input.is_empty() { + input_lines.push(Line::from(Span::styled( + "Write a task or use /.", + Style::default().fg(palette::TEXT_MUTED).italic(), + ))); + } else { + for line in &visible_lines { + input_lines.push(Line::from(Span::styled( + line.clone(), + Style::default().fg(palette::TEXT_PRIMARY), + ))); } } + let top_padding = composer_vertical_padding(input_lines.len(), input_rows_budget); + let mut lines = Vec::new(); + for _ in 0..top_padding { + lines.push(Line::from("")); + } + lines.extend(input_lines); + if !self.slash_menu_entries.is_empty() { let selected = self .app @@ -201,42 +256,56 @@ impl Renderable for ComposerWidget<'_> { } } - let paragraph = Paragraph::new(lines).style(background); - paragraph.render(area, buf); + let paragraph = Paragraph::new(lines) + .style(background) + .wrap(Wrap { trim: false }); + paragraph.render(inner_area, buf); } fn desired_height(&self, width: u16) -> u16 { composer_height( &self.app.input, width, - self.max_height, - self.prompt, + self.max_height.min(self.max_height_cap()), self.slash_menu_entries.len(), + self.app.composer_density, ) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - 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(self.slash_menu_entries.len()) - .max(1); + let inner_area = Self::inner_area(area); + let content_width = usize::from(inner_area.width.max(1)); + let input_rows_budget = + composer_input_rows_budget(inner_area.height, self.slash_menu_entries.len()); let (_visible_lines, cursor_row, cursor_col) = layout_input( &self.app.input, self.app.cursor_position, content_width, - max_height, + input_rows_budget, ); + let top_padding = if self.app.input.is_empty() { + let empty_lines = if self.app.input_history.is_empty() && input_rows_budget > 1 { + 2 + } else { + 1 + }; + composer_vertical_padding(empty_lines, input_rows_budget) + } else { + let visible_count = wrap_input_lines(&self.app.input, content_width) + .len() + .max(1); + composer_vertical_padding(visible_count.min(input_rows_budget), input_rows_budget) + }; let cursor_x = area .x - .saturating_add(prompt_width_u16) + .saturating_add(inner_area.x.saturating_sub(area.x)) .saturating_add(u16::try_from(cursor_col).unwrap_or(u16::MAX)); let cursor_y = area .y - .saturating_add(u16::try_from(cursor_row).unwrap_or(u16::MAX)); + .saturating_add(inner_area.y.saturating_sub(area.y)) + .saturating_add(u16::try_from(top_padding + cursor_row).unwrap_or(u16::MAX)); if cursor_x < area.x + area.width && cursor_y < area.y + area.height { Some((cursor_x, cursor_y)) } else { @@ -681,22 +750,110 @@ fn char_display_width(ch: char) -> usize { } } +fn should_render_empty_state(app: &App) -> bool { + app.history.is_empty() && !app.is_loading && !app.is_compacting +} + +fn build_empty_state_lines(app: &App, area: Rect) -> Vec> { + if area.width == 0 || area.height == 0 { + return Vec::new(); + } + + let workspace_name = app + .workspace + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .map(std::string::ToString::to_string) + .unwrap_or_else(|| app.workspace.to_string_lossy().into_owned()); + let body_width = usize::from(area.width.saturating_sub(8).clamp(24, 72)); + let left_padding = usize::from(area.width.saturating_sub(body_width as u16) / 2); + let inset = " ".repeat(left_padding); + + let mut body = vec![ + Line::from(Span::styled( + format!("{inset}DeepSeek TUI"), + Style::default().fg(palette::DEEPSEEK_BLUE).bold(), + )), + Line::from(Span::styled( + format!("{inset}{workspace_name} · {}", app.model), + Style::default().fg(palette::TEXT_MUTED), + )), + Line::from(""), + ]; + + for line in wrap_text( + "Start in plain language. The transcript stays clear until the first real turn.", + body_width, + ) { + body.push(Line::from(Span::styled( + format!("{inset}{line}"), + Style::default().fg(palette::TEXT_PRIMARY), + ))); + } + + let top_padding = usize::from(area.height.saturating_sub(body.len() as u16) / 3); + let mut lines = Vec::new(); + for _ in 0..top_padding { + lines.push(Line::from("")); + } + lines.extend(body); + lines +} + +fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize { + usize::from(inner_height).saturating_sub(extra_lines).max(1) +} + +fn composer_vertical_padding(content_lines: usize, rows_budget: usize) -> usize { + rows_budget.saturating_sub(content_lines) +} + +fn composer_min_input_rows(density: ComposerDensity) -> usize { + match density { + ComposerDensity::Compact => 2, + ComposerDensity::Comfortable => 3, + ComposerDensity::Spacious => 4, + } +} + +fn composer_max_height(density: ComposerDensity) -> u16 { + match density { + ComposerDensity::Compact => 7, + ComposerDensity::Comfortable => 9, + ComposerDensity::Spacious => 12, + } +} + fn composer_height( input: &str, width: u16, available_height: u16, - prompt: &str, extra_lines: usize, + density: ComposerDensity, ) -> u16 { - let prompt_width = prompt.width(); - let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX); - let content_width = usize::from(width.saturating_sub(prompt_width_u16).max(1)); + let has_panel = available_height >= 3 && width >= 12; + let chrome_height = if has_panel { + usize::from(COMPOSER_PANEL_HEIGHT) + } else { + 0 + }; + let content_width = if has_panel { + usize::from(width.saturating_sub(2).max(1)) + } else { + usize::from(width.max(1)) + }; let mut line_count = wrap_input_lines(input, content_width).len(); if line_count == 0 { line_count = 1; } - line_count = line_count.saturating_add(extra_lines); - let max_height = usize::from(available_height.clamp(1, 8)); + if has_panel { + line_count = line_count.max(composer_min_input_rows(density)); + } + line_count = line_count + .saturating_add(extra_lines) + .saturating_add(chrome_height); + let max_height = usize::from(available_height.clamp(1, composer_max_height(density))); line_count.clamp(1, max_height).try_into().unwrap_or(1) } @@ -864,10 +1021,12 @@ fn wrap_text(text: &str, width: usize) -> Vec { #[cfg(test)] mod tests { 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, + COMPOSER_PANEL_HEIGHT, apply_selection_to_line, composer_height, composer_max_height, + composer_min_input_rows, cursor_row_col, layout_input, pad_lines_to_bottom, + should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text, }; use crate::palette; + use crate::tui::app::ComposerDensity; use ratatui::{ style::Style, text::{Line, Span}, @@ -1058,15 +1217,32 @@ mod tests { #[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 height = composer_height( + input, + width, + available_height, + menu_lines, + ComposerDensity::Comfortable, + ); + let has_panel = available_height >= 3 && width >= 12; + let chrome_height = if has_panel { + usize::from(COMPOSER_PANEL_HEIGHT) + } else { + 0 + }; + let content_width = if has_panel { + usize::from(width.saturating_sub(2).max(1)) + } else { + usize::from(width.max(1)) + }; + let input_height_budget = usize::from(height) + .saturating_sub(menu_lines) + .saturating_sub(chrome_height) + .max(1); let (visible, cursor_row, cursor_col) = layout_input( input, input.chars().count(), @@ -1078,5 +1254,52 @@ mod tests { assert!(!visible.is_empty()); assert!(cursor_row < visible.len()); assert!(cursor_col < content_width.max(1)); + assert!(height >= 5); + } + + #[test] + fn composer_height_prefers_panel_shape_when_space_allows() { + let height = composer_height("", 40, 8, 0, ComposerDensity::Comfortable); + assert_eq!(height, 5); + } + + #[test] + fn composer_density_changes_min_rows_and_height_cap() { + assert_eq!(composer_min_input_rows(ComposerDensity::Compact), 2); + assert_eq!(composer_min_input_rows(ComposerDensity::Spacious), 4); + assert!( + composer_max_height(ComposerDensity::Spacious) + > composer_max_height(ComposerDensity::Compact) + ); + } + + #[test] + fn empty_state_renders_only_without_transcript_activity() { + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + let options = TuiOptions { + model: "deepseek-chat".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: true, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }; + let mut app = App::new(options, &Config::default()); + assert!(should_render_empty_state(&app)); + app.add_message(crate::tui::history::HistoryCell::User { + content: "hello".to_string(), + }); + assert!(!should_render_empty_state(&app)); } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c70c00a1..38162d81 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -86,10 +86,14 @@ Common settings keys: - `auto_compact` (on/off) - `show_thinking` (on/off) - `show_tool_details` (on/off) -- `default_mode` (normal, agent, plan, yolo) +- `default_mode` (agent, plan, yolo; legacy `normal` is accepted and normalized to `agent`) - `max_history` (number of input history entries) - `default_model` (model name override) +Only `agent`, `plan`, and `yolo` are visible modes in the UI. For compatibility, +older settings files with `default_mode = "normal"` still load as `agent`, and +the hidden `/normal` slash command switches to `Agent`. + Readability semantics: - Selection uses a unified style across transcript, composer menus, and modals. @@ -103,6 +107,8 @@ If you are upgrading from older releases: New: `/links` (aliases: `/dashboard`, `/api`) - Old: `/set model deepseek-reasoner` New: `/config` and edit the `model` row to `deepseek-reasoner` +- Old: visible `Normal` mode or `default_mode = "normal"` + New: use `Agent` / `default_mode = "agent"`; legacy `normal` still maps to `agent` - Old: discover `/set` in slash UX/help New: use `/config` for editing and `/settings` for read-only inspection diff --git a/docs/MODES.md b/docs/MODES.md index d256b440..50100290 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -2,19 +2,23 @@ DeepSeek TUI has two related concepts: -- **TUI mode**: what kind of interaction you’re in (Normal/Plan/Agent/YOLO). +- **TUI mode**: what kind of visible interaction you’re in (Plan/Agent/YOLO). - **Approval mode**: how aggressively the UI asks before executing tools. ## TUI Modes -Press `Tab` to cycle forward: **Normal → Agent → YOLO → Plan → Normal**. +Press `Tab` to cycle through the visible modes: **Plan → Agent → YOLO → Plan**. Press `Shift+Tab` to cycle in reverse. -- **Normal**: chat-first. Approvals for file writes, shell, and paid tools. -- **Plan**: design-first prompting. Approvals match Normal. +- **Plan**: design-first prompting. Read-only investigation tools stay available, but shell and patch execution stay off. - **Agent**: multi-step tool use. Approvals for shell and paid tools (file writes are allowed without a prompt). - **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos. +## Compatibility Notes + +- `/normal` is a hidden compatibility alias that switches to `Agent`. +- Older settings files with `default_mode = "normal"` still load as `agent`; saving rewrites the normalized value. + ## Escape Key Behavior `Esc` is a cancel stack, not a mode switch.