Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,49 @@
[package]
name = "xai-grok-pager-minimal"
version = "0.1.0"
edition.workspace = true
license = "Apache-2.0"
authors = ["xAI"]
# `xai-ratatui-inline` is reached only through the pager's re-exported
# `PagerTerminal` type (its `insert_before` / viewport methods), so this crate
# never names the crate in code — only in intra-doc links. cargo-shear can't see
# doc links, so keep it ignored rather than dropping the dep (which would break
# those links).
[package.metadata.cargo-shear]
ignored = ["xai-ratatui-inline"]
[dependencies]
# The pager crate whose view-model (AppView / views / scrollback) this render
# mode reads. NOTE: the dependency only points this way — `xai-grok-pager` must
# NOT depend on this crate (that would be a cargo cycle). The pager invokes this
# crate via the `xai_grok_pager::minimal_hook` fn-pointer seam, installed by the
# composition-root binary; see `install()`.
xai-grok-pager = { path = "../xai-grok-pager" }
# Rendering primitives (must match the pager's versions).
ratatui = { workspace = true, features = ["crossterm", "unstable-widget-ref"] }
crossterm = { workspace = true, features = ["event-stream", "bracketed-paste"] }
# Transcript temp-file names (`grok-transcript-<uuid>.ansi`).
uuid = { workspace = true, features = ["v4"] }
tracing = { workspace = true }
# Native scrollback / inline viewport engine (`insert_before`, viewport sizing).
# Used only via the pager's re-exported `PagerTerminal`; see the
# `[package.metadata.cargo-shear]` note above.
xai-ratatui-inline = { workspace = true }
# Misc used by the commit/live/full-view paths.
chrono = { workspace = true }
similar = { workspace = true }
xai-grok-shell = { workspace = true }
xai-grok-version = { workspace = true }
xai-token-estimation = { workspace = true }
[dev-dependencies]
# The unit tests reuse the pager's test-only view-model constructors
# (`test_agent_view`, yolo/auto setters), gated behind its test-only
# helpers.
xai-grok-pager = { path = "../xai-grok-pager", features = [] }
[features]

View file

