diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb072493..f8d2c9f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). risk score (see `zeph_tools::risk_chain` module docs for the full rationale and the accepted, bounded residual-evasion risk). Added `--init` wizard support and `--migrate-config` support (step 106). +- `zeph-tui`: content-driven side-panel sizing (issue #6675). The four side-panel slots + (Skills, Memory, Resources, SubAgents) are now sized from their own content each frame via + a new integer max-min fair water-filling allocator (`layout::fit_panel_heights`) instead of + a flat equal-share `Fill(1)` split — a sparse panel no longer wastes space and a busy one + is no longer silently clipped. `AppLayout::compute` takes a new `PanelSizing` (per-slot + `PanelDemand::{Collapsed, Rows(u16), Greedy}`) in place of the old `[bool; 4]` collapse + mask; `App::panel_demands()` builds it each frame from each widget's new pure + `desired_height` plus the chrome (focused-panel header, resources' gauge/compaction badge, + the subagents equalizer). All four measured panels route their rendering through a new + shared `widgets::panel::render_lines`, which truncates to the granted height and replaces + the last visible row with a muted `+N more` indicator on overflow. New `[tui] panel_sizing` + config key (`"auto"` default, `"even"` approximates the pre-#6675 equal-share split — same + total per slot, remainder placement may differ from the old cassowary-based split), + runtime-togglable via `/panel_sizing [auto|even]` or the `app:panel-sizing` command palette + entry; `--init` prompts for it and `--migrate-config` adds an advisory comment for existing + configs (step 107). - `zeph-tui`: inline, non-modal `@` mention picker (issues #6647, #6648), replacing the old modal file picker. Typing `@` at word-start inserts the character and opens a popup diff --git a/book/src/advanced/tui.md b/book/src/advanced/tui.md index cf205e944..33f3aa201 100644 --- a/book/src/advanced/tui.md +++ b/book/src/advanced/tui.md @@ -101,6 +101,7 @@ Note: Enabling this increases network traffic between parent and sub-agents. Onl | `Ctrl+F` | Open transcript search (case-insensitive substring search across message content and tool names) | | `Tab` | Cycle side panel focus (includes SubAgents panel) | | `a` | Focus the SubAgents panel | +| `Alt+1` / `Alt+2` / `Alt+3` / `Alt+4` | Toggle collapse for Skills / Memory / Resources / SubAgents (works in Normal and Insert mode) | ### Insert Mode @@ -546,6 +547,32 @@ The TUI adapts to terminal width: | >= 80 cols | Full layout: chat (70%) + side panels (30%) | | < 80 cols | Side panels hidden, chat takes full width | +### Side Panel Sizing + +Within the side column, the four panels (Skills, Memory, Resources, SubAgents) are sized +from their own content rather than split into equal shares — a sparse panel doesn't waste +space, and a busy one isn't silently clipped. Each panel's exact row count is computed fresh +every frame from what it's about to render, and every panel signals truncation the same way: +if its content doesn't fit the rows it was granted, the last visible row becomes a muted +`+N more` indicator instead of clipping silently. + +A panel collapsed with `Alt+1`..`Alt+4` (see [Keybindings](#keybindings)) still gets exactly +one summary row regardless of content — collapsing is unaffected by this change. Leftover +space beyond what all panels need is left blank at the bottom of the column; it can't be +donated to the chat pane, which is a horizontal sibling that already spans the full height. + +Set `[tui] panel_sizing` in `config.toml` to control the strategy: + +```toml +[tui] +panel_sizing = "auto" # default: size each panel from its content +# panel_sizing = "even" # pre-content-sizing behavior: split the column equally +``` + +Toggle at runtime with `/panel_sizing [auto|even]` (bare `/panel_sizing` toggles between the +two) or the `app:panel-sizing` command palette entry. The runtime toggle is not persisted +back to `config.toml`. + ## Live Metrics The TUI dashboard displays real-time metrics collected from the agent loop via `tokio::sync::watch` channel. The render loop polls the watch receiver before every frame. Frames are only emitted when the dirty flag is set (an event was received since the last draw), so the display does not redraw during idle 250 ms ticks with no activity. diff --git a/crates/zeph-config/src/lib.rs b/crates/zeph-config/src/lib.rs index 61ce98be4..887ede7ff 100644 --- a/crates/zeph-config/src/lib.rs +++ b/crates/zeph-config/src/lib.rs @@ -205,7 +205,8 @@ pub use ui::{ ACP_AUTH_CLIENT_ID_DEFAULT, ACP_AUTH_CLIENT_ID_LOCAL, AcpAuthClient, AcpAuthMethod, AcpConfig, AcpLspConfig, AcpModelConfigConfig, AcpSubagentsConfig, AcpTemperaturePreset, AcpTimeoutsConfig, AcpTransport, AdditionalDir, AdditionalDirError, ColorMode, DelightsConfig, - FleetConfig, Motion, SubagentPresetConfig, ThemeConfig, ToolDensity, TuiConfig, + FleetConfig, Motion, PanelSizingMode, SubagentPresetConfig, ThemeConfig, ToolDensity, + TuiConfig, }; pub use ui::{DiagnosticSeverity, DiagnosticsConfig, HoverConfig, LspConfig}; pub use vigil::VigilConfig; diff --git a/crates/zeph-config/src/migrate/features.rs b/crates/zeph-config/src/migrate/features.rs index baa08df76..0f3812128 100644 --- a/crates/zeph-config/src/migrate/features.rs +++ b/crates/zeph-config/src/migrate/features.rs @@ -97,7 +97,9 @@ pub fn migrate_tui_delights(toml_src: &str) -> Result Result { if !section_header_present(toml_src, "tui") { return Ok(MigrationResult { @@ -156,6 +158,74 @@ pub fn migrate_tui_mouse(toml_src: &str) -> Result Result { + if !section_header_present(toml_src, "tui") { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + let already_present = toml_src.lines().any(|l| { + let t = l.trim().trim_start_matches('#').trim(); + t.starts_with("panel_sizing") + }); + if already_present { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + let owned; + let src = if toml_src.ends_with('\n') { + toml_src + } else { + owned = format!("{toml_src}\n"); + &owned + }; + + if !TUI_HEADER_RE.is_match(src) { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + let insert = "# panel_sizing = \"auto\" # \"auto\" sizes panels from content, \"even\" splits equally (#6675)\n"; + let output = TUI_HEADER_RE + .replacen(src, 1, |caps: ®ex::Captures| { + format!("{}{insert}", &caps[0]) + }) + .into_owned(); + + let changed = output != toml_src; + let changed_count = usize::from(changed); + Ok(MigrationResult { + output, + changed_count, + sections_changed: if changed { + vec!["tui".to_owned()] + } else { + Vec::new() + }, + }) +} + /// Strip any existing `[memory.compression.predictor]` section from the config (#3251). /// /// The compression predictor feature was removed. This migration cleans up both active @@ -1202,6 +1272,44 @@ mod rate_limit_advisory_tests { } } +#[cfg(test)] +mod tui_panel_sizing_tests { + use super::*; + + #[test] + fn migrate_tui_panel_sizing_adds_advisory_comment() { + let base = "[tui]\nmouse = false\n"; + let result = migrate_tui_panel_sizing(base).unwrap(); + assert_eq!(result.changed_count, 1); + assert!(result.output.contains("# panel_sizing = \"auto\"")); + } + + #[test] + fn migrate_tui_panel_sizing_idempotent() { + let base = "[tui]\nmouse = false\n"; + let first = migrate_tui_panel_sizing(base).unwrap(); + let second = migrate_tui_panel_sizing(&first.output).unwrap(); + assert_eq!(second.changed_count, 0); + assert_eq!(second.output, first.output); + } + + #[test] + fn migrate_tui_panel_sizing_noop_when_key_already_present() { + let base = "[tui]\npanel_sizing = \"even\"\n"; + let result = migrate_tui_panel_sizing(base).unwrap(); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, base); + } + + #[test] + fn migrate_tui_panel_sizing_noop_when_no_tui_section() { + let base = "[agent]\nname = \"zeph\"\n"; + let result = migrate_tui_panel_sizing(base).unwrap(); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, base); + } +} + /// Step 104 — add an `expandable_blockquote_min_lines` advisory comment to an existing /// active `[telegram]` table (spec 007-3-telegram-rich-text, issue #6541). /// diff --git a/crates/zeph-config/src/migrate/mod.rs b/crates/zeph-config/src/migrate/mod.rs index 2b05dc5b9..ef2cdba53 100644 --- a/crates/zeph-config/src/migrate/mod.rs +++ b/crates/zeph-config/src/migrate/mod.rs @@ -32,7 +32,7 @@ pub use features::{ migrate_orchestration_persistence, migrate_orchestration_whole_plan_verifier_timeout, migrate_rate_limit_advisory, migrate_skill_trust_require_check, migrate_skills_registry, migrate_telegram_expandable_blockquote_config, migrate_tui_delights, migrate_tui_mouse, - migrate_tui_theme_config, migrate_tui_theme_defaults, + migrate_tui_panel_sizing, migrate_tui_theme_config, migrate_tui_theme_defaults, }; pub use infra::*; pub use integrity::migrate_integrity_config; @@ -640,9 +640,9 @@ use steps::{ MigrateSkillsRegistry, MigrateSttToProvider, MigrateSupervisorConfig, MigrateTelegramExpandableBlockquoteConfig, MigrateTelemetryConfig, MigrateToolsCompressionConfig, MigrateTraceMetadata, MigrateTuiDelights, MigrateTuiMouse, - MigrateTuiThemeConfig, MigrateTuiThemeDefaults, MigrateUtilityHighGainTools, - MigrateVigilConfig, MigrateWorktreeConfig, MigrateWorktreeGitTimeout, - MigrateWorktreeQuotaFields, + MigrateTuiPanelSizing, MigrateTuiThemeConfig, MigrateTuiThemeDefaults, + MigrateUtilityHighGainTools, MigrateVigilConfig, MigrateWorktreeConfig, + MigrateWorktreeGitTimeout, MigrateWorktreeQuotaFields, }; /// Ordered registry of all sequential migration steps (steps 1–99). @@ -867,6 +867,8 @@ pub static MIGRATIONS: std::sync::LazyLock> // Step 106 — add risk_chain_window_turns advisory comment to [tools.shell] // for RiskChainAccumulator's cross-turn multi-step chain detection (#6603) Box::new(MigrateShellRiskChainWindowTurns), + // Step 107 — add panel_sizing = "auto" advisory comment under [tui] (#6675) + Box::new(MigrateTuiPanelSizing), ] }); diff --git a/crates/zeph-config/src/migrate/steps.rs b/crates/zeph-config/src/migrate/steps.rs index e7d642f42..6d1f3cd33 100644 --- a/crates/zeph-config/src/migrate/steps.rs +++ b/crates/zeph-config/src/migrate/steps.rs @@ -79,7 +79,9 @@ //! step 100 adds a commented `[integrity]` advisory block for vault-anchor //! downgrade-resistance (issue #6449); step 101 adds a commented `[security.rate_limit]` //! advisory block — purely documentary, the tool rate limiter already defaults to enabled -//! at the struct-default level (issue #6469). +//! at the struct-default level (issue #6469); step 106 adds a commented +//! `panel_sizing = "auto"` advisory under `[tui]` — purely documentary, `auto` is already +//! the field's struct default (issue #6675). //! //! Each struct is a zero-size type that delegates to the corresponding free function in //! `super`. They exist solely to satisfy the object-safe [`super::Migration`] trait so the @@ -127,9 +129,9 @@ use super::{ migrate_stt_to_provider, migrate_supervisor_config, migrate_telegram_expandable_blockquote_config, migrate_telemetry_config, migrate_tools_compression_config, migrate_trace_metadata, migrate_tui_delights, - migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults, - migrate_utility_high_gain_tools, migrate_vigil_config, migrate_worktree_config, - migrate_worktree_git_timeout, migrate_worktree_quota_fields, + migrate_tui_mouse, migrate_tui_panel_sizing, migrate_tui_theme_config, + migrate_tui_theme_defaults, migrate_utility_high_gain_tools, migrate_vigil_config, + migrate_worktree_config, migrate_worktree_git_timeout, migrate_worktree_quota_fields, }; // ── Wrapper structs for all 73 sequential migration steps ─────────────────────────────────────── @@ -1357,6 +1359,8 @@ impl Migration for MigrateAgentsMaxSpawnsPerSession { } } +/// Step 106 — add `risk_chain_window_turns` advisory comment to `[tools.shell]` for +/// `RiskChainAccumulator`'s cross-turn multi-step chain detection (issue #6603). pub(super) struct MigrateShellRiskChainWindowTurns; impl Migration for MigrateShellRiskChainWindowTurns { fn name(&self) -> &'static str { @@ -1367,3 +1371,15 @@ impl Migration for MigrateShellRiskChainWindowTurns { migrate_shell_risk_chain_window_turns(toml_src) } } + +/// Step 107 — add `panel_sizing = "auto"` advisory comment under `[tui]` (issue #6675). +pub(super) struct MigrateTuiPanelSizing; +impl Migration for MigrateTuiPanelSizing { + fn name(&self) -> &'static str { + "migrate_tui_panel_sizing" + } + + fn apply(&self, toml_src: &str) -> Result { + migrate_tui_panel_sizing(toml_src) + } +} diff --git a/crates/zeph-config/src/migrate/tests.rs b/crates/zeph-config/src/migrate/tests.rs index cb14facf6..7fb190d6c 100644 --- a/crates/zeph-config/src/migrate/tests.rs +++ b/crates/zeph-config/src/migrate/tests.rs @@ -9,8 +9,8 @@ use super::*; fn migrations_registry_has_all_steps() { assert_eq!( MIGRATIONS.len(), - 106, - "MIGRATIONS registry must contain all 106 sequential steps" + 107, + "MIGRATIONS registry must contain all 107 sequential steps" ); for m in MIGRATIONS.iter() { assert!( @@ -2124,7 +2124,26 @@ fn migrate_focus_auto_consolidate_noop_when_only_commented_section() { #[test] fn registry_has_fifty_entries() { - assert_eq!(MIGRATIONS.len(), 106); + assert_eq!(MIGRATIONS.len(), 107); +} + +/// Mirrors the SC-003 wire-X-into-registry check below for `MigrateTuiPanelSizing` (#6675): +/// a function that exists but is never pushed into `MIGRATIONS` would pass its own isolated +/// unit tests in `migrate/features.rs` while doing nothing for a real `--migrate-config` run. +#[test] +fn full_registry_adds_panel_sizing_advisory_to_legacy_tui_config() { + let legacy = "[tui]\nmouse = false\n"; + let mut current = legacy.to_owned(); + for m in MIGRATIONS.iter() { + current = m + .apply(¤t) + .expect("registry migration must not fail") + .output; + } + assert!( + current.contains("# panel_sizing = \"auto\""), + "MIGRATIONS must add the panel_sizing advisory comment, got: {current}" + ); } /// SC-003 (issue #6545): the isolated `migrate_agents_max_spawns_per_session` tests in @@ -2307,6 +2326,7 @@ fn registry_preserves_order_matches_dispatch() { "migrate_telegram_expandable_blockquote_config", "migrate_agents_max_spawns_per_session", "migrate_shell_risk_chain_window_turns", + "migrate_tui_panel_sizing", ]; let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect(); assert_eq!(actual, expected); diff --git a/crates/zeph-config/src/ui.rs b/crates/zeph-config/src/ui.rs index 5d85b86e1..271c9e3e5 100644 --- a/crates/zeph-config/src/ui.rs +++ b/crates/zeph-config/src/ui.rs @@ -543,6 +543,30 @@ pub struct TuiConfig { /// the TUI. Text selection via Shift+drag still works. Default: `false`. #[serde(default)] pub mouse: bool, + /// Side-panel vertical sizing strategy (#6675). + /// + /// `auto` (default) sizes each unpinned side panel from its own content; `even` + /// approximates the pre-#6675 behavior of splitting the column equally regardless of + /// content (same total per slot; exact per-slot remainder placement can differ from the + /// old cassowary-based split — see `zeph_tui::layout::PanelDemand`'s docs). Runtime- + /// togglable via `/panel_sizing [auto|even]`. + #[serde(default)] + pub panel_sizing: PanelSizingMode, +} + +/// Side-panel vertical sizing strategy (see [`TuiConfig::panel_sizing`], #6675). +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PanelSizingMode { + /// Size each unpinned side panel from its own content (`desired_height`), via a + /// max-min fair water-filling allocator. Leftover space stays blank at the bottom of + /// the column. Default. + #[default] + Auto, + /// Approximates pre-#6675 behavior: unpinned panels split the column evenly, regardless + /// of content (same total height per slot; exact remainder placement can differ from + /// the old cassowary-based split). + Even, } /// Configuration for the TUI fleet panel (#3884). diff --git a/crates/zeph-tui/src/app/draw.rs b/crates/zeph-tui/src/app/draw.rs index 9c802fd4f..4460945dc 100644 --- a/crates/zeph-tui/src/app/draw.rs +++ b/crates/zeph-tui/src/app/draw.rs @@ -7,19 +7,16 @@ use crate::layout::AppLayout; use crate::widgets; use crate::widgets::wave::EqualizerWidget; -use super::{App, Panel}; +use super::{App, EQ_PANEL_H, Panel}; impl App { pub fn draw(&mut self, frame: &mut ratatui::Frame) { - // Height of the equalizer slot carved from the bottom of the subagents panel. - const EQ_PANEL_H: u16 = 4; - let collapsed = self.effective_collapsed(); let mut layout = AppLayout::compute( frame.area(), self.show_side_panels, self.desired_input_height(), - collapsed, + self.panel_demands(), ); // Micro-delight state is advanced in tick_delights() (called on AppEvent::Tick). @@ -58,7 +55,7 @@ impl App { let wave_tick = self.wave_tick(); let wave_active = self.is_agent_busy() || self.background_inflight() > 0; let eq_area = - if self.show_equalizer && wave_active && layout.subagents.height > EQ_PANEL_H + 2 { + if should_carve_equalizer(self.show_equalizer, wave_active, layout.subagents.height) { let sub_h = layout.subagents.height - EQ_PANEL_H; let eq = Rect { y: layout.subagents.y + sub_h, @@ -261,11 +258,16 @@ impl App { } else { layout.resources }; + // The badge row's height must track `compaction_badge::desired_height` exactly — + // a fixed Length(1) here would eat a row `panel_demands()` never budgeted for + // when no compaction has occurred, shifting `resources::render`'s own content + // down by one and desyncing sizing from rendering (#6675). + let badge_h = widgets::compaction_badge::desired_height(&self.metrics); let splits = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), - Constraint::Length(1), + Constraint::Length(badge_h), Constraint::Min(0), ]) .split(resources_area); @@ -276,11 +278,6 @@ impl App { let tick = self.throbber_state.index().cast_unsigned(); let ascii = self.is_ascii_only(); - let has_graph = self.metrics.orchestration_graph.as_ref().is_some_and(|s| { - // Use is_stale() to check if snapshot is too old to show (IC4). - !s.is_stale() - }); - let panel_focused = self.active_panel == Panel::SubAgents; if effective[3] { self.render_collapsed_summary( @@ -290,47 +287,45 @@ impl App { focused_panel == Panel::SubAgents, ); } else { - self.render_subagents_slot( - frame, - layout.subagents, - tick, - ascii, - panel_focused, - has_graph, - ); + self.render_subagents_slot(frame, layout.subagents, tick, ascii); } } + /// Render the `SubAgents` slot's base layer, chosen by `App::subagent_slot_mode`, then + /// layer any active overlay (Fleet/Durable/Settings/Tasks) on top of it. The mode + /// selection is computed once and shared with sizing (`App::panel_demands`) so the two + /// decisions can never disagree (#6675). fn render_subagents_slot( &mut self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect, tick: u8, ascii: bool, - panel_focused: bool, - has_graph: bool, ) { use ratatui::text::{Line, Span}; use ratatui::widgets::{Clear, Paragraph}; - // When SubAgents panel is focused (`a` key), always show the interactive sidebar. - // Otherwise: auto-show plan when graph active, security events, or subagents list. - if panel_focused { - widgets::subagents::render_interactive( - &self.metrics, - &mut self.subagent_sidebar, - frame, - area, - tick, - &self.theme, - ascii, - ); - } else if has_graph && !self.sessions.current().plan_view_active { - widgets::plan_view::render(&self.metrics, frame, area, tick, ascii, &self.theme); - } else if self.has_recent_security_events() { - widgets::security::render(&self.metrics, frame, area, &self.theme); - } else { - widgets::subagents::render(&self.metrics, frame, area, &self.theme); + match self.subagent_slot_mode() { + widgets::subagents::SubAgentSlotMode::Interactive => { + widgets::subagents::render_interactive( + &self.metrics, + &mut self.subagent_sidebar, + frame, + area, + tick, + &self.theme, + ascii, + ); + } + widgets::subagents::SubAgentSlotMode::PlanView => { + widgets::plan_view::render(&self.metrics, frame, area, tick, ascii, &self.theme); + } + widgets::subagents::SubAgentSlotMode::Security => { + widgets::security::render(&self.metrics, frame, area, &self.theme); + } + widgets::subagents::SubAgentSlotMode::List => { + widgets::subagents::render(&self.metrics, frame, area, &self.theme); + } } // Overlay fleet panel over the subagents slot when `f` key is active (#3884). @@ -435,6 +430,21 @@ impl App { } } +/// Whether the equalizer slot should be carved from the bottom of the granted subagents +/// rect this frame. +/// +/// Matches `App::panel_demands()`'s own accounting: when the equalizer is active, the +/// subagents demand already includes `+ EQ_PANEL_H` on top of its content rows, so a +/// fully-granted slot has height `>= content_rows + EQ_PANEL_H` (`content_rows >= 1` for +/// every base-layer mode). Requiring only `granted_height > EQ_PANEL_H` — the same +/// floor-of-1 guarantee `fit_panel_heights` upholds elsewhere — carves the slot exactly when +/// it was budgeted for (#6675 C1: the old `> EQ_PANEL_H + 2` margin predated content-driven +/// sizing and silently ate the equalizer whenever the granted height matched a small content +/// demand exactly, e.g. an empty sub-agent list). +fn should_carve_equalizer(show_equalizer: bool, wave_active: bool, granted_height: u16) -> bool { + show_equalizer && wave_active && granted_height > EQ_PANEL_H +} + /// Return `area` with the top `n` rows removed. fn shrink_top(area: ratatui::layout::Rect, n: u16) -> ratatui::layout::Rect { if n >= area.height { @@ -459,7 +469,7 @@ mod tests { use ratatui::backend::TestBackend; use tokio::sync::mpsc; - use super::App; + use super::{App, EQ_PANEL_H, should_carve_equalizer}; fn make_app() -> App { let (user_tx, _) = mpsc::channel(1); @@ -483,7 +493,7 @@ mod tests { frame.buffer_mut()[(x, y)].set_symbol("#"); } } - app.render_subagents_slot(frame, area, 0, false, false, false); + app.render_subagents_slot(frame, area, 0, false); }) .unwrap(); terminal.backend().buffer().clone() @@ -506,4 +516,95 @@ mod tests { ); } } + + // ── should_carve_equalizer / draw() regression (#6675 C1) ─────────────────── + + #[test] + fn should_carve_equalizer_true_when_granted_exactly_matches_budget() { + // An empty sub-agent list's List-mode content is 2 rows; with the equalizer active + // its demand is 2 + EQ_PANEL_H, and a fully-granted slot has exactly that height. + // The carve must still happen here — this used to require `> EQ_PANEL_H + 2`, which + // silently dropped the equalizer whenever granted height matched a small content + // demand exactly. + assert!(should_carve_equalizer(true, true, 2 + EQ_PANEL_H)); + } + + #[test] + fn should_carve_equalizer_false_when_no_room_for_any_content() { + assert!(!should_carve_equalizer(true, true, EQ_PANEL_H)); + } + + #[test] + fn should_carve_equalizer_false_when_hidden_or_idle() { + assert!(!should_carve_equalizer(false, true, 20)); + assert!(!should_carve_equalizer(true, false, 20)); + } + + #[test] + fn draw_side_panel_rects_tile_the_column_without_overlap_under_varying_content() { + // #6675 tester gap 4: with genuinely different content sizes per panel (so + // `panel_demands()` produces different Rows(n) for each slot, unlike the old + // uniform Fill(1) split), the four granted rects must still stack contiguously + // inside `side_panel` — no gaps, no overlap, no rect exceeding the terminal. + let mut app = make_app(); + app.metrics.active_skills = vec!["one".into(), "two".into()]; + app.metrics.total_skills = 2; + app.metrics.sqlite_message_count = 10; + app.metrics.provider_name = "claude".into(); + app.metrics.model_name = "opus-4".into(); + app.metrics.total_tokens = 1000; + + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| app.draw(frame)).unwrap(); + let layout = app.last_layout.expect("draw() must populate last_layout"); + + let panels = [ + layout.skills, + layout.memory, + layout.resources, + layout.subagents, + ]; + for rect in panels { + assert!( + rect.y + rect.height <= layout.side_panel.y + layout.side_panel.height, + "panel rect {rect:?} must not exceed the side_panel column {:?}", + layout.side_panel + ); + assert_eq!( + rect.x, layout.side_panel.x, + "panel rect must align with the side_panel column's left edge" + ); + assert_eq!(rect.width, layout.side_panel.width); + } + // Contiguous, non-overlapping stacking: each slot starts exactly where the + // previous one ends. + assert_eq!(layout.skills.y, layout.side_panel.y); + assert_eq!(layout.memory.y, layout.skills.y + layout.skills.height); + assert_eq!(layout.resources.y, layout.memory.y + layout.memory.height); + assert_eq!( + layout.subagents.y, + layout.resources.y + layout.resources.height + ); + } + + #[test] + fn draw_carves_equalizer_slot_for_empty_subagents_list_while_busy() { + // Full regression for #6675 C1: with zero sub-agents and the agent busy, the + // equalizer used to never render because the old threshold required more headroom + // than an empty list's content-driven demand actually grants. + let mut app = make_app(); + app.show_equalizer = true; + app.sessions.current_mut().status_label = Some("thinking...".to_owned()); + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| app.draw(frame)).unwrap(); + let layout = app.last_layout.expect("draw() must populate last_layout"); + assert!( + layout.subagents.height < 2 + EQ_PANEL_H, + "equalizer must be carved out of the subagents slot for an empty, busy session, \ + got subagents height={}", + layout.subagents.height + ); + } } diff --git a/crates/zeph-tui/src/app/keys.rs b/crates/zeph-tui/src/app/keys.rs index b648400b9..ce040fd67 100644 --- a/crates/zeph-tui/src/app/keys.rs +++ b/crates/zeph-tui/src/app/keys.rs @@ -454,6 +454,16 @@ impl App { [cmd] if cmd.eq_ignore_ascii_case("/mouse") => Some(TuiCommand::ToggleMouse), [cmd, "on"] if cmd.eq_ignore_ascii_case("/mouse") => Some(TuiCommand::SetMouse(true)), [cmd, "off"] if cmd.eq_ignore_ascii_case("/mouse") => Some(TuiCommand::SetMouse(false)), + // /panel_sizing [auto|even] — side-panel sizing strategy (#6675) + [cmd] if cmd.eq_ignore_ascii_case("/panel_sizing") => { + Some(TuiCommand::TogglePanelSizing) + } + [cmd, "auto"] if cmd.eq_ignore_ascii_case("/panel_sizing") => Some( + TuiCommand::SetPanelSizing(zeph_config::PanelSizingMode::Auto), + ), + [cmd, "even"] if cmd.eq_ignore_ascii_case("/panel_sizing") => Some( + TuiCommand::SetPanelSizing(zeph_config::PanelSizingMode::Even), + ), _ => None, } } diff --git a/crates/zeph-tui/src/app/mod.rs b/crates/zeph-tui/src/app/mod.rs index f287d9ad9..5d2601e25 100644 --- a/crates/zeph-tui/src/app/mod.rs +++ b/crates/zeph-tui/src/app/mod.rs @@ -29,6 +29,10 @@ use crate::types::PasteState; const MAX_VISIBLE_INPUT_LINES: u16 = 3; +/// Height of the equalizer slot carved from the bottom of the subagents panel while the +/// agent is busy or background work is inflight (see `App::draw` and `App::panel_demands`). +pub(crate) const EQ_PANEL_H: u16 = 4; + /// Tracks an in-flight background file-index build. /// /// When a [`TaskSupervisor`] is wired into the `App`, the build is routed through it @@ -510,6 +514,13 @@ pub struct App { /// Defaults to `true`. Ignored when `Motion` is not `Full`. pub(crate) show_equalizer: bool, + /// Side-panel vertical sizing strategy (#6675). + /// + /// Sourced from `[tui] panel_sizing` in config; runtime-switchable via + /// [`crate::command::TuiCommand::TogglePanelSizing`]. See + /// [`crate::App::panel_demands`] for how this is consumed. + pub(crate) panel_sizing: zeph_config::PanelSizingMode, + // --- Micro-delights (#5104) --- /// Individual feature toggles sourced from `[tui.delights]` in config. pub(crate) delights: zeph_config::DelightsConfig, diff --git a/crates/zeph-tui/src/app/reducer.rs b/crates/zeph-tui/src/app/reducer.rs index 60ddaf866..ab7a085c9 100644 --- a/crates/zeph-tui/src/app/reducer.rs +++ b/crates/zeph-tui/src/app/reducer.rs @@ -920,6 +920,24 @@ fn reduce_inner(app: &mut App, action: Action) -> Vec { app.show_equalizer = !app.show_equalizer; return vec![]; } + TuiCommand::SetPanelSizing(mode) => { + app.panel_sizing = *mode; + let label = match mode { + zeph_config::PanelSizingMode::Auto => "auto (content-sized)", + zeph_config::PanelSizingMode::Even => "even (equal share)", + }; + app.push_system_message_pub(format!("Panel sizing set to: {label}")); + return vec![]; + } + TuiCommand::TogglePanelSizing => { + app.toggle_panel_sizing(); + let label = match app.panel_sizing { + zeph_config::PanelSizingMode::Auto => "auto (content-sized)", + zeph_config::PanelSizingMode::Even => "even (equal share)", + }; + app.push_system_message_pub(format!("Panel sizing set to: {label}")); + return vec![]; + } // ── Group A — pure state mutations ────────────────────────────── TuiCommand::NewSession => { @@ -2250,7 +2268,7 @@ mod tests { area, app.show_side_panels(), app.desired_input_height(), - app.effective_collapsed(), + app.panel_demands(), )); (app, rx) } diff --git a/crates/zeph-tui/src/app/state.rs b/crates/zeph-tui/src/app/state.rs index 0e71ff149..6ecc5edb4 100644 --- a/crates/zeph-tui/src/app/state.rs +++ b/crates/zeph-tui/src/app/state.rs @@ -14,14 +14,17 @@ use crate::command::TuiCommand; use crate::event::AgentEvent; use crate::file_picker::FileIndex; use crate::hyperlink::HyperlinkSpan; +use crate::layout::{PanelDemand, PanelSizing}; use crate::metrics::MetricsSnapshot; use crate::session::SessionRegistry; use crate::types::PasteState; +use crate::widgets::subagents::SubAgentSlotMode; use crate::widgets::tool_view::ToolDensity; +use crate::widgets::{compaction_badge, memory, plan_view, resources, security, skills, subagents}; use super::{ - AgentViewTarget, App, ChatMessage, InputMode, MAX_VISIBLE_INPUT_LINES, MessageRole, Panel, - RenderCache, SubAgentSidebarState, TranscriptCache, is_tool_use_only, parse_tool_output, + AgentViewTarget, App, ChatMessage, EQ_PANEL_H, InputMode, MAX_VISIBLE_INPUT_LINES, MessageRole, + Panel, RenderCache, SubAgentSidebarState, TranscriptCache, is_tool_use_only, parse_tool_output, }; /// No-progress duration after which the wave transitions to `Stalled`. @@ -118,6 +121,7 @@ impl App { pending_quit_tick: None, last_progress_at: Instant::now(), show_equalizer: true, + panel_sizing: zeph_config::PanelSizingMode::default(), delights: zeph_config::DelightsConfig::default(), stream_rate: crate::delights::StreamRate::new(), toasts: crate::delights::ToastQueue::new(), @@ -1075,11 +1079,20 @@ impl App { self.collapsed_panels } - /// Compute the effective collapse mask used for layout and rendering. + /// Compute the effective collapse mask used for rendering (which content each slot + /// shows, not how many rows it gets — sizing is a crate-internal concern). /// - /// Index 3 (`SubAgents` slot) is forced expanded when any overlay currently - /// owns that slot — Fleet, Durable, Tasks, plan view, or security events. - /// Indices 0–2 pass through the raw `collapsed_panels` value unchanged. + /// A slot's user-set `collapsed_panels` pin means "show the single summary row" + /// regardless of content; unpinned (`false`) means "auto, content-sized" — the slot + /// renders its real widget and is sized from that widget's own `desired_height` + /// (pre-#6675 this meant "equal share" via `Fill(1)`; the mask's own pin/auto + /// semantics are unchanged). + /// + /// Index 3 (`SubAgents` slot) is forced expanded (`false`) whenever an overlay currently + /// owns that slot — Fleet, Durable, Settings, Tasks — or the base layer itself is + /// showing something other than the plain idle list (interactive focus, plan view, + /// security events). Indices 0–2 pass through the raw `collapsed_panels` value + /// unchanged. /// /// # Examples /// @@ -1097,23 +1110,146 @@ impl App { #[must_use] pub fn effective_collapsed(&self) -> [bool; 4] { let mut eff = self.collapsed_panels; - // Force-expand slot 3 whenever an overlay is rendering into the subagents rect. - let slot3_has_overlay = matches!( - self.active_panel, - Panel::SubAgents | Panel::Fleet | Panel::Durable | Panel::Settings - ) || self.show_task_panel - || self - .metrics - .orchestration_graph - .as_ref() - .is_some_and(|s| !s.is_stale() && !self.sessions.current().plan_view_active) - || self.has_recent_security_events(); - if slot3_has_overlay { + let slot3_forced_expand = + self.subagent_slot_mode() != SubAgentSlotMode::List || self.subagents_overlay_active(); + if slot3_forced_expand { eff[3] = false; } eff } + /// Determine which base-layer view the `SubAgents` slot renders this frame. + /// + /// Single source of truth for the `SubAgents` slot's content: consumed by both + /// [`Self::panel_demands`] (sizing) and `render_subagents_slot` (rendering) so the two + /// decisions can never disagree (#6675) — previously this priority chain was re-derived + /// independently in each place. + #[must_use] + pub(crate) fn subagent_slot_mode(&self) -> SubAgentSlotMode { + if self.active_panel == Panel::SubAgents { + SubAgentSlotMode::Interactive + } else if self.has_active_plan() { + SubAgentSlotMode::PlanView + } else if self.has_recent_security_events() { + SubAgentSlotMode::Security + } else { + SubAgentSlotMode::List + } + } + + /// `true` when a non-stale orchestration plan is active and not dismissed by the user. + fn has_active_plan(&self) -> bool { + self.metrics + .orchestration_graph + .as_ref() + .is_some_and(|s| !s.is_stale()) + && !self.sessions.current().plan_view_active + } + + /// `true` when Fleet, Durable, Settings, or the Tasks panel currently overlays the + /// `SubAgents` slot, independent of `subagent_slot_mode`'s base-layer choice. + fn subagents_overlay_active(&self) -> bool { + matches!( + self.active_panel, + Panel::Fleet | Panel::Durable | Panel::Settings + ) || self.show_task_panel + } + + /// Build this frame's per-slot sizing demand for the side-panel column (#6675). + /// + /// Composes each widget's pure `desired_height` with the chrome `draw_side_panel` layers + /// on top of it (focused-panel header row, resources' `context_gauge` + compaction badge, + /// the subagents equalizer) so [`crate::layout::AppLayout::compute`] never under-allocates + /// a slot that then clips its own header. A `collapsed_panels` pin overrides content + /// sizing entirely via [`PanelDemand::Collapsed`]. + #[must_use] + pub(crate) fn panel_demands(&self) -> PanelSizing { + let effective = self.effective_collapsed(); + + // `even` escape hatch (#6675): reproduce the pre-#6675 equal-share split by giving + // every unpinned slot a `Greedy` demand instead of measuring its content. + if self.panel_sizing == zeph_config::PanelSizingMode::Even { + let demands = effective.map(|collapsed| { + if collapsed { + PanelDemand::Collapsed + } else { + PanelDemand::Greedy + } + }); + return PanelSizing { + demands, + focus: None, + }; + } + + let focused_chrome = |panel: Panel| -> u16 { u16::from(self.active_panel == panel) }; + + let skills = if effective[0] { + PanelDemand::Collapsed + } else { + let rows = skills::desired_height(&self.metrics, &self.theme) + .saturating_add(focused_chrome(Panel::Skills)); + PanelDemand::Rows(rows) + }; + + let memory = if effective[1] { + PanelDemand::Collapsed + } else { + let rows = memory::desired_height(&self.metrics, &self.theme) + .saturating_add(focused_chrome(Panel::Memory)); + PanelDemand::Rows(rows) + }; + + let resources = if effective[2] { + PanelDemand::Collapsed + } else { + // +1 for context_gauge (always shown) + compaction_badge's own 0/1 rule. + let rows = resources::desired_height(&self.metrics, &self.theme) + .saturating_add(1) + .saturating_add(compaction_badge::desired_height(&self.metrics)) + .saturating_add(focused_chrome(Panel::Resources)); + PanelDemand::Rows(rows) + }; + + let subagents = if effective[3] { + PanelDemand::Collapsed + } else { + let mode = self.subagent_slot_mode(); + if self.subagents_overlay_active() || mode == SubAgentSlotMode::Interactive { + PanelDemand::Greedy + } else { + let mut rows = match mode { + SubAgentSlotMode::PlanView => plan_view::desired_height(&self.metrics), + SubAgentSlotMode::Security => { + security::desired_height(&self.metrics, &self.theme) + } + SubAgentSlotMode::List | SubAgentSlotMode::Interactive => { + subagents::desired_height(&self.metrics, &self.theme) + } + }; + if self.show_equalizer && (self.is_agent_busy() || self.background_inflight() > 0) { + rows = rows.saturating_add(EQ_PANEL_H); + } + PanelDemand::Rows(rows) + } + }; + + let focus = match self.active_panel { + Panel::Skills => Some(0), + Panel::Memory => Some(1), + Panel::Resources => Some(2), + Panel::SubAgents | Panel::Fleet | Panel::Durable | Panel::Settings | Panel::Tasks => { + Some(3) + } + Panel::Chat => None, + }; + + PanelSizing { + demands: [skills, memory, resources, subagents], + focus, + } + } + /// Returns the number of rows in the settings view's currently active tab /// (issue #6024), used to clamp `Action::SettingsSelectMove` navigation. pub(crate) fn settings_active_tab_len(&self) -> usize { @@ -1441,6 +1577,42 @@ impl App { self } + /// Set the side-panel sizing strategy at startup (#6675). + /// + /// Called from the builder chain in `tui_bridge` with `config.tui.panel_sizing`. + /// + /// # Examples + /// + /// ```rust + /// use tokio::sync::mpsc; + /// use zeph_config::PanelSizingMode; + /// use zeph_tui::App; + /// + /// let (tx, _) = mpsc::channel(1); + /// let (_, rx) = mpsc::channel(1); + /// let app = App::new(tx, rx).with_panel_sizing(PanelSizingMode::Even); + /// assert_eq!(app.panel_sizing(), PanelSizingMode::Even); + /// ``` + #[must_use] + pub fn with_panel_sizing(mut self, mode: zeph_config::PanelSizingMode) -> Self { + self.panel_sizing = mode; + self + } + + /// Return the current side-panel sizing strategy. + #[must_use] + pub fn panel_sizing(&self) -> zeph_config::PanelSizingMode { + self.panel_sizing + } + + /// Toggle between `auto` and `even` side-panel sizing at runtime. + pub(crate) fn toggle_panel_sizing(&mut self) { + self.panel_sizing = match self.panel_sizing { + zeph_config::PanelSizingMode::Auto => zeph_config::PanelSizingMode::Even, + zeph_config::PanelSizingMode::Even => zeph_config::PanelSizingMode::Auto, + }; + } + /// Return `true` when opt-in mouse capture is currently active. /// /// # Examples @@ -1550,6 +1722,8 @@ mod tests { use tokio::sync::mpsc; use super::{App, Panel}; + use crate::app::EQ_PANEL_H; + use crate::layout::PanelDemand; fn make_app() -> App { let (user_tx, _) = mpsc::channel(1); @@ -1889,4 +2063,115 @@ mod tests { recall with transcript text instead of genuinely-typed prior input (AC-20)" ); } + + // ── panel_demands() chrome accounting (#6675 tester gap 1) ────────────────── + + fn rows_of(demand: PanelDemand) -> u16 { + match demand { + PanelDemand::Rows(n) => n, + other => panic!("expected PanelDemand::Rows, got {other:?}"), + } + } + + #[test] + fn panel_demands_focused_skills_slot_adds_one_chrome_row() { + let mut app = make_app(); + let unfocused = rows_of(app.panel_demands().demands[0]); + app.active_panel = Panel::Skills; + let focused = rows_of(app.panel_demands().demands[0]); + assert_eq!( + focused, + unfocused + 1, + "focused skills slot must get exactly +1 chrome row for its section header" + ); + } + + #[test] + fn panel_demands_focused_memory_slot_adds_one_chrome_row() { + let mut app = make_app(); + let unfocused = rows_of(app.panel_demands().demands[1]); + app.active_panel = Panel::Memory; + let focused = rows_of(app.panel_demands().demands[1]); + assert_eq!(focused, unfocused + 1); + } + + #[test] + fn panel_demands_resources_adds_context_gauge_row() { + let app = make_app(); + let resources_rows = rows_of(app.panel_demands().demands[2]); + let widget_only = crate::widgets::resources::desired_height(&app.metrics, &app.theme); + // +1 for context_gauge (always shown); no compaction has occurred yet, so + // compaction_badge contributes 0. + assert_eq!(resources_rows, widget_only + 1); + } + + #[test] + fn panel_demands_resources_adds_compaction_badge_row_when_present() { + let mut app = make_app(); + app.metrics.compaction_last_at_ms = 1; + let resources_rows = rows_of(app.panel_demands().demands[2]); + let widget_only = crate::widgets::resources::desired_height(&app.metrics, &app.theme); + assert_eq!( + resources_rows, + widget_only + 2, + "+1 context_gauge, +1 compaction_badge once a compaction has occurred" + ); + } + + #[test] + fn panel_demands_focused_resources_adds_header_on_top_of_gauge_rows() { + let mut app = make_app(); + let unfocused = rows_of(app.panel_demands().demands[2]); + app.active_panel = Panel::Resources; + let focused = rows_of(app.panel_demands().demands[2]); + assert_eq!(focused, unfocused + 1); + } + + #[test] + fn panel_demands_subagents_adds_equalizer_rows_while_busy() { + let mut app = make_app(); + app.show_equalizer = true; + let idle = rows_of(app.panel_demands().demands[3]); + app.sessions.current_mut().status_label = Some("thinking...".to_owned()); + let busy = rows_of(app.panel_demands().demands[3]); + assert_eq!( + busy, + idle + EQ_PANEL_H, + "equalizer must add exactly EQ_PANEL_H rows to the subagents demand while the \ + agent is busy" + ); + } + + #[test] + fn panel_demands_subagents_no_equalizer_rows_when_show_equalizer_disabled() { + let mut app = make_app(); + app.show_equalizer = false; + let idle = rows_of(app.panel_demands().demands[3]); + app.sessions.current_mut().status_label = Some("thinking...".to_owned()); + let busy = rows_of(app.panel_demands().demands[3]); + assert_eq!(busy, idle, "no equalizer rows when the user has hidden it"); + } + + #[test] + fn panel_demands_collapsed_slot_ignores_content_and_chrome() { + let mut app = make_app(); + app.toggle_panel_collapse(0); + assert_eq!(app.panel_demands().demands[0], PanelDemand::Collapsed); + } + + #[test] + fn panel_demands_even_mode_forces_all_unpinned_slots_greedy() { + let mut app = make_app(); + app.panel_sizing = zeph_config::PanelSizingMode::Even; + app.toggle_panel_collapse(1); + let demands = app.panel_demands(); + assert_eq!(demands.demands[0], PanelDemand::Greedy); + assert_eq!( + demands.demands[1], + PanelDemand::Collapsed, + "pins still honored in even mode" + ); + assert_eq!(demands.demands[2], PanelDemand::Greedy); + assert_eq!(demands.demands[3], PanelDemand::Greedy); + } } diff --git a/crates/zeph-tui/src/command.rs b/crates/zeph-tui/src/command.rs index 13f9b4f8c..c018317ad 100644 --- a/crates/zeph-tui/src/command.rs +++ b/crates/zeph-tui/src/command.rs @@ -161,6 +161,11 @@ pub enum TuiCommand { ToggleMouse, /// Toggle the compact equalizer widget in the busy separator row. ToggleEqualizer, + // Side-panel sizing (#6675) + /// Set the side-panel sizing strategy at runtime (`/panel_sizing auto|even`). + SetPanelSizing(zeph_config::PanelSizingMode), + /// Toggle between `auto` and `even` side-panel sizing. + TogglePanelSizing, // SubAgent sidebar navigation (used by decode_normal_key → Action::Dispatch) /// Move the subagent list selection down by one. SubagentSidebarDown, @@ -437,6 +442,13 @@ fn build_app_commands() -> Vec { shortcut: None, command: TuiCommand::ToggleEqualizer, }, + CommandEntry { + id: "app:panel-sizing", + label: "Toggle side-panel sizing (auto: content-sized / even: equal share)", + category: "app", + shortcut: None, + command: TuiCommand::TogglePanelSizing, + }, ] } @@ -1151,8 +1163,8 @@ mod tests { #[test] fn registry_has_correct_count() { // +1 view:latency (#6059); +2 settings + search:transcript (#6024/#6023); - // +1 integrity:status (#6449) - assert_eq!(command_registry().len(), 31); + // +1 integrity:status (#6449); +1 app:panel-sizing (#6675) + assert_eq!(command_registry().len(), 32); } #[test] diff --git a/crates/zeph-tui/src/layout.rs b/crates/zeph-tui/src/layout.rs index e10858321..0cdef896c 100644 --- a/crates/zeph-tui/src/layout.rs +++ b/crates/zeph-tui/src/layout.rs @@ -4,6 +4,9 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +/// Number of side-panel slots: Skills, Memory, Resources, `SubAgents`. +const PANEL_SLOTS: usize = 4; + /// Truncates `s` to fit within `max_width` display columns, appending `…` if truncated. /// /// Accumulates display width character-by-character using [`UnicodeWidthChar`], reserving @@ -45,6 +48,173 @@ pub fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect { .split(vertical[1])[1] } +/// How much vertical space a side-panel slot wants this frame. +/// +/// Computed once per frame from [`crate::metrics::MetricsSnapshot`] content plus the chrome +/// each slot's renderer adds (focused-panel header row, resources' gauge + compaction badge, +/// the subagents equalizer). Must be a pure function of content — **never** of the allocated +/// [`Rect`]; sizing a slot from its own rendered area would create a layout feedback loop that +/// oscillates frame to frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelDemand { + /// User-pinned to its single summary row (see `App::toggle_panel_collapse`), regardless + /// of content. + Collapsed, + /// Wants exactly `rows` rows; anything beyond is wasted space. + Rows(u16), + /// Wants every row it can get (overlays, wrapped or scrollable views). + Greedy, +} + +impl Default for PanelDemand { + /// Defaults to [`PanelDemand::Greedy`]. Four `Greedy` demands give every slot the same + /// *total* the pre-#6675 equal-`Fill(1)` split did, and [`PanelDemand::Collapsed`] + /// reproduces the old `Length(1)` collapse behavior exactly — but `fit_panel_heights`' + /// top-down remainder placement is not pixel-identical to ratatui's cassowary solver + /// (which spreads remainder rows toward the middle slots rather than the topmost ones); + /// see [`fit_panel_heights`]'s own docs for the exact remainder rule. + fn default() -> Self { + Self::Greedy + } +} + +/// Per-frame sizing request for all four side-panel slots. +/// +/// Built once per frame (see `App::panel_demands`) and passed to [`AppLayout::compute`]. +#[derive(Debug, Clone, Copy)] +pub struct PanelSizing { + /// Demand for each slot, in `[skills, memory, resources, subagents]` order. + pub demands: [PanelDemand; PANEL_SLOTS], + /// Slot index that receives rounding remainders first when space is under pressure. + pub focus: Option, +} + +impl Default for PanelSizing { + fn default() -> Self { + Self { + demands: [PanelDemand::Greedy; PANEL_SLOTS], + focus: None, + } + } +} + +/// Upper bound on rows a single slot's demand can absorb. +fn demand_cap(demand: PanelDemand) -> u32 { + match demand { + PanelDemand::Collapsed => 1, + PanelDemand::Rows(rows) => u32::from(rows), + PanelDemand::Greedy => u32::MAX, + } +} + +/// Integer max-min fair water-filling allocator for the four side-panel slots. +/// +/// Distributes `available` rows across `sizing.demands` so that no slot is granted more +/// than it asked for, every visible slot gets a floor of one row (identity row / mouse hit +/// target / collapse affordance) whenever `available >= 4`, and any surplus beyond total +/// demand is left unallocated as trailing blank space at the bottom of the column — donating +/// it to chat is not geometrically possible, since chat is a horizontal sibling that already +/// spans the full band height. +/// +/// A slot demanding [`PanelDemand::Rows`]`(0)` is the one exception to the floor guarantee: +/// granting it a row would violate `granted <= demand`, so it is skipped entirely rather than +/// padded — this only matters for a slot with genuinely zero content to show. +/// +/// When `available < 4` there isn't room for one identity row per slot; rows are handed out +/// top-down (skipping any slot whose demand is zero) until either `available` or the slot +/// list is exhausted. +/// +/// Uses `u32` intermediates so `PanelDemand::Rows(u16::MAX)` cannot overflow the arithmetic. +#[must_use] +pub fn fit_panel_heights(sizing: &PanelSizing, available: u16) -> [u16; PANEL_SLOTS] { + let caps: [u32; PANEL_SLOTS] = core::array::from_fn(|i| demand_cap(sizing.demands[i])); + + if available < 4 { + let mut out = [0u16; PANEL_SLOTS]; + let mut left = available; + for (cap, slot) in caps.iter().zip(out.iter_mut()) { + if left == 0 { + break; + } + if *cap >= 1 { + *slot = 1; + left -= 1; + } + } + return out; + } + + let available = u32::from(available); + + // Floor stage: every slot whose demand allows it gets its one guaranteed row. + let mut grant = [0u32; PANEL_SLOTS]; + let mut cap_left = [0u32; PANEL_SLOTS]; + for i in 0..PANEL_SLOTS { + if caps[i] >= 1 { + grant[i] = 1; + } + cap_left[i] = caps[i].saturating_sub(grant[i]); + } + let mut remaining = available.saturating_sub(grant.iter().sum()); + let mut active: Vec = (0..PANEL_SLOTS).filter(|&i| cap_left[i] > 0).collect(); + + while remaining > 0 && !active.is_empty() { + let active_len = u32::try_from(active.len()).unwrap_or(u32::MAX); + let share = remaining / active_len; + if share == 0 { + distribute_remainder(remaining, &active, sizing.focus, &mut grant, &mut cap_left); + break; + } + let mut used = 0u32; + let mut next_active = Vec::with_capacity(active.len()); + for &i in &active { + let take = share.min(cap_left[i]); + grant[i] += take; + cap_left[i] -= take; + used += take; + if cap_left[i] > 0 { + next_active.push(i); + } + } + remaining -= used; + active = next_active; + } + + core::array::from_fn(|i| u16::try_from(grant[i]).unwrap_or(u16::MAX)) +} + +/// Distribute a residual `remaining < active.len()` rows one at a time: `focus` first (when +/// still active), then the rest of `active` in ascending (top-down) index order. +fn distribute_remainder( + mut remaining: u32, + active: &[usize], + focus: Option, + grant: &mut [u32; PANEL_SLOTS], + cap_left: &mut [u32; PANEL_SLOTS], +) { + let mut order: Vec = Vec::with_capacity(active.len()); + if let Some(f) = focus + && active.contains(&f) + { + order.push(f); + } + for &i in active { + if Some(i) != focus { + order.push(i); + } + } + for i in order { + if remaining == 0 { + break; + } + if cap_left[i] > 0 { + grant[i] += 1; + cap_left[i] -= 1; + remaining -= 1; + } + } +} + /// Pre-computed layout rectangles for all regions of the TUI dashboard. /// /// Call [`compute`](Self::compute) once per render frame; pass the result to @@ -59,10 +229,10 @@ pub fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect { /// /// ```rust /// use ratatui::layout::Rect; -/// use zeph_tui::layout::AppLayout; +/// use zeph_tui::layout::{AppLayout, PanelSizing}; /// /// let area = Rect::new(0, 0, 120, 40); -/// let layout = AppLayout::compute(area, true, 3, [false; 4]); +/// let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); /// assert_eq!(layout.header.height, 1); /// assert_eq!(layout.status.height, 1); /// assert!(layout.chat.width > layout.side_panel.width); @@ -99,26 +269,29 @@ impl AppLayout { /// * `area` — the full terminal rect (from `Frame::area()`). /// * `show_side_panels` — `false` hides the side panels regardless of width. /// * `input_height` — requested composer height including borders. - /// * `collapsed` — per-section collapse mask `[skills, memory, resources, subagents]`. - /// A collapsed section renders as a single summary row (`Length(1)`); an expanded - /// section uses `Fill(1)` to share the remaining space equally. + /// * `panels` — per-slot sizing demand, resolved into concrete row counts by + /// [`fit_panel_heights`]. /// /// # Examples /// /// ```rust /// use ratatui::layout::Rect; - /// use zeph_tui::layout::AppLayout; + /// use zeph_tui::layout::{AppLayout, PanelDemand, PanelSizing}; /// /// // Wide terminal: side panels visible. - /// let layout = AppLayout::compute(Rect::new(0, 0, 120, 40), true, 3, [false; 4]); + /// let layout = AppLayout::compute(Rect::new(0, 0, 120, 40), true, 3, PanelSizing::default()); /// assert!(layout.side_panel.width > 0); /// /// // Narrow terminal: side panels hidden. - /// let layout = AppLayout::compute(Rect::new(0, 0, 60, 24), true, 3, [false; 4]); + /// let layout = AppLayout::compute(Rect::new(0, 0, 60, 24), true, 3, PanelSizing::default()); /// assert_eq!(layout.side_panel.width, 0); /// /// // All panels collapsed: each gets a single summary row. - /// let layout = AppLayout::compute(Rect::new(0, 0, 120, 40), true, 3, [true; 4]); + /// let collapsed = PanelSizing { + /// demands: [PanelDemand::Collapsed; 4], + /// focus: None, + /// }; + /// let layout = AppLayout::compute(Rect::new(0, 0, 120, 40), true, 3, collapsed); /// assert!(layout.side_panel.width > 0); /// assert_eq!(layout.skills.height, 1); /// ``` @@ -127,7 +300,7 @@ impl AppLayout { area: Rect, show_side_panels: bool, input_height: u16, - collapsed: [bool; 4], + panels: PanelSizing, ) -> Self { let outer = Layout::default() .direction(Direction::Vertical) @@ -164,37 +337,32 @@ impl AppLayout { ]) .split(outer[1]); - // Each side section is either a single summary row (collapsed) or fills available space. - // When all four are collapsed the four Length(1) rows sit at the top and the remainder - // is blank; add a trailing Fill(1) spacer only in that case so the layout is clean. - let [c0, c1, c2, c3] = collapsed; - let mut side_constraints: Vec = [c0, c1, c2, c3] - .iter() - .map(|&col| { - if col { - Constraint::Length(1) - } else { - Constraint::Fill(1) - } - }) - .collect(); - if c0 && c1 && c2 && c3 { - side_constraints.push(Constraint::Fill(1)); + let side_area = main_split[2]; + let heights = fit_panel_heights(&panels, side_area.height); + + // Direct y-offset arithmetic rather than a second Layout::split: per-frame-varying + // Length constraints are the worst case for ratatui's internal layout cache. + let mut y = side_area.y; + let mut side_rects = [Rect::default(); PANEL_SLOTS]; + for (rect, height) in side_rects.iter_mut().zip(heights) { + *rect = Rect { + x: side_area.x, + y, + width: side_area.width, + height, + }; + y = y.saturating_add(height); } - let side_split = Layout::default() - .direction(Direction::Vertical) - .constraints(side_constraints) - .split(main_split[2]); Self { header: outer[0], chat: main_split[0], separator: main_split[1], - side_panel: main_split[2], - skills: side_split[0], - memory: side_split[1], - resources: side_split[2], - subagents: side_split[3], + side_panel: side_area, + skills: side_rects[0], + memory: side_rects[1], + resources: side_rects[2], + subagents: side_rects[3], input: outer[2], status: outer[3], } @@ -207,6 +375,13 @@ mod tests { use super::*; + fn sizing(demands: [PanelDemand; PANEL_SLOTS]) -> PanelSizing { + PanelSizing { + demands, + focus: None, + } + } + #[test] fn truncate_to_width_ascii_fits() { assert_eq!(truncate_to_width("hello", 10), "hello"); @@ -257,7 +432,7 @@ mod tests { #[test] fn layout_for_standard_terminal() { let area = Rect::new(0, 0, 120, 40); - let layout = AppLayout::compute(area, true, 3, [false; 4]); + let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); assert_eq!(layout.header.height, 1); assert_eq!(layout.input.height, 3); assert_eq!(layout.status.height, 1); @@ -267,7 +442,7 @@ mod tests { #[test] fn layout_for_small_terminal() { let area = Rect::new(0, 0, 80, 24); - let layout = AppLayout::compute(area, true, 3, [false; 4]); + let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); assert_eq!(layout.header.height, 1); assert_eq!(layout.status.height, 1); assert!(layout.chat.height >= 10); @@ -276,7 +451,7 @@ mod tests { #[test] fn layout_side_panels_stack_vertically() { let area = Rect::new(0, 0, 120, 40); - let layout = AppLayout::compute(area, true, 3, [false; 4]); + let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); assert!(layout.skills.y < layout.memory.y); assert!(layout.memory.y < layout.resources.y); assert!(layout.resources.y < layout.subagents.y); @@ -285,7 +460,7 @@ mod tests { #[test] fn layout_input_below_chat() { let area = Rect::new(0, 0, 100, 30); - let layout = AppLayout::compute(area, true, 3, [false; 4]); + let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); assert!(layout.input.y > layout.chat.y); assert!(layout.status.y > layout.input.y); } @@ -293,7 +468,7 @@ mod tests { #[test] fn layout_narrow_hides_side_panels() { let area = Rect::new(0, 0, 60, 24); - let layout = AppLayout::compute(area, true, 3, [false; 4]); + let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); assert_eq!(layout.side_panel, Rect::default()); assert_eq!(layout.skills, Rect::default()); assert_eq!(layout.memory, Rect::default()); @@ -305,7 +480,7 @@ mod tests { #[test] fn layout_very_narrow_hides_side_panels() { let area = Rect::new(0, 0, 30, 24); - let layout = AppLayout::compute(area, true, 3, [false; 4]); + let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); assert_eq!(layout.side_panel, Rect::default()); assert_eq!(layout.skills, Rect::default()); } @@ -313,7 +488,7 @@ mod tests { #[test] fn layout_boundary_at_80_shows_side_panels() { let area = Rect::new(0, 0, 80, 24); - let layout = AppLayout::compute(area, true, 3, [false; 4]); + let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); assert!(layout.side_panel.width > 0); assert!(layout.skills.width > 0); } @@ -321,14 +496,14 @@ mod tests { #[test] fn layout_boundary_at_79_hides_side_panels() { let area = Rect::new(0, 0, 79, 24); - let layout = AppLayout::compute(area, true, 3, [false; 4]); + let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); assert_eq!(layout.side_panel, Rect::default()); } #[test] fn layout_toggle_off_hides_side_panels() { let area = Rect::new(0, 0, 120, 40); - let layout = AppLayout::compute(area, false, 3, [false; 4]); + let layout = AppLayout::compute(area, false, 3, PanelSizing::default()); assert_eq!(layout.side_panel, Rect::default()); assert_eq!(layout.skills, Rect::default()); assert_eq!(layout.memory, Rect::default()); @@ -340,7 +515,7 @@ mod tests { #[test] fn layout_toggle_on_shows_side_panels() { let area = Rect::new(0, 0, 120, 40); - let layout = AppLayout::compute(area, true, 3, [false; 4]); + let layout = AppLayout::compute(area, true, 3, PanelSizing::default()); assert!(layout.side_panel.width > 0); assert!(layout.skills.width > 0); } @@ -384,7 +559,17 @@ mod tests { #[test] fn collapsed_panel_gets_single_row() { let area = Rect::new(0, 0, 120, 40); - let layout = AppLayout::compute(area, true, 3, [true, false, false, false]); + let layout = AppLayout::compute( + area, + true, + 3, + sizing([ + PanelDemand::Collapsed, + PanelDemand::Greedy, + PanelDemand::Greedy, + PanelDemand::Greedy, + ]), + ); assert_eq!(layout.skills.height, 1, "collapsed skills must be height 1"); assert!( layout.memory.height > 1, @@ -395,7 +580,7 @@ mod tests { #[test] fn all_panels_collapsed_no_panic_and_each_height_one() { let area = Rect::new(0, 0, 120, 40); - let layout = AppLayout::compute(area, true, 3, [true; 4]); + let layout = AppLayout::compute(area, true, 3, sizing([PanelDemand::Collapsed; 4])); assert_eq!(layout.skills.height, 1); assert_eq!(layout.memory.height, 1); assert_eq!(layout.resources.height, 1); @@ -409,7 +594,7 @@ mod tests { fn narrow_terminal_ignores_collapsed_mask() { // width < 80 → side panels hidden regardless of collapse state. let area = Rect::new(0, 0, 60, 24); - let layout = AppLayout::compute(area, true, 3, [true; 4]); + let layout = AppLayout::compute(area, true, 3, sizing([PanelDemand::Collapsed; 4])); assert_eq!(layout.side_panel, Rect::default()); assert_eq!(layout.skills, Rect::default()); } @@ -418,7 +603,17 @@ mod tests { fn single_expanded_panel_fills_remaining_height() { let area = Rect::new(0, 0, 120, 40); // Collapse first three, only subagents expanded. - let layout = AppLayout::compute(area, true, 3, [true, true, true, false]); + let layout = AppLayout::compute( + area, + true, + 3, + sizing([ + PanelDemand::Collapsed, + PanelDemand::Collapsed, + PanelDemand::Collapsed, + PanelDemand::Greedy, + ]), + ); assert_eq!(layout.skills.height, 1); assert_eq!(layout.memory.height, 1); assert_eq!(layout.resources.height, 1); @@ -439,13 +634,159 @@ mod tests { bits & 0b0100 != 0, bits & 0b1000 != 0, ]; - let layout = AppLayout::compute(area, true, 3, c); + let demands = c.map(|collapsed| { + if collapsed { + PanelDemand::Collapsed + } else { + PanelDemand::Greedy + } + }); + let layout = AppLayout::compute(area, true, 3, sizing(demands)); // All rects must be within terminal bounds. assert!(layout.skills.y + layout.skills.height <= area.height); assert!(layout.subagents.y + layout.subagents.height <= area.height); } } + // ── fit_panel_heights unit table ───────────────────────────────────────── + + #[test] + fn fit_all_greedy_splits_into_equal_totals_when_evenly_divisible() { + let s = sizing([PanelDemand::Greedy; 4]); + assert_eq!(fit_panel_heights(&s, 40), [10, 10, 10, 10]); + } + + #[test] + fn fit_all_greedy_remainder_placement_is_top_down_not_cassowary_middle_out() { + // #6675 S3: four Greedy demands reproduce the pre-#6675 Fill(1) split's *total* + // (5 rows split across 4 slots), but NOT its exact per-slot remainder placement. + // Ratatui's cassowary solver spreads the remainder toward the middle slots + // (observed: [1, 2, 1, 1]); this allocator's remainder rule is top-down instead + // (no `focus`, so the first slot(s) in iteration order absorb it first). + let s = sizing([PanelDemand::Greedy; 4]); + let granted = fit_panel_heights(&s, 5); + assert_eq!(granted.iter().sum::(), 5, "total must still match"); + assert_eq!( + granted, + [2, 1, 1, 1], + "remainder goes to the first slot top-down, unlike cassowary's middle-out [1,2,1,1]" + ); + } + + #[test] + fn fit_all_collapsed_caps_at_one_each() { + let s = sizing([PanelDemand::Collapsed; 4]); + assert_eq!(fit_panel_heights(&s, 40), [1, 1, 1, 1]); + } + + #[test] + fn fit_surplus_beyond_total_demand_left_unallocated() { + let s = sizing([ + PanelDemand::Rows(2), + PanelDemand::Rows(2), + PanelDemand::Rows(2), + PanelDemand::Rows(2), + ]); + let granted = fit_panel_heights(&s, 40); + assert_eq!(granted, [2, 2, 2, 2]); + assert!(granted.iter().sum::() < 40); + } + + #[test] + fn fit_exact_fit_grants_exactly_demand() { + let s = sizing([ + PanelDemand::Rows(3), + PanelDemand::Rows(5), + PanelDemand::Rows(2), + PanelDemand::Rows(6), + ]); + assert_eq!(fit_panel_heights(&s, 16), [3, 5, 2, 6]); + } + + #[test] + fn fit_pressure_below_floor_hands_out_one_row_top_down() { + let s = sizing([PanelDemand::Greedy; 4]); + assert_eq!(fit_panel_heights(&s, 0), [0, 0, 0, 0]); + assert_eq!(fit_panel_heights(&s, 1), [1, 0, 0, 0]); + assert_eq!(fit_panel_heights(&s, 2), [1, 1, 0, 0]); + assert_eq!(fit_panel_heights(&s, 3), [1, 1, 1, 0]); + } + + #[test] + fn fit_available_exactly_four_gives_floor_of_one_to_every_slot() { + // #6675 tester gap 5: available == 4 is the exact boundary between the "hand out + // one row top-down" branch (available < 4) and the normal floor-then-water-fill + // branch (available >= 4) — pin it as its own explicit case rather than relying on + // proptest ranges to happen to cover it. + let s = sizing([PanelDemand::Greedy; 4]); + assert_eq!(fit_panel_heights(&s, 4), [1, 1, 1, 1]); + } + + #[test] + fn fit_pressure_below_floor_skips_zero_demand_slots() { + let s = sizing([ + PanelDemand::Rows(0), + PanelDemand::Greedy, + PanelDemand::Greedy, + PanelDemand::Greedy, + ]); + // Slot 0 has nothing to show; its row goes to slot 1 instead. + assert_eq!(fit_panel_heights(&s, 2), [0, 1, 1, 0]); + } + + #[test] + fn fit_mixed_measured_and_greedy_gives_surplus_to_greedy() { + let s = sizing([ + PanelDemand::Rows(3), + PanelDemand::Greedy, + PanelDemand::Rows(2), + PanelDemand::Collapsed, + ]); + let granted = fit_panel_heights(&s, 20); + assert_eq!(granted[0], 3, "measured slot capped at its demand"); + assert_eq!(granted[2], 2, "measured slot capped at its demand"); + assert_eq!(granted[3], 1, "collapsed slot capped at one row"); + assert_eq!(granted[1], 20 - 3 - 2 - 1, "greedy slot absorbs the rest"); + } + + #[test] + fn fit_rows_zero_demand_not_padded_to_floor() { + let s = sizing([PanelDemand::Rows(0); 4]); + assert_eq!(fit_panel_heights(&s, 40), [0, 0, 0, 0]); + } + + #[test] + fn fit_rows_u16_max_does_not_overflow() { + let s = sizing([PanelDemand::Rows(u16::MAX); 4]); + let granted = fit_panel_heights(&s, 40); + assert_eq!(granted.iter().sum::(), 40); + } + + #[test] + fn fit_focus_gets_remainder_first() { + // 4 greedy slots sharing 10 rows: 10/4 = 2 rem 2 -> two slots get an extra row. + let s = PanelSizing { + demands: [PanelDemand::Greedy; 4], + focus: Some(2), + }; + let granted = fit_panel_heights(&s, 10); + assert_eq!(granted.iter().sum::(), 10); + assert_eq!( + granted[2], 3, + "focused slot must receive the first extra row" + ); + } + + #[test] + fn fit_out_of_range_focus_does_not_panic() { + let s = PanelSizing { + demands: [PanelDemand::Greedy; 4], + focus: Some(99), + }; + let granted = fit_panel_heights(&s, 10); + assert_eq!(granted.iter().sum::(), 10); + } + mod proptest_layout { use super::*; use proptest::prelude::*; @@ -461,6 +802,28 @@ mod tests { ); } + fn arb_demand() -> impl Strategy { + prop_oneof![ + Just(PanelDemand::Collapsed), + Just(PanelDemand::Greedy), + any::().prop_map(PanelDemand::Rows), + ] + } + + fn arb_sizing() -> impl Strategy { + ( + arb_demand(), + arb_demand(), + arb_demand(), + arb_demand(), + proptest::option::of(0usize..8), + ) + .prop_map(|(d0, d1, d2, d3, focus)| PanelSizing { + demands: [d0, d1, d2, d3], + focus, + }) + } + proptest! { #![proptest_config(ProptestConfig::with_cases(1000))] @@ -475,7 +838,10 @@ mod tests { c3 in proptest::bool::ANY, ) { let area = Rect::new(0, 0, width, height); - let layout = AppLayout::compute(area, show_side, 3, [c0, c1, c2, c3]); + let demands = [c0, c1, c2, c3].map(|collapsed| { + if collapsed { PanelDemand::Collapsed } else { PanelDemand::Greedy } + }); + let layout = AppLayout::compute(area, show_side, 3, sizing(demands)); assert_within_bounds(layout.header, area); assert_within_bounds(layout.chat, area); @@ -502,6 +868,96 @@ mod tests { let popup = centered_rect(percent_x, popup_h.min(area_h), area); assert_within_bounds(popup, area); } + + #[test] + fn fit_panel_heights_never_panics( + sizing in arb_sizing(), + available in 0u16..2000, + ) { + let _ = fit_panel_heights(&sizing, available); + } + + #[test] + fn fit_panel_heights_sum_never_exceeds_available( + sizing in arb_sizing(), + available in 0u16..2000, + ) { + let granted = fit_panel_heights(&sizing, available); + let total: u32 = granted.iter().map(|&h| u32::from(h)).sum(); + prop_assert!(total <= u32::from(available)); + } + + #[test] + fn fit_panel_heights_respects_rows_demand_cap( + r0 in any::(), r1 in any::(), r2 in any::(), r3 in any::(), + available in 0u16..2000, + ) { + let s = sizing([ + PanelDemand::Rows(r0), + PanelDemand::Rows(r1), + PanelDemand::Rows(r2), + PanelDemand::Rows(r3), + ]); + let granted = fit_panel_heights(&s, available); + prop_assert!(granted[0] <= r0); + prop_assert!(granted[1] <= r1); + prop_assert!(granted[2] <= r2); + prop_assert!(granted[3] <= r3); + } + + #[test] + fn fit_panel_heights_collapsed_never_exceeds_one( + available in 4u16..2000, + d1 in arb_demand(), d2 in arb_demand(), d3 in arb_demand(), + ) { + let s = sizing([PanelDemand::Collapsed, d1, d2, d3]); + let granted = fit_panel_heights(&s, available); + prop_assert!(granted[0] <= 1); + } + + #[test] + fn fit_panel_heights_floor_of_one_when_demand_allows( + available in 4u16..2000, + sizing in arb_sizing(), + ) { + let granted = fit_panel_heights(&sizing, available); + for (i, &demand) in sizing.demands.iter().enumerate() { + if demand_cap(demand) >= 1 { + prop_assert!( + granted[i] >= 1, + "slot {i} with non-zero demand must get its floor row" + ); + } + } + } + + #[test] + fn fit_panel_heights_monotone_in_available( + sizing in arb_sizing(), + base in 0u16..1000, + delta in 0u16..1000, + ) { + let low = fit_panel_heights(&sizing, base); + let high = fit_panel_heights(&sizing, base.saturating_add(delta)); + for (h, l) in high.iter().zip(low.iter()) { + prop_assert!(h >= l); + } + } + + #[test] + fn fit_panel_heights_monotone_in_own_demand( + d1 in arb_demand(), d2 in arb_demand(), d3 in arb_demand(), + r_low in any::(), + grow in any::(), + available in 0u16..2000, + ) { + let r_high = r_low.saturating_add(grow); + let low = sizing([PanelDemand::Rows(r_low), d1, d2, d3]); + let high = sizing([PanelDemand::Rows(r_high), d1, d2, d3]); + let granted_low = fit_panel_heights(&low, available); + let granted_high = fit_panel_heights(&high, available); + prop_assert!(granted_high[0] >= granted_low[0]); + } } } } diff --git a/crates/zeph-tui/src/test_utils.rs b/crates/zeph-tui/src/test_utils.rs index 8bb6a46b8..51148075f 100644 --- a/crates/zeph-tui/src/test_utils.rs +++ b/crates/zeph-tui/src/test_utils.rs @@ -59,6 +59,17 @@ where buffer_to_string(&buf) } +/// Count non-blank rows in a `render_to_string` output — used by `desired_height`/render +/// parity tests to verify a widget's measured row count matches what it actually draws, +/// not just what its internal line-builder returns (#6675). +#[must_use] +pub fn count_non_blank_rows(output: &str) -> usize { + output + .lines() + .filter(|line| !line.trim().is_empty()) + .count() +} + fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String { let mut output = String::new(); for y in 0..buf.area.height { diff --git a/crates/zeph-tui/src/widgets/compaction_badge.rs b/crates/zeph-tui/src/widgets/compaction_badge.rs index c69ae9b91..9818c0d25 100644 --- a/crates/zeph-tui/src/widgets/compaction_badge.rs +++ b/crates/zeph-tui/src/widgets/compaction_badge.rs @@ -17,6 +17,16 @@ use ratatui::widgets::Paragraph; use crate::metrics::MetricsSnapshot; use crate::theme::Theme; +/// Number of rows the compaction badge needs: `0` when no compaction has occurred this +/// session (`compaction_last_at_ms == 0`), `1` otherwise. +/// +/// Pure function of `metrics` — never of the allocated `Rect` — matching [`render`]'s own +/// hidden-when-no-compaction rule so the two can never disagree. +#[must_use] +pub fn desired_height(metrics: &MetricsSnapshot) -> u16 { + u16::from(metrics.compaction_last_at_ms != 0) +} + /// Render the compaction badge into `area`. /// /// Shows `compaction {before}k→{after}k (-{saved}k) {elapsed}` on a single line. @@ -110,4 +120,19 @@ mod tests { let m = MetricsSnapshot::default(); assert_eq!(m.compaction_last_at_ms, 0); } + + #[test] + fn desired_height_zero_when_no_compaction() { + let m = MetricsSnapshot::default(); + assert_eq!(desired_height(&m), 0); + } + + #[test] + fn desired_height_one_when_compaction_occurred() { + let m = MetricsSnapshot { + compaction_last_at_ms: 1, + ..MetricsSnapshot::default() + }; + assert_eq!(desired_height(&m), 1); + } } diff --git a/crates/zeph-tui/src/widgets/memory.rs b/crates/zeph-tui/src/widgets/memory.rs index 7d6d17e82..1e88a091a 100644 --- a/crates/zeph-tui/src/widgets/memory.rs +++ b/crates/zeph-tui/src/widgets/memory.rs @@ -5,10 +5,10 @@ use ratatui::Frame; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; use crate::metrics::{MetricsSnapshot, ProbeCategory, ProbeVerdict}; use crate::theme::Theme; +use crate::widgets::panel; fn cat_label(cat: ProbeCategory) -> &'static str { match cat { @@ -20,7 +20,7 @@ fn cat_label(cat: ProbeCategory) -> &'static str { } } -fn render_probe_last_line<'a>(metrics: &'a MetricsSnapshot, lines: &mut Vec>) { +fn render_probe_last_line(metrics: &MetricsSnapshot, lines: &mut Vec>) { let Some(verdict) = &metrics.last_probe_verdict else { return; }; @@ -78,7 +78,11 @@ fn render_probe_last_line<'a>(metrics: &'a MetricsSnapshot, lines: &mut Vec Vec> { let mut mem_lines = vec![Line::from(Span::styled( "memory", theme.system_message.add_modifier(Modifier::BOLD), @@ -164,8 +168,17 @@ pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: & metrics.guidelines_version, metrics.guidelines_updated_at, ))); } - let memory = Paragraph::new(mem_lines).block(Block::default().borders(Borders::NONE)); - frame.render_widget(memory, area); + mem_lines +} + +/// Number of rows the memory panel needs to show all of `metrics` without truncation. +#[must_use] +pub fn desired_height(metrics: &MetricsSnapshot, theme: &Theme) -> u16 { + u16::try_from(lines(metrics, theme).len()).unwrap_or(u16::MAX) +} + +pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) { + panel::render_lines(frame, area, lines(metrics, theme), theme); } #[cfg(test)] @@ -307,4 +320,62 @@ mod tests { }); assert_snapshot!(output); } + + // ── desired_height / render parity (#6675 tester gap 2) ───────────────────── + + #[test] + fn desired_height_matches_actual_rendered_row_count() { + use crate::test_utils::count_non_blank_rows; + + let metrics = MetricsSnapshot { + sqlite_message_count: 42, + qdrant_available: true, + vector_backend: "qdrant".into(), + embeddings_generated: 10, + compaction_probe_passes: 5, + last_probe_verdict: Some(ProbeVerdict::Pass), + last_probe_score: Some(0.9), + semantic_fact_count: 3, + guidelines_version: 2, + guidelines_updated_at: "2026-01-01T00:00:00.000Z".into(), + ..MetricsSnapshot::default() + }; + let theme = crate::theme::Theme::default(); + let expected = super::desired_height(&metrics, &theme); + + // Oversized area: nothing should truncate, so every measured line renders its own row. + let output = render_to_string(80, 30, |frame, area| { + super::render(&metrics, frame, area, &theme); + }); + assert_eq!( + u16::try_from(count_non_blank_rows(&output)).unwrap(), + expected, + "desired_height must match the actual non-blank rendered row count, got:\n{output}" + ); + } + + // ── overflow indicator via render() (#6675 tester gap 3) ──────────────────── + + #[test] + fn render_shows_overflow_indicator_when_area_too_small() { + let metrics = MetricsSnapshot { + sqlite_message_count: 42, + qdrant_available: true, + vector_backend: "qdrant".into(), + embeddings_generated: 10, + semantic_fact_count: 3, + guidelines_version: 2, + guidelines_updated_at: "2026-01-01T00:00:00.000Z".into(), + ..MetricsSnapshot::default() + }; + let theme = crate::theme::Theme::default(); + // desired_height for this fixture is comfortably more than 2 rows. + let output = render_to_string(50, 2, |frame, area| { + super::render(&metrics, frame, area, &theme); + }); + assert!( + output.contains("more"), + "must show overflow indicator when granted area is smaller than content, got:\n{output}" + ); + } } diff --git a/crates/zeph-tui/src/widgets/mod.rs b/crates/zeph-tui/src/widgets/mod.rs index 891270b1f..b058bac7f 100644 --- a/crates/zeph-tui/src/widgets/mod.rs +++ b/crates/zeph-tui/src/widgets/mod.rs @@ -14,6 +14,7 @@ pub mod help; pub mod input; pub mod memory; pub mod mention_picker; +pub mod panel; pub mod plan_view; pub mod resources; pub mod reverse_search; diff --git a/crates/zeph-tui/src/widgets/panel.rs b/crates/zeph-tui/src/widgets/panel.rs new file mode 100644 index 000000000..88f2d9973 --- /dev/null +++ b/crates/zeph-tui/src/widgets/panel.rs @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Shared rendering primitive for measured side-panel widgets (skills, memory, resources, +//! subagents). +//! +//! Content-driven sizing (#6675) means a slot's granted [`Rect`] can be smaller than its +//! `desired_height` under space pressure — every measured widget routes its final render +//! through [`render_lines`] so overflow is signalled consistently instead of silently +//! clipped. + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::theme::Theme; + +/// Render `lines` into `area`, truncating to the available height. +/// +/// When `lines` has more entries than `area.height` can show, `area.height - 1` lines are +/// rendered as-is and the **last visible row** is replaced with a muted `+N more` indicator, +/// where `N` is the total number of lines that are not shown (the truncated lines plus the +/// one whose row was overwritten by the indicator itself). Renders nothing when +/// `area.height == 0`. +pub fn render_lines(frame: &mut Frame, area: Rect, mut lines: Vec>, theme: &Theme) { + if area.height == 0 { + return; + } + let capacity = usize::from(area.height); + if lines.len() > capacity { + // The indicator itself occupies the last visible row, so only `capacity - 1` lines + // of real content remain visible; everything else — including that bumped line — is + // hidden. + let hidden = lines.len() - capacity + 1; + lines.truncate(capacity); + if let Some(last) = lines.last_mut() { + *last = Line::from(Span::styled( + format!(" +{hidden} more"), + theme.system_message.add_modifier(Modifier::ITALIC), + )); + } + } + let para = Paragraph::new(lines).block(Block::default().borders(Borders::NONE)); + frame.render_widget(para, area); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::render_to_string; + + fn theme() -> Theme { + Theme::default() + } + + fn lines_of(n: usize) -> Vec> { + (0..n).map(|i| Line::from(format!("line {i}"))).collect() + } + + #[test] + fn renders_all_lines_when_they_fit() { + let output = render_to_string(20, 5, |frame, area| { + render_lines(frame, area, lines_of(3), &theme()); + }); + assert!(output.contains("line 0")); + assert!(output.contains("line 1")); + assert!(output.contains("line 2")); + assert!(!output.contains("more")); + } + + #[test] + fn replaces_last_visible_row_with_overflow_indicator() { + let output = render_to_string(20, 3, |frame, area| { + render_lines(frame, area, lines_of(5), &theme()); + }); + assert!(output.contains("line 0")); + assert!(output.contains("line 1")); + assert!(!output.contains("line 2")); + assert!(!output.contains("line 4")); + // 5 lines, 3 rows: 2 visible ("line 0", "line 1") + 1 indicator row → 3 hidden + // (lines 2, 3, 4), including the row the indicator itself overwrote. + assert!(output.contains("+3 more"), "got: {output:?}"); + } + + #[test] + fn exact_fit_shows_no_overflow_indicator() { + let output = render_to_string(20, 3, |frame, area| { + render_lines(frame, area, lines_of(3), &theme()); + }); + assert!(!output.contains("more")); + } + + #[test] + fn zero_height_area_does_not_panic() { + render_to_string(20, 0, |frame, area| { + render_lines(frame, area, lines_of(3), &theme()); + }); + } + + #[test] + fn single_row_overflow_replaces_only_row() { + let output = render_to_string(20, 1, |frame, area| { + render_lines(frame, area, lines_of(4), &theme()); + }); + // 4 lines, 1 row: the only visible row is the indicator itself, so all 4 lines + // (including "line 0") are hidden. + assert!(output.contains("+4 more"), "got: {output:?}"); + } + + #[test] + fn overflow_count_includes_the_row_the_indicator_replaced() { + // Regression for the off-by-one found in review: 4 lines into 3 rows means 2 lines + // render as-is and 1 row becomes the indicator, so only 1 line's *text* survives + // unreplaced beyond the visible ones — but the indicator's own row also counts as + // hidden content, for a total of 2 hidden lines, not 1. + let output = render_to_string(20, 3, |frame, area| { + render_lines(frame, area, lines_of(4), &theme()); + }); + assert!(output.contains("line 0")); + assert!(output.contains("line 1")); + assert!(output.contains("+2 more"), "got: {output:?}"); + } +} diff --git a/crates/zeph-tui/src/widgets/plan_view.rs b/crates/zeph-tui/src/widgets/plan_view.rs index 09a9cd8b1..70eccc524 100644 --- a/crates/zeph-tui/src/widgets/plan_view.rs +++ b/crates/zeph-tui/src/widgets/plan_view.rs @@ -101,6 +101,44 @@ fn build_task_row(task: &crate::metrics::TaskSnapshotRow, tick: u8, ascii: bool) ]) } +/// Build the per-task table rows for an active, non-stale plan snapshot. +/// +/// Shared by [`render`] and [`desired_height`] so the row *count* measurement can never +/// drift from what `render` actually builds — `tick`/`ascii` only affect a spinner glyph, +/// never the number of rows, so [`desired_height`] calls this with fixed placeholder values. +fn build_rows( + snapshot: &crate::metrics::TaskGraphSnapshot, + tick: u8, + ascii: bool, +) -> Vec> { + snapshot + .tasks + .iter() + .map(|task| build_task_row(task, tick, ascii)) + .collect() +} + +/// Number of rows the plan view needs: `2` for the idle placeholder (header + hint line), +/// or `2 + tasks.len()` when an active, non-stale plan snapshot is present (header + table +/// column header + one row per task). +/// +/// Pure function of `metrics` — never of the allocated `Rect` — so [`desired_height`] and +/// [`render`] can never disagree about how many rows this panel needs. +#[must_use] +pub fn desired_height(metrics: &MetricsSnapshot) -> u16 { + let Some(ref snapshot) = metrics.orchestration_graph else { + return 2; + }; + if snapshot.is_stale() { + return 2; + } + // tick/ascii are irrelevant to row count; build_rows is the same function render() uses + // so a future change to which tasks get a row (e.g. filtering) can't silently desync + // measurement from what's actually drawn. + let rows = u16::try_from(build_rows(snapshot, 0, false).len()).unwrap_or(u16::MAX); + 2u16.saturating_add(rows) +} + /// Render the plan view widget in the given area. /// /// When `metrics.orchestration_graph` is `None`, renders a placeholder paragraph. @@ -186,11 +224,31 @@ pub fn render( Cell::from("ms").style(Style::default().fg(Color::DarkGray)), ]); - let rows: Vec> = snapshot - .tasks - .iter() - .map(|task| build_task_row(task, tick, ascii)) - .collect(); + let mut rows = build_rows(snapshot, tick, ascii); + + // The `Table` widget's own column header consumes 1 row of `splits[1]`; the rest is + // where `rows` render. `Table` silently drops rows beyond that budget with no visual + // cue, so — matching `widgets::panel::render_lines`'s overflow convention (#6675 M2) — + // truncate ourselves and replace the last visible row with a `+N more` indicator. + let capacity = usize::from(splits[1].height.saturating_sub(1)); + if rows.len() > capacity { + let hidden = rows.len() - capacity + 1; + rows.truncate(capacity); + if let Some(last) = rows.last_mut() { + *last = Row::new([ + Cell::from(""), + Cell::from(""), + Cell::from(format!("+{hidden} more")).style( + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ]); + } + } let table = Table::new(rows, widths) .header(col_header) @@ -261,6 +319,91 @@ mod tests { .collect::() } + #[test] + fn desired_height_placeholder_is_two() { + let metrics = MetricsSnapshot::default(); + assert_eq!(desired_height(&metrics), 2); + } + + #[test] + fn desired_height_matches_header_plus_column_header_plus_task_count() { + let metrics = MetricsSnapshot { + orchestration_graph: Some(make_snapshot( + "running", + vec![("Task Alpha", "pending"), ("Task Beta", "running")], + )), + ..MetricsSnapshot::default() + }; + assert_eq!(desired_height(&metrics), 4); + } + + #[test] + fn desired_height_stale_snapshot_is_placeholder_height() { + let mut metrics = MetricsSnapshot::default(); + let mut snap = make_snapshot("completed", vec![("Task", "completed")]); + snap.completed_at = Some( + std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(31)) + .unwrap(), + ); + metrics.orchestration_graph = Some(snap); + assert_eq!(desired_height(&metrics), 2); + } + + #[test] + fn desired_height_matches_actual_row_count_render_builds() { + // #6675 M1: desired_height must derive from the same build_rows() render() uses, + // not an independently-maintained count. + let metrics = MetricsSnapshot { + orchestration_graph: Some(make_snapshot( + "running", + vec![("A", "pending"), ("B", "running"), ("C", "completed")], + )), + ..MetricsSnapshot::default() + }; + let Some(ref snapshot) = metrics.orchestration_graph else { + unreachable!() + }; + let actual_rows = super::build_rows(snapshot, 0, false).len(); + assert_eq!( + desired_height(&metrics), + 2 + u16::try_from(actual_rows).unwrap() + ); + } + + #[test] + fn render_shows_overflow_indicator_when_tasks_exceed_area() { + // #6675 M2: plan_view's Table must not silently clip rows under pressure. + let metrics = MetricsSnapshot { + orchestration_graph: Some(make_snapshot( + "running", + vec![ + ("Task One", "pending"), + ("Task Two", "running"), + ("Task Three", "completed"), + ("Task Four", "pending"), + ("Task Five", "pending"), + ], + )), + ..MetricsSnapshot::default() + }; + // Outer header (1) + table column header (1) + only 2 data rows worth of space. + let output = crate::test_utils::render_to_string(80, 4, |frame, area| { + render( + &metrics, + frame, + area, + 0, + false, + &crate::theme::Theme::default(), + ); + }); + assert!( + output.contains("more"), + "overflowing task list must show an indicator, got:\n{output}" + ); + } + #[test] fn empty_graph_renders_placeholder() { let metrics = MetricsSnapshot::default(); diff --git a/crates/zeph-tui/src/widgets/resources.rs b/crates/zeph-tui/src/widgets/resources.rs index b8830eb68..6b10159bb 100644 --- a/crates/zeph-tui/src/widgets/resources.rs +++ b/crates/zeph-tui/src/widgets/resources.rs @@ -14,19 +14,20 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use std::fmt::Write as _; -use ratatui::widgets::{Block, Borders, Paragraph}; - use crate::layout::truncate_to_width; use crate::metrics::MetricsSnapshot; use crate::theme::Theme; +use crate::widgets::panel; -/// Render the resources panel into `area`. +/// Build the resources panel's content lines. /// /// Layout (spec §4): section title · tokens · api · route, with optional /// cache, MCP, background-shell, turn-latency, and classifier-latency lines -/// when non-zero. -pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) { - let mut lines: Vec> = vec![Line::from(Span::styled( +/// when non-zero. Pure function of `metrics` and `theme` — never of the allocated `Rect` — +/// so [`desired_height`] and [`render`] can never disagree about how many rows this panel +/// needs. +pub(crate) fn lines(metrics: &MetricsSnapshot, theme: &Theme) -> Vec> { + let mut lines: Vec> = vec![Line::from(Span::styled( "resources", theme.system_message.add_modifier(Modifier::BOLD), ))]; @@ -40,8 +41,18 @@ pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: & append_turn_latency_section(&mut lines, metrics); append_classifier_latency_line(&mut lines, metrics); - let resources = Paragraph::new(lines).block(Block::default().borders(Borders::NONE)); - frame.render_widget(resources, area); + lines +} + +/// Number of rows the resources panel needs to show all of `metrics` without truncation. +#[must_use] +pub fn desired_height(metrics: &MetricsSnapshot, theme: &Theme) -> u16 { + u16::try_from(lines(metrics, theme).len()).unwrap_or(u16::MAX) +} + +/// Render the resources panel into `area`. +pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) { + panel::render_lines(frame, area, lines(metrics, theme), theme); } /// `tokens Nk` (with reasoning suffix when non-zero). @@ -433,4 +444,77 @@ mod tests { "must show reasoning label; got: {output:?}" ); } + + // ── desired_height / render parity (#6675 tester gap 2) ───────────────────── + + #[test] + fn desired_height_matches_actual_rendered_row_count() { + use crate::test_utils::count_non_blank_rows; + use zeph_core::metrics::{ClassifierMetricsSnapshot, TaskMetricsSnapshot, TurnTimings}; + + let metrics = MetricsSnapshot { + provider_name: "claude".into(), + model_name: "opus-4".into(), + embedding_model: "nomic-embed-text".into(), + total_tokens: 12_500, + reasoning_tokens: 500, + api_calls: 5, + last_llm_latency_ms: 250, + cache_creation_tokens: 1000, + cache_read_tokens: 500, + mcp_server_count: 2, + mcp_connected_count: 2, + mcp_tool_count: 14, + timing_sample_count: 3, + last_turn_timings: TurnTimings { + prepare_context_ms: 12, + llm_chat_ms: 340, + tool_exec_ms: 58, + persist_message_ms: 4, + }, + classifier: ClassifierMetricsSnapshot { + injection: TaskMetricsSnapshot { + call_count: 4, + p50_ms: Some(7), + p95_ms: Some(15), + }, + pii: TaskMetricsSnapshot::default(), + feedback: TaskMetricsSnapshot::default(), + }, + ..MetricsSnapshot::default() + }; + let expected = super::desired_height(&metrics, &theme()); + + let output = render_to_string(80, 30, |frame, area| { + super::render(&metrics, frame, area, &theme()); + }); + assert_eq!( + u16::try_from(count_non_blank_rows(&output)).unwrap(), + expected, + "desired_height must match the actual non-blank rendered row count, got:\n{output}" + ); + } + + // ── overflow indicator via render() (#6675 tester gap 3) ──────────────────── + + #[test] + fn render_shows_overflow_indicator_when_area_too_small() { + let metrics = MetricsSnapshot { + provider_name: "claude".into(), + model_name: "opus-4".into(), + total_tokens: 12_500, + api_calls: 5, + mcp_server_count: 2, + mcp_connected_count: 2, + mcp_tool_count: 14, + ..MetricsSnapshot::default() + }; + let output = render_to_string(50, 2, |frame, area| { + super::render(&metrics, frame, area, &theme()); + }); + assert!( + output.contains("more"), + "must show overflow indicator when granted area is smaller than content, got:\n{output}" + ); + } } diff --git a/crates/zeph-tui/src/widgets/security.rs b/crates/zeph-tui/src/widgets/security.rs index 7d8123505..b8170fa10 100644 --- a/crates/zeph-tui/src/widgets/security.rs +++ b/crates/zeph-tui/src/widgets/security.rs @@ -2,31 +2,29 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use ratatui::Frame; -use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{List, ListItem, Paragraph}; use crate::metrics::{MetricsSnapshot, SecurityEventCategory}; use crate::theme::Theme; +use crate::widgets::panel; -pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) { +/// Build the security panel's content lines: a header, then either "No security events." or +/// the full metric list plus recent-event entries. +/// +/// Pure function of `metrics` and `theme` — never of the allocated `Rect` — so +/// [`desired_height`] and [`render`] can never disagree about how many rows this panel needs. +pub(crate) fn lines(metrics: &MetricsSnapshot, theme: &Theme) -> Vec> { let event_count = metrics.security_events.len(); let header_text = format!( "security · {event_count} event{}", if event_count == 1 { "" } else { "s" } ); - let header = Line::from(Span::styled( + let mut out = vec![Line::from(Span::styled( header_text, theme.system_message.add_modifier(Modifier::BOLD), - )); - let splits = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area); - frame.render_widget(Paragraph::new(header), splits[0]); - - let inner = splits[1]; - if inner.height == 0 { - return; - } + ))]; let all_zero = metrics.sanitizer_runs == 0 && metrics.sanitizer_injection_flags == 0 @@ -43,9 +41,11 @@ pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: & && metrics.security_events.is_empty(); if all_zero { - let msg = Paragraph::new("No security events.").style(theme.system_message); - frame.render_widget(msg, inner); - return; + out.push(Line::from(Span::styled( + "No security events.", + theme.system_message, + ))); + return out; } let base = theme.system_message; @@ -54,43 +54,52 @@ pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: & .add_modifier(Modifier::BOLD); let block_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); - let mut items = build_metric_items(metrics, base, flag_style, block_style); - append_event_items(metrics, &mut items, base, flag_style, block_style); + out.extend(build_metric_items(metrics, base, flag_style, block_style)); + append_event_items(metrics, &mut out, base, flag_style, block_style); + out +} - let list = List::new(items); - frame.render_widget(list, inner); +/// Number of rows the security panel needs to show all of `metrics` without truncation. +#[must_use] +pub fn desired_height(metrics: &MetricsSnapshot, theme: &Theme) -> u16 { + u16::try_from(lines(metrics, theme).len()).unwrap_or(u16::MAX) } -/// Build a `ListItem` with a plain styled label and value, using `base` style for both. +pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) { + panel::render_lines(frame, area, lines(metrics, theme), theme); +} + +/// Build a plain styled label+value line, using `base` style for both. fn plain_metric_item( label: &'static str, value: impl std::fmt::Display, base: Style, -) -> ListItem<'static> { - ListItem::new(Line::from(Span::styled(format!("{label}{value}"), base))) +) -> Line<'static> { + Line::from(Span::styled(format!("{label}{value}"), base)) } -/// Build a `ListItem` whose value span switches to `alert_style` when the value is non-zero. -fn styled_counter_item<'a>( +/// Build a label+value line whose value span switches to `alert_style` when the value is +/// non-zero. +fn styled_counter_item( label: &'static str, value: u64, base: Style, alert_style: Style, -) -> ListItem<'a> { - ListItem::new(Line::from(vec![ +) -> Line<'static> { + Line::from(vec![ Span::styled(label, base), Span::styled( value.to_string(), if value > 0 { alert_style } else { base }, ), - ])) + ]) } -fn build_sanitizer_items<'a>( +fn build_sanitizer_items( metrics: &MetricsSnapshot, base: Style, flag_style: Style, -) -> Vec> { +) -> Vec> { vec![ plain_metric_item("Sanitizer runs: ", metrics.sanitizer_runs, base), styled_counter_item( @@ -105,11 +114,11 @@ fn build_sanitizer_items<'a>( ] } -fn build_exfiltration_items<'a>( +fn build_exfiltration_items( metrics: &MetricsSnapshot, base: Style, block_style: Style, -) -> Vec> { +) -> Vec> { vec![ styled_counter_item( "Exfil images: ", @@ -131,12 +140,12 @@ fn build_exfiltration_items<'a>( ] } -fn build_pre_execution_items<'a>( +fn build_pre_execution_items( metrics: &MetricsSnapshot, base: Style, flag_style: Style, block_style: Style, -) -> Vec> { +) -> Vec> { vec![ styled_counter_item( "Verify blocks: ", @@ -153,12 +162,12 @@ fn build_pre_execution_items<'a>( ] } -fn build_egress_items<'a>( +fn build_egress_items( metrics: &MetricsSnapshot, base: Style, flag_style: Style, block_style: Style, -) -> Vec> { +) -> Vec> { vec![ plain_metric_item("Egress requests: ", metrics.egress_requests_total, base), styled_counter_item( @@ -176,12 +185,12 @@ fn build_egress_items<'a>( ] } -fn build_metric_items<'a>( +fn build_metric_items( metrics: &MetricsSnapshot, base: Style, flag_style: Style, block_style: Style, -) -> Vec> { +) -> Vec> { let mut items = build_sanitizer_items(metrics, base, flag_style); items.extend(build_exfiltration_items(metrics, base, block_style)); items.extend(build_pre_execution_items( @@ -194,9 +203,9 @@ fn build_metric_items<'a>( items } -fn append_event_items<'a>( - metrics: &'a MetricsSnapshot, - items: &mut Vec>, +fn append_event_items( + metrics: &MetricsSnapshot, + items: &mut Vec>, base: Style, flag_style: Style, block_style: Style, @@ -204,12 +213,12 @@ fn append_event_items<'a>( if metrics.security_events.is_empty() { return; } - items.push(ListItem::new(Line::from(Span::styled( + items.push(Line::from(Span::styled( "Recent events:", Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::UNDERLINED), - )))); + ))); // Show last 5 events (most recent last). let start = metrics.security_events.len().saturating_sub(5); @@ -236,15 +245,15 @@ fn append_event_items<'a>( _ => ("[unkn] ", Style::default().fg(Color::DarkGray)), }; let hm = format_hm(ev.timestamp); - items.push(ListItem::new(Line::from(vec![ + items.push(Line::from(vec![ Span::styled(format!("{hm} "), Style::default().fg(Color::DarkGray)), Span::styled(cat_str, cat_style), Span::styled(format!(" {}", ev.source), base), - ]))); - items.push(ListItem::new(Line::from(Span::styled( + ])); + items.push(Line::from(Span::styled( format!(" {}", ev.detail), Style::default().fg(Color::DarkGray), - )))); + ))); } } @@ -389,4 +398,55 @@ mod tests { render(&metrics, frame, area, &theme); }); } + + // ── desired_height / render parity (#6675 tester gap 2) ───────────────────── + + #[test] + fn desired_height_matches_actual_rendered_row_count() { + use crate::test_utils::count_non_blank_rows; + + let mut events = VecDeque::new(); + events.push_back(SecurityEvent::new( + SecurityEventCategory::InjectionFlag, + "web_scrape", + "Detected pattern: ignore previous", + )); + let metrics = MetricsSnapshot { + sanitizer_runs: 10, + sanitizer_injection_flags: 1, + security_events: events, + ..MetricsSnapshot::default() + }; + let theme = crate::theme::Theme::default(); + let expected = desired_height(&metrics, &theme); + + let output = render_to_string(80, 40, |frame, area| { + render(&metrics, frame, area, &theme); + }); + assert_eq!( + u16::try_from(count_non_blank_rows(&output)).unwrap(), + expected, + "desired_height must match the actual non-blank rendered row count, got:\n{output}" + ); + } + + // ── overflow indicator via render() (#6675 tester gap 3) ──────────────────── + + #[test] + fn render_shows_overflow_indicator_when_area_too_small() { + let metrics = MetricsSnapshot { + sanitizer_runs: 10, + sanitizer_injection_flags: 1, + ..MetricsSnapshot::default() + }; + let theme = crate::theme::Theme::default(); + // The always-present 13 metric rows + header comfortably exceed 2 rows. + let output = render_to_string(50, 2, |frame, area| { + render(&metrics, frame, area, &theme); + }); + assert!( + output.contains("more"), + "must show overflow indicator when granted area is smaller than content, got:\n{output}" + ); + } } diff --git a/crates/zeph-tui/src/widgets/skills.rs b/crates/zeph-tui/src/widgets/skills.rs index 48fd5178d..121b18141 100644 --- a/crates/zeph-tui/src/widgets/skills.rs +++ b/crates/zeph-tui/src/widgets/skills.rs @@ -5,26 +5,28 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; use crate::metrics::{McpServerConnectionStatus, MetricsSnapshot, SkillConfidence}; use crate::theme::Theme; +use crate::widgets::panel; -pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) { - let has_mcp = !metrics.active_mcp_tools.is_empty() || metrics.mcp_tool_count > 0; - let chunks = if has_mcp { - Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area) - } else { - Layout::vertical([Constraint::Percentage(100), Constraint::Min(0)]).split(area) - }; - +/// Build the skills panel's two logical sections: the active-skills list and, when any MCP +/// tools are configured, the MCP servers/tools list. Both are pure functions of `metrics` and +/// `theme` — never of the allocated `Rect` — so [`desired_height`] and [`render`] can never +/// disagree about how many rows this panel needs. +/// +/// Returns `(skills_lines, mcp_lines)`; `mcp_lines` is empty when no MCP tools are configured. +pub(crate) fn sections( + metrics: &MetricsSnapshot, + theme: &Theme, +) -> (Vec>, Vec>) { let confidence_map: std::collections::HashMap<&str, &SkillConfidence> = metrics .skill_confidence .iter() .map(|c| (c.name.as_str(), c)) .collect(); - let skill_lines: Vec> = metrics + let skill_lines: Vec> = metrics .active_skills .iter() .map(|s| { @@ -55,11 +57,10 @@ pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: & )); let mut skills_content = vec![skills_header]; skills_content.extend(skill_lines); - let skills = Paragraph::new(skills_content).block(Block::default().borders(Borders::NONE)); - frame.render_widget(skills, chunks[0]); + let has_mcp = !metrics.active_mcp_tools.is_empty() || metrics.mcp_tool_count > 0; + let mut mcp_lines: Vec> = Vec::new(); if has_mcp { - let mut mcp_lines: Vec> = Vec::new(); mcp_lines.push(Line::from(Span::styled( format!( "mcp tools {}/{}", @@ -91,9 +92,58 @@ pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: & for t in &metrics.active_mcp_tools { mcp_lines.push(Line::from(format!(" - {t}"))); } - let mcp = Paragraph::new(mcp_lines).block(Block::default().borders(Borders::NONE)); - frame.render_widget(mcp, chunks[1]); } + + (skills_content, mcp_lines) +} + +/// Number of rows the skills panel needs to show both sections without truncation. +#[must_use] +pub fn desired_height(metrics: &MetricsSnapshot, theme: &Theme) -> u16 { + let (skills, mcp) = sections(metrics, theme); + u16::try_from(skills.len() + mcp.len()).unwrap_or(u16::MAX) +} + +pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) { + let (skills_content, mcp_lines) = sections(metrics, theme); + if mcp_lines.is_empty() { + panel::render_lines(frame, area, skills_content, theme); + return; + } + + // Content-length split: when there's room, the skills section gets exactly its own line + // count and the MCP section gets the rest. Under space pressure `split_two_sections` + // still gives the MCP section its own floor row so it can show a `+N more` indicator + // instead of vanishing entirely when the skills section alone exceeds the granted area + // (#6675 S2). + let skills_demand = u16::try_from(skills_content.len()).unwrap_or(u16::MAX); + let mcp_demand = u16::try_from(mcp_lines.len()).unwrap_or(u16::MAX); + let skills_h = split_two_sections(skills_demand, mcp_demand, area.height); + let chunks = Layout::vertical([Constraint::Length(skills_h), Constraint::Min(0)]).split(area); + panel::render_lines(frame, chunks[0], skills_content, theme); + panel::render_lines(frame, chunks[1], mcp_lines, theme); +} + +/// Split `available` rows between two stacked sections so that, unless space is critically +/// tight, each section gets at least one row and can render its own overflow indicator +/// instead of being silently dropped when the other section's demand alone exceeds +/// `available`. Returns the height granted to the first section; the second is expected to +/// receive whatever remains (e.g. via `Constraint::Min(0)`), which reproduces "first section +/// gets exactly its own line count" whenever `available` covers both demands. +fn split_two_sections(first_demand: u16, second_demand: u16, available: u16) -> u16 { + if available == 0 { + return 0; + } + if available == 1 { + // Only one row total: give it to whichever section actually has something to show; + // ties (including neither having content) favor the first section. + return u16::from(first_demand >= 1 || second_demand == 0); + } + let first_floor = u16::from(first_demand >= 1); + let second_floor = u16::from(second_demand >= 1); + let remaining = available - first_floor - second_floor; + let first_room = first_demand.saturating_sub(first_floor); + first_floor + remaining.min(first_room) } fn confidence_bar(posterior: f64, width: usize) -> String { @@ -241,4 +291,106 @@ mod tests { "expected dash prefix, got:\n{output}" ); } + + // ── split_two_sections / MCP-under-pressure (#6675 S2) ──────────────────── + + #[test] + fn split_two_sections_generous_space_gives_first_its_full_demand() { + assert_eq!(super::split_two_sections(5, 3, 20), 5); + } + + #[test] + fn split_two_sections_zero_available_gives_nothing() { + assert_eq!(super::split_two_sections(5, 3, 0), 0); + } + + #[test] + fn split_two_sections_single_row_favors_first_when_both_want_it() { + assert_eq!(super::split_two_sections(5, 3, 1), 1); + } + + #[test] + fn split_two_sections_single_row_goes_to_second_when_first_is_empty() { + assert_eq!(super::split_two_sections(0, 3, 1), 0); + } + + #[test] + fn split_two_sections_second_keeps_its_floor_under_pressure() { + // First section alone would exceed the whole budget; second must still get >= 1 row. + let first_h = super::split_two_sections(10, 3, 4); + assert!( + first_h <= 3, + "second section must keep at least 1 row, got first={first_h}" + ); + } + + #[test] + fn mcp_section_shows_overflow_indicator_instead_of_vanishing_under_pressure() { + let metrics = MetricsSnapshot { + active_skills: vec![ + "one".into(), + "two".into(), + "three".into(), + "four".into(), + "five".into(), + ], + total_skills: 5, + mcp_tool_count: 3, + active_mcp_tools: vec!["tool-a".into(), "tool-b".into(), "tool-c".into()], + ..MetricsSnapshot::default() + }; + let theme = crate::theme::Theme::default(); + // Skills section alone (header + 5 lines = 6) exceeds this tiny area; the MCP + // section must still get a chance to show its own truncation indicator rather than + // disappearing with height 0. + let output = render_to_string(40, 4, |frame, area| { + super::render(&metrics, frame, area, &theme); + }); + assert!( + output.contains("more"), + "MCP section must not vanish silently under pressure, got:\n{output}" + ); + } + + // ── desired_height / render parity (#6675 tester gap 2) ───────────────────── + + #[test] + fn desired_height_matches_actual_rendered_row_count() { + use crate::metrics::McpServerConnectionStatus; + use crate::test_utils::count_non_blank_rows; + use zeph_core::metrics::McpServerStatus; + + let metrics = MetricsSnapshot { + active_skills: vec!["web-search".into(), "code-gen".into()], + total_skills: 5, + skill_confidence: vec![SkillConfidence { + name: "web-search".into(), + posterior: 0.8, + total_uses: 12, + }], + mcp_tool_count: 2, + active_mcp_tools: vec!["tool-a".into()], + mcp_servers: vec![McpServerStatus { + id: "srv1".into(), + status: McpServerConnectionStatus::Connected, + tool_count: 2, + error: String::new(), + input_schemas_dropped: 0, + output_schemas_dropped: 0, + }], + ..MetricsSnapshot::default() + }; + let theme = crate::theme::Theme::default(); + let expected = super::desired_height(&metrics, &theme); + + // Oversized area, comfortably split between both sections: nothing truncates. + let output = render_to_string(80, 30, |frame, area| { + super::render(&metrics, frame, area, &theme); + }); + assert_eq!( + u16::try_from(count_non_blank_rows(&output)).unwrap(), + expected, + "desired_height must match the actual non-blank rendered row count, got:\n{output}" + ); + } } diff --git a/crates/zeph-tui/src/widgets/subagents.rs b/crates/zeph-tui/src/widgets/subagents.rs index fa3fffdbd..08db5ffd3 100644 --- a/crates/zeph-tui/src/widgets/subagents.rs +++ b/crates/zeph-tui/src/widgets/subagents.rs @@ -12,10 +12,30 @@ use zeph_subagent::{ModelSpec, SubAgentDef, ToolPolicy, is_valid_agent_name}; use crate::layout::truncate_to_width; use crate::metrics::{MetricsSnapshot, SubAgentMetrics}; use crate::theme::Theme; +use crate::widgets::panel; use crate::widgets::spinner::breeze_frame; // ── Runtime sub-agent monitor ───────────────────────────────────────────────── +/// Which base-layer view the `SubAgents` slot renders this frame. +/// +/// Chosen once per frame by `App::subagent_slot_mode` and consumed by both sizing +/// (`App::panel_demands`) and rendering (`App::render_subagents_slot`) so the two decisions +/// can never disagree (#6675) — this mirrors the same priority chain that used to be +/// re-derived independently in `render_subagents_slot` and `App::effective_collapsed`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubAgentSlotMode { + /// User is focused on the `SubAgents` panel (`a` key): interactive sidebar, optionally with + /// a live forwarded transcript. Sized `Greedy` — the live transcript wraps. + Interactive, + /// DAG/orchestration plan view is active and not dismissed. + PlanView, + /// Recent security events summary. + Security, + /// Idle default: plain sub-agents list. + List, +} + fn state_color(state: &str) -> Color { match state { "working" | "submitted" => Color::Yellow, @@ -32,6 +52,10 @@ fn build_agent_list_item<'a>( selected: bool, ascii: bool, ) -> ListItem<'a> { + ListItem::new(build_agent_line(sa, tick, selected, ascii)) +} + +fn build_agent_line(sa: &SubAgentMetrics, tick: u8, selected: bool, ascii: bool) -> Line<'static> { let color = state_color(&sa.state); let is_working = matches!(sa.state.as_str(), "working" | "submitted"); let spinner = if is_working { @@ -56,7 +80,7 @@ fn build_agent_list_item<'a>( Style::default() }; - let line = Line::from(vec![ + Line::from(vec![ Span::styled(format!(" {spinner} "), Style::default().fg(color)), Span::styled( format!("{}{}{}", sa.name, bg_marker, perm_badge), @@ -70,52 +94,52 @@ fn build_agent_list_item<'a>( format!(" {}/{} {}s", sa.turns_used, sa.max_turns, sa.elapsed_secs), base_style, ), - ]); - ListItem::new(line) + ]) } -/// Non-interactive render (used when `SubAgents` panel is not focused). -pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) { - use ratatui::text::Span; - - if area.height == 0 || area.width == 0 { - return; - } - +/// Build the plain (non-interactive) sub-agents list's content lines: a header plus one row +/// per sub-agent, or a two-line placeholder when there are none. +/// +/// Pure function of `metrics` and `theme` — never of the allocated `Rect` — so +/// [`desired_height`] and [`render`] can never disagree about how many rows this view needs. +pub(crate) fn lines(metrics: &MetricsSnapshot, theme: &Theme) -> Vec> { if metrics.sub_agents.is_empty() { - let header = Line::from(Span::styled( - "agents · none", - theme.system_message.add_modifier(Modifier::BOLD), - )); - let body = Paragraph::new(vec![ - header, + return vec![ + Line::from(Span::styled( + "agents · none", + theme.system_message.add_modifier(Modifier::BOLD), + )), Line::from(" No sub-agents. Use /agent spawn to create one."), - ]); - frame.render_widget(body, area); - return; + ]; } + let mut out = vec![Line::from(Span::styled( + format!("agents · {}", metrics.sub_agents.len()), + theme.system_message.add_modifier(Modifier::BOLD), + ))]; // Non-interactive view uses tick=0 (no animation); ascii flag is irrelevant when idle, // but kept for consistency if a working agent appears in the static view. - let items: Vec> = metrics - .sub_agents - .iter() - .map(|sa| build_agent_list_item(sa, 0, false, false)) - .collect(); + out.extend( + metrics + .sub_agents + .iter() + .map(|sa| build_agent_line(sa, 0, false, false)), + ); + out +} - let header = Line::from(Span::styled( - format!("agents · {}", metrics.sub_agents.len()), - theme.system_message.add_modifier(Modifier::BOLD), - )); +/// Number of rows the plain sub-agents list needs to show all of `metrics` without truncation. +#[must_use] +pub fn desired_height(metrics: &MetricsSnapshot, theme: &Theme) -> u16 { + u16::try_from(lines(metrics, theme).len()).unwrap_or(u16::MAX) +} - // Render header, then the list below it. - if area.height <= 1 { - frame.render_widget(Paragraph::new(vec![header]), area); +/// Non-interactive render (used when `SubAgents` panel is not focused). +pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) { + if area.height == 0 || area.width == 0 { return; } - let splits = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area); - frame.render_widget(Paragraph::new(vec![header]), splits[0]); - frame.render_widget(List::new(items), splits[1]); + panel::render_lines(frame, area, lines(metrics, theme), theme); } /// Interactive render: shows selection highlight and spinner animation. @@ -1161,6 +1185,81 @@ mod tests { assert!(output.contains("[bypass!]")); } + // ── desired_height / lines parity (#6675) ────────────────────────────────── + + #[test] + fn desired_height_two_when_empty() { + let metrics = MetricsSnapshot::default(); + let theme = crate::theme::Theme::default(); + assert_eq!(desired_height(&metrics, &theme), 2); + } + + #[test] + fn desired_height_matches_header_plus_agent_count() { + let metrics = MetricsSnapshot { + sub_agents: vec![ + SubAgentMetrics { + id: "a".into(), + name: "one".into(), + state: "working".into(), + turns_used: 1, + max_turns: 5, + background: false, + elapsed_secs: 1, + permission_mode: String::new(), + transcript_dir: None, + live_transcript: Vec::new(), + }, + SubAgentMetrics { + id: "b".into(), + name: "two".into(), + state: "completed".into(), + turns_used: 2, + max_turns: 5, + background: false, + elapsed_secs: 2, + permission_mode: String::new(), + transcript_dir: None, + live_transcript: Vec::new(), + }, + ], + ..MetricsSnapshot::default() + }; + let theme = crate::theme::Theme::default(); + assert_eq!(desired_height(&metrics, &theme), 3); + assert_eq!(lines(&metrics, &theme).len(), 3); + } + + #[test] + fn render_shows_overflow_indicator_when_area_too_small() { + let metrics = MetricsSnapshot { + sub_agents: (0..5) + .map(|i| SubAgentMetrics { + id: format!("agent-{i}"), + name: format!("agent-{i}"), + state: "working".into(), + turns_used: 1, + max_turns: 5, + background: false, + elapsed_secs: 1, + permission_mode: String::new(), + transcript_dir: None, + live_transcript: Vec::new(), + }) + .collect(), + ..MetricsSnapshot::default() + }; + let theme = crate::theme::Theme::default(); + // 6 lines (header + 5 agents) into a 2-row area. + let output = render_to_string(60, 2, |frame, area| { + super::render(&metrics, frame, area, &theme); + }); + assert!( + output.contains("more"), + "must show overflow indicator when granted area is smaller than content, got:\n{output}" + ); + } + // ── Live transcript panel tests (issue #6359, FR-005) ───────────────────── fn agent_with_live_transcript(id: &str, live_transcript: Vec) -> SubAgentMetrics { diff --git a/specs/011-tui/spec.md b/specs/011-tui/spec.md index 2cfad68a9..2d8d95305 100644 --- a/specs/011-tui/spec.md +++ b/specs/011-tui/spec.md @@ -69,6 +69,98 @@ TuiApp Tab cycling order includes SubAgents. See `026-tui-subagent-management/spec.md` for full SubAgents panel spec. +## Side Panel Sizing (#6675) + +The four side-panel slots (Skills, Memory, Resources, SubAgents) are sized from their own +content each frame rather than split into equal shares, so a sparse panel doesn't waste space +and a busy one isn't silently clipped. + +**Types** (`crates/zeph-tui/src/layout.rs`): +- `PanelDemand` — `Collapsed` (user-pinned to one summary row), `Rows(u16)` (wants exactly + that many rows), or `Greedy` (wants every row it can get). `Default` is `Greedy`, so four + `Greedy` demands give every slot the same *total* height the pre-#6675 equal-`Fill(1)` + split did, and `Collapsed` reproduces the old `Length(1)` collapse behavior exactly. The + new allocator's remainder placement is top-down, not identical to ratatui's cassowary + solver (which spreads remainder toward the middle slots) — e.g. for `available=5` the old + split gives `[1,2,1,1]`, the new one gives `[2,1,1,1]`; totals match, per-slot layout does + not. See `fit_panel_heights`'s remainder rule below. +- `PanelSizing` — `demands: [PanelDemand; 4]` plus an optional `focus: Option` slot + index that receives rounding remainders first under space pressure. +- `AppLayout::compute(area, show_side_panels, input_height, panels: PanelSizing)` resolves + `panels` into concrete row counts via `fit_panel_heights` and builds each side-column `Rect` + via direct y-offset arithmetic (not a second `Layout::split` — per-frame-varying `Length` + constraints are the worst case for ratatui's internal layout cache). + +**Allocator** (`fit_panel_heights`): integer max-min fair water-filling, not the ratatui +`Constraint`/cassowary solver (which gives no priority-ordered shrink and unpredictable +over-constrained behavior). Guarantees, upheld for any input including `Rows(u16::MAX)` via +`u32` intermediates: +- `sum(granted) <= available` +- `granted[i] <= demand[i]` for `Rows(n)` — a slot is never given more than it asked for +- `granted[i] >= 1` for every slot whose demand allows it (i.e. `demand != Rows(0)`) once + `available >= 4` — the floor row is an identity row / mouse hit target / collapse affordance +- monotone non-decreasing in `available` and in each slot's own demand +- never panics + +When `available < 4` there's no room for a floor row per slot: rows are handed out top-down, +skipping zero-demand slots, until either budget is exhausted. Surplus beyond total demand is +left as a trailing blank at the bottom of the column — donating it to chat is not +geometrically possible, since chat is a horizontal sibling that already spans the full band +height. + +**Measurement — widget-local, never `Rect`-dependent.** Each measured widget exposes a pure +`desired_height(metrics, ...) -> u16` built from the same line-list the widget renders +(`skills`, `memory`, `resources`, the plain SubAgents list, `security`, `plan_view`), so +sizing and rendering can never disagree. `compaction_badge::desired_height` returns `0` when +`compaction_last_at_ms == 0` (no unconditional blank row for an absent badge). A widget's +`desired_height` **must never** be a function of the `Rect` it will be granted — that would +create a layout feedback loop that oscillates frame to frame. + +`App::panel_demands()` (`crates/zeph-tui/src/app/state.rs`) composes each widget's +`desired_height` with the chrome `draw_side_panel` layers on top: +1 row when the slot is +focused (section header), +2 rows for the resources slot (`context_gauge` always, plus +`compaction_badge`'s own 0/1 rule), and +`EQ_PANEL_H` for the subagents slot while the +equalizer is showing. A `collapsed_panels` pin overrides content sizing entirely via +`PanelDemand::Collapsed`. + +**Greedy vs measured classification**: +- Measured (finite, non-wrapping line lists): skills, memory, resources, the plain (idle) + subagents list, the security summary, the plan view. +- Greedy (overlays / wrapped / scrollable): Fleet, Durable, Settings, the task registry, and + the focused-interactive SubAgents view (its live-transcript tail wraps). + +`App::subagent_slot_mode()` is the single source of truth for which base-layer view the +SubAgents slot shows this frame (`SubAgentSlotMode::{Interactive, PlanView, Security, List}`) +— it is computed once per frame and consumed by both `panel_demands` (sizing) and +`render_subagents_slot` (rendering), so the two decisions can never disagree. Overlay +activity (Fleet/Durable/Settings/Tasks) is tracked separately and, together with +`Interactive` mode, forces `PanelDemand::Greedy`. + +**Overflow indicator.** `widgets::panel::render_lines(frame, area, lines, theme)` is the +shared render primitive for all four measured panels: it renders `area.height - 1` lines +as-is and, when `lines.len() > area.height`, replaces the **last visible row** with a muted +`+N more` (`N` = total lines not shown, including the one the indicator's own row replaced — +not just the truncated tail). One implementation, one snapshot-tested behavior, instead of +four independent truncation strategies. The skills panel's inner skills/MCP split +(`split_two_sections`) applies the same "give the other section a floor row so it can show +its own indicator" principle rather than letting one section's demand starve the other to +zero height. + +**`[tui] panel_sizing`** (`zeph_config::PanelSizingMode`, default `auto`): `even` is an +escape hatch that **approximates** the pre-#6675 equal-share split by giving every unpinned +slot a `Greedy` demand instead of measuring content — it reproduces the same *total* split +(see the `PanelDemand::Default` note above for why per-slot remainder placement can differ +from the old cassowary-based `Fill(1)`), and it does **not** revert unrelated bug fixes this +PR made unconditionally, e.g. the resources slot's `compaction_badge` row still sizes to +`compaction_badge::desired_height` (0 or 1) rather than the old unconditional `Length(1)` — +that row-height fix applies in both `auto` and `even` mode. Runtime-togglable via +`/panel_sizing [auto|even]` or the command palette; not persisted back to config. + +**Collapse mask reframing.** `collapsed_panels`/`toggle_panel_collapse` remain the user-pin +mechanism unchanged at the API level. `effective_collapsed()` still decides *which content* a +slot renders (single summary row vs. real widget); unpinned (`false`) now means "auto, +content-sized" instead of "equal share" — sizing itself is `panel_demands()`'s job. + ## Spinner Rule (NON-NEGOTIABLE) **Every background or implicit operation must show a visible spinner with a short status message.** diff --git a/specs/README.md b/specs/README.md index 5c4ce9acf..965aeacc3 100644 --- a/specs/README.md +++ b/specs/README.md @@ -107,7 +107,7 @@ Spec IDs follow a logical grouping (with gaps for open proposals and reserved nu | `010-security/010-5-egress-logging.md` | Egress logging sub-spec: `EgressEvent` per outbound HTTP call, `AuditEntry.correlation_id`, bounded mpsc telemetry (256 + drop counter), TUI Security panel surface | `zeph-tools`, `zeph-core`, `zeph-tui` | | `010-security/010-6-vigil-intent-anchoring.md` | VIGIL verify-before-commit sub-spec: pre-sanitizer regex tripwire with Block/Sanitize action, per-turn `current_turn_intent`, subagent exemption, non-retryable blocks via `error_category="vigil_blocked"` | `zeph-core`, `zeph-tools`, `zeph-config` | | `010-security/010-7-shadow-memory-guardrail.md` | Shadow Memory Guardrail sub-spec: MAGE multi-turn threat detection (goal hijacking via accumulating risk scores), SafeHarbor hierarchical guardrail tree (entropy-based evolution, adaptive rule injection) | `zeph-sanitizer`, `zeph-memory`, `zeph-agent-tools`, `zeph-core` | -| `011-tui/spec.md` | ratatui dashboard, spinner rule for background operations, TuiChannel, RenderCache, embed backfill progress, multi-session `SessionRegistry`, `/session` commands, compact paste indicator; Fleet panel (`f` key, #3884); reasoning token tracking; terminal title (#4354); fleet session lifecycle wiring (#4363); Ctrl+C interrupt & double-press-to-quit semantics + agent-cancel moved from Esc to Ctrl+C (#6646) | `zeph-tui` | +| `011-tui/spec.md` | ratatui dashboard, spinner rule for background operations, TuiChannel, RenderCache, embed backfill progress, multi-session `SessionRegistry`, `/session` commands, compact paste indicator; Fleet panel (`f` key, #3884); reasoning token tracking; terminal title (#4354); fleet session lifecycle wiring (#4363); Ctrl+C interrupt & double-press-to-quit semantics + agent-cancel moved from Esc to Ctrl+C (#6646); content-driven side-panel sizing — `PanelDemand`/`PanelSizing`, `fit_panel_heights` max-min fair water-filling allocator, `SubAgentSlotMode`, `[tui] panel_sizing` auto/even (#6675) | `zeph-tui` | | `012-graph-memory/spec.md` | Entity graph, BFS recall, community detection, MAGMA typed edges, SYNAPSE spreading activation | `zeph-memory` | | `004-memory/004-6-graph-memory.md` | Graph memory sub-spec (concise reference within 004-memory): MAGMA typed edges, SYNAPSE config, A-MEM link weights, key invariants | `zeph-memory` | | `013-acp/spec.md` | ACP transports, session management, permissions, fork/resume, session/close handlers, capability advertisement, /agent.json endpoint; 0.14.0 bump: session/set_model removed, message-id echo removed, provider renames, feature flag stabilizations; 2.0.0 crate-major migration planned (v1.10) — API renames/removals, crate-vs-wire-protocol-version invariant, pre-merge wire gate | `zeph-acp` | diff --git a/src/init/mod.rs b/src/init/mod.rs index b06f6b869..3a0bb7b62 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -379,6 +379,8 @@ pub(crate) struct WizardState { pub(crate) tui_delights_enabled: bool, /// Whether opt-in mouse capture is enabled at startup. pub(crate) tui_mouse_enabled: bool, + /// Side-panel vertical sizing strategy (#6675): `auto` (content-sized) or `even`. + pub(crate) tui_panel_sizing: zeph_config::PanelSizingMode, // Durable session persistence (spec-068, #5343, P4) /// Whether to maintain a durable, replayable JSONL event log per conversation-session. pub(crate) session_persistence_enabled: bool, @@ -619,6 +621,7 @@ impl Default for WizardState { tui_color_mode: zeph_config::ColorMode::Auto, tui_delights_enabled: true, tui_mouse_enabled: false, + tui_panel_sizing: zeph_config::PanelSizingMode::default(), session_persistence_enabled: zeph_config::SessionConfig::default().enabled, session_data_dir: zeph_config::SessionConfig::default().data_dir, serve_http_addr: zeph_config::ServeConfig::default().http_addr, @@ -709,6 +712,7 @@ pub fn run(output: Option) -> anyhow::Result<()> { step_tui_theme(&mut state)?; step_tui_delights(&mut state)?; step_tui_mouse(&mut state)?; + step_tui_panel_sizing(&mut state)?; step_quality(&mut state)?; step_review_and_write(&state, output)?; @@ -1538,6 +1542,9 @@ pub(crate) fn build_config(state: &WizardState) -> Config { // Apply TUI mouse mode (#5103). config.tui.mouse = state.tui_mouse_enabled; + // Apply TUI panel sizing (#6675). + config.tui.panel_sizing = state.tui_panel_sizing; + config } @@ -2309,6 +2316,31 @@ fn step_tui_mouse(state: &mut WizardState) -> anyhow::Result<()> { Ok(()) } +fn step_tui_panel_sizing(state: &mut WizardState) -> anyhow::Result<()> { + use dialoguer::Confirm; + println!("== TUI Side-Panel Sizing ==\n"); + println!("How should the Skills/Memory/Resources/SubAgents side panels split their column?"); + println!(" • auto (recommended): each panel is sized from its own content, so a sparse"); + println!(" panel doesn't waste space and a busy one isn't clipped"); + println!(" • even: split the column equally regardless of content (pre-#6675 behavior)"); + println!(); + println!("Controlled by [tui] panel_sizing in config.toml."); + println!("Toggle at runtime with /panel_sizing [auto|even].\n"); + + let auto = Confirm::new() + .with_prompt("Use content-driven (auto) panel sizing?") + .default(true) + .interact()?; + + state.tui_panel_sizing = if auto { + zeph_config::PanelSizingMode::Auto + } else { + zeph_config::PanelSizingMode::Even + }; + println!(); + Ok(()) +} + fn step_tui_theme(state: &mut WizardState) -> anyhow::Result<()> { use dialoguer::Select; diff --git a/src/tui_bridge.rs b/src/tui_bridge.rs index 74ce42415..86fb38cb4 100644 --- a/src/tui_bridge.rs +++ b/src/tui_bridge.rs @@ -124,7 +124,8 @@ pub(crate) fn start_tui_early( .with_effective_color_mode(tui_color_mode) .with_motion(config.tui.motion) .with_delights(config.tui.delights.clone()) - .with_mouse(config.tui.mouse); + .with_mouse(config.tui.mouse) + .with_panel_sizing(config.tui.panel_sizing); tui_app.set_show_source_labels(config.tui.show_source_labels); tui_app.set_show_balance(config.cocoon.show_balance); @@ -221,6 +222,7 @@ fn spawn_tui_thread( motion: zeph_config::Motion, delights: zeph_config::DelightsConfig, mouse: bool, + panel_sizing: zeph_config::PanelSizingMode, theme: zeph_tui::theme::Theme, theme_name: String, effective_color_mode: zeph_tui::theme::EffectiveColorMode, @@ -240,6 +242,7 @@ fn spawn_tui_thread( .with_motion(motion) .with_delights(delights) .with_mouse(mouse) + .with_panel_sizing(panel_sizing) .with_theme(theme) .with_theme_name(theme_name) .with_effective_color_mode(effective_color_mode); @@ -359,6 +362,7 @@ pub(crate) async fn run_tui_agent( params.config.tui.motion, params.config.tui.delights.clone(), params.config.tui.mouse, + params.config.tui.panel_sizing, legacy_theme, legacy_theme_name, legacy_color_mode, diff --git a/src/tui_remote.rs b/src/tui_remote.rs index bc5f3c8d2..41d60abec 100644 --- a/src/tui_remote.rs +++ b/src/tui_remote.rs @@ -412,6 +412,7 @@ pub(crate) async fn run_tui_remote( }; let mut tui_app = App::new(user_tx, agent_rx) .with_tool_density(config.tui.tool_density) + .with_panel_sizing(config.tui.panel_sizing) .with_theme(tui_theme) .with_theme_name(tui_theme_name) .with_effective_color_mode(tui_color_mode)