From 20f06b35411d6954164e2be570d90ebd9bd6b6cc Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 9 Jun 2026 19:49:26 +0900 Subject: [PATCH 1/2] tui: clarify panel focus and composer keys --- crates/tui/src/composer_keys.rs | 118 ++++++++++++++ crates/tui/src/lib.rs | 1 + crates/tui/src/multi_pod.rs | 253 +++++++++++++++++++++++------- crates/tui/src/single_pod.rs | 142 ++++++++--------- crates/tui/src/workspace_panel.rs | 2 +- 5 files changed, 384 insertions(+), 132 deletions(-) create mode 100644 crates/tui/src/composer_keys.rs 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()), } } From 573b02fbfc258d9cdce01955a9e11fd2998fbcfd Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 9 Jun 2026 20:18:47 +0900 Subject: [PATCH 2/2] tui: clarify panel composer enter hints --- crates/tui/src/multi_pod.rs | 209 +++++++++++++++++++++++++++++++++--- 1 file changed, 195 insertions(+), 14 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index c1f16630..121c9703 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -2860,12 +2860,35 @@ fn draw_separator(frame: &mut Frame<'_>, area: Rect) { } fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { + frame.render_widget(Paragraph::new(target_status_line(app)), area); +} + +fn target_status_line(app: &MultiPodApp) -> Line<'static> { + if !app.composer_is_blank() { + return Line::from(vec![ + Span::styled("focus ", Style::default().fg(Color::DarkGray)), + Span::styled("global composer", Style::default().fg(Color::Cyan)), + Span::styled(" · composer ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.composer_target().label(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" · Enter ", Style::default().fg(Color::DarkGray)), + Span::styled( + composer_enter_status_text(app), + Style::default().fg(Color::Green), + ), + ]); + } + 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 + if let Some(row) = app .selected_panel_row() .filter(|row| row.is_ticket_action()) { @@ -2911,8 +2934,7 @@ fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { ), Span::styled(" · no selection", Style::default().fg(Color::DarkGray)), ]) - }; - frame.render_widget(Paragraph::new(target), area); + } } fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { @@ -2933,8 +2955,97 @@ fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: R } } -fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - let left = if app.sending && app.composer_target() == ComposerTarget::TicketIntake { +fn composer_enter_status_text(app: &MultiPodApp) -> String { + match app.composer_target() { + ComposerTarget::Companion => companion_enter_status_text(app), + ComposerTarget::TicketIntake => "launch Intake with composer text".to_string(), + } +} + +fn composer_enter_actionbar_text(app: &MultiPodApp) -> String { + match app.composer_target() { + ComposerTarget::Companion => companion_enter_actionbar_text(app), + ComposerTarget::TicketIntake => { + "Ticket Intake target: Enter launches Intake with composer text".to_string() + } + } +} + +fn companion_enter_status_text(app: &MultiPodApp) -> String { + match companion_send_availability(app) { + CompanionSendAvailability::Ready => "send composer text to workspace Companion".to_string(), + CompanionSendAvailability::Unavailable(reason) => format!("keep draft; {reason}"), + } +} + +fn companion_enter_actionbar_text(app: &MultiPodApp) -> String { + match companion_send_availability(app) { + CompanionSendAvailability::Ready => { + "Companion target: Enter sends composer text to workspace Companion".to_string() + } + CompanionSendAvailability::Unavailable(reason) => { + format!("Companion target: Enter keeps draft; {reason}") + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CompanionSendAvailability { + Ready, + Unavailable(String), +} + +fn companion_send_availability(app: &MultiPodApp) -> CompanionSendAvailability { + let Some(companion) = app.panel.header.companion.as_ref() else { + return CompanionSendAvailability::Unavailable( + "workspace Companion is unavailable".to_string(), + ); + }; + if matches!( + companion.status, + CompanionPanelStatus::Unavailable + | CompanionPanelStatus::Missing + | CompanionPanelStatus::Stopped + ) { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion is {}", + companion.status.label() + )); + } + let Some(entry) = app + .list + .entries + .iter() + .find(|entry| entry.name == companion.pod_name) + else { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion `{}` is not in the Pod list", + companion.pod_name + )); + }; + let Some(live) = entry.live.as_ref() else { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion `{}` is stopped", + companion.pod_name + )); + }; + if !live.reachable { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion `{}` is unreachable", + companion.pod_name + )); + } + if live.status == Some(PodStatus::Running) { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion `{}` is running", + companion.pod_name + )); + } + CompanionSendAvailability::Ready +} + +fn actionbar_left_text(app: &MultiPodApp) -> String { + if app.sending && app.composer_target() == ComposerTarget::TicketIntake { "launching Ticket Intake…".to_string() } else if app.sending { "working…".to_string() @@ -2946,6 +3057,8 @@ fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { Some(notice) => format!("{notice} Refreshing workspace…"), None => "Refreshing workspace…".to_string(), } + } else if !app.composer_is_blank() { + composer_enter_actionbar_text(app) } else if let Some(notice) = app.notice.as_deref() { notice.to_string() } else if let Some(reason) = app.selected_open_disabled_reason() { @@ -2960,8 +3073,21 @@ fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { "Ticket Intake target: Enter launches Intake with composer text".to_string() } } - }; - let right = if app + } +} + +fn actionbar_right_text(app: &MultiPodApp) -> &'static str { + if !app.composer_is_blank() { + if app + .panel + .composer + .is_available(ComposerTarget::TicketIntake) + { + "↑/↓ row Enter composer target Tab target Esc composer Ctrl+C quit" + } else { + "↑/↓ row Enter composer target Esc composer Ctrl+C quit" + } + } else if app .panel .composer .is_available(ComposerTarget::TicketIntake) @@ -2969,7 +3095,12 @@ fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { "↑/↓ row Enter row action/open Right action focus Tab target Esc composer Ctrl+C quit" } else { "↑/↓ row Enter row action/open Right action focus Esc composer Ctrl+C quit" - }; + } +} + +fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { + let left = actionbar_left_text(app); + let right = actionbar_right_text(app); let left_width = area .width .saturating_sub(right.width() as u16) @@ -3269,8 +3400,9 @@ mod tests { let message = orchestrator_queue_notification_message(ticket); - assert!(message.contains("Ticket `route-ticket` (`20260606-000000-route-ticket`)")); - assert!(message.contains("title `Route queued Ticket`")); + assert!( + message.contains("Ticket `20260606-000000-route-ticket`, title `Route queued Ticket`") + ); assert!(message.contains("human authorized Orchestrator routing")); assert!(message.contains("not an unattended scheduler")); assert!(message.contains("Read the Ticket")); @@ -3291,7 +3423,7 @@ mod tests { assert!(message.contains("merge-ready dossier")); assert!(message.contains("without merge/close/final approval")); assert!(message.contains("If blocked, record a concise reason")); - assert!(message.contains("leave the Ticket queued or explicitly defer")); + assert!(message.contains("leave the Ticket queued or return it to planning")); assert!(!message.contains("Do not start implementation directly")); } @@ -3405,6 +3537,49 @@ mod tests { ); } + #[test] + fn selected_ticket_row_with_non_empty_composer_shows_composer_enter_behavior() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.header.companion = Some(CompanionPanelState::new( + "yoi", + CompanionPanelStatus::Live, + None, + )); + panel.rows.push(panel_test_ticket_row( + "queue-me", + "Queue Me", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("yoi", PodStatus::Idle)], + None, + 10, + ); + let mut app = app_with_panel(list, panel); + app.input.insert_str("draft to companion"); + + assert_eq!( + app.selected_ticket_action(), + Some(NextUserAction::Queue), + "selected row remains a Ticket action row" + ); + let actionbar_left = actionbar_left_text(&app); + let actionbar_right = actionbar_right_text(&app); + let target_status = plain_line(&target_status_line(&app)); + + assert!(actionbar_left.contains("Companion target: Enter sends composer text")); + assert!(actionbar_right.contains("Enter composer target")); + assert!(!actionbar_left.contains("Queue")); + assert!(!actionbar_right.contains("row action/open")); + assert!(target_status.contains("focus global composer")); + assert!(target_status.contains("Enter send composer text to workspace Companion")); + assert!(!target_status.contains("action Queue")); + } + #[test] fn multi_bare_panel_letters_append_to_composer_and_arrows_select_when_blank() { let mut app = test_app(vec![ @@ -3737,11 +3912,16 @@ mod tests { assert!(!review_line.starts_with("▶ Workspace panel composer targets")); assert_eq!(display_column(&review_line, "inprogress"), state_start); assert_eq!(display_column(&ready_line, "ready"), state_start); + let review_id = review_row.ticket.as_ref().unwrap().id.as_str(); + let ready_id = ready_row.ticket.as_ref().unwrap().id.as_str(); assert_eq!( - display_column(&review_line, "workspace-panel-composer-targets"), + display_column( + &review_line, + &truncate_with_ellipsis(review_id, TICKET_ID_COLUMN_WIDTH) + ), id_start ); - assert_eq!(display_column(&ready_line, "ticket-id"), id_start); + assert_eq!(display_column(&ready_line, ready_id), id_start); assert_eq!( display_column(&review_line, "Workspace panel composer targets"), title_start @@ -3766,8 +3946,9 @@ mod tests { let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1 + TICKET_ID_COLUMN_WIDTH + 1; assert_eq!(line.width(), 112); + let row_id = row.ticket.as_ref().unwrap().id.as_str(); assert_eq!( - display_column(&line, "ticket-id"), + display_column(&line, row_id), title_start - TICKET_ID_COLUMN_WIDTH - 1 ); assert_eq!(display_column(&line, "Very long Ticket"), title_start);