From 1ac32df6278a9befd59c9be7711b371d64fa652f Mon Sep 17 00:00:00 2001 From: Reid <61492567+reidliu41@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:52:02 +0800 Subject: [PATCH] feat(tui): dispatch hotbar slots from number keys (#3056) Wire hotbar key dispatch into the TUI event loop. Bare 1-8 now fires the matching hotbar slot only when the composer is empty. Alt+1 through Alt+8 fires the matching slot even when the composer has text. Modal and overlay views keep ownership of those keys, and empty slots remain a safe no-op. --- crates/tui/src/tui/ui.rs | 128 +++++++++++++++++++++++++++------ crates/tui/src/tui/ui/tests.rs | 62 ++++++++++++++++ 2 files changed, 168 insertions(+), 22 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1c0061fd..f5691056 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -82,6 +82,7 @@ use crate::tui::footer_ui::{ friendly_subagent_progress, is_noisy_subagent_progress, one_line_summary, render_footer, }; use crate::tui::format_helpers; +use crate::tui::hotbar::actions::HotbarDispatch; use crate::tui::key_shortcuts; use crate::tui::live_transcript::LiveTranscriptOverlay; use crate::tui::mcp_routing::{add_mcp_message, open_mcp_manager_pager}; @@ -3421,6 +3422,32 @@ async fn run_event_loop( continue; } + if let Some(slot) = hotbar_slot_from_key(app, &key) { + if let Some(dispatch) = dispatch_hotbar_slot(app, config, slot)? { + match dispatch { + HotbarDispatch::Handled => { + app.needs_redraw = true; + } + HotbarDispatch::AppAction(action) => { + if apply_command_result( + terminal, + app, + &mut engine_handle, + &task_manager, + config, + &mut web_config_session, + commands::CommandResult::action(action), + ) + .await? + { + return Ok(()); + } + } + } + } + continue; + } + // File-tree navigation: delegated to key_actions module. if key_actions::handle_file_tree_key(app, &key) { continue; @@ -3571,34 +3598,34 @@ async fn run_event_loop( toggle_live_transcript_overlay(app); continue; } - KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Work); - app.status_message = Some("Sidebar focus: work".to_string()); - } else { - apply_mode_update(app, &engine_handle, AppMode::Plan).await; - } + KeyCode::Char('1') + if key.modifiers.contains(KeyModifiers::ALT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.set_sidebar_focus(SidebarFocus::Work); + app.status_message = Some("Sidebar focus: work".to_string()); continue; } - KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Tasks); - app.status_message = Some("Sidebar focus: tasks".to_string()); - } else { - apply_mode_update(app, &engine_handle, AppMode::Agent).await; - } + KeyCode::Char('2') + if key.modifiers.contains(KeyModifiers::ALT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.set_sidebar_focus(SidebarFocus::Tasks); + app.status_message = Some("Sidebar focus: tasks".to_string()); continue; } - KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Agents); - app.status_message = Some("Sidebar focus: agents".to_string()); - } else { - apply_mode_update(app, &engine_handle, AppMode::Yolo).await; - } + KeyCode::Char('3') + if key.modifiers.contains(KeyModifiers::ALT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.set_sidebar_focus(SidebarFocus::Agents); + app.status_message = Some("Sidebar focus: agents".to_string()); continue; } - KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => { + KeyCode::Char('4') + if key.modifiers.contains(KeyModifiers::ALT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { apply_alt_4_shortcut(app, key.modifiers); continue; } @@ -4482,6 +4509,63 @@ async fn run_event_loop( } } +fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option { + if app.onboarding != OnboardingState::None || !app.view_stack.is_empty() { + return None; + } + + let KeyCode::Char(c) = key.code else { + return None; + }; + if !('1'..='8').contains(&c) { + return None; + } + let slot = c.to_digit(10).and_then(|digit| u8::try_from(digit).ok())?; + + if key.modifiers == KeyModifiers::NONE { + return app.input.is_empty().then_some(slot); + } + + if key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::SUPER) + { + return Some(slot); + } + + None +} + +fn dispatch_hotbar_slot( + app: &mut App, + config: &Config, + slot: u8, +) -> Result> { + let known_action_ids = app + .hotbar_actions + .iter() + .map(|action| action.id()) + .collect::>(); + let bindings = config.resolve_hotbar_bindings(&known_action_ids).bindings; + let Some(action_id) = bindings + .iter() + .find(|binding| binding.slot == slot) + .map(|binding| binding.action.clone()) + else { + return Ok(None); + }; + + let Some(action) = app.hotbar_actions.get(&action_id) else { + app.status_message = Some(format!( + "Hotbar slot {slot} action is not available: {action_id}" + )); + app.needs_redraw = true; + return Ok(Some(HotbarDispatch::Handled)); + }; + + action.dispatch(app).map(Some) +} + fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) { app.set_sidebar_focus(SidebarFocus::Agents); app.status_message = Some("Sidebar focus: agents".to_string()); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 683d8dd3..75b27f1c 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3131,6 +3131,68 @@ fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() { assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents")); } +#[test] +fn hotbar_bare_digit_fires_only_when_composer_empty() { + let mut app = create_test_app(); + app.onboarding = OnboardingState::None; + + let bare_four = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE); + assert_eq!(hotbar_slot_from_key(&app, &bare_four), Some(4)); + + app.input = "draft".to_string(); + assert_eq!(hotbar_slot_from_key(&app, &bare_four), None); + + app.input = " ".to_string(); + assert_eq!(hotbar_slot_from_key(&app, &bare_four), None); +} + +#[test] +fn hotbar_alt_digit_fires_when_composer_has_text() { + let mut app = create_test_app(); + app.onboarding = OnboardingState::None; + app.input = "draft".to_string(); + + let alt_four = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::ALT); + assert_eq!(hotbar_slot_from_key(&app, &alt_four), Some(4)); +} + +#[test] +fn hotbar_digits_are_blocked_while_overlay_is_open() { + let mut app = create_test_app(); + app.onboarding = OnboardingState::None; + app.view_stack.push(HelpView::new()); + + let bare_four = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE); + let alt_four = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::ALT); + + assert_eq!(hotbar_slot_from_key(&app, &bare_four), None); + assert_eq!(hotbar_slot_from_key(&app, &alt_four), None); +} + +#[test] +fn hotbar_dispatches_bound_slot_and_ignores_empty_slot() { + let mut app = create_test_app(); + let config = Config::default(); + app.onboarding = OnboardingState::None; + app.mode = AppMode::Plan; + + let dispatch = dispatch_hotbar_slot(&mut app, &config, 4).expect("hotbar dispatch"); + assert!(matches!( + dispatch, + Some(HotbarDispatch::AppAction(AppAction::ModeChanged( + AppMode::Agent + ))) + )); + assert_eq!(app.mode, AppMode::Agent); + + let mut empty_config = Config::default(); + empty_config.hotbar = Some(Vec::new()); + assert_eq!( + dispatch_hotbar_slot(&mut app, &empty_config, 1).expect("empty slot is ok"), + None + ); +} + #[test] fn alt_0_restores_auto_sidebar_focus() { let mut app = create_test_app();