@ -0,0 +1,331 @@
//! Minimal-mode sign-in rendering for the live region.
//!
//! Before any agent session exists (unauthenticated / folder-trust pending) the
//! minimal live region shows the sign-in flow itself — device or external-command
//! flow, a sign-in error, or a brief "starting" transient once authenticated —
//! since minimal has no welcome screen. [`draw_live`](super::live::draw_live)
//! computes a [`MinimalAuthHint`] from the app's [`AuthState`] and renders it via
//! [`render_auth`].
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use xai_grok_pager::app::app_view::AuthState;
use xai_grok_pager::theme::Theme;
/// What the minimal live region shows when there is no active agent yet: the
/// in-region sign-in flow (device or external-command), a sign-in error, or a
/// brief "starting" transient once authenticated. Computed from [`AuthState`]
/// before the draw closure so the closure can own it.
pub(super) enum MinimalAuthHint {
/// Interactive sign-in underway — show the URL (when known) and the device
/// code (when the URL carries one). Covers device flow and the external
/// command flow (where the provider opens its own browser; `url` may be
/// `None`).
SigningIn {
url: Option<String>,
code: Option<String>,
},
/// The last sign-in attempt failed; show the error.
Failed(String),
/// Authenticated — the session is being created (brief transient).
Starting,
}
/// Map the app's [`AuthState`] to what the no-agent live region should show.
pub(super) fn minimal_auth_hint(auth: &AuthState) -> MinimalAuthHint {
match auth {
AuthState::Authenticating { auth_url, .. } => MinimalAuthHint::SigningIn {
url: auth_url.clone(),
code: auth_url
.as_deref()
.and_then(device_user_code)
.map(str::to_owned),
},
AuthState::Pending { error: Some(err) } => MinimalAuthHint::Failed(err.clone()),
// Login is starting (auto-triggered at startup) — the URL arrives via
// AuthUrlReady, which flips us to `Authenticating`.
AuthState::Pending { error: None } => MinimalAuthHint::SigningIn {
url: None,
code: None,
},
AuthState::Done => MinimalAuthHint::Starting,
}
}
/// Parse the device-flow `user_code` from a verification URL (`None` if absent
/// or malformed). Mirrors `views::welcome::extract_user_code`, kept local so
/// minimal does not depend on welcome-screen internals.
fn device_user_code(url: &str) -> Option<&str> {
let code = url
.split('?')
.nth(1)?
.split('&')
.find_map(|kv| kv.strip_prefix("user_code="))?;
(!code.is_empty() && code.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
.then_some(code)
}
/// Write `line` at row `y` (when it fits) and return the next row.
fn put_line(buf: &mut Buffer, area: Rect, y: u16, bottom: u16, line: Line<'_>) -> u16 {
if y < bottom {
buf.set_line(area.x, y, &line, area.width);
y + 1
} else {
y
}
}
/// Write `url` character-by-character across as many rows as it needs (no
/// wrap-inserted spaces), so the terminal's native selection copies it verbatim
/// — minimal has no mouse capture, so copy is the terminal's job. Returns the
/// next free row.
fn render_url(
buf: &mut Buffer,
area: Rect,
start_y: u16,
bottom: u16,
url: &str,
style: Style,
) -> u16 {
let width = area.width.max(1);
// Snapshot the buffer bounds as values so the `&Rect` borrow doesn't outlive
// the mutable cell writes below.
let (max_x, max_y) = {
let a = buf.area();
(a.right(), a.bottom())
};
let mut col = 0u16;
let mut y = start_y;
for ch in url.chars() {
// Skip control chars to prevent terminal escape injection.
if ch.is_control() {
continue;
}
if col >= width {
col = 0;
y = y.saturating_add(1);
}
if y >= bottom {
return bottom;
}
let x = area.x + col;
if x < max_x && y < max_y {
buf[(x, y)].set_char(ch).set_style(style);
}
col += 1;
}
y.saturating_add(1)
}
/// Render the sign-in flow (or transient status) in the live region when no
/// agent exists yet. Top-aligned in `area`; clips to its height.
pub(super) fn render_auth(buf: &mut Buffer, area: Rect, theme: &Theme, hint: &MinimalAuthHint) {
if area.width == 0 || area.height == 0 {
return;
}
let bottom = area.y + area.height;
let mut y = area.y;
let gray = theme.muted().bg(Color::Reset);
let bold = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD)
.bg(Color::Reset);
match hint {
MinimalAuthHint::SigningIn { url, code } => {
y = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled("Sign in to Grok", bold)),
);
y = put_line(buf, area, y, bottom, Line::default());
match url {
Some(url) => {
y = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled(
"Open this URL in your browser to approve:",
gray,
)),
);
y = render_url(
buf,
area,
y,
bottom,
url,
Style::default().fg(theme.accent_user).bg(Color::Reset),
);
if let Some(code) = code {
y = put_line(buf, area, y, bottom, Line::default());
y = put_line(
buf,
area,
y,
bottom,
Line::from(vec![
Span::styled("Code: ", gray),
Span::styled(code.clone(), bold),
]),
);
}
y = put_line(buf, area, y, bottom, Line::default());
let _ = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled("Waiting for approval\u{2026}", gray)),
);
}
None => {
let _ = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled(
"Opening your browser to sign in\u{2026}",
gray,
)),
);
}
}
}
MinimalAuthHint::Failed(err) => {
let warn = Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD)
.bg(Color::Reset);
y = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled("Sign-in failed", warn)),
);
y = put_line(buf, area, y, bottom, Line::default());
let _ = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled(err.clone(), gray)),
);
}
MinimalAuthHint::Starting => {
let _ = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled(
"Signing in\u{2026} starting your session.",
gray,
)),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn device_user_code_parses_verification_url() {
assert_eq!(
device_user_code("https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH"),
Some("ABCD-EFGH")
);
assert_eq!(
device_user_code("https://accounts.x.ai/oauth2/device"),
None
);
assert_eq!(device_user_code("https://x/device?other=1"), None);
}
#[test]
fn auth_hint_maps_auth_state() {
use xai_grok_pager::app::app_view::AuthMode;
// Device flow → SigningIn carrying the URL and the parsed code.
let st = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: Some("https://accounts.x.ai/device?user_code=ABCD-EFGH".into()),
mode: AuthMode::Device,
};
match minimal_auth_hint(&st) {
MinimalAuthHint::SigningIn { url, code } => {
assert_eq!(
url.as_deref(),
Some("https://accounts.x.ai/device?user_code=ABCD-EFGH")
);
assert_eq!(code.as_deref(), Some("ABCD-EFGH"));
}
_ => panic!("expected SigningIn"),
}
// External command flow with no code → SigningIn, URL but no code.
let st = AuthState::Authenticating {
request_seq: 2,
handle: None,
auth_url: Some("https://provider.example/login".into()),
mode: AuthMode::Command,
};
match minimal_auth_hint(&st) {
MinimalAuthHint::SigningIn { url, code } => {
assert_eq!(url.as_deref(), Some("https://provider.example/login"));
assert!(code.is_none());
}
_ => panic!("expected SigningIn"),
}
assert!(matches!(
minimal_auth_hint(&AuthState::Done),
MinimalAuthHint::Starting
));
assert!(matches!(
minimal_auth_hint(&AuthState::Pending {
error: Some("nope".into())
}),
MinimalAuthHint::Failed(_)
));
}
#[test]
fn render_auth_shows_url_and_code() {
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 12);
let mut buf = Buffer::empty(area);
let hint = MinimalAuthHint::SigningIn {
url: Some("https://accounts.x.ai/device?user_code=ABCD-EFGH".into()),
code: Some("ABCD-EFGH".into()),
};
render_auth(&mut buf, area, &theme, &hint);
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
if let Some(c) = buf.cell((x, y)) {
text.push_str(c.symbol());
}
}
}
assert!(text.contains("Sign in to Grok"), "header: {text:?}");
assert!(text.contains("accounts.x.ai/device"), "url: {text:?}");
assert!(text.contains("ABCD-EFGH"), "device code: {text:?}");
assert!(
text.contains("Waiting for approval"),
"waiting line: {text:?}"
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,447 @@
//! Minimal-mode "full view": the complete conversation rendered with **every**
//! block fully expanded — reasoning shown in full (not the collapsed
//! `Thought for Xs` marker), tool output uncapped — as an ANSI string to open in
//! `$PAGER` (`less -R`).
//!
//! Minimal commits blocks into the terminal's *native* scrollback as static
//! text (collapsed reasoning, truncated tool output), which cannot be
//! re-rendered in place when the user toggles verbose. So "expand everything"
//! is served by re-rendering the whole transcript off-screen at full fidelity
//! and handing it to a pager (transcript mode). Reuses [`EntryRenderer`] (all
//! per-block layout, syntax highlighting, diff colors) with the display mode
//! forced to `Expanded`, then serializes the resulting cell buffer to ANSI so
//! colors survive in the pager.
use std::time::{Duration, Instant};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use xai_grok_pager::app::app_view::AppView;
use xai_grok_pager::minimal_api;
use xai_grok_pager::render::Renderable;
use xai_grok_pager::scrollback::entry::ScrollbackEntry;
use xai_grok_pager::scrollback::types::DisplayMode;
use xai_grok_pager::scrollback::wrappers::EntryRenderer;
use xai_grok_pager::theme::Theme;
/// Fixed render width for the transcript. A stable, readable column count
/// independent of the current terminal size (the pager wraps to the real
/// terminal, and committed-vs-full-view wrapping need not match).
const FULL_VIEW_WIDTH: u16 = 100;
/// Per-frame budget for the incremental transcript build. Small enough that a
/// slice never blocks input/streaming noticeably (frames tick at ~16ms while a
/// build is active — see `AppView::tick_interval_ceiling`), large enough to
/// drain a big session in a couple of seconds.
const PUMP_BUDGET: Duration = Duration::from_millis(8);
/// Advance the in-progress `/transcript` build by one time-budgeted slice.
/// Called once per frame from [`crate::draw`]; a no-op when no build is armed
/// (`minimal_api::request_minimal_transcript`).
///
/// Why sliced: the full-fidelity transcript is a layout + syntax-highlight +
/// ANSI-serialization pass over every block. Doing it in one shot froze the
/// event loop for seconds on long sessions, and it cannot move off-thread —
/// the block model is `!Send` (syntect's resumable highlighter lives inside
/// streaming-markdown blocks). Budgeted slices amortized across frames are how
/// other scrollback TUIs keep transcript-scale work off the critical path
/// (budgeted commit ticks / cell caches / throttled frame loops). On completion
/// the file is written and `pending_pager_path` armed; the event loop then
/// suspends into `$PAGER`.
pub fn pump_transcript(app: &mut AppView) {
let Some(mut build) = minimal_api::take_minimal_transcript(app) else {
return;
};
// Resolve against the build's OWNING agent, never the active view:
// `EntryId`s are per-`ScrollbackState` counters, so a session switch
// mid-build must not re-target the snapshot at another agent's scrollback
// (id collisions would stitch the transcript from the wrong session). The
// owner keeps existing across view switches, so the build also survives
// the user tabbing away — only a truly-removed agent drops it.
let id = build.agent;
let appearance = super::commit::committed_appearance(&app.appearance);
{
let Some(agent) = app.agents.get(&id) else {
tracing::warn!("minimal: transcript build's agent removed; dropping the build");
return;
};
let theme = Theme::current();
let sb = &agent.scrollback;
// Show every thinking entry THAT EXISTS in the session: this view is
// the advertised full-fidelity "expand everything" surface, and
// thinking entries render zero rows while the `[ui]`
// show_thinking_blocks toggle is off — they were silently omitted from
// the transcript (bugbot). The toggle is thread-local; restore it
// before returning to live rendering on this same thread.
//
// Scope caveat: with the setting off, the tracker drops reasoning at
// INGESTION (`handle_thought_chunk` returns before pushing — a
// deliberate, test-encoded memory tradeoff that predates minimal), so
// sessions run entirely with the toggle off have no thinking entries
// for any view to show. This override covers the sessions that do:
// toggle on at ingestion (the default), or toggled off mid-session.
let prev_thinking = xai_grok_pager::appearance::cache::load_show_thinking_blocks();
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
let start = Instant::now();
while build.next < build.ids.len() {
let eid = build.ids[build.next];
build.next += 1;
// Re-resolve by id: entries removed mid-build (rewind / clear)
// are skipped rather than skewing positions.
if let Some(entry) = sb.index_of_id(eid).and_then(|idx| sb.entry(idx)) {
render_entry_to_ansi(
entry,
&theme,
&appearance,
&agent.session.cwd,
&mut build.out,
);
}
if start.elapsed() >= PUMP_BUDGET {
break;
}
}
xai_grok_pager::appearance::cache::set_show_thinking_blocks(prev_thinking);
}
if build.next < build.ids.len() {
// More to do — resume next frame (ticks keep flowing via
// `needs_animation`; progress shows in the status row).
minimal_api::set_minimal_transcript(app, Some(build));
return;
}
// Done: hand the file to the event loop's suspend-into-$PAGER path.
finish_transcript(app, id, build.out);
}
/// Write the finished transcript and arm `pending_pager_path` (ANSI → the
/// event loop adds `-R` for `less`). Errors surface as a system block on the
/// build's owning agent (which may differ from the active view — the user can
/// tab away while the build runs).
fn finish_transcript(app: &mut AppView, id: xai_grok_pager::app::agent::AgentId, out: String) {
if out.is_empty() {
if let Some(agent) = app.agents.get_mut(&id) {
agent
.scrollback
.push_block(xai_grok_pager::scrollback::block::RenderBlock::system(
"No conversation transcript to view yet",
));
}
return;
}
let path = std::env::temp_dir().join(format!("grok-transcript-{}.ansi", uuid::Uuid::new_v4()));
match std::fs::write(&path, out) {
Ok(()) => {
app.pending_pager_path = Some(path);
app.pending_pager_ansi = true;
}
Err(e) => {
if let Some(agent) = app.agents.get_mut(&id) {
agent.scrollback.push_block(
xai_grok_pager::scrollback::block::RenderBlock::system(format!(
"Failed to write transcript: {e}"
)),
);
}
}
}
}
/// Render one entry (fully expanded, at [`FULL_VIEW_WIDTH`]) and append its
/// ANSI serialization to `out`. Cloning keeps the live entry's display mode —
/// which drives the on-screen committed look — untouched.
fn render_entry_to_ansi(
entry: &ScrollbackEntry,
theme: &Theme,
appearance: &xai_grok_pager::appearance::AppearanceConfig,
cwd: &std::path::Path,
out: &mut String,
) {
let mut expanded = entry.clone();
expanded.set_display_mode(DisplayMode::Expanded);
let renderer = EntryRenderer::new(&expanded, theme)
.with_appearance(appearance.clone())
.with_cwd(Some(cwd))
.with_flat_background(true);
let height = renderer.desired_height(FULL_VIEW_WIDTH);
if height == 0 {
return;
}
let area = Rect::new(0, 0, FULL_VIEW_WIDTH, height);
let mut buf = Buffer::empty(area);
renderer.render(area, &mut buf);
buffer_to_ansi(&buf, out);
// Blank line between blocks so the transcript breathes in the pager.
out.push('\n');
}
/// Serialize a rendered cell [`Buffer`] to ANSI text (one `\n`-terminated line
/// per row), emitting an SGR sequence whenever the style changes and resetting
/// at each row end. Trailing blank cells are trimmed so lines stay short.
fn buffer_to_ansi(buf: &Buffer, out: &mut String) {
let area = buf.area;
for y in area.y..area.y.saturating_add(area.height) {
// Last column carrying a visible glyph (trim trailing spaces).
let mut last: Option<u16> = None;
for x in (area.x..area.x.saturating_add(area.width)).rev() {
if let Some(cell) = buf.cell((x, y)) {
let s = cell.symbol();
if !s.is_empty() && s != " " {
last = Some(x);
break;
}
}
}
if let Some(last_x) = last {
// Track the current style as the raw (fg, bg, modifier) tuple and
// only build the escape string on a run boundary — comparing three
// Copy fields per cell is far cheaper than building + comparing an
// SGR string per cell (the previous hot spot on long transcripts).
let mut cur: Option<(Color, Color, Modifier)> = None;
let mut sgr = String::with_capacity(32);
for x in area.x..=last_x {
let Some(cell) = buf.cell((x, y)) else {
continue;
};
let sym = cell.symbol();
if sym.is_empty() {
// Continuation cell of a wide glyph — already emitted.
continue;
}
let style = (cell.fg, cell.bg, cell.modifier);
if cur != Some(style) {
cell_sgr(style.0, style.1, style.2, &mut sgr);
out.push_str(&sgr);
cur = Some(style);
}
out.push_str(sym);
}
out.push_str("\x1b[0m");
}
out.push('\n');
}
}
/// Build a full SGR sequence (leading reset, then modifiers + fg + bg) for a
/// cell's style. Emitted only when the style changes, so the reset can't leak
/// attributes across cells.
///
/// Writes into `sgr` (cleared first) instead of allocating: this runs once per
/// style *run*, which in syntax-highlighted code is nearly once per token —
/// the `Vec<String>` + `join` version dominated the serializer's profile on
/// long transcripts.
fn cell_sgr(fg: Color, bg: Color, modifier: Modifier, sgr: &mut String) {
use std::fmt::Write as _;
sgr.clear();
sgr.push_str("\x1b[0");
if modifier.contains(Modifier::BOLD) {
sgr.push_str(";1");
}
if modifier.contains(Modifier::DIM) {
sgr.push_str(";2");
}
if modifier.contains(Modifier::ITALIC) {
sgr.push_str(";3");
}
if modifier.contains(Modifier::UNDERLINED) {
sgr.push_str(";4");
}
if modifier.contains(Modifier::REVERSED) {
sgr.push_str(";7");
}
if modifier.contains(Modifier::CROSSED_OUT) {
sgr.push_str(";9");
}
sgr.push(';');
let _ = write!(sgr, "{}", color_code(fg, false));
sgr.push(';');
let _ = write!(sgr, "{}", color_code(bg, true));
sgr.push('m');
}
/// Map a ratatui [`Color`] to its SGR parameter (foreground, or background when
/// `bg`). Named colors use the 16-color codes; `Indexed`/`Rgb` use the 256 /
/// truecolor forms. `Reset` is the terminal default (39 fg / 49 bg).
fn color_code(color: Color, bg: bool) -> String {
// Named-color base code (30-series fg); +10 shifts to the 40-series bg.
let named = |n: u16| -> String { (if bg { n + 10 } else { n }).to_string() };
match color {
Color::Reset => named(39),
Color::Black => named(30),
Color::Red => named(31),
Color::Green => named(32),
Color::Yellow => named(33),
Color::Blue => named(34),
Color::Magenta => named(35),
Color::Cyan => named(36),
Color::Gray => named(37),
Color::DarkGray => named(90),
Color::LightRed => named(91),
Color::LightGreen => named(92),
Color::LightYellow => named(93),
Color::LightBlue => named(94),
Color::LightMagenta => named(95),
Color::LightCyan => named(96),
Color::White => named(97),
Color::Indexed(i) => format!("{};5;{}", if bg { 48 } else { 38 }, i),
Color::Rgb(r, g, b) => format!("{};2;{};{};{}", if bg { 48 } else { 38 }, r, g, b),
}
}
#[cfg(test)]
mod tests {
use super::*;
use xai_grok_pager::scrollback::block::RenderBlock;
fn test_cwd() -> &'static std::path::Path {
std::path::Path::new("/test/session")
}
/// Bugbot "Transcript omits thinking blocks": with the `[ui]`
/// show_thinking_blocks toggle off, a thinking entry renders zero rows and
/// vanished from the "full-fidelity" transcript. The pump enables the
/// (thread-local) toggle for the build; this locks the mechanism: off →
/// omitted, on (what `pump_transcript` sets) → included.
#[test]
fn transcript_includes_thinking_when_pump_enables_toggle() {
let theme = Theme::current();
let appearance = super::super::commit::committed_appearance(
&xai_grok_pager::appearance::AppearanceConfig::default(),
);
let entry = ScrollbackEntry::new(RenderBlock::thinking(
"deep reasoning about haikus and syllables",
));
xai_grok_pager::appearance::cache::set_show_thinking_blocks(false);
let mut out = String::new();
render_entry_to_ansi(&entry, &theme, &appearance, test_cwd(), &mut out);
assert!(
out.is_empty(),
"thinking hidden while the toggle is off: {out:?}"
);
// What `pump_transcript` sets for the duration of a slice.
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
let mut out = String::new();
render_entry_to_ansi(&entry, &theme, &appearance, test_cwd(), &mut out);
xai_grok_pager::appearance::cache::set_show_thinking_blocks(false);
assert!(
out.contains("reasoning"),
"thinking content included in the transcript: {out:?}"
);
}
/// A thinking entry built the way live streaming builds it (streaming
/// block + per-chunk pushes + finish) must render its BODY in the
/// transcript, not just the collapsed "Thought for Xs" header.
#[test]
fn transcript_expands_streamed_thinking_body() {
use xai_grok_pager::scrollback::state::ScrollbackState;
let theme = Theme::current();
let appearance = super::super::commit::committed_appearance(
&xai_grok_pager::appearance::AppearanceConfig::default(),
);
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let id = sb.push_block(RenderBlock::thinking_streaming());
assert!(sb.push_chunk_to_thinking(id, "REASONINGBODY pondering "));
sb.push_chunk_to_thinking(id, "quietly about wraps");
sb.finish_running_with_time(id, Some(1200));
let entry = sb.get_by_id(id).expect("thinking entry");
let mut out = String::new();
render_entry_to_ansi(entry, &theme, &appearance, test_cwd(), &mut out);
xai_grok_pager::appearance::cache::set_show_thinking_blocks(false);
assert!(
out.contains("REASONINGBODY"),
"transcript must include the streamed thinking body: {out:?}"
);
}
#[test]
fn transcript_uses_owning_session_cwd_for_tool_paths() {
let theme = Theme::current();
let appearance = super::super::commit::committed_appearance(
&xai_grok_pager::appearance::AppearanceConfig::default(),
);
let entry =
ScrollbackEntry::new(RenderBlock::edit("/alternate/worktree/src/main.rs", None));
let mut out = String::new();
render_entry_to_ansi(
&entry,
&theme,
&appearance,
std::path::Path::new("/alternate/worktree"),
&mut out,
);
assert!(out.contains("src/main.rs"), "transcript: {out:?}");
assert!(
!out.contains("/alternate/worktree"),
"session prefix should be elided: {out:?}"
);
}
#[test]
fn color_code_maps_reset_named_indexed_rgb() {
assert_eq!(color_code(Color::Reset, false), "39");
assert_eq!(color_code(Color::Reset, true), "49");
assert_eq!(color_code(Color::Red, false), "31");
assert_eq!(color_code(Color::Red, true), "41");
assert_eq!(color_code(Color::DarkGray, false), "90");
assert_eq!(color_code(Color::DarkGray, true), "100");
assert_eq!(color_code(Color::Indexed(200), false), "38;5;200");
assert_eq!(color_code(Color::Rgb(1, 2, 3), true), "48;2;1;2;3");
}
#[test]
fn cell_sgr_includes_modifiers_and_colors() {
let mut sgr = String::new();
cell_sgr(
Color::Rgb(10, 20, 30),
Color::Reset,
Modifier::BOLD | Modifier::ITALIC,
&mut sgr,
);
// Leading reset, bold, italic, truecolor fg, default bg.
assert_eq!(sgr, "\x1b[0;1;3;38;2;10;20;30;49m");
// Reused buffer is cleared, not appended.
cell_sgr(Color::Red, Color::Reset, Modifier::empty(), &mut sgr);
assert_eq!(sgr, "\x1b[0;31;49m");
}
#[test]
fn buffer_to_ansi_trims_trailing_and_terminates_rows() {
// A 6-wide, 2-row buffer: "hi" on row 0, blank row 1.
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 2));
buf.cell_mut((0, 0)).unwrap().set_symbol("h");
buf.cell_mut((1, 0)).unwrap().set_symbol("i");
let mut out = String::new();
buffer_to_ansi(&buf, &mut out);
let lines: Vec<&str> = out.split('\n').collect();
// Row 0 has content ending in a reset; row 1 is blank; trailing newline.
assert!(lines[0].contains('h') && lines[0].contains('i'));
assert!(
lines[0].ends_with("\x1b[0m"),
"row must reset: {:?}",
lines[0]
);
assert_eq!(lines[1], "", "blank row emits nothing but the newline");
// No trailing spaces before the reset.
assert!(
!lines[0].contains(" "),
"trailing spaces not trimmed: {:?}",
lines[0]
);
}
}

