tui: clarify panel focus and composer keys
This commit is contained in:
parent
d95b3ffff6
commit
20f06b3541
118
crates/tui/src/composer_keys.rs
Normal file
118
crates/tui/src/composer_keys.rs
Normal 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'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ mod block;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod command;
|
mod command;
|
||||||
mod composer_history;
|
mod composer_history;
|
||||||
|
mod composer_keys;
|
||||||
mod input;
|
mod input;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowSt
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
||||||
use crate::input::InputBuffer;
|
use crate::input::InputBuffer;
|
||||||
use crate::pod_list::{
|
use crate::pod_list::{
|
||||||
PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, read_reachable_live_pod_infos,
|
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) struct MultiPodApp {
|
||||||
pub(crate) list: PodList,
|
pub(crate) list: PodList,
|
||||||
pub(crate) panel: WorkspacePanelViewModel,
|
pub(crate) panel: WorkspacePanelViewModel,
|
||||||
pub(crate) input: InputBuffer,
|
pub(crate) input: InputBuffer,
|
||||||
selected_row: Option<PanelRowKey>,
|
selected_row: Option<PanelRowKey>,
|
||||||
|
focus: PanelFocus,
|
||||||
composer_target: ComposerTarget,
|
composer_target: ComposerTarget,
|
||||||
notice: Option<String>,
|
notice: Option<String>,
|
||||||
sending: bool,
|
sending: bool,
|
||||||
|
|
@ -548,6 +557,7 @@ impl MultiPodApp {
|
||||||
panel,
|
panel,
|
||||||
input: InputBuffer::new(),
|
input: InputBuffer::new(),
|
||||||
selected_row: None,
|
selected_row: None,
|
||||||
|
focus: PanelFocus::GlobalComposer,
|
||||||
composer_target: ComposerTarget::Companion,
|
composer_target: ComposerTarget::Companion,
|
||||||
notice: None,
|
notice: None,
|
||||||
sending: false,
|
sending: false,
|
||||||
|
|
@ -707,7 +717,7 @@ impl MultiPodApp {
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| row.key_hint.clone())
|
.or_else(|| row.key_hint.clone())
|
||||||
.unwrap_or_else(|| {
|
.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()
|
.to_string()
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -801,6 +811,37 @@ impl MultiPodApp {
|
||||||
self.list.selected_name = Some(name.clone());
|
self.list.selected_name = Some(name.clone());
|
||||||
}
|
}
|
||||||
self.selected_row = Some(key);
|
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) {
|
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 {
|
fn handle_key(&mut self, key: KeyEvent) -> MultiPodAction {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('d') if ctrl => MultiPodAction::Quit,
|
KeyCode::Char('d') if ctrl => MultiPodAction::Quit,
|
||||||
KeyCode::Char('c') if ctrl => MultiPodAction::Quit,
|
KeyCode::Char('c') if ctrl => MultiPodAction::Quit,
|
||||||
KeyCode::Esc => MultiPodAction::Quit,
|
KeyCode::Esc => {
|
||||||
KeyCode::Up => {
|
self.clear_panel_focus();
|
||||||
self.select_prev();
|
self.notice = Some("Focus: global composer target; Ctrl+C quits.".to_string());
|
||||||
MultiPodAction::None
|
MultiPodAction::None
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Tab => {
|
||||||
self.select_next();
|
// Completion owns Tab before panel target switching when a
|
||||||
MultiPodAction::None
|
// completion popup exists. The workspace panel currently has
|
||||||
}
|
// no completion source, so this is the target switch path.
|
||||||
KeyCode::Char('t') if ctrl => {
|
self.clear_panel_focus();
|
||||||
self.cycle_composer_target();
|
self.cycle_composer_target();
|
||||||
MultiPodAction::None
|
MultiPodAction::None
|
||||||
}
|
}
|
||||||
KeyCode::Enter if alt => {
|
KeyCode::Up if self.composer_is_blank() => {
|
||||||
self.input.insert_newline();
|
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
|
MultiPodAction::None
|
||||||
}
|
}
|
||||||
KeyCode::Enter
|
KeyCode::Enter
|
||||||
|
|
@ -1262,32 +1341,8 @@ impl MultiPodApp {
|
||||||
.prepare_companion_send()
|
.prepare_companion_send()
|
||||||
.map(MultiPodAction::SendCompanion)
|
.map(MultiPodAction::SendCompanion)
|
||||||
.unwrap_or(MultiPodAction::None),
|
.unwrap_or(MultiPodAction::None),
|
||||||
KeyCode::Backspace => {
|
_ if composer_edit_action(key).is_some() => {
|
||||||
self.input.delete_before();
|
self.apply_composer_edit_action(composer_edit_action(key).expect("checked above"));
|
||||||
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);
|
|
||||||
MultiPodAction::None
|
MultiPodAction::None
|
||||||
}
|
}
|
||||||
_ => MultiPodAction::None,
|
_ => MultiPodAction::None,
|
||||||
|
|
@ -2208,7 +2263,8 @@ fn open_disabled_reason(entry: &PodListEntry) -> String {
|
||||||
}
|
}
|
||||||
return match live.status {
|
return match live.status {
|
||||||
Some(PodStatus::Running) => {
|
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) => {
|
Some(PodStatus::Paused) => {
|
||||||
"Selected Pod is paused; open it explicitly to resume or start a new turn."
|
"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() {
|
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
|
entry
|
||||||
.actions
|
.actions
|
||||||
|
|
@ -2233,7 +2290,7 @@ fn selected_ticket_notice(row: Option<&PanelRow>) -> String {
|
||||||
Some(row) if row.is_ticket_action() => {
|
Some(row) if row.is_ticket_action() => {
|
||||||
let action = row.next_action.map(NextUserAction::label).unwrap_or("View");
|
let action = row.next_action.map(NextUserAction::label).unwrap_or("View");
|
||||||
format!(
|
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
|
row.title
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -2468,11 +2525,11 @@ fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
.composer
|
.composer
|
||||||
.is_available(ComposerTarget::TicketIntake)
|
.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 {
|
} else if app.panel.header.ticket_configured {
|
||||||
" Empty Enter dispatches selected Ticket action/open Pod"
|
" Row focus: Enter opens/dispatches · Right action focus"
|
||||||
} else {
|
} else {
|
||||||
" Pod-centric view · empty Enter open/attach selected Pod"
|
" Pod-centric view · Row focus: Enter opens · Right action focus"
|
||||||
};
|
};
|
||||||
let mut spans = vec![
|
let mut spans = vec![
|
||||||
Span::styled(
|
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) {
|
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
|
let target = if let Some(row) = app
|
||||||
.selected_panel_row()
|
.selected_panel_row()
|
||||||
.filter(|row| row.is_ticket_action())
|
.filter(|row| row.is_ticket_action())
|
||||||
{
|
{
|
||||||
let action = row.next_action.map(NextUserAction::label).unwrap_or("View");
|
let action = row.next_action.map(NextUserAction::label).unwrap_or("View");
|
||||||
Line::from(vec![
|
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(
|
Span::styled(
|
||||||
app.composer_target().label(),
|
app.composer_target().label(),
|
||||||
Style::default()
|
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(" · ticket ", Style::default().fg(Color::DarkGray)),
|
||||||
Span::styled(row.status.clone(), panel_priority_style(row.priority)),
|
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)),
|
Span::styled(action, Style::default().fg(Color::Magenta)),
|
||||||
])
|
])
|
||||||
} else if let Some(entry) = app.selected_pod_entry() {
|
} else if let Some(entry) = app.selected_pod_entry() {
|
||||||
let (status, status_style) = row_status_label(entry);
|
let (status, status_style) = row_status_label(entry);
|
||||||
Line::from(vec![
|
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(
|
Span::styled(
|
||||||
app.composer_target().label(),
|
app.composer_target().label(),
|
||||||
Style::default()
|
Style::default()
|
||||||
|
|
@ -2836,7 +2902,9 @@ fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
Line::from(vec![
|
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(
|
Span::styled(
|
||||||
app.composer_target().label(),
|
app.composer_target().label(),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
|
|
@ -2898,9 +2966,9 @@ fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
.composer
|
.composer
|
||||||
.is_available(ComposerTarget::TicketIntake)
|
.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 {
|
} 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
|
let left_width = area
|
||||||
.width
|
.width
|
||||||
|
|
@ -3338,7 +3406,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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![
|
let mut app = test_app(vec![
|
||||||
live_info("alpha", PodStatus::Idle),
|
live_info("alpha", PodStatus::Idle),
|
||||||
live_info("beta", PodStatus::Idle),
|
live_info("beta", PodStatus::Idle),
|
||||||
|
|
@ -3360,13 +3428,21 @@ mod tests {
|
||||||
MultiPodAction::None
|
MultiPodAction::None
|
||||||
));
|
));
|
||||||
assert_eq!(input_text(&app), "jkor");
|
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_eq!(app.list.selected_entry().unwrap().name, "beta");
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.handle_key(key(KeyCode::Up)),
|
app.handle_key(key(KeyCode::Up)),
|
||||||
MultiPodAction::None
|
MultiPodAction::None
|
||||||
));
|
));
|
||||||
assert_eq!(input_text(&app), "jkor");
|
assert_eq!(input_text(&app), "");
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
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]
|
#[test]
|
||||||
fn multi_composer_target_switch_preserves_typed_text() {
|
fn multi_composer_target_switch_preserves_typed_text() {
|
||||||
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
app.input.insert_str("draft intake request");
|
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.composer_target(), ComposerTarget::Companion));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.handle_key(modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL)),
|
app.handle_key(modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL)),
|
||||||
MultiPodAction::None
|
MultiPodAction::None
|
||||||
));
|
));
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(app.composer_target(), ComposerTarget::Companion));
|
||||||
app.composer_target(),
|
|
||||||
ComposerTarget::TicketIntake
|
|
||||||
));
|
|
||||||
assert_eq!(input_text(&app), "draft intake request");
|
assert_eq!(input_text(&app), "draft intake request");
|
||||||
assert!(app.notice.as_deref().unwrap().contains("Ticket Intake"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -4537,6 +4675,7 @@ mod tests {
|
||||||
panel,
|
panel,
|
||||||
input: InputBuffer::new(),
|
input: InputBuffer::new(),
|
||||||
selected_row: None,
|
selected_row: None,
|
||||||
|
focus: PanelFocus::GlobalComposer,
|
||||||
composer_target: ComposerTarget::Companion,
|
composer_target: ComposerTarget::Companion,
|
||||||
notice: None,
|
notice: None,
|
||||||
sending: false,
|
sending: false,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ use tokio::sync::mpsc;
|
||||||
use client::{PodClient, PodRuntimeCommand};
|
use client::{PodClient, PodRuntimeCommand};
|
||||||
|
|
||||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
|
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
||||||
use crate::picker::PickerOutcome;
|
use crate::picker::PickerOutcome;
|
||||||
use crate::spawn::{SpawnOutcome, SpawnReady};
|
use crate::spawn::{SpawnOutcome, SpawnReady};
|
||||||
use crate::{multi_pod, picker, spawn, ui};
|
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> {
|
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
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 => {
|
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'r') && ctrl => {
|
||||||
Some(app.request_rewind_picker())
|
Some(app.request_rewind_picker())
|
||||||
}
|
}
|
||||||
KeyCode::Char('a') if ctrl => {
|
_ if composer_edit_action(key).is_some_and(ComposerEditAction::is_modifier_action) => {
|
||||||
app.move_cursor_start();
|
if app.is_command_mode()
|
||||||
Some(app.refresh_completion())
|
&& matches!(
|
||||||
}
|
composer_edit_action(key),
|
||||||
KeyCode::Left if ctrl || alt => {
|
Some(ComposerEditAction::InsertNewline)
|
||||||
app.move_cursor_word_left();
|
)
|
||||||
Some(app.refresh_completion())
|
{
|
||||||
}
|
Some(None)
|
||||||
KeyCode::Right if ctrl || alt => {
|
} else {
|
||||||
app.move_cursor_word_right();
|
Some(apply_composer_edit_action(
|
||||||
Some(app.refresh_completion())
|
app,
|
||||||
}
|
composer_edit_action(key).expect("checked above"),
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('u') if ctrl && app.is_command_mode() => {
|
KeyCode::Char('u') if ctrl && app.is_command_mode() => {
|
||||||
app.clear_command_input();
|
app.clear_command_input();
|
||||||
|
|
@ -746,63 +762,41 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
KeyCode::Enter => app.submit_input(),
|
KeyCode::Enter => app.submit_input(),
|
||||||
KeyCode::Backspace => {
|
_ if composer_edit_action(key).is_some() => {
|
||||||
app.delete_char_before();
|
match composer_edit_action(key).expect("checked above") {
|
||||||
app.refresh_completion()
|
ComposerEditAction::MoveUp => {
|
||||||
}
|
if app.can_browse_input_history_older() && app.browse_input_history_older() {
|
||||||
KeyCode::Delete => {
|
app.refresh_completion()
|
||||||
app.delete_char_after();
|
} else {
|
||||||
app.refresh_completion()
|
apply_composer_edit_action(app, ComposerEditAction::MoveUp)
|
||||||
}
|
}
|
||||||
KeyCode::Left => {
|
}
|
||||||
app.move_cursor_left();
|
ComposerEditAction::MoveDown => {
|
||||||
app.refresh_completion()
|
if app.can_browse_input_history_newer() && app.browse_input_history_newer() {
|
||||||
}
|
app.refresh_completion()
|
||||||
KeyCode::Right => {
|
} else {
|
||||||
app.move_cursor_right();
|
apply_composer_edit_action(app, ComposerEditAction::MoveDown)
|
||||||
app.refresh_completion()
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
ComposerEditAction::InsertChar(':') if !alt && app.input.is_empty() => {
|
||||||
if app.can_browse_input_history_older() && app.browse_input_history_older() {
|
app.enter_command_mode();
|
||||||
app.refresh_completion()
|
None
|
||||||
} else {
|
}
|
||||||
app.move_cursor_up();
|
ComposerEditAction::InsertChar(c) => {
|
||||||
app.refresh_completion()
|
// 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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -921,7 +921,7 @@ fn pod_row(entry: &PodListEntry) -> PanelRow {
|
||||||
ticket: None,
|
ticket: None,
|
||||||
related_pods: Vec::new(),
|
related_pods: Vec::new(),
|
||||||
disabled_reason: entry.actions.disabled_reason.clone(),
|
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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user