Files
Kigi-CLI/crates/codegen/kigi-tui/src/scrollback/block.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

1624 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! BlockContent trait, RenderBlock enum, and shared bullet rendering.
use ratatui::style::Style;
use ratatui::text::{Span, Text};
use crate::diff::DiffHunk;
use crate::inline_media_ffmpeg::inline_media_reserved_rows;
use crate::prompt_images::{InlineMediaInfo, ScrollbackImageRef, ScrollbackVideoRef};
// Imported (rather than referenced as `crate::…`) so the default trait method
// below stays free of `crate::` tokens: `#[enum_delegate::register]` captures
// default method bodies into a generated macro, where `crate::` trips
// `clippy::crate_in_macro_def`.
use super::blocks::mermaid_content::DiagramAffordance;
use super::blocks::{
AgentMessageBlock, BgTaskBlock, BtwBlock, ContextInfoBlock, EditToolCallBlock,
ExecuteToolCallBlock, LineRange, ListDirToolCallBlock, OtherToolCallBlock, ReadToolCallBlock,
SearchFileMatch, SearchToolCallBlock, SessionEvent, SessionEventBlock, SubagentBlock,
SubagentBlockKind, SystemMessageBlock, ThinkingBlock, ToolCallBlock, UserPromptBlock,
};
use super::types::{
AccentStyle, BlockBackground, BlockContext, BlockOutput, DisplayMode, RenderedBlockOutput,
Selectable, SelectionBoundaries, derive_selection_text,
};
/// The trailing inline image anchored within a block's rendered output.
///
/// Built by the default [`inline_media_placements`](BlockContent::inline_media_placements)
/// for a block's single [`inline_media`](BlockContent::inline_media) image (tool
/// media, e.g. an `OtherToolCallBlock`). Mermaid diagrams do not use this path —
/// they render as a code block plus a text affordance row instead.
#[derive(Debug, Clone)]
pub struct AnchoredMedia {
/// Media metadata (path, raster dimensions, type).
pub info: InlineMediaInfo,
/// Post-wrap row offset, from the block's first content row, where the
/// image's top edge is anchored. (Tool-media blocks have no top vpad, so
/// this is measured from the entry top.)
pub row_offset: u16,
/// Height of the image area in rows (the crop region).
pub rows: u16,
}
/// Trait for block content description.
///
/// Each block type implements this trait. The RenderBlock enum delegates to
/// the inner type automatically via enum_delegate.
///
/// Note: This trait describes *what* to render (content, styles, padding).
/// Actual rendering to a Buffer is done via the `Renderable` trait from
/// `ui/render/renderable.rs`.
#[enum_delegate::register]
pub trait BlockContent {
fn output(&self, ctx: &BlockContext) -> BlockOutput;
/// Accent line style (color, animation).
///
/// Returns `None` for blocks without an accent line.
/// Returns `Some(AccentStyle)` with color and animation info.
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle>;
/// Bullet/icon color style.
///
/// Returns `None` to use default styling (gray when collapsed, primary when expanded).
/// Returns `Some(AccentStyle)` to use a specific color (dimmed when collapsed+groupable).
///
/// Default: delegates to `accent()` — bullet matches accent color.
/// Override for blocks where bullet differs from accent (e.g., Thinking: accent
/// when expanded but default bullet; failed Read: no accent but red bullet).
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
self.accent(ctx)
}
fn accent_background(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
/// Vertical padding (blank line with accent top/bottom).
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
true
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
true
}
/// Get the next display mode when toggling fold.
///
/// Default behavior: toggle between Collapsed and Expanded.
/// Blocks can override for 3-way cycling (e.g., thinking blocks).
///
/// The `is_running` parameter allows blocks to behave differently
/// while streaming (e.g., thinking blocks might skip Collapsed while running).
fn next_fold_mode(&self, current: DisplayMode, is_running: bool) -> DisplayMode {
let _ = is_running;
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
DisplayMode::Truncated | DisplayMode::Expanded => DisplayMode::Collapsed,
}
}
/// Get the display mode to use when explicitly collapsing (left/h key).
///
/// Default: Collapsed. Blocks can override to use a different minimum mode
/// when running (e.g., execute blocks use Truncated while running to keep
/// showing the streaming output preview).
fn collapse_mode(&self, is_running: bool) -> DisplayMode {
let _ = is_running;
DisplayMode::Collapsed
}
/// Get the default display mode for this block type.
///
/// Default: Expanded. Blocks can override (e.g., thinking defaults to Truncated).
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Expanded
}
/// Display mode to adopt when the entry finishes running.
///
/// Called by `finish_running()`. Returns `Some(mode)` to override
/// the current display mode, or `None` to keep it as-is.
///
/// Default: `None` (no change). Blocks that auto-collapse on finish
/// (thinking, execute) or on error (edit) should override this.
fn finished_display_mode(&self) -> Option<DisplayMode> {
None
}
/// Whether block can be selected via j/k navigation.
/// Non-selectable blocks are rendered normally but skipped during navigation.
fn is_selectable(&self) -> bool {
true
}
/// Whether this block should display a bullet/icon prefix.
///
/// Default: `false`. Override to opt in (e.g., ToolCallBlock when bullet
/// is configured, ThinkingBlock when collapsed). The bullet character and
/// color are determined by the appearance config and accent style.
fn has_bullet(&self, _ctx: &BlockContext) -> bool {
false
}
/// Preamble content for the fullscreen viewer.
///
/// Returns styled header lines shown above the ListPane content.
/// Uses expanded/bright styling (not dull-gray collapsed).
/// Returns `None` for blocks without a natural header (e.g., agent messages).
fn preamble(&self, _ctx: &BlockContext) -> Option<Text<'static>> {
None
}
/// Whether this block participates in dense group rendering.
///
/// Groupable blocks that are adjacent form a "group" — they render without
/// gap rows between them when collapsed. Non-groupable blocks (e.g.,
/// AgentMessage, UserPrompt) always have gap rows around them and break
/// any adjacent group.
///
/// Default: `false` (opt-in). Override to `true` for tool calls, thinking,
/// system messages, and other blocks that should pack densely.
fn is_groupable(&self) -> bool {
false
}
/// Image file references in this block (default: none).
fn image_references(&self) -> &[ScrollbackImageRef] {
&[]
}
/// Video file references in this block (default: none).
fn video_references(&self) -> &[ScrollbackVideoRef] {
&[]
}
/// Inline media metadata for blocks that should display media inline in
/// the scrollback. The renderer uses this to reserve height and the draw
/// loop uses it to emit terminal image escape sequences. Default: none.
fn inline_media(&self) -> Option<InlineMediaInfo> {
None
}
/// The block's trailing inline media, if any (tool media).
///
/// Wraps a block's single [`inline_media`](Self::inline_media) into one
/// trailing placement: the image sits one padding row below the block's
/// text, with `rows + 3` rows reserved beneath the text (padding + image +
/// padding + button) and the second text line exposed as the click-to-copy
/// filepath. Blocks without `inline_media()` return empty.
///
/// The `inline_media()`-is-`None` early return is the load-bearing fast path:
/// every non-media block (all agent messages, all non-media tool calls)
/// returns here without building `output()`. The `output()` rebuild below
/// runs only for an actual media block (today only `OtherToolCallBlock`),
/// whose `output()` is a cheap 2–3 line build.
fn inline_media_placements(&self, ctx: &BlockContext) -> Vec<AnchoredMedia> {
let Some(info) = self.inline_media() else {
return Vec::new();
};
// Trailing geometry: image starts one row below the block's text
// (`content_lines + 1`), fitted to the same cell budget
// `EntryRenderer::inline_media_rows` reserves. The line count needs the
// laid-out output; the generic wrapper can't know it otherwise.
let content_lines = self.output(ctx).lines.len() as u16;
let (rows, _total_rows) = inline_media_reserved_rows(&info, ctx.width);
vec![AnchoredMedia {
info,
row_offset: content_lines + 1,
rows,
}]
}
/// Clickable affordance rows for the diagrams in this block's `output()`
/// (the `auto`/`on` Mermaid display). Each entry's `row_offset` is a
/// block-relative post-wrap row the draw loop paints
/// `[Open Image] [Copy Image Path] [Copy Source]` onto and registers click hit-rects
/// for. Default: none (only agent messages with diagrams override this).
fn diagram_affordances(&self, _ctx: &BlockContext) -> Vec<DiagramAffordance> {
Vec::new()
}
/// Rows this block inserts into `output()` that the source-text height
/// *estimate* cannot see, so the off-screen estimate can add them and never
/// under-reserve. The only such rows today are Mermaid treatment rows (one
/// affordance row or fallback caption per detected diagram). Default: `0`.
fn estimate_extra_rows(&self) -> u16 {
0
}
/// For media blocks on terminals without inline-graphics support, returns
/// `(path, is_video)` so the block can render a clickable text `[Open]`
/// line. `None` on graphics terminals (the overlay hosts its own buttons).
fn inline_open_button(&self) -> Option<(std::path::PathBuf, bool)> {
None
}
}
/// Prepend a bullet/icon span to the first line of a block's output.
///
/// Called by `RenderBlock::output()` when `has_bullet()` returns true.
/// The bullet character comes from appearance config. The color comes from the
/// block's `bullet()` method:
/// - `Some(AccentStyle)` → use that color (dimming handled later by EntryRenderer)
/// - `None` → default: gray when collapsed, primary when expanded
pub fn prepend_bullet(output: &mut BlockOutput, ctx: &BlockContext, bullet: Option<AccentStyle>) {
let tool_cfg = &ctx.appearance.scrollback.blocks.tool;
let Some(bullet_str) = tool_cfg.bullet.char() else {
return;
};
let Some(first_line) = output.lines.first_mut() else {
return;
};
let theme = crate::theme::Theme::current();
let color = match bullet {
Some(style) => style.color,
None => {
if ctx.mode == DisplayMode::Collapsed {
theme.gray
} else {
theme.gray_bright
}
}
};
let bullet_span = Span::styled(format!("{bullet_str} "), Style::default().fg(color));
first_line.content.spans.insert(0, bullet_span);
crate::scrollback::types::shift_selection_metadata_for_prefix(first_line, 1);
}
/// A stub block that just renders plain text.
/// Used during bootstrap before real blocks are implemented.
#[derive(Debug, Clone)]
pub struct StubBlock {
pub text: String,
pub accent_color: ratatui::style::Color,
/// Whether this stub participates in dense group rendering.
/// Default: `true`. Set to `false` for stubs that simulate non-groupable blocks
/// (e.g., agent messages) in tests.
pub groupable: bool,
/// Per-line background applied to every output line, plus its
/// panel-ness ([`BlockLine::background_is_panel`]). Lets renderer tests
/// exercise line-background handling with a FIXED injected color —
/// independent of `Theme::current()`, whose process-global kind /
/// color-level state is not stable across a parallel test run.
pub line_bg: Option<(ratatui::style::Color, bool)>,
}
impl StubBlock {
pub fn new(text: impl Into<String>, accent_color: ratatui::style::Color) -> Self {
Self {
text: text.into(),
accent_color,
groupable: true,
line_bg: None,
}
}
/// Create a non-groupable stub (simulates AgentMessage-like behavior in tests).
pub fn non_groupable(text: impl Into<String>, accent_color: ratatui::style::Color) -> Self {
Self {
text: text.into(),
accent_color,
groupable: false,
line_bg: None,
}
}
/// Give every output line a background, marked panel (decorative — like a
/// tool preview) or not (semantic — like diff shading).
pub fn with_line_bg(mut self, color: ratatui::style::Color, panel: bool) -> Self {
self.line_bg = Some((color, panel));
self
}
}
impl BlockContent for StubBlock {
fn output(&self, _ctx: &BlockContext) -> BlockOutput {
let mut output = BlockOutput::plain(&self.text);
if let Some((color, panel)) = self.line_bg {
for line in &mut output.lines {
line.background = Some(color);
line.background_is_panel = panel;
}
}
output
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if ctx.is_running {
Some(AccentStyle::animated(self.accent_color))
} else {
Some(AccentStyle::static_color(self.accent_color))
}
}
fn is_groupable(&self) -> bool {
self.groupable
}
}
/// RenderBlock enum wrapping all block types.
///
/// BlockContent is manually implemented (not via enum_delegate) so we can
/// intercept `output()` to conditionally prepend bullets based on `has_bullet()`.
#[derive(Debug, Clone)]
pub enum RenderBlock {
Stub(StubBlock),
UserPrompt(UserPromptBlock),
AgentMessage(AgentMessageBlock),
/// Tool call result (Execute, Read, Edit, ListDir, Search, Other).
ToolCall(ToolCallBlock),
Thinking(ThinkingBlock),
/// System message (arbitrary text).
System(SystemMessageBlock),
/// Session-level event (typed: turn completed, cancelled, failed, etc.).
SessionEvent(SessionEventBlock),
/// Background task (always collapsed, animated bullet while running).
BgTask(BgTaskBlock),
/// Subagent lifecycle (started / completed / failed).
Subagent(SubagentBlock),
/// /btw side-question response (golden accent).
Btw(BtwBlock),
/// `/context` snapshot with categorical bar + breakdown.
ContextInfo(ContextInfoBlock),
}
macro_rules! delegate_block {
($self:expr, $method:ident ( $($arg:expr),* )) => {
match $self {
RenderBlock::Stub(b) => b.$method($($arg),*),
RenderBlock::UserPrompt(b) => b.$method($($arg),*),
RenderBlock::AgentMessage(b) => b.$method($($arg),*),
RenderBlock::ToolCall(b) => b.$method($($arg),*),
RenderBlock::Thinking(b) => b.$method($($arg),*),
RenderBlock::System(b) => b.$method($($arg),*),
RenderBlock::SessionEvent(b) => b.$method($($arg),*),
RenderBlock::BgTask(b) => b.$method($($arg),*),
RenderBlock::Subagent(b) => b.$method($($arg),*),
RenderBlock::Btw(b) => b.$method($($arg),*),
RenderBlock::ContextInfo(b) => b.$method($($arg),*),
}
};
}
fn plain_text_from_output(
output: &BlockOutput,
boundaries: &SelectionBoundaries,
) -> Option<String> {
let mut result = String::new();
let mut wrote_any = false;
for (line_index, line) in output.lines.iter().enumerate() {
if matches!(line.selectable, Selectable::None) {
continue;
}
let text = derive_selection_text(line);
if wrote_any {
result.push_str(line.joiner.as_deref().unwrap_or("\n"));
}
if let Some(boundary) = boundaries.get(line_index) {
result.push_str(&boundary.apply(text, true, true));
} else {
result.push_str(&text);
}
wrote_any = true;
}
wrote_any.then_some(result)
}
/// Join source-text parts for full-text search, dropping `None` and empty
/// strings so absent fields never inject blank lines or false matches.
///
/// Returns `None` when nothing remains — a block with no source text is
/// simply left out of the index rather than indexed as an empty string.
pub(crate) fn join_searchable(parts: impl IntoIterator<Item = Option<String>>) -> Option<String> {
let joined = parts
.into_iter()
.flatten()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("\n");
(!joined.is_empty()).then_some(joined)
}
impl RenderBlock {
pub(crate) fn rendered_output(&self, ctx: &BlockContext) -> RenderedBlockOutput {
let RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) = self else {
return RenderedBlockOutput::from(self.output(ctx));
};
let mut rendered = edit.rendered_output(ctx);
if self.has_bullet(ctx) {
prepend_bullet(&mut rendered.output, ctx, self.bullet(ctx));
}
rendered
}
}
impl BlockContent for RenderBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let mut output = delegate_block!(self, output(ctx));
if delegate_block!(self, has_bullet(ctx)) {
let bullet = delegate_block!(self, bullet(ctx));
prepend_bullet(&mut output, ctx, bullet);
}
output
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
delegate_block!(self, accent(ctx))
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
delegate_block!(self, bullet(ctx))
}
fn accent_background(&self, ctx: &BlockContext) -> bool {
delegate_block!(self, accent_background(ctx))
}
fn background(&self, ctx: &BlockContext) -> BlockBackground {
delegate_block!(self, background(ctx))
}
fn has_vpad(&self, ctx: &BlockContext) -> bool {
delegate_block!(self, has_vpad(ctx))
}
fn has_raw_mode(&self) -> bool {
delegate_block!(self, has_raw_mode())
}
fn is_foldable(&self) -> bool {
delegate_block!(self, is_foldable())
}
fn next_fold_mode(&self, current: DisplayMode, is_running: bool) -> DisplayMode {
delegate_block!(self, next_fold_mode(current, is_running))
}
fn collapse_mode(&self, is_running: bool) -> DisplayMode {
delegate_block!(self, collapse_mode(is_running))
}
fn default_display_mode(&self) -> DisplayMode {
delegate_block!(self, default_display_mode())
}
fn finished_display_mode(&self) -> Option<DisplayMode> {
delegate_block!(self, finished_display_mode())
}
fn is_selectable(&self) -> bool {
delegate_block!(self, is_selectable())
}
fn has_bullet(&self, ctx: &BlockContext) -> bool {
delegate_block!(self, has_bullet(ctx))
}
fn is_groupable(&self) -> bool {
delegate_block!(self, is_groupable())
}
fn preamble(&self, ctx: &BlockContext) -> Option<Text<'static>> {
delegate_block!(self, preamble(ctx))
}
fn image_references(&self) -> &[crate::prompt_images::ScrollbackImageRef] {
delegate_block!(self, image_references())
}
fn video_references(&self) -> &[crate::prompt_images::ScrollbackVideoRef] {
delegate_block!(self, video_references())
}
fn inline_media(&self) -> Option<InlineMediaInfo> {
delegate_block!(self, inline_media())
}
fn inline_media_placements(&self, ctx: &BlockContext) -> Vec<AnchoredMedia> {
delegate_block!(self, inline_media_placements(ctx))
}
fn diagram_affordances(&self, ctx: &BlockContext) -> Vec<DiagramAffordance> {
delegate_block!(self, diagram_affordances(ctx))
}
fn estimate_extra_rows(&self) -> u16 {
delegate_block!(self, estimate_extra_rows())
}
fn inline_open_button(&self) -> Option<(std::path::PathBuf, bool)> {
delegate_block!(self, inline_open_button())
}
}
impl RenderBlock {
/// Create a stub block (groupable by default).
pub fn stub(text: impl Into<String>, accent_color: ratatui::style::Color) -> Self {
RenderBlock::Stub(StubBlock::new(text, accent_color))
}
/// Create a non-groupable stub block (simulates AgentMessage-like behavior in tests).
pub fn stub_non_groupable(
text: impl Into<String>,
accent_color: ratatui::style::Color,
) -> Self {
RenderBlock::Stub(StubBlock::non_groupable(text, accent_color))
}
pub fn user_prompt(text: impl Into<String>) -> Self {
RenderBlock::UserPrompt(UserPromptBlock::new(text))
}
/// Create a bash prompt block.
pub fn bash_prompt(text: impl Into<String>) -> Self {
RenderBlock::UserPrompt(UserPromptBlock::bash(text))
}
/// Create a skill invocation prompt block.
pub fn skill_prompt(text: impl Into<String>) -> Self {
RenderBlock::UserPrompt(UserPromptBlock::skill(text))
}
/// Create a user prompt block with recognized mid-text slash tokens
/// styled in the skill accent (byte ranges into `text`).
pub fn user_prompt_with_skill_tokens(
text: impl Into<String>,
ranges: Vec<std::ops::Range<usize>>,
) -> Self {
RenderBlock::UserPrompt(UserPromptBlock::with_skill_tokens(text, ranges))
}
/// Create a scheduled (cron) prompt block.
pub fn cron_prompt(text: impl Into<String>) -> Self {
RenderBlock::UserPrompt(UserPromptBlock::cron(text))
}
/// Create a mid-turn interjection prompt block (standard user prompt
/// rendering, excluded from shell prompt-index bookkeeping).
pub fn interjection_prompt(text: impl Into<String>) -> Self {
RenderBlock::UserPrompt(UserPromptBlock::interjection(text))
}
pub fn agent_message(text: impl Into<String>) -> Self {
RenderBlock::AgentMessage(AgentMessageBlock::new(text))
}
/// Create an empty streaming agent message block.
///
/// Use `as_agent_message_mut()` to get the block and call `push_chunk()`
/// to append streaming content.
pub fn agent_message_streaming() -> Self {
RenderBlock::AgentMessage(AgentMessageBlock::streaming())
}
/// Create an Other tool call block (generic, parsed from name).
pub fn tool_call(kind: impl Into<String>, summary: impl Into<String>, success: bool) -> Self {
let kind_str = kind.into();
let mut block = ToolCallBlock::from_name(&kind_str, summary);
if let ToolCallBlock::Other(ref mut b) = block
&& !success
{
b.error = Some("Tool call failed".to_string());
}
RenderBlock::ToolCall(block)
}
/// Create an Other tool call block with output (for expanded view).
pub fn tool_call_with_details(
kind: impl Into<String>,
summary: impl Into<String>,
success: bool,
output: impl Into<String>,
) -> Self {
let mut block = OtherToolCallBlock::new(kind, summary).with_output(output);
if !success {
block = block.with_error("Tool call failed");
}
RenderBlock::ToolCall(ToolCallBlock::Other(block))
}
pub fn execute(command: impl Into<String>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::Execute(ExecuteToolCallBlock::new(command)))
}
/// Create an Execute tool block with output.
/// Use `error: None` for success, `error: Some(msg)` for failure.
pub fn execute_with_output(
command: impl Into<String>,
output: impl Into<String>,
error: Option<impl Into<String>>,
) -> Self {
let mut block = ExecuteToolCallBlock::new(command).with_output(output);
if let Some(e) = error {
block = block.with_error(e);
}
RenderBlock::ToolCall(ToolCallBlock::Execute(block))
}
pub fn read(path: impl Into<String>, line_range: Option<LineRange>) -> Self {
let mut block = ReadToolCallBlock::new(path);
if let Some(range) = line_range {
block = block.with_line_range(range);
}
RenderBlock::ToolCall(ToolCallBlock::Read(block))
}
pub fn list_dir(path: impl Into<String>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::ListDir(ListDirToolCallBlock::new(path)))
}
pub fn list_dir_with_output(path: impl Into<String>, output: impl Into<String>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::ListDir(
ListDirToolCallBlock::new(path).with_output(output),
))
}
pub fn search(
pattern: impl Into<String>,
match_count: usize,
file_matches: Vec<SearchFileMatch>,
) -> Self {
RenderBlock::ToolCall(ToolCallBlock::Search(
SearchToolCallBlock::new(pattern).with_matches(match_count, file_matches),
))
}
pub fn edit_with_hunks(path: impl Into<String>, hunks: Vec<DiffHunk>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::Edit(EditToolCallBlock::new(path, hunks)))
}
pub fn edit_failed(path: impl Into<String>, error: impl Into<String>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::Edit(
EditToolCallBlock::new(path, vec![]).with_error(error),
))
}
/// Create a simple Edit block (no hunks, for legacy compatibility).
pub fn edit(path: impl Into<String>, edit_info: Option<String>) -> Self {
let mut block = EditToolCallBlock::new(path, vec![]);
if let Some(info) = edit_info {
// Parse edit count from info like "2 edits"
if let Some(count) = info.split_whitespace().next().and_then(|s| s.parse().ok()) {
block = block.with_edit_count(count);
}
}
RenderBlock::ToolCall(ToolCallBlock::Edit(block))
}
pub fn thinking(text: impl Into<String>) -> Self {
RenderBlock::Thinking(ThinkingBlock::new(text))
}
/// Create a thinking block with thinking time set.
///
/// The time is displayed in collapsed mode as "Thought for Xs".
pub fn thinking_with_time(text: impl Into<String>, time_ms: i64) -> Self {
let mut block = ThinkingBlock::new(text);
block.set_elapsed_time_ms(Some(time_ms));
RenderBlock::Thinking(block)
}
/// Create an empty streaming thinking block.
///
/// Use `as_thinking_mut()` to get the block and call `push_chunk()`
/// to append streaming content.
pub fn thinking_streaming() -> Self {
RenderBlock::Thinking(ThinkingBlock::streaming())
}
/// Create an empty streaming thinking block for historical replay.
///
/// Does not arm a local elapsed timer — the collapsed "Thought for Xs"
/// duration comes from the server-reported elapsed instead. See
/// [`ThinkingBlock::streaming_replay`].
pub fn thinking_streaming_replay() -> Self {
RenderBlock::Thinking(ThinkingBlock::streaming_replay())
}
pub fn system(text: impl Into<String>) -> Self {
RenderBlock::System(SystemMessageBlock::new(text))
}
/// Create a `/context` snapshot block.
///
/// The block stores the raw `ContextInfo` snapshot + model name and
/// rebuilds its styled output on every redraw, so theme switches take
/// effect without re-running `/context`.
pub fn context_info(
snapshot: kigi_shell::session::ContextInfo,
model: impl Into<String>,
) -> Self {
RenderBlock::ContextInfo(ContextInfoBlock::new(snapshot, model))
}
pub fn session_event(event: SessionEvent) -> Self {
RenderBlock::SessionEvent(SessionEventBlock::new(event))
}
/// Create a "Task started" background task block.
pub fn bg_task(command: impl Into<String>, task_id: impl Into<String>) -> Self {
RenderBlock::BgTask(BgTaskBlock::started(command, task_id))
}
pub fn bg_task_completed(
command: impl Into<String>,
task_id: impl Into<String>,
elapsed: std::time::Duration,
) -> Self {
RenderBlock::BgTask(BgTaskBlock::completed(command, task_id, elapsed))
}
pub fn bg_task_failed(
command: impl Into<String>,
task_id: impl Into<String>,
elapsed: std::time::Duration,
exit_code: Option<i32>,
signal: Option<String>,
) -> Self {
RenderBlock::BgTask(BgTaskBlock::failed(
command, task_id, elapsed, exit_code, signal,
))
}
/// Set the description on a `BgTask` block (builder pattern, no-op for other variants).
pub fn with_bg_task_description(mut self, description: Option<String>) -> Self {
if let RenderBlock::BgTask(ref mut b) = self {
b.description = description;
}
self
}
pub fn as_stub_mut(&mut self) -> Option<&mut StubBlock> {
match self {
RenderBlock::Stub(b) => Some(b),
_ => None,
}
}
pub fn as_tool_call_mut(&mut self) -> Option<&mut ToolCallBlock> {
match self {
RenderBlock::ToolCall(b) => Some(b),
_ => None,
}
}
/// Get mutable access to an AgentMessageBlock if this is one.
///
/// This is useful for streaming: get the block, then call `push_chunk()`
/// to append streaming content.
pub fn as_agent_message_mut(&mut self) -> Option<&mut AgentMessageBlock> {
match self {
RenderBlock::AgentMessage(b) => Some(b),
_ => None,
}
}
/// Get shared (read-only) access to an AgentMessageBlock if this is one.
///
/// The read-only counterpart of [`as_agent_message_mut`](Self::as_agent_message_mut),
/// for inspecting a message's content (e.g. its detected diagrams) without
/// mutating it.
pub fn as_agent_message(&self) -> Option<&AgentMessageBlock> {
match self {
RenderBlock::AgentMessage(b) => Some(b),
_ => None,
}
}
pub fn as_thinking_mut(&mut self) -> Option<&mut ThinkingBlock> {
match self {
RenderBlock::Thinking(b) => Some(b),
_ => None,
}
}
pub fn is_user_prompt(&self) -> bool {
matches!(self, RenderBlock::UserPrompt(_))
}
/// Check if this block is a ToolCall (any variant).
///
/// Used by the entry cache to decide whether selection state should
/// invalidate the cached output — tool call variants undim their
/// collapsed header text when selected.
pub fn is_tool_call(&self) -> bool {
matches!(self, RenderBlock::ToolCall(_))
}
/// Check if this block is a Thinking block.
///
/// Used by the entry cache for the same reason as `is_tool_call`:
/// the thinking header text undims on selection.
pub fn is_thinking(&self) -> bool {
matches!(self, RenderBlock::Thinking(_))
}
/// Check if this block is a BgTask block.
///
/// Used by the entry cache for the same reason as `is_tool_call`:
/// the bold "Task" label undims on selection.
pub fn is_bg_task(&self) -> bool {
matches!(self, RenderBlock::BgTask(_))
}
/// Check if this block is a Subagent block.
///
/// Used by the entry cache for the same reason as `is_tool_call`:
/// the bold "Subagent" label undims on selection.
pub fn is_subagent(&self) -> bool {
matches!(self, RenderBlock::Subagent(_))
}
pub fn is_agent_message(&self) -> bool {
matches!(self, RenderBlock::AgentMessage(_))
}
/// Check if this block is a plan mode tool call (enter or exit).
///
/// Exact-matches the canonical tool-name set rather than substring-matching
/// the human title: titles incorporate raw model/user input, so a substring
/// match on `"enter_plan_mode"` false-positives on ordinary tool calls.
/// Covers both the raw function name and the refined display titles
/// emitted by the shell.
pub fn is_plan_mode_tool(&self) -> bool {
use super::blocks::ToolCallBlock;
const PLAN_MODE_TOOL_NAMES: &[&str] = &[
"EnterPlanMode",
"ExitPlanMode",
"enter_plan_mode",
"exit_plan_mode",
"Plan: Enter",
"Plan: Exit",
];
matches!(
self,
RenderBlock::ToolCall(ToolCallBlock::Other(b))
if PLAN_MODE_TOOL_NAMES.iter().any(|n| b.name == *n)
)
}
/// Absolute path of the media (image/video) this block references, if it is
/// a media-generation tool result. Used to resolve the short relative paths
/// the model prints in prose (`images/1.jpg`) to a clickable link.
pub(crate) fn media_ref_path(&self) -> Option<std::path::PathBuf> {
match self {
RenderBlock::ToolCall(ToolCallBlock::Other(b)) => b.media_ref_path(),
_ => None,
}
}
/// Drop rebuildable render caches held inside the block.
///
/// Currently the markdown word-wrap cache on markdown-backed blocks
/// (agent messages, thinking, /btw). The source text and pre-wrap render
/// stay; the next `output()` call rebuilds the wrap transparently. Called
/// by off-screen cache eviction — see
/// [`ScrollbackState::evict_offscreen_render_caches`](crate::scrollback::state::ScrollbackState::evict_offscreen_render_caches).
pub fn evict_render_caches(&self) {
match self {
RenderBlock::AgentMessage(b) => b.content().evict_wrap_cache(),
RenderBlock::Thinking(b) => b.content().evict_wrap_cache(),
RenderBlock::Btw(b) => b.content().evict_wrap_cache(),
_ => {}
}
}
/// Get the accent color for this block.
///
/// Returns the default static accent color for the block type.
/// Uses RGB colors from theme for proper fade blending.
pub fn accent_color(&self) -> Option<ratatui::style::Color> {
use crate::theme::Theme;
let theme = Theme::current();
match self {
RenderBlock::UserPrompt(_) => Some(theme.text_primary),
RenderBlock::AgentMessage(_) => None,
RenderBlock::ToolCall(block) => {
// Execute: Green for success, red for failure
// Read/Edit/ListDir/Search: No accent
match block {
ToolCallBlock::Execute(b) => {
if b.is_success() {
Some(theme.accent_success)
} else {
Some(theme.accent_error)
}
}
ToolCallBlock::Other(b) => {
if b.is_success() {
Some(theme.accent_tool)
} else {
Some(theme.accent_error)
}
}
_ => None,
}
}
RenderBlock::Thinking(_) => Some(theme.accent_thinking),
RenderBlock::BgTask(block) => {
if block.is_running() {
Some(theme.accent_running)
} else {
None
}
}
RenderBlock::Subagent(block) => {
if block.is_running() {
Some(theme.accent_running)
} else {
None
}
}
RenderBlock::System(_) | RenderBlock::SessionEvent(_) | RenderBlock::ContextInfo(_) => {
None
}
RenderBlock::Btw(_) => Some(theme.accent_plan),
RenderBlock::Stub(block) => Some(block.accent_color),
}
}
pub fn has_normal_fullscreen_viewer(&self) -> bool {
match self {
RenderBlock::AgentMessage(_)
| RenderBlock::Thinking(_)
| RenderBlock::ToolCall(ToolCallBlock::Execute(_))
| RenderBlock::ToolCall(ToolCallBlock::Edit(_))
| RenderBlock::ToolCall(ToolCallBlock::WebFetch(_))
| RenderBlock::ToolCall(ToolCallBlock::WebSearch(_))
| RenderBlock::ToolCall(ToolCallBlock::IntegrationSearch(_))
| RenderBlock::ToolCall(ToolCallBlock::UseTool(_))
| RenderBlock::BgTask(_) => true,
RenderBlock::ToolCall(ToolCallBlock::Read(b)) => b.has_content(),
RenderBlock::ToolCall(ToolCallBlock::Search(b)) => b.error.is_none(),
RenderBlock::ToolCall(ToolCallBlock::ListDir(b)) => {
b.error.is_none() && !b.output.is_empty()
}
_ => false,
}
}
pub fn supports_fullscreen(&self) -> bool {
self.has_normal_fullscreen_viewer()
|| !self.image_references().is_empty()
|| !self.video_references().is_empty()
}
pub fn supports_copy(&self) -> bool {
matches!(
self,
RenderBlock::UserPrompt(_)
| RenderBlock::AgentMessage(_)
| RenderBlock::Thinking(_)
| RenderBlock::ToolCall(ToolCallBlock::Execute(_))
| RenderBlock::ToolCall(ToolCallBlock::Read(_))
| RenderBlock::ToolCall(ToolCallBlock::Edit(_))
| RenderBlock::ToolCall(ToolCallBlock::WebFetch(_))
| RenderBlock::ToolCall(ToolCallBlock::WebSearch(_))
)
}
/// Whether this block can participate in whole-block drag selection.
pub fn is_drag_block_selectable(&self) -> bool {
!matches!(self, RenderBlock::Stub(_))
}
pub fn copy_visible_text_in_state(&self, ctx: &BlockContext) -> Option<String> {
let rendered = self.rendered_output(ctx);
plain_text_from_output(&rendered.output, &rendered.boundaries)
}
/// Get the copyable text for this block, if it supports copy.
///
/// The `raw` parameter controls whether markdown blocks return raw source
/// or rendered plain text. It is ignored for non-markdown block types.
pub fn copy_text(&self, raw: bool) -> Option<String> {
match self {
RenderBlock::UserPrompt(b) => Some(b.copy_text()),
RenderBlock::AgentMessage(b) => Some(b.copy_text(raw)),
RenderBlock::Thinking(b) => Some(b.copy_text(raw)),
RenderBlock::ToolCall(ToolCallBlock::Execute(b)) => Some(b.copy_text()),
RenderBlock::ToolCall(ToolCallBlock::Read(b)) => b.content.clone(),
RenderBlock::ToolCall(ToolCallBlock::Edit(b)) => Some(b.copy_text()),
RenderBlock::ToolCall(ToolCallBlock::WebFetch(b)) => Some(b.copy_text()),
RenderBlock::ToolCall(ToolCallBlock::WebSearch(b)) => Some(b.copy_text()),
RenderBlock::ToolCall(ToolCallBlock::IntegrationSearch(b)) => Some(b.copy_text()),
RenderBlock::ToolCall(ToolCallBlock::UseTool(b)) => Some(b.copy_text()),
_ => None,
}
}
/// Get block metadata text for clipboard (e.g., command for execute, path for edit).
///
/// Returns `None` for blocks without copyable metadata.
pub fn copy_meta(&self) -> Option<String> {
match self {
RenderBlock::ToolCall(ToolCallBlock::Execute(b)) => Some(b.command.clone()),
RenderBlock::ToolCall(ToolCallBlock::Read(b)) => Some(b.path.clone()),
RenderBlock::ToolCall(ToolCallBlock::Edit(b)) => Some(b.path.clone()),
RenderBlock::ToolCall(ToolCallBlock::WebFetch(b)) => Some(b.url.clone()),
RenderBlock::ToolCall(ToolCallBlock::WebSearch(b)) => Some(b.query.clone()),
RenderBlock::ToolCall(ToolCallBlock::Search(b)) => Some(b.pattern.clone()),
RenderBlock::BgTask(b) => Some(b.command.clone()),
_ => None,
}
}
/// Full searchable text of this block for full-text scrollback search.
///
/// For markdown blocks (agent/thinking/btw) this is the **rendered** plain
/// text (markdown markers stripped), so in pretty (non-raw) mode the index
/// matches what the on-screen highlight pass sees — searching `is important`
/// finds the rendered `is important` rather than missing the source
/// `is **important**`. (In per-entry raw mode the displayed rows are the
/// source while the index stays rendered, so counts and highlights can
/// diverge there — the rare inverse case.) This reads the renderer's
/// last-rendered view (populated at construction for completed blocks, and on
/// the first render/finish for streaming ones), without laying out
/// (`output()` / word-wrap) or re-highlighting syntax. Non-markdown blocks
/// return their stored source fields verbatim. Returns `None` for blocks
/// with no searchable text.
pub fn searchable_text(&self) -> Option<String> {
match self {
RenderBlock::Stub(b) => join_searchable([Some(b.text.clone())]),
RenderBlock::UserPrompt(b) => join_searchable([Some(b.text.clone())]),
RenderBlock::AgentMessage(b) => join_searchable([Some(b.copy_text(false))]),
RenderBlock::Thinking(b) => join_searchable([Some(b.copy_text(false))]),
RenderBlock::System(b) => join_searchable([Some(b.text.clone())]),
RenderBlock::SessionEvent(b) => join_searchable([Some(b.marker_text())]),
RenderBlock::BgTask(b) => {
join_searchable([Some(b.command.clone()), b.description.clone()])
}
RenderBlock::Subagent(b) => {
// Only the failed variant carries an error string worth indexing.
let error = match &b.kind {
SubagentBlockKind::Failed { error, .. } => error.clone(),
SubagentBlockKind::Started
| SubagentBlockKind::Completed { .. }
| SubagentBlockKind::Cancelled { .. } => None,
};
join_searchable([
Some(b.description.clone()),
Some(b.subagent_type.clone()),
b.persona.clone(),
b.role.clone(),
b.model.clone(),
b.activity_label.clone(),
error,
])
}
RenderBlock::Btw(b) => join_searchable([
Some(b.question.clone()),
Some(b.content().rendered_plain_text()),
]),
RenderBlock::ContextInfo(b) => join_searchable([Some(b.model.clone())]),
RenderBlock::ToolCall(tc) => tc.searchable_text(),
}
}
/// Label for the copy-meta shortcut hint (e.g., "copy cmd", "copy path").
///
/// Returns `None` for blocks without copyable metadata.
pub fn copy_meta_label(&self) -> Option<&'static str> {
match self {
RenderBlock::ToolCall(ToolCallBlock::Execute(_)) => Some("copy cmd"),
RenderBlock::ToolCall(ToolCallBlock::Read(_)) => Some("copy path"),
RenderBlock::ToolCall(ToolCallBlock::Edit(_)) => Some("copy path"),
RenderBlock::ToolCall(ToolCallBlock::WebFetch(_)) => Some("copy url"),
RenderBlock::ToolCall(ToolCallBlock::WebSearch(_)) => Some("copy query"),
RenderBlock::ToolCall(ToolCallBlock::Search(_)) => Some("copy pattern"),
_ => None,
}
}
/// Access pre-wrap hyperlink targets via a closure, avoiding allocation.
///
/// The hyperlinks are in the markdown renderer's coordinate space
/// (pre-wrap line index, display-cell column range). The caller is
/// responsible for mapping through word-wrapping and entry layout to
/// reach screen coordinates.
pub fn with_hyperlinks<R>(&self, f: impl FnOnce(&[kigi_markdown::HyperlinkTarget]) -> R) -> R {
match self {
RenderBlock::AgentMessage(b) => b.content().with_hyperlinks(f),
RenderBlock::Thinking(b) => b.content().with_hyperlinks(f),
RenderBlock::Btw(b) => b.content().with_hyperlinks(f),
_ => f(&[]),
}
}
/// Set the raw mode for blocks that support it.
///
/// This should be called before `output()` when the raw mode might have changed.
/// Only affects AgentMessage and Thinking blocks; other blocks ignore this.
pub fn set_raw_mode(&mut self, raw: bool) {
match self {
RenderBlock::AgentMessage(block) => block.set_raw_mode(raw),
RenderBlock::Thinking(block) => block.set_raw_mode(raw),
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::appearance::AppearanceConfig;
use crate::scrollback::DisplayMode;
use crate::scrollback::types::BlockLine;
use pretty_assertions::assert_eq;
use ratatui::text::{Line, Span};
fn ctx(mode: DisplayMode, is_running: bool) -> BlockContext {
BlockContext {
mode,
is_running,
width: 80,
raw: false,
max_lines: None,
appearance: AppearanceConfig::default(),
is_selected: false,
cwd: None,
}
}
/// A block with N text lines + one trailing inline image — exercises the
/// default `inline_media_placements` wrapper that preserves tool media.
struct TrailingMediaBlock {
lines: usize,
}
impl BlockContent for TrailingMediaBlock {
fn output(&self, _ctx: &BlockContext) -> BlockOutput {
BlockOutput {
lines: (0..self.lines)
.map(|i| BlockLine::text(format!("line {i}")))
.collect(),
}
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None
}
fn inline_media(&self) -> Option<InlineMediaInfo> {
Some(InlineMediaInfo {
path: std::path::PathBuf::from("/tmp/img.png"),
width: 400,
height: 200,
is_video: false,
alt_text: String::new(),
})
}
}
#[test]
fn default_inline_media_placements_wrap_trailing_media() {
// The default wrapper turns a single `inline_media()` into one trailing
// placement: anchored one row below the text, with the filepath line
// exposed — i.e. the historical tool-media geometry is preserved.
let block = TrailingMediaBlock { lines: 2 };
let c = ctx(DisplayMode::Expanded, false);
let placements = block.inline_media_placements(&c);
assert_eq!(placements.len(), 1);
let p = &placements[0];
assert_eq!(
p.row_offset, 3,
"image trails the 2 text lines + 1 padding row"
);
assert!(p.rows >= 2, "image area reserves the fitted rows");
assert_eq!(p.info.path, std::path::PathBuf::from("/tmp/img.png"));
}
#[test]
fn default_inline_media_placements_empty_without_media() {
// A block with no `inline_media()` yields no placements (the common case
// for every non-media block).
let block = StubBlock::new("hi", ratatui::style::Color::Blue);
assert!(
block
.inline_media_placements(&ctx(DisplayMode::Expanded, false))
.is_empty()
);
}
#[test]
fn test_stub_block() {
let block = RenderBlock::stub("Hello, world!", ratatui::style::Color::Blue);
let c = ctx(DisplayMode::Expanded, false);
let output = block.output(&c);
assert_eq!(output.len(), 1);
let accent = block.accent(&c);
assert!(accent.is_some());
assert!(!accent.unwrap().animated);
}
#[test]
fn test_stub_block_running() {
let block = RenderBlock::stub("Running...", ratatui::style::Color::Green);
let c = ctx(DisplayMode::Expanded, true);
let accent = block.accent(&c);
assert!(accent.is_some());
assert!(accent.unwrap().animated);
}
#[test]
fn test_user_prompt_block() {
let block = RenderBlock::user_prompt("How do I foo?");
let c = ctx(DisplayMode::Expanded, false);
let output = block.output(&c);
assert!(!output.lines.is_empty());
assert!(!block.is_foldable());
}
#[test]
fn test_agent_message_block() {
let block = RenderBlock::agent_message("Here's how to do it...");
let c = ctx(DisplayMode::Expanded, false);
let output = block.output(&c);
assert!(!output.lines.is_empty());
assert!(block.has_raw_mode());
}
#[test]
fn test_tool_call_block() {
let block = RenderBlock::tool_call("Read", "src/main.rs (50 lines)", true);
let c = ctx(DisplayMode::Collapsed, false);
let output = block.output(&c);
assert_eq!(output.len(), 1);
assert!(!block.is_foldable());
}
#[test]
fn test_tool_call_with_output_is_foldable() {
let block = RenderBlock::execute_with_output("cargo test", "test output", None::<String>);
assert!(block.is_foldable());
}
#[test]
fn test_search_block_with_matches_has_fullscreen_viewer() {
use crate::scrollback::blocks::SearchLineMatch;
let block = RenderBlock::search(
"fn main",
1,
vec![SearchFileMatch {
path: "src/main.rs".into(),
matches: vec![SearchLineMatch {
line_number: 1,
content: "fn main() {}".into(),
}],
}],
);
assert!(block.has_normal_fullscreen_viewer());
}
#[test]
fn test_errored_search_block_has_no_fullscreen_viewer() {
let block = RenderBlock::ToolCall(ToolCallBlock::Search(
SearchToolCallBlock::new("fn main").with_error("boom"),
));
assert!(!block.has_normal_fullscreen_viewer());
}
#[test]
fn test_list_dir_block_with_output_has_fullscreen_viewer() {
let block = RenderBlock::list_dir_with_output("/tmp", "a.txt\nb.txt");
assert!(block.has_normal_fullscreen_viewer());
}
#[test]
fn test_empty_list_dir_block_has_no_fullscreen_viewer() {
let block = RenderBlock::list_dir("/tmp");
assert!(!block.has_normal_fullscreen_viewer());
}
#[test]
fn test_copy_visible_text_in_state_rejoins_wrapped_lines() {
let block = RenderBlock::stub_non_groupable("hello world", ratatui::style::Color::Blue);
let copied = block.copy_visible_text_in_state(&ctx(DisplayMode::Expanded, false));
assert_eq!(copied.as_deref(), Some("hello world"));
}
#[test]
fn edit_whole_block_copy_preserves_path_boundary_whitespace() {
let block = RenderBlock::edit(" foo.rs ", None);
let mut context = ctx(DisplayMode::Expanded, false);
context.width = 8;
assert_eq!(
block.copy_visible_text_in_state(&context).as_deref(),
Some(" foo.rs ")
);
}
#[test]
fn test_copy_visible_text_in_state_skips_non_selectable_lines() {
let output = BlockOutput {
lines: vec![
BlockLine::separator(Line::raw("---")),
BlockLine::text("body"),
],
};
assert_eq!(
plain_text_from_output(&output, &SelectionBoundaries::default()).as_deref(),
Some("body")
);
}
#[test]
fn test_copy_does_not_capture_render_only_table_padding() {
// Table rows are padded to the content width for rendering. Block-drag
// copy must not include that trailing padding.
let output = BlockOutput {
lines: vec![
BlockLine::styled(Line::from(vec![
Span::raw("│ a │ b │"),
Span::raw(" "),
])),
BlockLine::styled(Line::from(vec![
Span::raw("│ c │ d │"),
Span::raw(" "),
])),
],
};
assert_eq!(
plain_text_from_output(&output, &SelectionBoundaries::default()).as_deref(),
Some("│ a │ b │\n│ c │ d │")
);
}
#[test]
fn test_bullet_preserves_selection_metadata() {
let mut output = BlockOutput {
lines: vec![
BlockLine::styled(Line::from(vec![Span::raw("body")]))
.with_selection_range(Some(3))
.with_selection_text(Some("body".to_string())),
],
};
prepend_bullet(&mut output, &ctx(DisplayMode::Expanded, false), None);
let line = &output.lines[0];
assert_eq!(line.selection_range, Some(3));
assert_eq!(line.selection_text.as_deref(), Some("body"));
assert!(matches!(line.selectable, Selectable::Spans(ref r) if *r == (1..2)));
}
}
#[cfg(test)]
mod searchable_text_tests {
use super::*;
use crate::scrollback::blocks::SearchLineMatch;
use crate::scrollback::blocks::tool::memory_search::{MemoryResult, MemorySearchToolCallBlock};
use crate::scrollback::blocks::tool::{LifecycleEventBlock, WebSearchToolCallBlock};
use kigi_shell::session::ContextInfo;
use std::time::Duration;
#[test]
fn system_indexes_message_text() {
let block = RenderBlock::system("disk almost full");
assert_eq!(block.searchable_text().as_deref(), Some("disk almost full"));
}
#[test]
fn user_prompt_indexes_text() {
let block = RenderBlock::user_prompt("how do I foo the bar");
assert_eq!(
block.searchable_text().as_deref(),
Some("how do I foo the bar")
);
}
#[test]
fn empty_only_source_field_returns_none() {
// join_searchable drops empty strings, so a block whose only source
// field is empty is left out of the index entirely.
assert_eq!(RenderBlock::system("").searchable_text(), None);
assert_eq!(RenderBlock::user_prompt("").searchable_text(), None);
}
#[test]
fn session_event_flattens_to_sentence() {
let block = RenderBlock::session_event(SessionEvent::TurnFailed {
error: "connection reset".into(),
elapsed: None,
});
let text = block.searchable_text().expect("session event text");
assert!(text.contains("connection reset"), "got: {text:?}");
}
#[test]
fn bg_task_indexes_command_and_description() {
let block = RenderBlock::bg_task("cargo build --release", "task-1")
.with_bg_task_description(Some("compile in release mode".into()));
let text = block.searchable_text().expect("bg task text");
assert!(text.contains("cargo build --release"), "got: {text:?}");
assert!(text.contains("compile in release mode"), "got: {text:?}");
}
#[test]
fn subagent_failed_indexes_metadata_and_error() {
let mut block = RenderBlock::Subagent(SubagentBlock::failed(
"investigate flaky test",
"child-1",
Duration::from_secs(3),
Some("panicked at assert".into()),
));
// Populate the metadata fields a failed background block leaves empty.
if let RenderBlock::Subagent(b) = &mut block {
b.subagent_type = "explore".into();
b.persona = Some("scout".into());
b.role = Some("researcher".into());
b.model = Some("kigi-test".into());
b.activity_label = Some("Running: cargo build".into());
}
let text = block.searchable_text().expect("subagent text");
assert!(text.contains("investigate flaky test"), "got: {text:?}");
assert!(text.contains("explore"), "got: {text:?}");
assert!(text.contains("scout"), "got: {text:?}");
assert!(text.contains("researcher"), "got: {text:?}");
assert!(text.contains("kigi-test"), "got: {text:?}");
assert!(text.contains("Running: cargo build"), "got: {text:?}");
assert!(text.contains("panicked at assert"), "got: {text:?}");
}
#[test]
fn btw_indexes_question_and_rendered_response() {
let block = RenderBlock::Btw(BtwBlock::new("what is rust", "a **systems** language"));
let text = block.searchable_text().expect("btw text");
assert!(text.contains("what is rust"), "got: {text:?}");
// Rendered plain text is indexed (markers stripped), so a query that
// spans the emphasis — "a systems language" — matches the index just
// as it matches the on-screen highlight.
assert!(text.contains("a systems language"), "got: {text:?}");
assert!(!text.contains("**"), "markers should be stripped: {text:?}");
}
#[test]
fn context_info_indexes_model_only() {
let snapshot = ContextInfo {
used: 100,
total: 1_000,
system_prompt_tokens: 10,
tool_definitions_count: 1,
tool_definitions_tokens: 20,
compaction_count: 0,
turn_count: 1,
tool_call_count: 1,
message_count: 1,
message_tokens: 30,
free_tokens: 900,
usage_pct: 10,
auto_compact_threshold_percent: 85,
usage_categories: vec![],
};
let block = RenderBlock::context_info(snapshot, "kigi-4.5");
// Only the model name is source text; the rest is a numeric breakdown.
assert_eq!(block.searchable_text().as_deref(), Some("kigi-4.5"));
}
#[test]
fn search_tool_indexes_pattern_and_match_line() {
let block = RenderBlock::search(
"fn main",
1,
vec![SearchFileMatch {
path: "src/main.rs".into(),
matches: vec![SearchLineMatch {
line_number: 10,
content: "fn main() {}".into(),
}],
}],
);
let text = block.searchable_text().expect("search text");
assert!(text.contains("fn main"), "got: {text:?}");
assert!(text.contains("src/main.rs"), "got: {text:?}");
assert!(text.contains("fn main() {}"), "got: {text:?}");
}
#[test]
fn list_dir_indexes_path_and_output() {
let block = RenderBlock::list_dir_with_output("/tmp/proj", "a.txt\nb.txt");
let text = block.searchable_text().expect("list dir text");
assert!(text.contains("/tmp/proj"), "got: {text:?}");
assert!(text.contains("a.txt"), "got: {text:?}");
}
#[test]
fn memory_search_indexes_query_and_results() {
let mut mem = MemorySearchToolCallBlock::new("deployment process");
mem.results = vec![MemoryResult {
score: 0.9,
source: "global".into(),
path: "MEMORY.md".into(),
start_line: 1,
end_line: 5,
snippet: "use graphite for PRs".into(),
}];
let block = RenderBlock::ToolCall(ToolCallBlock::MemorySearch(mem));
let text = block.searchable_text().expect("memory search text");
assert!(text.contains("deployment process"), "got: {text:?}");
assert!(text.contains("global"), "got: {text:?}");
assert!(text.contains("MEMORY.md"), "got: {text:?}");
assert!(text.contains("use graphite for PRs"), "got: {text:?}");
}
#[test]
fn lifecycle_indexes_event_name() {
let block = RenderBlock::ToolCall(ToolCallBlock::Lifecycle(LifecycleEventBlock::new(
"user_prompt_submit",
)));
assert_eq!(
block.searchable_text().as_deref(),
Some("user_prompt_submit")
);
}
#[test]
fn other_tool_indexes_name_summary_output_and_error() {
let block =
RenderBlock::tool_call_with_details("MyTool", "do something", false, "the output");
let text = block.searchable_text().expect("other tool text");
assert!(text.contains("MyTool"), "got: {text:?}");
assert!(text.contains("do something"), "got: {text:?}");
assert!(text.contains("the output"), "got: {text:?}");
assert!(text.contains("Tool call failed"), "got: {text:?}");
}
#[test]
fn execute_indexes_command_output_and_error() {
let block =
RenderBlock::execute_with_output("cargo test", "running 5 tests", Some("1 failed"));
let text = block.searchable_text().expect("execute text");
assert!(text.contains("cargo test"), "got: {text:?}");
assert!(text.contains("running 5 tests"), "got: {text:?}");
assert!(text.contains("1 failed"), "got: {text:?}");
}
#[test]
fn agent_message_indexes_rendered_text() {
// Rendered plain text is indexed (markers stripped) so the index agrees
// with the on-screen highlight: a phrase spanning emphasis is found.
let block = RenderBlock::agent_message("this is **really** important");
let text = block.searchable_text().expect("agent text");
assert!(text.contains("is really important"), "got: {text:?}");
assert!(!text.contains("**"), "markers should be stripped: {text:?}");
}
#[test]
fn thinking_indexes_rendered_text() {
let block = RenderBlock::thinking("plan: call `_foo()` then verify");
let text = block.searchable_text().expect("thinking text");
// Inline-code backticks are stripped in the rendered view; the code
// content itself is preserved.
assert!(text.contains("call _foo() then verify"), "got: {text:?}");
assert!(
!text.contains('`'),
"backticks should be stripped: {text:?}"
);
}
#[test]
fn web_search_indexes_query_and_citation() {
let mut ws = WebSearchToolCallBlock::new("rust async runtime");
ws.citations = vec!["https://tokio.rs/docs".into()];
let block = RenderBlock::ToolCall(ToolCallBlock::WebSearch(ws));
let text = block.searchable_text().expect("web search text");
assert!(text.contains("rust async runtime"), "got: {text:?}");
assert!(text.contains("https://tokio.rs/docs"), "got: {text:?}");
}
}