View file

@ -0,0 +1,45 @@
//! Compile-time guard for minimal mode's resize strategy (design K6 / risk #2).
//!
//! The terminal owns committed history, so minimal must use only the built-in
//! `autoresize` / `set_viewport_height` and must NEVER call the inline crate's
//! RIS-rerender helpers or `emit_to_scrollback` — those re-emit history the
//! terminal already has, double-printing (or, with ED3, wiping) committed
//! scrollback. This test fails loudly if such a call ever sneaks into the
//! minimal module.
/// The forbidden inline-crate helpers. Scanned against the minimal sources via
/// `include_str!` (this guard file is intentionally not scanned, since it names
/// the identifiers here).
#[test]
fn minimal_never_uses_ris_rerender_or_emit_to_scrollback() {
const FORBIDDEN: &[&str] = &[
"resize_purge_rerender",
"emit_to_scrollback",
"resize_viewport_height",
];
// EVERY module of this crate except this guard file (which names the
// forbidden identifiers). Keep in sync with `lib.rs`'s module list — a
// module missing here is a hole in the K6 guard.
let sources = [
("lib.rs", include_str!("lib.rs")),
("auth.rs", include_str!("auth.rs")),
("commit.rs", include_str!("commit.rs")),
("full_view.rs", include_str!("full_view.rs")),
("live.rs", include_str!("live.rs")),
("overlay.rs", include_str!("overlay.rs")),
("panel.rs", include_str!("panel.rs")),
("plan.rs", include_str!("plan.rs")),
("todo.rs", include_str!("todo.rs")),
("welcome.rs", include_str!("welcome.rs")),
];
for (name, src) in sources {
for needle in FORBIDDEN {
assert!(
!src.contains(needle),
"minimal/{name} references forbidden resize helper `{needle}` — it would \
double-print committed scrollback (design K6 / risk #2); use the built-in \
autoresize / set_viewport_height instead"
);
}
}
}

View file

@ -0,0 +1,118 @@
//! Minimal (scrollback-native) render mode — `grok --minimal`.
//!
//! In this mode finalized conversation blocks are printed once into the
//! terminal's *native* scrollback (via `xai_ratatui_inline::Terminal::insert_before`,
//! reusing `EntryRenderer`) while a small pinned live region holds the
//! running-turn status, the prompt, and a minimal status line. The interactive
//! `ScrollbackPane` (scroll, fold, selection, mouse) is not used; the terminal
//! owns history.
//!
//! - [`commit`] — committed-frontier logic, display policy, and the per-frame
//! commit-to-scrollback pass.
//! - [`live`] — the pinned live region (tail + 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 /
//! shrinks the live viewport).
//!
//! # Wiring
//!
//! `xai-grok-pager` (the lib) does **not** depend on this crate — that would be
//! a cargo dependency cycle, since this crate reads deeply into the pager's
//! [`AppView`] / view model. Instead the pager exposes an inversion-of-control
//! seam ([`xai_grok_pager::minimal_hook`]) of function pointers, and the
//! composition-root binary (`xai-grok-pager-bin`) calls [`install`] once at
//! startup to register this crate's [`draw`] entry point. When the seam is not
//! installed the pager's minimal-mode branches are inert.
pub mod auth;
pub mod commit;
pub mod full_view;
pub mod live;
pub mod overlay;
pub mod panel;
pub mod plan;
pub mod todo;
pub mod welcome;
#[cfg(test)]
mod guard;
use crossterm::QueueableCommand;
use crossterm::terminal::BeginSynchronizedUpdate;
use xai_grok_pager::app::PagerTerminal;
use xai_grok_pager::app::app_view::AppView;
/// Per-frame entry point for minimal mode, called from [`AppView::draw`].
///
/// Order matters:
/// 0. Open a synchronized update and adopt the current terminal size (see
/// below), so every write this frame — commits *and* the live region —
/// presents atomically at the right dimensions.
/// 1. Commit the pending welcome card (fresh session / `/new`) so it lands
/// above the first conversation block, and push any ready plan into
/// scrollback (`plan::maybe_commit_plan`) so it commits like a normal block
/// this frame — the live region then holds only the plan's decision controls.
/// 2. Size the viewport to its **post-commit** height (see
/// [`overlay::sync_viewport`] / [`live::tail_height`]). This runs *before* the
/// commit so that step 3's `insert_before` prints each finalized block and
/// repositions the correctly-sized viewport to sit directly after it
/// (content-anchored — the prompt follows the content, and once the screen is
/// full that position is the bottom). Otherwise the viewport was still at its
/// tall streaming height when the block committed, and the following shrink
/// stranded the prompt at the top of the screen ("input snaps to the top").
/// 3. Commit finalized blocks into native scrollback (each `insert_before`
/// scrolls committed rows up above the pinned viewport), then re-print any
/// `Ctrl+E` / `/expand` re-prints fully expanded below.
/// 4. Redraw the live region (tail · status · overlay · prompt) into the
/// viewport's final position.
///
/// ## Why step 0 exists (resize + flicker)
///
/// **Resize:** `draw_frame` runs `terminal.autoresize()` — but that is the
/// *last* step of this function, while the commit passes read
/// `viewport_area().width` first. On the frame that processes a terminal
/// resize, a block finalizing in that same frame would be laid out and printed
/// at the *stale* width; a shrink then hard-wraps every over-wide row on the
/// real terminal, permanently garbling the print-once committed copy. Adopting
/// the new size up front closes that window (a no-op on non-resize frames).
///
/// **Flicker:** the commit `insert_before`s scroll + repaint the screen and
/// flush per chunk. Without a synchronized update around them, a multi-block
/// commit (thinking + tool + message finalizing together) presents as several
/// visible scroll/paint bursts before the live region repaints. Opening the
/// synchronized update *before* the commits batches the whole frame — commits,
/// viewport reposition, and live redraw — into one atomic present. The
/// matching `EndSynchronizedUpdate` is emitted by `draw_frame` (step 4), which
/// every path through this function reaches; its own inner
/// `BeginSynchronizedUpdate` is redundant-but-harmless (DEC 2026 is a mode,
/// not a counter — the first End closes it).
pub fn draw(app: &mut AppView, terminal: &mut PagerTerminal) {
let _ = terminal.backend_mut().queue(BeginSynchronizedUpdate);
let _ = terminal.autoresize();
// Pending permission/question marks are synced ONCE, up front, so the
// viewport sizing (`sync_viewport` / `tail_height` / `will_commit`) and the
// commit pass judge committability against the same state (see
// `commit::sync_pending_marks`).
commit::sync_pending_marks(app);
// Advance any in-progress /transcript build by one time-budgeted slice
// (arms `pending_pager_path` when done; see `full_view::pump_transcript`).
full_view::pump_transcript(app);
welcome::maybe_commit_welcome(app, terminal);
plan::maybe_commit_plan(app);
overlay::sync_viewport(app, terminal);
commit::commit_active(app, terminal);
commit::expand_pending(app, terminal);
live::draw_live(app, terminal);
}
/// Register the minimal-mode render hooks with `xai-grok-pager`.
///
/// Call this exactly once, early in the binary's `main`, before any frame is
/// drawn. It installs the function-pointer seam so the pager's
/// `ScreenMode::Minimal` branches dispatch into this crate. Idempotent:
/// subsequent calls are ignored (see [`xai_grok_pager::minimal_hook`]).
pub fn install() {
xai_grok_pager::minimal_hook::install(xai_grok_pager::minimal_hook::MinimalHooks { draw });
}

View file

