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:
parent
8adf9013a0
commit
98c3b2438a
225 changed files with 18836 additions and 7156 deletions
|
|
@ -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 < `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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
464
crates/codegen/xai-grok-pager/src/views/dashboard/peek_tail.rs
Normal file
464
crates/codegen/xai-grok-pager/src/views/dashboard/peek_tail.rs
Normal 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(¤t, 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
®istry,
|
||||
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()),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue