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>
This commit is contained in:
Hunter B
2026-06-12 02:19:15 -07:00
parent 74a4a91204
commit 3de1d35c37
10 changed files with 199 additions and 51 deletions
+4
View File
@@ -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
+4
View File
@@ -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
+3 -3
View File
@@ -1744,8 +1744,8 @@ pub struct Config {
#[serde(default)]
pub auto: Option<AutoConfig>,
/// 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<Vec<codewhale_config::HotbarBindingToml>>,
@@ -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,
+6 -6
View File
@@ -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"
+1 -1
View File
@@ -335,7 +335,7 @@ pub(crate) fn active_subagent_status_label(app: &App) -> Option<String> {
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} "))
}
+1
View File
@@ -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));
+2 -3
View File
@@ -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;
+2 -2
View File
@@ -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,
},
+107 -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};
@@ -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<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());
+69 -14
View File
@@ -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::<String>()
}
#[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();