Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1325 lines
57 KiB
Rust
1325 lines
57 KiB
Rust
//! 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<EntryId> = 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<usize> {
|
|
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::<usize>::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::<String>,
|
|
),
|
|
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::<String>),
|
|
),
|
|
("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<String> = (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
|
|
);
|
|
}
|
|
}
|