diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 0a9f9fb3..37bf84c5 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1233,9 +1233,8 @@ pub(crate) struct PendingProviderSwitch { pub struct App { pub mode: AppMode, /// Registered hotbar actions available for future slot config/render layers. + #[allow(dead_code)] pub hotbar_actions: HotbarActionRegistry, - /// Resolved 1-8 hotbar slot bindings loaded from config or defaults. - pub hotbar_bindings: Vec, /// Composer sub-state (input, cursor, history, menus). pub composer: ComposerState, /// Viewport sub-state (scroll, cache, selection). @@ -2061,19 +2060,9 @@ impl App { crate::mcp::load_config_with_workspace(&mcp_config_path, &workspace) .map(|cfg| cfg.servers.len()) .unwrap_or(0); - let hotbar_actions = HotbarActionRegistry::with_builtins(); - let known_hotbar_action_ids = hotbar_actions - .iter() - .map(|action| action.id()) - .collect::>(); - let hotbar_bindings = config - .resolve_hotbar_bindings(&known_hotbar_action_ids) - .bindings; - Self { mode: initial_mode, - hotbar_actions, - hotbar_bindings, + hotbar_actions: HotbarActionRegistry::with_builtins(), composer: ComposerState { input: initial_input_text, cursor_position: initial_input_cursor, diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 26ba1836..65e10b9b 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -38,9 +38,6 @@ const COST_EQ_TOLERANCE: f64 = 1e-6; const RECENT_TOOL_SCAN_LIMIT: usize = 24; const ACTIVE_TOOL_COMPLETED_ROW_TTL: Duration = Duration::from_secs(8); const ACTIVE_TOOL_STALE_RUNNING_ROW_TTL: Duration = Duration::from_secs(600); -const HOTBAR_PANEL_HEIGHT: u16 = 5; -const HOTBAR_MIN_SIDEBAR_HEIGHT: u16 = 13; -const HOTBAR_SLOTS_PER_ROW: usize = 4; pub fn render_sidebar(f: &mut Frame, area: Rect, app: &mut App) { // Clear hover state at the start of each render @@ -54,40 +51,16 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, app: &mut App) { return; } - if app.sidebar_focus == SidebarFocus::Hidden { - Block::default() - .style(Style::default().bg(app.ui_theme.surface_bg)) - .render(area, f.buffer_mut()); - return; - } - - let (panel_area, hotbar_area) = sidebar_layout_with_hotbar(area); - match app.sidebar_focus { - SidebarFocus::Auto => render_sidebar_auto(f, panel_area, app), - SidebarFocus::Work => render_sidebar_work(f, panel_area, app), - SidebarFocus::Tasks => render_sidebar_tasks(f, panel_area, app), - SidebarFocus::Agents => render_sidebar_subagents(f, panel_area, app), - SidebarFocus::Context => render_context_panel(f, panel_area, app), - SidebarFocus::Hidden => unreachable!("hidden sidebar returned before render"), + SidebarFocus::Auto => render_sidebar_auto(f, area, app), + SidebarFocus::Work => render_sidebar_work(f, area, app), + SidebarFocus::Tasks => render_sidebar_tasks(f, area, app), + SidebarFocus::Agents => render_sidebar_subagents(f, area, app), + SidebarFocus::Context => render_context_panel(f, area, app), + SidebarFocus::Hidden => Block::default() + .style(Style::default().bg(app.ui_theme.surface_bg)) + .render(area, f.buffer_mut()), } - - if let Some(hotbar_area) = hotbar_area { - render_sidebar_hotbar(f, hotbar_area, app); - } -} - -fn sidebar_layout_with_hotbar(area: Rect) -> (Rect, Option) { - if area.height < HOTBAR_MIN_SIDEBAR_HEIGHT { - return (area, None); - } - - let split = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(3), Constraint::Length(HOTBAR_PANEL_HEIGHT)]) - .split(area); - - (split[0], Some(split[1])) } /// Build the Auto-mode panel stack. Empty panels collapse to zero height so @@ -2127,119 +2100,6 @@ fn agent_status_marker( } } -#[derive(Debug, Clone, PartialEq, Eq)] -struct HotbarDisplayCell { - slot: u8, - action: String, - label: String, - known: bool, - active: bool, -} - -fn hotbar_display_cells(app: &App) -> Vec { - app.hotbar_bindings - .iter() - .map(|binding| { - let action = app.hotbar_actions.get(&binding.action); - let label = binding - .label - .clone() - .filter(|label| !label.trim().is_empty()) - .unwrap_or_else(|| { - action - .as_ref() - .map(|action| action.short_label().to_string()) - .unwrap_or_else(|| binding.action.clone()) - }); - let active = action.as_ref().is_some_and(|action| action.is_active(app)); - - HotbarDisplayCell { - slot: binding.slot, - action: binding.action.clone(), - label, - known: action.is_some(), - active, - } - }) - .collect() -} - -fn hotbar_panel_lines( - cells: &[HotbarDisplayCell], - content_width: usize, - theme: &palette::UiTheme, -) -> Vec> { - let mut lines = Vec::with_capacity(2); - if cells.is_empty() { - lines.push(Line::from(Span::styled( - "No hotbar slots", - Style::default().fg(theme.text_muted), - ))); - return lines; - } - - for row in cells.chunks(HOTBAR_SLOTS_PER_ROW) { - let separator_width = row.len().saturating_sub(1); - let available_cell_width = content_width.saturating_sub(separator_width); - let cell_width = (available_cell_width / row.len().max(1)) - .max(1) - .min(content_width.max(1)); - let mut spans = Vec::with_capacity(row.len() * 2); - for (idx, cell) in row.iter().enumerate() { - let label_width = cell_width.saturating_sub(2).max(1); - let label = truncate_line_to_width(&cell.label, label_width); - let text = truncate_line_to_width(&format!("{}:{label}", cell.slot), cell_width); - let padded = format!("{text: Vec { - cells - .chunks(HOTBAR_SLOTS_PER_ROW) - .map(|row| { - row.iter() - .map(|cell| { - let status = if cell.known { "" } else { " (unknown)" }; - let active = if cell.active { " active" } else { "" }; - format!( - "{}: {} -> {}{}{}", - cell.slot, cell.label, cell.action, status, active - ) - }) - .collect::>() - .join(" · ") - }) - .collect() -} - -fn render_sidebar_hotbar(f: &mut Frame, area: Rect, app: &mut App) { - if area.height < 3 { - return; - } - - let content_width = area.width.saturating_sub(4) as usize; - let cells = hotbar_display_cells(app); - let lines = hotbar_panel_lines(&cells, content_width.max(1), &app.ui_theme); - let hover_texts = hotbar_panel_hover_texts(&cells); - render_sidebar_section(f, area, "Hotbar", lines, hover_texts, app); -} - /// Session-context panel (#504) — consolidated session state overview. /// /// Surfaces at-a-glance: working set, token usage / context %, running @@ -2491,13 +2351,12 @@ fn sidebar_hover_rows( mod tests { use super::{ ACTIVE_TOOL_COMPLETED_ROW_TTL, ACTIVE_TOOL_STALE_RUNNING_ROW_TTL, AutoSidebarPanel, - AutoSidebarState, HOTBAR_PANEL_HEIGHT, SidebarAgentRow, SidebarHoverRow, - SidebarHoverSection, SidebarHoverState, SidebarSubagentSummary, SidebarToolRow, - SidebarWorkChecklistItem, SidebarWorkStrategyStep, SidebarWorkSummary, ToolRowOrder, - auto_sidebar_panels, editorial_tool_rows, hotbar_display_cells, hotbar_panel_lines, - normalize_activity_text, render_sidebar, sidebar_hover_rows, sidebar_layout_with_hotbar, - sidebar_work_summary, subagent_panel_hover_texts, subagent_panel_lines, task_panel_lines, - work_panel_empty_hint, work_panel_hover_texts, work_panel_lines, + AutoSidebarState, SidebarAgentRow, SidebarHoverRow, SidebarHoverSection, SidebarHoverState, + SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem, SidebarWorkStrategyStep, + SidebarWorkSummary, ToolRowOrder, auto_sidebar_panels, editorial_tool_rows, + normalize_activity_text, sidebar_hover_rows, sidebar_work_summary, + subagent_panel_hover_texts, subagent_panel_lines, task_panel_lines, work_panel_empty_hint, + work_panel_hover_texts, work_panel_lines, }; use crate::config::Config; use crate::palette; @@ -2505,20 +2364,16 @@ mod tests { use crate::tools::plan::StepStatus; use crate::tools::todo::TodoStatus; use crate::tui::active_cell::ActiveCell; - use crate::tui::app::{App, AppMode, HuntVerdict, TaskPanelEntry, TuiOptions}; + use crate::tui::app::{App, HuntVerdict, TaskPanelEntry, TuiOptions}; use crate::tui::history::{ ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus, }; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; use ratatui::text::Line; use std::path::PathBuf; use std::time::{Duration, Instant}; - fn test_options() -> TuiOptions { - TuiOptions { + fn create_test_app() -> App { + let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("."), config_path: None, @@ -2538,15 +2393,8 @@ mod tests { yolo: false, resume_session_id: None, initial_input: None, - } - } - - fn create_test_app() -> App { - App::new(test_options(), &Config::default()) - } - - fn create_test_app_with_config(config: &Config) -> App { - App::new(test_options(), config) + }; + App::new(options, &Config::default()) } fn sidebar_tool_row(name: &str, status: ToolStatus) -> SidebarToolRow { @@ -2570,17 +2418,6 @@ mod tests { .collect() } - fn buffer_lines(buffer: &Buffer) -> Vec { - let area = buffer.area(); - (area.y..area.y.saturating_add(area.height)) - .map(|y| { - (area.x..area.x.saturating_add(area.width)) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - }) - .collect() - } - #[test] fn editorial_rows_keep_newer_failure_when_older_success_is_seen_later() { let rows = vec![ @@ -2668,125 +2505,6 @@ mod tests { assert_eq!(panels, vec![AutoSidebarPanel::Work]); } - #[test] - fn sidebar_reserves_bottom_hotbar_only_when_tall_enough() { - let (panel, hotbar) = sidebar_layout_with_hotbar(Rect::new(0, 0, 32, 18)); - assert_eq!(panel.height, 13); - assert_eq!(hotbar.expect("hotbar area").height, 5); - - let (panel, hotbar) = sidebar_layout_with_hotbar(Rect::new(0, 0, 32, 10)); - assert_eq!(panel.height, 10); - assert!(hotbar.is_none()); - } - - #[test] - fn hotbar_defaults_render_as_two_sidebar_rows() { - let app = create_test_app(); - let cells = hotbar_display_cells(&app); - - assert_eq!(cells.len(), 8); - assert_eq!(cells[0].label, "voice"); - assert_eq!(cells[1].label, "compact"); - assert_eq!(cells[2].label, "plan"); - assert!(cells.iter().all(|cell| cell.known)); - - let text = lines_to_text(&hotbar_panel_lines(&cells, 48, &palette::UI_THEME)); - assert_eq!(text.len(), 2); - assert!(text[0].contains("1:voice")); - assert!(text[0].contains("2:compact")); - assert!(text[1].contains("8:trust")); - } - - #[test] - fn hotbar_rows_fit_narrow_sidebar_width() { - let app = create_test_app(); - let cells = hotbar_display_cells(&app); - let content_width = 28; - let text = lines_to_text(&hotbar_panel_lines( - &cells, - content_width, - &palette::UI_THEME, - )); - - assert_eq!(text.len(), 2); - assert!( - text[0].contains("4:"), - "first row should keep slot 4: {text:?}" - ); - assert!( - text[1].contains("8:"), - "second row should keep slot 8: {text:?}" - ); - for line in &text { - let width = unicode_width::UnicodeWidthStr::width(line.as_str()); - assert!( - width <= content_width, - "hotbar row width {width} exceeded {content_width}: {line:?}" - ); - } - } - - #[test] - fn hotbar_uses_config_labels_and_marks_unknown_actions() { - let config = Config { - hotbar: Some(vec![ - codewhale_config::HotbarBindingToml { - slot: 1, - action: "mode.plan".to_string(), - label: Some("Plan".to_string()), - }, - codewhale_config::HotbarBindingToml { - slot: 2, - action: "plugin.missing".to_string(), - label: Some("Missing".to_string()), - }, - ]), - ..Config::default() - }; - let mut app = create_test_app_with_config(&config); - app.mode = AppMode::Plan; - - let cells = hotbar_display_cells(&app); - assert_eq!(cells.len(), 2); - assert_eq!(cells[0].label, "Plan"); - assert!(cells[0].known); - assert!(cells[0].active); - assert_eq!(cells[1].label, "Missing"); - assert!(!cells[1].known); - - let text = lines_to_text(&hotbar_panel_lines(&cells, 48, &palette::UI_THEME)); - assert!(text[0].contains("1:Plan")); - assert!(text[0].contains("2:Missing")); - } - - #[test] - fn render_sidebar_paints_hotbar_at_bottom() { - let mut app = create_test_app(); - let backend = TestBackend::new(48, 18); - let mut terminal = Terminal::new(backend).expect("create terminal"); - - terminal - .draw(|frame| render_sidebar(frame, Rect::new(0, 0, 48, 18), &mut app)) - .expect("draw sidebar"); - - let lines = buffer_lines(terminal.backend().buffer()); - let text = lines.join("\n"); - - assert!( - text.contains("Hotbar"), - "hotbar title should render:\n{text}" - ); - assert!(text.contains("1:voice"), "slot 1 should render:\n{text}"); - assert!(text.contains("8:trust"), "slot 8 should render:\n{text}"); - assert!( - lines - .iter() - .skip(lines.len().saturating_sub(HOTBAR_PANEL_HEIGHT as usize)) - .any(|line| line.contains("Hotbar")), - "hotbar should be in the bottom panel:\n{text}" - ); - } - #[test] fn work_panel_empty_hint_stays_quiet_and_truncates() { let hint = work_panel_empty_hint(10);