@ -0,0 +1,928 @@
//! 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.
//!
//! 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.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Widget};
use xai_grok_pager::app::PagerTerminal;
use xai_grok_pager::app::app_view::{ActiveView, AppView};
use xai_grok_pager::minimal_api;
use xai_grok_pager::render::Renderable;
use xai_grok_pager::scrollback::state::ScrollbackState;
use xai_grok_pager::scrollback::wrappers::EntryRenderer;
use xai_grok_pager::theme::Theme;
use xai_grok_pager::views::prompt_widget::PromptStyle;
use xai_grok_pager::views::turn_status;
/// Left inset (columns) for every auxiliary live-region row: the status row,
/// the info bar, the exit hint, and the todo panel — and the prompt's
/// `chrome_pad_left`.
///
/// Minimal is flush-left: committed/tail blocks zero block pads via
/// [`super::commit::committed_appearance`] and reclaim the accent column via
/// `hide_accent`, so content glyphs (`◆` / `$` / message text) start at column
/// 0, matching the welcome card's outer edge. The prompt and auxiliary rows
/// share that left edge (no chrome pad) so nothing sits ragged against the
/// welcome box.
pub(super) fn live_left_inset(_appearance: &xai_grok_pager::appearance::AppearanceConfig) -> u16 {
0
}
/// Shrink `area` from the left by `inset` columns (clamped to the width).
fn inset_left(area: Rect, inset: u16) -> Rect {
let dx = inset.min(area.width);
Rect {
x: area.x + dx,
width: area.width - dx,
..area
}
}
/// The prompt style used by the minimal live region.
///
/// Shared with [`super::overlay::sync_viewport`] so viewport sizing measures the
/// prompt's height exactly as the live region will draw it.
pub(super) fn prompt_style(
appearance: &xai_grok_pager::appearance::AppearanceConfig,
) -> PromptStyle {
PromptStyle {
focused: true,
show_prefix: appearance.prompt.show_prefix,
vpad_top: 0,
compact: appearance.prompt.compact,
chrome: true,
chrome_pad_left: live_left_inset(appearance),
chrome_pad_right: 0,
bg_override: Some(Color::Reset),
accent_color_override: None,
border_color_override: None,
prefix_override: None,
placeholder_override: None,
show_accent_line: false,
show_borders: false,
title: None,
image_preview: true,
}
}
/// Draw the pinned live region (tail + status + prompt) into the inline viewport.
pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) {
let force_todos = minimal_api::minimal_show_todos(app);
let auth_hint = crate::auth::minimal_auth_hint(&app.auth_state);
let pending_hint = minimal_pending_hint(&app.pending_action);
let transcript_hint = if minimal_api::minimal_ctrl_o_opens_transcript(app) {
"ctrl+o transcript"
} else {
"/transcript"
};
let transcript_progress = minimal_api::minimal_transcript_progress(app);
let AppView {
cursor,
agents,
active_view,
appearance,
..
} = app;
let agent_id = match active_view {
ActiveView::Agent(id) => Some(*id),
_ => None,
};
let theme = Theme::current();
let commit_app = super::commit::committed_appearance(appearance);
let compact = appearance.prompt.compact;
let style = prompt_style(appearance);
let row_inset = live_left_inset(appearance);
let layout_cfg = &appearance.scrollback.layout;
let term_h = terminal.last_known_area().height;
xai_grok_pager::render::draw::draw_frame(terminal, cursor, |frame, _link_spans| {
let area = frame.area();
if area.height == 0 || area.width < 4 {
return (None, None);
}
Clear.render(area, frame.buffer_mut());
let agent = agent_id.and_then(|id| agents.get_mut(&id));
let Some(agent) = agent else {
crate::auth::render_auth(frame.buffer_mut(), area, &theme, &auth_hint);
return (None, None);
};
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();
if let Some(kind) = super::panel::active(agent) {
let cursor = super::panel::render(frame.buffer_mut(), area, agent, kind, &theme);
return (cursor, None);
}
if super::overlay::app_modal_active(agent) {
super::overlay::render_app_modal(frame.buffer_mut(), area, agent, compact);
return (None, None);
}
if minimal_api::extensions_modal(agent).is_some() {
let tick = (now_millis() / 100) as u64;
if let Some(state) = minimal_api::extensions_modal_mut(agent) {
xai_grok_pager::views::extensions_modal::render_extensions_modal(
frame.buffer_mut(),
area,
state,
None,
compact,
tick,
);
}
return (None, None);
}
if let Some(modal) = super::overlay::active_modal(agent) {
let status_h = 1u16.min(area.height);
let content_w = area.width as usize;
let modal_h = super::overlay::modal_height(modal, agent, term_h, content_w)
.min(area.height.saturating_sub(status_h))
.max(1);
let tail_h = area.height.saturating_sub(status_h + modal_h);
let tick = (now_millis() / 100) as u64;
if tail_h > 0 {
let turn_running = agent.session.state.is_turn_running();
draw_tail(
frame.buffer_mut(),
Rect {
x: area.x,
y: area.y,
width: area.width,
height: tail_h,
},
&agent.scrollback,
turn_running,
&theme,
&commit_app,
&agent.session.cwd,
tick,
);
}
render_minimal_status(
frame.buffer_mut(),
inset_left(
Rect {
x: area.x,
y: area.y + tail_h,
width: area.width,
height: status_h,
},
row_inset,
),
agent,
&status_activity,
transcript_progress,
&theme,
);
let modal_area = Rect {
x: area.x,
y: area.y + tail_h + status_h,
width: area.width,
height: modal_h,
};
let cursor = super::overlay::render_modal(
frame.buffer_mut(),
modal_area,
modal,
agent,
&theme,
term_h,
);
return (cursor, None);
}
let status_h = 1u16.min(area.height);
let overlay_h = super::overlay::overlay_rows(&agent.prompt, area.width)
.min(area.height.saturating_sub(status_h + 1));
let info_h = if overlay_h == 0 {
1u16.min(area.height.saturating_sub(status_h + 1))
} else {
0
};
let below_h = overlay_h + info_h;
let avail = area.height.saturating_sub(status_h + below_h);
let prompt_h = agent
.prompt
.desired_height(area.width, &style, false, avail)
.min(avail)
.max(1);
let rest = avail.saturating_sub(prompt_h);
let todos_cap = if force_todos {
rest
} else {
rest.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;
if tail_h > 0 {
let tail_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: tail_h,
};
let turn_running = agent.session.state.is_turn_running();
draw_tail(
frame.buffer_mut(),
tail_area,
&agent.scrollback,
turn_running,
&theme,
&commit_app,
&agent.session.cwd,
tick,
);
}
if todos_h > 0 {
crate::todo::render_todo_panel(
frame.buffer_mut(),
inset_left(
Rect {
x: area.x,
y: area.y + tail_h,
width: area.width,
height: todos_h,
},
row_inset,
),
&theme,
&todo_lines,
);
}
let status_area = inset_left(
Rect {
x: area.x,
y: area.y + tail_h + todos_h,
width: area.width,
height: status_h,
},
row_inset,
);
render_minimal_status(
frame.buffer_mut(),
status_area,
agent,
&status_activity,
transcript_progress,
&theme,
);
let prompt_area = Rect {
x: area.x,
y: area.y + tail_h + todos_h + status_h,
width: area.width,
height: prompt_h,
};
if overlay_h > 0 {
super::overlay::render(
frame.buffer_mut(),
area,
prompt_area,
&mut agent.prompt,
layout_cfg,
compact,
&theme,
);
} else if info_h > 0 {
let info_area = inset_left(
Rect {
x: area.x,
y: prompt_area.y + prompt_h,
width: area.width,
height: info_h,
},
row_inset,
);
if let Some(hint) = &pending_hint {
render_exit_hint(frame.buffer_mut(), info_area, &theme, hint);
} else {
render_prompt_info(
frame.buffer_mut(),
info_area,
agent,
queued,
transcript_hint,
&theme,
);
}
}
let result = agent
.prompt
.draw(frame.buffer_mut(), prompt_area, None, &style, None, None);
(
result.cursor_pos,
result
.post_flush_escapes
.map(xai_grok_pager::terminal::overlay::PostFlush::from),
)
});
}
fn live_tail_renderer<'a>(
entry: &'a xai_grok_pager::scrollback::entry::ScrollbackEntry,
theme: &'a Theme,
appearance: &xai_grok_pager::appearance::AppearanceConfig,
cwd: &'a std::path::Path,
tick: u64,
) -> EntryRenderer<'a> {
EntryRenderer::new(entry, theme)
.with_appearance(appearance.clone())
.with_cwd(Some(cwd))
.with_tick(tick)
.with_flat_background(true)
.with_hide_accent(true)
}
/// Render the uncommitted tail (entries past the commit frontier), bottom-anchored
/// so the most recent output is always visible; the topmost visible entry is
/// clipped via `with_skip_rows` when the run is taller than the tail area.
///
/// Starts at the shared [`super::commit::scan_frontier`] stop point so it renders
/// exactly the entries [`tail_height`] measured (the viewport was sized to that —
/// any disagreement makes the prompt jump on commit).
#[allow(clippy::too_many_arguments)]
fn draw_tail(
buf: &mut Buffer,
area: Rect,
sb: &ScrollbackState,
turn_running: bool,
theme: &Theme,
appearance: &xai_grok_pager::appearance::AppearanceConfig,
cwd: &std::path::Path,
tick: u64,
) {
if area.height == 0 {
return;
}
let width = area.width;
let renderer = |e| live_tail_renderer(e, theme, appearance, cwd, tick);
let mut entries = Vec::new();
let mut i = super::commit::scan_frontier(sb, turn_running).tail_start;
while let Some(e) = sb.get(i) {
entries.push(e);
i += 1;
}
if entries.is_empty() {
return;
}
let gap = super::commit::MINIMAL_BLOCK_GAP;
let heights: Vec<u16> = entries
.iter()
.map(|e| renderer(*e).desired_height(width))
.collect();
let total: u16 = heights
.iter()
.fold(0u16, |acc, &h| acc.saturating_add(h).saturating_add(gap));
let mut skip_top = total.saturating_sub(area.height);
let mut y = area.y;
let bottom = area.y + area.height;
for (e, &content_h) in entries.iter().zip(&heights) {
let slot_h = content_h.saturating_add(gap);
if skip_top >= slot_h {
skip_top -= slot_h;
continue;
}
let slot_skip = skip_top;
skip_top = 0;
let entry_skip = slot_skip.min(content_h);
let visible_content = content_h.saturating_sub(entry_skip);
if visible_content > 0 {
let draw_h = visible_content.min(bottom.saturating_sub(y));
if draw_h == 0 {
break;
}
let rect = Rect {
x: area.x,
y,
width,
height: draw_h,
};
renderer(*e).with_skip_rows(entry_skip).render(rect, buf);
y += draw_h;
if y >= bottom {
break;
}
}
let gap_skipped = slot_skip.saturating_sub(entry_skip);
let gap_visible = gap
.saturating_sub(gap_skipped)
.min(bottom.saturating_sub(y));
y += gap_visible;
if y >= bottom {
break;
}
}
}
/// 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
/// draw path, so it must drive the same logic or the phase timer would never
/// reset. Returns the resolved activity for [`render_minimal_status`].
fn minimal_advance_phase_timer(
agent: &mut xai_grok_pager::app::agent_view::AgentView,
) -> Option<xai_grok_pager::acp::tracker::TurnActivity> {
let activity = minimal_api::resolve_turn_activity(agent);
if activity.as_ref() != minimal_api::last_activity(agent) {
agent.activity_started_at = Some(std::time::Instant::now());
minimal_api::set_last_activity(agent, activity.clone());
}
activity
}
/// Render the one-line minimal status indicator above the prompt.
///
/// 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
/// watchers) a small `minimal · /help` hint is shown instead.
fn render_minimal_status(
buf: &mut Buffer,
area: Rect,
agent: &xai_grok_pager::app::agent_view::AgentView,
activity: &Option<xai_grok_pager::acp::tracker::TurnActivity>,
transcript_progress: Option<(usize, usize)>,
theme: &Theme,
) {
if area.height == 0 || area.width == 0 {
return;
}
if let Some((done, total)) = transcript_progress {
let style = theme.primary().bg(Color::Reset);
buf.set_style(area, style);
buf.set_span(
area.x,
area.y,
&Span::styled(format!("rendering transcript… {done}/{total}"), style),
area.width,
);
return;
}
let watchers = minimal_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,
)
{
render_idle_hint(buf, area, theme);
return;
}
let is_pending_user_input =
!agent.permission_queue.is_empty() || minimal_api::question_view(agent).is_some();
let goal_verifying = agent
.goal_state
.as_ref()
.is_some_and(|g| g.verifying_completion);
turn_status::render_turn_status(
buf,
area,
&agent.session.state,
activity,
agent.turn_elapsed(),
agent.activity_started_at,
agent.scrollback.animation_tick(),
drain_blocked,
None,
false,
agent.context_state.as_ref().map(|c| c.used),
minimal_api::mcp_init_progress(agent),
agent.bash_turn,
is_pending_user_input,
goal_verifying,
watchers,
true,
minimal_api::held_queue_count(agent),
minimal_api::held_queue_top_sendable(agent),
);
}
/// Idle status: `minimal · [/fullscreen to go back ·] /help` (+ auto-set note).
fn render_idle_hint(buf: &mut Buffer, area: Rect, theme: &Theme) {
let style = theme.dim().bg(Color::Reset);
buf.set_style(area, style);
let auto = xai_grok_pager::app::minimal_auto_set_for_mouse_leak();
let switch_back = xai_grok_pager::app::minimal_show_switch_back_to_fullscreen();
let hint = match (auto, switch_back) {
(true, true) => {
"minimal · auto-set on JetBrains/Windows due to JetBrains mouse reporting issues \
· /fullscreen to go back · /help"
}
(true, false) => {
"minimal · auto-set on JetBrains/Windows due to JetBrains mouse reporting issues · /help"
}
(false, true) => "minimal · /fullscreen to go back · /help",
(false, false) => "minimal · /help",
};
buf.set_span(area.x, area.y, &Span::styled(hint, style), area.width);
}
/// Render the one-line info bar directly below the prompt: the selected model,
/// the active session mode (the Shift+Tab cycle: plan / always-approve / auto),
/// context usage (absolute + percentage), an `N queued` count when prompts
/// are waiting behind a running turn, and the full-transcript shortcut hint
/// (`transcript_hint`: "ctrl+o transcript", or "/transcript" where Ctrl+O is
/// the interject chord — Apple Terminal). Mirrors the regular TUI's model
/// label, mode flags, and context bar; the transcript hint stands in for the
/// full TUI's shortcuts bar, which minimal never renders — without it the
/// folded conversation has no visible way back to the full view. The mode flag
/// keeps its accent color so the Shift+Tab cycle — otherwise invisible in
/// minimal mode — is always shown. Drawn only when no menu/dropdown owns the
/// band below the prompt (the caller gates on that). The elapsed-time / token
/// count lives in the turn-status row above the prompt (see
/// [`render_minimal_status`]), so it is not repeated here.
fn render_prompt_info(
buf: &mut Buffer,
area: Rect,
agent: &xai_grok_pager::app::agent_view::AgentView,
queued: usize,
transcript_hint: &str,
theme: &Theme,
) {
use xai_grok_pager::views::context_bar::fmt_tokens;
let base = theme.primary().bg(Color::Reset);
let sep = theme.dim().bg(Color::Reset);
let mut segs: Vec<(String, Style)> = Vec::new();
if let Some(model) = agent.session.models.current_model_name() {
let label = match agent.session.models.reasoning_effort {
Some(eff) => format!("{model} ({eff})"),
None => model,
};
segs.push((label, base));
}
let effective_plan =
minimal_api::plan_mode_pending(agent).unwrap_or(minimal_api::plan_mode_active(agent));
let mode_flag: Option<(&str, Color)> = if effective_plan {
Some(("plan", theme.accent_plan))
} else if agent.session.is_yolo() {
Some(("always-approve", theme.warning))
} else if agent.session.is_auto() {
Some(("auto", theme.accent_system))
} else {
None
};
if let Some((label, color)) = mode_flag {
segs.push((label.to_string(), base.fg(color)));
}
let used = agent.context_state.as_ref().map(|c| c.used);
let total = agent
.context_state
.as_ref()
.and_then(|c| (c.total > 0).then_some(c.total))
.or_else(|| agent.session.models.get_context_window());
if let (Some(used), Some(total)) = (used, total)
&& total > 0
{
let pct = xai_token_estimation::usage_percentage(used, total);
segs.push((
format!("{} / {} ({:.0}%)", fmt_tokens(used), fmt_tokens(total), pct),
base,
));
}
if queued > 0 {
segs.push((format!("{queued} queued"), base));
segs.push(("/queue".to_string(), base));
}
segs.push((transcript_hint.to_string(), base));
if segs.is_empty() {
return;
}
buf.set_style(area, base);
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (text, style)) in segs.into_iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" · ", sep));
}
spans.push(Span::styled(text, style));
}
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
}
/// The double-press confirmation hint to show under the prompt (e.g. "press
/// Ctrl+q again to quit"), or `None` when nothing is armed / it has expired or
/// is a silent arm (no label). Mirrors the full-TUI shortcuts-bar `PendingHint`,
/// which minimal does not render.
fn minimal_pending_hint(
pending: &Option<xai_grok_pager::app::app_view::PendingAction>,
) -> Option<String> {
let pending = pending.as_ref()?;
if pending.expired() {
return None;
}
let label = pending.label?;
Some(format!(
"press {} again to {label}",
pending.shortcut.display()
))
}
/// Render the one-line double-press confirmation hint under the prompt, in the
/// warning color so it stands out from the model/context info row.
fn render_exit_hint(buf: &mut Buffer, area: Rect, theme: &Theme, hint: &str) {
let style = Style::default().fg(theme.warning).bg(Color::Reset);
buf.set_style(area, style);
buf.set_span(
area.x,
area.y,
&Span::styled(hint.to_string(), style),
area.width,
);
}
/// Height (rows) of the tail that will REMAIN after this frame's commit pass —
/// i.e. the entries `commit_active` will NOT consume, from the first
/// non-committable entry (past the scan cursor) onward.
///
/// The overlay host sizes the live viewport to this *post-commit* tail so the
/// prompt sits right after the streaming output (no fixed gap while a turn is
/// "thinking" with nothing streamed yet). Sizing to the post-commit tail
/// (rather than the current tail) is load-bearing: because `sync_viewport` runs
/// just *before* `commit_active`, the viewport is already at its post-commit
/// height when the commit's `insert_before` prints finalized blocks — so it can
/// reposition the correctly-sized viewport to sit directly after them
/// (content-anchored). Sizing to the tall streaming tail instead left the
/// viewport oversized at commit time, and the following collapse stranded the
/// prompt at the top of the screen (the "snaps to top" bug).
pub(super) fn tail_height(
agent: &xai_grok_pager::app::agent_view::AgentView,
width: u16,
appearance: &xai_grok_pager::appearance::AppearanceConfig,
) -> u16 {
let theme = Theme::current();
let sb = &agent.scrollback;
let turn_running = agent.session.state.is_turn_running();
let gap = super::commit::MINIMAL_BLOCK_GAP;
let mut i = super::commit::scan_frontier(sb, turn_running).tail_start;
let mut total = 0u16;
while let Some(e) = sb.get(i) {
let h =
live_tail_renderer(e, &theme, appearance, &agent.session.cwd, 0).desired_height(width);
total = total.saturating_add(h).saturating_add(gap);
i += 1;
}
total
}
fn now_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn agent() -> xai_grok_pager::app::agent_view::AgentView {
minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"))
}
#[test]
fn tail_height_uses_owning_session_cwd_for_tool_paths() {
use xai_grok_pager::app::agent::AgentState;
use xai_grok_pager::scrollback::RenderBlock;
use xai_grok_pager::scrollback::entry::ScrollbackEntry;
use xai_grok_pager::scrollback::types::DisplayMode;
let cwd = std::path::PathBuf::from("/alternate/worktree");
let mut agent = minimal_api::test_agent_view(Some("s1"), cwd.clone());
agent.session.state = AgentState::TurnRunning;
let mut entry = ScrollbackEntry::running(RenderBlock::edit(
"/alternate/worktree/src/components/really_long_file_name.rs",
None,
));
entry.set_display_mode(DisplayMode::Expanded);
agent.scrollback.push(entry);
let appearance = super::super::commit::committed_appearance(
&xai_grok_pager::appearance::AppearanceConfig::default(),
);
let theme = Theme::current();
let entry = agent.scrollback.get(0).unwrap();
let (width, painted_height, visible_accent_height) = (10..=40)
.find_map(|width| {
let painted =
live_tail_renderer(entry, &theme, &appearance, &cwd, 0).desired_height(width);
let visible_accent = EntryRenderer::new(entry, &theme)
.with_appearance(appearance.clone())
.with_cwd(Some(&cwd))
.with_tick(0)
.with_flat_background(true)
.desired_height(width);
(painted != visible_accent).then_some((width, painted, visible_accent))
})
.expect("fixture must wrap differently when the accent column is reclaimed");
assert_ne!(painted_height, visible_accent_height);
assert_eq!(
tail_height(&agent, width, &appearance),
painted_height.saturating_add(super::super::commit::MINIMAL_BLOCK_GAP)
);
}
#[test]
fn minimal_status_shows_rich_activity_and_idle_hint() {
use xai_grok_pager::acp::tracker::TurnActivity;
use xai_grok_pager::app::agent::AgentState;
let theme = Theme::current();
let area = Rect::new(0, 0, 60, 1);
let read = |buf: &Buffer| -> String {
(0..area.width)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
.collect()
};
xai_grok_pager::app::set_minimal_show_switch_back_to_fullscreen_for_test(false);
let a = agent();
let mut buf = Buffer::empty(area);
render_minimal_status(&mut buf, area, &a, &None, None, &theme);
let idle = read(&buf);
assert!(idle.contains("/help"), "idle hint: {idle:?}");
assert!(
!idle.contains("/fullscreen"),
"cold start must not show switch-back: {idle:?}"
);
xai_grok_pager::app::set_minimal_show_switch_back_to_fullscreen_for_test(true);
let mut buf = Buffer::empty(area);
render_minimal_status(&mut buf, area, &a, &None, None, &theme);
let switched = read(&buf);
assert!(
switched.contains("/fullscreen to go back"),
"relaunch into minimal must show switch-back: {switched:?}"
);
xai_grok_pager::app::set_minimal_show_switch_back_to_fullscreen_for_test(false);
let mut a = agent();
a.session.state = AgentState::TurnRunning;
let mut buf = Buffer::empty(area);
render_minimal_status(
&mut buf,
area,
&a,
&Some(TurnActivity::Responding),
None,
&theme,
);
let text = read(&buf);
assert!(text.contains("Responding"), "rich activity: {text:?}");
let mut buf = Buffer::empty(area);
render_minimal_status(
&mut buf,
area,
&a,
&Some(TurnActivity::Retrying {
attempt: 2,
max_retries: 3,
reason: "transient error".to_string(),
}),
None,
&theme,
);
assert!(read(&buf).contains("Retrying"), "retry: {:?}", read(&buf));
}
#[test]
fn minimal_status_shows_idle_watching_cue() {
use xai_grok_pager::app::agent::AgentState;
let theme = Theme::current();
let area = Rect::new(0, 0, 60, 1);
let read = |buf: &Buffer| -> String {
(0..area.width)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
.collect()
};
let mut a = agent();
a.session.state = AgentState::Idle;
a.session.scheduled_tasks.insert(
"loop-1".to_string(),
xai_grok_pager::app::agent::ScheduledTaskInfo {
task_id: "loop-1".to_string(),
prompt: "do the thing".to_string(),
human_schedule: "every 5m".to_string(),
created_at: std::time::Instant::now(),
next_fire_at: None,
tag: "loop".to_string(),
},
);
assert_eq!(minimal_watchers(&a).loops, 1);
let mut buf = Buffer::empty(area);
render_minimal_status(&mut buf, area, &a, &None, None, &theme);
let text = read(&buf);
assert!(text.contains("watching"), "watching cue: {text:?}");
assert!(!text.contains("/help"), "not the idle hint: {text:?}");
}
#[test]
fn prompt_info_renders_model_context_and_queued() {
let mut a = agent();
a.context_state = Some(xai_grok_shell::session::ContextInfo {
used: 276_000,
total: 2_000_000,
..Default::default()
});
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
render_prompt_info(&mut buf, area, &a, 3, "ctrl+o transcript", &theme);
let text: String = (0..area.width)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
.collect();
assert!(text.contains("276K"), "absolute used tokens: {text:?}");
assert!(text.contains("2.0M"), "total context window: {text:?}");
assert!(text.contains('%'), "percentage: {text:?}");
assert!(text.contains("3 queued"), "queued count: {text:?}");
assert!(
text.trim_end().ends_with("ctrl+o transcript"),
"trailing transcript hint: {text:?}"
);
}
/// Where Ctrl+O is the interject chord (Apple Terminal) the caller passes
/// the `/transcript` fallback, and the info row advertises that instead.
#[test]
fn prompt_info_shows_slash_transcript_fallback_hint() {
let a = agent();
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
render_prompt_info(&mut buf, area, &a, 0, "/transcript", &theme);
let text: String = (0..area.width)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
.collect();
assert!(text.contains("/transcript"), "fallback hint: {text:?}");
assert!(!text.contains("ctrl+o"), "no dead chord: {text:?}");
}
#[test]
fn prompt_info_shows_session_mode_flag() {
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 1);
let read = |buf: &Buffer| -> String {
(0..area.width)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
.collect()
};
let render = |a: &xai_grok_pager::app::agent_view::AgentView| -> String {
let mut buf = Buffer::empty(area);
render_prompt_info(&mut buf, area, a, 0, "ctrl+o transcript", &theme);
read(&buf)
};
let mut a = agent();
let text = render(&a);
assert!(!text.contains("plan"), "normal shows no flag: {text:?}");
assert!(!text.contains("always-approve"), "normal: {text:?}");
minimal_api::set_plan_mode_pending(&mut a, Some(true));
assert!(render(&a).contains("plan"), "plan flag: {:?}", render(&a));
minimal_api::set_plan_mode_pending(&mut a, None);
minimal_api::set_plan_mode_active(&mut a, false);
minimal_api::set_yolo_mode_for_test(&mut a.session, true);
minimal_api::set_auto_mode_for_test(&mut a.session, true);
let text = render(&a);
assert!(text.contains("always-approve"), "yolo flag: {text:?}");
minimal_api::set_yolo_mode_for_test(&mut a.session, false);
let text = render(&a);
assert!(text.contains("auto"), "auto flag: {text:?}");
}
#[test]
fn pending_hint_formats_press_again() {
use crossterm::event::{KeyCode, KeyModifiers};
use xai_grok_pager::app::actions::Action;
use xai_grok_pager::app::app_view::PendingAction;
use xai_grok_pager::input::key::KeyShortcut;
assert!(minimal_pending_hint(&None).is_none());
let shortcut = KeyShortcut::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
let pending = Some(PendingAction::new(Action::Quit, shortcut, "quit"));
assert_eq!(
minimal_pending_hint(&pending).as_deref(),
Some("press Ctrl+q again to quit")
);
let silent = Some(PendingAction::with_ttl(
Action::Quit,
shortcut,
None,
std::time::Duration::from_secs(1),
));
assert!(minimal_pending_hint(&silent).is_none());
let expired = Some(PendingAction::with_ttl(
Action::Quit,
shortcut,
Some("quit"),
std::time::Duration::ZERO,
));
assert!(minimal_pending_hint(&expired).is_none());
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,688 @@
//! Minimal-mode below-prompt **list panels**: `/resume` (session picker) and
//! `/mcps` (MCP server status), rendered as simple lists *below the input bar*
//! instead of centered modal windows (design nit: "the mcps / resume lists
//! should not be in a modal").
//!
//! ## Why this is a render-only change
//!
//! Input routing is unchanged — the existing `handle_modal_key`
//! (`ActiveModal::SessionPicker`) and `handle_extensions_modal_key`
//! (`extensions_modal`) own navigation and close-on-Esc. Two different coupling
//! contracts are honored here:
//!
//! * **Session picker** rebuilds its entry map from data on every keypress
//! (render-independent), so we just reuse the *same* builders
//! ([`build_grouped_picker_entries`]) — the rendered order then matches the
//! handler's `selected`.
//! * **Extensions modal** reads render-stored state (`entry_data_indices`,
//! `entry_group_keys`, `entry_non_selectable*`). The MCP renderer repopulates
//! those exactly as the full modal does (via the shared
//! [`build_mcp_servers_picker_rows`]), so keyboard nav + section fold stay in
//! sync without touching the input handler.
//!
//! Both reuse [`picker::render_picker_content`] for the rows, so row look +
//! selection highlight match the full TUI; only the modal-window chrome (border,
//! tabs, footer bar) is dropped.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use xai_grok_pager::app::agent_view::AgentView;
use xai_grok_pager::minimal_api;
use xai_grok_pager::theme::Theme;
use xai_grok_pager::views::extensions_modal::{ExtensionsTab, TabDataState};
use xai_grok_pager::views::modal::ActiveModal;
use xai_grok_pager::views::picker::{self, PickerEntry, PickerField, PickerHitAreas, PickerRow};
/// Rows of chrome around the scrolling list: title + subtitle/search + divider
/// + footer.
const CHROME_ROWS: u16 = 4;
/// Which below-prompt list panel is active for the focused agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ListPanel {
/// `/resume` session picker (`ActiveModal::SessionPicker`).
Resume,
/// `/mcps` MCP server status (extensions modal on the McpServers tab).
Mcps,
}
/// Detect an active below-prompt list panel, or `None`.
///
/// Only the session picker and the MCP-servers tab are hosted as simple lists;
/// every other modal keeps its existing (centered) rendering. Callers must check
/// this *before* `overlay::app_modal_active`, since `SessionPicker` is also an
/// `active_modal`.
pub(super) fn active(agent: &AgentView) -> Option<ListPanel> {
if matches!(agent.active_modal, Some(ActiveModal::SessionPicker { .. })) {
return Some(ListPanel::Resume);
}
if minimal_api::extensions_modal(agent)
.is_some_and(|s| s.active_tab == ExtensionsTab::McpServers)
{
return Some(ListPanel::Mcps);
}
None
}
/// Target viewport height for the active list panel: chrome + the exact body
/// height, clamped to `[CHROME_ROWS + 1, ceiling]`. Sizing to the exact content
/// height keeps the footer directly under the last row (no blank band); when the
/// body exceeds `ceiling` the list scrolls internally.
pub(super) fn panel_height(agent: &AgentView, kind: ListPanel, width: u16, ceiling: u16) -> u16 {
let body = match kind {
ListPanel::Resume => resume_body_rows(agent, width),
ListPanel::Mcps => mcps_body_rows(agent),
};
CHROME_ROWS
.saturating_add(body)
.clamp(CHROME_ROWS + 1, ceiling.max(CHROME_ROWS + 1))
}
/// Render the active list panel into `area` (the whole live region). Returns the
/// text cursor for the panel's search bar when search is focused, else `None`.
pub(super) fn render(
buf: &mut Buffer,
area: Rect,
agent: &mut AgentView,
kind: ListPanel,
theme: &Theme,
) -> Option<(u16, u16)> {
if area.height < 2 || area.width < 8 {
return None;
}
match kind {
ListPanel::Resume => render_resume(buf, area, agent, theme),
ListPanel::Mcps => render_mcps(buf, area, agent, theme),
}
}
// ─────────────────────────────── chrome ─────────────────────────────────────
/// Split `area` into (title_row, second_row, divider_row, list_area, footer_row).
/// `second_row` hosts the subtitle (mcps) or the search bar (resume).
fn chrome_layout(area: Rect) -> (Rect, Rect, Rect, Rect, Rect) {
let row = |dy: u16| Rect {
x: area.x,
y: area.y + dy,
width: area.width,
height: 1,
};
let title = row(0);
let second = row(1);
let divider = row(2);
let footer = Rect {
x: area.x,
y: area.y + area.height - 1,
..row(0)
};
let list = Rect {
x: area.x,
y: area.y + 3,
width: area.width,
height: area.height.saturating_sub(CHROME_ROWS),
};
(title, second, divider, list, footer)
}
fn render_title(buf: &mut Buffer, row: Rect, theme: &Theme, title: &str) {
buf.set_style(row, Style::default().bg(Color::Reset));
let style = Style::default()
.fg(theme.accent_user)
.bg(Color::Reset)
.add_modifier(Modifier::BOLD);
buf.set_span(row.x + 1, row.y, &Span::styled(title, style), row.width);
}
fn render_dim_line(buf: &mut Buffer, row: Rect, theme: &Theme, text: &str) {
buf.set_style(row, Style::default().bg(Color::Reset));
let style = theme.dim().bg(Color::Reset);
buf.set_span(row.x + 1, row.y, &Span::styled(text, style), row.width);
}
/// `/resume` session picker: Enter picks a session.
const RESUME_FOOTER: &str = "\u{2191}/\u{2193} navigate \u{00b7} enter confirm \u{00b7} esc cancel";
/// `/mcps` list: Enter expands tools; reconnect is space (off then on); `r` re-lists status.
const MCPS_FOOTER: &str = "\u{2191}/\u{2193} navigate \u{00b7} space enable/disable \u{00b7} r refresh \u{00b7} enter expand \u{00b7} esc cancel";
fn render_footer(buf: &mut Buffer, row: Rect, theme: &Theme, text: &str) {
render_dim_line(buf, row, theme, text);
}
fn render_divider(buf: &mut Buffer, row: Rect, theme: &Theme) {
picker::render_divider(buf, row.x, row.y, row.width, theme, None);
}
// ─────────────────────────────── resume ─────────────────────────────────────
/// Exact body height (display rows) for the session-picker list.
fn resume_body_rows(agent: &AgentView, width: u16) -> u16 {
let Some(ActiveModal::SessionPicker {
entries,
state,
source_filter,
..
}) = &agent.active_modal
else {
return 0;
};
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);
let built =
minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width);
let fields_vecs: Vec<Vec<PickerField>> = built
.iter()
.map(|b| {
b.field_data
.iter()
.map(|(l, v)| PickerField { label: l, value: v })
.collect()
})
.collect();
let current_repo = minimal_api::repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
let (picker_entries, _) = minimal_api::build_grouped_picker_entries(
entries_data,
&filtered,
&built,
&fields_vecs,
state,
Some(current_repo.as_str()),
);
measure_entries(&picker_entries)
}
fn render_resume(
buf: &mut Buffer,
area: Rect,
agent: &mut AgentView,
theme: &Theme,
) -> Option<(u16, u16)> {
let cwd = agent.session.cwd.to_string_lossy().to_string();
let Some(ActiveModal::SessionPicker {
entries,
state,
source_filter,
..
}) = &mut agent.active_modal
else {
return None;
};
let (title_row, search_row, divider_row, list_area, footer_row) = chrome_layout(area);
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);
let built =
minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width);
let fields_vecs: Vec<Vec<PickerField>> = built
.iter()
.map(|b| {
b.field_data
.iter()
.map(|(l, v)| PickerField { label: l, value: v })
.collect()
})
.collect();
let current_repo = minimal_api::repo_name_from_cwd(&cwd);
let (picker_entries, non_sel) = minimal_api::build_grouped_picker_entries(
entries_data,
&filtered,
&built,
&fields_vecs,
state,
Some(current_repo.as_str()),
);
render_title(buf, title_row, theme, "Resume session");
// Focus-aware search bar (cursor only when search is focused).
picker::render_search_bar(
buf,
search_row.x + 1,
search_row.y,
search_row.width.saturating_sub(1),
theme,
&state.query,
state.search_active,
true,
state.query_cursor,
None,
);
render_divider(buf, divider_row, theme);
let nsc = vec![false; picker_entries.len()];
let hit = picker::render_picker_content(
buf,
list_area,
theme,
state,
&picker_entries,
&non_sel,
&nsc,
None,
false,
);
state.hit_areas = Some(PickerHitAreas {
close_button: Rect::default(),
search_bar: search_row,
item_rects: hit.item_rects,
entry_indices: hit.entry_indices,
tab_rects: vec![],
filter_rect: None,
});
render_footer(buf, footer_row, theme, RESUME_FOOTER);
None
}
// ──────────────────────────────── mcps ──────────────────────────────────────
/// Exact body height (display rows) for the MCP list: one line per row.
fn mcps_body_rows(agent: &AgentView) -> u16 {
let Some(s) = minimal_api::extensions_modal(agent) else {
return 0;
};
let servers = match &s.mcps_data {
TabDataState::Loaded(v) => v.as_slice(),
_ => return 1, // a single "loading…" / error row
};
let rows = minimal_api::build_mcp_picker_rows(
servers,
&s.picker_state.query,
s.mcps_filter,
&s.mcps_collapsed_sections,
&s.mcps_tools_expanded,
);
rows.0.len() as u16
}
fn render_mcps(
buf: &mut Buffer,
area: Rect,
agent: &mut AgentView,
theme: &Theme,
) -> Option<(u16, u16)> {
let (title_row, subtitle_row, divider_row, list_area, footer_row) = chrome_layout(area);
render_title(buf, title_row, theme, "Manage MCP servers");
// Phase 1 (immutable): build the row mapping + owned per-row render data.
let labels: Vec<String>;
let group_keys: Vec<Option<String>>;
let data_indices: Vec<Option<usize>>;
let badges: Vec<String>;
let badge_colors: Vec<Option<Color>>;
let right_labels: Vec<String>;
let indents: Vec<u8>;
let collapsibles: Vec<bool>;
let expandeds: Vec<bool>;
let subtitle: String;
let loading;
{
let s = minimal_api::extensions_modal(agent)?;
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.mcps_filter,
&s.mcps_collapsed_sections,
&s.mcps_tools_expanded,
);
let n = row_labels.len();
let mut b = vec![String::new(); n];
let mut bc: Vec<Option<Color>> = vec![None; n];
let mut rl = vec![String::new(); n];
let mut ind = vec![0u8; n];
let mut col = vec![false; n];
let mut exp = vec![false; n];
for i in 0..n {
let gk = row_group_keys[i].as_deref();
if gk.is_some_and(|k| k.starts_with("mcp-section:")) {
col[i] = true;
exp[i] = !minimal_api::mcp_section_children_hidden(
&s.mcps_collapsed_sections,
gk.unwrap(),
searching,
);
} else if gk.is_some_and(|k| k.starts_with("mcp-tools:")) {
ind[i] = 1;
col[i] = true;
if let Some(si) = row_data_indices[i] {
exp[i] = s.mcps_tools_expanded.contains(&si);
if let Some(srv) = servers.get(si) {
if !srv.enabled {
b[i] = "disabled".to_string();
bc[i] = Some(theme.accent_error);
} else {
b[i] = minimal_api::mcp_status_label(&srv.status).to_string();
bc[i] = Some(minimal_api::mcp_status_theme_color(
&srv.status,
theme,
));
}
rl[i] = if srv.tool_count == 1 {
"1 tool".to_string()
} else {
format!("{} tools", srv.tool_count)
};
}
}
} else {
ind[i] = 2; // tool child
}
}
subtitle = format!(
"{} server{}",
servers.len(),
if servers.len() == 1 { "" } else { "s" }
);
labels = row_labels;
group_keys = row_group_keys;
data_indices = row_data_indices;
badges = b;
badge_colors = bc;
right_labels = rl;
indents = ind;
collapsibles = col;
expandeds = exp;
}
TabDataState::Loading => {
subtitle = "loading\u{2026}".to_string();
labels = vec![];
group_keys = vec![];
data_indices = vec![];
badges = vec![];
badge_colors = vec![];
right_labels = vec![];
indents = vec![];
collapsibles = vec![];
expandeds = vec![];
}
TabDataState::Error(msg) => {
subtitle = format!("error: {msg}");
labels = vec![];
group_keys = vec![];
data_indices = vec![];
badges = vec![];
badge_colors = vec![];
right_labels = vec![];
indents = vec![];
collapsibles = vec![];
expandeds = vec![];
}
}
}
let n = labels.len();
render_dim_line(buf, subtitle_row, theme, &subtitle);
render_divider(buf, divider_row, theme);
// Phase 2 (mutable): mirror the row mapping onto state for the input handler.
{
let s = minimal_api::extensions_modal_mut(agent)?;
s.entry_data_indices = data_indices;
s.entry_group_keys = group_keys;
s.entry_labels_cache = labels.clone();
s.entry_non_selectable = vec![false; n];
s.entry_non_selectable_clickable = vec![false; n];
if n == 0 {
s.picker_state.selected = 0;
} else if s.picker_state.selected >= n {
s.picker_state.selected = n - 1;
}
}
// Phase 3 (mutable picker_state): build PickerEntry from owned data + render.
let s = minimal_api::extensions_modal_mut(agent)?;
let selected = s.picker_state.selected;
let search_active = s.picker_state.search_active;
let empty_fields: [PickerField; 0] = [];
let no_lines: [&str; 0] = [];
let entries: Vec<PickerEntry> = (0..n)
.map(|i| {
PickerEntry::Row(PickerRow {
label: labels[i].as_str(),
right_label: right_labels[i].as_str(),
selected: !search_active && i == selected,
expanded: expandeds[i],
fields: &empty_fields,
description_lines: &no_lines,
summary_lines: &no_lines,
dimmed: false,
indent: indents[i],
badge: badges[i].as_str(),
badge_color: badge_colors[i],
collapsible: collapsibles[i],
underline_last_desc: false,
})
})
.collect();
let non_sel = vec![false; n];
let hit = picker::render_picker_content(
buf,
list_area,
theme,
&mut s.picker_state,
&entries,
&non_sel,
&non_sel,
None,
loading,
);
s.picker_state.hit_areas = Some(PickerHitAreas {
close_button: Rect::default(),
search_bar: Rect::default(),
item_rects: hit.item_rects,
entry_indices: hit.entry_indices,
tab_rects: vec![],
filter_rect: None,
});
render_footer(buf, footer_row, theme, MCPS_FOOTER);
None
}
// ─────────────────────────────── helpers ────────────────────────────────────
/// Sum the display height of grouped picker entries: a header is one row; a row
/// is its label line plus its collapsed summary lines (what the picker draws
/// when the row is not expanded).
fn measure_entries(entries: &[PickerEntry<'_>]) -> u16 {
entries
.iter()
.map(|e| match e {
PickerEntry::Header { .. } => 1u16,
PickerEntry::Row(r) => {
if r.expanded {
1u16.saturating_add(r.description_lines.len() as u16)
.saturating_add(r.fields.len() as u16)
} else {
1u16.saturating_add(r.summary_lines.len() as u16)
}
}
})
.fold(0u16, |acc, h| acc.saturating_add(h))
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
use xai_grok_pager::views::extensions_modal::ExtensionsModalState;
use xai_grok_pager::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo, McpWireSource};
fn agent() -> AgentView {
minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp/repo"))
}
fn mcp_server(name: &str, status: McpServerDisplayStatus, tools: usize) -> McpServerInfo {
McpServerInfo {
name: name.to_string(),
display_name: None,
status,
tool_count: tools,
auth_required: false,
tools: Vec::new(),
enabled: true,
source: "local".to_string(),
wire_source: McpWireSource::Local,
plugin_name: None,
is_managed_gateway: false,
}
}
fn with_mcps(servers: Vec<McpServerInfo>) -> AgentView {
let mut a = agent();
minimal_api::set_extensions_modal(
&mut a,
Some(ExtensionsModalState {
active_tab: ExtensionsTab::McpServers,
mcps_data: TabDataState::Loaded(servers),
..Default::default()
}),
);
a
}
fn session_entry(id: &str) -> xai_grok_pager::app::app_view::SessionPickerEntry {
xai_grok_pager::app::app_view::SessionPickerEntry {
id: id.into(),
summary: id.into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: "/tmp/repo".into(),
hostname: None,
source: String::new(),
model_id: None,
num_messages: 0,
last_active_at: None,
branch: None,
repo_name: "repo".into(),
worktree_label: None,
card_detail: None,
}
}
fn with_resume(entries: Vec<xai_grok_pager::app::app_view::SessionPickerEntry>) -> AgentView {
let mut a = agent();
a.active_modal = Some(ActiveModal::SessionPicker {
state: picker::PickerState::default(),
entries: Some(entries),
loading: false,
lanes: Default::default(),
previous_palette: None,
window: xai_grok_pager::views::modal_window::ModalWindowState::new(),
content_results: None,
content_loading: false,
deep_search_seq: 0,
source_filter: xai_grok_pager::views::session_picker::SourceFilter::default(),
pending_delete: None,
entries_query: None,
});
a
}
fn buffer_text(buf: &Buffer) -> String {
let area = buf.area;
let mut out = String::new();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
out.push_str(buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "));
}
out.push('\n');
}
out
}
#[test]
fn active_detects_resume_mcps_and_none() {
assert_eq!(active(&agent()), None);
assert_eq!(
active(&with_mcps(vec![mcp_server(
"alpha",
McpServerDisplayStatus::Ready,
3
)])),
Some(ListPanel::Mcps)
);
assert_eq!(
active(&with_resume(vec![session_entry("hello")])),
Some(ListPanel::Resume)
);
}
#[test]
fn mcps_panel_renders_list_and_mirrors_handler_state() {
let mut a = with_mcps(vec![
mcp_server("alpha", McpServerDisplayStatus::Ready, 3),
mcp_server("bravo", McpServerDisplayStatus::Unavailable, 0),
]);
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
render(&mut buf, area, &mut a, ListPanel::Mcps, &theme);
let text = buffer_text(&buf);
assert!(text.contains("Manage MCP servers"), "title:\n{text}");
assert!(text.contains("2 servers"), "subtitle:\n{text}");
assert!(text.contains("alpha"), "server row:\n{text}");
assert!(text.contains("bravo"), "server row:\n{text}");
assert!(text.contains("space enable/disable"), "footer:\n{text}");
assert!(text.contains("r refresh"), "footer:\n{text}");
assert!(text.contains("enter expand"), "footer:\n{text}");
assert!(
!text.contains("enter confirm"),
"MCP footer must not reuse resume confirm copy:\n{text}"
);
// The input handler reads these render-stored fields; the panel must
// mirror them (section header + 2 servers = 3 rows) so keyboard nav and
// fold stay correct without touching the handler.
let s = minimal_api::extensions_modal(&a).unwrap();
assert_eq!(s.entry_data_indices.len(), 3, "section + 2 servers");
assert_eq!(
s.entry_data_indices.iter().filter(|d| d.is_some()).count(),
2,
"two selectable server rows map to catalog indices"
);
assert_eq!(s.entry_non_selectable.len(), 3);
}
#[test]
fn resume_panel_renders_title_rows_and_footer() {
let mut a = with_resume(vec![session_entry("first task"), session_entry("second")]);
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
render(&mut buf, area, &mut a, ListPanel::Resume, &theme);
let text = buffer_text(&buf);
assert!(text.contains("Resume session"), "title:\n{text}");
assert!(text.contains("first task"), "session row:\n{text}");
assert!(text.contains("enter confirm"), "resume footer:\n{text}");
assert!(
!text.contains("r refresh"),
"resume footer must stay session-picker copy:\n{text}"
);
}
#[test]
fn mcps_panel_height_is_chrome_plus_rows() {
// One section header + 2 server rows = 3 body rows; + 4 chrome = 7.
let a = with_mcps(vec![
mcp_server("alpha", McpServerDisplayStatus::Ready, 1),
mcp_server("bravo", McpServerDisplayStatus::Ready, 2),
]);
assert_eq!(panel_height(&a, ListPanel::Mcps, 80, 40), 7);
// Clamps to the screen ceiling when content is taller.
assert_eq!(panel_height(&a, ListPanel::Mcps, 80, 5), 5);
}
}

