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