diff --git a/crates/tui/src/composer_keys.rs b/crates/tui/src/composer_keys.rs new file mode 100644 index 00000000..b75c2a94 --- /dev/null +++ b/crates/tui/src/composer_keys.rs @@ -0,0 +1,118 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ComposerEditAction { + InsertChar(char), + InsertNewline, + DeleteBefore, + DeleteAfter, + DeleteWordBefore, + MoveLeft, + MoveRight, + MoveWordLeft, + MoveWordRight, + MoveStart, + MoveHome, + MoveEnd, + MoveUp, + MoveDown, +} + +impl ComposerEditAction { + pub(crate) fn is_modifier_action(self) -> bool { + matches!( + self, + Self::InsertNewline + | Self::DeleteWordBefore + | Self::MoveWordLeft + | Self::MoveWordRight + | Self::MoveStart + | Self::MoveEnd + ) + } +} + +/// Shared readline-style composer editing keymap used by the normal Pod TUI +/// and the workspace panel. Callers still own higher-level routing such as +/// completion popups, Enter submission, Tab target switching, Esc focus, and +/// row/list navigation. +pub(crate) fn composer_edit_action(key: KeyEvent) -> Option { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + + match key.code { + KeyCode::Enter if alt && !ctrl => Some(ComposerEditAction::InsertNewline), + KeyCode::Char('a') if ctrl && !alt => Some(ComposerEditAction::MoveStart), + KeyCode::Char('e') if ctrl && !alt => Some(ComposerEditAction::MoveEnd), + KeyCode::Left if ctrl || alt => Some(ComposerEditAction::MoveWordLeft), + KeyCode::Right if ctrl || alt => Some(ComposerEditAction::MoveWordRight), + KeyCode::Backspace if ctrl || alt => Some(ComposerEditAction::DeleteWordBefore), + KeyCode::Char('w') if ctrl && !alt => Some(ComposerEditAction::DeleteWordBefore), + KeyCode::Backspace if !ctrl && !alt => Some(ComposerEditAction::DeleteBefore), + KeyCode::Delete if !ctrl && !alt => Some(ComposerEditAction::DeleteAfter), + KeyCode::Left if !ctrl && !alt => Some(ComposerEditAction::MoveLeft), + KeyCode::Right if !ctrl && !alt => Some(ComposerEditAction::MoveRight), + KeyCode::Up if !ctrl && !alt => Some(ComposerEditAction::MoveUp), + KeyCode::Down if !ctrl && !alt => Some(ComposerEditAction::MoveDown), + KeyCode::Home if !alt => Some(ComposerEditAction::MoveHome), + KeyCode::End if !alt => Some(ComposerEditAction::MoveEnd), + KeyCode::Char(c) if !ctrl && !alt => Some(ComposerEditAction::InsertChar(c)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn modified(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + #[test] + fn maps_word_motion_and_word_delete_keys() { + assert_eq!( + composer_edit_action(modified(KeyCode::Left, KeyModifiers::CONTROL)), + Some(ComposerEditAction::MoveWordLeft) + ); + assert_eq!( + composer_edit_action(modified(KeyCode::Right, KeyModifiers::ALT)), + Some(ComposerEditAction::MoveWordRight) + ); + assert_eq!( + composer_edit_action(modified(KeyCode::Backspace, KeyModifiers::CONTROL)), + Some(ComposerEditAction::DeleteWordBefore) + ); + assert_eq!( + composer_edit_action(modified(KeyCode::Char('w'), KeyModifiers::CONTROL)), + Some(ComposerEditAction::DeleteWordBefore) + ); + } + + #[test] + fn leaves_enter_tab_esc_and_control_letters_for_callers() { + assert_eq!(composer_edit_action(key(KeyCode::Enter)), None); + assert_eq!(composer_edit_action(key(KeyCode::Tab)), None); + assert_eq!(composer_edit_action(key(KeyCode::Esc)), None); + assert_eq!( + composer_edit_action(modified(KeyCode::Char('j'), KeyModifiers::CONTROL)), + None + ); + } + + #[test] + fn plain_letters_remain_text_input() { + assert_eq!( + composer_edit_action(key(KeyCode::Char('j'))), + Some(ComposerEditAction::InsertChar('j')) + ); + assert_eq!( + composer_edit_action(key(KeyCode::Char('o'))), + Some(ComposerEditAction::InsertChar('o')) + ); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 75d28cd7..1232759d 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -3,6 +3,7 @@ mod block; mod cache; mod command; mod composer_history; +mod composer_keys; mod input; pub mod keys; mod markdown; diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 0875be66..c1f16630 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -26,6 +26,7 @@ use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowSt use tokio::net::UnixStream; use unicode_width::UnicodeWidthStr; +use crate::composer_keys::{ComposerEditAction, composer_edit_action}; use crate::input::InputBuffer; use crate::pod_list::{ PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, read_reachable_live_pod_infos, @@ -514,11 +515,19 @@ fn commit_intake_registry_update(update: IntakeRegistryUpdate) -> Option } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PanelFocus { + GlobalComposer, + Row, + ItemAction, +} + pub(crate) struct MultiPodApp { pub(crate) list: PodList, pub(crate) panel: WorkspacePanelViewModel, pub(crate) input: InputBuffer, selected_row: Option, + focus: PanelFocus, composer_target: ComposerTarget, notice: Option, sending: bool, @@ -548,6 +557,7 @@ impl MultiPodApp { panel, input: InputBuffer::new(), selected_row: None, + focus: PanelFocus::GlobalComposer, composer_target: ComposerTarget::Companion, notice: None, sending: false, @@ -707,7 +717,7 @@ impl MultiPodApp { .clone() .or_else(|| row.key_hint.clone()) .unwrap_or_else(|| { - "Empty Enter dispatches this Ticket action; stale Tickets are re-checked before any mutation." + "Enter dispatches this Ticket action; Right marks action focus; stale Tickets are re-checked before any mutation." .to_string() }), ); @@ -801,6 +811,37 @@ impl MultiPodApp { self.list.selected_name = Some(name.clone()); } self.selected_row = Some(key); + self.focus = PanelFocus::Row; + } + + fn clear_panel_focus(&mut self) { + self.selected_row = None; + self.list.selected_name = None; + self.focus = PanelFocus::GlobalComposer; + } + + fn effective_focus(&self) -> PanelFocus { + if self.selected_row.is_none() { + PanelFocus::GlobalComposer + } else { + self.focus + } + } + + fn focus_item_action(&mut self) { + if self.selected_row.is_some() { + self.focus = PanelFocus::ItemAction; + } else { + self.notice = Some("No row selected; use ↑/↓ to select a row first.".to_string()); + } + } + + fn focus_selected_row(&mut self) { + if self.selected_row.is_some() { + self.focus = PanelFocus::Row; + } else { + self.focus = PanelFocus::GlobalComposer; + } } fn ensure_composer_target_available(&mut self) { @@ -1215,27 +1256,65 @@ impl MultiPodApp { } } + fn apply_composer_edit_action(&mut self, action: ComposerEditAction) { + match action { + ComposerEditAction::InsertChar(c) => self.input.insert_char(c), + ComposerEditAction::InsertNewline => self.input.insert_newline(), + ComposerEditAction::DeleteBefore => self.input.delete_before(), + ComposerEditAction::DeleteAfter => self.input.delete_after(), + ComposerEditAction::DeleteWordBefore => self.input.delete_word_before(), + ComposerEditAction::MoveLeft => self.input.move_left(), + ComposerEditAction::MoveRight => self.input.move_right(), + ComposerEditAction::MoveWordLeft => self.input.move_word_left(), + ComposerEditAction::MoveWordRight => self.input.move_word_right(), + ComposerEditAction::MoveStart => self.input.move_start(), + ComposerEditAction::MoveHome => self.input.move_home(), + ComposerEditAction::MoveEnd => self.input.move_end(), + ComposerEditAction::MoveUp => self.input.move_up(), + ComposerEditAction::MoveDown => self.input.move_down(), + } + } + fn handle_key(&mut self, key: KeyEvent) -> MultiPodAction { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - let alt = key.modifiers.contains(KeyModifiers::ALT); match key.code { KeyCode::Char('d') if ctrl => MultiPodAction::Quit, KeyCode::Char('c') if ctrl => MultiPodAction::Quit, - KeyCode::Esc => MultiPodAction::Quit, - KeyCode::Up => { - self.select_prev(); + KeyCode::Esc => { + self.clear_panel_focus(); + self.notice = Some("Focus: global composer target; Ctrl+C quits.".to_string()); MultiPodAction::None } - KeyCode::Down => { - self.select_next(); - MultiPodAction::None - } - KeyCode::Char('t') if ctrl => { + KeyCode::Tab => { + // Completion owns Tab before panel target switching when a + // completion popup exists. The workspace panel currently has + // no completion source, so this is the target switch path. + self.clear_panel_focus(); self.cycle_composer_target(); MultiPodAction::None } - KeyCode::Enter if alt => { - self.input.insert_newline(); + KeyCode::Up if self.composer_is_blank() => { + self.select_prev(); + MultiPodAction::None + } + KeyCode::Down if self.composer_is_blank() => { + self.select_next(); + MultiPodAction::None + } + KeyCode::Left + if self.composer_is_blank() && self.effective_focus() == PanelFocus::ItemAction => + { + self.focus_selected_row(); + MultiPodAction::None + } + KeyCode::Left + if self.composer_is_blank() && self.effective_focus() == PanelFocus::Row => + { + self.clear_panel_focus(); + MultiPodAction::None + } + KeyCode::Right if self.composer_is_blank() => { + self.focus_item_action(); MultiPodAction::None } KeyCode::Enter @@ -1262,32 +1341,8 @@ impl MultiPodApp { .prepare_companion_send() .map(MultiPodAction::SendCompanion) .unwrap_or(MultiPodAction::None), - KeyCode::Backspace => { - self.input.delete_before(); - MultiPodAction::None - } - KeyCode::Delete => { - self.input.delete_after(); - MultiPodAction::None - } - KeyCode::Left => { - self.input.move_left(); - MultiPodAction::None - } - KeyCode::Right => { - self.input.move_right(); - MultiPodAction::None - } - KeyCode::Home => { - self.input.move_home(); - MultiPodAction::None - } - KeyCode::End => { - self.input.move_end(); - MultiPodAction::None - } - KeyCode::Char(c) if !ctrl => { - self.input.insert_char(c); + _ if composer_edit_action(key).is_some() => { + self.apply_composer_edit_action(composer_edit_action(key).expect("checked above")); MultiPodAction::None } _ => MultiPodAction::None, @@ -2208,7 +2263,8 @@ fn open_disabled_reason(entry: &PodListEntry) -> String { } return match live.status { Some(PodStatus::Running) => { - "Selected Pod is running; press empty Enter to open/attach.".to_string() + "Selected Pod is running; Enter opens/attaches; Right marks action focus." + .to_string() } Some(PodStatus::Paused) => { "Selected Pod is paused; open it explicitly to resume or start a new turn." @@ -2219,7 +2275,8 @@ fn open_disabled_reason(entry: &PodListEntry) -> String { }; } if entry.stored.is_some() { - return "Selected Pod is stopped; press empty Enter to restore/open.".to_string(); + return "Selected Pod is stopped; Enter restores/opens; Right marks action focus." + .to_string(); } entry .actions @@ -2233,7 +2290,7 @@ fn selected_ticket_notice(row: Option<&PanelRow>) -> String { Some(row) if row.is_ticket_action() => { let action = row.next_action.map(NextUserAction::label).unwrap_or("View"); format!( - "Press Enter to dispatch {action} for Ticket '{}' after re-checking current Ticket authority.", + "Enter dispatches {action} for Ticket '{}' after re-checking current Ticket authority; Right marks action focus.", row.title ) } @@ -2468,11 +2525,11 @@ fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { .composer .is_available(ComposerTarget::TicketIntake) { - " Empty Enter dispatches selected Ticket action/open · Ctrl+T target" + " Row focus: Enter dispatches row action · Right action focus · Tab target" } else if app.panel.header.ticket_configured { - " Empty Enter dispatches selected Ticket action/open Pod" + " Row focus: Enter opens/dispatches · Right action focus" } else { - " Pod-centric view · empty Enter open/attach selected Pod" + " Pod-centric view · Row focus: Enter opens · Right action focus" }; let mut spans = vec![ Span::styled( @@ -2803,13 +2860,20 @@ fn draw_separator(frame: &mut Frame<'_>, area: Rect) { } fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { + let focus_label = match app.effective_focus() { + PanelFocus::GlobalComposer => "global composer", + PanelFocus::Row => "selected row", + PanelFocus::ItemAction => "item action", + }; let target = if let Some(row) = app .selected_panel_row() .filter(|row| row.is_ticket_action()) { let action = row.next_action.map(NextUserAction::label).unwrap_or("View"); Line::from(vec![ - Span::styled("composer ", Style::default().fg(Color::DarkGray)), + Span::styled("focus ", Style::default().fg(Color::DarkGray)), + Span::styled(focus_label, Style::default().fg(Color::Cyan)), + Span::styled(" · composer ", Style::default().fg(Color::DarkGray)), Span::styled( app.composer_target().label(), Style::default() @@ -2818,13 +2882,15 @@ fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { ), Span::styled(" · ticket ", Style::default().fg(Color::DarkGray)), Span::styled(row.status.clone(), panel_priority_style(row.priority)), - Span::styled(" · ", Style::default().fg(Color::DarkGray)), + Span::styled(" · action ", Style::default().fg(Color::DarkGray)), Span::styled(action, Style::default().fg(Color::Magenta)), ]) } else if let Some(entry) = app.selected_pod_entry() { let (status, status_style) = row_status_label(entry); Line::from(vec![ - Span::styled("composer ", Style::default().fg(Color::DarkGray)), + Span::styled("focus ", Style::default().fg(Color::DarkGray)), + Span::styled(focus_label, Style::default().fg(Color::Cyan)), + Span::styled(" · composer ", Style::default().fg(Color::DarkGray)), Span::styled( app.composer_target().label(), Style::default() @@ -2836,7 +2902,9 @@ fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { ]) } else { Line::from(vec![ - Span::styled("composer ", Style::default().fg(Color::DarkGray)), + Span::styled("focus ", Style::default().fg(Color::DarkGray)), + Span::styled(focus_label, Style::default().fg(Color::Cyan)), + Span::styled(" · composer ", Style::default().fg(Color::DarkGray)), Span::styled( app.composer_target().label(), Style::default().fg(Color::DarkGray), @@ -2898,9 +2966,9 @@ fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { .composer .is_available(ComposerTarget::TicketIntake) { - "↑/↓ select Empty Enter target/open Ctrl+T target Esc quit" + "↑/↓ row Enter row action/open Right action focus Tab target Esc composer Ctrl+C quit" } else { - "↑/↓ select Empty Enter open non-empty Enter diagnose Esc quit" + "↑/↓ row Enter row action/open Right action focus Esc composer Ctrl+C quit" }; let left_width = area .width @@ -3338,7 +3406,7 @@ mod tests { } #[test] - fn multi_bare_panel_letters_append_to_composer_and_arrows_select() { + fn multi_bare_panel_letters_append_to_composer_and_arrows_select_when_blank() { let mut app = test_app(vec![ live_info("alpha", PodStatus::Idle), live_info("beta", PodStatus::Idle), @@ -3360,13 +3428,21 @@ mod tests { MultiPodAction::None )); assert_eq!(input_text(&app), "jkor"); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + + app.input.clear(); + assert!(matches!( + app.handle_key(key(KeyCode::Down)), + MultiPodAction::None + )); + assert_eq!(input_text(&app), ""); assert_eq!(app.list.selected_entry().unwrap().name, "beta"); assert!(matches!( app.handle_key(key(KeyCode::Up)), MultiPodAction::None )); - assert_eq!(input_text(&app), "jkor"); + assert_eq!(input_text(&app), ""); assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); } @@ -4207,23 +4283,85 @@ mod tests { ); } + #[test] + fn multi_composer_shared_word_motion_and_delete_keys() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("hello world"); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Left, KeyModifiers::CONTROL)), + MultiPodAction::None + )); + assert!(matches!( + app.handle_key(key(KeyCode::Char('!'))), + MultiPodAction::None + )); + assert_eq!(input_text(&app), "hello !world"); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Right, KeyModifiers::CONTROL)), + MultiPodAction::None + )); + assert!(matches!( + app.handle_key(modified_key(KeyCode::Char('w'), KeyModifiers::CONTROL)), + MultiPodAction::None + )); + assert_eq!(input_text(&app), "hello !"); + } + + #[test] + fn multi_esc_clears_panel_focus_without_quitting() { + let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]); + + assert!(app.selected_row.is_some()); + assert!(matches!( + app.handle_key(key(KeyCode::Right)), + MultiPodAction::None + )); + assert_eq!(app.effective_focus(), PanelFocus::ItemAction); + assert!(matches!( + app.handle_key(key(KeyCode::Esc)), + MultiPodAction::None + )); + assert!(app.selected_row.is_none()); + assert_eq!(app.effective_focus(), PanelFocus::GlobalComposer); + assert!(matches!( + app.handle_key(modified_key(KeyCode::Char('c'), KeyModifiers::CONTROL)), + MultiPodAction::Quit + )); + } + #[test] fn multi_composer_target_switch_preserves_typed_text() { let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); app.input.insert_str("draft intake request"); + assert!(matches!(app.composer_target(), ComposerTarget::Companion)); + assert!(matches!( + app.handle_key(key(KeyCode::Tab)), + MultiPodAction::None + )); + + assert!(matches!( + app.composer_target(), + ComposerTarget::TicketIntake + )); + assert_eq!(input_text(&app), "draft intake request"); + } + + #[test] + fn multi_ctrl_t_does_not_switch_composer_target() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("draft intake request"); + assert!(matches!(app.composer_target(), ComposerTarget::Companion)); assert!(matches!( app.handle_key(modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL)), MultiPodAction::None )); - assert!(matches!( - app.composer_target(), - ComposerTarget::TicketIntake - )); + assert!(matches!(app.composer_target(), ComposerTarget::Companion)); assert_eq!(input_text(&app), "draft intake request"); - assert!(app.notice.as_deref().unwrap().contains("Ticket Intake")); } #[test] @@ -4537,6 +4675,7 @@ mod tests { panel, input: InputBuffer::new(), selected_row: None, + focus: PanelFocus::GlobalComposer, composer_target: ComposerTarget::Companion, notice: None, sending: false, diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 2f6f5264..61410aca 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -23,6 +23,7 @@ use tokio::sync::mpsc; use client::{PodClient, PodRuntimeCommand}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; +use crate::composer_keys::{ComposerEditAction, composer_edit_action}; use crate::picker::PickerOutcome; use crate::spawn::{SpawnOutcome, SpawnReady}; use crate::{multi_pod, picker, spawn, ui}; @@ -537,6 +538,26 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent) { } } +fn apply_composer_edit_action(app: &mut App, action: ComposerEditAction) -> Option { + match action { + ComposerEditAction::InsertChar(c) => app.insert_char(c), + ComposerEditAction::InsertNewline => app.insert_newline(), + ComposerEditAction::DeleteBefore => app.delete_char_before(), + ComposerEditAction::DeleteAfter => app.delete_char_after(), + ComposerEditAction::DeleteWordBefore => app.delete_word_before_cursor(), + ComposerEditAction::MoveLeft => app.move_cursor_left(), + ComposerEditAction::MoveRight => app.move_cursor_right(), + ComposerEditAction::MoveWordLeft => app.move_cursor_word_left(), + ComposerEditAction::MoveWordRight => app.move_cursor_word_right(), + ComposerEditAction::MoveStart => app.move_cursor_start(), + ComposerEditAction::MoveHome => app.move_cursor_home(), + ComposerEditAction::MoveEnd => app.move_cursor_end(), + ComposerEditAction::MoveUp => app.move_cursor_up(), + ComposerEditAction::MoveDown => app.move_cursor_down(), + } + app.refresh_completion() +} + fn handle_key(app: &mut App, key: KeyEvent) -> Option { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); @@ -579,25 +600,20 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { KeyCode::Char(c) if c.eq_ignore_ascii_case(&'r') && ctrl => { Some(app.request_rewind_picker()) } - KeyCode::Char('a') if ctrl => { - app.move_cursor_start(); - Some(app.refresh_completion()) - } - KeyCode::Left if ctrl || alt => { - app.move_cursor_word_left(); - Some(app.refresh_completion()) - } - KeyCode::Right if ctrl || alt => { - app.move_cursor_word_right(); - Some(app.refresh_completion()) - } - KeyCode::Backspace if ctrl || alt => { - app.delete_word_before_cursor(); - Some(app.refresh_completion()) - } - KeyCode::Char('w') if ctrl => { - app.delete_word_before_cursor(); - Some(app.refresh_completion()) + _ if composer_edit_action(key).is_some_and(ComposerEditAction::is_modifier_action) => { + if app.is_command_mode() + && matches!( + composer_edit_action(key), + Some(ComposerEditAction::InsertNewline) + ) + { + Some(None) + } else { + Some(apply_composer_edit_action( + app, + composer_edit_action(key).expect("checked above"), + )) + } } KeyCode::Char('u') if ctrl && app.is_command_mode() => { app.clear_command_input(); @@ -746,63 +762,41 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { None } KeyCode::Enter => app.submit_input(), - KeyCode::Backspace => { - app.delete_char_before(); - app.refresh_completion() - } - KeyCode::Delete => { - app.delete_char_after(); - app.refresh_completion() - } - KeyCode::Left => { - app.move_cursor_left(); - app.refresh_completion() - } - KeyCode::Right => { - app.move_cursor_right(); - app.refresh_completion() - } - KeyCode::Up => { - if app.can_browse_input_history_older() && app.browse_input_history_older() { - app.refresh_completion() - } else { - app.move_cursor_up(); - app.refresh_completion() + _ if composer_edit_action(key).is_some() => { + match composer_edit_action(key).expect("checked above") { + ComposerEditAction::MoveUp => { + if app.can_browse_input_history_older() && app.browse_input_history_older() { + app.refresh_completion() + } else { + apply_composer_edit_action(app, ComposerEditAction::MoveUp) + } + } + ComposerEditAction::MoveDown => { + if app.can_browse_input_history_newer() && app.browse_input_history_newer() { + app.refresh_completion() + } else { + apply_composer_edit_action(app, ComposerEditAction::MoveDown) + } + } + ComposerEditAction::InsertChar(':') if !alt && app.input.is_empty() => { + app.enter_command_mode(); + None + } + ComposerEditAction::InsertChar(c) => { + // Whitespace ends an in-flight completion token. Try the + // auto-confirm path first so an exact match (e.g. typed + // `@src/main.rs` matches the only popup entry) becomes a + // chip on the way out. Directories also commit here — + // ending with a space is an explicit "I want this dir" + // signal, not a drill-in. + if c.is_whitespace() { + app.chipify_completion_if_exact_match(); + } + apply_composer_edit_action(app, ComposerEditAction::InsertChar(c)) + } + action => apply_composer_edit_action(app, action), } } - KeyCode::Down => { - if app.can_browse_input_history_newer() && app.browse_input_history_newer() { - app.refresh_completion() - } else { - app.move_cursor_down(); - app.refresh_completion() - } - } - KeyCode::Home => { - app.move_cursor_home(); - app.refresh_completion() - } - KeyCode::End => { - 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 - // `@src/main.rs` matches the only popup entry) becomes a - // chip on the way out. Directories also commit here — - // ending with a space is an explicit "I want this dir" - // signal, not a drill-in. - if c.is_whitespace() { - app.chipify_completion_if_exact_match(); - } - app.insert_char(c); - app.refresh_completion() - } _ => None, } } diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index a9b37852..df2daf2d 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -921,7 +921,7 @@ fn pod_row(entry: &PodListEntry) -> PanelRow { ticket: None, related_pods: Vec::new(), disabled_reason: entry.actions.disabled_reason.clone(), - key_hint: Some("Press o or empty Enter to open/attach this Pod".to_string()), + key_hint: Some("Enter opens/attaches; Right marks action focus".to_string()), } }