From b58b0a8a5d6bcae324015280b94fbcc4f87bf29d Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 29 Jun 2026 12:38:24 -0700 Subject: [PATCH] feat(tui): hotbar Alt+1-8 discoverability + decision-card key disambiguation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the hotbar discoverability slice that shipped in the 0.8.66 local test candidate (and that #3731 describes as the v0.8.66 candidate state) but never had its own PR, so it could not land on main alongside the rest of 0.8.66. - Sidebar hotbar panel makes the activation chord explicit: title "Hotbar (Alt+1-8)", per-slot "Alt1:label" cells (no longer bare digits that read like raw number keys), and hover/full text "Slot N (Alt+N): ...". - Empty config (`hotbar = []`) now omits the panel entirely and reclaims the sidebar height (`hotbar_panel_enabled`). - `/hotbar help` explains Alt+1 through Alt+8 and `hotbar = []`. - Decision-card vs hotbar number-key disambiguation: bare digits select a decision-card option (`decision_card_number_from_key`, modifiers rejected), and Alt+digit hotbar dispatch is suppressed while a decision card is open — removing the number-key collision. - KEYBINDINGS.md documents Alt-1..8 (Option+number on macOS) and why F-keys / Cmd-number are not the primary chords; hotbar QA matrix covers the decision card state. Builds and tests green on current main (63 hotbar + 2 decision-card tests). Part of the hotbar lane (#3731); the remaining customization/terminal-QA work stays tracked there for 0.8.67. --- crates/tui/src/commands/groups/core/hotbar.rs | 13 +- crates/tui/src/tui/sidebar.rs | 162 ++++++++++++++---- crates/tui/src/tui/ui.rs | 76 ++++---- crates/tui/src/tui/ui/tests.rs | 41 +++++ docs/KEYBINDINGS.md | 6 +- docs/evidence/hotbar-qa-matrix.md | 9 +- 6 files changed, 231 insertions(+), 76 deletions(-) diff --git a/crates/tui/src/commands/groups/core/hotbar.rs b/crates/tui/src/commands/groups/core/hotbar.rs index e157d6b12..3cd0c8620 100644 --- a/crates/tui/src/commands/groups/core/hotbar.rs +++ b/crates/tui/src/commands/groups/core/hotbar.rs @@ -26,7 +26,7 @@ impl RegisterCommand for HotbarCmd { CommandResult::action(AppAction::OpenHotbarSetup) } Some("help" | "?") => CommandResult::message( - "Usage: /hotbar [setup]\n\n/hotbar opens the Hotbar setup wizard.", + "Usage: /hotbar [setup]\n\n/hotbar opens the Hotbar setup wizard. Press Alt+1 through Alt+8 to run configured slots; use hotbar = [] to hide the default bar.", ), Some(other) => CommandResult::error(format!( "Unknown /hotbar target '{other}'. Use `/hotbar` or `/hotbar setup`." @@ -95,12 +95,11 @@ mod tests { assert!(!result.is_error); assert!(result.action.is_none()); - assert!( - result - .message - .as_deref() - .is_some_and(|message| message.contains("/hotbar opens")) - ); + assert!(result.message.as_deref().is_some_and(|message| { + message.contains("/hotbar opens") + && message.contains("Alt+1 through Alt+8") + && message.contains("hotbar = []") + })); } #[test] diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 8cec89898..84aacd6f3 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -46,6 +46,7 @@ const TASK_STOP_TARGET_LABEL: &str = "[x]"; const TASK_STOP_TARGET_SUFFIX: &str = " [x]"; const HOTBAR_PANEL_HEIGHT: u16 = 4; const HOTBAR_ROW_COLUMNS: usize = 4; +const HOTBAR_PANEL_TITLE: &str = "Hotbar (Alt+1-8)"; pub fn render_sidebar(f: &mut Frame, area: Rect, app: &mut App, config: &Config) { // Clear hover state at the start of each render @@ -66,7 +67,12 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, app: &mut App, config: &Config) return; } - let (main_area, hotbar_area) = split_sidebar_hotbar_area(area); + let hotbar_enabled = hotbar_panel_enabled(app, config); + let (main_area, hotbar_area) = if hotbar_enabled { + split_sidebar_hotbar_area(area) + } else { + (area, None) + }; match app.sidebar_focus { SidebarFocus::Auto => render_sidebar_auto(f, main_area, app), SidebarFocus::Pinned => render_sidebar_pinned(f, main_area, app), @@ -244,7 +250,7 @@ fn render_hotbar_panel(f: &mut Frame, area: Rect, app: &mut App, config: &Config render_sidebar_section( f, area, - "Hotbar", + HOTBAR_PANEL_TITLE, hotbar_panel_lines(&slots, content_width, &app.ui_theme), hotbar_panel_hover_texts(&slots), Vec::new(), @@ -252,15 +258,12 @@ fn render_hotbar_panel(f: &mut Frame, area: Rect, app: &mut App, config: &Config ); } +fn hotbar_panel_enabled(app: &App, config: &Config) -> bool { + !resolved_hotbar_bindings(app, config).is_empty() +} + fn hotbar_panel_slots(app: &App, config: &Config) -> Vec { - let known_action_ids = app - .hotbar_actions - .iter() - .map(|action| action.id()) - .collect::>(); - let mut bindings = config - .resolve_hotbar_bindings(&known_action_ids) - .bindings + let mut bindings = resolved_hotbar_bindings(app, config) .into_iter() .map(|binding| (binding.slot, binding)) .collect::>(); @@ -271,7 +274,7 @@ fn hotbar_panel_slots(app: &App, config: &Config) -> Vec { return HotbarPanelSlot { slot, label: "-".to_string(), - full_text: format!("Slot {slot}: empty"), + full_text: format!("Slot {slot} (Alt+{slot}): empty"), state: HotbarSlotState::Empty, }; }; @@ -282,7 +285,10 @@ fn hotbar_panel_slots(app: &App, config: &Config) -> Vec { return HotbarPanelSlot { slot, label, - full_text: format!("Slot {slot}: unknown action {}", binding.action), + full_text: format!( + "Slot {slot} (Alt+{slot}): unknown action {}", + binding.action + ), state: HotbarSlotState::Unknown, }; }; @@ -300,7 +306,7 @@ fn hotbar_panel_slots(app: &App, config: &Config) -> Vec { slot, label: label.clone(), full_text: format!( - "Slot {slot}: {label}{status} ({}: {})", + "Slot {slot} (Alt+{slot}): {label}{status} ({}: {})", action.category(), action.id() ), @@ -310,6 +316,15 @@ fn hotbar_panel_slots(app: &App, config: &Config) -> Vec { .collect() } +fn resolved_hotbar_bindings(app: &App, config: &Config) -> Vec { + let known_action_ids = app + .hotbar_actions + .iter() + .map(|action| action.id()) + .collect::>(); + config.resolve_hotbar_bindings(&known_action_ids).bindings +} + fn hotbar_configured_label(label: Option<&str>) -> Option { label .map(str::trim) @@ -357,6 +372,7 @@ fn hotbar_panel_hover_texts(slots: &[HotbarPanelSlot]) -> Vec { } fn hotbar_slot_cell_text(slot: &HotbarPanelSlot, cell_width: usize) -> String { + let chord = format!("Alt{}", slot.slot); let marker = match slot.state { HotbarSlotState::Empty => "-", HotbarSlotState::Inactive => "", @@ -364,11 +380,11 @@ fn hotbar_slot_cell_text(slot: &HotbarPanelSlot, cell_width: usize) -> String { HotbarSlotState::Unknown => "?", }; let text = if marker.is_empty() { - format!("{}:{}", slot.slot, slot.label) + format!("{chord}:{}", slot.label) } else if slot.state == HotbarSlotState::Empty { - format!("{}:{marker}", slot.slot) + format!("{chord}:{marker}") } else { - format!("{}:{marker}{}", slot.slot, slot.label) + format!("{chord}:{marker}{}", slot.label) }; pad_to_display_width(clip_line_to_width(&text, cell_width), cell_width) } @@ -3242,12 +3258,12 @@ mod tests { SidebarHoverSection, SidebarHoverState, SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem, SidebarWorkStrategyStep, SidebarWorkSummary, ToolRowOrder, agent_row_hover_text, auto_sidebar_panels, background_task_spinner_prefix, - context_panel_cost_line, editorial_tool_rows, hotbar_panel_hover_texts, hotbar_panel_lines, - hotbar_panel_slots, normalize_activity_text, render_sidebar, sidebar_agent_rows, - sidebar_hover_rows, sidebar_work_summary, sort_sidebar_agent_rows_as_tree, - subagent_panel_hover_texts, subagent_panel_lines, subagent_panel_rows, - task_panel_hover_texts, task_panel_lines, task_panel_rows, work_panel_empty_hint, - work_panel_hover_texts, work_panel_lines, + context_panel_cost_line, editorial_tool_rows, hotbar_panel_enabled, + hotbar_panel_hover_texts, hotbar_panel_lines, hotbar_panel_slots, normalize_activity_text, + render_sidebar, sidebar_agent_rows, sidebar_hover_rows, sidebar_work_summary, + sort_sidebar_agent_rows_as_tree, subagent_panel_hover_texts, subagent_panel_lines, + subagent_panel_rows, task_panel_hover_texts, task_panel_lines, task_panel_rows, + work_panel_empty_hint, work_panel_hover_texts, work_panel_lines, }; use crate::config::Config; use crate::palette; @@ -3428,13 +3444,50 @@ mod tests { } #[test] - fn hotbar_panel_slots_resolve_default_bindings_and_active_state() { + fn hotbar_panel_uses_default_bindings_when_config_is_absent() { let mut app = create_test_app(); app.mode = AppMode::Agent; app.sidebar_focus = SidebarFocus::Pinned; + assert!(hotbar_panel_enabled(&app, &Config::default())); + let slots = hotbar_panel_slots(&app, &Config::default()); + assert_eq!(slots.len(), 8); + assert_eq!(slots[0].slot, 1); + assert_eq!(slots[0].label, "voice"); + assert_eq!(slots[0].state, HotbarSlotState::Inactive); + assert_eq!(slots[3].label, "agent"); + assert_eq!(slots[3].state, HotbarSlotState::Active); + assert!( + slots[3].full_text.contains("Alt+4"), + "hover text should expose the dispatch chord: {slots:?}" + ); + } + + #[test] + fn hotbar_panel_slots_resolve_configured_bindings_and_active_state() { + let mut app = create_test_app(); + app.mode = AppMode::Agent; + app.sidebar_focus = SidebarFocus::Pinned; + let config = Config { + hotbar: Some( + codewhale_config::default_hotbar_bindings() + .into_iter() + .map(|binding| codewhale_config::HotbarBindingToml { + slot: binding.slot, + action: binding.action, + label: binding.label, + }) + .collect(), + ), + ..Config::default() + }; + + assert!(hotbar_panel_enabled(&app, &config)); + + let slots = hotbar_panel_slots(&app, &config); + assert_eq!(slots.len(), 8); assert_eq!(slots[0].slot, 1); assert_eq!(slots[0].label, "voice"); @@ -3443,6 +3496,7 @@ mod tests { assert_eq!(slots[3].state, HotbarSlotState::Active); assert!(slots[3].full_text.contains("mode.agent")); assert!(slots[3].full_text.contains("active")); + assert!(slots[3].full_text.contains("Alt+4")); assert_eq!( slots[6].state, HotbarSlotState::Active, @@ -3510,33 +3564,75 @@ mod tests { let mut app = create_test_app(); app.mode = AppMode::Agent; app.sidebar_focus = SidebarFocus::Pinned; - let slots = hotbar_panel_slots(&app, &Config::default()); + let config = Config { + hotbar: Some( + codewhale_config::default_hotbar_bindings() + .into_iter() + .map(|binding| codewhale_config::HotbarBindingToml { + slot: binding.slot, + action: binding.action, + label: binding.label, + }) + .collect(), + ), + ..Config::default() + }; + let slots = hotbar_panel_slots(&app, &config); - let lines = hotbar_panel_lines(&slots, 20, &app.ui_theme); + let lines = hotbar_panel_lines(&slots, 32, &app.ui_theme); let text = lines_to_text(&lines); let hover = hotbar_panel_hover_texts(&slots); assert_eq!(text.len(), 2); assert!( text.iter() - .all(|line| unicode_width::UnicodeWidthStr::width(line.as_str()) <= 20), + .all(|line| unicode_width::UnicodeWidthStr::width(line.as_str()) <= 32), "hotbar lines must stay within the sidebar content width: {text:?}" ); assert!( - text[0].contains("1:vo"), + text[0].contains("Alt1"), "first row should show slot 1: {text:?}" ); assert!( - text[0].contains("4:*a"), + text[0].contains("Alt4:*"), "active slot should be visibly marked in the fixed grid: {text:?}" ); assert_eq!(hover.len(), 2); assert!( - hover[0].contains("Slot 4: agent active"), + hover[0].contains("Slot 4 (Alt+4): agent active"), "row hover text should expose active status: {hover:?}" ); } + #[test] + fn sidebar_hotbar_render_smoke_omits_panel_when_empty_config() { + let mut app = create_test_app(); + app.sidebar_focus = SidebarFocus::Pinned; + app.mode = AppMode::Agent; + let config = Config { + hotbar: Some(Vec::new()), + ..Config::default() + }; + + let backend = TestBackend::new(44, 12); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| render_sidebar(frame, frame.area(), &mut app, &config)) + .expect("draw sidebar"); + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + + assert!( + !rendered.contains("Hotbar"), + "empty hotbar config should not render hotbar panel: {rendered:?}" + ); + } + #[test] fn sidebar_hotbar_render_smoke_paints_default_slots() { let mut app = create_test_app(); @@ -3562,11 +3658,15 @@ mod tests { "hotbar panel title missing: {rendered:?}" ); assert!( - rendered.contains("1:voice"), + rendered.contains("Alt+1-8") || rendered.contains("Alt+1"), + "hotbar panel title should expose the accelerator: {rendered:?}" + ); + assert!( + rendered.contains("Alt1"), "slot 1 default binding should render: {rendered:?}" ); assert!( - rendered.contains("4:*agent"), + rendered.contains("Alt4"), "active agent-mode slot should render distinctly: {rendered:?}" ); } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a71aec469..f4dab1124 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3578,39 +3578,39 @@ async fn run_event_loop( if app.view_stack.is_empty() && let Some(card) = app.decision_card.as_mut() { - match key.code { - KeyCode::Char(c @ '1'..='9') => { - let n = (c as u8 - b'1' + 1) as usize; - card.select_number(n); - card.confirm(); - app.status_message = card - .confirmed_label() - .map(|label| format!("Selected: {label}")); - app.decision_card = None; - app.needs_redraw = true; - } - KeyCode::Char('j') | KeyCode::Down => { - card.select_next(); - app.needs_redraw = true; - } - KeyCode::Char('k') | KeyCode::Up => { - card.select_prev(); - app.needs_redraw = true; - } - KeyCode::Enter => { - card.confirm(); - app.status_message = card - .confirmed_label() - .map(|label| format!("Selected: {label}")); - app.decision_card = None; - app.needs_redraw = true; - } - KeyCode::Esc => { - app.decision_card = None; - app.status_message = Some("Decision cancelled".to_string()); - app.needs_redraw = true; + if let Some(n) = decision_card_number_from_key(&key) { + card.select_number(n); + card.confirm(); + app.status_message = card + .confirmed_label() + .map(|label| format!("Selected: {label}")); + app.decision_card = None; + app.needs_redraw = true; + } else { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + card.select_next(); + app.needs_redraw = true; + } + KeyCode::Char('k') | KeyCode::Up => { + card.select_prev(); + app.needs_redraw = true; + } + KeyCode::Enter => { + card.confirm(); + app.status_message = card + .confirmed_label() + .map(|label| format!("Selected: {label}")); + app.decision_card = None; + app.needs_redraw = true; + } + KeyCode::Esc => { + app.decision_card = None; + app.status_message = Some("Decision cancelled".to_string()); + app.needs_redraw = true; + } + _ => {} } - _ => {} } submit_initial_input_if_ready(app, config, &engine_handle).await?; continue; @@ -5115,6 +5115,7 @@ fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option { if app.onboarding != OnboardingState::None || !app.view_stack.is_empty() || app.is_history_search_active() + || app.decision_card.is_some() || !visible_slash_menu_entries(app, SLASH_MENU_LIMIT).is_empty() { return None; @@ -5126,6 +5127,17 @@ fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option { None } +fn decision_card_number_from_key(key: &event::KeyEvent) -> Option { + let KeyCode::Char(c @ '1'..='9') = key.code else { + return None; + }; + if !key.modifiers.is_empty() { + return None; + } + + Some((c as u8 - b'1' + 1) as usize) +} + fn dispatch_hotbar_slot( app: &mut App, config: &Config, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index bc570dfc6..0f06619db 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4142,6 +4142,47 @@ fn hotbar_alt_digit_is_blocked_while_inline_selectors_are_open() { assert_eq!(hotbar_slot_from_key(&app, &alt_four), None); } +#[test] +fn decision_card_numeric_shortcuts_accept_bare_digits_only() { + assert_eq!( + decision_card_number_from_key(&KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE)), + Some(4) + ); + assert_eq!( + decision_card_number_from_key(&KeyEvent::new(KeyCode::Char('4'), KeyModifiers::ALT)), + None + ); + assert_eq!( + decision_card_number_from_key(&KeyEvent::new(KeyCode::Char('4'), KeyModifiers::CONTROL)), + None + ); +} + +#[test] +fn hotbar_alt_digit_is_blocked_while_decision_card_is_active() { + let mut app = create_test_app(); + app.onboarding = OnboardingState::None; + app.decision_card = Some(crate::tui::widgets::decision_card::DecisionCard::new( + "Pick one".to_string(), + vec![ + crate::tui::widgets::decision_card::DecisionOption { + label: "First".to_string(), + description: None, + }, + crate::tui::widgets::decision_card::DecisionOption { + label: "Second".to_string(), + description: None, + }, + ], + 0, + )); + + assert_eq!( + hotbar_slot_from_key(&app, &KeyEvent::new(KeyCode::Char('1'), KeyModifiers::ALT)), + None + ); +} + #[test] fn hotbar_dispatches_bound_slot_and_ignores_empty_slot() { let mut app = create_test_app(); diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index af7831212..9d9e16779 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -2,7 +2,7 @@ This is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under "Composer" only takes effect when the composer is focused; one under "Transcript" only when the transcript has focus; and so on. -Bindings are not (yet) user-configurable — tracked for a future release (#436, #437). This document is the contract that future config-file overrides will name into. +Global key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`. ## Global (any context) @@ -53,7 +53,9 @@ Editing the message you're about to send. ### Hotbar -Hotbar trigger semantics are intentionally `Alt-1` through `Alt-8` only. Bare `1`-`8` is normal text input in the composer and remains owned by pickers, onboarding, approval prompts, and modal views. +Hotbar trigger semantics are intentionally `Alt-1` through `Alt-8` only. On macOS keyboards this is the Option/Alt key plus the number row. Bare `1`-`8` is normal text input in the composer and remains owned by pickers, onboarding, approval prompts, and modal views. + +Function keys and `Cmd-1` through `Cmd-8` are not the primary Hotbar chords. Many terminals reserve those keys for tabs, windows, or OS shortcuts, and some never forward them to terminal apps. If a terminal is configured to send `Alt-1` for a custom shortcut, the Hotbar receives the same reliable chord. Fresh configs resolve to this default bar unless `[[hotbar]]` overrides it or `hotbar = []` disables it: diff --git a/docs/evidence/hotbar-qa-matrix.md b/docs/evidence/hotbar-qa-matrix.md index 19c10ab72..5c161f239 100644 --- a/docs/evidence/hotbar-qa-matrix.md +++ b/docs/evidence/hotbar-qa-matrix.md @@ -34,7 +34,7 @@ coverage. | Normal TUI/composer | `Alt-1` through `Alt-8` dispatch configured slots; bare digits remain text input. | `crates/tui/src/tui/ui/tests.rs::hotbar_alt_digit_fires_from_composer_and_sidebar_states`; `crates/tui/src/tui/ui/tests.rs::hotbar_bare_digit_inserts_text_even_when_composer_empty` | | Hidden/sidebar focus states | Hotbar dispatch is still available from hidden, auto, pinned, and focused sidebar states. | `crates/tui/src/tui/ui/tests.rs::hotbar_alt_digit_fires_from_composer_and_sidebar_states` | | Narrow sidebar | Hotbar panel keeps fixed two-row layout and bounded hover/status text. | `crates/tui/src/tui/sidebar.rs::hotbar_panel_lines_keep_two_fixed_rows_and_hover_status`; `docs/evidence/terminal-visual-regression-matrix.md` | -| Modal/overlay open | Modal, approval, picker, and onboarding states block Hotbar numeric ownership. | `crates/tui/src/tui/ui/tests.rs::hotbar_digits_are_blocked_while_modal_or_onboarding_is_active`; `crates/tui/src/tui/ui/tests.rs::hotbar_alt_digit_is_blocked_while_inline_selectors_are_open` | +| Modal/overlay open | Modal, approval, picker, decision-card, and onboarding states block Hotbar numeric ownership. | `crates/tui/src/tui/ui/tests.rs::hotbar_digits_are_blocked_while_modal_or_onboarding_is_active`; `crates/tui/src/tui/ui/tests.rs::hotbar_alt_digit_is_blocked_while_inline_selectors_are_open`; `crates/tui/src/tui/ui/tests.rs::hotbar_alt_digit_is_blocked_while_decision_card_is_active` | | Setup wizard open/save | Setup lists supported source categories, updates draft bindings, saves, and persists. | `crates/tui/src/tui/hotbar/setup.rs::wizard_sources_follow_registered_action_categories`; `crates/tui/src/tui/hotbar/setup.rs::wizard_save_emits_bindings_but_escape_only_closes`; `crates/tui/src/tui/ui/tests.rs::hotbar_setup_save_persists_bindings_to_config_path` | | Restart/re-dispatch | Persisted bindings parse back into config and resolve through the same dispatch path. | `crates/config/src/tests.rs::hotbar_tables_parse_and_round_trip`; `crates/tui/src/tui/ui/tests.rs::hotbar_dispatches_bound_slot_and_ignores_empty_slot` | @@ -63,13 +63,14 @@ Run before claiming Hotbar MVP readiness: Manual pass, if a release candidate binary is available: 1. Start with no `[hotbar]` config and verify the default eight slots render in - the sidebar. + the sidebar with visible `Alt1` through `Alt8` accelerator labels. 2. Open `/hotbar`, bind a slash command, save, restart, and verify the binding persists. 3. Press `Alt-1` through `Alt-8` from composer/sidebar states and verify only `Alt` chords dispatch. -4. Open command palette, slash menu, setup wizard, and an approval modal; verify - Hotbar digits are blocked while those surfaces own input. +4. Open command palette, slash menu, setup wizard, decision card, and an + approval modal; verify Hotbar digits are blocked while those surfaces own + input. 5. Confirm MCP, skill, and plugin entries remain discoverable through their existing command-palette or slash-command paths and are not offered as direct Hotbar bindable actions.