Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
49
crates/codegen/xai-grok-pager-minimal/Cargo.toml
Normal file
49
crates/codegen/xai-grok-pager-minimal/Cargo.toml
Normal 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]
|
||||
331
crates/codegen/xai-grok-pager-minimal/src/auth.rs
Normal file
331
crates/codegen/xai-grok-pager-minimal/src/auth.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
1324
crates/codegen/xai-grok-pager-minimal/src/commit.rs
Normal file
1324
crates/codegen/xai-grok-pager-minimal/src/commit.rs
Normal file
File diff suppressed because it is too large
Load diff
447
crates/codegen/xai-grok-pager-minimal/src/full_view.rs
Normal file
447
crates/codegen/xai-grok-pager-minimal/src/full_view.rs
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
45
crates/codegen/xai-grok-pager-minimal/src/guard.rs
Normal file
45
crates/codegen/xai-grok-pager-minimal/src/guard.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
118
crates/codegen/xai-grok-pager-minimal/src/lib.rs
Normal file
118
crates/codegen/xai-grok-pager-minimal/src/lib.rs
Normal 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 });
|
||||
}
|
||||
928
crates/codegen/xai-grok-pager-minimal/src/live.rs
Normal file
928
crates/codegen/xai-grok-pager-minimal/src/live.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
1033
crates/codegen/xai-grok-pager-minimal/src/overlay.rs
Normal file
1033
crates/codegen/xai-grok-pager-minimal/src/overlay.rs
Normal file
File diff suppressed because it is too large
Load diff
688
crates/codegen/xai-grok-pager-minimal/src/panel.rs
Normal file
688
crates/codegen/xai-grok-pager-minimal/src/panel.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
264
crates/codegen/xai-grok-pager-minimal/src/plan.rs
Normal file
264
crates/codegen/xai-grok-pager-minimal/src/plan.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
279
crates/codegen/xai-grok-pager-minimal/src/todo.rs
Normal file
279
crates/codegen/xai-grok-pager-minimal/src/todo.rs
Normal 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…");
|
||||
}
|
||||
}
|
||||
143
crates/codegen/xai-grok-pager-minimal/src/welcome.rs
Normal file
143
crates/codegen/xai-grok-pager-minimal/src/welcome.rs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue