From 3de1d35c379e13fb21dcc8badca33ef92bb3bf63 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 12 Jun 2026 02:19:15 -0700 Subject: [PATCH] feat(tui): dispatch hotbar slots from number keys Harvests PR #3056 by @reidliu41, keeping overlays in control of number keys, reclaiming Alt+1 through Alt+8 for hotbar dispatch, and updating the help/footer shortcut copy. Co-authored-by: reidliu41 <61492567+reidliu41@users.noreply.github.com> --- CHANGELOG.md | 4 + crates/tui/CHANGELOG.md | 4 + crates/tui/src/config.rs | 6 +- crates/tui/src/localization.rs | 12 +-- crates/tui/src/tui/footer_ui.rs | 2 +- crates/tui/src/tui/hotbar/actions.rs | 1 + crates/tui/src/tui/hotbar/mod.rs | 5 +- crates/tui/src/tui/keybindings.rs | 4 +- crates/tui/src/tui/ui.rs | 129 ++++++++++++++++++++++----- crates/tui/src/tui/ui/tests.rs | 83 ++++++++++++++--- 10 files changed, 199 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adf38e0..cee8a633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Localized config editor labels (#2919).** The config editor modal now localizes edit labels, default/unavailable placeholders, and effective currency hints. Thanks @gordonlu for the PR. +- **Hotbar number-key dispatch (#3056).** Bare `1`-`8` now trigger bound + hotbar slots only when the composer is empty, while `Alt+1`-`Alt+8` trigger + slots regardless of composer text and overlays keep key ownership. Thanks + @reidliu41 for the PR. ### Fixed diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 5ca93b72..caf0e766 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -42,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Localized config editor labels (#2919).** The config editor modal now localizes edit labels, default/unavailable placeholders, and effective currency hints. Thanks @gordonlu for the PR. +- **Hotbar number-key dispatch (#3056).** Bare `1`-`8` now trigger bound + hotbar slots only when the composer is empty, while `Alt+1`-`Alt+8` trigger + slots regardless of composer text and overlays keep key ownership. Thanks + @reidliu41 for the PR. ### Fixed diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 08ddaec7..2e439734 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1744,8 +1744,8 @@ pub struct Config { #[serde(default)] pub auto: Option, - /// Optional 1-8 hotbar slot bindings (#2064). When absent, future hotbar - /// UI slices use the built-in defaults from `codewhale_config`. + /// Optional 1-8 hotbar slot bindings (#2064). When absent, hotbar UI and + /// dispatch layers use the built-in defaults from `codewhale_config`. #[serde(default)] pub hotbar: Option>, @@ -3135,7 +3135,7 @@ impl Config { self.update.clone().unwrap_or_default() } - /// Resolve durable hotbar bindings for future render/dispatch layers. + /// Resolve durable hotbar bindings for render/dispatch layers. #[must_use] pub fn resolve_hotbar_bindings( &self, diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 98516f52..e9ffbab7 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1517,7 +1517,7 @@ fn english(id: MessageId) -> &'static str { MessageId::KbCompleteCycleModes => { "Complete /command, queue running-turn follow-up, cycle modes; Shift+Tab cycles reasoning effort" } - MessageId::KbJumpPlanAgentYolo => "Jump directly to Plan / Agent / YOLO mode", + MessageId::KbJumpPlanAgentYolo => "Trigger hotbar slots", MessageId::KbAltJumpPlanAgentYolo => "Alternative jump to Plan / Agent / YOLO mode", MessageId::KbFocusSidebar => { "Focus Work / Tasks / Agents / Context / Auto sidebar; Ctrl+Alt+0 hides it" @@ -2107,7 +2107,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::KbCompleteCycleModes => { "Hoàn thành /command, xếp hàng theo dõi lượt đang chạy, chuyển đổi chế độ; Shift+Tab để chuyển đổi mức độ suy luận" } - MessageId::KbJumpPlanAgentYolo => "Nhảy trực tiếp sang chế độ Plan / Agent / YOLO", + MessageId::KbJumpPlanAgentYolo => "Kích hoạt các ô hotbar", MessageId::KbAltJumpPlanAgentYolo => { "Phím tắt thay thế để nhảy sang chế độ Plan / Agent / YOLO" } @@ -2828,7 +2828,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::KbCompleteCycleModes => { "/command を補完、実行中ターンのフォローアップをキュー、モードを切り替え;Shift+Tab で推論強度を切り替え" } - MessageId::KbJumpPlanAgentYolo => "Plan / Agent / YOLO モードに直接ジャンプ", + MessageId::KbJumpPlanAgentYolo => "ホットバースロットを起動", MessageId::KbAltJumpPlanAgentYolo => "Plan / Agent / YOLO モードへの代替ジャンプ", MessageId::KbFocusSidebar => { "Work / Tasks / Agents / Context / Auto / Hidden サイドバーにフォーカス" @@ -3348,7 +3348,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::KbCompleteCycleModes => { "补全 /command、排队运行轮次跟进、切换模式;Shift+Tab 切换推理强度" } - MessageId::KbJumpPlanAgentYolo => "直接跳转到 Plan / Agent / YOLO 模式", + MessageId::KbJumpPlanAgentYolo => "触发快捷栏槽位", MessageId::KbAltJumpPlanAgentYolo => "替代快捷键跳转到 Plan / Agent / YOLO 模式", MessageId::KbFocusSidebar => "聚焦 Work / 任务 / 代理 / Context / 自动 / 隐藏侧边栏", MessageId::KbTogglePlanAgent => "在 Plan 和 Agent 模式之间切换", @@ -3896,7 +3896,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::KbCompleteCycleModes => { "Completar /command, enfileirar follow-up, ciclar modos; Shift+Tab cicla esforço de raciocínio" } - MessageId::KbJumpPlanAgentYolo => "Pular direto para modo Plan / Agent / YOLO", + MessageId::KbJumpPlanAgentYolo => "Acionar slots da hotbar", MessageId::KbAltJumpPlanAgentYolo => "Salto alternativo para modo Plan / Agent / YOLO", MessageId::KbFocusSidebar => { "Focar barra lateral Work / Tasks / Agents / Context / Auto / Ocultar" @@ -4492,7 +4492,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::KbCompleteCycleModes => { "Completar /command, encolar follow-up, ciclar modos; Shift+Tab cicla esfuerzo de razonamiento" } - MessageId::KbJumpPlanAgentYolo => "Saltar directo a modo Plan / Agent / YOLO", + MessageId::KbJumpPlanAgentYolo => "Activar ranuras de la hotbar", MessageId::KbAltJumpPlanAgentYolo => "Salto alternativo a modo Plan / Agent / YOLO", MessageId::KbFocusSidebar => { "Enfocar barra lateral Work / Tasks / Agents / Context / Auto / Ocultar" diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 7ed02585..79d5d85d 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -335,7 +335,7 @@ pub(crate) fn active_subagent_status_label(app: &App) -> Option { if let Some(elapsed) = elapsed { parts.push(elapsed); } - parts.push("Alt+4".to_string()); + parts.push("Ctrl+Alt+4".to_string()); Some(parts.join(" \u{00B7} ")) } diff --git a/crates/tui/src/tui/hotbar/actions.rs b/crates/tui/src/tui/hotbar/actions.rs index 2301a467..0a76c493 100644 --- a/crates/tui/src/tui/hotbar/actions.rs +++ b/crates/tui/src/tui/hotbar/actions.rs @@ -416,6 +416,7 @@ mod tests { let registry = HotbarActionRegistry::with_builtins(); let reasoning = registry.get("reasoning.cycle").expect("reasoning action"); let mut app = test_app(); + app.api_provider = ApiProvider::Deepseek; app.reasoning_effort = ReasoningEffort::Off; assert!(!reasoning.is_active(&app)); diff --git a/crates/tui/src/tui/hotbar/mod.rs b/crates/tui/src/tui/hotbar/mod.rs index 41c983b0..540bf995 100644 --- a/crates/tui/src/tui/hotbar/mod.rs +++ b/crates/tui/src/tui/hotbar/mod.rs @@ -1,8 +1,7 @@ //! Hotbar action registry foundation. //! -//! Later hotbar slices add config, sidebar rendering, and key dispatch. This -//! module only defines the action surface and the built-in actions that those -//! layers will consume. +//! Config, sidebar rendering, and key dispatch consume this action surface and +//! the built-in actions defined here. pub mod actions; diff --git a/crates/tui/src/tui/keybindings.rs b/crates/tui/src/tui/keybindings.rs index 90ebc851..96b12fa2 100644 --- a/crates/tui/src/tui/keybindings.rs +++ b/crates/tui/src/tui/keybindings.rs @@ -18,7 +18,7 @@ //! Entries are grouped by `KeybindingSection`. The `chord` field is a //! human-readable string formatted exactly the way it should appear in help — //! we avoid storing `KeyBinding` values directly because many shortcuts are -//! pairs (`↑/↓`) or families (`Alt+1/2/3`) that don't map cleanly to a single +//! pairs (`↑/↓`) or families (`1-8`) that don't map cleanly to a single //! chord. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -237,7 +237,7 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ section: KeybindingSection::Modes, }, KeybindingEntry { - chord: "Alt+1 / Alt+2 / Alt+3", + chord: "1-8 / Alt+1-8", description_id: crate::localization::MessageId::KbJumpPlanAgentYolo, section: KeybindingSection::Modes, }, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 15b06eeb..b9fab27a 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}; @@ -3431,6 +3432,33 @@ 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(()); + } + app.needs_redraw = true; + } + } + } + continue; + } + // File-tree navigation: delegated to key_actions module. if key_actions::handle_file_tree_key(app, &key) { continue; @@ -3581,34 +3609,34 @@ async fn run_event_loop( toggle_live_transcript_overlay(app); continue; } - KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => { - if key_shortcuts::has_control_like_modifier(key.modifiers) { - 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_shortcuts::has_control_like_modifier(key.modifiers) => + { + 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_shortcuts::has_control_like_modifier(key.modifiers) { - 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_shortcuts::has_control_like_modifier(key.modifiers) => + { + 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_shortcuts::has_control_like_modifier(key.modifiers) { - 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_shortcuts::has_control_like_modifier(key.modifiers) => + { + 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_shortcuts::has_control_like_modifier(key.modifiers) => + { apply_alt_4_shortcut(app, key.modifiers); continue; } @@ -4492,6 +4520,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 39f690b2..68a001a0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -20,7 +20,8 @@ use crate::tui::footer_ui::{ use crate::tui::history::{ ExecCell, ExecSource, GenericToolCell, HistoryCell, SubAgentCell, ToolCell, ToolStatus, }; -use crate::tui::views::{ModalView, ViewAction}; +use crate::tui::hotbar::actions::HotbarDispatch; +use crate::tui::views::{HelpView, ModalView, ViewAction}; use crate::working_set::Workspace; use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::text::Span; @@ -3267,19 +3268,6 @@ fn spans_text(spans: &[Span<'_>]) -> String { .collect::() } -#[test] -fn alt_4_focuses_agents_sidebar_without_switching_modes() { - let mut app = create_test_app(); - app.mode = AppMode::Agent; - app.sidebar_focus = SidebarFocus::Auto; - - apply_alt_4_shortcut(&mut app, KeyModifiers::ALT); - - assert_eq!(app.mode, AppMode::Agent); - assert_eq!(app.sidebar_focus, SidebarFocus::Agents); - assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents")); -} - #[test] fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() { let mut app = create_test_app(); @@ -3293,6 +3281,73 @@ 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; + app.needs_redraw = false; + + 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); + assert!( + app.needs_redraw, + "mode-changing hotbar actions should leave the app ready to redraw" + ); + + 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();