View file

@ -0,0 +1,264 @@
//! Minimal-mode plan-approval host (design PR10).
//!
//! The full TUI renders plan approval as a fullscreen line-viewer plus a live
//! feedback prompt. Minimal takes a simpler route: the **whole plan is committed
//! into native scrollback** as a normal conversation block (see
//! [`maybe_commit_plan`]), so it reads and scrolls exactly like the rest of the
//! transcript. The prompt-anchored live region then holds only the decision
//! controls — approve / revise / keep planning — plus the feedback input when
//! revising. Nothing of the plan body is drawn under the prompt.
//!
//! Input routing is unchanged: while `line_viewer.is_some()` the agent's input
//! handler already routes keys to `handle_line_viewer_key` (Preview focus:
//! `a` approve / `s`/`Tab` revise / `q` keep planning) and `handle_plan_feedback_key`
//! (Prompt focus: type feedback, `Enter` send, `Esc` back). Minimal keeps the
//! line viewer open (so those keys fire) but renders this compact controls strip
//! in place of the never-drawn fullscreen viewer.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use xai_grok_pager::app::agent_view::AgentView;
use xai_grok_pager::app::app_view::{ActiveView, AppView};
use xai_grok_pager::minimal_api;
use xai_grok_pager::scrollback::block::RenderBlock;
use xai_grok_pager::theme::Theme;
use xai_grok_pager::views::plan_approval_view::PlanApprovalFocus;
use xai_grok_pager::views::prompt_widget::PromptStyle;
/// The active plan-approval focus, defaulting to `Preview`.
fn focus(agent: &AgentView) -> PlanApprovalFocus {
minimal_api::plan_approval_view(agent)
.map(|p| p.focus)
.unwrap_or(PlanApprovalFocus::Preview)
}
/// Scrollback notice when exit_plan_mode parks with no plan body.
///
/// Kept short and plain (no markdown chrome) so native scrollback reads cleanly
/// under minimal mode's chromeless commit path.
const EMPTY_PLAN_SCROLLBACK: &str = "\
No plan written yet.
Approve to leave plan mode and start implementing, request changes to send the \
agent back to planning, or quit to abandon.";
/// Controls-strip header for the parked plan-approval surface.
fn plan_header(has_plan: bool) -> &'static str {
if has_plan {
"Plan ready for review"
} else {
"No plan written yet"
}
}
/// Body committed into native scrollback for a parked plan approval.
fn plan_scrollback_body(plan_content: Option<&str>) -> String {
plan_content
.filter(|s| !s.trim().is_empty())
.map(str::to_owned)
.unwrap_or_else(|| EMPTY_PLAN_SCROLLBACK.to_owned())
}
/// Commit the active plan into native scrollback, once per plan (and once per
/// revision).
///
/// Minimal has no separate plan pane: the terminal's scrollback *is* the
/// history, so the plan is pushed as an ordinary finalized agent-message block
/// and printed into native scrollback by the normal commit pass — leaving only
/// the decision controls under the prompt. De-duplicated by the plan's
/// `tool_call_id`; a revised plan arrives as a fresh ExitPlanMode with a new id
/// and is committed as its own block. Empty / whitespace-only plans still commit
/// a short notice so the user sees *why* approval is parked (otherwise only the
/// controls strip appears and the session looks stuck).
///
/// NOTE (draw-path state mutation + replay durability): this pushes into
/// `ScrollbackState` from the render path — a deliberate exception, since the
/// plan block must enter the normal commit pipeline. The pushed block is
/// client-render state, not a server event: a resumed session will not replay
/// it, so post-reload `/transcript` shows the plan only through whatever the
/// agent itself messaged. Accepted for v1 (the live session — the mode's whole
/// surface — is consistent).
///
/// Call once per frame from [`crate::draw`], before the commit pass.
pub fn maybe_commit_plan(app: &mut AppView) {
let ActiveView::Agent(id) = &app.active_view else {
return;
};
let id = *id;
// Extract the plan (owned) under a short immutable borrow so the mutable
// scrollback push and the `minimal_state` read/write below don't overlap it.
let plan = app.agents.get(&id).and_then(|agent| {
minimal_api::plan_approval_view(agent).map(|pav| {
let content = plan_scrollback_body(pav.plan_content.as_deref());
(pav.tool_call_id.clone(), content)
})
});
let Some((tool_call_id, content)) = plan else {
return;
};
if minimal_api::minimal_committed_plan_id(app) == Some(tool_call_id.as_str()) {
return; // already emitted this plan
}
// Mark the plan as emitted only when the block was actually pushed: the
// agent borrow can't fail here (the plan was just extracted from it), but
// if it ever did, stamping the id anyway would treat the plan as committed
// while nothing ever reaches native scrollback.
if let Some(agent) = app.agents.get_mut(&id) {
agent
.scrollback
.push_block(RenderBlock::agent_message(content));
minimal_api::set_minimal_committed_plan_id(app, Some(tool_call_id));
}
}
/// Desired controls-strip height: header + controls + optional feedback input.
pub fn height(agent: &AgentView) -> u16 {
let input = if focus(agent) == PlanApprovalFocus::Prompt {
1
} else {
0
};
// header (1) + controls (1) + input (0/1)
2u16.saturating_add(input)
}
/// Render the compact plan-approval controls strip into `area`. The plan itself
/// lives in native scrollback ([`maybe_commit_plan`]); this only draws the
/// header, the decision hint, and — when revising — the feedback input. Returns
/// the text cursor when the feedback input is focused, else `None`.
pub fn render(
buf: &mut Buffer,
area: Rect,
agent: &mut AgentView,
theme: &Theme,
) -> Option<(u16, u16)> {
if area.height == 0 || area.width < 4 {
return None;
}
let foc = focus(agent);
let input_h: u16 = if foc == PlanApprovalFocus::Prompt {
1
} else {
0
};
// header (1) · controls (1) · input (0/1)
let controls_y = (area.y + area.height).saturating_sub(1 + input_h);
// ── header ──
let has_plan = minimal_api::plan_approval_view(agent)
.map(|p| p.has_plan)
.unwrap_or(false);
let header_style = Style::default()
.fg(theme.accent_user)
.bg(Color::Reset)
.add_modifier(Modifier::BOLD);
buf.set_style(
Rect { height: 1, ..area },
Style::default().bg(Color::Reset),
);
buf.set_span(
area.x,
area.y,
&Span::styled(plan_header(has_plan), header_style),
area.width,
);
// ── controls hint ──
let has_content = minimal_api::plan_approval_view(agent)
.map(|p| !p.comments.is_empty())
.unwrap_or(false)
|| !agent.prompt.text().trim().is_empty();
// Tab reopens the preview (including the empty-plan placeholder).
let hint = match foc {
PlanApprovalFocus::Prompt if has_content => {
"enter request changes \u{00b7} tab plan \u{00b7} esc back"
}
PlanApprovalFocus::Prompt => "enter approve \u{00b7} tab plan \u{00b7} esc back",
PlanApprovalFocus::Commenting => "enter save comment \u{00b7} esc cancel",
PlanApprovalFocus::Preview => "a approve \u{00b7} s revise \u{00b7} q keep planning",
};
let hint_style = theme.dim().bg(Color::Reset);
let controls_rect = Rect {
x: area.x,
y: controls_y,
width: area.width,
height: 1,
};
buf.set_style(controls_rect, hint_style);
buf.set_span(
area.x,
controls_y,
&Span::styled(hint, hint_style),
area.width,
);
// ── feedback input (revise mode) ──
if input_h > 0 {
let row = Rect {
x: area.x,
y: (area.y + area.height).saturating_sub(1),
width: area.width,
height: 1,
};
let style = input_style(theme);
buf.set_style(row, Style::default().bg(theme.bg_visual));
return agent
.prompt
.draw(buf, row, None, &style, None, None)
.cursor_pos;
}
None
}
/// Chromeless prompt style for the feedback editor (the modal supplies framing).
fn input_style(theme: &Theme) -> PromptStyle {
PromptStyle {
focused: true,
show_prefix: false,
vpad_top: 0,
compact: false,
chrome: false,
chrome_pad_left: 0,
chrome_pad_right: 0,
bg_override: Some(theme.bg_visual),
accent_color_override: None,
border_color_override: None,
prefix_override: None,
placeholder_override: None,
show_accent_line: false,
show_borders: false,
title: None,
image_preview: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_plan_header_is_explicit() {
assert_eq!(plan_header(true), "Plan ready for review");
assert_eq!(plan_header(false), "No plan written yet");
}
#[test]
fn empty_plan_scrollback_uses_notice_not_silence() {
let body = plan_scrollback_body(None);
assert!(body.contains("No plan written yet"));
assert!(body.contains("Approve"));
let whitespace = plan_scrollback_body(Some(" \n\t "));
assert_eq!(whitespace, body, "whitespace-only counts as empty");
let real = plan_scrollback_body(Some("# Plan\n- do it"));
assert_eq!(real, "# Plan\n- do it");
}
}

View file

@ -0,0 +1,279 @@
//! Minimal-mode todo panel: the persistent list shown directly above the prompt
//! while a turn has todos.
//!
//! It auto-hides once every todo is done (so a finished list doesn't linger),
//! unless pinned open with `Ctrl+T` ([`todo_panel_visible`]). The overlay host
//! sizes the idle viewport with [`todo_panel_height`] so the prompt sits right
//! after the panel; [`draw_live`](super::live::draw_live) paints it with
//! [`todo_panel_lines`] + [`render_todo_panel`]. Mirrors the full-TUI `TodoPane`
//! glyphs/colors without its interactive chrome.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use xai_grok_pager::theme::Theme;
use xai_grok_shell::tools::TodoStatus;
/// Default cap on visible todo rows (the last becomes a `+N more` overflow row);
/// `Ctrl+T` expands past it.
pub(super) const MAX_TODO_ROWS: u16 = 8;
/// Whether the todo panel should render this frame. Hidden when there are no
/// todos, or when every todo is finished (so a completed list doesn't linger —
/// nit: "still showing old TODOs on every turn even though all are complete").
/// A new turn that creates fresh pending todos re-shows it immediately. `force`
/// (Ctrl+T) pins it visible regardless, e.g. to review a finished list.
pub(super) fn todo_panel_visible(
agent: &xai_grok_pager::app::agent_view::AgentView,
force: bool,
) -> bool {
let todos = agent.todo.todos();
if todos.is_empty() {
return false;
}
if force {
return true;
}
todos
.iter()
.any(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
}
/// Rows the todo panel will occupy (0 when hidden — see [`todo_panel_visible`] —
/// or there are no todos), capped at [`MAX_TODO_ROWS`]. The overlay host uses
/// this to size the idle viewport to exactly its content so the prompt sits
/// right after the committed conversation (no bottom-pin, no gap).
pub(super) fn todo_panel_height(
agent: &xai_grok_pager::app::agent_view::AgentView,
force: bool,
) -> u16 {
if !todo_panel_visible(agent, force) {
return 0;
}
let len = agent.todo.todos().len() as u16;
// Ctrl+T (force) expands the full list (clamped to the screen by the caller);
// otherwise cap at `MAX_TODO_ROWS` with a `+N more` overflow row.
if force { len } else { len.min(MAX_TODO_ROWS) }
}
/// Render the persistent todo panel into `area` (one line per item). Background
/// is reset so the panel inherits the terminal's own background (transparency),
/// matching the rest of the minimal live region.
pub(super) fn render_todo_panel(
buf: &mut Buffer,
area: Rect,
theme: &Theme,
lines: &[Line<'static>],
) {
buf.set_style(area, theme.muted().bg(Color::Reset));
for (i, line) in lines.iter().enumerate() {
let y = area.y + i as u16;
if y >= area.y + area.height {
break;
}
buf.set_line(area.x, y, line, area.width);
}
}
/// Build the persistent todo-panel lines (status glyph + content per item),
/// shown directly above the prompt while there are todos. Capped to `max_rows`
/// (the last row becomes `… +N more` on overflow). Empty when there are no
/// todos. Mirrors the full-TUI `TodoPane`'s glyphs/colors.
pub(super) fn todo_panel_lines(
agent: &xai_grok_pager::app::agent_view::AgentView,
max_rows: u16,
force: bool,
) -> Vec<Line<'static>> {
let todos = agent.todo.todos();
if todos.is_empty() || max_rows == 0 {
return Vec::new();
}
let theme = Theme::current();
let cap = max_rows as usize;
let overflow = todos.len() > cap;
// Leave the last row for the overflow marker when truncating.
let shown = if overflow {
cap.saturating_sub(1)
} else {
todos.len()
};
let mut lines: Vec<Line<'static>> = todos
.iter()
.take(shown)
.map(|t| {
let (glyph, style) = match t.status {
TodoStatus::Pending => ("\u{25a1}", Style::default().fg(theme.text_primary)),
TodoStatus::InProgress => (
"\u{25b6}",
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
),
TodoStatus::Completed => (xai_grok_pager::glyphs::check_mark(), theme.muted()),
TodoStatus::Cancelled => (
xai_grok_pager::glyphs::ballot_x(),
theme.muted().add_modifier(Modifier::CROSSED_OUT),
),
};
let content = truncate_chars(t.content.lines().next().unwrap_or("").trim(), 64);
// No leading pad: the caller places the panel at the shared
// live-region left edge (`live::live_left_inset` = 0, flush-left),
// so the glyph
// column lines up with committed `◆` bullets and the prompt ``.
Line::from(vec![
Span::styled(format!("{glyph} "), style),
Span::styled(content, style),
])
})
.collect();
if overflow {
let remaining = todos.len() - shown;
// When collapsed, advertise the chord that expands the full list; when
// already forced open (still overflowing a tiny screen) drop the hint.
let label = if force {
format!("\u{2026} +{remaining} more")
} else {
format!("\u{2026} +{remaining} more \u{00b7} ctrl+t to expand")
};
lines.push(Line::from(Span::styled(label, theme.dim())));
}
lines
}
/// Truncate `s` to at most `max` characters, appending `…` when shortened.
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}")
}
#[cfg(test)]
mod tests {
use super::*;
use xai_grok_pager::minimal_api;
use xai_grok_shell::tools::{TodoItem, TodoPriority};
fn agent() -> xai_grok_pager::app::agent_view::AgentView {
minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"))
}
fn todo(content: &str, status: TodoStatus) -> TodoItem {
TodoItem {
content: content.into(),
priority: TodoPriority::default(),
status,
meta: None,
}
}
/// Plain text of a rendered line (span contents concatenated).
fn line_text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn todo_panel_visibility_auto_hides_when_work_is_done() {
use xai_grok_pager::app::agent::AgentState;
let mut a = agent();
// No todos → hidden.
assert!(!todo_panel_visible(&a, false));
// At least one unfinished todo → shown.
a.todo.update_todos(vec![
todo("done", TodoStatus::Completed),
todo("doing", TodoStatus::InProgress),
]);
assert!(todo_panel_visible(&a, false));
// All completed + idle → auto-hidden (don't linger forever).
a.todo.update_todos(vec![
todo("a", TodoStatus::Completed),
todo("b", TodoStatus::Completed),
]);
assert!(
!todo_panel_visible(&a, false),
"auto-hide once every todo is done and the turn is idle"
);
// …and stays hidden even while a turn is actively running, so a previous
// turn's finished list never lingers at the start of the next turn.
a.session.state = AgentState::TurnRunning;
assert!(
!todo_panel_visible(&a, false),
"all-complete list hides even mid-turn"
);
// The Ctrl+T force-show pin overrides the auto-hide.
a.session.state = AgentState::Idle;
assert!(
todo_panel_visible(&a, true),
"Ctrl+T pin keeps a finished list visible"
);
}
#[test]
fn todo_panel_empty_when_no_todos() {
assert!(todo_panel_lines(&agent(), 8, false).is_empty());
// …and empty when the cap is zero, regardless of todos.
let mut a = agent();
a.todo.update_todos(vec![todo("x", TodoStatus::Pending)]);
assert!(todo_panel_lines(&a, 0, false).is_empty());
}
#[test]
fn todo_panel_lists_items_with_status_glyphs() {
let mut agent = agent();
agent.todo.update_todos(vec![
todo("done one", TodoStatus::Completed),
todo("active item", TodoStatus::InProgress),
todo("later", TodoStatus::Pending),
]);
let lines = todo_panel_lines(&agent, 8, false);
assert_eq!(lines.len(), 3);
assert!(line_text(&lines[0]).contains("done one"));
assert!(
line_text(&lines[1]).contains("\u{25b6}"),
"in-progress row uses the ▶ glyph"
);
assert!(line_text(&lines[1]).contains("active item"));
assert!(
line_text(&lines[2]).contains("\u{25a1}"),
"pending row uses the □ glyph"
);
}
#[test]
fn todo_panel_caps_with_overflow_row() {
let mut agent = agent();
agent.todo.update_todos(
(0..10)
.map(|i| todo(&format!("item {i}"), TodoStatus::Pending))
.collect(),
);
let lines = todo_panel_lines(&agent, 4, false);
assert_eq!(lines.len(), 4, "capped to max_rows");
// 3 items + a "+7 more" overflow row (10 total, 3 shown), with a hint.
assert!(
line_text(&lines[3]).contains("+7 more"),
"got: {:?}",
line_text(&lines[3])
);
assert!(
line_text(&lines[3]).contains("ctrl+t"),
"overflow row advertises the expand chord: {:?}",
line_text(&lines[3])
);
}
#[test]
fn truncate_chars_adds_ellipsis_only_when_needed() {
assert_eq!(truncate_chars("hello", 10), "hello");
assert_eq!(truncate_chars("hello world", 5), "hell…");
}
}

