feat(tui): clarify Plan panel role + drop empty-state placeholder (#408)

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) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-03 04:00:47 -05:00
parent 256f59dd33
commit 68ec91999b
+83 -5
View File
@@ -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<Lin
#[cfg(test)]
mod tests {
use super::{SidebarSubagentSummary, subagent_navigator_lines};
use super::{SidebarSubagentSummary, plan_panel_empty_hint, subagent_navigator_lines};
use ratatui::text::Line;
fn lines_to_text(lines: &[Line<'static>]) -> Vec<String> {
@@ -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();