//! Minimal-mode commit pipeline: which finalized blocks get printed into the //! terminal's native scrollback, and in what display mode. //! //! This module is deliberately terminal-agnostic and unit-testable: the actual //! `insert_before` call is injected as a closure by the caller (the per-frame //! draw loop, added in a later PR). The "committed frontier" is the leading //! contiguous run of finalized, non-pending entries; everything past it stays //! in the live region until it finalizes. use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::Span; use kigi_tui::app::PagerTerminal; use kigi_tui::app::app_view::{ActiveView, AppView}; use kigi_tui::appearance::AppearanceConfig; use kigi_tui::minimal_api; use kigi_tui::render::Renderable; use kigi_tui::scrollback::block::RenderBlock; use kigi_tui::scrollback::blocks::ToolCallBlock; use kigi_tui::scrollback::entry::{EntryId, ScrollbackEntry}; use kigi_tui::scrollback::state::ScrollbackState; use kigi_tui::scrollback::types::DisplayMode; use kigi_tui::scrollback::wrappers::EntryRenderer; use kigi_tui::theme::Theme; /// Blank rows emitted after each committed block — and held after each live-tail /// entry (`super::live`) — in minimal mode. /// /// Now `0`: adjacent blocks abut, relying on each block's own chrome (the accent /// column + the `◆`/bullet that marks a block start) to read the boundary. A /// full separator row per block made the transcript too airy for short /// collapsed thinking / tool blocks (dogfood feedback), so the gap is dropped. /// The constant is kept (rather than deleting the plumbing) so the spacing stays /// tunable in one place. /// /// Whatever the value, it must be applied identically on both sides of the /// commit frontier (the committed footprint here and the live-tail footprint in /// [`super::live::draw_tail`] / [`super::live::tail_height`]) so a block's total /// height is unchanged when it moves from the live region into native /// scrollback — otherwise the prompt would shift on every commit. pub(crate) const MINIMAL_BLOCK_GAP: u16 = 0; /// Whether the entry at the commit frontier may be committed to native /// scrollback yet. `is_last` is whether it is the final entry in the scrollback /// (the only one that may still be actively streaming). /// /// While a turn is **running**, a block is committable once it has finished /// running AND is not awaiting user input. The pending-input gate is what makes /// the mid-stream permission hold fall out for free: a tool blocked on a /// permission / `ask_user_question` prompt is flagged `is_pending_user_input`, /// so the leading-run scan stops *before* it and it stays in the live region /// until the user answers. /// /// **Lingering-`is_running` agent messages (the "accumulate, don't cap" fix):** /// the tracker leaves an agent message's `is_running` flag set until turn end — /// `handle_tool_call` resets `current_agent_msg` to `None` *without* finishing /// the entry when a tool follows. That stale flag would wedge the frontier at /// the message, piling the rest of the turn into the fixed-height live tail /// (which then scrolls/caps). But an agent message that has a *later* block is /// provably complete (the tracker moved past it and will never append to it /// again), so we commit it anyway. The commit pass finalizes it before /// rendering, so it prints in finished form. Only agent messages get this /// relaxation: a running **tool** may still update its result, so it keeps the /// strict `is_running` gate, and the **last** entry always stays live. /// /// **Background-task lifecycle blocks commit even while "running".** A fresh /// background task is pushed as a `BgTask` "started" block with the entry's /// `is_running` flag set (`handle_task_backgrounded` → `set_last_running(true)`), /// but that flag only drives bullet animation — the block's *content* never /// changes (task completion pushes a **separate** `bg_task_completed`/`failed` /// block, and live output goes to the task store, not this entry). An async task /// can outlive its turn, so gating it on `is_running` would wedge the commit /// frontier for the rest of the turn: the "started" block — and everything after /// it — would stay stuck in the live tail (scrolled out of view), so the task is /// invisible until it finishes. Committing it immediately matches design §6.11 /// ("status blocks committed") and keeps the frontier moving. /// /// Once the turn is **idle** (`turn_running == false`) everything except a /// pending-user-input block is stable and committable: the tracker can also /// leave a thinking block's `is_running` flag set after the turn ends (finalize /// missed at a transition), and that stale flag must not permanently wedge the /// commit frontier. The caller finalizes such entries before rendering so they /// print in their finished form. The pending-input gate deliberately applies in /// every turn state (see the first check below). /// /// ⚠️ **Print-once caveat for idle-pushed running blocks:** a spinner-style /// entry pushed as `running` while the turn is idle is committed *immediately* /// by this rule — any later in-place fill of that entry never reaches the /// terminal. Handlers that fill placeholders (e.g. `SessionRecap`) must check /// `ScrollbackState::is_committed` and append a fresh block instead. pub fn is_committable(entry: &ScrollbackEntry, turn_running: bool, is_last: bool) -> bool { // A block awaiting user input (permission / ask_user_question) holds the // frontier in EVERY turn state, not just mid-turn: its rendered form is // still going to change when the prompt resolves, so committing it // (print-once) would freeze the "waiting" form on the terminal. The idle // case is defensive — permissions normally resolve within the turn, but a // pending mark must never be committed out from under its modal. if entry.is_pending_user_input { return false; } if !turn_running { return true; } if !entry.is_running { return true; } // Running, mid-turn. Two block kinds are safe to commit despite a set // `is_running` flag: a BgTask lifecycle block (finalized event; flag is // animation-only — see above), and a non-last agent message (the tracker has // provably moved past it). A running **tool** may still update its result, // and the **last** non-BgTask entry may still be streaming, so both stay live. matches!(entry.block, RenderBlock::BgTask(_)) || (!is_last && matches!(entry.block, RenderBlock::AgentMessage(_))) } /// The display mode a block should be committed in (minimal mode, print-once). /// /// Independent of the interactive `default_display_mode` / `finished_display_mode` /// because committed scrollback can't be re-folded later (it is static terminal /// text). The per-type fidelity policy (design decision K9) lives here in one /// place: messages full, reasoning collapsed-but-expandable, tool output /// truncated, diffs always full. pub fn minimal_commit_display_mode(block: &RenderBlock) -> DisplayMode { match block { // Diffs are the key artifact of an edit — always full. RenderBlock::ToolCall(ToolCallBlock::Edit(_)) => DisplayMode::Expanded, // Other tool calls: truncated (first/last N + hidden-line count). RenderBlock::ToolCall(_) => DisplayMode::Truncated, // Reasoning: collapsed marker ("Thought for Xs"); expandable via Ctrl+E. RenderBlock::Thinking(_) => DisplayMode::Collapsed, // Messages, system/session events, etc.: full. _ => DisplayMode::Expanded, } } /// One step of the frontier walk — the single classification shared by every /// consumer ([`commit_leading_run`], [`scan_frontier`]). Keeping this in one /// place is load-bearing: the commit pass, the `will_commit` resize gate, the /// viewport sizing (`tail_height`), and the tail renderer must all agree on /// where the frontier stops, or a block's height flips between the live region /// and native scrollback and the prompt jumps on commit. enum Step { /// Uncommitted and committable: a commit pass consumes it. Commit, /// Already committed: skip over it (the id-set is authoritative; the scan /// cursor is only a lower-bound hint). Skip, /// End of entries, or the first uncommitted non-committable entry — the /// live tail starts here. Stop, } /// Classify the entry at `i` relative to the commit frontier. fn classify(state: &ScrollbackState, i: usize, turn_running: bool) -> Step { let is_last = i + 1 >= state.len(); match state.get(i) { None => Step::Stop, Some(e) if minimal_api::is_committed(state, e) => Step::Skip, Some(e) if !is_committable(e, turn_running, is_last) => Step::Stop, Some(_) => Step::Commit, } } /// Read-only projection of what a commit pass would do, for the consumers that /// must agree with it without running it. pub struct FrontierScan { /// Index of the first entry a commit pass would NOT consume — where the /// live tail starts after this frame's commit. Everything from here on /// stays in the pinned live region. pub tail_start: usize, /// Whether a commit pass would emit at least one block into native /// scrollback this frame. pub will_commit: bool, } /// Walk the frontier read-only (no cursor mutation, nothing marked committed). /// /// Used by the overlay host's viewport sizing ([`super::overlay::sync_viewport`] /// via [`super::live::tail_height`]) and its commit gate — both run *before* /// [`commit_active`] in the frame and must mirror its stop condition exactly. pub fn scan_frontier(state: &ScrollbackState, turn_running: bool) -> FrontierScan { let mut i = minimal_api::commit_scan_cursor(state); let mut will_commit = false; loop { match classify(state, i, turn_running) { Step::Stop => break, Step::Skip => i += 1, Step::Commit => { will_commit = true; i += 1; } } } FrontierScan { tail_start: i, will_commit, } } /// Commit the leading contiguous run of newly-committable entries (those past /// the scan cursor that are finalized and not pending), in insertion order. /// /// For each such entry, `on_commit(state, index)` runs first — the caller /// finalizes/stamps the entry and renders it into native scrollback — and /// **only if it returns `true`** is the entry marked committed. A `false` /// return (the terminal write failed) stops the walk with the entry still /// uncommitted and the cursor before it, so the next frame retries instead of /// marking a block committed that never reached the terminal (a print-once /// mode can never re-emit it — the block would silently vanish; bugbot). /// /// The scan stops at the first still-running / pending entry (so a turn /// streams smoothly and a sibling tool awaiting permission holds the /// frontier). Returns the number of entries committed. /// /// This is the ONE mutating frontier walk; [`commit_active`] drives it in /// production and the unit tests below drive it directly, so the tested loop /// and the production loop cannot drift. pub fn commit_leading_run( state: &mut ScrollbackState, turn_running: bool, mut on_commit: impl FnMut(&mut ScrollbackState, usize) -> bool, ) -> usize { let mut i = minimal_api::commit_scan_cursor(state); let mut count = 0usize; loop { match classify(state, i, turn_running) { Step::Stop => break, Step::Skip => i += 1, Step::Commit => { if !on_commit(state, i) { break; // emit failed — leave uncommitted, retry next frame } minimal_api::mark_committed(state, i); count += 1; i += 1; } } } minimal_api::set_commit_scan_cursor(state, i); count } /// Appearance used when committing blocks to native scrollback. /// /// Timestamps are forced off: `EntryRenderer::desired_height` subtracts the /// timestamp column but `render` treats it as an overlay, so leaving timestamps /// on would make the reserved `insert_before` height disagree with the painted /// rows (design decision K5). Block horizontal padding is zeroed so committed /// content is flush-left with the welcome card (which paints edge-to-edge); /// paired with [`EntryRenderer::with_hide_accent`] reclaiming the accent /// column, glyphs start at column 0. The live region's prompt / status / /// info rows mirror that via [`super::live::live_left_inset`]. pub(crate) fn committed_appearance(base: &AppearanceConfig) -> AppearanceConfig { let mut a = base.clone(); a.show_timestamps = false; // Flush-left minimal look: no block horizontal padding (align with the // welcome card, which paints edge-to-edge with no outer h-pad). a.scrollback.layout.block_pad_left = 0; a.scrollback.layout.block_pad_right = 0; a } /// Build the renderer used for a committed (print-once) block: no selection /// highlight, a static tick (no running-wave animation), timestamps off. fn committed_renderer<'a>( entry: &'a ScrollbackEntry, theme: &'a Theme, appearance: AppearanceConfig, cwd: &'a std::path::Path, ) -> EntryRenderer<'a> { EntryRenderer::new(entry, theme) .with_appearance(appearance) .with_cwd(Some(cwd)) .with_tick(0) // Blend committed blocks with the real terminal background (no // user-message `bg_light` band etc.). .with_flat_background(true) // Drop the left accent bar for a cleaner, un-gutter'd minimal look; the // per-block `◆`/bullet marker still reads the block boundary. .with_hide_accent(true) } /// Emit one committed block into native scrollback via `insert_before`, capping /// its height at `max_rows` (0 = unbounded). /// /// "Diffs always full" (K9) means a multi-thousand-line `Edit` would otherwise /// allocate one `Buffer` of `desired_height` rows and emit a huge writer-thread /// send burst (§6.15). When the block is taller than `max_rows`, only the top /// `max_rows - 1` content rows are committed and the final row becomes a /// `… N more lines — /transcript to view` footer. The block is laid out at its /// **full** `desired_height` so wrapping is byte-identical to an uncapped commit /// (K5); the `insert_before` buffer is only `commit_h` rows tall, so any content /// past it is clipped — bounding the allocation to the cap. fn insert_committed( terminal: &mut PagerTerminal, renderer: EntryRenderer<'_>, width: u16, max_rows: u16, footer_style: Style, ) -> std::io::Result<()> { let full_h = renderer.desired_height(width); if full_h == 0 { return Ok(()); } let commit_h = if max_rows > 0 && full_h > max_rows { max_rows } else { full_h }; // Propagated (not swallowed): the caller must NOT mark the entry committed // when the terminal write failed — print-once means a marked-but-unprinted // block can never be emitted again (bugbot). terminal.insert_before(commit_h, move |buf| { paint_committed(buf, renderer, width, full_h, footer_style); })?; insert_gap(terminal); Ok(()) } /// Emit [`MINIMAL_BLOCK_GAP`] blank rows into native scrollback as the trailing /// gap after a committed block. The rows are left unpainted, so they inherit the /// terminal's own background (matching the flat, transparent committed look). pub(super) fn insert_gap(terminal: &mut PagerTerminal) { if MINIMAL_BLOCK_GAP == 0 { return; } let _ = terminal.insert_before(MINIMAL_BLOCK_GAP, |_buf| {}); } /// Paint a committed block into `buf` (a `commit_h`-row buffer), laying it out at /// its full `full_h` so wrapping matches an uncapped commit exactly (K5). When /// `buf` is shorter than `full_h` the block is capped: rows past it are clipped /// and the final row becomes a `… N more lines — /transcript to view` footer /// (§6.15). Extracted from [`insert_committed`] so the cap is unit-testable /// without a live terminal. fn paint_committed( buf: &mut ratatui::buffer::Buffer, renderer: EntryRenderer<'_>, width: u16, full_h: u16, footer_style: Style, ) { let commit_h = buf.area.height; let area = Rect { x: buf.area.x, y: buf.area.y, width, height: full_h, }; renderer.render(area, buf); if commit_h > 0 && commit_h < full_h { // Top `commit_h - 1` rows are content; the last row is the footer. let hidden = full_h.saturating_sub(commit_h.saturating_sub(1)); let y = buf.area.y + commit_h - 1; let row = Rect { x: buf.area.x, y, width, height: 1, }; // Dim default-fg chrome (not hard-coded DarkGray) under terminal-native. let style = footer_style.bg(Color::Reset); // Clear any clipped content that landed on the footer row first. buf.set_style(row, style); let text = format!("\u{2026} {hidden} more lines \u{2014} /transcript to view"); buf.set_span(buf.area.x, y, &Span::styled(text, style), width); } } /// Commit the active agent's newly-finalized blocks into native scrollback. /// /// For each entry in the leading committable run: stamp its print-once display /// mode (K9), then `insert_before` it using the shared `EntryRenderer` at /// exactly `desired_height(width)` rows (K5). /// /// On resume/attach (`loading_replay`) the replayed transcript is printed into /// native scrollback like any other finalized block — minimal has no separate /// history pane, so the terminal's scrollback *is* the history; without this a /// resumed session looks empty (nothing redrawn). The commit frontier /// (`committed` flags + `commit_scan_cursor`) still guarantees each block prints /// exactly once. 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 }; // Snapshot the commit appearance before borrowing `agents` mutably. let appearance = committed_appearance(&app.appearance); let Some(agent) = app.agents.get_mut(&id) else { return; }; // Hold commits while a centered fullscreen app-modal (settings) is open: it // takes the whole live region, so an `insert_before` underneath it would // scroll the popup. Deferred commits flush on the next frame after it closes. if super::overlay::app_modal_active(agent) { return; } // NB: `sync_pending_user_input_marks` already ran at the top of the frame // ([`sync_pending_marks`], called from `crate::draw` BEFORE the viewport // sizing) so the sizing pass and this commit pass judge committability // against the same marks — syncing here made a tool look committable to // `sync_viewport`/`tail_height` on the very frame its permission arrived. // // Whether a turn is actively running. When idle, every remaining entry is // stable and committable (see `is_committable`); a stale `is_running` flag // left by the tracker must not wedge the frontier. let turn_running = agent.session.state.is_turn_running(); let cwd = agent.session.cwd.as_path(); let sb = &mut agent.scrollback; // NB: resume/attach replay (`agent.session.loading_replay`) intentionally // falls through to the normal commit pass below, so the loaded transcript is // printed into native scrollback (a resumed session must be visible). let theme = Theme::current(); let footer_style = theme.dim(); let max_rows = appearance.minimal_max_commit_rows; let width = terminal.viewport_area().width; if width == 0 { return; } // Drive the ONE frontier walk (`commit_leading_run` — also what the unit // tests exercise) with the production per-entry work: finalize, stamp the // print-once display mode, print, then remember folded blocks for Ctrl+E. commit_leading_run(sb, turn_running, |sb, i| { // If the turn is idle but this entry still carries a stale // `is_running` flag, finalize it first so it renders in its // finished form (e.g. "Thought for Xs", not an animated "Thinking…"). if let Some(id) = sb.get(i).filter(|e| e.is_running).map(|e| e.id) { sb.finish_running(id); } // Stamp the print-once display mode before measuring/rendering. if let Some(e) = sb.get_mut(i) { let mode = minimal_commit_display_mode(&e.block); e.set_display_mode(mode); } if let Some(e) = sb.get(i) { // `insert_committed` pushes these rows above the pinned viewport, // into the terminal's own scrollback (capped — §6.15). A failed // write returns `false` so the walk leaves the entry uncommitted // (retried next frame) instead of marking a never-printed block. // // NOTE (print-once contract): from a successful insert on, the // entry's content is frozen on the user's terminal. Mutating it in // place later (`get_by_id_mut` + edit, the `/recap` fill pattern) // will NOT reach the screen — append a fresh block instead (see // the `SessionRecap` handler in `acp_handler.rs`). let renderer = committed_renderer(e, &theme, appearance.clone(), cwd); if insert_committed(terminal, renderer, width, max_rows, footer_style).is_err() { return false; } } // Remember folded blocks (collapsed reasoning / truncated output) so // `Ctrl+E` / `/expand` can re-print them in full later (K10) — only // after the print actually succeeded. if let Some((id, mode)) = sb.get(i).map(|e| (e.id, e.display_mode())) && matches!(mode, DisplayMode::Collapsed | DisplayMode::Truncated) { minimal_api::record_committed_for_expand(sb, id); } true }); // Stamp the still-uncommitted "live tail" entries with the same print-once // display policy they will commit with (collapsed reasoning, truncated tool // output, full diffs/messages). The tail renders each entry at its current // `display_mode`, but blocks stream Expanded and commit folded — so without // this the live region is tall while a block streams and snaps short the // instant it finalizes, jerking the prompt upward (dogfood nit). Matching // the tail height to the committed height keeps the prompt put across a // commit. Idempotent: `set_display_mode` no-ops when unchanged. let mut j = minimal_api::commit_scan_cursor(sb); while let Some(e) = sb.get_mut(j) { let mode = minimal_commit_display_mode(&e.block); e.set_display_mode(mode); j += 1; } } /// Re-print the entries queued by `Ctrl+E` / `/expand` into native scrollback, /// fully expanded, below the committed conversation (design decision K10). /// /// Committed terminal text cannot be mutated in place, so "expanding" a folded /// block is an honest re-print of the same entry in `Expanded` mode. The entry /// itself is already committed and past the scan cursor, so flipping its display /// mode has no effect on the live tail. /// /// The re-print is **uncapped** (`max_rows = 0`): the initial commit truncated /// the block under `minimal_max_commit_rows`, and this is the explicit "show me /// the whole thing" action — capping it again just reprinted the same footer /// (bugbot). A one-shot user-initiated tall insert is an acceptable burst. pub fn expand_pending(app: &mut AppView, terminal: &mut PagerTerminal) { if minimal_api::minimal_pending_expand(app).is_empty() { return; } let id = match &app.active_view { ActiveView::Agent(id) => *id, _ => return, }; let width = terminal.viewport_area().width; if width == 0 { return; } let appearance = committed_appearance(&app.appearance); // Guards: a missing active agent must leave the IDs queued, so confirm it // exists before consuming the queue below (the queue take needs `&mut app`, // which can't overlap the agent borrow — hence the check-then-reborrow). // Likewise hold the whole queue while a centered app-modal owns the live // region — an `insert_before` would scroll the popup and the user wouldn't // see the re-print (same hold as `commit_active`; bugbot). match app.agents.get(&id) { Some(agent) if !super::overlay::app_modal_active(agent) => {} _ => return, } let theme = Theme::current(); let footer_style = theme.dim(); // Consume the expand queue only after every guard above has passed: a // non-agent active view, a 0-width (probe) frame, or an open app-modal must // leave the IDs queued for a later frame, not silently drop a Ctrl+E / // /expand request. let ids = minimal_api::take_minimal_pending_expand(app); let mut requeue: Vec = Vec::new(); { let Some(agent) = app.agents.get_mut(&id) else { // Can't happen (existence checked just above, nothing in between // can remove the agent) — but if it ever does, the drained queue // must go back rather than silently vanish. minimal_api::requeue_minimal_pending_expand(app, ids); return; }; let cwd = agent.session.cwd.as_path(); let sb = &mut agent.scrollback; 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 }; if let Some(e) = sb.get_mut(idx) { e.set_display_mode(DisplayMode::Expanded); } if let Some(e) = sb.get(idx) { let renderer = committed_renderer(e, &theme, appearance.clone(), cwd); if insert_committed(terminal, renderer, width, 0, footer_style).is_err() { // Terminal write failed: keep this id and the rest queued // so the request retries next frame instead of vanishing. requeue.push(eid); requeue.extend(iter); break; } } } } if !requeue.is_empty() { minimal_api::requeue_minimal_pending_expand(app, requeue); } } /// Re-mark tool entries that are blocked on a pending permission/question so /// the frontier holds them in the live region (the full TUI does this each /// frame in `AgentView::draw`, which minimal bypasses); `is_committable` reads /// `is_pending_user_input`. /// /// Called at the TOP of the frame (from [`crate::draw`]), before /// [`super::overlay::sync_viewport`]: the viewport sizing, the `will_commit` /// gate, and the commit pass must all judge committability against the same /// marks — syncing inside the commit pass let a just-arrived permission's tool /// look committable to the sizing walk for one frame (bugbot). pub fn sync_pending_marks(app: &mut AppView) { if let ActiveView::Agent(id) = &app.active_view && let Some(agent) = app.agents.get_mut(id) { minimal_api::sync_pending_user_input_marks(agent); } } #[cfg(test)] mod tests { use super::*; use kigi_tui::scrollback::block::RenderBlock; use kigi_tui::scrollback::entry::ScrollbackEntry; use kigi_tui::scrollback::state::ScrollbackState; use ratatui::style::Color; fn test_cwd() -> &'static std::path::Path { std::path::Path::new("/test/session") } fn finalized(text: &str) -> ScrollbackEntry { ScrollbackEntry::new(RenderBlock::stub(text, Color::Blue)) } fn running(text: &str) -> ScrollbackEntry { ScrollbackEntry::running(RenderBlock::stub(text, Color::Blue)) } /// Run a commit pass for a RUNNING turn, returning the emitted indices. fn commit_collect(state: &mut ScrollbackState) -> Vec { let mut seen = Vec::new(); commit_leading_run(state, true, |_, i| { seen.push(i); true }); seen } #[test] fn commits_leading_finalized_run_and_stops_at_running() { let mut s = ScrollbackState::new(); s.push(finalized("a")); s.push(finalized("b")); s.push(running("c")); s.push(finalized("d")); // after the running block — must NOT commit yet assert_eq!(commit_collect(&mut s), vec![0, 1]); assert_eq!(minimal_api::commit_scan_cursor(&s), 2); 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())); assert!(!minimal_api::is_committed(&s, s.get(3).unwrap())); // Finalize "c"; the next pass commits "c" then "d". s.get_mut(2).unwrap().mark_completed(); assert_eq!(commit_collect(&mut s), vec![2, 3]); assert_eq!(minimal_api::commit_scan_cursor(&s), 4); } #[test] 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 s.push(finalized("after")); assert!(s.set_pending_user_input(tool, true)); // Stops before the pending tool, even though it (and "after") are finalized. assert_eq!(commit_collect(&mut s), vec![0]); // Resolving the prompt releases the rest of the run. assert!(s.set_pending_user_input(tool, false)); assert_eq!(commit_collect(&mut s), vec![1, 2]); } #[test] fn running_agent_message_commits_once_a_later_block_exists() { // The tracker leaves an agent message's `is_running` flag set until turn // end (handle_tool_call resets current_agent_msg without finishing the // entry when a tool follows). Minimal must still commit that message // mid-turn once a later block proves it's complete — otherwise the rest // of the turn piles up in the fixed-height live tail and scrolls instead // of accumulating into native scrollback. let mut s = ScrollbackState::new(); s.push(ScrollbackEntry::running(RenderBlock::agent_message( "answer text", ))); // While it's the last entry it may still be streaming → stays live. assert_eq!(commit_collect(&mut s), Vec::::new()); assert_eq!(minimal_api::commit_scan_cursor(&s), 0); // A later block (the tracker moved on) proves the message is done → it // commits even though its is_running flag still lingers. The new // last/running entry stays in the live tail. s.push(running("tool")); assert_eq!(commit_collect(&mut s), vec![0]); assert!(minimal_api::is_committed(&s, s.get(0).unwrap())); assert!(!minimal_api::is_committed(&s, s.get(1).unwrap())); } #[test] fn running_tool_still_holds_the_frontier_even_with_a_later_block() { // The agent-message relaxation must NOT extend to tools: a running tool // can still update its result, so committing it (print-once) would lose // 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 s.push(finalized("after")); assert_eq!(commit_collect(&mut s), vec![0]); assert_eq!(minimal_api::commit_scan_cursor(&s), 1); } #[test] fn bg_task_started_commits_while_running_and_does_not_wedge_frontier() { // A fresh background task is pushed as a running "started" block // (`set_last_running(true)`). Its `is_running` flag is animation-only — // the block is a finalized lifecycle event whose content never changes — // so it must commit immediately even mid-turn. Otherwise it wedges the // frontier and the task (plus everything after it) stays hidden in the // live tail until the task finishes (the reported dogfood bug). let mut s = ScrollbackState::new(); s.push(finalized("a")); s.push(ScrollbackEntry::running(RenderBlock::bg_task( "sleep 60", "task-1", ))); s.push(running("later tool")); // more turn output after the bg task // "a" + the running bg task commit; only the trailing running tool stays. assert_eq!(commit_collect(&mut s), vec![0, 1]); assert!(minimal_api::is_committed(&s, s.get(1).unwrap())); assert!(!minimal_api::is_committed(&s, s.get(2).unwrap())); } #[test] fn bg_task_started_commits_as_last_running_entry() { // Even as the last entry of a still-running turn the bg "started" block // commits — a lifecycle block never streams more content (completion is // a separate block). let mut s = ScrollbackState::new(); s.push(finalized("a")); s.push(ScrollbackEntry::running(RenderBlock::bg_task( "sleep 60", "task-1", ))); assert_eq!(commit_collect(&mut s), vec![0, 1]); } #[test] fn no_double_commit_after_mid_list_shift_remove() { let mut s = ScrollbackState::new(); let a = s.push(finalized("a")); s.push(finalized("b")); s.push(finalized("c")); assert_eq!(commit_collect(&mut s), vec![0, 1, 2]); assert_eq!(minimal_api::commit_scan_cursor(&s), 3); // Remove an already-committed entry below the cursor (shift_remove shifts // 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 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 } #[test] fn mid_list_removal_below_cursor_does_not_strand_uncommitted_entries() { // Regression (review bug 2): a committed placeholder ("Loading // session...") is removed AFTER new uncommitted entries were appended // past the cursor — the `/resume` / reconnect `SessionLoaded` ordering. // Removing below the cursor shifts the uncommitted entries down one; // without the cursor decrement in `remove_entry` the first of them // slid below the cursor and was never committed NOR drawn in the live // tail (silently missing from minimal mode). let mut s = ScrollbackState::new(); s.push(finalized("old-1")); let placeholder = s.push(finalized("Loading session...")); // A draw commits both; cursor = 2. let mut seen = Vec::new(); commit_leading_run(&mut s, false, |_, i| { seen.push(i); true }); assert_eq!(seen, vec![0, 1]); assert_eq!(minimal_api::commit_scan_cursor(&s), 2); // Replay appends entries, then the placeholder is removed in the same // event cycle (before the next commit pass). s.push(finalized("replayed-A")); s.push(finalized("replayed-B")); assert!(s.remove_entry(placeholder)); // The cursor moved down with the shifted entries. assert_eq!(minimal_api::commit_scan_cursor(&s), 1); // The next pass commits BOTH replayed entries — none stranded. let mut seen = Vec::new(); commit_leading_run(&mut s, false, |_, i| { seen.push(i); 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 } #[test] fn pending_user_input_holds_the_frontier_even_when_idle() { // A block awaiting a permission / question answer must never commit, // even if the turn state reads idle (e.g. a prompt outliving its turn): // its rendered form still changes when the prompt resolves, and a // committed copy is frozen. The idle relaxation only applies to // *stale-running* flags, not pending-input marks. let mut s = ScrollbackState::new(); s.push(finalized("a")); let tool = s.push(finalized("tool")); assert!(s.set_pending_user_input(tool, true)); let mut seen = Vec::new(); commit_leading_run(&mut s, false, |_, i| { seen.push(i); true }); assert_eq!(seen, vec![0], "pending entry must hold the frontier"); assert!(!minimal_api::is_committed(&s, s.get(1).unwrap())); // Resolving the prompt releases it. assert!(s.set_pending_user_input(tool, false)); let mut seen = Vec::new(); commit_leading_run(&mut s, false, |_, i| { seen.push(i); true }); assert_eq!(seen, vec![1]); } #[test] fn failed_emit_leaves_entry_uncommitted_for_retry() { // Regression (bugbot "Committed flag set on IO failure"): a terminal // write failure must NOT mark the entry committed — print-once means a // marked-but-unprinted block can never be emitted again. The walk stops // with the cursor before the failed entry and retries next frame. let mut s = ScrollbackState::new(); s.push(finalized("a")); s.push(finalized("b")); // First pass: the emit fails on the first entry. let mut calls = 0usize; let n = commit_leading_run(&mut s, false, |_, _| { calls += 1; false }); assert_eq!(n, 0, "nothing committed on failure"); assert_eq!(calls, 1, "walk stops at the first failure"); assert!(!minimal_api::is_committed(&s, s.get(0).unwrap())); assert_eq!(minimal_api::commit_scan_cursor(&s), 0, "cursor holds"); // Retry pass succeeds and commits both. let n = commit_leading_run(&mut s, false, |_, _| true); assert_eq!(n, 2); assert!(minimal_api::is_committed(&s, s.get(0).unwrap())); assert!(minimal_api::is_committed(&s, s.get(1).unwrap())); } #[test] fn scan_frontier_mirrors_commit_leading_run() { // `scan_frontier` (read-only: viewport sizing + the will-commit gate) // must agree exactly with the mutating walk, in every phase. let mut s = ScrollbackState::new(); s.push(finalized("a")); s.push(finalized("b")); s.push(running("c")); s.push(finalized("d")); // Pre-commit: the pass would commit a+b and stop at the running entry. let scan = scan_frontier(&s, true); assert!(scan.will_commit); assert_eq!(scan.tail_start, 2); let n = commit_leading_run(&mut s, true, |_, _| true); assert_eq!(n, 2); assert_eq!(minimal_api::commit_scan_cursor(&s), scan.tail_start); // Post-commit: nothing left to commit; the tail starts at the cursor. let scan = scan_frontier(&s, true); assert!(!scan.will_commit); assert_eq!(scan.tail_start, 2); // Idle with no entries pending: everything committable. let scan = scan_frontier(&s, false); assert!(scan.will_commit); assert_eq!(scan.tail_start, 4); } #[test] fn remove_from_below_frontier_then_push_still_commits() { let mut s = ScrollbackState::new(); s.push(finalized("a")); s.push(finalized("b")); s.push(finalized("c")); assert_eq!(commit_collect(&mut s), vec![0, 1, 2]); // Rewind: drop everything from index 1 (keep only "a"). Without the // cursor clamp this would strand the cursor at 3 and silently skip the // next pushes. let removed = s.remove_from(1); assert_eq!(removed.len(), 2); assert_eq!(minimal_api::commit_scan_cursor(&s), 1); s.push(finalized("d")); // index 1 assert_eq!(commit_collect(&mut s), vec![1]); } #[test] fn commit_leading_run_advances_frontier_and_marks_committed_once() { let mut s = ScrollbackState::new(); s.push(finalized("h1")); s.push(finalized("h2")); s.push(finalized("h3")); // Advances the frontier, marking the leading finalized run committed. let mut emitted = Vec::new(); let n = commit_leading_run(&mut s, false, |_, i| { emitted.push(i); true }); assert_eq!(n, 3); assert_eq!(emitted, vec![0, 1, 2]); assert_eq!(minimal_api::commit_scan_cursor(&s), 3); assert!((0..3).all(|i| minimal_api::is_committed(&s, s.get(i).unwrap()))); // A second pass commits nothing (already-committed entries are skipped). let mut again = Vec::new(); commit_leading_run(&mut s, false, |_, i| { again.push(i); true }); assert!(again.is_empty()); } #[test] fn idle_turn_commits_past_stale_running_entry() { // Regression for the missing-edit/stuck-spinner bug: the agent tracker // can leave an entry's `is_running` flag set after the turn ends (e.g. a // thinking block whose finalize was missed at the thinking→tool // transition). While the turn runs, that entry correctly holds the // 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 s.push(finalized("c")); // Running turn: blocked at the running entry. let mut seen = Vec::new(); commit_leading_run(&mut s, true, |_, i| { seen.push(i); true }); assert_eq!(seen, vec![0]); assert_eq!(minimal_api::commit_scan_cursor(&s), 1); // Idle turn: commit everything past the stale flag. let mut seen = Vec::new(); commit_leading_run(&mut s, false, |_, i| { seen.push(i); true }); assert_eq!(seen, vec![1, 2]); assert_eq!(minimal_api::commit_scan_cursor(&s), 3); } #[test] fn clear_resets_the_frontier() { let mut s = ScrollbackState::new(); s.push(finalized("a")); commit_collect(&mut s); assert_eq!(minimal_api::commit_scan_cursor(&s), 1); s.clear(); assert_eq!(minimal_api::commit_scan_cursor(&s), 0); } /// Height-exactness guard (design K5 / risk #1). `commit_active` reserves /// exactly `desired_height(width)` rows via `insert_before`; if `render` /// paints real content beyond that, those rows are silently clipped (lost) /// from native scrollback. Render each block type into an over-tall buffer /// and assert no non-space glyph lands past `desired_height`. (Background /// fill of blank spaces past `h` is fine — only real content matters.) fn assert_committed_fits(label: &str, block: RenderBlock, width: u16) { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let mut entry = ScrollbackEntry::new(block); entry.set_display_mode(minimal_commit_display_mode(&entry.block)); let theme = Theme::current(); let appearance = committed_appearance(&AppearanceConfig::default()); 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 // 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(); let extra = 8u16; let area = Rect::new(0, 0, width, h + extra); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); for y in h..(h + extra) { for x in chrome..width { let sym = buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "); assert!( sym.trim().is_empty(), "{label}@{width}: content {sym:?} painted at row {y} col {x}, past \ desired_height {h} — insert_before would clip it from scrollback" ); } } } #[test] fn committed_renderer_uses_owning_session_cwd_for_tool_paths() { use ratatui::buffer::Buffer; let cwd = std::path::Path::new("/alternate/worktree"); let mut entry = ScrollbackEntry::new(RenderBlock::edit("/alternate/worktree/src/main.rs", None)); entry.set_display_mode(DisplayMode::Expanded); let theme = Theme::current(); let appearance = committed_appearance(&AppearanceConfig::default()); let renderer = committed_renderer(&entry, &theme, appearance, cwd); let width = 80; let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); let mut text = String::new(); for y in 0..height { for x in 0..width { text.push_str(buf[(x, y)].symbol()); } } assert!(text.contains("src/main.rs"), "rendered text: {text:?}"); assert!( !text.contains("/alternate/worktree"), "session prefix should be elided: {text:?}" ); } #[test] fn committed_blocks_fit_desired_height() { // Thinking blocks render zero rows unless `show_thinking_blocks` is on. // The toggle is a thread-local, so pin it on here so the thinking // block's committed height is actually exercised. minimal_api::set_show_thinking_blocks(true); let long = "Hello there — this is a longer message that should wrap across \ several lines at narrow widths to exercise the wrapping math, with \ enough words to overflow eighty columns comfortably."; for width in [40u16, 80, 120] { assert_committed_fits("user_prompt", RenderBlock::user_prompt(long), width); assert_committed_fits( "agent_message", RenderBlock::agent_message(format!( "{long}\n\n- bullet one\n- bullet two\n\n```rust\nfn main() {{}}\n```" )), width, ); assert_committed_fits("thinking", RenderBlock::thinking(long), width); assert_committed_fits( "execute", RenderBlock::execute_with_output( "cargo build --release", "line 1\nline 2\nline 3\nline 4\nline 5\nline 6", None::, ), width, ); assert_committed_fits("edit", RenderBlock::edit("src/main.rs", None), width); assert_committed_fits("read", RenderBlock::read("src/main.rs", None), width); assert_committed_fits( "list_dir", RenderBlock::list_dir_with_output("src", "a.rs\nb.rs\nc.rs"), width, ); assert_committed_fits("search", RenderBlock::search("TODO", 0, vec![]), width); assert_committed_fits("system", RenderBlock::system("Session restored"), width); assert_committed_fits("bg_task", RenderBlock::bg_task("sleep 30", "task-1"), width); } } /// Regression pinned: `md_style::to_anstyle` used to map `Color::Reset` /// to a concrete ANSI-7 silver, washing out assistant/thinking markdown /// body text on light terminals; `highlight_bash_command` leaked raw /// syntect RGB. #[test] fn terminal_native_lock_paints_only_native_colors() { use kigi_tui::theme::cache as theme_cache; use ratatui::buffer::Buffer; let _guard = theme_cache::test_lock() .lock() .unwrap_or_else(|e| e.into_inner()); struct LockReset; impl Drop for LockReset { fn drop(&mut self) { kigi_tui::theme::cache::set_terminal_native_lock(false); } } let _reset = LockReset; theme_cache::set_terminal_native_lock(true); minimal_api::set_show_thinking_blocks(true); let md = "Intro paragraph with **bold**, _italic_, `inline code`, and a \ [link](https://example.com).\n\n# Heading one\n\n## Heading two\n\n\ - item one\n- item two\n\n> a quote\n\n```rust\nfn main() { println!(\"hi\"); }\n```"; use similar::ChangeTag; let hunk = vec![ kigi_tui::diff::DiffLine { text: "let x = 1;\n".into(), lo: 1, ln: 1, tag: ChangeTag::Equal, }, kigi_tui::diff::DiffLine { text: "let y = 2;\n".into(), lo: 2, ln: 0, tag: ChangeTag::Delete, }, kigi_tui::diff::DiffLine { text: "let y = 3;\n".into(), lo: 0, ln: 2, tag: ChangeTag::Insert, }, ]; let blocks = vec![ ("agent_message", RenderBlock::agent_message(md)), ( "thinking", RenderBlock::thinking("Weighing the *options* with `care`."), ), ("user_prompt", RenderBlock::user_prompt("run the tests")), ( "edit", RenderBlock::edit_with_hunks("src/main.rs", vec![hunk]), ), ( "execute", RenderBlock::execute_with_output("cargo build", "ok\n", None::), ), ("system", RenderBlock::system("Session restored")), ]; for (label, block) in blocks { let mut entry = ScrollbackEntry::new(block); entry.set_display_mode(minimal_commit_display_mode(&entry.block)); let theme = Theme::current(); let appearance = committed_appearance(&AppearanceConfig::default()); let renderer = committed_renderer(&entry, &theme, appearance, test_cwd()); let width = 100u16; let h = renderer.desired_height(width).max(1); let area = Rect::new(0, 0, width, h); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); for y in 0..h { for x in 0..width { let Some(cell) = buf.cell((x, y)) else { continue; }; for (which, c) in [("fg", cell.fg), ("bg", cell.bg)] { assert!( !matches!(c, Color::Rgb(..) | Color::Indexed(_)), "{label}: non-native {which} {c:?} at ({x},{y}) under \ symbol {:?} — minimal must only use Reset / named \ ANSI-16 so the terminal palette controls rendering", cell.symbol() ); } } } } } #[test] fn large_commit_is_capped_with_footer() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let theme = Theme::current(); let appearance = committed_appearance(&AppearanceConfig::default()); // A tall block: a fenced code block keeps each line on its own row // (markdown would otherwise join soft-wrapped prose into one paragraph), // so the block is comfortably taller than the cap. let lines: Vec = (0..60).map(|i| format!("line {i}")).collect(); let body = format!("```\n{}\n```", lines.join("\n")); let mut entry = ScrollbackEntry::new(RenderBlock::agent_message(body)); entry.set_display_mode(minimal_commit_display_mode(&entry.block)); let width = 80u16; let renderer = committed_renderer(&entry, &theme, appearance, test_cwd()); let full_h = renderer.desired_height(width); assert!(full_h > 12, "expected a tall block, got {full_h}"); // Paint into a cap-height buffer (what `insert_committed` allocates). let cap = 12u16; let area = Rect::new(0, 0, width, cap); let mut buf = Buffer::empty(area); paint_committed(&mut buf, renderer, width, full_h, theme.dim()); // The final row is the overflow footer naming the hidden line count and // pointing at /transcript; the buffer is exactly `cap` rows (bounded). let last: String = (0..width) .filter_map(|x| buf.cell((x, cap - 1)).map(|c| c.symbol().to_string())) .collect(); assert!(last.contains("more lines"), "footer row: {last:?}"); assert!(last.contains("/transcript"), "footer row: {last:?}"); // A hidden-line count is present (full_h minus the kept content rows). let hidden = full_h - (cap - 1); assert!( last.contains(&hidden.to_string()), "footer should name {hidden} hidden lines: {last:?}" ); } #[test] fn small_commit_is_not_capped() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let theme = Theme::current(); let appearance = committed_appearance(&AppearanceConfig::default()); let mut entry = ScrollbackEntry::new(RenderBlock::agent_message("one short line")); entry.set_display_mode(minimal_commit_display_mode(&entry.block)); let width = 80u16; let renderer = committed_renderer(&entry, &theme, appearance, test_cwd()); let full_h = renderer.desired_height(width); // Buffer is exactly the block's height → no footer (uncapped path). let area = Rect::new(0, 0, width, full_h); let mut buf = Buffer::empty(area); paint_committed(&mut buf, renderer, width, full_h, theme.dim()); let mut all = String::new(); for y in 0..full_h { for x in 0..width { all.push_str(buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" ")); } } assert!( !all.contains("more lines"), "no footer when uncapped: {all:?}" ); } #[test] fn committed_edit_keeps_diff_line_backgrounds() { use kigi_tui::diff::DiffLine; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use similar::ChangeTag; let hunk = vec![ DiffLine { text: "let x = 1;\n".into(), lo: 10, ln: 10, tag: ChangeTag::Equal, }, DiffLine { text: "let y = 2;\n".into(), lo: 11, ln: 0, tag: ChangeTag::Delete, }, DiffLine { text: "let y = 3;\n".into(), lo: 0, ln: 11, tag: ChangeTag::Insert, }, DiffLine { text: "let z = 4;\n".into(), lo: 12, ln: 12, tag: ChangeTag::Equal, }, ]; let block = RenderBlock::edit_with_hunks("src/main.rs", vec![hunk]); let mut entry = ScrollbackEntry::new(block); entry.set_display_mode(minimal_commit_display_mode(&entry.block)); let theme = Theme::current(); let appearance = committed_appearance(&AppearanceConfig::default()); let renderer = committed_renderer(&entry, &theme, appearance, test_cwd()); let width = 80u16; let h = renderer.desired_height(width); let area = Rect::new(0, 0, width, h); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); // The committed edit uses a flat background (terminal transparency), but // must still paint the per-line diff backgrounds — otherwise an added / // removed line is indistinguishable from context. let mut saw_insert = false; let mut saw_delete = false; for y in 0..h { for x in 0..width { if let Some(cell) = buf.cell((x, y)) { saw_insert |= cell.bg == theme.diff_insert_bg; saw_delete |= cell.bg == theme.diff_delete_bg; } } } assert!( saw_insert, "committed edit lost the insert (green) diff background" ); assert!( saw_delete, "committed edit lost the delete (red) diff background" ); } #[test] fn commit_display_mode_policy() { assert_eq!( minimal_commit_display_mode(&RenderBlock::thinking("reasoning")), DisplayMode::Collapsed ); assert_eq!( minimal_commit_display_mode(&RenderBlock::edit("file.rs", None)), DisplayMode::Expanded ); assert_eq!( minimal_commit_display_mode(&RenderBlock::execute("ls")), DisplayMode::Truncated ); assert_eq!( minimal_commit_display_mode(&RenderBlock::agent_message("hi")), DisplayMode::Expanded ); } }