Synced from monorepo
Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
parent
98c3b2438a
commit
7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions
|
|
@ -884,6 +884,42 @@ mod tests {
|
|||
assert_eq!(commit_collect(&mut s), vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn btw_block_emits_once_across_repeated_frontier_passes() {
|
||||
let mut s = ScrollbackState::new();
|
||||
s.push(ScrollbackEntry::new(RenderBlock::Btw(
|
||||
xai_grok_pager::scrollback::blocks::BtwBlock::new(
|
||||
"original question",
|
||||
"original answer",
|
||||
),
|
||||
)));
|
||||
|
||||
let mut emitted = Vec::new();
|
||||
assert_eq!(
|
||||
commit_leading_run(&mut s, false, |state, i| {
|
||||
let RenderBlock::Btw(block) = &state.get(i).unwrap().block else {
|
||||
panic!("expected Btw block")
|
||||
};
|
||||
assert_eq!(block.question, "original question");
|
||||
assert_eq!(block.content().text(), "original answer");
|
||||
emitted.push(i);
|
||||
true
|
||||
}),
|
||||
1
|
||||
);
|
||||
assert!(minimal_api::is_committed(&s, s.get(0).unwrap()));
|
||||
|
||||
assert_eq!(
|
||||
commit_leading_run(&mut s, false, |_, i| {
|
||||
emitted.push(i);
|
||||
true
|
||||
}),
|
||||
0
|
||||
);
|
||||
assert_eq!(emitted, vec![0]);
|
||||
assert!(!scan_frontier(&s, false).will_commit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_leading_run_advances_frontier_and_marks_committed_once() {
|
||||
let mut s = ScrollbackState::new();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
//!
|
||||
//! - [`commit`] — committed-frontier logic, display policy, and the per-frame
|
||||
//! commit-to-scrollback pass.
|
||||
//! - [`live`] — the pinned live region (tail + status + prompt).
|
||||
//! - [`live`] — the pinned live region (tail + todos + `/btw` + status + prompt).
|
||||
//! - [`todo`] — the persistent todo panel shown above the prompt.
|
||||
//! - [`auth`] — the in-region sign-in flow shown before a session exists.
|
||||
//! - [`overlay`] — the inline-overlay host (prompt-anchored dropdowns; grows /
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
//! Minimal-mode live region: the small pinned viewport holding the running-turn
|
||||
//! tail (model B), a one-line status indicator, and the always-focused prompt.
|
||||
//! tail (model B), optional todos / `/btw` panels, a one-line status indicator,
|
||||
//! and the always-focused prompt.
|
||||
//!
|
||||
//! Layout (top → bottom): live tail · status · prompt. The tail shows the
|
||||
//! bottom of the uncommitted run (streaming message / running tool) so output
|
||||
//! is visible as it generates; finished blocks scroll up into native scrollback
|
||||
//! via [`super::commit`]. When idle the tail is empty and only status + prompt
|
||||
//! show.
|
||||
//! Layout (top → bottom): live tail · todos · `/btw` · status · prompt ·
|
||||
//! overlay/info. The tail shows the bottom of the uncommitted run (streaming
|
||||
//! message / running tool) so output is visible as it generates; finished blocks
|
||||
//! scroll up into native scrollback via [`super::commit`]. When idle the tail is
|
||||
//! empty and only status + prompt (+ optional panels) show.
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
|
|
@ -42,6 +43,22 @@ fn inset_left(area: Rect, inset: u16) -> Rect {
|
|||
..area
|
||||
}
|
||||
}
|
||||
/// Drop cached `/btw` geometry so minimal input cannot scroll an invisible
|
||||
/// panel after a modal host path skipped painting it.
|
||||
fn clear_btw_geometry(agent: &mut xai_grok_pager::app::agent_view::AgentView) {
|
||||
agent.last_btw_selection_model =
|
||||
xai_grok_pager::scrollback::text_selection::ResolvedSelectionModel::default();
|
||||
agent.last_btw_area = Rect::default();
|
||||
}
|
||||
/// Keep a paintable `/btw` area only when it is wholly inside the frame buffer.
|
||||
fn paintable_btw_area(frame_area: Rect, area: Rect) -> Option<Rect> {
|
||||
(minimal_api::minimal_btw_geometry_is_paintable(area)
|
||||
&& area.x >= frame_area.x
|
||||
&& area.y >= frame_area.y
|
||||
&& area.x.saturating_add(area.width) <= frame_area.x.saturating_add(frame_area.width)
|
||||
&& area.y.saturating_add(area.height) <= frame_area.y.saturating_add(frame_area.height))
|
||||
.then_some(area)
|
||||
}
|
||||
/// The prompt style used by the minimal live region.
|
||||
///
|
||||
/// Shared with [`super::overlay::sync_viewport`] so viewport sizing measures the
|
||||
|
|
@ -102,6 +119,11 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) {
|
|||
let row_inset = live_left_inset(appearance);
|
||||
let layout_cfg = &appearance.scrollback.layout;
|
||||
let term_h = terminal.last_known_area().height;
|
||||
if let Some(id) = agent_id
|
||||
&& let Some(agent) = agents.get_mut(&id)
|
||||
{
|
||||
clear_btw_geometry(agent);
|
||||
}
|
||||
xai_grok_pager::render::draw::draw_frame(terminal, cursor, |frame, _link_spans| {
|
||||
let area = frame.area();
|
||||
if area.height == 0 || area.width < 4 {
|
||||
|
|
@ -113,6 +135,7 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) {
|
|||
crate::auth::render_auth(frame.buffer_mut(), area, &theme, &auth_hint);
|
||||
return (None, None);
|
||||
};
|
||||
agent.active_pane = xai_grok_pager::app::agent_view::AgentPane::Prompt;
|
||||
let status_activity = minimal_advance_phase_timer(agent);
|
||||
let show_todos = crate::todo::todo_panel_visible(agent, force_todos);
|
||||
let queued = agent.session.pending_prompts.len() + agent.shared_queue.len();
|
||||
|
|
@ -212,19 +235,30 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) {
|
|||
.min(avail)
|
||||
.max(1);
|
||||
let rest = avail.saturating_sub(prompt_h);
|
||||
let todos_cap = if force_todos {
|
||||
rest
|
||||
let raw_btw = if minimal_api::minimal_btw_surface_available(agent) {
|
||||
xai_grok_pager::views::btw_overlay::btw_panel_height(
|
||||
agent.btw_state.as_ref(),
|
||||
area.width,
|
||||
)
|
||||
} else {
|
||||
rest.min(crate::todo::MAX_TODO_ROWS)
|
||||
0
|
||||
};
|
||||
let btw_desired = minimal_api::minimal_btw_visible_height(raw_btw, area.width, rest);
|
||||
let after_btw = rest.saturating_sub(btw_desired);
|
||||
let todos_cap = if force_todos {
|
||||
after_btw
|
||||
} else {
|
||||
after_btw.min(crate::todo::MAX_TODO_ROWS)
|
||||
};
|
||||
let todo_lines = if show_todos {
|
||||
crate::todo::todo_panel_lines(agent, todos_cap, force_todos)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let todos_h = (todo_lines.len() as u16).min(rest);
|
||||
let tail_h = rest.saturating_sub(todos_h);
|
||||
let tick = (now_millis() / 100) as u64;
|
||||
let todos_h = (todo_lines.len() as u16).min(after_btw);
|
||||
let btw_h = btw_desired;
|
||||
let tail_h = rest.saturating_sub(todos_h + btw_h);
|
||||
let tick = agent.scrollback.animation_tick();
|
||||
if tail_h > 0 {
|
||||
let tail_area = Rect {
|
||||
x: area.x,
|
||||
|
|
@ -260,10 +294,34 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) {
|
|||
&todo_lines,
|
||||
);
|
||||
}
|
||||
let btw_area = paintable_btw_area(
|
||||
area,
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: area.y.saturating_add(tail_h).saturating_add(todos_h),
|
||||
width: area.width,
|
||||
height: btw_h,
|
||||
},
|
||||
);
|
||||
if let (Some(btw), Some(btw_area)) = (agent.btw_state.as_ref(), btw_area) {
|
||||
let focused = minimal_api::btw_focused(agent);
|
||||
xai_grok_pager::views::btw_overlay::render_btw_panel(
|
||||
frame.buffer_mut(),
|
||||
btw,
|
||||
btw_area,
|
||||
tick,
|
||||
focused,
|
||||
None,
|
||||
&mut agent.last_btw_selection_model,
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
agent.last_btw_area = btw_area;
|
||||
}
|
||||
let status_area = inset_left(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: area.y + tail_h + todos_h,
|
||||
y: area.y + tail_h + todos_h + btw_h,
|
||||
width: area.width,
|
||||
height: status_h,
|
||||
},
|
||||
|
|
@ -279,7 +337,7 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) {
|
|||
);
|
||||
let prompt_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + tail_h + todos_h + status_h,
|
||||
y: area.y + tail_h + todos_h + btw_h + status_h,
|
||||
width: area.width,
|
||||
height: prompt_h,
|
||||
};
|
||||
|
|
@ -421,29 +479,6 @@ fn draw_tail(
|
|||
}
|
||||
}
|
||||
}
|
||||
/// Count idle-surviving "watchers" — running monitors, active scheduled
|
||||
/// `/loop` tasks, and running (background) subagents — so the shared turn-status
|
||||
/// widget can show the persistent "watching · N monitors · M loops · K
|
||||
/// subagents" cue while the agent is idle. Mirrors the full-TUI computation in
|
||||
/// `AgentView::draw` (which minimal bypasses).
|
||||
fn minimal_watchers(agent: &xai_grok_pager::app::agent_view::AgentView) -> turn_status::Watchers {
|
||||
turn_status::Watchers {
|
||||
monitors: agent
|
||||
.session
|
||||
.bg_tasks
|
||||
.values()
|
||||
.filter(|t| {
|
||||
t.is_monitor && t.status == xai_grok_pager::app::agent::BgTaskStatus::Running
|
||||
})
|
||||
.count(),
|
||||
loops: agent.session.scheduled_tasks.len(),
|
||||
subagents: agent
|
||||
.subagent_sessions
|
||||
.values()
|
||||
.filter(|s| s.is_running())
|
||||
.count(),
|
||||
}
|
||||
}
|
||||
/// Resolve the current turn activity and advance the phase timer when it
|
||||
/// changes. The full TUI runs this inside its own `draw` (reset
|
||||
/// `activity_started_at` on every phase transition); minimal has a separate
|
||||
|
|
@ -464,11 +499,12 @@ fn minimal_advance_phase_timer(
|
|||
/// Reuses the full-TUI [`turn_status::render_turn_status`] widget so minimal
|
||||
/// surfaces the same rich activity detail (`Run …` / `Thinking…` /
|
||||
/// `Waiting on subagent…` / `Retrying (attempt N)…` / `Cancelling…`), the
|
||||
/// per-phase + turn timers, and the idle "watching · …" cue (running monitors /
|
||||
/// loops / background subagents) — instead of collapsing everything to
|
||||
/// "working…". Keyboard-only, so the mouse `[stop]` / `[↓]` buttons are
|
||||
/// suppressed (`None`), and `flat_background` keeps the row transparent like the
|
||||
/// rest of the live region. When the widget would draw nothing (plain idle, no
|
||||
/// per-phase + turn timers, and the "watching · …" cue (running commands /
|
||||
/// monitors / loops / background subagents, shown while idle or parked) —
|
||||
/// instead of collapsing everything to "working…". Keyboard-only, so the
|
||||
/// mouse `[stop]` / `[↓]` buttons are suppressed (`None`), and
|
||||
/// `flat_background` keeps the row transparent like the rest of the live
|
||||
/// region. When the widget would draw nothing (plain idle or parked, no
|
||||
/// watchers) a small `minimal · /help` hint is shown instead.
|
||||
fn render_minimal_status(
|
||||
buf: &mut Buffer,
|
||||
|
|
@ -492,16 +528,16 @@ fn render_minimal_status(
|
|||
);
|
||||
return;
|
||||
}
|
||||
let watchers = minimal_watchers(agent);
|
||||
let watchers = minimal_api::watchers(agent);
|
||||
let drain_blocked = minimal_api::drain_blocked(agent);
|
||||
if minimal_api::renders_parked(agent)
|
||||
|| !turn_status::should_show(
|
||||
&agent.session.state,
|
||||
drain_blocked,
|
||||
minimal_api::mcp_init_progress(agent),
|
||||
watchers,
|
||||
)
|
||||
{
|
||||
let parked = minimal_api::renders_parked(agent);
|
||||
if !turn_status::should_show(
|
||||
&agent.session.state,
|
||||
drain_blocked,
|
||||
minimal_api::mcp_init_progress(agent),
|
||||
watchers,
|
||||
parked,
|
||||
) {
|
||||
render_idle_hint(buf, area, theme);
|
||||
return;
|
||||
}
|
||||
|
|
@ -528,6 +564,7 @@ fn render_minimal_status(
|
|||
is_pending_user_input,
|
||||
goal_verifying,
|
||||
watchers,
|
||||
parked,
|
||||
true,
|
||||
minimal_api::held_queue_count(agent),
|
||||
minimal_api::held_queue_top_sendable(agent),
|
||||
|
|
@ -707,6 +744,21 @@ mod tests {
|
|||
minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"))
|
||||
}
|
||||
#[test]
|
||||
fn btw_area_must_be_fully_paintable() {
|
||||
let frame = Rect::new(0, 0, 80, 20);
|
||||
assert_eq!(
|
||||
paintable_btw_area(frame, Rect::new(0, 4, 80, 3)),
|
||||
Some(Rect::new(0, 4, 80, 3))
|
||||
);
|
||||
assert!(!minimal_api::minimal_btw_size_is_paintable(11, 3));
|
||||
assert!(minimal_api::minimal_btw_size_is_paintable(12, 3));
|
||||
assert!(!minimal_api::minimal_btw_size_is_paintable(80, 2));
|
||||
assert!(paintable_btw_area(frame, Rect::new(0, 4, 11, 3)).is_none());
|
||||
assert!(paintable_btw_area(frame, Rect::new(0, 4, 80, 2)).is_none());
|
||||
assert!(paintable_btw_area(frame, Rect::new(0, 19, 80, 3)).is_none());
|
||||
assert!(paintable_btw_area(frame, Rect::new(79, 4, 2, 3)).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn tail_height_uses_owning_session_cwd_for_tool_paths() {
|
||||
use xai_grok_pager::app::agent::AgentState;
|
||||
use xai_grok_pager::scrollback::RenderBlock;
|
||||
|
|
@ -826,7 +878,7 @@ mod tests {
|
|||
tag: "loop".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(minimal_watchers(&a).loops, 1);
|
||||
assert_eq!(minimal_api::watchers(&a).loops, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_minimal_status(&mut buf, area, &a, &None, None, &theme);
|
||||
let text = read(&buf);
|
||||
|
|
|
|||
|
|
@ -293,27 +293,50 @@ fn compute_target(app: &mut AppView, term_h: u16, width: u16) -> u16 {
|
|||
.max(1);
|
||||
|
||||
// Size the viewport to exactly its content — tail (uncommitted streaming
|
||||
// output) + todo panel + status + overlay + prompt — so the prompt sits
|
||||
// directly after the conversation with no gap, whether idle or mid-turn.
|
||||
// When a turn is "thinking" the tail is empty, so the prompt stays right
|
||||
// under the content instead of floating below a fixed empty region; as
|
||||
// output streams the tail grows and the viewport grows downward with it.
|
||||
// The region is not bottom-pinned, so the rest of the screen below stays
|
||||
// empty (the app "owns" the window from the top down).
|
||||
// output) + todo panel + /btw panel + status + overlay + prompt — so the
|
||||
// prompt sits directly after the conversation with no gap, whether idle or
|
||||
// mid-turn. When a turn is "thinking" the tail is empty, so the prompt
|
||||
// stays right under the content instead of floating below a fixed empty
|
||||
// region; as output streams the tail grows and the viewport grows downward
|
||||
// with it. The region is not bottom-pinned, so the rest of the screen below
|
||||
// stays empty (the app "owns" the window from the top down).
|
||||
let tail_h = super::live::tail_height(agent, width, &commit_app);
|
||||
let todos_h = super::todo::todo_panel_height(agent, force_todos);
|
||||
// Below the prompt sits either the dropdown overlay or the 1-row info bar
|
||||
// (model · context usage · turn time/tokens); reserve at least the info row
|
||||
// when no dropdown is open so it isn't clipped / doesn't scroll content.
|
||||
let below_h = overlay_h.max(1);
|
||||
content_target(tail_h, todos_h, below_h, prompt_h, ceiling)
|
||||
// `/btw` is a non-blocking side panel above the status/prompt (same place
|
||||
// as the full TUI). Height is measured at full viewport width so wrap
|
||||
// matches `live::draw_live`. Only reserve rows the shared minimal paint
|
||||
// policy accepts, otherwise a narrow or short terminal leaves a blank strip.
|
||||
let raw_btw = if minimal_api::minimal_btw_surface_available(agent) {
|
||||
xai_grok_pager::views::btw_overlay::btw_panel_height(agent.btw_state.as_ref(), width)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let chrome = 1u16 // status row
|
||||
.saturating_add(below_h)
|
||||
.saturating_add(prompt_h);
|
||||
let available = ceiling.saturating_sub(chrome);
|
||||
let btw_h = minimal_api::minimal_btw_visible_height(raw_btw, width, available);
|
||||
content_target(tail_h, todos_h, btw_h, below_h, prompt_h, ceiling)
|
||||
}
|
||||
|
||||
/// Live-viewport height sized to exactly its content: tail + todo panel + status
|
||||
/// row + overlay + prompt. Floored at 2 (status + prompt), capped at the screen.
|
||||
fn content_target(tail_h: u16, todos_h: u16, overlay_h: u16, prompt_h: u16, ceiling: u16) -> u16 {
|
||||
/// Live-viewport height sized to exactly its content: tail + todo panel + /btw
|
||||
/// panel + status row + overlay + prompt. Floored at 2 (status + prompt), capped
|
||||
/// at the screen.
|
||||
fn content_target(
|
||||
tail_h: u16,
|
||||
todos_h: u16,
|
||||
btw_h: u16,
|
||||
overlay_h: u16,
|
||||
prompt_h: u16,
|
||||
ceiling: u16,
|
||||
) -> u16 {
|
||||
tail_h
|
||||
.saturating_add(todos_h)
|
||||
.saturating_add(btw_h)
|
||||
.saturating_add(1) // status row
|
||||
.saturating_add(overlay_h)
|
||||
.saturating_add(prompt_h)
|
||||
|
|
@ -990,18 +1013,24 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn content_target_fits_content_with_no_gap() {
|
||||
// Viewport = tail + todos + status(1) + overlay + prompt — no base
|
||||
// Viewport = tail + todos + btw + status(1) + overlay + prompt — no base
|
||||
// floor, so the prompt sits right after the conversation. Idle (tail 0,
|
||||
// empty prompt) is just status + prompt.
|
||||
assert_eq!(content_target(0, 0, 0, 1, 40), 2); // status + 1-row prompt
|
||||
assert_eq!(content_target(0, 3, 0, 1, 40), 5); // + 3 todo rows
|
||||
assert_eq!(content_target(0, 3, 5, 2, 40), 11); // + overlay(5) + 2-row prompt
|
||||
assert_eq!(content_target(0, 0, 0, 0, 1, 40), 2); // status + 1-row prompt
|
||||
assert_eq!(content_target(0, 3, 0, 0, 1, 40), 5); // + 3 todo rows
|
||||
assert_eq!(content_target(0, 3, 0, 5, 2, 40), 11); // + overlay(5) + 2-row prompt
|
||||
// /btw Loading/Error is 3 rows; Done grows with the answer.
|
||||
assert_eq!(content_target(0, 0, 3, 0, 1, 40), 5); // + btw(3)
|
||||
// Production idle always reserves ≥1 below the prompt (info bar).
|
||||
assert_eq!(content_target(0, 0, 3, 1, 1, 40), 6); // btw+status+info+prompt
|
||||
// todos + btw stack without collapsing either.
|
||||
assert_eq!(content_target(0, 3, 3, 0, 1, 40), 8);
|
||||
// The streaming tail grows the viewport (no fixed empty gap while
|
||||
// "thinking": tail 0 → just status + prompt).
|
||||
assert_eq!(content_target(6, 0, 0, 1, 40), 8); // tail(6) + status + prompt
|
||||
assert_eq!(content_target(6, 0, 0, 0, 1, 40), 8); // tail(6) + status + prompt
|
||||
// Floored at 2 (status + prompt) and capped at the screen ceiling.
|
||||
assert_eq!(content_target(0, 0, 0, 0, 40), 2);
|
||||
assert_eq!(content_target(50, 0, 0, 0, 20), 20);
|
||||
assert_eq!(content_target(0, 0, 0, 0, 0, 40), 2);
|
||||
assert_eq!(content_target(50, 0, 0, 0, 0, 20), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1036,7 +1065,25 @@ mod tests {
|
|||
fn content_target_clamps_to_screen() {
|
||||
// Content taller than the screen clamps to the ceiling (then the tail
|
||||
// scrolls / clips); a tiny terminal still yields at least the floor.
|
||||
assert_eq!(content_target(30, 0, 0, 1, 24), 24);
|
||||
assert_eq!(content_target(5, 0, 0, 1, 2), 2);
|
||||
assert_eq!(content_target(30, 0, 0, 0, 1, 24), 24);
|
||||
assert_eq!(content_target(5, 0, 0, 0, 1, 2), 2);
|
||||
// A tall /btw Done answer still clamps rather than overflowing.
|
||||
assert_eq!(content_target(0, 0, 20, 0, 1, 10), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn btw_height_policy_matches_draw_live_boundaries() {
|
||||
let visible = minimal_api::minimal_btw_visible_height;
|
||||
assert_eq!(visible(3, 80, 40), 3);
|
||||
assert_eq!(visible(12, 80, 40), 12);
|
||||
assert_eq!(visible(20, 80, 10), 10);
|
||||
assert_eq!(visible(0, 80, 40), 0);
|
||||
// Width 11/12 and available rows 2/3 are the production boundary.
|
||||
assert_eq!(visible(3, 11, 40), 0);
|
||||
assert_eq!(visible(3, 12, 40), 3);
|
||||
assert_eq!(visible(3, 80, 2), 0);
|
||||
assert_eq!(visible(3, 80, 3), 3);
|
||||
assert_eq!(content_target(0, 0, visible(3, 11, 40), 1, 1, 40), 3);
|
||||
assert_eq!(content_target(0, 0, visible(3, 80, 2), 1, 1, 5), 3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ fn resume_body_rows(agent: &AgentView, width: u16) -> u16 {
|
|||
let entries_data = entries.as_deref().unwrap_or(&[]);
|
||||
let content_width = width.saturating_sub(2);
|
||||
let filtered =
|
||||
minimal_api::filter_session_entries(entries.as_deref(), &state.query, *source_filter);
|
||||
minimal_api::filter_session_entries(entries.as_deref(), state.query(), *source_filter);
|
||||
let built =
|
||||
minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width);
|
||||
let fields_vecs: Vec<Vec<PickerField>> = built
|
||||
|
|
@ -217,7 +217,7 @@ fn render_resume(
|
|||
let entries_data = entries.as_deref().unwrap_or(&[]);
|
||||
let content_width = area.width.saturating_sub(2);
|
||||
let filtered =
|
||||
minimal_api::filter_session_entries(entries.as_deref(), &state.query, *source_filter);
|
||||
minimal_api::filter_session_entries(entries.as_deref(), state.query(), *source_filter);
|
||||
let built =
|
||||
minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width);
|
||||
let fields_vecs: Vec<Vec<PickerField>> = built
|
||||
|
|
@ -241,16 +241,17 @@ fn render_resume(
|
|||
|
||||
render_title(buf, title_row, theme, "Resume session");
|
||||
// Focus-aware search bar (cursor only when search is focused).
|
||||
picker::render_search_bar(
|
||||
minimal_api::render_picker_search_bar(
|
||||
buf,
|
||||
search_row.x + 1,
|
||||
search_row.y,
|
||||
search_row.width.saturating_sub(1),
|
||||
Rect::new(
|
||||
search_row.x + 1,
|
||||
search_row.y,
|
||||
search_row.width.saturating_sub(1),
|
||||
1,
|
||||
),
|
||||
theme,
|
||||
&state.query,
|
||||
state.search_active,
|
||||
state,
|
||||
true,
|
||||
state.query_cursor,
|
||||
None,
|
||||
);
|
||||
render_divider(buf, divider_row, theme);
|
||||
|
|
@ -293,7 +294,7 @@ fn mcps_body_rows(agent: &AgentView) -> u16 {
|
|||
};
|
||||
let rows = minimal_api::build_mcp_picker_rows(
|
||||
servers,
|
||||
&s.picker_state.query,
|
||||
s.picker_state.query(),
|
||||
s.mcps_filter,
|
||||
&s.mcps_collapsed_sections,
|
||||
&s.mcps_tools_expanded,
|
||||
|
|
@ -324,14 +325,14 @@ fn render_mcps(
|
|||
let loading;
|
||||
{
|
||||
let s = minimal_api::extensions_modal(agent)?;
|
||||
let searching = !s.picker_state.query.is_empty();
|
||||
let searching = !s.picker_state.query().is_empty();
|
||||
loading = matches!(s.mcps_data, TabDataState::Loading);
|
||||
match &s.mcps_data {
|
||||
TabDataState::Loaded(servers) => {
|
||||
let (row_labels, row_group_keys, row_data_indices) =
|
||||
minimal_api::build_mcp_picker_rows(
|
||||
servers,
|
||||
&s.picker_state.query,
|
||||
s.picker_state.query(),
|
||||
s.mcps_filter,
|
||||
&s.mcps_collapsed_sections,
|
||||
&s.mcps_tools_expanded,
|
||||
|
|
@ -677,6 +678,52 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_search_uses_picker_grapheme_viewport_at_narrow_width() {
|
||||
let grapheme = "👩🏽\u{200d}💻";
|
||||
let combining = "e\u{301}";
|
||||
let mut agent = with_resume(vec![session_entry("match")]);
|
||||
let Some(ActiveModal::SessionPicker { state, .. }) = &mut agent.active_modal else {
|
||||
panic!("expected session picker");
|
||||
};
|
||||
state.set_query(format!("a{grapheme}{combining}"));
|
||||
state.search_active = true;
|
||||
|
||||
let theme = Theme::current();
|
||||
let area = Rect::new(0, 0, 14, 5);
|
||||
let mut actual = Buffer::empty(area);
|
||||
render(&mut actual, area, &mut agent, ListPanel::Resume, &theme);
|
||||
|
||||
let Some(ActiveModal::SessionPicker { state, .. }) = &agent.active_modal else {
|
||||
panic!("expected session picker");
|
||||
};
|
||||
let mut expected = Buffer::empty(area);
|
||||
minimal_api::render_picker_search_bar(
|
||||
&mut expected,
|
||||
Rect::new(1, 1, 13, 1),
|
||||
&theme,
|
||||
state,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
for x in 1..14 {
|
||||
let actual_cell = actual.cell((x, 1)).expect("actual search cell");
|
||||
let expected_cell = expected.cell((x, 1)).expect("expected search cell");
|
||||
assert_eq!(actual_cell.symbol(), expected_cell.symbol(), "column {x}");
|
||||
assert_eq!(actual_cell.style(), expected_cell.style(), "column {x}");
|
||||
}
|
||||
let text = buffer_text(&actual);
|
||||
assert!(text.contains(grapheme), "ZWJ grapheme was split: {text:?}");
|
||||
assert!(
|
||||
text.contains(combining),
|
||||
"combining grapheme was split: {text:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.cell((13, 1)).expect("cursor cell").bg,
|
||||
theme.text_primary
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcps_panel_height_is_chrome_plus_rows() {
|
||||
// One section header + 2 server rows = 3 body rows; + 4 chrome = 7.
|
||||
|
|
|
|||
Loading…
Reference in a new issue