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()
);
}
}

View file

@ -1110,6 +1110,101 @@ pub enum ModalInputOutcome {
},
}
#[derive(Debug, Clone)]
pub struct McpSetupFormState {
pub server_name: String,
pub field: crate::views::mcps_modal::McpSetupField,
pub selected: usize,
pub error: Option<String>,
}
impl McpSetupFormState {
pub fn new(server: &crate::views::mcps_modal::McpServerInfo) -> Option<Self> {
let setup = server.setup.as_ref()?.clone();
Self::from_setup(server.name.clone(), setup, server.setup_values.clone())
}
pub fn from_setup(
server_name: String,
setup: crate::views::mcps_modal::McpSetupConfig,
values: std::collections::HashMap<String, String>,
) -> Option<Self> {
if setup.fields.len() != 1 {
return None;
}
let field = setup.fields.into_iter().next()?;
if field.options.is_empty() {
return None;
}
let selected = values
.get(&field.id)
.or(field.default.as_ref())
.and_then(|value| {
field
.options
.iter()
.position(|option| option.value == *value)
})
.unwrap_or(0);
Some(Self {
server_name,
field,
selected,
error: None,
})
}
pub fn selected_value(&self) -> Option<String> {
self.field
.options
.get(self.selected)
.map(|option| option.value.clone())
}
pub fn values(&self) -> Option<std::collections::HashMap<String, String>> {
let mut values = std::collections::HashMap::new();
values.insert(self.field.id.clone(), self.selected_value()?);
Some(values)
}
pub fn handle_key(&mut self, key: &KeyEvent) -> McpSetupOutcome {
match key.code {
KeyCode::Esc => McpSetupOutcome::Cancel,
KeyCode::Up | KeyCode::Char('k') => {
self.error = None;
if self.selected > 0 {
self.selected -= 1;
}
McpSetupOutcome::Changed
}
KeyCode::Down | KeyCode::Char('j') => {
self.error = None;
if self.selected + 1 < self.field.options.len() {
self.selected += 1;
}
McpSetupOutcome::Changed
}
KeyCode::Enter => {
if self.selected_value().is_none() {
self.error = Some("Select an option".to_string());
McpSetupOutcome::Changed
} else {
McpSetupOutcome::Submit
}
}
_ => McpSetupOutcome::Unchanged,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpSetupOutcome {
Changed,
Unchanged,
Cancel,
Submit,
}
/// Modal message overlay (errors, confirmations).
#[derive(Debug, Clone)]
pub enum ModalMessage {
@ -1776,6 +1871,7 @@ fn parse_mcp_add_fields(name: &str, url_or_cmd: &str) -> Option<ButtonAction> {
transport,
enabled: true,
oauth: None,
setup: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
tool_timeouts: None,
@ -1823,6 +1919,7 @@ pub struct ExtensionsModalState {
/// Active inline input (when the user is typing an argument for a command).
/// `None` = normal button mode, `Some` = input mode.
pub input: Option<ModalInput>,
pub mcp_setup: Option<McpSetupFormState>,
/// Modal message state (error, confirmation prompt, etc.).
pub modal_message: Option<ModalMessage>,
/// Description of an in-flight action (blocks buttons while set).
@ -1923,6 +2020,7 @@ impl ExtensionsModalState {
plugins_data: TabDataState::Loading,
button_areas: Vec::new(),
input: None,
mcp_setup: None,
modal_message: None,
pending_action: None,
pending_entry_index: None,
@ -1995,6 +2093,7 @@ impl ExtensionsModalState {
self.active_tab = tab;
// Clear modal flow state from the previous tab.
self.input = None;
self.mcp_setup = None;
self.modal_message = None;
self.pending_action = None;
self.pending_entry_index = None;
@ -2653,7 +2752,7 @@ pub fn render_extensions_modal(
};
// Input mode hides the entry list (form overlay owns the content area).
let in_input_mode = state.input.is_some();
let in_input_mode = state.input.is_some() || state.mcp_setup.is_some();
// Rebuild the entry list *before* footer action labels so Space
// enable/disable can use this frame's mapping (passed as locals to
@ -3328,12 +3427,29 @@ pub fn render_extensions_modal(
// Modal message overlay (error/confirmation) is rendered with
// its own dismissal hint in the footer below — leave the
// standard shortcuts list empty.
} else if state.picker_state.search_active && state.input.is_none() {
} else if state.picker_state.search_active && state.input.is_none() && state.mcp_setup.is_none()
{
// Search bar has focus — hide the shortcuts footer entirely so
// it doesn't compete visually with the typing cursor and so
// typed letters don't appear to map to advertised actions
// (they're going into the query, not triggering shortcuts).
// Input-mode is handled below; it owns its own footer.
} else if state.mcp_setup.is_some() {
shortcuts.push(Shortcut {
label: "Enter save and authenticate",
clickable: false,
id: 0,
});
shortcuts.push(Shortcut {
label: "↑/↓ select",
clickable: false,
id: 0,
});
shortcuts.push(Shortcut {
label: "Esc cancel",
clickable: false,
id: 0,
});
} else if let Some(ref input) = state.input {
// "Add"/input mode: surface the keys the input form actually
// handles. Tab is either path completion (single-field) or
@ -3621,7 +3737,14 @@ pub fn render_extensions_modal(
state.entry_non_selectable_clickable = non_selectable_clickable;
// Render input form overlay (when in input mode).
if let Some(ref input) = state.input {
if let Some(ref setup) = state.mcp_setup {
let form_y = entries_start_y;
let form_height = entries_area.height;
if form_height > 0 {
let form_area = Rect::new(content_area.x, form_y, content_area.width, form_height);
render_mcp_setup_form(buf, form_area, setup, &theme);
}
} else if let Some(ref input) = state.input {
let form_y = entries_start_y;
let form_height = entries_area.height;
if form_height > 0 {
@ -3779,6 +3902,61 @@ pub fn render_extensions_modal(
}
}
fn render_mcp_setup_form(buf: &mut Buffer, area: Rect, setup: &McpSetupFormState, theme: &Theme) {
if area.height < 6 || area.width < 20 {
return;
}
let h_inset: u16 = 2;
let x = area.x + h_inset;
let w = area.width.saturating_sub(h_inset * 2);
let rows = (setup.field.options.len() as u16).saturating_add(4);
let top = area.y + area.height.saturating_sub(rows) / 2;
let title = format!("{}{}", setup.server_name, setup.field.label);
buf.set_string(
x,
top,
take_by_width(&title, w as usize),
Style::default()
.fg(theme.accent_user)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD),
);
let hint = "Save and authenticate";
buf.set_string(
x,
top.saturating_add(1),
hint,
Style::default().fg(theme.gray).bg(theme.bg_base),
);
for (idx, option) in setup.field.options.iter().enumerate() {
let y = top.saturating_add(3).saturating_add(idx as u16);
if y >= area.y + area.height {
break;
}
let selected = idx == setup.selected;
let marker = if selected { "" } else { " " };
let label = format!("{marker} {}", option.label);
let style = if selected {
Style::default()
.fg(theme.text_primary)
.bg(theme.bg_highlight)
} else {
Style::default().fg(theme.text_primary).bg(theme.bg_base)
};
buf.set_string(x, y, " ".repeat(w as usize), style);
buf.set_string(x, y, take_by_width(&label, w as usize), style);
}
if let Some(ref err) = setup.error {
let y = area.y + area.height.saturating_sub(1);
buf.set_string(
x,
y,
take_by_width(err, w as usize),
Style::default().fg(theme.accent_error).bg(theme.bg_base),
);
}
}
/// Kind of modal message overlay currently showing.
#[derive(Debug, Clone, Copy)]
enum ModalMsgKind {
@ -4162,6 +4340,55 @@ mod tests {
state
}
#[test]
fn mcp_setup_form_defaults_and_pref_value() {
use crate::views::mcps_modal::{
McpServerDisplayStatus, McpServerInfo, McpSetupConfig, McpSetupField, McpSetupOption,
McpWireSource,
};
let mut server = McpServerInfo {
name: "acme".into(),
display_name: None,
status: McpServerDisplayStatus::SetupRequired,
tool_count: 0,
auth_required: false,
setup_required: true,
setup: Some(McpSetupConfig {
fields: vec![McpSetupField {
id: "site".into(),
label: "Site".into(),
field_type: "select".into(),
required: true,
default: Some("us1".into()),
options: vec![
McpSetupOption {
label: "US1".into(),
value: "us1".into(),
},
McpSetupOption {
label: "US5".into(),
value: "us5".into(),
},
],
}],
}),
setup_values: std::collections::HashMap::new(),
tools: vec![],
enabled: true,
source: "plugin: acme".into(),
wire_source: McpWireSource::Local,
plugin_name: Some("acme".into()),
is_managed_gateway: false,
};
let form = McpSetupFormState::new(&server).unwrap();
assert_eq!(form.selected_value().as_deref(), Some("us1"));
server.setup_values.insert("site".into(), "us5".into());
let form = McpSetupFormState::new(&server).unwrap();
assert_eq!(form.selected_value().as_deref(), Some("us5"));
assert_eq!(form.values().unwrap()["site"], "us5");
}
#[test]
fn selected_mcp_tool_returns_none_on_server_row() {
let mut state = fixture_with_two_servers_and_tools();
@ -4237,6 +4464,9 @@ mod tests {
status: McpServerDisplayStatus::NeedsAuth,
tool_count: 0,
auth_required: true,
setup_required: false,
setup: None,
setup_values: std::collections::HashMap::new(),
tools: vec![],
enabled: true,
source: "managed".into(),
@ -4285,6 +4515,9 @@ mod tests {
status: McpServerDisplayStatus::Ready,
tool_count: tc,
auth_required: false,
setup_required: false,
setup: None,
setup_values: std::collections::HashMap::new(),
tools: tool_details,
enabled: true,
source: "local".into(),
@ -4374,6 +4607,9 @@ mod tests {
status: McpServerDisplayStatus::Ready,
tool_count: 0,
auth_required: false,
setup_required: false,
setup: None,
setup_values: std::collections::HashMap::new(),
tools: vec![],
enabled: true,
source: "plugin: alpha".into(),
@ -4387,6 +4623,9 @@ mod tests {
status: McpServerDisplayStatus::Ready,
tool_count: 0,
auth_required: false,
setup_required: false,
setup: None,
setup_values: std::collections::HashMap::new(),
tools: vec![],
enabled: true,
source: "plugin: beta".into(),
@ -4433,6 +4672,9 @@ mod tests {
status: McpServerDisplayStatus::Ready,
tool_count: 0,
auth_required: false,
setup_required: false,
setup: None,
setup_values: std::collections::HashMap::new(),
tools: vec![],
enabled: true,
source: plugin

View file

@ -1164,6 +1164,7 @@ mod tests {
},
enabled: true,
oauth: None,
setup: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
tool_timeouts: None,

View file

@ -146,6 +146,10 @@ pub struct McpsServerEntry {
#[serde(default, rename = "type")]
pub config_type: Option<String>,
#[serde(default)]
pub setup: Option<McpSetupConfig>,
#[serde(default)]
pub setup_values: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
pub session: Option<McpsServerSession>,
}
@ -158,6 +162,34 @@ pub struct McpsServerSession {
pub tools: Vec<serde_json::Value>,
#[serde(default)]
pub auth_required: bool,
#[serde(default)]
pub setup_required: bool,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
pub struct McpSetupConfig {
#[serde(default)]
pub fields: Vec<McpSetupField>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
pub struct McpSetupField {
pub id: String,
pub label: String,
#[serde(rename = "type")]
pub field_type: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub options: Vec<McpSetupOption>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
pub struct McpSetupOption {
pub label: String,
pub value: String,
}
#[derive(Debug, Clone)]
@ -175,6 +207,9 @@ pub struct McpServerInfo {
pub status: McpServerDisplayStatus,
pub tool_count: usize,
pub auth_required: bool,
pub setup_required: bool,
pub setup: Option<McpSetupConfig>,
pub setup_values: std::collections::HashMap<String, String>,
/// Detailed tool list for expanded view.
pub tools: Vec<McpToolDetail>,
/// Whether the server is enabled in config.
@ -192,6 +227,7 @@ pub struct McpServerInfo {
pub enum McpServerDisplayStatus {
Ready,
NeedsAuth,
SetupRequired,
Unavailable,
Initializing,
}
@ -202,6 +238,7 @@ impl McpServerDisplayStatus {
match self {
Self::Ready => theme.accent_success,
Self::NeedsAuth => theme.warning,
Self::SetupRequired => theme.warning,
Self::Unavailable => theme.accent_error,
Self::Initializing => theme.running,
}
@ -212,6 +249,7 @@ impl McpServerDisplayStatus {
match self {
Self::Ready => "ready",
Self::NeedsAuth => "needs auth",
Self::SetupRequired => "setup required",
Self::Unavailable => "unavailable",
Self::Initializing => "initializing",
}
@ -226,7 +264,16 @@ pub fn convert_list_response(resp: McpsListResponse) -> Vec<McpServerInfo> {
let (status, tool_count, tools, auth_required, enabled) =
if let Some(session) = &entry.session {
let enabled = session.enabled;
if session.auth_required {
// Prefer setupRequired bool; status is a fallback for older shells.
if session.setup_required {
(
McpServerDisplayStatus::SetupRequired,
0,
vec![],
false,
enabled,
)
} else if session.auth_required {
(McpServerDisplayStatus::NeedsAuth, 0, vec![], true, enabled)
} else if !enabled {
(McpServerDisplayStatus::Unavailable, 0, vec![], false, false)
@ -234,6 +281,7 @@ pub fn convert_list_response(resp: McpsListResponse) -> Vec<McpServerInfo> {
let st = match session.status.as_deref() {
Some("ready") => McpServerDisplayStatus::Ready,
Some("initializing") => McpServerDisplayStatus::Initializing,
Some("setuprequired") => McpServerDisplayStatus::SetupRequired,
_ => McpServerDisplayStatus::Unavailable,
};
let tools: Vec<McpToolDetail> = session
@ -270,12 +318,20 @@ pub fn convert_list_response(resp: McpsListResponse) -> Vec<McpServerInfo> {
.source_label
.or(entry.source)
.unwrap_or_else(|| "local".to_string());
let setup_required = entry
.session
.as_ref()
.is_some_and(|session| session.setup_required)
|| matches!(status, McpServerDisplayStatus::SetupRequired);
McpServerInfo {
name: entry.name,
display_name: entry.display_name,
status,
tool_count,
auth_required,
setup_required,
setup: entry.setup,
setup_values: entry.setup_values.unwrap_or_default(),
tools,
enabled,
source,
@ -351,6 +407,9 @@ mod tests {
status,
tool_count: 0,
auth_required: false,
setup_required: false,
setup: None,
setup_values: std::collections::HashMap::new(),
tools: Vec::new(),
enabled: true,
source: "local".to_string(),
@ -381,11 +440,14 @@ mod tests {
source: source.map(str::to_string),
source_label: source_label.map(str::to_string),
config_type: config_type.map(str::to_string),
setup: None,
setup_values: None,
session: Some(McpsServerSession {
enabled: true,
status: Some("ready".into()),
tools: vec![],
auth_required: false,
setup_required: false,
}),
}],
})
@ -529,11 +591,14 @@ mod tests {
source: Some("managed".to_string()),
source_label: None,
config_type: Some("managedGateway".to_string()),
setup: None,
setup_values: None,
session: Some(McpsServerSession {
enabled: true,
status: Some("ready".to_string()),
tools: vec![],
auth_required: false,
setup_required: false,
}),
}
}
@ -548,6 +613,45 @@ mod tests {
assert_eq!(servers[1].display_name.as_deref(), Some("Zeta"));
}
#[test]
fn convert_list_response_setup_required_takes_priority() {
let servers = convert_list_response(McpsListResponse {
servers: vec![McpsServerEntry {
name: "acme".into(),
display_name: None,
source: Some("local".into()),
source_label: Some("plugin: acme".into()),
config_type: Some("http".into()),
setup: Some(McpSetupConfig {
fields: vec![McpSetupField {
id: "site".into(),
label: "Site".into(),
field_type: "select".into(),
required: true,
default: Some("us1".into()),
options: vec![McpSetupOption {
label: "US1".into(),
value: "us1".into(),
}],
}],
}),
setup_values: None,
session: Some(McpsServerSession {
enabled: true,
status: Some("setuprequired".into()),
tools: vec![],
auth_required: true,
setup_required: true,
}),
}],
});
assert_eq!(servers.len(), 1);
assert!(servers[0].setup_required);
assert!(!servers[0].auth_required);
assert_eq!(servers[0].status, McpServerDisplayStatus::SetupRequired);
assert!(servers[0].setup.is_some());
}
#[test]
fn patch_server_row_updates_existing() {
let mut servers = vec![
@ -606,6 +710,9 @@ mod tests {
status: McpServerDisplayStatus::Ready,
tool_count: 3,
auth_required: false,
setup_required: false,
setup: None,
setup_values: std::collections::HashMap::new(),
tools: vec![McpToolDetail {
name: "existing".into(),
display_name: None,

View file

@ -330,6 +330,10 @@ impl ListItem for QueuedPromptEntry {
fn search_text(&self) -> &str {
&self.text
}
fn copy_text(&self) -> String {
self.text.clone()
}
}
// ---------------------------------------------------------------------------
@ -1311,6 +1315,75 @@ mod tests {
assert!(text.contains("(+4 lines)"));
}
/// GB-4151: `y` on a multiline queue row must copy the full prompt text,
/// not the display line that ends with `(+N lines)`.
#[test]
fn copy_text_returns_full_prompt_not_display_suffix() {
let full = "line one\nline two\nline three\nline four";
let entry = QueuedPromptEntry::new(&local_prompt(1, full), 1);
// Precondition: display path still shows the collapsed row indicator.
let display: String = entry
.content()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
display.contains("(+3 lines)"),
"display should keep (+N lines) indicator, got: {display}"
);
let copied = entry.copy_text();
assert_eq!(copied, full);
assert!(
!copied.contains("(+"),
"copied text must not include the (+N lines) display suffix"
);
}
/// End-to-end: `ListPaneState::copy_selected` (the `y` path) uses
/// `copy_text`, so multiline rows paste the full prompt.
#[test]
fn yank_selected_multiline_copies_full_text() {
use std::sync::{Arc, Mutex};
use xai_ratatui_textarea::ClipboardProvider;
#[derive(Debug, Clone)]
struct RecordingClip {
last: Arc<Mutex<Option<String>>>,
}
impl ClipboardProvider for RecordingClip {
fn get(&mut self) -> Option<String> {
self.last.lock().unwrap().clone()
}
fn set(&mut self, text: &str) {
*self.last.lock().unwrap() = Some(text.to_string());
}
}
let full = "first line of prompt\nsecond line\nthird line";
let mut pane = QueuePane::new();
let mut local = std::collections::VecDeque::new();
local.push_back(local_prompt(1, full));
pane.sync_from_merged(&local, &[], None, None, &Default::default());
// `select_by_id` is resolved into `selected_index` by prepare_layout.
pane.list_state.select_by_id(1);
pane.list_state.prepare_layout(&pane.entries, 80, 10);
let clip = Arc::new(Mutex::new(None));
pane.list_state
.set_clipboard_provider(Box::new(RecordingClip { last: clip.clone() }));
assert!(
pane.list_state.copy_selected(&pane.entries),
"y/copy_selected must succeed for a selected queue row"
);
let copied = clip.lock().unwrap().clone();
assert_eq!(copied.as_deref(), Some(full));
}
#[test]
fn test_multiline_singular() {
let styled = QueuedPromptEntry::build_styled("first line", 2, QueueEntryKind::Prompt, None);

View file

@ -71,6 +71,60 @@ fn first_user_prompt_text(agent: &AgentView) -> Option<String> {
None
}
/// First line of the most recent user prompt (`RenderBlock::UserPrompt`) in
/// the agent's scrollback, ANSI-stripped + sanitised; `None` when the user
/// hasn't sent any prompts yet.
pub(crate) fn last_user_prompt_line(agent: &AgentView) -> Option<String> {
let len = agent.scrollback.len();
for idx in (0..len).rev() {
let entry = agent.scrollback.entry(idx)?;
if let RenderBlock::UserPrompt(b) = &entry.block {
let first = b.text.lines().next().unwrap_or("").trim();
if first.is_empty() {
continue;
}
let stripped = strip_ansi_escapes::strip_str(first);
let safe = sanitize_display_text(&stripped).into_owned();
return Some(safe.trim().to_string());
}
}
None
}
/// First renderable line of the newest agent message, ANSI-stripped +
/// sanitised. Pairing guarantee: returns `None` when a `UserPrompt` is newer
/// than every agent message (that prompt is unanswered — an older reply would
/// misrepresent the latest exchange), or when the message has no renderable
/// line (older messages are not scanned).
pub(crate) fn last_agent_message_line(agent: &AgentView) -> Option<String> {
let len = agent.scrollback.len();
for idx in (0..len).rev() {
let entry = agent.scrollback.entry(idx)?;
match &entry.block {
RenderBlock::AgentMessage(msg) => {
let text = msg.text();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let stripped = strip_ansi_escapes::strip_str(trimmed);
let safe = sanitize_display_text(&stripped).into_owned();
let safe = safe.trim().to_string();
if !safe.is_empty() {
return Some(safe);
}
}
return None;
}
// The user's latest prompt marks the turn boundary — no reply yet.
RenderBlock::UserPrompt(_) => return None,
_ => {}
}
}
None
}
/// Take the first `MAX_TITLE_CHARS` chars and append an ellipsis when
/// truncated. Char-based (not byte-based) so multi-byte codepoints
/// don't get split.

View file

@ -675,6 +675,7 @@ pub(super) fn action_for_bool(key: SettingKey, new: bool) -> Option<Action> {
"contextual_hints.send_now" => Some(Action::SetContextualHintSendNow(new)),
"contextual_hints.small_screen" => Some(Action::SetContextualHintSmallScreen(new)),
"contextual_hints.word_select" => Some(Action::SetContextualHintWordSelect(new)),
"contextual_hints.ssh_wrap" => Some(Action::SetContextualHintSshWrap(new)),
"multiline_mode" => Some(Action::SetMultilineMode(new)),
"vim_mode" => Some(Action::SetVimMode(new)),
"remember_tool_approvals" => Some(Action::SetRememberToolApprovals(new)),

View file

@ -1673,8 +1673,9 @@ fn render_welcome_done(
// Heights that don't depend on the menu — computed first so the menu
// builder can probe the layout to decide whether to add a Changelog row.
// Startup-warning hint height (multi-line aware).
let hint_height = p.startup_warnings.first().map_or(0u16, |w| {
// Startup-warning hint height (multi-line aware). Must pick the same
// entry `render_startup_warnings` draws — see `startup::banner_warning`.
let hint_height = crate::startup::banner_warning(p.startup_warnings).map_or(0u16, |w| {
let msg_lines = w.message.lines().count() as u16;
let action_line = if w.action.is_some() { 1 } else { 0 };
msg_lines + action_line + 1 // +1 for buffer spacing
@ -2424,22 +2425,23 @@ fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &st
}
}
/// Render the first startup warning centered in the given area.
/// Render one startup warning centered in the given area.
///
/// `startup_warnings` can hold more than one entry (the WezTerm
/// kitty-keyboard banner is prepended ahead of `summarize_warnings()`
/// output — see `diagnostics::assemble_startup_warnings`), but only the
/// first is rendered; all of them point at `/terminal-setup`, which lists
/// every issue. One message line, one optional action line, plus a buffer
/// row for spacing. Severity controls color (yellow for `Warning`, dim
/// for `Info`).
/// output — see `diagnostics::assemble_startup_warnings`), but only one is
/// rendered — the severity-aware pick from `startup::banner_warning`, so a
/// runtime-pushed Warning displaces an earlier Info entry; all of them point
/// at `/terminal-setup`, which lists every issue. One message line, one
/// optional action line, plus a buffer row for spacing. Severity controls
/// color (yellow for `Warning`, dim for `Info`).
fn render_startup_warnings(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
warnings: &[StartupWarning],
) -> Option<Rect> {
let w = warnings.first()?;
let w = crate::startup::banner_warning(warnings)?;
// Skip the import-claude startup warning entirely — the import row in the
// menu now carries the call-to-action with the same visual weight as