From 68ec91999bfdda42996922cdf2e5314bd251e924 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 3 May 2026 04:00:47 -0500 Subject: [PATCH] feat(tui): clarify Plan panel role + drop empty-state placeholder (#408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plan panel used to render a blunt "No active plan" line whenever the model hadn't called \`update_plan\` yet — even when the panel had a goal or a cycle counter to show above it. That made the panel look broken on every fresh session. Per the audit posted on the issue (option 1 of three), this PR keeps the Plan panel as the **single source of truth for the \`update_plan\` tool's output** and drops the placeholder when the panel is fully quiet, replacing it with a one-line hint that explains what the panel tracks. When a goal or cycle counter is already showing above, the empty-steps body collapses entirely so the section doesn't look ambiguous next to populated content. The panel's role is documented in a doc comment on \`render_sidebar_plan\` so the next person doesn't have to re-derive it from the issue tracker. ### What's wired - \`render_sidebar_plan\` checks "anything above" (goal + cycle_count) before deciding whether to emit the empty-state hint. If either is showing, the empty steps body adds nothing. - New \`plan_panel_empty_hint(width)\` helper composes the hint string with proper width-aware truncation. - New module-level doc comment explains the Plan panel's role (update_plan output + /goal + cycle counter) and contrasts it with Todos. ### Tests - 3 new tests in \`tui::sidebar::tests\`: - hint mentions \`update_plan\` and \`/goal\` so the user understands what populates the panel - hint truncates correctly to a 16-column sidebar without overflowing - regression guard: the hint never re-introduces "no active plan" wording ### Verification cargo fmt --all -- --check ✓ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings ✓ cargo test --workspace --all-features --locked ✓ 1850 + supporting Closes #408 (option 1 of the audit). Options 2 (merge with todos) and 3 (move to top-row chip) remain open as v0.9.0 design candidates once #504's right-panel work is on the table. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/tui/sidebar.rs | 88 +++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 9cf32914..caf6a9ec 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -60,6 +60,21 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) { } } +/// The Plan section is the **single source of truth for the +/// `update_plan` tool's output** (#408). It is intentionally distinct +/// from the Todos section: todos are checklist work items the user +/// or model is tracking; plan steps are the model's higher-level +/// strategy as recorded by `update_plan`. The panel also hosts two +/// session-wide indicators that don't fit the other sections — Goal +/// (`/goal`) and the cycle counter (#124) — because they share the +/// "what's the agent trying to do, big-picture" theme. +/// +/// When the panel is fully empty (no goal, no cycles, no plan) it +/// renders as a quiet section with a single dim hint at the bottom +/// rather than the blunt "No active plan" placeholder it used to show. +/// That kept the user wondering whether the panel was broken; the +/// hint instead tells them what the panel is for and how to populate +/// it. fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) { if area.height < 3 { return; @@ -123,10 +138,21 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) { match app.plan_state.try_lock() { Ok(plan) => { if plan.is_empty() { - lines.push(Line::from(Span::styled( - "No active plan", - Style::default().fg(theme.plan_summary_color), - ))); + // The blunt "No active plan" placeholder used to land + // here on every render with no plan steps, even when the + // user had a goal set or had cycled — making the panel + // look broken. After #408 we instead emit a quiet hint + // that explains what the panel is for, but only when + // *all* of the panel's signals are empty so we don't + // crowd a panel that already has a goal / cycle + // indicator above. + let nothing_above = app.goal.goal_objective.is_none() && app.cycle_count == 0; + if nothing_above { + lines.push(Line::from(Span::styled( + plan_panel_empty_hint(content_width.max(1)), + Style::default().fg(palette::TEXT_MUTED).italic(), + ))); + } } else { let (pending, in_progress, completed) = plan.counts(); let total = pending + in_progress + completed; @@ -187,6 +213,17 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) { render_sidebar_section(f, area, "Plan", lines); } +/// One-line hint shown when the Plan section has nothing to display +/// (no goal, no cycle, no steps). Ellipsizes for narrow widths so +/// even a 24-column sidebar doesn't wrap mid-word. Visible across +/// modes — the panel's role doesn't change between Plan / Agent / +/// YOLO; only its content does. +#[must_use] +fn plan_panel_empty_hint(content_width: usize) -> String { + let full = "tracks update_plan / /goal / cycles"; + truncate_line_to_width(full, content_width) +} + fn render_sidebar_todos(f: &mut Frame, area: Rect, app: &App) { if area.height < 3 { return; @@ -559,7 +596,7 @@ fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec]) -> Vec { @@ -574,6 +611,47 @@ mod tests { .collect() } + // ---- #408 Plan panel empty-state hint ---- + + #[test] + fn plan_panel_empty_hint_mentions_panels_role() { + // The hint replaces the old "No active plan" placeholder; it + // should explain what the panel tracks so the user can tell + // whether the panel is broken vs simply unused this turn. + let hint = plan_panel_empty_hint(80); + assert!( + hint.contains("update_plan"), + "hint should name the tool: {hint:?}" + ); + assert!( + hint.contains("/goal") || hint.contains("goal"), + "hint should mention /goal: {hint:?}" + ); + } + + #[test] + fn plan_panel_empty_hint_truncates_to_narrow_widths() { + // Width 16 forces an ellipsis; the hint should still fit. + let hint = plan_panel_empty_hint(16); + assert!( + hint.chars().count() <= 16, + "hint width {} > 16: {hint:?}", + hint.chars().count() + ); + } + + #[test] + fn plan_panel_empty_hint_does_not_say_no_active_plan() { + // Regression guard: the placeholder used to say "No active + // plan" which made the panel look broken. The hint should + // never re-introduce that wording. + let hint = plan_panel_empty_hint(80); + assert!( + !hint.to_ascii_lowercase().contains("no active plan"), + "hint regressed to old placeholder: {hint:?}" + ); + } + #[test] fn navigator_empty_state_says_no_agents() { let summary = SidebarSubagentSummary::default();