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).
1056 lines
41 KiB
Rust
1056 lines
41 KiB
Rust
//! ExecuteToolCallBlock - runs shell commands with streaming output.
|
|
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span, Text};
|
|
|
|
use super::TOOL_HEADER_RANGE;
|
|
use crate::appearance::ExecuteHeaderStyle;
|
|
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::theme::Theme;
|
|
|
|
const EXECUTE_STDOUT_RANGE_BASE: u16 = 1;
|
|
|
|
#[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>,
|
|
pub description: Option<String>,
|
|
/// Streamed incrementally.
|
|
pub output: Option<String>,
|
|
pub started_at: Option<std::time::Instant>,
|
|
/// 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.
|
|
pub bash_mode: bool,
|
|
/// Peeled display form for the header; `command` stays the full source of truth.
|
|
pub header_display: Option<String>,
|
|
}
|
|
impl ExecuteToolCallBlock {
|
|
/// Create a new execute block.
|
|
///
|
|
/// `started_at` defaults to `None`. For streaming blocks, timing begins
|
|
/// when the block enters running UI state (via `start_timing()`).
|
|
/// Pre-completed blocks never get timing — they show `"—"`.
|
|
pub fn new(command: impl Into<String>) -> Self {
|
|
Self {
|
|
command: command.into(),
|
|
error: None,
|
|
description: None,
|
|
output: None,
|
|
started_at: None,
|
|
elapsed_ms: None,
|
|
bash_mode: false,
|
|
header_display: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_error(mut self, error: impl Into<String>) -> Self {
|
|
self.error = Some(error.into());
|
|
self
|
|
}
|
|
|
|
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
|
|
self.description = Some(desc.into());
|
|
self
|
|
}
|
|
|
|
pub fn with_output(mut self, output: impl Into<String>) -> Self {
|
|
self.output = Some(output.into());
|
|
self
|
|
}
|
|
|
|
pub fn push_output(&mut self, chunk: &str) {
|
|
match &mut self.output {
|
|
Some(o) => o.push_str(chunk),
|
|
None => self.output = Some(chunk.to_string()),
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
{
|
|
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
|
|
}
|
|
self.error = error;
|
|
}
|
|
|
|
pub fn is_success(&self) -> bool {
|
|
self.error.is_none()
|
|
}
|
|
|
|
/// 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),
|
|
None => self
|
|
.started_at
|
|
.map(|start| start.elapsed().as_millis() as i64),
|
|
}
|
|
}
|
|
|
|
/// Copyable text: rendered stdout output, or empty string if none.
|
|
pub fn copy_text(&self) -> String {
|
|
self.output
|
|
.as_deref()
|
|
.map(crate::render::terminal_output::render_terminal_plain)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Display form of the command for the header (may peel `cd &&` prefix).
|
|
///
|
|
/// Preserves physical newlines so soft-wrap / copy can keep line structure.
|
|
/// Callers that need a single ratatui line must flatten themselves.
|
|
fn command_display(&self) -> &str {
|
|
self.header_display.as_deref().unwrap_or(&self.command)
|
|
}
|
|
|
|
/// Non-empty description for the header title, if the model supplied one.
|
|
///
|
|
/// When `strip_run_prefix` is true (Label style already has a bold `Run `
|
|
/// prefix), a leading `Run` / `Running` on the description is dropped so
|
|
/// we never render `Run Run the tests`.
|
|
fn description_display(&self, strip_run_prefix: bool) -> Option<String> {
|
|
self.description.as_ref().and_then(|d| {
|
|
let trimmed = d.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
// Collapse newlines so the title stays one logical line
|
|
// (`wrap_header_hanging` may still soft-wrap for width).
|
|
let mut text = trimmed.replace('\n', " ");
|
|
if strip_run_prefix {
|
|
text = strip_leading_run_word(&text);
|
|
if text.is_empty() {
|
|
return None;
|
|
}
|
|
}
|
|
Some(text)
|
|
})
|
|
}
|
|
|
|
/// `$ command` line (shell prompt style). Sole header when there is no
|
|
/// description; secondary line when there is one.
|
|
///
|
|
/// Flattens newlines for single-line layouts (collapsed truncate). Expanded
|
|
/// rendering uses soft-wrap via [`Self::push_shell_command_soft_wrap`].
|
|
fn shell_command_line(&self, theme: &Theme, muted_command: bool) -> Line<'static> {
|
|
let command = self.command_display().replace('\n', " ");
|
|
let command = if command.trim().is_empty() {
|
|
"\u{2026}".to_string()
|
|
} else {
|
|
command
|
|
};
|
|
let command_spans: Vec<Span<'static>> = if muted_command || command == "\u{2026}" {
|
|
vec![Span::styled(command, theme.muted())]
|
|
} else {
|
|
crate::views::tasks_pane::highlight_bash_command(&command)
|
|
};
|
|
// `$` uses gray_dim — dimmer than muted, no bold
|
|
let mut spans = vec![Span::styled("$ ", theme.dim())];
|
|
spans.extend(command_spans);
|
|
Line::from(spans)
|
|
}
|
|
|
|
/// Prefix spans for a soft-wrapped command header (`$ ` or `Run [(user) ]`).
|
|
///
|
|
/// Returns `(prefix_spans, hang_width)` — hang is the display width of the
|
|
/// first-row prefix so continuations indent under the command body.
|
|
fn command_header_prefix(
|
|
&self,
|
|
theme: &Theme,
|
|
header_style: ExecuteHeaderStyle,
|
|
muted_command: bool,
|
|
) -> (Vec<Span<'static>>, usize) {
|
|
use unicode_width::UnicodeWidthStr;
|
|
match header_style {
|
|
ExecuteHeaderStyle::Shell => {
|
|
let prefix = "$ ";
|
|
(
|
|
vec![Span::styled(prefix.to_string(), theme.dim())],
|
|
UnicodeWidthStr::width(prefix),
|
|
)
|
|
}
|
|
ExecuteHeaderStyle::Label => {
|
|
let label_style = if muted_command {
|
|
theme.muted().add_modifier(Modifier::BOLD)
|
|
} else {
|
|
theme.primary().add_modifier(Modifier::BOLD)
|
|
};
|
|
let mut spans = vec![Span::styled("Run ".to_string(), label_style)];
|
|
let mut hang = UnicodeWidthStr::width("Run ");
|
|
if self.bash_mode {
|
|
spans.push(Span::styled("(user) ".to_string(), theme.muted()));
|
|
hang += UnicodeWidthStr::width("(user) ");
|
|
}
|
|
(spans, hang)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Multi-line command header using permission-panel soft-wrap (operators + quotes).
|
|
///
|
|
/// Used for both Shell (`$ command`) and Label (`Run [(user) ]command`) so
|
|
/// physical newlines / `\` continuations match the permission overlay.
|
|
fn push_command_soft_wrap(
|
|
&self,
|
|
lines: &mut Vec<BlockLine>,
|
|
theme: &Theme,
|
|
header_style: ExecuteHeaderStyle,
|
|
muted_command: bool,
|
|
width: usize,
|
|
extra_indent: usize,
|
|
) {
|
|
let command = self.command_display();
|
|
let command = if command.trim().is_empty() {
|
|
"\u{2026}"
|
|
} else {
|
|
command
|
|
};
|
|
let (prefix_spans, prefix_w) =
|
|
self.command_header_prefix(theme, header_style, muted_command);
|
|
let prefix_span_count = prefix_spans.len();
|
|
let hang = extra_indent.saturating_add(prefix_w);
|
|
let cmd_width = width.saturating_sub(hang).max(1);
|
|
|
|
let cmd_rows: Vec<Line<'static>> = if muted_command || command == "\u{2026}" {
|
|
let style = theme.muted();
|
|
let flat = command.replace('\n', " ");
|
|
let line = Line::from(Span::styled(flat, style));
|
|
crate::render::wrapping::word_wrap_lines(std::iter::once(line), cmd_width)
|
|
} else {
|
|
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(BlockLine {
|
|
selectable: Selectable::None,
|
|
selection_range: Some(TOOL_HEADER_RANGE),
|
|
content: Line::from(prefix_spans),
|
|
..Default::default()
|
|
});
|
|
return;
|
|
}
|
|
for (i, row) in cmd_rows.into_iter().enumerate() {
|
|
let mut spans = if i == 0 {
|
|
prefix_spans.clone()
|
|
} else {
|
|
vec![Span::raw(hang_indent.clone())]
|
|
};
|
|
spans.extend(row.spans);
|
|
let total = spans.len();
|
|
let select_start = if i == 0 {
|
|
prefix_span_count.min(total)
|
|
} else {
|
|
0
|
|
};
|
|
lines.push(BlockLine {
|
|
selectable: Selectable::Spans(select_start..total),
|
|
selection_range: Some(TOOL_HEADER_RANGE),
|
|
// Preserve physical/soft-wrap structure as newlines when copying.
|
|
joiner: if i == 0 { None } else { Some("\n".to_string()) },
|
|
content: Line::from(spans),
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Primary title line for Label style: `Run [(user) ]<description|command>`.
|
|
///
|
|
/// When `title` is empty (eager placeholder before `raw_input.command`
|
|
/// arrives), renders `Run …` so we never flash an internal tool id.
|
|
fn label_title_line(
|
|
&self,
|
|
theme: &Theme,
|
|
muted_command: bool,
|
|
title: &str,
|
|
highlight_as_command: bool,
|
|
) -> Line<'static> {
|
|
let label_style = if muted_command {
|
|
theme.muted().add_modifier(Modifier::BOLD)
|
|
} else {
|
|
theme.primary().add_modifier(Modifier::BOLD)
|
|
};
|
|
let mut spans = vec![Span::styled("Run ", label_style)];
|
|
if self.bash_mode {
|
|
// Same style as session event messages (e.g. "Worked for 2.3s.")
|
|
spans.push(Span::styled("(user) ", theme.muted()));
|
|
}
|
|
// Single ratatui Line — never pass raw newlines (callers that need
|
|
// multi-line command display use `push_command_soft_wrap`).
|
|
let title_owned;
|
|
let title = if title.trim().is_empty() {
|
|
// "\u{2026}" is the ellipsis character "…".
|
|
"\u{2026}"
|
|
} else if title.contains('\n') {
|
|
title_owned = title.replace('\n', " ");
|
|
title_owned.as_str()
|
|
} else {
|
|
title
|
|
};
|
|
if highlight_as_command && !muted_command && title != "\u{2026}" {
|
|
spans.extend(crate::views::tasks_pane::highlight_bash_command(title));
|
|
} else if muted_command {
|
|
spans.push(Span::styled(title.to_string(), theme.muted()));
|
|
} else {
|
|
// Description (or loading ellipsis): plain primary text.
|
|
spans.push(Span::styled(title.to_string(), theme.primary()));
|
|
}
|
|
Line::from(spans)
|
|
}
|
|
|
|
/// Header lines for the execute block (description-first when a description exists).
|
|
///
|
|
/// Without description:
|
|
/// - Shell: `$ command`
|
|
/// - Label: `Run [(user) ]command`
|
|
///
|
|
/// With description:
|
|
/// - Shell: description title; optionally `$ command` on the next line
|
|
/// - Label: `Run [(user) ]description`; optionally `$ command` on the next line
|
|
///
|
|
/// Collapsed mode passes `include_command = false` so only the description
|
|
/// title is shown (density). Expanded/truncated include the command line.
|
|
///
|
|
/// When `muted` is true, command/description body uses muted gray (collapsed).
|
|
/// Uses precomputed `header_display` when set; `self.command` stays full.
|
|
///
|
|
/// Returns `(line, prefix_span_count)` — prefix spans are not selectable
|
|
/// (`"Run "` / `"(user) "` / `"$ "`).
|
|
fn header_lines(
|
|
&self,
|
|
theme: &Theme,
|
|
header_style: ExecuteHeaderStyle,
|
|
muted_command: bool,
|
|
include_command: bool,
|
|
) -> Vec<(Line<'static>, usize)> {
|
|
let strip_run = matches!(header_style, ExecuteHeaderStyle::Label);
|
|
match self.description_display(strip_run) {
|
|
Some(desc) => {
|
|
let title = match header_style {
|
|
ExecuteHeaderStyle::Label => {
|
|
let prefix_spans = if self.bash_mode { 2 } else { 1 };
|
|
(
|
|
self.label_title_line(theme, muted_command, &desc, false),
|
|
prefix_spans,
|
|
)
|
|
}
|
|
ExecuteHeaderStyle::Shell => {
|
|
// Description as plain title (no `$`); command follows when included.
|
|
let style = if muted_command {
|
|
theme.muted()
|
|
} else {
|
|
theme.primary()
|
|
};
|
|
(Line::from(Span::styled(desc, style)), 0)
|
|
}
|
|
};
|
|
if include_command {
|
|
let cmd = (self.shell_command_line(theme, muted_command), 1);
|
|
vec![title, cmd]
|
|
} else {
|
|
vec![title]
|
|
}
|
|
}
|
|
None => {
|
|
// No description: single-line header (callers that soft-wrap the
|
|
// command as title use `push_command_soft_wrap` instead).
|
|
// Flatten newlines — raw `\n` is invalid inside one ratatui Line.
|
|
let line = match header_style {
|
|
ExecuteHeaderStyle::Shell => self.shell_command_line(theme, muted_command),
|
|
ExecuteHeaderStyle::Label => {
|
|
let flat = self.command_display().replace('\n', " ");
|
|
self.label_title_line(theme, muted_command, &flat, true)
|
|
}
|
|
};
|
|
let prefix_spans = match header_style {
|
|
ExecuteHeaderStyle::Shell => 1,
|
|
ExecuteHeaderStyle::Label => {
|
|
if self.bash_mode {
|
|
2
|
|
} else {
|
|
1
|
|
}
|
|
}
|
|
};
|
|
vec![(line, prefix_spans)]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Push header `BlockLine`s, selecting non-prefix spans.
|
|
///
|
|
/// When `truncate_to_width` is true (collapsed one-line budget), each
|
|
/// logical header line is hard-truncated. Otherwise soft-wraps: **command**
|
|
/// lines (Shell `$ …` and Label `Run …` when the command is the title, or
|
|
/// the secondary `$ command` under a description) use permission-panel bash
|
|
/// soft-wrap; description titles use hanging word-wrap.
|
|
/// `include_command` is false in collapsed mode so a description title
|
|
/// alone is shown without the command line.
|
|
///
|
|
/// `width` must already be the block content width (bullet accounted for).
|
|
/// Do not also pass bullet width as `extra_indent` — hang is only for the
|
|
/// `$` / `Run` prefix.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn push_header_lines(
|
|
&self,
|
|
lines: &mut Vec<BlockLine>,
|
|
theme: &Theme,
|
|
header_style: ExecuteHeaderStyle,
|
|
muted_command: bool,
|
|
width: usize,
|
|
extra_indent: usize,
|
|
truncate_to_width: bool,
|
|
include_command: bool,
|
|
) {
|
|
let strip_run = matches!(header_style, ExecuteHeaderStyle::Label);
|
|
// Expanded/truncated: command-as-title soft-wrap (Shell + Label).
|
|
if !truncate_to_width && include_command && self.description_display(strip_run).is_none() {
|
|
self.push_command_soft_wrap(
|
|
lines,
|
|
theme,
|
|
header_style,
|
|
muted_command,
|
|
width,
|
|
extra_indent,
|
|
);
|
|
return;
|
|
}
|
|
// Description title (word-wrap) then soft-wrapped `$ command` (both styles).
|
|
if !truncate_to_width && include_command && self.description_display(strip_run).is_some() {
|
|
let headers = self.header_lines(theme, header_style, muted_command, false);
|
|
for (line, prefix_spans) in headers {
|
|
let wrapped =
|
|
crate::render::wrapping::wrap_header_hanging(line, width, extra_indent);
|
|
for (i, wrapped_line) in wrapped.into_iter().enumerate() {
|
|
let total = wrapped_line.spans.len();
|
|
let start = if i == 0 { prefix_spans.min(total) } else { 0 };
|
|
lines.push(BlockLine {
|
|
selectable: Selectable::Spans(start..total),
|
|
selection_range: Some(TOOL_HEADER_RANGE),
|
|
joiner: if i == 0 { None } else { Some(" ".to_string()) },
|
|
content: wrapped_line,
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
// Secondary command line is always shell-style (`$ …`).
|
|
self.push_command_soft_wrap(
|
|
lines,
|
|
theme,
|
|
ExecuteHeaderStyle::Shell,
|
|
muted_command,
|
|
width,
|
|
extra_indent,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let headers = self.header_lines(theme, header_style, muted_command, include_command);
|
|
for (line, prefix_spans) in headers {
|
|
if truncate_to_width {
|
|
let line = crate::render::line_utils::truncate_line(line, width);
|
|
let total = line.spans.len();
|
|
let start = prefix_spans.min(total);
|
|
lines.push(BlockLine {
|
|
selectable: Selectable::Spans(start..total),
|
|
selection_range: Some(TOOL_HEADER_RANGE),
|
|
content: line,
|
|
..Default::default()
|
|
});
|
|
} else {
|
|
let wrapped =
|
|
crate::render::wrapping::wrap_header_hanging(line, width, extra_indent);
|
|
for (i, wrapped_line) in wrapped.into_iter().enumerate() {
|
|
let total = wrapped_line.spans.len();
|
|
// Only the first wrapped segment of each logical header line
|
|
// has the prefix (`Run ` / `$ `); continuations are fully selectable.
|
|
let start = if i == 0 { prefix_spans.min(total) } else { 0 };
|
|
lines.push(BlockLine {
|
|
selectable: Selectable::Spans(start..total),
|
|
selection_range: Some(TOOL_HEADER_RANGE),
|
|
joiner: if i == 0 { None } else { Some(" ".to_string()) },
|
|
content: wrapped_line,
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render with optional truncation.
|
|
///
|
|
/// If `truncate` is Some((first, last)), shows first N lines, ellipsis, last M lines.
|
|
/// If `truncate` is None, shows all output lines.
|
|
fn render_with_truncation(
|
|
&self,
|
|
theme: &Theme,
|
|
width: usize,
|
|
truncate: Option<(usize, usize)>,
|
|
header_style: ExecuteHeaderStyle,
|
|
extra_indent: usize,
|
|
) -> BlockOutput {
|
|
let mut lines: Vec<BlockLine> = Vec::new();
|
|
self.push_header_lines(
|
|
&mut lines,
|
|
theme,
|
|
header_style,
|
|
false,
|
|
width,
|
|
extra_indent,
|
|
false,
|
|
// include $ command when expanded/truncated
|
|
true,
|
|
);
|
|
|
|
if self.output.is_none()
|
|
&& let Some(error) = &self.error
|
|
&& !error.is_empty()
|
|
{
|
|
lines.push(BlockLine::separator(Line::from("")));
|
|
let error_style = ratatui::style::Style::default().fg(theme.accent_error);
|
|
for line in error.lines() {
|
|
lines.push(BlockLine::separator(Line::from(Span::styled(
|
|
line.to_string(),
|
|
error_style,
|
|
))));
|
|
}
|
|
}
|
|
|
|
if let Some(output) = &self.output
|
|
&& !output.is_empty()
|
|
{
|
|
lines.push(BlockLine::separator(Line::from("")));
|
|
|
|
// Default foreground for the output body. Under the terminal-native
|
|
// (minimal) palette, muted used to collapse to ANSI bright black and
|
|
// washed out on many dark profiles; content must track the terminal's
|
|
// default fg (`primary` / Reset). Labels / chrome stay on `muted`/`dim`.
|
|
let styled_lines: Vec<Line<'static>> =
|
|
crate::render::terminal_output::render_terminal_lines(output, theme.primary())
|
|
.into_iter()
|
|
.map(|rl| rl.line)
|
|
.collect();
|
|
|
|
let (wrapped, joiners) =
|
|
word_wrap_lines_with_joiners(styled_lines, width.saturating_sub(2).max(20));
|
|
let total = wrapped.len();
|
|
|
|
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_selection_range(Some(EXECUTE_STDOUT_RANGE_BASE))
|
|
.with_joiner(joiner.clone()),
|
|
);
|
|
}
|
|
// Ellipsis (non-selectable, breaks range continuity)
|
|
lines.push(
|
|
BlockLine::separator(Line::from(Span::styled("\u{2026}", theme.muted())))
|
|
.with_panel_background(theme.bg_dark),
|
|
);
|
|
// Distinct selection range from the first chunk (range base + 1).
|
|
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_selection_range(Some(EXECUTE_STDOUT_RANGE_BASE + 1))
|
|
.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_selection_range(Some(EXECUTE_STDOUT_RANGE_BASE))
|
|
.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_selection_range(Some(EXECUTE_STDOUT_RANGE_BASE))
|
|
.with_joiner(joiner),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
BlockOutput { lines }
|
|
}
|
|
}
|
|
|
|
/// Drop a leading `Run` / `Running` word (case-insensitive) plus following
|
|
/// whitespace so Label headers do not read `Run Run the tests`.
|
|
fn strip_leading_run_word(s: &str) -> String {
|
|
let lower = s.to_ascii_lowercase();
|
|
let rest = if let Some(rest) = lower.strip_prefix("running") {
|
|
rest
|
|
} else if let Some(rest) = lower.strip_prefix("run") {
|
|
rest
|
|
} else {
|
|
return s.to_string();
|
|
};
|
|
// Require a word boundary after Run/Running (space or end).
|
|
if rest.is_empty() {
|
|
return String::new();
|
|
}
|
|
if !rest.starts_with(|c: char| c.is_whitespace()) {
|
|
return s.to_string();
|
|
}
|
|
// Map back to original casing via byte length of the prefix consumed
|
|
// (`to_ascii_lowercase` preserves length for ASCII prefixes).
|
|
let prefix_len = s.len() - rest.len();
|
|
s[prefix_len..].trim_start().to_string()
|
|
}
|
|
|
|
impl BlockContent for ExecuteToolCallBlock {
|
|
fn output(&self, ctx: &BlockContext) -> BlockOutput {
|
|
let theme = Theme::current();
|
|
let config = &ctx.appearance.scrollback.blocks.execute;
|
|
let header_style = config.header_style;
|
|
|
|
// Content width already nets out the bullet; hang indent is only for
|
|
// `$` / `Run` prefix (not the bullet again — that double-counted).
|
|
let content_width = ctx.content_width();
|
|
|
|
match ctx.mode {
|
|
DisplayMode::Collapsed => {
|
|
let muted = ctx.mute_when_collapsed(config.muted_command_collapsed);
|
|
let mut lines = Vec::new();
|
|
// Collapsed: description title only (no `$ command`) for density;
|
|
// without description, still show the single-line command header.
|
|
self.push_header_lines(
|
|
&mut lines,
|
|
&theme,
|
|
header_style,
|
|
muted,
|
|
content_width,
|
|
0,
|
|
true,
|
|
// hide command when description is the title
|
|
false,
|
|
);
|
|
BlockOutput { lines }
|
|
}
|
|
DisplayMode::Truncated => self.render_with_truncation(
|
|
&theme,
|
|
content_width,
|
|
Some((config.first_lines as usize, config.last_lines as usize)),
|
|
header_style,
|
|
0,
|
|
),
|
|
DisplayMode::Expanded => {
|
|
self.render_with_truncation(&theme, content_width, None, header_style, 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
|
|
if !ctx.appearance.scrollback.blocks.execute.accent_enabled {
|
|
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(
|
|
ctx.appearance.scrollback.blocks.execute.running_accent,
|
|
))
|
|
} else {
|
|
Some(AccentStyle::static_color(theme.accent_success))
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// Collapsed with a description hides `$ command`; expand reveals it
|
|
// (and output/error when present). Use Label-style stripping so a bare
|
|
// "Run"/"Running" description (stripped to empty) does not claim a
|
|
// fold when collapsed and expanded headers are identical.
|
|
self.description_display(true).is_some() || self.output.is_some() || self.error.is_some()
|
|
}
|
|
|
|
/// Fold cycle. Agent tools toggle Collapsed <-> Truncated (glanceable
|
|
/// preview; full output in the viewer). User `!` commands toggle
|
|
/// Collapsed <-> Expanded: re-expanding must restore the full output,
|
|
/// never the lossy first/last window.
|
|
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
|
|
match current {
|
|
DisplayMode::Collapsed if self.bash_mode => DisplayMode::Expanded,
|
|
DisplayMode::Collapsed => DisplayMode::Truncated,
|
|
DisplayMode::Truncated | DisplayMode::Expanded => DisplayMode::Collapsed,
|
|
}
|
|
}
|
|
|
|
/// Minimum fold mode used by collapse + the running expand chevron.
|
|
///
|
|
/// Agent tools default to Collapsed (title only) — no auto-expand. User
|
|
/// `!` bash while running uses Truncated so interactive output streams and
|
|
/// the chevron treats that as min-fold. When finished, Collapsed is always
|
|
/// the true minimum (user can fold to title-only).
|
|
fn collapse_mode(&self, is_running: bool) -> DisplayMode {
|
|
if self.bash_mode && is_running {
|
|
DisplayMode::Truncated
|
|
} else {
|
|
DisplayMode::Collapsed
|
|
}
|
|
}
|
|
|
|
/// Agent tools start **Collapsed** (no auto-expand of stdout). User `!`
|
|
/// bash starts Truncated so output streams — errors included: the
|
|
/// tracker's Completed refinement resets to this default right before
|
|
/// `finish_running`, so a Collapsed error default would defeat the
|
|
/// Expanded finish.
|
|
fn default_display_mode(&self) -> DisplayMode {
|
|
if self.bash_mode {
|
|
DisplayMode::Truncated
|
|
} else {
|
|
DisplayMode::Collapsed
|
|
}
|
|
}
|
|
|
|
fn finished_display_mode(&self) -> Option<DisplayMode> {
|
|
if self.bash_mode {
|
|
// Interactive bash: full output on finish, like a terminal —
|
|
// the streaming preview would silently drop the middle lines.
|
|
Some(DisplayMode::Expanded)
|
|
} else {
|
|
// Agent tools: do **not** force a mode on finish.
|
|
// - Never auto-expanded at start (default Collapsed).
|
|
// - If the user manually expanded while running, keep that mode
|
|
// (no snap-shut). If they left it collapsed, it stays collapsed.
|
|
None
|
|
}
|
|
}
|
|
|
|
fn preamble(&self, ctx: &BlockContext) -> Option<Text<'static>> {
|
|
let theme = Theme::current();
|
|
let header_style = ctx.appearance.scrollback.blocks.execute.header_style;
|
|
|
|
let mut lines: Vec<Line<'static>> = self
|
|
.header_lines(&theme, header_style, false, true)
|
|
.into_iter()
|
|
.map(|(line, _)| line)
|
|
.collect();
|
|
|
|
if self.output.is_none()
|
|
&& let Some(error) = &self.error
|
|
&& !error.is_empty()
|
|
{
|
|
lines.push(Line::from(""));
|
|
let error_style = ratatui::style::Style::default().fg(theme.accent_error);
|
|
for line in error.lines() {
|
|
lines.push(Line::from(Span::styled(line.to_string(), error_style)));
|
|
}
|
|
}
|
|
|
|
Some(Text::from(lines))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::appearance::ExecuteHeaderStyle;
|
|
|
|
fn line_text(line: &Line<'_>) -> String {
|
|
line.spans.iter().map(|s| s.content.as_ref()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn header_line_uses_header_display_when_set_command_stays_full() {
|
|
let mut block = ExecuteToolCallBlock::new("cd /proj && echo hi");
|
|
block.header_display = Some("echo hi".into());
|
|
assert_eq!(block.command, "cd /proj && echo hi");
|
|
let theme = Theme::current();
|
|
let headers = block.header_lines(&theme, ExecuteHeaderStyle::Label, false, true);
|
|
assert_eq!(headers.len(), 1);
|
|
let text = line_text(&headers[0].0);
|
|
assert!(text.contains("echo hi"), "header={text:?}");
|
|
assert!(
|
|
!text.contains("cd /proj"),
|
|
"header must use header_display: {text:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn label_header_with_description_shows_title_then_command() {
|
|
let block = ExecuteToolCallBlock::new("cargo test --lib")
|
|
.with_description("Run the unit test suite");
|
|
let theme = Theme::current();
|
|
let headers = block.header_lines(&theme, ExecuteHeaderStyle::Label, false, true);
|
|
assert_eq!(headers.len(), 2);
|
|
let title = line_text(&headers[0].0);
|
|
let cmd = line_text(&headers[1].0);
|
|
// Leading "Run " on the description is stripped (Label already has it).
|
|
assert_eq!(title, "Run the unit test suite");
|
|
assert!(cmd.starts_with("$ "), "cmd={cmd:?}");
|
|
assert!(cmd.contains("cargo test --lib"), "cmd={cmd:?}");
|
|
// Prefix span counts: "Run " only on title; "$ " on command.
|
|
assert_eq!(headers[0].1, 1);
|
|
assert_eq!(headers[1].1, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn collapsed_header_with_description_hides_command() {
|
|
let block = ExecuteToolCallBlock::new("cargo test --lib")
|
|
.with_description("Run the unit test suite");
|
|
let theme = Theme::current();
|
|
let headers = block.header_lines(&theme, ExecuteHeaderStyle::Label, true, false);
|
|
assert_eq!(headers.len(), 1);
|
|
assert_eq!(line_text(&headers[0].0), "Run the unit test suite");
|
|
}
|
|
|
|
#[test]
|
|
fn strip_leading_run_word_handles_run_and_running() {
|
|
assert_eq!(strip_leading_run_word("Run the tests"), "the tests");
|
|
assert_eq!(strip_leading_run_word("running checks"), "checks");
|
|
assert_eq!(strip_leading_run_word("runtime config"), "runtime config");
|
|
assert_eq!(strip_leading_run_word("Check status"), "Check status");
|
|
}
|
|
|
|
#[test]
|
|
fn shell_header_with_description_shows_title_then_command() {
|
|
let block =
|
|
ExecuteToolCallBlock::new("git status -sb").with_description("Check git status");
|
|
let theme = Theme::current();
|
|
let headers = block.header_lines(&theme, ExecuteHeaderStyle::Shell, false, true);
|
|
assert_eq!(headers.len(), 2);
|
|
assert_eq!(line_text(&headers[0].0), "Check git status");
|
|
assert_eq!(headers[0].1, 0);
|
|
let cmd = line_text(&headers[1].0);
|
|
assert!(cmd.starts_with("$ "), "cmd={cmd:?}");
|
|
assert!(cmd.contains("git status -sb"), "cmd={cmd:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn label_header_without_description_is_single_run_command_line() {
|
|
let block = ExecuteToolCallBlock::new("echo hi");
|
|
let theme = Theme::current();
|
|
let headers = block.header_lines(&theme, ExecuteHeaderStyle::Label, false, false);
|
|
assert_eq!(headers.len(), 1);
|
|
let text = line_text(&headers[0].0);
|
|
assert!(text.starts_with("Run "), "header={text:?}");
|
|
assert!(text.contains("echo hi"), "header={text:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn label_header_lines_flattens_command_newlines() {
|
|
// Single-line header path must never embed raw `\n` (ratatui drops them).
|
|
let block = ExecuteToolCallBlock::new("cargo test \\\n --all");
|
|
let theme = Theme::current();
|
|
let headers = block.header_lines(&theme, ExecuteHeaderStyle::Label, false, true);
|
|
assert_eq!(headers.len(), 1);
|
|
let text = line_text(&headers[0].0);
|
|
assert!(
|
|
!text.contains('\n'),
|
|
"label single-line header must flatten newlines: {text:?}"
|
|
);
|
|
assert!(text.contains("cargo test"), "header={text:?}");
|
|
assert!(text.contains("--all"), "header={text:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn label_expanded_soft_wraps_multiline_command_like_permission_panel() {
|
|
let block = ExecuteToolCallBlock::new(
|
|
"git status --short --branch && cargo test --workspace --all-features",
|
|
);
|
|
let theme = Theme::current();
|
|
let mut lines = Vec::new();
|
|
block.push_header_lines(
|
|
&mut lines,
|
|
&theme,
|
|
ExecuteHeaderStyle::Label,
|
|
false,
|
|
40,
|
|
0,
|
|
// expanded: soft-wrap
|
|
false,
|
|
true,
|
|
);
|
|
assert!(
|
|
lines.len() >= 2,
|
|
"expected operator soft-wrap rows, got {}",
|
|
lines.len()
|
|
);
|
|
let first = line_text(&lines[0].content);
|
|
assert!(
|
|
first.starts_with("Run "),
|
|
"Label soft-wrap first row needs Run prefix: {first:?}"
|
|
);
|
|
assert!(
|
|
first.contains("&&"),
|
|
"first row should keep the operator: {first:?}"
|
|
);
|
|
assert!(
|
|
!first.contains('\n'),
|
|
"each BlockLine is one visual row: {first:?}"
|
|
);
|
|
let second = line_text(&lines[1].content);
|
|
assert!(
|
|
second.trim_start().starts_with("cargo"),
|
|
"continuation under hang: {second:?}"
|
|
);
|
|
assert_eq!(
|
|
lines[1].joiner.as_deref(),
|
|
Some("\n"),
|
|
"copy must preserve line breaks between soft-wrap rows"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_description_treated_as_absent() {
|
|
let block = ExecuteToolCallBlock::new("echo hi").with_description(" \n ");
|
|
let theme = Theme::current();
|
|
let headers = block.header_lines(&theme, ExecuteHeaderStyle::Label, false, false);
|
|
assert_eq!(headers.len(), 1);
|
|
assert!(line_text(&headers[0].0).contains("echo hi"));
|
|
}
|
|
|
|
#[test]
|
|
fn label_bash_mode_with_description_keeps_user_marker_on_title() {
|
|
let mut block = ExecuteToolCallBlock::new("ls -la").with_description("List files");
|
|
block.bash_mode = true;
|
|
let theme = Theme::current();
|
|
let headers = block.header_lines(&theme, ExecuteHeaderStyle::Label, false, true);
|
|
assert_eq!(headers.len(), 2);
|
|
let title = line_text(&headers[0].0);
|
|
assert_eq!(title, "Run (user) List files");
|
|
// "Run " + "(user) "
|
|
assert_eq!(headers[0].1, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn description_makes_block_foldable_to_reveal_command() {
|
|
let block = ExecuteToolCallBlock::new("true").with_description("no-op");
|
|
assert!(block.is_foldable());
|
|
let with_output = block.clone().with_output("ok\n");
|
|
assert!(with_output.is_foldable());
|
|
let no_desc = ExecuteToolCallBlock::new("true");
|
|
assert!(!no_desc.is_foldable());
|
|
}
|
|
|
|
#[test]
|
|
fn agent_execute_does_not_auto_expand_and_preserves_fold_on_finish() {
|
|
let agent = ExecuteToolCallBlock::new("echo hi").with_description("say hi");
|
|
// No auto-expand: start collapsed (title only).
|
|
assert_eq!(agent.default_display_mode(), DisplayMode::Collapsed);
|
|
assert_eq!(agent.collapse_mode(true), DisplayMode::Collapsed);
|
|
assert_eq!(agent.collapse_mode(false), DisplayMode::Collapsed);
|
|
// Finish does not force open or shut — preserve user choice (None).
|
|
assert_eq!(agent.finished_display_mode(), None);
|
|
|
|
let mut bash = ExecuteToolCallBlock::new("ls");
|
|
bash.bash_mode = true;
|
|
// User ! streams truncated, finishes with the full output.
|
|
assert_eq!(bash.default_display_mode(), DisplayMode::Truncated);
|
|
assert_eq!(bash.finished_display_mode(), Some(DisplayMode::Expanded));
|
|
assert_eq!(bash.collapse_mode(true), DisplayMode::Truncated);
|
|
assert_eq!(bash.collapse_mode(false), DisplayMode::Collapsed);
|
|
// Fold cycle for user bash skips the lossy Truncated window.
|
|
assert_eq!(
|
|
bash.next_fold_mode(DisplayMode::Collapsed, false),
|
|
DisplayMode::Expanded
|
|
);
|
|
assert_eq!(
|
|
bash.next_fold_mode(DisplayMode::Expanded, false),
|
|
DisplayMode::Collapsed
|
|
);
|
|
|
|
let failed = ExecuteToolCallBlock::new("false").with_error("exit 1");
|
|
assert_eq!(failed.default_display_mode(), DisplayMode::Collapsed);
|
|
assert_eq!(failed.finished_display_mode(), None);
|
|
|
|
// Failed user bash keeps the Truncated default: a Collapsed default
|
|
// would defeat the finish expand (see default_display_mode).
|
|
let mut failed_bash = ExecuteToolCallBlock::new("pytest").with_error("exit 2");
|
|
failed_bash.bash_mode = true;
|
|
assert_eq!(failed_bash.default_display_mode(), DisplayMode::Truncated);
|
|
assert_eq!(
|
|
failed_bash.finished_display_mode(),
|
|
Some(DisplayMode::Expanded)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bare_run_description_is_not_foldable() {
|
|
let bare = ExecuteToolCallBlock::new("echo hi").with_description("Run");
|
|
assert!(
|
|
!bare.is_foldable(),
|
|
"Label strips bare Run; headers identical → not foldable"
|
|
);
|
|
let real = ExecuteToolCallBlock::new("echo hi").with_description("say hi");
|
|
assert!(real.is_foldable());
|
|
}
|
|
|
|
#[test]
|
|
fn test_push_output_basic() {
|
|
let mut block = ExecuteToolCallBlock::new("echo test");
|
|
block.push_output("hello ");
|
|
block.push_output("world");
|
|
block.finish();
|
|
assert_eq!(block.output, Some("hello world".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_push_output_multiple_chunks() {
|
|
let mut block = ExecuteToolCallBlock::new("cargo build");
|
|
block.push_output("Com");
|
|
block.push_output("pili");
|
|
block.push_output("ng ");
|
|
block.push_output("crat");
|
|
block.push_output("e\n");
|
|
block.finish();
|
|
assert_eq!(block.output, Some("Compiling crate\n".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_with_output() {
|
|
let block = ExecuteToolCallBlock::new("echo test").with_output("plain text output");
|
|
assert_eq!(block.output, Some("plain text output".to_string()));
|
|
}
|
|
}
|