From 14381b8ba54bc6fcc7f17c49f1ca1856446b97a7 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 24 May 2026 08:32:21 +0900 Subject: [PATCH] feat: add TUI command mode --- crates/tui/src/app.rs | 115 +++++++++++-- crates/tui/src/command.rs | 340 ++++++++++++++++++++++++++++++++++++++ crates/tui/src/input.rs | 20 +++ crates/tui/src/main.rs | 211 ++++++++++++++++++++++- crates/tui/src/ui.rs | 63 ++++++- 5 files changed, 724 insertions(+), 25 deletions(-) create mode 100644 crates/tui/src/command.rs diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 89705aad..051fef7b 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -10,6 +10,7 @@ use crate::block::{ Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState, }; use crate::cache::FileCache; +use crate::command::{CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry}; use crate::input::InputBuffer; use crate::scroll::Scroll; use crate::task::TaskStore; @@ -88,7 +89,12 @@ pub struct App { pub context_window: u64, pub turn_index: usize, pub current_tool: Option, + /// Normal composer input that is submitted as `Method::Run`. pub input: InputBuffer, + /// Separate command-line input. It is never submitted as a user message. + pub command_input: InputBuffer, + pub input_mode: CommandInputMode, + pub command_registry: CommandRegistry, pub quit: bool, /// 2-tap guard for `Ctrl-C` when the Pod is not running. First press /// records the instant; a second press within the timeout exits the @@ -143,6 +149,9 @@ impl App { turn_index: 0, current_tool: None, input: InputBuffer::new(), + command_input: InputBuffer::new(), + input_mode: CommandInputMode::Composer, + command_registry: CommandRegistry::default(), quit: false, quit_confirm: None, blocks: Vec::new(), @@ -190,6 +199,10 @@ impl App { /// Callers should invoke this after every input mutation that could /// move the cursor or change atoms. pub fn refresh_completion(&mut self) -> Option { + if self.is_command_mode() { + self.completion = None; + return None; + } match self.input.pending_completion_prefix() { Some((kind, start, prefix)) => { let need_query = match &self.completion { @@ -1013,43 +1026,119 @@ impl App { } } + pub fn is_command_mode(&self) -> bool { + self.input_mode == CommandInputMode::Command + } + + pub fn enter_command_mode(&mut self) { + self.input_mode = CommandInputMode::Command; + self.completion = None; + self.quit_confirm = None; + } + + pub fn exit_command_mode(&mut self) { + self.input_mode = CommandInputMode::Composer; + self.command_input.clear(); + } + + pub fn clear_command_input(&mut self) { + self.command_input.clear(); + } + + pub fn command_text(&self) -> String { + self.command_input.plain_text() + } + + pub fn command_suggestions(&self) -> Vec { + self.command_registry.suggest(&self.command_text()) + } + + fn command_environment(&self) -> CommandEnvironment { + CommandEnvironment { + connected: self.connected, + running: self.running, + paused: self.paused, + } + } + + pub fn submit_command(&mut self) -> Option { + let command_line = self.command_text(); + let environment = self.command_environment(); + let result = self.command_registry.dispatch(&command_line, &environment); + self.apply_command_execution(result) + } + + fn apply_command_execution(&mut self, result: CommandExecution) -> Option { + for diagnostic in result.diagnostics { + self.push_command_diagnostic(diagnostic.message); + } + if result.clear_input { + self.command_input.clear(); + } + if result.exit_command_mode { + self.input_mode = CommandInputMode::Composer; + } + result.method + } + + fn push_command_diagnostic(&mut self, message: impl Into) { + self.blocks.push(Block::Alert { + level: AlertLevel::Warn, + source: AlertSource::Pod, + message: format!("TUI command: {}", message.into()), + }); + } + + fn active_input_mut(&mut self) -> &mut InputBuffer { + if self.is_command_mode() { + &mut self.command_input + } else { + &mut self.input + } + } + // Input manipulation — thin forwarders so call sites in main.rs - // stay readable. + // stay readable. In command mode these operate on the command line, + // keeping the normal composer buffer intact. pub fn insert_char(&mut self, c: char) { - self.input.insert_char(c); + self.active_input_mut().insert_char(c); } pub fn insert_newline(&mut self) { - self.input.insert_newline(); + self.active_input_mut().insert_newline(); } pub fn insert_paste(&mut self, content: String) { - self.input.insert_paste(content); + if self.is_command_mode() { + self.command_input.insert_str(&content); + } else { + self.input.insert_paste(content); + } } pub fn delete_char_before(&mut self) { - self.input.delete_before(); + self.active_input_mut().delete_before(); } pub fn delete_char_after(&mut self) { - self.input.delete_after(); + self.active_input_mut().delete_after(); } pub fn move_cursor_left(&mut self) { - self.input.move_left(); + self.active_input_mut().move_left(); } pub fn move_cursor_right(&mut self) { - self.input.move_right(); + self.active_input_mut().move_right(); } pub fn move_cursor_start(&mut self) { - self.input.move_start(); + self.active_input_mut().move_start(); } pub fn move_cursor_home(&mut self) { - self.input.move_home(); + self.active_input_mut().move_home(); } pub fn move_cursor_end(&mut self) { - self.input.move_end(); + self.active_input_mut().move_end(); } pub fn move_cursor_up(&mut self) { - self.input.move_up(); + self.active_input_mut().move_up(); } pub fn move_cursor_down(&mut self) { - self.input.move_down(); + self.active_input_mut().move_down(); } /// Reset the block list and replay a connect-time `Event::Snapshot`. diff --git a/crates/tui/src/command.rs b/crates/tui/src/command.rs new file mode 100644 index 00000000..ced220e9 --- /dev/null +++ b/crates/tui/src/command.rs @@ -0,0 +1,340 @@ +use protocol::Method; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandInputMode { + Composer, + Command, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandArgs { + raw: String, + argv: Vec, +} + +impl CommandArgs { + pub fn parse_whitespace(raw: &str) -> Self { + Self { + raw: raw.to_owned(), + argv: raw.split_whitespace().map(str::to_owned).collect(), + } + } + + pub fn raw(&self) -> &str { + &self.raw + } + + pub fn argv(&self) -> &[String] { + &self.argv + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandDiagnostic { + pub message: String, +} + +impl CommandDiagnostic { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandEnvironment { + pub connected: bool, + pub running: bool, + pub paused: bool, +} + +#[derive(Debug, Clone)] +pub struct CommandExecution { + pub method: Option, + pub diagnostics: Vec, + pub exit_command_mode: bool, + pub clear_input: bool, +} + +impl CommandExecution { + pub fn diagnostic(message: impl Into) -> Self { + Self { + method: None, + diagnostics: vec![CommandDiagnostic::new(message)], + exit_command_mode: false, + clear_input: false, + } + } + + pub fn notice(message: impl Into) -> Self { + Self { + method: None, + diagnostics: vec![CommandDiagnostic::new(message)], + exit_command_mode: true, + clear_input: true, + } + } +} + +pub type ArgumentParser = fn(&str) -> Result; +pub type AvailabilityCheck = fn(&CommandEnvironment) -> Result<(), CommandDiagnostic>; +pub type CommandExecutor = fn(CommandInvocation<'_>) -> CommandExecution; + +#[derive(Clone)] +pub struct CommandSpec { + pub name: &'static str, + pub aliases: &'static [&'static str], + pub usage: &'static str, + pub description: &'static str, + pub argument_parser: ArgumentParser, + pub can_execute: AvailabilityCheck, + pub executor: CommandExecutor, +} + +pub struct CommandInvocation<'a> { + pub registry: &'a CommandRegistry, + pub command: &'a CommandSpec, + pub args: CommandArgs, + pub environment: &'a CommandEnvironment, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandCandidate { + pub name: &'static str, + pub usage: &'static str, + pub description: &'static str, +} + +#[derive(Clone)] +pub struct CommandRegistry { + commands: Vec, +} + +impl CommandRegistry { + pub fn new() -> Self { + Self { + commands: Vec::new(), + } + } + + pub fn builtins() -> Self { + let mut registry = Self::new(); + registry.register(CommandSpec { + name: "help", + aliases: &["?"], + usage: "help [command]", + description: "Show available TUI commands or details for one command.", + argument_parser: help_args, + can_execute: always_available, + executor: help_command, + }); + registry.register(CommandSpec { + name: "noop", + aliases: &["nop"], + usage: "noop", + description: "Validate command dispatch without side effects.", + argument_parser: no_args, + can_execute: always_available, + executor: noop_command, + }); + registry + } + + pub fn register(&mut self, spec: CommandSpec) { + debug_assert!(!self.commands.iter().any(|c| c.name == spec.name)); + self.commands.push(spec); + } + + pub fn commands(&self) -> &[CommandSpec] { + &self.commands + } + + pub fn find(&self, name_or_alias: &str) -> Option<&CommandSpec> { + self.commands.iter().find(|command| { + command.name == name_or_alias + || command.aliases.iter().any(|alias| *alias == name_or_alias) + }) + } + + pub fn suggest(&self, command_line: &str) -> Vec { + let prefix = command_prefix(command_line); + if prefix.is_empty() { + return self.commands.iter().map(CommandCandidate::from).collect(); + } + self.commands + .iter() + .filter(|command| { + command.name.starts_with(prefix) + || command + .aliases + .iter() + .any(|alias| alias.starts_with(prefix)) + }) + .map(CommandCandidate::from) + .collect() + } + + pub fn dispatch( + &self, + command_line: &str, + environment: &CommandEnvironment, + ) -> CommandExecution { + let trimmed = command_line.trim(); + if trimmed.is_empty() { + return CommandExecution::diagnostic( + "Empty command. Type :help for available commands.", + ); + } + let (name, raw_args) = split_command(trimmed); + let Some(command) = self.find(name) else { + return CommandExecution::diagnostic(format!( + "Unknown command: {name}. Type :help for available commands." + )); + }; + let args = match (command.argument_parser)(raw_args) { + Ok(args) => args, + Err(err) => return CommandExecution::diagnostic(err.message), + }; + if let Err(err) = (command.can_execute)(environment) { + return CommandExecution::diagnostic(err.message); + } + (command.executor)(CommandInvocation { + registry: self, + command, + args, + environment, + }) + } +} + +impl Default for CommandRegistry { + fn default() -> Self { + Self::builtins() + } +} + +impl From<&CommandSpec> for CommandCandidate { + fn from(command: &CommandSpec) -> Self { + Self { + name: command.name, + usage: command.usage, + description: command.description, + } + } +} + +fn split_command(trimmed: &str) -> (&str, &str) { + match trimmed.find(char::is_whitespace) { + Some(idx) => { + let (name, rest) = trimmed.split_at(idx); + (name, rest.trim_start()) + } + None => (trimmed, ""), + } +} + +fn command_prefix(command_line: &str) -> &str { + let trimmed = command_line.trim_start(); + match trimmed.find(char::is_whitespace) { + Some(idx) => &trimmed[..idx], + None => trimmed, + } +} + +fn always_available(_environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> { + Ok(()) +} + +fn no_args(raw: &str) -> Result { + let args = CommandArgs::parse_whitespace(raw); + if args.argv().is_empty() { + Ok(args) + } else { + Err(CommandDiagnostic::new("Invalid arguments. Usage: noop")) + } +} + +fn help_args(raw: &str) -> Result { + let args = CommandArgs::parse_whitespace(raw); + if args.argv().len() <= 1 { + Ok(args) + } else { + Err(CommandDiagnostic::new( + "Invalid arguments. Usage: help [command]", + )) + } +} + +fn help_command(invocation: CommandInvocation<'_>) -> CommandExecution { + if let Some(name) = invocation.args.argv().first() { + let Some(command) = invocation.registry.find(name) else { + return CommandExecution::diagnostic(format!( + "Unknown command: {name}. Type :help for available commands." + )); + }; + let aliases = if command.aliases.is_empty() { + "".to_owned() + } else { + format!(" aliases: {}.", command.aliases.join(", ")) + }; + return CommandExecution::notice(format!( + "command: {} — usage: {}.{} {}", + command.name, command.usage, aliases, command.description + )); + } + + let list = invocation + .registry + .commands() + .iter() + .map(|command| format!("{} ({})", command.name, command.usage)) + .collect::>() + .join(", "); + CommandExecution::notice(format!("available commands: {list}")) +} + +fn noop_command(invocation: CommandInvocation<'_>) -> CommandExecution { + let _ = invocation.command; + let _ = invocation.environment; + let _ = invocation.args.raw(); + CommandExecution::notice("noop: no action") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env() -> CommandEnvironment { + CommandEnvironment { + connected: true, + running: false, + paused: false, + } + } + + #[test] + fn builtins_suggest_by_prefix() { + let registry = CommandRegistry::builtins(); + assert_eq!(registry.suggest("he")[0].name, "help"); + assert_eq!(registry.suggest("n")[0].name, "noop"); + } + + #[test] + fn unknown_command_is_local_diagnostic() { + let registry = CommandRegistry::builtins(); + let result = registry.dispatch("wat", &env()); + assert!(result.method.is_none()); + assert!(!result.exit_command_mode); + assert!(result.diagnostics[0].message.contains("Unknown command")); + } + + #[test] + fn invalid_arguments_are_local_diagnostic() { + let registry = CommandRegistry::builtins(); + let result = registry.dispatch("noop extra", &env()); + assert!(result.method.is_none()); + assert!(!result.exit_command_mode); + assert!(result.diagnostics[0].message.contains("Invalid arguments")); + } +} diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 50e58546..59a48bb7 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -223,6 +223,26 @@ impl InputBuffer { self.cursor += 1; } + pub fn insert_str(&mut self, text: &str) { + for c in text.chars() { + self.insert_char(c); + } + } + + pub fn plain_text(&self) -> String { + let mut text = String::new(); + for atom in &self.atoms { + match atom { + Atom::Char(c) => text.push(*c), + Atom::Paste(paste) => text.push_str(&paste.content), + Atom::FileRef(file) => text.push_str(&file.path), + Atom::KnowledgeRef(knowledge) => text.push_str(&knowledge.slug), + Atom::WorkflowInvoke(workflow) => text.push_str(&workflow.slug), + } + } + text + } + pub fn insert_newline(&mut self) { self.insert_char('\n'); } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index b394cf66..2691f7c1 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1,6 +1,7 @@ mod app; mod block; mod cache; +mod command; mod input; mod markdown; mod picker; @@ -644,7 +645,13 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { app.move_cursor_start(); Some(app.refresh_completion()) } - KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') && alt && !ctrl => { + KeyCode::Char('u') if ctrl && app.is_command_mode() => { + app.clear_command_input(); + Some(None) + } + KeyCode::Char(c) + if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() => + { if app.restore_next_queued_input_to_composer() { Some(app.refresh_completion()) } else { @@ -668,8 +675,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { Some(None) } KeyCode::Enter if alt => { - app.insert_newline(); - Some(app.refresh_completion()) + if app.is_command_mode() { + Some(None) + } else { + app.insert_newline(); + Some(app.refresh_completion()) + } } _ => None, } { @@ -705,6 +716,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { _ => {} } + if app.is_command_mode() { + return handle_command_key(app, key); + } + // Completion popup overrides — only when there's something to // navigate / commit. An empty popup (request in flight) falls // through to the default behaviour. @@ -790,6 +805,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { app.move_cursor_end(); app.refresh_completion() } + KeyCode::Char(':') if !alt && app.input.is_empty() => { + app.enter_command_mode(); + None + } KeyCode::Char(c) => { // Whitespace ends an in-flight completion token. Try the // auto-confirm path first so an exact match (e.g. typed @@ -807,6 +826,60 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { } } +fn handle_command_key(app: &mut App, key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => { + app.exit_command_mode(); + None + } + KeyCode::Enter => app.submit_command(), + KeyCode::Backspace => { + app.delete_char_before(); + None + } + KeyCode::Delete => { + app.delete_char_after(); + None + } + KeyCode::Left => { + app.move_cursor_left(); + None + } + KeyCode::Right => { + app.move_cursor_right(); + None + } + KeyCode::Up => { + app.move_cursor_up(); + None + } + KeyCode::Down => { + app.move_cursor_down(); + None + } + KeyCode::Home => { + app.move_cursor_home(); + None + } + KeyCode::End => { + app.move_cursor_end(); + None + } + KeyCode::Tab => None, + KeyCode::Char(c) => { + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + return None; + } + app.insert_char(c); + None + } + _ => None, + } +} + const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); /// Running → send `Method::Pause`. @@ -1056,6 +1129,138 @@ mod tests { assert_eq!(app.queued_input_count(), 0); } + #[test] + fn command_mode_enters_with_colon_and_esc_restores_composer() { + let mut app = App::new("agent".to_string()); + app.insert_char('d'); + app.insert_char('r'); + app.insert_char('a'); + app.insert_char('f'); + app.insert_char('t'); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), "draft:"); + + app.input.clear(); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!(app.is_command_mode()); + for c in "help".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + assert_eq!(input_text(&app), ""); + assert_eq!(app.command_text(), "help"); + + assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_none()); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn unknown_command_is_not_sent_as_user_message() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + for c in "does-not-exist".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + + let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(method.is_none()); + assert!(app.is_command_mode()); + assert_eq!(input_text(&app), ""); + assert_eq!(app.queued_input_count(), 0); + assert!(app.blocks.iter().any(|block| match block { + crate::block::Block::Alert { message, .. } => message.contains("Unknown command"), + _ => false, + })); + } + + #[test] + fn command_enter_dispatches_registry_without_run() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + for c in "noop".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + + let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(method.is_none()); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), ""); + assert!(app.blocks.iter().any(|block| match block { + crate::block::Block::Alert { message, .. } => message.contains("noop: no action"), + _ => false, + })); + } + + #[test] + fn command_registry_suggestions_are_available() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!( + app.command_suggestions() + .iter() + .any(|candidate| candidate.name == "help") + ); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE) + ) + .is_none() + ); + let suggestions = app.command_suggestions(); + assert_eq!(suggestions.len(), 1); + assert_eq!(suggestions[0].name, "noop"); + } + fn input_text(app: &App) -> String { protocol::Segment::flatten_to_text(&app.input.submit_segments()) } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 9c80e1c9..325c35b2 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -61,10 +61,14 @@ impl Mode { pub fn draw(frame: &mut Frame, app: &mut App) { let area = frame.area(); - // Input content starts after the "> " / " " prompt, so the width + // Input content starts after the prompt (`> ` or `: `), so the width // available for wrapping is two columns narrower than the frame. let input_content_width = area.width.saturating_sub(2).max(1); - let input_render = app.input.render(input_content_width); + let input_render = if app.is_command_mode() { + app.command_input.render(input_content_width) + } else { + app.input.render(input_content_width) + }; let input_height = input_area_height(&input_render, area.height); let mini_view_h = task_mini_view_height(&app.task_store); // One blank row separates the history tail from the mini-view so @@ -89,9 +93,11 @@ pub fn draw(frame: &mut Frame, app: &mut App) { } draw_separator(frame, chunks[3]); draw_status(frame, app, chunks[4]); - draw_input(frame, &input_render, chunks[5]); + draw_input(frame, app, &input_render, chunks[5]); draw_actionbar(frame, app, chunks[6]); - if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) { + if !app.is_command_mode() + && let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) + { draw_completion_popup(frame, state, chunks[5]); } } @@ -1146,6 +1152,16 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled(queue, Style::default().fg(Color::Magenta))); } + if app.is_command_mode() { + spans.push(Span::raw(" | ")); + spans.push(Span::styled( + "command", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + } + let right_text = context_usage_text(app); let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray))) .alignment(ratatui::layout::Alignment::Right); @@ -1156,7 +1172,28 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) { let mut left: Vec> = Vec::new(); - if app.queued_input_count() > 0 { + if app.is_command_mode() { + left.push(Span::styled( + "COMMAND Esc cancel Enter dispatch", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + let suggestions = app.command_suggestions(); + if !suggestions.is_empty() { + let suggestion_text = suggestions + .iter() + .take(4) + .map(|candidate| format!("{} — {}", candidate.name, candidate.description)) + .collect::>() + .join(" | "); + left.push(Span::styled(" ", Style::default())); + left.push(Span::styled( + truncate_with_ellipsis(&suggestion_text, area.width.saturating_sub(34) as usize), + Style::default().fg(Color::DarkGray), + )); + } + } else if app.queued_input_count() > 0 { left.push(Span::styled( "Alt-q edit queued Alt-c clear queued", Style::default().fg(Color::DarkGray), @@ -1196,13 +1233,21 @@ fn queue_status_text(app: &App) -> Option { Some(text) } -fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) { - // Prefix "> " on the first row, two-space gutter for continuation +fn draw_input(frame: &mut Frame, app: &App, render: &crate::input::InputRender, area: Rect) { + // Prefix prompt on the first row, matching-width gutter for continuation // rows so multi-line input aligns visually. - let prompt_style = Style::default().fg(Color::DarkGray); + let prompt = if app.is_command_mode() { ": " } else { "> " }; + let continuation = if app.is_command_mode() { ": " } else { " " }; + let prompt_style = if app.is_command_mode() { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; let mut lines: Vec> = Vec::with_capacity(render.lines.len()); for (i, src) in render.lines.iter().enumerate() { - let prefix = if i == 0 { "> " } else { " " }; + let prefix = if i == 0 { prompt } else { continuation }; let mut spans = vec![Span::styled(prefix.to_owned(), prompt_style)]; spans.extend(src.spans.iter().cloned()); lines.push(Line::from(spans));