Synced from monorepo

Changes:
- Classify clipboard delivery confidence
- Add durable session update append
- Scope the xAI session bearer to first-party memory embedding endpoints
- Persist subagent outputs to disk and bound long-lived agent state
- Add MiniSweAgent:bash for mini-swe-agent parity
- Revert taking local sessions off the persistent shell
- Contextual tip recommending grok wrap on SSH sessions
- Voice STT bearer from model BYOK env_key/api_key
- Define exact website policies for sandbox
- Gate unsafe shell environments
- Shared pin hoist; single require_sha gate for marketplace plugins
- Server-signed is-managed claim (closes sidecar-removal downgrade)
- Optional require_sha pin for remote plugin installs
- Show session title and last exchange in the exit resume hint
- Gate shell output redirects
- Warn when fail_closed is present but not a boolean
- Add canonical text editing core (ratatui-textarea)
- Keep execution state out of goal scratch
- Add acknowledged persistence primitives
- Inherit child network restrictions in sandbox
- Fail closed when hook matchers fail to recompile
- Add MCP setup preferences for plugin MCPs
- Gate sourced shell scripts
- Gate file-typed project hooks
- grok wrap: restore terminal modes on child death
- Harden owner-only permissions on auth and MCP credentials
- Create crash dump files with owner-only permissions
- Write the agent_id cache owner-only (0600)
- SessionMetrics mode skips Mixpanel profile sync
- Dashboard: slim live-tail peek
- Yank full queued prompt text, not (+N lines)
- Defeat clock-rollback on the signed managed-config cache
- Stop early session/cancel from overtaking the prompt and wedging the turn slot
- Self-heal a diverged agent entrypoint on startup
- Add matched inference expectations in test-support
- Add AuthSingleFlight cancel/successor gap tests
- Remove consumer from external OTEL allowlist and pin scrub coverage
- Enable /copy in minimal mode
- Surface capacity and API-key detail on 429 errors
- Single-flight interactive auth
- Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
grokkybara[bot] 2026-07-17 14:19:50 +01:00
commit 98c3b2438a
225 changed files with 18836 additions and 7156 deletions

View file

