merge: improve panel composer keys

This commit is contained in:
Keisuke Hirata 2026-06-09 20:26:09 +09:00
commit 57ed405890
No known key found for this signature in database
5 changed files with 579 additions and 146 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,43 @@ fn draw_separator(frame: &mut Frame<'_>, area: Rect) {
}
fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
let target = if let Some(row) = app
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",
};
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 +2905,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,15 +2925,16 @@ 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),
),
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) {
@ -2865,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()
@ -2878,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() {
@ -2892,16 +3073,34 @@ 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)
{
"↑/↓ 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"
}
}
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)
@ -3201,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"));
@ -3223,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"));
}
@ -3338,7 +3538,50 @@ mod tests {
}
#[test]
fn multi_bare_panel_letters_append_to_composer_and_arrows_select() {
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![
live_info("alpha", PodStatus::Idle),
live_info("beta", PodStatus::Idle),
@ -3360,13 +3603,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");
}
@ -3661,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
@ -3690,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);
@ -4207,23 +4464,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 +4856,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()),
}
}