fix: refine command mode footer

This commit is contained in:
Keisuke Hirata 2026-05-25 01:08:41 +09:00
parent 9224951000
commit e5fda7efdf
2 changed files with 106 additions and 29 deletions

View File

@ -834,7 +834,11 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
}
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());

View File

@ -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<Line<'static>> = 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<Span<'static>> = 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::<Vec<_>>()
.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",