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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1164,6 +1164,7 @@ mod tests {
|
|||
},
|
||||
enabled: true,
|
||||
oauth: None,
|
||||
setup: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
tool_timeouts: None,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue