diff --git a/.gitignore b/.gitignore index 0e5b8b72..010508bd 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ docs/rlm-paper.txt # Local runtime state .deepseek/ session_*.json + +# Companion app (tracked separately) +apps/ diff --git a/src/commands/config.rs b/src/commands/config.rs index c0cd898e..b55a1a84 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,4 +1,4 @@ -//! Config commands: config, set, settings, yolo, trust, logout +//! Config commands: config, set, settings, mode switches, trust, logout use super::CommandResult; use crate::config::{COMMON_DEEPSEEK_MODELS, canonical_model_name, clear_api_key}; @@ -209,6 +209,26 @@ 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) +pub fn normal_mode(app: &mut App) -> CommandResult { + app.set_mode(AppMode::Normal); + CommandResult::message("Normal mode enabled.") +} + +/// Enable agent mode (autonomous tool use with approvals) +pub fn agent_mode(app: &mut App) -> CommandResult { + app.set_mode(AppMode::Agent); + CommandResult::message("Agent mode enabled.") +} + +/// Enable plan mode (tool planning, then choose execution route) +pub fn plan_mode(app: &mut App) -> CommandResult { + app.set_mode(AppMode::Plan); + CommandResult::message( + "Plan mode enabled. Describe your goal and I will create a plan before execution.", + ) +} + /// Enable trust mode (file access outside workspace) pub fn trust(app: &mut App) -> CommandResult { app.trust_mode = true; @@ -344,6 +364,17 @@ mod tests { assert_eq!(app.mode, AppMode::Yolo); } + #[test] + fn test_mode_switch_commands() { + let mut app = create_test_app(); + let _ = normal_mode(&mut app); + assert_eq!(app.mode, AppMode::Normal); + let _ = agent_mode(&mut app); + assert_eq!(app.mode, AppMode::Agent); + let _ = plan_mode(&mut app); + assert_eq!(app.mode, AppMode::Plan); + } + #[test] fn test_show_config_displays_all_fields() { let mut app = create_test_app(); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index dcccb41f..fdabb626 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -77,6 +77,28 @@ pub struct CommandInfo { pub usage: &'static str, } +impl CommandInfo { + pub fn requires_argument(&self) -> bool { + self.usage.contains('<') || self.usage.contains('[') + } + + pub fn palette_command(&self) -> String { + if self.requires_argument() { + format!("/{} ", self.name) + } else { + format!("/{}", self.name) + } + } + + pub fn palette_description(&self) -> String { + if self.aliases.is_empty() { + self.description.to_string() + } else { + format!("{} aliases: {}", self.description, self.aliases.join(", ")) + } + } +} + /// All registered commands pub const COMMANDS: &[CommandInfo] = &[ // Core commands @@ -196,6 +218,24 @@ 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: &[], + description: "Switch to agent mode", + usage: "/agent", + }, + CommandInfo { + name: "plan", + aliases: &[], + description: "Switch to plan mode and review suggested implementation steps", + usage: "/plan", + }, CommandInfo { name: "trust", aliases: &[], @@ -313,6 +353,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "settings" => config::show_settings(app), "set" => config::set_config(app, arg), "yolo" => config::yolo(app), + "normal" => config::normal_mode(app), + "agent" => config::agent_mode(app), + "plan" => config::plan_mode(app), "trust" => config::trust(app), "logout" => config::logout(app), diff --git a/src/tui/app.rs b/src/tui/app.rs index d65270cf..a46e4e07 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -37,7 +37,9 @@ fn format_welcome_banner(model: &str, workspace: &PathBuf, yolo: bool) -> String }; format!( - "Tips: Tab to switch modes, F1 or /help for commands, Esc to cancel\n\ + "Tips: Tab cycles modes forward/reverse (Normal→Agent→YOLO→Plan),\n\ + Alt+N/A/Y/P or Alt+1/2/3/4 for direct mode switch, F1 or /help, Esc to cancel\n\ + Alt+!/@/#/$/) to focus sidebar sections (Plan/Todos/Tasks/Agents/Auto), F1 for help\n\ {mode_line}\ Directory: {}\n\ Model: {}", @@ -360,6 +362,10 @@ pub struct App { pub last_prompt_tokens: Option, /// Last completion token usage pub last_completion_tokens: Option, + /// Cached git context snapshot for the footer. + pub workspace_context: Option, + /// Timestamp for cached workspace context. + pub workspace_context_refreshed_at: Option, /// Cached background tasks for sidebar rendering. pub task_panel: Vec, /// Whether the UI needs to be redrawn. @@ -598,6 +604,8 @@ impl App { runtime_turn_status: None, last_prompt_tokens: None, last_completion_tokens: None, + workspace_context: None, + workspace_context_refreshed_at: None, task_panel: Vec::new(), needs_redraw: true, } @@ -630,8 +638,12 @@ impl App { }); } - pub fn set_mode(&mut self, mode: AppMode) { + pub fn set_mode(&mut self, mode: AppMode) -> bool { let previous_mode = self.mode; + if previous_mode == mode { + return false; + } + self.mode = mode; self.status_message = Some(format!("Switched to {} mode", mode.label())); self.allow_shell = true; @@ -655,16 +667,29 @@ impl App { .with_model(&self.model); let _ = self.hooks.execute(HookEvent::ModeChange, &context); self.needs_redraw = true; + true } - /// Cycle through modes: Plan -> Agent -> YOLO -> Plan + /// Cycle through modes: Normal -> Agent -> YOLO -> Plan pub fn cycle_mode(&mut self) { let next = match self.mode { - AppMode::Plan => AppMode::Agent, + AppMode::Normal => AppMode::Agent, AppMode::Agent => AppMode::Yolo, - AppMode::Yolo | AppMode::Normal => AppMode::Plan, + AppMode::Yolo => AppMode::Plan, + AppMode::Plan => AppMode::Normal, }; - self.set_mode(next); + let _ = self.set_mode(next); + } + + /// Cycle through modes in reverse: Plan -> YOLO -> Agent -> Normal + pub fn cycle_mode_reverse(&mut self) { + let next = match self.mode { + AppMode::Normal => AppMode::Plan, + AppMode::Agent => AppMode::Normal, + AppMode::Yolo => AppMode::Agent, + AppMode::Plan => AppMode::Yolo, + }; + let _ = self.set_mode(next); } /// Execute hooks for a specific event with the given context @@ -1277,6 +1302,19 @@ mod tests { assert_ne!(app.mode, initial_mode); } + #[test] + fn test_cycle_mode_reverse_transitions() { + let mut app = App::new(test_options(false), &Config::default()); + + app.mode = AppMode::Plan; + app.cycle_mode_reverse(); + assert_eq!(app.mode, AppMode::Yolo); + + app.mode = AppMode::Normal; + app.cycle_mode_reverse(); + assert_eq!(app.mode, AppMode::Plan); + } + #[test] fn test_clear_input() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/src/tui/command_palette.rs b/src/tui/command_palette.rs index c187bde1..73ca8e68 100644 --- a/src/tui/command_palette.rs +++ b/src/tui/command_palette.rs @@ -7,19 +7,30 @@ use ratatui::{ buffer::Buffer, layout::Rect, prelude::Stylize, - style::Style, + style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}, + widgets::{Block, Borders, Clear, Paragraph, Padding, Widget, Wrap}, }; use unicode_width::UnicodeWidthStr; use crate::commands; use crate::palette; +use crate::tools::{ToolContext, ToolRegistryBuilder}; +use crate::tools::spec::ApprovalRequirement; +use crate::tools::spec::ToolCapability; use crate::skills::SkillRegistry; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum PaletteSection { + Command, + Skill, + Tool, +} + #[derive(Debug, Clone)] pub struct CommandPaletteEntry { + section: PaletteSection, pub label: String, pub description: String, pub command: String, @@ -32,36 +43,219 @@ pub struct CommandPaletteView { selected: usize, } -pub fn build_entries(skills_dir: &Path) -> Vec { +pub fn build_entries(skills_dir: &Path, workspace: &Path) -> Vec { let mut entries = Vec::new(); for command in commands::COMMANDS { - let requires_args = command.usage.contains('<'); - let command_text = if requires_args { - format!("/{} ", command.name) - } else { - format!("/{}", command.name) - }; + let mut description = command.palette_description(); + if command.requires_argument() { + description.push_str(" "); + description.push_str(command.usage); + } entries.push(CommandPaletteEntry { + section: PaletteSection::Command, label: format!("/{}", command.name), - description: command.description.to_string(), - command: command_text, + description, + command: command.palette_command(), }); } let skills = SkillRegistry::discover(skills_dir); for skill in skills.list() { entries.push(CommandPaletteEntry { + section: PaletteSection::Skill, label: format!("skill:{}", skill.name), description: skill.description.clone(), command: format!("/skill {}", skill.name), }); } + let context = ToolContext::new(workspace); + let registry = ToolRegistryBuilder::new() + .with_file_tools() + .with_search_tools() + .with_shell_tools() + .with_web_tools() + .with_git_tools() + .with_structured_data_tools() + .with_user_input_tool() + .with_parallel_tool() + .with_patch_tools() + .with_note_tool() + .with_diagnostics_tool() + .with_project_tools() + .with_test_runner_tool() + .build(context); + + let mut tool_entries = registry + .all() + .into_iter() + .filter_map(|tool| { + let name = tool.name().to_string(); + let capabilities = tool.capabilities(); + + let mut tags = Vec::new(); + if tool.is_read_only() { + tags.push("read-only"); + } + if capabilities.contains(&ToolCapability::WritesFiles) { + tags.push("writes"); + } + if capabilities.contains(&ToolCapability::ExecutesCode) { + tags.push("shell"); + } + if capabilities.contains(&ToolCapability::Network) { + tags.push("network"); + } + if tool.supports_parallel() { + tags.push("parallel"); + } + match tool.approval_requirement() { + ApprovalRequirement::Required => tags.push("requires approval"), + ApprovalRequirement::Suggest => tags.push("suggest approval"), + ApprovalRequirement::Auto => {} + } + + let mut description = tool.description().to_string(); + if !tags.is_empty() { + description.push_str(" ["); + description.push_str(&tags.join(", ")); + description.push(']'); + } + + if name.trim().is_empty() { + return None; + } + Some(CommandPaletteEntry { + section: PaletteSection::Tool, + label: format!("tool:{name}"), + description, + command: name, + }) + }) + .collect::>(); + tool_entries.sort_by(|a, b| a.label.cmp(&b.label)); + entries.extend(tool_entries); + entries.sort_by(|a, b| a.label.cmp(&b.label)); + entries.sort_by_key(|entry| entry.section); entries } +fn modal_block() -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)) +} + +fn parse_section_term(term: &str) -> Option<(PaletteSection, String)> { + let mut parts = term.splitn(2, ':'); + let section = parts.next()?; + let query = parts.next()?; + + if section.is_empty() || query.is_empty() { + return None; + } + + let query = query.to_ascii_lowercase(); + let section = match section { + "c" | "cmd" | "command" | "commands" => PaletteSection::Command, + "s" | "skill" | "skills" => PaletteSection::Skill, + "t" | "tool" | "tools" => PaletteSection::Tool, + _ => return None, + }; + + Some((section, query)) +} + +fn section_tag(section: PaletteSection) -> &'static str { + match section { + PaletteSection::Command => "command", + PaletteSection::Skill => "skill", + PaletteSection::Tool => "tool", + } +} + +fn section_rank(section: PaletteSection) -> usize { + match section { + PaletteSection::Command => 0, + PaletteSection::Skill => 1, + PaletteSection::Tool => 2, + } +} + +fn term_score(term: &str, label: &str, description: &str, command: &str, haystack: &str) -> usize { + if term.is_empty() { + return 0; + } + + if label == term || command == term || description == term { + return 0; + } + + if label.starts_with(term) { + return 8; + } + + if command.starts_with(term) { + return 16; + } + + if description.contains(term) { + return 64; + } + + if label.contains(term) { + return 32; + } + + if command.contains(term) { + return 48; + } + + if haystack.contains(term) { + return 96; + } + + 128 +} + +fn entry_match_score(entry: &CommandPaletteEntry, terms: &[&str]) -> Option { + if terms.is_empty() { + return Some(0); + } + + let section = section_tag(entry.section); + let label = entry.label.to_ascii_lowercase(); + let description = entry.description.to_ascii_lowercase(); + let command = entry.command.to_ascii_lowercase(); + let entry_text = format!("{section} {label} {description} {command}"); + + let mut total_score = 0usize; + + for term in terms { + if let Some((required_section, scoped_query)) = parse_section_term(term) { + if entry.section != required_section { + return None; + } + if !entry_text.contains(&scoped_query) { + return None; + } + total_score += term_score(&scoped_query, &label, &description, &command, &entry_text); + continue; + } + + if !entry_text.contains(term) { + return None; + } + total_score += term_score(term, &label, &description, &command, &entry_text); + } + + Some(total_score) +} + impl CommandPaletteView { pub fn new(entries: Vec) -> Self { let mut view = Self { @@ -76,28 +270,66 @@ impl CommandPaletteView { fn refilter(&mut self) { let query = self.query.trim().to_ascii_lowercase(); - self.filtered = self + let terms: Vec<&str> = query + .split_whitespace() + .filter(|term| !term.is_empty()) + .collect(); + + let mut filtered = self .entries .iter() .enumerate() - .filter_map(|(idx, entry)| { - if query.is_empty() - || entry.label.to_ascii_lowercase().contains(&query) - || entry.description.to_ascii_lowercase().contains(&query) - || entry.command.to_ascii_lowercase().contains(&query) - { - Some(idx) - } else { - None - } - }) - .collect(); + .filter_map(|(idx, entry)| entry_match_score(entry, &terms).map(|score| (idx, score))) + .collect::>(); + filtered.sort_by_key(|(idx, score)| { + let entry = &self.entries[*idx]; + (section_rank(entry.section), *score, &entry.label) + }); + self.filtered = filtered.into_iter().map(|(idx, _)| idx).collect(); if self.selected >= self.filtered.len() { self.selected = 0; } } + fn scope_hint_lines() -> Line<'static> { + let hint = "scope: c:/cmd: , s:/skill: , t:/tool:"; + Line::from(Span::styled( + hint, + Style::default().fg(palette::TEXT_DIM).add_modifier(Modifier::ITALIC), + )) + } + + fn format_section_label(section: PaletteSection, count: usize) -> Line<'static> { + let title = match section { + PaletteSection::Command => "Commands", + PaletteSection::Skill => "Skills", + PaletteSection::Tool => "Tools", + }; + Line::from(vec![Span::styled( + format!(" {title} ({count}) "), + Style::default().fg(palette::DEEPSEEK_SKY).add_modifier(Modifier::BOLD), + )]) + } + + fn scope_examples() -> Vec> { + vec![ + Line::from(Span::styled("Try:", Style::default().fg(palette::TEXT_DIM))), + Line::from(Span::styled( + " c: Command-only e.g. c:agent", + Style::default().fg(palette::TEXT_MUTED), + )), + Line::from(Span::styled( + " s: Skill-only e.g. s:search", + Style::default().fg(palette::TEXT_MUTED), + )), + Line::from(Span::styled( + " t: Tool-only e.g. t:git", + Style::default().fg(palette::TEXT_MUTED), + )), + ] + } + fn move_selection(&mut self, delta: isize) { if self.filtered.is_empty() { self.selected = 0; @@ -178,7 +410,7 @@ impl ModalView for CommandPaletteView { let mut lines = Vec::new(); let query_label = if self.query.is_empty() { - "Type to filter…".to_string() + "Type to filter".to_string() } else { format!("Filter: {}", self.query) }; @@ -186,21 +418,58 @@ impl ModalView for CommandPaletteView { query_label, Style::default().fg(palette::TEXT_MUTED), ))); + let match_count = if self.query.is_empty() { + format!("{} entries", self.entries.len()) + } else { + format!("{} / {} matches", self.filtered.len(), self.entries.len()) + }; + lines.push(Line::from(Span::styled( + match_count, + Style::default().fg(palette::TEXT_DIM).italic(), + ))); + lines.push(Self::scope_hint_lines()); + lines.extend(Self::scope_examples()); lines.push(Line::from("")); - let visible = popup_height.saturating_sub(5) as usize; + let visible = popup_height.saturating_sub(6) as usize; + let mut command_count = 0usize; + let mut skill_count = 0usize; + let mut tool_count = 0usize; + for idx in &self.filtered { + match self.entries[*idx].section { + PaletteSection::Command => command_count += 1, + PaletteSection::Skill => skill_count += 1, + PaletteSection::Tool => tool_count += 1, + } + } if self.filtered.is_empty() { lines.push(Line::from(Span::styled( "No matches.", Style::default().fg(palette::TEXT_MUTED).italic(), ))); } else { + let label_width = 24.min(popup_width.saturating_sub(26) as usize); let start = self.selected.saturating_sub(visible.saturating_sub(1)); let end = (start + visible).min(self.filtered.len()); + let mut active_section = None; for (slot, idx) in self.filtered[start..end].iter().enumerate() { let absolute = start + slot; let is_selected = absolute == self.selected; let entry = &self.entries[*idx]; + + if active_section != Some(entry.section) { + if slot > 0 { + lines.push(Line::from("")); + } + let count = match entry.section { + PaletteSection::Command => command_count, + PaletteSection::Skill => skill_count, + PaletteSection::Tool => tool_count, + }; + lines.push(Self::format_section_label(entry.section, count)); + active_section = Some(entry.section); + } + let style = if is_selected { Style::default() .fg(palette::DEEPSEEK_SKY) @@ -209,11 +478,12 @@ impl ModalView for CommandPaletteView { Style::default().fg(palette::TEXT_PRIMARY) }; - let mut line = format!("{:<24}", entry.label); - let desc = if entry.description.width() > 56 { + let mut line = format!(" {: desc_capacity { let mut shortened = String::new(); for ch in entry.description.chars() { - if shortened.width() >= 53 { + if shortened.width() >= desc_capacity.saturating_sub(3) { break; } shortened.push(ch); @@ -222,20 +492,22 @@ impl ModalView for CommandPaletteView { } else { entry.description.clone() }; + if is_selected { + line = format!("> {: CommandPaletteEntry { + CommandPaletteEntry { + section, + label: label.to_string(), + description: description.to_string(), + command: command.to_string(), + } + } + + #[test] + fn command_palette_filters_with_section_shortcuts() { + let entries = vec![ + palette_entry(PaletteSection::Command, "/agent", "agent command", "/agent"), + palette_entry(PaletteSection::Skill, "skill:search", "search skill", "/skill search"), + palette_entry(PaletteSection::Tool, "tool:git", "git tool", "git"), + palette_entry(PaletteSection::Tool, "tool:search", "search utility", "search"), + ]; + let mut view = CommandPaletteView::new(entries); + + view.query = "c:agent".to_string(); + view.refilter(); + assert_eq!(view.filtered, vec![0]); + + view.query = "s:search".to_string(); + view.refilter(); + assert_eq!(view.filtered, vec![1]); + + view.query = "t:search".to_string(); + view.refilter(); + assert_eq!(view.filtered, vec![3]); + } + + #[test] + fn command_palette_ranks_label_matches_before_description_matches() { + let entries = vec![ + palette_entry( + PaletteSection::Command, + "/git", + "status summary for repository", + "git", + ), + palette_entry( + PaletteSection::Command, + "/config", + "configure git settings", + "config", + ), + palette_entry( + PaletteSection::Command, + "/sync", + "sync repository state", + "sync", + ), + ]; + let mut view = CommandPaletteView::new(entries); + + view.query = "git".to_string(); + view.refilter(); + + assert_eq!(view.entries[view.filtered[0]].label, "/git"); + assert_eq!(view.entries[view.filtered[1]].label, "/config"); + } + + #[test] + fn command_palette_supports_multiple_terms() { + let entries = vec![ + palette_entry( + PaletteSection::Command, + "/search-code", + "search with ripgrep", + "search code", + ), + palette_entry( + PaletteSection::Tool, + "tool:search", + "search web and files", + "search", + ), + palette_entry( + PaletteSection::Skill, + "skill:search", + "search files and docs", + "/skill search", + ), + ]; + let mut view = CommandPaletteView::new(entries); + + view.query = "search code".to_string(); + view.refilter(); + assert_eq!(view.filtered.len(), 1); + assert_eq!(view.entries[view.filtered[0]].label, "/search-code"); + + view.query = "s:search".to_string(); + view.refilter(); + assert_eq!(view.filtered.len(), 1); + assert_eq!(view.entries[view.filtered[0]].label, "skill:search"); + } +} diff --git a/src/tui/onboarding/mod.rs b/src/tui/onboarding/mod.rs index 99fa5ecb..1584b95a 100644 --- a/src/tui/onboarding/mod.rs +++ b/src/tui/onboarding/mod.rs @@ -94,7 +94,15 @@ pub fn tips_lines() -> Vec> { .add_modifier(Modifier::BOLD), )), Line::from(""), - Line::from(Span::raw(" - Tab cycles modes (Plan → Agent → YOLO)")), + Line::from(Span::raw( + " - Tab cycles modes (Normal → Agent → YOLO → Plan), Shift+Tab reverses", + )), + Line::from(Span::raw( + " - Alt+1/2/3/4 switch modes (Normal/Agent/YOLO/Plan)", + )), + Line::from(Span::raw( + " - Alt+!/@/#/$/) focus sidebar sections (Plan/Todos/Tasks/Agents/Auto)", + )), Line::from(Span::raw(" - Ctrl+R opens the session picker")), Line::from(Span::raw(" - l opens the pager for the last message")), Line::from(Span::raw(" - Ctrl+C cancels or exits")), diff --git a/src/tui/pager.rs b/src/tui/pager.rs index 2a21af50..6922ca5e 100644 --- a/src/tui/pager.rs +++ b/src/tui/pager.rs @@ -6,7 +6,7 @@ use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}, + widgets::{Block, Borders, Clear, Paragraph, Padding, Widget, Wrap}, }; use unicode_width::UnicodeWidthStr; @@ -260,7 +260,9 @@ impl ModalView for PagerView { let block = Block::default() .title(self.title.clone()) .borders(Borders::ALL) - .border_style(Style::default().fg(palette::DEEPSEEK_SKY)); + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)); let paragraph = Paragraph::new(visible_lines) .block(block) diff --git a/src/tui/plan_prompt.rs b/src/tui/plan_prompt.rs index e7434a13..4a546bf0 100644 --- a/src/tui/plan_prompt.rs +++ b/src/tui/plan_prompt.rs @@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Paragraph, Padding, Wrap}; use crate::palette; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; @@ -70,6 +70,38 @@ impl ModalView for PlanPromptView { self.selected = (self.selected + 1).min(self.max_index()); ViewAction::None } + KeyCode::Char('1') => { + self.selected = 0; + self.submit_selected() + } + KeyCode::Char('2') => { + self.selected = 1; + self.submit_selected() + } + KeyCode::Char('3') => { + self.selected = 2; + self.submit_selected() + } + KeyCode::Char('4') => { + self.selected = 3; + self.submit_selected() + } + KeyCode::Char('a') | KeyCode::Char('A') => { + self.selected = 0; + self.submit_selected() + } + KeyCode::Char('y') | KeyCode::Char('Y') => { + self.selected = 1; + self.submit_selected() + } + KeyCode::Char('r') | KeyCode::Char('R') => { + self.selected = 2; + self.submit_selected() + } + KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Char('e') | KeyCode::Char('E') => { + self.selected = 3; + self.submit_selected() + } KeyCode::Char(ch) if ch.is_ascii_digit() => { let number = ch.to_digit(10).unwrap_or(0); Self::submit_number(number) @@ -93,7 +125,10 @@ impl ModalView for PlanPromptView { let prefix = if selected { ">" } else { " " }; let number = idx + 1; let style = if selected { - Style::default().fg(palette::DEEPSEEK_SKY).bold() + Style::default() + .fg(palette::DEEPSEEK_SKY) + .bg(palette::SELECTION_BG) + .bold() } else { Style::default().fg(palette::TEXT_PRIMARY) }; @@ -110,7 +145,7 @@ impl ModalView for PlanPromptView { lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "1-4=quick pick, Up/Down=select, Enter=confirm, Esc=close", + "1-4 / a / y / r / q = quick pick, Up/Down=select, Enter=confirm, Esc=close", Style::default().fg(palette::TEXT_MUTED), ))); @@ -124,7 +159,9 @@ impl ModalView for PlanPromptView { Style::default().fg(palette::DEEPSEEK_BLUE).bold(), )])) .borders(Borders::ALL) - .border_style(Style::default().fg(palette::DEEPSEEK_SKY)), + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)), ); let popup_area = centered_rect(66, 42, area); diff --git a/src/tui/session_picker.rs b/src/tui/session_picker.rs index b87bd3f4..4857f5c5 100644 --- a/src/tui/session_picker.rs +++ b/src/tui/session_picker.rs @@ -9,7 +9,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}, + widgets::{Block, Borders, Clear, Paragraph, Padding, Widget, Wrap}, }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -17,6 +17,20 @@ use crate::palette; use crate::session_manager::{SavedSession, SessionManager, SessionMetadata}; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; +fn modal_block(title: &str) -> Block<'static> { + Block::default() + .title(Line::from(vec![Span::styled( + title.to_string(), + Style::default() + .fg(palette::DEEPSEEK_BLUE) + .add_modifier(Modifier::BOLD), + )])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)) +} + #[derive(Debug, Clone, Copy)] enum SortMode { Recent, @@ -311,12 +325,7 @@ impl ModalView for SessionPickerView { self.status.as_deref(), ); let list = Paragraph::new(list_lines) - .block( - Block::default() - .title(" Sessions ") - .borders(Borders::ALL) - .border_style(Style::default().fg(palette::DEEPSEEK_SKY)), - ) + .block(modal_block(" Sessions ")) .wrap(Wrap { trim: false }); list.render(chunks[0], buf); @@ -327,12 +336,7 @@ impl ModalView for SessionPickerView { ); let preview = Paragraph::new(preview_lines) - .block( - Block::default() - .title(" Preview ") - .borders(Borders::ALL) - .border_style(Style::default().fg(palette::DEEPSEEK_SKY)), - ) + .block(modal_block(" Preview ")) .wrap(Wrap { trim: false }); preview.render(chunks[1], buf); } @@ -388,7 +392,7 @@ fn build_list_lines( let style = if idx == selected { Style::default() .fg(palette::DEEPSEEK_SKY) - .add_modifier(Modifier::REVERSED) + .bg(palette::SELECTION_BG) } else { Style::default().fg(palette::TEXT_PRIMARY) }; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index bdb21350..61df8ea6 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -2,7 +2,8 @@ use std::fmt::Write; use std::io::{self, Stdout}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::{Duration, Instant}; use anyhow::Result; @@ -21,7 +22,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Style, Stylize}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{Block, Borders, Padding, Paragraph, Wrap}, }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -91,6 +92,7 @@ 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 WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15; /// Run the interactive TUI event loop. /// @@ -818,6 +820,7 @@ async fn run_event_loop( let now = Instant::now(); app.flush_paste_burst_if_due(now); app.sync_status_message_to_toasts(); + refresh_workspace_context_if_needed(app, now); if app.needs_redraw { terminal.draw(|f| render(f, app))?; // app is &mut @@ -982,7 +985,7 @@ async fn run_event_loop( if app.view_stack.top_kind() == Some(ModalKind::Help) { app.view_stack.pop(); } else { - app.view_stack.push(HelpView::new()); + app.view_stack.push(HelpView::new_for_workspace(app.workspace.clone())); } continue; } @@ -991,7 +994,7 @@ async fn run_event_loop( if app.view_stack.top_kind() == Some(ModalKind::Help) { app.view_stack.pop(); } else { - app.view_stack.push(HelpView::new()); + app.view_stack.push(HelpView::new_for_workspace(app.workspace.clone())); } continue; } @@ -1000,6 +1003,7 @@ async fn run_event_loop( app.view_stack .push(CommandPaletteView::new(build_command_palette_entries( &app.skills_dir, + &app.workspace, ))); continue; } @@ -1054,25 +1058,66 @@ async fn run_event_loop( } } KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + app.set_sidebar_focus(SidebarFocus::Plan); + app.status_message = Some("Sidebar focus: plan".to_string()); + } else { + app.set_mode(AppMode::Normal); + } + continue; + } + KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + app.set_sidebar_focus(SidebarFocus::Todos); + app.status_message = Some("Sidebar focus: todos".to_string()); + } else { + app.set_mode(AppMode::Agent); + } + continue; + } + KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + app.set_sidebar_focus(SidebarFocus::Tasks); + app.status_message = Some("Sidebar focus: tasks".to_string()); + } else { + app.set_mode(AppMode::Yolo); + } + 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); + } + continue; + } + KeyCode::Char('!') if key.modifiers.contains(KeyModifiers::ALT) => { app.set_sidebar_focus(SidebarFocus::Plan); app.status_message = Some("Sidebar focus: plan".to_string()); continue; } - KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => { + KeyCode::Char('@') if key.modifiers.contains(KeyModifiers::ALT) => { app.set_sidebar_focus(SidebarFocus::Todos); app.status_message = Some("Sidebar focus: todos".to_string()); continue; } - KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => { + KeyCode::Char('#') if key.modifiers.contains(KeyModifiers::ALT) => { app.set_sidebar_focus(SidebarFocus::Tasks); app.status_message = Some("Sidebar focus: tasks".to_string()); continue; } - KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => { + KeyCode::Char('$') if key.modifiers.contains(KeyModifiers::ALT) => { app.set_sidebar_focus(SidebarFocus::Agents); app.status_message = Some("Sidebar focus: agents".to_string()); continue; } + KeyCode::Char(')') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Auto); + app.status_message = Some("Sidebar focus: auto".to_string()); + continue; + } KeyCode::Char('0') if key.modifiers.contains(KeyModifiers::ALT) => { app.set_sidebar_focus(SidebarFocus::Auto); app.status_message = Some("Sidebar focus: auto".to_string()); @@ -1154,6 +1199,9 @@ async fn run_event_loop( } app.cycle_mode(); } + KeyCode::BackTab => { + app.cycle_mode_reverse(); + } KeyCode::Char('g') if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open => { @@ -1451,6 +1499,38 @@ async fn run_event_loop( 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; + } + KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_mode(AppMode::Yolo); + continue; + } + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => { + 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; + } + KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_mode(AppMode::Yolo); + continue; + } + KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_mode(AppMode::Plan); + continue; + } KeyCode::Char(c) => { app.insert_char(c); } @@ -2459,7 +2539,9 @@ fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec bool { + self.staged == 0 + && self.modified == 0 + && self.untracked == 0 + && self.conflicts == 0 + } +} + +fn collect_workspace_context(workspace: &Path) -> Option { + let branch = workspace_git_branch(workspace)?; + let summary = workspace_git_change_summary(workspace)?; + + let mut parts = Vec::new(); + if summary.staged > 0 { + parts.push(format!("{} staged", summary.staged)); + } + if summary.modified > 0 { + parts.push(format!("{} modified", summary.modified)); + } + if summary.untracked > 0 { + parts.push(format!("{} untracked", summary.untracked)); + } + if summary.conflicts > 0 { + parts.push(format!("{} conflicts", summary.conflicts)); + } + + let status = if summary.is_clean() { + "clean".to_string() + } else { + parts.join(", ") + }; + + Some(format!("{branch} | {status}")) +} + +fn workspace_git_branch(workspace: &Path) -> Option { + let branch = run_git_query(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; + let branch = branch.trim().to_string(); + if branch == "HEAD" || branch.is_empty() { + let short_hash = run_git_query(workspace, &["rev-parse", "--short", "HEAD"]).ok()?; + let short_hash = short_hash.trim(); + if short_hash.is_empty() { + return None; + } + return Some(format!("detached:{short_hash}")); + } + Some(branch) +} + +fn workspace_git_change_summary(workspace: &Path) -> Option { + let status = run_git_query(workspace, &[ + "status", + "--short", + "--untracked-files=normal", + ]) + .ok()?; + + if status.trim().is_empty() { + return Some(WorkspaceChangeSummary::default()); + } + + let mut summary = WorkspaceChangeSummary::default(); + for line in status.lines() { + if line.trim().is_empty() { + continue; + } + + let mut chars = line.chars(); + let staged = chars.next()?; + let modified = chars.next().unwrap_or(' '); + + if staged == ' ' && modified == ' ' { + continue; + } + if staged == '?' && modified == '?' { + summary.untracked = summary.untracked.saturating_add(1); + continue; + } + + if staged == 'U' || modified == 'U' { + summary.conflicts = summary.conflicts.saturating_add(1); + } + if staged != ' ' && staged != '?' { + summary.staged = summary.staged.saturating_add(1); + } + if modified != ' ' && modified != '?' { + summary.modified = summary.modified.saturating_add(1); + } + } + + Some(summary) +} + +fn run_git_query(workspace: &Path, args: &[&str]) -> std::io::Result { + let output = Command::new("git").args(args).current_dir(workspace).output()?; + if !output.status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "git command failed", + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + fn pause_terminal( terminal: &mut Terminal>, use_alt_screen: bool, @@ -2861,9 +3073,46 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { Style::default().fg(status_color(toast.level)), )] } else { - // Compact footer: session + token cost + help hint + // Compact footer: mode + workspace + session + token cost + help hint let mut spans = Vec::new(); + // Mode indicator + let (mode_label, mode_color) = match app.mode { + crate::tui::app::AppMode::Normal => ("[Normal]", palette::MODE_NORMAL), + crate::tui::app::AppMode::Agent => ("[Agent]", palette::MODE_AGENT), + crate::tui::app::AppMode::Yolo => ("[YOLO]", palette::MODE_YOLO), + crate::tui::app::AppMode::Plan => ("[Plan]", palette::MODE_PLAN), + }; + spans.push(Span::styled( + format!("{} ", mode_label), + Style::default().fg(mode_color), + )); + + // Workspace (directory name) + if let Some(workspace_name) = app.workspace.file_name() { + if let Some(name) = workspace_name.to_str() { + let ws = format!("{} ", name); + spans.push(Span::styled( + ws, + Style::default().fg(palette::TEXT_DIM), + )); + } + } + + if let Some(workspace_context) = app.workspace_context.as_deref() { + let context = truncate_line_to_width( + &format!("ctx: {workspace_context}"), + available_width / 2, + ); + if !context.is_empty() { + spans.push(Span::styled( + format!("{context} "), + Style::default().fg(palette::TEXT_DIM), + )); + } + } + + // Session ID if let Some(ref sid) = app.current_session_id { spans.push(Span::styled( format!("session:{} ", &sid[..8.min(sid.len())]), @@ -2871,6 +3120,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { )); } + // Token cost if app.total_conversation_tokens > 0 { let tokens_k = app.total_conversation_tokens as f64 / 1000.0; spans.push(Span::styled( @@ -2879,6 +3129,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { )); } + // Help hint spans.push(Span::styled( "F1 help", Style::default().fg(palette::TEXT_DIM), diff --git a/src/tui/user_input.rs b/src/tui/user_input.rs index b059dcb5..ecede0d1 100644 --- a/src/tui/user_input.rs +++ b/src/tui/user_input.rs @@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; use crate::palette; use crate::tools::user_input::{ @@ -11,6 +11,18 @@ use crate::tools::user_input::{ }; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; +fn modal_block(title: &str) -> Block<'static> { + Block::default() + .title(Line::from(vec![Span::styled( + title.to_string(), + Style::default().fg(palette::DEEPSEEK_BLUE).bold(), + )])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputMode { Selecting, @@ -195,43 +207,53 @@ impl ModalView for UserInputView { let selected = self.selected == idx; let prefix = if selected { ">" } else { " " }; let number = idx + 1; - let style = if selected { - Style::default().fg(palette::DEEPSEEK_SKY).bold() + if selected { + // Single span with consistent foreground and background + let content = format!("{prefix} {number}) {} - {}", option.label, option.description); + lines.push(Line::from(Span::styled( + content, + Style::default() + .fg(palette::DEEPSEEK_SKY) + .bg(palette::SELECTION_BG) + .bold(), + ))); } else { - Style::default().fg(palette::TEXT_PRIMARY) - }; - lines.push(Line::from(vec![ - Span::raw(format!("{prefix} {number}) ")), - Span::styled(option.label.clone(), style), - Span::raw(" - "), - Span::styled( - option.description.clone(), - Style::default().fg(palette::TEXT_MUTED), - ), - ])); + // Keep original multi‑span formatting + lines.push(Line::from(vec![ + Span::raw(format!("{prefix} {number}) ")), + Span::styled(option.label.clone(), Style::default().fg(palette::TEXT_PRIMARY)), + Span::raw(" - "), + Span::styled( + option.description.clone(), + Style::default().fg(palette::TEXT_MUTED), + ), + ])); + } } let other_index = question.options.len(); let other_selected = self.selected == other_index; - let other_style = if other_selected { - Style::default().fg(palette::DEEPSEEK_SKY).bold() - } else { - Style::default().fg(palette::TEXT_PRIMARY) - }; let other_number = other_index + 1; - lines.push(Line::from(vec![ - Span::raw(if other_selected { - format!("> {other_number}) ") - } else { - format!(" {other_number}) ") - }), - Span::styled("Other", other_style), - Span::raw(" - "), - Span::styled( - "Provide a custom response", - Style::default().fg(palette::TEXT_MUTED), - ), - ])); + if other_selected { + let content = format!("> {other_number}) Other - Provide a custom response"); + lines.push(Line::from(Span::styled( + content, + Style::default() + .fg(palette::DEEPSEEK_SKY) + .bg(palette::SELECTION_BG) + .bold(), + ))); + } else { + lines.push(Line::from(vec![ + Span::raw(format!(" {other_number}) ")), + Span::styled("Other", Style::default().fg(palette::TEXT_PRIMARY)), + Span::raw(" - "), + Span::styled( + "Provide a custom response", + Style::default().fg(palette::TEXT_MUTED), + ), + ])); + } if self.mode == InputMode::OtherInput { lines.push(Line::from("")); @@ -263,15 +285,7 @@ impl ModalView for UserInputView { let paragraph = Paragraph::new(lines) .alignment(Alignment::Left) .wrap(Wrap { trim: true }) - .block( - Block::default() - .title(Line::from(vec![Span::styled( - header, - Style::default().fg(palette::DEEPSEEK_BLUE).bold(), - )])) - .borders(Borders::ALL) - .border_style(Style::default().fg(palette::DEEPSEEK_SKY)), - ); + .block(modal_block(&header)); let popup_area = centered_rect(80, 60, area); paragraph.render(popup_area, buf); diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index 2f6b1bc4..7b779e5d 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -1,9 +1,13 @@ use crossterm::event::KeyEvent; use ratatui::{buffer::Buffer, layout::Rect}; use std::fmt; +use std::path::{Path, PathBuf}; use crate::palette; use crate::tools::UserInputResponse; +use crate::tools::spec::ApprovalRequirement; +use crate::tools::spec::ToolCapability; +use crate::tools::{ToolContext, ToolRegistryBuilder}; use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType}; use crate::tui::approval::{ElevationOption, ReviewDecision}; @@ -165,14 +169,154 @@ impl fmt::Debug for ViewStack { } } +const HELP_COMMAND_SECTION_ORDER: [&str; 7] = [ + "Core", + "Modes", + "Session", + "Configuration", + "Workflows", + "Planning", + "Debug", +]; + +fn help_command_section(name: &str) -> &'static str { + match name { + "help" | "clear" | "exit" | "model" | "models" | "home" | "deepseek" => "Core", + "normal" | "agent" | "yolo" | "plan" | "trust" | "logout" => "Modes", + "save" | "sessions" | "load" | "export" | "compact" | "queue" => "Session", + "config" | "set" | "settings" => "Configuration", + "task" | "skills" | "skill" | "subagents" | "review" => "Workflows", + "note" | "cost" | "context" | "system" | "undo" | "retry" => "Planning", + "init" => "Debug", + _ => "Debug", + } +} + +fn grouped_commands( + commands: &'static [crate::commands::CommandInfo], +) -> Vec<(&'static str, Vec<&'static crate::commands::CommandInfo>)> { + let mut grouped: Vec<(&'static str, Vec<&crate::commands::CommandInfo>)> = HELP_COMMAND_SECTION_ORDER + .iter() + .map(|section| (*section, Vec::new())) + .collect(); + + for command in commands { + let section = help_command_section(command.name); + let Some((_, entries)) = grouped + .iter_mut() + .find(|(entry_section, _)| *entry_section == section) + else { + continue; + }; + entries.push(command); + } + + grouped + .into_iter() + .filter(|(_, entries)| !entries.is_empty()) + .collect() +} + pub struct HelpView { scroll: usize, + tool_sections: Vec<(String, Vec)>, } impl HelpView { pub fn new() -> Self { - Self { scroll: 0 } + Self::new_for_workspace(PathBuf::from(".")) } + + pub fn new_for_workspace(workspace: PathBuf) -> Self { + Self { + scroll: 0, + tool_sections: build_help_tool_sections(&workspace), + } + } +} + +fn build_help_tool_sections(workspace: &Path) -> Vec<(String, Vec)> { + let context = ToolContext::new(workspace.to_path_buf()); + let registry = ToolRegistryBuilder::new() + .with_file_tools() + .with_search_tools() + .with_shell_tools() + .with_web_tools() + .with_git_tools() + .with_structured_data_tools() + .with_user_input_tool() + .with_parallel_tool() + .with_patch_tools() + .with_note_tool() + .with_diagnostics_tool() + .with_project_tools() + .with_test_runner_tool() + .build(context); + + let mut auto = Vec::new(); + let mut suggest = Vec::new(); + let mut required = Vec::new(); + + for tool in registry.all() { + let mut tags = Vec::new(); + let capabilities = tool.capabilities(); + if capabilities.contains(&ToolCapability::ReadOnly) { + tags.push("read-only"); + } + if capabilities.contains(&ToolCapability::WritesFiles) { + tags.push("writes"); + } + if capabilities.contains(&ToolCapability::ExecutesCode) { + tags.push("shell"); + } + if capabilities.contains(&ToolCapability::Network) { + tags.push("network"); + } + if tool.supports_parallel() { + tags.push("parallel"); + } + + let mut description = tool.description().to_string(); + if !tags.is_empty() { + description.push_str(" ["); + description.push_str(&tags.join(", ")); + description.push(']'); + } + + let name = tool.name(); + if name.trim().is_empty() { + continue; + } + + let line = if description.is_empty() { + format!(" {}", name) + } else { + format!(" {:<15} - {}", name, description) + }; + + match tool.approval_requirement() { + ApprovalRequirement::Required => required.push(line), + ApprovalRequirement::Suggest => suggest.push(line), + ApprovalRequirement::Auto => auto.push(line), + } + } + + auto.sort_unstable(); + suggest.sort_unstable(); + required.sort_unstable(); + + let mut sections = Vec::new(); + if !auto.is_empty() { + sections.push(("Tools (auto/low risk)".to_string(), auto)); + } + if !suggest.is_empty() { + sections.push(("Tools (suggest approval)".to_string(), suggest)); + } + if !required.is_empty() { + sections.push(("Tools (requires approval)".to_string(), required)); + } + + sections } impl ModalView for HelpView { @@ -202,7 +346,7 @@ impl ModalView for HelpView { prelude::Stylize, style::Style, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Widget}, + widgets::{Block, Borders, Clear, Paragraph, Padding, Widget}, }; let popup_width = 70.min(area.width.saturating_sub(4)); @@ -267,9 +411,12 @@ impl ModalView for HelpView { "=== Modes ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Tab - Complete /command or cycle modes"), + 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+!/@/#/$/+) - 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(" Alt+1..4 / Alt+0 - Focus sidebar section / auto layout"), Line::from(""), Line::from(vec![Span::styled( "=== Sessions ===", @@ -298,10 +445,10 @@ impl ModalView for HelpView { Line::from(" Drag to select - Select text (auto-copies on release)"), Line::from(""), Line::from(vec![Span::styled( - "Modes:", + "Mode Cycle:", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Tab cycles modes: Plan → Agent → YOLO"), + Line::from(" Normal → Agent → YOLO → Plan (Tab), reverse with Shift+Tab"), Line::from(""), Line::from(vec![Span::styled( "Commands:", @@ -309,44 +456,42 @@ impl ModalView for HelpView { )]), ]; - for cmd in crate::commands::COMMANDS.iter() { - help_lines.push(Line::from(format!( - " /{:<12} - {}", - cmd.name, cmd.description - ))); + let grouped = grouped_commands(crate::commands::COMMANDS); + for (section, commands) in grouped { + if !commands.is_empty() { + help_lines.push(Line::from(Span::styled( + format!(" [{}]", section), + Style::default().fg(palette::DEEPSEEK_BLUE).bold(), + ))); + for cmd in commands { + help_lines.push(Line::from(format!( + " /{:<11} - {}", + cmd.name, cmd.description + ))); + } + help_lines.push(Line::from("")); + } } help_lines.push(Line::from("")); - help_lines.push(Line::from(vec![Span::styled( + help_lines.push(Line::from(Span::styled( "Tools:", Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )])); - help_lines.push(Line::from( - " web.run - Browse the web (search/open/click/find/screenshot)", - )); - help_lines.push(Line::from( - " web_search - Quick web search (DuckDuckGo; MCP optional)", - )); - help_lines.push(Line::from( - " request_user_input - Ask the user to choose from short prompts", - )); - help_lines.push(Line::from( - " multi_tool_use.parallel - Execute multiple tools in parallel", - )); - help_lines.push(Line::from(" weather - Daily forecast for a location")); - help_lines.push(Line::from(" finance - Stock/crypto price lookup")); - help_lines.push(Line::from(" sports - League schedules/standings")); - help_lines.push(Line::from(" time - Current time for UTC offsets")); - help_lines.push(Line::from( - " calculator - Evaluate arithmetic expressions", - )); - help_lines.push(Line::from( - " list_mcp_resources - List MCP resources (optionally by server)", - )); - help_lines.push(Line::from( - " list_mcp_resource_templates - List MCP resource templates", - )); - help_lines.push(Line::from(" mcp_* - Tools exposed by MCP servers")); + ))); + if self.tool_sections.is_empty() { + help_lines.push(Line::from(" Tool registry unavailable")); + } else { + for (section, tools) in &self.tool_sections { + help_lines.push(Line::from(Span::styled( + format!(" {}", section), + Style::default().fg(palette::DEEPSEEK_BLUE).bold(), + ))); + for tool in tools { + help_lines.push(Line::from(tool.as_str())); + } + help_lines.push(Line::from("")); + } + } help_lines.push(Line::from("")); let total_lines = help_lines.len(); @@ -372,7 +517,9 @@ impl ModalView for HelpView { Span::styled(scroll_indicator, Style::default().fg(palette::DEEPSEEK_SKY)), ])) .borders(Borders::ALL) - .border_style(Style::default().fg(palette::DEEPSEEK_SKY)), + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)), ) .scroll((scroll as u16, 0)); @@ -427,7 +574,7 @@ impl ModalView for SubAgentsView { prelude::Stylize, style::Style, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Widget}, + widgets::{Block, Borders, Clear, Paragraph, Padding, Widget}, }; let popup_width = 78.min(area.width.saturating_sub(4)); @@ -443,21 +590,7 @@ impl ModalView for SubAgentsView { Clear.render(popup_area, buf); let mut lines: Vec = Vec::new(); - lines.push(Line::from(vec![ - Span::styled("ID", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::raw(" "), - Span::styled("TYPE", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::raw(" "), - Span::styled("STATUS", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::raw(" "), - Span::styled("STEPS", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::raw(" "), - Span::styled("TIME", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - ])); - lines.push(Line::from(Span::styled( - "----------------------------------------", - Style::default().fg(palette::TEXT_MUTED), - ))); + let content_width = popup_width.saturating_sub(4) as usize; if self.agents.is_empty() { lines.push(Line::from(Span::styled( @@ -465,45 +598,95 @@ impl ModalView for SubAgentsView { Style::default().fg(palette::TEXT_MUTED), ))); } else { - let content_width = popup_width.saturating_sub(4) as usize; - for agent in &self.agents { - let id = truncate_view_text(&agent.agent_id, 8); - let kind = format_agent_type(&agent.agent_type); - let (status, status_style) = format_agent_status(&agent.status); - let line = Line::from(vec![ - Span::styled( - format!("{id:<8}"), - Style::default().fg(palette::TEXT_PRIMARY).bold(), - ), - Span::raw(" "), - Span::styled( - format!("{kind:<6}"), - Style::default().fg(palette::TEXT_MUTED), - ), - Span::raw(" "), - Span::styled(format!("{status:<10}"), status_style), - Span::raw(" "), - Span::styled( - format!("{:>5}", agent.steps_taken), - Style::default().fg(palette::TEXT_DIM), - ), - Span::raw(" "), - Span::styled( - format!("{:>5}ms", agent.duration_ms), - Style::default().fg(palette::TEXT_DIM), - ), - ]); - lines.push(line); + let mut running = Vec::new(); + let mut completed = Vec::new(); + let mut failed = Vec::new(); + let mut cancelled = Vec::new(); - if let Some(result) = agent.result.as_ref() { - let max_len = content_width.saturating_sub(10); - let preview = truncate_view_text(result, max_len); - lines.push(Line::from(vec![ - Span::styled(" Result: ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled(preview, Style::default().fg(palette::TEXT_DIM)), - ])); + for agent in &self.agents { + match agent.status { + SubAgentStatus::Running => running.push(agent), + SubAgentStatus::Completed => completed.push(agent), + SubAgentStatus::Failed(_) => failed.push(agent), + SubAgentStatus::Cancelled => cancelled.push(agent), } } + + let status_summary = [ + ("Running", running.len(), palette::STATUS_WARNING), + ("Completed", completed.len(), palette::STATUS_SUCCESS), + ("Failed", failed.len(), palette::DEEPSEEK_RED), + ("Cancelled", cancelled.len(), palette::TEXT_MUTED), + ]; + + lines.push(Line::from(Span::styled( + "Sub-agent swarm", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ))); + + let mut summary_parts = Vec::new(); + for (label, count, color) in status_summary { + summary_parts.push(Line::from(Span::styled( + format!("{}: {}", label, count), + Style::default().fg(color), + ))); + } + + let mut summary = vec![Span::styled(" ", Style::default().fg(palette::TEXT_DIM))]; + for (idx, part) in summary_parts.into_iter().enumerate() { + if idx > 0 { + summary.push(Span::raw(" · ")); + } + summary.extend(part.into_iter()); + } + lines.push(Line::from(summary)); + lines.push(Line::from(Span::styled("", Style::default().fg(palette::TEXT_DIM)))); + + running.sort_by(|a, b| { + let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type)); + order.then_with(|| a.agent_id.cmp(&b.agent_id)) + }); + completed.sort_by(|a, b| { + let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type)); + order.then_with(|| a.agent_id.cmp(&b.agent_id)) + }); + failed.sort_by(|a, b| { + let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type)); + order.then_with(|| a.agent_id.cmp(&b.agent_id)) + }); + cancelled.sort_by(|a, b| { + let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type)); + order.then_with(|| a.agent_id.cmp(&b.agent_id)) + }); + + append_subagent_group( + &mut lines, + "Running", + palette::STATUS_WARNING.into(), + &running, + content_width, + ); + append_subagent_group( + &mut lines, + "Completed", + palette::STATUS_SUCCESS.into(), + &completed, + content_width, + ); + append_subagent_group( + &mut lines, + "Failed", + palette::DEEPSEEK_RED.into(), + &failed, + content_width, + ); + append_subagent_group( + &mut lines, + "Cancelled", + palette::TEXT_MUTED.into(), + &cancelled, + content_width, + ); } let total_lines = lines.len(); @@ -530,7 +713,9 @@ impl ModalView for SubAgentsView { Span::styled(scroll_indicator, Style::default().fg(palette::DEEPSEEK_SKY)), ])) .borders(Borders::ALL) - .border_style(Style::default().fg(palette::DEEPSEEK_SKY)), + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)), ) .scroll((scroll as u16, 0)); @@ -538,6 +723,78 @@ impl ModalView for SubAgentsView { } } +fn append_subagent_group( + lines: &mut Vec>, + title: &str, + section_style: ratatui::style::Style, + agents: &[&SubAgentResult], + content_width: usize, +) { + use ratatui::{prelude::Stylize, style::Style, text::{Line, Span}}; + if agents.is_empty() { + return; + } + + lines.push(Line::from(Span::styled( + format!("{title} ({})", agents.len()), + section_style.bold(), + ))); + + for agent in agents { + let id = truncate_view_text(&agent.agent_id, 11); + let kind = format_agent_type(&agent.agent_type); + let (status, status_style, status_detail) = format_agent_status(&agent.status); + + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("{id:<12}"), Style::default().fg(palette::TEXT_PRIMARY)), + Span::styled(format!("{kind:<9}"), Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled(format!("{status:<10}"), status_style), + Span::raw(" "), + Span::styled( + format!("{:>4}✦", agent.steps_taken), + Style::default().fg(palette::TEXT_DIM), + ), + Span::raw(" "), + Span::styled( + format!("{:>6}ms", agent.duration_ms), + Style::default().fg(palette::TEXT_DIM), + ), + ])); + + if let Some(detail) = status_detail { + let max_len = content_width.saturating_sub(10); + let detail = truncate_view_text(detail, max_len); + lines.push(Line::from(vec![ + Span::styled(" reason: ", Style::default().fg(palette::TEXT_MUTED),), + Span::styled(detail, Style::default().fg(palette::DEEPSEEK_RED)), + ])); + } + + if let Some(result) = agent.result.as_ref() { + let max_len = content_width.saturating_sub(16); + let preview = truncate_view_text(result, max_len); + lines.push(Line::from(vec![ + Span::styled(" result: ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled(preview, Style::default().fg(palette::TEXT_DIM)), + ])); + } + } + + lines.push(Line::from("")); +} + +fn agent_type_order(agent_type: &SubAgentType) -> u8 { + match agent_type { + SubAgentType::General => 0, + SubAgentType::Explore => 1, + SubAgentType::Plan => 2, + SubAgentType::Review => 3, + SubAgentType::Custom => 4, + } +} + fn format_agent_type(agent_type: &SubAgentType) -> &'static str { match agent_type { SubAgentType::General => "general", @@ -548,14 +805,20 @@ fn format_agent_type(agent_type: &SubAgentType) -> &'static str { } } -fn format_agent_status(status: &SubAgentStatus) -> (&'static str, ratatui::style::Style) { +fn format_agent_status( + status: &SubAgentStatus, +) -> (&'static str, ratatui::style::Style, Option<&str>) { use ratatui::style::Style; match status { - SubAgentStatus::Running => ("running", Style::default().fg(palette::DEEPSEEK_SKY)), - SubAgentStatus::Completed => ("completed", Style::default().fg(palette::DEEPSEEK_BLUE)), - SubAgentStatus::Cancelled => ("cancelled", Style::default().fg(palette::TEXT_MUTED)), - SubAgentStatus::Failed(_) => ("failed", Style::default().fg(palette::DEEPSEEK_RED)), + SubAgentStatus::Running => ("running", Style::default().fg(palette::DEEPSEEK_SKY), None), + SubAgentStatus::Completed => { + ("completed", Style::default().fg(palette::DEEPSEEK_BLUE), None) + } + SubAgentStatus::Cancelled => ("cancelled", Style::default().fg(palette::TEXT_MUTED), None), + SubAgentStatus::Failed(reason) => { + ("failed", Style::default().fg(palette::DEEPSEEK_RED), Some(reason.as_str())) + } } } diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 3077734a..2105baa1 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -15,7 +15,7 @@ use ratatui::{ prelude::Stylize, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}, + widgets::{Block, Borders, Clear, Paragraph, Padding, Widget, Wrap}, }; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -335,7 +335,7 @@ impl Renderable for ApprovalWidget<'_> { let style = if is_selected { Style::default() .fg(palette::DEEPSEEK_SKY) - .add_modifier(Modifier::REVERSED) + .bg(palette::SELECTION_BG) } else { Style::default() }; @@ -354,7 +354,9 @@ impl Renderable for ApprovalWidget<'_> { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(palette::STATUS_WARNING)); + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)); let paragraph = Paragraph::new(lines) .block(block) @@ -443,7 +445,7 @@ impl Renderable for ElevationWidget<'_> { let style = if is_selected { Style::default() .fg(palette::DEEPSEEK_SKY) - .add_modifier(Modifier::REVERSED) + .bg(palette::SELECTION_BG) } else { Style::default() }; @@ -482,7 +484,9 @@ impl Renderable for ElevationWidget<'_> { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(palette::STATUS_ERROR)); + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)); let paragraph = Paragraph::new(lines) .block(block)