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