Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions crates/tui/src/commands/groups/core/hotbar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`, \
Expand Down Expand Up @@ -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:?}"
Expand Down
148 changes: 122 additions & 26 deletions crates/tui/src/tui/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<HotbarPanelSlot> {
let known_action_ids = app
.hotbar_actions
.iter()
.map(|action| action.id())
.collect::<Vec<_>>();
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::<BTreeMap<_, _>>();
Expand Down Expand Up @@ -330,6 +328,15 @@ fn hotbar_panel_slots(app: &App, config: &Config) -> Vec<HotbarPanelSlot> {
.collect()
}

fn resolved_hotbar_bindings(app: &App, config: &Config) -> Vec<codewhale_config::HotbarBinding> {
let known_action_ids = app
.hotbar_actions
.iter()
.map(|action| action.id())
.collect::<Vec<_>>();
config.resolve_hotbar_bindings(&known_action_ids).bindings
}

fn hotbar_configured_label(label: Option<&str>) -> Option<String> {
label
.map(str::trim)
Expand Down Expand Up @@ -377,18 +384,19 @@ fn hotbar_panel_hover_texts(slots: &[HotbarPanelSlot]) -> Vec<String> {
}

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 => "",
HotbarSlotState::Active => "*",
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)
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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,
Expand Down Expand Up @@ -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::<String>();

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();
Expand All @@ -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:?}"
);
}
Expand Down
76 changes: 44 additions & 32 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -5122,6 +5122,7 @@ fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option<u8> {
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;
Expand All @@ -5133,6 +5134,17 @@ fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option<u8> {
None
}

fn decision_card_number_from_key(key: &event::KeyEvent) -> Option<usize> {
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,
Expand Down
Loading
Loading