diff --git a/src/tui/app.rs b/src/tui/app.rs index 9aef6c5..462481c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -31,7 +31,7 @@ const MAX_IMAGE_SOURCE_BYTES: usize = 10 * 1024 * 1024; const MAX_RETAINED_IMAGE_SOURCE_BYTES: usize = 32 * 1024 * 1024; use super::{ - command::{Parsed, known_token, parse}, + command::{self, Command as SlashCommand, Parsed, known_token, parse}, editor::Editor, plan::{PlanNode, parse as parse_plan}, wrap::LinkHit, @@ -96,7 +96,7 @@ pub enum Update { /// Agent-advertised slash commands for one session. AvailableCommands { session_id: String, - commands: Vec, + commands: Vec, }, /// Full session configuration snapshot. ConfigOptions(Vec), @@ -529,7 +529,10 @@ pub struct App { pub effort_dialog: Option, pub session_choices: Vec, pub session_dialog: Option, - pub available_commands: Vec, + pub available_commands: Vec, + pub command_completion_selected: usize, + command_completion_query: Option, + command_completion_dismissed: Option, pub a2a: String, pub session_id: Option, /// Session currently associated with the ordered runtime side channel. @@ -815,6 +818,9 @@ impl App { session_choices: Vec::new(), session_dialog: None, available_commands: Vec::new(), + command_completion_selected: 0, + command_completion_query: None, + command_completion_dismissed: None, a2a, session_id: None, runtime_session_id: None, @@ -875,6 +881,44 @@ impl App { self.phase != Phase::Idle } + fn command_completion_prefix(&self) -> Option<&str> { + (self.phase == Phase::Idle) + .then(|| command::completion_prefix(self.editor.text(), self.editor.cursor())) + .flatten() + } + + pub fn command_completions(&self) -> Vec { + let Some(prefix) = self.command_completion_prefix() else { + return Vec::new(); + }; + if self.command_completion_dismissed.as_deref() == Some(prefix) { + return Vec::new(); + } + command::completions( + self.editor.text(), + self.editor.cursor(), + &self.available_commands, + ) + } + + fn sync_command_completion(&mut self) { + let query = self.command_completion_prefix().map(str::to_string); + if query != self.command_completion_query { + self.command_completion_selected = 0; + self.command_completion_dismissed = None; + self.command_completion_query = query; + } + let count = command::completions( + self.editor.text(), + self.editor.cursor(), + &self.available_commands, + ) + .len(); + self.command_completion_selected = self + .command_completion_selected + .min(count.saturating_sub(1)); + } + fn collapse_last_tool_output(&mut self) { let previous = self.blocks.len().checked_sub(1).filter(|&index| { matches!( @@ -1625,6 +1669,8 @@ impl App { } => { if self.session_id.as_deref() == Some(session_id.as_str()) { self.available_commands = commands; + self.command_completion_dismissed = None; + self.sync_command_completion(); } } Update::SteerAccepted { id, text } => { @@ -1949,6 +1995,9 @@ impl App { pub fn start_session(&mut self, session_id: String) { self.session_id = Some(session_id); self.available_commands.clear(); + self.command_completion_selected = 0; + self.command_completion_query = None; + self.command_completion_dismissed = None; self.blocks.clear(); self.transcript_cache.clear(); self.transcript_revisions.clear(); @@ -2234,6 +2283,7 @@ impl App { /// rest is easy to miss. pub fn paste(&mut self, text: &str) { self.editor.insert_str(text); + self.sync_command_completion(); let lines = text.lines().count(); if lines > 1 { self.toast(format!("pasted {lines} lines")); @@ -2432,6 +2482,39 @@ impl App { let word = alt || control; let line = command || control; + self.sync_command_completion(); + let completions = self.command_completions(); + if !completions.is_empty() && key.modifiers.is_empty() { + match key.code { + KeyCode::Esc => { + self.command_completion_dismissed = + self.command_completion_prefix().map(str::to_string); + return Action::None; + } + KeyCode::Up => { + self.command_completion_selected = + self.command_completion_selected.saturating_sub(1); + return Action::None; + } + KeyCode::Down => { + self.command_completion_selected = (self.command_completion_selected + 1) + .min(completions.len().saturating_sub(1)); + return Action::None; + } + KeyCode::Tab => { + let selected = self + .command_completion_selected + .min(completions.len().saturating_sub(1)); + let replacement = completions[selected].name.clone(); + self.editor.replace_command_token(&replacement); + self.command_completion_query = Some(replacement.clone()); + self.command_completion_dismissed = Some(replacement); + return Action::None; + } + _ => {} + } + } + match key.code { KeyCode::Char('b') if key.modifiers == KeyModifiers::SUPER => { let Some(call_id) = self.newest_foreground_compose().map(|call| call.id.clone()) @@ -2637,6 +2720,7 @@ impl App { KeyCode::Char(character) if !control && !command => self.editor.insert_char(character), _ => {} } + self.sync_command_completion(); if self.follow { self.scroll = usize::MAX; } @@ -3640,13 +3724,25 @@ mod tests { session_id: "one".into(), commands: vec!["compact".into()], }); - assert_eq!(app.available_commands, ["compact"]); + assert_eq!( + app.available_commands + .iter() + .map(|command| command.name.as_str()) + .collect::>(), + ["compact"] + ); app.apply(Update::AvailableCommands { session_id: "stale".into(), commands: vec!["ignored".into()], }); - assert_eq!(app.available_commands, ["compact"]); + assert_eq!( + app.available_commands + .iter() + .map(|command| command.name.as_str()) + .collect::>(), + ["compact"] + ); app.apply(Update::AvailableCommands { session_id: "one".into(), @@ -3712,6 +3808,53 @@ mod tests { )); } + #[test] + fn tab_completes_a_slash_command_without_submitting() { + let mut app = app(); + app.paste("/mo"); + assert_eq!(app.command_completions()[0].name, "/model"); + + assert!(matches!(app.handle_key(press(KeyCode::Tab)), Action::None)); + assert_eq!(app.editor.text(), "/model"); + assert!(app.command_completions().is_empty()); + } + + #[test] + fn completion_keys_select_and_dismiss_before_normal_editor_actions() { + let mut app = app(); + app.paste("/"); + app.handle_key(press(KeyCode::Down)); + app.handle_key(press(KeyCode::Tab)); + assert_eq!(app.editor.text(), "/resume"); + + app.editor.clear(); + app.paste("/m"); + app.handle_key(press(KeyCode::Esc)); + assert!(app.command_completions().is_empty()); + app.handle_key(press(KeyCode::Char('o'))); + assert_eq!(app.command_completions()[0].name, "/model"); + } + + #[test] + fn tab_keeps_inserting_spaces_without_an_active_completion() { + let mut app = app(); + app.paste("plain"); + app.handle_key(press(KeyCode::Tab)); + assert_eq!(app.editor.text(), "plain "); + } + + #[test] + fn completion_is_hidden_after_arguments_and_while_working() { + let mut app = app(); + app.paste("/model sonnet"); + assert!(app.command_completions().is_empty()); + + app.editor.clear(); + app.paste("/mo"); + app.phase = Phase::Working; + assert!(app.command_completions().is_empty()); + } + #[test] fn new_command_carries_an_optional_first_prompt() { let mut app = app(); diff --git a/src/tui/command.rs b/src/tui/command.rs index 6a3e881..4651e77 100644 --- a/src/tui/command.rs +++ b/src/tui/command.rs @@ -18,38 +18,68 @@ enum Kind { struct Spec { token: &'static str, + description: &'static str, kind: Kind, } +/// Slash command advertised by the active agent session. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Command { + pub name: String, + pub description: String, +} + +impl Command { + pub fn new(name: impl Into, description: impl Into) -> Self { + Self { + name: name.into(), + description: description.into(), + } + } +} + +impl From<&str> for Command { + fn from(name: &str) -> Self { + Self::new(name, "") + } +} + // Agent-advertised commands remain ordinary prompts. Only these commands are // interpreted by the client itself. const LOCAL_COMMANDS: &[Spec] = &[ Spec { token: "/new", + description: "Start a new session", kind: Kind::New, }, Spec { token: "/resume", + description: "Resume a session by ID", kind: Kind::Resume, }, Spec { token: "/sessions", + description: "Browse saved sessions", kind: Kind::Sessions, }, Spec { token: "/close", + description: "Close the current session", kind: Kind::Close, }, Spec { token: "/model", + description: "Choose a model", kind: Kind::Model, }, Spec { token: "/effort", + description: "Set the reasoning effort", kind: Kind::Effort, }, Spec { token: "/agents", + description: "Show the agent roster", kind: Kind::Agents, }, ]; @@ -101,7 +131,7 @@ pub fn parse(input: &str) -> Parsed<'_> { } /// Byte range of a local or agent-advertised command token. -pub fn known_token(input: &str, advertised: &[String]) -> Option> { +pub fn known_token(input: &str, advertised: &[Command]) -> Option> { if let Some((spec, token_end)) = recognized_local(input) { if matches!(spec.kind, Kind::Agents) && !input[token_end..].trim().is_empty() { return None; @@ -112,13 +142,55 @@ pub fn known_token(input: &str, advertised: &[String]) -> Option> { let name = token.strip_prefix('/')?; advertised .iter() - .any(|command| !command.is_empty() && command == name) + .any(|command| { + let advertised = command.name.strip_prefix('/').unwrap_or(&command.name); + !advertised.is_empty() && advertised == name + }) .then_some(0..token_end) } +/// Commands matching the initial slash token at `cursor`. +/// +/// Completion is deliberately limited to a prompt containing only its first +/// token. Once arguments or leading whitespace are present, normal editing +/// and history navigation take over. +pub fn completion_prefix(input: &str, cursor: usize) -> Option<&str> { + if cursor > input.len() + || !input.is_char_boundary(cursor) + || input.chars().any(char::is_whitespace) + { + return None; + } + let prefix = &input[..cursor]; + prefix.starts_with('/').then_some(prefix) +} + +pub fn completions(input: &str, cursor: usize, advertised: &[Command]) -> Vec { + let Some(prefix) = completion_prefix(input, cursor) else { + return Vec::new(); + }; + + let mut matches: Vec = LOCAL_COMMANDS + .iter() + .filter(|spec| spec.token.starts_with(prefix)) + .map(|spec| Command::new(spec.token, spec.description)) + .collect(); + for command in advertised { + let name = command.name.strip_prefix('/').unwrap_or(&command.name); + if name.is_empty() { + continue; + } + let token = format!("/{name}"); + if token.starts_with(prefix) && !matches.iter().any(|candidate| candidate.name == token) { + matches.push(Command::new(token, command.description.clone())); + } + } + matches +} + #[cfg(test)] mod tests { - use super::{Parsed, known_token, parse}; + use super::{Command, Parsed, completions, known_token, parse}; #[test] fn parses_commands_with_or_without_a_following_prompt() { @@ -178,12 +250,15 @@ mod tests { for input in ["/agents now", "/agentsx", " /agents"] { assert_eq!(parse(input), Parsed::Prompt(input)); } - assert_eq!(known_token("/agents now", &["agents".into()]), None); + assert_eq!( + known_token("/agents now", &[Command::new("agents", "")]), + None + ); } #[test] fn advertised_commands_are_discovered_but_not_parsed_locally() { - let advertised = vec!["compact".to_string(), "new".to_string()]; + let advertised = vec![Command::new("compact", ""), Command::new("new", "")]; assert_eq!(known_token("/compact next", &advertised), Some(0..8)); assert_eq!(parse("/compact next"), Parsed::Prompt("/compact next")); assert_eq!( @@ -193,4 +268,41 @@ mod tests { } ); } + + #[test] + fn completes_local_and_advertised_commands_with_local_precedence() { + let advertised = vec![ + Command::new("compact", "Compact context"), + Command::new("new", "Agent new"), + Command::new("", "Ignored"), + ]; + let matches = completions("/", 1, &advertised); + assert_eq!( + matches + .iter() + .map(|command| command.name.as_str()) + .collect::>(), + [ + "/new", + "/resume", + "/sessions", + "/close", + "/model", + "/effort", + "/agents", + "/compact", + ] + ); + assert_eq!(matches[0].description, "Start a new session"); + assert_eq!(matches.last().unwrap().description, "Compact context"); + } + + #[test] + fn completion_uses_the_cursor_prefix_and_stops_at_whitespace() { + assert_eq!(completions("/mo", 3, &[])[0].name, "/model"); + assert_eq!(completions("/model", 3, &[])[0].name, "/model"); + for (input, cursor) in [(" /mo", 4), ("/mo arg", 3), ("plain", 5)] { + assert!(completions(input, cursor, &[]).is_empty()); + } + } } diff --git a/src/tui/editor.rs b/src/tui/editor.rs index a19f0a6..1eafab0 100644 --- a/src/tui/editor.rs +++ b/src/tui/editor.rs @@ -23,6 +23,25 @@ impl Editor { &self.text } + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Replaces the initial slash-command token and leaves following text intact. + pub fn replace_command_token(&mut self, replacement: &str) -> bool { + if !self.text.starts_with('/') { + return false; + } + let end = self + .text + .char_indices() + .find_map(|(index, character)| character.is_whitespace().then_some(index)) + .unwrap_or(self.text.len()); + self.text.replace_range(..end, replacement); + self.cursor = replacement.len(); + true + } + pub fn is_empty(&self) -> bool { self.text.trim().is_empty() } @@ -472,4 +491,21 @@ mod tests { editor.delete_forward(); assert_eq!(editor.text(), "hllo"); } + + #[test] + fn replaces_the_command_token_and_preserves_following_text() { + let mut editor = editor("/mødel trailing text"); + assert!(editor.replace_command_token("/model")); + assert_eq!(editor.text(), "/model trailing text"); + assert_eq!(editor.cursor(), 6); + editor.insert_char('!'); + assert_eq!(editor.text(), "/model! trailing text"); + } + + #[test] + fn refuses_to_replace_a_non_command_prompt() { + let mut editor = editor("hello"); + assert!(!editor.replace_command_token("/model")); + assert_eq!(editor.text(), "hello"); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 575b764..abdda68 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1455,7 +1455,7 @@ fn translate(notification: UpdateSessionNotification) -> (String, Vec) { commands: update .available_commands .into_iter() - .map(|command| command.name) + .map(|available| command::Command::new(available.name, available.description)) .collect(), }], SessionUpdate::ConfigOptionUpdate(update) => { @@ -1699,7 +1699,7 @@ mod tests { use super::{ ActiveSessionRoute, MAX_ATTACHMENTS, ModelChoice, QueuedUpdate, accept_queued_update, - attachments_from_paste, current_model_choice, detach_from_controlling_terminal, + attachments_from_paste, command, current_model_choice, detach_from_controlling_terminal, durable_session_id, effort_state, handle, message_of, osc52, previous_session_for_resume, prompt_blocks, readable, refresh_config_state, save_effort_default_to, save_model_defaults_to, transition_route, translate, translate_for_session, @@ -1780,7 +1780,9 @@ mod tests { assert!(matches!( updates.as_slice(), [Update::AvailableCommands { session_id, commands }] - if session_id == "session" && commands == &["compact"] + if session_id == "session" + && commands.as_slice() + == [command::Command::new("compact", "Compact context")] )); } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 9679662..bb0ef53 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -112,6 +112,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) { } draw_pending_steers(frame, app, pending); draw_prompt(frame, app, prompt); + draw_command_popup(frame, app, prompt); draw_status(frame, app, status); } if app.session_dialog.is_some() { @@ -480,6 +481,7 @@ fn draw_start(frame: &mut Frame<'_>, app: &App, width: u16, prompt_rows: u16) { let prompt = Rect::new(x, y, width, prompt_rows); draw_start_prompt(frame, app, prompt); + draw_command_popup(frame, app, prompt); let status = Rect::new(x, y + prompt_rows, width, 1); draw_status(frame, app, status); } @@ -1620,6 +1622,65 @@ fn draw_start_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) { ); } +fn draw_command_popup(frame: &mut Frame<'_>, app: &App, anchor: Rect) { + const MAX_ROWS: usize = 7; + + let commands = app.command_completions(); + let available = anchor.y.saturating_sub(frame.area().y); + if commands.is_empty() || available < 3 || anchor.width < 4 { + return; + } + let rows = commands + .len() + .min(MAX_ROWS) + .min(available.saturating_sub(2) as usize); + if rows == 0 { + return; + } + let selected = app + .command_completion_selected + .min(commands.len().saturating_sub(1)); + let first = selected + .saturating_sub(rows.saturating_sub(1)) + .min(commands.len().saturating_sub(rows)); + let area = Rect::new( + anchor.x, + anchor.y - rows as u16 - 2, + anchor + .width + .min(frame.area().right().saturating_sub(anchor.x)), + rows as u16 + 2, + ); + let panel = Panel::bordered() + .title(" commands ") + .border_type(BorderType::Rounded) + .border_style(theme::accent()); + let inner = panel.inner(area); + let lines = commands + .iter() + .enumerate() + .skip(first) + .take(rows) + .map(|(index, command)| { + let marker = if index == selected { "› " } else { " " }; + let line = Line::from(vec![ + Span::styled(marker, theme::accent()), + Span::styled(command.name.clone(), theme::accent()), + Span::styled(" ".to_string(), theme::text()), + Span::styled(command.description.clone(), theme::faint()), + ]); + if index == selected { + line.style(theme::selection()) + } else { + line + } + }) + .collect::>(); + frame.render_widget(Clear, area); + frame.render_widget(panel, area); + frame.render_widget(Paragraph::new(lines), inner); +} + fn draw_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) { let border = if app.phase == Phase::Working && app.can_steer || app.phase == Phase::Idle { Style::default().fg(theme::accent_color()) @@ -1707,7 +1768,7 @@ fn draw_prompt_editor(frame: &mut Frame<'_>, app: &App, area: Rect, placeholder_ fn prompt_lines( rows: Vec, input: &str, - available_commands: &[String], + available_commands: &[command::Command], ) -> Vec> { let mut highlighted = command::known_token(input, available_commands).map_or(0, |range| range.len()); @@ -2242,6 +2303,41 @@ mod tests { .join("\n") } + #[test] + fn command_popup_renders_in_start_and_compact_layouts() { + let mut app = App::new( + PathBuf::from("/tmp"), + "openai".into(), + "gpt".into(), + "127.0.0.1:7331".into(), + ); + app.paste("/mo"); + let start = render(&mut app, 80, 24); + assert!(start.contains("commands"), "{start}"); + assert!(start.contains("/model"), "{start}"); + assert!(start.contains("Choose a model"), "{start}"); + + app.show_logs = true; + let compact = render(&mut app, 80, 24); + assert!(compact.contains("/model"), "{compact}"); + assert!(compact.contains("Choose a model"), "{compact}"); + } + + #[test] + fn command_popup_handles_tiny_terminals_and_disappears_after_dismissal() { + let mut app = App::new( + PathBuf::from("/tmp"), + "openai".into(), + "gpt".into(), + "127.0.0.1:7331".into(), + ); + app.paste("/"); + let _ = render(&mut app, 8, 2); + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let dismissed = render(&mut app, 80, 24); + assert!(!dismissed.contains(" commands "), "{dismissed}"); + } + #[test] fn agents_panel_is_hidden_before_the_first_transcript_block() { let mut app = panel_app(1); @@ -3089,7 +3185,10 @@ mod tests { assert_eq!(unknown[0].spans.len(), 1); assert_eq!(unknown[0].spans[0].style, crate::tui::theme::text()); - let advertised = vec!["compact".to_string()]; + let advertised = vec![crate::tui::command::Command::new( + "compact", + "Compact context", + )]; let dynamic = prompt_lines( vec!["/compact prompt".into()], "/compact prompt",