docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
1 parent
ff0fb56c67
commit
a02b555e66
1458 files changed
+10729
-21750
No files matched your search
@@ -52,7 +52,6 @@ impl AgentMessageBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a streaming chunk of markdown text.
|
||||
pub fn push_chunk(&mut self, chunk: &str) {
|
||||
self.content.push_chunk(chunk);
|
||||
}
|
||||
@@ -62,7 +61,6 @@ impl AgentMessageBlock {
|
||||
self.content.push_chunk_deferred(chunk);
|
||||
}
|
||||
|
||||
/// Get the source markdown text.
|
||||
pub fn text(&self) -> String {
|
||||
self.content.text()
|
||||
}
|
||||
@@ -99,7 +97,6 @@ impl AgentMessageBlock {
|
||||
&self.content
|
||||
}
|
||||
|
||||
/// Mutable access to the underlying markdown content.
|
||||
pub fn content_mut(&mut self) -> &mut MarkdownContent {
|
||||
&mut self.content
|
||||
}
|
||||
@@ -376,8 +373,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- diagram affordance rows ---------------------------------------------
|
||||
|
||||
mod affordances {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ pub enum BgTaskKind {
|
||||
/// 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,
|
||||
@@ -47,7 +46,6 @@ pub struct BgTaskBlock {
|
||||
}
|
||||
|
||||
impl BgTaskBlock {
|
||||
/// Create a "Task started" block.
|
||||
pub fn started(command: impl Into<String>, task_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
command: command.into(),
|
||||
@@ -57,7 +55,6 @@ impl BgTaskBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a "Task completed" block.
|
||||
pub fn completed(
|
||||
command: impl Into<String>,
|
||||
task_id: impl Into<String>,
|
||||
@@ -71,7 +68,6 @@ impl BgTaskBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a "Task failed" block.
|
||||
pub fn failed(
|
||||
command: impl Into<String>,
|
||||
task_id: impl Into<String>,
|
||||
|
||||
@@ -23,7 +23,6 @@ pub struct BtwBlock {
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -31,7 +30,6 @@ impl BtwBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the underlying markdown content.
|
||||
pub fn content(&self) -> &MarkdownContent {
|
||||
&self.content
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ use kigi_shell::session::{ContextInfo, count_detail};
|
||||
/// enter the bar.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContextInfoBlock {
|
||||
/// The captured context-window snapshot.
|
||||
pub snapshot: ContextInfo,
|
||||
/// Active model name at the time of capture (display-only).
|
||||
pub model: String,
|
||||
@@ -254,7 +253,6 @@ impl RowLayout {
|
||||
}
|
||||
|
||||
impl ContextInfoBlock {
|
||||
/// Create a new context-info block.
|
||||
pub fn new(snapshot: ContextInfo, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
snapshot,
|
||||
@@ -301,10 +299,10 @@ impl ContextInfoBlock {
|
||||
// are the conversation the user is actually steering. System
|
||||
// prompt uses the same diamond glyph as messages but in muted
|
||||
// gray so it reads as a quiet base layer.
|
||||
let system_color = quantize(theme.gray_bright); // gray
|
||||
let tools_color = quantize(theme.accent_skill); // teal / skill accent
|
||||
let messages_color = quantize(theme.text_primary); // primary (white)
|
||||
let empty_color = quantize(theme.gray_dim); // free / outline
|
||||
let system_color = quantize(theme.gray_bright);
|
||||
let tools_color = quantize(theme.accent_skill);
|
||||
let messages_color = quantize(theme.text_primary);
|
||||
let empty_color = quantize(theme.gray_dim);
|
||||
let overhead_color = quantize(theme.accent_verify);
|
||||
|
||||
// Categorical bar: 100 cells laid out as `bar.rows` rows of
|
||||
@@ -314,11 +312,16 @@ impl ContextInfoBlock {
|
||||
// Routed through `glyphs` so the diamonds degrade to CP437-safe
|
||||
// stand-ins (`◆`→`♦`, `◇`→`○`) on legacy Windows consoles that
|
||||
// can't render the U+25Cx diamonds.
|
||||
let system_glyph = crate::glyphs::diamond_filled(); // ◆ (gray)
|
||||
let tools_glyph = crate::glyphs::diamond_dotted(); // ◈
|
||||
let messages_glyph = crate::glyphs::diamond_filled(); // ◆ (primary)
|
||||
let free_glyph = crate::glyphs::diamond_hollow(); // ◇
|
||||
let overhead_glyph = crate::glyphs::diamond_filled(); // ◆ (violet)
|
||||
// ◆ (gray)
|
||||
let system_glyph = crate::glyphs::diamond_filled();
|
||||
// ◈
|
||||
let tools_glyph = crate::glyphs::diamond_dotted();
|
||||
// ◆ (primary)
|
||||
let messages_glyph = crate::glyphs::diamond_filled();
|
||||
// ◇
|
||||
let free_glyph = crate::glyphs::diamond_hollow();
|
||||
// ◆ (violet)
|
||||
let overhead_glyph = crate::glyphs::diamond_filled();
|
||||
|
||||
let total_cells = bar.total();
|
||||
let cells_for = |tokens: u64| -> usize {
|
||||
@@ -421,9 +424,7 @@ impl ContextInfoBlock {
|
||||
let label_style = Style::default().fg(theme.text_secondary);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![
|
||||
// Header: bold white "Context"
|
||||
Line::from(Span::styled("Context", primary)),
|
||||
// Blank row between header and the at-a-glance summary
|
||||
Line::from(""),
|
||||
// Sub-header: token totals + percent. Uses `text_secondary` for
|
||||
// a touch more contrast than `muted` so the at-a-glance numbers
|
||||
@@ -448,7 +449,6 @@ impl ContextInfoBlock {
|
||||
model.to_string(),
|
||||
Style::default().fg(theme.gray_bright),
|
||||
)),
|
||||
// Blank space before the bar
|
||||
Line::from(""),
|
||||
];
|
||||
lines.extend(bar_lines);
|
||||
@@ -501,7 +501,6 @@ impl ContextInfoBlock {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Footer stats
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(
|
||||
"Turns: {turn_count} \u{00b7} Tool calls: {tool_call_count} \u{00b7} Compactions: {compaction_count}"
|
||||
@@ -598,7 +597,6 @@ impl BlockContent for ContextInfoBlock {
|
||||
.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 {
|
||||
@@ -634,7 +632,8 @@ impl BlockContent for ContextInfoBlock {
|
||||
}
|
||||
|
||||
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
|
||||
false // Compact like SystemMessageBlock
|
||||
// Compact like SystemMessageBlock
|
||||
false
|
||||
}
|
||||
|
||||
fn has_raw_mode(&self) -> bool {
|
||||
@@ -769,7 +768,8 @@ mod tests {
|
||||
// avoid stacking two warning-styled lines that contradict each
|
||||
// other (manual /compact vs. auto-compact about to fire).
|
||||
let mut snap = snapshot();
|
||||
snap.usage_pct = 85; // the historical default (and value in snapshot() helper)
|
||||
// the historical default (and value in snapshot() helper)
|
||||
snap.usage_pct = 85;
|
||||
let block = ContextInfoBlock::new(snap, "kigi-4");
|
||||
let theme = test_theme();
|
||||
let lines = block.build_lines(&theme, BarLayout::WIDE);
|
||||
@@ -821,7 +821,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fmt_tok_big_switches_to_millions_at_one_million() {
|
||||
assert_eq!(fmt_tok_big(999_999), fmt_tok(999_999)); // delegates below 1m
|
||||
// delegates below 1m
|
||||
assert_eq!(fmt_tok_big(999_999), fmt_tok(999_999));
|
||||
assert_eq!(fmt_tok_big(1_000_000), "1.0m");
|
||||
assert_eq!(fmt_tok_big(1_500_000), "1.5m");
|
||||
assert_eq!(fmt_tok_big(2_345_678), "2.3m");
|
||||
@@ -873,7 +874,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Bar partition tests
|
||||
//
|
||||
// The bar lives at line indices 5..(5+layout.rows) (after header /
|
||||
@@ -881,11 +881,11 @@ mod tests {
|
||||
// spans separated by raw-space spans. To count cells per category,
|
||||
// we walk the bar lines for the given layout and count spans whose
|
||||
// content matches each category's glyph.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const SYSTEM_GLYPH_TEST: &str = "\u{25C6}";
|
||||
const TOOLS_GLYPH_TEST: &str = "\u{25C8}";
|
||||
const MESSAGES_GLYPH_TEST: &str = "\u{25C6}"; // same as system; distinguished by color in real render
|
||||
// same as system; distinguished by color in real render
|
||||
const MESSAGES_GLYPH_TEST: &str = "\u{25C6}";
|
||||
const FREE_GLYPH_TEST: &str = "\u{25C7}";
|
||||
|
||||
/// `layout` tells the function how many bar rows to slice (5 for
|
||||
@@ -898,7 +898,8 @@ mod tests {
|
||||
let mut diamonds = 0usize;
|
||||
let mut tools = 0usize;
|
||||
let mut free = 0usize;
|
||||
let bar_start = 5; // header / blank / tokens / model / blank
|
||||
// header / blank / tokens / model / blank
|
||||
let bar_start = 5;
|
||||
let bar_end = bar_start + layout.rows;
|
||||
for line in &lines[bar_start..bar_end] {
|
||||
for span in &line.spans {
|
||||
@@ -1112,13 +1113,11 @@ mod tests {
|
||||
assert_eq!(percent_of_window(500_000, 1_000_000), "50%");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Responsive bar layout tests
|
||||
//
|
||||
// The bar's shape (5×20 vs 10×10) is chosen by `BarLayout::for_width`
|
||||
// based on terminal width, so narrow terminals get a square bar that
|
||||
// still fits in their column budget.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn bar_layout_wide_is_5_rows_of_20() {
|
||||
@@ -1238,9 +1237,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Legend label color + responsive wrapping tests
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Helper: find the legend row (or row 1, for the narrow layout)
|
||||
/// whose label text starts with `label_prefix`. Returns the matching
|
||||
|
||||
@@ -184,7 +184,6 @@ impl MarkdownContent {
|
||||
self.generation += 1;
|
||||
}
|
||||
|
||||
/// Get the source markdown text.
|
||||
pub fn text(&self) -> String {
|
||||
self.state.borrow().renderer.source().to_string()
|
||||
}
|
||||
@@ -266,7 +265,6 @@ impl MarkdownContent {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// Whether the content is currently in raw mode.
|
||||
pub fn is_raw(&self) -> bool {
|
||||
self.current_raw
|
||||
}
|
||||
@@ -285,7 +283,8 @@ impl MarkdownContent {
|
||||
}
|
||||
state.cache_lines = Vec::new();
|
||||
state.cache_joiners = Vec::new();
|
||||
state.cache_generation = u64::MAX; // force rebuild on next use
|
||||
// Force rebuild on next use.
|
||||
state.cache_generation = u64::MAX;
|
||||
state.frozen_pre_wrap_count = 0;
|
||||
state.frozen_wrapped_count = 0;
|
||||
}
|
||||
@@ -319,7 +318,8 @@ impl MarkdownContent {
|
||||
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
|
||||
// Force cache miss.
|
||||
state.cache_generation = u64::MAX;
|
||||
// set_style resets renderer frozen state, so our tracking is stale
|
||||
state.frozen_pre_wrap_count = 0;
|
||||
state.frozen_wrapped_count = 0;
|
||||
@@ -342,7 +342,7 @@ impl MarkdownContent {
|
||||
|
||||
let frozen_count = state.renderer.frozen_lines_count();
|
||||
|
||||
// --- Incremental wrapping ---
|
||||
// Incremental wrapping:
|
||||
//
|
||||
// The renderer guarantees that view().lines[0..frozen_count] are stable.
|
||||
// We only need to wrap:
|
||||
@@ -373,13 +373,12 @@ impl MarkdownContent {
|
||||
None
|
||||
};
|
||||
|
||||
// Now mutate the cache (no more borrows of view/renderer)
|
||||
// Truncate stale tail, keeping only the previously frozen wrapped output
|
||||
// Now mutate the cache (no more borrows of view/renderer).
|
||||
// Truncate stale tail, keeping only the already-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);
|
||||
@@ -387,7 +386,6 @@ impl MarkdownContent {
|
||||
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);
|
||||
@@ -651,7 +649,6 @@ mod tests {
|
||||
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);
|
||||
|
||||
|
||||
@@ -822,8 +822,6 @@ mod tests {
|
||||
assert_eq!(prewrap_end_rows(&out.lines), vec![1, 3, 4]);
|
||||
}
|
||||
|
||||
// -- theme mapping + cache filename --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn theme_is_dark_maps_kigiday_to_light_only() {
|
||||
assert!(
|
||||
|
||||
@@ -683,7 +683,8 @@ impl BlockContent for SessionEventBlock {
|
||||
}
|
||||
|
||||
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
|
||||
false // Compact like SystemMessageBlock
|
||||
// Compact like SystemMessageBlock.
|
||||
false
|
||||
}
|
||||
|
||||
fn has_raw_mode(&self) -> bool {
|
||||
|
||||
@@ -47,21 +47,16 @@ pub enum SubagentBlockKind {
|
||||
/// 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.
|
||||
///
|
||||
@@ -159,11 +154,11 @@ impl SubagentBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(); // "…"
|
||||
// "…"
|
||||
return "\u{201C}\u{2026}\u{201D}".to_string();
|
||||
}
|
||||
let inner = truncate_str(desc, max_width - 2);
|
||||
format!("\u{201C}{inner}\u{201D}")
|
||||
@@ -213,7 +208,6 @@ impl BlockContent for SubagentBlock {
|
||||
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()
|
||||
@@ -225,7 +219,6 @@ impl BlockContent for SubagentBlock {
|
||||
Span::styled(desc, muted),
|
||||
])
|
||||
}
|
||||
// Failed: Subagent failed in Xs: "description"
|
||||
(SubagentBlockKind::Failed { elapsed, error }, _) => {
|
||||
let time_str = format_duration(*elapsed);
|
||||
let detail = error
|
||||
@@ -240,7 +233,6 @@ impl BlockContent for SubagentBlock {
|
||||
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()
|
||||
|
||||
@@ -7,15 +7,12 @@ 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() }
|
||||
}
|
||||
@@ -37,7 +34,6 @@ impl BlockContent for SystemMessageBlock {
|
||||
.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 {
|
||||
@@ -69,11 +65,11 @@ impl BlockContent for SystemMessageBlock {
|
||||
}
|
||||
|
||||
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
|
||||
None // System messages have no accent
|
||||
None
|
||||
}
|
||||
|
||||
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
|
||||
false // System messages are compact
|
||||
false
|
||||
}
|
||||
|
||||
fn has_raw_mode(&self) -> bool {
|
||||
@@ -81,11 +77,11 @@ impl BlockContent for SystemMessageBlock {
|
||||
}
|
||||
|
||||
fn is_foldable(&self) -> bool {
|
||||
false // System messages are short
|
||||
false
|
||||
}
|
||||
|
||||
fn is_selectable(&self) -> bool {
|
||||
false // System messages are not navigable
|
||||
false
|
||||
}
|
||||
|
||||
fn is_groupable(&self) -> bool {
|
||||
|
||||
@@ -31,7 +31,6 @@ pub struct ThinkingBlock {
|
||||
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),
|
||||
@@ -40,7 +39,6 @@ impl ThinkingBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an empty block for streaming.
|
||||
pub fn streaming() -> Self {
|
||||
Self {
|
||||
content: MarkdownContent::streaming(),
|
||||
@@ -67,7 +65,6 @@ impl ThinkingBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a streaming chunk of markdown text.
|
||||
pub fn push_chunk(&mut self, chunk: &str) {
|
||||
self.content.push_chunk(chunk);
|
||||
}
|
||||
@@ -84,9 +81,6 @@ impl ThinkingBlock {
|
||||
/// 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
|
||||
{
|
||||
@@ -94,7 +88,6 @@ impl ThinkingBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the source text.
|
||||
pub fn text(&self) -> String {
|
||||
self.content.text()
|
||||
}
|
||||
@@ -112,8 +105,6 @@ impl ThinkingBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -129,7 +120,6 @@ impl ThinkingBlock {
|
||||
&self.content
|
||||
}
|
||||
|
||||
/// Mutable access to the underlying markdown content.
|
||||
pub fn content_mut(&mut self) -> &mut MarkdownContent {
|
||||
&mut self.content
|
||||
}
|
||||
@@ -146,7 +136,6 @@ impl ThinkingBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format elapsed time for display.
|
||||
fn format_time(&self) -> Option<String> {
|
||||
self.elapsed_time_ms.map(|ms| {
|
||||
let secs = ms as f64 / 1000.0;
|
||||
@@ -259,7 +248,6 @@ impl ThinkingBlock {
|
||||
|
||||
let total = wrapped.lines.len();
|
||||
if total <= n {
|
||||
// Content fits within N lines, show all (with blending)
|
||||
let output = BlockOutput {
|
||||
lines: wrapped
|
||||
.lines
|
||||
@@ -280,15 +268,12 @@ impl ThinkingBlock {
|
||||
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],
|
||||
@@ -383,9 +368,9 @@ impl BlockContent for ThinkingBlock {
|
||||
/// 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
|
||||
self.accent(ctx)
|
||||
} else {
|
||||
None // default gray/primary
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,6 @@ impl Default for DiffRenderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout constants.
|
||||
const INDENT: &str = " ";
|
||||
const GUTTER_GAP: &str = " ";
|
||||
const CONTENT_GAP: &str = " ";
|
||||
@@ -179,7 +178,6 @@ fn render_diff_hunks_core(
|
||||
|
||||
for (i, hunk) in hunks.iter().enumerate() {
|
||||
if i > 0 && !lines.is_empty() && !config.hunk_separator.is_empty() {
|
||||
// Add separator between hunks (no background)
|
||||
let indent = if config.indent { INDENT } else { "" };
|
||||
let sep_text = match hunk_gap_lines(&hunks[i - 1], hunk) {
|
||||
Some(1) => format!("{} 1 unchanged line", config.hunk_separator),
|
||||
@@ -477,21 +475,16 @@ fn assemble_diff_line_outputs(
|
||||
/// Compute where background starts based on config.
|
||||
fn compute_bg_start(config: &DiffRenderConfig, gutter_width: usize, indent_width: usize) -> u16 {
|
||||
if config.gutter_bg {
|
||||
// Gutter bg is on - check if we should skip indent
|
||||
// indent_bg = true means skip indent (keep it clean)
|
||||
// indent_bg = false means include indent in background
|
||||
if config.indent && config.indent_bg {
|
||||
indent_width as u16
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
// Gutter bg is off - color from content only
|
||||
gutter_width as u16
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple word-wrap implementation.
|
||||
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
|
||||
if max_width == 0 || text.is_empty() {
|
||||
return vec![text.to_string()];
|
||||
@@ -537,7 +530,6 @@ struct GutterLayout {
|
||||
dual: bool,
|
||||
}
|
||||
|
||||
/// Calculate gutter layout for a hunk.
|
||||
fn gutter_layout(hunk: &DiffHunk, config: &DiffRenderConfig) -> GutterLayout {
|
||||
let mut max_old = 1usize;
|
||||
let mut max_new = 1usize;
|
||||
@@ -572,7 +564,6 @@ fn gutter_layout(hunk: &DiffHunk, config: &DiffRenderConfig) -> GutterLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the line number gutter.
|
||||
fn render_gutter(
|
||||
spans: &mut Vec<Span<'static>>,
|
||||
line: &crate::diff::DiffLine,
|
||||
@@ -582,13 +573,11 @@ fn render_gutter(
|
||||
) {
|
||||
let gutter_style = Style::default().fg(theme.diff_gutter_fg);
|
||||
|
||||
// Indent (configurable)
|
||||
if config.indent {
|
||||
spans.push(Span::raw(INDENT));
|
||||
}
|
||||
|
||||
if layout.dual {
|
||||
// Dual mode: two columns (old + new) like GitHub unified diff
|
||||
let w_old = layout.width_old;
|
||||
let w_new = layout.width_new;
|
||||
match line.tag {
|
||||
@@ -615,7 +604,6 @@ fn render_gutter(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single mode: one column with the relevant line number
|
||||
let w = layout.width_new;
|
||||
match line.tag {
|
||||
ChangeTag::Equal => {
|
||||
@@ -636,7 +624,6 @@ fn render_gutter(
|
||||
}
|
||||
}
|
||||
|
||||
// Gap between gutter and content
|
||||
spans.push(Span::raw(CONTENT_GAP));
|
||||
}
|
||||
|
||||
@@ -647,7 +634,6 @@ fn painted(text: &str, style: Style) -> Span<'static> {
|
||||
Span::styled(text.to_string(), style)
|
||||
}
|
||||
|
||||
/// Render content spans with syntax highlighting.
|
||||
fn render_content_spans(
|
||||
content: &str,
|
||||
tag: ChangeTag,
|
||||
@@ -666,7 +652,6 @@ fn render_content_spans(
|
||||
return spans;
|
||||
}
|
||||
|
||||
// Try syntax highlighting
|
||||
if let Some(hl) = highlighter.as_mut()
|
||||
&& let Ok(ranges) = hl.highlight_line(&format!("{content}\n"), &syntect.syntax_set)
|
||||
{
|
||||
@@ -688,7 +673,6 @@ fn render_content_spans(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: plain text
|
||||
let style = match tag {
|
||||
ChangeTag::Equal => Style::default().fg(theme.diff_equal_fg),
|
||||
ChangeTag::Delete | ChangeTag::Insert => Style::default().fg(theme.text_primary),
|
||||
@@ -703,12 +687,9 @@ fn syntect_to_ratatui_fg(style: SyntectStyle) -> Style {
|
||||
crate::syntax::syntect_to_ratatui_fg(style)
|
||||
}
|
||||
|
||||
/// Edit tool call block - displays file edit with diff.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditToolCallBlock {
|
||||
/// File path being edited.
|
||||
pub path: String,
|
||||
/// Diff hunks.
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
/// Number of edits (for multi-edit display).
|
||||
pub edit_count: usize,
|
||||
@@ -732,8 +713,6 @@ pub struct EditToolCallBlock {
|
||||
}
|
||||
|
||||
impl EditToolCallBlock {
|
||||
/// Create a new edit 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`).
|
||||
@@ -766,13 +745,11 @@ impl EditToolCallBlock {
|
||||
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()
|
||||
}
|
||||
@@ -800,7 +777,6 @@ impl EditToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get elapsed time in ms (Phase 2).
|
||||
pub fn elapsed_ms(&self) -> Option<i64> {
|
||||
match self.elapsed_ms {
|
||||
Some(ms) => Some(ms),
|
||||
@@ -810,7 +786,6 @@ impl EditToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set edit count explicitly.
|
||||
pub fn with_edit_count(mut self, count: usize) -> Self {
|
||||
self.edit_count = count;
|
||||
self
|
||||
@@ -823,14 +798,12 @@ impl EditToolCallBlock {
|
||||
diff_hunks_to_patch(&self.path, &self.hunks)
|
||||
}
|
||||
|
||||
/// Set hunks (mutable).
|
||||
pub fn set_hunks(&mut self, hunks: Vec<DiffHunk>) {
|
||||
self.edit_count = hunks.len().max(1);
|
||||
self.change_counts = Self::compute_changes(&hunks);
|
||||
self.hunks = hunks;
|
||||
}
|
||||
|
||||
/// Count insertions and deletions across all hunks.
|
||||
fn compute_changes(hunks: &[DiffHunk]) -> (usize, usize) {
|
||||
let mut insertions = 0;
|
||||
let mut deletions = 0;
|
||||
@@ -846,7 +819,6 @@ impl EditToolCallBlock {
|
||||
(insertions, deletions)
|
||||
}
|
||||
|
||||
/// Get pre-computed insertion/deletion counts.
|
||||
fn count_changes(&self) -> (usize, usize) {
|
||||
self.change_counts
|
||||
}
|
||||
@@ -1156,7 +1128,6 @@ impl EditToolCallBlock {
|
||||
|
||||
match ctx.mode {
|
||||
DisplayMode::Collapsed => {
|
||||
// Collapsed: just header line. Path (span 1) is selectable.
|
||||
let line = self.header_line(
|
||||
&theme,
|
||||
muted_collapsed,
|
||||
@@ -1185,7 +1156,6 @@ impl EditToolCallBlock {
|
||||
})
|
||||
}
|
||||
DisplayMode::Truncated | DisplayMode::Expanded => {
|
||||
// Convert appearance config to render config
|
||||
let config = DiffRenderConfig {
|
||||
indent: edit_cfg.indent,
|
||||
gutter_bg: edit_cfg.gutter_bg,
|
||||
@@ -1193,7 +1163,6 @@ impl EditToolCallBlock {
|
||||
hunk_separator: edit_cfg.hunk_separator.clone(),
|
||||
dual_line_numbers: edit_cfg.dual_line_numbers,
|
||||
};
|
||||
// Header with word-wrap and hanging indent
|
||||
let header = self.header_line(
|
||||
&theme,
|
||||
false,
|
||||
@@ -1237,7 +1206,6 @@ impl EditToolCallBlock {
|
||||
});
|
||||
}
|
||||
|
||||
// Error message (non-selectable decoration)
|
||||
if let Some(err) = &self.error {
|
||||
lines.push(BlockLine::separator(Line::from("")));
|
||||
for line in err.lines() {
|
||||
@@ -1248,7 +1216,6 @@ impl EditToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
// Diff content with syntax highlighting and full-width backgrounds
|
||||
if !self.hunks.is_empty() {
|
||||
// Empty line after header (non-selectable)
|
||||
lines.push(BlockLine::separator(Line::from("")));
|
||||
@@ -1283,7 +1250,6 @@ impl EditToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply max_lines if set
|
||||
if let Some(max) = ctx.max_lines {
|
||||
lines.truncate(max as usize);
|
||||
}
|
||||
@@ -1304,8 +1270,7 @@ impl BlockContent for EditToolCallBlock {
|
||||
}
|
||||
|
||||
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
|
||||
// Edit blocks: use config accent color if set, otherwise no accent.
|
||||
// Note: errors no longer show red accent — they show red bullet instead.
|
||||
// Failed edits are marked by a red bullet(), not this accent.
|
||||
ctx.appearance
|
||||
.scrollback
|
||||
.blocks
|
||||
@@ -1341,7 +1306,6 @@ impl BlockContent for EditToolCallBlock {
|
||||
}
|
||||
|
||||
fn is_foldable(&self) -> bool {
|
||||
// Foldable if there are hunks to show, or an error message to reveal.
|
||||
!self.hunks.is_empty() || self.error.is_some()
|
||||
}
|
||||
|
||||
@@ -1356,7 +1320,8 @@ impl BlockContent for EditToolCallBlock {
|
||||
if self.error.is_some() {
|
||||
Some(DisplayMode::Collapsed)
|
||||
} else {
|
||||
None // keep current mode (the config-aware default, or a user fold)
|
||||
// Keep current mode (the config-aware default, or a user fold).
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1740,7 +1705,8 @@ mod tests {
|
||||
let block = EditToolCallBlock::new("src/lib.rs", vec![make_hunk()]);
|
||||
let ctx = test_ctx();
|
||||
let output = block.output(&ctx);
|
||||
assert_eq!(output.lines.len(), 6); // header + empty + 4 diff
|
||||
// header + empty + 4 diff
|
||||
assert_eq!(output.lines.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1749,11 +1715,16 @@ mod tests {
|
||||
let ctx = test_ctx();
|
||||
let output = block.output(&ctx);
|
||||
|
||||
assert_eq!(output.lines[0].background, None); // header
|
||||
assert_eq!(output.lines[2].background, None); // equal
|
||||
assert!(output.lines[3].background.is_some()); // delete
|
||||
assert!(output.lines[4].background.is_some()); // insert
|
||||
assert_eq!(output.lines[5].background, None); // equal
|
||||
// header
|
||||
assert_eq!(output.lines[0].background, None);
|
||||
// equal
|
||||
assert_eq!(output.lines[2].background, None);
|
||||
// delete
|
||||
assert!(output.lines[3].background.is_some());
|
||||
// insert
|
||||
assert!(output.lines[4].background.is_some());
|
||||
// equal
|
||||
assert_eq!(output.lines[5].background, None);
|
||||
|
||||
// Insert/delete shading is semantic, NOT a decorative panel — it must
|
||||
// survive minimal mode's flat rendering (EntryRenderer::flat_background),
|
||||
@@ -2226,8 +2197,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// --- dual_line_numbers = true snapshots ---
|
||||
|
||||
fn dual_config() -> DiffRenderConfig {
|
||||
DiffRenderConfig {
|
||||
dual_line_numbers: true,
|
||||
@@ -2402,15 +2371,12 @@ mod tests {
|
||||
line2,
|
||||
);
|
||||
|
||||
// content_text should also have expanded tabs.
|
||||
assert!(
|
||||
!outputs[1].content_text.contains('\t'),
|
||||
"content_text should also have tabs expanded",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Edit syntax-highlight harness (triple-quote spill) ──
|
||||
//
|
||||
// Asserts use **raw syntect RGB** (not ratatui FG after quantize). Under
|
||||
// `NO_COLOR` quantize maps every RGB → Reset, which would make keyword vs
|
||||
// string asserts tautological / false.
|
||||
@@ -2530,10 +2496,12 @@ class ProcessQueueItem(BaseModel):
|
||||
fn fixture_python_close_hunk() -> (String, DiffHunk, usize) {
|
||||
let (file, close_ln) = fixture_python_parts();
|
||||
let all: Vec<&str> = file.lines().collect();
|
||||
let start = close_ln - 1; // 0-based
|
||||
// 0-based
|
||||
let start = close_ln - 1;
|
||||
let mut hunk = DiffHunk::new();
|
||||
for (i, line) in all[start..].iter().enumerate() {
|
||||
let ln = start + i + 1; // 1-based
|
||||
// 1-based
|
||||
let ln = start + i + 1;
|
||||
hunk.push(DiffLine {
|
||||
text: format!("{line}\n"),
|
||||
lo: ln,
|
||||
@@ -2545,7 +2513,8 @@ class ProcessQueueItem(BaseModel):
|
||||
.iter()
|
||||
.position(|l| l.contains("notes") || l.contains("category_id"))
|
||||
.expect("field line in hunk");
|
||||
(file, hunk, start + field_offset + 1) // 1-based field ln
|
||||
// 1-based field ln
|
||||
(file, hunk, start + field_offset + 1)
|
||||
}
|
||||
|
||||
/// Fix pin: file-scoped field styles match full-file raw styles
|
||||
@@ -2692,7 +2661,8 @@ class ProcessQueueItem(BaseModel):
|
||||
let path = Path::new("probe.py");
|
||||
let file = "x = 1\ny = 2\n";
|
||||
let hunk = vec![DiffLine {
|
||||
text: "x = 999\n".into(), // differs from disk line 1
|
||||
// differs from disk line 1
|
||||
text: "x = 999\n".into(),
|
||||
lo: 1,
|
||||
ln: 1,
|
||||
tag: ChangeTag::Insert,
|
||||
|
||||
@@ -14,20 +14,17 @@ use crate::theme::Theme;
|
||||
|
||||
const EXECUTE_STDOUT_RANGE_BASE: u16 = 1;
|
||||
|
||||
/// Execute tool call - runs a shell command.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecuteToolCallBlock {
|
||||
/// Full command that was run (search / copy_meta / export source of truth).
|
||||
pub command: String,
|
||||
/// Error message if the command failed (None = success).
|
||||
pub error: Option<String>,
|
||||
/// Optional description of what the command does.
|
||||
pub description: Option<String>,
|
||||
/// The terminal output. Streamed incrementally.
|
||||
/// Streamed incrementally.
|
||||
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).
|
||||
/// Elapsed time in ms, set once the command finishes.
|
||||
pub elapsed_ms: Option<i64>,
|
||||
/// Whether this is a user-initiated bash-mode (`!`) command.
|
||||
/// Streams as a truncated live tail, expands to full output on finish.
|
||||
@@ -54,25 +51,21 @@ impl ExecuteToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set error (marks as failed).
|
||||
pub fn with_error(mut self, error: impl Into<String>) -> Self {
|
||||
self.error = Some(error.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set description.
|
||||
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = Some(desc.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set output.
|
||||
pub fn with_output(mut self, output: impl Into<String>) -> Self {
|
||||
self.output = Some(output.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Push streaming output chunk.
|
||||
pub fn push_output(&mut self, chunk: &str) {
|
||||
match &mut self.output {
|
||||
Some(o) => o.push_str(chunk),
|
||||
@@ -93,7 +86,7 @@ impl ExecuteToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
|
||||
/// Sets error; also computes elapsed time from `started_at` if not already set.
|
||||
pub fn set_error(&mut self, error: Option<String>) {
|
||||
if self.elapsed_ms.is_none()
|
||||
&& let Some(start) = self.started_at
|
||||
@@ -103,12 +96,11 @@ impl ExecuteToolCallBlock {
|
||||
self.error = error;
|
||||
}
|
||||
|
||||
/// Check if successful (no error).
|
||||
pub fn is_success(&self) -> bool {
|
||||
self.error.is_none()
|
||||
}
|
||||
|
||||
/// Get elapsed time in ms. Returns current elapsed if still running, or stored value if finished (Phase 2).
|
||||
/// Returns current elapsed if still running, or the stored value once finished.
|
||||
pub fn elapsed_ms(&self) -> Option<i64> {
|
||||
match self.elapsed_ms {
|
||||
Some(ms) => Some(ms),
|
||||
@@ -118,9 +110,7 @@ impl ExecuteToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get copyable text for this block (stdout output).
|
||||
///
|
||||
/// Returns the output string if available, or empty string if no output.
|
||||
/// Copyable text: rendered stdout output, or empty string if none.
|
||||
pub fn copy_text(&self) -> String {
|
||||
self.output
|
||||
.as_deref()
|
||||
@@ -312,7 +302,8 @@ impl ExecuteToolCallBlock {
|
||||
// multi-line command display use `push_command_soft_wrap`).
|
||||
let title_owned;
|
||||
let title = if title.trim().is_empty() {
|
||||
"\u{2026}" // …
|
||||
// "\u{2026}" is the ellipsis character "…".
|
||||
"\u{2026}"
|
||||
} else if title.contains('\n') {
|
||||
title_owned = title.replace('\n', " ");
|
||||
title_owned.as_str()
|
||||
@@ -332,7 +323,7 @@ impl ExecuteToolCallBlock {
|
||||
|
||||
/// Header lines for the execute block (description-first when a description exists).
|
||||
///
|
||||
/// Without description (unchanged):
|
||||
/// Without description:
|
||||
/// - Shell: `$ command`
|
||||
/// - Label: `Run [(user) ]command`
|
||||
///
|
||||
@@ -530,7 +521,8 @@ impl ExecuteToolCallBlock {
|
||||
width,
|
||||
extra_indent,
|
||||
false,
|
||||
true, // include $ command when expanded/truncated
|
||||
// include $ command when expanded/truncated
|
||||
true,
|
||||
);
|
||||
|
||||
if self.output.is_none()
|
||||
@@ -566,11 +558,9 @@ impl ExecuteToolCallBlock {
|
||||
word_wrap_lines_with_joiners(styled_lines, width.saturating_sub(2).max(20));
|
||||
let total = wrapped.len();
|
||||
|
||||
// Apply truncation if specified and content exceeds limits
|
||||
if let Some((first, last)) = truncate {
|
||||
let threshold = first + last;
|
||||
if total > threshold {
|
||||
// First N lines: stdout range base
|
||||
for (wrapped_line, joiner) in wrapped.iter().zip(joiners.iter()).take(first) {
|
||||
lines.push(
|
||||
BlockLine::styled(wrapped_line.clone())
|
||||
@@ -584,7 +574,7 @@ impl ExecuteToolCallBlock {
|
||||
BlockLine::separator(Line::from(Span::styled("\u{2026}", theme.muted())))
|
||||
.with_panel_background(theme.bg_dark),
|
||||
);
|
||||
// Last M lines: range base + 1 (distinct from first chunk)
|
||||
// Distinct selection range from the first chunk (range base + 1).
|
||||
for (wrapped_line, joiner) in
|
||||
wrapped.iter().zip(joiners.iter()).skip(total - last)
|
||||
{
|
||||
@@ -596,7 +586,6 @@ impl ExecuteToolCallBlock {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Content fits, show all with same range
|
||||
for (wrapped_line, joiner) in wrapped.into_iter().zip(joiners) {
|
||||
lines.push(
|
||||
BlockLine::styled(wrapped_line)
|
||||
@@ -607,7 +596,6 @@ impl ExecuteToolCallBlock {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No truncation, show all with same range
|
||||
for (wrapped_line, joiner) in wrapped.into_iter().zip(joiners) {
|
||||
lines.push(
|
||||
BlockLine::styled(wrapped_line)
|
||||
@@ -671,7 +659,8 @@ impl BlockContent for ExecuteToolCallBlock {
|
||||
content_width,
|
||||
0,
|
||||
true,
|
||||
false, // hide command when description is the title
|
||||
// hide command when description is the title
|
||||
false,
|
||||
);
|
||||
BlockOutput { lines }
|
||||
}
|
||||
@@ -918,7 +907,8 @@ mod tests {
|
||||
false,
|
||||
40,
|
||||
0,
|
||||
false, // expanded: soft-wrap
|
||||
// expanded: soft-wrap
|
||||
false,
|
||||
true,
|
||||
);
|
||||
assert!(
|
||||
@@ -969,7 +959,8 @@ mod tests {
|
||||
assert_eq!(headers.len(), 2);
|
||||
let title = line_text(&headers[0].0);
|
||||
assert_eq!(title, "Run (user) List files");
|
||||
assert_eq!(headers[0].1, 2); // "Run " + "(user) "
|
||||
// "Run " + "(user) "
|
||||
assert_eq!(headers[0].1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,8 +11,6 @@ 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 {
|
||||
@@ -63,11 +61,8 @@ impl ToolCallHookData {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
@@ -176,7 +171,6 @@ 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))
|
||||
@@ -184,7 +178,6 @@ fn render_hooks_expanded(event: &str, runs: &[HookRunEntry]) -> Vec<BlockLine> {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Header: indented, bold, muted
|
||||
lines.push(
|
||||
Line::from(vec![Span::styled(
|
||||
format!("{}{}", INDENT, event),
|
||||
@@ -193,7 +186,6 @@ fn render_hooks_expanded(event: &str, runs: &[HookRunEntry]) -> Vec<BlockLine> {
|
||||
.into(),
|
||||
);
|
||||
|
||||
// Per-hook detail lines
|
||||
lines.extend(render_hooks_expanded_inner(runs));
|
||||
|
||||
lines
|
||||
@@ -261,7 +253,6 @@ fn render_hooks_expanded_inner(runs: &[HookRunEntry]) -> Vec<BlockLine> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -279,7 +270,6 @@ fn render_hooks_expanded_inner(runs: &[HookRunEntry]) -> Vec<BlockLine> {
|
||||
lines
|
||||
}
|
||||
|
||||
/// Render hook lines for a given display mode.
|
||||
pub fn render_hooks_for_mode(
|
||||
event: &str,
|
||||
runs: &[HookRunEntry],
|
||||
@@ -308,7 +298,6 @@ pub fn render_hooks_detail(runs: &[HookRunEntry], mode: DisplayMode) -> Vec<Bloc
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a separator line (for use between tool output and hooks in expanded mode).
|
||||
pub fn render_hook_separator() -> BlockLine {
|
||||
render_separator()
|
||||
}
|
||||
@@ -11,7 +11,6 @@ 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`).
|
||||
|
||||
@@ -11,18 +11,15 @@ 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).
|
||||
/// Elapsed time in ms, set once the command finishes.
|
||||
pub elapsed_ms: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -42,24 +39,21 @@ impl ListDirToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Sets error; also computes elapsed time from `started_at` if not already set.
|
||||
pub fn set_error(&mut self, error: Option<String>) {
|
||||
if self.elapsed_ms.is_none()
|
||||
&& let Some(start) = self.started_at
|
||||
@@ -82,7 +76,7 @@ impl ListDirToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get elapsed time in ms (Phase 2).
|
||||
/// Returns current elapsed if still running, or the stored value once finished.
|
||||
pub fn elapsed_ms(&self) -> Option<i64> {
|
||||
match self.elapsed_ms {
|
||||
Some(ms) => Some(ms),
|
||||
@@ -92,7 +86,6 @@ impl ListDirToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set output (mutable).
|
||||
pub fn set_output(&mut self, output: impl Into<String>) {
|
||||
self.output = output.into();
|
||||
}
|
||||
@@ -164,7 +157,6 @@ impl BlockContent for ListDirToolCallBlock {
|
||||
&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();
|
||||
@@ -181,7 +173,8 @@ impl BlockContent for ListDirToolCallBlock {
|
||||
}
|
||||
|
||||
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
|
||||
None // ListDir blocks never have an accent line
|
||||
// ListDir blocks never have an accent line.
|
||||
None
|
||||
}
|
||||
|
||||
fn bullet(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
|
||||
@@ -206,7 +199,6 @@ impl BlockContent for ListDirToolCallBlock {
|
||||
}
|
||||
|
||||
fn is_foldable(&self) -> bool {
|
||||
// Not foldable if failed
|
||||
if self.error.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -317,7 +317,6 @@ fn shorten_path(path: &str) -> &str {
|
||||
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()) {
|
||||
@@ -365,7 +364,6 @@ pub fn parse_memory_results(output: &str) -> Vec<MemoryResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract snippet between ``` markers
|
||||
let full = section;
|
||||
if let Some(code_start) = full.find("```\n") {
|
||||
let after_start = &full[code_start + 4..];
|
||||
|
||||
@@ -58,12 +58,11 @@ pub struct LineRange {
|
||||
}
|
||||
|
||||
impl LineRange {
|
||||
/// Create a new line range.
|
||||
pub fn new(start: usize, end: usize) -> Self {
|
||||
Self { start, end }
|
||||
}
|
||||
|
||||
/// Format as "(start:end)" for display.
|
||||
/// Format as "start:end" for display.
|
||||
pub fn display(&self) -> String {
|
||||
format!("{}:{}", self.start, self.end)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ pub struct OtherToolCallBlock {
|
||||
pub error: Option<String>,
|
||||
/// Optional output.
|
||||
pub output: Option<String>,
|
||||
/// When the tool started running (Phase 2: time tracking).
|
||||
/// When the tool started running.
|
||||
pub started_at: Option<std::time::Instant>,
|
||||
/// Elapsed time in ms after completion (Phase 2: time tracking).
|
||||
/// Elapsed time in ms after completion.
|
||||
pub elapsed_ms: Option<i64>,
|
||||
/// Image references detected in the tool output.
|
||||
image_refs: Vec<crate::prompt_images::ScrollbackImageRef>,
|
||||
@@ -100,7 +100,7 @@ impl OtherToolCallBlock {
|
||||
None
|
||||
}
|
||||
|
||||
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
|
||||
/// Set error (mutable) — compute elapsed time if not already set.
|
||||
pub fn set_error(&mut self, error: Option<String>) {
|
||||
if self.elapsed_ms.is_none()
|
||||
&& let Some(start) = self.started_at
|
||||
@@ -123,7 +123,7 @@ impl OtherToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get elapsed time in ms (Phase 2).
|
||||
/// Get elapsed time in ms.
|
||||
pub fn elapsed_ms(&self) -> Option<i64> {
|
||||
match self.elapsed_ms {
|
||||
Some(ms) => Some(ms),
|
||||
@@ -327,9 +327,9 @@ impl BlockContent for OtherToolCallBlock {
|
||||
let theme = Theme::current();
|
||||
Some(AccentStyle::static_color(theme.accent_error))
|
||||
} else if ctx.mode == DisplayMode::Collapsed {
|
||||
None // default gray
|
||||
None
|
||||
} else {
|
||||
self.accent(ctx) // inherit from accent when expanded/running
|
||||
self.accent(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +346,6 @@ impl BlockContent for OtherToolCallBlock {
|
||||
}
|
||||
|
||||
fn is_foldable(&self) -> bool {
|
||||
// Not foldable if failed
|
||||
if self.error.is_some() {
|
||||
return false;
|
||||
}
|
||||
@@ -426,8 +425,6 @@ impl BlockContent for OtherToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
// ── AskUserQuestion output parser ────────────────────────────────────
|
||||
|
||||
/// Parse Q&A pairs from an AskUserQuestion tool result string.
|
||||
///
|
||||
/// Recognizes all three accepted output formats:
|
||||
@@ -441,7 +438,6 @@ impl BlockContent for OtherToolCallBlock {
|
||||
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);
|
||||
@@ -450,7 +446,6 @@ fn parse_ask_user_qa_pairs(output: &str) -> Vec<(String, String)> {
|
||||
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;
|
||||
@@ -460,16 +455,17 @@ fn parse_ask_user_qa_pairs(output: &str) -> Vec<(String, String)> {
|
||||
if !remaining.starts_with('"') {
|
||||
break;
|
||||
}
|
||||
remaining = &remaining[1..]; // skip opening "
|
||||
// Skip opening quote.
|
||||
remaining = &remaining[1..];
|
||||
|
||||
// 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 "="
|
||||
// Skip `"="`.
|
||||
remaining = &remaining[q_end + 3..];
|
||||
|
||||
// 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());
|
||||
|
||||
@@ -500,9 +496,9 @@ fn parse_ask_user_qa_pairs(output: &str) -> Vec<(String, String)> {
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// Path D: cancelled
|
||||
// Path D: cancelled — no Q&A to show.
|
||||
if output.starts_with("User declined to answer") {
|
||||
return vec![]; // No Q&A to show
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Paths B/C: plan mode — bullet format
|
||||
|
||||
@@ -29,18 +29,16 @@ pub enum ReadMediaKind {
|
||||
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).
|
||||
/// When the tool started running.
|
||||
pub started_at: Option<std::time::Instant>,
|
||||
/// Elapsed time in ms after completion (Phase 2: time tracking).
|
||||
/// Elapsed time in ms after completion.
|
||||
pub elapsed_ms: Option<i64>,
|
||||
/// Raw file content (unformatted). `None` for errors, images, PDFs.
|
||||
pub content: Option<String>,
|
||||
@@ -53,8 +51,6 @@ pub struct ReadToolCallBlock {
|
||||
}
|
||||
|
||||
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`).
|
||||
@@ -72,26 +68,22 @@ impl ReadToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
@@ -112,7 +104,7 @@ impl ReadToolCallBlock {
|
||||
self.skill_name().is_some()
|
||||
}
|
||||
|
||||
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
|
||||
/// Compute elapsed time if not already set, then store the error.
|
||||
pub fn set_error(&mut self, error: Option<String>) {
|
||||
if self.elapsed_ms.is_none()
|
||||
&& let Some(start) = self.started_at
|
||||
@@ -135,7 +127,6 @@ impl ReadToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get elapsed time in ms (Phase 2).
|
||||
pub fn elapsed_ms(&self) -> Option<i64> {
|
||||
match self.elapsed_ms {
|
||||
Some(ms) => Some(ms),
|
||||
|
||||
@@ -70,10 +70,8 @@ pub struct SearchInputMeta {
|
||||
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,
|
||||
@@ -86,15 +84,13 @@ pub struct SearchToolCallBlock {
|
||||
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).
|
||||
/// When the tool started running.
|
||||
pub started_at: Option<std::time::Instant>,
|
||||
/// Elapsed time in ms after completion (Phase 2: time tracking).
|
||||
/// Elapsed time in ms after completion.
|
||||
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`).
|
||||
@@ -111,25 +107,22 @@ impl SearchToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Compute elapsed time if not already set, then store the error.
|
||||
pub fn set_error(&mut self, error: Option<String>) {
|
||||
if self.elapsed_ms.is_none()
|
||||
&& let Some(start) = self.started_at
|
||||
@@ -152,7 +145,6 @@ impl SearchToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get elapsed time in ms (Phase 2).
|
||||
pub fn elapsed_ms(&self) -> Option<i64> {
|
||||
match self.elapsed_ms {
|
||||
Some(ms) => Some(ms),
|
||||
@@ -162,7 +154,6 @@ impl SearchToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -192,7 +183,8 @@ impl SearchToolCallBlock {
|
||||
}
|
||||
}
|
||||
SearchOutputMode::FilesWithMatches => {
|
||||
let n = self.match_count; // match_count = # of files in this mode
|
||||
// In this mode, match_count holds the number of files, not matches.
|
||||
let n = self.match_count;
|
||||
if n == 1 {
|
||||
"(1 file)".to_string()
|
||||
} else {
|
||||
@@ -257,17 +249,13 @@ impl SearchToolCallBlock {
|
||||
|
||||
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));
|
||||
@@ -428,10 +416,8 @@ impl BlockContent for SearchToolCallBlock {
|
||||
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()))
|
||||
@@ -448,11 +434,9 @@ impl BlockContent for SearchToolCallBlock {
|
||||
|
||||
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),
|
||||
@@ -488,7 +472,7 @@ impl BlockContent for SearchToolCallBlock {
|
||||
// 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 ':'
|
||||
let count_part = &path[colon_pos..];
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{indent}{file_part}"),
|
||||
@@ -519,7 +503,7 @@ impl BlockContent for SearchToolCallBlock {
|
||||
}
|
||||
|
||||
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
|
||||
None // Search blocks never have an accent line
|
||||
None
|
||||
}
|
||||
|
||||
fn bullet(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
|
||||
|
||||
@@ -24,17 +24,13 @@ pub struct DiscoveredTool {
|
||||
/// 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>,
|
||||
|
||||
@@ -23,7 +23,6 @@ pub struct UseToolCallBlock {
|
||||
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>,
|
||||
|
||||
@@ -11,27 +11,22 @@ use crate::scrollback::types::{
|
||||
};
|
||||
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>,
|
||||
@@ -232,7 +227,6 @@ impl BlockContent for WebFetchToolCallBlock {
|
||||
})
|
||||
.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));
|
||||
|
||||
@@ -13,7 +13,6 @@ use crate::scrollback::types::{
|
||||
};
|
||||
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.
|
||||
@@ -22,15 +21,12 @@ 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>,
|
||||
|
||||
@@ -80,7 +80,6 @@ fn token_styled_line(
|
||||
/// Block displaying a user's prompt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserPromptBlock {
|
||||
/// The user's input text.
|
||||
pub text: String,
|
||||
/// Whether this was a bash command (! prefix).
|
||||
pub is_bash: bool,
|
||||
@@ -136,7 +135,8 @@ impl UserPromptBlock {
|
||||
let token_end = first_line
|
||||
.find(char::is_whitespace)
|
||||
.unwrap_or(first_line.len());
|
||||
#[allow(clippy::single_range_in_vec_init)] // field is multi-range capable
|
||||
// field is multi-range capable
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
let skill_token_ranges = if token_end > 0 {
|
||||
vec![0..token_end]
|
||||
} else {
|
||||
@@ -279,7 +279,6 @@ impl UserPromptBlock {
|
||||
let ellipsis = " \u{2026}";
|
||||
let ellipsis_width = 2;
|
||||
|
||||
// Available width for text content (after prefix/indent)
|
||||
let base_content_width = (width as usize).saturating_sub(prefix_width).max(1);
|
||||
|
||||
let mut all_lines: Vec<BlockLine> = Vec::new();
|
||||
@@ -288,7 +287,6 @@ impl UserPromptBlock {
|
||||
|
||||
for (logical_idx, line_text) in logical_lines.iter().enumerate() {
|
||||
if line_text.is_empty() {
|
||||
// Empty line - just show prefix/indent
|
||||
let indent = " ".repeat(prefix_width);
|
||||
let line = if logical_idx == 0 {
|
||||
Line::from(vec![Span::styled(prefix.to_string(), prefix_style)])
|
||||
@@ -296,8 +294,9 @@ impl UserPromptBlock {
|
||||
Line::from(vec![Span::styled(indent, prefix_style)])
|
||||
};
|
||||
let block_line = with_band(BlockLine {
|
||||
// empty content range past prefix
|
||||
selectable: if has_visible_prefix {
|
||||
Selectable::Spans(1..1) // empty content range past prefix
|
||||
Selectable::Spans(1..1)
|
||||
} else {
|
||||
Selectable::All
|
||||
},
|
||||
@@ -311,7 +310,6 @@ impl UserPromptBlock {
|
||||
if let Some(max) = max_lines
|
||||
&& all_lines.len() >= max
|
||||
{
|
||||
// There's more content if we haven't processed all logical lines
|
||||
let has_more = logical_idx + 1 < total_logical;
|
||||
if has_more {
|
||||
// Add ellipsis to last line
|
||||
@@ -326,7 +324,6 @@ impl UserPromptBlock {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wrap this line's content.
|
||||
let content_line = if self.skill_token_ranges.is_empty() {
|
||||
Line::from(Span::styled(line_text.to_string(), text_style))
|
||||
} else {
|
||||
@@ -359,10 +356,8 @@ impl UserPromptBlock {
|
||||
let indent: String = " ".repeat(prefix_width);
|
||||
let line_prefix = if is_first_line { prefix } else { &indent };
|
||||
|
||||
// Check if this will be the last allowed line
|
||||
let will_be_last = max_lines.is_some_and(|max| all_lines.len() + 1 == max);
|
||||
|
||||
// Check if there's more content after this line
|
||||
let has_more_wrapped = wrap_idx + 1 < wrapped_count;
|
||||
let has_more_logical = logical_idx + 1 < total_logical;
|
||||
let has_more = has_more_wrapped || has_more_logical;
|
||||
@@ -389,7 +384,6 @@ impl UserPromptBlock {
|
||||
.next()
|
||||
.unwrap_or_else(Line::default);
|
||||
|
||||
// Build final line with prefix, content, and ellipsis
|
||||
let mut spans = vec![Span::styled(line_prefix.to_string(), prefix_style)];
|
||||
spans.extend(final_content.spans.into_iter().map(|s| Span {
|
||||
content: s.content.to_string().into(),
|
||||
@@ -398,7 +392,8 @@ impl UserPromptBlock {
|
||||
spans.push(Span::styled(ellipsis.to_string(), text_style));
|
||||
|
||||
let content_start = if has_visible_prefix { 1 } else { 0 };
|
||||
let content_end = spans.len() - 1; // exclude ellipsis
|
||||
// exclude ellipsis
|
||||
let content_end = spans.len() - 1;
|
||||
let block_line = with_band(BlockLine {
|
||||
content: Line::from(spans),
|
||||
selectable: if content_start < content_end {
|
||||
@@ -414,7 +409,6 @@ impl UserPromptBlock {
|
||||
return all_lines;
|
||||
}
|
||||
|
||||
// Normal line (not truncated)
|
||||
let mut spans = vec![Span::styled(line_prefix.to_string(), prefix_style)];
|
||||
spans.extend(wrapped_line.spans.into_iter().map(|s| Span {
|
||||
content: s.content.to_string().into(),
|
||||
@@ -443,7 +437,6 @@ impl UserPromptBlock {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty text
|
||||
if all_lines.is_empty() {
|
||||
all_lines.push(with_band(BlockLine {
|
||||
content: Line::from(Span::styled(prefix.trim().to_string(), prefix_style)),
|
||||
@@ -481,7 +474,8 @@ impl BlockContent for UserPromptBlock {
|
||||
}
|
||||
|
||||
fn accent_background(&self, _ctx: &BlockContext) -> bool {
|
||||
true // fill accent column with block bg so it matches content
|
||||
// fill accent column with block bg so it matches content
|
||||
true
|
||||
}
|
||||
|
||||
fn background(&self, ctx: &BlockContext) -> BlockBackground {
|
||||
@@ -595,7 +589,8 @@ mod tests {
|
||||
fn test_ellipsis_fits_within_width() {
|
||||
// Create a prompt that wraps to exactly fill lines
|
||||
let block = UserPromptBlock::new("aaaa bbbb cccc dddd eeee ffff");
|
||||
let width = 15; // Narrow width to force wrapping
|
||||
// Narrow width to force wrapping
|
||||
let width = 15;
|
||||
let lines = block.wrap_prompt_lines(width, Some(2), true, false);
|
||||
|
||||
assert_eq!(lines.len(), 2);
|
||||
@@ -672,8 +667,6 @@ mod tests {
|
||||
assert_eq!(line1[1].style.fg, Some(theme.text_primary));
|
||||
}
|
||||
|
||||
// --- Mid-text skill token styling (with_skill_tokens) ---
|
||||
|
||||
#[test]
|
||||
fn mid_text_token_only_token_is_teal() {
|
||||
let text = "great /pr-workflow all good now";
|
||||
@@ -734,15 +727,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn invalid_token_ranges_are_dropped() {
|
||||
let text = "héllo /model now"; // 'é' is 2 bytes: "/model" = 7..13
|
||||
// 'é' is 2 bytes: "/model" = 7..13
|
||||
let text = "héllo /model now";
|
||||
let block = UserPromptBlock::with_skill_tokens(
|
||||
text,
|
||||
vec![
|
||||
2..3, // not a char boundary (inside 'é')
|
||||
40..50, // out of bounds
|
||||
9..9, // empty
|
||||
7..13, // valid token
|
||||
10..15, // overlaps the kept 7..13
|
||||
// not a char boundary (inside 'é')
|
||||
2..3,
|
||||
// out of bounds
|
||||
40..50,
|
||||
// empty
|
||||
9..9,
|
||||
// valid token
|
||||
7..13,
|
||||
// overlaps the kept 7..13
|
||||
10..15,
|
||||
],
|
||||
);
|
||||
assert_eq!(block.skill_token_ranges, vec![7..13]);
|
||||
@@ -769,8 +768,6 @@ mod tests {
|
||||
assert_eq!(lines[0].content.spans[1].style.fg, Some(theme.text_primary));
|
||||
}
|
||||
|
||||
// --- Token styling across soft-wrap and collapsed truncation ---
|
||||
|
||||
/// Concatenated content of a line's skill-accent spans.
|
||||
fn teal_text(line: &Line, theme: &Theme) -> String {
|
||||
line.spans
|
||||
@@ -851,7 +848,8 @@ mod tests {
|
||||
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(line_text(&lines[0].content).starts_with(crate::glyphs::prompt_arrow()));
|
||||
assert!(line_text(&lines[1].content).starts_with(" ")); // continuation indent
|
||||
// continuation indent
|
||||
assert!(line_text(&lines[1].content).starts_with(" "));
|
||||
assert!(line_text(&lines[2].content).starts_with(" "));
|
||||
}
|
||||
|
||||
@@ -921,8 +919,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Selection metadata tests ---
|
||||
|
||||
#[test]
|
||||
fn test_prompt_lines_have_selection_range() {
|
||||
let block = UserPromptBlock::new("hello");
|
||||
@@ -1020,8 +1016,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fold behavior tests ---
|
||||
|
||||
#[test]
|
||||
fn test_short_prompt_not_foldable() {
|
||||
let block = UserPromptBlock::new("hello");
|
||||
@@ -1045,8 +1039,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_long_single_line_is_foldable() {
|
||||
// A single line long enough to wrap past 3 visual lines at 60-char width
|
||||
let long_line = "a ".repeat(120); // 240 chars → 4 visual lines at 60
|
||||
// A single line long enough to wrap past 3 visual lines at 60-char width:
|
||||
// 240 chars → 4 visual lines at 60
|
||||
let long_line = "a ".repeat(120);
|
||||
let block = UserPromptBlock::new(long_line);
|
||||
assert!(block.is_foldable());
|
||||
assert_eq!(block.default_display_mode(), DisplayMode::Collapsed);
|
||||
@@ -1054,8 +1049,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_short_single_line_not_foldable() {
|
||||
// A single line that fits in 3 visual lines at 60-char width
|
||||
let short_line = "a ".repeat(60); // 120 chars → 2 visual lines at 60
|
||||
// A single line that fits in 3 visual lines at 60-char width:
|
||||
// 120 chars → 2 visual lines at 60
|
||||
let short_line = "a ".repeat(60);
|
||||
let block = UserPromptBlock::new(short_line);
|
||||
assert!(!block.is_foldable());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user