refactor(tui): delete the legacy sidebar's live control machinery

Spec step 3 (part 4) of rail-unification-task-20260802 (checklist
section 2A). Everything that gated behavior on the unrenderable classic
sidebar is deleted or re-pointed at the rail.

Re-pointed at the rail:
- y/Y turn-id clipboard and Ctrl+X /jobs cancel-all prefill now gate on
  the rail's Tasks panel actually rendering (panel == Tasks && last_area).
- visible_background_task_has_live_motion likewise.
- Hotbar sidebar.toggle now toggles rail placement off/top and reports
  it; is_active reflects placement != Off.
- should_render_empty_state checks live work directly (todo snapshot,
  goal quarry) instead of the deleted compact_work_indicator.

Deleted (subjects gone):
- Mouse drag-resize of the classic sidebar handle (handle_sidebar_resize_mouse
  + dispatch); the rail's own divider drag (work_surface/input.rs) is the
  resize surface for all three placements.
- Footer compact work chip (footer_compact_work_chip + FooterProps.work):
  it existed only to cover a width-suppressed sidebar; the rail already
  shows the work itself.
- sidebar.rs: sidebar_width_for_chat_area, sidebar_auto_idle,
  auto_sidebar_state, AutoSidebarState/Panel, auto_sidebar_panels,
  compact_work_indicator, SidebarWorkSummary::compact_indicator.
  Auto-collapse is deliberately dropped (placement off covers hiding).

Tests: the legacy resize-handle tests (7), the sidebar width-fn tests
(3), the auto-idle tests (4), the footer chip test, and sidebar.rs's
auto-mode tests (5) are removed with their subjects — rail-side
equivalents already exist (work_surface resize/placement tests, rail
command tests). hotbar sidebar_toggle and ctrl_x_jobs_prefill tests
rewritten to the rail gating.

SidebarFocus the type, App.sidebar_focus, set_sidebar_focus, and the
/config sidebar_width|sidebar_focus arms remain for the next commit
(step 4), which removes them together with the settings migration and
the config_ui/views rows that consume the same keys.

