diff --git a/crates/tui/src/commands/groups/core/hotbar.rs b/crates/tui/src/commands/groups/core/hotbar.rs index 6fb608809..a4b9615b3 100644 --- a/crates/tui/src/commands/groups/core/hotbar.rs +++ b/crates/tui/src/commands/groups/core/hotbar.rs @@ -32,9 +32,9 @@ impl RegisterCommand for HotbarCmd { CommandResult::action(AppAction::RestoreHotbarDefaults) } Some("help" | "?") => CommandResult::message( - "Hotbar gives you Alt-1..Alt-8 shortcuts (Option key on macOS, Alt \ - elsewhere). Use `/hotbar` to customize, `/hotbar off` to hide it, \ - `/hotbar on` to restore the default slots.", + "Hotbar gives you Alt+1 through Alt+8 shortcuts (Option key on macOS, Alt \ + elsewhere). Use `/hotbar` to customize, `/hotbar off` to hide it \ + (`hotbar = []`), and `/hotbar on` to restore the default slots.", ), Some(other) => CommandResult::error(format!( "Unknown /hotbar target '{other}'. Try `/hotbar`, `/hotbar off`, \ @@ -109,9 +109,15 @@ mod tests { .as_deref() .expect("help should return a message"); assert!( - message.contains("/hotbar") && message.contains("customize"), + message.contains("/hotbar") + && message.contains("customize") + && message.contains("Alt+1 through Alt+8"), "help should point at /hotbar to customize: {message:?}" ); + assert!( + message.contains("hotbar = []"), + "help should mention the explicit disabled config: {message:?}" + ); assert!( message.contains("/hotbar off") && message.contains("/hotbar on"), "help should mention both disable and restore paths: {message:?}" diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 1758a8d95..4c2659013 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -66,7 +66,8 @@ 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, !is_hotbar_disabled(config)); + let hotbar_enabled = hotbar_panel_enabled(app, config) && !is_hotbar_disabled(config); + let (main_area, hotbar_area) = split_sidebar_hotbar_area(area, hotbar_enabled); match app.sidebar_focus { SidebarFocus::Auto => render_sidebar_auto(f, main_area, app), SidebarFocus::Pinned => render_sidebar_pinned(f, main_area, app), @@ -266,15 +267,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::>(); @@ -330,6 +328,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) @@ -377,6 +384,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 => "", @@ -384,11 +392,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) } @@ -3262,12 +3270,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, is_hotbar_disabled, 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, is_hotbar_disabled, + 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; @@ -3477,8 +3485,46 @@ mod tests { 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); + let slot_4_chord = format!("{}4", crate::tui::widgets::key_hint::alt_prefix()); + assert!( + slots[3].full_text.contains(&slot_4_chord), + "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"); @@ -3487,6 +3533,8 @@ mod tests { assert_eq!(slots[3].state, HotbarSlotState::Active); assert!(slots[3].full_text.contains("mode.agent")); assert!(slots[3].full_text.contains("active")); + let slot_4_chord = format!("{}4", crate::tui::widgets::key_hint::alt_prefix()); + assert!(slots[3].full_text.contains(&slot_4_chord)); assert_eq!( slots[6].state, HotbarSlotState::Active, @@ -3554,33 +3602,76 @@ 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); + let slot_4_chord = format!("{}4", crate::tui::widgets::key_hint::alt_prefix()); assert!( - hover[0].contains("Slot 4: agent active"), + hover[0].contains(&slot_4_chord) && hover[0].contains("Slot 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(); @@ -3605,12 +3696,17 @@ mod tests { rendered.contains("Hotbar"), "hotbar panel title missing: {rendered:?}" ); + let hotbar_range = format!("{}1-8", crate::tui::widgets::key_hint::alt_prefix()); + assert!( + rendered.contains(&hotbar_range), + "hotbar panel title should expose the accelerator: {rendered:?}" + ); assert!( - rendered.contains("1:voice"), + 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 e0d359965..62f465a5a 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3585,39 +3585,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; @@ -5122,6 +5122,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; @@ -5133,6 +5134,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.