fix(tui): restore content-first top strip with goal-only titles

Rail unification fixed non-Tasks panels at four rows with a loud panel
name. Top now auto-fits content like Tasks always did; Left/Right collapse
when empty and keep configured width as the ceiling. The only Top title is
an active goal (shared with the ocean header chip) — never "Pinned".

Verified: cargo test -p codewhale-tui --bin codewhale-tui -- work_surface::
(67 passed).
This commit is contained in:
Hmbown
2026-08-04 00:22:54 -07:00
parent c2facb653b
commit f3cdc3485e
4 changed files with 567 additions and 142 deletions
+70 -33
View File
@@ -164,11 +164,45 @@ pub(crate) fn work_panel_lines(
max_rows: usize,
palette_mode: palette::PaletteMode,
ui_theme: &palette::UiTheme,
) -> Vec<Line<'static>> {
work_panel_lines_with_opts(
summary,
content_width,
max_rows,
palette_mode,
ui_theme,
WorkPanelOpts::default(),
)
}
/// Options for the Pinned work panel body.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct WorkPanelOpts {
/// When true, skip the primary `Goal: …` objective line. Used on Top
/// placement where that line is already the strip title — repeating it
/// in the body wastes a scarce row.
pub omit_goal_objective: bool,
}
pub(crate) fn work_panel_lines_with_opts(
summary: &SidebarWorkSummary,
content_width: usize,
max_rows: usize,
palette_mode: palette::PaletteMode,
ui_theme: &palette::UiTheme,
opts: WorkPanelOpts,
) -> Vec<Line<'static>> {
let _ = palette_mode;
let mut lines: Vec<Line<'static>> = Vec::with_capacity(max_rows.max(4));
push_work_goal_lines(summary, content_width, max_rows, &mut lines, ui_theme);
push_work_goal_lines(
summary,
content_width,
max_rows,
&mut lines,
ui_theme,
opts.omit_goal_objective,
);
if summary.state_updating && lines.len() < max_rows {
lines.push(Line::from(Span::styled(
@@ -206,6 +240,7 @@ fn push_work_goal_lines(
max_rows: usize,
lines: &mut Vec<Line<'static>>,
theme: &palette::UiTheme,
omit_objective: bool,
) {
let Some(objective) = summary.goal_objective.as_deref() else {
return;
@@ -214,39 +249,41 @@ fn push_work_goal_lines(
return;
}
let icon = if summary.goal_completed {
crate::tui::glyphs::DONE
} else if summary.workflow_paused {
crate::tui::glyphs::PAUSED
} else {
crate::tui::glyphs::ATTENTION
};
let status_style = if summary.goal_completed {
Style::default()
.fg(theme.success)
.add_modifier(ratatui::style::Modifier::BOLD)
} else {
Style::default()
.fg(theme.warning)
.add_modifier(ratatui::style::Modifier::BOLD)
};
// Show the full goal objective — this is goal mode's primary status
// surface. Prefix with "Goal:" so the compact row is clearly labelled
// as a goal-mode objective, not a generic session title.
let label = if let Some(indicator) = summary.pause_indicator.as_deref() {
format!("Goal: {objective} {indicator}")
} else {
format!("Goal: {objective}")
};
if !omit_objective {
let icon = if summary.goal_completed {
crate::tui::glyphs::DONE
} else if summary.workflow_paused {
crate::tui::glyphs::PAUSED
} else {
crate::tui::glyphs::ATTENTION
};
let status_style = if summary.goal_completed {
Style::default()
.fg(theme.success)
.add_modifier(ratatui::style::Modifier::BOLD)
} else {
Style::default()
.fg(theme.warning)
.add_modifier(ratatui::style::Modifier::BOLD)
};
// Show the full goal objective — this is goal mode's primary status
// surface. Prefix with "Goal:" so the compact row is clearly labelled
// as a goal-mode objective, not a generic session title.
let label = if let Some(indicator) = summary.pause_indicator.as_deref() {
format!("Goal: {objective} {indicator}")
} else {
format!("Goal: {objective}")
};
lines.push(Line::from(Span::styled(
format!(
"{} {}",
icon,
truncate_line_to_width(&label, content_width.saturating_sub(2).max(1))
),
status_style,
)));
lines.push(Line::from(Span::styled(
format!(
"{} {}",
icon,
truncate_line_to_width(&label, content_width.saturating_sub(2).max(1))
),
status_style,
)));
}
// Elapsed time
if let Some(started) = summary.goal_started_at
+234 -30
View File
@@ -10,18 +10,33 @@
//! Two settings are orthogonal and are routinely mixed up:
//!
//! - **placement** — where it renders. `Top` (default) | `Left` | `Right` |
//! `Off`. Top height comes from `work_surface_top_height` (2..=16), which
//! drag-resizing the divider persists to `settings.toml`.
//! `Off`. Drag-resizing the divider persists `work_surface_top_height`
//! (2..=16) or `work_surface_side_width` (26..=80) to `settings.toml`.
//! - **panel** — what it shows. [`RailPanel`]: `Tasks` (default) | `Agents` |
//! `Context` | `Pinned`, from the `rail_panel` setting. The legacy
//! `sidebar_focus` key migrates into it.
//!
//! So the word "Pinned" on screen is a PANEL name, not a state.
//!
//! ## Auto-fit by placement
//!
//! Placement changes *which axis is the ceiling*, not the content rule:
//!
//! | Placement | Ceiling | Auto-fit | Empty |
//! |---|---|---|---|
//! | `Top` | `top_height` (rows) | content rows + divider, clamped to ceiling | `height() == 0` |
//! | `Left`/`Right` | `side_width` (cols) | full chat height at that width | no column reserved |
//! | `Off` | — | — | nothing |
//!
//! Shared rules: content drives size; the setting is a ceiling, never padding;
//! empty work is not a rail. Top never paints a chrome panel title (a checklist
//! reads as a checklist). Side rails keep a muted title because a full-height
//! column among other chrome needs naming. Narrow hosts that cannot fit a side
//! column fall back to Top, where height auto-fit takes over.
//!
//! Height is decided once per frame by [`render::height`]; the row budget it is
//! given comes from `crate::tui::ui::rail_row_budget`, which is its only
//! production caller. An empty panel returns 0 rows rather than rendering a
//! title over nothing.
//! production caller.
//!
//! Placement, scrolling, selection, and pager ownership remain local to this
//! component. Every visible work row derives from the active-session graph.
@@ -639,24 +654,169 @@ mod tests {
}
}
/// The collapse cliff belongs to the terminal, not to the user. A short
/// `top_height` is a request, and honouring it costs the transcript
/// nothing: the budget is what protects the transcript's floor.
/// `top_height` is a ceiling, not a fixed size. A short ceiling must still
/// render (not collapse), and content longer than the ceiling is clamped
/// to it rather than padded with blank water.
#[test]
fn a_short_top_height_is_honoured_rather_than_collapsed() {
for top_height in [2_u16, 3] {
let mut app = app();
app.work_surface.placement = WorkSurfacePlacement::Top;
app.work_surface.panel = super::RailPanel::Agents;
app.work_surface.top_height = top_height;
app.composer_border = true;
let budget = working_budget(&app, 40);
assert_eq!(
super::height(&mut app, 100, 40, budget),
top_height,
"a {top_height}-row strip was asked for and must be what renders"
);
}
fn a_short_top_height_caps_content_rather_than_collapsing() {
let mut capped = app();
capped.work_surface.placement = WorkSurfacePlacement::Top;
capped.work_surface.panel = super::RailPanel::Pinned;
capped.work_surface.top_height = 2;
capped.composer_border = true;
// Goal + several checklist rows: content wants more than 2, the cap wins.
capped.hunt.quarry = Some("ship the release".to_string());
add_todos(&mut capped, 6);
let budget = working_budget(&capped, 40);
assert_eq!(
super::height(&mut capped, 100, 40, budget),
2,
"short top_height is a cap the strip must fit under, not a cliff"
);
// Content shorter than the cap shrinks: a single goal line + divider
// is 2 rows, not a padded 8-row band.
let mut short = app();
short.work_surface.placement = WorkSurfacePlacement::Top;
short.work_surface.panel = super::RailPanel::Pinned;
short.work_surface.top_height = 8;
short.hunt.quarry = Some("one goal only".to_string());
let budget = working_budget(&short, 40);
let h = super::height(&mut short, 100, 40, budget);
assert!(
(2..=4).contains(&h),
"short content auto-fits under the cap, got {h}"
);
}
/// Non-Tasks Top panels auto-fit the same way Tasks always did: content
/// rows + divider, never a fixed four-row chrome band. An active goal
/// adds exactly one title row (not a panel name).
#[test]
fn top_panel_auto_fits_content_like_tasks() {
let mut pinned = app();
pinned.work_surface.placement = WorkSurfacePlacement::Top;
pinned.work_surface.panel = super::RailPanel::Pinned;
pinned.work_surface.top_height = 12;
pinned.hunt.quarry = Some("goal".to_string());
add_todos(&mut pinned, 3);
let budget = working_budget(&pinned, 40);
let h = super::height(&mut pinned, 100, 40, budget);
// goal title + 3 checklist + divider ≈ 5; must not be the old fixed 4,
// and must not pad out to the 12-row cap.
assert!(
h >= 4 && h <= 8,
"Pinned should auto-fit checklist content, got {h}"
);
// Empty Pinned collapses entirely.
let mut empty = app();
empty.work_surface.placement = WorkSurfacePlacement::Top;
empty.work_surface.panel = super::RailPanel::Pinned;
empty.work_surface.top_height = 12;
assert_eq!(
super::height(&mut empty, 100, 40, AMPLE_BUDGET),
0,
"empty Pinned is not a panel"
);
// Empty Agents collapses too (no "No agents" chrome strip).
let mut agents = app();
agents.work_surface.placement = WorkSurfacePlacement::Top;
agents.work_surface.panel = super::RailPanel::Agents;
agents.work_surface.top_height = 12;
assert_eq!(
super::height(&mut agents, 100, 40, AMPLE_BUDGET),
0,
"empty Agents is not a panel"
);
}
/// Top titles only when a live goal is set — never the panel name.
#[test]
fn top_title_is_goal_only_never_panel_chrome() {
// With a goal: title is "Goal: …".
let mut with_goal = app();
with_goal.work_surface.placement = WorkSurfacePlacement::Top;
with_goal.work_surface.panel = super::RailPanel::Pinned;
with_goal.work_surface.top_height = 8;
with_goal.hunt.quarry = Some("ship 0.9.4".to_string());
let text = render_text(&mut with_goal, 80, 8);
assert!(
text.contains("Goal: ship 0.9.4"),
"active goal must be the Top title: {text:?}"
);
assert!(
!text.contains("Pinned"),
"panel name is not a Top title: {text:?}"
);
// Without a goal, only checklist: no Goal title, no Pinned chrome.
let mut no_goal = app();
no_goal.work_surface.placement = WorkSurfacePlacement::Top;
no_goal.work_surface.panel = super::RailPanel::Pinned;
no_goal.work_surface.top_height = 8;
add_todos(&mut no_goal, 2);
let text = render_text(&mut no_goal, 80, 6);
assert!(
!text.contains("Goal:"),
"no live goal → no Goal title: {text:?}"
);
assert!(
!text.contains("Pinned"),
"panel name is never a Top title: {text:?}"
);
}
/// Tasks with only a goal (no todos/agents) still shows a strip.
#[test]
fn top_tasks_goal_alone_still_renders_a_strip() {
let mut app = app();
app.work_surface.placement = WorkSurfacePlacement::Top;
app.work_surface.panel = super::RailPanel::Tasks;
app.work_surface.top_height = 8;
app.hunt.quarry = Some("only a goal".to_string());
let budget = working_budget(&app, 40);
let h = super::height(&mut app, 100, 40, budget);
assert!(
h >= 2,
"goal alone must reserve title + divider, got {h}"
);
let text = render_text(&mut app, 80, h);
assert!(
text.contains("Goal: only a goal"),
"goal-alone strip must paint the title: {text:?}"
);
}
/// Side rails share the empty-collapse rule: no content → no column.
/// Width stays the configured ceiling when content exists.
#[test]
fn side_rail_collapses_when_empty_and_reserves_when_contentful() {
let area = ratatui::layout::Rect::new(0, 0, 120, 32);
// Empty Pinned: no side column.
let mut empty = app();
empty.work_surface.placement = WorkSurfacePlacement::Right;
empty.work_surface.panel = super::RailPanel::Pinned;
empty.work_surface.side_width = 30;
assert_eq!(
super::split_chat(&mut empty, area, 0),
(area, None),
"empty Pinned must not reserve a side column"
);
// Contentful Pinned: full-height column at configured width.
let mut full = app();
full.work_surface.placement = WorkSurfacePlacement::Right;
full.work_surface.panel = super::RailPanel::Pinned;
full.work_surface.side_width = 30;
full.hunt.quarry = Some("ship it".to_string());
let (chat, rail) = super::split_chat(&mut full, area, 0);
let rail = rail.expect("contentful Pinned reserves a side rail");
assert_eq!(rail.width, 30);
assert_eq!(chat.width, area.width - 30);
assert_eq!(rail.height, area.height);
}
#[test]
@@ -1176,8 +1336,8 @@ mod tests {
/// Render-level smoke coverage for the ported rail panels — reinstates
/// the sidebar render smoke tests removed with the classic shell
/// (739616787). Every non-Tasks panel must render its title in every
/// placement the rail supports.
/// (739616787). Placement decides chrome: side rails keep a muted panel
/// title; Top never spends a row on one (content is self-evident).
#[test]
fn rail_panels_render_in_all_placements() {
for panel in [
@@ -1193,10 +1353,19 @@ mod tests {
let mut app = app();
app.work_surface.placement = placement;
app.work_surface.panel = panel;
// This test is about placement, not about the empty state. An
// empty Pinned panel deliberately renders nothing, so give the
// work summary content or Top would have no title to find.
// Content so empty-collapse does not hide the panel. Agents
// needs a cached worker; Pinned needs a goal; Context always
// has session facts.
app.hunt.quarry = Some("ship the release".to_string());
if panel == super::RailPanel::Agents {
app.subagent_cache.push(cached_worker(
"agent-a",
"explore",
Some("scout"),
None,
SubAgentStatus::Running,
));
}
let area = ratatui::layout::Rect::new(0, 0, 100, 24);
// Render coverage, not yield coverage: a 24-row terminal with
@@ -1222,10 +1391,45 @@ mod tests {
})
.expect("draw");
let text = terminal_text(&terminal);
assert!(
text.contains(panel.title()),
"{panel:?} in {placement:?} should render its title; got: {text}"
);
match placement {
super::WorkSurfacePlacement::Top => {
assert!(
strip > 0,
"{panel:?} on Top should auto-fit a content strip; got height 0"
);
// Panel chrome ("Pinned"/"Agents") never on Top.
// An active goal *is* a title — and this fixture sets one.
assert!(
!text.contains(panel.title())
|| panel.title() == "Context" && text.contains("Context"),
"{panel:?} on Top must not spend a row on panel chrome; got: {text}"
);
if panel != super::RailPanel::Context {
assert!(
!text
.split_whitespace()
.any(|tok| tok == panel.title()),
"{panel:?} on Top must not print the panel name as chrome; got: {text}"
);
}
// Goal title when a live goal is set.
assert!(
text.contains("Goal:") && text.contains("ship the release"),
"Top with an active goal must title with Goal: …; got: {text}"
);
}
super::WorkSurfacePlacement::Left | super::WorkSurfacePlacement::Right => {
assert!(
rail.is_some() || strip > 0,
"{panel:?} in {placement:?} should reserve a rail"
);
assert!(
text.contains(panel.title()),
"{panel:?} in {placement:?} should render its muted title; got: {text}"
);
}
super::WorkSurfacePlacement::Off => {}
}
}
}
}
+76 -5
View File
@@ -1,10 +1,17 @@
//! Non-Tasks rail panels, ported from the legacy classic-shell sidebar
//! during the 0.9.4 rail unification (spec step 2). Agents, Context, and
//! Pinned render as titled line lists inside the one work-surface rail, in
//! Pinned render as line lists inside the one work-surface rail, in
//! whatever placement the user picked; panel selection is orthogonal to
//! placement. The Tasks panel is *not* here — it renders through the
//! row/hitbox machinery in `render.rs`.
//!
//! On Top placement the strip auto-fits its content the way Tasks always
//! did (and the way GrokBuild's tasks pane does): a two-agent fan-out is
//! two rows, not a fixed four-row band with a chrome title. The only Top
//! title is an active **goal** (not panel names like "Pinned"). Side rails
//! keep a muted panel label because a column among other chrome needs
//! naming.
//!
//! The line builders themselves still live in `tui::sidebar` (they are
//! `pub(crate)` there) while the sidebar module is wound down; the rail is
//! their only production caller now.
@@ -12,18 +19,27 @@
use ratatui::text::Line;
use crate::tui::app::App;
use crate::tui::sidebar::{self, SidebarSubagentSummary};
use crate::tui::sidebar::{self, SidebarSubagentSummary, WorkPanelOpts};
use crate::tui::subagent_routing::active_fanout_counts;
use super::model::RailPanel;
/// Cap used when measuring natural content height so a pathological
/// checklist cannot allocate unbounded lines during layout. The strip's
/// real cap (`top_cap`) still clamps the visible window.
const NATURAL_HEIGHT_PROBE: usize = 64;
/// Display lines for a non-Tasks rail panel, or `None` for Tasks (which the
/// caller renders through the row machinery instead).
///
/// `omit_goal_objective` is set on Top when the goal is already the strip
/// title, so Pinned does not repeat `Goal: …` in the body.
pub(crate) fn panel_lines(
app: &mut App,
panel: RailPanel,
content_width: usize,
max_rows: usize,
omit_goal_objective: bool,
) -> Option<Vec<Line<'static>>> {
let content_width = content_width.max(1);
let max_rows = max_rows.max(1);
@@ -31,10 +47,59 @@ pub(crate) fn panel_lines(
RailPanel::Tasks => None,
RailPanel::Agents => Some(agents_panel_lines(app, content_width, max_rows)),
RailPanel::Context => Some(sidebar::context_panel_lines(app, content_width)),
RailPanel::Pinned => Some(pinned_panel_lines(app, content_width, max_rows)),
RailPanel::Pinned => Some(pinned_panel_lines(
app,
content_width,
max_rows,
omit_goal_objective,
)),
}
}
/// Whether a non-Tasks panel has anything worth spending a top-strip row on.
/// Empty projections collapse to zero the way Tasks does — an empty panel is
/// not a panel. Context always has session facts, so it always has content.
pub(crate) fn panel_has_useful_content(app: &mut App, panel: RailPanel) -> bool {
match panel {
RailPanel::Tasks => true,
RailPanel::Pinned => sidebar::sidebar_work_summary(app).has_useful_content(),
RailPanel::Agents => agents_have_useful_content(app),
RailPanel::Context => true,
}
}
/// Natural content row count for height auto-fit. Does not include the
/// divider row or the optional Top goal title that `height()` adds.
pub(crate) fn panel_content_row_count(
app: &mut App,
panel: RailPanel,
content_width: usize,
omit_goal_objective: bool,
) -> usize {
panel_lines(
app,
panel,
content_width,
NATURAL_HEIGHT_PROBE,
omit_goal_objective,
)
.map(|lines| lines.len())
.unwrap_or(0)
}
fn agents_have_useful_content(app: &App) -> bool {
if !app.subagent_cache.is_empty() {
return true;
}
if !app.agent_progress.is_empty() {
return true;
}
if active_fanout_counts(app).is_some_and(|(_, total)| total > 0) {
return true;
}
sidebar::foreground_rlm_running(app)
}
/// Agents panel: cached sub-agents plus progress-only and fanout signals.
/// The summary projection is lifted from the legacy `render_sidebar_subagents`
/// so the panel keeps its exact content in the rail.
@@ -87,13 +152,19 @@ fn agents_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec<L
/// Pinned panel: the durable work summary (goal + checklist) the legacy
/// sidebar showed in Pinned focus.
fn pinned_panel_lines(app: &mut App, content_width: usize, max_rows: usize) -> Vec<Line<'static>> {
fn pinned_panel_lines(
app: &mut App,
content_width: usize,
max_rows: usize,
omit_goal_objective: bool,
) -> Vec<Line<'static>> {
let summary = sidebar::sidebar_work_summary(app);
sidebar::work_panel_lines(
sidebar::work_panel_lines_with_opts(
&summary,
content_width,
max_rows,
app.ui_theme.mode,
&app.ui_theme,
WorkPanelOpts { omit_goal_objective },
)
}
+187 -74
View File
@@ -32,17 +32,18 @@ fn effective_placement(configured: WorkSurfacePlacement, host_width: u16) -> Wor
}
}
/// A non-Tasks panel is a title row plus three content rows. Below that it is
/// a heading over one truncated line — chrome, not information — so it
/// collapses rather than degrades.
const PANEL_STRIP_HEIGHT: u16 = 4;
/// Responsive work-surface height.
///
/// `rail_budget` is the caller's answer to "how many rows can the transcript
/// actually spare this frame" — terminal height minus fixed chrome minus the
/// transcript's own floor. See [`crate::tui::ui::rail_row_budget`]. The rail
/// takes spare rows; it never takes rows the transcript needs.
///
/// Every Top panel auto-fits its content the same way: content rows + optional
/// goal title + the divider, capped by `top_height` and ambient room. A
/// two-item checklist is two rows; eight agents grow to show eight. The only
/// Top title is an active goal — never panel chrome ("Pinned"). Side rails
/// keep a muted panel name because a full-height column needs naming.
pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16) -> u16 {
app.work_surface.effective_placement = effective_placement(app.work_surface.placement, width);
// Off hides the rail outright: no strip, no side reservation, no stale
@@ -51,62 +52,74 @@ pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16)
collapse_strip(app);
return 0;
}
// Non-Tasks panels own a strip once selected — but only when they have
// something to say. The old rule assumed "the user asked for the panel",
// which was false for everyone: the settings migration folded the default
// `sidebar_focus = "auto"` into `rail_panel = "pinned"`, so any user with a
// settings.toml was handed this panel without choosing it. Four rows
// spending themselves on the words "No active work" is density without
// meaning, and it costs the transcript real estate the operator did want.
//
// Tasks has always collapsed to zero on an empty projection. This makes
// Pinned behave the same way, which is the honest reading of the rule: an
// empty panel is not a panel. It reappears the instant there is work.
// Non-Tasks panels on Top auto-fit like Tasks. Empty projections collapse
// to zero — an empty panel is not a panel. Side placements reserve via
// `split_chat` and take no top strip.
if app.work_surface.panel != RailPanel::Tasks {
if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
return 0;
}
if app.work_surface.panel == RailPanel::Pinned
&& !crate::tui::sidebar::sidebar_work_summary(app).has_useful_content()
{
if !super::panels::panel_has_useful_content(app, app.work_surface.panel) {
collapse_strip(app);
return 0;
}
// What the panel is actually asking for: its design height, or less
// if the user set a shorter strip. `top_height` is a preference, not
// a budget — a user who drags the divider to its 2-row minimum wants
// a 2-row strip, and that preference persists to settings.toml.
let desired = PANEL_STRIP_HEIGHT.min(
app.work_surface
.top_height
.max(super::model::TOP_HEIGHT_MIN),
let cap = top_cap(app, terminal_height, rail_budget);
if cap < super::model::TOP_HEIGHT_MIN {
collapse_strip(app);
return 0;
}
let goal_rows = u16::from(top_goal_title(app).is_some());
let content_width = usize::from(width.saturating_sub(2).max(1));
// When the goal is the strip title, omit it from Pinned body rows so
// height and paint agree.
let content_rows = super::panels::panel_content_row_count(
app,
app.work_surface.panel,
content_width,
goal_rows > 0,
);
// The collapse cliff is charged against the *ambient* ceilings only —
// never against `desired`. Folding the user's own height in here
// would delete the panel at every terminal size for anyone who asked
// for a short one, which is the opposite of honouring the request.
if ambient_cap(terminal_height, rail_budget) < desired {
if content_rows == 0 && goal_rows == 0 {
collapse_strip(app);
return 0;
}
return desired;
let desired = u16::try_from(content_rows)
.unwrap_or(u16::MAX)
.saturating_add(goal_rows)
.saturating_add(1); // divider
return desired.clamp(super::model::TOP_HEIGHT_MIN, cap);
}
let rows = project_visible(app);
let goal_rows = u16::from(
app.work_surface.effective_placement == WorkSurfacePlacement::Top
&& top_goal_title(app).is_some(),
);
if rows.is_empty() {
collapse_strip(app);
app.work_surface.latest_rows.clear();
app.work_surface.visible_rows = 0;
app.work_surface.total_rows = 0;
app.work_surface.scroll_offset = 0;
return 0;
// A live goal alone still deserves a strip: title + divider.
if goal_rows == 0 {
collapse_strip(app);
app.work_surface.latest_rows.clear();
app.work_surface.visible_rows = 0;
app.work_surface.total_rows = 0;
app.work_surface.scroll_offset = 0;
return 0;
}
if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
return 0;
}
let cap = top_cap(app, terminal_height, rail_budget);
if cap < super::model::TOP_HEIGHT_MIN {
collapse_strip(app);
return 0;
}
return (goal_rows.saturating_add(1)).clamp(super::model::TOP_HEIGHT_MIN, cap);
}
if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
return 0;
}
// The strip auto-fits its content: the literal selectable list plus the
// pinned progress receipt and the divider row, bounded by `top_cap`. So a
// two-step plan takes two rows while an eight-step plan grows to show all
// eight — never a fixed-height band of blank water.
// optional goal title, the pinned progress receipt, and the divider row,
// bounded by `top_cap`.
let cap = top_cap(app, terminal_height, rail_budget);
if cap < super::model::TOP_HEIGHT_MIN {
collapse_strip(app);
@@ -117,6 +130,7 @@ pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16)
let desired = u16::try_from(selectable)
.unwrap_or(u16::MAX)
.saturating_add(progress)
.saturating_add(goal_rows)
.saturating_add(1);
desired.clamp(super::model::TOP_HEIGHT_MIN, cap)
}
@@ -168,6 +182,14 @@ fn collapse_strip(app: &mut App) {
/// Split the transcript slot for a side rail. Top placement consumes its own
/// vertical row before this point, so it returns the chat area unchanged.
///
/// Placement and auto-fit are orthogonal but share one rule: **empty work is
/// not a rail**. Top expresses that as `height() == 0`. Left/Right express it
/// here — no column is reserved when the selected panel has nothing to say.
/// When there *is* content, the rail takes the full chat height at the
/// configured `side_width` (width is the ceiling, the way `top_height` is the
/// ceiling on Top). Narrow terminals that cannot fit the rail fall back to
/// Top, where height auto-fit takes over.
///
/// `min_chat_width` is the column-axis twin of `height`'s `rail_budget`: the
/// columns the transcript must keep. When the idle ocean is on screen that is
/// the ambient floor, and a rail that cannot fit beside it hides rather than
@@ -175,10 +197,12 @@ fn collapse_strip(app: &mut App) {
pub fn split_chat(app: &mut App, area: Rect, min_chat_width: u16) -> (Rect, Option<Rect>) {
let placement = effective_placement(app.work_surface.placement, area.width);
app.work_surface.effective_placement = placement;
if placement == WorkSurfacePlacement::Top
|| placement == WorkSurfacePlacement::Off
|| (app.work_surface.panel == RailPanel::Tasks && app.work_surface.latest_rows.is_empty())
{
if placement == WorkSurfacePlacement::Top || placement == WorkSurfacePlacement::Off {
return (area, None);
}
// Same empty-collapse rule as Top: a panel with nothing to show does not
// spend columns on a blank (or "No agents") column.
if !side_rail_has_content(app) {
return (area, None);
}
@@ -189,6 +213,9 @@ pub fn split_chat(app: &mut App, area: Rect, min_chat_width: u16) -> (Rect, Opti
.clamp(super::model::SIDE_WIDTH_MIN, super::model::SIDE_WIDTH_MAX)
.min(area.width.saturating_sub(min_chat_width));
if rail_width < super::model::SIDE_WIDTH_MIN {
// Too narrow for a side column — fall back to Top. The caller will
// re-ask height() with effective_placement Top so content auto-fits
// as a strip instead of vanishing.
app.work_surface.effective_placement = WorkSurfacePlacement::Top;
return (area, None);
}
@@ -221,6 +248,14 @@ pub fn split_chat(app: &mut App, area: Rect, min_chat_width: u16) -> (Rect, Opti
}
}
/// Whether a Left/Right rail should reserve columns this frame.
fn side_rail_has_content(app: &mut App) -> bool {
match app.work_surface.panel {
RailPanel::Tasks => !project_visible(app).is_empty(),
panel => super::panels::panel_has_useful_content(app, panel),
}
}
pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
if area.width == 0 || area.height == 0 {
app.work_surface.last_area = None;
@@ -276,21 +311,27 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
HashMap::new()
};
let ordinal_width = todo_ordinals.len().max(1).to_string().len();
let goal_title = (placement == WorkSurfacePlacement::Top)
.then(|| top_goal_title(app))
.flatten();
let todo_progress = (placement == WorkSurfacePlacement::Top)
.then(|| top_todo_progress(app, &rows))
.flatten();
// At the minimum two-row surface, preserve the one usable content row and
// the divider. Taller surfaces pin the authoritative progress receipt
// above the scrollable/selectable rows.
let progress_height = u16::from(todo_progress.is_some() && body_area.height >= 2);
let list_height = body_area.height.saturating_sub(progress_height);
// Pin goal title, then progress receipt, above the scrollable rows.
// At the minimum two-row surface keep one usable content row + divider.
let goal_height = u16::from(goal_title.is_some() && body_area.height >= 1);
let progress_height = u16::from(
todo_progress.is_some() && body_area.height.saturating_sub(goal_height) >= 2,
);
let header_height = goal_height.saturating_add(progress_height);
let list_height = body_area.height.saturating_sub(header_height);
let body_height = usize::from(list_height);
let overflow = rows.len() > body_height;
let inset = u16::from(body_area.width >= 60);
let rail_width = u16::from(overflow);
let content_area = Rect {
x: body_area.x.saturating_add(inset),
y: body_area.y.saturating_add(progress_height),
y: body_area.y.saturating_add(header_height),
width: body_area
.width
.saturating_sub(inset.saturating_mul(2))
@@ -311,6 +352,22 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
.style(Style::default().bg(app.ui_theme.surface_bg))
.render(area, frame.buffer_mut());
if let Some((goal_text, goal_style)) = goal_title.filter(|_| goal_height > 0) {
let goal_text = truncate_line_to_width(&goal_text, usize::from(content_area.width));
Paragraph::new(Line::from(Span::styled(
goal_text,
goal_style.bg(app.ui_theme.surface_bg),
)))
.render(
Rect {
y: body_area.y,
height: 1,
..content_area
},
frame.buffer_mut(),
);
}
if let Some(progress) = todo_progress.filter(|_| progress_height > 0) {
let progress = truncate_line_to_width(&progress, usize::from(content_area.width));
Paragraph::new(Line::from(Span::styled(
@@ -322,7 +379,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
)))
.render(
Rect {
y: body_area.y,
y: body_area.y.saturating_add(goal_height),
height: 1,
..content_area
},
@@ -433,11 +490,10 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
});
}
/// Render a non-Tasks rail panel (Agents / Context / Pinned) as a titled
/// line list in the same body area and with the same divider and scrollbar
/// the Tasks list would use. Row interactivity (hitboxes, selection,
/// click actions) is Tasks-only for now; panels scroll via the shared
/// `scroll_offset`.
/// Render a non-Tasks rail panel (Agents / Context / Pinned) as a line list
/// in the same body area and with the same divider and scrollbar the Tasks
/// list would use. Row interactivity (hitboxes, selection, click actions)
/// is Tasks-only for now; panels scroll via the shared `scroll_offset`.
fn render_panel(frame: &mut Frame, area: Rect, body_area: Rect, app: &mut App) {
let panel = app.work_surface.panel;
let placement = app.work_surface.effective_placement;
@@ -446,25 +502,54 @@ fn render_panel(frame: &mut Frame, area: Rect, body_area: Rect, app: &mut App) {
.style(Style::default().bg(app.ui_theme.surface_bg))
.render(area, frame.buffer_mut());
// Title row.
Paragraph::new(Line::from(Span::styled(
truncate_line_to_width(panel.title(), usize::from(body_area.width).max(1)),
Style::default()
.fg(app.ui_theme.accent_primary)
.bg(app.ui_theme.surface_bg)
.add_modifier(Modifier::BOLD),
)))
.render(
Rect {
height: 1,
..body_area
},
frame.buffer_mut(),
// Title row policy:
// - Top: only an active goal (`Goal: …`). Never panel chrome ("Pinned").
// - Left/Right: muted panel name — a full-height column needs naming.
let goal = (placement == WorkSurfacePlacement::Top)
.then(|| top_goal_title(app))
.flatten();
let side_panel_title = matches!(
placement,
WorkSurfacePlacement::Left | WorkSurfacePlacement::Right
);
let title_rows = if let Some((goal_text, goal_style)) = goal.as_ref() {
let goal_text =
truncate_line_to_width(goal_text, usize::from(body_area.width).max(1));
Paragraph::new(Line::from(Span::styled(
goal_text,
goal_style.bg(app.ui_theme.surface_bg),
)))
.render(
Rect {
height: 1,
..body_area
},
frame.buffer_mut(),
);
1_u16
} else if side_panel_title {
Paragraph::new(Line::from(Span::styled(
truncate_line_to_width(panel.title(), usize::from(body_area.width).max(1)),
Style::default()
.fg(app.ui_theme.text_muted)
.bg(app.ui_theme.surface_bg),
)))
.render(
Rect {
height: 1,
..body_area
},
frame.buffer_mut(),
);
1_u16
} else {
0
};
let content_area = Rect {
y: body_area.y.saturating_add(1),
height: body_area.height.saturating_sub(1),
y: body_area.y.saturating_add(title_rows),
height: body_area.height.saturating_sub(title_rows),
..body_area
};
let body_height = usize::from(content_area.height);
@@ -473,6 +558,7 @@ fn render_panel(frame: &mut Frame, area: Rect, body_area: Rect, app: &mut App) {
panel,
usize::from(content_area.width),
body_height.max(1),
goal.is_some(),
)
.unwrap_or_default();
@@ -513,6 +599,33 @@ fn render_panel(frame: &mut Frame, area: Rect, body_area: Rect, app: &mut App) {
app.work_surface.hovered = None;
}
/// Active goal as the Top strip's only title. Uses the same
/// paused/active/terminal resolution as the ocean header chip so a goal set
/// via `create_goal` is either visible everywhere or nowhere. Returns
/// `None` when no live goal exists — Top then paints no title row at all.
fn top_goal_title(app: &App) -> Option<(String, Style)> {
let (objective, paused) = crate::tui::footer_ui::active_goal_chip_state(app)?;
let flat = objective.trim().replace(['\n', '\r'], " ");
if flat.is_empty() {
return None;
}
let text = if paused {
format!("Goal (paused): {flat}")
} else {
format!("Goal: {flat}")
};
let style = if paused {
Style::default()
.fg(app.ui_theme.warning)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(app.ui_theme.status_working)
.add_modifier(Modifier::BOLD)
};
Some((text, style))
}
fn todo_ordinals(rows: &[WorkRow]) -> HashMap<String, usize> {
rows.iter()
.filter(|row| row.id.0.starts_with("graph:"))