M0: compilable skeleton — Kigi 0.1.0 fork surgery

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).
This commit is contained in:
ZacharyZhang-NY committed 2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 files changed
+1353757

No files matched your search

@@ -0,0 +1,491 @@
//! AgentMessageBlock - displays agent responses with markdown.
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput};
use super::markdown_content::MarkdownContent;
use super::mermaid_content::{self, MermaidContent};
/// Block displaying an agent message with streaming markdown support.
///
/// This block uses [`MarkdownContent`] for incremental markdown rendering
/// with cached word-wrapping. When text arrives in chunks, call
/// `push_chunk()` to append without re-rendering everything.
///
/// When `ctx.raw` is false, renders pretty markdown (hiding syntax).
/// When `ctx.raw` is true, renders the source markdown as-is.
#[derive(Debug, Clone)]
pub struct AgentMessageBlock {
content: MarkdownContent,
/// Cached image references extracted from the markdown source.
image_refs: Vec<crate::prompt_images::ScrollbackImageRef>,
/// Cached video references extracted from the markdown source.
video_refs: Vec<crate::prompt_images::ScrollbackVideoRef>,
/// Detected ` ```mermaid ` diagrams + render skeleton, populated at
/// construction/finish (never per streaming chunk) like the media refs.
mermaid: MermaidContent,
}
impl AgentMessageBlock {
/// Create a new agent message block with complete text.
pub fn new(text: impl Into<String>) -> Self {
let text = text.into();
let image_refs = crate::prompt_images::extract_image_refs(&text);
let video_refs = crate::prompt_images::extract_video_refs(&text);
let content = MarkdownContent::new(text);
let mermaid = content.mermaid_content();
Self {
content,
image_refs,
video_refs,
mermaid,
}
}
/// Create an empty block for streaming.
pub fn streaming() -> Self {
Self {
content: MarkdownContent::streaming(),
image_refs: Vec::new(),
video_refs: Vec::new(),
mermaid: MermaidContent::default(),
}
}
/// Push a streaming chunk of markdown text.
pub fn push_chunk(&mut self, chunk: &str) {
self.content.push_chunk(chunk);
}
/// Push a chunk without rendering immediately.
pub fn push_chunk_deferred(&mut self, chunk: &str) {
self.content.push_chunk_deferred(chunk);
}
/// Get the source markdown text.
pub fn text(&self) -> String {
self.content.text()
}
/// Whether the source markdown is empty (zero-alloc, unlike `text()`).
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
/// Finish streaming and do a full re-render for safety.
pub fn finish(&mut self) {
self.content.finish();
let text = self.content.text();
self.image_refs = crate::prompt_images::extract_image_refs(&text);
self.video_refs = crate::prompt_images::extract_video_refs(&text);
// Detection runs once the render is final, after the renderer freezes —
// never per streaming chunk.
self.mermaid = self.content.mermaid_content();
}
/// The detected Mermaid diagrams for this message (empty until finished or
/// constructed from complete text).
pub fn mermaid(&self) -> &MermaidContent {
&self.mermaid
}
/// Set the raw mode, re-rendering if it changed.
pub fn set_raw_mode(&mut self, raw: bool) {
self.content.set_raw_mode(raw);
}
/// Access the underlying markdown content (for viewer item building).
pub fn content(&self) -> &MarkdownContent {
&self.content
}
/// Mutable access to the underlying markdown content.
pub fn content_mut(&mut self) -> &mut MarkdownContent {
&mut self.content
}
/// Get copyable text for this block.
///
/// When `raw` is true, returns the raw markdown source.
/// When `raw` is false, returns the rendered text (styles stripped).
pub fn copy_text(&self, raw: bool) -> String {
if raw {
self.content.text()
} else {
self.content.rendered_plain_text()
}
}
}
impl AgentMessageBlock {
/// Resolve the diagram display mode from the user setting without building
/// `output()` — cheap enough to gate the per-frame affordance path.
fn mermaid_display_mode(&self) -> mermaid_content::MermaidDisplay {
// Minimal mode commits static text with no draw loop to paint the
// clickable affordance row, so suppress it there (the diagram art still
// renders; its source stays natively selectable). The inline-overlay
// force-off flag is set iff minimal.
mermaid_content::mermaid_display_static(
crate::appearance::cache::load_render_mermaid(),
crate::terminal::image::scrollback_inline_overlay_forced_off(),
)
}
/// Build the block's output and the diagram affordance rows together so the
/// inserted rows (in the output) and the anchored placements (their offsets)
/// are always derived from the same layout.
///
/// [`output`](Self::output) and [`diagram_affordances`](Self::diagram_affordances)
/// each call this independently (so it runs twice per frame for a diagram
/// message); it is deterministic for a given `ctx`, so the two calls produce
/// matching rows + offsets without a shared cache that could drift.
///
/// Only callers that have already confirmed there are diagrams and we are
/// not in raw mode should reach here (so the common diagram-free path never
/// pays this build).
fn rendered_output(
&self,
ctx: &BlockContext,
) -> (BlockOutput, Vec<mermaid_content::DiagramAffordance>) {
let mut out = self.content.output(ctx.width as usize);
// Diagram pre-wrap ranges in document order. The fence count and order
// are width-invariant, so range index `idx` pairs positionally with the
// diagram's source (`self.mermaid.source(idx)`).
let ranges = self.content.mermaid_block_ranges();
match self.mermaid_display_mode() {
mermaid_content::MermaidDisplay::SourceOnly => (out, Vec::new()),
mermaid_content::MermaidDisplay::Affordances => {
let affordances =
mermaid_content::apply_affordance_rows(&mut out, &ranges, |idx| {
self.mermaid.source(idx).unwrap_or_default().to_string()
});
(out, affordances)
}
}
}
}
impl BlockContent for AgentMessageBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
// Common path: no diagrams (or raw mode) → plain markdown, no affordance
// machinery and no extra output rebuild.
if ctx.raw || self.mermaid.is_empty() {
return self.content.output(ctx.width as usize);
}
self.rendered_output(ctx).0
}
fn diagram_affordances(&self, ctx: &BlockContext) -> Vec<mermaid_content::DiagramAffordance> {
// Affordance rows exist only under the affordance display with diagrams;
// for every other (much more common) case, return without building
// output().
if ctx.raw
|| self.mermaid.is_empty()
|| self.mermaid_display_mode() != mermaid_content::MermaidDisplay::Affordances
{
return Vec::new();
}
self.rendered_output(ctx).1
}
fn estimate_extra_rows(&self) -> u16 {
// Each detected diagram inserts one treatment row (affordance row or
// fallback caption) into output() that the source-text estimate can't
// see. Count one per diagram (a safe over-estimate if a range is empty)
// so the off-screen estimate never under-reserves; raw mode and the
// `off` setting add no such row.
if self.mermaid.is_empty()
|| self.content.is_raw()
|| self.mermaid_display_mode() == mermaid_content::MermaidDisplay::SourceOnly
{
return 0;
}
self.mermaid.len() as u16
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn has_raw_mode(&self) -> bool {
true
}
fn is_foldable(&self) -> bool {
false
}
fn image_references(&self) -> &[crate::prompt_images::ScrollbackImageRef] {
&self.image_refs
}
fn video_references(&self) -> &[crate::prompt_images::ScrollbackVideoRef] {
&self.video_refs
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::appearance::{AppearanceConfig, RenderMermaid};
use crate::scrollback::types::Selectable;
fn ctx(width: u16, raw: bool) -> BlockContext {
BlockContext {
mode: crate::scrollback::DisplayMode::Expanded,
is_running: false,
width,
raw,
max_lines: None,
appearance: AppearanceConfig::default(),
is_selected: false,
cwd: None,
}
}
#[test]
fn agent_markdown_body_uses_single_logical_range() {
let block = AgentMessageBlock::new("hello world this should wrap across lines");
let out = block.output(&ctx(10, false));
assert!(out.lines.len() > 1);
assert!(out.lines.iter().all(|line| line.selection_range == Some(0)));
assert!(
out.lines
.iter()
.all(|line| !matches!(line.selectable, Selectable::None))
);
}
#[test]
fn agent_copy_text_preserves_raw_semantics() {
let block = AgentMessageBlock::new("**bold** text");
assert_eq!(block.copy_text(true), "**bold** text");
assert_eq!(
block.copy_text(false),
block.content().rendered_plain_text()
);
}
#[test]
fn mermaid_detected_at_construction() {
let block = AgentMessageBlock::new("```mermaid\nflowchart TD\n A --> B\n```\n");
assert_eq!(block.mermaid().len(), 1);
assert_eq!(block.mermaid().source(0), Some("flowchart TD\n A --> B\n"));
}
#[test]
fn mermaid_not_detected_during_streaming_until_finish() {
let mut block = AgentMessageBlock::streaming();
block.push_chunk("```mermaid\nflowchart TD\n");
// Fence still open mid-stream → no detection.
assert!(block.mermaid().is_empty());
block.push_chunk("A --> B\n```\n");
assert!(
block.mermaid().is_empty(),
"detection runs at finish(), not per chunk"
);
block.finish();
assert_eq!(block.mermaid().len(), 1);
}
const MERMAID_MD: &str = "```mermaid\nflowchart TD\n A --> B\n```\n";
#[test]
fn mermaid_treatment_row_shown_in_auto_not_off_not_raw() {
let non_selectable = |o: &BlockOutput| {
o.lines
.iter()
.filter(|l| matches!(l.selectable, Selectable::None))
.count()
};
// off → plain code block, no extra row.
crate::appearance::cache::set_render_mermaid(RenderMermaid::Off);
let off = AgentMessageBlock::new(MERMAID_MD).output(&ctx(40, false));
assert_eq!(non_selectable(&off), 0, "off mode must not add a row");
// auto → exactly one extra non-selectable row beneath the diagram (the
// affordance row). The row is blank in `output()` — the draw loop paints
// its `◇ mermaid [Open Image] [Copy Image Path] [Copy Source]` buttons.
crate::appearance::cache::set_render_mermaid(RenderMermaid::Auto);
let auto = AgentMessageBlock::new(MERMAID_MD).output(&ctx(40, false));
assert_eq!(
auto.lines.len(),
off.lines.len() + 1,
"auto mode adds one treatment row"
);
assert_eq!(non_selectable(&auto), 1);
// raw → verbatim source, no extra row even in auto.
crate::appearance::cache::set_render_mermaid(RenderMermaid::Auto);
let raw = AgentMessageBlock::new(MERMAID_MD).output(&ctx(40, true));
assert_eq!(non_selectable(&raw), 0, "raw mode shows the fence verbatim");
}
#[test]
fn mermaid_treatment_row_preserves_hyperlink_line_mapping() {
// The inserted treatment row (caption or affordance) is a joiner-
// continuation line, so it must NOT add a logical (pre-wrap) line —
// otherwise the hyperlink overlay walk desyncs for the paragraph after
// the diagram.
let md = "before\n\n```mermaid\nA-->B\n```\n\n[link](https://example.com) trailing\n";
crate::appearance::cache::set_render_mermaid(RenderMermaid::Off);
let off = AgentMessageBlock::new(md).output(&ctx(60, false));
crate::appearance::cache::set_render_mermaid(RenderMermaid::Auto);
let block = AgentMessageBlock::new(md);
let auto = block.output(&ctx(60, false));
let logical = |o: &BlockOutput| o.lines.iter().filter(|l| l.joiner.is_none()).count();
assert_eq!(
logical(&auto),
logical(&off),
"treatment row must not introduce a new logical line",
);
// The renderer's hyperlinks are pre-wrap and unchanged by the inserted
// row (it lives in the BlockOutput, not the renderer). Walk the output's
// joiners to recover each row's pre-wrap index and confirm the link's
// pre-wrap line still maps to its row — i.e. the row did not shift it.
let link_line = block
.content()
.with_hyperlinks(|hs| hs.iter().map(|h| h.line_index).min())
.expect("the trailing link must be detected");
let mut prewrap = 0usize;
let mut mapped_text = String::new();
for (row, line) in auto.lines.iter().enumerate() {
if row > 0 && line.joiner.is_none() {
prewrap += 1;
}
if prewrap == link_line {
mapped_text = line
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
break;
}
}
assert!(
mapped_text.contains("example.com"),
"link pre-wrap line {link_line} must still map to the link row, got {mapped_text:?}",
);
}
// -- diagram affordance rows ---------------------------------------------
mod affordances {
use super::*;
fn shown_text(out: &BlockOutput) -> String {
out.lines
.iter()
.flat_map(|l| l.content.spans.iter())
.map(|s| s.content.as_ref())
.collect()
}
/// A detected diagram keeps its source code block on screen and exposes a
/// single affordance row carrying the diagram source (the data every
/// lazy `[Open]`/`[Copy path]`/`[Copy source]` button acts on). Rendering
/// is lazy, so no path/state is tracked on the row.
#[test]
fn diagram_exposes_affordance_carrying_source_and_keeps_source_block() {
crate::appearance::cache::set_render_mermaid(RenderMermaid::On);
let block = AgentMessageBlock::new("intro\n\n```mermaid\nA-->B\n```\n\nbye\n");
let affs = block.diagram_affordances(&ctx(60, false));
assert_eq!(affs.len(), 1, "one diagram → one affordance row");
assert_eq!(affs[0].source, "A-->B\n");
// The diagram is shown as its source code block (never an image), and
// the affordance row sits at its reported (non-selectable) offset.
let out = block.output(&ctx(60, false));
assert!(
shown_text(&out).contains("A-->B"),
"the source code block stays on screen",
);
assert!(matches!(
out.lines[affs[0].row_offset as usize].selectable,
Selectable::None
));
}
#[test]
fn raw_mode_suppresses_affordances_and_shows_source() {
crate::appearance::cache::set_render_mermaid(RenderMermaid::On);
let block = AgentMessageBlock::new("```mermaid\nA-->B\n```\n");
assert!(
block.diagram_affordances(&ctx(60, true)).is_empty(),
"raw mode suppresses affordances",
);
assert!(shown_text(&block.output(&ctx(60, true))).contains("A-->B"));
// Toggling back to pretty restores the affordance row.
assert_eq!(block.diagram_affordances(&ctx(60, false)).len(), 1);
}
#[test]
fn off_setting_shows_source_with_no_affordances() {
crate::appearance::cache::set_render_mermaid(RenderMermaid::Off);
let block = AgentMessageBlock::new("```mermaid\nA-->B\n```\n");
assert!(block.diagram_affordances(&ctx(60, false)).is_empty());
assert!(shown_text(&block.output(&ctx(60, false))).contains("A-->B"));
}
#[test]
fn copy_over_diagram_yields_fence_body() {
use crate::scrollback::block::RenderBlock;
crate::appearance::cache::set_render_mermaid(RenderMermaid::On);
// Drive the real whole-block copy path (`copy_visible_text_in_state`
// → `plain_text_from_output`) rather than re-implementing the
// selectable filter, so the test tracks production: the source code
// block is selectable, the blank affordance row is excluded.
let block = RenderBlock::AgentMessage(AgentMessageBlock::new(
"```mermaid\nA-->B\nC-->D\n```\n",
));
let copied = block
.copy_visible_text_in_state(&ctx(60, false))
.expect("the source code block yields selectable copy text");
assert!(copied.contains("A-->B"), "copy yields source: {copied:?}");
assert!(copied.contains("C-->D"), "copy yields source: {copied:?}");
}
/// With two diagrams, each affordance row anchors at its OWN
/// (non-selectable) row in the final output, in document order, and
/// carries that diagram's own source.
#[test]
fn two_diagrams_each_anchor_at_their_own_row() {
crate::appearance::cache::set_render_mermaid(RenderMermaid::On);
let md = "intro line\n\n```mermaid\nAAA-->BBB\n```\n\nmid line\n\n```mermaid\nCCC-->DDD\n```\n\nbye line\n";
let block = AgentMessageBlock::new(md);
assert_eq!(block.mermaid().len(), 2, "two diagrams");
let out = block.output(&ctx(60, false));
let affs = block.diagram_affordances(&ctx(60, false));
assert_eq!(affs.len(), 2);
assert!(
affs[0].row_offset < affs[1].row_offset,
"diagram order preserved: {} < {}",
affs[0].row_offset,
affs[1].row_offset,
);
assert_eq!(affs[0].source, "AAA-->BBB\n");
assert_eq!(affs[1].source, "CCC-->DDD\n");
for aff in &affs {
assert!(matches!(
out.lines[aff.row_offset as usize].selectable,
Selectable::None
));
}
// Both diagrams' sources remain visible as code blocks.
assert!(shown_text(&out).contains("AAA-->BBB"));
assert!(shown_text(&out).contains("CCC-->DDD"));
}
}
}
@@ -0,0 +1,577 @@
//! BgTaskBlock — scrollback entries for background task lifecycle.
//!
//! Three kinds: Started, Completed, Failed. All render as always-collapsed,
//! groupable blocks with dimmed colored bullets (same dimming as execute blocks).
//! Enter / Ctrl-F opens block viewer with stdout from central store.
use std::time::Duration;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span, Text};
use crate::render::color::blend_color;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput, DisplayMode};
use crate::theme::Theme;
use crate::util::format_duration;
/// What kind of bg task lifecycle event this block represents.
#[derive(Debug, Clone)]
pub enum BgTaskKind {
/// Task was started (process is running).
Started,
/// Task completed successfully.
Completed { elapsed: Duration },
/// Task failed (non-zero exit, signal, timeout, etc.).
Failed {
elapsed: Duration,
exit_code: Option<i32>,
signal: Option<String>,
},
}
/// Background task scrollback block.
///
/// Always collapsed, not foldable, groupable, selectable.
/// Enter / Ctrl-F opens block viewer with stdout from the central store.
#[derive(Debug, Clone)]
pub struct BgTaskBlock {
/// The command that was run.
pub command: String,
/// Background task ID (for looking up stdout in central store).
pub task_id: String,
/// Lifecycle kind (started / completed / failed).
pub kind: BgTaskKind,
/// Optional description (from the tool call's description field).
pub description: Option<String>,
}
impl BgTaskBlock {
/// Create a "Task started" block.
pub fn started(command: impl Into<String>, task_id: impl Into<String>) -> Self {
Self {
command: command.into(),
task_id: task_id.into(),
kind: BgTaskKind::Started,
description: None,
}
}
/// Create a "Task completed" block.
pub fn completed(
command: impl Into<String>,
task_id: impl Into<String>,
elapsed: Duration,
) -> Self {
Self {
command: command.into(),
task_id: task_id.into(),
kind: BgTaskKind::Completed { elapsed },
description: None,
}
}
/// Create a "Task failed" block.
pub fn failed(
command: impl Into<String>,
task_id: impl Into<String>,
elapsed: Duration,
exit_code: Option<i32>,
signal: Option<String>,
) -> Self {
Self {
command: command.into(),
task_id: task_id.into(),
kind: BgTaskKind::Failed {
elapsed,
exit_code,
signal,
},
description: None,
}
}
/// Set the description (builder pattern).
pub fn with_description(mut self, description: Option<String>) -> Self {
self.description = description;
self
}
/// Whether this block represents a running task (Started kind).
pub fn is_running(&self) -> bool {
matches!(self.kind, BgTaskKind::Started)
}
/// Mark a Started block as completed (called when task finishes).
pub fn mark_completed(&mut self, elapsed: Duration) {
self.kind = BgTaskKind::Completed { elapsed };
}
/// Mark a Started block as failed (called when task fails).
pub fn mark_failed(
&mut self,
elapsed: Duration,
exit_code: Option<i32>,
signal: Option<String>,
) {
self.kind = BgTaskKind::Failed {
elapsed,
exit_code,
signal,
};
}
}
impl BlockContent for BgTaskBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
// When selected, lift only the bold "Task" label to `text_primary`
// so it reads as undimmed (mirrors `read.rs` / `search.rs`, which
// bump only the label and leave the rest at `muted`). The detail
// text (verb + description) stays muted in every state.
let bold = if ctx.is_selected {
theme.primary().add_modifier(Modifier::BOLD)
} else {
theme.muted().add_modifier(Modifier::BOLD)
};
let muted = theme.muted();
// Collapse newlines for single-line display (ratatui drops '\n' as zero-width,
// merging adjacent lines without spacing).
let command = self.command.replace('\n', " ");
// Prefer description over raw command for the collapsed one-line display.
// The full command is always available in the block viewer (preamble).
let display = match &self.description {
Some(d) if !d.trim().is_empty() => d.replace('\n', " "),
_ => command,
};
let line = match &self.kind {
BgTaskKind::Started => Line::from(vec![
Span::styled("Task ", bold),
Span::styled("started: ", muted),
Span::styled(display, muted),
]),
BgTaskKind::Completed { elapsed } => Line::from(vec![
Span::styled("Task ", bold),
Span::styled(
format!("completed in {}: ", format_duration(*elapsed)),
muted,
),
Span::styled(display, muted),
]),
BgTaskKind::Failed {
elapsed,
exit_code,
signal,
} => {
// Detect kill signals to show "killed" instead of "failed"
let is_killed = signal
.as_deref()
.is_some_and(|s| matches!(s, "killed" | "SIGTERM" | "SIGKILL" | "oom"));
let verb = if is_killed { "killed" } else { "failed" };
let detail = if is_killed {
String::new()
} else {
match (exit_code, signal) {
(_, Some(sig)) => format!(" ({})", sig),
(Some(code), None) => format!(" (exit {})", code),
(None, None) => String::new(),
}
};
Line::from(vec![
Span::styled("Task ", bold),
Span::styled(format!("{verb} in {}: ", format_duration(*elapsed)), muted),
Span::styled(format!("{}{}", display, detail), muted),
])
}
};
BlockOutput {
lines: vec![line.into()],
}
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
let theme = Theme::current();
match &self.kind {
BgTaskKind::Started if ctx.is_running => {
Some(AccentStyle::static_color(theme.accent_running))
}
_ => None,
}
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
let theme = Theme::current();
match &self.kind {
BgTaskKind::Started => {
if ctx.is_running {
// Animated pulse between bg and dimmed magenta.
// Pre-dim using the same ratio as collapsed execute bullets
// so the peak brightness matches other collapsed blocks.
let dim = ctx.appearance.scrollback.display.dim_accent;
let dimmed = blend_color(theme.bg_base, theme.accent_running, dim)
.unwrap_or(theme.accent_running);
Some(AccentStyle::animated(dimmed))
} else {
// Normal gray after finish_running() is called
None
}
}
BgTaskKind::Completed { .. } => Some(AccentStyle::static_color(theme.accent_success)),
BgTaskKind::Failed { .. } => Some(AccentStyle::static_color(theme.accent_error)),
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
false
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn is_selectable(&self) -> bool {
true
}
fn has_bullet(&self, _ctx: &BlockContext) -> bool {
true
}
fn is_groupable(&self) -> bool {
true
}
fn preamble(&self, ctx: &BlockContext) -> Option<Text<'static>> {
let theme = Theme::current();
let mut lines = Vec::new();
// Description first (primary text), then a blank separator, then
// the `$ command` with bash syntax highlighting. When there is no
// description, the command stands alone with no leading blank row.
let description = self
.description
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(desc) = description {
// Trim trailing whitespace per line and collapse runs of blank
// lines to a single blank — multi-line descriptions can carry
// noisy internal blank rows that would otherwise stretch the
// preamble.
let mut prev_blank = false;
for line in desc.lines() {
let trimmed = line.trim_end();
if trimmed.is_empty() {
if prev_blank {
continue;
}
prev_blank = true;
} else {
prev_blank = false;
}
lines.push(Line::from(Span::styled(
trimmed.to_string(),
theme.primary(),
)));
}
lines.push(Line::from(""));
}
// Multi-line `$ command` must be separate ratatui Lines. A single Line
// drops '\n' as zero-width, which smashes `cmd1\ncmd2` into `cmd1cmd2`
// (visible when expanding a started bg task in the block viewer).
// Match execute / permission-panel soft-wrap so physical newlines and
// long lines render the same way as foreground shell tool calls.
push_shell_command_preamble_lines(&mut lines, &self.command, ctx.width as usize, &theme);
Some(Text::from(lines))
}
}
/// Append a soft-wrapped `$ command` block to `lines` (first row prefixed with
/// `$ `, continuations hang-indented under the command body).
fn push_shell_command_preamble_lines(
lines: &mut Vec<Line<'static>>,
command: &str,
width: usize,
theme: &Theme,
) {
use unicode_width::UnicodeWidthStr;
let prefix = "$ ";
let hang = UnicodeWidthStr::width(prefix);
let cmd_width = width.saturating_sub(hang).max(1);
let command = if command.trim().is_empty() {
"\u{2026}"
} else {
command
};
let cmd_rows =
crate::views::permission_view::render_bash_command_display_lines(command, cmd_width);
let hang_indent: String = " ".repeat(hang);
if cmd_rows.is_empty() {
lines.push(Line::from(vec![Span::styled(
prefix.to_string(),
theme.dim(),
)]));
return;
}
for (i, row) in cmd_rows.into_iter().enumerate() {
let mut spans = if i == 0 {
vec![Span::styled(prefix.to_string(), theme.dim())]
} else {
vec![Span::raw(hang_indent.clone())]
};
spans.extend(row.spans);
lines.push(Line::from(spans));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::appearance::AppearanceConfig;
fn test_ctx() -> BlockContext {
BlockContext {
mode: DisplayMode::Collapsed,
is_running: false,
width: 120,
raw: false,
max_lines: None,
appearance: AppearanceConfig::default(),
is_selected: false,
cwd: None,
}
}
fn line_text(block: &BgTaskBlock) -> String {
block.output(&test_ctx()).lines[0]
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
#[test]
fn multiline_command_collapses_newlines() {
let block = BgTaskBlock::started("echo foo\necho bar", "t1");
let text = line_text(&block);
assert!(
text.contains("foo echo bar"),
"newlines should become spaces, got: {text:?}"
);
assert!(
!text.contains('\n'),
"output line must not contain literal newlines"
);
}
#[test]
fn started_and_completed_prefer_description_over_command() {
let started = BgTaskBlock::started("sleep 20", "t1")
.with_description(Some("Wait twenty seconds".into()));
let started_text = line_text(&started);
assert!(
started_text.contains("Wait twenty seconds"),
"started={started_text:?}"
);
assert!(
!started_text.contains("sleep 20"),
"started should not show raw command when description present: {started_text:?}"
);
let completed = BgTaskBlock::completed("sleep 20", "t1", Duration::from_secs(20))
.with_description(Some("Wait twenty seconds".into()));
let completed_text = line_text(&completed);
assert!(
completed_text.contains("completed"),
"completed={completed_text:?}"
);
assert!(
completed_text.contains("Wait twenty seconds"),
"completed={completed_text:?}"
);
assert!(
!completed_text.contains("sleep 20"),
"completed should not show raw command when description present: {completed_text:?}"
);
let failed = BgTaskBlock::failed("sleep 20", "t1", Duration::from_secs(1), Some(1), None)
.with_description(Some("Wait twenty seconds".into()));
let failed_text = line_text(&failed);
assert!(
failed_text.contains("Wait twenty seconds"),
"failed={failed_text:?}"
);
assert!(!failed_text.contains("sleep 20"), "failed={failed_text:?}");
}
#[test]
fn completed_multiline_command_collapses_newlines() {
let block = BgTaskBlock::completed("cmd1\ncmd2", "t1", std::time::Duration::from_secs(1));
let output = block.output(&test_ctx());
let text: String = output.lines[0]
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
text.contains("cmd1 cmd2"),
"newlines should become spaces in completed variant, got: {text:?}"
);
}
fn preamble_plain(block: &BgTaskBlock) -> Vec<String> {
let text = block.preamble(&test_ctx()).expect("preamble");
text.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn preamble_description_first_then_blank_then_command() {
let block = BgTaskBlock::started("cargo test --release", "t1")
.with_description(Some("Run release tests".into()));
let plain = preamble_plain(&block);
assert_eq!(plain.len(), 3, "expected description + blank + command");
assert_eq!(plain[0], "Run release tests");
assert_eq!(plain[1], "");
assert_eq!(plain[2], "$ cargo test --release");
}
#[test]
fn preamble_uses_primary_text_color_for_description() {
// Pin theme to avoid races with parallel tests that call `cache::set`.
crate::theme::cache::set(crate::theme::ThemeKind::GrokNight);
let block =
BgTaskBlock::started("ls", "t1").with_description(Some("List the files".into()));
let text = block.preamble(&test_ctx()).expect("preamble");
let theme = Theme::current();
let span = &text.lines[0].spans[0];
assert_eq!(span.content.as_ref(), "List the files");
assert_eq!(span.style.fg, Some(theme.text_primary));
}
#[test]
fn preamble_without_description_renders_only_command() {
let block = BgTaskBlock::started("ls -la", "t1");
let plain = preamble_plain(&block);
assert_eq!(plain.len(), 1);
assert_eq!(plain[0], "$ ls -la");
}
#[test]
fn preamble_blank_description_falls_back_to_command_only() {
let block = BgTaskBlock::started("ls", "t1").with_description(Some(" \n ".into()));
let plain = preamble_plain(&block);
assert_eq!(plain.len(), 1);
assert_eq!(plain[0], "$ ls");
}
#[test]
fn preamble_multiline_description_keeps_separate_lines() {
let block = BgTaskBlock::started("ls", "t1")
.with_description(Some("First line\nSecond line".into()));
let plain = preamble_plain(&block);
assert_eq!(plain.len(), 4);
assert_eq!(plain[0], "First line");
assert_eq!(plain[1], "Second line");
assert_eq!(plain[2], "");
assert_eq!(plain[3], "$ ls");
}
#[test]
fn preamble_collapses_blank_run_and_trims_trailing_whitespace() {
// Runs of blank lines collapse to one; trailing whitespace on a
// line is stripped so the rendered row doesn't carry stray spaces.
let block = BgTaskBlock::started("ls", "t1")
.with_description(Some("First \n\n\n\nSecond ".into()));
let plain = preamble_plain(&block);
// First, single blank (collapsed from 3 internal blanks), Second,
// separator blank, command.
assert_eq!(plain, vec!["First", "", "Second", "", "$ ls"]);
}
#[test]
fn preamble_multiline_command_keeps_separate_lines() {
// Regression: a single ratatui Line drops '\n' as zero-width, which
// smashed multi-line bg-task commands into one unreadable blob when
// expanded in the block viewer.
let block = BgTaskBlock::started(
"export XAI_ROOT=/tmp\ncd /tmp\necho start\nprod-run start backend",
"t1",
)
.with_description(Some("Start backend".into()));
let plain = preamble_plain(&block);
assert_eq!(
plain,
vec![
"Start backend",
"",
"$ export XAI_ROOT=/tmp",
" cd /tmp",
" echo start",
" prod-run start backend",
]
);
// Sanity: must not be the smashed single-line form.
let joined = plain.join("");
assert!(
!joined.contains("/tmpcd") && !joined.contains("/tmpecho"),
"newlines must not be dropped: {plain:?}"
);
}
#[test]
fn preamble_multiline_command_without_description() {
let block = BgTaskBlock::started("echo a\necho b", "t1");
let plain = preamble_plain(&block);
assert_eq!(plain, vec!["$ echo a", " echo b"]);
}
#[test]
fn failed_multiline_command_collapses_newlines() {
let block = BgTaskBlock::failed(
"a\nb",
"t1",
std::time::Duration::from_secs(2),
Some(1),
None,
);
let output = block.output(&test_ctx());
let text: String = output.lines[0]
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
text.contains("a b"),
"newlines should become spaces in failed variant, got: {text:?}"
);
}
}
@@ -0,0 +1,102 @@
//! BtwBlock — scrollback entry for /btw side-question responses.
//!
//! Renders with a golden accent line. Collapsed (default) shows a
//! single `/btw <question>` header line; expanded shows the full
//! markdown response below the header.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{AccentStyle, BlockContext, BlockLine, BlockOutput, DisplayMode};
use crate::theme::Theme;
use super::markdown_content::MarkdownContent;
/// Block displaying a /btw side-question and its response.
#[derive(Debug, Clone)]
pub struct BtwBlock {
/// The original question text.
pub question: String,
/// Rendered response content (markdown).
content: MarkdownContent,
}
impl BtwBlock {
/// Create a btw block from the question and response text.
pub fn new(question: impl Into<String>, response: impl Into<String>) -> Self {
Self {
question: question.into(),
content: MarkdownContent::new(response),
}
}
/// Access the underlying markdown content.
pub fn content(&self) -> &MarkdownContent {
&self.content
}
}
impl BlockContent for BtwBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let is_collapsed = ctx.mode == DisplayMode::Collapsed;
let tool_cfg = &ctx.appearance.scrollback.blocks.tool;
let is_muted = tool_cfg.muted_collapsed && is_collapsed;
// Header: "/btw <question>"
let header_style = if is_muted {
theme.muted().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme.accent_plan)
.add_modifier(Modifier::BOLD)
};
let header = Line::from(Span::styled(
format!("/btw {}", self.question),
header_style,
));
let mut lines = vec![BlockLine::styled(header).with_selection_range(Some(0))];
// Collapsed: header only. Expanded: header + separator + markdown body.
if !is_collapsed {
lines.push(BlockLine::separator(Line::from("")));
let body = self.content.output(ctx.width as usize);
for mut bl in body.lines {
bl.selection_range = Some(0);
lines.push(bl);
}
}
BlockOutput { lines }
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None
}
fn has_bullet(&self, _ctx: &BlockContext) -> bool {
true
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
true
}
fn is_groupable(&self) -> bool {
true
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,253 @@
//! CreditLimitBlock — scrollback card shown when a max-tier user exhausts credits.
//!
//! Replaces the Q&A question modal for users already at the highest tier
//! (SuperGrok Heavy). Instead of offering "Upgrade tier" + PAYG / buy-credits
//! options in the question overlay, this block renders an inline card with a
//! descriptive message and a link to the usage/billing page.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{AccentStyle, BlockContext, BlockLine, BlockOutput, DisplayMode};
use crate::theme::Theme;
/// Which continue-path the max-tier credit-limit card recommends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreditLimitCardAction {
/// Legacy on-demand: PAYG not enabled yet.
EnablePayg,
/// Legacy on-demand: PAYG on but at spending cap.
IncreasePaygLimit,
/// Unified usage billing: purchase prepaid credits.
PurchaseCredits,
}
/// Inline scrollback card for credit-limit exhaustion on max-tier accounts.
#[derive(Debug, Clone)]
pub struct CreditLimitBlock {
/// Card heading (e.g. "You've hit your free credits limit.").
pub heading: String,
/// Continue-path body copy selector.
pub action: CreditLimitCardAction,
/// URL to the usage/billing page.
pub url: String,
}
impl CreditLimitBlock {
/// Create a new credit-limit card.
pub fn new(
heading: impl Into<String>,
action: CreditLimitCardAction,
url: impl Into<String>,
) -> Self {
Self {
heading: heading.into(),
action,
url: url.into(),
}
}
}
impl BlockContent for CreditLimitBlock {
fn output(&self, _ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
// Heading in bold warning color (amber/yellow).
let heading_style = Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD);
let heading = Line::from(Span::styled(self.heading.clone(), heading_style));
// Body copy — contextual message based on billing mode.
let muted = theme.muted();
let body = match self.action {
CreditLimitCardAction::IncreasePaygLimit => {
"You can continue by increasing your spending limit."
}
CreditLimitCardAction::EnablePayg => {
"You can continue by enabling pay-as-you-go usage."
}
CreditLimitCardAction::PurchaseCredits => {
"You can continue by purchasing more credits."
}
};
let body_line = Line::from(Span::styled(body.to_string(), muted));
// Clickable link styled as a button.
let link_style = theme.link_style();
let link_line = Line::from(vec![Span::styled(self.url.clone(), link_style)]);
BlockOutput {
lines: vec![
BlockLine::styled(heading).with_selection_range(Some(0)),
BlockLine::separator(Line::from("")),
BlockLine::styled(body_line).with_selection_range(Some(0)),
BlockLine::styled(link_line).with_selection_range(Some(0)),
],
}
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.warning))
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
true
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
false
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Expanded
}
fn is_selectable(&self) -> bool {
true
}
fn is_groupable(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::appearance::AppearanceConfig;
fn ctx() -> BlockContext {
BlockContext {
mode: DisplayMode::Expanded,
is_running: false,
width: 80,
raw: false,
max_lines: None,
appearance: AppearanceConfig::default(),
is_selected: false,
cwd: None,
}
}
#[test]
fn output_payg_off_mentions_enabling() {
let block = CreditLimitBlock::new(
"You\u{2019}ve hit your credit limit.",
CreditLimitCardAction::EnablePayg,
"https://grok.com?_s=usage",
);
let output = block.output(&ctx());
let all_text: String = output
.lines
.iter()
.flat_map(|l| l.content.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(all_text.contains("credit limit"));
assert!(all_text.contains("enabling pay-as-you-go"));
assert!(all_text.contains("grok.com?_s=usage"));
}
#[test]
fn output_payg_on_mentions_increasing() {
let block = CreditLimitBlock::new(
"You\u{2019}ve hit your spending cap.",
CreditLimitCardAction::IncreasePaygLimit,
"https://grok.com?_s=usage",
);
let output = block.output(&ctx());
let all_text: String = output
.lines
.iter()
.flat_map(|l| l.content.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(all_text.contains("spending cap"));
assert!(all_text.contains("increasing your spending limit"));
assert!(all_text.contains("grok.com?_s=usage"));
}
#[test]
fn output_unified_mentions_purchasing_credits() {
let block = CreditLimitBlock::new(
"You hit your weekly limit.",
CreditLimitCardAction::PurchaseCredits,
"https://grok.com?_s=usage",
);
let output = block.output(&ctx());
let all_text: String = output
.lines
.iter()
.flat_map(|l| l.content.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(all_text.contains("purchasing more credits"));
assert!(all_text.contains("grok.com?_s=usage"));
}
#[test]
fn has_warning_accent() {
let block = CreditLimitBlock::new("heading", CreditLimitCardAction::EnablePayg, "url");
let accent = block.accent(&ctx());
let theme = Theme::current();
assert!(accent.is_some());
assert_eq!(accent.unwrap().color, theme.warning);
}
#[test]
fn block_content_contract() {
let block = CreditLimitBlock::new("heading", CreditLimitCardAction::EnablePayg, "url");
let c = ctx();
assert!(!block.is_foldable());
assert!(block.is_selectable());
assert!(!block.is_groupable());
assert!(matches!(
block.default_display_mode(),
DisplayMode::Expanded
));
assert!(block.has_vpad(&c));
assert!(!block.has_raw_mode());
}
#[test]
fn output_structure_and_content() {
let url = "https://grok.com?_s=usage";
let block = CreditLimitBlock::new("Test heading", CreditLimitCardAction::EnablePayg, url);
let output = block.output(&ctx());
// heading, separator, body, link = 4 lines
assert_eq!(output.lines.len(), 4);
let all_text: String = output
.lines
.iter()
.flat_map(|l| l.content.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(all_text.contains(url));
// Heading uses bold modifier.
assert!(
output.lines[0]
.content
.spans
.iter()
.any(|s| s.style.add_modifier.contains(Modifier::BOLD))
);
}
#[test]
fn new_stores_fields_correctly() {
let block = CreditLimitBlock::new(
"my heading",
CreditLimitCardAction::IncreasePaygLimit,
"https://example.com",
);
assert_eq!(block.heading, "my heading");
assert_eq!(block.action, CreditLimitCardAction::IncreasePaygLimit);
assert_eq!(block.url, "https://example.com");
}
}
@@ -0,0 +1,667 @@
//! Shared markdown content with cached word-wrapping.
//!
//! [`MarkdownContent`] wraps a [`StreamingMarkdownRenderer`] and caches the
//! word-wrapped output so that repeated calls to [`output()`](MarkdownContent::output)
//! at the same width are free after the first wrap. Used by both
//! [`AgentMessageBlock`](super::AgentMessageBlock) and
//! [`ThinkingBlock`](super::ThinkingBlock).
use std::borrow::Cow;
use std::cell::RefCell;
use ratatui::text::Line;
use crate::render::wrapping::word_wrap_lines_with_joiners;
use crate::scrollback::types::{BlockLine, BlockOutput};
use super::quote_bar::QuoteBarStrip;
pub(crate) const MARKDOWN_BODY_RANGE: u16 = 0;
use crate::syntax::get_syntect;
use crate::theme::{ThemeKind, cache as theme_cache, md_style};
use kigi_markdown::StreamingMarkdownRenderer;
/// Mutable rendering state behind a single `RefCell`.
///
/// Groups the renderer and wrap-cache together so `ensure_wrapped` (called
/// from `&self` methods via the `BlockContent` trait) can update both the
/// table-width setting and the cache in a single borrow.
#[derive(Debug, Clone)]
struct RenderState {
renderer: StreamingMarkdownRenderer,
/// Cached word-wrap result keyed on `(width, generation, theme)`.
cache_width: usize,
cache_generation: u64,
cache_theme: ThemeKind,
cache_lines: Vec<Line<'static>>,
cache_joiners: Vec<Option<String>>,
/// Number of pre-wrap (renderer output) lines that were frozen at the time
/// we last wrapped. Lines `0..frozen_pre_wrap_count` are stable and their
/// wrapped output is cached in `cache_lines[0..frozen_wrapped_count]`.
frozen_pre_wrap_count: usize,
/// Number of post-wrap lines produced by the frozen prefix.
frozen_wrapped_count: usize,
}
/// Shared markdown content with generation-tracked word-wrap cache.
///
/// Owns a [`StreamingMarkdownRenderer`] and provides:
/// - Mutation via `push_chunk`, `finish`, `set_raw_mode`
/// - Cached word-wrapping via `wrapped_lines` and `output`
///
/// Every mutation bumps an internal generation counter. The wrap cache is
/// keyed on `(width, generation)`, so scrolling (which doesn't change content)
/// returns the cached result instantly.
#[derive(Debug, Clone)]
pub struct MarkdownContent {
state: RefCell<RenderState>,
current_raw: bool,
generation: u64,
}
/// Borrowed view of cached wrapped lines + joiners.
///
/// Returned by [`MarkdownContent::wrapped_lines`] for blocks that need to
/// post-process the wrapped output (e.g., blending, truncation).
pub struct WrappedLines<'a> {
pub lines: &'a [Line<'static>],
pub joiners: &'a [Option<String>],
}
/// Expand tab characters to spaces using the current global tab_width.
///
/// Returns `Cow::Borrowed` when the input contains no tabs (zero-copy fast path).
fn expand_tabs(text: &str) -> Cow<'_, str> {
let tw = crate::appearance::tab_width();
if tw == 0 || !text.contains('\t') {
return Cow::Borrowed(text);
}
Cow::Owned(text.replace('\t', &" ".repeat(tw as usize)))
}
impl MarkdownContent {
/// Create with initial text (rendered immediately).
pub fn new(text: impl Into<String>) -> Self {
Self::new_with_table_width(text, None)
}
/// Create with initial text and an optional table width constraint.
///
/// When `max_table_width` is `Some(w)`, tables are constrained to fit
/// within `w` display columns. This is useful for pre-rendering
/// markdown before the final display width is known (e.g., plan preview).
pub fn new_with_table_width(text: impl Into<String>, max_table_width: Option<usize>) -> Self {
Self::new_inner(text, max_table_width, true)
}
/// Create source-faithful content: CommonMark soft breaks are preserved
/// as line breaks instead of collapsing to spaces, so each source line
/// maps 1:1 to a rendered line.
///
/// Used by the line-numbered plan preview, where rendered lines must map
/// back to file lines (e.g. for commenting on a line range).
pub fn new_source_faithful(text: impl Into<String>, max_table_width: Option<usize>) -> Self {
Self::new_inner(text, max_table_width, false)
}
fn new_inner(
text: impl Into<String>,
max_table_width: Option<usize>,
collapse_soft_breaks: bool,
) -> Self {
let mut renderer = StreamingMarkdownRenderer::new(md_style::style(), true);
renderer.set_max_table_width(max_table_width);
renderer.set_collapse_soft_breaks(collapse_soft_breaks);
let text = text.into();
let expanded = expand_tabs(&text);
renderer.push(&expanded);
// finish() (not render()) so the streaming LaTeX-delimiter normalizer
// flushes any trailing held-back delimiter bytes for this complete,
// one-shot document.
renderer.finish(Some(get_syntect()));
Self {
state: RefCell::new(RenderState {
renderer,
cache_width: 0,
cache_generation: 0,
cache_theme: theme_cache::current_kind(),
cache_lines: Vec::new(),
cache_joiners: Vec::new(),
frozen_pre_wrap_count: 0,
frozen_wrapped_count: 0,
}),
current_raw: false,
generation: 1,
}
}
/// Create empty for streaming.
pub fn streaming() -> Self {
Self {
state: RefCell::new(RenderState {
renderer: StreamingMarkdownRenderer::new(md_style::style(), true),
cache_width: 0,
cache_generation: 0,
cache_theme: theme_cache::current_kind(),
cache_lines: Vec::new(),
cache_joiners: Vec::new(),
frozen_pre_wrap_count: 0,
frozen_wrapped_count: 0,
}),
current_raw: false,
generation: 0,
}
}
/// Append a streaming chunk and re-render.
pub fn push_chunk(&mut self, chunk: &str) {
let expanded = expand_tabs(chunk);
self.state
.get_mut()
.renderer
.push_and_render(&expanded, Some(get_syntect()));
self.generation += 1;
}
/// Append a chunk without rendering immediately.
///
/// Used for historical replay during `session/load` so the pager can batch
/// markdown work and render once after replay completes.
pub fn push_chunk_deferred(&mut self, chunk: &str) {
let expanded = expand_tabs(chunk);
self.state.get_mut().renderer.push(&expanded);
self.generation += 1;
}
/// Finish streaming — full re-render for correctness.
pub fn finish(&mut self) {
let state = self.state.get_mut();
state.renderer.finish(Some(get_syntect()));
// finish() does a full re-render; reset frozen tracking so the
// next ensure_wrapped re-wraps everything from the new output.
state.frozen_pre_wrap_count = 0;
state.frozen_wrapped_count = 0;
self.generation += 1;
}
/// Get the source markdown text.
pub fn text(&self) -> String {
self.state.borrow().renderer.source().to_string()
}
/// Whether the source markdown is empty (zero-alloc, unlike `text()`).
pub fn is_empty(&self) -> bool {
self.state.borrow().renderer.source().is_empty()
}
/// Get the rendered text as plain text (styles stripped).
///
/// Returns the styled markdown output with all ratatui styles removed,
/// producing a plain-text representation of the rendered content.
/// Useful for copy-to-clipboard in pretty mode.
pub fn rendered_plain_text(&self) -> String {
let state = self.state.borrow();
let view = state.renderer.view();
let mut result = String::new();
for (i, line) in view.lines.iter().enumerate() {
if i > 0 {
result.push('\n');
}
for span in &line.spans {
result.push_str(&span.content);
}
}
result
}
/// Get the line source map (rendered line index → source line number).
///
/// Each entry maps a pre-wrap rendered line to the source line it came from.
/// Used for cursor stability when toggling raw/pretty mode.
pub fn line_source_map(&self) -> Vec<usize> {
self.state.borrow().renderer.view().line_source_map.to_vec()
}
/// Get the pre-wrap rendered lines (before word wrapping).
///
/// Returns cloned lines from the markdown renderer's current output.
/// These are styled `Line<'static>` objects at their natural width,
/// suitable for feeding into a ListPane which handles its own wrapping.
pub fn pre_wrap_lines(&self) -> Vec<Line<'static>> {
self.state.borrow().renderer.view().lines.to_vec()
}
/// Access the pre-wrap hyperlink targets via a closure, avoiding allocation.
pub fn with_hyperlinks<R>(&self, f: impl FnOnce(&[kigi_markdown::HyperlinkTarget]) -> R) -> R {
let state = self.state.borrow();
f(state.renderer.view().hyperlinks)
}
/// Pre-wrap line ranges of the ` ```mermaid ` blocks in the current
/// rendered output, reflecting the current render width.
///
/// Allocation-light (no source rebuild) for the per-frame caption path; the
/// detection skeleton with the diagram source lives in
/// [`mermaid_content`](Self::mermaid_content).
pub fn mermaid_block_ranges(&self) -> Vec<std::ops::Range<usize>> {
let state = self.state.borrow();
super::mermaid_content::mermaid_block_ranges(&state.renderer.view())
}
/// Build the Mermaid detection skeleton from the current rendered output.
///
/// Call at construction/finish (never per streaming chunk) to capture the
/// detected diagrams (detection only — rendering is lazy, driven by the
/// affordance row on click).
pub fn mermaid_content(&self) -> super::mermaid_content::MermaidContent {
let state = self.state.borrow();
super::mermaid_content::MermaidContent::from_view(&state.renderer.view())
}
/// Get the current generation counter.
///
/// Bumped on every content mutation (push_chunk, finish, set_raw_mode).
/// Used by viewers to detect when items need rebuilding.
pub fn generation(&self) -> u64 {
self.generation
}
/// Whether the content is currently in raw mode.
pub fn is_raw(&self) -> bool {
self.current_raw
}
/// Drop the word-wrap cache (`cache_lines` / `cache_joiners`).
///
/// The next `output()` / `wrapped_lines()` call transparently rebuilds it
/// from the renderer's pre-wrap output — the exact path a width or theme
/// change already takes. Used by off-screen cache eviction: for a long
/// session the post-wrap copy of every styled line is one of the largest
/// per-block allocations, and only entries near the viewport need it hot.
pub fn evict_wrap_cache(&self) {
let mut state = self.state.borrow_mut();
if state.cache_lines.is_empty() && state.cache_joiners.is_empty() {
return;
}
state.cache_lines = Vec::new();
state.cache_joiners = Vec::new();
state.cache_generation = u64::MAX; // force rebuild on next use
state.frozen_pre_wrap_count = 0;
state.frozen_wrapped_count = 0;
}
/// Toggle raw mode, re-rendering if changed.
pub fn set_raw_mode(&mut self, raw: bool) {
if self.current_raw != raw {
self.current_raw = raw;
let state = self.state.get_mut();
state.renderer.set_pretty(!raw);
state.renderer.render(Some(get_syntect()));
// set_pretty resets renderer frozen state
state.frozen_pre_wrap_count = 0;
state.frozen_wrapped_count = 0;
self.generation += 1;
}
}
/// Ensure the wrap cache is populated for the given width.
///
/// Uses incremental wrapping: only re-wraps lines after the renderer's
/// frozen boundary. Frozen (stable) lines are wrapped once and cached.
/// This turns streaming from O(N^2) total wrapping to ~O(N).
fn ensure_wrapped(&self, width: usize) {
let mut state = self.state.borrow_mut();
let current_theme = theme_cache::current_kind();
// If the theme changed, update the renderer's style so the re-render
// below picks up the new colors. Resetting cache_generation forces
// the cache to rebuild even if width and content haven't changed.
if state.cache_theme != current_theme {
state.renderer.set_style(md_style::style());
state.cache_theme = current_theme;
state.cache_generation = u64::MAX; // force cache miss
// set_style resets renderer frozen state, so our tracking is stale
state.frozen_pre_wrap_count = 0;
state.frozen_wrapped_count = 0;
}
if state.cache_width == width && state.cache_generation == self.generation {
return;
}
// Width or theme changed → full re-wrap (frozen cache invalid)
let width_changed = state.cache_width != width;
if width_changed {
state.frozen_pre_wrap_count = 0;
state.frozen_wrapped_count = 0;
}
// Update table width and re-render (only re-renders tail internally).
state.renderer.set_max_table_width(Some(width));
state.renderer.render(Some(get_syntect()));
let frozen_count = state.renderer.frozen_lines_count();
// --- Incremental wrapping ---
//
// The renderer guarantees that view().lines[0..frozen_count] are stable.
// We only need to wrap:
// 1. Newly frozen lines (frozen_pre_wrap_count..frozen_count)
// 2. Tail lines (frozen_count..total_lines)
//
// The cached frozen wrapped output (cache_lines[0..frozen_wrapped_count])
// is preserved as-is.
//
// We clone the line slices we need *before* mutating cache_lines,
// because view() borrows the renderer immutably.
// Step 1: Wrap any newly frozen lines
let new_frozen_wrapped = if frozen_count > state.frozen_pre_wrap_count {
let new_frozen: Vec<Line<'static>> =
state.renderer.view().lines[state.frozen_pre_wrap_count..frozen_count].to_vec();
Some(word_wrap_lines_with_joiners(new_frozen, width))
} else {
None
};
// Step 2: Wrap the tail (unfrozen) lines
let total_lines = state.renderer.view().lines.len();
let tail_wrapped = if frozen_count < total_lines {
let tail: Vec<Line<'static>> = state.renderer.view().lines[frozen_count..].to_vec();
Some(word_wrap_lines_with_joiners(tail, width))
} else {
None
};
// Now mutate the cache (no more borrows of view/renderer)
// Truncate stale tail, keeping only the previously frozen wrapped output
let frozen_wc = state.frozen_wrapped_count;
state.cache_lines.truncate(frozen_wc);
state.cache_joiners.truncate(frozen_wc);
// Append newly frozen wrapped lines
if let Some((new_lines, new_joiners)) = new_frozen_wrapped {
state.cache_lines.extend(new_lines);
state.cache_joiners.extend(new_joiners);
state.frozen_pre_wrap_count = frozen_count;
state.frozen_wrapped_count = state.cache_lines.len();
}
// Append tail wrapped lines
if let Some((tail_lines, tail_joiners)) = tail_wrapped {
state.cache_lines.extend(tail_lines);
state.cache_joiners.extend(tail_joiners);
}
state.cache_width = width;
state.cache_generation = self.generation;
}
/// Access cached wrapped lines + joiners for post-processing.
///
/// The closure receives a [`WrappedLines`] reference valid for the
/// duration of the call. This avoids cloning when the caller only
/// needs to inspect or slice the lines (e.g., ThinkingBlock truncation).
pub fn with_wrapped_lines<R>(&self, width: usize, f: impl FnOnce(WrappedLines<'_>) -> R) -> R {
self.ensure_wrapped(width);
let state = self.state.borrow();
f(WrappedLines {
lines: &state.cache_lines,
joiners: &state.cache_joiners,
})
}
/// Build a [`BlockOutput`] from the cached wrapped lines.
///
/// Each line is converted to a [`BlockLine`] with joiner and optional
/// background color (from the line's style, e.g., for code blocks).
/// This is the common path used by [`AgentMessageBlock`](super::AgentMessageBlock).
pub fn output(&self, width: usize) -> BlockOutput {
// Raw mode shows the source `>` markers verbatim — nothing to exclude.
let strip = QuoteBarStrip::new(!self.current_raw);
self.with_wrapped_lines(width, |wrapped| {
if wrapped.lines.is_empty() {
BlockOutput {
lines: vec![Line::from("").into()],
}
} else {
BlockOutput {
lines: wrapped
.lines
.iter()
.zip(wrapped.joiners.iter())
.map(|(line, joiner)| {
let mut content = line.clone();
let selectable = strip.selectable(&mut content);
let mut block_line = BlockLine::styled(content)
.with_selection_range(Some(MARKDOWN_BODY_RANGE))
.with_joiner(joiner.clone());
block_line.selectable = selectable;
if let Some(bg) = line.style.bg {
block_line.with_background(bg)
} else {
block_line
}
})
.collect(),
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scrollback::types::Selectable;
#[test]
fn cache_hit_on_same_width() {
let md = MarkdownContent::new("Hello world, this is a test line");
let out1 = md.output(80);
let out2 = md.output(80);
// Same content and width → should return identical output.
assert_eq!(out1.lines.len(), out2.lines.len());
// Verify cache was actually used (generation matches).
let state = md.state.borrow();
assert_eq!(state.cache_generation, 1);
assert_eq!(state.cache_width, 80);
}
#[test]
fn cache_invalidated_on_width_change() {
let md = MarkdownContent::new("short");
let out_wide = md.output(80);
let out_narrow = md.output(5);
// Different widths may produce different line counts.
// At minimum, verify we didn't panic and cache updated.
let state = md.state.borrow();
assert_eq!(state.cache_width, 5);
assert!(!out_wide.lines.is_empty());
assert!(!out_narrow.lines.is_empty());
}
#[test]
fn cache_invalidated_on_push_chunk() {
let mut md = MarkdownContent::streaming();
md.push_chunk("Hello");
let out1 = md.output(80);
md.push_chunk(" world");
let out2 = md.output(80);
// Content changed → output should differ.
let text1: String = out1.lines.iter().map(|l| l.content.to_string()).collect();
let text2: String = out2.lines.iter().map(|l| l.content.to_string()).collect();
assert_ne!(text1, text2);
}
#[test]
fn with_wrapped_lines_provides_access() {
// Use CommonMark hard breaks (two trailing spaces + \n) so the
// three logical lines render as three visual lines. Bare `\n`
// between text lines is a soft break and collapses to a space.
let md = MarkdownContent::new("Line one \nLine two \nLine three");
md.with_wrapped_lines(80, |wrapped| {
assert_eq!(wrapped.lines.len(), 3);
assert_eq!(wrapped.joiners.len(), 3);
});
}
/// End-to-end regression for the table "ghost cell" bug: a markdown table
/// with emoji-presentation glyphs (`⚠\u{FE0F}`, `✅`, `✗`) and an em-dash
/// must render every row at exactly the content width.
#[test]
fn table_rows_fill_content_width_with_emoji() {
use unicode_width::UnicodeWidthStr;
let md = "| Status | Note |\n|---|---|\n| \u{26A0}\u{FE0F} warn | em \u{2014} dash |\n| \u{2705} ok | \u{2717} no |\n";
let width = 48;
let md_content = MarkdownContent::new(md);
let out = md_content.output(width);
assert!(out.lines.len() >= 6, "table should produce border + rows");
for (i, line) in out.lines.iter().enumerate() {
let text: String = line
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(
text.width(),
width,
"table line {i} must fill the content width, got {:?}",
text
);
}
}
#[test]
fn empty_content_returns_placeholder() {
let md = MarkdownContent::streaming();
let out = md.output(80);
assert_eq!(out.lines.len(), 1);
}
#[test]
fn raw_mode_invalidates_cache() {
let mut md = MarkdownContent::new("**bold** text");
let _out1 = md.output(80);
let gen_before = md.generation;
md.set_raw_mode(true);
assert_eq!(md.generation, gen_before + 1);
md.set_raw_mode(true);
assert_eq!(md.generation, gen_before + 1);
}
#[test]
fn markdown_body_lines_share_one_selection_range() {
let md = MarkdownContent::new("hello world this should wrap across lines");
let out = md.output(10);
assert!(out.lines.len() > 1);
assert!(
out.lines
.iter()
.all(|line| line.selection_range == Some(MARKDOWN_BODY_RANGE))
);
}
#[test]
fn markdown_output_keeps_joiners_for_wrapped_lines() {
let md = MarkdownContent::new("hello world this should wrap across lines");
let out = md.output(10);
assert!(out.lines.len() > 1);
assert_eq!(out.lines[0].joiner, None);
assert!(out.lines.iter().skip(1).any(|line| line.joiner.is_some()));
}
#[test]
fn markdown_body_lines_remain_selectable() {
let md = MarkdownContent::new("hello");
let out = md.output(80);
assert!(matches!(out.lines[0].selectable, Selectable::All));
}
/// Verify that incremental wrapping during streaming produces the same
/// output as creating a fresh MarkdownContent with the full text.
#[test]
fn incremental_wrap_matches_full_wrap() {
let width = 40;
// Build content incrementally (simulating streaming)
let mut streaming = MarkdownContent::streaming();
let chunks = [
"Hello world, this is a fairly long line that should definitely wrap.\n\n",
"Second paragraph with more text to wrap around the edges.\n\n",
"- bullet one\n",
"- bullet two with extra words to cause wrapping\n",
"- bullet three\n",
];
for chunk in &chunks {
streaming.push_chunk(chunk);
// Call output() between chunks to exercise incremental path
let _ = streaming.output(width);
}
let incremental_output = streaming.output(width);
// Build the same content in one shot
let full_text: String = chunks.iter().copied().collect();
let full = MarkdownContent::new(&full_text);
let full_output = full.output(width);
// Compare line-by-line text content
let incremental_text: Vec<String> = incremental_output
.lines
.iter()
.map(|l| l.content.to_string())
.collect();
let full_text_lines: Vec<String> = full_output
.lines
.iter()
.map(|l| l.content.to_string())
.collect();
assert_eq!(
incremental_text.len(),
full_text_lines.len(),
"Line count mismatch: incremental={}, full={}",
incremental_text.len(),
full_text_lines.len(),
);
for (i, (inc, full)) in incremental_text
.iter()
.zip(full_text_lines.iter())
.enumerate()
{
assert_eq!(inc, full, "Line {i} mismatch");
}
}
/// Verify that the frozen wrap cache is actually being used (not just
/// re-wrapping everything each time).
#[test]
fn frozen_cache_is_reused() {
let width = 40;
let mut md = MarkdownContent::streaming();
// Push enough content to establish frozen lines
md.push_chunk("First paragraph of text.\n\nSecond paragraph.\n\n");
let _ = md.output(width);
let state = md.state.borrow();
let frozen_count_after_first = state.frozen_wrapped_count;
drop(state);
// Push more content
md.push_chunk("Third paragraph.\n\n");
let _ = md.output(width);
let state = md.state.borrow();
// Frozen wrapped count should have grown (more lines became frozen)
assert!(
state.frozen_wrapped_count >= frozen_count_after_first,
"Frozen count should grow monotonically: before={}, after={}",
frozen_count_after_first,
state.frozen_wrapped_count,
);
}
}
@@ -0,0 +1,930 @@
//! Mermaid diagram detection and the on-screen affordance row.
//!
//! The markdown renderer draws ` ```mermaid ` blocks inline as Unicode
//! box-drawing art. This module detects those blocks in an agent message (via
//! the generic [`CodeBlockSpan`](kigi_markdown::CodeBlockSpan) API) and
//! exposes each diagram's clean source so a full-fidelity PNG can be rendered on
//! demand. It never renders and tracks no per-diagram render state (rendering is
//! lazy, driven by the affordance row on click). For `auto`/`on` a clickable
//! affordance row (`◇ mermaid [Open Image] [Copy Image Path] [Copy Source]`) is
//! placed beneath the inline art; for `off` only the inline art is shown. The
//! rendered PNG is never drawn inline — it is reached only through the affordance
//! row's actions.
use std::ops::Range;
use kigi_markdown::MarkdownRenderView;
use ratatui::text::Line;
use unicode_width::UnicodeWidthStr;
use crate::appearance::RenderMermaid;
use crate::scrollback::types::{BlockLine, BlockOutput};
use crate::theme::ThemeKind;
/// Fence info string identifying a Mermaid diagram.
pub const MERMAID_INFO: &str = "mermaid";
/// Subtle `◇ mermaid` marker: the leading (dim, non-clickable) label on the
/// affordance row.
const MERMAID_LABEL: &str = "\u{25c7} mermaid";
/// Status hint shown in the affordance row while an on-click diagram render is
/// in flight.
const MERMAID_RENDERING: &str = "rendering diagram\u{2026}";
/// Affordance-row button label: open the rendered PNG in the OS default app.
const AFFORDANCE_OPEN: &str = "[Open Image]";
/// Affordance-row button label: copy the rendered PNG's filesystem path.
const AFFORDANCE_COPY_PATH: &str = "[Copy Image Path]";
/// Affordance-row button label: copy the diagram's Mermaid source.
const AFFORDANCE_COPY_SOURCE: &str = "[Copy Source]";
/// Display-column gap between adjacent affordance-row buttons (and before the
/// trailing status hint).
const AFFORDANCE_GAP: u16 = 3;
/// Width quantum (in display columns) for the cache key's width bucket. Renders
/// are reused across small resizes by bucketing the target width. Only applies
/// to [`MermaidRenderQuality::Terminal`]; the open tier ignores terminal width.
const MERMAID_WIDTH_BUCKET: u16 = 8;
/// Sentinel width-bucket for [`MermaidRenderQuality::Open`] (OS viewer / copy
/// path): not derived from terminal columns, so open-tier PNGs never collide
/// with terminal-budget renders of the same source+theme.
const OPEN_QUALITY_WIDTH_BUCKET: u16 = u16::MAX;
/// Quantize a target content-column count to the cache key's width bucket, so a
/// sub-bucket resize maps to the same key (no re-render, no rescan).
fn width_bucket(target_width_cols: u16) -> u16 {
target_width_cols / MERMAID_WIDTH_BUCKET
}
/// Output quality tier for a rendered Mermaid PNG.
///
/// `[Open Image]` / `[Copy Image Path]` use [`Open`] so the PNG is sharp in an
/// OS image viewer; a future terminal-budget path can use [`Terminal`] without
/// sharing cache files with the open tier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum MermaidRenderQuality {
/// Sized from the terminal content width (HiDPI oversample + modest caps).
#[default]
Terminal,
/// Auto-scaled for OS viewers: prefer ≥2× intrinsic SVG size and a
/// minimum pixel width, with higher height/area headroom.
Open,
}
/// Content hash of a diagram source — the theme/width-independent component of a
/// [`MermaidCacheKey`]. Matching a pending render against this (rather than the
/// full key) keeps the `rendering…` hint tied to the diagram even if the live
/// theme/width changes mid-render.
pub(crate) fn hash_source(source: &str) -> [u8; 32] {
*blake3::hash(source.as_bytes()).as_bytes()
}
/// A detected Mermaid block within a rendered agent message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MermaidBlock {
/// The clean diagram source — the fence body with container markers
/// (blockquote `>`, list indentation) stripped and CRLF normalized, taken
/// from [`CodeBlockSpan::body`](kigi_markdown::CodeBlockSpan::body). For
/// a blockquoted or list-nested diagram this is the de-prefixed code, not
/// the raw source slice.
pub source: String,
/// Range of pre-wrap rendered body lines this diagram occupies, as indices
/// into [`MarkdownRenderView::lines`]. Mirrors
/// [`CodeBlockSpan::output_line_range`](kigi_markdown::CodeBlockSpan::output_line_range).
pub prewrap_line_range: Range<usize>,
}
/// Whether a fence info string identifies a Mermaid diagram: its first
/// whitespace-delimited token equals `mermaid` (case-insensitive), so
/// ` ```mermaid `, ` ```Mermaid `, and ` ```mermaid theme=base ` all match
/// while a code block in another language does not.
fn is_mermaid_info(info: &str) -> bool {
info.split_whitespace()
.next()
.is_some_and(|token| token.eq_ignore_ascii_case(MERMAID_INFO))
}
/// The view's code-block spans that are Mermaid fences, in document order.
fn mermaid_spans<'a>(
view: &'a MarkdownRenderView,
) -> impl Iterator<Item = &'a kigi_markdown::CodeBlockSpan> {
view.code_blocks
.iter()
.filter(|span| is_mermaid_info(&span.info))
}
/// Filter a rendered view's code-block spans down to Mermaid fences.
///
/// Returns one [`MermaidBlock`] per closed ` ```mermaid ` fence, in document
/// order, carrying the clean de-prefixed diagram source. Allocates a `source`
/// String per block; for the per-frame render path that only needs line
/// positions use [`mermaid_block_ranges`] instead.
pub fn mermaid_blocks(view: &MarkdownRenderView) -> Vec<MermaidBlock> {
mermaid_spans(view)
.map(|span| MermaidBlock {
source: span.body.clone(),
prewrap_line_range: span.output_line_range.clone(),
})
.collect()
}
/// Pre-wrap line ranges of the view's Mermaid fences, in document order.
///
/// The allocation-free counterpart of [`mermaid_blocks`] for the render hot
/// path (caption placement needs only line positions, never the source).
pub fn mermaid_block_ranges(view: &MarkdownRenderView) -> Vec<Range<usize>> {
mermaid_spans(view)
.map(|span| span.output_line_range.clone())
.collect()
}
/// Whether a theme renders diagrams on a dark surface.
///
/// `GrokDay` is the only light theme; every other concrete theme (and the
/// `GrokNight` default that `Auto` resolves to before it reaches the cache) is
/// dark. The render worker maps this to `kigi_mermaid::MermaidTheme`; it
/// lives here (rather than referencing the engine crate) so the
/// always-compiled detection module stays independent of the optional
/// `mermaid` feature.
pub fn theme_is_dark(theme: ThemeKind) -> bool {
!matches!(theme, ThemeKind::GrokDay)
}
/// Cache key for a rendered diagram: content hash + theme + quality tier +
/// (for terminal tier) bucketed width.
///
/// Keys the rendered-PNG cache. Theme, quality, and width are part of the key so
/// a theme switch, resize, or open-vs-terminal tier is a lookup (usually a hit)
/// or a fresh render, never a stale-color/size diagram.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MermaidCacheKey {
/// `blake3` hash of the diagram source.
pub source_hash: [u8; 32],
/// Active theme (its surface color is baked into the rendered diagram).
pub theme: ThemeKind,
/// Target render width quantized to [`MERMAID_WIDTH_BUCKET`] columns for
/// [`MermaidRenderQuality::Terminal`]; [`OPEN_QUALITY_WIDTH_BUCKET`] for
/// [`MermaidRenderQuality::Open`].
pub width_bucket: u16,
/// Terminal-budget vs OS-viewer quality tier.
pub quality: MermaidRenderQuality,
}
impl MermaidCacheKey {
/// Derive a cache key from a diagram's source, the active theme, target
/// render width (in display columns; ignored for [`MermaidRenderQuality::Open`]),
/// and quality tier.
pub fn derive(
source: &str,
theme: ThemeKind,
target_width_cols: u16,
quality: MermaidRenderQuality,
) -> Self {
let width_bucket = match quality {
MermaidRenderQuality::Terminal => width_bucket(target_width_cols),
MermaidRenderQuality::Open => OPEN_QUALITY_WIDTH_BUCKET,
};
Self {
source_hash: hash_source(source),
theme,
width_bucket,
quality,
}
}
/// Stable, filesystem-safe filename for this key's on-disk PNG.
///
/// Content hash + theme + width bucket + quality tag + render revision, so
/// the same diagram at the same theme/width/tier reuses one file and never
/// leaks the source in the name.
pub fn cache_filename(&self) -> String {
use std::fmt::Write as _;
let mut name = String::with_capacity(64 + 24);
for byte in self.source_hash {
let _ = write!(name, "{byte:02x}");
}
let quality_tag = match self.quality {
MermaidRenderQuality::Terminal => "t",
MermaidRenderQuality::Open => "o",
};
let _ = write!(
name,
"-{}-{}-{}-r{RENDER_REVISION}.png",
self.theme as u8, self.width_bucket, quality_tag
);
name
}
}
/// Render-pipeline revision baked into [`MermaidCacheKey::cache_filename`];
/// bump whenever the renderer's output changes for the same source/theme/width/tier.
const RENDER_REVISION: u8 = 3;
/// Detected Mermaid diagrams for one agent message.
///
/// A detection skeleton: it records detection results and exposes each diagram's
/// source, but never renders and tracks no per-diagram render state. Constructed
/// once at message construction/finish (never per streaming chunk), mirroring
/// the image/video reference precedent. Rendering is lazy — driven by the
/// affordance row's `[Open]`/`[Copy path]` click, not by this type.
#[derive(Debug, Clone, Default)]
pub struct MermaidContent {
blocks: Vec<MermaidBlock>,
}
impl MermaidContent {
/// Detect Mermaid blocks in a finished render view.
pub fn from_view(view: &MarkdownRenderView) -> Self {
Self {
blocks: mermaid_blocks(view),
}
}
/// Whether the message contains any Mermaid diagrams.
pub fn is_empty(&self) -> bool {
self.blocks.is_empty()
}
/// Number of detected diagrams.
pub fn len(&self) -> usize {
self.blocks.len()
}
/// Diagram source at `idx`, if it exists.
pub fn source(&self, idx: usize) -> Option<&str> {
self.blocks.get(idx).map(|b| b.source.as_str())
}
}
/// How a detected Mermaid block's affordance row is presented. The diagram
/// itself is always drawn inline as Unicode art by the markdown renderer; the
/// rendered PNG is never inline (it is reached only through the affordance row).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MermaidDisplay {
/// The inline diagram art alone, with no affordance row
/// (`render_mermaid = off`).
SourceOnly,
/// The inline diagram art plus the clickable affordance row
/// (`◇ mermaid [Open Image] [Copy Image Path] [Copy Source]`) — `auto`/`on`.
Affordances,
}
/// Decide how to present a Mermaid block's affordance row from the user setting.
///
/// `off` shows the inline art alone; `auto`/`on` add the clickable affordance
/// row. The render engine is always compiled in, so engine availability is not a
/// factor. Terminal graphics capability is intentionally not consulted either:
/// the affordance row is text plus mouse hit-rects, so it works in every
/// terminal (the rendered PNG opens in the OS viewer, never inline).
pub fn mermaid_display(setting: RenderMermaid) -> MermaidDisplay {
match setting {
RenderMermaid::Off => MermaidDisplay::SourceOnly,
RenderMermaid::Auto | RenderMermaid::On => MermaidDisplay::Affordances,
}
}
/// [`mermaid_display`], but forced to [`MermaidDisplay::SourceOnly`] when the
/// scrollback is committed as static text (`static_commit = true`, i.e. minimal
/// mode). The clickable affordance row is painted by the interactive draw loop,
/// which minimal never runs — so it would commit as a blank reserved line and
/// its buttons would be inert. Suppressing it keeps the inline diagram art (the
/// source stays natively selectable) without the dead row.
pub fn mermaid_display_static(setting: RenderMermaid, static_commit: bool) -> MermaidDisplay {
if static_commit {
MermaidDisplay::SourceOnly
} else {
mermaid_display(setting)
}
}
/// Which click action an affordance-row button triggers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AffordanceKind {
/// Render the diagram (if not already cached) at the live theme/width, then
/// open the resulting PNG in the OS default app.
Open,
/// Render the diagram (if not already cached), then copy the PNG's path.
CopyPath,
/// Copy the diagram's Mermaid source (no render needed).
CopySource,
}
/// One button in a diagram's affordance row, with its start column so the
/// painted label and the click hit-rect can't drift. Every button is always
/// clickable — `[Open]`/`[Copy path]` render lazily on click.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AffordanceButton {
/// Display label, e.g. `[Open]`.
pub label: &'static str,
/// The click action this button triggers.
pub kind: AffordanceKind,
/// Start column, in display cells from the affordance row's left edge.
pub col: u16,
}
/// The full affordance-row layout: the leading `◇ mermaid` label, the three
/// buttons (with columns), and the trailing status hint (with column), so the
/// painter and the click hit-rects draw from one source of truth and can't
/// drift.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AffordanceRow {
/// `(start_col, text)` of the leading dim, non-clickable `◇ mermaid` label.
pub label: (u16, &'static str),
/// `[Open Image] [Copy Image Path] [Copy Source]`, left-to-right with their
/// columns (shifted right past the leading label).
pub buttons: [AffordanceButton; 3],
/// `(start_col, text)` of the trailing `rendering…` hint, present only while
/// an on-click render for this diagram is in flight.
pub status: Option<(u16, &'static str)>,
}
/// The affordance row's three buttons laid out left-to-right starting at
/// `start_col` (which leaves room for the leading `◇ mermaid` label).
fn affordance_buttons(start_col: u16) -> [AffordanceButton; 3] {
let specs = [
(AFFORDANCE_OPEN, AffordanceKind::Open),
(AFFORDANCE_COPY_PATH, AffordanceKind::CopyPath),
(AFFORDANCE_COPY_SOURCE, AffordanceKind::CopySource),
];
let mut col = start_col;
specs.map(|(label, kind)| {
let button = AffordanceButton { label, kind, col };
col += UnicodeWidthStr::width(label) as u16 + AFFORDANCE_GAP;
button
})
}
/// The whole affordance-row layout for a diagram: the leading `◇ mermaid` label,
/// the three (always-clickable) buttons shifted past it, and the trailing
/// `rendering…` hint when `rendering` is true. One source of truth shared by the
/// painter and hit-testing, so the painted columns and click hit-rects align.
pub(crate) fn affordance_row(rendering: bool) -> AffordanceRow {
let buttons_start = UnicodeWidthStr::width(MERMAID_LABEL) as u16 + AFFORDANCE_GAP;
let buttons = affordance_buttons(buttons_start);
let status = rendering.then(|| {
let last = &buttons[buttons.len() - 1];
let after = last.col + UnicodeWidthStr::width(last.label) as u16 + AFFORDANCE_GAP;
(after, MERMAID_RENDERING)
});
AffordanceRow {
label: (0, MERMAID_LABEL),
buttons,
status,
}
}
/// A diagram's clickable affordance row, anchored within a block's output.
///
/// Carries no raster — only the row position plus the diagram source the
/// affordance buttons act on (rendering is lazy, driven from the source on
/// click, so no rendered path is tracked here).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagramAffordance {
/// Post-wrap, block-relative row offset of the affordance row (its index in
/// the block's `output()` lines).
pub row_offset: u16,
/// Diagram source (the fence body); the data every button acts on.
pub source: String,
}
/// Post-wrap end row of every pre-wrap line, indexed by pre-wrap line number.
///
/// `out[p]` is one past the last display row of pre-wrap line `p`. Built from
/// the shared [`prewrap_index_per_row`](crate::scrollback::types::prewrap_index_per_row)
/// walk so it can't drift from the media-row / hyperlink mappings.
fn prewrap_end_rows(lines: &[BlockLine]) -> Vec<usize> {
let mut ends = Vec::new();
for (row, prewrap) in crate::scrollback::types::prewrap_index_per_row(lines)
.into_iter()
.enumerate()
{
if prewrap < ends.len() {
ends[prewrap] = row + 1;
} else {
ends.push(row + 1);
}
}
ends
}
/// Row in `lines` at which a continuation row sits right after each non-empty
/// pre-wrap range's last body row, paired with the range's document-order index.
///
/// Returned in ascending insertion order so callers can derive a final
/// post-wrap offset (`insert_at + k` for the k-th entry) and insert back-to-front
/// without invalidating earlier positions. Anchors each diagram's affordance row.
fn diagram_insert_rows(lines: &[BlockLine], ranges: &[Range<usize>]) -> Vec<(usize, usize)> {
let ends = prewrap_end_rows(lines);
ranges
.iter()
.enumerate()
.filter(|(_, range)| !range.is_empty())
.filter_map(|(idx, range)| ends.get(range.end - 1).map(|&insert_at| (insert_at, idx)))
.collect()
}
/// A non-selectable continuation row inserted beneath a diagram.
///
/// `separator` ⇒ not selectable (excluded from copy); the empty joiner marks it
/// a continuation of the diagram's last logical line so the pre-wrap →
/// post-wrap walk for hyperlinks is unaffected.
fn continuation_row(line: Line<'static>) -> BlockLine {
BlockLine::separator(line).with_joiner(Some(String::new()))
}
/// Insert a blank, non-selectable affordance row beneath each detected diagram
/// and return one [`DiagramAffordance`] per inserted row (document order).
///
/// The blank row reserves the vertical space the draw loop paints the
/// `◇ mermaid [Open Image] [Copy Image Path] [Copy Source]` row into; it is a
/// joiner-continuation of the diagram's last body line (so it neither shifts
/// pre-wrap line indices nor reaches the clipboard), exactly like the fallback
/// caption. Each returned `row_offset` is the row's final post-wrap index,
/// accounting for the rows inserted above it. `source_for` is invoked once per
/// non-empty diagram to supply its Mermaid source.
pub(crate) fn apply_affordance_rows(
output: &mut BlockOutput,
prewrap_ranges: &[Range<usize>],
mut source_for: impl FnMut(usize) -> String,
) -> Vec<DiagramAffordance> {
let inserts = diagram_insert_rows(&output.lines, prewrap_ranges);
let affordances: Vec<DiagramAffordance> = inserts
.iter()
.enumerate()
.map(|(k, &(insert_at, idx))| DiagramAffordance {
row_offset: (insert_at + k) as u16,
source: source_for(idx),
})
.collect();
for &(insert_at, _) in inserts.iter().rev() {
output
.lines
.insert(insert_at, continuation_row(Line::from(String::new())));
}
affordances
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scrollback::types::Selectable;
use crate::syntax::get_syntect;
use crate::theme::md_style;
use kigi_markdown::StreamingMarkdownRenderer;
/// Render markdown to a view and collect the detected mermaid blocks.
fn detect(src: &str, pretty: bool) -> Vec<MermaidBlock> {
let mut renderer = StreamingMarkdownRenderer::new(md_style::style(), pretty);
renderer.push(src);
let view = renderer.finish(Some(get_syntect()));
mermaid_blocks(&view)
}
#[test]
fn detects_mermaid_and_ignores_other_fences() {
let src =
"intro\n\n```rust\nfn a() {}\n```\n\n```mermaid\nflowchart TD\n A --> B\n```\n\nbye\n";
for pretty in [true, false] {
let blocks = detect(src, pretty);
assert_eq!(blocks.len(), 1, "pretty={pretty}");
// `source` is the clean fence body (trailing newline included).
assert_eq!(blocks[0].source, "flowchart TD\n A --> B\n");
assert!(!blocks[0].prewrap_line_range.is_empty());
}
}
#[test]
fn detects_multiple_mermaid_blocks_in_order() {
let src = "```mermaid\nA-->B\n```\n\ntext\n\n```mermaid\nC-->D\n```\n";
let blocks = detect(src, true);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].source, "A-->B\n");
assert_eq!(blocks[1].source, "C-->D\n");
assert!(blocks[0].prewrap_line_range.end <= blocks[1].prewrap_line_range.start);
}
#[test]
fn detect_blockquote_fence_yields_clean_source() {
// The blockquote case: the closing fence is "> ```" and the source must
// come out de-prefixed (no leaked "> "/"│ "), in both modes.
let src = "> ```mermaid\n> flowchart TD\n> A --> B\n> ```\n";
for pretty in [true, false] {
let blocks = detect(src, pretty);
assert_eq!(blocks.len(), 1, "pretty={pretty}");
assert_eq!(
blocks[0].source, "flowchart TD\n A --> B\n",
"pretty={pretty}"
);
}
}
#[test]
fn detect_list_nested_fence_yields_clean_source() {
let src = "- item\n ```mermaid\n flowchart TD\n A --> B\n ```\n";
for pretty in [true, false] {
let blocks = detect(src, pretty);
assert_eq!(blocks.len(), 1, "pretty={pretty}");
assert_eq!(
blocks[0].source, "flowchart TD\n A --> B\n",
"pretty={pretty}"
);
}
}
#[test]
fn detect_matches_info_first_token_case_insensitively() {
// `mermaid theme=base`, `Mermaid` and `MERMAID` all detect; a different
// first token does not.
for info in ["mermaid theme=base", "Mermaid", "MERMAID"] {
let src = format!("```{info}\nA-->B\n```\n");
assert_eq!(detect(&src, true).len(), 1, "info={info:?}");
}
assert!(detect("```mermaidx\nA-->B\n```\n", true).is_empty());
assert!(detect("```rust\nlet a = 1;\n```\n", true).is_empty());
}
#[test]
fn block_ranges_match_block_spans() {
let mut renderer = StreamingMarkdownRenderer::new(md_style::style(), true);
renderer.push("```mermaid\nA-->B\n```\n\ntext\n\n```mermaid\nC-->D\n```\n");
let view = renderer.finish(Some(get_syntect()));
let from_blocks: Vec<Range<usize>> = mermaid_blocks(&view)
.into_iter()
.map(|b| b.prewrap_line_range)
.collect();
assert_eq!(mermaid_block_ranges(&view), from_blocks);
}
#[test]
fn no_blocks_for_non_mermaid_or_empty() {
assert!(detect("just prose, no fences\n", true).is_empty());
assert!(detect("```python\nprint(1)\n```\n", true).is_empty());
}
#[test]
fn open_fence_during_stream_is_not_detected() {
// Detection is meaningful only on a closed fence; an unterminated fence
// in the streamed tail yields no block until it closes.
let mut renderer = StreamingMarkdownRenderer::new(md_style::style(), true);
renderer.push_and_render("```mermaid\nflowchart TD\n", Some(get_syntect()));
assert!(mermaid_blocks(&renderer.view()).is_empty());
renderer.push_and_render("A --> B\n```\n", Some(get_syntect()));
let view = renderer.finish(Some(get_syntect()));
assert_eq!(mermaid_blocks(&view).len(), 1);
}
#[test]
fn cache_key_sensitivity() {
let dark = MermaidCacheKey::derive(
"flowchart TD\nA-->B",
ThemeKind::GrokNight,
80,
MermaidRenderQuality::Terminal,
);
// Same inputs ⇒ same key.
assert_eq!(
dark,
MermaidCacheKey::derive(
"flowchart TD\nA-->B",
ThemeKind::GrokNight,
80,
MermaidRenderQuality::Terminal,
)
);
// Source change ⇒ different key.
assert_ne!(
dark,
MermaidCacheKey::derive(
"flowchart TD\nA-->C",
ThemeKind::GrokNight,
80,
MermaidRenderQuality::Terminal,
)
);
// Theme change ⇒ different key.
assert_ne!(
dark,
MermaidCacheKey::derive(
"flowchart TD\nA-->B",
ThemeKind::GrokDay,
80,
MermaidRenderQuality::Terminal,
)
);
// Width change beyond the bucket ⇒ different key.
assert_ne!(
dark,
MermaidCacheKey::derive(
"flowchart TD\nA-->B",
ThemeKind::GrokNight,
160,
MermaidRenderQuality::Terminal,
)
);
// Quality tier change ⇒ different key (and filename).
let open = MermaidCacheKey::derive(
"flowchart TD\nA-->B",
ThemeKind::GrokNight,
80,
MermaidRenderQuality::Open,
);
assert_ne!(dark, open);
assert_ne!(dark.cache_filename(), open.cache_filename());
// Open tier ignores terminal width.
assert_eq!(
open,
MermaidCacheKey::derive(
"flowchart TD\nA-->B",
ThemeKind::GrokNight,
999,
MermaidRenderQuality::Open,
)
);
}
#[test]
fn cache_key_width_bucketing() {
// Widths within the same bucket collapse to one key.
let a = MermaidCacheKey::derive(
"x",
ThemeKind::GrokNight,
80,
MermaidRenderQuality::Terminal,
);
let b = MermaidCacheKey::derive(
"x",
ThemeKind::GrokNight,
80 + MERMAID_WIDTH_BUCKET - 1,
MermaidRenderQuality::Terminal,
);
assert_eq!(a, b);
let c = MermaidCacheKey::derive(
"x",
ThemeKind::GrokNight,
80 + MERMAID_WIDTH_BUCKET,
MermaidRenderQuality::Terminal,
);
assert_ne!(a, c);
}
#[test]
fn cache_key_usable_in_hash_set() {
// The derived `Hash` must round-trip through a `HashSet` (PNG cache).
use std::collections::HashSet;
let mut set = HashSet::new();
let key = MermaidCacheKey::derive(
"A-->B\n",
ThemeKind::GrokNight,
80,
MermaidRenderQuality::Terminal,
);
set.insert(key.clone());
assert!(set.contains(&key));
assert!(!set.contains(&MermaidCacheKey::derive(
"A-->B\n",
ThemeKind::GrokDay,
80,
MermaidRenderQuality::Terminal,
)));
}
#[test]
fn mermaid_content_skeleton_detects_source_without_render_state() {
let mut renderer = StreamingMarkdownRenderer::new(md_style::style(), true);
renderer.push("```mermaid\nA-->B\n```\n");
let view = renderer.finish(Some(get_syntect()));
let content = MermaidContent::from_view(&view);
assert_eq!(content.len(), 1);
assert!(!content.is_empty());
// The skeleton only exposes the clean source (what the lazy click path
// renders); there is no per-diagram render state to track.
assert_eq!(content.source(0), Some("A-->B\n"));
assert_eq!(content.source(1), None);
}
#[test]
fn display_selection_matrix() {
// Off ⇒ inline art only; Auto/On ⇒ inline art + the clickable affordance
// row (the engine is always compiled in; no terminal-capability input).
assert_eq!(
mermaid_display(RenderMermaid::Off),
MermaidDisplay::SourceOnly
);
for setting in [RenderMermaid::Auto, RenderMermaid::On] {
assert_eq!(
mermaid_display(setting),
MermaidDisplay::Affordances,
"setting={setting:?}",
);
}
}
#[test]
fn static_commit_forces_source_only() {
// Minimal (static commit) suppresses the affordance row for every
// setting; non-static keeps the normal per-setting behavior.
for setting in [RenderMermaid::Off, RenderMermaid::Auto, RenderMermaid::On] {
assert_eq!(
mermaid_display_static(setting, true),
MermaidDisplay::SourceOnly,
"static_commit must force SourceOnly (setting={setting:?})",
);
assert_eq!(
mermaid_display_static(setting, false),
mermaid_display(setting),
"non-static must match mermaid_display (setting={setting:?})",
);
}
}
#[test]
fn affordance_buttons_start_after_the_label_with_a_fixed_gap() {
// Buttons are laid out from `start_col` (which leaves room for the
// leading `◇ mermaid` label) with a fixed inter-button gap; every button
// is clickable (no per-button enable flag).
let start = UnicodeWidthStr::width(MERMAID_LABEL) as u16 + AFFORDANCE_GAP;
let buttons = affordance_buttons(start);
assert_eq!(
buttons.map(|b| (b.label, b.kind)),
[
("[Open Image]", AffordanceKind::Open),
("[Copy Image Path]", AffordanceKind::CopyPath),
("[Copy Source]", AffordanceKind::CopySource),
],
);
assert_eq!(buttons[0].col, start);
for win in buttons.windows(2) {
let prev_end = win[0].col + UnicodeWidthStr::width(win[0].label) as u16;
assert_eq!(win[1].col, prev_end + AFFORDANCE_GAP, "fixed gap: {win:?}");
}
}
#[test]
fn affordance_row_has_label_and_shows_status_only_while_rendering() {
// Display widths: `◇ mermaid` (9) + gap (3) → buttons start at col 12;
// [Open Image] (12), [Copy Image Path] (17), [Copy Source] (13) with
// gap-3 between.
let start = UnicodeWidthStr::width(MERMAID_LABEL) as u16 + AFFORDANCE_GAP;
let idle = affordance_row(false);
assert_eq!(idle.label, (0, MERMAID_LABEL));
assert_eq!(idle.buttons.map(|b| b.col), [start, start + 15, start + 35]);
assert_eq!(
idle.buttons.map(|b| b.label),
["[Open Image]", "[Copy Image Path]", "[Copy Source]"],
);
assert_eq!(idle.status, None, "no status unless a render is in flight");
// While rendering, the `rendering…` hint sits after the last button + gap;
// the label and button columns are unchanged.
let busy = affordance_row(true);
let last = busy.buttons[2];
let after = last.col + UnicodeWidthStr::width(last.label) as u16 + AFFORDANCE_GAP;
assert_eq!(busy.status, Some((after, MERMAID_RENDERING)));
assert_eq!(busy.label, idle.label);
assert_eq!(busy.buttons, idle.buttons);
}
/// Build a `BlockOutput` whose joiners describe the given pre-wrap → row
/// layout. `wraps[i]` is the number of post-wrap rows pre-wrap line `i`
/// occupies (≥ 1).
fn output_with_wraps(wraps: &[usize]) -> BlockOutput {
let mut lines = Vec::new();
for (pre, &rows) in wraps.iter().enumerate() {
for row in 0..rows {
let joiner = if row == 0 { None } else { Some(String::new()) };
lines.push(BlockLine::text(format!("pre{pre}-row{row}")).with_joiner(joiner));
}
}
BlockOutput { lines }
}
fn caption_text(line: &BlockLine) -> String {
line.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
/// One-element range slice (a bound value, so it sidesteps the
/// `single_range_in_vec_init` lint that a `&[a..b]` literal trips).
fn one(range: Range<usize>) -> Vec<Range<usize>> {
vec![range]
}
#[test]
fn prewrap_end_rows_handles_wrapping() {
// pre0: 1 row, pre1: 2 rows, pre2: 1 row → rows [0],[1,2],[3].
let out = output_with_wraps(&[1, 2, 1]);
assert_eq!(prewrap_end_rows(&out.lines), vec![1, 3, 4]);
}
// -- theme mapping + cache filename --------------------------------------
#[test]
fn theme_is_dark_maps_grokday_to_light_only() {
assert!(
!theme_is_dark(ThemeKind::GrokDay),
"GrokDay is the light theme"
);
for dark in [
ThemeKind::GrokNight,
ThemeKind::TokyoNight,
ThemeKind::RosePineMoon,
ThemeKind::OscuraMidnight,
] {
assert!(theme_is_dark(dark), "{dark:?} should be dark");
}
}
#[test]
fn cache_filename_is_stable_and_keyed() {
let a = MermaidCacheKey::derive(
"flowchart TD\nA-->B",
ThemeKind::GrokNight,
80,
MermaidRenderQuality::Terminal,
);
// Deterministic + ends in .png, hex hash + theme + bucket fields.
assert_eq!(a.cache_filename(), a.cache_filename());
assert!(a.cache_filename().ends_with(".png"));
assert!(
a.cache_filename()
.ends_with(&format!("-r{RENDER_REVISION}.png")),
"filename must carry the render revision: {}",
a.cache_filename()
);
// Different theme / source / width → different filename.
let b = MermaidCacheKey::derive(
"flowchart TD\nA-->B",
ThemeKind::GrokDay,
80,
MermaidRenderQuality::Terminal,
);
assert_ne!(a.cache_filename(), b.cache_filename());
let c = MermaidCacheKey::derive(
"flowchart TD\nA-->C",
ThemeKind::GrokNight,
80,
MermaidRenderQuality::Terminal,
);
assert_ne!(a.cache_filename(), c.cache_filename());
let open = MermaidCacheKey::derive(
"flowchart TD\nA-->B",
ThemeKind::GrokNight,
80,
MermaidRenderQuality::Open,
);
assert_ne!(a.cache_filename(), open.cache_filename());
// No raw source in the name (only the hash).
assert!(!a.cache_filename().contains("flowchart"));
}
#[test]
fn apply_affordance_rows_inserts_blank_rows_and_reports_source() {
// Two diagrams at pre-wrap 0..1 and 2..3 in a 4-line output; each
// affordance row carries its own diagram's source (document order).
let mut out = output_with_wraps(&[1, 1, 1, 1]);
let sources = ["A-->B\n", "C-->D\n"];
let mut iter = sources.into_iter();
let affs = apply_affordance_rows(&mut out, &[0..1, 2..3], |_| {
iter.next().unwrap().to_string()
});
// One blank, non-selectable continuation row inserted per diagram.
assert_eq!(out.lines.len(), 6);
let blanks: Vec<usize> = out
.lines
.iter()
.enumerate()
.filter(|(_, l)| matches!(l.selectable, Selectable::None))
.map(|(i, _)| i)
.collect();
assert_eq!(blanks, vec![1, 4], "affordance rows after each diagram");
// The reported offsets point at the inserted rows in the FINAL output.
assert_eq!(affs.len(), 2);
assert_eq!(affs[0].row_offset, 1);
assert_eq!(affs[1].row_offset, 4);
assert!(matches!(out.lines[1].selectable, Selectable::None));
assert!(matches!(out.lines[4].selectable, Selectable::None));
assert_eq!(affs[0].source, "A-->B\n");
assert_eq!(affs[1].source, "C-->D\n");
}
#[test]
fn apply_affordance_rows_offset_follows_wrapped_body() {
// The diagram's single body pre-wrap line (index 1) wraps to two rows
// [1,2]; the affordance row must land after the LAST wrapped row (3).
let mut out = output_with_wraps(&[1, 2, 1]);
let affs = apply_affordance_rows(&mut out, &one(1..2), |_| "A-->B\n".to_string());
assert_eq!(affs.len(), 1);
assert_eq!(affs[0].row_offset, 3);
assert!(matches!(out.lines[3].selectable, Selectable::None));
// The trailing pre2 row is pushed down, not overwritten.
assert_eq!(caption_text(&out.lines[4]), "pre2-row0");
}
}
@@ -0,0 +1,39 @@
//! Block implementations for v3 pager.
//!
//! Each block type represents a different kind of content in the scrollback.
mod agent;
mod bg_task;
mod btw;
mod context_info;
mod credit_limit;
pub mod markdown_content;
pub mod mermaid_content;
mod quote_bar;
mod session_event;
mod subagent;
mod system;
mod thinking;
pub mod tool;
mod user;
pub use agent::AgentMessageBlock;
pub use bg_task::{BgTaskBlock, BgTaskKind};
pub use btw::BtwBlock;
pub use context_info::ContextInfoBlock;
pub use credit_limit::{CreditLimitBlock, CreditLimitCardAction};
pub use session_event::{EndWork, SessionEvent, SessionEventBlock};
pub use subagent::{SubagentBlock, SubagentBlockKind};
pub use system::SystemMessageBlock;
pub use thinking::ThinkingBlock;
pub use tool::{
DiffLineOutput, DiffRenderConfig, DiscoveredTool, EditToolCallBlock, ExecuteToolCallBlock,
IntegrationSearchToolCallBlock, LineRange, ListDirToolCallBlock, OtherToolCallBlock,
ReadToolCallBlock, SearchFileMatch, SearchLineMatch, SearchToolCallBlock, ToolCallBlock,
UseToolCallBlock, discovered_tool_action, render_diff_hunk_highlighted,
render_diff_hunks_highlighted,
};
pub use user::UserPromptBlock;
// Backwards compatibility alias
pub type EditBlock = EditToolCallBlock;
@@ -0,0 +1,471 @@
//! Rendered blockquote-bar detection for selection/copy metadata.
//!
//! The markdown renderer rewrites each `>` quote marker to a `│` bar styled
//! `blockquote_outer` (kigi-markdown parse.rs), so the decoration becomes
//! ordinary span content and would otherwise leak into drag-select copies.
//! The helpers here detect that prefix on a rendered row and exclude it from
//! selection via [`Selectable::Spans`] — the same decoration-exclusion
//! machinery tool headers and diff gutters use. Shared by
//! [`MarkdownContent::output`](super::markdown_content::MarkdownContent::output)
//! and [`ThinkingBlock`](super::ThinkingBlock)'s render paths.
use std::borrow::Cow;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::scrollback::types::Selectable;
use crate::theme::Theme;
/// Per-render quote-bar stripping context: the raw-mode gate plus the
/// theme-derived bar style. Build once per output pass; raw mode skips the
/// `Theme::current()` lookup entirely.
#[derive(Clone, Copy)]
pub(crate) struct QuoteBarStrip {
/// `None` = stripping disabled (raw mode shows source `>` markers).
bar_style: Option<Style>,
}
impl QuoteBarStrip {
pub(crate) fn new(enabled: bool) -> Self {
Self {
bar_style: enabled.then(quote_bar_style),
}
}
/// Selection metadata for one rendered row; see [`quote_prefix_selectable`].
pub(crate) fn selectable(&self, line: &mut Line<'static>) -> Selectable {
match self.bar_style {
Some(bar_style) => quote_prefix_selectable(line, bar_style),
None => Selectable::All,
}
}
}
/// The exact ratatui style the renderer paints parser-generated blockquote
/// bars with: `md_style` sets `blockquote_outer = fg(md_muted).dimmed()` and
/// a `Reset` fg is dropped in the anstyle round-trip, leaving DIM alone.
/// Mirrors pager-render theme/md_style.rs `blockquote_outer` (breadcrumbed
/// there); the end-to-end tests below trip if either side drifts.
fn quote_bar_style() -> Style {
let muted = Theme::current().md_muted;
let style = Style::default().add_modifier(Modifier::DIM);
if muted == Color::Reset {
style
} else {
style.fg(muted)
}
}
/// Byte length of a rendered blockquote prefix at the start of `line`:
/// `bar_style`-styled bars separated by exactly one space, through the space
/// before content (`│ text` → 4, `│ │ deep` → 8), or the whole line for
/// bar-only rows (blank line inside a quote: `│`, `│ │`, optional trailing
/// space).
///
/// Every prefix bar must carry `bar_style` — a differently-styled bar is
/// quote CONTENT (e.g. source `> │ box art`), which ends the prefix and then
/// trips the interior-bar rule. Returns `None` for anything else: any `│`
/// after the prefix marks table rows, literal box art, or literal-bar quote
/// content (same interior-bar rule `is_table_line` uses in the wrap layer),
/// so the row conservatively stays fully selectable.
///
/// Shape must agree with the looser `blockquote_prefix_len` in pager-render's
/// wrapping.rs (which re-injects this prefix on wrapped continuation rows,
/// preserving the bar spans + style this scanner keys on).
fn rendered_quote_prefix_len(line: &Line<'_>, bar_style: Style) -> Option<usize> {
const BAR: char = '\u{2502}';
const BAR_LEN: usize = '\u{2502}'.len_utf8();
let mut chars = line
.spans
.iter()
.flat_map(|s| s.content.chars().map(move |c| (c, s.style)))
.peekable();
let mut len = 0usize;
loop {
match chars.next() {
Some((BAR, style)) if style == bar_style => len += BAR_LEN,
_ => return None,
}
match chars.next() {
None => return Some(len),
Some((' ', _)) => {
len += 1;
match chars.peek() {
None => return Some(len),
Some((BAR, style)) if *style == bar_style => continue,
Some(_) => break,
}
}
Some(_) => return None,
}
}
if chars.any(|(c, _)| c == BAR) {
return None;
}
Some(len)
}
/// Split `line`'s spans at `byte_offset` (splitting a straddling span in two)
/// and return the number of spans covering `0..byte_offset`.
fn split_spans_at(line: &mut Line<'static>, byte_offset: usize) -> usize {
let mut acc = 0usize;
for i in 0..line.spans.len() {
let end = acc + line.spans[i].content.len();
if end == byte_offset {
return i + 1;
}
if end > byte_offset {
let local = byte_offset - acc;
let span = &mut line.spans[i];
let tail: Cow<'static, str> = match &mut span.content {
Cow::Borrowed(s) => {
let (head, tail) = s.split_at(local);
span.content = Cow::Borrowed(head);
Cow::Borrowed(tail)
}
Cow::Owned(s) => Cow::Owned(s.split_off(local)),
};
let style = span.style;
line.spans.insert(
i + 1,
Span {
content: tail,
style,
},
);
return i + 1;
}
acc = end;
}
line.spans.len()
}
/// Selection metadata for a pretty-mode markdown row: when the row is a
/// parser-generated blockquote line, exclude the `│ ` prefix (all nesting
/// levels) from copy by splitting the straddling span at the prefix boundary
/// and returning a `Selectable::Spans` range past it. Bar-only rows (blank
/// quote lines) return an empty end range so multi-line copies keep the
/// blank line. Returns `Selectable::All` for every other row.
///
/// The bar must be the FIRST span: quotes indented under other constructs
/// (e.g. a list item's `- > quoted`, bullet span first) keep their prefix in
/// copies — an accepted conservative false negative, like interior bars.
fn quote_prefix_selectable(line: &mut Line<'static>, bar_style: Style) -> Selectable {
// Only parser-generated bars are a lone 1-char span carrying the
// blockquote_outer style; a literal "│ " in prose or code stays glued to
// its content span or carries a different style, so it is left intact.
let genuine = line
.spans
.first()
.is_some_and(|s| s.content.as_ref() == "\u{2502}" && s.style == bar_style);
if !genuine {
return Selectable::All;
}
let Some(prefix_len) = rendered_quote_prefix_len(line, bar_style) else {
return Selectable::All;
};
let prefix_spans = split_spans_at(line, prefix_len);
Selectable::Spans(prefix_spans..line.spans.len())
}
#[cfg(test)]
mod tests {
use super::super::markdown_content::{MARKDOWN_BODY_RANGE, MarkdownContent};
use super::*;
use crate::scrollback::text_selection::{ActiveTextDrag, RangeHit};
use crate::scrollback::types::{
BlockLine, BlockOutput, derive_selection_text, line_plain_text, selectable_cols,
};
fn find_line<'a>(out: &'a BlockOutput, needle: &str) -> &'a BlockLine {
out.lines
.iter()
.find(|l| line_plain_text(&l.content).contains(needle))
.unwrap_or_else(|| panic!("no output line contains {needle:?}"))
}
#[test]
fn rendered_quote_prefix_len_shapes() {
let bq = quote_bar_style();
let quote = Line::from(vec![Span::styled("│", bq), Span::raw(" text")]);
assert_eq!(rendered_quote_prefix_len(&quote, bq), Some(4));
let nested = Line::from(vec![
Span::styled("│", bq),
Span::raw(" "),
Span::styled("│", bq),
Span::raw(" deep"),
]);
assert_eq!(rendered_quote_prefix_len(&nested, bq), Some(8));
// Bar-only rows (blank quote line), optional trailing space.
assert_eq!(
rendered_quote_prefix_len(&Line::from(Span::styled("│", bq)), bq),
Some(3)
);
let nested_blank = Line::from(vec![
Span::styled("│", bq),
Span::raw(" "),
Span::styled("│", bq),
]);
assert_eq!(rendered_quote_prefix_len(&nested_blank, bq), Some(7));
assert_eq!(
rendered_quote_prefix_len(&Line::from(vec![Span::styled("│", bq), Span::raw(" ")]), bq),
Some(4)
);
// Unstyled bars are content, never a prefix.
assert_eq!(rendered_quote_prefix_len(&Line::raw("│ text"), bq), None);
assert_eq!(rendered_quote_prefix_len(&Line::raw("│ "), bq), None);
// Interior bars mark table rows / box art — never a quote prefix,
// even when the leading border span carries the identical style.
let table_row = Line::from(vec![Span::styled("│", bq), Span::raw(" a │ b │")]);
assert_eq!(rendered_quote_prefix_len(&table_row, bq), None);
let blank_table_row = Line::from(vec![Span::styled("│", bq), Span::raw(" │ │")]);
assert_eq!(rendered_quote_prefix_len(&blank_table_row, bq), None);
assert_eq!(rendered_quote_prefix_len(&Line::raw("││"), bq), None);
assert_eq!(rendered_quote_prefix_len(&Line::raw("── rule"), bq), None);
assert_eq!(rendered_quote_prefix_len(&Line::raw("plain"), bq), None);
}
#[test]
fn rendered_quote_prefix_len_rejects_content_bars_after_genuine_prefix() {
let bq = quote_bar_style();
// Source `> │ box art`: genuine bar, then a literal (unstyled) bar as
// the first content char — must not be consumed as a nesting level.
let literal_second = Line::from(vec![Span::styled("│", bq), Span::raw(" │ box art")]);
assert_eq!(rendered_quote_prefix_len(&literal_second, bq), None);
// Degenerate `> │` (content is just a bar).
let bar_only_content = Line::from(vec![Span::styled("│", bq), Span::raw(" │")]);
assert_eq!(rendered_quote_prefix_len(&bar_only_content, bq), None);
// Nested variant `> > │ deep`: two genuine bars, then a literal one.
let nested_literal = Line::from(vec![
Span::styled("│", bq),
Span::raw(" "),
Span::styled("│", bq),
Span::raw(" │ deep"),
]);
assert_eq!(rendered_quote_prefix_len(&nested_literal, bq), None);
}
#[test]
fn quote_prefix_selectable_requires_bar_style() {
// Same content, wrong style (no DIM): a literal bar span is not a
// parser-generated quote bar and must stay fully selectable.
let mut line = Line::from(vec![Span::raw("│"), Span::raw(" text")]);
assert_eq!(
quote_prefix_selectable(&mut line, quote_bar_style()),
Selectable::All
);
let mut line = Line::from(vec![
Span::styled("│", quote_bar_style()),
Span::raw(" text"),
]);
assert_eq!(
quote_prefix_selectable(&mut line, quote_bar_style()),
Selectable::Spans(2..3)
);
// The glued " text" span was split so the prefix ends on a boundary.
assert_eq!(line.spans[1].content.as_ref(), " ");
assert_eq!(line.spans[2].content.as_ref(), "text");
}
#[test]
fn quote_line_selection_excludes_bar_prefix() {
let md = MarkdownContent::new("intro\n\n> QUOTE alpha\n\noutro");
let out = md.output(80);
let line = find_line(&out, "QUOTE");
// Pretty mode renders the bar on screen…
assert!(
line_plain_text(&line.content).starts_with("│ "),
"expected rendered bar prefix, got {:?}",
line_plain_text(&line.content)
);
// …but the bar is excluded from selection/copy.
assert!(
matches!(line.selectable, Selectable::Spans(_)),
"quote line should exclude its prefix, got {:?}",
line.selectable
);
assert_eq!(derive_selection_text(line), "QUOTE alpha");
// Non-quote lines stay fully selectable.
assert!(matches!(
find_line(&out, "intro").selectable,
Selectable::All
));
assert!(matches!(
find_line(&out, "outro").selectable,
Selectable::All
));
}
#[test]
fn nested_quote_selection_excludes_all_bars() {
let md = MarkdownContent::new("> outer line\n>\n> > NESTED deep");
let out = md.output(80);
let line = find_line(&out, "NESTED");
assert!(line_plain_text(&line.content).starts_with("│ │ "));
let text = derive_selection_text(line);
assert_eq!(text, "NESTED deep");
assert!(!text.contains('│'));
}
#[test]
fn wrapped_quote_continuations_exclude_reinjected_prefix() {
let md = MarkdownContent::new("> alpha bravo charlie delta echo foxtrot golf hotel india");
let out = md.output(16);
assert!(out.lines.len() > 1, "quote must wrap at width 16");
for line in &out.lines {
assert!(
line_plain_text(&line.content).starts_with('│'),
"every wrapped row repeats the bar: {:?}",
line_plain_text(&line.content)
);
let text = derive_selection_text(line);
assert!(!text.contains('│'), "copy text has a bar: {text:?}");
assert!(
!text.starts_with(' '),
"copy text keeps prefix space: {text:?}"
);
}
// Joiner-based reconstruction (the drag-copy join rule) is clean.
let mut joined = String::new();
for (i, line) in out.lines.iter().enumerate() {
if i > 0 {
joined.push_str(line.joiner.as_deref().unwrap_or("\n"));
}
joined.push_str(&derive_selection_text(line));
}
assert_eq!(
joined,
"alpha bravo charlie delta echo foxtrot golf hotel india"
);
}
#[test]
fn blank_quote_line_survives_drag_copy_as_blank() {
let md = MarkdownContent::new("> QUOTE_A first\n>\n> QUOTE_B second");
let out = md.output(80);
assert_eq!(out.lines.len(), 3, "quote renders as three rows");
// The bar-only middle row keeps an (empty) selectable range so it
// stays in the selection model and contributes its newline.
let mid = &out.lines[1];
assert_eq!(line_plain_text(&mid.content), "│");
assert!(
matches!(&mid.selectable, Selectable::Spans(r) if r.is_empty()),
"bar-only row should have an empty Spans range, got {:?}",
mid.selectable
);
assert!(selectable_cols(&mid.content, &mid.selectable).is_some());
assert_eq!(derive_selection_text(mid), "");
// Full drag from the first to the last row copies a\n\nb.
let drag = ActiveTextDrag {
anchor: RangeHit {
entry_idx: 0,
range_id: MARKDOWN_BODY_RANGE,
block_line_idx: 0,
col_within_range: 0,
},
head: RangeHit {
entry_idx: 0,
range_id: MARKDOWN_BODY_RANGE,
block_line_idx: 2,
col_within_range: u16::MAX,
},
kind: Default::default(),
anchor_content_width: None,
};
let text =
crate::scrollback::text_selection::reconstruct_full_selection_text(&out.lines, &drag)
.expect("drag reconstruction");
assert_eq!(text, "QUOTE_A first\n\nQUOTE_B second");
}
#[test]
fn literal_bar_at_quote_content_start_is_not_stripped() {
// Quoted box-drawing output: the content's own bar must never be
// consumed as a nesting level (that would DELETE user bytes from the
// copy). The row degrades to the conservative interior-bar class.
let md = MarkdownContent::new("> │ box art");
let out = md.output(80);
let line = find_line(&out, "box art");
assert!(matches!(line.selectable, Selectable::All));
assert_eq!(derive_selection_text(line), "│ │ box art");
}
#[test]
fn literal_bar_as_entire_quote_content_is_not_dropped() {
// Degenerate `> │`: without the style-aware scan this classified as a
// bar-only blank row and the content bar vanished from copies.
let md = MarkdownContent::new("> │");
let out = md.output(80);
let line = find_line(&out, "│");
assert!(matches!(line.selectable, Selectable::All));
assert_eq!(derive_selection_text(line), "│ │");
}
#[test]
fn list_nested_quote_keeps_prefix() {
// Bullet span precedes the bar, so the first-span guard skips the row
// (documented conservative false negative on quote_prefix_selectable).
let md = MarkdownContent::new("- > quoted text");
let out = md.output(80);
let line = find_line(&out, "quoted text");
assert!(matches!(line.selectable, Selectable::All));
assert!(derive_selection_text(line).contains("│ quoted text"));
}
#[test]
fn literal_bar_in_paragraph_is_not_stripped() {
// A plain paragraph starting with a literal bar (file-tree art) is a
// single glued span without the quote-bar style — left fully selectable.
let md = MarkdownContent::new("│ literal tree line");
let out = md.output(80);
let line = find_line(&out, "literal");
assert!(matches!(line.selectable, Selectable::All));
assert_eq!(derive_selection_text(line), "│ literal tree line");
}
#[test]
fn literal_bar_in_code_block_is_not_stripped() {
let md = MarkdownContent::new("```\n│ box art\n└── tree\n```");
let out = md.output(80);
let line = find_line(&out, "box art");
assert!(matches!(line.selectable, Selectable::All));
assert_eq!(derive_selection_text(line), "│ box art");
}
#[test]
fn table_rows_keep_borders_in_copy() {
let md = MarkdownContent::new("| a | b |\n|---|---|\n| CELL1 | CELL2 |");
let out = md.output(40);
let row = find_line(&out, "CELL1");
assert!(matches!(row.selectable, Selectable::All));
let text = derive_selection_text(row);
assert!(
text.starts_with('│') && text.ends_with('│'),
"table row copy keeps its borders: {text:?}"
);
}
#[test]
fn raw_mode_quote_lines_stay_fully_selectable() {
let mut md = MarkdownContent::new("> QUOTE alpha");
md.set_raw_mode(true);
let out = md.output(80);
let line = find_line(&out, "QUOTE");
assert!(matches!(line.selectable, Selectable::All));
assert_eq!(derive_selection_text(line), "> QUOTE alpha");
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,322 @@
//! SubagentBlock — scrollback entries for subagent lifecycle.
//!
//! Similar to BgTaskBlock: always collapsed, animated bullet while running,
//! colored bullet when done. Enter / Ctrl-F opens the subagent view.
//!
//! Two modes:
//! - **Blocking** (sync): Single `Started` block. Blinks while running,
//! turns green/red when done. Text: `Subagent "description"`
//! - **Background** (async): `Started` block stays forever (turns gray).
//! A separate `Completed`/`Failed` block is added when done.
//! Started text: `Subagent started: "description"`
//! Completed text: `Subagent completed in 43s: "description"`
use std::time::Duration;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::app::subagent::format_subagent_meta;
use crate::render::color::blend_color;
use crate::render::line_utils::truncate_str;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput, DisplayMode};
use crate::theme::Theme;
use crate::util::format_duration;
/// What kind of subagent lifecycle event this block represents.
#[derive(Debug, Clone)]
pub enum SubagentBlockKind {
/// Subagent is running (or was running — `finish_running` stops animation).
Started,
/// Subagent completed successfully.
Completed { elapsed: Duration },
/// Subagent failed.
Failed {
elapsed: Duration,
error: Option<String>,
},
/// Subagent was cancelled.
Cancelled { elapsed: Duration },
}
/// Subagent scrollback block.
///
/// Always collapsed, not foldable, groupable, selectable.
/// Enter / Ctrl-F opens the subagent view.
#[derive(Debug, Clone)]
pub struct SubagentBlock {
/// Human-readable description of the task.
pub description: String,
/// Child session ID (for opening the subagent view).
pub child_session_id: String,
/// Subagent type (e.g. "general-purpose", "explore").
pub subagent_type: String,
/// Named persona applied to this subagent, if any.
pub persona: Option<String>,
/// Role that supplied defaults for this subagent, if any.
pub role: Option<String>,
/// Effective model ID used by the subagent, if available.
pub model: Option<String>,
/// Whether the subagent was launched in background mode.
pub is_background: bool,
/// Lifecycle kind.
pub kind: SubagentBlockKind,
/// Live activity label from the child session's turn tracker.
///
/// Updated on each `SubagentProgress` tick while the subagent is running.
/// Shown inline in the collapsed scrollback line (e.g. "Thinking",
/// "Running: cargo build") so the user sees interactive progress without
/// opening the subagent view.
pub activity_label: Option<String>,
}
impl SubagentBlock {
/// Create a "Subagent started" block (for both sync and async).
pub fn started(
description: impl Into<String>,
child_session_id: impl Into<String>,
subagent_type: impl Into<String>,
persona: Option<String>,
role: Option<String>,
model: Option<String>,
is_background: bool,
) -> Self {
Self {
description: description.into(),
child_session_id: child_session_id.into(),
subagent_type: subagent_type.into(),
persona,
role,
model,
is_background,
kind: SubagentBlockKind::Started,
activity_label: None,
}
}
/// Create a "Subagent completed" block (background mode only).
pub fn completed(
description: impl Into<String>,
child_session_id: impl Into<String>,
elapsed: Duration,
) -> Self {
Self {
description: description.into(),
child_session_id: child_session_id.into(),
subagent_type: String::new(),
persona: None,
role: None,
model: None,
is_background: true,
kind: SubagentBlockKind::Completed { elapsed },
activity_label: None,
}
}
/// Create a "Subagent failed" block (background mode only).
pub fn failed(
description: impl Into<String>,
child_session_id: impl Into<String>,
elapsed: Duration,
error: Option<String>,
) -> Self {
Self {
description: description.into(),
child_session_id: child_session_id.into(),
subagent_type: String::new(),
persona: None,
role: None,
model: None,
is_background: true,
kind: SubagentBlockKind::Failed { elapsed, error },
activity_label: None,
}
}
/// Create a "Subagent cancelled" block (background mode only).
pub fn cancelled(
description: impl Into<String>,
child_session_id: impl Into<String>,
elapsed: Duration,
) -> Self {
Self {
description: description.into(),
child_session_id: child_session_id.into(),
subagent_type: String::new(),
persona: None,
role: None,
model: None,
is_background: true,
kind: SubagentBlockKind::Cancelled { elapsed },
activity_label: None,
}
}
pub fn is_running(&self) -> bool {
matches!(self.kind, SubagentBlockKind::Started)
}
}
/// Truncate description and wrap in quotes for display.
fn quoted_desc(desc: &str, max_width: usize) -> String {
// Reserve 2 chars for quotes
if max_width <= 2 {
return "\u{201C}\u{2026}\u{201D}".to_string(); // "…"
}
let inner = truncate_str(desc, max_width - 2);
format!("\u{201C}{inner}\u{201D}")
}
impl BlockContent for SubagentBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
// When selected, lift only the bold "Subagent" label to
// `text_primary` so it reads as undimmed (mirrors `read.rs` /
// `search.rs`, which bump only the label and leave the rest at
// `muted`). The detail text (verb + description + meta) stays
// muted in every state.
let bold = if ctx.is_selected {
theme.primary().add_modifier(Modifier::BOLD)
} else {
theme.muted().add_modifier(Modifier::BOLD)
};
let muted = theme.muted();
let w = ctx.width as usize;
let line = match (&self.kind, self.is_background) {
(SubagentBlockKind::Started, bg) => {
let verb = if bg { "started: " } else { "running: " };
let activity_suffix: String = self
.activity_label
.as_deref()
.filter(|s| !s.is_empty())
.map(|a| format!(" \u{2014} {a}"))
.unwrap_or_default();
let meta = format_subagent_meta(
self.persona.as_deref(),
self.role.as_deref(),
self.model.as_deref(),
);
// "Subagent running: " / "Subagent started: " = 18 chars
let overhead = 18 + meta.width() + activity_suffix.width();
let desc = quoted_desc(&self.description, w.saturating_sub(overhead));
let mut spans = vec![
Span::styled("Subagent ", bold),
Span::styled(verb, muted),
Span::styled(desc, muted),
];
if !activity_suffix.is_empty() {
spans.push(Span::styled(activity_suffix, muted));
}
spans.push(Span::styled(meta, muted));
Line::from(spans)
}
// Completed: Subagent completed in Xs: "description"
(SubagentBlockKind::Completed { elapsed }, _) => {
let time_str = format_duration(*elapsed);
// "Subagent completed in Xs: " = 26 + time_str.len()
let prefix_len = 26 + time_str.len();
let desc = quoted_desc(&self.description, w.saturating_sub(prefix_len));
Line::from(vec![
Span::styled("Subagent ", bold),
Span::styled(format!("completed in {time_str}: "), muted),
Span::styled(desc, muted),
])
}
// Failed: Subagent failed in Xs: "description"
(SubagentBlockKind::Failed { elapsed, error }, _) => {
let time_str = format_duration(*elapsed);
let detail = error
.as_deref()
.map(|e| format!(" ({e})"))
.unwrap_or_default();
let prefix_len = 21 + time_str.len() + detail.len();
let desc = quoted_desc(&self.description, w.saturating_sub(prefix_len));
Line::from(vec![
Span::styled("Subagent ", bold),
Span::styled(format!("failed in {time_str}{detail}: "), muted),
Span::styled(desc, muted),
])
}
// Cancelled: Subagent cancelled in Xs: "description"
(SubagentBlockKind::Cancelled { elapsed }, _) => {
let time_str = format_duration(*elapsed);
// "Subagent cancelled in Xs: " = 26 + time_str.len()
let prefix_len = 26 + time_str.len();
let desc = quoted_desc(&self.description, w.saturating_sub(prefix_len));
Line::from(vec![
Span::styled("Subagent ", bold),
Span::styled(format!("cancelled in {time_str}: "), muted),
Span::styled(desc, muted),
])
}
};
BlockOutput {
lines: vec![line.into()],
}
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
let theme = Theme::current();
match &self.kind {
SubagentBlockKind::Started if ctx.is_running => {
Some(AccentStyle::static_color(theme.accent_running))
}
_ => None,
}
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
let theme = Theme::current();
match &self.kind {
SubagentBlockKind::Started => {
if ctx.is_running {
let dim = ctx.appearance.scrollback.display.dim_accent;
let dimmed = blend_color(theme.bg_base, theme.accent_running, dim)
.unwrap_or(theme.accent_running);
Some(AccentStyle::animated(dimmed))
} else {
// Finished — gray bullet (same as bg task "started" after completion)
None
}
}
SubagentBlockKind::Completed { .. } => {
Some(AccentStyle::static_color(theme.accent_success))
}
SubagentBlockKind::Failed { .. } | SubagentBlockKind::Cancelled { .. } => {
Some(AccentStyle::static_color(theme.accent_error))
}
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
false
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn is_selectable(&self) -> bool {
true
}
fn has_bullet(&self, _ctx: &BlockContext) -> bool {
true
}
fn is_groupable(&self) -> bool {
true
}
}
@@ -0,0 +1,94 @@
//! SystemMessageBlock - displays system messages.
use ratatui::text::{Line, Span};
use crate::render::wrapping::word_wrap_lines;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{AccentStyle, BlockContext, BlockLine, BlockOutput, Selectable};
use crate::theme::Theme;
/// Block displaying a system message.
#[derive(Debug, Clone)]
pub struct SystemMessageBlock {
/// The message text.
pub text: String,
}
impl SystemMessageBlock {
/// Create a new system message block.
pub fn new(text: impl Into<String>) -> Self {
Self { text: text.into() }
}
}
impl BlockContent for SystemMessageBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let style = theme.muted();
let styled_lines: Vec<Line<'static>> = self
.text
.lines()
.map(|line| Line::from(Span::styled(line.to_string(), style)))
.collect();
let wrapped = word_wrap_lines(styled_lines, ctx.width as usize);
let all_lines: Vec<BlockLine> = wrapped
.into_iter()
.map(|line| BlockLine::styled(line).with_selection_range(Some(0)))
.collect();
// Apply max_lines budget if set
let lines = if let Some(max) = ctx.max_lines {
let max = max as usize;
if all_lines.len() > max && max > 0 {
let take_count = if max > 1 { max - 1 } else { 1 };
let mut truncated: Vec<BlockLine> =
all_lines.into_iter().take(take_count).collect();
if let Some(last) = truncated.last_mut() {
let content_end = last.content.spans.len();
last.content
.spans
.push(Span::styled(" \u{2026}".to_string(), style));
last.selectable = Selectable::Spans(0..content_end);
}
truncated
} else {
all_lines
}
} else {
all_lines
};
if lines.is_empty() {
BlockOutput {
lines: vec![BlockLine::styled(Line::from("")).with_selection_range(Some(0))],
}
} else {
BlockOutput { lines }
}
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None // System messages have no accent
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false // System messages are compact
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
false // System messages are short
}
fn is_selectable(&self) -> bool {
false // System messages are not navigable
}
fn is_groupable(&self) -> bool {
true
}
}
@@ -0,0 +1,556 @@
//! ThinkingBlock - displays agent thinking/reasoning content with markdown support.
use ratatui::style::{Color, Stylize};
use ratatui::text::{Line, Span, Text};
use crate::render::color::blend_line_with_default;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode,
};
use crate::theme::Theme;
use super::markdown_content::MarkdownContent;
use super::quote_bar::QuoteBarStrip;
/// Block displaying agent thinking content with markdown rendering.
///
/// Uses [`MarkdownContent`] for incremental markdown rendering with cached
/// word-wrapping, plus special display modes:
/// - **Collapsed**: Shows "Thought" or "Thought for Xs" if time is set
/// - **Truncated** (default): Shows "…" + last N lines
/// - **Expanded**: Full content
#[derive(Debug, Clone)]
pub struct ThinkingBlock {
content: MarkdownContent,
/// Optional elapsed time in milliseconds (from server).
/// When set, collapsed view shows "Thought for Xs".
elapsed_time_ms: Option<i64>,
/// When the thinking block started (local timestamp for live elapsed).
started_at: Option<std::time::Instant>,
}
impl ThinkingBlock {
/// Create a new thinking block with complete text.
pub fn new(text: impl Into<String>) -> Self {
Self {
content: MarkdownContent::new(text),
elapsed_time_ms: None,
started_at: None,
}
}
/// Create an empty block for streaming.
pub fn streaming() -> Self {
Self {
content: MarkdownContent::streaming(),
elapsed_time_ms: None,
started_at: Some(std::time::Instant::now()),
}
}
/// Create an empty streaming block for **historical replay**.
///
/// Unlike [`streaming`], this does NOT arm the local `started_at` timer.
/// Replay re-applies a whole session's persisted chunks back-to-back in
/// microseconds, so a local wall-clock timer would freeze to ~0ms in
/// [`finish`] and render a bogus "Thought for 0.0s". With no local timer
/// `finish` leaves `elapsed_time_ms` unset, so
/// [`ScrollbackState::finish_running_with_time`] falls back to the
/// server-reported elapsed (derived from `agentTimestampMs - streamStartMs`),
/// which is the real duration the user originally experienced.
pub fn streaming_replay() -> Self {
Self {
content: MarkdownContent::streaming(),
elapsed_time_ms: None,
started_at: None,
}
}
/// Push a streaming chunk of markdown text.
pub fn push_chunk(&mut self, chunk: &str) {
self.content.push_chunk(chunk);
}
/// Push a chunk without rendering immediately.
pub fn push_chunk_deferred(&mut self, chunk: &str) {
self.content.push_chunk_deferred(chunk);
}
/// Finish streaming and do a full re-render for safety.
///
/// Freezes the local elapsed time from `started_at` so the collapsed
/// view shows the actual wall-clock duration the user experienced,
/// not the server-reported delta.
pub fn finish(&mut self) {
self.content.finish();
// Freeze local elapsed if no server time has been set.
// The local timer (started_at → now) captures the full duration
// from block creation to finish, which is what the user perceives.
if self.elapsed_time_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_time_ms = Some(start.elapsed().as_millis() as i64);
}
}
/// Get the source text.
pub fn text(&self) -> String {
self.content.text()
}
/// Get the elapsed thinking time in milliseconds.
///
/// Returns server-reported time if available, otherwise live elapsed
/// from `started_at` (for running thinking blocks).
pub fn elapsed_time_ms(&self) -> Option<i64> {
match self.elapsed_time_ms {
Some(ms) => Some(ms),
None => self
.started_at
.map(|start| start.elapsed().as_millis() as i64),
}
}
/// Set the elapsed time (in milliseconds).
///
/// When set, the collapsed view will show "Thought for Xs".
pub fn set_elapsed_time_ms(&mut self, time_ms: Option<i64>) {
self.elapsed_time_ms = time_ms;
}
/// Set the raw mode, re-rendering if it changed.
pub fn set_raw_mode(&mut self, raw: bool) {
self.content.set_raw_mode(raw);
}
/// Access the underlying markdown content (for viewer item building).
pub fn content(&self) -> &MarkdownContent {
&self.content
}
/// Mutable access to the underlying markdown content.
pub fn content_mut(&mut self) -> &mut MarkdownContent {
&mut self.content
}
/// Get copyable text for this block.
///
/// When `raw` is true, returns the raw markdown source.
/// When `raw` is false, returns the rendered text (styles stripped).
pub fn copy_text(&self, raw: bool) -> String {
if raw {
self.content.text()
} else {
self.content.rendered_plain_text()
}
}
/// Format elapsed time for display.
fn format_time(&self) -> Option<String> {
self.elapsed_time_ms.map(|ms| {
let secs = ms as f64 / 1000.0;
if secs < 60.0 {
format!("{:.1}s", secs)
} else {
let mins = (secs / 60.0).floor() as u32;
let remaining = secs - (mins as f64 * 60.0);
format!("{}m{:.0}s", mins, remaining)
}
})
}
/// Build the header line: "Thinking..." (running) or "Thought for Xs" (done).
///
/// Respects muted_collapsed: when collapsed and muting is on, uses muted style.
/// When the entry is selected, the muted treatment is suppressed and
/// the label is forced to the bright/primary style so the selected
/// header reads as undimmed — same rule as the tool-call variants.
fn header_line(&self, ctx: &BlockContext) -> Line<'static> {
let theme = Theme::current();
let tool_cfg = &ctx.appearance.scrollback.blocks.tool;
let thinking_cfg = &ctx.appearance.scrollback.blocks.thinking;
let is_collapsed = ctx.mode == DisplayMode::Collapsed;
let is_muted = is_collapsed && ctx.mute_when_collapsed(tool_cfg.muted_collapsed);
// Bright on selection or config opt-in, but never while muted
// — keeps legacy-ConHost collapse uniformly muted.
let use_bright = !is_muted && (ctx.is_selected || thinking_cfg.header_bright);
let label_style = if use_bright {
theme.primary().bold()
} else {
theme.muted().bold()
};
let detail_style = theme.muted();
if ctx.is_running {
Line::from(Span::styled("Thinking…", label_style))
} else if let Some(time_str) = self.format_time() {
Line::from(vec![
Span::styled("Thought", label_style),
Span::styled(format!(" for {time_str}"), detail_style),
])
} else {
Line::from(Span::styled("Thought", label_style))
}
}
/// Render the collapsed view: header line only, truncated to fit.
fn render_collapsed(&self, ctx: &BlockContext) -> BlockOutput {
let line = self.header_line(ctx);
let line = crate::render::line_utils::truncate_line(line, ctx.content_width());
BlockOutput {
lines: vec![BlockLine::separator(line)],
}
}
/// Prepend header + blank line to output, if header config is enabled.
fn maybe_prepend_header(&self, mut output: BlockOutput, ctx: &BlockContext) -> BlockOutput {
if ctx.appearance.scrollback.blocks.thinking.header {
output.lines.insert(0, BlockLine::separator(Line::from("")));
output
.lines
.insert(0, BlockLine::separator(self.header_line(ctx)));
}
output
}
/// One wrapped markdown line → selectable, blended [`BlockLine`].
///
/// Quote-bar exclusion must run before blending: blending rewrites span
/// fg colors, which would defeat the bar-style detection (it preserves
/// span structure, so the computed span indices stay valid after it).
fn thinking_body_line(
line: &Line<'static>,
joiner: &Option<String>,
strip: &QuoteBarStrip,
bg_base: Color,
fg_default: Color,
blend_factor: f32,
) -> BlockLine {
let mut content = line.clone();
let selectable = strip.selectable(&mut content);
let blended = blend_line_with_default(content, bg_base, fg_default, blend_factor);
let mut block_line = BlockLine::styled(blended)
.with_selection_range(Some(0))
.with_joiner(joiner.clone());
block_line.selectable = selectable;
block_line
}
/// Render truncated view: optional header + "…" + last N lines.
fn render_truncated(&self, ctx: &BlockContext) -> BlockOutput {
let config = &ctx.appearance.scrollback.blocks.thinking;
let n = config.truncated_lines as usize;
let width = ctx.width as usize;
let blend_factor = config.bg_blend;
let strip = QuoteBarStrip::new(!self.content.is_raw());
self.content.with_wrapped_lines(width, |wrapped| {
if wrapped.lines.is_empty() {
return self.render_empty_placeholder(ctx);
}
let theme = Theme::current();
let bg_base = theme.bg_base;
let fg_default = theme.text_primary;
let total = wrapped.lines.len();
if total <= n {
// Content fits within N lines, show all (with blending)
let output = BlockOutput {
lines: wrapped
.lines
.iter()
.zip(wrapped.joiners.iter())
.map(|(line, joiner)| {
Self::thinking_body_line(
line,
joiner,
&strip,
bg_base,
fg_default,
blend_factor,
)
})
.collect(),
};
return self.maybe_prepend_header(output, ctx);
}
// Build truncated output: "…" + last N lines
let theme = Theme::current();
let mut output_lines = Vec::with_capacity(n + 1);
// Ellipsis line
let ellipsis = Line::from(Span::styled("…", theme.muted()));
output_lines.push(ellipsis.into());
// Last N lines (with blending)
for i in (total - n)..total {
output_lines.push(Self::thinking_body_line(
&wrapped.lines[i],
&wrapped.joiners[i],
&strip,
bg_base,
fg_default,
blend_factor,
));
}
self.maybe_prepend_header(
BlockOutput {
lines: output_lines,
},
ctx,
)
})
}
/// Render expanded view: full content.
fn render_expanded(&self, ctx: &BlockContext) -> BlockOutput {
let config = &ctx.appearance.scrollback.blocks.thinking;
let width = ctx.width as usize;
let blend_factor = config.bg_blend;
let strip = QuoteBarStrip::new(!self.content.is_raw());
self.content.with_wrapped_lines(width, |wrapped| {
if wrapped.lines.is_empty() {
return self.render_empty_placeholder(ctx);
}
let theme = Theme::current();
let bg_base = theme.bg_base;
let fg_default = theme.text_primary;
let output = BlockOutput {
lines: wrapped
.lines
.iter()
.zip(wrapped.joiners.iter())
.map(|(line, joiner)| {
Self::thinking_body_line(
line,
joiner,
&strip,
bg_base,
fg_default,
blend_factor,
)
})
.collect(),
};
self.maybe_prepend_header(output, ctx)
})
}
/// Placeholder for empty thinking block — shows the same header
/// as collapsed mode ("Thinking…" or "Thought for Xs").
fn render_empty_placeholder(&self, ctx: &BlockContext) -> BlockOutput {
self.render_collapsed(ctx)
}
}
impl BlockContent for ThinkingBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
match ctx.mode {
DisplayMode::Collapsed => self.render_collapsed(ctx),
DisplayMode::Truncated => self.render_truncated(ctx),
DisplayMode::Expanded => self.render_expanded(ctx),
}
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
let cfg = &ctx.appearance.scrollback.blocks.thinking;
if !cfg.accent_enabled {
return None;
}
// No accent when collapsed — accent is only for expanded/truncated content.
// TODO: revisit if we want accent in collapsed state with header enabled.
if ctx.mode == DisplayMode::Collapsed {
return None;
}
if cfg.animate && ctx.is_running {
Some(AccentStyle::animated(cfg.accent))
} else {
Some(AccentStyle::static_color(cfg.accent))
}
}
/// Thinking bullet: default (None) when not running, animated when running.
/// This means collapsed thinking shows gray bullet, running thinking syncs with accent.
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if ctx.is_running {
self.accent(ctx) // sync bullet with accent animation when running
} else {
None // default gray/primary
}
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn accent_background(&self, _ctx: &BlockContext) -> bool {
false
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn has_raw_mode(&self) -> bool {
true
}
fn is_foldable(&self) -> bool {
true
}
fn next_fold_mode(&self, current: DisplayMode, is_running: bool) -> DisplayMode {
if is_running {
match current {
DisplayMode::Collapsed | DisplayMode::Truncated => DisplayMode::Expanded,
DisplayMode::Expanded => DisplayMode::Truncated,
}
} else {
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
DisplayMode::Truncated | DisplayMode::Expanded => DisplayMode::Collapsed,
}
}
}
fn collapse_mode(&self, is_running: bool) -> DisplayMode {
if is_running {
DisplayMode::Truncated
} else {
DisplayMode::Collapsed
}
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Truncated
}
fn finished_display_mode(&self) -> Option<DisplayMode> {
Some(DisplayMode::Collapsed)
}
fn has_bullet(&self, ctx: &BlockContext) -> bool {
let cfg = &ctx.appearance.scrollback.blocks.thinking;
let has_header_visible = ctx.mode == DisplayMode::Collapsed || cfg.header;
has_header_visible
&& ctx
.appearance
.scrollback
.blocks
.tool
.bullet
.char()
.is_some()
}
fn is_groupable(&self) -> bool {
true
}
fn preamble(&self, ctx: &BlockContext) -> Option<Text<'static>> {
// Use expanded (bright) styling — not muted collapsed
let bright_ctx = BlockContext {
mode: DisplayMode::Expanded,
..ctx.clone()
};
Some(Text::from(self.header_line(&bright_ctx)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::appearance::AppearanceConfig;
use crate::scrollback::types::Selectable;
fn ctx(mode: DisplayMode, width: u16) -> BlockContext {
BlockContext {
mode,
is_running: false,
width,
raw: false,
max_lines: None,
appearance: AppearanceConfig::default(),
is_selected: false,
cwd: None,
}
}
#[test]
fn collapsed_thinking_header_is_non_selectable() {
let block = ThinkingBlock::new("hello world");
let out = block.output(&ctx(DisplayMode::Collapsed, 40));
assert_eq!(out.lines.len(), 1);
assert!(matches!(out.lines[0].selectable, Selectable::None));
assert_eq!(out.lines[0].selection_range, None);
}
#[test]
fn prepended_thinking_header_is_non_selectable() {
let mut appearance = AppearanceConfig::default();
appearance.scrollback.blocks.thinking.header = true;
let ctx = BlockContext {
appearance,
..ctx(DisplayMode::Expanded, 40)
};
let block = ThinkingBlock::new("hello world");
let out = block.output(&ctx);
assert!(out.lines.len() >= 3);
assert!(matches!(out.lines[0].selectable, Selectable::None));
assert!(matches!(out.lines[1].selectable, Selectable::None));
assert!(
out.lines
.iter()
.skip(2)
.all(|line| line.selection_range == Some(0))
);
}
#[test]
fn thinking_body_lines_keep_markdown_range_ids() {
let mut appearance = AppearanceConfig::default();
appearance.scrollback.blocks.thinking.header = false;
let ctx = BlockContext {
appearance,
..ctx(DisplayMode::Expanded, 10)
};
let block = ThinkingBlock::new("hello world this should wrap across lines");
let out = block.output(&ctx);
assert!(out.lines.len() > 1);
assert!(out.lines.iter().all(|line| line.selection_range == Some(0)));
}
#[test]
fn thinking_quote_line_selection_excludes_bar_prefix() {
use crate::scrollback::types::{derive_selection_text, line_plain_text};
let mut appearance = AppearanceConfig::default();
appearance.scrollback.blocks.thinking.header = false;
let ctx = BlockContext {
appearance,
..ctx(DisplayMode::Expanded, 40)
};
let block = ThinkingBlock::new("> QUOTE alpha");
let out = block.output(&ctx);
let line = out
.lines
.iter()
.find(|l| line_plain_text(&l.content).contains("QUOTE"))
.expect("quote line rendered");
assert!(line_plain_text(&line.content).starts_with("│ "));
assert!(matches!(line.selectable, Selectable::Spans(_)));
assert_eq!(derive_selection_text(line), "QUOTE alpha");
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,314 @@
//! Hook data types and rendering helpers for tool call blocks.
//!
//! Hook runs are displayed as part of tool call blocks rather than
//! as standalone scrollback entries. The tool header comes first,
//! then pre_tool_use hooks, then post_tool_use hooks.
use std::time::Duration;
use ratatui::text::{Line, Span};
use crate::scrollback::types::{BlockLine, DisplayMode};
use crate::theme::Theme;
// ── Data types ────────────────────────────────────────────────────────
/// Status of a single hook execution within a batch.
#[derive(Debug, Clone)]
pub enum HookRunStatus {
Success { elapsed: Duration },
Skipped,
Failed { error: String, elapsed: Duration },
}
/// A single hook run entry for display.
#[derive(Debug, Clone)]
pub struct HookRunEntry {
pub name: String,
pub status: HookRunStatus,
/// Truncated stdout/stderr from the hook command, if any.
pub output: Option<String>,
}
/// Which phase of tool execution the hooks belong to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookPhase {
Pre,
Post,
}
/// Hook data attached to a tool call block.
#[derive(Debug, Clone, Default)]
pub struct ToolCallHookData {
pub pre_hooks: Vec<HookRunEntry>,
pub post_hooks: Vec<HookRunEntry>,
/// Lifecycle hooks (session_start, session_end, stop) — rendered with their own event name.
pub lifecycle: Vec<(String, Vec<HookRunEntry>)>,
}
impl ToolCallHookData {
pub fn is_empty(&self) -> bool {
self.pre_hooks.is_empty() && self.post_hooks.is_empty() && self.lifecycle.is_empty()
}
pub fn has_content(&self) -> bool {
self.pre_hooks
.iter()
.chain(self.post_hooks.iter())
.any(|r| !matches!(r.status, HookRunStatus::Skipped))
|| self.lifecycle.iter().any(|(_, runs)| {
runs.iter()
.any(|r| !matches!(r.status, HookRunStatus::Skipped))
})
}
}
// ── Rendering helpers ─────────────────────────────────────────────────
const INDENT: &str = " ";
/// Count successes and failures across all hook entries.
fn count_hooks(entries: &[&[HookRunEntry]]) -> (usize, usize) {
let mut success = 0usize;
let mut failed = 0usize;
for runs in entries {
for r in *runs {
match r.status {
HookRunStatus::Success { .. } => success += 1,
HookRunStatus::Failed { .. } => failed += 1,
HookRunStatus::Skipped => {}
}
}
}
(success, failed)
}
/// `[hooks: N/M]` spans (green successes, red failures) with a leading
/// two-space gap. Returns `None` when nothing ran.
fn hooks_count_spans(success: usize, failed: usize) -> Option<Vec<Span<'static>>> {
if success == 0 && failed == 0 {
return None;
}
let theme = Theme::current();
let mut spans = vec![Span::styled(" [hooks: ", theme.muted())];
if success > 0 {
spans.push(Span::styled(
format!("{}", success),
theme
.fg(theme.accent_success)
.add_modifier(ratatui::style::Modifier::DIM),
));
}
if success > 0 && failed > 0 {
spans.push(Span::styled("/", theme.muted()));
}
if failed > 0 {
spans.push(Span::styled(
format!("{}", failed),
theme
.fg(theme.accent_error)
.add_modifier(ratatui::style::Modifier::DIM),
));
}
spans.push(Span::styled("]", theme.muted()));
Some(spans)
}
/// Render an inline `[hooks: N/M]` suffix to append to the tool header line.
///
/// - Green number for successes, red for failures
/// - If no errors, only show success count
/// - If no successes, only show error count
/// - Returns None if no hooks ran
pub fn render_hooks_inline_suffix(data: &ToolCallHookData) -> Option<Vec<Span<'static>>> {
let all_runs: Vec<&[HookRunEntry]> = [data.pre_hooks.as_slice(), data.post_hooks.as_slice()]
.into_iter()
.chain(data.lifecycle.iter().map(|(_, runs)| runs.as_slice()))
.collect();
let (success, failed) = count_hooks(&all_runs);
hooks_count_spans(success, failed)
}
/// Right-side summary for stop hooks merged onto a turn-terminal marker line:
/// `stop [hooks: 2]` per group (bold muted event name + colored counts),
/// groups joined by two spaces. Returns `None` when nothing ran.
pub fn render_stop_hooks_summary(
groups: &[(String, Vec<HookRunEntry>)],
) -> Option<Vec<Span<'static>>> {
let theme = Theme::current();
let mut spans: Vec<Span<'static>> = Vec::new();
for (event_name, runs) in groups {
let (success, failed) = count_hooks(&[runs.as_slice()]);
let Some(count_spans) = hooks_count_spans(success, failed) else {
continue;
};
if !spans.is_empty() {
spans.push(Span::styled(" ", theme.muted()));
}
spans.push(Span::styled(
event_name.clone(),
theme.muted().add_modifier(ratatui::style::Modifier::BOLD),
));
spans.extend(count_spans);
}
if spans.is_empty() { None } else { Some(spans) }
}
/// Render a separator line between tool output and hooks.
fn render_separator() -> BlockLine {
let theme = Theme::current();
Line::from(vec![Span::styled(
format!("{}\u{2500}\u{2500}\u{2500}", INDENT),
theme.muted(),
)])
.into()
}
/// Render hook details as expanded lines.
///
/// Format:
/// **pre_tool_use**
/// \u2713 hook-name (12ms)
/// \u2717 hook-name (3ms): error message
/// **post_tool_use**
/// \u2713 hook-name (5ms)
fn render_hooks_expanded(event: &str, runs: &[HookRunEntry]) -> Vec<BlockLine> {
let theme = Theme::current();
let mut lines = Vec::new();
// If all hooks were skipped, render nothing.
if runs
.iter()
.all(|r| matches!(r.status, HookRunStatus::Skipped))
{
return lines;
}
// Header: indented, bold, muted
lines.push(
Line::from(vec![Span::styled(
format!("{}{}", INDENT, event),
theme.muted().add_modifier(ratatui::style::Modifier::BOLD),
)])
.into(),
);
// Per-hook detail lines
lines.extend(render_hooks_expanded_inner(runs));
lines
}
/// Render per-hook detail lines without a section header.
fn render_hooks_expanded_inner(runs: &[HookRunEntry]) -> Vec<BlockLine> {
let theme = Theme::current();
let mut lines = Vec::new();
for run in runs {
match &run.status {
HookRunStatus::Success { elapsed } => {
lines.push(
Line::from(vec![
Span::styled(format!("{} ", INDENT), theme.muted()),
Span::styled(
format!("{} ", crate::glyphs::check_mark()),
theme.fg(theme.accent_success),
),
Span::styled(run.name.clone(), theme.muted()),
Span::styled(format!(" ({}ms)", elapsed.as_millis()), theme.muted()),
])
.into(),
);
}
HookRunStatus::Skipped => {
lines.push(
Line::from(vec![
Span::styled(format!("{} ", INDENT), theme.muted()),
Span::styled("- ", theme.muted()),
Span::styled(run.name.clone(), theme.muted()),
Span::styled(" skipped", theme.muted()),
])
.into(),
);
}
HookRunStatus::Failed { error, elapsed } => {
lines.push(
Line::from(vec![
Span::styled(format!("{} ", INDENT), theme.muted()),
Span::styled(
format!("{} ", crate::glyphs::ballot_x()),
theme.fg(theme.accent_error),
),
Span::styled(run.name.clone(), theme.muted()),
Span::styled(format!(" ({}ms)", elapsed.as_millis()), theme.muted()),
])
.into(),
);
// Error text — strip redundant hook name prefix if present
let cleaned = error
.strip_prefix(&format!("hook '{}' ", run.name))
.unwrap_or(error);
let err_text = crate::render::line_utils::truncate_str(cleaned, 120);
for err_line in err_text.lines().take(3) {
lines.push(
Line::from(vec![
Span::styled(format!("{} ", INDENT), theme.muted()),
Span::styled(err_line.to_string(), theme.fg(theme.accent_error)),
])
.into(),
);
}
}
}
// Truncated output (if present)
if let Some(ref output) = run.output {
let truncated = crate::render::line_utils::truncate_str(output, 120);
for out_line in truncated.lines().take(3) {
lines.push(
Line::from(vec![
Span::styled(format!("{} ", INDENT), theme.muted()),
Span::styled(out_line.to_string(), theme.muted()),
])
.into(),
);
}
}
}
lines
}
/// Render hook lines for a given display mode.
pub fn render_hooks_for_mode(
event: &str,
runs: &[HookRunEntry],
mode: DisplayMode,
) -> Vec<BlockLine> {
if runs.is_empty() {
return Vec::new();
}
match mode {
DisplayMode::Collapsed => Vec::new(),
DisplayMode::Expanded | DisplayMode::Truncated => render_hooks_expanded(event, runs),
}
}
/// Render hook detail lines (no section header) for expanded/truncated modes.
///
/// Used by lifecycle blocks where the block header already shows the event name,
/// so repeating it as a section header would be redundant.
pub fn render_hooks_detail(runs: &[HookRunEntry], mode: DisplayMode) -> Vec<BlockLine> {
if runs.is_empty() {
return Vec::new();
}
match mode {
DisplayMode::Collapsed => Vec::new(),
DisplayMode::Expanded | DisplayMode::Truncated => render_hooks_expanded_inner(runs),
}
}
/// Render a separator line (for use between tool output and hooks in expanded mode).
pub fn render_hook_separator() -> BlockLine {
render_separator()
}
@@ -0,0 +1,63 @@
//! LifecycleEventBlock — standalone block for lifecycle hook events
//! (e.g. `user_prompt_submit`, `session_start`, `session_end`).
//!
//! These are rendered like tool call blocks but are *not* real tool calls.
//! Having a dedicated variant lets `last_tool_call_entry_id()` skip them
//! so that tool-associated hooks (pre/post_tool_use) don't misattach.
use ratatui::text::{Line, Span};
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput, DisplayMode};
use crate::theme::Theme;
/// Block representing a lifecycle hook event.
#[derive(Debug, Clone)]
pub struct LifecycleEventBlock {
/// Event name (e.g. `user_prompt_submit`).
pub name: String,
}
impl LifecycleEventBlock {
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
}
impl BlockContent for LifecycleEventBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let muted_collapsed =
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
let style = if matches!(ctx.mode, DisplayMode::Collapsed) && muted_collapsed {
theme.muted()
} else {
theme.primary()
};
let bold = style.add_modifier(ratatui::style::Modifier::BOLD);
BlockOutput {
lines: vec![Line::from(vec![Span::styled(self.name.clone(), bold)]).into()],
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None
}
fn is_foldable(&self) -> bool {
true
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn is_groupable(&self) -> bool {
true
}
}
@@ -0,0 +1,226 @@
//! ListDirToolCallBlock - lists directory contents.
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
};
use crate::theme::Theme;
use super::TOOL_HEADER_RANGE;
/// List directory tool call.
#[derive(Debug, Clone)]
pub struct ListDirToolCallBlock {
/// Path to the directory.
pub path: String,
/// The formatted directory listing output.
pub output: String,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// When the tool started running (Phase 2: time tracking).
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion (Phase 2: time tracking).
pub elapsed_ms: Option<i64>,
}
impl ListDirToolCallBlock {
/// Create a new list_dir block.
///
/// Pre-completed blocks have no meaningful local timing — `started_at`
/// is `None`. Timing is only set for blocks that enter a running UI
/// state (via `set_last_running(true)` in `ScrollbackState`).
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
output: String::new(),
error: None,
started_at: None,
elapsed_ms: None,
}
}
/// Set the output.
pub fn with_output(mut self, output: impl Into<String>) -> Self {
self.output = output.into();
self
}
/// Set error (marks as failed).
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
/// Check if successful (no error).
pub fn is_success(&self) -> bool {
self.error.is_none()
}
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
self.error = error;
}
/// Finalize elapsed time from `started_at`.
///
/// Idempotent: no-op if `started_at` is `None` (pre-completed block)
/// or if `elapsed_ms` is already set (already finalized).
pub fn finish(&mut self) {
if self.elapsed_ms.is_some() {
return;
}
if let Some(start) = self.started_at {
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
}
/// Get elapsed time in ms (Phase 2).
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
None => self
.started_at
.map(|start| start.elapsed().as_millis() as i64),
}
}
/// Set output (mutable).
pub fn set_output(&mut self, output: impl Into<String>) {
self.output = output.into();
}
/// Render collapsed line: `List path`.
///
/// When `width` is provided, the path is fish-shortened to fit.
fn collapsed_line(&self, theme: &Theme, muted: bool, width: Option<usize>) -> Line<'static> {
let text_style = if muted {
theme.muted()
} else {
theme.primary()
};
let bold_style = text_style.add_modifier(Modifier::BOLD);
let path_style = if muted {
theme.muted()
} else {
theme.fg(theme.path)
};
let prefix = "List ";
let path_budget = width
.map(|w| w.saturating_sub(prefix.len()))
.unwrap_or(usize::MAX);
let path = crate::render::tool_paths::shorten_path(&self.path, path_budget);
Line::from(vec![
Span::styled(prefix, bold_style),
Span::styled(path, path_style),
])
}
/// Header line with only the path span selectable (exclude "List " prefix).
fn header_block_line(&self, line: Line<'static>) -> BlockLine {
let path_end = 2.min(line.spans.len()).max(1);
BlockLine {
selectable: Selectable::Spans(1..path_end),
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: Some(self.path.clone()),
content: line,
..Default::default()
}
}
}
impl BlockContent for ListDirToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let muted_collapsed =
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
let terminal_bg = ctx.appearance.scrollback.blocks.list_dir.terminal_bg;
match ctx.mode {
DisplayMode::Collapsed => BlockOutput {
lines: vec![self.header_block_line(self.collapsed_line(
&theme,
muted_collapsed,
Some(ctx.content_width()),
))],
},
DisplayMode::Truncated | DisplayMode::Expanded => {
let mut lines: Vec<BlockLine> =
vec![self.header_block_line(self.collapsed_line(&theme, false, None))];
if !self.output.is_empty() {
lines.push(BlockLine::separator(Line::from("")));
for rl in crate::render::terminal_output::render_terminal_lines(
&self.output,
theme.primary(),
) {
// Indent output by 2 spaces
let mut spans = vec![Span::styled(" ".to_string(), theme.primary())];
spans.extend(rl.line.spans);
let mut block_line: BlockLine = Line::from(spans).into();
if terminal_bg {
block_line = block_line.with_panel_background(theme.bg_dark);
}
lines.push(block_line);
}
}
BlockOutput { lines }
}
}
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None // ListDir blocks never have an accent line
}
fn bullet(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
if self.error.is_some() {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else {
None
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
// Not foldable if failed
if self.error.is_some() {
return false;
}
!self.output.is_empty()
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
_ => DisplayMode::Collapsed,
}
}
}
@@ -0,0 +1,462 @@
//! MemorySearchToolCallBlock — structured memory search results display.
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use super::TOOL_HEADER_RANGE;
use crate::render::line_utils::truncate_str;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
};
use crate::theme::Theme;
/// A single memory search result parsed from the tool output.
#[derive(Debug, Clone)]
pub struct MemoryResult {
pub score: f64,
pub source: String,
pub path: String,
pub start_line: usize,
pub end_line: usize,
pub snippet: String,
}
/// Memory search tool call block with structured result display.
#[derive(Debug, Clone)]
pub struct MemorySearchToolCallBlock {
pub query: String,
pub results: Vec<MemoryResult>,
pub error: Option<String>,
pub started_at: Option<std::time::Instant>,
pub elapsed_ms: Option<i64>,
}
impl MemorySearchToolCallBlock {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
results: Vec::new(),
error: None,
started_at: None,
elapsed_ms: None,
}
}
pub fn is_success(&self) -> bool {
self.error.is_none()
}
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
self.error = error;
}
pub fn finish(&mut self) {
if self.elapsed_ms.is_some() {
return;
}
if let Some(start) = self.started_at {
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
}
pub fn elapsed_ms(&self) -> Option<i64> {
self.elapsed_ms.or_else(|| {
self.started_at
.map(|start| start.elapsed().as_millis() as i64)
})
}
fn header_line(&self, theme: &Theme, muted: bool, max_width: Option<usize>) -> Line<'static> {
let text_style = if muted {
theme.muted()
} else {
theme.primary()
};
let bold_style = text_style.add_modifier(Modifier::BOLD);
let query_style = if muted {
theme.muted()
} else {
theme.fg(theme.command)
};
let prefix = "Memory Search ";
let count = self.results.len();
let suffix = if count > 0 {
let s = if count == 1 { "" } else { "s" };
format!(" ({count} result{s})")
} else {
String::new()
};
match max_width {
Some(w) => {
let suffix_fits = prefix.len() + suffix.len() < w;
let effective_suffix = if suffix_fits { &suffix } else { "" };
let query_budget = w
.saturating_sub(prefix.len())
.saturating_sub(effective_suffix.len());
let display_query = truncate_str(&self.query, query_budget);
let mut spans = vec![
Span::styled(prefix, bold_style),
Span::styled(display_query, query_style),
];
if !effective_suffix.is_empty() {
spans.push(Span::styled(effective_suffix.to_string(), theme.dim()));
}
Line::from(spans)
}
None => Line::from(vec![
Span::styled(prefix, bold_style),
Span::styled(self.query.clone(), query_style),
Span::styled(suffix, theme.dim()),
]),
}
}
/// Header line with only the query span selectable (exclude label/suffix).
fn header_block_line(&self, line: Line<'static>) -> BlockLine {
let query_end = 2.min(line.spans.len()).max(1);
BlockLine {
selectable: Selectable::Spans(1..query_end),
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: Some(self.query.clone()),
content: line,
..Default::default()
}
}
}
impl BlockContent for MemorySearchToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let muted_collapsed =
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
match ctx.mode {
DisplayMode::Collapsed => BlockOutput {
lines: vec![self.header_block_line(self.header_line(
&theme,
muted_collapsed,
Some(ctx.content_width()),
))],
},
DisplayMode::Truncated | DisplayMode::Expanded => {
let header = self.header_line(&theme, false, None);
let wrapped = crate::render::wrapping::wrap_header_flush(
header,
ctx.width as usize,
ctx.bullet_indent(),
);
let mut lines: Vec<BlockLine> = wrapped
.into_iter()
.enumerate()
.map(|(i, line)| {
// First span is label (or indent on continuations); only
// the query span is selectable on the first visual row.
let selectable = if i == 0 {
let query_end = 2.min(line.spans.len()).max(1);
Selectable::Spans(1..query_end)
} else {
Selectable::Spans(1..line.spans.len())
};
BlockLine {
selectable,
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: if i == 0 {
Some(self.query.clone())
} else {
None
},
joiner: if i == 0 { None } else { Some(" ".to_string()) },
content: line,
..Default::default()
}
})
.collect();
if self.results.is_empty() && self.error.is_none() {
lines.push(BlockLine::separator(Line::from("")));
lines.push(BlockLine::separator(Line::from(Span::styled(
" (no results)",
theme.muted(),
))));
}
for (i, r) in self.results.iter().enumerate() {
lines.push(Line::from("").into());
// " 1. path/file.md:10-25 (score: 0.72, global)"
let idx_span = Span::styled(format!(" {}. ", i + 1), theme.muted());
let path_display = shorten_path(&r.path);
let path_span = Span::styled(
format!("{path_display}:{}-{}", r.start_line, r.end_line),
theme.primary().add_modifier(Modifier::BOLD),
);
let meta_span = Span::styled(
format!(" (score: {:.2}, {})", r.score, r.source),
theme.dim(),
);
lines.push(BlockLine::styled(Line::from(vec![
idx_span, path_span, meta_span,
])));
// Snippet preview (first 3 non-empty lines, with bg_dark)
let snippet_lines: Vec<&str> = r
.snippet
.lines()
.filter(|l| !l.trim().is_empty())
.take(3)
.collect();
for sl in &snippet_lines {
let trimmed = sl.trim();
let display = truncate_str(trimmed, ctx.content_width().saturating_sub(4));
lines.push(
BlockLine::from(Line::from(Span::styled(
format!(" {display}"),
theme.muted(),
)))
.with_panel_background(theme.bg_dark),
);
}
}
if let Some(ref err) = self.error {
lines.push(Line::from("").into());
lines.push(
Line::from(Span::styled(
format!(" {err}"),
theme.fg(theme.accent_error),
))
.into(),
);
}
BlockOutput { lines }
}
}
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if ctx.mode == DisplayMode::Collapsed {
return None;
}
let theme = Theme::current();
if self.error.is_some() {
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.is_running {
Some(AccentStyle::animated(theme.accent_running))
} else {
Some(AccentStyle::static_color(theme.accent_tool))
}
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if self.error.is_some() {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.mode == DisplayMode::Collapsed {
None
} else {
self.accent(ctx)
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
self.error.is_none() && !self.results.is_empty()
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
_ => DisplayMode::Collapsed,
}
}
fn collapse_mode(&self, _is_running: bool) -> DisplayMode {
DisplayMode::Collapsed
}
}
fn shorten_path(path: &str) -> &str {
let memory_root = kigi_config::kigi_home().join("memory");
let memory_prefix = memory_root.display().to_string();
if let Some(rest) = path.strip_prefix(&memory_prefix) {
let rest = rest.strip_prefix('/').unwrap_or(rest);
if let Some(after_slash) = rest.find('/') {
return &rest[after_slash + 1..];
}
return rest;
}
// Fallback: strip to filename
path.rsplit('/').next().unwrap_or(path)
}
pub fn parse_memory_results(output: &str) -> Vec<MemoryResult> {
let mut results = Vec::new();
// Split on "### Result " markers
for section in output.split("### Result ") {
// Skip the preamble ("Found N memory result(s):\n")
if !section.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
let mut score = 0.0;
let mut source = String::new();
let mut path = String::new();
let mut start_line = 0;
let mut end_line = 0;
let mut snippet = String::new();
let lines: Vec<&str> = section.lines().collect();
// Line 0: "1 (score: 0.72, source: global)"
if let Some(first) = lines.first() {
if let Some(score_start) = first.find("score: ") {
let after = &first[score_start + 7..];
if let Some(end) = after.find(',') {
score = after[..end].parse().unwrap_or(0.0);
}
}
if let Some(src_start) = first.find("source: ") {
let after = &first[src_start + 8..];
let end = after.find(')').unwrap_or(after.len());
source = after[..end].to_string();
}
}
// Line 1: "**File:** /path (lines 10-25)"
for line in &lines[1..] {
if let Some(rest) = line.strip_prefix("**File:** ") {
if let Some(paren) = rest.find(" (lines ") {
path = rest[..paren].to_string();
let range_str = &rest[paren + 8..];
let range_str = range_str.trim_end_matches(')');
if let Some((s, e)) = range_str.split_once('-') {
start_line = s.parse().unwrap_or(0);
end_line = e.parse().unwrap_or(0);
}
} else {
path = rest.to_string();
}
}
}
// Extract snippet between ``` markers
let full = section;
if let Some(code_start) = full.find("```\n") {
let after_start = &full[code_start + 4..];
if let Some(code_end) = after_start.find("\n```") {
snippet = after_start[..code_end].to_string();
}
}
if !path.is_empty() || !snippet.is_empty() {
results.push(MemoryResult {
score,
source,
path,
start_line,
end_line,
snippet,
});
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_single_result() {
let output = r#"Found 1 memory result(s):
### Result 1 (score: 0.72, source: global)
**File:** /root/.kigi/memory/MEMORY.md (lines 0-10)
```
## Project Conventions
* Always use graphite for PRs
```
"#;
let results = parse_memory_results(output);
assert_eq!(results.len(), 1);
assert!((results[0].score - 0.72).abs() < 0.01);
assert_eq!(results[0].source, "global");
assert_eq!(results[0].path, "/root/.kigi/memory/MEMORY.md");
assert_eq!(results[0].start_line, 0);
assert_eq!(results[0].end_line, 10);
assert!(results[0].snippet.contains("graphite"));
}
#[test]
fn parse_multiple_results() {
let output = r#"Found 2 memory result(s):
### Result 1 (score: 0.85, source: workspace)
**File:** /root/.kigi/memory/ws/MEMORY.md (lines 1-5)
```
workspace content
```
### Result 2 (score: 0.42, source: session)
**File:** /root/.kigi/memory/ws/sessions/2026-05-01.md (lines 10-20)
```
session content
```
"#;
let results = parse_memory_results(output);
assert_eq!(results.len(), 2);
assert!((results[0].score - 0.85).abs() < 0.01);
assert_eq!(results[0].source, "workspace");
assert!((results[1].score - 0.42).abs() < 0.01);
assert_eq!(results[1].source, "session");
}
#[test]
fn parse_no_results() {
let output = "No memory results found for query.";
let results = parse_memory_results(output);
assert!(results.is_empty());
}
#[test]
fn shorten_memory_path() {
// Paths under the configured grok memory root keep one trailing segment group.
let memory_root = kigi_config::kigi_home().join("memory");
let session = memory_root.join("xai-50aa78f0/sessions/2026-05-01.md");
let top = memory_root.join("MEMORY.md");
assert_eq!(
shorten_path(session.to_str().expect("utf8 path")),
"sessions/2026-05-01.md"
);
assert_eq!(shorten_path(top.to_str().expect("utf8 path")), "MEMORY.md");
// Outside the memory root falls back to the filename.
assert_eq!(shorten_path("/some/other/path.md"), "path.md");
}
}
@@ -0,0 +1,722 @@
//! Tool call blocks - sum type for different tool types.
mod edit;
mod execute;
pub(crate) mod hook;
mod lifecycle;
pub mod list_dir;
pub(crate) mod memory_search;
mod other;
mod read;
pub mod search;
mod search_tool;
mod use_tool;
mod web_fetch;
mod web_search;
pub use edit::{
DiffLineOutput, DiffRenderConfig, EDIT_HL_MAX_BYTES, EDIT_HL_MAX_LINES, EditHighlightPhase,
EditLineStyles, EditToolCallBlock, compute_file_scoped_styles, file_text_within_hl_caps,
render_diff_hunk_highlighted, render_diff_hunks_highlighted, render_diff_hunks_with_styles,
};
pub use execute::ExecuteToolCallBlock;
pub use hook::{HookPhase, HookRunEntry, HookRunStatus, ToolCallHookData};
pub use lifecycle::LifecycleEventBlock;
pub use list_dir::ListDirToolCallBlock;
pub use memory_search::MemorySearchToolCallBlock;
pub use other::OtherToolCallBlock;
pub use read::{ReadMediaKind, ReadToolCallBlock};
pub use search::{
SearchFileMatch, SearchInputMeta, SearchLineMatch, SearchOutputMode, SearchToolCallBlock,
};
pub use search_tool::{
DiscoveredTool, SearchToolCallBlock as IntegrationSearchToolCallBlock, discovered_tool_action,
};
pub use use_tool::UseToolCallBlock;
pub use web_fetch::WebFetchToolCallBlock;
pub use web_search::WebSearchToolCallBlock;
use crate::scrollback::block::{BlockContent, join_searchable};
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockOutput, DisplayMode,
};
use std::fmt;
/// Shared selection-range id for tool-call header lines.
///
/// Headers are single logical selection targets (path/query/url/command);
/// using one id across tool kinds keeps multi-line drag/copy grouping simple.
pub(crate) const TOOL_HEADER_RANGE: u16 = 0;
/// 1-based inclusive line range for display.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineRange {
/// Start line (1-based).
pub start: usize,
/// End line (1-based, inclusive).
pub end: usize,
}
impl LineRange {
/// Create a new line range.
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
/// Format as "(start:end)" for display.
pub fn display(&self) -> String {
format!("{}:{}", self.start, self.end)
}
}
impl fmt::Display for LineRange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}-{}", self.start, self.end)
}
}
/// Semantic class of a verb-groupable (non-destructive) run member, naming
/// what a folded run of consecutive rows touched: "Read 3 files", "Searched
/// 4 patterns". Most kinds classify tool blocks via
/// [`ToolCallBlock::verb_group_kind`]; `Subagent` classifies subagent
/// lifecycle render blocks, which are not tool calls.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum VerbGroupKind {
/// Plain file reads.
File,
/// Skill reads and `Skill` invocations (distinct noun from plain files).
Skill,
/// Pattern searches (grep/glob).
Search,
/// Directory listings.
Dir,
/// Web fetches.
WebFetch,
/// Web searches, including X search.
WebSearch,
/// Memory searches.
MemorySearch,
/// MCP tool discovery (`search_tool`).
IntegrationSearch,
/// Subagent lifecycle rows (`RenderBlock::Subagent`).
Subagent,
/// Shell commands. Label-only: commands never fold eagerly
/// ([`ToolCallBlock::verb_group_kind`] excludes them), but a truncation
/// header describing hidden rows buckets them ("Ran 6 commands").
Command,
/// File edits. Label-only, like [`Self::Command`].
EditFile,
/// MCP tool dispatches (`use_tool`). Label-only, like [`Self::Command`].
McpCall,
/// Unclassified tools. Label-only, like [`Self::Command`].
OtherTool,
}
impl VerbGroupKind {
/// Verb-group row verb: present tense while running, past otherwise.
pub fn verb(self, running: bool) -> &'static str {
let (past, present) = match self {
VerbGroupKind::File | VerbGroupKind::Skill => ("Read", "Reading"),
VerbGroupKind::Search
| VerbGroupKind::WebSearch
| VerbGroupKind::MemorySearch
| VerbGroupKind::IntegrationSearch => ("Searched", "Searching"),
VerbGroupKind::Dir => ("Listed", "Listing"),
VerbGroupKind::WebFetch => ("Fetched", "Fetching"),
VerbGroupKind::Subagent | VerbGroupKind::Command | VerbGroupKind::OtherTool => {
("Ran", "Running")
}
VerbGroupKind::EditFile => ("Edited", "Editing"),
VerbGroupKind::McpCall => ("Called", "Calling"),
};
if running { present } else { past }
}
/// Verb-group row noun, pluralized by `count`.
pub fn noun(self, count: usize) -> &'static str {
let (one, many) = match self {
VerbGroupKind::File | VerbGroupKind::EditFile => ("file", "files"),
VerbGroupKind::Skill => ("skill", "skills"),
VerbGroupKind::Search => ("pattern", "patterns"),
VerbGroupKind::Dir => ("dir", "dirs"),
VerbGroupKind::WebFetch | VerbGroupKind::WebSearch => ("website", "websites"),
VerbGroupKind::MemorySearch => ("memory", "memories"),
VerbGroupKind::IntegrationSearch | VerbGroupKind::McpCall => ("MCP tool", "MCP tools"),
VerbGroupKind::Subagent => ("subagent", "subagents"),
VerbGroupKind::Command => ("command", "commands"),
VerbGroupKind::OtherTool => ("tool", "tools"),
};
if count == 1 { one } else { many }
}
}
/// Tool call block - a sum type for different tool types.
///
/// BlockContent is manually implemented (not via enum_delegate) so we can
/// intercept `output()` to prepend the tool bullet configured in appearance.
#[derive(Debug, Clone)]
pub enum ToolCallBlock {
/// Execute a shell command.
Execute(ExecuteToolCallBlock),
/// Read a file.
Read(ReadToolCallBlock),
/// Edit a file (with diff).
Edit(EditToolCallBlock),
/// List directory contents.
ListDir(ListDirToolCallBlock),
/// Search/grep for pattern.
Search(SearchToolCallBlock),
/// Web fetch (URL content retrieval).
WebFetch(WebFetchToolCallBlock),
/// Web search (web search with citations).
WebSearch(WebSearchToolCallBlock),
/// MCP integration tool discovery (search_tool).
IntegrationSearch(IntegrationSearchToolCallBlock),
/// MCP integration tool dispatch (use_tool).
UseTool(UseToolCallBlock),
/// Memory search with structured result display.
MemorySearch(MemorySearchToolCallBlock),
/// Skill invocation (user skills / slash commands via the Skill tool).
Skill(OtherToolCallBlock),
/// Other/unknown tool types.
Other(OtherToolCallBlock),
/// Lifecycle event (e.g. `user_prompt_submit`, `session_start`).
/// Not a real tool call — skipped by `last_tool_call_entry_id()`.
Lifecycle(LifecycleEventBlock),
}
/// Delegate to inner variant, with tool bullet prepended to output.
macro_rules! delegate_tool {
($self:expr, $method:ident ( $($arg:expr),* )) => {
match $self {
ToolCallBlock::Execute(b) => b.$method($($arg),*),
ToolCallBlock::Read(b) => b.$method($($arg),*),
ToolCallBlock::Edit(b) => b.$method($($arg),*),
ToolCallBlock::ListDir(b) => b.$method($($arg),*),
ToolCallBlock::Search(b) => b.$method($($arg),*),
ToolCallBlock::WebFetch(b) => b.$method($($arg),*),
ToolCallBlock::WebSearch(b) => b.$method($($arg),*),
ToolCallBlock::IntegrationSearch(b) => b.$method($($arg),*),
ToolCallBlock::UseTool(b) => b.$method($($arg),*),
ToolCallBlock::MemorySearch(b) => b.$method($($arg),*),
ToolCallBlock::Skill(b) => b.$method($($arg),*),
ToolCallBlock::Other(b) => b.$method($($arg),*),
ToolCallBlock::Lifecycle(b) => b.$method($($arg),*),
}
};
}
impl BlockContent for ToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
// Bullet prepending is handled by RenderBlock::output() via has_bullet().
delegate_tool!(self, output(ctx))
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
delegate_tool!(self, accent(ctx))
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
delegate_tool!(self, bullet(ctx))
}
fn accent_background(&self, ctx: &BlockContext) -> bool {
delegate_tool!(self, accent_background(ctx))
}
fn background(&self, ctx: &BlockContext) -> BlockBackground {
delegate_tool!(self, background(ctx))
}
fn has_vpad(&self, ctx: &BlockContext) -> bool {
delegate_tool!(self, has_vpad(ctx))
}
fn has_raw_mode(&self) -> bool {
delegate_tool!(self, has_raw_mode())
}
fn is_foldable(&self) -> bool {
delegate_tool!(self, is_foldable())
}
fn next_fold_mode(&self, current: DisplayMode, is_running: bool) -> DisplayMode {
delegate_tool!(self, next_fold_mode(current, is_running))
}
fn collapse_mode(&self, is_running: bool) -> DisplayMode {
delegate_tool!(self, collapse_mode(is_running))
}
fn default_display_mode(&self) -> DisplayMode {
delegate_tool!(self, default_display_mode())
}
fn finished_display_mode(&self) -> Option<DisplayMode> {
delegate_tool!(self, finished_display_mode())
}
fn is_selectable(&self) -> bool {
delegate_tool!(self, is_selectable())
}
fn has_bullet(&self, ctx: &BlockContext) -> bool {
ctx.appearance
.scrollback
.blocks
.tool
.bullet
.char()
.is_some()
}
fn is_groupable(&self) -> bool {
true
}
fn image_references(&self) -> &[crate::prompt_images::ScrollbackImageRef] {
delegate_tool!(self, image_references())
}
fn video_references(&self) -> &[crate::prompt_images::ScrollbackVideoRef] {
delegate_tool!(self, video_references())
}
fn inline_media(&self) -> Option<crate::prompt_images::InlineMediaInfo> {
delegate_tool!(self, inline_media())
}
fn inline_open_button(&self) -> Option<(std::path::PathBuf, bool)> {
delegate_tool!(self, inline_open_button())
}
fn preamble(&self, ctx: &BlockContext) -> Option<ratatui::text::Text<'static>> {
delegate_tool!(self, preamble(ctx))
}
}
impl ToolCallBlock {
/// Transfer timing data from another block of the same variant.
///
/// Used when a running block is replaced with its completed version
/// (e.g., in `handle_tool_call_update` completion path). The new block
/// inherits `started_at` from the old block so `finish()` can compute
/// real elapsed time.
pub fn transfer_timing_from(&mut self, old: &ToolCallBlock) {
match (self, old) {
(ToolCallBlock::Execute(new), ToolCallBlock::Execute(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::Read(new), ToolCallBlock::Read(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::Edit(new), ToolCallBlock::Edit(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::Search(new), ToolCallBlock::Search(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::ListDir(new), ToolCallBlock::ListDir(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::WebFetch(new), ToolCallBlock::WebFetch(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::WebSearch(new), ToolCallBlock::WebSearch(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::IntegrationSearch(new), ToolCallBlock::IntegrationSearch(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::UseTool(new), ToolCallBlock::UseTool(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::Skill(new), ToolCallBlock::Skill(old)) => {
new.started_at = old.started_at;
}
(ToolCallBlock::Other(new), ToolCallBlock::Other(old)) => {
new.started_at = old.started_at;
}
// Variant mismatch (shouldn't happen in practice) — skip.
_ => {}
}
}
/// Set `started_at` on the inner variant block.
///
/// Unlike `transfer_timing_from`, this works across variant boundaries
/// (e.g. setting `started_at` on a `Search` block from a value captured
/// when the block was still `Other`).
pub fn set_started_at(&mut self, instant: std::time::Instant) {
match self {
ToolCallBlock::Execute(b) => b.started_at = Some(instant),
ToolCallBlock::Read(b) => b.started_at = Some(instant),
ToolCallBlock::Edit(b) => b.started_at = Some(instant),
ToolCallBlock::Search(b) => b.started_at = Some(instant),
ToolCallBlock::ListDir(b) => b.started_at = Some(instant),
ToolCallBlock::WebFetch(b) => b.started_at = Some(instant),
ToolCallBlock::WebSearch(b) => b.started_at = Some(instant),
ToolCallBlock::IntegrationSearch(b) => b.started_at = Some(instant),
ToolCallBlock::UseTool(b) => b.started_at = Some(instant),
ToolCallBlock::MemorySearch(b) => b.started_at = Some(instant),
ToolCallBlock::Skill(b) => b.started_at = Some(instant),
ToolCallBlock::Other(b) => b.started_at = Some(instant),
// Lifecycle events have no timing.
ToolCallBlock::Lifecycle(_) => {}
}
}
/// Start timing for this block (sets `started_at = now`).
///
/// Called when a block enters running UI state. Only blocks that
/// actually run in the UI get meaningful timing. Pre-completed blocks
/// keep `started_at = None` and show no timing data.
pub fn start_timing(&mut self) {
match self {
ToolCallBlock::Execute(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::Read(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::Edit(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::Search(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::ListDir(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::WebFetch(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::WebSearch(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::IntegrationSearch(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::UseTool(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::MemorySearch(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::Skill(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
ToolCallBlock::Other(b) => {
if b.started_at.is_none() {
b.started_at = Some(std::time::Instant::now());
}
}
// Lifecycle events have no timing.
ToolCallBlock::Lifecycle(_) => {}
}
}
/// Create from tool name string (for parsing ACP tool calls).
pub fn from_name(name: &str, summary: impl Into<String>) -> Self {
match name.to_lowercase().as_str() {
"run_terminal_command" | "run_terminal_cmd" | "bash" | "shell" | "execute" => {
ToolCallBlock::Execute(ExecuteToolCallBlock::new(summary))
}
"read_file" | "read" => ToolCallBlock::Read(ReadToolCallBlock::new(summary)),
"search_replace" | "edit" | "apply_patch" | "strreplace" => {
ToolCallBlock::Edit(EditToolCallBlock::new(summary, Vec::new()))
}
"write" => ToolCallBlock::Edit(
EditToolCallBlock::new(summary, Vec::new()).with_prefix("Creating "),
),
"list_dir" | "ls" => ToolCallBlock::ListDir(ListDirToolCallBlock::new(summary)),
"grep" | "search" | "glob" => {
ToolCallBlock::Search(SearchToolCallBlock::new(summary.into()))
}
"web_fetch" | "fetch" => ToolCallBlock::WebFetch(WebFetchToolCallBlock::new(summary)),
"web_search" => ToolCallBlock::WebSearch(WebSearchToolCallBlock::new(summary)),
"search_tool" => {
ToolCallBlock::IntegrationSearch(IntegrationSearchToolCallBlock::new(summary))
}
"use_tool" => ToolCallBlock::UseTool(UseToolCallBlock::new(summary)),
"skill" => ToolCallBlock::Skill(OtherToolCallBlock::new("Skill", summary)),
_ => ToolCallBlock::Other(OtherToolCallBlock::new(name, summary)),
}
}
/// Full stored SOURCE text of this tool call for full-text scrollback
/// search.
///
/// Reads stored source fields and the `copy_text` accessors that read
/// source data — never lays out (`output()` / word-wrap) or
/// syntax-highlights — so indexing stays cheap.
pub(crate) fn searchable_text(&self) -> Option<String> {
match self {
ToolCallBlock::Execute(b) => join_searchable([
Some(b.command.clone()),
b.description.clone(),
b.output.clone(),
b.error.clone(),
]),
ToolCallBlock::Read(b) => {
join_searchable([Some(b.path.clone()), b.content.clone(), b.error.clone()])
}
ToolCallBlock::Edit(b) => join_searchable([Some(b.copy_text()), b.error.clone()]),
ToolCallBlock::ListDir(b) => join_searchable([
Some(b.path.clone()),
Some(b.output.clone()),
b.error.clone(),
]),
ToolCallBlock::Search(b) => {
// Each file group contributes its path plus every matched line.
let file_matches = join_searchable(b.file_matches.iter().flat_map(|fm| {
std::iter::once(Some(fm.path.clone()))
.chain(fm.matches.iter().map(|m| Some(m.content.clone())))
}));
let file_paths = join_searchable(b.file_paths.iter().cloned().map(Some));
join_searchable([
Some(b.pattern.clone()),
b.meta.path.clone(),
b.meta.glob.clone(),
b.meta.file_type.clone(),
file_paths,
file_matches,
b.error.clone(),
])
}
ToolCallBlock::WebFetch(b) => {
join_searchable([Some(b.url.clone()), b.output.clone(), b.error.clone()])
}
ToolCallBlock::WebSearch(b) => {
let citations = join_searchable(b.citations.iter().cloned().map(Some));
join_searchable([
Some(b.query.clone()),
b.content.clone(),
citations,
b.label.clone(),
b.error.clone(),
])
}
ToolCallBlock::IntegrationSearch(b) => {
join_searchable([Some(b.copy_text()), b.content.clone(), b.error.clone()])
}
ToolCallBlock::UseTool(b) => join_searchable([Some(b.copy_text()), b.error.clone()]),
ToolCallBlock::MemorySearch(b) => {
// Flatten each result's source, path, and snippet.
let results = join_searchable(b.results.iter().flat_map(|r| {
[
Some(r.source.clone()),
Some(r.path.clone()),
Some(r.snippet.clone()),
]
}));
join_searchable([Some(b.query.clone()), results, b.error.clone()])
}
ToolCallBlock::Skill(b) | ToolCallBlock::Other(b) => join_searchable([
Some(b.name.clone()),
Some(b.summary.clone()),
b.output.clone(),
b.error.clone(),
]),
ToolCallBlock::Lifecycle(b) => join_searchable([Some(b.name.clone())]),
}
}
/// Verb-group kind; `None` renders standalone and splits verb-group runs
/// (still dense-packs via `is_groupable`).
pub fn verb_group_kind(&self) -> Option<VerbGroupKind> {
match self {
ToolCallBlock::Read(b) => Some(if b.is_skill_read() {
VerbGroupKind::Skill
} else {
VerbGroupKind::File
}),
ToolCallBlock::ListDir(_) => Some(VerbGroupKind::Dir),
ToolCallBlock::Search(_) => Some(VerbGroupKind::Search),
ToolCallBlock::WebFetch(_) => Some(VerbGroupKind::WebFetch),
ToolCallBlock::WebSearch(_) => Some(VerbGroupKind::WebSearch),
ToolCallBlock::IntegrationSearch(_) => Some(VerbGroupKind::IntegrationSearch),
ToolCallBlock::MemorySearch(_) => Some(VerbGroupKind::MemorySearch),
ToolCallBlock::Skill(_) => Some(VerbGroupKind::Skill),
ToolCallBlock::Execute(_)
| ToolCallBlock::Edit(_)
| ToolCallBlock::UseTool(_)
| ToolCallBlock::Other(_)
| ToolCallBlock::Lifecycle(_) => None,
}
}
/// Bucket identity for aggregated header LABELS. Superset of
/// [`Self::verb_group_kind`]: the action kinds excluded from eager verb
/// folding still get a bucket when a truncation header describes the
/// rows it hides. `None` only for lifecycle chrome, which is never
/// worth labeling. Variants are listed explicitly so a new
/// `ToolCallBlock` variant must decide here too.
pub fn label_kind(&self) -> Option<VerbGroupKind> {
match self {
ToolCallBlock::Execute(_) => Some(VerbGroupKind::Command),
ToolCallBlock::Edit(_) => Some(VerbGroupKind::EditFile),
ToolCallBlock::UseTool(_) => Some(VerbGroupKind::McpCall),
ToolCallBlock::Other(_) => Some(VerbGroupKind::OtherTool),
ToolCallBlock::Lifecycle(_) => None,
ToolCallBlock::Read(_)
| ToolCallBlock::ListDir(_)
| ToolCallBlock::Search(_)
| ToolCallBlock::WebFetch(_)
| ToolCallBlock::WebSearch(_)
| ToolCallBlock::IntegrationSearch(_)
| ToolCallBlock::MemorySearch(_)
| ToolCallBlock::Skill(_) => self.verb_group_kind(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verb_is_tense_aware() {
assert_eq!(VerbGroupKind::File.verb(false), "Read");
assert_eq!(VerbGroupKind::File.verb(true), "Reading");
assert_eq!(VerbGroupKind::Skill.verb(false), "Read");
assert_eq!(VerbGroupKind::Search.verb(false), "Searched");
assert_eq!(VerbGroupKind::Search.verb(true), "Searching");
assert_eq!(VerbGroupKind::Dir.verb(false), "Listed");
assert_eq!(VerbGroupKind::Dir.verb(true), "Listing");
assert_eq!(VerbGroupKind::WebFetch.verb(false), "Fetched");
assert_eq!(VerbGroupKind::WebFetch.verb(true), "Fetching");
assert_eq!(VerbGroupKind::WebSearch.verb(false), "Searched");
assert_eq!(VerbGroupKind::MemorySearch.verb(false), "Searched");
assert_eq!(VerbGroupKind::IntegrationSearch.verb(true), "Searching");
assert_eq!(VerbGroupKind::Subagent.verb(false), "Ran");
assert_eq!(VerbGroupKind::Subagent.verb(true), "Running");
assert_eq!(VerbGroupKind::Command.verb(false), "Ran");
assert_eq!(VerbGroupKind::Command.verb(true), "Running");
assert_eq!(VerbGroupKind::EditFile.verb(false), "Edited");
assert_eq!(VerbGroupKind::EditFile.verb(true), "Editing");
assert_eq!(VerbGroupKind::McpCall.verb(false), "Called");
assert_eq!(VerbGroupKind::McpCall.verb(true), "Calling");
assert_eq!(VerbGroupKind::OtherTool.verb(false), "Ran");
}
#[test]
fn noun_pluralizes_by_count() {
assert_eq!(VerbGroupKind::File.noun(1), "file");
assert_eq!(VerbGroupKind::File.noun(2), "files");
assert_eq!(VerbGroupKind::Skill.noun(2), "skills");
assert_eq!(VerbGroupKind::Search.noun(1), "pattern");
assert_eq!(VerbGroupKind::Dir.noun(2), "dirs");
assert_eq!(VerbGroupKind::WebFetch.noun(1), "website");
assert_eq!(VerbGroupKind::WebSearch.noun(2), "websites");
// Irregular plural.
assert_eq!(VerbGroupKind::MemorySearch.noun(1), "memory");
assert_eq!(VerbGroupKind::MemorySearch.noun(2), "memories");
assert_eq!(VerbGroupKind::IntegrationSearch.noun(1), "MCP tool");
assert_eq!(VerbGroupKind::IntegrationSearch.noun(2), "MCP tools");
assert_eq!(VerbGroupKind::Subagent.noun(1), "subagent");
assert_eq!(VerbGroupKind::Subagent.noun(2), "subagents");
assert_eq!(VerbGroupKind::Command.noun(1), "command");
assert_eq!(VerbGroupKind::Command.noun(2), "commands");
assert_eq!(VerbGroupKind::EditFile.noun(2), "files");
assert_eq!(VerbGroupKind::McpCall.noun(1), "MCP tool");
assert_eq!(VerbGroupKind::OtherTool.noun(1), "tool");
assert_eq!(VerbGroupKind::OtherTool.noun(2), "tools");
}
#[test]
fn every_variant_has_a_group_decision() {
let blocks = [
ToolCallBlock::Execute(ExecuteToolCallBlock::new("ls")),
ToolCallBlock::Read(ReadToolCallBlock::new("src/main.rs")),
ToolCallBlock::Read(ReadToolCallBlock::new("/x/skills/deploy/SKILL.md")),
ToolCallBlock::Edit(EditToolCallBlock::new("src/main.rs", Vec::new())),
ToolCallBlock::ListDir(ListDirToolCallBlock::new("src")),
ToolCallBlock::Search(SearchToolCallBlock::new("todo")),
ToolCallBlock::WebFetch(WebFetchToolCallBlock::new("https://example.com")),
ToolCallBlock::WebSearch(WebSearchToolCallBlock::new("grok")),
ToolCallBlock::IntegrationSearch(IntegrationSearchToolCallBlock::new("linear")),
ToolCallBlock::UseTool(UseToolCallBlock::new("linear__save_issue")),
ToolCallBlock::MemorySearch(MemorySearchToolCallBlock::new("auth")),
ToolCallBlock::Skill(OtherToolCallBlock::new("Skill", "deploy")),
ToolCallBlock::Other(OtherToolCallBlock::new("todo_write", "update")),
ToolCallBlock::Lifecycle(LifecycleEventBlock::new("session_start")),
];
for block in &blocks {
// Exhaustive on purpose: a new variant fails compilation here
// until it gets an explicit verb-grouping decision.
let expected = match block {
ToolCallBlock::Read(b) if b.is_skill_read() => Some(VerbGroupKind::Skill),
ToolCallBlock::Read(_) => Some(VerbGroupKind::File),
ToolCallBlock::ListDir(_) => Some(VerbGroupKind::Dir),
ToolCallBlock::Search(_) => Some(VerbGroupKind::Search),
ToolCallBlock::WebFetch(_) => Some(VerbGroupKind::WebFetch),
ToolCallBlock::WebSearch(_) => Some(VerbGroupKind::WebSearch),
ToolCallBlock::IntegrationSearch(_) => Some(VerbGroupKind::IntegrationSearch),
ToolCallBlock::MemorySearch(_) => Some(VerbGroupKind::MemorySearch),
ToolCallBlock::Skill(_) => Some(VerbGroupKind::Skill),
ToolCallBlock::Execute(_)
| ToolCallBlock::Edit(_)
| ToolCallBlock::UseTool(_)
| ToolCallBlock::Other(_)
| ToolCallBlock::Lifecycle(_) => None,
};
assert_eq!(block.verb_group_kind(), expected, "block: {block:?}");
}
}
#[test]
fn label_kind_extends_verb_kinds_to_action_tools() {
assert_eq!(
ToolCallBlock::Execute(ExecuteToolCallBlock::new("ls")).label_kind(),
Some(VerbGroupKind::Command)
);
assert_eq!(
ToolCallBlock::Edit(EditToolCallBlock::new("src/main.rs", Vec::new())).label_kind(),
Some(VerbGroupKind::EditFile)
);
assert_eq!(
ToolCallBlock::UseTool(UseToolCallBlock::new("linear__save_issue")).label_kind(),
Some(VerbGroupKind::McpCall)
);
assert_eq!(
ToolCallBlock::Other(OtherToolCallBlock::new("todo_write", "update")).label_kind(),
Some(VerbGroupKind::OtherTool)
);
assert_eq!(
ToolCallBlock::Lifecycle(LifecycleEventBlock::new("session_start")).label_kind(),
None
);
// Verb-groupable kinds defer to the fold's own classification.
assert_eq!(
ToolCallBlock::Read(ReadToolCallBlock::new("src/main.rs")).label_kind(),
Some(VerbGroupKind::File)
);
assert_eq!(
ToolCallBlock::Read(ReadToolCallBlock::new("/x/skills/deploy/SKILL.md")).label_kind(),
Some(VerbGroupKind::Skill)
);
}
}
@@ -0,0 +1,543 @@
//! OtherToolCallBlock - unknown/generic tool types.
use ratatui::text::{Line, Span};
use crate::render::wrapping::word_wrap_lines;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode,
};
use crate::theme::Theme;
/// Other/unknown tool call.
#[derive(Debug, Clone)]
pub struct OtherToolCallBlock {
/// Tool name.
pub name: String,
/// Summary/target.
pub summary: String,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// Optional output.
pub output: Option<String>,
/// When the tool started running (Phase 2: time tracking).
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion (Phase 2: time tracking).
pub elapsed_ms: Option<i64>,
/// Image references detected in the tool output.
image_refs: Vec<crate::prompt_images::ScrollbackImageRef>,
/// Video references detected in the tool output.
video_refs: Vec<crate::prompt_images::ScrollbackVideoRef>,
}
impl OtherToolCallBlock {
/// Create a new other tool block.
///
/// Pre-completed blocks have no meaningful local timing — `started_at`
/// is `None`. Timing is only set for blocks that enter a running UI
/// state (via `set_last_running(true)` in `ScrollbackState`).
pub fn new(name: impl Into<String>, summary: impl Into<String>) -> Self {
Self {
name: name.into(),
summary: summary.into(),
error: None,
output: None,
started_at: None,
elapsed_ms: None,
image_refs: Vec::new(),
video_refs: Vec::new(),
}
}
/// Set error (marks as failed).
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
/// Set output (builder).
pub fn with_output(mut self, output: impl Into<String>) -> Self {
self.set_output_text(output.into());
self
}
/// Set or replace the output text.
pub fn set_output_text(&mut self, text: String) {
self.image_refs = crate::prompt_images::extract_image_refs(&text);
self.video_refs = crate::prompt_images::extract_video_refs(&text);
self.output = Some(text);
}
/// Set the media reference from a typed path (no prose scraping).
/// `from_path` validates the file and normalizes `\\?\`; an unresolvable
/// path is a no-op.
pub fn with_media_ref(mut self, path: impl Into<std::path::PathBuf>, is_video: bool) -> Self {
let path = path.into();
if is_video {
if let Some(r) = crate::prompt_images::ScrollbackVideoRef::from_path(path) {
self.video_refs = vec![r];
}
} else if let Some(r) = crate::prompt_images::ScrollbackImageRef::from_path(path) {
self.image_refs = vec![r];
}
self
}
/// Check if successful (no error).
pub fn is_success(&self) -> bool {
self.error.is_none()
}
/// Path of the first media reference (image or video) for the filepath
/// line of an inline-media block, independent of inline-graphics support.
pub(crate) fn media_ref_path(&self) -> Option<std::path::PathBuf> {
if let Some(img) = self.image_refs.first() {
return Some(img.path.clone());
}
if let Some(vid) = self.video_refs.first() {
return Some(vid.path.clone());
}
None
}
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
self.error = error;
}
/// Finalize elapsed time from `started_at`.
///
/// Idempotent: no-op if `started_at` is `None` (pre-completed block)
/// or if `elapsed_ms` is already set (already finalized).
pub fn finish(&mut self) {
if self.elapsed_ms.is_some() {
return;
}
if let Some(start) = self.started_at {
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
}
/// Get elapsed time in ms (Phase 2).
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
None => self
.started_at
.map(|start| start.elapsed().as_millis() as i64),
}
}
/// Render collapsed line: **`Label`** `content` or **`Name`**.
///
/// If the name contains `: `, splits into bold label + muted/primary content
/// (e.g. "Ask: What is your favorite language?"). Otherwise renders
/// the full name in bold.
///
/// When `muted` is true (collapsed state), all text uses dim styles to
/// match other collapsed blocks. The label ("Ask") stays bold.
fn collapsed_line(&self, theme: &Theme, muted: bool, width: Option<usize>) -> Line<'static> {
let text_style = if muted {
theme.muted()
} else {
theme.primary()
};
let bold_style = text_style.add_modifier(ratatui::style::Modifier::BOLD);
let mut spans = if let Some((label, content)) = self.name.split_once(": ") {
vec![
Span::styled(format!("{} ", label), bold_style),
Span::styled(content.to_string(), text_style),
]
} else {
vec![Span::styled(self.name.clone(), bold_style)]
};
if !self.summary.is_empty() {
if let Some(w) = width {
// Only include summary if there's room.
let used: usize = spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum();
let summary = format!(" {}", self.summary);
if used + summary.len() <= w {
spans.push(Span::styled(summary, theme.muted()));
}
} else {
spans.push(Span::styled(format!(" {}", self.summary), theme.muted()));
}
}
let line = Line::from(spans);
if let Some(w) = width {
crate::render::line_utils::truncate_line(line, w)
} else {
line
}
}
}
impl BlockContent for OtherToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let width = ctx.width as usize;
let muted_collapsed =
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
// Inline media blocks (image_gen / video_gen): render the header and a
// filepath line on every terminal.
if let Some(media_path) = self.media_ref_path() {
let header = self.collapsed_line(&theme, muted_collapsed, Some(ctx.content_width()));
let max_w = ctx.content_width();
// Percent-decode for display only (e.g. `%2F` → `/`); the stored
// path is unchanged so Open / copy-path still target the file.
let raw_path = media_path.display().to_string();
let path_str = urlencoding::decode(&raw_path)
.map(|s| s.into_owned())
.unwrap_or(raw_path);
// Char-boundary middle-ellipsis (decoded paths may be multibyte).
let path_display = if path_str.chars().count() > max_w {
let keep = max_w.saturating_sub(3) / 2;
let end_keep = max_w.saturating_sub(3) - keep;
let chars: Vec<char> = path_str.chars().collect();
let head: String = chars[..keep].iter().collect();
let tail: String = chars[chars.len() - end_keep..].iter().collect();
format!("{head}...{tail}")
} else {
path_str
};
let path_line = Line::from(Span::styled(
path_display,
ratatui::style::Style::default().fg(theme.gray_dim),
));
let mut lines: Vec<BlockLine> = vec![header.into(), path_line.into()];
// No inline graphics: centered "[Open]" button between blank
// spacers (its click target is registered in render.rs).
if let Some((_, is_video)) = self.inline_open_button() {
let label = crate::scrollback::render::media_open_button_label(is_video);
let col = crate::scrollback::render::media_open_button_col(
ctx.content_width() as u16,
is_video,
);
let open_line = Line::from(vec![
Span::raw(" ".repeat(col as usize)),
Span::styled(
label.to_string(),
ratatui::style::Style::default()
.fg(theme.md_code)
.add_modifier(ratatui::style::Modifier::BOLD),
),
]);
lines.push(Line::from("").into());
lines.push(open_line.into());
lines.push(Line::from("").into());
}
return BlockOutput { lines };
}
match ctx.mode {
DisplayMode::Collapsed => BlockOutput {
lines: vec![
self.collapsed_line(&theme, muted_collapsed, Some(ctx.content_width()))
.into(),
],
},
DisplayMode::Truncated | DisplayMode::Expanded => {
let mut lines: Vec<BlockLine> =
vec![self.collapsed_line(&theme, false, None).into()];
if let Some(output) = &self.output {
// Try to render as structured Q&A (AskUserQuestion output).
let qa_lines = parse_ask_user_qa_pairs(output);
if !qa_lines.is_empty() {
for (i, (question, answer)) in qa_lines.iter().enumerate() {
// " 1. question text"
let q_line = Line::from(vec![
Span::styled(format!(" {}. ", i + 1), theme.muted()),
Span::styled(question.clone(), theme.primary()),
]);
lines.push(BlockLine::styled(q_line));
// " → answer" or " (no answer)"
let a_line = if answer.is_empty() {
Line::from(Span::styled(
" (no answer)".to_string(),
theme.dim(),
))
} else {
Line::from(vec![
Span::styled(
" \u{2192} ".to_string(),
theme.fg(theme.accent_user),
),
Span::styled(answer.clone(), theme.fg(theme.accent_user)),
])
};
lines.push(BlockLine::styled(a_line));
}
} else {
// Generic output rendering (non-Q&A tools).
lines.push(Line::from("").into());
let styled_lines: Vec<Line<'static>> = output
.lines()
.map(|line| Line::from(Span::styled(line.to_string(), theme.muted())))
.collect();
let wrapped =
word_wrap_lines(styled_lines, width.saturating_sub(2).max(20));
for wrapped_line in wrapped {
lines.push(BlockLine::styled(wrapped_line));
}
}
}
BlockOutput { lines }
}
}
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
// No accent when collapsed — keeps accents reserved for Execute blocks in dense groups
if ctx.mode == DisplayMode::Collapsed {
return None;
}
let theme = Theme::current();
if self.error.is_some() {
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.is_running {
Some(AccentStyle::animated(theme.accent_running))
} else {
Some(AccentStyle::static_color(theme.accent_tool))
}
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
// Failed: red bullet. Running/expanded: accent color. Collapsed: default.
if self.error.is_some() {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.mode == DisplayMode::Collapsed {
None // default gray
} else {
self.accent(ctx) // inherit from accent when expanded/running
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
// Not foldable if failed
if self.error.is_some() {
return false;
}
self.output.is_some()
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn next_fold_mode(&self, current: DisplayMode, is_running: bool) -> DisplayMode {
if is_running {
match current {
DisplayMode::Truncated => DisplayMode::Expanded,
_ => DisplayMode::Truncated,
}
} else {
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
_ => DisplayMode::Collapsed,
}
}
}
fn collapse_mode(&self, is_running: bool) -> DisplayMode {
if is_running {
DisplayMode::Truncated
} else {
DisplayMode::Collapsed
}
}
fn image_references(&self) -> &[crate::prompt_images::ScrollbackImageRef] {
&self.image_refs
}
fn video_references(&self) -> &[crate::prompt_images::ScrollbackVideoRef] {
&self.video_refs
}
fn inline_media(&self) -> Option<crate::prompt_images::InlineMediaInfo> {
if let Some(img) = self.image_refs.first() {
let (w, h) = img.dimensions?;
return Some(crate::prompt_images::InlineMediaInfo {
path: img.path.clone(),
width: w,
height: h,
is_video: false,
alt_text: img.alt_text.clone(),
});
}
if let Some(vid) = self.video_refs.first() {
return Some(crate::prompt_images::InlineMediaInfo {
path: vid.path.clone(),
width: 1280,
height: 720,
is_video: true,
alt_text: vid.alt_text.clone(),
});
}
None
}
fn inline_open_button(&self) -> Option<(std::path::PathBuf, bool)> {
// Only used when there is no inline-graphics overlay to host the button
// row. When the overlay is active it draws its own button row instead.
if crate::terminal::image::scrollback_inline_overlay_active() {
return None;
}
if let Some(img) = self.image_refs.first() {
return Some((img.path.clone(), false));
}
if let Some(vid) = self.video_refs.first() {
return Some((vid.path.clone(), true));
}
None
}
}
// ── AskUserQuestion output parser ────────────────────────────────────
/// Parse Q&A pairs from an AskUserQuestion tool result string.
///
/// Recognizes all three accepted output formats:
///
/// **Path A (accepted):** `User has answered your questions: "Q1"="A1", "Q2"="A2". You can now...`
/// **Path D (cancelled):** `User declined to answer...`
/// **Paths B/C (plan mode):** `- "Q1"\n Answer: A1\n- "Q2"\n (No answer provided)`
///
/// Returns `Vec<(question, answer)>`. Empty vec means the output is not a
/// recognized Q&A format and should be rendered generically.
fn parse_ask_user_qa_pairs(output: &str) -> Vec<(String, String)> {
// Path A: "User has answered your questions: "Q"="A", "Q"="A". You can now..."
if let Some(rest) = output.strip_prefix("User has answered your questions: ") {
// Strip the trailing ". You can now continue with the user's answers in mind."
let body = rest
.strip_suffix(". You can now continue with the user's answers in mind.")
.unwrap_or(rest);
if body.is_empty() {
return vec![];
}
// Parse "Q1"="A1", "Q2"="A2" pairs.
// Split on `", "` that appears between pairs (after `"="value"`).
let mut pairs = Vec::new();
let mut remaining = body;
while !remaining.is_empty() {
// Expect: "question"="answer" [optional annotations...]
if !remaining.starts_with('"') {
break;
}
remaining = &remaining[1..]; // skip opening "
// Find the closing " before =
let Some(q_end) = remaining.find("\"=\"") else {
break;
};
let question = remaining[..q_end].to_string();
remaining = &remaining[q_end + 3..]; // skip "="
// Find the end of the answer: next `", "` pair start or end of string.
// The answer value continues until we hit `, "` (next pair) or end.
let answer_end = remaining.find(", \"").unwrap_or(remaining.len());
let mut answer_text = remaining[..answer_end].to_string();
// Strip trailing quote if present (answer is quoted)
if answer_text.ends_with('"') {
answer_text.pop();
}
// Remove annotation suffixes (selected preview:..., user notes:...)
// for display — keep just the label.
if let Some(ann_start) = answer_text.find(" selected preview:") {
answer_text.truncate(ann_start);
}
if let Some(ann_start) = answer_text.find(" user notes:") {
answer_text.truncate(ann_start);
}
pairs.push((question, answer_text));
// Advance past the separator
remaining = &remaining[answer_end..];
if remaining.starts_with(", ") {
remaining = &remaining[2..];
}
}
return pairs;
}
// Path D: cancelled
if output.starts_with("User declined to answer") {
return vec![]; // No Q&A to show
}
// Paths B/C: plan mode — bullet format
// - "Q1"\n Answer: A1\n- "Q2"\n (No answer provided)
if output.contains("Questions asked") && output.contains("- \"") {
let mut pairs = Vec::new();
let lines: Vec<&str> = output.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim_start_matches([' ', '-']).trim();
// Check for "question text"
if line.starts_with('"') && line.ends_with('"') {
let question = line[1..line.len() - 1].to_string();
let answer = if i + 1 < lines.len() {
let next = lines[i + 1].trim();
if let Some(a) = next.strip_prefix("Answer: ") {
i += 1;
a.to_string()
} else if next == "(No answer provided)" {
i += 1;
String::new()
} else {
String::new()
}
} else {
String::new()
};
pairs.push((question, answer));
}
i += 1;
}
if !pairs.is_empty() {
return pairs;
}
}
vec![]
}
@@ -0,0 +1,748 @@
//! ReadToolCallBlock - reads a file with syntax highlighting.
use std::path::Path;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use super::{LineRange, TOOL_HEADER_RANGE};
use crate::prompt_images::ScrollbackImageRef;
use crate::render::wrapping::word_wrap_lines_with_joiners;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
};
use crate::syntax::get_syntect;
use crate::theme::Theme;
const FIRST_LINES: usize = 5;
const LAST_LINES: usize = 3;
use kigi_tools::implementations::skills::types::skill_name_from_path;
/// What kind of non-text media this read produced.
#[derive(Debug, Clone)]
pub enum ReadMediaKind {
/// Image file (PNG, JPEG, etc.)
Image,
/// PDF rendered as page images.
Pdf { pages: usize },
}
/// Read file tool call.
#[derive(Debug, Clone)]
pub struct ReadToolCallBlock {
/// Path to the file being read.
pub path: String,
/// Line range if specified: [start, end] (1-based, inclusive).
pub line_range: Option<LineRange>,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// When the tool started running (Phase 2: time tracking).
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion (Phase 2: time tracking).
pub elapsed_ms: Option<i64>,
/// Raw file content (unformatted). `None` for errors, images, PDFs.
pub content: Option<String>,
/// Total number of lines in the file (from `FileContent.total_lines`).
pub total_lines: Option<usize>,
/// Inline image reference (for image file reads).
pub image_ref: Option<ScrollbackImageRef>,
/// Non-text media kind (image, PDF).
pub media_kind: Option<ReadMediaKind>,
}
impl ReadToolCallBlock {
/// Create a new read block.
///
/// Pre-completed blocks have no meaningful local timing — `started_at`
/// is `None`. Timing is only set for blocks that enter a running UI
/// state (via `set_last_running(true)` in `ScrollbackState`).
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
line_range: None,
error: None,
started_at: None,
elapsed_ms: None,
content: None,
total_lines: None,
image_ref: None,
media_kind: None,
}
}
/// Set line range.
pub fn with_line_range(mut self, range: LineRange) -> Self {
self.line_range = Some(range);
self
}
/// Set file content and total line count.
pub fn with_content(mut self, content: String, total_lines: usize) -> Self {
self.content = Some(content);
self.total_lines = Some(total_lines);
self
}
/// Set error (marks as failed).
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
/// Check if successful (no error).
pub fn is_success(&self) -> bool {
self.error.is_none()
}
/// Whether the block has non-empty text content to display.
pub fn has_content(&self) -> bool {
self.content.as_ref().is_some_and(|c| !c.is_empty())
}
/// Skill name when this read targets a skill definition (`SKILL.md`).
/// Single source of truth for skill-read detection.
pub fn skill_name(&self) -> Option<&str> {
skill_name_from_path(&self.path)
}
/// Whether this read targets a skill definition rather than a plain file.
pub fn is_skill_read(&self) -> bool {
self.skill_name().is_some()
}
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
self.error = error;
}
/// Finalize elapsed time from `started_at`.
///
/// Idempotent: no-op if `started_at` is `None` (pre-completed block)
/// or if `elapsed_ms` is already set (already finalized).
pub fn finish(&mut self) {
if self.elapsed_ms.is_some() {
return;
}
if let Some(start) = self.started_at {
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
}
/// Get elapsed time in ms (Phase 2).
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
None => self
.started_at
.map(|start| start.elapsed().as_millis() as i64),
}
}
/// Render header line: `Read path (start-end)`.
fn collapsed_line(
&self,
theme: &Theme,
muted: bool,
dim_details: bool,
surface: crate::render::tool_paths::ToolPathSurface,
cwd: Option<&std::path::Path>,
width: Option<usize>,
) -> Line<'static> {
let text_style = if muted {
theme.muted()
} else {
theme.primary()
};
let bold_style = text_style.add_modifier(Modifier::BOLD);
let path_style = if muted {
theme.muted()
} else {
theme.fg(theme.path)
};
let detail_style = if dim_details {
theme.dim()
} else {
theme.muted()
};
// SKILL.md reads render as "Skill {skill_name}".
if let Some(skill) = self.skill_name() {
return Line::from(vec![
Span::styled("Skill ", bold_style),
Span::styled(skill.to_owned(), path_style),
]);
}
let prefix = "Read ";
let range_suffix = self
.line_range
.map(|r| {
if let Some(total) = self.total_lines
&& total > r.end.saturating_sub(r.start) + 1
{
format!(" ({} of {total})", r)
} else {
format!(" ({})", r)
}
})
.unwrap_or_default();
// Extra suffix for errors or empty content
let extra_suffix = if self.content.as_ref().is_some_and(|c| c.is_empty()) {
" (empty)".to_string()
} else if let Some(media) = &self.media_kind {
match media {
ReadMediaKind::Image => " (image)".to_string(),
ReadMediaKind::Pdf { pages } => format!(" ({pages} pages)"),
}
} else {
String::new()
};
let total_suffix_len = range_suffix.len() + extra_suffix.len();
let path = crate::render::tool_paths::path_for_tool_surface(
&self.path,
surface,
cwd,
width,
prefix.len() + total_suffix_len,
);
let mut spans = vec![
Span::styled(prefix, bold_style),
Span::styled(path, path_style),
];
if !range_suffix.is_empty() {
spans.push(Span::styled(range_suffix, detail_style));
}
if !extra_suffix.is_empty() {
spans.push(Span::styled(extra_suffix, detail_style));
}
Line::from(spans)
}
/// Header line with only the path (or skill name) span selectable.
///
/// Spans: `["Read ", path, optional_range_suffix, optional_extra_suffix]`
/// or `["Skill ", skill_name]`. Prefix/suffixes excluded (no `selection_text`
/// override). Sets absolute `file://` `link_url` for non-skill paths.
fn header_block_line(&self, line: Line<'static>, cwd: Option<&std::path::Path>) -> BlockLine {
let path_end = 2.min(line.spans.len()).max(1);
let link_url = if self.skill_name().is_some() {
None
} else {
crate::render::osc8::tool_path_file_url(&self.path, cwd)
};
BlockLine {
selectable: Selectable::Spans(1..path_end),
selection_range: Some(TOOL_HEADER_RANGE),
content: line,
link_url,
..Default::default()
}
}
/// Render content lines with absolute line numbers in the gutter.
///
/// Wraps all lines first, then applies truncation -- matching ExecuteToolCallBlock.
fn render_content_lines(
&self,
theme: &Theme,
width: usize,
truncate: Option<(usize, usize)>,
) -> Vec<BlockLine> {
let Some(content) = &self.content else {
return Vec::new();
};
let base_line = self.line_range.map_or(1, |r| r.start);
let raw_lines: Vec<&str> = content.lines().collect();
let gutter_width = digit_count(base_line + raw_lines.len().saturating_sub(1));
let content_width = width.saturating_sub(gutter_width + 2).max(20);
let gutter_style = Style::default().fg(theme.gray_dim);
let text_style = theme.primary();
let syntect = get_syntect();
let mut highlighter = syntect.highlight_lines_by_file_path(Path::new(&self.path));
let styled_lines: Vec<Line<'static>> = raw_lines
.iter()
.enumerate()
.map(|(i, text)| {
let gutter = format!("{:>w$} ", base_line + i, w = gutter_width);
let mut spans = vec![Span::styled(gutter, gutter_style)];
spans.extend(crate::syntax::highlight_line(
text,
&mut highlighter,
syntect,
text_style,
));
Line::from(spans)
})
.collect();
let (wrapped, joiners) = word_wrap_lines_with_joiners(styled_lines, content_width);
let total = wrapped.len();
let mut lines = Vec::new();
if let Some((first, last)) = truncate {
let threshold = first + last;
if total > threshold {
for (wrapped_line, joiner) in wrapped.iter().zip(joiners.iter()).take(first) {
lines.push(
BlockLine::styled(wrapped_line.clone())
.with_panel_background(theme.bg_dark)
.with_joiner(joiner.clone()),
);
}
lines.push(
BlockLine::separator(Line::from(Span::styled("\u{2026}", theme.muted())))
.with_panel_background(theme.bg_dark),
);
for (wrapped_line, joiner) in wrapped.iter().zip(joiners.iter()).skip(total - last)
{
lines.push(
BlockLine::styled(wrapped_line.clone())
.with_panel_background(theme.bg_dark)
.with_joiner(joiner.clone()),
);
}
} else {
for (wrapped_line, joiner) in wrapped.into_iter().zip(joiners) {
lines.push(
BlockLine::styled(wrapped_line)
.with_panel_background(theme.bg_dark)
.with_joiner(joiner),
);
}
}
} else {
for (wrapped_line, joiner) in wrapped.into_iter().zip(joiners) {
lines.push(
BlockLine::styled(wrapped_line)
.with_panel_background(theme.bg_dark)
.with_joiner(joiner),
);
}
}
lines
}
}
/// Count decimal digits in a number (for gutter width).
fn digit_count(n: usize) -> usize {
n.checked_ilog10().map_or(1, |d| d as usize + 1)
}
impl BlockContent for ReadToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let tool_cfg = &ctx.appearance.scrollback.blocks.tool;
let muted_collapsed = ctx.mute_when_collapsed(tool_cfg.muted_collapsed);
let cwd = ctx.cwd.as_deref();
match ctx.mode {
DisplayMode::Collapsed => BlockOutput {
lines: vec![self.header_block_line(
self.collapsed_line(
&theme,
muted_collapsed,
tool_cfg.dim_details,
crate::render::tool_paths::ToolPathSurface::Collapsed,
cwd,
Some(ctx.content_width()),
),
cwd,
)],
},
DisplayMode::Truncated | DisplayMode::Expanded => {
let truncate = if ctx.mode == DisplayMode::Truncated {
Some((FIRST_LINES, LAST_LINES))
} else {
None
};
let header = self.collapsed_line(
&theme,
false,
tool_cfg.dim_details,
crate::render::tool_paths::ToolPathSurface::Expanded,
cwd,
None,
);
let mut lines: Vec<BlockLine> = vec![self.header_block_line(header, cwd)];
if self.has_content() {
lines.push(BlockLine::separator(Line::from("")));
lines.extend(self.render_content_lines(&theme, ctx.width as usize, truncate));
} else if let Some(err) = &self.error {
lines.push(BlockLine::separator(Line::from("")));
let error_style = Style::default().fg(theme.accent_error);
for line in err.lines() {
lines.push(BlockLine::separator(Line::from(Span::styled(
line.to_string(),
error_style,
))));
}
}
BlockOutput { lines }
}
}
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None
}
fn bullet(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
if self.error.is_some() {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else {
None
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
self.has_content()
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn finished_display_mode(&self) -> Option<DisplayMode> {
Some(DisplayMode::Collapsed)
}
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
match current {
DisplayMode::Collapsed => DisplayMode::Truncated,
DisplayMode::Truncated | DisplayMode::Expanded => DisplayMode::Collapsed,
}
}
fn image_references(&self) -> &[ScrollbackImageRef] {
match &self.image_ref {
Some(r) => std::slice::from_ref(r),
None => &[],
}
}
fn preamble(&self, ctx: &BlockContext) -> Option<Text<'static>> {
let theme = Theme::current();
let dim_details = ctx.appearance.scrollback.blocks.tool.dim_details;
Some(Text::from(self.collapsed_line(
&theme,
false,
dim_details,
crate::render::tool_paths::ToolPathSurface::Fullscreen,
ctx.cwd.as_deref(),
None,
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scrollback::types::{BlockContext, DisplayMode};
fn make_ctx() -> BlockContext {
BlockContext {
width: 80,
mode: DisplayMode::Collapsed,
is_running: false,
raw: false,
max_lines: None,
appearance: Default::default(),
is_selected: false,
cwd: None,
}
}
#[test]
fn skill_md_renders_as_skill_label() {
let block = ReadToolCallBlock::new("/home/user/.kigi/skills/deploy/SKILL.md");
let output = block.output(&make_ctx());
let text: String = output.lines[0]
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(text, "Skill deploy");
}
#[test]
fn regular_file_renders_as_read() {
let block = ReadToolCallBlock::new("src/main.rs");
let output = block.output(&make_ctx());
let text: String = output.lines[0]
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
text.starts_with("Read "),
"expected 'Read ...' got '{text}'"
);
assert!(text.contains("main.rs"));
}
#[test]
fn collapsed_header_shows_basename_only() {
let block = ReadToolCallBlock::new("/Users/me/project/src/main.rs")
.with_line_range(LineRange::new(1, 10));
let output = block.output(&make_ctx());
let text: String = output.lines[0]
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(text, "Read main.rs (1-10)");
}
#[test]
fn expanded_shows_relative_when_under_cwd_preamble_absolute() {
let abs = "/Users/me/project/src/main.rs";
let cwd = std::path::PathBuf::from("/Users/me/project");
let block = ReadToolCallBlock::new(abs).with_content("hello".into(), 1);
let mut ctx = make_ctx();
ctx.mode = DisplayMode::Expanded;
ctx.cwd = Some(cwd.clone());
let output = block.output(&ctx);
let header: String = output.lines[0]
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(header, "Read src/main.rs");
let preamble = block.preamble(&ctx).unwrap();
let preamble_text: String = preamble
.lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert_eq!(preamble_text, "Read /Users/me/project/src/main.rs");
}
#[test]
fn content_preview_shading_is_marked_panel() {
// The preview's bg_dark band is decorative chrome, not semantic
// shading — it must be flagged `background_is_panel` so minimal
// mode's flat rendering can drop it (EntryRenderer::flat_background).
let block = ReadToolCallBlock::new("notes.txt").with_content("alpha\nbravo".to_string(), 2);
let mut ctx = make_ctx();
ctx.mode = DisplayMode::Expanded;
let output = block.output(&ctx);
let shaded: Vec<_> = output
.lines
.iter()
.filter(|l| l.background.is_some())
.collect();
assert!(
!shaded.is_empty(),
"expanded read preview must shade its content lines"
);
assert!(
shaded.iter().all(|l| l.background_is_panel),
"read preview shading must be marked panel"
);
}
#[test]
fn header_only_path_is_selectable() {
use crate::scrollback::types::{Selectable, derive_selection_text};
let block = ReadToolCallBlock::new("/Users/me/project/src/main.rs")
.with_line_range(LineRange::new(1, 10));
let output = block.output(&make_ctx());
let header = &output.lines[0];
assert!(
matches!(&header.selectable, Selectable::Spans(r) if *r == (1..2)),
"only path span should be selectable, got {:?}",
header.selectable
);
// Collapsed: copy the painted basename, not a full-path override.
assert_eq!(
derive_selection_text(header),
"main.rs",
"copy/highlight should match the painted path span, not 'Read …'"
);
assert_eq!(header.content.spans[0].content.as_ref(), "Read ");
assert!(header.selection_text.is_none());
}
#[test]
fn expanded_header_selection_matches_relative_path() {
use crate::scrollback::types::derive_selection_text;
let block = ReadToolCallBlock::new("/Users/me/project/src/main.rs");
let mut ctx = make_ctx();
ctx.mode = DisplayMode::Expanded;
ctx.cwd = Some(std::path::PathBuf::from("/Users/me/project"));
let header = &block.output(&ctx).lines[0];
assert_eq!(derive_selection_text(header), "src/main.rs");
}
#[test]
fn header_link_url_is_absolute_for_collapsed_and_expanded() {
let abs = "/Users/me/project/src/main.rs";
let block = ReadToolCallBlock::new(abs);
let mut ctx = make_ctx();
ctx.cwd = Some(std::path::PathBuf::from("/Users/me/project"));
let collapsed = block.output(&ctx);
let url = collapsed.lines[0].link_url.as_ref().expect("link_url");
assert!(url.starts_with("file://"), "got {url}");
assert!(url.contains("main.rs"), "got {url}");
assert_eq!(
collapsed.lines[0].content.spans[1].content.as_ref(),
"main.rs"
);
ctx.mode = DisplayMode::Expanded;
let expanded = block.output(&ctx);
assert_eq!(
expanded.lines[0].content.spans[1].content.as_ref(),
"src/main.rs"
);
assert_eq!(expanded.lines[0].link_url.as_deref(), Some(url.as_ref()));
}
#[test]
fn skill_header_selects_skill_name_only() {
use crate::scrollback::types::{Selectable, derive_selection_text};
let block = ReadToolCallBlock::new("/home/user/.kigi/skills/deploy/SKILL.md");
let output = block.output(&make_ctx());
let header = &output.lines[0];
assert!(matches!(&header.selectable, Selectable::Spans(r) if *r == (1..2)));
assert_eq!(derive_selection_text(header), "deploy");
assert_eq!(header.content.spans[0].content.as_ref(), "Skill ");
}
#[test]
fn is_skill_read_only_for_skill_paths() {
assert!(ReadToolCallBlock::new("/x/skills/deploy/SKILL.md").is_skill_read());
assert!(!ReadToolCallBlock::new("src/main.rs").is_skill_read());
assert!(!ReadToolCallBlock::new("/x/skills/deploy/README.md").is_skill_read());
}
#[test]
fn foldable_when_content_present() {
let block = ReadToolCallBlock::new("f.rs").with_content("hello\nworld".into(), 2);
assert!(block.is_foldable());
}
#[test]
fn not_foldable_when_no_content() {
let block = ReadToolCallBlock::new("f.rs");
assert!(!block.is_foldable());
}
#[test]
fn fold_cycles_collapsed_truncated() {
let block = ReadToolCallBlock::new("f.rs").with_content("a\nb\nc".into(), 3);
assert_eq!(
block.next_fold_mode(DisplayMode::Collapsed, false),
DisplayMode::Truncated
);
assert_eq!(
block.next_fold_mode(DisplayMode::Truncated, false),
DisplayMode::Collapsed
);
}
#[test]
fn truncated_output_includes_content_lines() {
let content = (1..=20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let block = ReadToolCallBlock::new("f.rs")
.with_line_range(LineRange::new(1, 20))
.with_content(content, 20);
let ctx = BlockContext {
mode: DisplayMode::Truncated,
..make_ctx()
};
let output = block.output(&ctx);
// Header + blank separator + FIRST_LINES + ellipsis + LAST_LINES
assert!(
output.lines.len() > 1,
"truncated should have content lines"
);
let all_text: String = output
.lines
.iter()
.map(|l| {
l.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(all_text.contains("line 1"), "should contain first line");
assert!(all_text.contains("line 20"), "should contain last line");
assert!(all_text.contains("\u{2026}"), "should contain ellipsis");
}
#[test]
fn absolute_line_numbers_with_offset() {
let block = ReadToolCallBlock::new("f.rs")
.with_line_range(LineRange::new(50, 52))
.with_content("fn foo() {}\nfn bar() {}\nfn baz() {}".into(), 100);
let ctx = BlockContext {
mode: DisplayMode::Truncated,
..make_ctx()
};
let output = block.output(&ctx);
let all_text: String = output
.lines
.iter()
.map(|l| {
l.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(all_text.contains("50"), "should show line 50");
assert!(all_text.contains("52"), "should show line 52");
}
}
@@ -0,0 +1,562 @@
//! SearchToolCallBlock - search/grep for pattern.
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
};
use crate::theme::Theme;
use super::TOOL_HEADER_RANGE;
/// A single line match from search results.
#[derive(Debug, Clone)]
pub struct SearchLineMatch {
/// Line number in the file.
pub line_number: usize,
/// Content of the matching line.
pub content: String,
}
/// A file with its line matches from search results.
#[derive(Debug, Clone)]
pub struct SearchFileMatch {
/// Path to the file.
pub path: String,
/// Line matches within this file.
pub matches: Vec<SearchLineMatch>,
}
/// Output mode mirroring `OutputMode` from kigi-tools.
/// We keep our own copy to avoid pulling in that dependency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SearchOutputMode {
/// Matching lines with context (default).
#[default]
Content,
/// File paths only.
FilesWithMatches,
/// Match counts per file.
Count,
}
impl SearchOutputMode {
/// Parse from the rawInput `output_mode` string.
pub fn from_str_opt(s: Option<&str>) -> Self {
match s {
Some("files_with_matches") => Self::FilesWithMatches,
Some("count") => Self::Count,
_ => Self::Content,
}
}
}
/// Extra metadata from `GrepSearchInput` — carried for display purposes.
#[derive(Debug, Clone, Default)]
pub struct SearchInputMeta {
/// Search path (subdirectory), if not workspace root.
pub path: Option<String>,
/// Glob filter (e.g. `"*.rs"`).
pub glob: Option<String>,
/// Output mode.
pub output_mode: SearchOutputMode,
/// Case-insensitive search.
pub case_insensitive: bool,
/// File type filter (rg `--type`), e.g. `"rust"`.
pub file_type: Option<String>,
/// Multiline regex mode.
pub multiline: bool,
}
/// Search/grep tool call.
#[derive(Debug, Clone)]
pub struct SearchToolCallBlock {
/// The search pattern.
pub pattern: String,
/// Total number of matches found.
pub match_count: usize,
/// Matches grouped by file (line-level matches).
pub file_matches: Vec<SearchFileMatch>,
/// File paths only (for `files_with_matches` output mode).
/// Used when `file_matches` is empty but results exist.
pub file_paths: Vec<String>,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// Extra metadata from the search input (path, glob, mode, etc.).
pub meta: SearchInputMeta,
/// When the tool started running (Phase 2: time tracking).
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion (Phase 2: time tracking).
pub elapsed_ms: Option<i64>,
}
impl SearchToolCallBlock {
/// Create a new search block.
///
/// Pre-completed blocks have no meaningful local timing — `started_at`
/// is `None`. Timing is only set for blocks that enter a running UI
/// state (via `set_last_running(true)` in `ScrollbackState`).
pub fn new(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
match_count: 0,
file_matches: Vec::new(),
file_paths: Vec::new(),
error: None,
meta: SearchInputMeta::default(),
started_at: None,
elapsed_ms: None,
}
}
/// Set match count and file matches.
pub fn with_matches(mut self, match_count: usize, file_matches: Vec<SearchFileMatch>) -> Self {
self.match_count = match_count;
self.file_matches = file_matches;
self
}
/// Set error (marks as failed).
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
/// Check if successful (no error).
pub fn is_success(&self) -> bool {
self.error.is_none()
}
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
self.error = error;
}
/// Finalize elapsed time from `started_at`.
///
/// Idempotent: no-op if `started_at` is `None` (pre-completed block)
/// or if `elapsed_ms` is already set (already finalized).
pub fn finish(&mut self) {
if self.elapsed_ms.is_some() {
return;
}
if let Some(start) = self.started_at {
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
}
/// Get elapsed time in ms (Phase 2).
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
None => self
.started_at
.map(|start| start.elapsed().as_millis() as i64),
}
}
/// Set file matches (mutable).
pub fn set_file_matches(&mut self, match_count: usize, file_matches: Vec<SearchFileMatch>) {
self.match_count = match_count;
self.file_matches = file_matches;
}
/// Build the match summary string, adapted by output mode.
///
/// - `Content`: `(3 matches in 2 files)` / `(1 match)` / `(no matches)`
/// - `FilesWithMatches`: `(3 files)` / `(1 file)` / `(no matches)`
/// - `Count`: `(42 matches across 5 files)` / `(no matches)`
fn match_summary(&self) -> String {
if self.match_count == 0 {
return match self.meta.output_mode {
SearchOutputMode::FilesWithMatches => "(no files)".to_string(),
_ => "(no matches)".to_string(),
};
}
match self.meta.output_mode {
SearchOutputMode::Content => {
let file_count = self.file_matches.len();
if file_count > 1 {
format!("({} matches in {} files)", self.match_count, file_count)
} else if self.match_count == 1 {
"(1 match)".to_string()
} else {
format!("({} matches)", self.match_count)
}
}
SearchOutputMode::FilesWithMatches => {
let n = self.match_count; // match_count = # of files in this mode
if n == 1 {
"(1 file)".to_string()
} else {
format!("({n} files)")
}
}
SearchOutputMode::Count => {
let file_count = self.file_paths.len().max(self.file_matches.len());
if file_count > 1 {
format!("({} matches across {} files)", self.match_count, file_count)
} else if self.match_count == 1 {
"(1 match)".to_string()
} else {
format!("({} matches)", self.match_count)
}
}
}
}
/// Whether the pattern is trivial (`"."` or empty) — meaning the glob
/// is the real search term when present.
fn is_trivial_pattern(&self) -> bool {
self.pattern.is_empty() || self.pattern == "."
}
/// Render the header line.
///
/// Three cases:
/// 1. Trivial pattern + glob → `Search glob in path (summary)`
/// glob is string-styled without quotes (it IS the search term).
/// 2. Real pattern + glob → `Search "pattern" in glob in path (summary)`
/// glob shown as path scope after first "in".
/// 3. No glob → `Search "pattern" in path (summary)`
fn header_line(
&self,
theme: &Theme,
muted: bool,
dim_details: bool,
width: Option<usize>,
) -> Line<'static> {
let text_style = if muted {
theme.muted()
} else {
theme.primary()
};
let bold_style = text_style.add_modifier(Modifier::BOLD);
let pattern_style = if muted {
theme.muted()
} else {
theme.fg(theme.accent_success)
};
let detail_style = if dim_details {
theme.dim()
} else {
theme.muted()
};
let path_style = if muted {
theme.muted()
} else {
theme.fg(theme.path)
};
let mut spans = vec![Span::styled("Search ".to_string(), bold_style)];
// Search term: either promoted glob or quoted pattern
if self.is_trivial_pattern()
&& let Some(ref glob) = self.meta.glob
{
// Case 1: glob IS the search term — no quotes, string-styled
spans.push(Span::styled(glob.to_string(), pattern_style));
} else {
// Cases 2 & 3: quoted regex pattern
spans.push(Span::styled(format!("{:?}", self.pattern), pattern_style));
// Case 2: glob shown as first "in" scope (string-styled, not path)
if let Some(ref glob) = self.meta.glob {
spans.push(Span::styled(" in ".to_string(), text_style));
spans.push(Span::styled(glob.to_string(), pattern_style));
}
}
// Path scope (always after glob if both present).
// When width-constrained, fish-shorten the path.
if let Some(ref path) = self.meta.path {
spans.push(Span::styled(" in ".to_string(), text_style));
if let Some(w) = width {
let used: usize = spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum();
let summary = format!(" {}", self.match_summary());
// Reserve space for summary; if path can't fit even without it, drop summary.
let path_budget = w.saturating_sub(used + summary.len());
let shortened = crate::render::tool_paths::shorten_path(path, path_budget);
spans.push(Span::styled(shortened, path_style));
} else {
spans.push(Span::styled(path.to_string(), path_style));
}
}
// Match summary — always last.
// When width-constrained, only include if there's room.
let summary = format!(" {}", self.match_summary());
if let Some(w) = width {
let used: usize = spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if used + summary.len() <= w {
spans.push(Span::styled(summary, detail_style));
}
} else {
spans.push(Span::styled(summary, detail_style));
}
let line = Line::from(spans);
if let Some(w) = width {
crate::render::line_utils::truncate_line(line, w)
} else {
line
}
}
/// Textable operand shown in the header (glob when it replaces a trivial pattern).
fn header_selection_text(&self) -> String {
if self.is_trivial_pattern()
&& let Some(ref glob) = self.meta.glob
{
glob.clone()
} else {
self.pattern.clone()
}
}
/// Header line with only the search term span selectable (exclude "Search " prefix).
///
/// Span 0 is always the label; span 1 is the pattern/glob. Later "in path"
/// and summary spans stay non-selectable so copy yields the search term.
fn header_block_line(&self, line: Line<'static>) -> BlockLine {
let term_end = 2.min(line.spans.len()).max(1);
BlockLine {
selectable: Selectable::Spans(1..term_end),
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: Some(self.header_selection_text()),
content: line,
..Default::default()
}
}
/// Build a single comma-separated metadata line.
///
/// Always present (at minimum shows `mode: pattern`).
/// Glob is never shown here (always inline in header).
/// All flags use `key: value` form. Values in primary fg, keys in muted.
fn metadata_line(&self, theme: &Theme) -> Line<'static> {
let label_style = theme.muted();
let value_style = theme.primary();
let mut parts: Vec<Vec<Span<'static>>> = Vec::new();
// Mode is always first — grounds the user in what kind of search this is.
let mode_str = match self.meta.output_mode {
SearchOutputMode::Content => "pattern",
SearchOutputMode::FilesWithMatches => "files",
SearchOutputMode::Count => "count",
};
parts.push(vec![
Span::styled("mode: ", label_style),
Span::styled(mode_str.to_string(), value_style),
]);
if let Some(ref ft) = self.meta.file_type {
parts.push(vec![
Span::styled("type: ", label_style),
Span::styled(ft.to_string(), value_style),
]);
}
if self.meta.case_insensitive {
parts.push(vec![
Span::styled("case-insensitive: ", label_style),
Span::styled("true", value_style),
]);
}
if self.meta.multiline {
parts.push(vec![
Span::styled("multiline: ", label_style),
Span::styled("true", value_style),
]);
}
let indent = " ";
let mut spans: Vec<Span<'static>> = vec![Span::styled(indent.to_string(), label_style)];
for (i, part) in parts.into_iter().enumerate() {
if i > 0 {
spans.push(Span::styled(", ", label_style));
}
spans.extend(part);
}
Line::from(spans)
}
}
impl BlockContent for SearchToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let tool_cfg = &ctx.appearance.scrollback.blocks.tool;
let muted_collapsed = ctx.mute_when_collapsed(tool_cfg.muted_collapsed);
let dim_details = tool_cfg.dim_details;
match ctx.mode {
DisplayMode::Collapsed => BlockOutput {
lines: vec![self.header_block_line(self.header_line(
&theme,
muted_collapsed,
dim_details,
Some(ctx.content_width()),
))],
},
DisplayMode::Truncated | DisplayMode::Expanded => {
let mut lines: Vec<BlockLine> = vec![self.header_block_line(self.header_line(
&theme,
false,
dim_details,
None,
))];
// Metadata line (mode + non-default input fields, comma-separated).
// Blank line separates header from metadata.
lines.push(BlockLine::separator(Line::from("")));
lines.push(BlockLine::separator(self.metadata_line(&theme)));
let has_results = !self.file_matches.is_empty() || !self.file_paths.is_empty();
if has_results {
// Blank line before results
lines.push(Line::from("").into());
} else if self.match_count == 0 {
// No results — show a hint
lines.push(Line::from("").into());
lines.push(
Line::from(Span::styled(" (no results)".to_string(), theme.muted()))
.into(),
);
}
if !self.file_matches.is_empty() {
// Line-level matches (content mode).
// Each file group is a separate bg_dark block, separated
// by a blank line.
let indent = " ";
let match_indent = " ";
for (i, file_match) in self.file_matches.iter().enumerate() {
if i > 0 {
// Blank line between file groups
lines.push(Line::from("").into());
}
// File path line
lines.push(
BlockLine::from(Line::from(Span::styled(
format!("{}{}", indent, file_match.path),
theme.fg(theme.path),
)))
.with_panel_background(theme.bg_dark),
);
// Match lines: " 42 content..."
for m in &file_match.matches {
let line_num_str = format!("{:>4}", m.line_number);
let content_trimmed = m.content.trim_end();
lines.push(
BlockLine::from(Line::from(vec![
Span::styled(match_indent.to_string(), theme.primary()),
Span::styled(line_num_str, theme.muted()),
Span::styled(" ".to_string(), theme.primary()),
Span::styled(content_trimmed.to_string(), theme.primary()),
]))
.with_panel_background(theme.bg_dark),
);
}
}
} else if !self.file_paths.is_empty() {
// File paths only (files_with_matches mode) OR
// count mode (path:N lines).
let indent = " ";
let is_count = self.meta.output_mode == SearchOutputMode::Count;
for path in &self.file_paths {
let line = if is_count {
// Count mode: "path:N" — split at last ':',
// path part in path color, ":N" in normal fg.
if let Some(colon_pos) = path.rfind(':') {
let file_part = &path[..colon_pos];
let count_part = &path[colon_pos..]; // includes ':'
Line::from(vec![
Span::styled(
format!("{indent}{file_part}"),
theme.fg(theme.path),
),
Span::styled(count_part.to_string(), theme.primary()),
])
} else {
Line::from(Span::styled(
format!("{indent}{path}"),
theme.fg(theme.path),
))
}
} else {
Line::from(Span::styled(
format!("{indent}{path}"),
theme.fg(theme.path),
))
};
lines.push(BlockLine::from(line).with_panel_background(theme.bg_dark));
}
}
BlockOutput { lines }
}
}
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None // Search blocks never have an accent line
}
fn bullet(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
if self.error.is_some() {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else {
None
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
// Always foldable (even with no results — expand shows metadata
// and/or "(no results)" for consistency).
self.error.is_none()
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
_ => DisplayMode::Collapsed,
}
}
}
@@ -0,0 +1,357 @@
//! SearchToolCallBlock — integration tool discovery results.
use kigi_workspace::permission::mcp_titleize_segment;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span, Text};
use super::TOOL_HEADER_RANGE;
use crate::render::line_utils::truncate_str;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
};
use crate::theme::Theme;
/// A tool discovered via search_tool.
#[derive(Debug, Clone)]
pub struct DiscoveredTool {
pub name: String,
pub server: String,
pub description: String,
pub score: f64,
}
/// Search tool call — discovering MCP integration tools by keyword.
#[derive(Debug, Clone)]
pub struct SearchToolCallBlock {
/// The search query.
pub query: String,
/// Limit parameter from the input (None = default 8).
pub limit: Option<u8>,
/// Number of results found.
pub result_count: usize,
/// Discovered tools (parsed from output).
pub results: Vec<DiscoveredTool>,
/// Raw output content (full JSON) for the fullscreen viewer.
pub content: Option<String>,
/// Error message if the tool call failed.
pub error: Option<String>,
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion.
pub elapsed_ms: Option<i64>,
}
pub fn discovered_tool_action(tool: &DiscoveredTool) -> &str {
tool.name
.strip_prefix(&tool.server)
.and_then(|rest| rest.strip_prefix("__"))
.unwrap_or(&tool.name)
}
impl SearchToolCallBlock {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
limit: None,
result_count: 0,
results: Vec::new(),
content: None,
error: None,
started_at: None,
elapsed_ms: None,
}
}
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
pub fn is_success(&self) -> bool {
self.error.is_none()
}
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
self.error = error;
}
pub fn finish(&mut self) {
if self.elapsed_ms.is_some() {
return;
}
if let Some(start) = self.started_at {
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
}
pub fn elapsed_ms(&self) -> Option<i64> {
self.elapsed_ms.or_else(|| {
self.started_at
.map(|start| start.elapsed().as_millis() as i64)
})
}
pub fn copy_text(&self) -> String {
let mut out = format!("query: {}\n", self.query);
if let Some(limit) = self.limit {
out.push_str(&format!("limit: {limit}\n"));
}
let s = if self.result_count == 1 { "" } else { "s" };
out.push_str(&format!("{} result{s}\n", self.result_count));
for (i, tool) in self.results.iter().enumerate() {
out.push('\n');
let action = mcp_titleize_segment(discovered_tool_action(tool));
let server = mcp_titleize_segment(&tool.server);
out.push_str(&format!("{}. {} {}\n", i + 1, action, server));
if !tool.description.is_empty() {
out.push_str(&format!(" {}\n", tool.description));
}
}
out
}
/// Render the header line: **Search Tools** `query` `(N results)`
fn header_line(&self, theme: &Theme, muted: bool, max_width: Option<usize>) -> Line<'static> {
let text_style = if muted {
theme.muted()
} else {
theme.primary()
};
let bold_style = text_style.add_modifier(Modifier::BOLD);
let query_style = if muted {
theme.muted()
} else {
theme.fg(theme.command)
};
let prefix = "Search Tools ";
match max_width {
Some(w) => {
let s = if self.result_count == 1 { "" } else { "s" };
let suffix = format!(" ({} result{s})", self.result_count);
let suffix_fits = prefix.len() + suffix.len() < w;
let effective_suffix = if suffix_fits { &suffix } else { "" };
let query_budget = w
.saturating_sub(prefix.len())
.saturating_sub(effective_suffix.len());
let display_query = truncate_str(&self.query, query_budget);
let mut spans = vec![
Span::styled(prefix, bold_style),
Span::styled(display_query, query_style),
];
if !effective_suffix.is_empty() {
spans.push(Span::styled(effective_suffix.to_string(), theme.dim()));
}
Line::from(spans)
}
None => Line::from(vec![
Span::styled(prefix, bold_style),
Span::styled(self.query.clone(), query_style),
]),
}
}
/// Header line with only the query span selectable (exclude label/suffix).
fn header_block_line(&self, line: Line<'static>) -> BlockLine {
let query_end = 2.min(line.spans.len()).max(1);
BlockLine {
selectable: Selectable::Spans(1..query_end),
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: Some(self.query.clone()),
content: line,
..Default::default()
}
}
}
impl BlockContent for SearchToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let muted_collapsed =
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
match ctx.mode {
DisplayMode::Collapsed => BlockOutput {
lines: vec![self.header_block_line(self.header_line(
&theme,
muted_collapsed,
Some(ctx.content_width()),
))],
},
DisplayMode::Truncated | DisplayMode::Expanded => {
let header = self.header_line(&theme, false, None);
let wrapped = crate::render::wrapping::wrap_header_flush(
header,
ctx.width as usize,
ctx.bullet_indent(),
);
let mut lines: Vec<BlockLine> = wrapped
.into_iter()
.enumerate()
.map(|(i, line)| {
let total = line.spans.len();
BlockLine {
selectable: Selectable::Spans(1..total),
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: if i == 0 {
Some(self.query.clone())
} else {
None
},
joiner: if i == 0 { None } else { Some(" ".to_string()) },
content: line,
..Default::default()
}
})
.collect();
if !self.results.is_empty() {
lines.push(BlockLine::separator(Line::from("")));
for (i, tool) in self.results.iter().enumerate() {
let idx_span = Span::styled(format!(" {}. ", i + 1), theme.muted());
// Strip the trusted server prefix from tool_name and
// title-case both halves; show the action bold and
// the server name ghosted on the right.
let action = mcp_titleize_segment(discovered_tool_action(tool));
let server_label = mcp_titleize_segment(&tool.server);
let name_span =
Span::styled(action, theme.primary().add_modifier(Modifier::BOLD));
let mut spans = vec![idx_span, name_span];
if !server_label.is_empty() {
spans.push(Span::styled(format!(" {server_label}"), theme.dim()));
}
lines.push(BlockLine::styled(Line::from(spans)));
}
} else if self.error.is_none() {
lines.push(Line::from("").into());
lines.push(
Line::from(Span::styled(" (no results found)", theme.muted())).into(),
);
}
if let Some(ref err) = self.error {
lines.push(Line::from("").into());
lines.push(
Line::from(Span::styled(
format!(" {err}"),
theme.fg(theme.accent_error),
))
.into(),
);
}
BlockOutput { lines }
}
}
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if ctx.mode == DisplayMode::Collapsed {
return None;
}
let theme = Theme::current();
if self.error.is_some() {
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.is_running {
Some(AccentStyle::animated(theme.accent_running))
} else {
Some(AccentStyle::static_color(theme.accent_tool))
}
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if self.error.is_some() {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.mode == DisplayMode::Collapsed {
None
} else {
self.accent(ctx)
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
self.error.is_none() && !self.results.is_empty()
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
_ => DisplayMode::Collapsed,
}
}
fn preamble(&self, _ctx: &BlockContext) -> Option<Text<'static>> {
let theme = Theme::current();
Some(Text::from(vec![self.header_line(&theme, false, None)]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discovered_tool_action_strips_local_mcp_prefix() {
let tool = DiscoveredTool {
name: "linear__save_issue".into(),
server: "linear".into(),
description: String::new(),
score: 1.0,
};
assert_eq!(discovered_tool_action(&tool), "save_issue");
}
#[test]
fn discovered_tool_action_keeps_gateway_flat_name() {
let tool = DiscoveredTool {
name: "google_calendar_search".into(),
server: "Google Calendar".into(),
description: String::new(),
score: 1.0,
};
assert_eq!(discovered_tool_action(&tool), "google_calendar_search");
}
#[test]
fn discovered_tool_action_strips_gateway_mcp_prefix() {
let tool = DiscoveredTool {
name: "google_calendar__search".into(),
server: "google_calendar".into(),
description: String::new(),
score: 1.0,
};
assert_eq!(discovered_tool_action(&tool), "search");
}
}
@@ -0,0 +1,8 @@
---
source: crates/codegen/kigi-tui/src/scrollback/blocks/tool/edit.rs
expression: diff_outputs_to_string(&outputs)
---
10 let x = 1;
11 let y = 2;
11 let y = 3;
12 let z = 4;
@@ -0,0 +1,8 @@
---
source: crates/codegen/kigi-tui/src/scrollback/blocks/tool/edit.rs
expression: diff_outputs_to_string(&outputs)
---
10 10 let x = 1;
11 let y = 2;
11 let y = 3;
12 12 let z = 4;
@@ -0,0 +1,13 @@
---
source: crates/codegen/kigi-tui/src/scrollback/blocks/tool/edit.rs
expression: diff_outputs_to_string(&outputs)
---
3 fn one() {
4 old_one();
4 new_one();
… 7 unchanged lines
12 ctx_two();
13 add_two();
… 6 unchanged lines
20 ctx_three();
21 add_three();
@@ -0,0 +1,9 @@
---
source: crates/codegen/kigi-tui/src/scrollback/blocks/tool/edit.rs
expression: diff_outputs_to_string(&outputs)
---
5 first hunk context
6 deleted in first
… 43 unchanged lines
49 second hunk context
50 inserted in second
@@ -0,0 +1,9 @@
---
source: crates/codegen/kigi-tui/src/scrollback/blocks/tool/edit.rs
expression: diff_outputs_to_string(&outputs)
---
5 5 first hunk context
6 deleted in first
… 43 unchanged lines
50 49 second hunk context
50 inserted in second
@@ -0,0 +1,8 @@
---
source: crates/codegen/kigi-tui/src/scrollback/blocks/tool/edit.rs
expression: diff_outputs_to_string(&outputs)
---
1 short line
2 this is a very long line that will
definitely wrap to multiple lines
3 another short one
@@ -0,0 +1,9 @@
---
source: crates/codegen/kigi-tui/src/scrollback/blocks/tool/edit.rs
expression: diff_outputs_to_string(&outputs)
---
1 1 short line
2 this is a very long line that
will definitely wrap to multiple
lines
2 3 another short one
@@ -0,0 +1,8 @@
---
source: crates/codegen/kigi-tui/src/scrollback/blocks/tool/edit.rs
expression: diff_outputs_to_string(&outputs)
---
99 context before
100 old code
100 new code
101 context after
@@ -0,0 +1,8 @@
---
source: crates/codegen/kigi-tui/src/scrollback/blocks/tool/edit.rs
expression: diff_outputs_to_string(&outputs)
---
99 99 context before
100 old code
100 new code
101 101 context after
@@ -0,0 +1,286 @@
//! UseToolCallBlock — MCP integration tool dispatch.
use kigi_workspace::permission::{MCP_TOOL_NAME_DELIMITER, mcp_titleize_segment};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span, Text};
use crate::render::line_utils::truncate_str;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode,
};
use crate::theme::Theme;
/// Max lines of output shown inline before truncation.
const MAX_INLINE_LINES: usize = 10;
/// Use tool call — dispatching to an MCP integration tool.
#[derive(Debug, Clone)]
pub struct UseToolCallBlock {
/// The qualified tool name (e.g. "linear__save_issue").
pub tool_name: String,
/// Input arguments as key-value pairs (extracted from tool_input JSON).
pub input_args: Vec<(String, String)>,
/// Output text from the dispatched tool.
pub output: Option<String>,
/// Error message if the tool call failed.
pub error: Option<String>,
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion.
pub elapsed_ms: Option<i64>,
}
impl UseToolCallBlock {
pub fn new(tool_name: impl Into<String>) -> Self {
Self {
tool_name: tool_name.into(),
input_args: Vec::new(),
output: None,
error: None,
started_at: None,
elapsed_ms: None,
}
}
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
pub fn is_success(&self) -> bool {
self.error.is_none()
}
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
self.error = error;
}
pub fn finish(&mut self) {
if self.elapsed_ms.is_some() {
return;
}
if let Some(start) = self.started_at {
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
}
pub fn elapsed_ms(&self) -> Option<i64> {
self.elapsed_ms.or_else(|| {
self.started_at
.map(|start| start.elapsed().as_millis() as i64)
})
}
pub fn copy_text(&self) -> String {
let mut out = format!("tool: {}\n", self.tool_name);
for (k, v) in &self.input_args {
out.push_str(&format!("{k}: {v}\n"));
}
out.push('\n');
out.push_str(self.output.as_deref().unwrap_or("(no output)"));
out
}
/// Split `tool_name` on the (validated-unambiguous)
/// `MCP_TOOL_NAME_DELIMITER` and title-case each segment. Returns
/// `(server_title, action_title)` for qualified names, or
/// `("", titleized_tool_name)` for unqualified ones (which fall
/// through to a single-span render in `header_line`).
fn split_name(&self) -> (String, String) {
match self.tool_name.split_once(MCP_TOOL_NAME_DELIMITER) {
Some((server, action)) => (mcp_titleize_segment(server), mcp_titleize_segment(action)),
None => (String::new(), mcp_titleize_segment(&self.tool_name)),
}
}
/// Render the header line: **Server** `Action`
fn header_line(&self, theme: &Theme, muted: bool, max_width: Option<usize>) -> Line<'static> {
let text_style = if muted {
theme.muted()
} else {
theme.primary()
};
let bold_style = text_style.add_modifier(Modifier::BOLD);
let action_style = if muted {
theme.muted()
} else {
theme.fg(theme.command)
};
let (server, action) = self.split_name();
if server.is_empty() {
let display = match max_width {
Some(w) => truncate_str(&action, w),
None => action,
};
return Line::from(vec![Span::styled(display, bold_style)]);
}
let prefix = format!("{server} ");
match max_width {
Some(w) => {
let budget = w.saturating_sub(prefix.len());
let display_action = truncate_str(&action, budget);
Line::from(vec![
Span::styled(prefix, bold_style),
Span::styled(display_action, action_style),
])
}
None => Line::from(vec![
Span::styled(prefix, bold_style),
Span::styled(action, action_style),
]),
}
}
}
impl BlockContent for UseToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let muted_collapsed =
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
match ctx.mode {
DisplayMode::Collapsed => BlockOutput {
lines: vec![
self.header_line(&theme, muted_collapsed, Some(ctx.content_width()))
.into(),
],
},
DisplayMode::Truncated | DisplayMode::Expanded => {
let header = self.header_line(&theme, false, None);
let wrapped = crate::render::wrapping::wrap_header_flush(
header,
ctx.width as usize,
ctx.bullet_indent(),
);
let mut lines: Vec<BlockLine> = wrapped.into_iter().map(BlockLine::from).collect();
// Input arguments
if !self.input_args.is_empty() {
lines.push(Line::from("").into());
for (key, val) in &self.input_args {
lines.push(BlockLine::styled(Line::from(vec![
Span::styled(format!(" {key}: "), theme.muted()),
Span::styled(val.clone(), theme.primary()),
])));
}
}
// Output preview
if let Some(ref output) = self.output {
lines.push(Line::from("").into());
lines
.push(BlockLine::from(Line::from("")).with_panel_background(theme.bg_dark));
let indent = " ";
let content_lines: Vec<&str> = output.lines().collect();
for (i, line) in content_lines.iter().enumerate() {
if i >= MAX_INLINE_LINES {
let remaining = content_lines.len() - MAX_INLINE_LINES;
lines.push(
BlockLine::from(Line::from(Span::styled(
format!(
"{indent}... ({remaining} more lines, press Enter to view)",
),
theme.dim(),
)))
.with_panel_background(theme.bg_dark),
);
break;
}
lines.push(
BlockLine::from(Line::from(Span::styled(
format!("{indent}{line}"),
theme.primary(),
)))
.with_panel_background(theme.bg_dark),
);
}
lines
.push(BlockLine::from(Line::from("")).with_panel_background(theme.bg_dark));
}
if let Some(ref err) = self.error {
lines.push(Line::from("").into());
lines.push(
Line::from(Span::styled(
format!(" {err}"),
theme.fg(theme.accent_error),
))
.into(),
);
}
BlockOutput { lines }
}
}
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if ctx.mode == DisplayMode::Collapsed {
return None;
}
let theme = Theme::current();
if self.error.is_some() {
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.is_running {
Some(AccentStyle::animated(theme.accent_running))
} else {
Some(AccentStyle::static_color(theme.accent_tool))
}
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if self.error.is_some() {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.mode == DisplayMode::Collapsed {
None
} else {
self.accent(ctx)
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
!self.input_args.is_empty() || self.output.is_some() || self.error.is_some()
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
_ => DisplayMode::Collapsed,
}
}
fn preamble(&self, _ctx: &BlockContext) -> Option<Text<'static>> {
let theme = Theme::current();
Some(Text::from(vec![self.header_line(&theme, false, None)]))
}
}
@@ -0,0 +1,349 @@
//! WebFetchToolCallBlock — URL fetch with content preview.
use ratatui::style::Modifier;
use ratatui::text::{Line, Span, Text};
use super::TOOL_HEADER_RANGE;
use crate::render::line_utils::truncate_str;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
};
use crate::theme::Theme;
/// Max lines of content shown inline before truncation.
const MAX_INLINE_LINES: usize = 10;
/// Web fetch tool call — fetching a URL and returning markdown content.
#[derive(Debug, Clone)]
pub struct WebFetchToolCallBlock {
/// The fetched URL.
pub url: String,
/// HTTP status code (e.g. 200, 404).
/// `Option` because the block exists pre-completion (pending/running state)
/// before any response data arrives.
pub status_code: Option<u16>,
/// Content type (e.g. "markdown", "text/plain").
pub content_type: Option<String>,
/// Content size in bytes.
pub bytes: Option<usize>,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// Fetched content (markdown or raw text).
pub output: Option<String>,
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion.
pub elapsed_ms: Option<i64>,
}
impl WebFetchToolCallBlock {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
status_code: None,
content_type: None,
bytes: None,
error: None,
output: None,
started_at: None,
elapsed_ms: None,
}
}
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
pub fn with_output(mut self, output: impl Into<String>) -> Self {
self.output = Some(output.into());
self
}
pub fn is_success(&self) -> bool {
self.error.is_none()
}
pub fn copy_text(&self) -> String {
self.output.clone().unwrap_or_default()
}
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
self.error = error;
}
pub fn finish(&mut self) {
if self.elapsed_ms.is_some() {
return;
}
if let Some(start) = self.started_at {
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
}
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
None => self
.started_at
.map(|start| start.elapsed().as_millis() as i64),
}
}
/// Format byte count as human-readable (e.g. "14.2 KB").
fn format_bytes(bytes: usize) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
/// Render the header line: **Fetch** `url`
///
/// When `max_width` is `Some`, the URL is truncated with ellipsis to fit.
/// When `None`, the full URL is rendered (for expanded view / fullscreen).
fn header_line(&self, theme: &Theme, muted: bool, max_width: Option<usize>) -> Line<'static> {
let text_style = if muted {
theme.muted()
} else {
theme.primary()
};
let bold_style = text_style.add_modifier(Modifier::BOLD);
let url_style = if muted {
theme.muted()
} else {
theme.fg(theme.command)
};
let prefix = "Fetch ";
let display_url = match max_width {
Some(w) => truncate_str(&self.url, w.saturating_sub(prefix.len())),
None => self.url.clone(),
};
Line::from(vec![
Span::styled(prefix, bold_style),
Span::styled(display_url, url_style),
])
}
/// Header line with only the URL span selectable (exclude "Fetch " prefix).
fn header_block_line(&self, line: Line<'static>) -> BlockLine {
let url_end = 2.min(line.spans.len()).max(1);
BlockLine {
selectable: Selectable::Spans(1..url_end),
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: Some(self.url.clone()),
content: line,
..Default::default()
}
}
/// Build the metadata line: status, content_type, size.
fn metadata_line(&self, theme: &Theme) -> Option<Line<'static>> {
let label_style = theme.muted();
let value_style = theme.primary();
let mut parts: Vec<Vec<Span<'static>>> = Vec::new();
if let Some(code) = self.status_code {
parts.push(vec![
Span::styled("status: ", label_style),
Span::styled(code.to_string(), value_style),
]);
}
if let Some(ref ct) = self.content_type {
parts.push(vec![
Span::styled("content_type: ", label_style),
Span::styled(ct.clone(), value_style),
]);
}
if let Some(bytes) = self.bytes {
parts.push(vec![
Span::styled("size: ", label_style),
Span::styled(Self::format_bytes(bytes), value_style),
]);
}
if parts.is_empty() {
return None;
}
let indent = " ";
let mut spans: Vec<Span<'static>> = vec![Span::styled(indent.to_owned(), label_style)];
for (i, part) in parts.into_iter().enumerate() {
if i > 0 {
spans.push(Span::styled(", ", label_style));
}
spans.extend(part);
}
Some(Line::from(spans))
}
}
impl BlockContent for WebFetchToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let muted_collapsed =
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
match ctx.mode {
DisplayMode::Collapsed => BlockOutput {
lines: vec![self.header_block_line(self.header_line(
&theme,
muted_collapsed,
Some(ctx.content_width()),
))],
},
// Fetch completes in one shot (no streaming), so Truncated
// is never visible in practice. Treat it the same as Expanded
// to always show the full content the model saw.
DisplayMode::Truncated | DisplayMode::Expanded => {
let header = self.header_line(&theme, false, None);
let wrapped = crate::render::wrapping::wrap_header_flush(
header,
ctx.width as usize,
ctx.bullet_indent(),
);
// Header lines: "Fetch " prefix excluded, URL selectable.
let mut lines: Vec<BlockLine> = wrapped
.into_iter()
.enumerate()
.map(|(i, line)| {
let total = line.spans.len();
BlockLine {
selectable: Selectable::Spans(1..total),
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: if i == 0 { Some(self.url.clone()) } else { None },
joiner: if i == 0 { None } else { Some(" ".to_string()) },
content: line,
..Default::default()
}
})
.collect();
// Metadata line (status, content_type, size).
if let Some(meta) = self.metadata_line(&theme) {
lines.push(BlockLine::separator(Line::from("")));
lines.push(BlockLine::separator(meta));
}
// Content preview with bg_dark background, capped at
// MAX_INLINE_LINES. Full content is available via the
// fullscreen viewer (Enter/o).
if let Some(ref output) = self.output {
lines.push(Line::from("").into());
// Top padding inside the content box.
lines
.push(BlockLine::from(Line::from("")).with_panel_background(theme.bg_dark));
let indent = " ";
let total_lines = output.lines().count();
for (i, line) in output.lines().enumerate() {
if i >= MAX_INLINE_LINES {
lines.push(
BlockLine::from(Line::from(Span::styled(
format!(
"{indent}... ({} more lines, press Enter to view)",
total_lines - MAX_INLINE_LINES
),
theme.dim(),
)))
.with_panel_background(theme.bg_dark),
);
break;
}
lines.push(
BlockLine::from(Line::from(Span::styled(
format!("{indent}{line}"),
theme.primary(),
)))
.with_panel_background(theme.bg_dark),
);
}
// Bottom padding inside the content box.
lines
.push(BlockLine::from(Line::from("")).with_panel_background(theme.bg_dark));
} else if self.error.is_none() {
lines.push(Line::from("").into());
lines.push(
Line::from(Span::styled(" (no content)".to_owned(), theme.muted())).into(),
);
}
BlockOutput { lines }
}
}
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if ctx.mode == DisplayMode::Collapsed {
return None;
}
let theme = Theme::current();
if self.error.is_some() {
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.is_running {
Some(AccentStyle::animated(theme.accent_running))
} else {
Some(AccentStyle::static_color(theme.accent_tool))
}
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if self.error.is_some() {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.mode == DisplayMode::Collapsed {
None
} else {
self.accent(ctx)
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
self.error.is_none() && self.output.is_some()
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
// No special running-state handling: fetch completes in one shot (no streaming).
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
_ => DisplayMode::Collapsed,
}
}
fn preamble(&self, _ctx: &BlockContext) -> Option<Text<'static>> {
let theme = Theme::current();
Some(Text::from(vec![self.header_line(&theme, false, None)]))
}
}
@@ -0,0 +1,381 @@
//! WebSearchToolCallBlock — web search with citations preview.
use std::collections::HashSet;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span, Text};
use super::TOOL_HEADER_RANGE;
use crate::render::line_utils::truncate_str;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
};
use crate::theme::Theme;
/// Max lines of content shown inline before truncation.
const MAX_INLINE_LINES: usize = 10;
/// Max number of domain names shown in the sources summary line.
const MAX_INLINE_SOURCES: usize = 3;
/// Web search tool call — searching the web and returning markdown results.
#[derive(Debug, Clone)]
pub struct WebSearchToolCallBlock {
/// The search query.
pub query: String,
/// Markdown-formatted search results.
pub content: Option<String>,
/// Source URLs from the search.
pub citations: Vec<String>,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion.
pub elapsed_ms: Option<i64>,
/// Header label override (default: "Web Search ").
pub label: Option<String>,
/// True for X search (backend); suppresses the content body since
/// structured post results are not exposed to the TUI client.
pub is_x_search: bool,
}
impl WebSearchToolCallBlock {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
content: None,
citations: Vec::new(),
error: None,
started_at: None,
elapsed_ms: None,
label: None,
is_x_search: false,
}
}
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
pub fn is_success(&self) -> bool {
self.error.is_none()
}
pub fn copy_text(&self) -> String {
self.content.as_deref().unwrap_or_default().to_owned()
}
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
{
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
self.error = error;
}
pub fn finish(&mut self) {
if self.elapsed_ms.is_some() {
return;
}
if let Some(start) = self.started_at {
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
}
}
pub fn elapsed_ms(&self) -> Option<i64> {
self.elapsed_ms.or_else(|| {
self.started_at
.map(|start| start.elapsed().as_millis() as i64)
})
}
/// Render the header line: **Web Search** `query` `(N sources)`
///
/// In collapsed mode (`max_width` is `Some`), reserves space for the source
/// count suffix and truncates the query to fit — so the suffix is always
/// visible. In expanded mode (`None`), renders the full query with no suffix.
fn header_line(&self, theme: &Theme, muted: bool, max_width: Option<usize>) -> Line<'static> {
let text_style = if muted {
theme.muted()
} else {
theme.primary()
};
let bold_style = text_style.add_modifier(Modifier::BOLD);
let query_style = if muted {
theme.muted()
} else {
theme.fg(theme.command)
};
let prefix = self.label.as_deref().unwrap_or("Web Search ").to_owned();
match max_width {
Some(w) => {
// Collapsed shows deduplicated domain count as "sites".
// The fullscreen footer shows raw citation count as "Sources".
let site_count = self.unique_domains().len();
let suffix = if site_count > 0 {
let s = if site_count == 1 { "" } else { "s" };
format!(" ({site_count} site{s})")
} else {
String::new()
};
// Only show suffix if prefix + suffix fit within width.
// Otherwise drop it to avoid overflow on narrow terminals.
let suffix_fits = prefix.len() + suffix.len() < w;
let effective_suffix = if suffix_fits { &suffix } else { "" };
let query_budget = w
.saturating_sub(prefix.len())
.saturating_sub(effective_suffix.len());
let display_query = truncate_str(&self.query, query_budget);
let mut spans = vec![
Span::styled(prefix, bold_style),
Span::styled(display_query, query_style),
];
if !effective_suffix.is_empty() {
spans.push(Span::styled(effective_suffix.to_string(), theme.dim()));
}
Line::from(spans)
}
None => {
// Expanded: full query, no suffix.
Line::from(vec![
Span::styled(prefix, bold_style),
Span::styled(self.query.clone(), query_style),
])
}
}
}
/// Header line with only the query span selectable (exclude label prefix/suffix).
fn header_block_line(&self, line: Line<'static>) -> BlockLine {
// Spans: [prefix, query, optional_suffix] — only the query (index 1).
let query_end = 2.min(line.spans.len()).max(1);
BlockLine {
selectable: Selectable::Spans(1..query_end),
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: Some(self.query.clone()),
content: line,
..Default::default()
}
}
/// Unique domain names from citations, deduplicated and order-preserved.
fn unique_domains(&self) -> Vec<String> {
let mut seen = HashSet::new();
self.citations
.iter()
.filter_map(|url| extract_domain(url))
.filter(|d| seen.insert(d.clone()))
.collect()
}
/// Build the sources summary line from citations.
///
/// Extracts domain names from URLs and renders a compact one-liner:
/// `Sources: stripe.com, react.dev, stackoverflow.com (+2 more)`
fn sources_line(&self, theme: &Theme) -> Option<Line<'static>> {
let unique = self.unique_domains();
if unique.is_empty() {
return None;
}
let label_style = theme.muted();
let value_style = theme.primary();
let mut spans: Vec<Span<'static>> = vec![Span::styled(" Sources: ", label_style)];
let shown = unique.len().min(MAX_INLINE_SOURCES);
for (i, domain) in unique.iter().take(shown).enumerate() {
if i > 0 {
spans.push(Span::styled(", ", label_style));
}
spans.push(Span::styled(domain.clone(), value_style));
}
let remaining = unique.len().saturating_sub(MAX_INLINE_SOURCES);
if remaining > 0 {
spans.push(Span::styled(format!(" (+{remaining} more)"), label_style));
}
Some(Line::from(spans))
}
}
/// Extract the host/domain from a URL for display purposes.
fn extract_domain(raw: &str) -> Option<String> {
url::Url::parse(raw)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_owned()))
}
impl BlockContent for WebSearchToolCallBlock {
fn output(&self, ctx: &BlockContext) -> BlockOutput {
let theme = Theme::current();
let muted_collapsed =
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
match ctx.mode {
DisplayMode::Collapsed => BlockOutput {
lines: vec![self.header_block_line(self.header_line(
&theme,
muted_collapsed,
Some(ctx.content_width()),
))],
},
DisplayMode::Truncated | DisplayMode::Expanded => {
let header = self.header_line(&theme, false, None);
let wrapped = crate::render::wrapping::wrap_header_flush(
header,
ctx.width as usize,
ctx.bullet_indent(),
);
// Header lines: label prefix excluded, query selectable.
let mut lines: Vec<BlockLine> = wrapped
.into_iter()
.enumerate()
.map(|(i, line)| {
let total = line.spans.len();
BlockLine {
selectable: Selectable::Spans(1..total),
selection_range: Some(TOOL_HEADER_RANGE),
selection_text: if i == 0 {
Some(self.query.clone())
} else {
None
},
joiner: if i == 0 { None } else { Some(" ".to_string()) },
content: line,
..Default::default()
}
})
.collect();
// Content preview with bg_dark background, capped at
// MAX_INLINE_LINES. Full content is available via the
// fullscreen viewer (Enter/o).
if let Some(ref content) = self.content {
lines.push(BlockLine::separator(Line::from("")));
// Top padding inside the content box.
lines
.push(BlockLine::from(Line::from("")).with_panel_background(theme.bg_dark));
let indent = " ";
let content_lines: Vec<&str> = content.lines().collect();
for (i, line) in content_lines.iter().enumerate() {
if i >= MAX_INLINE_LINES {
let remaining = content_lines.len() - MAX_INLINE_LINES;
lines.push(
BlockLine::from(Line::from(Span::styled(
format!(
"{indent}... ({remaining} more lines, press Enter to view)",
),
theme.dim(),
)))
.with_panel_background(theme.bg_dark),
);
break;
}
lines.push(
BlockLine::from(Line::from(Span::styled(
format!("{indent}{line}"),
theme.primary(),
)))
.with_panel_background(theme.bg_dark),
);
}
// Bottom padding inside the content box.
lines
.push(BlockLine::from(Line::from("")).with_panel_background(theme.bg_dark));
} else if let Some(ref err) = self.error {
lines.push(Line::from("").into());
lines.push(
Line::from(Span::styled(
format!(" {err}"),
theme.fg(theme.accent_error),
))
.into(),
);
} else if !self.is_x_search {
lines.push(Line::from("").into());
lines.push(Line::from(Span::styled(" (no content)", theme.muted())).into());
}
// Sources summary line (after content, matching fullscreen order).
if let Some(sources) = self.sources_line(&theme) {
lines.push(Line::from("").into());
lines.push(sources.into());
}
BlockOutput { lines }
}
}
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if ctx.mode == DisplayMode::Collapsed {
return None;
}
let theme = Theme::current();
if self.error.is_some() {
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.is_running {
Some(AccentStyle::animated(theme.accent_running))
} else {
Some(AccentStyle::static_color(theme.accent_tool))
}
}
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if self.error.is_some() {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.mode == DisplayMode::Collapsed {
None
} else {
self.accent(ctx)
}
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false
}
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
fn has_raw_mode(&self) -> bool {
false
}
fn is_foldable(&self) -> bool {
self.error.is_none() && self.content.is_some() && !self.is_x_search
}
fn default_display_mode(&self) -> DisplayMode {
DisplayMode::Collapsed
}
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
_ => DisplayMode::Collapsed,
}
}
fn preamble(&self, _ctx: &BlockContext) -> Option<Text<'static>> {
let theme = Theme::current();
Some(Text::from(vec![self.header_line(&theme, false, None)]))
}
}
File diff suppressed because it is too large. Load diff