Complete TUI phase 2/3 UX and help polish

This commit is contained in:
Hunter Bown
2026-02-19 00:00:24 -06:00
parent f3fe107b8e
commit d55a9c1231
13 changed files with 1293 additions and 215 deletions
+3
View File
@@ -63,3 +63,6 @@ docs/rlm-paper.txt
# Local runtime state
.deepseek/
session_*.json
# Companion app (tracked separately)
apps/
+32 -1
View File
@@ -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();
+43
View File
@@ -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),
+44 -6
View File
@@ -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<u32>,
/// Last completion token usage
pub last_completion_tokens: Option<u32>,
/// Cached git context snapshot for the footer.
pub workspace_context: Option<String>,
/// Timestamp for cached workspace context.
pub workspace_context_refreshed_at: Option<Instant>,
/// Cached background tasks for sidebar rendering.
pub task_panel: Vec<TaskPanelEntry>,
/// 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());
+414 -34
View File
@@ -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<CommandPaletteEntry> {
pub fn build_entries(skills_dir: &Path, workspace: &Path) -> Vec<CommandPaletteEntry> {
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::<Vec<_>>();
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<usize> {
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<CommandPaletteEntry>) -> 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::<Vec<_>>();
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<Line<'static>> {
vec![
Line::from(Span::styled("Try:", Style::default().fg(palette::TEXT_DIM))),
Line::from(Span::styled(
" c:<term> Command-only e.g. c:agent",
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
" s:<term> Skill-only e.g. s:search",
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
" t:<term> 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!(" {:<label_width$}", entry.label);
let desc_capacity = popup_width as usize - (label_width + 4);
let desc = if entry.description.width() > 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!("> {:<label_width$}", entry.label);
}
line.push_str(" ");
line.push_str(&desc);
lines.push(Line::from(Span::styled(line, style)));
}
}
let block = Block::default()
let block = modal_block()
.title(" Command Palette ")
.title_bottom(Line::from(vec![
Span::styled(" Enter insert ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(" ↑/↓/j/k move ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled("Enter insert ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled("Esc close", Style::default().fg(palette::TEXT_MUTED)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR));
]));
Paragraph::new(lines)
.block(block)
@@ -243,3 +515,111 @@ impl ModalView for CommandPaletteView {
.render(popup_area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn palette_entry(
section: PaletteSection,
label: &str,
description: &str,
command: &str,
) -> 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");
}
}
+9 -1
View File
@@ -94,7 +94,15 @@ pub fn tips_lines() -> Vec<ratatui::text::Line<'static>> {
.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")),
+4 -2
View File
@@ -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)
+41 -4
View File
@@ -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);
+18 -14
View File
@@ -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)
};
+260 -9
View File
@@ -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<Lin
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)),
);
f.render_widget(section, area);
@@ -2639,6 +2721,8 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) {
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
app.current_session_id = Some(session.metadata.id.clone());
app.workspace_context = None;
app.workspace_context_refreshed_at = None;
if let Some(sp) = session.system_prompt.as_ref() {
app.system_prompt = Some(SystemPrompt::Text(sp.clone()));
} else {
@@ -2647,6 +2731,134 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) {
app.scroll_to_bottom();
}
fn refresh_workspace_context_if_needed(app: &mut App, now: Instant) {
if app
.workspace_context_refreshed_at
.is_some_and(|refreshed_at| {
now.duration_since(refreshed_at)
< Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS)
})
{
return;
}
app.workspace_context = collect_workspace_context(&app.workspace);
app.workspace_context_refreshed_at = Some(now);
}
#[derive(Debug, Default, Clone, Copy)]
struct WorkspaceChangeSummary {
staged: usize,
modified: usize,
untracked: usize,
conflicts: usize,
}
impl WorkspaceChangeSummary {
fn is_clean(&self) -> bool {
self.staged == 0
&& self.modified == 0
&& self.untracked == 0
&& self.conflicts == 0
}
}
fn collect_workspace_context(workspace: &Path) -> Option<String> {
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<String> {
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<WorkspaceChangeSummary> {
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<String> {
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<CrosstermBackend<Stdout>>,
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),
+55 -41
View File
@@ -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 multispan 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);
+361 -98
View File
@@ -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<String>)>,
}
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<String>)> {
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<Line> = 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<ratatui::text::Line<'static>>,
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()))
}
}
}
+9 -5
View File
@@ -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)