@ -1,4 +1,7 @@
//! Pure layout computation for the dashboard view.
//!
//! Peek vs roster vertical policy:
//! [`docs/internal/33-dashboard-peek-responsive-layout.md`](../../../../docs/internal/33-dashboard-peek-responsive-layout.md).
use ratatui::layout::Rect;
@ -7,10 +10,160 @@ use ratatui::layout::Rect;
/// view; row labels are middle-truncated.
pub const MIN_DASHBOARD_WIDTH: u16 = 40;
/// Minimum total height at which the peek panel is allowed to render.
/// Below this we drop the peek section even when toggled on so the
/// row list still has room to breathe.
pub const MIN_PEEK_HEIGHT: u16 = 12;
/// Min list-band height (terminal rows) while evaluating/opening peek.
pub const LIST_FLOOR_ROWS: u16 = 12;
/// Min whole peek box (borders + status + body + reply) for live-tail.
pub const PEEK_MIN_BOX_LIVE_TAIL: u16 = 8;
/// Min whole peek box for question/permission peeks (options need room).
pub const PEEK_MIN_BOX_QUESTION: u16 = 10;
/// Peek max = ⌊H × PEEK_MAX_FRAC_NUM / PEEK_MAX_FRAC_DEN⌋ (whole box).
pub const PEEK_MAX_FRAC_NUM: u16 = 3;
pub const PEEK_MAX_FRAC_DEN: u16 = 8;
/// Secondary cap on live-tail body rows inside an allocated peek box.
pub const MAX_LIVE_TAIL_ROWS: u16 = 28;
/// Live-tail height budget for a no-question peek (status + optional blank + reply).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PeekLiveTailBudget {
pub live_tail: u16,
pub blank_row: bool,
pub content_rows: u16,
}
/// Result of list-first peek allocation for height `H`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PeekAllocation {
pub show_peek: bool,
/// Whole peek box height including borders; 0 if `!show_peek`.
pub peek_box_h: u16,
/// Max inner content rows for a peek at full allowed size (`peek_box_h - 2`).
pub max_content_rows: u16,
}
/// Shrink-to-content desired inner rows for a live-tail peek.
///
/// Recipe (matches dense paint): `status + [pin?] + body + [blank?] + reply`.
/// - `body_measured`: densified **current-turn** lines (after last user).
/// - `pin_user`: last user exists → budget one pin row (paint charges it too).
/// - Blank when body > 0 and room remains after pin+body (paint blanks only
/// when middle still has ≥2 rows after the blank so pin + body share).
/// Empty body reserves 1 row for the empty/hint line. Never exceeds
/// `max_content`; body is also capped by [`MAX_LIVE_TAIL_ROWS`].
pub fn peek_live_tail_desired_content(
max_content: u16,
reply_rows: u16,
body_measured: u16,
pin_user: bool,
) -> PeekLiveTailBudget {
let reply_rows = reply_rows.max(1);
let pin = u16::from(pin_user);
let fixed = 1u16 + reply_rows + pin; // status + reply + optional pin
if max_content < fixed {
return PeekLiveTailBudget {
live_tail: 0,
blank_row: false,
content_rows: max_content,
};
}
let room_no_blank = max_content.saturating_sub(fixed).min(MAX_LIVE_TAIL_ROWS);
if room_no_blank == 0 {
return PeekLiveTailBudget {
live_tail: 0,
blank_row: false,
content_rows: fixed,
};
}
// Prefer a breathing blank whenever body is non-empty and room remains.
let room_with_blank = max_content
.saturating_sub(fixed + 1)
.min(MAX_LIVE_TAIL_ROWS);
let blank = room_with_blank > 0;
let body_cap = if blank {
room_with_blank
} else {
room_no_blank
};
let body = if body_measured == 0 {
1u16.min(body_cap)
} else {
body_measured.min(body_cap)
};
// If body collapsed to 0, no blank either.
let blank = blank && body > 0;
let content_rows = fixed + u16::from(blank) + body;
PeekLiveTailBudget {
live_tail: body,
blank_row: blank,
content_rows: content_rows.min(max_content),
}
}
/// ⌊H × 3/8⌋ whole-box peek max.
pub fn peek_max_box_rows(h: u16) -> u16 {
((u32::from(h) * u32::from(PEEK_MAX_FRAC_NUM)) / u32::from(PEEK_MAX_FRAC_DEN)) as u16
}
/// Chrome rows (header/gaps/footer/margins) — not list, not peek.
pub fn chrome_overhead(area: Rect) -> u16 {
dashboard_fixed_overhead(area).0
}
/// List-first peek allocation.
///
/// 1. Reserve [`LIST_FLOOR_ROWS`] for the list band (clamped to space after chrome).
/// 2. Remainder → candidate peek, capped by [`peek_max_box_rows`].
/// 3. If candidate &lt; `peek_min_box` → no peek.
/// 4. Else peek height = `min(desired_content+2, max_candidate)`, at least
/// `peek_min_box` when showing.
///
/// `desired_content_rows` is inner content (no borders). Reply growth should
/// increase this; list may shrink only down to the floor (enforced by max
/// candidate).
pub fn allocate_peek(
area_h: u16,
fixed_overhead: u16,
desired_content_rows: u16,
peek_min_box: u16,
) -> PeekAllocation {
let after = area_h.saturating_sub(fixed_overhead);
if after == 0 {
return PeekAllocation {
show_peek: false,
peek_box_h: 0,
max_content_rows: 0,
};
}
let list_floor = LIST_FLOOR_ROWS.min(after);
let remainder = after.saturating_sub(list_floor);
let peek_max = peek_max_box_rows(area_h);
let max_peek = remainder.min(peek_max);
let max_content_rows = max_peek.saturating_sub(2);
if max_peek < peek_min_box {
return PeekAllocation {
show_peek: false,
peek_box_h: 0,
max_content_rows,
};
}
let desired_box = desired_content_rows.saturating_add(2);
let peek_box_h = desired_box.max(peek_min_box).min(max_peek);
PeekAllocation {
show_peek: true,
peek_box_h,
max_content_rows,
}
}
/// Outer horizontal padding for the dispatch box (cols on each side).
///
@ -19,9 +172,8 @@ pub const MIN_PEEK_HEIGHT: u16 = 12;
pub const DISPATCH_OUTER_HPAD: u16 = 2;
/// Outer horizontal padding for the top page header (cols on each side).
/// Slightly less than the list to give the title and status chips a bit
/// more horizontal real estate.
pub const HEADER_OUTER_HPAD: u16 = 1;
/// Matches list/dispatch so the title aligns with content below.
pub const HEADER_OUTER_HPAD: u16 = 2;
/// Outer horizontal padding for the row list (cols on each side).
///
@ -80,18 +232,97 @@ pub fn compute_layout(area: Rect, peek_visible: bool) -> DashboardLayout {
compute_layout_with_dispatch(area, peek_visible, 1)
}
fn dashboard_chrome_heights(area: Rect) -> (u16, u16, u16, u16, u16, u16, u16, bool) {
// Match welcome/agent top margin; drop on short terminals.
let top_margin_h: u16 = if area.height > 6 { 1 } else { 0 };
let header_h: u16 = if area.height > 4 { 1 } else { 0 };
// Header↔list gap; collapses with dispatch/shortcuts gaps on short terms.
let header_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
let footer_h: u16 = if area.height >= 2 { 1 } else { 0 };
// Match agent prompt/shortcuts gaps; drop on short terminals.
let dispatch_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
let shortcuts_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
// Match agent bottom_vpad; drop when height <= 16.
let bottom_margin_h: u16 = if area.height > 16 { 1 } else { 0 };
let short_terminal = area.height <= 8;
(
top_margin_h,
header_h,
header_gap_h,
footer_h,
dispatch_gap_h,
shortcuts_gap_h,
bottom_margin_h,
short_terminal,
)
}
fn dashboard_fixed_overhead(area: Rect) -> (u16, bool) {
let (
top_margin_h,
header_h,
header_gap_h,
footer_h,
dispatch_gap_h,
shortcuts_gap_h,
bottom_margin_h,
short_terminal,
) = dashboard_chrome_heights(area);
let fixed_overhead = top_margin_h
+ header_h
+ header_gap_h
+ footer_h
+ dispatch_gap_h
+ shortcuts_gap_h
+ bottom_margin_h;
(fixed_overhead, short_terminal)
}
/// Max inner content rows available for a peek under list-first allocation
/// (list floor + peek max fraction). 0 when a peek cannot open.
pub fn max_peek_content_rows(area: Rect) -> u16 {
if area.height <= 8 {
return 0;
}
let fixed = chrome_overhead(area);
let probe = allocate_peek(
area.height,
fixed,
// Probe with enough content that allocation uses full max candidate.
255,
PEEK_MIN_BOX_LIVE_TAIL,
);
probe.max_content_rows
}
/// Like [`compute_layout`] but with a fixed whole peek-box height
/// (from [`allocate_peek`]). List band receives the rest after chrome.
pub fn compute_layout_with_peek_box(area: Rect, peek_box_h: u16) -> DashboardLayout {
compute_layout_with_dispatch_inner(area, true, 0, Some(peek_box_h.max(3)))
}
/// Like [`compute_layout`] but lets the caller request a taller
/// dispatch box. `dispatch_text_rows` is the number of *text* rows the
/// dispatch input wants (≥1); the box adds 2 more for its top/bottom
/// border chrome. Used to grow the box as the user inserts newlines
/// (Shift+Enter) so multiline dispatch prompts are fully visible.
///
/// The caller is responsible for clamping `dispatch_text_rows` so the
/// row list keeps usable space; this function only enforces a ≥1 floor.
/// When `peek_visible`, uses list-first [`allocate_peek`] with
/// [`PEEK_MIN_BOX_LIVE_TAIL`]. Prefer [`compute_layout_with_peek_box`]
/// when the caller already allocated.
pub fn compute_layout_with_dispatch(
area: Rect,
peek_visible: bool,
dispatch_text_rows: u16,
) -> DashboardLayout {
compute_layout_with_dispatch_inner(area, peek_visible, dispatch_text_rows, None)
}
fn compute_layout_with_dispatch_inner(
area: Rect,
peek_visible: bool,
dispatch_text_rows: u16,
forced_peek_box_h: Option<u16>,
) -> DashboardLayout {
// When `area.height == 0`, every subrect collapses
// to zero. A footer_h = 1 default would produce a non-zero
@ -114,63 +345,41 @@ pub fn compute_layout_with_dispatch(
bottom_margin: z,
};
}
// Match the welcome / agent view's top margin so
// the dashboard's header doesn't sit flush against the alt-screen's
// top edge. The welcome view uses `v_margin = 1` (see
// `views::welcome::render_welcome`). Dropped to 0 on very short
// terminals so we don't starve the row list.
let top_margin_h: u16 = if area.height > 6 { 1 } else { 0 };
let header_h: u16 = if area.height > 4 { 1 } else { 0 };
// 1-row gap between the header and the row list so the title /
// status chips don't sit flush against the first row (or the
// first group header). Collapses on short terminals so the row
// list isn't starved (same threshold as the dispatch/shortcuts
// gaps).
let header_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
let footer_h: u16 = if area.height >= 2 { 1 } else { 0 };
// Vertical gaps around the dispatch box, matching
// the agent view's `prompt_gap` and `shortcuts_gap` (both = 1) so
// the dispatch chrome doesn't sit flush against the list above or
// the footer below. Gaps drop to 0 on short terminals so the row
// list still gets visible space. Computed BEFORE `dispatch_h` so the
// content-sized peek box can leave the row list at least one row.
let dispatch_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
let shortcuts_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
// Bottom margin below the shortcuts bar, matching
// the agent view's `bottom_vpad` (`outer_vpad = 1` from
// `LayoutConfig::default` dropped to 0 when `area.height <= 16`).
let bottom_margin_h: u16 = if area.height > 16 { 1 } else { 0 };
let (
top_margin_h,
header_h,
header_gap_h,
footer_h,
dispatch_gap_h,
shortcuts_gap_h,
bottom_margin_h,
short_terminal,
) = dashboard_chrome_heights(area);
let (fixed_overhead, _) = dashboard_fixed_overhead(area);
// The peek panel sizes to its CONTENT instead of a fixed
// height. Its inner rows are: status (1) + wrapped response (N) +
// one blank breathing row (1) + ` reply` (1); the caller passes
// that inner content count via `dispatch_text_rows` (floored at
// status + blank + reply = 3 when there's no response yet). Adding
// the 2 borders gives the box height, clamped so the row list keeps
// at least one visible row.
//
// Otherwise (no peek) the dispatch reserves 2 borders + N text rows
// so the rounded box reads as a real input field and grows for
// multiline (Alt+Enter) prompts. Very short terminals (height ≤ 8)
// fall back to a single line so the row list isn't starved.
let dispatch_h: u16 = if peek_visible {
if area.height <= 8 {
// Peek: list-first allocation (see `allocate_peek`). No peek → normal
// dispatch chrome. `forced_peek_box_h` skips re-allocation when the
// caller already chose a height (and peek min for question vs live-tail).
let dispatch_h: u16 = if let Some(h) = forced_peek_box_h {
let after = area.height.saturating_sub(fixed_overhead);
let list_floor = LIST_FLOOR_ROWS.min(after);
let max_peek = after
.saturating_sub(list_floor)
.min(peek_max_box_rows(area.height));
h.min(max_peek).max(3)
} else if peek_visible {
if short_terminal {
1
} else {
let fixed_overhead = top_margin_h
+ header_h
+ header_gap_h
+ footer_h
+ dispatch_gap_h
+ shortcuts_gap_h
+ bottom_margin_h;
let content = dispatch_text_rows.max(3);
let desired = content + 2;
// Keep ≥1 row for the list; never collapse below a 3-row box.
let max_box = area.height.saturating_sub(fixed_overhead + 1).max(3);
desired.min(max_box)
let alloc = allocate_peek(
area.height,
fixed_overhead,
dispatch_text_rows,
PEEK_MIN_BOX_LIVE_TAIL,
);
if alloc.show_peek { alloc.peek_box_h } else { 3 }
}
} else if area.height > 8 {
} else if !short_terminal {
2 + dispatch_text_rows.max(1)
} else {
1
@ -202,9 +411,7 @@ pub fn compute_layout_with_dispatch(
height: top_margin_h,
};
y += top_margin_h;
// Inset the top page header using its own (slightly smaller) padding
// so the title and status chips have breathing room without losing
// as much width as the list content.
// Inset the top page header to match list/dispatch content columns.
let header_inner_pad = HEADER_OUTER_HPAD.saturating_mul(2);
let header_width = area.width.saturating_sub(header_inner_pad);
let header_x = if header_width > 0 {
@ -490,12 +697,12 @@ mod tests {
);
}
/// The header rect is inset by HEADER_OUTER_HPAD (slightly less
/// than the list) for side breathing room on the title and status chips.
/// Header h-pad matches list so title aligns with content columns.
#[test]
fn layout_applies_outer_hpad_to_header() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
assert_eq!(HEADER_OUTER_HPAD, LIST_OUTER_HPAD);
assert_eq!(
layout.header.x,
area.x + HEADER_OUTER_HPAD,
@ -506,6 +713,8 @@ mod tests {
area.width - HEADER_OUTER_HPAD * 2,
"header width must lose HEADER_OUTER_HPAD on each side",
);
assert_eq!(layout.header.x, layout.list.x);
assert_eq!(layout.header.width, layout.list.width);
}
/// A 1-row gap separates the list/peek from the
@ -666,24 +875,25 @@ mod tests {
assert_eq!(layout.footer.height, 0);
}
/// One row below the peek minimum hides the peek.
/// List-first: short heights cannot open peek (remainder < peek min).
#[test]
fn layout_just_below_min_peek_height_hides_peek() {
let area = Rect::new(0, 0, 80, MIN_PEEK_HEIGHT - 1);
let layout = compute_layout(area, true);
assert_eq!(layout.peek.height, 0);
fn allocate_peek_refuses_when_remainder_below_min() {
let area = Rect::new(0, 0, 80, 24);
let fixed = chrome_overhead(area);
let alloc = allocate_peek(area.height, fixed, 20, PEEK_MIN_BOX_LIVE_TAIL);
// chrome≈7, after≈17, floor=12, rem≈5 < 8 → no peek
assert!(
!alloc.show_peek,
"h=24 should not fit list floor + peek min"
);
}
/// The standalone peek rect was retired (peek now
/// renders INSIDE the dispatch box). The peek rect is always
/// zero-height; what changes when `peek_visible == true` is
/// the dispatch rect, which grows from 3 to 5 rows to host
/// the peek's status + reply input.
/// Standalone peek rect is always zero; peek uses dispatch.
#[test]
fn layout_grows_dispatch_when_peek_visible() {
let area = Rect::new(0, 0, 80, 30);
let area = Rect::new(0, 0, 80, 40);
let no_peek = compute_layout(area, false);
let with_peek = compute_layout(area, true);
let with_peek = compute_layout_with_dispatch(area, true, 12);
assert_eq!(no_peek.peek.height, 0);
assert_eq!(with_peek.peek.height, 0);
assert!(
@ -692,23 +902,19 @@ mod tests {
no_peek.dispatch.height,
with_peek.dispatch.height,
);
assert!(with_peek.list.height >= LIST_FLOOR_ROWS);
}
/// The peek box sizes to its content: 2 borders + the
/// inner content rows (status + response + blank + reply) the caller
/// passes via `dispatch_text_rows`. A bigger response → taller box.
/// Larger desired content → taller peek box until max fraction.
#[test]
fn peek_box_sizes_to_content_rows() {
let area = Rect::new(0, 0, 80, 40);
// content = status(1) + blank(1) + reply(1) = 3 → box 5 (no response).
let empty = compute_layout_with_dispatch(area, true, 3);
// content = status + 3 response + blank + reply = 6 → box 8.
let full = compute_layout_with_dispatch(area, true, 6);
assert_eq!(empty.dispatch.height, 5);
assert_eq!(full.dispatch.height, 8);
assert!(full.dispatch.height > empty.dispatch.height);
// The list reclaims the rows the smaller box doesn't use.
assert!(empty.list.height > full.list.height);
let small = compute_layout_with_dispatch(area, true, 6);
let large = compute_layout_with_dispatch(area, true, 20);
assert!(large.dispatch.height >= small.dispatch.height);
assert!(large.list.height <= small.list.height);
assert!(large.list.height >= LIST_FLOOR_ROWS);
assert!(large.dispatch.height <= peek_max_box_rows(40));
}
/// Zero-width area returns valid zero-width rects.
@ -733,4 +939,136 @@ mod tests {
);
assert!(layout.list.height > 0);
}
#[test]
fn max_peek_content_rows_zero_on_short_terminal() {
assert_eq!(max_peek_content_rows(Rect::new(0, 0, 80, 8)), 0);
assert_eq!(max_peek_content_rows(Rect::new(0, 0, 80, 1)), 0);
}
#[test]
fn allocate_peek_list_floor_and_max_fraction() {
for h in [28u16, 32, 40, 60, 80] {
let area = Rect::new(0, 0, 80, h);
let fixed = chrome_overhead(area);
let alloc = allocate_peek(h, fixed, 255, PEEK_MIN_BOX_LIVE_TAIL);
assert!(alloc.show_peek, "h={h} should open peek");
assert!(
alloc.peek_box_h <= peek_max_box_rows(h),
"h={h} peek {} > max {}",
alloc.peek_box_h,
peek_max_box_rows(h)
);
assert!(alloc.peek_box_h >= PEEK_MIN_BOX_LIVE_TAIL);
let layout = compute_layout_with_peek_box(area, alloc.peek_box_h);
assert!(
layout.list.height >= LIST_FLOOR_ROWS,
"h={h} list {} < floor",
layout.list.height
);
assert_eq!(layout.dispatch.height, alloc.peek_box_h);
}
}
#[test]
fn allocate_peek_respects_three_eighths_cap() {
assert_eq!(peek_max_box_rows(40), 15); // floor(40*3/8)
assert_eq!(peek_max_box_rows(60), 22);
assert_eq!(peek_max_box_rows(8), 3);
}
#[test]
fn reply_growth_steals_from_list_down_to_floor_then_body() {
let area = Rect::new(0, 0, 80, 40);
let fixed = chrome_overhead(area);
let one = allocate_peek(40, fixed, 6, PEEK_MIN_BOX_LIVE_TAIL);
let multi = allocate_peek(40, fixed, 14, PEEK_MIN_BOX_LIVE_TAIL);
assert!(one.show_peek && multi.show_peek);
assert!(multi.peek_box_h >= one.peek_box_h);
let layout_multi = compute_layout_with_peek_box(area, multi.peek_box_h);
assert!(layout_multi.list.height >= LIST_FLOOR_ROWS);
assert!(multi.peek_box_h <= peek_max_box_rows(40));
}
#[test]
fn layout_header_aligns_with_list_and_dispatch() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
assert_eq!(layout.header.x, layout.list.x);
assert_eq!(layout.header.x, layout.dispatch.x);
assert_eq!(layout.header.width, layout.list.width);
assert_eq!(layout.header.width, layout.dispatch.width);
}
#[test]
fn peek_live_tail_desired_empty_uses_one_body_row() {
let d = peek_live_tail_desired_content(20, 1, 0, false);
assert_eq!(d.live_tail, 1);
assert!(d.blank_row, "empty/hint body still budgets blank when room");
assert_eq!(d.content_rows, 1 + 1 + 1 + 1); // status+reply+blank+body
}
#[test]
fn peek_live_tail_desired_tight_pin_skips_blank() {
// fixed = status + reply3 + pin = 5; max_content = fixed+1 → body 1, no blank.
let d = peek_live_tail_desired_content(6, 3, 1, true);
assert!(!d.blank_row);
assert_eq!(d.live_tail, 1);
assert_eq!(d.content_rows, 1 + 3 + 1 + 1); // status+reply+pin+body
}
#[test]
fn peek_live_tail_desired_short_body_budgets_blank_and_pin() {
let d = peek_live_tail_desired_content(40, 1, 2, false);
assert_eq!(d.live_tail, 2);
assert!(d.blank_row);
assert_eq!(d.content_rows, 1 + 1 + 1 + 2);
let with_pin = peek_live_tail_desired_content(40, 1, 2, true);
assert_eq!(with_pin.live_tail, 2);
assert!(with_pin.blank_row);
assert_eq!(with_pin.content_rows, 1 + 1 + 1 + 1 + 2); // +pin
assert!(with_pin.content_rows > d.content_rows);
}
#[test]
fn peek_live_tail_desired_long_body_hits_live_tail_cap() {
let d = peek_live_tail_desired_content(80, 1, 200, false);
assert_eq!(d.live_tail, MAX_LIVE_TAIL_ROWS);
assert!(d.blank_row);
assert_eq!(d.content_rows, 1 + 1 + 1 + MAX_LIVE_TAIL_ROWS);
}
#[test]
fn peek_live_tail_desired_pin_fits_in_measured_body_budget() {
// body that fits without pin must not force ellipsis solely due to pin:
// desired grows by the pin row so paint body_budget still covers body.
let body = 4u16;
let d = peek_live_tail_desired_content(40, 1, body, true);
assert_eq!(d.live_tail, body);
assert_eq!(
d.content_rows,
1 + 1 + 1 + 1 + body,
"status+reply+pin+blank+body"
);
}
#[test]
fn peek_live_tail_desired_never_exceeds_max_content() {
for max_content in 0..=40u16 {
for reply in 1..=6u16 {
for body in [0u16, 1, 3, 10, 50, 200] {
for pin in [false, true] {
let d = peek_live_tail_desired_content(max_content, reply, body, pin);
assert!(
d.content_rows <= max_content,
"content_rows={} > max={max_content} reply={reply} body={body} pin={pin}",
d.content_rows
);
assert!(d.live_tail <= MAX_LIVE_TAIL_ROWS);
}
}
}
}
}
}

View file

@ -26,6 +26,7 @@
pub mod layout;
pub mod peek;
pub mod peek_tail;
pub mod render;
pub mod row;
pub mod state;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,464 @@
//! Dense live-tail paint for the dashboard peek middle.
//!
//! Reads the agent's leased [`ScrollbackState`] without mutating fold state,
//! layout cache, follow mode, or view mode. Dashboard lease
//! (`begin_peek_viewport`) already forced follow + AllTurns for attach restore.
//!
//! Density vs full [`ScrollbackPane`]: no sticky headers, no vpad, no gap rows,
//! no horizontal accent/pad chrome. Foldable entries project Collapsed; messages
//! keep full expanded body.
//!
//! Layout (top → bottom):
//! 1. Last user prompt pinned (1 line), when present and height allows
//! 2. Top `…` when the current-turn body is truncated from above
//! 3. Pure tail of content **after** the last user (current turn only)
//!
//! Body is **always** current-turn when a last user exists (including the
//! list-first min-box ~3-row middle). Pin is dropped only when the middle
//! has no rows left for it. After a fresh user send with no agent lines,
//! the middle is pin + empty — prior turns are not pulled up.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use crate::appearance::cache::load_show_thinking_blocks;
use crate::render::SafeBuf;
use crate::scrollback::block::BlockContent;
use crate::scrollback::entry::ScrollbackEntry;
use crate::scrollback::state::ScrollbackState;
use crate::scrollback::types::{BlockLine, DisplayMode};
use crate::theme::Theme;
/// Densified body line count for shrink-to-content (v1: current-turn body).
///
/// Content **after** the last user prompt only. Pin / ellipsis are layout
/// chrome and are budgeted separately in desired peek content.
/// `width` is the middle content width (same as the paint area width).
pub fn densified_body_line_count(scrollback: &ScrollbackState, width: u16) -> u16 {
if width == 0 || scrollback.is_empty() {
return 0;
}
let after = find_last_user_idx(scrollback).map(|i| i + 1).unwrap_or(0);
densified_lines_from(scrollback, width, after).len() as u16
}
/// Whether scrollback has a user prompt that dense paint will pin when height
/// allows (drives the pin row in shrink desired content).
pub fn scrollback_has_last_user(scrollback: &ScrollbackState) -> bool {
find_last_user_idx(scrollback).is_some()
}
/// Paint a dense live tail into `area`.
///
/// Does not call `prepare_layout` / `enable_follow` / `set_view_mode` — those
/// either belong to the viewport lease or would dirty attach-path state.
pub fn paint_peek_live_tail(scrollback: &ScrollbackState, area: Rect, buf: &mut Buffer) {
if area.width < 1 || area.height == 0 || scrollback.is_empty() {
return;
}
let theme = Theme::current();
let appearance = scrollback.appearance();
let cwd = scrollback.cwd();
let content_w = area.width;
let height = area.height as usize;
let last_user = find_last_user_idx(scrollback);
// Always current-turn when a last user exists; full stream otherwise.
let body_start = last_user.map(|i| i + 1).unwrap_or(0);
let flat = densified_lines_from(scrollback, content_w, body_start);
// Pin when we have a last user and at least one middle row.
let pin = last_user.and_then(|idx| {
let entry = scrollback.entry(idx)?;
let lines = dense_entry_lines(entry, content_w, appearance, cwd);
lines.into_iter().next()
});
let pin_rows = usize::from(pin.is_some());
let body_budget = height.saturating_sub(pin_rows);
let (ellipsis, body) = pure_tail_with_ellipsis(flat, body_budget);
let bg = Style::default().bg(theme.bg_base);
for row in 0..area.height {
let y = area.y + row;
for x in area.x..area.x.saturating_add(area.width) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(bg);
cell.set_symbol(" ");
}
}
}
let mut y = area.y;
if let Some(line) = pin {
buf.set_line_safe(area.x, y, &line, content_w);
y = y.saturating_add(1);
}
if ellipsis {
let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
let ell = Line::from(Span::styled("", style));
buf.set_line_safe(area.x, y, &ell, content_w);
y = y.saturating_add(1);
}
for line in &body {
if y >= area.y.saturating_add(area.height) {
break;
}
buf.set_line_safe(area.x, y, line, content_w);
y = y.saturating_add(1);
}
}
/// Take a pure tail of `flat` into `budget` rows, reserving one row for a top
/// `…` when content is omitted above.
fn pure_tail_with_ellipsis(flat: Vec<Line<'static>>, budget: usize) -> (bool, Vec<Line<'static>>) {
if budget == 0 {
return (false, Vec::new());
}
if flat.len() <= budget {
return (false, flat);
}
if budget == 1 {
// No room for both marker and content — keep the live tail line.
return (false, flat[flat.len() - 1..].to_vec());
}
let take = budget - 1;
(true, flat[flat.len() - take..].to_vec())
}
fn find_last_user_idx(scrollback: &ScrollbackState) -> Option<usize> {
(0..scrollback.len()).rev().find(|&idx| {
scrollback
.entry(idx)
.is_some_and(|e| e.block.is_user_prompt())
})
}
/// Densified lines from entry index `start` (inclusive) through the end.
fn densified_lines_from(
scrollback: &ScrollbackState,
width: u16,
start: usize,
) -> Vec<Line<'static>> {
let appearance = scrollback.appearance();
let cwd = scrollback.cwd();
let show_thinking = load_show_thinking_blocks();
let mut flat = Vec::new();
for idx in start..scrollback.len() {
let Some(entry) = scrollback.entry(idx) else {
continue;
};
if entry.is_hidden_thinking(show_thinking) {
continue;
}
flat.extend(dense_entry_lines(entry, width, appearance, cwd));
}
flat
}
fn dense_mode(entry: &ScrollbackEntry) -> DisplayMode {
if entry.is_foldable() {
entry.block.collapse_mode(entry.is_running)
} else if entry.block.is_user_prompt() {
DisplayMode::Collapsed
} else {
DisplayMode::Expanded
}
}
fn dense_entry_lines(
entry: &ScrollbackEntry,
width: u16,
appearance: &crate::appearance::AppearanceConfig,
cwd: Option<&std::path::Path>,
) -> Vec<Line<'static>> {
let mode = dense_mode(entry);
let ctx = entry.context_with_mode(width, mode, appearance, cwd);
let output = entry.output_with_hooks(&ctx);
let keep_blanks = !entry.is_foldable();
output
.lines
.into_iter()
.map(|bl: BlockLine| bl.content)
.filter(|line| keep_blanks || !line_is_blank(line))
.collect()
}
fn line_is_blank(line: &Line<'_>) -> bool {
line.spans
.iter()
.all(|s| s.content.chars().all(|c| c.is_whitespace()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scrollback::block::RenderBlock;
use crate::scrollback::entry::ScrollbackEntry;
use crate::scrollback::state::ScrollbackState;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
fn filled_buf(area: Rect) -> Buffer {
Buffer::empty(area)
}
fn plain_cells(buf: &Buffer, area: Rect) -> Vec<String> {
(area.y..area.y + area.height)
.map(|y| {
let mut s = String::new();
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell((x, y)) {
s.push_str(cell.symbol());
}
}
s.trim_end().to_string()
})
.collect()
}
#[test]
fn pure_tail_with_ellipsis_fits_without_marker() {
let lines: Vec<Line<'static>> = (0..3).map(|i| Line::raw(format!("L{i}"))).collect();
let (ell, body) = pure_tail_with_ellipsis(lines, 5);
assert!(!ell);
assert_eq!(body.len(), 3);
}
#[test]
fn pure_tail_with_ellipsis_takes_tail_and_marks() {
let lines: Vec<Line<'static>> = (0..10).map(|i| Line::raw(format!("L{i}"))).collect();
let (ell, body) = pure_tail_with_ellipsis(lines, 4);
assert!(ell);
assert_eq!(body.len(), 3);
assert_eq!(body[0].spans[0].content.as_ref(), "L7");
assert_eq!(body[2].spans[0].content.as_ref(), "L9");
}
#[test]
fn dense_tail_pins_last_user_at_top() {
let mut sb = ScrollbackState::new();
sb.push(ScrollbackEntry::new(RenderBlock::user_prompt(
"first prompt",
)));
sb.push(ScrollbackEntry::new(RenderBlock::agent_message(
"old answer",
)));
sb.push(ScrollbackEntry::new(RenderBlock::user_prompt(
"latest prompt",
)));
sb.push(ScrollbackEntry::new(RenderBlock::tool_call(
"bash", "tool-a", true,
)));
sb.push(ScrollbackEntry::new(RenderBlock::agent_message(
"new answer",
)));
let area = Rect::new(0, 0, 48, 8);
let mut buf = filled_buf(area);
paint_peek_live_tail(&sb, area, &mut buf);
let rows = plain_cells(&buf, area);
assert!(
rows[0].contains("latest prompt"),
"pinned user on first row: {rows:?}"
);
assert!(
!rows[0].contains("first prompt"),
"older user must not pin: {rows:?}"
);
let joined = rows.join("\n");
assert!(
joined.contains("new answer") || joined.contains("tool-a"),
"current turn body under pin: {joined:?}"
);
assert!(
!joined.contains("old answer"),
"prior-turn body must not fill under pin: {joined:?}"
);
assert!(
!joined.contains("first prompt"),
"prior user must not appear under pin: {joined:?}"
);
}
#[test]
fn dense_tail_after_fresh_user_send_body_is_empty() {
let mut sb = ScrollbackState::new();
sb.push(ScrollbackEntry::new(RenderBlock::user_prompt("old ask")));
sb.push(ScrollbackEntry::new(RenderBlock::agent_message(
"long prior answer with a table and status",
)));
sb.push(ScrollbackEntry::new(RenderBlock::user_prompt(
"say hi to me and nothing else",
)));
for h in [3u16, 8] {
let area = Rect::new(0, 0, 48, h);
let mut buf = filled_buf(area);
paint_peek_live_tail(&sb, area, &mut buf);
let rows = plain_cells(&buf, area);
assert!(
rows[0].contains("say hi to me"),
"h={h}: new user pinned: {rows:?}"
);
let body = rows[1..].join("\n");
assert!(
!body.contains("prior answer")
&& !body.contains("old ask")
&& !body.contains("table"),
"h={h}: fresh send must not re-show prior turn: {body:?}"
);
assert!(
body.chars().all(|c| c.is_whitespace()) || body.is_empty(),
"h={h}: body under pin empty until agent streams: {body:?}"
);
}
}
#[test]
fn dense_tail_shows_top_ellipsis_when_body_truncated() {
let mut sb = ScrollbackState::new();
sb.push(ScrollbackEntry::new(RenderBlock::user_prompt("ask")));
for i in 0..20 {
sb.push(ScrollbackEntry::new(RenderBlock::tool_call(
"bash",
format!("cmd-{i}"),
true,
)));
}
let area = Rect::new(0, 0, 40, 6);
let mut buf = filled_buf(area);
paint_peek_live_tail(&sb, area, &mut buf);
let rows = plain_cells(&buf, area);
assert!(rows[0].contains("ask"), "user pin first: {rows:?}");
assert!(
rows[1].contains('…') || rows[1].contains("..."),
"top ellipsis under pin when truncated: {rows:?}"
);
let joined = rows.join("\n");
assert!(
joined.contains("cmd-19"),
"pure tail keeps latest: {joined:?}"
);
assert!(
!joined.contains("cmd-0"),
"oldest dropped under ellipsis: {joined:?}"
);
}
#[test]
fn dense_tail_min_box_middle_is_current_turn_with_pin() {
// List-first min box leaves ~3 middle rows after status/reply/blank.
let mut sb = ScrollbackState::new();
sb.push(ScrollbackEntry::new(RenderBlock::user_prompt("old")));
sb.push(ScrollbackEntry::new(RenderBlock::agent_message(
"prior turn bulk answer",
)));
sb.push(ScrollbackEntry::new(RenderBlock::user_prompt("ask")));
for i in 0..12 {
sb.push(ScrollbackEntry::new(RenderBlock::tool_call(
"bash",
format!("cmd-{i}"),
true,
)));
}
let area = Rect::new(0, 0, 40, 3);
let mut buf = filled_buf(area);
paint_peek_live_tail(&sb, area, &mut buf);
let rows = plain_cells(&buf, area);
let joined = rows.join("\n");
assert!(rows[0].contains("ask"), "pin on min-box middle: {rows:?}");
assert!(
!joined.contains("prior turn") && !joined.contains("old"),
"prior turn must not fill min-box middle: {joined:?}"
);
assert!(
joined.contains("cmd-11"),
"current-turn pure tail on min-box middle: {joined:?}"
);
}
#[test]
fn dense_tail_pure_tail_keeps_message_end() {
let mut sb = ScrollbackState::new();
let body = (0..20)
.map(|i| format!("LINE{i:02}-{}", "x".repeat(36)))
.collect::<Vec<_>>()
.join("\n\n");
sb.push(ScrollbackEntry::new(RenderBlock::agent_message(body)));
let area = Rect::new(0, 0, 40, 4);
let mut buf = filled_buf(area);
paint_peek_live_tail(&sb, area, &mut buf);
let joined = plain_cells(&buf, area).join("\n");
assert!(
joined.contains("LINE19") || joined.contains("LINE18"),
"pure tail keeps end: {joined:?}"
);
assert!(
joined.contains('…') || !joined.contains("LINE00"),
"head omitted with ellipsis or absence: {joined:?}"
);
}
#[test]
fn dense_tail_does_not_mutate_scrollback_viewport() {
let mut sb = ScrollbackState::new();
sb.push(ScrollbackEntry::new(RenderBlock::agent_message("hello")));
let before = sb.capture_viewport_snapshot();
let area = Rect::new(0, 0, 40, 4);
let mut buf = filled_buf(area);
paint_peek_live_tail(&sb, area, &mut buf);
let after = sb.capture_viewport_snapshot();
assert_eq!(before, after);
}
#[test]
fn dense_tail_empty_scrollback_is_noop() {
let sb = ScrollbackState::new();
let area = Rect::new(0, 0, 40, 4);
let mut buf = filled_buf(area);
paint_peek_live_tail(&sb, area, &mut buf);
assert!(plain_cells(&buf, area).iter().all(|r| r.is_empty()));
}
#[test]
fn densified_body_line_count_is_current_turn_only() {
let mut current = ScrollbackState::new();
current.push(ScrollbackEntry::new(RenderBlock::user_prompt("ask")));
current.push(ScrollbackEntry::new(RenderBlock::tool_call(
"bash", "one", true,
)));
current.push(ScrollbackEntry::new(RenderBlock::tool_call(
"bash", "two", true,
)));
let current_n = densified_body_line_count(&current, 40);
assert!(current_n >= 2, "current-turn tools contribute: {current_n}");
let mut with_prior = ScrollbackState::new();
with_prior.push(ScrollbackEntry::new(RenderBlock::user_prompt("old")));
with_prior.push(ScrollbackEntry::new(RenderBlock::agent_message(
"prior turn bulk that is many lines of text",
)));
with_prior.push(ScrollbackEntry::new(RenderBlock::user_prompt("ask")));
with_prior.push(ScrollbackEntry::new(RenderBlock::tool_call(
"bash", "one", true,
)));
with_prior.push(ScrollbackEntry::new(RenderBlock::tool_call(
"bash", "two", true,
)));
assert_eq!(
densified_body_line_count(&with_prior, 40),
current_n,
"prior turn must not inflate densified body count"
);
let mut user_only = ScrollbackState::new();
user_only.push(ScrollbackEntry::new(RenderBlock::user_prompt("only")));
assert_eq!(densified_body_line_count(&user_only, 40), 0);
}
}

View file

@ -9,7 +9,8 @@ use ratatui::text::Span;
use super::layout::{MIN_DASHBOARD_WIDTH, compute_layout};
use super::row::{DashboardRow, RowBadge, build_rows_with_roster};
use super::state::{
DashboardState, Filter, Focusable, Grouping, LocationPickerState, RowState, SectionKey,
DashboardRowId, DashboardState, Filter, Focusable, Grouping, LocationPickerState, RowState,
SectionKey,
};
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
@ -32,6 +33,32 @@ const NEEDS_INPUT_BLINK_DIVISOR: u64 = 10;
// need a strong visual presence (needs-input, completed, failed, blocked);
// hollow marks idle rows.
fn ensure_peek_viewport_lifecycle(
state: &mut DashboardState,
agents: &mut IndexMap<AgentId, AgentView>,
) {
if state.attached_agent.is_some() {
return;
}
// Peek row → begin/keep lease; else restore agent viewport.
let Some(row) = state.peek.as_ref().map(|p| p.row.clone()) else {
state.restore_peek_viewport(agents);
return;
};
if state
.peek_viewport
.as_ref()
.is_some_and(|lease| lease.row == row)
{
return;
}
if super::state::scrollback_available_for_row(&row, agents) {
state.begin_peek_viewport(row, agents);
} else {
state.restore_peek_viewport(agents);
}
}
// The thin left vertical bar marking the active/selected row
// (`crate::glyphs::selection_bar()`, with a `│` CP437 fallback on legacy
// consoles) is painted on *every* content line of a selected row so it
@ -71,7 +98,7 @@ pub fn render_dashboard(
buf: &mut Buffer,
area: Rect,
state: &mut DashboardState,
agents: &IndexMap<AgentId, AgentView>,
agents: &mut IndexMap<AgentId, AgentView>,
registry: &crate::actions::ActionRegistry,
// App-level double-press confirmation hint (e.g. "press again to
// quit" for Ctrl+Q / Ctrl+C / Ctrl+D). Threaded to the footer so the
@ -88,8 +115,6 @@ pub fn render_dashboard(
// Promo upgrade CTA to paint in the header after the location label
// (`None` = no CTA); field meanings live on [`HeaderUpgradeCta`].
upgrade_cta: Option<HeaderUpgradeCta<'_>>,
// `_compact` removed in this version. Hide-chrome / shortened
// activity strings are a Phase 5 polish item.
) -> Option<(u16, u16)> {
// Cache whether a pinned (non-dismissible) promo CTA is live so the key
// handler can steal Ctrl+O for it; the dispatch re-resolves the gate.
@ -168,99 +193,90 @@ pub fn render_dashboard(
return None;
}
// The peek panel is shown by DEFAULT whenever an agent row is
// selected: it replaces the new-session dispatch box, follows the
// selection cursor, and surfaces live status + the last response (or
// a pending permission / ask question). With no selection (the
// `[+ New Agent]` button focused, or after Esc) the peek closes and
// the new-session input shows instead.
//
// `apply_fields` preserves the in-progress reply draft (held by the
// dashboard-owned `peek_reply` widget), reporting a row change so
// the draft is cleared only when the peeked row changes. The panel
// only opens when the terminal is tall enough to render it;
// otherwise the dispatch box shows even with a row selected. Done
// BEFORE the layout so the box can size to live content.
// Peek: list-first allocation (see layout::allocate_peek /
// docs/internal/33-dashboard-peek-responsive-layout.md). Provisional
// layout gives dispatch width for reply wrapping before we decide
// whether peek fits.
let mut layout = compute_layout(area, false);
let fixed = super::layout::chrome_overhead(area);
let reply_text_w = layout.dispatch.width.saturating_sub(6);
match state.selected.clone() {
Some(sel) if area.height >= super::layout::MIN_PEEK_HEIGHT => {
match super::peek::compute_peek_fields(&sel, agents) {
Some(fields) => {
// Record the peeked agent's cwd so the reply's `@`
// picker can lazily retarget to it on first compose
// (the retarget itself is deferred — see
// `DashboardState::ensure_peek_reply_cwd`).
Some(sel) => match super::peek::compute_peek_fields(&sel, agents) {
Some(fields) => {
let question = fields.question.is_some();
let peek_min = if question {
super::layout::PEEK_MIN_BOX_QUESTION
} else {
super::layout::PEEK_MIN_BOX_LIVE_TAIL
};
let content_rows = if question {
1 + fields.options.len().min(9) as u16
} else {
let reply_rows = super::peek::reply_row_count(
&state.peek_reply,
reply_text_w,
super::peek::MAX_REPLY_ROWS,
);
let max_content = super::layout::max_peek_content_rows(area);
// Middle content width ≈ dispatch box minus borders + insets.
let middle_w = layout.dispatch.width.saturating_sub(4);
let (body_measured, pin_user) =
super::state::scrollback_mut_for_row(&sel, agents)
.map(|sb| {
(
super::peek_tail::densified_body_line_count(sb, middle_w),
super::peek_tail::scrollback_has_last_user(sb),
)
})
.unwrap_or((0, false));
super::layout::peek_live_tail_desired_content(
max_content,
reply_rows,
body_measured,
pin_user,
)
.content_rows
};
let alloc =
super::layout::allocate_peek(area.height, fixed, content_rows, peek_min);
if alloc.show_peek {
state.set_peek_reply_target_cwd(peeked_agent_cwd(&sel, agents));
// Live model + mode for the bottom-border config badge.
// Read before `sel` is moved into the panel below.
let badge = super::peek::peek_model_and_mode(&sel, agents);
match state.peek.as_mut() {
Some(p) => {
if p.apply_fields(sel, fields) {
// Row changed under an open panel — a
// half-typed reply must not be sent to the
// newly-peeked agent (clears undo history too,
// so Ctrl+Z can't resurrect it onto the new row).
state.clear_peek_reply();
}
}
None => state.set_peek(Some(super::peek::PeekPanelState::new(sel, fields))),
}
// `apply_fields` / `new` carry only the display snapshot;
// the config badge is set live here so a `/model` switch
// or yolo toggle reflects immediately.
if let Some(p) = state.peek.as_mut() {
p.model_name = badge.model;
p.auto_approve = badge.yolo;
p.auto = badge.auto;
p.plan_mode = badge.plan;
}
}
// Selected agent vanished — nothing to peek.
None => {
layout = super::layout::compute_layout_with_peek_box(area, alloc.peek_box_h);
} else {
state.set_peek_reply_target_cwd(None);
state.set_peek(None);
}
}
}
// No selection (or too short to render) → new-session input.
_ => {
None => {
state.set_peek_reply_target_cwd(None);
state.set_peek(None);
}
},
None => {
state.set_peek_reply_target_cwd(None);
state.set_peek(None);
}
}
// Compute the layout. Both the dispatch box and the peek reply grow
// vertically for multi-line input (Shift+Enter / Alt+Enter
// newlines); the peek box also sizes to its wrapped response. Both
// keep the row list usable.
let mut layout = compute_layout(area, state.peek.is_some());
if let Some(panel) = state.peek.as_ref() {
// Size the peek box to its content. When a permission / ask
// question is pending the box holds the question (1) + its
// options and the ` reply` row is hidden; otherwise it holds
// status (1) + wrapped response (≤ MAX_RESPONSE_ROWS) + a blank
// breathing row (1) + the reply (which GROWS with multi-line
// drafts, ≤ MAX_REPLY_ROWS). The box width is independent of its
// height, so the wrap widths are read from the first layout pass:
// the response uses `dispatch.width 4` (2 border + 2 inset);
// the reply text loses a further 2 for the ` ` prefix ( 6).
let content_rows = if panel.question.is_some() {
1 + panel.options.len().min(9) as u16
} else {
let inner_w = layout.dispatch.width.saturating_sub(4) as usize;
let resp =
super::peek::response_row_count(panel, inner_w, super::peek::MAX_RESPONSE_ROWS);
let reply_text_w = layout.dispatch.width.saturating_sub(6);
let reply_rows = super::peek::reply_row_count(
&state.peek_reply,
reply_text_w,
super::peek::MAX_REPLY_ROWS,
);
// status(1) + response(resp) + blank(1) + reply(reply_rows)
resp as u16 + 2 + reply_rows
};
layout = super::layout::compute_layout_with_dispatch(area, true, content_rows);
} else if area.height > 8 && !state.dispatch.text().is_empty() {
ensure_peek_viewport_lifecycle(state, agents);
if state.peek.is_none() && area.height > 8 && !state.dispatch.text().is_empty() {
let rows = dispatch_text_rows(state, layout.dispatch.width, area.height);
if rows > 1 {
layout = super::layout::compute_layout_with_dispatch(area, false, rows);
@ -314,25 +330,56 @@ pub fn render_dashboard(
let voice_listening = state.voice_listening;
let voice_interim = state.voice_interim.clone();
let multiline = state.multiline_mode;
let DashboardState {
peek, peek_reply, ..
} = state;
let render = peek
.as_ref()
.map(|panel| {
super::peek::render_peek_panel(
buf,
layout.dispatch,
panel,
peek_reply,
&theme,
voice_listening,
voice_interim.as_deref(),
multiline,
Some(layout.list).filter(|r| r.area() > 0),
)
})
.unwrap_or_default();
let peeked_row = state.peek.as_ref().map(|p| p.row.clone());
let question_pending = state.peek.as_ref().is_some_and(|p| p.question.is_some());
let (empty_hint, has_scrollback) = match peeked_row.as_ref() {
Some(DashboardRowId::Subagent {
parent,
child_session_id,
}) => {
let parent_ok = agents
.get(parent)
.is_some_and(|p| p.subagent_sessions.contains_key(child_session_id));
let loaded = agents
.get(parent)
.is_some_and(|p| p.subagent_views.contains_key(child_session_id));
if parent_ok && !loaded {
(Some("Subagent not loaded"), false)
} else {
(None, loaded)
}
}
Some(row) => (
None,
super::state::scrollback_available_for_row(row, agents),
),
None => (None, false),
};
let render = if let Some(panel) = state.peek.as_ref() {
let live_tail = if !question_pending && has_scrollback {
peeked_row
.as_ref()
.and_then(|row| super::state::scrollback_mut_for_row(row, agents))
.map(|scrollback| super::peek::PeekLiveTailArgs { scrollback })
} else {
None
};
super::peek::render_peek_panel(
buf,
layout.dispatch,
panel,
&mut state.peek_reply,
&theme,
voice_listening,
voice_interim.as_deref(),
multiline,
Some(layout.list).filter(|r| r.area() > 0),
live_tail,
empty_hint,
)
} else {
Default::default()
};
state.peek_reply_rect = render.reply_rect;
let cursor = render.caret;
// The reply is a full PromptWidget, so its `@` file-context
@ -6880,14 +6927,14 @@ mod tests {
let seed = ratatui::style::Color::Rgb(0xFF, 0x00, 0xFF);
buf.set_style(area, Style::default().bg(seed));
let agents: IndexMap<AgentId, AgentView> = IndexMap::new();
let mut agents: IndexMap<AgentId, AgentView> = IndexMap::new();
let mut state = DashboardState::new();
let registry = crate::actions::ActionRegistry::defaults();
let _ = render_dashboard(
&mut buf,
area,
&mut state,
&agents,
&mut agents,
&registry,
None,
&[],
@ -7645,8 +7692,6 @@ mod tests {
time_ago: String::new(),
response_type: "Idle".into(),
last_user_message: None,
last_agent_lines: Vec::new(),
last_response_truncated: false,
question: None,
options: Vec::new(),
request_id: None,
@ -7688,8 +7733,6 @@ mod tests {
time_ago: String::new(),
response_type: "Idle".into(),
last_user_message: None,
last_agent_lines: Vec::new(),
last_response_truncated: false,
question: None,
options: Vec::new(),
request_id: None,
@ -7740,8 +7783,6 @@ mod tests {
time_ago: String::new(),
response_type: "Idle".into(),
last_user_message: None,
last_agent_lines: Vec::new(),
last_response_truncated: false,
question: None,
options: Vec::new(),
request_id: None,
@ -7791,8 +7832,6 @@ mod tests {
time_ago: String::new(),
response_type: "NeedsInput".into(),
last_user_message: None,
last_agent_lines: Vec::new(),
last_response_truncated: false,
question: Some("Allow?".into()),
options: vec![
("allow".into(), "Allow".into()),
@ -7875,8 +7914,6 @@ mod tests {
time_ago: String::new(),
response_type: "NeedsInput".into(),
last_user_message: None,
last_agent_lines: Vec::new(),
last_response_truncated: false,
question: Some("Allow?".into()),
options: vec![
("allow".into(), "Allow".into()),

View file

@ -47,6 +47,49 @@ impl DashboardRowId {
pub fn is_subagent(&self) -> bool {
matches!(self, Self::Subagent { .. })
}
pub(crate) fn matches_top_level_agent(&self, agent_id: AgentId) -> bool {
matches!(self, Self::TopLevel(id) if *id == agent_id)
}
}
pub(crate) struct PeekViewportLease {
pub row: DashboardRowId,
pub snapshot: crate::scrollback::state::ViewportSnapshot,
pub page_flip_entry: Option<usize>,
}
pub(crate) fn scrollback_mut_for_row<'a>(
row: &DashboardRowId,
agents: &'a mut indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
) -> Option<&'a mut crate::scrollback::state::ScrollbackState> {
match row {
DashboardRowId::TopLevel(id) => agents.get_mut(id).map(|a| &mut a.scrollback),
DashboardRowId::Subagent {
parent,
child_session_id,
} => agents
.get_mut(parent)
.and_then(|p| p.subagent_views.get_mut(child_session_id))
.map(|c| &mut c.scrollback),
DashboardRowId::Roster { .. } => None,
}
}
pub(crate) fn scrollback_available_for_row(
row: &DashboardRowId,
agents: &indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
) -> bool {
match row {
DashboardRowId::TopLevel(id) => agents.contains_key(id),
DashboardRowId::Subagent {
parent,
child_session_id,
} => agents
.get(parent)
.is_some_and(|p| p.subagent_views.contains_key(child_session_id)),
DashboardRowId::Roster { .. } => false,
}
}
/// A dispatch-input send (spawn a new session) stashed while a clipboard
@ -408,6 +451,9 @@ pub struct DashboardState {
pub(crate) deferred_peek_send: Option<DeferredPeekSend>,
/// Peek panel state (Space toggles).
pub peek: Option<PeekPanelState>,
/// Session-scoped guest viewport for the live-tail peek (capture once
/// on select; sticky while the same row is peeked; restore on leave).
pub(crate) peek_viewport: Option<PeekViewportLease>,
/// The peek panel's ` reply` input — a full [`PromptWidget`] so
/// the reply gets paste chips (`[Pasted: N lines]`), word
/// navigation, undo, and text selection exactly like [`Self::dispatch`].
@ -1255,6 +1301,7 @@ impl DashboardState {
deferred_dispatch_send: None,
deferred_peek_send: None,
peek: None,
peek_viewport: None,
peek_reply,
peek_reply_rect: None,
peek_reply_cwd: None,
@ -1701,6 +1748,10 @@ impl DashboardState {
/// open, or retarget). Per-frame refreshes of an open panel go
/// through `PeekPanelState::apply_fields` (not here), so an
/// in-progress draft survives live updates.
///
/// Does **not** restore the live-tail viewport lease — permission
/// refresh may call `set_peek(None)` while the same row stays
/// selected and reopens next paint.
pub fn set_peek(&mut self, peek: Option<PeekPanelState>) {
if peek.is_none() {
self.peek_close_rect = None;
@ -1713,6 +1764,96 @@ impl DashboardState {
self.peek = peek;
}
pub fn restore_peek_viewport(
&mut self,
agents: &mut indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
) {
let Some(lease) = self.peek_viewport.take() else {
return;
};
let Some(sb) = scrollback_mut_for_row(&lease.row, agents) else {
return;
};
let page_flip = lease.page_flip_entry;
let w = lease.snapshot.last_width;
let h = lease.snapshot.viewport_height;
sb.restore_viewport_snapshot(lease.snapshot);
if let Some(idx) = page_flip {
if w > 0 && h > 0 {
sb.prepare_layout(w, h);
}
sb.set_selected(Some(idx));
sb.scroll_to_entry_top(idx);
sb.enable_follow_with_preserve();
}
}
pub fn begin_peek_viewport(
&mut self,
row: DashboardRowId,
agents: &mut indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
) {
if self
.peek_viewport
.as_ref()
.is_some_and(|lease| lease.row == row)
{
return;
}
self.restore_peek_viewport(agents);
let Some(sb) = scrollback_mut_for_row(&row, agents) else {
return;
};
let snapshot = sb.capture_viewport_snapshot();
sb.set_view_mode(crate::scrollback::state::ViewMode::AllTurns);
sb.enable_follow_mode();
self.peek_viewport = Some(PeekViewportLease {
row,
snapshot,
page_flip_entry: None,
});
}
pub fn note_page_flip_for_lease(
&mut self,
agent_id: AgentId,
agents: &mut indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
) {
let Some(row) = self
.peek_viewport
.as_ref()
.filter(|lease| lease.row.matches_top_level_agent(agent_id))
.map(|lease| lease.row.clone())
else {
return;
};
let Some(sb) = scrollback_mut_for_row(&row, agents) else {
return;
};
let selected = sb.selected();
let current_turn = sb.current_turn();
self.note_page_flip_from_scroll(agent_id, selected, current_turn);
}
pub(crate) fn note_page_flip_from_scroll(
&mut self,
agent_id: AgentId,
selected: Option<usize>,
current_turn: Option<usize>,
) {
let Some(lease) = self.peek_viewport.as_mut() else {
return;
};
if !lease.row.matches_top_level_agent(agent_id) {
return;
}
lease.page_flip_entry = selected;
lease.snapshot.follow_mode = true;
lease.snapshot.follow_preserve_scroll = true;
lease.snapshot.selected = selected;
lease.snapshot.current_turn = current_turn;
}
/// Clear the peek reply draft AND its undo history.
///
/// The history wipe is the load-bearing part: `set_text("")` alone
@ -5393,8 +5534,6 @@ mod tests {
time_ago: String::new(),
response_type: response_type.to_string(),
last_user_message: None,
last_agent_lines: Vec::new(),
last_response_truncated: false,
question: None,
options: Vec::new(),
request_id: None,
@ -6584,8 +6723,6 @@ mod tests {
time_ago: String::new(),
response_type: "Idle".into(),
last_user_message: None,
last_agent_lines: vec![],
last_response_truncated: false,
question: None,
options: vec![],
request_id: None,
@ -10260,4 +10397,179 @@ mod tests {
other => panic!("expected DashboardChangeLocation, got {other:?}"),
}
}
fn lease_fixture_agent() -> (
AgentId,
indexmap::IndexMap<AgentId, crate::app::agent_view::AgentView>,
) {
use crate::scrollback::block::RenderBlock;
let id = AgentId(1);
let mut agent = crate::test_util::make_agent_view(Some("s1"), "/tmp");
agent.scrollback.push_block(RenderBlock::user_prompt("one"));
agent
.scrollback
.push_block(RenderBlock::agent_message("long response body for wrap"));
agent.scrollback.push_block(RenderBlock::user_prompt("two"));
agent
.scrollback
.push_block(RenderBlock::agent_message("second reply"));
agent.scrollback.prepare_layout(80, 24);
agent.scrollback.set_selected(Some(0));
agent.scrollback.set_scroll_offset(2);
let mut agents = indexmap::IndexMap::new();
agents.insert(id, agent);
(id, agents)
}
#[test]
fn peek_viewport_lease_restore_without_page_flip_keeps_pre_guest_nav() {
let (id, mut agents) = lease_fixture_agent();
let pre = agents[&id].scrollback.capture_viewport_snapshot();
let mut dash = DashboardState::new();
let row = DashboardRowId::TopLevel(id);
dash.begin_peek_viewport(row, &mut agents);
assert!(dash.peek_viewport.is_some());
assert!(agents[&id].scrollback.is_follow_mode());
assert!(
agents
.get_mut(&id)
.unwrap()
.scrollback
.prepare_layout(40, 6),
"guest width change is Case 1"
);
dash.restore_peek_viewport(&mut agents);
assert!(dash.peek_viewport.is_none());
let sb = &mut agents.get_mut(&id).unwrap().scrollback;
assert_eq!(sb.scroll_offset(), pre.scroll_offset);
assert_eq!(sb.is_follow_mode(), pre.follow_mode);
assert_eq!(sb.selected(), pre.selected);
assert!(
sb.prepare_layout(80, 24),
"restore must invalidate so full-width prepare is Case 1"
);
let snap = sb.capture_viewport_snapshot();
assert_eq!(snap.last_width, 80);
}
#[test]
fn peek_viewport_lease_page_flip_re_pins_entry_on_restore() {
let (id, mut agents) = lease_fixture_agent();
let mut dash = DashboardState::new();
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
{
let sb = &mut agents.get_mut(&id).unwrap().scrollback;
sb.prepare_layout(40, 6);
let last = sb.len().saturating_sub(1);
sb.set_selected(Some(last));
sb.scroll_to_entry_top(last);
sb.enable_follow_with_preserve();
}
assert!(agents[&id].scrollback.is_follow_preserve_scroll());
dash.note_page_flip_for_lease(id, &mut agents);
assert_eq!(
dash.peek_viewport.as_ref().and_then(|l| l.page_flip_entry),
Some(agents[&id].scrollback.len().saturating_sub(1))
);
dash.restore_peek_viewport(&mut agents);
let sb = &agents[&id].scrollback;
assert!(sb.is_follow_mode());
assert!(sb.is_follow_preserve_scroll());
assert_eq!(sb.selected(), Some(sb.len().saturating_sub(1)));
let snap = sb.capture_viewport_snapshot();
assert_eq!(snap.last_width, 80);
}
#[test]
fn set_peek_none_does_not_clear_viewport_lease() {
let (id, mut agents) = lease_fixture_agent();
let mut dash = DashboardState::new();
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
assert!(dash.peek_viewport.is_some());
dash.set_peek(None);
assert!(dash.peek_viewport.is_some());
dash.restore_peek_viewport(&mut agents);
assert!(dash.peek_viewport.is_none());
}
#[test]
fn sticky_begin_peek_does_not_recapture() {
let (id, mut agents) = lease_fixture_agent();
let mut dash = DashboardState::new();
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
let snap_offset = dash
.peek_viewport
.as_ref()
.map(|l| l.snapshot.scroll_offset)
.unwrap();
agents.get_mut(&id).unwrap().scrollback.set_scroll_offset(0);
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
assert_eq!(
dash.peek_viewport.as_ref().unwrap().snapshot.scroll_offset,
snap_offset
);
}
#[test]
fn note_page_flip_from_scroll_only_when_row_matches() {
let (id, mut agents) = lease_fixture_agent();
let mut dash = DashboardState::new();
dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents);
dash.note_page_flip_from_scroll(AgentId(99), Some(3), Some(1));
assert!(
dash.peek_viewport
.as_ref()
.unwrap()
.page_flip_entry
.is_none()
);
dash.note_page_flip_from_scroll(id, Some(3), Some(1));
let lease = dash.peek_viewport.as_ref().unwrap();
assert_eq!(lease.page_flip_entry, Some(3));
assert!(lease.snapshot.follow_preserve_scroll);
assert_eq!(lease.snapshot.selected, Some(3));
}
#[test]
fn note_page_flip_ignores_subagent_lease_on_parent_agent() {
let (id, mut agents) = lease_fixture_agent();
let child = crate::test_util::make_agent_view(Some("child"), "/tmp");
agents
.get_mut(&id)
.unwrap()
.subagent_views
.insert("child".into(), Box::new(child));
let mut dash = DashboardState::new();
dash.begin_peek_viewport(
DashboardRowId::Subagent {
parent: id,
child_session_id: "child".into(),
},
&mut agents,
);
dash.note_page_flip_from_scroll(id, Some(3), Some(1));
assert!(
dash.peek_viewport
.as_ref()
.unwrap()
.page_flip_entry
.is_none(),
"parent drain must not write parent indices onto a subagent lease"
);
agents
.get_mut(&id)
.unwrap()
.scrollback
.enable_follow_with_preserve();
dash.note_page_flip_for_lease(id, &mut agents);
assert!(
dash.peek_viewport
.as_ref()
.unwrap()
.page_flip_entry
.is_none()
);
}
}