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.
This commit is contained in:
Reid
2026-06-13 01:52:02 +08:00
committed by GitHub
parent 4968983339
commit 1ac32df627
2 changed files with 168 additions and 22 deletions
+106 -22
View File
@@ -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<u8> {
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<Option<HotbarDispatch>> {
let known_action_ids = app
.hotbar_actions
.iter()
.map(|action| action.id())
.collect::<Vec<_>>();
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());
+62
View File
@@ -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();