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:
2026-07-23 16:55:39 -04:00
parent ff0fb56c67
commit a02b555e66
1458 changed files with 10729 additions and 21750 deletions
+20 -31
View File
@@ -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,
+28 -17
View File
@@ -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() {
+17 -42
View File
@@ -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 });
+41 -64
View File
@@ -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,
+23 -38
View File
@@ -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);
}