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:
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("e, 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");
|
||||
}
|
||||
}
|
||||
+8
@@ -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;
|
||||
+8
@@ -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;
|
||||
+13
@@ -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();
|
||||
+9
@@ -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
|
||||
+9
@@ -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
|
||||
+8
@@ -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
|
||||
+9
@@ -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
|
||||
+8
@@ -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
|
||||
+8
@@ -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
Reference in new issue
Block a user