From e5fda7efdf128faed2f97a64e5a60dd8b6048009 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 25 May 2026 01:08:41 +0900 Subject: [PATCH] fix: refine command mode footer --- crates/tui/src/main.rs | 59 +++++++++++++++++++++++++++++++- crates/tui/src/ui.rs | 76 ++++++++++++++++++++++++++---------------- 2 files changed, 106 insertions(+), 29 deletions(-) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 9072295e..ce79a65c 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -834,7 +834,11 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option { } KeyCode::Enter => app.submit_command(), KeyCode::Backspace => { - app.delete_char_before(); + if app.command_text().is_empty() { + app.exit_command_mode(); + } else { + app.delete_char_before(); + } None } KeyCode::Delete => { @@ -1173,6 +1177,59 @@ mod tests { assert_eq!(input_text(&app), ""); } + #[test] + fn command_mode_empty_backspace_restores_composer() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!(app.is_command_mode()); + assert_eq!(app.command_text(), ""); + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE) + ) + .is_none() + ); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn command_mode_non_empty_backspace_keeps_command_mode() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE) + ) + .is_none() + ); + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE) + ) + .is_none() + ); + assert!(app.is_command_mode()); + assert_eq!(app.command_text(), ""); + } + #[test] fn unknown_command_is_not_sent_as_user_message() { let mut app = App::new("agent".to_string()); diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 325c35b2..52a9ccc4 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -27,6 +27,7 @@ use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment}; use crate::app::{App, CompletionState, alert_source_label, fmt_tokens}; use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; +use crate::command::CommandCandidate; use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore}; /// Display density for the history view. @@ -95,9 +96,9 @@ pub fn draw(frame: &mut Frame, app: &mut App) { draw_status(frame, app, chunks[4]); draw_input(frame, app, &input_render, chunks[5]); draw_actionbar(frame, app, chunks[6]); - if !app.is_command_mode() - && let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) - { + if app.is_command_mode() { + draw_command_popup(frame, app, chunks[5]); + } else if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) { draw_completion_popup(frame, state, chunks[5]); } } @@ -256,6 +257,49 @@ fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area: frame.render_widget(Paragraph::new(lines), popup_area); } +fn draw_command_popup(frame: &mut Frame, app: &App, input_area: Rect) { + let suggestions = app.command_suggestions(); + if suggestions.is_empty() || input_area.y == 0 { + return; + } + + let visible = suggestions.len().min(CompletionState::MAX_VISIBLE); + let visible_suggestions = &suggestions[..visible]; + let max_label = visible_suggestions + .iter() + .map(|candidate| command_suggestion_label(candidate).width() as u16) + .max() + .unwrap_or(0); + let popup_w = max_label.saturating_add(2).min(input_area.width).max(1); + let popup_h = (visible as u16).min(input_area.y); + let popup_area = Rect::new( + input_area.x, + input_area.y.saturating_sub(popup_h), + popup_w, + popup_h, + ); + + let command_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let description_style = Style::default().fg(Color::DarkGray); + let mut lines: Vec> = Vec::with_capacity(popup_h as usize); + for candidate in visible_suggestions.iter().take(popup_h as usize) { + lines.push(Line::from(vec![ + Span::styled(candidate.name.to_owned(), command_style), + Span::styled(" — ", description_style), + Span::styled(candidate.description.to_owned(), description_style), + ])); + } + + frame.render_widget(Clear, popup_area); + frame.render_widget(Paragraph::new(lines), popup_area); +} + +fn command_suggestion_label(candidate: &CommandCandidate) -> String { + format!("{} — {}", candidate.name, candidate.description) +} + /// Cap the input area so it doesn't eat the history view: grows with the /// buffer but never past `min(10, terminal_height / 3)`. fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -> u16 { @@ -1152,16 +1196,6 @@ 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); @@ -1174,25 +1208,11 @@ fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) { let mut left: Vec> = Vec::new(); if app.is_command_mode() { left.push(Span::styled( - "COMMAND Esc cancel Enter dispatch", + "COMMAND", 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",