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:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
Reference in New Issue
Block a user