Complete TUI phase 2/3 UX and help polish
This commit is contained in:
@@ -63,3 +63,6 @@ docs/rlm-paper.txt
|
||||
# Local runtime state
|
||||
.deepseek/
|
||||
session_*.json
|
||||
|
||||
# Companion app (tracked separately)
|
||||
apps/
|
||||
|
||||
+32
-1
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
+361
-98
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user