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:
grokkybara[bot] 2026-07-18 19:48:28 +01:00
commit 7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions

View file

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

View file

@ -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 /

View file

@ -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);

View file

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

View file

@ -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.