docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
//! 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`].
|
||||
//! Minimal has no welcome screen, so before any agent session exists the live
|
||||
//! region itself shows the sign-in flow.
|
||||
//! [`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;
|
||||
@@ -15,26 +13,20 @@ use ratatui::text::{Line, Span};
|
||||
use kigi_tui::app::app_view::AuthState;
|
||||
use kigi_tui::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.
|
||||
/// What the no-agent live region shows. 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`).
|
||||
/// Covers both the device flow and the external command flow, where the
|
||||
/// provider opens its own browser and `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).
|
||||
/// 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 {
|
||||
@@ -45,7 +37,7 @@ pub(super) fn minimal_auth_hint(auth: &AuthState) -> MinimalAuthHint {
|
||||
.map(str::to_owned),
|
||||
},
|
||||
AuthState::Pending { error: Some(err) } => MinimalAuthHint::Failed(err.clone()),
|
||||
// Login is starting (auto-triggered at startup) — the URL arrives via
|
||||
// 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,
|
||||
@@ -55,9 +47,8 @@ pub(super) fn minimal_auth_hint(auth: &AuthState) -> MinimalAuthHint {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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('?')
|
||||
@@ -68,7 +59,7 @@ fn device_user_code(url: &str) -> Option<&str> {
|
||||
.then_some(code)
|
||||
}
|
||||
|
||||
/// Write `line` at row `y` (when it fits) and return the next row.
|
||||
/// Returns the next free row; `y` unchanged when the line did not fit.
|
||||
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);
|
||||
@@ -78,10 +69,10 @@ fn put_line(buf: &mut Buffer, area: Rect, y: u16, bottom: u16, line: Line<'_>) -
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Writes `url` character-by-character across as many rows as it needs, so no
|
||||
/// wrap-inserted spaces land inside it and 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,
|
||||
@@ -91,7 +82,7 @@ fn render_url(
|
||||
style: Style,
|
||||
) -> u16 {
|
||||
let width = area.width.max(1);
|
||||
// Snapshot the buffer bounds as values so the `&Rect` borrow doesn't outlive
|
||||
// Snapshot the bounds as values so the `&Rect` borrow of `buf` ends before
|
||||
// the mutable cell writes below.
|
||||
let (max_x, max_y) = {
|
||||
let a = buf.area();
|
||||
@@ -120,8 +111,7 @@ fn render_url(
|
||||
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.
|
||||
/// 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;
|
||||
@@ -258,7 +248,6 @@ mod tests {
|
||||
fn auth_hint_maps_auth_state() {
|
||||
use kigi_tui::app::app_view::AuthMode;
|
||||
|
||||
// Device flow → SigningIn carrying the URL and the parsed code.
|
||||
let st = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
@@ -276,7 +265,7 @@ mod tests {
|
||||
_ => panic!("expected SigningIn"),
|
||||
}
|
||||
|
||||
// External command flow with no code → SigningIn, URL but no code.
|
||||
// The external command flow carries no `user_code` in its URL.
|
||||
let st = AuthState::Authenticating {
|
||||
request_seq: 2,
|
||||
handle: None,
|
||||
|
||||
@@ -229,7 +229,8 @@ pub fn commit_leading_run(
|
||||
Step::Skip => i += 1,
|
||||
Step::Commit => {
|
||||
if !on_commit(state, i) {
|
||||
break; // emit failed — leave uncommitted, retry next frame
|
||||
// emit failed — leave uncommitted, retry next frame
|
||||
break;
|
||||
}
|
||||
minimal_api::mark_committed(state, i);
|
||||
count += 1;
|
||||
@@ -383,7 +384,8 @@ fn paint_committed(
|
||||
pub fn commit_active(app: &mut AppView, terminal: &mut PagerTerminal) {
|
||||
let id = match &app.active_view {
|
||||
ActiveView::Agent(id) => *id,
|
||||
_ => return, // welcome / dashboard: nothing to commit
|
||||
// welcome / dashboard: nothing to commit
|
||||
_ => return,
|
||||
};
|
||||
// Snapshot the commit appearance before borrowing `agents` mutably.
|
||||
let appearance = committed_appearance(&app.appearance);
|
||||
@@ -409,7 +411,7 @@ pub fn commit_active(app: &mut AppView, terminal: &mut PagerTerminal) {
|
||||
let cwd = agent.session.cwd.as_path();
|
||||
let sb = &mut agent.scrollback;
|
||||
|
||||
// NB: resume/attach replay (`agent.session.loading_replay`) intentionally
|
||||
// NB: resume/attach replay (`agent.session.loading_replay`) deliberately
|
||||
// falls through to the normal commit pass below, so the loaded transcript is
|
||||
// printed into native scrollback (a resumed session must be visible).
|
||||
|
||||
@@ -535,7 +537,8 @@ pub fn expand_pending(app: &mut AppView, terminal: &mut PagerTerminal) {
|
||||
let mut iter = ids.into_iter();
|
||||
while let Some(eid) = iter.next() {
|
||||
let Some(idx) = sb.index_of_id(eid) else {
|
||||
continue; // entry removed (rewind / clear) since the keypress
|
||||
// entry removed (rewind / clear) since the keypress
|
||||
continue;
|
||||
};
|
||||
if let Some(e) = sb.get_mut(idx) {
|
||||
e.set_display_mode(DisplayMode::Expanded);
|
||||
@@ -611,7 +614,8 @@ mod tests {
|
||||
s.push(finalized("a"));
|
||||
s.push(finalized("b"));
|
||||
s.push(running("c"));
|
||||
s.push(finalized("d")); // after the running block — must NOT commit yet
|
||||
// after the running block — must NOT commit yet
|
||||
s.push(finalized("d"));
|
||||
|
||||
assert_eq!(commit_collect(&mut s), vec![0, 1]);
|
||||
assert_eq!(minimal_api::commit_scan_cursor(&s), 2);
|
||||
@@ -630,7 +634,8 @@ mod tests {
|
||||
fn pending_user_input_holds_the_frontier() {
|
||||
let mut s = ScrollbackState::new();
|
||||
s.push(finalized("a"));
|
||||
let tool = s.push(finalized("tool")); // finalized but awaiting permission
|
||||
// finalized but awaiting permission
|
||||
let tool = s.push(finalized("tool"));
|
||||
s.push(finalized("after"));
|
||||
assert!(s.set_pending_user_input(tool, true));
|
||||
|
||||
@@ -675,7 +680,8 @@ mod tests {
|
||||
// the update. It holds the frontier regardless of later blocks.
|
||||
let mut s = ScrollbackState::new();
|
||||
s.push(finalized("a"));
|
||||
s.push(running("running tool")); // stub == not an AgentMessage
|
||||
// stub == not an AgentMessage
|
||||
s.push(running("running tool"));
|
||||
s.push(finalized("after"));
|
||||
assert_eq!(commit_collect(&mut s), vec![0]);
|
||||
assert_eq!(minimal_api::commit_scan_cursor(&s), 1);
|
||||
@@ -694,7 +700,8 @@ mod tests {
|
||||
s.push(ScrollbackEntry::running(RenderBlock::bg_task(
|
||||
"sleep 60", "task-1",
|
||||
)));
|
||||
s.push(running("later tool")); // more turn output after the bg task
|
||||
// more turn output after the bg task
|
||||
s.push(running("later tool"));
|
||||
|
||||
// "a" + the running bg task commit; only the trailing running tool stays.
|
||||
assert_eq!(commit_collect(&mut s), vec![0, 1]);
|
||||
@@ -728,12 +735,13 @@ mod tests {
|
||||
// the remaining indices down). The cursor is clamped; the per-entry
|
||||
// `committed` flags travel with "b"/"c", so neither is re-emitted.
|
||||
assert!(s.remove_entry(a));
|
||||
s.push(finalized("d")); // now at index 2
|
||||
// now at index 2
|
||||
s.push(finalized("d"));
|
||||
|
||||
assert_eq!(commit_collect(&mut s), vec![2]);
|
||||
assert!(minimal_api::is_committed(&s, s.get(0).unwrap())); // b
|
||||
assert!(minimal_api::is_committed(&s, s.get(1).unwrap())); // c
|
||||
assert!(minimal_api::is_committed(&s, s.get(2).unwrap())); // d
|
||||
assert!(minimal_api::is_committed(&s, s.get(0).unwrap()));
|
||||
assert!(minimal_api::is_committed(&s, s.get(1).unwrap()));
|
||||
assert!(minimal_api::is_committed(&s, s.get(2).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -773,8 +781,10 @@ mod tests {
|
||||
true
|
||||
});
|
||||
assert_eq!(seen, vec![1, 2]);
|
||||
assert!(minimal_api::is_committed(&s, s.get(1).unwrap())); // replayed-A
|
||||
assert!(minimal_api::is_committed(&s, s.get(2).unwrap())); // replayed-B
|
||||
// replayed-A
|
||||
assert!(minimal_api::is_committed(&s, s.get(1).unwrap()));
|
||||
// replayed-B
|
||||
assert!(minimal_api::is_committed(&s, s.get(2).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -880,7 +890,7 @@ mod tests {
|
||||
assert_eq!(removed.len(), 2);
|
||||
assert_eq!(minimal_api::commit_scan_cursor(&s), 1);
|
||||
|
||||
s.push(finalized("d")); // index 1
|
||||
s.push(finalized("d"));
|
||||
assert_eq!(commit_collect(&mut s), vec![1]);
|
||||
}
|
||||
|
||||
@@ -920,7 +930,8 @@ mod tests {
|
||||
// frontier; once the turn is idle the frontier must advance past it.
|
||||
let mut s = ScrollbackState::new();
|
||||
s.push(finalized("a"));
|
||||
s.push(running("stale")); // stale is_running flag
|
||||
// stale is_running flag
|
||||
s.push(running("stale"));
|
||||
s.push(finalized("c"));
|
||||
|
||||
// Running turn: blocked at the running entry.
|
||||
@@ -971,7 +982,7 @@ mod tests {
|
||||
let renderer = committed_renderer(&entry, &theme, appearance, test_cwd());
|
||||
let h = renderer.desired_height(width);
|
||||
assert!(h > 0, "{label}@{width}: desired_height was 0");
|
||||
// The accent bar and background fill intentionally stretch to the given
|
||||
// The accent bar and background fill deliberately stretch to the given
|
||||
// area height (chrome, not content). Only the content columns
|
||||
// (x >= chrome_width) carry real text that `insert_before` would clip.
|
||||
let chrome = renderer.chrome_width();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! 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
|
||||
/// `include_str!` (this guard file is deliberately not scanned, since it names
|
||||
/// the identifiers here).
|
||||
#[test]
|
||||
fn minimal_never_uses_ris_rerender_or_emit_to_scrollback() {
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
//! `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
|
||||
//!
|
||||
//! `kigi-tui` (the lib) does **not** depend on this crate — that would be
|
||||
@@ -46,29 +38,16 @@ use kigi_tui::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.
|
||||
/// The call order is load-bearing. [`overlay::sync_viewport`] sizes the
|
||||
/// viewport to its **post-commit** height and must run *before*
|
||||
/// [`commit::commit_active`], so that each `insert_before` prints a finalized
|
||||
/// block and repositions an already-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). A viewport still at its tall
|
||||
/// streaming height when the block commits collapses afterwards and strands the
|
||||
/// prompt at the top of the screen ("input snaps to the top").
|
||||
///
|
||||
/// ## Why step 0 exists (resize + flicker)
|
||||
/// ## Why the synchronized update and autoresize come first
|
||||
///
|
||||
/// **Resize:** `draw_frame` runs `terminal.autoresize()` — but that is the
|
||||
/// *last* step of this function, while the commit passes read
|
||||
@@ -84,20 +63,17 @@ use kigi_tui::app::app_view::AppView;
|
||||
/// 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
|
||||
/// matching `EndSynchronizedUpdate` is emitted by `draw_frame`, 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`).
|
||||
// Sync pending permission/question marks ONCE, up front, so that viewport
|
||||
// sizing (`sync_viewport` / `tail_height` / `will_commit`) and the commit
|
||||
// pass judge committability against the same state.
|
||||
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);
|
||||
@@ -107,11 +83,10 @@ pub fn draw(app: &mut AppView, terminal: &mut PagerTerminal) {
|
||||
live::draw_live(app, terminal);
|
||||
}
|
||||
|
||||
/// Register the minimal-mode render hooks with `kigi-tui`.
|
||||
/// Installs the function-pointer seam so the pager's `ScreenMode::Minimal`
|
||||
/// branches dispatch into this crate.
|
||||
///
|
||||
/// 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:
|
||||
/// Call early in the binary's `main`, before any frame is drawn. Idempotent:
|
||||
/// subsequent calls are ignored (see [`kigi_tui::minimal_hook`]).
|
||||
pub fn install() {
|
||||
kigi_tui::minimal_hook::install(kigi_tui::minimal_hook::MinimalHooks { draw });
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
//! 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.
|
||||
//! via [`super::commit`].
|
||||
use kigi_tui::app::PagerTerminal;
|
||||
use kigi_tui::app::app_view::{ActiveView, AppView};
|
||||
use kigi_tui::minimal_api;
|
||||
@@ -20,20 +19,17 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Clear, Widget};
|
||||
/// 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`.
|
||||
/// Left inset (columns) for every auxiliary live-region row — status, info bar,
|
||||
/// exit hint, 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.
|
||||
/// 0, matching the welcome card's outer edge. The auxiliary rows share that
|
||||
/// edge so nothing sits ragged against the welcome box.
|
||||
pub(super) fn live_left_inset(_appearance: &kigi_tui::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 {
|
||||
@@ -42,8 +38,6 @@ fn inset_left(area: Rect, inset: u16) -> Rect {
|
||||
..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: &kigi_tui::appearance::AppearanceConfig) -> PromptStyle {
|
||||
@@ -66,7 +60,6 @@ pub(super) fn prompt_style(appearance: &kigi_tui::appearance::AppearanceConfig)
|
||||
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);
|
||||
@@ -334,13 +327,13 @@ fn live_tail_renderer<'a>(
|
||||
.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.
|
||||
/// Renders the uncommitted tail 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).
|
||||
/// exactly the entries [`tail_height`] measured — the viewport is sized to that,
|
||||
/// and any disagreement makes the prompt jump on commit.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_tail(
|
||||
buf: &mut Buffer,
|
||||
@@ -414,11 +407,9 @@ fn draw_tail(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Count idle-surviving "watchers" — running monitors, active scheduled
|
||||
/// `/loop` tasks, and running (background) subagents — so the shared turn-status
|
||||
/// widget can show the persistent "watching · N monitors · M loops · K
|
||||
/// subagents" cue while the agent is idle. Mirrors the full-TUI computation in
|
||||
/// `AgentView::draw` (which minimal bypasses).
|
||||
/// Counts the "watchers" that survive an idle turn, feeding the shared
|
||||
/// turn-status widget's "watching · …" cue. Mirrors the full-TUI computation in
|
||||
/// `AgentView::draw`, which minimal bypasses.
|
||||
fn minimal_watchers(agent: &kigi_tui::app::agent_view::AgentView) -> turn_status::Watchers {
|
||||
turn_status::Watchers {
|
||||
monitors: agent
|
||||
@@ -435,11 +426,10 @@ fn minimal_watchers(agent: &kigi_tui::app::agent_view::AgentView) -> turn_status
|
||||
.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`].
|
||||
/// Resolves the current turn activity, resetting `activity_started_at` on every
|
||||
/// phase transition. The full TUI does this inside its own `draw`; minimal has a
|
||||
/// separate draw path, so it must drive the same logic or the phase timer never
|
||||
/// resets.
|
||||
fn minimal_advance_phase_timer(
|
||||
agent: &mut kigi_tui::app::agent_view::AgentView,
|
||||
) -> Option<kigi_tui::acp::tracker::TurnActivity> {
|
||||
@@ -450,17 +440,13 @@ fn minimal_advance_phase_timer(
|
||||
}
|
||||
activity
|
||||
}
|
||||
/// Render the one-line minimal status indicator above the prompt.
|
||||
/// Renders the one-line 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.
|
||||
/// surfaces the same rich activity detail instead of collapsing everything to
|
||||
/// "working…". Minimal is keyboard-only, so the mouse `[stop]` / `[↓]` buttons
|
||||
/// are suppressed (`None`). When the widget would draw nothing (plain idle, no
|
||||
/// watchers) [`render_idle_hint`] takes the row instead.
|
||||
fn render_minimal_status(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
@@ -524,7 +510,6 @@ fn render_minimal_status(
|
||||
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);
|
||||
@@ -543,20 +528,15 @@ fn render_idle_hint(buf: &mut Buffer, area: Rect, theme: &Theme) {
|
||||
};
|
||||
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.
|
||||
/// Renders the one-line info bar directly below the prompt. The caller gates
|
||||
/// this on no menu/dropdown owning the band below the prompt.
|
||||
///
|
||||
/// The mode flag keeps its accent color so the Shift+Tab cycle — otherwise
|
||||
/// invisible in minimal mode — is always visible. `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. Elapsed time
|
||||
/// and token count live in the turn-status row above the prompt (see
|
||||
/// [`render_minimal_status`]) and are not repeated here.
|
||||
fn render_prompt_info(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
@@ -624,9 +604,9 @@ fn render_prompt_info(
|
||||
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.
|
||||
/// Ctrl+q again to quit"). `None` when nothing is armed, the arm has expired, or
|
||||
/// it is a silent arm carrying no label. Mirrors the full-TUI shortcuts-bar
|
||||
/// `PendingHint`, which minimal does not render.
|
||||
fn minimal_pending_hint(
|
||||
pending: &Option<kigi_tui::app::app_view::PendingAction>,
|
||||
) -> Option<String> {
|
||||
@@ -640,8 +620,6 @@ fn minimal_pending_hint(
|
||||
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);
|
||||
@@ -658,14 +636,13 @@ fn render_exit_hint(buf: &mut Buffer, area: Rect, theme: &Theme, hint: &str) {
|
||||
///
|
||||
/// 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).
|
||||
/// "thinking" with nothing streamed yet). Measuring the post-commit tail rather
|
||||
/// than the current one is load-bearing: `sync_viewport` runs just *before*
|
||||
/// `commit_active`, so the viewport is already at its final height when the
|
||||
/// commit's `insert_before` prints finalized blocks and repositions it to sit
|
||||
/// directly after them. A viewport sized to the tall streaming tail is oversized
|
||||
/// at commit time, and the collapse that follows strands the prompt at the top
|
||||
/// of the screen (the "snaps to top" bug).
|
||||
pub(super) fn tail_height(
|
||||
agent: &kigi_tui::app::agent_view::AgentView,
|
||||
width: u16,
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
//! shrink path keeps the top fixed, so the prompt simply moves back up. No
|
||||
//! explicit bottom re-anchoring is needed (or wanted).
|
||||
//!
|
||||
//! `set_viewport_height` early-returns when the height is unchanged, so steady
|
||||
//! `set_viewport_height` early-returns when the height is `unchanged`, so steady
|
||||
//! state is a no-op.
|
||||
//!
|
||||
//! [`Terminal::set_viewport_height`]: kigi_ratatui_inline::Terminal::set_viewport_height
|
||||
@@ -125,7 +125,8 @@ fn app_modal_target(base: u16, ceiling: u16) -> u16 {
|
||||
fn modal_target(tail_h: u16, modal_h: u16, base: u16, ceiling: u16) -> u16 {
|
||||
tail_h
|
||||
.saturating_add(modal_h)
|
||||
.saturating_add(1) // status row between the tail and the modal
|
||||
// status row between the tail and the modal
|
||||
.saturating_add(1)
|
||||
.max(base)
|
||||
.min(ceiling)
|
||||
.max(3)
|
||||
@@ -139,7 +140,7 @@ fn modal_target(tail_h: u16, modal_h: u16, base: u16, ceiling: u16) -> u16 {
|
||||
/// scrolls committed content into scrollback and closing it leaves **no blank
|
||||
/// band** — and only scrolls when the growth would overflow the screen bottom.
|
||||
/// As blocks commit, `insert_before` pushes the viewport down naturally; once it
|
||||
/// reaches the bottom, further commits scroll. A no-op when height is unchanged.
|
||||
/// reaches the bottom, further commits scroll. A no-op when height is `unchanged`.
|
||||
pub fn sync_viewport(app: &mut AppView, terminal: &mut PagerTerminal) {
|
||||
let term_h = terminal.last_known_area().height;
|
||||
if term_h < 3 {
|
||||
@@ -305,7 +306,7 @@ fn compute_target(app: &mut AppView, term_h: u16, width: u16) -> u16 {
|
||||
fn content_target(tail_h: u16, todos_h: u16, overlay_h: u16, prompt_h: u16, ceiling: u16) -> u16 {
|
||||
tail_h
|
||||
.saturating_add(todos_h)
|
||||
.saturating_add(1) // status row
|
||||
.saturating_add(1)
|
||||
.saturating_add(overlay_h)
|
||||
.saturating_add(prompt_h)
|
||||
.clamp(2, ceiling)
|
||||
@@ -343,12 +344,14 @@ pub fn render(
|
||||
buf,
|
||||
item_count,
|
||||
item_rows,
|
||||
None, // no inline prompt area; anchor straight below `prompt_area`
|
||||
// no inline prompt area; anchor straight below `prompt_area`
|
||||
None,
|
||||
prompt_area,
|
||||
viewport_area,
|
||||
layout_cfg,
|
||||
compact,
|
||||
true, // minimal: anchor the dropdown *below* the input bar
|
||||
// minimal: anchor the dropdown *below* the input bar
|
||||
true,
|
||||
theme,
|
||||
) else {
|
||||
return;
|
||||
@@ -381,7 +384,7 @@ pub fn render(
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── modal overlays (PR10) ───────────────────────────
|
||||
// modal overlays (PR10)
|
||||
//
|
||||
// Unlike the prompt-anchored dropdowns above, these modals *replace* the prompt:
|
||||
// they occupy the bottom region and the user interacts with them directly. Keys
|
||||
@@ -530,7 +533,7 @@ pub fn render_modal(
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── app-modals (PR13 / PR15) ────────────────────────
|
||||
// app-modals (PR13 / PR15)
|
||||
//
|
||||
// A second family of overlays lives in `AgentView::active_modal` (the full-TUI
|
||||
// `ActiveModal` enum) rather than the per-feature fields the [`Modal`]s above
|
||||
@@ -869,7 +872,8 @@ mod tests {
|
||||
/// editor rows over the question list.
|
||||
#[test]
|
||||
fn question_editor_render_cap_matches_reserved_cap() {
|
||||
let screen_h = 40u16; // cap = 13
|
||||
// cap = 13
|
||||
let screen_h = 40u16;
|
||||
let content_w = 80usize;
|
||||
let cap = question_editor_cap(screen_h);
|
||||
assert_eq!(cap, 13);
|
||||
@@ -949,7 +953,8 @@ mod tests {
|
||||
minimal_api::prompt_suggestions_mut(&mut pw).dropdown.items =
|
||||
vec![completion_item(), completion_item(), completion_item()];
|
||||
assert_eq!(active(&pw, 80), Some((Kind::Completion, 3)));
|
||||
assert_eq!(overlay_rows(&pw, 80), 5); // 3 items + 2 borders
|
||||
// 3 items + 2 borders
|
||||
assert_eq!(overlay_rows(&pw, 80), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -967,7 +972,8 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_open_dropdown_reports_nothing() {
|
||||
let mut pw = PromptWidget::new();
|
||||
minimal_api::prompt_suggestions_mut(&mut pw).dropdown.open = true; // open but no items
|
||||
// open but no items
|
||||
minimal_api::prompt_suggestions_mut(&mut pw).dropdown.open = true;
|
||||
assert_eq!(overlay_rows(&pw, 80), 0);
|
||||
assert!(active(&pw, 80).is_none());
|
||||
}
|
||||
@@ -977,12 +983,16 @@ mod tests {
|
||||
// Viewport = tail + todos + status(1) + overlay + prompt — no base
|
||||
// floor, so the prompt sits right after the conversation. Idle (tail 0,
|
||||
// empty prompt) is just status + prompt.
|
||||
assert_eq!(content_target(0, 0, 0, 1, 40), 2); // status + 1-row prompt
|
||||
assert_eq!(content_target(0, 3, 0, 1, 40), 5); // + 3 todo rows
|
||||
assert_eq!(content_target(0, 3, 5, 2, 40), 11); // + overlay(5) + 2-row prompt
|
||||
// status + 1-row prompt
|
||||
assert_eq!(content_target(0, 0, 0, 1, 40), 2);
|
||||
// + 3 todo rows
|
||||
assert_eq!(content_target(0, 3, 0, 1, 40), 5);
|
||||
// + overlay(5) + 2-row prompt
|
||||
assert_eq!(content_target(0, 3, 5, 2, 40), 11);
|
||||
// The streaming tail grows the viewport (no fixed empty gap while
|
||||
// "thinking": tail 0 → just status + prompt).
|
||||
assert_eq!(content_target(6, 0, 0, 1, 40), 8); // tail(6) + status + prompt
|
||||
// tail(6) + status + prompt
|
||||
assert_eq!(content_target(6, 0, 0, 1, 40), 8);
|
||||
// Floored at 2 (status + prompt) and capped at the screen ceiling.
|
||||
assert_eq!(content_target(0, 0, 0, 0, 40), 2);
|
||||
assert_eq!(content_target(50, 0, 0, 0, 20), 20);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! ## Why this is a render-only change
|
||||
//!
|
||||
//! Input routing is unchanged — the existing `handle_modal_key`
|
||||
//! 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:
|
||||
@@ -99,7 +99,7 @@ pub(super) fn render(
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────── chrome ─────────────────────────────────────
|
||||
// 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).
|
||||
@@ -156,7 +156,7 @@ fn render_divider(buf: &mut Buffer, row: Rect, theme: &Theme) {
|
||||
picker::render_divider(buf, row.x, row.y, row.width, theme, None);
|
||||
}
|
||||
|
||||
// ─────────────────────────────── resume ─────────────────────────────────────
|
||||
// resume
|
||||
|
||||
/// Exact body height (display rows) for the session-picker list.
|
||||
fn resume_body_rows(agent: &AgentView, width: u16) -> u16 {
|
||||
@@ -280,7 +280,7 @@ fn render_resume(
|
||||
None
|
||||
}
|
||||
|
||||
// ──────────────────────────────── mcps ──────────────────────────────────────
|
||||
// mcps
|
||||
|
||||
/// Exact body height (display rows) for the MCP list: one line per row.
|
||||
fn mcps_body_rows(agent: &AgentView) -> u16 {
|
||||
@@ -289,7 +289,8 @@ fn mcps_body_rows(agent: &AgentView) -> u16 {
|
||||
};
|
||||
let servers = match &s.mcps_data {
|
||||
TabDataState::Loaded(v) => v.as_slice(),
|
||||
_ => return 1, // a single "loading…" / error row
|
||||
// a single "loading…" / error row
|
||||
_ => return 1,
|
||||
};
|
||||
let rows = minimal_api::build_mcp_picker_rows(
|
||||
servers,
|
||||
@@ -376,7 +377,7 @@ fn render_mcps(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ind[i] = 2; // tool child
|
||||
ind[i] = 2;
|
||||
}
|
||||
}
|
||||
subtitle = format!(
|
||||
@@ -489,7 +490,7 @@ fn render_mcps(
|
||||
None
|
||||
}
|
||||
|
||||
// ─────────────────────────────── helpers ────────────────────────────────────
|
||||
// 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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! 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
|
||||
//! 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
|
||||
@@ -102,7 +102,8 @@ pub fn maybe_commit_plan(app: &mut AppView) {
|
||||
};
|
||||
|
||||
if minimal_api::minimal_committed_plan_id(app) == Some(tool_call_id.as_str()) {
|
||||
return; // already emitted this plan
|
||||
// already emitted this plan
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the plan as emitted only when the block was actually pushed: the
|
||||
@@ -151,7 +152,7 @@ pub fn render(
|
||||
// header (1) · controls (1) · input (0/1)
|
||||
let controls_y = (area.y + area.height).saturating_sub(1 + input_h);
|
||||
|
||||
// ── header ──
|
||||
// header
|
||||
let has_plan = minimal_api::plan_approval_view(agent)
|
||||
.map(|p| p.has_plan)
|
||||
.unwrap_or(false);
|
||||
@@ -170,7 +171,7 @@ pub fn render(
|
||||
area.width,
|
||||
);
|
||||
|
||||
// ── controls hint ──
|
||||
// controls hint
|
||||
let has_content = minimal_api::plan_approval_view(agent)
|
||||
.map(|p| !p.comments.is_empty())
|
||||
.unwrap_or(false)
|
||||
@@ -199,7 +200,7 @@ pub fn render(
|
||||
area.width,
|
||||
);
|
||||
|
||||
// ── feedback input (revise mode) ──
|
||||
// feedback input (revise mode)
|
||||
if input_h > 0 {
|
||||
let row = Rect {
|
||||
x: area.x,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! 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.
|
||||
//! It auto-hides once every todo is done, 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. Mirrors the
|
||||
//! full-TUI `TodoPane` glyphs/colors without its interactive chrome.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -20,11 +18,10 @@ use kigi_tui::theme::Theme;
|
||||
/// `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.
|
||||
/// Hidden when every todo is finished, so a completed list does not linger
|
||||
/// across turns; a new turn creating 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: &kigi_tui::app::agent_view::AgentView,
|
||||
force: bool,
|
||||
@@ -41,23 +38,21 @@ pub(super) fn todo_panel_visible(
|
||||
.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).
|
||||
/// Rows the panel will occupy, capped at [`MAX_TODO_ROWS`] unless `force`. 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: &kigi_tui::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.
|
||||
// The forced full list is clamped to the screen by the caller.
|
||||
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.
|
||||
/// The 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,
|
||||
@@ -74,10 +69,8 @@ pub(super) fn render_todo_panel(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Capped to `max_rows`, where the last row becomes `… +N more` on overflow.
|
||||
/// Mirrors the full-TUI `TodoPane`'s glyphs/colors.
|
||||
pub(super) fn todo_panel_lines(
|
||||
agent: &kigi_tui::app::agent_view::AgentView,
|
||||
max_rows: u16,
|
||||
@@ -117,8 +110,7 @@ pub(super) fn todo_panel_lines(
|
||||
};
|
||||
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
|
||||
// live-region left edge (`live::live_left_inset` = 0), so the glyph
|
||||
// column lines up with committed `◆` bullets and the prompt `❯`.
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{glyph} "), style),
|
||||
@@ -129,8 +121,8 @@ pub(super) fn todo_panel_lines(
|
||||
|
||||
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.
|
||||
// Once already forced open (still overflowing a tiny screen) the expand
|
||||
// chord is useless, so drop the hint.
|
||||
let label = if force {
|
||||
format!("\u{2026} +{remaining} more")
|
||||
} else {
|
||||
@@ -141,7 +133,6 @@ pub(super) fn todo_panel_lines(
|
||||
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();
|
||||
@@ -169,7 +160,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
@@ -178,17 +168,14 @@ mod tests {
|
||||
fn todo_panel_visibility_auto_hides_when_work_is_done() {
|
||||
use kigi_tui::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),
|
||||
@@ -198,15 +185,13 @@ mod tests {
|
||||
"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 previous turn's finished list must not linger into 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),
|
||||
@@ -217,7 +202,7 @@ mod tests {
|
||||
#[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.
|
||||
// Also empty when the row 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());
|
||||
@@ -255,7 +240,7 @@ mod tests {
|
||||
);
|
||||
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.
|
||||
// 10 todos, 3 shown, so the 4th row is the "+7 more" overflow marker.
|
||||
assert!(
|
||||
line_text(&lines[3]).contains("+7 more"),
|
||||
"got: {:?}",
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
//! 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 [`kigi_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.
|
||||
//! Minimal skips the full-screen welcome view, so a fresh session would
|
||||
//! otherwise be invisible — you land straight at the prompt. This commits a
|
||||
//! compact rounded card (logo, version, cwd, model, hint) into native
|
||||
//! scrollback via [`kigi_ratatui_inline::Terminal::insert_before`], gated on an
|
||||
//! `AppView` flag set at session creation and on `/new`, so it prints exactly
|
||||
//! once per session.
|
||||
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -21,8 +16,6 @@ use kigi_tui::app::app_view::{ActiveView, AppView};
|
||||
use kigi_tui::minimal_api;
|
||||
use kigi_tui::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) {
|
||||
@@ -30,20 +23,16 @@ pub fn maybe_commit_welcome(app: &mut AppView, terminal: &mut PagerTerminal) {
|
||||
return;
|
||||
}
|
||||
let width = terminal.viewport_area().width;
|
||||
// Too narrow to draw a bordered card — leave the flag set and retry next
|
||||
// Too narrow for a bordered card — leave the flag pending 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.
|
||||
// Move the live viewport to row 0 and clear it so the card commits at the
|
||||
// top and the app owns the window; the viewport is not bottom-pinned, so
|
||||
// later commits flow downward from here. Pre-existing native scrollback is
|
||||
// untouched.
|
||||
let live_h = terminal.viewport_area().height;
|
||||
terminal.set_viewport_area(ratatui::layout::Rect {
|
||||
x: 0,
|
||||
@@ -68,7 +57,6 @@ pub fn maybe_commit_welcome(app: &mut AppView, terminal: &mut PagerTerminal) {
|
||||
_ => (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(
|
||||
@@ -91,13 +79,14 @@ pub fn maybe_commit_welcome(app: &mut AppView, terminal: &mut PagerTerminal) {
|
||||
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.
|
||||
// The logo carries a blank separator row when present.
|
||||
let logo_block = if logo_lines > 0 { logo_lines + 1 } else { 0 };
|
||||
// Two border rows, one padding row above, the logo block, the info lines,
|
||||
// one padding row below.
|
||||
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.
|
||||
// Terminal-native themes carry no RGB to blend, so the border falls back to
|
||||
// the theme's own dim gray and the terminal default fg draws the chrome.
|
||||
let border_color = kigi_tui::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45)
|
||||
.unwrap_or(theme.gray_dim);
|
||||
|
||||
@@ -131,12 +120,10 @@ pub fn maybe_commit_welcome(app: &mut AppView, terminal: &mut PagerTerminal) {
|
||||
}
|
||||
});
|
||||
if inserted.is_err() {
|
||||
// Terminal write failed — keep the flag pending so the card retries on
|
||||
// the next frame instead of being dropped forever.
|
||||
// Keep the flag pending so a failed terminal write retries on the next
|
||||
// frame instead of dropping the card 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user