Gates: cargo fmt --check -p codewhale-tui clean;
sidebar: 133 passed; mouse: 70; footer: 104; hotbar: 92; resize: 8 — all 0 failed.
This commit is contained in:
Hmbown
2026-08-02 21:38:57 -07:00
parent b1a0d84db4
commit d8c2ac9cb4
7 changed files with 56 additions and 796 deletions
+23 -11
View File
@@ -985,7 +985,9 @@ impl HotbarAction for AppHotbarAction {
AppHotbarKind::ReasoningCycle => {
app.reasoning_effort != crate::tui::app::ReasoningEffort::Off
}
AppHotbarKind::SidebarToggle => app.sidebar_focus != SidebarFocus::Hidden,
AppHotbarKind::SidebarToggle => {
app.work_surface.placement != crate::tui::work_surface::WorkSurfacePlacement::Off
}
AppHotbarKind::FileTreeToggle => app.file_tree.is_some(),
AppHotbarKind::PaletteOpen => false,
AppHotbarKind::TrustToggle => app.trust_mode,
@@ -1030,13 +1032,17 @@ impl HotbarAction for AppHotbarAction {
}
}
AppHotbarKind::SidebarToggle => {
if app.sidebar_focus == SidebarFocus::Hidden {
app.set_sidebar_focus(SidebarFocus::Pinned);
app.status_message = Some("Sidebar focus: pinned".to_string());
if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off
{
app.work_surface.placement =
crate::tui::work_surface::WorkSurfacePlacement::Top;
app.status_message = Some("Rail: top placement".to_string());
} else {
app.set_sidebar_focus(SidebarFocus::Hidden);
app.status_message = Some("Sidebar hidden".to_string());
app.work_surface.placement =
crate::tui::work_surface::WorkSurfacePlacement::Off;
app.status_message = Some("Rail is off".to_string());
}
app.needs_redraw = true;
Ok(HotbarDispatch::Handled)
}
AppHotbarKind::FileTreeToggle => {
@@ -2547,18 +2553,24 @@ mod tests {
let registry = HotbarActionRegistry::with_builtins();
let sidebar = registry.get("sidebar.toggle").expect("sidebar action");
let mut app = test_app();
app.sidebar_focus = SidebarFocus::Pinned;
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
assert!(sidebar.is_active(&app));
assert_eq!(
sidebar.dispatch(&mut app).expect("dispatch sidebar hide"),
sidebar.dispatch(&mut app).expect("dispatch rail hide"),
HotbarDispatch::Handled
);
assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);
assert_eq!(
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Off
);
assert!(!sidebar.is_active(&app));
sidebar.dispatch(&mut app).expect("dispatch sidebar show");
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
sidebar.dispatch(&mut app).expect("dispatch rail show");
assert_eq!(
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Top
);
assert!(sidebar.is_active(&app));
}
-56
View File
@@ -65,56 +65,6 @@ fn toggle_tool_run_expand(app: &mut App, mouse: MouseEvent) -> bool {
app.toggle_tool_run_expansion_at(original_idx)
}
/// Handle mouse events on the sidebar resize handle (the 1-col vertical bar
/// between the chat area and the sidebar). Returns true when the event was
/// consumed so other handlers skip it.
fn handle_sidebar_resize_mouse(app: &mut App, mouse: MouseEvent) -> bool {
let Some(handle) = app.last_sidebar_handle_area else {
return false;
};
let hit = mouse.column == handle.x
&& mouse.row >= handle.y
&& mouse.row < handle.y.saturating_add(handle.height);
match mouse.kind {
MouseEventKind::Moved => {
if app.sidebar_resize_hovered != hit {
app.sidebar_resize_hovered = hit;
app.needs_redraw = true;
}
false
}
MouseEventKind::Down(MouseButton::Left) if hit => {
app.sidebar_resizing = true;
app.sidebar_resize_hovered = true;
app.sidebar_resize_anchor_x = mouse.column;
app.sidebar_resize_anchor_width = app.last_sidebar_area.map(|a| a.width).unwrap_or(28);
app.needs_redraw = true;
true
}
MouseEventKind::Drag(MouseButton::Left) if app.sidebar_resizing => {
let delta = app.sidebar_resize_anchor_x as i32 - mouse.column as i32;
let new_width = (app.sidebar_resize_anchor_width as i32 + delta).max(24) as u16;
let total = app.sidebar_resize_total_width.max(1);
let new_pct = ((new_width as u32 * 100) / total as u32).clamp(10, 50) as u16;
if new_pct != app.sidebar_width_percent {
app.sidebar_width_percent = new_pct;
app.needs_redraw = true;
}
true
}
MouseEventKind::Up(MouseButton::Left) if app.sidebar_resizing => {
app.sidebar_resizing = false;
app.sidebar_resize_hovered = hit;
app.sidebar_width_dirty = true;
app.needs_redraw = true;
true
}
_ => false,
}
}
/// Map a mouse (column, row) within the composer area to a char index
/// in the composer input string. Uses the canonical prompt-adjusted text rect
/// for coordinate mapping, and accounts for vertical padding and scroll offset.
@@ -413,12 +363,6 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
return Vec::new();
}
// Sidebar resize handle — check before composer so it doesn't compete
// with text selection / scrolling.
if handle_sidebar_resize_mouse(app, mouse) {
return Vec::new();
}
// Ocean work surface owns its rect, scrolling, focus, and row actions.
// Route it before workflow/composer/transcript so wheel events never leak
// into an unrelated viewport.
+11 -246
View File
@@ -49,116 +49,6 @@ const TASK_STOP_TARGET_SUFFIX: &str = " [x]";
const HOTBAR_PANEL_HEIGHT: u16 = 4;
const HOTBAR_ROW_COLUMNS: usize = 4;
pub(crate) fn sidebar_width_for_chat_area(app: &App, chat_width: u16) -> Option<u16> {
if app.sidebar_focus == SidebarFocus::Hidden || chat_width < FILE_TREE_MIN_HOST_WIDTH {
return None;
}
let preferred_sidebar =
(u32::from(chat_width) * u32::from(app.sidebar_width_percent.clamp(10, 50)) / 100) as u16;
// Width-aware floor: the classic 24-column sidebar feels cramped next to
// status/status-adjacent info at ultrawide sizes. At 120+ columns a 28
// column rail still leaves the transcript most of the room.
let sidebar_width = preferred_sidebar
.max(28)
.max(chat_width / 10)
.min(chat_width.saturating_sub(40));
(sidebar_width >= 20).then_some(sidebar_width)
}
/// Compute the Auto-mode panel signals. Shared by `render_sidebar_auto` (which
/// panel boxes to show) and `sidebar_auto_idle` (whether to collapse the whole
/// sidebar to a full-width transcript). Content-gated: the jobs/tasks panel
/// appears only when there are real durable tasks or background shell jobs,
/// never merely because a turn is in flight.
fn auto_sidebar_state(app: &mut App) -> AutoSidebarState {
AutoSidebarState {
work_has_content: sidebar_work_summary(app).has_useful_content(),
// The jobs/tasks panel appears in Auto mode only for live background
// work — running or queued shell jobs, RLM, or durable Fleet tasks.
// Completed jobs, per-turn tools, and model reasoning do not reopen
// the panel; they remain visible only when Tasks is explicitly focused.
tasks_empty: !app.task_panel.iter().any(background_task_is_live),
agents_empty: app.subagent_cache.is_empty()
&& app.agent_progress.is_empty()
&& active_fanout_counts(app).is_none()
&& !foreground_rlm_running(app),
context_enabled: app.context_panel,
sessions_rail_enabled: app.sessions_rail,
}
}
/// Auto-reveal: in Auto focus mode the sidebar collapses to nothing when there
/// is no active content (no To-do, no live/queued fleet, no background jobs, no
/// pinned context), so an idle session gets a full-width transcript. Any active
/// content brings it back; completed agents linger in the cache as a natural
/// grace before it retracts. Explicit panel focus and Hidden bypass this (the
/// former should always show, the latter is handled by the width helper).
pub(crate) fn sidebar_auto_idle(app: &mut App) -> bool {
if app.sidebar_focus != SidebarFocus::Auto {
return false;
}
let state = auto_sidebar_state(app);
!state.work_has_content
&& state.tasks_empty
&& state.agents_empty
&& !state.context_enabled
// An enabled rail is durable content: collapsing it away on idle would
// hide the very surface the user turned on to browse between sessions.
&& !state.sessions_rail_enabled
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AutoSidebarPanel {
Work,
Tasks,
Agents,
Context,
/// Persistent Sessions rail (#2934). Opt-in via the `sessions_rail`
/// setting; unlike the content-gated panels it stays visible while
/// enabled, because "there are no sessions yet" is itself the thing a
/// browsing surface needs to say.
Sessions,
}
#[derive(Debug, Clone, Copy)]
struct AutoSidebarState {
work_has_content: bool,
tasks_empty: bool,
agents_empty: bool,
context_enabled: bool,
sessions_rail_enabled: bool,
}
fn auto_sidebar_panels(state: AutoSidebarState) -> Vec<AutoSidebarPanel> {
let nothing_else_active = state.tasks_empty
&& state.agents_empty
&& !state.context_enabled
&& !state.sessions_rail_enabled;
let mut visible = Vec::with_capacity(5);
if state.work_has_content || nothing_else_active {
visible.push(AutoSidebarPanel::Work);
}
if !state.tasks_empty {
visible.push(AutoSidebarPanel::Tasks);
}
if !state.agents_empty {
visible.push(AutoSidebarPanel::Agents);
}
if state.context_enabled {
visible.push(AutoSidebarPanel::Context);
}
// Last in the stack: the rail is a navigation aid, so live work keeps the
// rows above it.
if state.sessions_rail_enabled {
visible.push(AutoSidebarPanel::Sessions);
}
visible
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HotbarSlotState {
Empty,
@@ -384,44 +274,6 @@ impl SidebarWorkSummary {
|| !self.checklist_items.is_empty()
|| self.state_updating
}
fn compact_indicator(&self) -> Option<String> {
if !self.checklist_items.is_empty() {
return Some(format!(
"To-do {} · {}%",
self.checklist_items.len(),
self.checklist_completion_pct
));
}
self.goal_objective
.as_ref()
.map(|_| "Work goal active".to_string())
}
}
/// Compact Work fallback for surfaces used when the sidebar cannot fit.
/// Reads the live stores first and only falls back to the last rendered
/// summary during brief lock contention.
pub(crate) fn compact_work_indicator(app: &App) -> Option<String> {
let todos = app.todos.try_lock().ok().map(|todos| todos.snapshot());
if let Some(snapshot) = todos.as_ref().filter(|snapshot| !snapshot.is_empty()) {
return Some(format!(
"To-do {} · {}%",
snapshot.items.len(),
snapshot.completion_pct
));
}
if app.hunt.quarry.is_some() || app.paused_quarry.is_some() {
return Some("Work goal active".to_string());
}
if todos.is_none() {
return app
.cached_work_summary
.as_ref()
.and_then(SidebarWorkSummary::compact_indicator);
}
None
}
/// Objective of the active goal, if any. Paused goals keep showing their
@@ -3001,17 +2853,17 @@ fn agent_stop_action_for_click(action: &SidebarRowAction) -> Option<SidebarRowAc
#[cfg(test)]
mod tests {
use super::{
ACTIVE_TOOL_COMPLETED_ROW_TTL, ACTIVE_TOOL_STALE_RUNNING_ROW_TTL, AutoSidebarPanel,
AutoSidebarState, HotbarSlotState, SidebarAgentRow, SidebarFocus, SidebarHoverRow,
SidebarHoverSection, SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem,
SidebarWorkSummary, ToolRowOrder, agent_row_hover_text, auto_sidebar_panels,
background_task_spinner_prefix, cached_agent_activity_is_live, context_panel_cost_line,
editorial_tool_rows, hotbar_panel_enabled, hotbar_panel_hover_texts, hotbar_panel_lines,
hotbar_panel_slots, normalize_activity_text, sidebar_agent_rows, sidebar_auto_idle,
sidebar_hover_rows, sidebar_work_summary, sort_sidebar_agent_rows_as_tree,
subagent_output_handle, subagent_panel_hover_texts, subagent_panel_lines,
subagent_panel_rows, task_panel_hover_texts, task_panel_lines, task_panel_row_sets,
task_panel_rows, work_panel_empty_hint, work_panel_hover_texts, work_panel_lines,
ACTIVE_TOOL_COMPLETED_ROW_TTL, ACTIVE_TOOL_STALE_RUNNING_ROW_TTL, HotbarSlotState,
SidebarAgentRow, SidebarFocus, SidebarHoverRow, SidebarHoverSection,
SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem, SidebarWorkSummary,
ToolRowOrder, agent_row_hover_text, background_task_spinner_prefix,
cached_agent_activity_is_live, context_panel_cost_line, editorial_tool_rows,
hotbar_panel_enabled, hotbar_panel_hover_texts, hotbar_panel_lines, hotbar_panel_slots,
normalize_activity_text, sidebar_agent_rows, sidebar_hover_rows, sidebar_work_summary,
sort_sidebar_agent_rows_as_tree, subagent_output_handle, subagent_panel_hover_texts,
subagent_panel_lines, subagent_panel_rows, task_panel_hover_texts, task_panel_lines,
task_panel_row_sets, task_panel_rows, work_panel_empty_hint, work_panel_hover_texts,
work_panel_lines,
};
use crate::config::Config;
use crate::localization::Locale;
@@ -3302,93 +3154,6 @@ mod tests {
);
}
#[test]
fn auto_sidebar_does_not_reserve_empty_work_when_other_panels_are_active() {
let panels = auto_sidebar_panels(AutoSidebarState {
work_has_content: false,
tasks_empty: false,
agents_empty: true,
context_enabled: false,
sessions_rail_enabled: false,
});
assert_eq!(panels, vec![AutoSidebarPanel::Tasks]);
}
#[test]
fn auto_sidebar_uses_work_as_single_empty_state() {
let panels = auto_sidebar_panels(AutoSidebarState {
work_has_content: false,
tasks_empty: true,
agents_empty: true,
context_enabled: false,
sessions_rail_enabled: false,
});
assert_eq!(panels, vec![AutoSidebarPanel::Work]);
}
#[test]
fn sessions_rail_is_absent_until_the_setting_opts_in() {
let without = auto_sidebar_panels(AutoSidebarState {
work_has_content: true,
tasks_empty: true,
agents_empty: true,
context_enabled: false,
sessions_rail_enabled: false,
});
assert!(!without.contains(&AutoSidebarPanel::Sessions));
let with = auto_sidebar_panels(AutoSidebarState {
work_has_content: true,
tasks_empty: true,
agents_empty: true,
context_enabled: false,
sessions_rail_enabled: true,
});
assert_eq!(
with,
vec![AutoSidebarPanel::Work, AutoSidebarPanel::Sessions],
"the rail renders last so live work keeps the rows above it"
);
}
#[test]
fn an_enabled_rail_keeps_the_empty_work_placeholder_from_taking_the_slot() {
// With nothing live and the rail on, the rail is the content — the
// "one quiet empty state" Work placeholder must not also appear.
let panels = auto_sidebar_panels(AutoSidebarState {
work_has_content: false,
tasks_empty: true,
agents_empty: true,
context_enabled: false,
sessions_rail_enabled: true,
});
assert_eq!(panels, vec![AutoSidebarPanel::Sessions]);
}
#[test]
fn an_enabled_rail_prevents_idle_auto_collapse() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Auto;
// Pin both opt-in panels off: `App::new` reads the developer's real
// persisted settings, so the baseline has to be set, not assumed.
app.context_panel = false;
app.sessions_rail = false;
assert!(
sidebar_auto_idle(&mut app),
"an idle session with no rail should still auto-collapse"
);
app.sessions_rail = true;
assert!(
!sidebar_auto_idle(&mut app),
"an enabled rail is durable content and must survive idle collapse"
);
}
#[test]
fn hotbar_panel_hidden_for_fresh_default_config() {
// #3807: a fresh config has no `hotbar` key, so the panel is hidden
+7 -10
View File
@@ -6199,12 +6199,13 @@ async fn run_event_loop(
continue;
}
// y / Y in the Activity sidebar: yank the current turn id (y)
// y / Y in the rail's Tasks panel: yank the current turn id (y)
// or copy full task detail (Y) to the system clipboard.
// Only active when the composer is empty to avoid stealing
// keystrokes from typed input (#2000).
if app.view_stack.is_empty()
&& app.sidebar_focus == SidebarFocus::Tasks
&& app.work_surface.panel == crate::tui::work_surface::RailPanel::Tasks
&& app.work_surface.last_area.is_some()
&& app.input.is_empty()
&& !app.runtime_turn_id.as_deref().unwrap_or("").is_empty()
{
@@ -17428,7 +17429,8 @@ fn request_active_foreground_shell_background(app: &App) -> Result<()> {
pub(crate) fn prefill_jobs_cancel_all_if_tasks_sidebar(app: &mut App) -> bool {
if !app.view_stack.is_empty()
|| app.sidebar_focus != SidebarFocus::Tasks
|| app.work_surface.panel != crate::tui::work_surface::RailPanel::Tasks
|| app.work_surface.last_area.is_none()
|| !app
.task_panel
.iter()
@@ -17822,13 +17824,8 @@ fn should_tick_status_animation(
}
fn visible_background_task_has_live_motion(app: &App) -> bool {
matches!(
app.sidebar_focus,
SidebarFocus::Auto | SidebarFocus::Pinned | SidebarFocus::Tasks
) && app
.last_sidebar_area
.or(app.viewport.last_sidebar_area)
.is_some()
app.work_surface.panel == crate::tui::work_surface::RailPanel::Tasks
&& app.work_surface.last_area.is_some()
&& app.task_panel.iter().any(|task| task.status == "running")
}
+4 -397
View File
@@ -23,7 +23,6 @@ use crate::tui::history::{
};
use crate::tui::hotbar::actions::{HotbarActionCategory, HotbarDispatch};
use crate::tui::provider_picker::ProviderPickerView;
use crate::tui::sidebar::sidebar_width_for_chat_area;
use crate::tui::ui_text::truncate_line_to_width;
use crate::tui::views::{HelpView, ModalView, ViewAction};
use crate::working_set::Workspace;
@@ -2004,85 +2003,6 @@ fn loading_mouse_filter_keeps_hover_and_active_drags() {
);
}
#[test]
fn loading_mouse_filter_allows_sidebar_resize_down_drag_up() {
let mut app = create_test_app();
app.is_loading = true;
setup_resize_handle(&mut app, 80, 33, 120);
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 80,
row: 5,
modifiers: KeyModifiers::NONE,
};
assert!(!should_drop_loading_mouse_motion(&app, down));
handle_mouse_event(&mut app, down);
assert!(app.sidebar_resizing, "down on handle starts resize");
let drag = MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
};
assert!(
!should_drop_loading_mouse_motion(&app, drag),
"resize drag must not be dropped while loading"
);
handle_mouse_event(&mut app, drag);
let expected = ((37u32 * 100) / 120) as u16;
assert_eq!(app.sidebar_width_percent, expected);
let up = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
};
assert!(!should_drop_loading_mouse_motion(&app, up));
handle_mouse_event(&mut app, up);
assert!(!app.sidebar_resizing);
assert!(app.sidebar_width_dirty);
}
#[test]
fn loading_mouse_filter_allows_sidebar_hover_popovers() {
let mut app = create_test_app();
app.is_loading = true;
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
app.sidebar_hover.sections.push(SidebarHoverSection {
content_area: Rect::new(60, 4, 20, 6),
lines: vec!["Visible row".to_string()],
rows: vec![SidebarHoverRow {
row_y: 5,
display_text: "Truncated".to_string(),
full_text: "Full sidebar task label".to_string(),
detail: Some("Detailed context".to_string()),
is_truncated: true,
click_action: None,
stop_action: None,
stop_zone_start_col: None,
stop_zone_end_col: None,
}],
});
let moved = MouseEvent {
kind: MouseEventKind::Moved,
column: 65,
row: 5,
modifiers: KeyModifiers::NONE,
};
assert!(!should_drop_loading_mouse_motion(&app, moved));
handle_mouse_event(&mut app, moved);
assert_eq!(
app.sidebar_hover_tooltip.as_deref(),
Some("Full sidebar task label\nDetailed context")
);
assert_eq!(app.last_mouse_pos, Some((65, 5)));
}
#[test]
fn loading_mouse_filter_allows_sidebar_hover_to_clear() {
let mut app = create_test_app();
@@ -9176,48 +9096,6 @@ fn sidebar_focus_dirty_persists_saved_focus() {
assert_eq!(settings.sidebar_focus, "hidden");
}
#[test]
fn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {
let mut app = create_test_app();
app.sidebar_width_percent = 28;
app.sidebar_focus = SidebarFocus::Pinned;
assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));
app.sidebar_focus = SidebarFocus::Hidden;
assert_eq!(sidebar_width_for_chat_area(&app, 120), None);
}
#[test]
fn compact_sidebar_split_survives_eighty_column_file_tree_host() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Pinned;
// 80-column body -> 20-column file tree + 60-column chat host.
assert_eq!(sidebar_width_for_chat_area(&app, 60), Some(20));
assert_eq!(sidebar_width_for_chat_area(&app, 59), None);
}
#[test]
fn sidebar_width_floor_raises_with_chat_width() {
// The classic 24-column floor leaves status/status-adjacent info cramped
// at ultrawide sizes. The floor is now width-aware: at least 28 columns,
// scaling to 10% of the chat host on very wide terminals.
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Pinned;
app.sidebar_width_percent = 10;
// Minimum percent at ordinary width: 12 preferred -> 28 floor.
assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(28));
// Ultrawide: 10% floor grows past the 28-column constant.
assert_eq!(sidebar_width_for_chat_area(&app, 320), Some(32));
// The chat host still caps the rail (chat_width - 40).
assert_eq!(sidebar_width_for_chat_area(&app, 80), Some(28));
// Above the floor the configured percent still rules.
app.sidebar_width_percent = 50;
assert_eq!(sidebar_width_for_chat_area(&app, 200), Some(100));
}
#[test]
fn rail_command_reports_off_without_claiming_visibility() {
// Replaces the old sidebar_render_state tests: the render-state machine
@@ -9239,77 +9117,6 @@ fn rail_command_reports_off_without_claiming_visibility() {
);
}
#[test]
fn sidebar_auto_idle_collapses_when_nothing_active() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Auto;
// A fresh session has no To-do, no fleet, no background jobs, no context.
assert!(crate::tui::sidebar::sidebar_auto_idle(&mut app));
}
#[test]
fn sidebar_auto_idle_false_when_fleet_active() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Auto;
app.agent_progress
.insert("agent_1".to_string(), "running".to_string());
assert!(!crate::tui::sidebar::sidebar_auto_idle(&mut app));
}
#[test]
fn sidebar_auto_idle_false_for_explicit_focus() {
let mut app = create_test_app();
// An explicit panel pin is never auto-collapsed.
app.sidebar_focus = SidebarFocus::Agents;
assert!(!crate::tui::sidebar::sidebar_auto_idle(&mut app));
}
#[test]
fn jobs_panel_ignores_completed_history_but_shows_for_real_jobs() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Auto;
// Completed background history must not reopen the auto Tasks panel.
app.task_panel.push(crate::tui::app::TaskPanelEntry {
id: "shell_1".to_string(),
status: "completed".to_string(),
prompt_summary: "shell: cargo fmt".to_string(),
duration_ms: Some(10),
kind: crate::tui::app::TaskPanelEntryKind::Background,
stale: false,
elapsed_since_output_ms: None,
owner_agent_id: None,
owner_agent_name: None,
current_tool: None,
role: None,
files_touched: 0,
});
assert!(
crate::tui::sidebar::sidebar_auto_idle(&mut app),
"completed background jobs must not reopen the auto jobs panel"
);
// A live background job (Background + running/queued) does surface it.
app.task_panel.push(crate::tui::app::TaskPanelEntry {
id: "shell_2".to_string(),
status: "running".to_string(),
prompt_summary: "shell: cargo test".to_string(),
duration_ms: Some(10),
kind: crate::tui::app::TaskPanelEntryKind::Background,
stale: false,
elapsed_since_output_ms: None,
owner_agent_id: None,
owner_agent_name: None,
current_tool: None,
role: None,
files_touched: 0,
});
assert!(
!crate::tui::sidebar::sidebar_auto_idle(&mut app),
"a live background job must surface the jobs panel"
);
}
#[test]
fn background_receipt_tip_only_detects_a_visible_active_to_completed_transition() {
let active = HashSet::from(["task_running", "shell_running"]);
@@ -9323,7 +9130,8 @@ fn background_receipt_tip_only_detects_a_visible_active_to_completed_transition(
#[test]
fn ctrl_x_jobs_prefill_only_catches_running_shell_jobs_in_tasks_sidebar() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Tasks;
app.work_surface.panel = crate::tui::work_surface::RailPanel::Tasks;
app.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 100, 3));
app.input = "draft".to_string();
app.cursor_position = app.input.len();
app.task_panel.push(TaskPanelEntry {
@@ -9375,7 +9183,8 @@ fn ctrl_x_jobs_prefill_falls_through_outside_tasks_sidebar_shell_jobs() {
assert_eq!(non_shell.input, "draft");
let mut other_sidebar = create_test_app();
other_sidebar.sidebar_focus = SidebarFocus::Agents;
other_sidebar.work_surface.panel = crate::tui::work_surface::RailPanel::Agents;
other_sidebar.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 100, 3));
other_sidebar.input = "draft".to_string();
other_sidebar.cursor_position = other_sidebar.input.len();
other_sidebar.task_panel.push(TaskPanelEntry {
@@ -9399,208 +9208,6 @@ fn ctrl_x_jobs_prefill_falls_through_outside_tasks_sidebar_shell_jobs() {
assert_eq!(other_sidebar.input, "draft");
}
// ── Sidebar resize-handle mouse tests ──────────────────────────────
fn setup_resize_handle(app: &mut App, handle_x: u16, sidebar_width: u16, total_width: u16) {
let y = 2;
let h = 10;
app.last_sidebar_handle_area = Some(Rect {
x: handle_x,
y,
width: 1,
height: h,
});
app.last_sidebar_area = Some(Rect {
x: handle_x,
y,
width: sidebar_width,
height: h,
});
app.sidebar_resize_total_width = total_width;
app.sidebar_width_percent = 28;
}
#[test]
fn sidebar_resize_down_on_handle_starts_resizing() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 80,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(
app.sidebar_resizing,
"should start resizing on handle click"
);
assert_eq!(app.sidebar_resize_anchor_x, 80);
assert_eq!(app.sidebar_resize_anchor_width, 33);
}
#[test]
fn sidebar_resize_handle_tracks_hover_for_visible_feedback() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Moved,
column: 80,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(app.sidebar_resize_hovered);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Moved,
column: 79,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(!app.sidebar_resize_hovered);
}
#[test]
fn sidebar_resize_down_outside_handle_does_not_start_resizing() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 79, // one column left of handle
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(
!app.sidebar_resizing,
"should not resize on non-handle click"
);
}
#[test]
fn sidebar_resize_drag_adjusts_width_percent() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
// 33 / 120 * 100 ≈ 27.5 → initial percent = 28 (the setup defaults to 28)
app.sidebar_width_percent = 28;
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
// Drag left by 4 cols (making sidebar wider): 33 + 4 = 37 → 37/120*100 ≈ 30
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
let expected = ((37u32 * 100) / 120) as u16; // ~30
assert_eq!(app.sidebar_width_percent, expected);
}
#[test]
fn sidebar_resize_drag_clamps_to_10_50_range() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
// Drag far right → sidebar should shrink but not below 10%
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 200,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(app.sidebar_width_percent >= 10);
// Drag far left → sidebar should grow but not above 50%
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 0,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(app.sidebar_width_percent <= 50);
}
#[test]
fn sidebar_resize_up_ends_resizing_and_marks_dirty() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(!app.sidebar_resizing, "should stop resizing on mouse up");
assert!(
app.sidebar_width_dirty,
"should mark width dirty for persistence"
);
}
#[test]
fn sidebar_resize_up_outside_handle_still_ends_resizing() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
// Release far away from the handle and the sidebar entirely.
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 5,
row: 20,
modifiers: KeyModifiers::NONE,
},
);
assert!(
!app.sidebar_resizing,
"mouse up must clear resize state even outside the handle"
);
assert!(app.sidebar_width_dirty);
}
fn make_subagent(
id: &str,
status: crate::tools::subagent::SubAgentStatus,
+2 -75
View File
@@ -34,7 +34,7 @@ use unicode_width::UnicodeWidthStr;
use crate::localization::{Locale, MessageId, tr};
use crate::palette;
use crate::tui::app::{App, AppMode, SidebarFocus};
use crate::tui::app::{App, AppMode};
use super::Renderable;
@@ -74,9 +74,6 @@ pub struct FooterProps {
pub mcp: Vec<Span<'static>>,
/// Permission posture chip (Ask / Auto-Review / Full Access) when visible.
pub permission: Vec<Span<'static>>,
/// Compact nonempty Work indicator when terminal width suppresses the
/// sidebar. Empty when Work is visible or explicitly hidden.
pub work: Vec<Span<'static>>,
/// Cumulative model-work chip spans ("worked 3m 12s"). Sums the
/// elapsed time of completed turns (from `App::cumulative_turn_duration`),
/// **not** wall-clock since launch — an idle TUI shouldn't claim
@@ -289,7 +286,6 @@ impl FooterProps {
.map(|s| s.servers.iter().filter(|server| server.connected).count());
let mcp = footer_mcp_chip(mcp_connected, mcp_configured);
let permission = footer_permission_chip(app);
let work = footer_compact_work_chip(app);
// #448: cumulative work-time chip. Sums actual turn durations
// (set on `TurnComplete`) rather than wall-clock uptime — a TUI
// that's been open and idle for 4 minutes shouldn't claim
@@ -311,7 +307,6 @@ impl FooterProps {
cache,
mcp,
permission,
work,
worked,
cost,
balance,
@@ -353,22 +348,6 @@ pub fn footer_permission_chip(app: &App) -> Vec<Span<'static>> {
]
}
fn footer_compact_work_chip(app: &App) -> Vec<Span<'static>> {
if app.sidebar_focus == SidebarFocus::Hidden
|| app
.last_sidebar_host_width
.is_none_or(|width| width >= crate::tui::ui::FILE_TREE_MIN_HOST_WIDTH)
{
return Vec::new();
}
crate::tui::sidebar::compact_work_indicator(app).map_or_else(Vec::new, |label| {
vec![Span::styled(
label,
Style::default().fg(palette::WHALE_INFO),
)]
})
}
/// Pure-render footer. Build once per frame, then `render(area, buf)`.
pub struct FooterWidget {
props: FooterProps,
@@ -387,7 +366,6 @@ impl FooterWidget {
// disappear without disturbing the steady mode·model·cost line.
let parts: Vec<&Vec<Span<'static>>> = [
&self.props.permission,
&self.props.work,
&self.props.agents,
&self.props.reasoning_replay,
&self.props.cache,
@@ -702,9 +680,7 @@ impl Renderable for FooterWidget {
// long toast/model label to consume the row, then let lower-priority
// auxiliary chips fill whatever remains.
let permission_width = span_width(&self.props.permission);
let work_width = span_width(&self.props.work);
let critical_inner_gap = usize::from(permission_width > 0 && work_width > 0) * 2;
let critical_width = permission_width + critical_inner_gap + work_width;
let critical_width = permission_width;
let reserved_gap = usize::from(critical_width > 0) * 2;
let preview_left_budget = if critical_width > 0 {
available_width
@@ -1303,55 +1279,6 @@ mod tests {
);
}
#[test]
fn width_suppressed_sidebar_falls_back_to_compact_work_chip() {
let mut app = make_app();
app.mode = AppMode::Operate;
app.approval_mode = crate::tui::approval::ApprovalMode::Bypass;
app.last_sidebar_host_width = Some(59);
{
let mut todos = app.todos.try_lock().expect("todos lock");
todos.add(
"inspect".to_string(),
crate::tools::todo::TodoStatus::Completed,
);
todos.add(
"patch".to_string(),
crate::tools::todo::TodoStatus::InProgress,
);
}
let props = FooterProps::from_app(
&app,
None,
"ready",
palette::TEXT_MUTED,
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
);
assert_eq!(super::spans_text(&props.work), "To-do 2 · 50%");
let line = render_at_width(props, 59);
assert!(line.contains("To-do 2 · 50%"), "{line:?}");
assert!(line.contains("perm Full Access"), "{line:?}");
app.sidebar_focus = crate::tui::app::SidebarFocus::Hidden;
let hidden = FooterProps::from_app(
&app,
None,
"ready",
palette::TEXT_MUTED,
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
);
assert!(hidden.work.is_empty());
}
#[test]
fn permission_safety_chip_survives_long_toast_at_release_widths() {
let mut app = make_app();
+9 -1
View File
@@ -3254,7 +3254,15 @@ fn should_render_empty_state(app: &App) -> bool {
.task_panel
.iter()
.any(|task| task.kind == crate::tui::app::TaskPanelEntryKind::Background)
&& crate::tui::sidebar::compact_work_indicator(app).is_none()
// Live work suppresses the empty state. On lock contention, treat
// the todo store as non-empty rather than flash the empty ocean.
&& !app
.todos
.try_lock()
.map(|todos| !todos.snapshot().is_empty())
.unwrap_or(true)
&& app.hunt.quarry.is_none()
&& app.paused_quarry.is_none()
}
fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {