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:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -0,0 +1,833 @@
|
||||
//! ScrollbackEntry - wraps a block with display state.
|
||||
|
||||
use std::cell::{Ref, RefCell};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
use super::block::{BlockContent, RenderBlock};
|
||||
use super::types::{BlockContext, BlockOutput, DisplayMode, RenderedBlockOutput};
|
||||
use crate::appearance::AppearanceConfig;
|
||||
use crate::theme::{ThemeKind, cache as theme_cache};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CachedOutput {
|
||||
width: u16,
|
||||
raw: bool,
|
||||
theme: ThemeKind,
|
||||
is_selected: bool,
|
||||
cwd: Option<PathBuf>,
|
||||
rendered: RenderedBlockOutput,
|
||||
}
|
||||
|
||||
/// Cached truncated-mode height: `(width, raw, theme, cwd, height)`.
|
||||
///
|
||||
/// Computing the truncated-mode height requires calling `block.output()` with
|
||||
/// the display mode forced to `Truncated`, which for Edit blocks triggers full
|
||||
/// syntect syntax highlighting and for Markdown blocks triggers full word-wrap.
|
||||
/// During heavy streaming on a busy subagent, the layout cache is invalidated
|
||||
/// every time a new block is pushed, so this height is recomputed for every
|
||||
/// entry on every redraw without a per-entry cache. We only need the line
|
||||
/// count, so this caches just the resulting `u16` height. `cwd` is keyed
|
||||
/// because Expanded/Truncated Edit/Read header wrap can change absolute↔relative.
|
||||
type CachedTruncatedHeight = (u16, bool, ThemeKind, Option<PathBuf>, u16);
|
||||
|
||||
/// Unique identifier for a scrollback entry.
|
||||
///
|
||||
/// EntryIds are stable across mutations - they won't become invalid if other
|
||||
/// entries are added or removed. Use this for external handles to entries
|
||||
/// (e.g., streaming tasks that need to push chunks to a specific block).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct EntryId(u64);
|
||||
|
||||
impl EntryId {
|
||||
/// Create a new EntryId with a specific value.
|
||||
///
|
||||
/// Note: For production use, prefer getting EntryId from `ScrollbackState::push()`
|
||||
/// which assigns IDs automatically. This is mainly for placeholders/testing.
|
||||
pub fn new(id: u64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
/// Get the raw ID value.
|
||||
pub fn value(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A scrollback entry: block content + display state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollbackEntry {
|
||||
/// Unique identifier for this entry.
|
||||
pub id: EntryId,
|
||||
|
||||
/// The block content.
|
||||
pub block: RenderBlock,
|
||||
|
||||
/// Whether block is still running (for animation, auto-collapse).
|
||||
pub is_running: bool,
|
||||
|
||||
/// Whether this entry is currently waiting on user input (permission
|
||||
/// prompt, ask-user-question, etc.). When true, the renderer replaces
|
||||
/// the wave "loading" animation with a pulsing-circle bullet to draw
|
||||
/// attention without implying active work.
|
||||
///
|
||||
/// Maintained by `AgentView` from `permission_queue` and
|
||||
/// `question_view` state via `ScrollbackState::set_pending_user_input`.
|
||||
pub is_pending_user_input: bool,
|
||||
|
||||
/// Current display mode.
|
||||
pub display_mode: DisplayMode,
|
||||
|
||||
pub display_mode_pinned: bool,
|
||||
|
||||
/// Raw mode: if true and block has_raw_mode(), render markdown as raw.
|
||||
pub raw: bool,
|
||||
|
||||
/// Hook data attached to this entry (only meaningful for ToolCall blocks).
|
||||
pub hook_data: Option<super::blocks::tool::ToolCallHookData>,
|
||||
|
||||
/// When this entry was created (local time).
|
||||
pub created_at: Option<DateTime<Local>>,
|
||||
|
||||
/// When this entry finished running (monotonic). Used by the renderer
|
||||
/// to flash the accent briefly after completion.
|
||||
pub finished_at: Option<std::time::Instant>,
|
||||
|
||||
/// Cached output and its render key.
|
||||
/// Interior-mutable so EntryRenderer (which holds `&self`) can populate and
|
||||
/// read the cache without &mut self.
|
||||
///
|
||||
/// The `is_selected` key is only meaningful for blocks whose output varies
|
||||
/// by selection state (currently only `UserPrompt`). For all other blocks
|
||||
/// the stored value is always `false` regardless of actual selection,
|
||||
/// preventing unnecessary cache misses on selection changes. `cwd` is
|
||||
/// keyed so Expanded tool path paint (relative vs absolute) invalidates.
|
||||
cached_output: RefCell<Option<CachedOutput>>,
|
||||
|
||||
/// Cached truncated-mode height. See [`CachedTruncatedHeight`] for why
|
||||
/// this needs its own cache separate from `cached_output`.
|
||||
///
|
||||
/// Populated lazily by `ensure_truncated_height_cached`. Cleared by
|
||||
/// `invalidate_cache` together with `cached_output`.
|
||||
cached_truncated_height: RefCell<Option<CachedTruncatedHeight>>,
|
||||
|
||||
/// Cached cheap height-estimate line count: `(content_width, lines)`. Lets a
|
||||
/// same-width rebuild reuse the estimate instead of re-cloning the block's
|
||||
/// source text. Cleared by `invalidate_cache`.
|
||||
cached_estimate_lines: RefCell<Option<(u16, u16)>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EffectiveOutputKind {
|
||||
Cached,
|
||||
Selected,
|
||||
}
|
||||
|
||||
pub enum EffectiveOutputData<'a> {
|
||||
Borrowed(Ref<'a, BlockOutput>),
|
||||
Owned(BlockOutput),
|
||||
}
|
||||
|
||||
impl EffectiveOutputData<'_> {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn as_ref(&self) -> &BlockOutput {
|
||||
match self {
|
||||
Self::Borrowed(output) => output,
|
||||
Self::Owned(output) => output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EffectiveOutput<'a> {
|
||||
pub ctx: BlockContext,
|
||||
pub output: EffectiveOutputData<'a>,
|
||||
pub has_vpad: bool,
|
||||
pub kind: EffectiveOutputKind,
|
||||
}
|
||||
|
||||
impl EffectiveOutput<'_> {
|
||||
pub fn output(&self) -> &BlockOutput {
|
||||
self.output.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollbackEntry {
|
||||
/// Create a new entry with expanded display mode.
|
||||
///
|
||||
/// Note: For production use, prefer `ScrollbackState::push()` which assigns
|
||||
/// the EntryId automatically. This constructor is mainly for testing.
|
||||
pub fn new(block: RenderBlock) -> Self {
|
||||
Self::with_id(EntryId(0), block)
|
||||
}
|
||||
|
||||
/// Create a new entry with a specific ID.
|
||||
///
|
||||
/// The display mode is set to the block's default (Expanded for most,
|
||||
/// Truncated for thinking blocks).
|
||||
pub fn with_id(id: EntryId, block: RenderBlock) -> Self {
|
||||
let display_mode = block.default_display_mode();
|
||||
Self {
|
||||
id,
|
||||
block,
|
||||
is_running: false,
|
||||
is_pending_user_input: false,
|
||||
display_mode,
|
||||
display_mode_pinned: false,
|
||||
raw: false,
|
||||
hook_data: None,
|
||||
created_at: Some(Local::now()),
|
||||
finished_at: None,
|
||||
cached_output: RefCell::new(None),
|
||||
cached_truncated_height: RefCell::new(None),
|
||||
cached_estimate_lines: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new entry that is currently running.
|
||||
///
|
||||
/// Note: For production use, prefer `ScrollbackState::push()` which assigns
|
||||
/// the EntryId automatically. This constructor is mainly for testing.
|
||||
pub fn running(block: RenderBlock) -> Self {
|
||||
Self::running_with_id(EntryId(0), block)
|
||||
}
|
||||
|
||||
/// Create a new running entry with a specific ID.
|
||||
///
|
||||
/// The display mode is set to the block's default (Expanded for most,
|
||||
/// Truncated for thinking blocks).
|
||||
pub fn running_with_id(id: EntryId, block: RenderBlock) -> Self {
|
||||
let display_mode = block.default_display_mode();
|
||||
Self {
|
||||
id,
|
||||
block,
|
||||
is_running: true,
|
||||
is_pending_user_input: false,
|
||||
display_mode,
|
||||
display_mode_pinned: false,
|
||||
raw: false,
|
||||
hook_data: None,
|
||||
created_at: Some(Local::now()),
|
||||
finished_at: None,
|
||||
cached_output: RefCell::new(None),
|
||||
cached_truncated_height: RefCell::new(None),
|
||||
cached_estimate_lines: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the display mode (builder pattern).
|
||||
pub fn with_display_mode(mut self, mode: DisplayMode) -> Self {
|
||||
self.display_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Toggle raw mode if the block supports it.
|
||||
pub fn toggle_raw(&mut self) {
|
||||
if self.block.has_raw_mode() {
|
||||
self.raw = !self.raw;
|
||||
self.block.set_raw_mode(self.raw);
|
||||
self.invalidate_cache();
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle between display modes.
|
||||
///
|
||||
/// Most blocks toggle between Collapsed and Expanded.
|
||||
/// Some blocks (like thinking) cycle through 3 modes.
|
||||
pub fn toggle_fold(&mut self) {
|
||||
if self.is_foldable() {
|
||||
if self.block.is_foldable() {
|
||||
self.display_mode = self
|
||||
.block
|
||||
.next_fold_mode(self.display_mode, self.is_running);
|
||||
} else {
|
||||
// Block itself isn't foldable but hooks make it foldable:
|
||||
// toggle between Collapsed and Expanded.
|
||||
self.display_mode = match self.display_mode {
|
||||
DisplayMode::Collapsed => DisplayMode::Expanded,
|
||||
_ => DisplayMode::Collapsed,
|
||||
};
|
||||
}
|
||||
self.invalidate_cache();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current display mode.
|
||||
pub fn display_mode(&self) -> DisplayMode {
|
||||
self.display_mode
|
||||
}
|
||||
|
||||
/// Set the display mode.
|
||||
pub fn set_display_mode(&mut self, mode: DisplayMode) {
|
||||
if self.display_mode != mode {
|
||||
self.display_mode = mode;
|
||||
self.invalidate_cache();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the block as completed (no longer running).
|
||||
///
|
||||
/// Also clears `is_pending_user_input` since a completed tool cannot
|
||||
/// be waiting on a user response anymore.
|
||||
pub fn mark_completed(&mut self) {
|
||||
self.is_running = false;
|
||||
self.is_pending_user_input = false;
|
||||
self.invalidate_cache();
|
||||
}
|
||||
|
||||
/// Invalidate cached output.
|
||||
pub fn invalidate_cache(&mut self) {
|
||||
*self.cached_output.borrow_mut() = None;
|
||||
*self.cached_truncated_height.borrow_mut() = None;
|
||||
*self.cached_estimate_lines.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// Drop the heavyweight cached render output (and the block's internal
|
||||
/// rebuildable caches) while KEEPING the cheap height caches, so layout —
|
||||
/// entry heights, scroll position — is untouched. Re-rendering happens
|
||||
/// transparently if the entry scrolls back into view.
|
||||
///
|
||||
/// Returns `true` when something was actually dropped (for sweep stats).
|
||||
pub(crate) fn evict_render_cache(&self) -> bool {
|
||||
let had_output = self.cached_output.borrow().is_some();
|
||||
if had_output {
|
||||
*self.cached_output.borrow_mut() = None;
|
||||
}
|
||||
self.block.evict_render_caches();
|
||||
had_output
|
||||
}
|
||||
|
||||
/// Memoized cheap height-estimate line count for `content_width`, if cached.
|
||||
pub fn cached_estimate_lines(&self, content_width: u16) -> Option<u16> {
|
||||
self.cached_estimate_lines
|
||||
.borrow()
|
||||
.filter(|&(w, _)| w == content_width)
|
||||
.map(|(_, lines)| lines)
|
||||
}
|
||||
|
||||
/// Store the cheap height-estimate line count for `content_width`.
|
||||
pub fn store_estimate_lines(&self, content_width: u16, lines: u16) {
|
||||
*self.cached_estimate_lines.borrow_mut() = Some((content_width, lines));
|
||||
}
|
||||
|
||||
/// Whether this entry's laid-out output is cached. Lazy-layout tests use this
|
||||
/// to assert off-screen entries aren't rendered: `desired_height` populates
|
||||
/// the cache, the cheap estimate does not.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_cached_output(&self) -> bool {
|
||||
self.cached_output.borrow().is_some()
|
||||
}
|
||||
|
||||
/// Ensure the cache is populated for the given width/appearance/selection.
|
||||
///
|
||||
/// This works with `&self` (via RefCell) so `EntryRenderer` can call it
|
||||
/// without needing `&mut self`. After calling this, use `cached_output_ref()`
|
||||
/// to borrow the output.
|
||||
pub fn ensure_cached(
|
||||
&self,
|
||||
width: u16,
|
||||
appearance: &AppearanceConfig,
|
||||
is_selected: bool,
|
||||
cwd: Option<&Path>,
|
||||
) {
|
||||
// UserPrompt, ToolCall, Thinking, BgTask and Subagent vary their
|
||||
// output() based on is_selected — for all other blocks the output
|
||||
// is identical regardless of selection state. Normalize to false
|
||||
// for those blocks so selection changes don't thrash the cache.
|
||||
let effective_selected = is_selected
|
||||
&& (self.block.is_user_prompt()
|
||||
|| self.block.is_tool_call()
|
||||
|| self.block.is_thinking()
|
||||
|| self.block.is_bg_task()
|
||||
|| self.block.is_subagent());
|
||||
|
||||
let current_theme = theme_cache::current_kind();
|
||||
let cwd_key = cwd.map(|p| p.to_path_buf());
|
||||
{
|
||||
let cache = self.cached_output.borrow();
|
||||
if let Some(cached) = cache.as_ref()
|
||||
&& cached.width == width
|
||||
&& cached.raw == self.raw
|
||||
&& cached.theme == current_theme
|
||||
&& cached.is_selected == effective_selected
|
||||
&& cached.cwd == cwd_key
|
||||
{
|
||||
return; // cache hit
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss — regenerate
|
||||
let ctx = BlockContext {
|
||||
mode: self.display_mode,
|
||||
is_running: self.is_running,
|
||||
width,
|
||||
raw: self.raw,
|
||||
max_lines: None,
|
||||
appearance: appearance.clone(),
|
||||
is_selected: effective_selected,
|
||||
cwd: cwd_key.clone(),
|
||||
};
|
||||
let rendered = self.rendered_output_with_hooks(&ctx);
|
||||
*self.cached_output.borrow_mut() = Some(CachedOutput {
|
||||
width,
|
||||
raw: self.raw,
|
||||
theme: current_theme,
|
||||
is_selected: effective_selected,
|
||||
cwd: cwd_key,
|
||||
rendered,
|
||||
});
|
||||
}
|
||||
|
||||
/// Borrow the cached output.
|
||||
///
|
||||
/// Panics if `ensure_cached` was not called first for the current width.
|
||||
pub fn cached_output_ref(&self) -> Ref<'_, BlockOutput> {
|
||||
Ref::map(self.cached_output.borrow(), |opt| {
|
||||
&opt.as_ref()
|
||||
.expect("ensure_cached must be called first")
|
||||
.rendered
|
||||
.output
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn cached_rendered_output_ref(&self) -> Ref<'_, RenderedBlockOutput> {
|
||||
Ref::map(self.cached_output.borrow(), |opt| {
|
||||
&opt.as_ref()
|
||||
.expect("ensure_cached must be called first")
|
||||
.rendered
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensure the truncated-mode height cache is populated, returning the height.
|
||||
///
|
||||
/// Returns the line count (including vpad) the entry would occupy if
|
||||
/// rendered in `DisplayMode::Truncated`. Used by the layout cache to
|
||||
/// precompute sticky header heights for every entry.
|
||||
///
|
||||
/// Without this cache, `block.output(&ctx)` runs uncached on every layout
|
||||
/// rebuild; for Edit blocks that triggers full syntect highlighting and
|
||||
/// for Markdown blocks a full word-wrap. During heavy subagent streaming
|
||||
/// the layout cache is invalidated on every new block (see
|
||||
/// `ScrollbackState::push`), so this would otherwise re-highlight every
|
||||
/// entry on every redraw.
|
||||
///
|
||||
/// The cache key is `(content_width, raw, theme, cwd)`. `is_selected` is
|
||||
/// intentionally excluded because line count never depends on selection
|
||||
/// styling. Cleared together with `cached_output` by `invalidate_cache`.
|
||||
pub fn ensure_truncated_height_cached(
|
||||
&self,
|
||||
content_width: u16,
|
||||
appearance: &AppearanceConfig,
|
||||
cwd: Option<&Path>,
|
||||
) -> u16 {
|
||||
let current_theme = theme_cache::current_kind();
|
||||
let cwd_key = cwd.map(|p| p.to_path_buf());
|
||||
{
|
||||
let cache = self.cached_truncated_height.borrow();
|
||||
if let Some(&(cached_width, cached_raw, cached_theme, ref cached_cwd, height)) =
|
||||
cache.as_ref()
|
||||
&& cached_width == content_width
|
||||
&& cached_raw == self.raw
|
||||
&& cached_theme == current_theme
|
||||
&& *cached_cwd == cwd_key
|
||||
{
|
||||
return height;
|
||||
}
|
||||
}
|
||||
|
||||
// Force Truncated for sticky-header height; include cwd (header wrap).
|
||||
let ctx = self.context_with_mode(content_width, DisplayMode::Truncated, appearance, cwd);
|
||||
let output = self.block.output(&ctx);
|
||||
let has_vpad = self.block.has_vpad(&ctx);
|
||||
let content_height = output.len() as u16;
|
||||
let vpad = if has_vpad { 2 } else { 0 };
|
||||
let height = content_height + vpad;
|
||||
|
||||
*self.cached_truncated_height.borrow_mut() =
|
||||
Some((content_width, self.raw, current_theme, cwd_key, height));
|
||||
height
|
||||
}
|
||||
|
||||
pub fn effective_output(
|
||||
&self,
|
||||
width: u16,
|
||||
appearance: &AppearanceConfig,
|
||||
is_selected: bool,
|
||||
cwd: Option<&Path>,
|
||||
) -> EffectiveOutput<'_> {
|
||||
let mut ctx = self.context(width, appearance, cwd);
|
||||
ctx.is_selected = is_selected;
|
||||
|
||||
let has_vpad = self.block.has_vpad(&ctx);
|
||||
self.ensure_cached(width, appearance, is_selected, cwd);
|
||||
EffectiveOutput {
|
||||
ctx,
|
||||
output: EffectiveOutputData::Borrowed(self.cached_output_ref()),
|
||||
has_vpad,
|
||||
kind: if is_selected {
|
||||
EffectiveOutputKind::Selected
|
||||
} else {
|
||||
EffectiveOutputKind::Cached
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the block output, using cache if valid.
|
||||
/// Note: cache doesn't track appearance - caller should invalidate on appearance change.
|
||||
pub fn output(
|
||||
&mut self,
|
||||
width: u16,
|
||||
appearance: &AppearanceConfig,
|
||||
cwd: Option<&Path>,
|
||||
) -> &BlockOutput {
|
||||
self.ensure_cached(width, appearance, false, cwd);
|
||||
// We know the cache is populated, so unwrap through the RefCell
|
||||
// Safety: we just populated the cache above
|
||||
let cache = self.cached_output.get_mut();
|
||||
&cache.as_ref().unwrap().rendered.output
|
||||
}
|
||||
|
||||
/// Get a BlockContext for this entry.
|
||||
pub fn context(
|
||||
&self,
|
||||
width: u16,
|
||||
appearance: &AppearanceConfig,
|
||||
cwd: Option<&Path>,
|
||||
) -> BlockContext {
|
||||
BlockContext {
|
||||
mode: self.display_mode,
|
||||
is_running: self.is_running,
|
||||
width,
|
||||
raw: self.raw,
|
||||
max_lines: None,
|
||||
appearance: appearance.clone(),
|
||||
is_selected: false,
|
||||
cwd: cwd.map(|p| p.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this entry is foldable — considers both the block and attached hooks.
|
||||
pub fn is_foldable(&self) -> bool {
|
||||
self.block.is_foldable() || self.hook_data.as_ref().is_some_and(|hd| hd.has_content())
|
||||
}
|
||||
|
||||
/// True for a thinking block hidden by the Appearance toggle. Takes the
|
||||
/// flag as a param so hot layout loops can hoist the cache read.
|
||||
pub fn is_hidden_thinking(&self, show_thinking: bool) -> bool {
|
||||
self.block.is_thinking() && !show_thinking
|
||||
}
|
||||
|
||||
fn rendered_output_with_hooks(&self, ctx: &BlockContext) -> RenderedBlockOutput {
|
||||
let mut rendered = self.block.rendered_output(ctx);
|
||||
let output = &mut rendered.output;
|
||||
if let Some(ref hd) = self.hook_data {
|
||||
use super::blocks::tool::ToolCallBlock;
|
||||
use super::blocks::tool::hook::{
|
||||
render_hook_separator, render_hooks_detail, render_hooks_for_mode,
|
||||
render_hooks_inline_suffix,
|
||||
};
|
||||
let is_lifecycle = matches!(
|
||||
self.block,
|
||||
super::block::RenderBlock::ToolCall(ToolCallBlock::Lifecycle(_))
|
||||
);
|
||||
match ctx.mode {
|
||||
super::types::DisplayMode::Collapsed => {
|
||||
// Append [hooks: N/M] to the first (header) line for all events
|
||||
if let Some(suffix_spans) = render_hooks_inline_suffix(hd)
|
||||
&& let Some(first_line) = output.lines.first_mut()
|
||||
{
|
||||
first_line.content.spans.extend(suffix_spans);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Expanded: separator + separate sections
|
||||
let pre = render_hooks_for_mode("pre_tool_use", &hd.pre_hooks, ctx.mode);
|
||||
let post = render_hooks_for_mode("post_tool_use", &hd.post_hooks, ctx.mode);
|
||||
let has_any = !pre.is_empty() || !post.is_empty() || !hd.lifecycle.is_empty();
|
||||
// Lifecycle blocks already show the event name as the block header,
|
||||
// so skip the separator (no tool output above) and the section header.
|
||||
if has_any && !is_lifecycle {
|
||||
output.lines.push(render_hook_separator());
|
||||
}
|
||||
output.lines.extend(pre);
|
||||
output.lines.extend(post);
|
||||
for (event_name, runs) in &hd.lifecycle {
|
||||
if is_lifecycle {
|
||||
output.lines.extend(render_hooks_detail(runs, ctx.mode));
|
||||
} else {
|
||||
output
|
||||
.lines
|
||||
.extend(render_hooks_for_mode(event_name, runs, ctx.mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
/// Produce block output with hook lines injected (tool first, then hooks).
|
||||
pub fn output_with_hooks(&self, ctx: &BlockContext) -> BlockOutput {
|
||||
self.rendered_output_with_hooks(ctx).output
|
||||
}
|
||||
|
||||
/// Get a BlockContext for this entry with a row budget.
|
||||
pub fn context_with_budget(
|
||||
&self,
|
||||
width: u16,
|
||||
max_lines: u16,
|
||||
appearance: &AppearanceConfig,
|
||||
cwd: Option<&Path>,
|
||||
) -> BlockContext {
|
||||
BlockContext {
|
||||
mode: self.display_mode,
|
||||
is_running: self.is_running,
|
||||
width,
|
||||
raw: self.raw,
|
||||
max_lines: Some(max_lines),
|
||||
appearance: appearance.clone(),
|
||||
is_selected: false,
|
||||
cwd: cwd.map(|p| p.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a BlockContext with a specific display mode override.
|
||||
///
|
||||
/// This is used to compute heights for different display modes without
|
||||
/// modifying the entry's actual display_mode (avoiding cloning).
|
||||
pub fn context_with_mode(
|
||||
&self,
|
||||
width: u16,
|
||||
mode: DisplayMode,
|
||||
appearance: &AppearanceConfig,
|
||||
cwd: Option<&Path>,
|
||||
) -> BlockContext {
|
||||
BlockContext {
|
||||
mode,
|
||||
is_running: self.is_running,
|
||||
width,
|
||||
raw: self.raw,
|
||||
max_lines: None,
|
||||
appearance: appearance.clone(),
|
||||
is_selected: false,
|
||||
cwd: cwd.map(|p| p.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a BlockContext with both display mode override AND row budget.
|
||||
///
|
||||
/// This is used for rendering sticky headers where we want:
|
||||
/// - Expanded content (not collapsed summary)
|
||||
/// - But truncated to a specific number of lines
|
||||
///
|
||||
/// This avoids mutating the entry's display_mode during render.
|
||||
pub fn context_with_mode_and_budget(
|
||||
&self,
|
||||
width: u16,
|
||||
mode: DisplayMode,
|
||||
max_lines: u16,
|
||||
appearance: &AppearanceConfig,
|
||||
is_selected: bool,
|
||||
cwd: Option<&Path>,
|
||||
) -> BlockContext {
|
||||
BlockContext {
|
||||
mode,
|
||||
is_running: self.is_running,
|
||||
width,
|
||||
raw: self.raw,
|
||||
max_lines: Some(max_lines),
|
||||
appearance: appearance.clone(),
|
||||
is_selected,
|
||||
cwd: cwd.map(|p| p.to_path_buf()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn test_entry_new() {
|
||||
let entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue));
|
||||
assert!(!entry.is_running);
|
||||
assert!(matches!(entry.display_mode, DisplayMode::Expanded));
|
||||
assert!(!entry.display_mode_pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_running() {
|
||||
let entry = ScrollbackEntry::running(RenderBlock::stub("test", Color::Blue));
|
||||
assert!(entry.is_running);
|
||||
assert!(!entry.display_mode_pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_toggle_fold() {
|
||||
let mut entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue));
|
||||
assert!(matches!(entry.display_mode, DisplayMode::Expanded));
|
||||
|
||||
entry.toggle_fold();
|
||||
assert!(matches!(entry.display_mode, DisplayMode::Collapsed));
|
||||
|
||||
entry.toggle_fold();
|
||||
assert!(matches!(entry.display_mode, DisplayMode::Expanded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_cache() {
|
||||
let mut entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue));
|
||||
let appearance = AppearanceConfig::default();
|
||||
|
||||
let output1 = entry.output(80, &appearance, None);
|
||||
assert_eq!(output1.len(), 1);
|
||||
assert!(entry.cached_rendered_output_ref().boundaries.is_empty());
|
||||
|
||||
let output2 = entry.output(80, &appearance, None);
|
||||
assert_eq!(output2.len(), 1);
|
||||
|
||||
let output3 = entry.output(100, &appearance, None);
|
||||
assert_eq!(output3.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_boundary_sidecar_tracks_cached_output_mode() {
|
||||
let mut entry = ScrollbackEntry::new(RenderBlock::edit(" foo.rs", None));
|
||||
let appearance = AppearanceConfig::default();
|
||||
|
||||
entry.set_display_mode(DisplayMode::Expanded);
|
||||
entry.ensure_cached(8, &appearance, false, None);
|
||||
{
|
||||
let rendered = entry.cached_rendered_output_ref();
|
||||
assert!(!rendered.output.lines.is_empty());
|
||||
assert!(!rendered.boundaries.is_empty());
|
||||
}
|
||||
entry.ensure_cached(8, &appearance, true, None);
|
||||
assert!(!entry.cached_rendered_output_ref().boundaries.is_empty());
|
||||
|
||||
entry.set_display_mode(DisplayMode::Collapsed);
|
||||
entry.ensure_cached(8, &appearance, false, None);
|
||||
assert!(entry.cached_rendered_output_ref().boundaries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_output_uses_cached_branch_when_not_selected() {
|
||||
let entry = ScrollbackEntry::new(RenderBlock::user_prompt("hello"));
|
||||
let appearance = AppearanceConfig::default();
|
||||
let effective = entry.effective_output(80, &appearance, false, None);
|
||||
|
||||
assert_eq!(effective.kind, EffectiveOutputKind::Cached);
|
||||
assert_eq!(effective.output().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_output_uses_selected_branch_when_selected() {
|
||||
let entry = ScrollbackEntry::new(RenderBlock::user_prompt("hello"));
|
||||
let appearance = AppearanceConfig::default();
|
||||
let effective = entry.effective_output(80, &appearance, true, None);
|
||||
|
||||
assert_eq!(effective.kind, EffectiveOutputKind::Selected);
|
||||
assert!(effective.ctx.is_selected);
|
||||
assert_eq!(effective.output().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncated_height_cache_populates_on_first_call() {
|
||||
let entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));
|
||||
let appearance = AppearanceConfig::default();
|
||||
|
||||
assert!(entry.cached_truncated_height.borrow().is_none());
|
||||
|
||||
let height = entry.ensure_truncated_height_cached(80, &appearance, None);
|
||||
assert!(height > 0);
|
||||
assert!(entry.cached_truncated_height.borrow().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncated_height_cache_hits_when_key_unchanged() {
|
||||
let entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));
|
||||
let appearance = AppearanceConfig::default();
|
||||
|
||||
let h1 = entry.ensure_truncated_height_cached(80, &appearance, None);
|
||||
let cached_before = entry.cached_truncated_height.borrow().clone();
|
||||
let h2 = entry.ensure_truncated_height_cached(80, &appearance, None);
|
||||
|
||||
assert_eq!(h1, h2);
|
||||
// Cache pointer/value should be unchanged - no recompute happened.
|
||||
assert_eq!(*entry.cached_truncated_height.borrow(), cached_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncated_height_cache_misses_on_width_change() {
|
||||
let entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));
|
||||
let appearance = AppearanceConfig::default();
|
||||
|
||||
let _ = entry.ensure_truncated_height_cached(80, &appearance, None);
|
||||
let cached_at_80 = entry.cached_truncated_height.borrow().clone();
|
||||
let _ = entry.ensure_truncated_height_cached(40, &appearance, None);
|
||||
let cached_at_40 = entry.cached_truncated_height.borrow().clone();
|
||||
|
||||
// Different width should overwrite the cache entry.
|
||||
assert_ne!(cached_at_80, cached_at_40);
|
||||
assert_eq!(cached_at_40.unwrap().0, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalidate_cache_clears_truncated_height_cache() {
|
||||
let mut entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));
|
||||
let appearance = AppearanceConfig::default();
|
||||
|
||||
let _ = entry.ensure_truncated_height_cached(80, &appearance, None);
|
||||
assert!(entry.cached_truncated_height.borrow().is_some());
|
||||
|
||||
entry.invalidate_cache();
|
||||
assert!(entry.cached_truncated_height.borrow().is_none());
|
||||
assert!(entry.cached_output.borrow().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_lines_cache_stores_keyed_on_width_and_clears_on_invalidate() {
|
||||
let mut entry = ScrollbackEntry::new(RenderBlock::stub("hello", Color::Blue));
|
||||
|
||||
assert_eq!(entry.cached_estimate_lines(40), None);
|
||||
entry.store_estimate_lines(40, 7);
|
||||
assert_eq!(entry.cached_estimate_lines(40), Some(7));
|
||||
// Keyed on content width: a different width is a miss.
|
||||
assert_eq!(entry.cached_estimate_lines(41), None);
|
||||
|
||||
// invalidate_cache clears the estimate too.
|
||||
entry.invalidate_cache();
|
||||
assert_eq!(entry.cached_estimate_lines(40), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_new_has_timestamp() {
|
||||
let entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue));
|
||||
assert!(
|
||||
entry.created_at.is_some(),
|
||||
"ScrollbackEntry::new() should set created_at to Some"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_running_has_timestamp() {
|
||||
let entry = ScrollbackEntry::running(RenderBlock::stub("test", Color::Blue));
|
||||
assert!(
|
||||
entry.created_at.is_some(),
|
||||
"ScrollbackEntry::running() should set created_at to Some"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_with_display_mode_preserves_timestamp() {
|
||||
let entry = ScrollbackEntry::new(RenderBlock::stub("test", Color::Blue))
|
||||
.with_display_mode(DisplayMode::Collapsed);
|
||||
assert!(
|
||||
entry.created_at.is_some(),
|
||||
"with_display_mode() should not clear created_at"
|
||||
);
|
||||
assert_eq!(entry.display_mode, DisplayMode::Collapsed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Pure functions for exporting a conversation transcript as human-readable Markdown.
|
||||
//!
|
||||
//! Used by the `/export` slash command (and its dispatch handler). The converter walks
|
||||
//! `RenderBlock`s and produces `## User` / `## Assistant` / `## Tools` sections with
|
||||
//! compact one-line tool summaries. Non-conversation blocks (system chrome, thinking,
|
||||
//! subagent lifecycle, etc.) are intentionally skipped so the output is useful for
|
||||
//! "continue elsewhere" or archival.
|
||||
|
||||
use super::{RenderBlock, ToolCallBlock};
|
||||
|
||||
/// Convert an iterator of `RenderBlock` references into a Markdown transcript.
|
||||
///
|
||||
/// The output is a clean, readable document suitable for saving or clipboard:
|
||||
/// - `## User` for user prompts (raw text)
|
||||
/// - `## Assistant` for agent responses (prefers raw source Markdown via `copy_text(true)`)
|
||||
/// - `## Tools` section with one-line summaries for every tool call kind
|
||||
///
|
||||
/// Consecutive assistant messages are coalesced under a single header.
|
||||
/// Thinking / system / subagent / credit / etc. blocks are skipped.
|
||||
///
|
||||
/// This function is pure and easily unit-testable with synthetic blocks (including `Stub`).
|
||||
pub fn render_blocks_to_markdown<'a>(blocks: impl IntoIterator<Item = &'a RenderBlock>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut last_was_agent = false;
|
||||
let mut in_tools_section = false;
|
||||
|
||||
for b in blocks {
|
||||
match b {
|
||||
RenderBlock::UserPrompt(u) => {
|
||||
if in_tools_section {
|
||||
out.push('\n');
|
||||
in_tools_section = false;
|
||||
}
|
||||
out.push_str("## User\n\n");
|
||||
out.push_str(&u.copy_text());
|
||||
out.push_str("\n\n");
|
||||
last_was_agent = false;
|
||||
}
|
||||
RenderBlock::AgentMessage(a) => {
|
||||
if !last_was_agent {
|
||||
if in_tools_section {
|
||||
out.push('\n');
|
||||
in_tools_section = false;
|
||||
}
|
||||
out.push_str("## Assistant\n\n");
|
||||
}
|
||||
// Prefer raw source Markdown for fidelity in the exported .md
|
||||
out.push_str(&a.copy_text(true));
|
||||
out.push_str("\n\n");
|
||||
last_was_agent = true;
|
||||
}
|
||||
RenderBlock::ToolCall(tc) => {
|
||||
if !in_tools_section {
|
||||
out.push_str("## Tools\n\n");
|
||||
in_tools_section = true;
|
||||
}
|
||||
out.push_str("- ");
|
||||
out.push_str(&tool_summary(tc));
|
||||
out.push('\n');
|
||||
last_was_agent = false;
|
||||
}
|
||||
// Skip all non-conversation chrome: Thinking, System, SessionEvent, BgTask,
|
||||
// Subagent, Btw, CreditLimit, Stub, etc. Thinking blocks are
|
||||
// treated as intra-Assistant glue (no new header).
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed_len = out.trim_end().len();
|
||||
out.truncate(trimmed_len);
|
||||
out
|
||||
}
|
||||
|
||||
fn tool_summary(tc: &ToolCallBlock) -> String {
|
||||
match tc {
|
||||
ToolCallBlock::Read(r) => {
|
||||
let range = r
|
||||
.line_range
|
||||
.as_ref()
|
||||
.map_or(String::new(), |lr| format!(" ({})", lr));
|
||||
format!("Read: {}{}", r.path, range)
|
||||
}
|
||||
ToolCallBlock::Edit(e) => format!("Edit: {}", e.path),
|
||||
ToolCallBlock::Execute(ex) => {
|
||||
let desc = ex
|
||||
.description
|
||||
.as_deref()
|
||||
.map_or(String::new(), |d| format!(" ({})", d));
|
||||
format!("Execute: {}{}", ex.command, desc)
|
||||
}
|
||||
ToolCallBlock::ListDir(l) => format!("ListDir: {}", l.path),
|
||||
ToolCallBlock::Search(s) => format!("Search: {}", s.pattern),
|
||||
ToolCallBlock::WebFetch(w) => format!("WebFetch: {}", w.url),
|
||||
ToolCallBlock::WebSearch(w) => format!("WebSearch: {}", w.query),
|
||||
ToolCallBlock::UseTool(u) => format!("UseTool: {}", u.tool_name),
|
||||
ToolCallBlock::IntegrationSearch(_) => "IntegrationSearch (MCP tool discovery)".into(),
|
||||
ToolCallBlock::MemorySearch(_) => "MemorySearch".into(),
|
||||
ToolCallBlock::Skill(o) | ToolCallBlock::Other(o) => format!("Tool: {}", o.name),
|
||||
ToolCallBlock::Lifecycle(_) => "Lifecycle event".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_blocks_yield_empty_string() {
|
||||
let out = render_blocks_to_markdown(std::iter::empty::<&RenderBlock>());
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Horizontal layout for scrollback entries.
|
||||
//!
|
||||
//! Defines the column structure shared by all scrollback entries.
|
||||
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
|
||||
use crate::appearance::LayoutConfig;
|
||||
|
||||
/// Horizontal layout columns for scrollback entries.
|
||||
///
|
||||
/// ```text
|
||||
/// │A│PL│ Content │PR│
|
||||
/// │1│ 2│ flex │ 1│
|
||||
/// ```
|
||||
///
|
||||
/// Where:
|
||||
/// - A = Accent line (1 char)
|
||||
/// - PL = Left padding (configurable, default 2)
|
||||
/// - Content = Flexible width
|
||||
/// - PR = Right padding (configurable, default 1)
|
||||
///
|
||||
/// Note: Selection borders are drawn INTO the outer viewport padding,
|
||||
/// not as part of this layout. Scrollbar is handled separately.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HorizontalLayout {
|
||||
/// Accent line column.
|
||||
pub accent: Rect,
|
||||
/// Left padding area (between accent and content).
|
||||
pub left_padding: Rect,
|
||||
/// Main content area.
|
||||
pub content: Rect,
|
||||
/// Right padding area.
|
||||
pub right_padding: Rect,
|
||||
}
|
||||
|
||||
impl HorizontalLayout {
|
||||
/// Accent width is always 1.
|
||||
pub const ACCENT: u16 = 1;
|
||||
|
||||
/// Create layout for the given area with config values.
|
||||
pub fn new(area: Rect, config: &LayoutConfig) -> Self {
|
||||
let [accent, left_padding, content, right_padding] = Layout::horizontal([
|
||||
Constraint::Length(Self::ACCENT),
|
||||
Constraint::Length(config.block_pad_left),
|
||||
Constraint::Min(1), // Content takes remaining space
|
||||
Constraint::Length(config.block_pad_right),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
Self {
|
||||
accent,
|
||||
left_padding,
|
||||
content,
|
||||
right_padding,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create layout with default config (for backwards compatibility).
|
||||
pub fn new_default(area: Rect) -> Self {
|
||||
Self::new(area, &LayoutConfig::default())
|
||||
}
|
||||
|
||||
/// Total chrome width for a given config.
|
||||
pub fn chrome_width(config: &LayoutConfig) -> u16 {
|
||||
Self::ACCENT + config.block_pad_left + config.block_pad_right
|
||||
}
|
||||
|
||||
/// Get the area for rendering entry content (accent through right padding).
|
||||
///
|
||||
/// This is the area passed to `EntryRenderer`.
|
||||
/// Layout: `│A│PL│Content│PR│`
|
||||
pub fn entry_content_area(&self) -> Rect {
|
||||
Rect {
|
||||
x: self.accent.x,
|
||||
y: self.accent.y,
|
||||
width: self.accent.width
|
||||
+ self.left_padding.width
|
||||
+ self.content.width
|
||||
+ self.right_padding.width,
|
||||
height: self.accent.height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the accent column area.
|
||||
pub fn accent_area(&self) -> Rect {
|
||||
self.accent
|
||||
}
|
||||
|
||||
/// Get the content width (for BlockContext).
|
||||
pub fn content_width(&self) -> u16 {
|
||||
self.content.width
|
||||
}
|
||||
|
||||
/// Get the full entry area (same as entry_content_area).
|
||||
pub fn entry_area(&self) -> Rect {
|
||||
self.entry_content_area()
|
||||
}
|
||||
|
||||
/// Get the selection area (extends 1 column into outer padding on both sides).
|
||||
///
|
||||
/// The selection border is drawn INTO the padding areas:
|
||||
/// - Left edge: 1 column before accent (in outer_hpad_left)
|
||||
/// - Right edge: 1 column after right_padding (in gap_left area before scrollbar)
|
||||
///
|
||||
/// Returns the area where selection borders should be drawn.
|
||||
pub fn selection_area(&self) -> Rect {
|
||||
// Selection extends 1 column left of accent into outer padding
|
||||
// and 1 column right of entry into gap_left area
|
||||
let x = self.accent.x.saturating_sub(1);
|
||||
let width = self.entry_content_area().width + 2; // +1 left, +1 right
|
||||
|
||||
Rect {
|
||||
x,
|
||||
y: self.accent.y,
|
||||
width,
|
||||
height: self.accent.height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a row-specific layout (same columns, different y/height).
|
||||
pub fn for_row(&self, y: u16, height: u16) -> Self {
|
||||
Self {
|
||||
accent: Rect {
|
||||
y,
|
||||
height,
|
||||
..self.accent
|
||||
},
|
||||
left_padding: Rect {
|
||||
y,
|
||||
height,
|
||||
..self.left_padding
|
||||
},
|
||||
content: Rect {
|
||||
y,
|
||||
height,
|
||||
..self.content
|
||||
},
|
||||
right_padding: Rect {
|
||||
y,
|
||||
height,
|
||||
..self.right_padding
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_horizontal_layout() {
|
||||
let config = LayoutConfig::default();
|
||||
let area = Rect::new(0, 0, 80, 10);
|
||||
let layout = HorizontalLayout::new(area, &config);
|
||||
|
||||
// Check widths
|
||||
assert_eq!(layout.accent.width, 1);
|
||||
assert_eq!(layout.left_padding.width, config.block_pad_left);
|
||||
assert_eq!(layout.right_padding.width, config.block_pad_right);
|
||||
|
||||
// Content should be 80 - chrome
|
||||
let chrome = HorizontalLayout::chrome_width(&config);
|
||||
assert_eq!(layout.content.width, 80 - chrome);
|
||||
|
||||
// All have same height
|
||||
assert_eq!(layout.accent.height, 10);
|
||||
assert_eq!(layout.content.height, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_content_area() {
|
||||
let config = LayoutConfig::default();
|
||||
let area = Rect::new(5, 10, 80, 20);
|
||||
let layout = HorizontalLayout::new(area, &config);
|
||||
|
||||
let entry_area = layout.entry_content_area();
|
||||
|
||||
// Entry area starts at accent column
|
||||
assert_eq!(entry_area.x, layout.accent.x);
|
||||
// Width includes accent + left_pad + content + right_pad
|
||||
assert_eq!(
|
||||
entry_area.width,
|
||||
1 + config.block_pad_left + layout.content.width + config.block_pad_right
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_row() {
|
||||
let config = LayoutConfig::default();
|
||||
let area = Rect::new(0, 0, 80, 10);
|
||||
let layout = HorizontalLayout::new(area, &config);
|
||||
|
||||
let row_layout = layout.for_row(5, 3);
|
||||
|
||||
assert_eq!(row_layout.accent.y, 5);
|
||||
assert_eq!(row_layout.accent.height, 3);
|
||||
assert_eq!(row_layout.content.y, 5);
|
||||
assert_eq!(row_layout.content.height, 3);
|
||||
// X positions should be unchanged
|
||||
assert_eq!(row_layout.accent.x, layout.accent.x);
|
||||
assert_eq!(row_layout.content.x, layout.content.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_area() {
|
||||
let config = LayoutConfig::default();
|
||||
// Area starts at x=5 (simulating outer padding already applied)
|
||||
let area = Rect::new(5, 10, 80, 20);
|
||||
let layout = HorizontalLayout::new(area, &config);
|
||||
|
||||
let selection = layout.selection_area();
|
||||
|
||||
// Selection area should extend 1 column LEFT of accent into outer padding
|
||||
assert_eq!(selection.x, layout.accent.x - 1);
|
||||
// Width should be entry_content_area width + 2 (1 left, 1 right)
|
||||
assert_eq!(selection.width, layout.entry_content_area().width + 2);
|
||||
// Y and height same as accent
|
||||
assert_eq!(selection.y, layout.accent.y);
|
||||
assert_eq!(selection.height, layout.accent.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_area_at_edge() {
|
||||
let config = LayoutConfig::default();
|
||||
// Area starts at x=0 (no outer padding)
|
||||
let area = Rect::new(0, 0, 80, 10);
|
||||
let layout = HorizontalLayout::new(area, &config);
|
||||
|
||||
let selection = layout.selection_area();
|
||||
|
||||
// Selection at edge should saturate at x=0 (no underflow)
|
||||
assert_eq!(selection.x, 0);
|
||||
// Width is entry width + 2
|
||||
assert_eq!(selection.width, layout.entry_content_area().width + 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! VisibleLinkMap — per-frame map of clickable link regions on screen.
|
||||
//!
|
||||
//! Populated during the scrollback render pass from the `LinkOverlay`
|
||||
//! (markdown hyperlinks) and citation URLs from web_search / web_fetch
|
||||
//! tool blocks. Used by the mouse handler for click-to-open.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::render::osc8::LinkOverlay;
|
||||
|
||||
/// A clickable link region on screen.
|
||||
///
|
||||
/// A single logical link may span multiple screen rows when word-wrap
|
||||
/// splits it. Each row segment is a separate `Rect` in `rects`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VisibleLink {
|
||||
pub rects: Vec<Rect>,
|
||||
pub url: Arc<str>,
|
||||
pub id: Option<u32>,
|
||||
}
|
||||
|
||||
impl VisibleLink {
|
||||
/// Check whether screen position `(col, row)` falls inside any of
|
||||
/// this link's row segments.
|
||||
pub fn contains(&self, col: u16, row: u16) -> bool {
|
||||
self.rects
|
||||
.iter()
|
||||
.any(|r| col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height)
|
||||
}
|
||||
|
||||
/// True when painted cell width equals the URL's display width (bare URL
|
||||
/// text on screen, not a short label or wide citation block).
|
||||
pub fn looks_like_bare_url_text(&self) -> bool {
|
||||
let painted: usize = self.rects.iter().map(|r| usize::from(r.width)).sum();
|
||||
painted == unicode_width::UnicodeWidthStr::width(self.url.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame map of visible link regions, with generation-based staleness.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct VisibleLinkMap {
|
||||
links: Vec<VisibleLink>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl VisibleLinkMap {
|
||||
/// Find the link at a given screen position, if any.
|
||||
pub fn link_at(&self, col: u16, row: u16) -> Option<&VisibleLink> {
|
||||
self.links.iter().find(|link| link.contains(col, row))
|
||||
}
|
||||
|
||||
/// Whether this map is stale relative to the current scrollback generation.
|
||||
pub fn is_stale(&self, current_generation: u64) -> bool {
|
||||
self.generation != current_generation
|
||||
}
|
||||
|
||||
/// Rebuild the link map from a `LinkOverlay` and citation URLs.
|
||||
///
|
||||
/// Consecutive `OverlayLink`s with the same `id` (e.g. a single link
|
||||
/// that word-wrapped across rows) are merged into one `VisibleLink`
|
||||
/// with multiple `rects`.
|
||||
pub fn rebuild(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
overlay: &LinkOverlay,
|
||||
citation_links: Vec<VisibleLink>,
|
||||
) {
|
||||
self.links.clear();
|
||||
self.generation = generation;
|
||||
self.links
|
||||
.reserve(overlay.links().len() + citation_links.len());
|
||||
self.push_overlay_links(overlay, /* merge_from */ 0);
|
||||
self.links.extend(citation_links);
|
||||
}
|
||||
|
||||
/// Append overlay links (e.g. `/btw`) without changing generation.
|
||||
///
|
||||
/// Same-`id` merge applies only *within this append* — markdown link ids
|
||||
/// are per-document, so they will not merge with anything appended
|
||||
/// earlier this frame (whether that is the scrollback prefix from
|
||||
/// [`Self::rebuild`] or a previous [`Self::append_from_overlay`] call
|
||||
/// from another overlay source). Wrapped segments of the same logical
|
||||
/// link inside `overlay` still merge correctly.
|
||||
///
|
||||
/// Callers that re-append the same source every frame must
|
||||
/// [`Self::truncate`] back to the desired prefix length first, otherwise
|
||||
/// each frame's links will accumulate.
|
||||
pub fn append_from_overlay(&mut self, overlay: &LinkOverlay) {
|
||||
let start_len = self.links.len();
|
||||
self.push_overlay_links(overlay, start_len);
|
||||
}
|
||||
|
||||
/// Push overlay segments, merging same-`id` only with entries at
|
||||
/// indices `>= merge_from` (0 for rebuild; map length for append).
|
||||
fn push_overlay_links(&mut self, overlay: &LinkOverlay, merge_from: usize) {
|
||||
self.links.reserve(overlay.links().len());
|
||||
for link in overlay.links() {
|
||||
let width = link.col_end.saturating_sub(link.col_start);
|
||||
if width == 0 {
|
||||
continue;
|
||||
}
|
||||
let rect = Rect::new(link.col_start, link.screen_row, width, 1);
|
||||
if let Some(id) = link.id
|
||||
&& self.links.len() > merge_from
|
||||
&& let Some(prev) = self.links.last_mut()
|
||||
&& prev.id == Some(id)
|
||||
{
|
||||
prev.rects.push(rect);
|
||||
} else {
|
||||
self.links.push(VisibleLink {
|
||||
rects: vec![rect],
|
||||
url: Arc::clone(&link.url),
|
||||
id: link.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate to the first `n` links (used to drop previously-appended
|
||||
/// overlay links before re-appending for the current frame).
|
||||
pub fn truncate(&mut self, n: usize) {
|
||||
self.links.truncate(n);
|
||||
}
|
||||
|
||||
/// Number of links currently in the map.
|
||||
pub fn len(&self) -> usize {
|
||||
self.links.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.links.is_empty()
|
||||
}
|
||||
|
||||
pub fn links(&self) -> &[VisibleLink] {
|
||||
&self.links
|
||||
}
|
||||
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::render::osc8::{LinkOverlay, OverlayLink};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_overlay(links: Vec<(u16, u16, u16, &str, Option<u32>)>) -> LinkOverlay {
|
||||
let mut overlay = LinkOverlay::new();
|
||||
for (row, col_start, col_end, url, id) in links {
|
||||
overlay.push(OverlayLink {
|
||||
screen_row: row,
|
||||
col_start,
|
||||
col_end,
|
||||
url: Arc::from(url),
|
||||
id,
|
||||
});
|
||||
}
|
||||
overlay
|
||||
}
|
||||
|
||||
fn link(url: &str, widths: &[u16]) -> VisibleLink {
|
||||
VisibleLink {
|
||||
rects: widths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| Rect::new(0, i as u16, *w, 1))
|
||||
.collect(),
|
||||
url: Arc::from(url),
|
||||
id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_bare_url_text_when_painted_equals_url_width() {
|
||||
let url = "https://example.com";
|
||||
let w = unicode_width::UnicodeWidthStr::width(url) as u16;
|
||||
assert!(link(url, &[w]).looks_like_bare_url_text());
|
||||
assert!(link(url, &[10, w.saturating_sub(10)]).looks_like_bare_url_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_bare_url_text_false_for_short_label() {
|
||||
assert!(!link("https://example.com/long/path", &[4]).looks_like_bare_url_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_bare_url_text_false_for_wide_citation_block() {
|
||||
let url = "https://example.com";
|
||||
let url_w = unicode_width::UnicodeWidthStr::width(url) as u16;
|
||||
assert!(!link(url, &[url_w.saturating_add(40)]).looks_like_bare_url_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_at_hit_and_miss() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
let overlay = make_overlay(vec![(5, 10, 20, "https://example.com", Some(1))]);
|
||||
map.rebuild(1, &overlay, vec![]);
|
||||
|
||||
assert_eq!(map.links().len(), 1);
|
||||
// Hit inside the link
|
||||
let hit = map.link_at(15, 5);
|
||||
assert!(hit.is_some());
|
||||
assert_eq!(&*hit.unwrap().url, "https://example.com");
|
||||
// Miss: wrong row
|
||||
assert!(map.link_at(15, 6).is_none());
|
||||
// Miss: before start col
|
||||
assert!(map.link_at(9, 5).is_none());
|
||||
// Miss: at end col (exclusive)
|
||||
assert!(map.link_at(20, 5).is_none());
|
||||
// Hit: exact start col
|
||||
assert!(map.link_at(10, 5).is_some());
|
||||
// Hit: last valid col
|
||||
assert!(map.link_at(19, 5).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staleness_tracking() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
assert!(map.is_stale(1));
|
||||
|
||||
let overlay = make_overlay(vec![]);
|
||||
map.rebuild(1, &overlay, vec![]);
|
||||
assert!(!map.is_stale(1));
|
||||
assert!(map.is_stale(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_clears_previous_links() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
let overlay1 = make_overlay(vec![(0, 0, 5, "https://first.com", None)]);
|
||||
map.rebuild(1, &overlay1, vec![]);
|
||||
assert_eq!(map.links().len(), 1);
|
||||
|
||||
let overlay2 = make_overlay(vec![
|
||||
(1, 0, 3, "https://second.com", None),
|
||||
(2, 0, 4, "https://third.com", None),
|
||||
]);
|
||||
map.rebuild(2, &overlay2, vec![]);
|
||||
assert_eq!(map.links().len(), 2);
|
||||
assert_eq!(&*map.links()[0].url, "https://second.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_links_are_skipped() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
let overlay = make_overlay(vec![
|
||||
(0, 5, 5, "https://zero-width.com", None), // col_start == col_end
|
||||
(0, 5, 10, "https://valid.com", None),
|
||||
]);
|
||||
map.rebuild(1, &overlay, vec![]);
|
||||
assert_eq!(map.links().len(), 1);
|
||||
assert_eq!(&*map.links()[0].url, "https://valid.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_links_are_included() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
let overlay = make_overlay(vec![(0, 0, 5, "https://md-link.com", Some(1))]);
|
||||
let citations = vec![VisibleLink {
|
||||
rects: vec![Rect::new(2, 10, 30, 1)],
|
||||
url: Arc::from("https://citation.com"),
|
||||
id: None,
|
||||
}];
|
||||
map.rebuild(1, &overlay, citations);
|
||||
assert_eq!(map.links().len(), 2);
|
||||
|
||||
// Markdown link
|
||||
let hit = map.link_at(3, 0);
|
||||
assert!(hit.is_some());
|
||||
assert_eq!(&*hit.unwrap().url, "https://md-link.com");
|
||||
|
||||
// Citation link
|
||||
let hit = map.link_at(15, 10);
|
||||
assert!(hit.is_some());
|
||||
assert_eq!(&*hit.unwrap().url, "https://citation.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_links_first_match_wins() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
// Two links that overlap on screen (shouldn't happen in practice, but tests precedence)
|
||||
let overlay = make_overlay(vec![
|
||||
(5, 0, 10, "https://first.com", None),
|
||||
(5, 5, 15, "https://second.com", None),
|
||||
]);
|
||||
map.rebuild(1, &overlay, vec![]);
|
||||
|
||||
// Position 5 is in both links; first match wins (iter order)
|
||||
let hit = map.link_at(5, 5);
|
||||
assert!(hit.is_some());
|
||||
assert_eq!(&*hit.unwrap().url, "https://first.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_overlay_and_no_citations() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
let overlay = make_overlay(vec![]);
|
||||
map.rebuild(1, &overlay, vec![]);
|
||||
assert!(map.is_empty());
|
||||
assert!(map.link_at(0, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_link_merges_into_single_entry() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
// Same id=42 on two consecutive rows (word-wrap)
|
||||
let overlay = make_overlay(vec![
|
||||
(3, 10, 30, "https://wrapped.com", Some(42)),
|
||||
(4, 0, 15, "https://wrapped.com", Some(42)),
|
||||
]);
|
||||
map.rebuild(1, &overlay, vec![]);
|
||||
|
||||
// Should be 1 logical link with 2 rects
|
||||
assert_eq!(map.links().len(), 1);
|
||||
assert_eq!(map.links()[0].rects.len(), 2);
|
||||
assert_eq!(&*map.links()[0].url, "https://wrapped.com");
|
||||
|
||||
// Hit on first row segment
|
||||
assert!(map.link_at(15, 3).is_some());
|
||||
// Hit on second row segment
|
||||
assert!(map.link_at(5, 4).is_some());
|
||||
// Miss between segments (wrong col on row 4)
|
||||
assert!(map.link_at(20, 4).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_ids_stay_separate() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
let overlay = make_overlay(vec![
|
||||
(3, 0, 10, "https://a.com", Some(1)),
|
||||
(4, 0, 10, "https://b.com", Some(2)),
|
||||
]);
|
||||
map.rebuild(1, &overlay, vec![]);
|
||||
|
||||
assert_eq!(map.links().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_id_links_never_merge() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
let overlay = make_overlay(vec![
|
||||
(3, 0, 10, "https://same.com", None),
|
||||
(4, 0, 10, "https://same.com", None),
|
||||
]);
|
||||
map.rebuild(1, &overlay, vec![]);
|
||||
|
||||
assert_eq!(map.links().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_does_not_merge_ids_with_scrollback_prefix() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
let scrollback = make_overlay(vec![(0, 0, 10, "https://scrollback.com", Some(0))]);
|
||||
map.rebuild(1, &scrollback, vec![]);
|
||||
assert_eq!(map.len(), 1);
|
||||
|
||||
let btw = make_overlay(vec![(5, 0, 10, "https://btw.com", Some(0))]);
|
||||
map.append_from_overlay(&btw);
|
||||
assert_eq!(
|
||||
map.len(),
|
||||
2,
|
||||
"colliding per-doc ids must not merge across append"
|
||||
);
|
||||
assert_eq!(&*map.link_at(5, 0).unwrap().url, "https://scrollback.com");
|
||||
assert_eq!(&*map.link_at(5, 5).unwrap().url, "https://btw.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_merges_wrapped_segments_within_batch() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
map.rebuild(1, &make_overlay(vec![]), vec![]);
|
||||
let btw = make_overlay(vec![
|
||||
(3, 10, 30, "https://wrapped.com", Some(7)),
|
||||
(4, 0, 15, "https://wrapped.com", Some(7)),
|
||||
]);
|
||||
map.append_from_overlay(&btw);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert_eq!(map.links()[0].rects.len(), 2);
|
||||
assert!(map.link_at(12, 3).is_some());
|
||||
assert!(map.link_at(5, 4).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_then_append_replaces_overlay_suffix() {
|
||||
let mut map = VisibleLinkMap::default();
|
||||
let scrollback = make_overlay(vec![(0, 0, 5, "https://sb.com", None)]);
|
||||
map.rebuild(1, &scrollback, vec![]);
|
||||
let prefix = map.len();
|
||||
map.append_from_overlay(&make_overlay(vec![(1, 0, 5, "https://old-btw.com", None)]));
|
||||
assert_eq!(map.len(), 2);
|
||||
|
||||
map.truncate(prefix);
|
||||
assert_eq!(map.len(), 1);
|
||||
map.append_from_overlay(&make_overlay(vec![(2, 0, 5, "https://new-btw.com", None)]));
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map.link_at(1, 1).is_none());
|
||||
assert_eq!(&*map.link_at(1, 2).unwrap().url, "https://new-btw.com");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Scrollback — conversation display with blocks, scroll, selection, turns.
|
||||
//!
|
||||
//! This module owns the scrollback rendering pipeline:
|
||||
//! - `block.rs` / `blocks/` — content block types (agent, thinking, tool, etc.)
|
||||
//! - `entry.rs` — ScrollbackEntry wraps a block with display state
|
||||
//! - `state.rs` — ScrollbackState manages entries, scroll, selection, turns
|
||||
//! - `layout.rs` — HorizontalLayout for entry column structure
|
||||
//! - `sticky.rs` — Sticky header computation for turn prompts
|
||||
//! - `selection.rs` — SelectionBox rendering
|
||||
//! - `render.rs` — Scroll-aware rendering with scratch buffers
|
||||
//! - `types.rs` — Core types (BlockLine, BlockOutput, DisplayMode, etc.)
|
||||
//! - `wrappers/` — Rendering composition (EntryRenderer, BlockRenderer, etc.)
|
||||
|
||||
pub mod block;
|
||||
pub mod blocks;
|
||||
pub mod entry;
|
||||
pub mod export;
|
||||
pub mod layout;
|
||||
pub mod link_map;
|
||||
pub mod render;
|
||||
pub mod scrollback_pane;
|
||||
pub mod search;
|
||||
pub mod selection;
|
||||
pub mod state;
|
||||
pub mod sticky;
|
||||
pub mod table_geometry;
|
||||
pub mod text_selection;
|
||||
pub mod types;
|
||||
pub mod wrappers;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use block::{BlockContent, RenderBlock};
|
||||
pub use blocks::{
|
||||
AgentMessageBlock, SystemMessageBlock, ThinkingBlock, ToolCallBlock, UserPromptBlock,
|
||||
};
|
||||
pub use entry::{EntryId, ScrollbackEntry};
|
||||
pub use layout::HorizontalLayout;
|
||||
pub use link_map::{VisibleLink, VisibleLinkMap};
|
||||
pub use render::ScratchBuffer;
|
||||
pub use scrollback_pane::ScrollbackPane;
|
||||
pub use search::{ScrollbackMatch, ScrollbackSearchIndex, ScrollbackSearchState};
|
||||
pub use selection::{RenderOutput, SelectionBox};
|
||||
pub use state::{EntryLayoutInfo, ScrollbackState};
|
||||
pub use text_selection::*;
|
||||
pub use types::*;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
//! Selection box rendering for v3 pager.
|
||||
//!
|
||||
//! The `SelectionBox` is computed by components (like ScrollbackPane) and rendered
|
||||
//! by the frame, allowing selection boxes to span component boundaries.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
use crate::render::osc8::LinkOverlay;
|
||||
use crate::scrollback::text_selection::ResolvedSelectionModel;
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Box drawing characters for selection border.
|
||||
mod border_chars {
|
||||
pub const TOP_LEFT: char = '┌';
|
||||
pub const TOP_RIGHT: char = '┐';
|
||||
pub const BOTTOM_LEFT: char = '└';
|
||||
pub const BOTTOM_RIGHT: char = '┘';
|
||||
pub const VERTICAL: char = '│';
|
||||
/// Dashed vertical - used on edge rows when clipped to indicate continuation.
|
||||
pub const VERTICAL_DASHED: char = '┆';
|
||||
}
|
||||
|
||||
/// A selection box that can be drawn around a selected block.
|
||||
///
|
||||
/// The box consists of:
|
||||
/// - Side borders (│) on the left and right edges of `inner_area`
|
||||
/// - Top corners (┌┐) one row above `inner_area` (if `!top_clipped`)
|
||||
/// - Bottom corners (└┘) one row below `inner_area` (if `!bottom_clipped`)
|
||||
///
|
||||
/// This struct is returned by components (like ScrollbackPane) and rendered
|
||||
/// by the frame, allowing selection boxes to span component boundaries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SelectionBox {
|
||||
/// The inner area surrounded by the selection border.
|
||||
pub inner_area: Rect,
|
||||
/// True if the block has rows clipped at top (scrolled out of view).
|
||||
pub top_clipped: bool,
|
||||
/// True if the block has rows clipped at bottom.
|
||||
pub bottom_clipped: bool,
|
||||
/// Style for the border (typically just fg color).
|
||||
pub style: Style,
|
||||
/// Whether to render a close control replacing the top-right corner.
|
||||
pub closable: bool,
|
||||
/// Whether the close control is currently hovered.
|
||||
pub close_hovered: bool,
|
||||
/// Optional close label; `None` uses default `✗`.
|
||||
pub close_label: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Output from render that needs post-processing.
|
||||
///
|
||||
/// Render returns this instead of mutating state, keeping render pure.
|
||||
/// The caller is responsible for rendering these elements after the main pass.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let output = pane.render_with_scratch(area, buf, &state, &mut scratch);
|
||||
///
|
||||
/// // Post-render pass
|
||||
/// if let Some(sel) = output.selection_box {
|
||||
/// sel.render(buf);
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RenderOutput {
|
||||
/// Selection box to render around the selected entry.
|
||||
/// Rendered after main content so it can span component boundaries.
|
||||
pub selection_box: Option<SelectionBox>,
|
||||
/// Scroll info for scrollbar rendering.
|
||||
/// Viewport uses this to render the scrollbar at the correct position.
|
||||
pub scroll_info: Option<ScrollInfo>,
|
||||
/// Screen area of the individual selected entry (within a group).
|
||||
/// Used by agent_view to position inline buttons on the correct row.
|
||||
pub selected_entry_area: Option<Rect>,
|
||||
/// Per-frame resolved selection metadata for visible content.
|
||||
pub selection_model: ResolvedSelectionModel,
|
||||
/// OSC 8 link overlay for post-flush emission.
|
||||
pub link_overlay: LinkOverlay,
|
||||
/// Inline media to render via post-flush escape sequences.
|
||||
pub inline_media: Vec<crate::scrollback::render::InlineMediaPlacement>,
|
||||
/// Mermaid diagram affordance rows to paint + register click hit-rects for.
|
||||
pub diagram_affordances: Vec<crate::scrollback::render::DiagramAffordancePlacement>,
|
||||
}
|
||||
|
||||
/// Scroll information for scrollbar rendering.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ScrollInfo {
|
||||
/// Current scroll offset (lines from top). `usize`: tall sessions exceed
|
||||
/// `u16::MAX`.
|
||||
pub scroll_offset: usize,
|
||||
/// Visible viewport height (lines). Stays `u16` (a terminal is never that tall).
|
||||
pub viewport_height: u16,
|
||||
/// Total content height (lines). `usize` for the same reason as `scroll_offset`.
|
||||
pub total_height: usize,
|
||||
}
|
||||
|
||||
impl RenderOutput {
|
||||
/// Create empty render output.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create render output with a selection box.
|
||||
pub fn with_selection_box(selection_box: SelectionBox) -> Self {
|
||||
Self {
|
||||
selection_box: Some(selection_box),
|
||||
scroll_info: None,
|
||||
selected_entry_area: None,
|
||||
selection_model: ResolvedSelectionModel::default(),
|
||||
link_overlay: Default::default(),
|
||||
inline_media: Vec::new(),
|
||||
diagram_affordances: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add scroll info to the output.
|
||||
pub fn with_scroll_info(mut self, scroll_info: ScrollInfo) -> Self {
|
||||
self.scroll_info = Some(scroll_info);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectionBox {
|
||||
/// Create a new selection box with the given inner area and style.
|
||||
pub fn new(inner_area: Rect, style: Style) -> Self {
|
||||
Self {
|
||||
inner_area,
|
||||
top_clipped: false,
|
||||
bottom_clipped: false,
|
||||
style,
|
||||
closable: false,
|
||||
close_hovered: false,
|
||||
close_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set whether the top is clipped (no top corners).
|
||||
pub fn with_top_clipped(mut self, clipped: bool) -> Self {
|
||||
self.top_clipped = clipped;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether the bottom is clipped (no bottom corners).
|
||||
pub fn with_bottom_clipped(mut self, clipped: bool) -> Self {
|
||||
self.bottom_clipped = clipped;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable a close control replacing the top-right corner `┐` (default: `✗`).
|
||||
///
|
||||
/// Normal state: same color as the border. Hovered: bright white.
|
||||
pub fn with_closable(mut self, closable: bool, hovered: bool) -> Self {
|
||||
self.closable = closable;
|
||||
self.close_hovered = hovered;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set close control label (`Some` implies closable).
|
||||
pub fn with_close_label(mut self, label: Option<&'static str>) -> Self {
|
||||
self.close_label = label;
|
||||
if label.is_some() {
|
||||
self.closable = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Hit-test rect for the close control, if it would be rendered.
|
||||
///
|
||||
/// Pure computation — does not touch the buffer. Use for mouse hit-testing.
|
||||
/// Returns `None` if not closable, top is clipped, or no room.
|
||||
pub fn close_button_rect(&self) -> Option<Rect> {
|
||||
if !self.closable || self.top_clipped || self.inner_area.y == 0 {
|
||||
return None;
|
||||
}
|
||||
let label_w = self
|
||||
.close_label
|
||||
.map(|s| s.chars().count() as u16)
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let right_x = self.inner_area.x + self.inner_area.width.saturating_sub(1);
|
||||
let x = right_x.saturating_sub(label_w.saturating_sub(1));
|
||||
Some(Rect {
|
||||
x,
|
||||
y: self.inner_area.y - 1,
|
||||
width: label_w,
|
||||
height: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Render the selection box to the buffer.
|
||||
///
|
||||
/// Draws:
|
||||
/// - Side borders (│) on left and right edges of inner_area
|
||||
/// - Dashed borders (┆) on edge rows when clipped, to indicate continuation
|
||||
/// - Top corners (┌┐) at inner_area.y - 1 if !top_clipped and y > 0
|
||||
/// - Bottom corners (└┘) at inner_area.y + height if !bottom_clipped
|
||||
/// - Close button (✗) left of ┐ if enabled
|
||||
pub fn render(&self, buf: &mut Buffer) {
|
||||
let area = self.inner_area;
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let left_x = area.x;
|
||||
let right_x = area.x + area.width.saturating_sub(1);
|
||||
let y_top = area.y;
|
||||
let y_bottom = area.y + area.height.saturating_sub(1);
|
||||
|
||||
// Draw side borders
|
||||
for y in y_top..=y_bottom {
|
||||
let is_first_row = y == y_top;
|
||||
let is_last_row = y == y_bottom;
|
||||
let use_dashed =
|
||||
(is_first_row && self.top_clipped) || (is_last_row && self.bottom_clipped);
|
||||
|
||||
let vert_char = if use_dashed {
|
||||
border_chars::VERTICAL_DASHED
|
||||
} else {
|
||||
border_chars::VERTICAL
|
||||
};
|
||||
|
||||
if let Some(cell) = buf.cell_mut((left_x, y)) {
|
||||
cell.set_char(vert_char).set_style(self.style);
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((right_x, y)) {
|
||||
cell.set_char(vert_char).set_style(self.style);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw top corners (if not clipped and there's room)
|
||||
if !self.top_clipped && y_top > 0 {
|
||||
let corner_y = y_top - 1;
|
||||
if let Some(cell) = buf.cell_mut((left_x, corner_y)) {
|
||||
cell.set_char(border_chars::TOP_LEFT).set_style(self.style);
|
||||
}
|
||||
// Close control replaces ┐, or draw normal corner
|
||||
if let Some(close_rect) = self.close_button_rect() {
|
||||
let style = if self.close_hovered {
|
||||
Style::default().fg(Theme::current().text_primary)
|
||||
} else {
|
||||
self.style
|
||||
};
|
||||
if let Some(label) = self.close_label {
|
||||
use crate::render::SafeBuf;
|
||||
buf.set_string_safe(close_rect.x, close_rect.y, label, style);
|
||||
} else if let Some(cell) = buf.cell_mut((close_rect.x, close_rect.y)) {
|
||||
cell.set_symbol(crate::glyphs::ballot_x()).set_style(style);
|
||||
}
|
||||
} else if let Some(cell) = buf.cell_mut((right_x, corner_y)) {
|
||||
cell.set_char(border_chars::TOP_RIGHT).set_style(self.style);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw bottom corners (if not clipped)
|
||||
if !self.bottom_clipped {
|
||||
let corner_y = y_bottom + 1;
|
||||
if let Some(cell) = buf.cell_mut((left_x, corner_y)) {
|
||||
cell.set_char(border_chars::BOTTOM_LEFT)
|
||||
.set_style(self.style);
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((right_x, corner_y)) {
|
||||
cell.set_char(border_chars::BOTTOM_RIGHT)
|
||||
.set_style(self.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_selection_box_render() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
||||
|
||||
let selection = SelectionBox::new(Rect::new(0, 2, 10, 4), Style::default());
|
||||
|
||||
selection.render(&mut buf);
|
||||
|
||||
// Check top corners at y=1 (inner_area.y - 1)
|
||||
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "┌");
|
||||
assert_eq!(buf.cell((9, 1)).unwrap().symbol(), "┐");
|
||||
|
||||
// Check side borders at y=2..=5 (all solid, not clipped)
|
||||
for y in 2..=5 {
|
||||
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
|
||||
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
|
||||
}
|
||||
|
||||
// Check bottom corners at y=6 (inner_area.y + height)
|
||||
assert_eq!(buf.cell((0, 6)).unwrap().symbol(), "└");
|
||||
assert_eq!(buf.cell((9, 6)).unwrap().symbol(), "┘");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_box_top_clipped() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
||||
|
||||
let selection =
|
||||
SelectionBox::new(Rect::new(0, 2, 10, 4), Style::default()).with_top_clipped(true);
|
||||
|
||||
selection.render(&mut buf);
|
||||
|
||||
// Top corners should NOT be drawn
|
||||
assert_ne!(buf.cell((0, 1)).unwrap().symbol(), "┌");
|
||||
assert_ne!(buf.cell((9, 1)).unwrap().symbol(), "┐");
|
||||
|
||||
// First row (y=2) should have DASHED borders
|
||||
assert_eq!(buf.cell((0, 2)).unwrap().symbol(), "┆");
|
||||
assert_eq!(buf.cell((9, 2)).unwrap().symbol(), "┆");
|
||||
|
||||
// Middle rows should have solid borders
|
||||
for y in 3..=5 {
|
||||
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
|
||||
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
|
||||
}
|
||||
|
||||
// Bottom corners should be drawn
|
||||
assert_eq!(buf.cell((0, 6)).unwrap().symbol(), "└");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_box_bottom_clipped() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
||||
|
||||
let selection =
|
||||
SelectionBox::new(Rect::new(0, 2, 10, 4), Style::default()).with_bottom_clipped(true);
|
||||
|
||||
selection.render(&mut buf);
|
||||
|
||||
// Top corners should be drawn
|
||||
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "┌");
|
||||
assert_eq!(buf.cell((9, 1)).unwrap().symbol(), "┐");
|
||||
|
||||
// First rows should have solid borders
|
||||
for y in 2..=4 {
|
||||
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
|
||||
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
|
||||
}
|
||||
|
||||
// Last row (y=5) should have DASHED borders
|
||||
assert_eq!(buf.cell((0, 5)).unwrap().symbol(), "┆");
|
||||
assert_eq!(buf.cell((9, 5)).unwrap().symbol(), "┆");
|
||||
|
||||
// Bottom corners should NOT be drawn
|
||||
assert_ne!(buf.cell((0, 6)).unwrap().symbol(), "└");
|
||||
assert_ne!(buf.cell((9, 6)).unwrap().symbol(), "┘");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_box_both_clipped() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
||||
|
||||
let selection = SelectionBox::new(Rect::new(0, 2, 10, 4), Style::default())
|
||||
.with_top_clipped(true)
|
||||
.with_bottom_clipped(true);
|
||||
|
||||
selection.render(&mut buf);
|
||||
|
||||
// No corners should be drawn
|
||||
assert_ne!(buf.cell((0, 1)).unwrap().symbol(), "┌");
|
||||
assert_ne!(buf.cell((0, 6)).unwrap().symbol(), "└");
|
||||
|
||||
// First row (y=2) should have DASHED borders
|
||||
assert_eq!(buf.cell((0, 2)).unwrap().symbol(), "┆");
|
||||
assert_eq!(buf.cell((9, 2)).unwrap().symbol(), "┆");
|
||||
|
||||
// Middle rows should have solid borders
|
||||
for y in 3..=4 {
|
||||
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
|
||||
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
|
||||
}
|
||||
|
||||
// Last row (y=5) should have DASHED borders
|
||||
assert_eq!(buf.cell((0, 5)).unwrap().symbol(), "┆");
|
||||
assert_eq!(buf.cell((9, 5)).unwrap().symbol(), "┆");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_box_single_row_both_clipped() {
|
||||
// Edge case: only 1 row visible, both ends clipped
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
||||
|
||||
let selection = SelectionBox::new(Rect::new(0, 3, 10, 1), Style::default())
|
||||
.with_top_clipped(true)
|
||||
.with_bottom_clipped(true);
|
||||
|
||||
selection.render(&mut buf);
|
||||
|
||||
// The single row should have DASHED borders (first row = last row, both clipped)
|
||||
assert_eq!(buf.cell((0, 3)).unwrap().symbol(), "┆");
|
||||
assert_eq!(buf.cell((9, 3)).unwrap().symbol(), "┆");
|
||||
|
||||
// No corners
|
||||
assert_ne!(buf.cell((0, 2)).unwrap().symbol(), "┌");
|
||||
assert_ne!(buf.cell((0, 4)).unwrap().symbol(), "└");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_box_single_row_top_clipped_only() {
|
||||
// Edge case: only 1 row visible, only top clipped
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
||||
|
||||
let selection =
|
||||
SelectionBox::new(Rect::new(0, 3, 10, 1), Style::default()).with_top_clipped(true);
|
||||
|
||||
selection.render(&mut buf);
|
||||
|
||||
// The single row should have DASHED borders (it's first row and top_clipped)
|
||||
assert_eq!(buf.cell((0, 3)).unwrap().symbol(), "┆");
|
||||
assert_eq!(buf.cell((9, 3)).unwrap().symbol(), "┆");
|
||||
|
||||
// Bottom corners should be drawn
|
||||
assert_eq!(buf.cell((0, 4)).unwrap().symbol(), "└");
|
||||
assert_eq!(buf.cell((9, 4)).unwrap().symbol(), "┘");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_box_at_top_edge() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
||||
|
||||
// Selection at y=0 (no room for top corners even if not clipped)
|
||||
let selection = SelectionBox::new(Rect::new(0, 0, 10, 4), Style::default());
|
||||
|
||||
selection.render(&mut buf);
|
||||
|
||||
// Side borders at y=0..=3 (all solid, not clipped)
|
||||
for y in 0..=3 {
|
||||
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
|
||||
}
|
||||
|
||||
// Bottom corners at y=4
|
||||
assert_eq!(buf.cell((0, 4)).unwrap().symbol(), "└");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
//! Derived group model for the scrollback's view-time folds.
|
||||
//!
|
||||
//! One scan owns every grouping decision: verb-group runs claim their
|
||||
//! entries first, then group truncation ("N more") runs over the rest. The
|
||||
//! scan produces [`GroupSpan`]s — the authoritative description of every
|
||||
//! fold — and [`project_to_layout`] is the single writer that turns spans
|
||||
//! into the per-entry `EntryLayoutInfo` flags the renderer and navigation
|
||||
//! consume. Keeping the decision (scan) and the flag writes (projection)
|
||||
//! in one module means a consumer can never observe a fold shape the model
|
||||
//! doesn't describe.
|
||||
//!
|
||||
//! The spans are stored on the layout cache (see `LayoutCache::groups`) and
|
||||
//! rebuilt whenever the folds are re-applied. Like the per-entry flags, they
|
||||
//! go stale between an incremental entry append and the next structural
|
||||
//! rebuild (`gaps_may_be_dirty` covers both).
|
||||
//!
|
||||
//! Per-entry run classification ([`run_step`]) and the rendered header label
|
||||
//! stay in [`super::verb_group`]; this module owns run *shapes*.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Range;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::types::EntryLayoutInfo;
|
||||
use super::verb_group::{RunStep, run_step, scan_run_forward};
|
||||
use crate::scrollback::block::BlockContent;
|
||||
use crate::scrollback::entry::{EntryId, ScrollbackEntry};
|
||||
use crate::scrollback::types::DisplayMode;
|
||||
|
||||
/// One folded region of the transcript, in entry indices.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GroupSpan {
|
||||
/// Entries the fold walked. For verb runs this ends one past the last
|
||||
/// claimed entry (trailing transparent entries stay outside). For
|
||||
/// truncation it is the whole dense run, visible tail included, and may
|
||||
/// end with trailing hidden-thinking entries the walk skipped over.
|
||||
pub range: Range<usize>,
|
||||
/// Which fold produced this span and its count data.
|
||||
pub kind: GroupKind,
|
||||
/// Whether the user manually expanded this group (keyed by the first
|
||||
/// entry's ID in `ScrollbackState::expanded_groups`).
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
/// The two fold families. Both render a synthetic header row; they differ in
|
||||
/// when they fold and what the header says.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GroupKind {
|
||||
/// Eagerly folded run of verb-groupable members (tool calls and subagent
|
||||
/// rows) — the aggregated "Read 2 skills" header. `members` counts
|
||||
/// label-bearing members only; claimed thoughts fold in but never count.
|
||||
VerbRun { members: usize },
|
||||
/// Budget truncation of an over-long dense run — the "N more" header.
|
||||
/// `participants` counts the entries eligible to hide (hidden thinking
|
||||
/// excluded); `hidden` is how many of them the collapsed state conceals
|
||||
/// (`participants - max_visible`, > 0 by the fold gate). The header row
|
||||
/// is the first hidden participant, so its plain count shows
|
||||
/// `hidden - 1` while its aggregated label describes all `hidden`.
|
||||
Truncation { participants: usize, hidden: usize },
|
||||
}
|
||||
|
||||
/// Binary-search sorted, disjoint spans for the one containing `idx`.
|
||||
pub fn span_containing(spans: &[GroupSpan], idx: usize) -> Option<&GroupSpan> {
|
||||
let pos = spans.partition_point(|s| s.range.end <= idx);
|
||||
spans.get(pos).filter(|s| s.range.contains(&idx))
|
||||
}
|
||||
|
||||
/// Reset every entry's group flags, scan for spans, and project them onto
|
||||
/// the layout slice. Returns the spans for the caller to store on the
|
||||
/// layout cache. Reads the `group_tool_verbs` / `show_thinking_blocks`
|
||||
/// settings once so both fold families and the projection agree within a
|
||||
/// rebuild.
|
||||
pub(super) fn apply(
|
||||
entries: &IndexMap<EntryId, ScrollbackEntry>,
|
||||
layout_cache: &mut [EntryLayoutInfo],
|
||||
max_visible: usize,
|
||||
expanded_groups: &HashSet<EntryId>,
|
||||
) -> Vec<GroupSpan> {
|
||||
for info in layout_cache.iter_mut() {
|
||||
info.group_header_count = 0;
|
||||
info.group_collapse_header = false;
|
||||
info.verb_group_header = false;
|
||||
}
|
||||
let group_tool_verbs = crate::appearance::cache::load_group_tool_verbs();
|
||||
let show_thinking = crate::appearance::cache::load_show_thinking_blocks();
|
||||
let spans = scan(
|
||||
entries,
|
||||
max_visible,
|
||||
expanded_groups,
|
||||
group_tool_verbs,
|
||||
show_thinking,
|
||||
);
|
||||
project_to_layout(&spans, entries, layout_cache, show_thinking);
|
||||
spans
|
||||
}
|
||||
|
||||
/// Scan the transcript for every fold, in the order the folds take
|
||||
/// precedence: verb runs claim entries first, truncation runs over the
|
||||
/// rest (claimed entries break truncation runs). Returns spans sorted by
|
||||
/// start index; spans never overlap.
|
||||
pub(super) fn scan(
|
||||
entries: &IndexMap<EntryId, ScrollbackEntry>,
|
||||
max_visible: usize,
|
||||
expanded_groups: &HashSet<EntryId>,
|
||||
group_tool_verbs: bool,
|
||||
show_thinking: bool,
|
||||
) -> Vec<GroupSpan> {
|
||||
let (mut spans, claimed) =
|
||||
scan_verb_runs(entries, expanded_groups, group_tool_verbs, show_thinking);
|
||||
spans.extend(scan_truncations(
|
||||
entries,
|
||||
max_visible,
|
||||
expanded_groups,
|
||||
show_thinking,
|
||||
&claimed,
|
||||
));
|
||||
// Both scans emit in ascending order over disjoint ranges; interleave.
|
||||
spans.sort_unstable_by_key(|s| s.range.start);
|
||||
spans
|
||||
}
|
||||
|
||||
/// Find maximal runs of verb-groupable member entries — plus any finished
|
||||
/// collapsed thoughts among them — that fold per `RunScan::folds`, gated on
|
||||
/// the `group_tool_verbs` setting. Also returns the claimed-entry mask
|
||||
/// (members and thought members of folding runs): the truncation scan
|
||||
/// treats claimed entries as run breakers.
|
||||
fn scan_verb_runs(
|
||||
entries: &IndexMap<EntryId, ScrollbackEntry>,
|
||||
expanded_groups: &HashSet<EntryId>,
|
||||
group_tool_verbs: bool,
|
||||
show_thinking: bool,
|
||||
) -> (Vec<GroupSpan>, Vec<bool>) {
|
||||
let n = entries.len();
|
||||
let mut spans = Vec::new();
|
||||
let mut claimed = vec![false; n];
|
||||
if n == 0 || !group_tool_verbs {
|
||||
return (spans, claimed);
|
||||
}
|
||||
|
||||
let entry_at = |i: usize| entries.get_index(i).map(|(_, e)| e);
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
// Trailing transparent thinking stays outside the run (`scan.end`).
|
||||
let Some(scan) = scan_run_forward(entry_at, i, show_thinking) else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
if !scan.folds() {
|
||||
i = scan.stop;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Which in-run entries claim must agree with the member arms in
|
||||
// `scan_run_forward`; transparent entries stay unclaimed inside the
|
||||
// span and keep rendering their own rows.
|
||||
for (offset, slot) in claimed[i..scan.end].iter_mut().enumerate() {
|
||||
if matches!(
|
||||
run_step(
|
||||
entry_at(i + offset).expect("index within entries"),
|
||||
show_thinking
|
||||
),
|
||||
RunStep::Member(_) | RunStep::ThoughtMember
|
||||
) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
|
||||
let first_id = *entries.get_index(i).expect("index within entries").0;
|
||||
spans.push(GroupSpan {
|
||||
range: i..scan.end,
|
||||
kind: GroupKind::VerbRun {
|
||||
members: scan.members,
|
||||
},
|
||||
expanded: expanded_groups.contains(&first_id),
|
||||
});
|
||||
i = scan.end;
|
||||
}
|
||||
(spans, claimed)
|
||||
}
|
||||
|
||||
/// Collapsed+groupable entries that may join a truncation run.
|
||||
/// Hidden thinking is excluded so tools elect their own "N more" header.
|
||||
fn participates_in_truncation(entry: &ScrollbackEntry, show_thinking: bool) -> bool {
|
||||
entry.block.is_groupable()
|
||||
&& entry.display_mode == DisplayMode::Collapsed
|
||||
&& !entry.is_hidden_thinking(show_thinking)
|
||||
}
|
||||
|
||||
/// Find consecutive runs of collapsed+groupable entries longer than
|
||||
/// `max_visible + 1`. Hidden thinking is transparent (skipped, not a
|
||||
/// run-breaker), mirroring the gap rule in `recompute_gap_after`, so an
|
||||
/// interspersed thought can't split a run and suppress truncation. Entries
|
||||
/// claimed by the verb scan break runs.
|
||||
fn scan_truncations(
|
||||
entries: &IndexMap<EntryId, ScrollbackEntry>,
|
||||
max_visible: usize,
|
||||
expanded_groups: &HashSet<EntryId>,
|
||||
show_thinking: bool,
|
||||
claimed: &[bool],
|
||||
) -> Vec<GroupSpan> {
|
||||
let mut spans = Vec::new();
|
||||
if max_visible == 0 || entries.is_empty() {
|
||||
return spans;
|
||||
}
|
||||
|
||||
let n = entries.len();
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let (_, entry) = entries.get_index(i).unwrap();
|
||||
if claimed[i] || !participates_in_truncation(entry, show_thinking) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let group_start = i;
|
||||
let mut group_len = 1;
|
||||
let mut j = i + 1;
|
||||
while j < n {
|
||||
let (_, e) = entries.get_index(j).unwrap();
|
||||
if claimed[j] {
|
||||
break;
|
||||
}
|
||||
if participates_in_truncation(e, show_thinking) {
|
||||
group_len += 1;
|
||||
} else if !e.is_hidden_thinking(show_thinking) {
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
let group_end = j;
|
||||
|
||||
if group_len <= max_visible + 1 {
|
||||
i = group_end;
|
||||
continue;
|
||||
}
|
||||
|
||||
let first_id = *entries.get_index(group_start).unwrap().0;
|
||||
spans.push(GroupSpan {
|
||||
range: group_start..group_end,
|
||||
kind: GroupKind::Truncation {
|
||||
participants: group_len,
|
||||
hidden: group_len - max_visible,
|
||||
},
|
||||
expanded: expanded_groups.contains(&first_id),
|
||||
});
|
||||
i = group_end;
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// The single writer of group heights, gaps, and header flags. Every layout
|
||||
/// consequence of a fold happens here, driven only by the spans (plus
|
||||
/// per-entry `run_step` classification for verb runs, whose transparent
|
||||
/// entries keep their own rows).
|
||||
pub(super) fn project_to_layout(
|
||||
spans: &[GroupSpan],
|
||||
entries: &IndexMap<EntryId, ScrollbackEntry>,
|
||||
layout_cache: &mut [EntryLayoutInfo],
|
||||
show_thinking: bool,
|
||||
) {
|
||||
for span in spans {
|
||||
match span.kind {
|
||||
GroupKind::VerbRun { members } => {
|
||||
project_verb_run(span, members, entries, layout_cache, show_thinking);
|
||||
}
|
||||
GroupKind::Truncation {
|
||||
participants,
|
||||
hidden,
|
||||
} => {
|
||||
project_truncation(
|
||||
span,
|
||||
participants,
|
||||
hidden,
|
||||
entries,
|
||||
layout_cache,
|
||||
show_thinking,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsed: header renders the aggregated label at `height=1`; other
|
||||
/// claimed entries fold to `height=0`. Expanded: the header slot is an
|
||||
/// absolute `height=2` — the header line plus entry 0's own row — so ALL
|
||||
/// members, including the first, reveal below it (unlike the N-more
|
||||
/// expanded shape, which replaces entry 0's content); members keep their
|
||||
/// normal heights. Gaps zero only WITHIN the run: `virtual_y` accumulates
|
||||
/// `gap_after` even for height-0 entries, so the LAST claimed entry keeps
|
||||
/// the pairwise boundary gap `recompute_gap_after` computed against the
|
||||
/// entry after the run — zeroing it glued the folded header to whatever
|
||||
/// followed. Transparent entries (live/opened thinking, opened members)
|
||||
/// keep their rows but donate their trailing gap while collapsed.
|
||||
fn project_verb_run(
|
||||
span: &GroupSpan,
|
||||
members: usize,
|
||||
entries: &IndexMap<EntryId, ScrollbackEntry>,
|
||||
layout_cache: &mut [EntryLayoutInfo],
|
||||
show_thinking: bool,
|
||||
) {
|
||||
let last_claimed = span.range.end - 1;
|
||||
for idx in span.range.clone() {
|
||||
let (_, e) = entries.get_index(idx).unwrap();
|
||||
let cached = &mut layout_cache[idx];
|
||||
match run_step(e, show_thinking) {
|
||||
RunStep::Member(_) | RunStep::ThoughtMember => {}
|
||||
RunStep::Transparent => {
|
||||
if !span.expanded {
|
||||
cached.gap_after = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Unreachable: a span's range never spans a Break (arm kept for
|
||||
// match exhaustiveness).
|
||||
RunStep::Break => continue,
|
||||
}
|
||||
if idx == span.range.start {
|
||||
cached.verb_group_header = true;
|
||||
cached.group_collapse_header = span.expanded;
|
||||
cached.group_header_count = members.min(u16::MAX as usize) as u16;
|
||||
cached.height = if span.expanded { 2 } else { 1 };
|
||||
// A singleton run's header is also its last claimed entry: it
|
||||
// keeps the pairwise boundary gap, else the header glues to what
|
||||
// follows.
|
||||
if idx != last_claimed {
|
||||
cached.gap_after = 0;
|
||||
}
|
||||
} else if !span.expanded {
|
||||
cached.height = 0;
|
||||
if idx != last_claimed {
|
||||
cached.gap_after = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsed: the first participating entry becomes the "N more" header
|
||||
/// (count excludes the header itself), older participants hide, and the
|
||||
/// last `max_visible` stay untouched. Expanded: entry 0 becomes a
|
||||
/// standalone collapse header (`height=1`, content replaced) counting the
|
||||
/// `participants - 1` entries below it, which all keep their own rows.
|
||||
/// Hidden thinking is skipped in both states.
|
||||
/// `verb_group::truncation_header_label` mirrors this walk's participant
|
||||
/// rule for the header's aggregated label; a new transparency category here
|
||||
/// must update that walk too.
|
||||
fn project_truncation(
|
||||
span: &GroupSpan,
|
||||
participants: usize,
|
||||
hidden: usize,
|
||||
entries: &IndexMap<EntryId, ScrollbackEntry>,
|
||||
layout_cache: &mut [EntryLayoutInfo],
|
||||
show_thinking: bool,
|
||||
) {
|
||||
if span.expanded {
|
||||
let cached = &mut layout_cache[span.range.start];
|
||||
cached.group_collapse_header = true;
|
||||
cached.group_header_count = (participants - 1).min(u16::MAX as usize) as u16;
|
||||
cached.height = 1;
|
||||
cached.gap_after = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let mut seen = 0;
|
||||
for idx in span.range.clone() {
|
||||
let (_, e) = entries.get_index(idx).unwrap();
|
||||
if e.is_hidden_thinking(show_thinking) {
|
||||
continue;
|
||||
}
|
||||
let cached = &mut layout_cache[idx];
|
||||
if seen == 0 {
|
||||
cached.height = 1;
|
||||
cached.gap_after = 0;
|
||||
cached.group_header_count = (hidden - 1).min(u16::MAX as usize) as u16;
|
||||
} else if seen < hidden {
|
||||
cached.height = 0;
|
||||
cached.gap_after = 0;
|
||||
cached.group_header_count = 0;
|
||||
}
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::tool::{ReadToolCallBlock, ToolCallBlock};
|
||||
|
||||
fn skill_read() -> ScrollbackEntry {
|
||||
ScrollbackEntry::new(RenderBlock::ToolCall(ToolCallBlock::Read(
|
||||
ReadToolCallBlock::new("/x/skills/deploy/SKILL.md"),
|
||||
)))
|
||||
.with_display_mode(DisplayMode::Collapsed)
|
||||
}
|
||||
|
||||
fn execute() -> ScrollbackEntry {
|
||||
ScrollbackEntry::new(RenderBlock::execute("ls")).with_display_mode(DisplayMode::Collapsed)
|
||||
}
|
||||
|
||||
fn thought() -> ScrollbackEntry {
|
||||
ScrollbackEntry::new(RenderBlock::thinking("hmm")).with_display_mode(DisplayMode::Collapsed)
|
||||
}
|
||||
|
||||
fn map(entries: Vec<ScrollbackEntry>) -> IndexMap<EntryId, ScrollbackEntry> {
|
||||
entries
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (EntryId::new(i as u64), e))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Seed layout distinct from anything the projection writes, so a
|
||||
/// changed entry is distinguishable from an untouched one.
|
||||
fn seeded_layout(n: usize) -> Vec<EntryLayoutInfo> {
|
||||
vec![
|
||||
EntryLayoutInfo {
|
||||
height: 5,
|
||||
gap_after: 1,
|
||||
..Default::default()
|
||||
};
|
||||
n
|
||||
]
|
||||
}
|
||||
|
||||
fn scan_and_project(
|
||||
entries: &IndexMap<EntryId, ScrollbackEntry>,
|
||||
layout: &mut [EntryLayoutInfo],
|
||||
max_visible: usize,
|
||||
expanded: &HashSet<EntryId>,
|
||||
) -> Vec<GroupSpan> {
|
||||
let spans = scan(entries, max_visible, expanded, true, true);
|
||||
project_to_layout(&spans, entries, layout, true);
|
||||
spans
|
||||
}
|
||||
|
||||
/// The "Read 2 skills / 8 more" transcript shape: a verb run followed by
|
||||
/// a 19-row dense run of commands and thoughts with the default budget.
|
||||
#[test]
|
||||
fn verb_run_breaks_truncation_and_both_spans_project() {
|
||||
let mut list = vec![skill_read(), skill_read()];
|
||||
for i in 0..19 {
|
||||
list.push(if i % 3 == 2 { thought() } else { execute() });
|
||||
}
|
||||
let entries = map(list);
|
||||
let mut layout = seeded_layout(entries.len());
|
||||
let spans = scan_and_project(&entries, &mut layout, 10, &HashSet::new());
|
||||
|
||||
assert_eq!(
|
||||
spans,
|
||||
vec![
|
||||
GroupSpan {
|
||||
range: 0..2,
|
||||
kind: GroupKind::VerbRun { members: 2 },
|
||||
expanded: false,
|
||||
},
|
||||
GroupSpan {
|
||||
range: 2..21,
|
||||
kind: GroupKind::Truncation {
|
||||
participants: 19,
|
||||
hidden: 9,
|
||||
},
|
||||
expanded: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Verb header row plus its folded member.
|
||||
assert!(layout[0].verb_group_header);
|
||||
assert_eq!(layout[0].group_header_count, 2);
|
||||
assert_eq!(layout[0].height, 1);
|
||||
assert_eq!(layout[1].height, 0);
|
||||
|
||||
// Truncation header reads "8 more" and hides the 8 rows behind it.
|
||||
assert!(!layout[2].verb_group_header);
|
||||
assert_eq!(layout[2].group_header_count, 8);
|
||||
assert_eq!(layout[2].height, 1);
|
||||
for info in &layout[3..11] {
|
||||
assert_eq!((info.height, info.group_header_count), (0, 0));
|
||||
}
|
||||
// The newest `max_visible` rows keep their seeded layout.
|
||||
for info in &layout[11..21] {
|
||||
assert_eq!((info.height, info.gap_after), (5, 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_at_or_under_budget_produce_no_truncation_span() {
|
||||
let entries = map((0..11).map(|_| execute()).collect());
|
||||
let mut layout = seeded_layout(entries.len());
|
||||
let spans = scan_and_project(&entries, &mut layout, 10, &HashSet::new());
|
||||
assert!(spans.is_empty());
|
||||
assert!(layout.iter().all(|i| i.height == 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verb_toggle_off_feeds_members_to_truncation() {
|
||||
let mut list = vec![skill_read(), skill_read()];
|
||||
list.extend((0..12).map(|_| execute()));
|
||||
let entries = map(list);
|
||||
let spans = scan(&entries, 10, &HashSet::new(), false, true);
|
||||
assert_eq!(
|
||||
spans,
|
||||
vec![GroupSpan {
|
||||
range: 0..14,
|
||||
kind: GroupKind::Truncation {
|
||||
participants: 14,
|
||||
hidden: 4,
|
||||
},
|
||||
expanded: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_verb_run_stacks_header_and_keeps_member_rows() {
|
||||
let entries = map(vec![skill_read(), skill_read()]);
|
||||
let mut layout = seeded_layout(entries.len());
|
||||
let expanded: HashSet<EntryId> = [EntryId::new(0)].into();
|
||||
let spans = scan_and_project(&entries, &mut layout, 10, &expanded);
|
||||
assert!(spans[0].expanded);
|
||||
assert!(layout[0].group_collapse_header);
|
||||
assert_eq!(layout[0].height, 2);
|
||||
assert_eq!(layout[1].height, 5, "expanded members keep their rows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_truncation_becomes_collapse_header_counting_rest() {
|
||||
let entries = map((0..13).map(|_| execute()).collect());
|
||||
let mut layout = seeded_layout(entries.len());
|
||||
let expanded: HashSet<EntryId> = [EntryId::new(0)].into();
|
||||
let spans = scan_and_project(&entries, &mut layout, 10, &expanded);
|
||||
assert_eq!(
|
||||
spans[0].kind,
|
||||
GroupKind::Truncation {
|
||||
participants: 13,
|
||||
hidden: 3,
|
||||
}
|
||||
);
|
||||
assert!(layout[0].group_collapse_header);
|
||||
assert_eq!(layout[0].group_header_count, 12);
|
||||
assert_eq!(layout[0].height, 1);
|
||||
assert!(layout[1..].iter().all(|i| i.height == 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_thinking_flows_through_truncation_without_participating() {
|
||||
// 12 executes with a hidden thought interleaved: the run still
|
||||
// truncates, the thought neither counts nor gets written.
|
||||
let mut list: Vec<ScrollbackEntry> = (0..6).map(|_| execute()).collect();
|
||||
list.push(thought());
|
||||
list.extend((0..6).map(|_| execute()));
|
||||
let entries = map(list);
|
||||
let mut layout = seeded_layout(entries.len());
|
||||
let spans = scan(
|
||||
&entries,
|
||||
10,
|
||||
&HashSet::new(),
|
||||
true,
|
||||
/*show_thinking=*/ false,
|
||||
);
|
||||
project_to_layout(&spans, &entries, &mut layout, false);
|
||||
assert_eq!(
|
||||
spans[0].kind,
|
||||
GroupKind::Truncation {
|
||||
participants: 12,
|
||||
hidden: 2,
|
||||
}
|
||||
);
|
||||
assert_eq!(layout[6].height, 5, "hidden thought layout untouched");
|
||||
// Header + one hidden row land on the participating executes around it.
|
||||
assert_eq!(layout[0].group_header_count, 1);
|
||||
assert_eq!(layout[1].height, 0);
|
||||
assert_eq!(layout[2].height, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn span_containing_hits_inside_and_misses_gaps_and_ends() {
|
||||
let span = |start: usize, end: usize| GroupSpan {
|
||||
range: start..end,
|
||||
kind: GroupKind::VerbRun { members: 1 },
|
||||
expanded: false,
|
||||
};
|
||||
let spans = [span(2, 5), span(9, 12)];
|
||||
assert!(span_containing(&spans, 1).is_none());
|
||||
assert_eq!(span_containing(&spans, 2), Some(&spans[0]));
|
||||
assert_eq!(span_containing(&spans, 4), Some(&spans[0]));
|
||||
assert!(span_containing(&spans, 5).is_none(), "end is exclusive");
|
||||
assert!(span_containing(&spans, 7).is_none(), "gap between spans");
|
||||
assert_eq!(span_containing(&spans, 11), Some(&spans[1]));
|
||||
assert!(span_containing(&spans, 12).is_none());
|
||||
assert!(span_containing(&[], 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_are_sorted_and_disjoint() {
|
||||
let mut list = vec![skill_read(), skill_read(), execute()];
|
||||
list.extend((0..12).map(|_| execute()));
|
||||
list.push(skill_read());
|
||||
list.push(skill_read());
|
||||
let entries = map(list);
|
||||
let spans = scan(&entries, 10, &HashSet::new(), true, true);
|
||||
assert!(spans.len() >= 2);
|
||||
for pair in spans.windows(2) {
|
||||
assert!(pair[0].range.end <= pair[1].range.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,437 @@
|
||||
//! Conversation timeline: one entry per turn, for jump navigation UIs
|
||||
//! (`/jump` picker; the timeline sidebar builds on the same data).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Max preview length stored per timeline entry. Render paths truncate
|
||||
/// further to the available width; this only bounds the snapshot.
|
||||
const PREVIEW_MAX_CHARS: usize = 120;
|
||||
|
||||
/// One turn in the conversation timeline.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TimelineEntry {
|
||||
/// Turn's display ordinal (snapshot-only; not used to act on the transcript).
|
||||
pub turn_idx: usize,
|
||||
/// Stable id of the turn's `UserPrompt` entry — the jump/preview target,
|
||||
/// resolved to an index only at the [`ScrollbackState`] boundary so a
|
||||
/// removal (`shift_remove`) can't make a stale index target another block.
|
||||
pub prompt_entry_id: EntryId,
|
||||
/// First non-empty line of the prompt text, char-capped.
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
/// First non-empty line, char-capped with a `…` marker. Bounded single pass:
|
||||
/// the length probe stops one char past the cap, so a huge one-line prompt
|
||||
/// costs O(cap), not O(line length). Char cap (not display width) on purpose —
|
||||
/// this bounds the stored snapshot; render paths re-truncate to their width.
|
||||
fn prompt_preview(text: &str) -> String {
|
||||
let line = text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("");
|
||||
let mut out: String = line.chars().take(PREVIEW_MAX_CHARS).collect();
|
||||
if out.chars().count() == PREVIEW_MAX_CHARS && line.chars().nth(PREVIEW_MAX_CHARS).is_some() {
|
||||
out.pop();
|
||||
out.push('\u{2026}');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl ScrollbackState {
|
||||
/// Timeline entries, one per turn in conversation order (oldest first).
|
||||
///
|
||||
/// Each entry carries the prompt's stable [`EntryId`]; dispatch resolves it
|
||||
/// to an index at the boundary, so the snapshot stays correct across both
|
||||
/// appends and removals.
|
||||
pub fn timeline_entries(&self) -> Vec<TimelineEntry> {
|
||||
self.turns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(turn_idx, turn)| {
|
||||
let (id, entry) = self.entries.get_index(turn.prompt_index)?;
|
||||
let preview = match &entry.block {
|
||||
RenderBlock::UserPrompt(block) => prompt_preview(&block.text),
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(TimelineEntry {
|
||||
turn_idx,
|
||||
prompt_entry_id: *id,
|
||||
preview,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Preview for one turn (avoids building the whole entry list when a
|
||||
/// single hover needs it, e.g. the sidebar tick popup).
|
||||
pub fn turn_preview(&self, turn_idx: usize) -> Option<String> {
|
||||
let turn = self.turns.get(turn_idx)?;
|
||||
self.entries
|
||||
.get_index(turn.prompt_index)
|
||||
.and_then(|(_, entry)| match &entry.block {
|
||||
RenderBlock::UserPrompt(block) => Some(prompt_preview(&block.text)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The focused turn: the last turn whose prompt is at/above the
|
||||
/// viewport top, or the first turn while pre-turn content owns the top.
|
||||
/// `None` only when there are no turns or no layout. Trailing turns
|
||||
/// short enough to never own the top row never become active — they're
|
||||
/// fully on screen when it matters.
|
||||
pub fn active_turn_for_viewport(&self) -> Option<usize> {
|
||||
if self.view_mode == ViewMode::SingleTurn {
|
||||
return self.current_turn;
|
||||
}
|
||||
if self.turns.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(self.prompts_above_top(false)?.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// The nearest turn an upward scroll can land on: the last turn whose
|
||||
/// prompt is STRICTLY above the viewport top, `None` when nothing is
|
||||
/// above. The ▲ chevron steps here rather than `active - 1`: from
|
||||
/// mid-turn it first aligns the current turn's own prompt (like the
|
||||
/// h key), and it can never target a trailing turn that no scroll
|
||||
/// reaches (the stuck-▲ bug).
|
||||
pub fn turn_above_viewport_top(&self) -> Option<usize> {
|
||||
if self.view_mode == ViewMode::SingleTurn {
|
||||
return self.current_turn?.checked_sub(1);
|
||||
}
|
||||
self.prompts_above_top(true)?.checked_sub(1)
|
||||
}
|
||||
|
||||
/// The nearest turn below the viewport top. Before the first prompt,
|
||||
/// this is the first turn; on a prompt row, it is the following turn.
|
||||
pub fn turn_below_viewport_top(&self) -> Option<usize> {
|
||||
if self.view_mode == ViewMode::SingleTurn {
|
||||
let next = self.current_turn?.checked_add(1)?;
|
||||
return (next < self.turns.len()).then_some(next);
|
||||
}
|
||||
let next = self.prompts_above_top(false)?;
|
||||
(next < self.turns.len()).then_some(next)
|
||||
}
|
||||
|
||||
/// Count of turns whose prompt row is above the viewport top (`strict`:
|
||||
/// strictly above; else at-or-above). Prompt rows are monotone in turn
|
||||
/// order, so this is a partition point over cached `virtual_y`.
|
||||
fn prompts_above_top(&self, strict: bool) -> Option<usize> {
|
||||
let cache = self.layout_cache.as_ref()?;
|
||||
let range = self.visible_entry_range();
|
||||
let base = *cache.virtual_y.get(range.start)?;
|
||||
let top = base + self.scroll_offset;
|
||||
Some(self.turns.partition_point(|turn| {
|
||||
cache
|
||||
.virtual_y
|
||||
.get(turn.prompt_index)
|
||||
.is_some_and(|&prompt_y| {
|
||||
if strict {
|
||||
prompt_y < top
|
||||
} else {
|
||||
prompt_y <= top
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_util::*;
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn timeline_entries_one_per_turn_in_order() {
|
||||
let mut state = ScrollbackState::new();
|
||||
state.push_block(stub_block("session banner")); // 0: pre-turn
|
||||
state.push_block(user_block("first question")); // 1
|
||||
state.push_block(agent_block("first answer")); // 2
|
||||
state.push_block(user_block("second question")); // 3
|
||||
state.push_block(tool_block("ls")); // 4
|
||||
state.prepare_layout(80, 10);
|
||||
|
||||
let entries = state.timeline_entries();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].turn_idx, 0);
|
||||
assert_eq!(state.index_of_id(entries[0].prompt_entry_id), Some(1));
|
||||
assert_eq!(entries[0].preview, "first question");
|
||||
assert_eq!(entries[1].turn_idx, 1);
|
||||
assert_eq!(state.index_of_id(entries[1].prompt_entry_id), Some(3));
|
||||
assert_eq!(entries[1].preview, "second question");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_takes_first_nonempty_line_and_caps_length() {
|
||||
let mut state = ScrollbackState::new();
|
||||
state.push_block(user_block("\n\n leading blanks skipped \nsecond line"));
|
||||
let long = "x".repeat(500);
|
||||
state.push_block(user_block(&long));
|
||||
state.prepare_layout(80, 10);
|
||||
|
||||
let entries = state.timeline_entries();
|
||||
assert_eq!(entries[0].preview, "leading blanks skipped");
|
||||
assert_eq!(entries[1].preview.chars().count(), 120);
|
||||
assert!(entries[1].preview.ends_with('\u{2026}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_tracks_viewport_top() {
|
||||
let mut state = ScrollbackState::new();
|
||||
state.push_block(user_block("Q1")); // 0
|
||||
state.push_block(tall_agent_block()); // 1
|
||||
state.push_block(user_block("Q2")); // 2
|
||||
state.push_block(tall_agent_block()); // 3
|
||||
state.push_block(user_block("Q3")); // 4
|
||||
state.push_block(tall_agent_block()); // 5
|
||||
state.prepare_layout(80, 6);
|
||||
|
||||
state.goto_top();
|
||||
assert_eq!(state.active_turn_for_viewport(), Some(0));
|
||||
|
||||
state.scroll_to_entry_top(2);
|
||||
assert_eq!(state.active_turn_for_viewport(), Some(1));
|
||||
|
||||
state.goto_bottom();
|
||||
assert_eq!(state.active_turn_for_viewport(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_stays_top_anchored_at_the_bottom() {
|
||||
// A screenful of short trailing turns: even at the bottom the
|
||||
// active turn is the one owning the top row (the web-timeline
|
||||
// rule) — never a newest-turn clamp, whose one-step-off-bottom
|
||||
// highlight leap and stuck-▲ chevron this replaced.
|
||||
let mut state = ScrollbackState::new();
|
||||
state.push_block(user_block("Q1"));
|
||||
state.push_block(tall_agent_block());
|
||||
for i in 2..8 {
|
||||
state.push_block(user_block(&format!("Q{i}")));
|
||||
state.push_block(agent_block("ok"));
|
||||
}
|
||||
state.prepare_layout(80, 12);
|
||||
|
||||
state.goto_bottom();
|
||||
let at_bottom = state.active_turn_for_viewport().expect("active at bottom");
|
||||
assert!(at_bottom < 6, "top-anchored, not the newest: {at_bottom}");
|
||||
|
||||
// Nudging off the bottom moves the highlight at most one boundary
|
||||
// (the old clamp leapt from the newest turn to the top-anchored one).
|
||||
state.scroll_up(1);
|
||||
let nudged = state.active_turn_for_viewport().expect("still in a turn");
|
||||
assert!(
|
||||
at_bottom - nudged <= 1,
|
||||
"no highlight leap: {at_bottom} -> {nudged}"
|
||||
);
|
||||
}
|
||||
|
||||
/// One render-frame + chevron click, wired exactly like the app:
|
||||
/// render.rs builds the rail from viewport state, mouse.rs resolves the
|
||||
/// hit through `chevron_target` and jumps. `None` = the chevron was dim.
|
||||
fn click_chevron(state: &mut ScrollbackState, viewport_height: u16, up: bool) -> Option<usize> {
|
||||
use crate::views::timeline::{RailViewport, TimelineHit, chevron_target, compute_rail};
|
||||
state.prepare_layout(80, viewport_height);
|
||||
let area = ratatui::layout::Rect::new(0, 0, 80, viewport_height);
|
||||
let vp = RailViewport {
|
||||
active: state.active_turn_for_viewport(),
|
||||
up_target: state.turn_above_viewport_top(),
|
||||
down_target: state.turn_below_viewport_top(),
|
||||
at_bottom: !state.has_content_below(),
|
||||
};
|
||||
let rail = compute_rail(area, 78, state.turn_count(), vp).expect("rail eligible");
|
||||
let hit = if up {
|
||||
TimelineHit::Up
|
||||
} else {
|
||||
TimelineHit::Down
|
||||
};
|
||||
let target = chevron_target(&rail, hit)?;
|
||||
state.jump_to_turn(target);
|
||||
Some(target)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chevrons_walk_the_conversation_end_to_end_without_sticking() {
|
||||
// The stuck-▲ shape: one tall response, then six short turns that
|
||||
// all cluster inside the final screenful.
|
||||
let mut state = ScrollbackState::new();
|
||||
state.push_block(user_block("Q1"));
|
||||
state.push_block(tall_agent_block());
|
||||
for i in 2..8 {
|
||||
state.push_block(user_block(&format!("Q{i}")));
|
||||
state.push_block(agent_block("ok"));
|
||||
}
|
||||
state.prepare_layout(80, 12);
|
||||
state.goto_bottom();
|
||||
|
||||
// ▲ to the very top: every click moves the viewport up, one
|
||||
// boundary per click once on a prompt row, no sticking.
|
||||
let mut up_visits = Vec::new();
|
||||
while up_visits.len() < 16 {
|
||||
let before = state.scroll_offset();
|
||||
let Some(target) = click_chevron(&mut state, 12, true) else {
|
||||
break;
|
||||
};
|
||||
assert!(
|
||||
state.scroll_offset() < before,
|
||||
"▲ #{} must move the viewport up",
|
||||
up_visits.len()
|
||||
);
|
||||
up_visits.push(target);
|
||||
}
|
||||
assert_eq!(state.scroll_offset(), 0, "▲ walk reaches the top");
|
||||
assert_eq!(up_visits.last(), Some(&0), "▲ walk ends at the first turn");
|
||||
assert!(
|
||||
up_visits.windows(2).all(|w| w[0] - w[1] == 1),
|
||||
"one boundary per click: {up_visits:?}"
|
||||
);
|
||||
assert_eq!(click_chevron(&mut state, 12, true), None, "▲ dim at top");
|
||||
|
||||
// ▼ back down: strictly forward, never sticking, and it terminates
|
||||
// (dims) rather than repeating a turn or running forever.
|
||||
let mut down_visits = Vec::new();
|
||||
while down_visits.len() < 16 {
|
||||
let Some(target) = click_chevron(&mut state, 12, false) else {
|
||||
break;
|
||||
};
|
||||
if let Some(&prev) = down_visits.last() {
|
||||
assert!(
|
||||
target > prev,
|
||||
"▼ moves strictly forward: {down_visits:?} then {target}"
|
||||
);
|
||||
}
|
||||
down_visits.push(target);
|
||||
}
|
||||
assert!(!down_visits.is_empty(), "▼ steps down from the top");
|
||||
assert!(
|
||||
down_visits.len() < 16,
|
||||
"▼ walk terminates (dims), no sticking: {down_visits:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_chevron_enters_trailing_turns_at_the_bottom() {
|
||||
// Reported bug: a cluster of short turns fills the final screenful,
|
||||
// leaving ▼ dim at the bottom even though clicking those ticks jumped
|
||||
// to them. ▼ now targets the next turn — the same turn a tick click
|
||||
// resolves to (both go through jump_to_turn).
|
||||
let mut state = ScrollbackState::new();
|
||||
state.push_block(user_block("Q1"));
|
||||
state.push_block(tall_agent_block());
|
||||
for i in 2..8 {
|
||||
state.push_block(user_block(&format!("Q{i}")));
|
||||
state.push_block(agent_block("ok"));
|
||||
}
|
||||
state.prepare_layout(80, 12);
|
||||
state.goto_bottom();
|
||||
|
||||
let active = state.active_turn_for_viewport().expect("active at bottom");
|
||||
assert!(active < 7, "trailing turns sit below the top-anchored turn");
|
||||
assert_eq!(
|
||||
state.turn_below_viewport_top(),
|
||||
Some(active + 1),
|
||||
"▼ has a target below the top-anchored turn"
|
||||
);
|
||||
assert_eq!(
|
||||
click_chevron(&mut state, 12, false),
|
||||
Some(active + 1),
|
||||
"▼ steps to the next turn instead of dimming"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_chevron_snaps_to_the_current_prompt_mid_turn() {
|
||||
// Midway through a response ▲ first aligns the current turn's own
|
||||
// prompt to the top (matching the h key), then steps to older turns.
|
||||
let mut state = ScrollbackState::new();
|
||||
state.push_block(user_block("Q1"));
|
||||
state.push_block(tall_agent_block());
|
||||
state.push_block(user_block("Q2"));
|
||||
state.push_block(tall_agent_block());
|
||||
state.prepare_layout(80, 6);
|
||||
state.goto_top();
|
||||
state.scroll_down(3);
|
||||
|
||||
assert_eq!(state.active_turn_for_viewport(), Some(0));
|
||||
assert_eq!(
|
||||
click_chevron(&mut state, 6, true),
|
||||
Some(0),
|
||||
"snap to own prompt"
|
||||
);
|
||||
assert_eq!(state.scroll_offset(), 0);
|
||||
assert_eq!(
|
||||
click_chevron(&mut state, 6, true),
|
||||
None,
|
||||
"then dim at the top"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chevrons_when_everything_fits_on_one_screen() {
|
||||
// Fits with room to spare: the first turn owns the top. ▲ dims (nothing
|
||||
// above), but ▼ still enters the next turn — anchoring it to the top
|
||||
// like clicking its tick — rather than dimming.
|
||||
let mut state = ScrollbackState::new();
|
||||
state.push_block(user_block("Q1"));
|
||||
state.push_block(agent_block("a1"));
|
||||
state.push_block(user_block("Q2"));
|
||||
state.push_block(agent_block("a2"));
|
||||
state.prepare_layout(80, 40);
|
||||
|
||||
assert_eq!(state.active_turn_for_viewport(), Some(0));
|
||||
assert_eq!(
|
||||
click_chevron(&mut state, 40, true),
|
||||
None,
|
||||
"▲ dim at first turn"
|
||||
);
|
||||
assert_eq!(
|
||||
click_chevron(&mut state, 40, false),
|
||||
Some(1),
|
||||
"▼ enters the second turn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_turn_content_dims_up_and_down_enters_the_first_turn() {
|
||||
let mut state = ScrollbackState::new();
|
||||
for i in 0..10 {
|
||||
state.push_block(stub_block(&format!("banner {i}")));
|
||||
}
|
||||
state.push_block(user_block("Q1"));
|
||||
state.push_block(tall_agent_block());
|
||||
state.push_block(user_block("Q2"));
|
||||
state.push_block(agent_block("ok"));
|
||||
state.prepare_layout(80, 12);
|
||||
state.goto_top();
|
||||
|
||||
// Pre-turn content focuses the first tick; ▲ is dim while ▼ enters
|
||||
// that first turn rather than skipping it.
|
||||
assert_eq!(state.active_turn_for_viewport(), Some(0));
|
||||
assert_eq!(click_chevron(&mut state, 12, true), None);
|
||||
assert_eq!(click_chevron(&mut state, 12, false), Some(0));
|
||||
assert_eq!(state.active_turn_for_viewport(), Some(0));
|
||||
assert_eq!(
|
||||
click_chevron(&mut state, 12, true),
|
||||
None,
|
||||
"▲ dim on the first turn (nothing above)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_is_first_before_first_prompt() {
|
||||
let mut state = ScrollbackState::new();
|
||||
for i in 0..10 {
|
||||
state.push_block(stub_block(&format!("banner {i}")));
|
||||
}
|
||||
state.push_block(user_block("Q1"));
|
||||
state.push_block(tall_agent_block());
|
||||
state.prepare_layout(80, 4);
|
||||
|
||||
state.goto_top();
|
||||
assert_eq!(state.active_turn_for_viewport(), Some(0));
|
||||
|
||||
state.goto_bottom();
|
||||
assert_eq!(state.active_turn_for_viewport(), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use std::ops::Range;
|
||||
|
||||
/// Status of a turn in the conversation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TurnStatus {
|
||||
/// Turn is currently running (agent is responding).
|
||||
#[default]
|
||||
Running,
|
||||
/// Turn completed successfully.
|
||||
Completed,
|
||||
/// Turn failed (error occurred).
|
||||
Failed,
|
||||
/// Turn was cancelled by user.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Navigation direction for block selection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum NavDirection {
|
||||
/// Moving down (j key) - should show top of block first.
|
||||
#[default]
|
||||
Down,
|
||||
/// Moving up (k key) - should show bottom of block first.
|
||||
Up,
|
||||
}
|
||||
|
||||
/// A turn in the conversation (user prompt + all responses until next prompt).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Turn {
|
||||
/// Index of the UserPrompt entry that starts this turn.
|
||||
pub prompt_index: usize,
|
||||
/// Index past the last entry (exclusive, like Range).
|
||||
pub end_index: usize,
|
||||
/// Current status.
|
||||
pub status: TurnStatus,
|
||||
}
|
||||
|
||||
impl Turn {
|
||||
/// Get the range of entry indices in this turn.
|
||||
pub fn range(&self) -> Range<usize> {
|
||||
self.prompt_index..self.end_index
|
||||
}
|
||||
|
||||
/// Number of entries in this turn.
|
||||
pub fn len(&self) -> usize {
|
||||
self.end_index - self.prompt_index
|
||||
}
|
||||
|
||||
/// Whether this turn is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// How the scrollback is displayed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ViewMode {
|
||||
/// Show all turns in a single timeline.
|
||||
#[default]
|
||||
AllTurns,
|
||||
/// Show only a single turn.
|
||||
SingleTurn,
|
||||
}
|
||||
|
||||
/// Maximum truncated header height for AllTurns sticky headers.
|
||||
/// (vpad + 3 content lines + ellipsis if needed + vpad)
|
||||
pub(super) const MAX_TRUNCATED_HEADER_HEIGHT: u16 = 6;
|
||||
|
||||
/// Duration (ms) an entry's accent stays bright after finishing.
|
||||
/// Used by the renderer to flash the accent on recently-finished entries.
|
||||
pub const FINISH_FLASH_DURATION_MS: u64 = 400;
|
||||
|
||||
/// Extra entries measured EXACTLY just beyond the visible viewport edge when
|
||||
/// settling lazy heights. A small below-margin means a just-off-screen entry is
|
||||
/// already exact before it scrolls in, so small estimate errors don't leave a
|
||||
/// visible entry sized from an estimate.
|
||||
pub(super) const MEASURE_MARGIN_ENTRIES: usize = 8;
|
||||
|
||||
/// Entries kept (not swept) on each side of the measurement window by
|
||||
/// off-screen render-cache eviction — several screens' worth, so normal
|
||||
/// paging never touches a cold entry, while the bulk of a long session's
|
||||
/// rendered output can be reclaimed.
|
||||
pub(super) const EVICT_KEEP_MARGIN_ENTRIES: usize = 128;
|
||||
|
||||
/// On a bottom-pinned full rebuild (resume) we eagerly measure this many pages
|
||||
/// of entries ABOVE the viewport, so an immediate scroll-up lands on already
|
||||
/// exact heights (no estimate->exact rebuild, hence no jump) and the scrollbar
|
||||
/// is accurate right away. Bounded — keeps resume at O(viewport), not O(history).
|
||||
pub(super) const RESUME_WARM_PAGES: u16 = 3;
|
||||
|
||||
/// Per-entry layout info, cached for rendering and navigation.
|
||||
///
|
||||
/// Combines height and gap data in a single struct for cache-friendliness
|
||||
/// (they're always accessed together during layout and rendering).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct EntryLayoutInfo {
|
||||
/// Rendered height at current width.
|
||||
pub height: u16,
|
||||
/// Gap rows after this entry (0 for dense group members, 1 otherwise).
|
||||
pub gap_after: u16,
|
||||
/// When non-zero, this entry renders as a group header instead of its
|
||||
/// normal block content. For N-more truncation headers it is the number
|
||||
/// of hidden entries (drives the plain "╶╶ N more" fallback text; frames
|
||||
/// with fold spans render the aggregated bucket label instead). For
|
||||
/// verb-group headers it is the run's MEMBER count — tool calls and
|
||||
/// subagent rows; folded thoughts never count — and the value is never
|
||||
/// rendered — header gates go through [`Self::is_group_header`].
|
||||
pub group_header_count: u16,
|
||||
/// When true, this entry renders as an expanded-group collapse header.
|
||||
/// Set on the first entry of a manually-expanded group. N-more headers
|
||||
/// replace the entry's content with the "N tool calls" line; expanded
|
||||
/// verb-group headers stack the header line ABOVE the entry's own row
|
||||
/// (slot height 2), so every member stays visible.
|
||||
pub group_collapse_header: bool,
|
||||
/// When true, this entry heads a verb-group run: it renders the aggregated
|
||||
/// "Verb N noun" label instead of its own content (collapsed state) or
|
||||
/// marks the collapse header of an expanded verb group. The run's other
|
||||
/// claimed entries — members and folded thoughts — hide behind it
|
||||
/// (height 0) until the group is expanded.
|
||||
pub verb_group_header: bool,
|
||||
}
|
||||
|
||||
impl EntryLayoutInfo {
|
||||
/// Whether this entry renders as any kind of group header (N-more
|
||||
/// truncation, expanded-group collapse, or verb) in place of its own
|
||||
/// block content. The single gate shared by every consumer, so no site
|
||||
/// re-derives it from the raw fields and silently drops one header
|
||||
/// family (the count's meaning differs per family; see
|
||||
/// [`Self::group_header_count`]).
|
||||
pub fn is_group_header(&self) -> bool {
|
||||
self.group_header_count > 0 || self.group_collapse_header
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
//! Verb-group aggregation: the "Read 10 files, Ran 2 subagents" header
|
||||
//! label for a folded run of consecutive non-destructive tool calls and
|
||||
//! subagent lifecycle rows, plus any finished collapsed thoughts the run
|
||||
//! claims. Also home of the run classification ([`run_step`]) shared by the
|
||||
//! layout fold, range resolution, and the label walk.
|
||||
//!
|
||||
//! The layout pass in `state/layout.rs` detects the runs and marks the header
|
||||
//! via `EntryLayoutInfo::verb_group_header`; the render loop calls
|
||||
//! [`verb_group_header_label`] to build the live label each frame (running
|
||||
//! entries repaint every tick, so tense and counts update in place — no
|
||||
//! per-call detail churns beside the label while the run executes).
|
||||
//!
|
||||
//! The same bucket vocabulary labels group-truncation ("N more") headers:
|
||||
//! the render loop calls [`truncation_header_label`] with the fold's span,
|
||||
//! and both walks feed the shared `BucketAccumulator` so the two label
|
||||
//! families can't drift.
|
||||
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SubagentBlockKind;
|
||||
use crate::scrollback::blocks::tool::{ToolCallBlock, VerbGroupKind};
|
||||
use crate::scrollback::entry::ScrollbackEntry;
|
||||
use crate::scrollback::types::DisplayMode;
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// One step of a verb-group run walk.
|
||||
pub(crate) enum RunStep {
|
||||
/// A collapsed verb-groupable tool or subagent entry: joins the run and
|
||||
/// counts toward the fold threshold ([`RunScan::folds`]).
|
||||
Member(VerbGroupKind),
|
||||
/// A finished, collapsed, shown thinking entry: claims into the run
|
||||
/// (folds to height 0) but never counts toward the threshold and never
|
||||
/// appears in the header label.
|
||||
ThoughtMember,
|
||||
/// An entry that renders its own rows (or none) without joining or
|
||||
/// breaking the run: hidden, streaming, user-opened, or chrome-carrying
|
||||
/// thinking, and a manually-opened verb-groupable tool.
|
||||
Transparent,
|
||||
/// Anything else: ends the run.
|
||||
Break,
|
||||
}
|
||||
|
||||
/// Classify one entry for run walking — the single source of truth shared by
|
||||
/// the layout fold scan, `verb_group_range_of`, and the label walk.
|
||||
///
|
||||
/// Members are collapsed verb-groupable tool calls and subagent lifecycle
|
||||
/// rows; pending-user-input and hook-annotated rows stay standalone (their
|
||||
/// prompt / `[hooks: N/M]` chrome must remain visible). A manually-opened
|
||||
/// member is [`RunStep::Transparent`] — it keeps its own rows inside the run
|
||||
/// instead of splitting it. Thinking never breaks a run: a finished
|
||||
/// collapsed thought folds in as [`RunStep::ThoughtMember`]; hidden,
|
||||
/// still-streaming, opened, or chrome-carrying thinking is
|
||||
/// [`RunStep::Transparent`].
|
||||
pub(crate) fn run_step(entry: &ScrollbackEntry, show_thinking: bool) -> RunStep {
|
||||
// Prompt / `[hooks: N/M]` chrome must stay visible, so chrome-carrying
|
||||
// entries never claim into a run.
|
||||
let no_chrome = !entry.is_pending_user_input && entry.hook_data.is_none();
|
||||
// Claimed entries are collapsed single-row + chromeless — the contract
|
||||
// the fold's absolute height-2 expanded slot leans on. Tools check the
|
||||
// collapsed half inline to split Member from Transparent.
|
||||
let claimable = entry.display_mode == DisplayMode::Collapsed && no_chrome;
|
||||
if let RenderBlock::ToolCall(block) = &entry.block
|
||||
&& let Some(kind) = block.verb_group_kind()
|
||||
&& no_chrome
|
||||
{
|
||||
if entry.display_mode == DisplayMode::Collapsed {
|
||||
RunStep::Member(kind)
|
||||
} else {
|
||||
// A manually-opened member keeps its own rows without splitting
|
||||
// the run — same treatment as opened thinking — so toggling a
|
||||
// member of an expanded group never dissolves the group.
|
||||
RunStep::Transparent
|
||||
}
|
||||
} else if matches!(entry.block, RenderBlock::Subagent(_)) {
|
||||
if claimable {
|
||||
RunStep::Member(VerbGroupKind::Subagent)
|
||||
} else {
|
||||
// Subagent rows are always collapsed single-row entries; this
|
||||
// arm only guards chrome — prompt / hook rows must stay visible,
|
||||
// so such an entry splits the run like a chrome-carrying tool.
|
||||
RunStep::Break
|
||||
}
|
||||
} else if entry.block.is_thinking() {
|
||||
if show_thinking && !entry.is_running && claimable {
|
||||
RunStep::ThoughtMember
|
||||
} else {
|
||||
RunStep::Transparent
|
||||
}
|
||||
} else {
|
||||
RunStep::Break
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an in-place block swap changes the entry's verb-group kind (e.g.
|
||||
/// the eager `Other` placeholder refining into a `Read`). Such swaps change
|
||||
/// fold membership, so the swap site must mark the entry structurally dirty
|
||||
/// for the layout fold to catch up on the next frame.
|
||||
pub(crate) fn verb_group_kind_changed(old: &RenderBlock, new: &RenderBlock) -> bool {
|
||||
let kind_of = |block: &RenderBlock| match block {
|
||||
RenderBlock::ToolCall(tc) => tc.verb_group_kind(),
|
||||
_ => None,
|
||||
};
|
||||
kind_of(old) != kind_of(new)
|
||||
}
|
||||
|
||||
/// Shape of one forward run walk, as reported by [`scan_run_forward`].
|
||||
pub(crate) struct RunScan {
|
||||
/// Member entries counted (tool calls and subagent rows), including a
|
||||
/// member start entry. Thought members claim but never count — the fold
|
||||
/// threshold is members-only.
|
||||
pub(crate) members: usize,
|
||||
/// Exclusive run end: one past the last claimed entry (member or thought
|
||||
/// member), so trailing transparent entries stay outside the run.
|
||||
pub(crate) end: usize,
|
||||
/// Where the walk stopped: the breaking entry's index, or the first index
|
||||
/// where `entry_at` returned `None`.
|
||||
pub(crate) stop: usize,
|
||||
}
|
||||
|
||||
impl RunScan {
|
||||
/// Whether the run folds into a verb-group header row. One member is
|
||||
/// enough — the compact label beats the member's own row, and the header
|
||||
/// appearing with the first streaming call avoids a fold-in jump when the
|
||||
/// second arrives. Members-only: thought members claim into runs but
|
||||
/// never count, so a pure-thought run (whose label would be empty) never
|
||||
/// folds. The single predicate shared by the layout fold and
|
||||
/// `verb_group_range_of` so the two can't drift.
|
||||
pub(crate) fn folds(&self) -> bool {
|
||||
self.members >= 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a run forward from `start` until a breaking entry or the end of the
|
||||
/// entries, and report the run's shape. Returns `None` when the entry at
|
||||
/// `start` is missing or cannot anchor a run (members and thought members
|
||||
/// can; transparent and breaking entries cannot) — anchor eligibility lives
|
||||
/// in this function's matches, not in caller pre-checks — so a returned scan
|
||||
/// always has `end > start` and `stop > start` (`members` may be 0 for a
|
||||
/// thought-anchored walk with no members). The layout fold scan and
|
||||
/// `verb_group_range_of` share this walk so both agree on the exact run
|
||||
/// shape; the label walk needs per-member block data and stays its own loop,
|
||||
/// kept in sync by its exhaustive `RunStep` match.
|
||||
pub(crate) fn scan_run_forward<'e>(
|
||||
entry_at: impl Fn(usize) -> Option<&'e ScrollbackEntry>,
|
||||
start: usize,
|
||||
show_thinking: bool,
|
||||
) -> Option<RunScan> {
|
||||
// Members and finished thoughts anchor runs; transparent thinking may
|
||||
// sit inside one but cannot start one.
|
||||
match run_step(entry_at(start)?, show_thinking) {
|
||||
RunStep::Member(_) | RunStep::ThoughtMember => {}
|
||||
RunStep::Transparent | RunStep::Break => return None,
|
||||
}
|
||||
let mut members = 0usize;
|
||||
let mut end = start;
|
||||
let mut i = start;
|
||||
while let Some(entry) = entry_at(i) {
|
||||
match run_step(entry, show_thinking) {
|
||||
RunStep::Member(_) => {
|
||||
members += 1;
|
||||
end = i + 1;
|
||||
}
|
||||
RunStep::ThoughtMember => end = i + 1,
|
||||
RunStep::Transparent => {}
|
||||
RunStep::Break => break,
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Some(RunScan {
|
||||
members,
|
||||
end,
|
||||
stop: i,
|
||||
})
|
||||
}
|
||||
|
||||
/// Aggregated header state for one verb-group run.
|
||||
pub struct VerbGroupHeaderLabel {
|
||||
/// Styled label line rendered on the header row.
|
||||
pub line: Line<'static>,
|
||||
/// Plain-text label (selection/copy text for the header row).
|
||||
pub text: String,
|
||||
/// Any member still running (animated accent + present-tense verbs).
|
||||
pub running: bool,
|
||||
/// Any member failed (error accent).
|
||||
pub failed: bool,
|
||||
}
|
||||
|
||||
/// The single channel a group-header row's aggregated label travels
|
||||
/// through, mirroring the fold families of `groups::GroupKind`. A header
|
||||
/// row belongs to exactly one fold, so a row carries at most one label —
|
||||
/// the exclusivity is structural. The variant picks the header chrome
|
||||
/// (verb-run headers wear run-state accents; truncation headers keep the
|
||||
/// dimmed fold chrome); the label payload is shared.
|
||||
pub enum GroupHeaderLabel {
|
||||
/// Verb-group run header ("Read 3 files, Searched 2 patterns").
|
||||
VerbRun(VerbGroupHeaderLabel),
|
||||
/// Labeled truncation ("N more") header ("Ran 6 commands").
|
||||
Truncation(VerbGroupHeaderLabel),
|
||||
}
|
||||
|
||||
impl GroupHeaderLabel {
|
||||
/// The aggregated label payload, whichever fold family produced it.
|
||||
pub fn label(&self) -> &VerbGroupHeaderLabel {
|
||||
match self {
|
||||
GroupHeaderLabel::VerbRun(label) | GroupHeaderLabel::Truncation(label) => label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-kind aggregation bucket, ordered by first appearance in the run.
|
||||
/// Borrows citation strings from the walked blocks (per-frame, no allocation).
|
||||
struct Bucket<'e> {
|
||||
kind: VerbGroupKind,
|
||||
calls: usize,
|
||||
/// Distinct-count override: when non-empty its size replaces `calls` as
|
||||
/// the displayed count. Holds WebSearch citation URLs (distinct result
|
||||
/// websites) and subagent child session ids (started + terminal rows of
|
||||
/// one subagent count once; a burst of terminal rows counts each
|
||||
/// distinct subagent).
|
||||
sources: std::collections::HashSet<&'e str>,
|
||||
}
|
||||
|
||||
/// Walk the verb-group run starting at `header_idx` (same [`run_step`] rules
|
||||
/// as the layout fold: thinking and hidden entries are skipped, anything
|
||||
/// else ends the run) and build the aggregated label. The label counts
|
||||
/// members only: folded thoughts contribute nothing here and surface as
|
||||
/// their own member rows only when the group is expanded.
|
||||
///
|
||||
/// `end` is the run's exclusive upper bound in `entries` indices. Callers
|
||||
/// with the fold's span (see `state::groups`) pass its exact end so the
|
||||
/// label counts precisely the entries the fold claimed; callers without one
|
||||
/// pass `entries.len()` and rely on the [`RunStep::Break`] arm, which is
|
||||
/// kept as the in-bound stop in either case.
|
||||
pub fn verb_group_header_label(
|
||||
entries: &[&ScrollbackEntry],
|
||||
header_idx: usize,
|
||||
end: usize,
|
||||
show_thinking: bool,
|
||||
theme: &Theme,
|
||||
) -> VerbGroupHeaderLabel {
|
||||
let mut acc = BucketAccumulator::default();
|
||||
|
||||
let end = end.min(entries.len());
|
||||
for &entry in &entries[header_idx.min(end)..end] {
|
||||
let kind = match run_step(entry, show_thinking) {
|
||||
RunStep::Member(kind) => kind,
|
||||
RunStep::Break => break,
|
||||
RunStep::ThoughtMember | RunStep::Transparent => continue,
|
||||
};
|
||||
acc.push(kind, entry);
|
||||
}
|
||||
|
||||
acc.into_label(theme)
|
||||
}
|
||||
|
||||
/// Aggregated label for a truncation ("N more") header, describing the rows
|
||||
/// the fold hid — "Ran 6 commands, Read 2 files" — through the same bucket
|
||||
/// vocabulary as verb-group headers.
|
||||
///
|
||||
/// Walks the span's participants (skipping hidden thinking exactly like the
|
||||
/// fold's projection) from `range.start`, stopping after `limit`
|
||||
/// participants when given — the collapsed header describes only its hidden
|
||||
/// prefix; the expanded collapse header passes `None` and describes the
|
||||
/// whole run. Thoughts occupy participant slots but are NEVER bucketed:
|
||||
/// like verb-group labels, group labels stay tools-only. Returns `None` —
|
||||
/// the caller keeps the plain "N more" count — when nothing was bucketed (a
|
||||
/// pure-thought prefix) or when any walked participant has no bucket
|
||||
/// (System/SessionEvent rows, lifecycle chrome): thoughts are the only
|
||||
/// participants a label may silently omit, anything else would make it
|
||||
/// under-describe what the fold conceals.
|
||||
pub fn truncation_header_label(
|
||||
entries: &[&ScrollbackEntry],
|
||||
range: std::ops::Range<usize>,
|
||||
limit: Option<usize>,
|
||||
show_thinking: bool,
|
||||
theme: &Theme,
|
||||
) -> Option<VerbGroupHeaderLabel> {
|
||||
let mut acc = BucketAccumulator::default();
|
||||
let end = range.end.min(entries.len());
|
||||
let mut participants = 0usize;
|
||||
|
||||
for &entry in &entries[range.start.min(end)..end] {
|
||||
if limit.is_some_and(|n| participants >= n) {
|
||||
break;
|
||||
}
|
||||
if entry.is_hidden_thinking(show_thinking) {
|
||||
continue;
|
||||
}
|
||||
participants += 1;
|
||||
if entry.block.is_thinking() {
|
||||
continue;
|
||||
}
|
||||
match &entry.block {
|
||||
RenderBlock::ToolCall(block) => acc.push(block.label_kind()?, entry),
|
||||
RenderBlock::Subagent(_) => acc.push(VerbGroupKind::Subagent, entry),
|
||||
// A participant the vocabulary can't name would leave the label
|
||||
// dishonest about what's hidden; decline so the numerically
|
||||
// exact plain count renders instead.
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
if acc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(acc.into_label(theme))
|
||||
}
|
||||
|
||||
/// Shared bucket accumulation + label rendering for the aggregated group
|
||||
/// headers. Callers own the walk (which entries join and under what
|
||||
/// classification); this owns per-kind counting, distinct-source overrides,
|
||||
/// failure counting, and the rendered line.
|
||||
#[derive(Default)]
|
||||
struct BucketAccumulator<'e> {
|
||||
buckets: Vec<Bucket<'e>>,
|
||||
running: bool,
|
||||
failed_count: usize,
|
||||
}
|
||||
|
||||
impl<'e> BucketAccumulator<'e> {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.buckets.is_empty()
|
||||
}
|
||||
|
||||
fn push(&mut self, kind: VerbGroupKind, entry: &'e ScrollbackEntry) {
|
||||
let pos = match self.buckets.iter().position(|b| b.kind == kind) {
|
||||
Some(pos) => pos,
|
||||
None => {
|
||||
self.buckets.push(Bucket {
|
||||
kind,
|
||||
calls: 0,
|
||||
sources: std::collections::HashSet::new(),
|
||||
});
|
||||
self.buckets.len() - 1
|
||||
}
|
||||
};
|
||||
let bucket = &mut self.buckets[pos];
|
||||
bucket.calls += 1;
|
||||
// Bucketed entries are tool-call or subagent rows by construction
|
||||
// (both walks); the block feeds the distinct-count override and
|
||||
// failure detection.
|
||||
match &entry.block {
|
||||
RenderBlock::ToolCall(block) => {
|
||||
if let ToolCallBlock::WebSearch(b) = block
|
||||
&& b.is_success()
|
||||
{
|
||||
bucket
|
||||
.sources
|
||||
.extend(b.citations.iter().map(String::as_str));
|
||||
}
|
||||
if block_failed(block) {
|
||||
self.failed_count += 1;
|
||||
}
|
||||
}
|
||||
RenderBlock::Subagent(sb) => {
|
||||
bucket.sources.insert(sb.child_session_id.as_str());
|
||||
// Cancelled is deliberate, not an error — only Failed feeds
|
||||
// the red suffix.
|
||||
if matches!(sb.kind, SubagentBlockKind::Failed { .. }) {
|
||||
self.failed_count += 1;
|
||||
}
|
||||
}
|
||||
// Unreachable today; release keeps the generic count so the
|
||||
// label can't desync from the fold that claimed the entry.
|
||||
_ => debug_assert!(false, "bucketed entry has a block with no label-extras arm"),
|
||||
}
|
||||
|
||||
if entry.is_running {
|
||||
self.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn into_label(self, theme: &Theme) -> VerbGroupHeaderLabel {
|
||||
let text_style = theme.fg(theme.gray_bright).add_modifier(Modifier::BOLD);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut text = String::new();
|
||||
for (i, bucket) in self.buckets.iter().enumerate() {
|
||||
let count = if bucket.sources.is_empty() {
|
||||
bucket.calls
|
||||
} else {
|
||||
bucket.sources.len()
|
||||
};
|
||||
let segment = format!(
|
||||
"{}{} {} {}",
|
||||
if i == 0 { "" } else { ", " },
|
||||
bucket.kind.verb(self.running),
|
||||
count,
|
||||
bucket.kind.noun(count)
|
||||
);
|
||||
text.push_str(&segment);
|
||||
spans.push(Span::styled(segment, text_style));
|
||||
}
|
||||
if self.failed_count > 0 {
|
||||
let suffix = format!(" · {} failed", self.failed_count);
|
||||
text.push_str(&suffix);
|
||||
spans.push(Span::styled(suffix, theme.fg(theme.accent_error)));
|
||||
}
|
||||
|
||||
VerbGroupHeaderLabel {
|
||||
line: Line::from(spans),
|
||||
text,
|
||||
running: self.running,
|
||||
failed: self.failed_count > 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a bucketed block completed with an error. Variants are listed
|
||||
/// explicitly so a new `ToolCallBlock` variant must decide here too. The
|
||||
/// action kinds reach labels only through truncation buckets (verb folds
|
||||
/// exclude them), where their failures count like any other member's.
|
||||
fn block_failed(block: &ToolCallBlock) -> bool {
|
||||
match block {
|
||||
ToolCallBlock::Read(b) => !b.is_success(),
|
||||
ToolCallBlock::ListDir(b) => !b.is_success(),
|
||||
ToolCallBlock::Search(b) => !b.is_success(),
|
||||
ToolCallBlock::WebFetch(b) => !b.is_success(),
|
||||
ToolCallBlock::WebSearch(b) => !b.is_success(),
|
||||
ToolCallBlock::MemorySearch(b) => !b.is_success(),
|
||||
ToolCallBlock::IntegrationSearch(b) => !b.is_success(),
|
||||
ToolCallBlock::Skill(b) => !b.is_success(),
|
||||
ToolCallBlock::Execute(b) => !b.is_success(),
|
||||
ToolCallBlock::Edit(b) => !b.is_success(),
|
||||
ToolCallBlock::UseTool(b) => !b.is_success(),
|
||||
ToolCallBlock::Other(b) => !b.is_success(),
|
||||
ToolCallBlock::Lifecycle(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scrollback::blocks::SubagentBlock;
|
||||
use crate::scrollback::blocks::tool::{
|
||||
ListDirToolCallBlock, ReadToolCallBlock, SearchToolCallBlock, WebSearchToolCallBlock,
|
||||
};
|
||||
|
||||
fn entry(block: ToolCallBlock) -> ScrollbackEntry {
|
||||
ScrollbackEntry::new(RenderBlock::ToolCall(block)).with_display_mode(DisplayMode::Collapsed)
|
||||
}
|
||||
|
||||
fn read(path: &str) -> ScrollbackEntry {
|
||||
entry(ToolCallBlock::Read(ReadToolCallBlock::new(path)))
|
||||
}
|
||||
|
||||
fn subagent(block: SubagentBlock) -> ScrollbackEntry {
|
||||
ScrollbackEntry::new(RenderBlock::Subagent(block))
|
||||
}
|
||||
|
||||
fn sub_started(child_sid: &str) -> ScrollbackEntry {
|
||||
subagent(SubagentBlock::started(
|
||||
"task", child_sid, "explore", None, None, None, /*is_background=*/ true,
|
||||
))
|
||||
}
|
||||
|
||||
fn sub_completed(child_sid: &str) -> ScrollbackEntry {
|
||||
subagent(SubagentBlock::completed(
|
||||
"task",
|
||||
child_sid,
|
||||
std::time::Duration::from_secs(3),
|
||||
))
|
||||
}
|
||||
|
||||
fn label(entries: &[ScrollbackEntry]) -> VerbGroupHeaderLabel {
|
||||
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
||||
verb_group_header_label(
|
||||
&refs,
|
||||
0,
|
||||
refs.len(),
|
||||
/*show_thinking=*/ true,
|
||||
&Theme::current(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buckets_in_first_appearance_order_with_plurality() {
|
||||
let entries = vec![
|
||||
read("a.rs"),
|
||||
entry(ToolCallBlock::Search(SearchToolCallBlock::new("todo"))),
|
||||
read("b.rs"),
|
||||
entry(ToolCallBlock::ListDir(ListDirToolCallBlock::new("src"))),
|
||||
];
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Read 2 files, Searched 1 pattern, Listed 1 dir");
|
||||
assert!(!l.running);
|
||||
assert!(!l.failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_reads_bucket_separately_from_files() {
|
||||
let entries = vec![
|
||||
read("a.rs"),
|
||||
read("/x/skills/deploy/SKILL.md"),
|
||||
read("b.rs"),
|
||||
];
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Read 2 files, Read 1 skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_members_append_suffix_and_flag() {
|
||||
let entries = vec![
|
||||
read("a.rs"),
|
||||
entry(ToolCallBlock::Read(
|
||||
ReadToolCallBlock::new("gone.rs").with_error("no such file"),
|
||||
)),
|
||||
entry(ToolCallBlock::Read(
|
||||
ReadToolCallBlock::new("also-gone.rs").with_error("no such file"),
|
||||
)),
|
||||
];
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Read 3 files · 2 failed");
|
||||
assert!(l.failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_flips_tense_only() {
|
||||
let mut entries = vec![
|
||||
read("a.rs"),
|
||||
entry(ToolCallBlock::Search(SearchToolCallBlock::new("todo"))),
|
||||
];
|
||||
entries[1].is_running = true;
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Reading 1 file, Searching 1 pattern");
|
||||
assert!(l.running);
|
||||
|
||||
entries[1].is_running = false;
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Read 1 file, Searched 1 pattern");
|
||||
assert!(!l.running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_counts_distinct_sources_with_call_fallback() {
|
||||
let searched = |query: &str, citations: &[&str]| {
|
||||
let mut b = WebSearchToolCallBlock::new(query);
|
||||
b.citations = citations.iter().map(|s| s.to_string()).collect();
|
||||
b.content = Some("results".into());
|
||||
entry(ToolCallBlock::WebSearch(b))
|
||||
};
|
||||
// Three distinct URLs across two searches, one duplicated.
|
||||
let entries = vec![
|
||||
searched("grok", &["https://a.com", "https://b.com"]),
|
||||
searched("pager", &["https://b.com", "https://c.com"]),
|
||||
];
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Searched 3 websites");
|
||||
|
||||
// No citations yet (still running / no results): fall back to call count.
|
||||
let entries = vec![
|
||||
entry(ToolCallBlock::WebSearch(WebSearchToolCallBlock::new("a"))),
|
||||
entry(ToolCallBlock::WebSearch(WebSearchToolCallBlock::new("b"))),
|
||||
];
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Searched 2 websites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_ends_at_separator_and_skips_hidden_thinking() {
|
||||
let entries = [
|
||||
read("a.rs"),
|
||||
ScrollbackEntry::new(RenderBlock::thinking("hmm")),
|
||||
read("b.rs"),
|
||||
ScrollbackEntry::new(RenderBlock::execute("ls")),
|
||||
read("c.rs"),
|
||||
];
|
||||
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
||||
let l = verb_group_header_label(
|
||||
&refs,
|
||||
0,
|
||||
refs.len(),
|
||||
/*show_thinking=*/ false,
|
||||
&Theme::current(),
|
||||
);
|
||||
assert_eq!(l.text, "Read 2 files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_stays_tools_only_across_shown_thinking_states() {
|
||||
let mut streaming = ScrollbackEntry::new(RenderBlock::thinking("live"));
|
||||
streaming.is_running = true;
|
||||
let entries = [
|
||||
read("a.rs"),
|
||||
// Finished + collapsed: folds into the run, never labeled.
|
||||
ScrollbackEntry::new(RenderBlock::thinking("done"))
|
||||
.with_display_mode(DisplayMode::Collapsed),
|
||||
read("b.rs"),
|
||||
// Streaming: transparent, keeps its own live panel.
|
||||
streaming,
|
||||
read("c.rs"),
|
||||
// User-opened: transparent, keeps its own rows.
|
||||
ScrollbackEntry::new(RenderBlock::thinking("opened"))
|
||||
.with_display_mode(DisplayMode::Expanded),
|
||||
read("d.rs"),
|
||||
// Non-thinking separators still end the run.
|
||||
ScrollbackEntry::new(RenderBlock::execute("ls")),
|
||||
read("e.rs"),
|
||||
];
|
||||
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
||||
let l = verb_group_header_label(
|
||||
&refs,
|
||||
0,
|
||||
refs.len(),
|
||||
/*show_thinking=*/ true,
|
||||
&Theme::current(),
|
||||
);
|
||||
assert_eq!(l.text, "Read 4 files");
|
||||
}
|
||||
|
||||
fn execute() -> ScrollbackEntry {
|
||||
ScrollbackEntry::new(RenderBlock::execute("ls")).with_display_mode(DisplayMode::Collapsed)
|
||||
}
|
||||
|
||||
fn thought() -> ScrollbackEntry {
|
||||
ScrollbackEntry::new(RenderBlock::thinking("hmm")).with_display_mode(DisplayMode::Collapsed)
|
||||
}
|
||||
|
||||
fn trunc_label(
|
||||
entries: &[ScrollbackEntry],
|
||||
limit: Option<usize>,
|
||||
) -> Option<VerbGroupHeaderLabel> {
|
||||
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
||||
truncation_header_label(
|
||||
&refs,
|
||||
0..refs.len(),
|
||||
limit,
|
||||
/*show_thinking=*/ true,
|
||||
&Theme::current(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_label_buckets_commands_and_never_thoughts() {
|
||||
// 3 commands + 2 thoughts hidden: thoughts occupy participant slots
|
||||
// but the label stays tools-only.
|
||||
let entries = vec![execute(), thought(), execute(), thought(), execute()];
|
||||
let l = trunc_label(&entries, None).expect("commands bucket");
|
||||
assert_eq!(l.text, "Ran 3 commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_label_limit_counts_participants_not_buckets() {
|
||||
// limit=3 covers [execute, thought, execute]: the thought consumes a
|
||||
// participant slot without appearing in the label.
|
||||
let entries = vec![execute(), thought(), execute(), execute(), execute()];
|
||||
let l = trunc_label(&entries, Some(3)).expect("prefix buckets");
|
||||
assert_eq!(l.text, "Ran 2 commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_label_mixes_kinds_in_first_appearance_order() {
|
||||
let entries = vec![
|
||||
execute(),
|
||||
read("a.rs"),
|
||||
ScrollbackEntry::new(RenderBlock::edit("src/main.rs", None))
|
||||
.with_display_mode(DisplayMode::Collapsed),
|
||||
execute(),
|
||||
];
|
||||
let l = trunc_label(&entries, None).expect("buckets");
|
||||
assert_eq!(l.text, "Ran 2 commands, Read 1 file, Edited 1 file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_label_none_for_pure_thought_prefix() {
|
||||
let entries = vec![thought(), thought(), execute()];
|
||||
assert!(
|
||||
trunc_label(&entries, Some(2)).is_none(),
|
||||
"a prefix of only thoughts buckets nothing; caller falls back to 'N more'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_label_none_for_prefix_with_unbucketable_rows() {
|
||||
let system = ScrollbackEntry::new(RenderBlock::system("hook ran"))
|
||||
.with_display_mode(DisplayMode::Collapsed);
|
||||
let entries = vec![execute(), system, execute()];
|
||||
assert!(
|
||||
trunc_label(&entries, None).is_none(),
|
||||
"a hidden System row has no bucket; the plain count stays numerically honest"
|
||||
);
|
||||
// The unbucketable row past the limit never walks: the prefix labels.
|
||||
let entries = vec![
|
||||
execute(),
|
||||
ScrollbackEntry::new(RenderBlock::system("hook ran"))
|
||||
.with_display_mode(DisplayMode::Collapsed),
|
||||
];
|
||||
let l = trunc_label(&entries, Some(1)).expect("prefix buckets");
|
||||
assert_eq!(l.text, "Ran 1 command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_label_counts_failed_commands() {
|
||||
let failed = RenderBlock::execute_with_output("false", "", Some("exit 1"));
|
||||
let entries = vec![
|
||||
execute(),
|
||||
ScrollbackEntry::new(failed).with_display_mode(DisplayMode::Collapsed),
|
||||
];
|
||||
let l = trunc_label(&entries, None).expect("buckets");
|
||||
assert_eq!(l.text, "Ran 2 commands · 1 failed");
|
||||
assert!(l.failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_label_skips_hidden_thinking_without_consuming_limit() {
|
||||
let mut hidden_thought = ScrollbackEntry::new(RenderBlock::thinking("hidden"));
|
||||
hidden_thought.set_display_mode(DisplayMode::Collapsed);
|
||||
let entries = [execute(), hidden_thought, execute()];
|
||||
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
||||
// show_thinking=false: the thought is hidden chrome, not a
|
||||
// participant — both commands fit in a limit of 2.
|
||||
let l = truncation_header_label(&refs, 0..refs.len(), Some(2), false, &Theme::current())
|
||||
.expect("buckets");
|
||||
assert_eq!(l.text, "Ran 2 commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_rows_bucket_with_tools_and_count_distinct_subagents() {
|
||||
// A background subagent leaves BOTH its started row and a terminal
|
||||
// row in the run; the child-session-id source override collapses
|
||||
// them to one displayed subagent.
|
||||
let entries = vec![
|
||||
read("a.rs"),
|
||||
sub_started("child-A"),
|
||||
read("b.rs"),
|
||||
sub_completed("child-A"),
|
||||
];
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Read 2 files, Ran 1 subagent");
|
||||
assert!(!l.failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_completion_burst_counts_each_subagent() {
|
||||
let entries = vec![sub_completed("child-A"), sub_completed("child-B")];
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Ran 2 subagents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_failed_feeds_suffix_cancelled_does_not() {
|
||||
let entries = vec![
|
||||
subagent(SubagentBlock::failed(
|
||||
"task",
|
||||
"child-A",
|
||||
std::time::Duration::from_secs(3),
|
||||
Some("boom".into()),
|
||||
)),
|
||||
subagent(SubagentBlock::cancelled(
|
||||
"task",
|
||||
"child-B",
|
||||
std::time::Duration::from_secs(3),
|
||||
)),
|
||||
];
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Ran 2 subagents · 1 failed");
|
||||
assert!(l.failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_subagent_flips_group_tense() {
|
||||
let mut entries = vec![read("a.rs"), sub_started("child-A")];
|
||||
entries[1].is_running = true;
|
||||
let l = label(&entries);
|
||||
assert_eq!(l.text, "Reading 1 file, Running 1 subagent");
|
||||
assert!(l.running);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,619 @@
|
||||
//! Box-drawing table grid detection so selection inside rendered tables
|
||||
//! operates on cells; anything `detect` can't prove falls back to linear.
|
||||
//! Table lines never soft-wrap, so one rendered line is one block line.
|
||||
|
||||
use std::ops::Range;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// A cell position within a detected grid: `row` indexes logical rows
|
||||
/// (header = 0), `col` indexes columns left to right.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CellRef {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
/// Geometry of one box-drawing table, in the block's line/column space:
|
||||
/// line indices are `block_line_idx` values, columns are display columns in
|
||||
/// the same space as `RangeHit::col_within_range`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TableGeometry {
|
||||
/// Full extent of the grid, top border line ..= bottom border line
|
||||
/// (half-open).
|
||||
line_range: Range<usize>,
|
||||
/// Display columns of the vertical grid lines, ascending.
|
||||
/// `junction_cols.len() == column count + 1`.
|
||||
junction_cols: Vec<u16>,
|
||||
/// Per logical row, the contiguous block-line range of its content lines
|
||||
/// (a row wrapped inside cells spans several lines). Never empty.
|
||||
rows: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
/// Border-row family, keyed by its corner/junction glyphs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BorderKind {
|
||||
/// `┌──┬──┐`
|
||||
Top,
|
||||
/// `├──┼──┤`
|
||||
Divider,
|
||||
/// `└──┴──┘`
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// One line classified against (or independent of) a grid context.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum GridLine {
|
||||
Border {
|
||||
junctions: Vec<u16>,
|
||||
kind: BorderKind,
|
||||
},
|
||||
Content,
|
||||
Other,
|
||||
}
|
||||
|
||||
const BAR: char = '\u{2502}'; // │
|
||||
|
||||
/// Chars permitted before a grid's left edge: indentation and blockquote
|
||||
/// bars (`│ `-prefixed tables render inside quotes with fully selectable
|
||||
/// text — see `QuoteBarStrip`).
|
||||
fn is_prefix_char(c: char) -> bool {
|
||||
c == ' ' || c == BAR
|
||||
}
|
||||
|
||||
/// (display column, first char) for every grapheme in `text`, mirroring the
|
||||
/// column arithmetic of `slice_display_cols` / `word_boundaries_at_col`.
|
||||
fn grapheme_cols(text: &str) -> impl Iterator<Item = (u16, char)> + '_ {
|
||||
let mut col = 0u16;
|
||||
text.graphemes(true).filter_map(move |g| {
|
||||
let width = UnicodeWidthStr::width(g) as u16;
|
||||
if width == 0 {
|
||||
return None;
|
||||
}
|
||||
let at = col;
|
||||
col = col.saturating_add(width);
|
||||
Some((at, g.chars().next().unwrap_or(' ')))
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a border row (`┌──┬──┐` / `├──┼──┤` / `└──┴──┘`), tolerating an
|
||||
/// indentation/blockquote prefix. Returns the junction columns (corners
|
||||
/// included) and the row family, or `None` when the line is not a border row.
|
||||
fn parse_border_row(text: &str) -> Option<(Vec<u16>, BorderKind)> {
|
||||
let (kind, mid, close) = ('\u{250C}', '\u{252C}', '\u{2510}'); // ┌ ┬ ┐
|
||||
let (dkind, dmid, dclose) = ('\u{251C}', '\u{253C}', '\u{2524}'); // ├ ┼ ┤
|
||||
let (bkind, bmid, bclose) = ('\u{2514}', '\u{2534}', '\u{2518}'); // └ ┴ ┘
|
||||
const H: char = '\u{2500}'; // ─
|
||||
|
||||
let mut junctions: Vec<u16> = Vec::new();
|
||||
let mut family: Option<BorderKind> = None;
|
||||
let mut closed = false;
|
||||
|
||||
for (col, c) in grapheme_cols(text) {
|
||||
match family {
|
||||
None => {
|
||||
// Still in the optional prefix; the first corner glyph opens
|
||||
// the grid and fixes the family.
|
||||
let f = match c {
|
||||
_ if c == kind => Some(BorderKind::Top),
|
||||
_ if c == dkind => Some(BorderKind::Divider),
|
||||
_ if c == bkind => Some(BorderKind::Bottom),
|
||||
_ if is_prefix_char(c) => None,
|
||||
_ => return None,
|
||||
};
|
||||
if let Some(f) = f {
|
||||
family = Some(f);
|
||||
junctions.push(col);
|
||||
}
|
||||
}
|
||||
Some(f) => {
|
||||
if closed {
|
||||
// Trailing content after the closing corner: not a grid row.
|
||||
return None;
|
||||
}
|
||||
let (m, cl) = match f {
|
||||
BorderKind::Top => (mid, close),
|
||||
BorderKind::Divider => (dmid, dclose),
|
||||
BorderKind::Bottom => (bmid, bclose),
|
||||
};
|
||||
if c == m {
|
||||
junctions.push(col);
|
||||
} else if c == cl {
|
||||
junctions.push(col);
|
||||
closed = true;
|
||||
} else if c != H {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A grid needs at least two junctions (one column) and a closing corner.
|
||||
if !closed || junctions.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some((junctions, family.expect("closed implies family")))
|
||||
}
|
||||
|
||||
/// Whether `text` is a content row of a grid with the given junction set:
|
||||
/// a `│` at every junction column, nothing but prefix chars before the left
|
||||
/// edge, and nothing after the right edge (selection text is end-trimmed).
|
||||
fn is_content_row(text: &str, junctions: &[u16]) -> bool {
|
||||
let (Some(&left), Some(&right)) = (junctions.first(), junctions.last()) else {
|
||||
return false;
|
||||
};
|
||||
let mut needed = junctions.iter().peekable();
|
||||
let mut last_col = 0u16;
|
||||
for (col, c) in grapheme_cols(text) {
|
||||
last_col = col;
|
||||
if col < left && !is_prefix_char(c) {
|
||||
return false;
|
||||
}
|
||||
if col > right {
|
||||
return false;
|
||||
}
|
||||
if needed.peek() == Some(&&col) {
|
||||
if c != BAR {
|
||||
return false;
|
||||
}
|
||||
needed.next();
|
||||
}
|
||||
}
|
||||
needed.peek().is_none() && last_col == right
|
||||
}
|
||||
|
||||
/// Classify one line against a known junction set.
|
||||
fn classify(text: &str, junctions: &[u16]) -> GridLine {
|
||||
if let Some((j, kind)) = parse_border_row(text) {
|
||||
if j == junctions {
|
||||
return GridLine::Border { junctions: j, kind };
|
||||
}
|
||||
return GridLine::Other;
|
||||
}
|
||||
if is_content_row(text, junctions) {
|
||||
return GridLine::Content;
|
||||
}
|
||||
GridLine::Other
|
||||
}
|
||||
|
||||
impl TableGeometry {
|
||||
/// Detect the grid containing `at_line`, reading lines through
|
||||
/// `text_at`. `None` unless `at_line` sits inside a fully-enclosed,
|
||||
/// column-consistent grid — callers then fall back to linear.
|
||||
pub fn detect(text_at: impl Fn(usize) -> Option<String>, at_line: usize) -> Option<Self> {
|
||||
// The anchor line itself must be part of a grid; its border row (or,
|
||||
// for content rows, the nearest border row above) fixes the junction
|
||||
// set every other line is validated against.
|
||||
let anchor_text = text_at(at_line)?;
|
||||
let junctions: Vec<u16> = if let Some((j, _)) = parse_border_row(&anchor_text) {
|
||||
j
|
||||
} else {
|
||||
// Walk up to the nearest border row to fix the junction set.
|
||||
// Capped: a real anchor's border is at most one wrapped row
|
||||
// above; a long walk means prefix-led prose, not a table.
|
||||
const MAX_JUNCTION_SEARCH: usize = 400;
|
||||
let mut found: Option<Vec<u16>> = None;
|
||||
let mut line = at_line;
|
||||
while line > 0 && at_line - line < MAX_JUNCTION_SEARCH {
|
||||
line -= 1;
|
||||
let Some(text) = text_at(line) else { break };
|
||||
if let Some((j, _)) = parse_border_row(&text) {
|
||||
found = Some(j);
|
||||
break;
|
||||
}
|
||||
// Cheap plausibility gate so we don't scan a whole prose
|
||||
// block: rows of a grid always start with a prefix char.
|
||||
if !text.chars().next().is_some_and(is_prefix_char) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
found?
|
||||
};
|
||||
|
||||
// Validate outward: walk up to the top border, down to the bottom
|
||||
// border, requiring every line in between to belong to the grid.
|
||||
let mut top = at_line;
|
||||
loop {
|
||||
let text = text_at(top)?;
|
||||
match classify(&text, &junctions) {
|
||||
GridLine::Border {
|
||||
kind: BorderKind::Top,
|
||||
..
|
||||
} => break,
|
||||
// Hitting a bottom border strictly above `at_line` means
|
||||
// `at_line` was below the grid, not inside it. (`at_line`
|
||||
// itself may be the bottom border.)
|
||||
GridLine::Border {
|
||||
kind: BorderKind::Bottom,
|
||||
..
|
||||
} if top < at_line => return None,
|
||||
GridLine::Border { .. } | GridLine::Content if top > 0 => top -= 1,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let mut bottom = at_line;
|
||||
loop {
|
||||
let text = text_at(bottom)?;
|
||||
match classify(&text, &junctions) {
|
||||
GridLine::Border {
|
||||
kind: BorderKind::Bottom,
|
||||
..
|
||||
} => break,
|
||||
GridLine::Border {
|
||||
kind: BorderKind::Top,
|
||||
..
|
||||
} if bottom > at_line => return None,
|
||||
GridLine::Border { .. } | GridLine::Content => bottom += 1,
|
||||
GridLine::Other => return None,
|
||||
}
|
||||
}
|
||||
|
||||
// Logical rows: contiguous content-line runs between border rows.
|
||||
let mut rows: Vec<Range<usize>> = Vec::new();
|
||||
let mut run_start: Option<usize> = None;
|
||||
for line in top..=bottom {
|
||||
let text = text_at(line)?;
|
||||
match classify(&text, &junctions) {
|
||||
GridLine::Content => {
|
||||
run_start.get_or_insert(line);
|
||||
}
|
||||
GridLine::Border { .. } => {
|
||||
if let Some(start) = run_start.take() {
|
||||
rows.push(start..line);
|
||||
}
|
||||
}
|
||||
GridLine::Other => return None,
|
||||
}
|
||||
}
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
line_range: top..bottom + 1,
|
||||
junction_cols: junctions,
|
||||
rows,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full grid extent (top border ..= bottom border, half-open).
|
||||
pub fn line_range(&self) -> Range<usize> {
|
||||
self.line_range.clone()
|
||||
}
|
||||
|
||||
pub fn n_cols(&self) -> usize {
|
||||
self.junction_cols.len() - 1
|
||||
}
|
||||
|
||||
pub fn n_rows(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
/// The logical row containing `line`, if `line` is a content line.
|
||||
pub fn row_of_line(&self, line: usize) -> Option<usize> {
|
||||
self.rows.iter().position(|r| r.contains(&line))
|
||||
}
|
||||
|
||||
/// Content-line range of a logical row.
|
||||
pub fn row_lines(&self, row: usize) -> Range<usize> {
|
||||
self.rows[row].clone()
|
||||
}
|
||||
|
||||
/// Display-column band of a column's cell interior: everything strictly
|
||||
/// between the two flanking `│` glyphs (padding included).
|
||||
pub fn band(&self, col: usize) -> Range<u16> {
|
||||
self.junction_cols[col].saturating_add(1)..self.junction_cols[col + 1]
|
||||
}
|
||||
|
||||
/// The cell at (`line`, `col`), or `None` when `line` is a border row or
|
||||
/// `col` falls outside the grid. A click exactly on a `│` snaps to the
|
||||
/// cell on its right (left for the closing border).
|
||||
pub fn cell_at(&self, line: usize, col: u16) -> Option<CellRef> {
|
||||
let row = self.row_of_line(line)?;
|
||||
let first = *self.junction_cols.first().expect("non-empty");
|
||||
let last = *self.junction_cols.last().expect("non-empty");
|
||||
if col < first || col > last {
|
||||
return None;
|
||||
}
|
||||
let c = match self.junction_cols.iter().rposition(|&j| j <= col) {
|
||||
Some(j) if j == self.junction_cols.len() - 1 => self.n_cols() - 1,
|
||||
Some(j) => j,
|
||||
None => 0,
|
||||
};
|
||||
Some(CellRef { row, col: c })
|
||||
}
|
||||
|
||||
/// The column whose content interior (band minus the renderer's one
|
||||
/// padding column per side) contains `col`.
|
||||
fn interior_col_at(&self, col: u16) -> Option<usize> {
|
||||
(0..self.n_cols()).find(|&c| {
|
||||
let band = self.band(c);
|
||||
let lo = band.start.saturating_add(1);
|
||||
let hi = band.end.saturating_sub(1);
|
||||
(lo..hi).contains(&col)
|
||||
})
|
||||
}
|
||||
|
||||
/// Latched head-cell resolution: borders, padding, and divider rows
|
||||
/// keep `held`; only another cell's content interior (or the grid's
|
||||
/// outer edge, which clamps) moves it. Empty cells never capture it.
|
||||
pub fn latched_cell_at(&self, held: CellRef, line: usize, col: u16) -> CellRef {
|
||||
let row = if let Some(row) = self.row_of_line(line) {
|
||||
row
|
||||
} else if line < self.line_range.start {
|
||||
0
|
||||
} else if line >= self.line_range.end {
|
||||
self.n_rows() - 1
|
||||
} else {
|
||||
held.row
|
||||
};
|
||||
let col = if let Some(col) = self.interior_col_at(col) {
|
||||
col
|
||||
} else if col < *self.junction_cols.first().expect("non-empty") {
|
||||
0
|
||||
} else if col > *self.junction_cols.last().expect("non-empty") {
|
||||
self.n_cols() - 1
|
||||
} else {
|
||||
held.col
|
||||
};
|
||||
CellRef { row, col }
|
||||
}
|
||||
|
||||
/// A cell's text: its per-line band slices trimmed and joined with a
|
||||
/// space (cells wrap at spaces/punctuation, so a space join reconstructs
|
||||
/// the content).
|
||||
pub fn cell_text(&self, cell: CellRef, text_at: impl Fn(usize) -> Option<String>) -> String {
|
||||
let band = self.band(cell.col);
|
||||
let mut out = String::new();
|
||||
for line in self.row_lines(cell.row) {
|
||||
let Some(text) = text_at(line) else { continue };
|
||||
let slice = crate::scrollback::types::slice_display_cols(&text, band.start, band.end);
|
||||
let fragment = slice.trim();
|
||||
if fragment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(fragment);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// TSV for the rectangular cell range spanned by `a` and `b` (order
|
||||
/// irrelevant): cells tab-joined, rows newline-joined. Tabs inside cell
|
||||
/// text are flattened to spaces so the TSV shape survives.
|
||||
pub fn grid_tsv(
|
||||
&self,
|
||||
a: CellRef,
|
||||
b: CellRef,
|
||||
text_at: impl Fn(usize) -> Option<String>,
|
||||
) -> String {
|
||||
let (r0, r1) = (a.row.min(b.row), a.row.max(b.row));
|
||||
let (c0, c1) = (a.col.min(b.col), a.col.max(b.col));
|
||||
let mut rows_out: Vec<String> = Vec::new();
|
||||
for row in r0..=r1 {
|
||||
let cells: Vec<String> = (c0..=c1)
|
||||
.map(|col| {
|
||||
self.cell_text(CellRef { row, col }, &text_at)
|
||||
.replace('\t', " ")
|
||||
})
|
||||
.collect();
|
||||
rows_out.push(cells.join("\t"));
|
||||
}
|
||||
rows_out.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
/// Text source over a static list of lines.
|
||||
fn src<'a>(lines: &'a [&'a str]) -> impl Fn(usize) -> Option<String> + 'a {
|
||||
move |i| lines.get(i).map(|s| s.to_string())
|
||||
}
|
||||
|
||||
const TABLE: &[&str] = &[
|
||||
"Intro prose",
|
||||
"┌─────────┬────────┐",
|
||||
"│ Name │ Role │",
|
||||
"├─────────┼────────┤",
|
||||
"│ Alice │ Eng │",
|
||||
"├─────────┼────────┤",
|
||||
"│ Bob │ Design │",
|
||||
"└─────────┴────────┘",
|
||||
"Outro prose",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn detects_from_content_and_border_lines() {
|
||||
for at in 1..=7 {
|
||||
let geom = TableGeometry::detect(src(TABLE), at).expect("grid detected");
|
||||
assert_eq!(geom.line_range(), 1..8);
|
||||
assert_eq!(geom.n_cols(), 2);
|
||||
assert_eq!(geom.n_rows(), 3);
|
||||
assert_eq!(geom.row_lines(0), 2..3);
|
||||
assert_eq!(geom.row_lines(2), 6..7);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_grid_outside_table() {
|
||||
assert_eq!(TableGeometry::detect(src(TABLE), 0), None);
|
||||
assert_eq!(TableGeometry::detect(src(TABLE), 8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_lookup_and_bands() {
|
||||
let geom = TableGeometry::detect(src(TABLE), 4).unwrap();
|
||||
// "│ Alice │ Eng │" — junctions at cols 0, 10, 19.
|
||||
assert_eq!(geom.band(0), 1..10);
|
||||
assert_eq!(geom.band(1), 11..19);
|
||||
assert_eq!(geom.cell_at(4, 3), Some(CellRef { row: 1, col: 0 }));
|
||||
assert_eq!(geom.cell_at(4, 12), Some(CellRef { row: 1, col: 1 }));
|
||||
// Junction col snaps right; closing border snaps left.
|
||||
assert_eq!(geom.cell_at(4, 10), Some(CellRef { row: 1, col: 1 }));
|
||||
assert_eq!(geom.cell_at(4, 19), Some(CellRef { row: 1, col: 1 }));
|
||||
assert_eq!(geom.cell_at(4, 0), Some(CellRef { row: 1, col: 0 }));
|
||||
// Border rows have no cells.
|
||||
assert_eq!(geom.cell_at(3, 3), None);
|
||||
// Outside the grid columns.
|
||||
assert_eq!(geom.cell_at(4, 25), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latched_cell_moves_only_via_content_or_past_the_edge() {
|
||||
let geom = TableGeometry::detect(src(TABLE), 4).unwrap();
|
||||
let held = CellRef { row: 1, col: 0 };
|
||||
// Another row's content line moves the row latch.
|
||||
assert_eq!(geom.latched_cell_at(held, 6, 3), CellRef { row: 2, col: 0 });
|
||||
// Divider and border rows keep the held row (no snap below).
|
||||
assert_eq!(geom.latched_cell_at(held, 5, 3), held);
|
||||
assert_eq!(geom.latched_cell_at(held, 1, 3), held);
|
||||
// Above / below the grid clamps to the first / last row.
|
||||
assert_eq!(geom.latched_cell_at(held, 0, 3), CellRef { row: 0, col: 0 });
|
||||
assert_eq!(geom.latched_cell_at(held, 8, 3), CellRef { row: 2, col: 0 });
|
||||
// "│ Alice │ Eng │" — junctions at 0, 10, 19; bands 1..10, 11..19.
|
||||
// The junction and both flanking padding columns keep the held column.
|
||||
assert_eq!(geom.latched_cell_at(held, 4, 9), held);
|
||||
assert_eq!(geom.latched_cell_at(held, 4, 10), held);
|
||||
assert_eq!(geom.latched_cell_at(held, 4, 11), held);
|
||||
// The neighbor's content interior captures the latch.
|
||||
assert_eq!(
|
||||
geom.latched_cell_at(held, 4, 12),
|
||||
CellRef { row: 1, col: 1 }
|
||||
);
|
||||
// Past the right edge clamps to the last column.
|
||||
assert_eq!(
|
||||
geom.latched_cell_at(held, 4, 40),
|
||||
CellRef { row: 1, col: 1 }
|
||||
);
|
||||
// Latch releases symmetrically: held in Role, back into Name content.
|
||||
let held_role = CellRef { row: 1, col: 1 };
|
||||
assert_eq!(geom.latched_cell_at(held_role, 4, 10), held_role);
|
||||
assert_eq!(
|
||||
geom.latched_cell_at(held_role, 4, 5),
|
||||
CellRef { row: 1, col: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_text_and_tsv() {
|
||||
let geom = TableGeometry::detect(src(TABLE), 4).unwrap();
|
||||
assert_eq!(
|
||||
geom.cell_text(CellRef { row: 1, col: 0 }, src(TABLE)),
|
||||
"Alice"
|
||||
);
|
||||
assert_eq!(
|
||||
geom.grid_tsv(
|
||||
CellRef { row: 1, col: 0 },
|
||||
CellRef { row: 2, col: 0 },
|
||||
src(TABLE)
|
||||
),
|
||||
"Alice\nBob"
|
||||
);
|
||||
assert_eq!(
|
||||
geom.grid_tsv(
|
||||
CellRef { row: 2, col: 1 },
|
||||
CellRef { row: 1, col: 0 },
|
||||
src(TABLE)
|
||||
),
|
||||
"Alice\tEng\nBob\tDesign"
|
||||
);
|
||||
}
|
||||
|
||||
const WRAPPED: &[&str] = &[
|
||||
"┌─────────┬──────────┐",
|
||||
"│ Name │ Notes │",
|
||||
"├─────────┼──────────┤",
|
||||
"│ Alice │ likes │",
|
||||
"│ │ long │",
|
||||
"│ │ walks │",
|
||||
"└─────────┴──────────┘",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn wrapped_cell_fragments_join_with_space() {
|
||||
let geom = TableGeometry::detect(src(WRAPPED), 4).unwrap();
|
||||
assert_eq!(geom.n_rows(), 2);
|
||||
assert_eq!(geom.row_lines(1), 3..6);
|
||||
assert_eq!(
|
||||
geom.cell_text(CellRef { row: 1, col: 1 }, src(WRAPPED)),
|
||||
"likes long walks"
|
||||
);
|
||||
// Empty fragments (the padding rows of the Name cell) are skipped.
|
||||
assert_eq!(
|
||||
geom.cell_text(CellRef { row: 1, col: 0 }, src(WRAPPED)),
|
||||
"Alice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockquoted_table_with_quote_bar_prefix() {
|
||||
let quoted: &[&str] = &[
|
||||
"│ ┌─────┬─────┐",
|
||||
"│ │ A │ B │",
|
||||
"│ ├─────┼─────┤",
|
||||
"│ │ one │ two │",
|
||||
"│ └─────┴─────┘",
|
||||
];
|
||||
let geom = TableGeometry::detect(src(quoted), 3).expect("quoted grid");
|
||||
assert_eq!(geom.n_cols(), 2);
|
||||
assert_eq!(
|
||||
geom.cell_text(CellRef { row: 1, col: 0 }, src(quoted)),
|
||||
"one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_glyphs_use_display_columns() {
|
||||
let emoji: &[&str] = &["┌──────┬──────┐", "│ 名前 │ ok │", "└──────┴──────┘"];
|
||||
let geom = TableGeometry::detect(src(emoji), 1).expect("grid");
|
||||
assert_eq!(geom.band(0), 1..7);
|
||||
assert_eq!(
|
||||
geom.cell_text(CellRef { row: 0, col: 0 }, src(emoji)),
|
||||
"名前"
|
||||
);
|
||||
// Click on the second display column of 名 resolves to col 0.
|
||||
assert_eq!(geom.cell_at(1, 3), Some(CellRef { row: 0, col: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_junctions_bail() {
|
||||
let broken: &[&str] = &[
|
||||
"┌─────┬─────┐",
|
||||
"│ A │ B │",
|
||||
"├────────┼──┤", // misaligned divider
|
||||
"│ one │ two │",
|
||||
"└─────┴─────┘",
|
||||
];
|
||||
assert_eq!(TableGeometry::detect(src(broken), 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_grid_bails() {
|
||||
let unclosed: &[&str] = &["┌─────┬─────┐", "│ A │ B │", "prose again"];
|
||||
assert_eq!(TableGeometry::detect(src(unclosed), 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stray_bar_in_cell_content_is_not_a_junction() {
|
||||
let stray: &[&str] = &["┌───────┬─────┐", "│ a │ b │ c │", "└───────┴─────┘"];
|
||||
let geom = TableGeometry::detect(src(stray), 1).expect("grid");
|
||||
assert_eq!(geom.n_cols(), 2);
|
||||
// The stray │ inside the first cell is content, not a boundary.
|
||||
assert_eq!(
|
||||
geom.cell_text(CellRef { row: 0, col: 0 }, src(stray)),
|
||||
"a │ b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_prose_and_rules_are_not_grids() {
|
||||
let prose: &[&str] = &["hello world", "─────────", "goodbye"];
|
||||
assert_eq!(TableGeometry::detect(src(prose), 0), None);
|
||||
assert_eq!(TableGeometry::detect(src(prose), 1), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,748 @@
|
||||
//! Core types for pager v3.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ratatui::style::Color;
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::appearance::AppearanceConfig;
|
||||
|
||||
/// How to wrap content that exceeds the available width.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub enum WrapMode {
|
||||
#[default]
|
||||
Word,
|
||||
Character,
|
||||
Truncate,
|
||||
}
|
||||
|
||||
/// Accent/bullet color style for a block.
|
||||
///
|
||||
/// Used by both `accent()` and `bullet()` trait methods.
|
||||
/// When `animated` is true, the renderer uses a wave animation effect.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccentStyle {
|
||||
pub color: Color,
|
||||
pub animated: bool,
|
||||
}
|
||||
|
||||
impl AccentStyle {
|
||||
/// Create a static (non-animated) accent style.
|
||||
pub const fn static_color(color: Color) -> Self {
|
||||
Self {
|
||||
color,
|
||||
animated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an animated accent style (wave effect for running blocks).
|
||||
pub const fn animated(color: Color) -> Self {
|
||||
Self {
|
||||
color,
|
||||
animated: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::appearance::BlockBackground;
|
||||
|
||||
/// How a block is currently displayed.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub enum DisplayMode {
|
||||
Collapsed,
|
||||
Truncated,
|
||||
#[default]
|
||||
Expanded,
|
||||
}
|
||||
|
||||
/// Which parts of a line can be selected for copying.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub enum Selectable {
|
||||
/// All spans are selectable (default).
|
||||
#[default]
|
||||
All,
|
||||
/// Only these span indices are selectable (contiguous range).
|
||||
Spans(Range<usize>),
|
||||
/// Line is not selectable (decoration, acts as region boundary).
|
||||
None,
|
||||
}
|
||||
|
||||
impl Selectable {
|
||||
/// Return the span range clamped to `len`, guaranteeing `start <= end <= len`.
|
||||
fn clamped_span_range(&self, len: usize) -> Option<Range<usize>> {
|
||||
match self {
|
||||
Selectable::Spans(range) => {
|
||||
let end = range.end.min(len);
|
||||
let start = range.start.min(end);
|
||||
Some(start..end)
|
||||
}
|
||||
_ => Option::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context passed to block methods for rendering decisions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockContext {
|
||||
pub mode: DisplayMode,
|
||||
pub is_running: bool,
|
||||
pub width: u16,
|
||||
pub raw: bool,
|
||||
/// Optional row budget. When Some(n), block must fit within n lines.
|
||||
pub max_lines: Option<u16>,
|
||||
/// Appearance config (from ~/.kigi/pager.toml).
|
||||
pub appearance: AppearanceConfig,
|
||||
/// Whether this entry is currently selected in the scrollback.
|
||||
pub is_selected: bool,
|
||||
/// Session/worktree cwd (`AgentSession.cwd`); `None` → no relativization.
|
||||
pub cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl BlockContext {
|
||||
/// Width of the bullet prefix (char + trailing space), or 0 if disabled.
|
||||
pub fn bullet_indent(&self) -> usize {
|
||||
self.appearance
|
||||
.scrollback
|
||||
.blocks
|
||||
.tool
|
||||
.bullet
|
||||
.char()
|
||||
.map(|c| unicode_width::UnicodeWidthStr::width(c) + 1)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Effective content width after subtracting the bullet prefix (if enabled).
|
||||
///
|
||||
/// Blocks that render single-line collapsed content should use this instead
|
||||
/// of `self.width` to avoid overflowing past the bullet character that gets
|
||||
/// prepended by `RenderBlock::output()`.
|
||||
pub fn content_width(&self) -> usize {
|
||||
(self.width as usize).saturating_sub(self.bullet_indent())
|
||||
}
|
||||
|
||||
/// Whether a collapsed block should render with the muted style.
|
||||
/// Keeps the "bright while selected" affordance everywhere except
|
||||
/// legacy ConHost — where the selected/unselected color gap reads
|
||||
/// as palette noise after 16-color quantization, and the selection
|
||||
/// box already indicates focus.
|
||||
pub fn mute_when_collapsed(&self, muted_collapsed_enabled: bool) -> bool {
|
||||
if !muted_collapsed_enabled {
|
||||
return false;
|
||||
}
|
||||
crate::glyphs::is_legacy_windows_console() || !self.is_selected
|
||||
}
|
||||
}
|
||||
|
||||
/// A single line of block output.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockLine {
|
||||
pub content: Line<'static>,
|
||||
pub background: Option<Color>,
|
||||
/// Whether [`background`](Self::background) is a decorative "panel" band
|
||||
/// (tool result previews — Read/Search/Execute/… content boxes) rather than
|
||||
/// semantic shading (diff insert/delete rows, markdown code-block fill).
|
||||
/// Panel bands are suppressed when the entry renders with a flat
|
||||
/// background (minimal mode) so previews blend with the terminal's own
|
||||
/// background; semantic shading always paints.
|
||||
pub background_is_panel: bool,
|
||||
/// Column where background starts (0 = full width, >0 = partial background).
|
||||
pub bg_start_col: u16,
|
||||
pub wrap: WrapMode,
|
||||
pub selectable: Selectable,
|
||||
/// Logical selection range id within this block output. Ids count up
|
||||
/// from 0; `u16::MAX` is reserved for the render-level synthetic
|
||||
/// labeled group-header row (`render::GROUP_HEADER_RANGE_ID`).
|
||||
pub selection_range: Option<u16>,
|
||||
/// Optional source-of-truth text for the selectable portion of this line.
|
||||
pub selection_text: Option<String>,
|
||||
/// Soft-wrap joiner: how this line connects to the previous when copying.
|
||||
///
|
||||
/// - `None` = hard break (new source line, join with `\n`)
|
||||
/// - `Some("")` = mid-word break (no separator)
|
||||
/// - `Some(" ")` = word break (join with space)
|
||||
///
|
||||
/// The first line of a block should always have `None`.
|
||||
pub joiner: Option<String>,
|
||||
/// OSC 8 URL when paint text is not a scannable absolute path (tool headers).
|
||||
pub link_url: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
impl Default for BlockLine {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
content: Line::default(),
|
||||
background: None,
|
||||
background_is_panel: false,
|
||||
bg_start_col: 0,
|
||||
wrap: WrapMode::Word,
|
||||
selectable: Selectable::All,
|
||||
selection_range: None,
|
||||
selection_text: None,
|
||||
joiner: None,
|
||||
link_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Line<'static>> for BlockLine {
|
||||
fn from(content: Line<'static>) -> Self {
|
||||
Self {
|
||||
content,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockLine {
|
||||
/// Fully selectable plain text line.
|
||||
pub fn text(s: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: Line::raw(s.into()),
|
||||
selectable: Selectable::All,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled line, fully selectable.
|
||||
pub fn styled(content: Line<'static>) -> Self {
|
||||
Self {
|
||||
content,
|
||||
selectable: Selectable::All,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoration line (not selectable, acts as region boundary).
|
||||
pub fn separator(content: Line<'static>) -> Self {
|
||||
Self {
|
||||
content,
|
||||
selectable: Selectable::None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the background color.
|
||||
pub fn with_background(mut self, color: Color) -> Self {
|
||||
self.background = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a decorative "panel" background (tool result preview boxes).
|
||||
///
|
||||
/// Unlike [`with_background`](Self::with_background) (semantic shading:
|
||||
/// diff insert/delete rows, markdown code-block fill), a panel background
|
||||
/// is suppressed when the entry renders flat (minimal mode) so the preview
|
||||
/// blends with the terminal's own background.
|
||||
pub fn with_panel_background(mut self, color: Color) -> Self {
|
||||
self.background = Some(color);
|
||||
self.background_is_panel = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a partial background color starting from a specific column.
|
||||
pub fn with_background_from(mut self, color: Color, start_col: u16) -> Self {
|
||||
self.background = Some(color);
|
||||
self.bg_start_col = start_col;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the wrap mode.
|
||||
pub fn with_wrap(mut self, mode: WrapMode) -> Self {
|
||||
self.wrap = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the logical selection range id.
|
||||
pub fn with_selection_range(mut self, range: Option<u16>) -> Self {
|
||||
self.selection_range = range;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set explicit selectable text for this line.
|
||||
pub fn with_selection_text(mut self, text: Option<String>) -> Self {
|
||||
self.selection_text = text;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the soft-wrap joiner.
|
||||
pub fn with_joiner(mut self, joiner: Option<String>) -> Self {
|
||||
self.joiner = joiner;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_link_url(mut self, url: Option<Arc<str>>) -> Self {
|
||||
self.link_url = url;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten a rendered line's spans into the plain text drawn on that row.
|
||||
/// Shared by selection-text derivation and the search-highlight post-pass.
|
||||
pub fn line_plain_text(line: &Line) -> String {
|
||||
let mut out = String::new();
|
||||
line_plain_text_into(line, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Append a rendered line's plain text to `out`, reusing its capacity. Lets the
|
||||
/// per-frame highlight pass avoid a fresh allocation for every visible row.
|
||||
pub fn line_plain_text_into(line: &Line, out: &mut String) {
|
||||
for span in &line.spans {
|
||||
out.push_str(span.content.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive_selection_text(line: &BlockLine) -> String {
|
||||
if let Some(text) = &line.selection_text {
|
||||
return text.clone();
|
||||
}
|
||||
|
||||
match &line.selectable {
|
||||
Selectable::None => String::new(),
|
||||
Selectable::All => {
|
||||
// Strip trailing whitespace so the render-only padding table rows
|
||||
// carry (added so the app owns every column) never reaches the
|
||||
// clipboard. Deliberately broadened to every `Selectable::All` line,
|
||||
// matching conventional terminal/tmux copy behavior. The canonical
|
||||
// single-block `y` copy is unaffected (it uses the pre-wrap path).
|
||||
let text = line_plain_text(&line.content);
|
||||
let trimmed = text.trim_end();
|
||||
if trimmed.len() == text.len() {
|
||||
text
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
sel @ Selectable::Spans(_) => {
|
||||
let r = sel.clamped_span_range(line.content.spans.len()).unwrap();
|
||||
line.content.spans[r]
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slice_display_cols(text: &str, start: u16, end: u16) -> String {
|
||||
if start >= end || text.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let mut col = 0u16;
|
||||
|
||||
for grapheme in text.graphemes(true) {
|
||||
let width = grapheme_width(grapheme) as u16;
|
||||
let next_col = col.saturating_add(width);
|
||||
|
||||
if width == 0 {
|
||||
if col >= start && col < end {
|
||||
out.push_str(grapheme);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if next_col <= start {
|
||||
col = next_col;
|
||||
continue;
|
||||
}
|
||||
if col >= end {
|
||||
break;
|
||||
}
|
||||
if col >= start && next_col <= end {
|
||||
out.push_str(grapheme);
|
||||
}
|
||||
col = next_col;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub fn block_line_selectable_width(line: &BlockLine) -> u16 {
|
||||
derive_selection_text(line).width() as u16
|
||||
}
|
||||
|
||||
pub fn shift_selection_metadata_for_prefix(line: &mut BlockLine, prefix_span_count: usize) {
|
||||
if prefix_span_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
line.selectable = match &line.selectable {
|
||||
Selectable::All => Selectable::Spans(prefix_span_count..line.content.spans.len()),
|
||||
Selectable::Spans(range) => {
|
||||
let shifted =
|
||||
Selectable::Spans(range.start + prefix_span_count..range.end + prefix_span_count);
|
||||
let r = shifted
|
||||
.clamped_span_range(line.content.spans.len())
|
||||
.unwrap();
|
||||
Selectable::Spans(r)
|
||||
}
|
||||
Selectable::None => Selectable::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn grapheme_width(grapheme: &str) -> usize {
|
||||
UnicodeWidthStr::width(grapheme)
|
||||
}
|
||||
|
||||
/// Complete output produced by a block for rendering.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BlockOutput {
|
||||
pub lines: Vec<BlockLine>,
|
||||
}
|
||||
|
||||
/// Rare copy-only source bytes omitted from an Edit header's visible row.
|
||||
/// TODO: Copy the absolute Read/Edit target for a full painted-path drag; partial drags copy painted columns only to keep highlight and clipboard aligned.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct SelectionBoundary {
|
||||
prefix: String,
|
||||
suffix: String,
|
||||
}
|
||||
|
||||
impl SelectionBoundary {
|
||||
pub(crate) fn new(prefix: String, suffix: String) -> Self {
|
||||
Self { prefix, suffix }
|
||||
}
|
||||
|
||||
pub(crate) fn apply(
|
||||
&self,
|
||||
selected: String,
|
||||
include_prefix: bool,
|
||||
include_suffix: bool,
|
||||
) -> String {
|
||||
let mut output = String::with_capacity(
|
||||
selected.len()
|
||||
+ usize::from(include_prefix) * self.prefix.len()
|
||||
+ usize::from(include_suffix) * self.suffix.len(),
|
||||
);
|
||||
if include_prefix {
|
||||
output.push_str(&self.prefix);
|
||||
}
|
||||
output.push_str(&selected);
|
||||
if include_suffix {
|
||||
output.push_str(&self.suffix);
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SelectionBoundaryEntry {
|
||||
pub(crate) line_index: usize,
|
||||
pub(crate) boundary: Arc<SelectionBoundary>,
|
||||
}
|
||||
|
||||
/// Sparse immutable sidecar keyed to exact output line indices.
|
||||
/// Ordinary outputs keep `None`; clones share the boundary payloads through `Arc`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct SelectionBoundaries(Option<Arc<[SelectionBoundaryEntry]>>);
|
||||
|
||||
impl SelectionBoundaries {
|
||||
pub(crate) fn from_entries(entries: Vec<SelectionBoundaryEntry>) -> Self {
|
||||
if entries.is_empty() {
|
||||
Self(None)
|
||||
} else {
|
||||
Self(Some(Arc::from(entries)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, line_index: usize) -> Option<&Arc<SelectionBoundary>> {
|
||||
self.0
|
||||
.as_deref()?
|
||||
.iter()
|
||||
.find(|entry| entry.line_index == line_index)
|
||||
.map(|entry| &entry.boundary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.0.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct RenderedBlockOutput {
|
||||
pub(crate) output: BlockOutput,
|
||||
pub(crate) boundaries: SelectionBoundaries,
|
||||
}
|
||||
|
||||
impl From<BlockOutput> for RenderedBlockOutput {
|
||||
fn from(output: BlockOutput) -> Self {
|
||||
Self {
|
||||
output,
|
||||
boundaries: SelectionBoundaries::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockOutput {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Plain text, all lines fully selectable.
|
||||
pub fn plain(text: &str) -> Self {
|
||||
Self {
|
||||
lines: text.lines().map(BlockLine::text).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, line: BlockLine) {
|
||||
self.lines.push(line);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.is_empty()
|
||||
}
|
||||
|
||||
pub fn height(&self) -> u16 {
|
||||
self.lines.len() as u16
|
||||
}
|
||||
|
||||
/// Wrap first line with prefix, last line with suffix.
|
||||
/// Decorations are NOT selectable.
|
||||
pub fn with_decorations(
|
||||
mut self,
|
||||
prefix: Option<Span<'static>>,
|
||||
suffix: Option<Span<'static>>,
|
||||
) -> Self {
|
||||
if let Some(prefix_span) = prefix
|
||||
&& let Some(first) = self.lines.first_mut()
|
||||
{
|
||||
let mut new_spans = vec![prefix_span];
|
||||
new_spans.extend(first.content.spans.iter().cloned());
|
||||
first.content = Line::from(new_spans);
|
||||
shift_selection_metadata_for_prefix(first, 1);
|
||||
}
|
||||
|
||||
if let Some(suffix_span) = suffix
|
||||
&& let Some(last) = self.lines.last_mut()
|
||||
{
|
||||
let content_end = last.content.spans.len();
|
||||
last.content.spans.push(suffix_span);
|
||||
|
||||
last.selectable = match &last.selectable {
|
||||
Selectable::All => Selectable::Spans(0..content_end),
|
||||
Selectable::Spans(r) => Selectable::Spans(r.clone()),
|
||||
Selectable::None => Selectable::None,
|
||||
};
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-wrap (logical source) line index for each post-wrap output row.
|
||||
///
|
||||
/// A row whose `joiner` is `None` starts a new pre-wrap line; soft-wrap
|
||||
/// continuations (`Some(_)`) stay on the current one. The first row is always
|
||||
/// index 0. This is the single source of truth for the pre-wrap → post-wrap
|
||||
/// mapping used by Mermaid treatment-row insertion (fallback caption / affordance
|
||||
/// row) and the hyperlink overlay.
|
||||
pub(crate) fn prewrap_index_per_row(lines: &[BlockLine]) -> Vec<usize> {
|
||||
let mut indices = Vec::with_capacity(lines.len());
|
||||
let mut prewrap = 0usize;
|
||||
for (row, line) in lines.iter().enumerate() {
|
||||
if row > 0 && line.joiner.is_none() {
|
||||
prewrap += 1;
|
||||
}
|
||||
indices.push(prewrap);
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
/// Convert span indices to display columns without terminal-coordinate narrowing.
|
||||
pub(crate) fn selectable_cols_usize(line: &Line, selectable: &Selectable) -> Option<Range<usize>> {
|
||||
match selectable {
|
||||
Selectable::None => None,
|
||||
Selectable::All => Some(0..line.width()),
|
||||
sel @ Selectable::Spans(_) => {
|
||||
let r = sel.clamped_span_range(line.spans.len())?;
|
||||
let start_col = line.spans[..r.start].iter().map(|s| s.width()).sum();
|
||||
let end_col = line.spans[..r.end].iter().map(|s| s.width()).sum();
|
||||
Some(start_col..end_col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert span indices to terminal-sized columns for hit-testing.
|
||||
pub fn selectable_cols(line: &Line, selectable: &Selectable) -> Option<Range<u16>> {
|
||||
let cols = selectable_cols_usize(line, selectable)?;
|
||||
Some(u16::try_from(cols.start).ok()?..u16::try_from(cols.end).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_block_output_plain() {
|
||||
let output = BlockOutput::plain("line1\nline2\nline3");
|
||||
assert_eq!(output.len(), 3);
|
||||
assert!(matches!(output.lines[0].selectable, Selectable::All));
|
||||
assert!(matches!(output.lines[1].selectable, Selectable::All));
|
||||
assert!(matches!(output.lines[2].selectable, Selectable::All));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_line_separator() {
|
||||
let line = BlockLine::separator(Line::raw("───"));
|
||||
assert!(matches!(line.selectable, Selectable::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_line_exhaustive_literal_keeps_legacy_shape() {
|
||||
let _line = BlockLine {
|
||||
content: Line::default(),
|
||||
background: None,
|
||||
background_is_panel: false,
|
||||
bg_start_col: 0,
|
||||
wrap: WrapMode::Word,
|
||||
selectable: Selectable::All,
|
||||
selection_range: None,
|
||||
selection_text: None,
|
||||
joiner: None,
|
||||
link_url: None,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selectable_cols() {
|
||||
let line = Line::from(vec![
|
||||
Span::raw("prefix: "), // 8 chars, span 0
|
||||
Span::raw("content"), // 7 chars, span 1
|
||||
]);
|
||||
|
||||
// All spans selectable
|
||||
let cols = selectable_cols(&line, &Selectable::All);
|
||||
assert_eq!(cols, Some(0..15));
|
||||
|
||||
// Only span 1 selectable
|
||||
let cols = selectable_cols(&line, &Selectable::Spans(1..2));
|
||||
assert_eq!(cols, Some(8..15));
|
||||
|
||||
// Not selectable
|
||||
let cols = selectable_cols(&line, &Selectable::None);
|
||||
assert_eq!(cols, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selectable_cols_usize_preserves_ranges_beyond_terminal_width() {
|
||||
let long = "x".repeat(70_000);
|
||||
let line = Line::from(vec![Span::raw("Read "), Span::raw(long)]);
|
||||
|
||||
assert_eq!(
|
||||
selectable_cols_usize(&line, &Selectable::Spans(1..2)),
|
||||
Some(5..70_005)
|
||||
);
|
||||
assert_eq!(selectable_cols(&line, &Selectable::Spans(1..2)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_decorations() {
|
||||
let output = BlockOutput::plain("content");
|
||||
let decorated =
|
||||
output.with_decorations(Some(Span::raw("Prefix: ")), Some(Span::raw(" [suffix]")));
|
||||
|
||||
assert_eq!(decorated.lines.len(), 1);
|
||||
let line = &decorated.lines[0];
|
||||
|
||||
assert_eq!(line.content.spans.len(), 3);
|
||||
assert!(matches!(line.selectable, Selectable::Spans(ref r) if *r == (1..2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_selection_text_prefers_override() {
|
||||
let line = BlockLine::styled(Line::from(vec![Span::raw("visible")]))
|
||||
.with_selection_text(Some("override".to_string()));
|
||||
assert_eq!(derive_selection_text(&line), "override");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_selection_text_from_selectable_spans() {
|
||||
let line = BlockLine {
|
||||
content: Line::from(vec![Span::raw("prefix "), Span::raw("body")]),
|
||||
selectable: Selectable::Spans(1..2),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(derive_selection_text(&line), "body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_selection_text_trims_render_only_table_padding() {
|
||||
// A table row padded to the content width (Selectable::All). The trailing
|
||||
// padding spaces are render-only and must not reach the clipboard.
|
||||
let line = BlockLine::styled(Line::from(vec![
|
||||
Span::raw("│ a │ b │"),
|
||||
Span::raw(" "),
|
||||
]));
|
||||
assert_eq!(derive_selection_text(&line), "│ a │ b │");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_selection_text_trims_trailing_ws_for_all_selectable_lines() {
|
||||
// Intentional broadened scope (see comment at the trim site): trailing
|
||||
// whitespace is stripped from EVERY Selectable::All line's copy text, not
|
||||
// just table rows — matching conventional terminal/tmux copy behavior.
|
||||
let line = BlockLine::styled(Line::from(vec![Span::raw("stdout line ")]));
|
||||
assert_eq!(derive_selection_text(&line), "stdout line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_selection_text_keeps_interior_spaces() {
|
||||
// Only trailing whitespace is trimmed; interior alignment spaces stay.
|
||||
let line = BlockLine::styled(Line::from(vec![Span::raw("│ a │ b │")]));
|
||||
assert_eq!(derive_selection_text(&line), "│ a │ b │");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_display_cols_ascii() {
|
||||
assert_eq!(slice_display_cols("abcdef", 1, 4), "bcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_display_cols_wide_unicode() {
|
||||
assert_eq!(slice_display_cols("a界b", 1, 3), "界");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_display_cols_combining_character() {
|
||||
assert_eq!(slice_display_cols("e\u{301}f", 0, 1), "e\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_display_cols_tab() {
|
||||
assert_eq!(slice_display_cols("a\tb", 0, 2), "a\t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_line_selectable_width_uses_override() {
|
||||
let line = BlockLine::text("ignored").with_selection_text(Some("界".to_string()));
|
||||
assert_eq!(block_line_selectable_width(&line), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_decorations_preserves_selection_metadata() {
|
||||
let output = BlockOutput {
|
||||
lines: vec![
|
||||
BlockLine::styled(Line::from(vec![Span::raw("body")]))
|
||||
.with_selection_range(Some(7))
|
||||
.with_selection_text(Some("body".to_string())),
|
||||
],
|
||||
};
|
||||
let decorated = output.with_decorations(Some(Span::raw("> ")), None);
|
||||
let line = &decorated.lines[0];
|
||||
|
||||
assert_eq!(line.selection_range, Some(7));
|
||||
assert_eq!(line.selection_text.as_deref(), Some("body"));
|
||||
assert!(matches!(line.selectable, Selectable::Spans(ref r) if *r == (1..2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Accented wrapper - adds an accent line on the left.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
|
||||
use crate::render::Renderable;
|
||||
|
||||
/// Wraps content with an accent line on the left.
|
||||
///
|
||||
/// The accent line takes 1 column. Content renders in the remaining space.
|
||||
///
|
||||
/// ```text
|
||||
/// │A│ Content here... │
|
||||
/// │A│ More content... │
|
||||
/// ↑
|
||||
/// Accent column (1 char)
|
||||
/// ```
|
||||
pub struct Accented<'a, T> {
|
||||
inner: &'a T,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
impl<'a, T> Accented<'a, T> {
|
||||
/// Create a new accented wrapper.
|
||||
pub fn new(inner: &'a T, style: Style) -> Self {
|
||||
Self { inner, style }
|
||||
}
|
||||
|
||||
/// Create with just a foreground color.
|
||||
pub fn with_fg(inner: &'a T, color: ratatui::style::Color) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
style: Style::default().fg(color),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Renderable> Renderable for Accented<'_, T> {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// Accent takes 1 column, so content gets width - 1
|
||||
let content_width = width.saturating_sub(1);
|
||||
self.inner.desired_height(content_width)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Split horizontally: [accent (1)] [content (rest)]
|
||||
let [accent_area, content_area] =
|
||||
Layout::horizontal([Constraint::Length(1), Constraint::Min(0)]).areas(area);
|
||||
|
||||
// Draw accent line for all rows
|
||||
for y in accent_area.y..accent_area.y + accent_area.height {
|
||||
buf.set_string(accent_area.x, y, crate::glyphs::accent_bar(), self.style);
|
||||
}
|
||||
|
||||
// Render inner content
|
||||
if content_area.width > 0 {
|
||||
self.inner.render(content_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// Simple test content that renders as fixed height.
|
||||
struct TestContent {
|
||||
height: u16,
|
||||
text: &'static str,
|
||||
}
|
||||
|
||||
impl Renderable for TestContent {
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
self.height
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
for y in area.y..area.y + area.height.min(self.height) {
|
||||
buf.set_string(area.x, y, self.text, Style::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desired_height_accounts_for_accent() {
|
||||
let content = TestContent {
|
||||
height: 3,
|
||||
text: "test",
|
||||
};
|
||||
let accented = Accented::with_fg(&content, Color::Blue);
|
||||
|
||||
// Width 80 -> content gets 79
|
||||
assert_eq!(accented.desired_height(80), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_places_accent() {
|
||||
let content = TestContent {
|
||||
height: 2,
|
||||
text: "Hi",
|
||||
};
|
||||
let accented = Accented::with_fg(&content, Color::Blue);
|
||||
|
||||
let area = Rect::new(0, 0, 10, 2);
|
||||
let mut buf = Buffer::empty(area);
|
||||
accented.render(area, &mut buf);
|
||||
|
||||
// Check accent line is in column 0
|
||||
assert_eq!(
|
||||
buf.cell((0, 0)).unwrap().symbol(),
|
||||
crate::glyphs::accent_bar()
|
||||
);
|
||||
assert_eq!(
|
||||
buf.cell((0, 1)).unwrap().symbol(),
|
||||
crate::glyphs::accent_bar()
|
||||
);
|
||||
|
||||
// Check content starts at column 1
|
||||
assert_eq!(buf.cell((1, 0)).unwrap().symbol(), "H");
|
||||
assert_eq!(buf.cell((2, 0)).unwrap().symbol(), "i");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_empty_area() {
|
||||
let content = TestContent {
|
||||
height: 1,
|
||||
text: "x",
|
||||
};
|
||||
let accented = Accented::with_fg(&content, Color::Blue);
|
||||
|
||||
let area = Rect::new(0, 0, 0, 0);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
||||
accented.render(area, &mut buf); // Should not panic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! BlockRenderer - bridges BlockContent to Renderable.
|
||||
//!
|
||||
//! This wrapper takes a block that implements `BlockContent` and renders
|
||||
//! its output into a given area, implementing the `Renderable` trait.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
use crate::appearance::AppearanceConfig;
|
||||
use crate::render::{Renderable, SafeBuf};
|
||||
use crate::scrollback::block::BlockContent;
|
||||
use crate::scrollback::types::{BlockBackground, BlockContext, DisplayMode};
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Renders a `BlockContent` implementation as a `Renderable`.
|
||||
pub struct BlockRenderer<'a, B> {
|
||||
block: &'a B,
|
||||
mode: DisplayMode,
|
||||
is_running: bool,
|
||||
raw: bool,
|
||||
background: Option<ratatui::style::Color>,
|
||||
max_lines: Option<u16>,
|
||||
appearance: AppearanceConfig,
|
||||
}
|
||||
|
||||
impl<'a, B> BlockRenderer<'a, B> {
|
||||
pub fn new(block: &'a B) -> Self {
|
||||
Self {
|
||||
block,
|
||||
mode: DisplayMode::Expanded,
|
||||
is_running: false,
|
||||
raw: false,
|
||||
background: None,
|
||||
max_lines: None,
|
||||
appearance: AppearanceConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mode(mut self, mode: DisplayMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn running(mut self, is_running: bool) -> Self {
|
||||
self.is_running = is_running;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn raw(mut self, raw: bool) -> Self {
|
||||
self.raw = raw;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn background(mut self, bg: ratatui::style::Color) -> Self {
|
||||
self.background = Some(bg);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn max_lines(mut self, max: u16) -> Self {
|
||||
self.max_lines = Some(max);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn appearance(mut self, appearance: AppearanceConfig) -> Self {
|
||||
self.appearance = appearance;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: BlockContent> BlockRenderer<'_, B> {
|
||||
fn make_context(&self, width: u16) -> BlockContext {
|
||||
BlockContext {
|
||||
mode: self.mode,
|
||||
is_running: self.is_running,
|
||||
width,
|
||||
raw: self.raw,
|
||||
max_lines: self.max_lines,
|
||||
appearance: self.appearance.clone(),
|
||||
is_selected: false,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_background(&self, block_bg: BlockBackground) -> Option<ratatui::style::Color> {
|
||||
// Explicit override takes precedence
|
||||
if let Some(bg) = self.background {
|
||||
return Some(bg);
|
||||
}
|
||||
|
||||
// Otherwise use block's declared background
|
||||
let theme = Theme::current();
|
||||
match block_bg {
|
||||
BlockBackground::None => None,
|
||||
BlockBackground::Light => Some(theme.bg_light),
|
||||
BlockBackground::Dark => Some(theme.bg_dark),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: BlockContent> Renderable for BlockRenderer<'_, B> {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let ctx = self.make_context(width);
|
||||
let output = self.block.output(&ctx);
|
||||
let has_vpad = self.block.has_vpad(&ctx);
|
||||
|
||||
let content_height = output.len() as u16;
|
||||
let vpad = if has_vpad { 2 } else { 0 }; // top + bottom
|
||||
|
||||
content_height + vpad
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let ctx = self.make_context(area.width);
|
||||
let output = self.block.output(&ctx);
|
||||
let has_vpad = self.block.has_vpad(&ctx);
|
||||
let block_bg = self.block.background(&ctx);
|
||||
|
||||
// Resolve background color
|
||||
let bg_color = self.resolve_background(block_bg);
|
||||
|
||||
// Fill background if specified
|
||||
if let Some(bg) = bg_color {
|
||||
let bg_style = Style::default().bg(bg);
|
||||
for y in area.y..area.y + area.height {
|
||||
for x in area.x..area.x + area.width {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
cell.set_style(bg_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut row = area.y;
|
||||
let max_row = area.y + area.height;
|
||||
|
||||
// Top vpad (empty row)
|
||||
if has_vpad && row < max_row {
|
||||
row += 1;
|
||||
}
|
||||
|
||||
// Content lines
|
||||
for line in &output.lines {
|
||||
if row >= max_row {
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply line-specific background if set
|
||||
// Respects bg_start_col for partial background
|
||||
if let Some(line_bg) = line.background {
|
||||
let bg_x = area.x + line.bg_start_col;
|
||||
let bg_width = area.width.saturating_sub(line.bg_start_col);
|
||||
if bg_width > 0 {
|
||||
let line_rect = Rect::new(bg_x, row, bg_width, 1);
|
||||
buf.set_style(line_rect, Style::default().bg(line_bg));
|
||||
}
|
||||
}
|
||||
|
||||
// Render the line content
|
||||
buf.set_line_safe(area.x, row, &line.content, area.width);
|
||||
row += 1;
|
||||
}
|
||||
|
||||
// Bottom vpad (empty row) - just skip, background already applied
|
||||
// (no explicit rendering needed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scrollback::block::StubBlock;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn test_desired_height_with_vpad() {
|
||||
let block = StubBlock::new("Hello", Color::Blue);
|
||||
let renderer = BlockRenderer::new(&block);
|
||||
|
||||
// StubBlock has 1 line + vpad (top + bottom) = 3
|
||||
assert_eq!(renderer.desired_height(80), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_fills_area() {
|
||||
let block = StubBlock::new("Test content", Color::Blue);
|
||||
let renderer = BlockRenderer::new(&block);
|
||||
|
||||
let area = Rect::new(0, 0, 20, 5);
|
||||
let mut buf = Buffer::empty(area);
|
||||
renderer.render(area, &mut buf);
|
||||
|
||||
// Row 0 = top vpad (empty)
|
||||
// Row 1 = content "Test content"
|
||||
// Row 2 = bottom vpad (empty)
|
||||
// Content should be at row 1
|
||||
let content_cell = buf.cell((0, 1)).unwrap();
|
||||
assert_eq!(content_cell.symbol(), "T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_with_explicit_background() {
|
||||
let block = StubBlock::new("BG test", Color::Blue);
|
||||
let renderer = BlockRenderer::new(&block).background(Color::Red);
|
||||
|
||||
let area = Rect::new(0, 0, 10, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
renderer.render(area, &mut buf);
|
||||
|
||||
// All cells should have red background
|
||||
for y in 0..3 {
|
||||
for x in 0..10 {
|
||||
assert_eq!(buf.cell((x, y)).unwrap().bg, Color::Red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_display_modes() {
|
||||
let block = StubBlock::new("Mode test", Color::Blue);
|
||||
|
||||
let expanded = BlockRenderer::new(&block).mode(DisplayMode::Expanded);
|
||||
let collapsed = BlockRenderer::new(&block).mode(DisplayMode::Collapsed);
|
||||
|
||||
// Both should have same height for StubBlock (it doesn't vary by mode)
|
||||
assert_eq!(expanded.desired_height(80), collapsed.desired_height(80));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
//! Wrapper types for composable rendering.
|
||||
//!
|
||||
//! These wrappers implement the `Renderable` trait and add decorations
|
||||
//! (accent lines, padding, etc.) around inner content.
|
||||
//!
|
||||
//! ## Composition Example
|
||||
//!
|
||||
//! ```text
|
||||
//! Padded::standard( // Adds left=2, right=1 padding
|
||||
//! &Accented::with_fg( // Adds accent line on left
|
||||
//! &BlockRenderer::new(&block), // Renders block content
|
||||
//! Color::Blue,
|
||||
//! )
|
||||
//! )
|
||||
//! ```
|
||||
//!
|
||||
//! This produces:
|
||||
//! ```text
|
||||
//! │PP│A│ Content here... │P│
|
||||
//! ↑↑ ↑ ↑
|
||||
//! │ └─ Accent └─ Right padding
|
||||
//! └───── Left padding
|
||||
//! ```
|
||||
|
||||
mod accented;
|
||||
mod block_renderer;
|
||||
mod entry_renderer;
|
||||
mod padded;
|
||||
|
||||
pub use accented::Accented;
|
||||
pub use block_renderer::BlockRenderer;
|
||||
pub use entry_renderer::EntryRenderer;
|
||||
pub(crate) use entry_renderer::group_header_chrome_prefix_width;
|
||||
pub use padded::Padded;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::render::Renderable;
|
||||
use crate::scrollback::block::StubBlock;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// Test that wrappers can be composed together.
|
||||
#[test]
|
||||
fn test_wrapper_composition() {
|
||||
let block = StubBlock::new("Hello", Color::Blue);
|
||||
let renderer = BlockRenderer::new(&block);
|
||||
let accented = Accented::with_fg(&renderer, Color::Blue);
|
||||
let padded = Padded::standard(&accented);
|
||||
|
||||
// Check height calculation chains correctly
|
||||
// BlockRenderer: 3 (1 content + 2 vpad)
|
||||
// Accented: takes 1 column, height unchanged
|
||||
// Padded: takes 3 columns (2+1), height unchanged
|
||||
assert_eq!(padded.desired_height(80), 3);
|
||||
|
||||
// Render and verify structure
|
||||
let area = Rect::new(0, 0, 20, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
padded.render(area, &mut buf);
|
||||
|
||||
// Layout should be:
|
||||
// Cols 0-1: padding (empty)
|
||||
// Col 2: accent line
|
||||
// Cols 3+: content
|
||||
// Last col: right padding
|
||||
|
||||
// Check accent is at column 2
|
||||
assert_eq!(buf.cell((2, 0)).unwrap().symbol(), "┃");
|
||||
assert_eq!(buf.cell((2, 1)).unwrap().symbol(), "┃");
|
||||
assert_eq!(buf.cell((2, 2)).unwrap().symbol(), "┃");
|
||||
|
||||
// Check content starts at column 3 (after accent)
|
||||
// Row 0 is vpad (empty), row 1 is content
|
||||
assert_eq!(buf.cell((3, 1)).unwrap().symbol(), "H");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Padded wrapper - adds horizontal padding around content.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
|
||||
use crate::render::Renderable;
|
||||
|
||||
/// Wraps content with horizontal padding.
|
||||
///
|
||||
/// Adds left and right padding columns. Optionally fills padding with a background.
|
||||
///
|
||||
/// ```text
|
||||
/// │PP│ Content here... │P│
|
||||
/// │PP│ More content... │P│
|
||||
/// ↑↑ ↑
|
||||
/// Left padding (2) Right padding (1)
|
||||
/// ```
|
||||
pub struct Padded<'a, T> {
|
||||
inner: &'a T,
|
||||
left: u16,
|
||||
right: u16,
|
||||
bg: Option<ratatui::style::Color>,
|
||||
}
|
||||
|
||||
impl<'a, T> Padded<'a, T> {
|
||||
/// Create a new padded wrapper.
|
||||
pub fn new(inner: &'a T, left: u16, right: u16) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
left,
|
||||
right,
|
||||
bg: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with standard pager padding (2 left, 1 right).
|
||||
pub fn standard(inner: &'a T) -> Self {
|
||||
Self::new(inner, 2, 1)
|
||||
}
|
||||
|
||||
/// Set background color for padding and content area.
|
||||
pub fn with_bg(mut self, color: ratatui::style::Color) -> Self {
|
||||
self.bg = Some(color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Renderable> Renderable for Padded<'_, T> {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// Padding takes left + right columns
|
||||
let content_width = width.saturating_sub(self.left + self.right);
|
||||
self.inner.desired_height(content_width)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill background if specified
|
||||
if let Some(bg) = self.bg {
|
||||
let bg_style = Style::default().bg(bg);
|
||||
for y in area.y..area.y + area.height {
|
||||
for x in area.x..area.x + area.width {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
cell.set_style(bg_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split horizontally: [left pad] [content] [right pad]
|
||||
let [_left_area, content_area, _right_area] = Layout::horizontal([
|
||||
Constraint::Length(self.left),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(self.right),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
// Render inner content
|
||||
if content_area.width > 0 {
|
||||
self.inner.render(content_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// Simple test content that renders as fixed height.
|
||||
struct TestContent {
|
||||
height: u16,
|
||||
text: &'static str,
|
||||
}
|
||||
|
||||
impl Renderable for TestContent {
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
self.height
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
for y in area.y..area.y + area.height.min(self.height) {
|
||||
buf.set_string(area.x, y, self.text, Style::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desired_height_accounts_for_padding() {
|
||||
let content = TestContent {
|
||||
height: 3,
|
||||
text: "test",
|
||||
};
|
||||
let padded = Padded::new(&content, 2, 1);
|
||||
|
||||
// Width 80 -> content gets 77 (80 - 2 - 1)
|
||||
assert_eq!(padded.desired_height(80), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_standard_padding() {
|
||||
let content = TestContent {
|
||||
height: 1,
|
||||
text: "x",
|
||||
};
|
||||
let padded = Padded::standard(&content);
|
||||
|
||||
// Standard is 2 left, 1 right
|
||||
// Width 10 -> content gets 7
|
||||
assert_eq!(padded.desired_height(10), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_places_content_with_offset() {
|
||||
let content = TestContent {
|
||||
height: 1,
|
||||
text: "Hi",
|
||||
};
|
||||
let padded = Padded::new(&content, 2, 1);
|
||||
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
padded.render(area, &mut buf);
|
||||
|
||||
// Content starts at column 2 (after 2-char left padding)
|
||||
assert_eq!(buf.cell((2, 0)).unwrap().symbol(), "H");
|
||||
assert_eq!(buf.cell((3, 0)).unwrap().symbol(), "i");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_with_background() {
|
||||
let content = TestContent {
|
||||
height: 1,
|
||||
text: "X",
|
||||
};
|
||||
let padded = Padded::new(&content, 1, 1).with_bg(Color::Blue);
|
||||
|
||||
let area = Rect::new(0, 0, 5, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
padded.render(area, &mut buf);
|
||||
|
||||
// All cells should have blue background
|
||||
for x in 0..5 {
|
||||
let cell = buf.cell((x, 0)).unwrap();
|
||||
assert_eq!(cell.bg, Color::Blue);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user