View file

@ -0,0 +1,143 @@
//! Minimal-mode welcome card.
//!
//! Minimal skips the full-screen welcome view entirely, so the start of a
//! session is otherwise invisible — you land straight at the prompt. To make a
//! fresh session obvious (and on `/new` / `Ctrl+N`), this commits a compact,
//! rounded card once into native scrollback: the braille logo, the version, the
//! cwd, the model, and a one-line hint. It mirrors the full-TUI hero box's style
//! (rounded dim border + logo) without its menu/onboarding.
//!
//! It is printed via [`xai_ratatui_inline::Terminal::insert_before`] — the same
//! one-shot mechanism the commit pipeline uses — gated on an `AppView` flag set
//! at session creation, so it prints exactly once per session and re-prints when
//! a new session starts.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Widget};
use xai_grok_pager::app::PagerTerminal;
use xai_grok_pager::app::app_view::{ActiveView, AppView};
use xai_grok_pager::minimal_api;
use xai_grok_pager::theme::Theme;
/// Commit the welcome card when one is pending (set at session start / `/new`).
///
/// Called at the top of the minimal draw, before `commit_active`, so the card
/// lands above the first conversation block in native scrollback.
pub fn maybe_commit_welcome(app: &mut AppView, terminal: &mut PagerTerminal) {
if !minimal_api::minimal_welcome_pending(app) {
return;
}
let width = terminal.viewport_area().width;
// Too narrow to draw a bordered card — leave the flag set and retry next
// frame (e.g. during an initial 0-width probe).
if width < 8 {
return;
}
// NB: the pending flag is cleared only after the `insert_before` at the
// bottom SUCCEEDS — clearing it up front meant a failed insert silently
// dropped the card forever (bugbot). A failed frame retries next draw.
// Reset the live viewport to the TOP of the screen and clear what's visible,
// so the welcome card commits at row 0 and the app "owns" the window. The
// viewport is not bottom-pinned, so subsequent commits flow downward from
// here. Pre-existing native scrollback is untouched — scrolling up still
// shows whatever was there before.
let live_h = terminal.viewport_area().height;
terminal.set_viewport_area(ratatui::layout::Rect {
x: 0,
y: 0,
width,
height: live_h,
});
let _ = terminal.clear();
let theme = Theme::current();
let version = xai_grok_version::VERSION;
let (cwd, model) = match &app.active_view {
ActiveView::Agent(id) => {
let agent = app.agents.get(id);
(
agent
.map(|a| a.session.cwd.display().to_string())
.unwrap_or_default(),
agent.and_then(|a| a.session.models.current_model_name()),
)
}
_ => (app.cwd.display().to_string(), None),
};
// Info lines below the logo: title + version, cwd, optional model, hint.
let mut info: Vec<Line<'static>> = Vec::new();
info.push(Line::from(vec![
Span::styled(
"Grok Build",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" v{version}"), theme.muted()),
]));
if !cwd.is_empty() {
info.push(Line::from(Span::styled(cwd, theme.muted())));
}
if let Some(model) = model {
info.push(Line::from(Span::styled(
format!("Model · {model}"),
theme.muted(),
)));
}
info.push(Line::from(Span::styled("/help for commands", theme.dim())));
let logo_lines = minimal_api::compact_logo_line_count();
// logo (+ a blank separator row) when present, then the info lines, wrapped
// in a border with one row of vertical padding top and bottom.
let logo_block = if logo_lines > 0 { logo_lines + 1 } else { 0 };
let height = 2 + 1 + logo_block + info.len() as u16 + 1;
// RGB themes: blend a soft border. Terminal-native (both Reset): fall
// through to Reset so the terminal default fg draws the chrome.
let border_color =
xai_grok_pager::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45)
.unwrap_or(theme.gray_dim);
let inserted = terminal.insert_before(height, move |buf| {
let area = buf.area;
Block::new()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.render(area, buf);
let inner_x = area.x + 2;
let inner_w = area.width.saturating_sub(4);
// Top border + one row of vertical padding.
let mut y = area.y + 2;
if logo_lines > 0 {
let logo_area = ratatui::layout::Rect {
x: area.x + 1,
y,
width: area.width.saturating_sub(2),
height: logo_lines,
};
minimal_api::render_compact_logo(logo_area, buf, &Theme::current());
y += logo_lines + 1;
}
for line in &info {
buf.set_line(inner_x, y, line, inner_w);
y += 1;
}
});
if inserted.is_err() {
// Terminal write failed — keep the flag pending so the card retries on
// the next frame instead of being dropped forever.
return;
}
minimal_api::set_minimal_welcome_pending(app, false);
// Trailing gap, matching every committed block, so the first conversation
// block is separated from the card.
super::commit::insert_gap(terminal);
}