tui: clarify panel focus and composer keys

This commit is contained in:
Keisuke Hirata 2026-06-09 19:49:26 +09:00
parent d95b3ffff6
commit 20f06b3541
No known key found for this signature in database
5 changed files with 384 additions and 132 deletions

View File

@ -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<ComposerEditAction> {
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'))
);
}
}

View File

@ -3,6 +3,7 @@ mod block;
mod cache;
mod command;
mod composer_history;
mod composer_keys;
mod input;
pub mod keys;
mod markdown;

View File

@ -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<String>
}
}
#[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<PanelRowKey>,
focus: PanelFocus,
composer_target: ComposerTarget,
notice: Option<String>,
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,

View File

@ -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<Method> {
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<Method> {
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<Method> {
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<Method> {
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,
}
}

View File

@ -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()),
}
}