//! Minimal-mode "full view": the complete conversation rendered with **every** //! block fully expanded — reasoning shown in full (not the collapsed //! `Thought for Xs` marker), tool output uncapped — as an ANSI string to open in //! `$PAGER` (`less -R`). //! //! Minimal commits blocks into the terminal's *native* scrollback as static //! text (collapsed reasoning, truncated tool output), which cannot be //! re-rendered in place when the user toggles verbose. So "expand everything" //! is served by re-rendering the whole transcript off-screen at full fidelity //! and handing it to a pager (transcript mode). Reuses [`EntryRenderer`] (all //! per-block layout, syntax highlighting, diff colors) with the display mode //! forced to `Expanded`, then serializes the resulting cell buffer to ANSI so //! colors survive in the pager. use std::time::{Duration, Instant}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier}; use kigi_tui::app::app_view::AppView; use kigi_tui::minimal_api; use kigi_tui::render::Renderable; use kigi_tui::scrollback::entry::ScrollbackEntry; use kigi_tui::scrollback::types::DisplayMode; use kigi_tui::scrollback::wrappers::EntryRenderer; use kigi_tui::theme::Theme; /// Fixed render width for the transcript. A stable, readable column count /// independent of the current terminal size (the pager wraps to the real /// terminal, and committed-vs-full-view wrapping need not match). const FULL_VIEW_WIDTH: u16 = 100; /// Per-frame budget for the incremental transcript build. Small enough that a /// slice never blocks input/streaming noticeably (frames tick at ~16ms while a /// build is active — see `AppView::tick_interval_ceiling`), large enough to /// drain a big session in a couple of seconds. const PUMP_BUDGET: Duration = Duration::from_millis(8); /// Advance the in-progress `/transcript` build by one time-budgeted slice. /// Called once per frame from [`crate::draw`]; a no-op when no build is armed /// (`minimal_api::request_minimal_transcript`). /// /// Why sliced: the full-fidelity transcript is a layout + syntax-highlight + /// ANSI-serialization pass over every block. Doing it in one shot froze the /// event loop for seconds on long sessions, and it cannot move off-thread — /// the block model is `!Send` (syntect's resumable highlighter lives inside /// streaming-markdown blocks). Budgeted slices amortized across frames are how /// other scrollback TUIs keep transcript-scale work off the critical path /// (budgeted commit ticks / cell caches / throttled frame loops). On completion /// the file is written and `pending_pager_path` armed; the event loop then /// suspends into `$PAGER`. pub fn pump_transcript(app: &mut AppView) { let Some(mut build) = minimal_api::take_minimal_transcript(app) else { return; }; // Resolve against the build's OWNING agent, never the active view: // `EntryId`s are per-`ScrollbackState` counters, so a session switch // mid-build must not re-target the snapshot at another agent's scrollback // (id collisions would stitch the transcript from the wrong session). The // owner keeps existing across view switches, so the build also survives // the user tabbing away — only a truly-removed agent drops it. let id = build.agent; let appearance = super::commit::committed_appearance(&app.appearance); { let Some(agent) = app.agents.get(&id) else { tracing::warn!("minimal: transcript build's agent removed; dropping the build"); return; }; let theme = Theme::current(); let sb = &agent.scrollback; // Show every thinking entry THAT EXISTS in the session: this view is // the advertised full-fidelity "expand everything" surface, and // thinking entries render zero rows while the `[ui]` // show_thinking_blocks toggle is off — they were silently omitted from // the transcript (bugbot). The toggle is thread-local; restore it // before returning to live rendering on this same thread. // // Scope caveat: with the setting off, the tracker drops reasoning at // INGESTION (`handle_thought_chunk` returns before pushing — a // deliberate, test-encoded memory tradeoff that predates minimal), so // sessions run entirely with the toggle off have no thinking entries // for any view to show. This override covers the sessions that do: // toggle on at ingestion (the default), or toggled off mid-session. let prev_thinking = kigi_tui::appearance::cache::load_show_thinking_blocks(); kigi_tui::appearance::cache::set_show_thinking_blocks(true); let start = Instant::now(); while build.next < build.ids.len() { let eid = build.ids[build.next]; build.next += 1; // Re-resolve by id: entries removed mid-build (rewind / clear) // are skipped rather than skewing positions. if let Some(entry) = sb.index_of_id(eid).and_then(|idx| sb.entry(idx)) { render_entry_to_ansi( entry, &theme, &appearance, &agent.session.cwd, &mut build.out, ); } if start.elapsed() >= PUMP_BUDGET { break; } } kigi_tui::appearance::cache::set_show_thinking_blocks(prev_thinking); } if build.next < build.ids.len() { // More to do — resume next frame (ticks keep flowing via // `needs_animation`; progress shows in the status row). minimal_api::set_minimal_transcript(app, Some(build)); return; } // Done: hand the file to the event loop's suspend-into-$PAGER path. finish_transcript(app, id, build.out); } /// Write the finished transcript and arm `pending_pager_path` (ANSI → the /// event loop adds `-R` for `less`). Errors surface as a system block on the /// build's owning agent (which may differ from the active view — the user can /// tab away while the build runs). fn finish_transcript(app: &mut AppView, id: kigi_tui::app::agent::AgentId, out: String) { if out.is_empty() { if let Some(agent) = app.agents.get_mut(&id) { agent .scrollback .push_block(kigi_tui::scrollback::block::RenderBlock::system( "No conversation transcript to view yet", )); } return; } let path = std::env::temp_dir().join(format!("kigi-transcript-{}.ansi", uuid::Uuid::new_v4())); match std::fs::write(&path, out) { Ok(()) => { app.pending_pager_path = Some(path); app.pending_pager_ansi = true; } Err(e) => { if let Some(agent) = app.agents.get_mut(&id) { agent .scrollback .push_block(kigi_tui::scrollback::block::RenderBlock::system(format!( "Failed to write transcript: {e}" ))); } } } } /// Render one entry (fully expanded, at [`FULL_VIEW_WIDTH`]) and append its /// ANSI serialization to `out`. Cloning keeps the live entry's display mode — /// which drives the on-screen committed look — untouched. fn render_entry_to_ansi( entry: &ScrollbackEntry, theme: &Theme, appearance: &kigi_tui::appearance::AppearanceConfig, cwd: &std::path::Path, out: &mut String, ) { let mut expanded = entry.clone(); expanded.set_display_mode(DisplayMode::Expanded); let renderer = EntryRenderer::new(&expanded, theme) .with_appearance(appearance.clone()) .with_cwd(Some(cwd)) .with_flat_background(true); let height = renderer.desired_height(FULL_VIEW_WIDTH); if height == 0 { return; } let area = Rect::new(0, 0, FULL_VIEW_WIDTH, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); buffer_to_ansi(&buf, out); // Blank line between blocks so the transcript breathes in the pager. out.push('\n'); } /// Serialize a rendered cell [`Buffer`] to ANSI text (one `\n`-terminated line /// per row), emitting an SGR sequence whenever the style changes and resetting /// at each row end. Trailing blank cells are trimmed so lines stay short. fn buffer_to_ansi(buf: &Buffer, out: &mut String) { let area = buf.area; for y in area.y..area.y.saturating_add(area.height) { // Last column carrying a visible glyph (trim trailing spaces). let mut last: Option = None; for x in (area.x..area.x.saturating_add(area.width)).rev() { if let Some(cell) = buf.cell((x, y)) { let s = cell.symbol(); if !s.is_empty() && s != " " { last = Some(x); break; } } } if let Some(last_x) = last { // Track the current style as the raw (fg, bg, modifier) tuple and // only build the escape string on a run boundary — comparing three // Copy fields per cell is far cheaper than building + comparing an // SGR string per cell (the previous hot spot on long transcripts). let mut cur: Option<(Color, Color, Modifier)> = None; let mut sgr = String::with_capacity(32); for x in area.x..=last_x { let Some(cell) = buf.cell((x, y)) else { continue; }; let sym = cell.symbol(); if sym.is_empty() { // Continuation cell of a wide glyph — already emitted. continue; } let style = (cell.fg, cell.bg, cell.modifier); if cur != Some(style) { cell_sgr(style.0, style.1, style.2, &mut sgr); out.push_str(&sgr); cur = Some(style); } out.push_str(sym); } out.push_str("\x1b[0m"); } out.push('\n'); } } /// Build a full SGR sequence (leading reset, then modifiers + fg + bg) for a /// cell's style. Emitted only when the style changes, so the reset can't leak /// attributes across cells. /// /// Writes into `sgr` (cleared first) instead of allocating: this runs once per /// style *run*, which in syntax-highlighted code is nearly once per token — /// the `Vec` + `join` version dominated the serializer's profile on /// long transcripts. fn cell_sgr(fg: Color, bg: Color, modifier: Modifier, sgr: &mut String) { use std::fmt::Write as _; sgr.clear(); sgr.push_str("\x1b[0"); if modifier.contains(Modifier::BOLD) { sgr.push_str(";1"); } if modifier.contains(Modifier::DIM) { sgr.push_str(";2"); } if modifier.contains(Modifier::ITALIC) { sgr.push_str(";3"); } if modifier.contains(Modifier::UNDERLINED) { sgr.push_str(";4"); } if modifier.contains(Modifier::REVERSED) { sgr.push_str(";7"); } if modifier.contains(Modifier::CROSSED_OUT) { sgr.push_str(";9"); } sgr.push(';'); let _ = write!(sgr, "{}", color_code(fg, false)); sgr.push(';'); let _ = write!(sgr, "{}", color_code(bg, true)); sgr.push('m'); } /// Map a ratatui [`Color`] to its SGR parameter (foreground, or background when /// `bg`). Named colors use the 16-color codes; `Indexed`/`Rgb` use the 256 / /// truecolor forms. `Reset` is the terminal default (39 fg / 49 bg). fn color_code(color: Color, bg: bool) -> String { // Named-color base code (30-series fg); +10 shifts to the 40-series bg. let named = |n: u16| -> String { (if bg { n + 10 } else { n }).to_string() }; match color { Color::Reset => named(39), Color::Black => named(30), Color::Red => named(31), Color::Green => named(32), Color::Yellow => named(33), Color::Blue => named(34), Color::Magenta => named(35), Color::Cyan => named(36), Color::Gray => named(37), Color::DarkGray => named(90), Color::LightRed => named(91), Color::LightGreen => named(92), Color::LightYellow => named(93), Color::LightBlue => named(94), Color::LightMagenta => named(95), Color::LightCyan => named(96), Color::White => named(97), Color::Indexed(i) => format!("{};5;{}", if bg { 48 } else { 38 }, i), Color::Rgb(r, g, b) => format!("{};2;{};{};{}", if bg { 48 } else { 38 }, r, g, b), } } #[cfg(test)] mod tests { use super::*; use kigi_tui::scrollback::block::RenderBlock; fn test_cwd() -> &'static std::path::Path { std::path::Path::new("/test/session") } /// Bugbot "Transcript omits thinking blocks": with the `[ui]` /// show_thinking_blocks toggle off, a thinking entry renders zero rows and /// vanished from the "full-fidelity" transcript. The pump enables the /// (thread-local) toggle for the build; this locks the mechanism: off → /// omitted, on (what `pump_transcript` sets) → included. #[test] fn transcript_includes_thinking_when_pump_enables_toggle() { let theme = Theme::current(); let appearance = super::super::commit::committed_appearance( &kigi_tui::appearance::AppearanceConfig::default(), ); let entry = ScrollbackEntry::new(RenderBlock::thinking( "deep reasoning about haikus and syllables", )); kigi_tui::appearance::cache::set_show_thinking_blocks(false); let mut out = String::new(); render_entry_to_ansi(&entry, &theme, &appearance, test_cwd(), &mut out); assert!( out.is_empty(), "thinking hidden while the toggle is off: {out:?}" ); // What `pump_transcript` sets for the duration of a slice. kigi_tui::appearance::cache::set_show_thinking_blocks(true); let mut out = String::new(); render_entry_to_ansi(&entry, &theme, &appearance, test_cwd(), &mut out); kigi_tui::appearance::cache::set_show_thinking_blocks(false); assert!( out.contains("reasoning"), "thinking content included in the transcript: {out:?}" ); } /// A thinking entry built the way live streaming builds it (streaming /// block + per-chunk pushes + finish) must render its BODY in the /// transcript, not just the collapsed "Thought for Xs" header. #[test] fn transcript_expands_streamed_thinking_body() { use kigi_tui::scrollback::state::ScrollbackState; let theme = Theme::current(); let appearance = super::super::commit::committed_appearance( &kigi_tui::appearance::AppearanceConfig::default(), ); kigi_tui::appearance::cache::set_show_thinking_blocks(true); let mut sb = ScrollbackState::new(); let id = sb.push_block(RenderBlock::thinking_streaming()); assert!(sb.push_chunk_to_thinking(id, "REASONINGBODY pondering ")); sb.push_chunk_to_thinking(id, "quietly about wraps"); sb.finish_running_with_time(id, Some(1200)); let entry = sb.get_by_id(id).expect("thinking entry"); let mut out = String::new(); render_entry_to_ansi(entry, &theme, &appearance, test_cwd(), &mut out); kigi_tui::appearance::cache::set_show_thinking_blocks(false); assert!( out.contains("REASONINGBODY"), "transcript must include the streamed thinking body: {out:?}" ); } #[test] fn transcript_uses_owning_session_cwd_for_tool_paths() { let theme = Theme::current(); let appearance = super::super::commit::committed_appearance( &kigi_tui::appearance::AppearanceConfig::default(), ); let entry = ScrollbackEntry::new(RenderBlock::edit("/alternate/worktree/src/main.rs", None)); let mut out = String::new(); render_entry_to_ansi( &entry, &theme, &appearance, std::path::Path::new("/alternate/worktree"), &mut out, ); assert!(out.contains("src/main.rs"), "transcript: {out:?}"); assert!( !out.contains("/alternate/worktree"), "session prefix should be elided: {out:?}" ); } #[test] fn color_code_maps_reset_named_indexed_rgb() { assert_eq!(color_code(Color::Reset, false), "39"); assert_eq!(color_code(Color::Reset, true), "49"); assert_eq!(color_code(Color::Red, false), "31"); assert_eq!(color_code(Color::Red, true), "41"); assert_eq!(color_code(Color::DarkGray, false), "90"); assert_eq!(color_code(Color::DarkGray, true), "100"); assert_eq!(color_code(Color::Indexed(200), false), "38;5;200"); assert_eq!(color_code(Color::Rgb(1, 2, 3), true), "48;2;1;2;3"); } #[test] fn cell_sgr_includes_modifiers_and_colors() { let mut sgr = String::new(); cell_sgr( Color::Rgb(10, 20, 30), Color::Reset, Modifier::BOLD | Modifier::ITALIC, &mut sgr, ); // Leading reset, bold, italic, truecolor fg, default bg. assert_eq!(sgr, "\x1b[0;1;3;38;2;10;20;30;49m"); // Reused buffer is cleared, not appended. cell_sgr(Color::Red, Color::Reset, Modifier::empty(), &mut sgr); assert_eq!(sgr, "\x1b[0;31;49m"); } #[test] fn buffer_to_ansi_trims_trailing_and_terminates_rows() { // A 6-wide, 2-row buffer: "hi" on row 0, blank row 1. let mut buf = Buffer::empty(Rect::new(0, 0, 6, 2)); buf.cell_mut((0, 0)).unwrap().set_symbol("h"); buf.cell_mut((1, 0)).unwrap().set_symbol("i"); let mut out = String::new(); buffer_to_ansi(&buf, &mut out); let lines: Vec<&str> = out.split('\n').collect(); // Row 0 has content ending in a reset; row 1 is blank; trailing newline. assert!(lines[0].contains('h') && lines[0].contains('i')); assert!( lines[0].ends_with("\x1b[0m"), "row must reset: {:?}", lines[0] ); assert_eq!(lines[1], "", "blank row emits nothing but the newline"); // No trailing spaces before the reset. assert!( !lines[0].contains(" "), "trailing spaces not trimmed: {:?}", lines[0] ); } }