TUIのEditツール周りの表示とカラー
This commit is contained in:
parent
3717569533
commit
7ce77f0ad5
|
|
@ -125,6 +125,7 @@ impl App {
|
||||||
args_stream: String::new(),
|
args_stream: String::new(),
|
||||||
arguments: None,
|
arguments: None,
|
||||||
state: ToolCallState::Pending,
|
state: ToolCallState::Pending,
|
||||||
|
edit_snapshot: None,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Event::ToolCallArgsDelta { id, json } => {
|
Event::ToolCallArgsDelta { id, json } => {
|
||||||
|
|
@ -158,11 +159,26 @@ impl App {
|
||||||
output,
|
output,
|
||||||
is_error,
|
is_error,
|
||||||
} => {
|
} => {
|
||||||
|
// Pull the name / args out first so we can look at the
|
||||||
|
// (immutable) cache before taking the mutable block
|
||||||
|
// borrow below.
|
||||||
|
let (name, args) = self
|
||||||
|
.find_tool_call_mut(&id)
|
||||||
|
.map(|b| (b.name.clone(), b.arguments.clone()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let edit_snapshot = if !is_error && name == "Edit" {
|
||||||
|
args.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||||
|
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
|
||||||
|
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(b) = self.find_tool_call_mut(&id) {
|
if let Some(b) = self.find_tool_call_mut(&id) {
|
||||||
// Capture data we need for cache updates before we
|
if edit_snapshot.is_some() {
|
||||||
// move `output` into the new state.
|
b.edit_snapshot = edit_snapshot;
|
||||||
let name = b.name.clone();
|
}
|
||||||
let args = b.arguments.clone();
|
|
||||||
b.state = if is_error {
|
b.state = if is_error {
|
||||||
ToolCallState::Error {
|
ToolCallState::Error {
|
||||||
summary,
|
summary,
|
||||||
|
|
@ -377,6 +393,7 @@ impl App {
|
||||||
args_stream: arguments.clone().unwrap_or_default(),
|
args_stream: arguments.clone().unwrap_or_default(),
|
||||||
arguments,
|
arguments,
|
||||||
state: ToolCallState::Executing,
|
state: ToolCallState::Executing,
|
||||||
|
edit_snapshot: None,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
"tool_result" => {
|
"tool_result" => {
|
||||||
|
|
@ -384,9 +401,22 @@ impl App {
|
||||||
let summary = item["summary"].as_str().unwrap_or("").to_owned();
|
let summary = item["summary"].as_str().unwrap_or("").to_owned();
|
||||||
let output = item["content"].as_str().map(|s| s.to_owned());
|
let output = item["content"].as_str().map(|s| s.to_owned());
|
||||||
let is_error = item["is_error"].as_bool().unwrap_or(false);
|
let is_error = item["is_error"].as_bool().unwrap_or(false);
|
||||||
|
let (name, args) = self
|
||||||
|
.find_tool_call_mut(&id)
|
||||||
|
.map(|b| (b.name.clone(), b.arguments.clone()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let edit_snapshot = if !is_error && name == "Edit" {
|
||||||
|
args.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||||
|
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
|
||||||
|
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
if let Some(tc) = self.find_tool_call_mut(&id) {
|
if let Some(tc) = self.find_tool_call_mut(&id) {
|
||||||
let name = tc.name.clone();
|
if edit_snapshot.is_some() {
|
||||||
let args = tc.arguments.clone();
|
tc.edit_snapshot = edit_snapshot;
|
||||||
|
}
|
||||||
tc.state = if is_error {
|
tc.state = if is_error {
|
||||||
ToolCallState::Error {
|
ToolCallState::Error {
|
||||||
summary,
|
summary,
|
||||||
|
|
@ -436,6 +466,28 @@ pub fn fmt_tokens(n: u64) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strip the `cat -n` line-number gutter that the Read tool prepends to
|
||||||
|
/// its output (one `"{n:>6}\t{content}"` per line) and return the raw
|
||||||
|
/// file body. Lines that don't match the pattern are kept verbatim, so
|
||||||
|
/// unrelated payloads pass through unharmed.
|
||||||
|
fn strip_cat_n_prefix(formatted: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(formatted.len());
|
||||||
|
let mut first = true;
|
||||||
|
for line in formatted.split('\n') {
|
||||||
|
if !first {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
match line.split_once('\t') {
|
||||||
|
Some((prefix, rest)) if prefix.trim().chars().all(|c| c.is_ascii_digit()) => {
|
||||||
|
out.push_str(rest);
|
||||||
|
}
|
||||||
|
_ => out.push_str(line),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
pub fn notification_source_label(source: NotificationSource) -> &'static str {
|
pub fn notification_source_label(source: NotificationSource) -> &'static str {
|
||||||
match source {
|
match source {
|
||||||
NotificationSource::Pod => "pod",
|
NotificationSource::Pod => "pod",
|
||||||
|
|
@ -464,7 +516,11 @@ fn apply_cache_update(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(content) = output {
|
if let Some(content) = output {
|
||||||
cache.put(path, content.to_owned());
|
// The Read tool emits a `cat -n` style display: each
|
||||||
|
// line is "{lineno:>6}\tcontent". Strip that framing
|
||||||
|
// so the cache mirrors the real file body and the
|
||||||
|
// Edit diff renderer has a faithful "before" view.
|
||||||
|
cache.put(path, strip_cat_n_prefix(content));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"Write" => {
|
"Write" => {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ pub struct ToolCallBlock {
|
||||||
/// Final arguments text once `ToolCallDone` lands.
|
/// Final arguments text once `ToolCallDone` lands.
|
||||||
pub arguments: Option<String>,
|
pub arguments: Option<String>,
|
||||||
pub state: ToolCallState,
|
pub state: ToolCallState,
|
||||||
|
/// For Edit tool calls: snapshot of the file content *before* the
|
||||||
|
/// edit was applied to the cache. Captured at result time so the
|
||||||
|
/// diff renderer can reproduce the old-content context even after
|
||||||
|
/// subsequent mutations have rolled the cache forward.
|
||||||
|
pub edit_snapshot: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum ToolCallState {
|
pub enum ToolCallState {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use crate::block::{Block, ToolCallBlock, ToolCallState};
|
use crate::block::{Block, ToolCallBlock, ToolCallState};
|
||||||
use crate::cache::FileCache;
|
use crate::cache::FileCache;
|
||||||
|
|
@ -28,6 +29,7 @@ pub fn render_tool(
|
||||||
cache: &FileCache,
|
cache: &FileCache,
|
||||||
blocks: &[Block],
|
blocks: &[Block],
|
||||||
start: usize,
|
start: usize,
|
||||||
|
width: u16,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
) -> ToolRenderOutput {
|
) -> ToolRenderOutput {
|
||||||
let Some(Block::ToolCall(tc)) = blocks.get(start) else {
|
let Some(Block::ToolCall(tc)) = blocks.get(start) else {
|
||||||
|
|
@ -40,7 +42,7 @@ pub fn render_tool(
|
||||||
match tc.name.as_str() {
|
match tc.name.as_str() {
|
||||||
"Read" => render_read_aggregate(blocks, start, mode),
|
"Read" => render_read_aggregate(blocks, start, mode),
|
||||||
"Write" => single(render_write(cache, tc, mode)),
|
"Write" => single(render_write(cache, tc, mode)),
|
||||||
"Edit" => single(render_edit(cache, tc, mode)),
|
"Edit" => single(render_edit(cache, tc, width, mode)),
|
||||||
"Glob" => single(render_search(tc, mode, "Glob")),
|
"Glob" => single(render_search(tc, mode, "Glob")),
|
||||||
"Grep" => single(render_search(tc, mode, "Grep")),
|
"Grep" => single(render_search(tc, mode, "Grep")),
|
||||||
_ => single(render_default(tc, mode)),
|
_ => single(render_default(tc, mode)),
|
||||||
|
|
@ -83,9 +85,9 @@ fn render_read_aggregate(blocks: &[Block], start: usize, mode: Mode) -> ToolRend
|
||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
let header = if in_progress {
|
let header = if in_progress {
|
||||||
format!("[tool] Read — reading ({count} file{}…)", plural(count))
|
format!("Read — reading ({count} file{}…)", plural(count))
|
||||||
} else {
|
} else {
|
||||||
format!("[tool] Read — {count} file{} read", plural(count))
|
format!("Read — {count} file{} read", plural(count))
|
||||||
};
|
};
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(header, tool_style),
|
Span::styled(header, tool_style),
|
||||||
|
|
@ -161,7 +163,7 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'
|
||||||
|
|
||||||
if matches!(mode, Mode::Overview) {
|
if matches!(mode, Mode::Overview) {
|
||||||
return vec![Line::from(vec![
|
return vec![Line::from(vec![
|
||||||
Span::styled("[tool] Write — ".to_owned(), tool_style),
|
Span::styled("Write — ".to_owned(), tool_style),
|
||||||
Span::styled(format!("{label} "), label_style),
|
Span::styled(format!("{label} "), label_style),
|
||||||
Span::styled(path, Style::default().fg(Color::White)),
|
Span::styled(path, Style::default().fg(Color::White)),
|
||||||
])];
|
])];
|
||||||
|
|
@ -169,7 +171,7 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'
|
||||||
|
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("[tool] Write — ".to_owned(), tool_style),
|
Span::styled("Write — ".to_owned(), tool_style),
|
||||||
Span::styled(format!("{label} "), label_style),
|
Span::styled(format!("{label} "), label_style),
|
||||||
Span::styled(path.clone(), Style::default().fg(Color::White)),
|
Span::styled(path.clone(), Style::default().fg(Color::White)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
|
|
@ -212,7 +214,7 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'
|
||||||
// Edit
|
// Edit
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
fn render_edit(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'static>> {
|
fn render_edit(cache: &FileCache, tc: &ToolCallBlock, width: u16, mode: Mode) -> Vec<Line<'static>> {
|
||||||
let args = parsed_args(tc);
|
let args = parsed_args(tc);
|
||||||
let path = args
|
let path = args
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -229,7 +231,7 @@ fn render_edit(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'s
|
||||||
|
|
||||||
let tool_style = Style::default().fg(Color::Cyan);
|
let tool_style = Style::default().fg(Color::Cyan);
|
||||||
let header = Line::from(vec![
|
let header = Line::from(vec![
|
||||||
Span::styled(format!("[tool] Edit — {}", path), tool_style),
|
Span::styled(format!("Edit — {}", path), tool_style),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(" ({})", state_suffix(&tc.state)),
|
format!(" ({})", state_suffix(&tc.state)),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
|
|
@ -242,87 +244,249 @@ fn render_edit(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'s
|
||||||
|
|
||||||
let mut lines = vec![header];
|
let mut lines = vec![header];
|
||||||
|
|
||||||
// Best-effort diff. Uses the cached content as the "before" snapshot
|
// Prefer the snapshot captured right before the cache was rolled
|
||||||
// so what we show is consistent with the TUI's own state even if
|
// forward (so we always diff against the true "before" even after
|
||||||
// the on-disk file has since diverged.
|
// subsequent mutations). Fall back to the current cache for
|
||||||
let diff_lines = cache
|
// replayed history where no snapshot was recorded.
|
||||||
.get(&path)
|
let before = tc.edit_snapshot.as_deref().or_else(|| cache.get(&path));
|
||||||
.map(|content| build_edit_diff(content, &old, &new));
|
if let Some(content) = before {
|
||||||
if let Some(diff) = diff_lines {
|
for l in build_edit_diff(content, &old, &new, width) {
|
||||||
for l in diff {
|
|
||||||
lines.push(l);
|
lines.push(l);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(Span::styled(
|
||||||
Span::raw(" "),
|
" (no cached content — run Read first for a diff view)".to_owned(),
|
||||||
Span::styled(
|
Style::default().fg(Color::DarkGray),
|
||||||
"(no cached content — run Read first for a diff view)".to_owned(),
|
)));
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
maybe_error_line(&mut lines, &tc.state);
|
maybe_error_line(&mut lines, &tc.state);
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_edit_diff(content: &str, old: &str, new: &str) -> Vec<Line<'static>> {
|
fn build_edit_diff(content: &str, old: &str, new: &str, width: u16) -> Vec<Line<'static>> {
|
||||||
// Locate the first (and typically only) match.
|
// Locate the first (and typically only) match.
|
||||||
let Some(idx) = content.find(old) else {
|
let Some(idx) = content.find(old) else {
|
||||||
return vec![Line::from(vec![
|
return vec![Line::from(Span::styled(
|
||||||
Span::raw(" "),
|
" (old_string not found in cached content)".to_owned(),
|
||||||
Span::styled(
|
Style::default().fg(Color::DarkGray),
|
||||||
"(old_string not found in cached content)".to_owned(),
|
))];
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
),
|
|
||||||
])];
|
|
||||||
};
|
};
|
||||||
let end = idx + old.len();
|
let end = idx + old.len();
|
||||||
|
|
||||||
// Convert byte ranges to line ranges.
|
// Convert byte ranges to line ranges.
|
||||||
let before = &content[..idx];
|
//
|
||||||
let line_of_idx = before.lines().count();
|
// Zero-based line index of `idx` = number of newlines preceding
|
||||||
// `lines()` omits a trailing empty line when the text ends in \n —
|
// it. `before.lines().count()` does NOT give this: for content
|
||||||
// fine for our purposes (we only need approximate line ranges).
|
// without a trailing newline it over-counts on the last line
|
||||||
let replaced_line_count = content[idx..end].lines().count().max(1);
|
// (e.g. "h".lines() == ["h"].count() == 1 but "h" is still on
|
||||||
|
// line 0).
|
||||||
let all_lines: Vec<&str> = content.lines().collect();
|
let all_lines: Vec<&str> = content.lines().collect();
|
||||||
|
let line_of_idx = content[..idx].matches('\n').count().min(all_lines.len());
|
||||||
|
let replaced_line_count = content[idx..end].lines().count().max(1);
|
||||||
|
// Keep subsequent slice math in-bounds no matter what the
|
||||||
|
// positions say — Edit never wants to panic the whole TUI just
|
||||||
|
// because cached content and live args disagree.
|
||||||
|
let replaced_end = (line_of_idx + replaced_line_count).min(all_lines.len());
|
||||||
let ctx_start = line_of_idx.saturating_sub(EDIT_DIFF_CONTEXT);
|
let ctx_start = line_of_idx.saturating_sub(EDIT_DIFF_CONTEXT);
|
||||||
let ctx_end = (line_of_idx + replaced_line_count + EDIT_DIFF_CONTEXT).min(all_lines.len());
|
let ctx_end = (replaced_end + EDIT_DIFF_CONTEXT).min(all_lines.len());
|
||||||
|
|
||||||
let ctx_style = Style::default().fg(Color::Gray);
|
let old_line_count = old.lines().count().max(1);
|
||||||
let minus_style = Style::default().fg(Color::Red);
|
let new_line_count = new.lines().count().max(1);
|
||||||
let plus_style = Style::default().fg(Color::Green);
|
|
||||||
|
// Width for the line-number gutter: fit the largest number we'll
|
||||||
|
// print across either file's version of this hunk.
|
||||||
|
let max_line = ctx_end
|
||||||
|
.max(line_of_idx + new_line_count)
|
||||||
|
.max(1);
|
||||||
|
let num_w = max_line.to_string().len();
|
||||||
|
|
||||||
|
// BG-highlighted rows for -/+ so the change stripe extends full
|
||||||
|
// width; context lines stay plain. FG is left at terminal default
|
||||||
|
// on colored rows so contrast survives user terminal themes.
|
||||||
|
let ctx_text = Style::default().fg(Color::Gray);
|
||||||
|
let num_style_ctx = Style::default().fg(Color::DarkGray);
|
||||||
|
let num_style_dim = Style::default().fg(Color::White);
|
||||||
|
// kanagawa-nvim palette:
|
||||||
|
// winterRed #43242B / winterGreen #2B3328 — diff row backgrounds
|
||||||
|
// autumnRed #C34043 / autumnGreen #76946A — git add/delete markers
|
||||||
|
let minus_line_style = Style::default().bg(Color::Rgb(0x43, 0x24, 0x2B));
|
||||||
|
let plus_line_style = Style::default().bg(Color::Rgb(0x2B, 0x33, 0x28));
|
||||||
|
let minus_marker_style = Style::default().fg(Color::Rgb(0xC3, 0x40, 0x43));
|
||||||
|
let plus_marker_style = Style::default().fg(Color::Rgb(0x76, 0x94, 0x6A));
|
||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
for l in &all_lines[ctx_start..line_of_idx] {
|
// Layout per row (independent of outer history padding):
|
||||||
lines.push(Line::from(vec![
|
// 1-space gutter + line number (right-aligned to num_w) + 1 space
|
||||||
Span::raw(" "),
|
// + 1-char marker (" " / "-" / "+") + content
|
||||||
Span::styled(format!(" {l}"), ctx_style),
|
let num_gutter_w = num_w + 2; // leading sp + num + trailing sp
|
||||||
]));
|
let fmt_num = |n: usize| format!(" {n:>num_w$} ", n = n, num_w = num_w);
|
||||||
|
let blank_num = " ".repeat(num_gutter_w);
|
||||||
|
|
||||||
|
// Pre-context: 1-based line numbers in the original file.
|
||||||
|
for (offset, l) in all_lines[ctx_start..line_of_idx].iter().enumerate() {
|
||||||
|
let n = ctx_start + offset + 1;
|
||||||
|
emit_diff_row(
|
||||||
|
&mut lines,
|
||||||
|
fmt_num(n),
|
||||||
|
&blank_num,
|
||||||
|
num_style_ctx,
|
||||||
|
' ',
|
||||||
|
Style::default(),
|
||||||
|
l,
|
||||||
|
Style::default(), // no BG
|
||||||
|
ctx_text,
|
||||||
|
width,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for l in old.lines() {
|
// Removed: old line numbers (line_of_idx + offset + 1). Row BG is
|
||||||
lines.push(Line::from(vec![
|
// red; continuation rows keep the same BG via the wrap helper.
|
||||||
Span::raw(" "),
|
for (offset, l) in old.lines().enumerate() {
|
||||||
Span::styled(format!("-{l}"), minus_style),
|
let n = line_of_idx + offset + 1;
|
||||||
]));
|
emit_diff_row(
|
||||||
|
&mut lines,
|
||||||
|
fmt_num(n),
|
||||||
|
&blank_num,
|
||||||
|
num_style_dim,
|
||||||
|
'-',
|
||||||
|
minus_marker_style,
|
||||||
|
l,
|
||||||
|
minus_line_style,
|
||||||
|
Style::default(),
|
||||||
|
width,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for l in new.lines() {
|
// Added: new line numbers — same base as removed since everything
|
||||||
lines.push(Line::from(vec![
|
// above the hunk is unchanged.
|
||||||
Span::raw(" "),
|
for (offset, l) in new.lines().enumerate() {
|
||||||
Span::styled(format!("+{l}"), plus_style),
|
let n = line_of_idx + offset + 1;
|
||||||
]));
|
emit_diff_row(
|
||||||
|
&mut lines,
|
||||||
|
fmt_num(n),
|
||||||
|
&blank_num,
|
||||||
|
num_style_dim,
|
||||||
|
'+',
|
||||||
|
plus_marker_style,
|
||||||
|
l,
|
||||||
|
plus_line_style,
|
||||||
|
Style::default(),
|
||||||
|
width,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for l in &all_lines[line_of_idx + replaced_line_count..ctx_end] {
|
// Post-context: positions relative to the ORIGINAL file so numbers
|
||||||
lines.push(Line::from(vec![
|
// stay consistent with the snapshot we're diffing against. After a
|
||||||
Span::raw(" "),
|
// successful edit the real file's numbering will shift by
|
||||||
Span::styled(format!(" {l}"), ctx_style),
|
// `new_line_count - old_line_count`, but the purpose of this view
|
||||||
]));
|
// is to read the change itself, not to navigate the updated file.
|
||||||
|
for (offset, l) in all_lines[replaced_end..ctx_end].iter().enumerate() {
|
||||||
|
let n = replaced_end + offset + 1;
|
||||||
|
emit_diff_row(
|
||||||
|
&mut lines,
|
||||||
|
fmt_num(n),
|
||||||
|
&blank_num,
|
||||||
|
num_style_ctx,
|
||||||
|
' ',
|
||||||
|
Style::default(),
|
||||||
|
l,
|
||||||
|
Style::default(),
|
||||||
|
ctx_text,
|
||||||
|
width,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suppress unused-variable lint for the value used only in
|
||||||
|
// `max_line` computation (kept for readability there).
|
||||||
|
let _ = old_line_count;
|
||||||
|
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit one diff row, wrapping long content into continuation rows that
|
||||||
|
/// keep the vertical diff structure intact:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// 3 +very long line that overflows into a second terminal row
|
||||||
|
/// +of the same logical diff line
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `num_text` is the gutter text for the first row (e.g. " 3 "),
|
||||||
|
/// `blank_num` is the same width but all spaces for continuation rows.
|
||||||
|
/// `line_style` applies to every emitted row so BG-highlighted diffs
|
||||||
|
/// retain their highlight across the wrap.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn emit_diff_row(
|
||||||
|
out: &mut Vec<Line<'static>>,
|
||||||
|
num_text: String,
|
||||||
|
blank_num: &str,
|
||||||
|
num_style: Style,
|
||||||
|
marker: char,
|
||||||
|
marker_style: Style,
|
||||||
|
content: &str,
|
||||||
|
line_style: Style,
|
||||||
|
content_text_style: Style,
|
||||||
|
width: u16,
|
||||||
|
) {
|
||||||
|
let total = width as usize;
|
||||||
|
let gutter_w = UnicodeWidthStr::width(num_text.as_str());
|
||||||
|
// Reserve one more col for the marker char; anything beyond goes
|
||||||
|
// into the content.
|
||||||
|
let content_budget = total.saturating_sub(gutter_w).saturating_sub(1);
|
||||||
|
|
||||||
|
let mut is_first = true;
|
||||||
|
let mut remaining = content;
|
||||||
|
loop {
|
||||||
|
let (chunk, rest) = if content_budget == 0 {
|
||||||
|
(remaining, "")
|
||||||
|
} else {
|
||||||
|
split_at_width(remaining, content_budget)
|
||||||
|
};
|
||||||
|
|
||||||
|
let gutter = if is_first {
|
||||||
|
num_text.clone()
|
||||||
|
} else {
|
||||||
|
blank_num.to_owned()
|
||||||
|
};
|
||||||
|
let content_span = if content_text_style == Style::default() {
|
||||||
|
Span::raw(chunk.to_owned())
|
||||||
|
} else {
|
||||||
|
Span::styled(chunk.to_owned(), content_text_style)
|
||||||
|
};
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(gutter, num_style),
|
||||||
|
Span::styled(marker.to_string(), marker_style),
|
||||||
|
content_span,
|
||||||
|
])
|
||||||
|
.style(line_style);
|
||||||
|
out.push(line);
|
||||||
|
|
||||||
|
is_first = false;
|
||||||
|
remaining = rest;
|
||||||
|
if remaining.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `s` at the first char boundary whose accumulated display width
|
||||||
|
/// exceeds `max_cols`. Returns `(head, tail)` — head's display width is
|
||||||
|
/// guaranteed ≤ `max_cols`.
|
||||||
|
fn split_at_width(s: &str, max_cols: usize) -> (&str, &str) {
|
||||||
|
if max_cols == 0 {
|
||||||
|
return (s, "");
|
||||||
|
}
|
||||||
|
let mut col = 0usize;
|
||||||
|
let mut end_byte = 0usize;
|
||||||
|
for (i, c) in s.char_indices() {
|
||||||
|
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
|
||||||
|
if col + cw > max_cols {
|
||||||
|
return s.split_at(i);
|
||||||
|
}
|
||||||
|
col += cw;
|
||||||
|
end_byte = i + c.len_utf8();
|
||||||
|
}
|
||||||
|
s.split_at(end_byte)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Glob / Grep
|
// Glob / Grep
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
@ -343,14 +507,14 @@ fn render_search(tc: &ToolCallBlock, mode: Mode, label: &str) -> Vec<Line<'stati
|
||||||
.unwrap_or(state_suffix(&tc.state))
|
.unwrap_or(state_suffix(&tc.state))
|
||||||
.to_owned();
|
.to_owned();
|
||||||
return vec![Line::from(vec![
|
return vec![Line::from(vec![
|
||||||
Span::styled(format!("[tool] {label} — "), tool_style),
|
Span::styled(format!("{label} — "), tool_style),
|
||||||
Span::styled(first, Style::default().fg(Color::White)),
|
Span::styled(first, Style::default().fg(Color::White)),
|
||||||
])];
|
])];
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut lines = vec![Line::from(vec![
|
let mut lines = vec![Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("[tool] {label} — {}", state_suffix(&tc.state)),
|
format!("{label} — {}", state_suffix(&tc.state)),
|
||||||
tool_style,
|
tool_style,
|
||||||
),
|
),
|
||||||
])];
|
])];
|
||||||
|
|
@ -397,9 +561,9 @@ fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'static>> {
|
||||||
_ => state_suffix(&tc.state).to_owned(),
|
_ => state_suffix(&tc.state).to_owned(),
|
||||||
};
|
};
|
||||||
let label = if suffix.is_empty() {
|
let label = if suffix.is_empty() {
|
||||||
format!("[tool] {} — {}", tc.name, state_suffix(&tc.state))
|
format!("{} — {}", tc.name, state_suffix(&tc.state))
|
||||||
} else {
|
} else {
|
||||||
format!("[tool] {} — {suffix}", tc.name)
|
format!("{} — {suffix}", tc.name)
|
||||||
};
|
};
|
||||||
return vec![Line::from(vec![
|
return vec![Line::from(vec![
|
||||||
Span::styled(label, tool_style),
|
Span::styled(label, tool_style),
|
||||||
|
|
@ -408,7 +572,7 @@ fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'static>> {
|
||||||
|
|
||||||
let mut lines = vec![Line::from(vec![
|
let mut lines = vec![Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("[tool] {} — {}", tc.name, state_suffix(&tc.state)),
|
format!("{} — {}", tc.name, state_suffix(&tc.state)),
|
||||||
tool_style,
|
tool_style,
|
||||||
),
|
),
|
||||||
])];
|
])];
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap};
|
use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap};
|
||||||
use unicode_width::UnicodeWidthChar;
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use protocol::{Greeting, NotificationLevel};
|
use protocol::{Greeting, NotificationLevel};
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
||||||
logical_turn_starts.push(logical.len());
|
logical_turn_starts.push(logical.len());
|
||||||
}
|
}
|
||||||
if matches!(block, Block::ToolCall(_)) {
|
if matches!(block, Block::ToolCall(_)) {
|
||||||
let out = crate::tool::render_tool(&app.cache, &app.blocks, i, app.mode);
|
let out = crate::tool::render_tool(&app.cache, &app.blocks, i, width, app.mode);
|
||||||
logical.extend(out.lines);
|
logical.extend(out.lines);
|
||||||
i += out.consumed.max(1);
|
i += out.consumed.max(1);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -136,9 +136,6 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
||||||
HistoryLayout { lines, turn_starts }
|
HistoryLayout { lines, turn_starts }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum body lines a normal-mode block may emit before truncation.
|
|
||||||
const NORMAL_MAX_LINES: usize = 6;
|
|
||||||
|
|
||||||
/// Horizontal gutter around the log area. Applied via a
|
/// Horizontal gutter around the log area. Applied via a
|
||||||
/// [`Block`](ratatui::widgets::Block)'s padding so *every* row — including
|
/// [`Block`](ratatui::widgets::Block)'s padding so *every* row — including
|
||||||
/// continuation rows from wrapping — sits inside the same margin, no
|
/// continuation rows from wrapping — sits inside the same margin, no
|
||||||
|
|
@ -188,7 +185,11 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split one logical line into one-terminal-row `Line`s via char-aware
|
/// Split one logical line into one-terminal-row `Line`s via char-aware
|
||||||
/// wrapping. Preserves per-span styles and the source line's alignment.
|
/// wrapping. Preserves per-span styles, the source line's alignment,
|
||||||
|
/// and the source line's own style. If the source line carries a
|
||||||
|
/// background color, each emitted row is padded to `width` with spaces
|
||||||
|
/// styled by that line style — so diff-style row highlights (red/green
|
||||||
|
/// backgrounds) extend cleanly to the right edge of the terminal.
|
||||||
fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec<Line<'static>>) {
|
fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec<Line<'static>>) {
|
||||||
if width == 0 {
|
if width == 0 {
|
||||||
out.push(line);
|
out.push(line);
|
||||||
|
|
@ -196,6 +197,8 @@ fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec<Line<'static>>)
|
||||||
}
|
}
|
||||||
let w = width as usize;
|
let w = width as usize;
|
||||||
let alignment = line.alignment;
|
let alignment = line.alignment;
|
||||||
|
let line_style = line.style;
|
||||||
|
let fill_to_width = line_style.bg.is_some();
|
||||||
|
|
||||||
let mut current: Vec<Span<'static>> = Vec::new();
|
let mut current: Vec<Span<'static>> = Vec::new();
|
||||||
let mut row_width: usize = 0;
|
let mut row_width: usize = 0;
|
||||||
|
|
@ -219,7 +222,12 @@ fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec<Line<'static>>)
|
||||||
let push_row = |current: &mut Vec<Span<'static>>,
|
let push_row = |current: &mut Vec<Span<'static>>,
|
||||||
row_width: &mut usize,
|
row_width: &mut usize,
|
||||||
out: &mut Vec<Line<'static>>| {
|
out: &mut Vec<Line<'static>>| {
|
||||||
let mut l = Line::from(std::mem::take(current));
|
if fill_to_width && *row_width < w {
|
||||||
|
let pad = w - *row_width;
|
||||||
|
current.push(Span::styled(" ".repeat(pad), line_style));
|
||||||
|
*row_width = w;
|
||||||
|
}
|
||||||
|
let mut l = Line::from(std::mem::take(current)).style(line_style);
|
||||||
if let Some(a) = alignment {
|
if let Some(a) = alignment {
|
||||||
l = l.alignment(a);
|
l = l.alignment(a);
|
||||||
}
|
}
|
||||||
|
|
@ -292,12 +300,15 @@ fn render_block_into(
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Block::UserMessage { text } => match mode {
|
Block::UserMessage { text } => match mode {
|
||||||
Mode::Overview => push_overview_line(lines, text, MessageKind::User, "> "),
|
Mode::Overview => push_overview_line(lines, text, width, MessageKind::User, "> "),
|
||||||
_ => push_padded_truncated(lines, text, MessageKind::User, mode),
|
// User input and assistant prose are the primary readable
|
||||||
|
// content of a turn — never compressed in detail / normal.
|
||||||
|
// Only `overview` folds them to a single line.
|
||||||
|
_ => push_padded_lines(lines, text, MessageKind::User),
|
||||||
},
|
},
|
||||||
Block::AssistantText { text } => match mode {
|
Block::AssistantText { text } => match mode {
|
||||||
Mode::Overview => push_overview_line(lines, text, MessageKind::Assistant, ""),
|
Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""),
|
||||||
_ => push_padded_truncated(lines, text, MessageKind::Assistant, mode),
|
_ => push_padded_lines(lines, text, MessageKind::Assistant),
|
||||||
},
|
},
|
||||||
// ToolCall is dispatched in `compute_history` via `tool::render_tool`
|
// ToolCall is dispatched in `compute_history` via `tool::render_tool`
|
||||||
// so it can consume multiple adjacent blocks (Read aggregation).
|
// so it can consume multiple adjacent blocks (Read aggregation).
|
||||||
|
|
@ -318,11 +329,11 @@ fn render_block_into(
|
||||||
let label = notification_source_label(*source);
|
let label = notification_source_label(*source);
|
||||||
let text = format!("{prefix} {label}: {message}");
|
let text = format!("{prefix} {label}: {message}");
|
||||||
match mode {
|
match mode {
|
||||||
Mode::Overview => push_overview_line(lines, &text, kind, ""),
|
Mode::Overview => push_overview_line(lines, &text, width, kind, ""),
|
||||||
_ => push_padded_truncated(lines, &text, kind, mode),
|
_ => push_padded_lines(lines, &text, kind),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Block::Compact(evt) => render_compact(lines, evt, mode),
|
Block::Compact(evt) => render_compact(lines, evt, width, mode),
|
||||||
Block::TurnStats {
|
Block::TurnStats {
|
||||||
requests,
|
requests,
|
||||||
input_tokens,
|
input_tokens,
|
||||||
|
|
@ -352,62 +363,119 @@ fn push_padded_lines(lines: &mut Vec<Line<'static>>, text: &str, kind: MessageKi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normal / detail padded text: detail prints every line; normal caps at
|
/// Single-line summary for overview mode. The output is clipped to
|
||||||
/// `NORMAL_MAX_LINES` and appends a "+N more" footer.
|
/// exactly one rendered terminal row at `width` columns — the first
|
||||||
fn push_padded_truncated(
|
/// non-empty logical line is truncated (with `…`) to fit alongside an
|
||||||
lines: &mut Vec<Line<'static>>,
|
/// optional prefix and a `(+N lines)` tail that counts visual rows
|
||||||
text: &str,
|
/// hidden by the fold (auto-wrap inclusive, so a single long paragraph
|
||||||
kind: MessageKind,
|
/// that wraps to many rows correctly reports the hidden rows).
|
||||||
mode: Mode,
|
|
||||||
) {
|
|
||||||
if matches!(mode, Mode::Detail) {
|
|
||||||
push_padded_lines(lines, text, kind);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let style = kind_style(kind);
|
|
||||||
let all: Vec<&str> = text.lines().collect();
|
|
||||||
let shown = all.len().min(NORMAL_MAX_LINES);
|
|
||||||
for raw in &all[..shown] {
|
|
||||||
lines.push(Line::from(Span::styled((*raw).to_owned(), style)));
|
|
||||||
}
|
|
||||||
if all.len() > shown {
|
|
||||||
let hidden = all.len() - shown;
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
format!("… +{hidden} more lines"),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if text.is_empty() {
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Single-line summary for overview mode. First non-empty line of the
|
|
||||||
/// source text, with an optional prefix (e.g. "> " for user messages).
|
|
||||||
fn push_overview_line(
|
fn push_overview_line(
|
||||||
lines: &mut Vec<Line<'static>>,
|
lines: &mut Vec<Line<'static>>,
|
||||||
text: &str,
|
text: &str,
|
||||||
|
width: u16,
|
||||||
kind: MessageKind,
|
kind: MessageKind,
|
||||||
prefix: &str,
|
prefix: &str,
|
||||||
) {
|
) {
|
||||||
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
|
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
|
||||||
let more = text.lines().count().saturating_sub(1);
|
let total_visual = count_visual_rows(text, width);
|
||||||
|
let first_visual = count_visual_rows(first, width);
|
||||||
|
|
||||||
|
// Budget for the first line's truncated content. Reserve columns
|
||||||
|
// for the prefix up front and for the "(+N lines)" tail if we'll
|
||||||
|
// emit one. `more` counts visual rows hidden by the fold; the one
|
||||||
|
// row we're about to render is subtracted.
|
||||||
|
let total_cols = width.max(1) as usize;
|
||||||
|
let prefix_width = UnicodeWidthStr::width(prefix);
|
||||||
|
let more = total_visual.saturating_sub(1);
|
||||||
|
let tentative_tail = if more > 0 {
|
||||||
|
format!(" (+{more} lines)")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let avail = total_cols.saturating_sub(prefix_width);
|
||||||
|
// On very narrow widths where the tail alone would eat the row,
|
||||||
|
// drop it — keeping at least some of the actual content visible
|
||||||
|
// matters more than the hidden-rows label.
|
||||||
|
let (tail, budget) = {
|
||||||
|
let tw = UnicodeWidthStr::width(tentative_tail.as_str());
|
||||||
|
if !tentative_tail.is_empty() && tw >= avail {
|
||||||
|
(String::new(), avail)
|
||||||
|
} else {
|
||||||
|
(tentative_tail, avail.saturating_sub(tw))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let first_width = UnicodeWidthStr::width(first);
|
||||||
|
let needs_truncation = first_width > budget || first_visual > 1;
|
||||||
|
let shown = if needs_truncation {
|
||||||
|
truncate_with_ellipsis(first, budget)
|
||||||
|
} else {
|
||||||
|
first.to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
let style = kind_style(kind);
|
let style = kind_style(kind);
|
||||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||||
if !prefix.is_empty() {
|
if !prefix.is_empty() {
|
||||||
spans.push(Span::styled(prefix.to_owned(), style));
|
spans.push(Span::styled(prefix.to_owned(), style));
|
||||||
}
|
}
|
||||||
spans.push(Span::styled(first.to_owned(), style));
|
spans.push(Span::styled(shown, style));
|
||||||
if more > 0 {
|
if !tail.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(tail, Style::default().fg(Color::DarkGray)));
|
||||||
format!(" (+{more} lines)"),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
lines.push(Line::from(spans));
|
lines.push(Line::from(spans));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_compact(lines: &mut Vec<Line<'static>>, evt: &CompactEvent, mode: Mode) {
|
/// Truncate `s` so its display width fits within `max_width`, appending
|
||||||
|
/// `…` when a cut is actually applied. A budget of 0 yields an empty
|
||||||
|
/// string; a budget of 1 drops the ellipsis and returns at most one
|
||||||
|
/// column of content so we never blow past the cap.
|
||||||
|
fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
|
||||||
|
if max_width == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let s_width = UnicodeWidthStr::width(s);
|
||||||
|
if s_width <= max_width {
|
||||||
|
return s.to_owned();
|
||||||
|
}
|
||||||
|
// Reserve 1 col for the ellipsis when we have room; on a 1-col
|
||||||
|
// budget there's no room for both content and the marker, so just
|
||||||
|
// clip to whatever single column fits.
|
||||||
|
let (inner_budget, append_ellipsis) = if max_width >= 2 {
|
||||||
|
(max_width - 1, true)
|
||||||
|
} else {
|
||||||
|
(max_width, false)
|
||||||
|
};
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut w = 0usize;
|
||||||
|
for c in s.chars() {
|
||||||
|
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
|
||||||
|
if w + cw > inner_budget {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
w += cw;
|
||||||
|
}
|
||||||
|
if append_ellipsis {
|
||||||
|
out.push('…');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual row count of `text` when rendered at `width` columns with
|
||||||
|
/// simple char-based wrap. Each empty logical line counts as 1 row.
|
||||||
|
fn count_visual_rows(text: &str, width: u16) -> usize {
|
||||||
|
if text.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let w = width.max(1) as usize;
|
||||||
|
let mut total = 0usize;
|
||||||
|
for line in text.lines() {
|
||||||
|
let lw = UnicodeWidthStr::width(line);
|
||||||
|
total += if lw == 0 { 1 } else { lw.div_ceil(w) };
|
||||||
|
}
|
||||||
|
total.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_compact(lines: &mut Vec<Line<'static>>, evt: &CompactEvent, width: u16, mode: Mode) {
|
||||||
let (text, kind) = match evt {
|
let (text, kind) = match evt {
|
||||||
CompactEvent::Start => ("[compact] starting".to_owned(), MessageKind::NoticeWarn),
|
CompactEvent::Start => ("[compact] starting".to_owned(), MessageKind::NoticeWarn),
|
||||||
CompactEvent::Done { new_session_id } => {
|
CompactEvent::Done { new_session_id } => {
|
||||||
|
|
@ -423,7 +491,7 @@ fn render_compact(lines: &mut Vec<Line<'static>>, evt: &CompactEvent, mode: Mode
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
match mode {
|
match mode {
|
||||||
Mode::Overview => push_overview_line(lines, &text, kind, ""),
|
Mode::Overview => push_overview_line(lines, &text, width, kind, ""),
|
||||||
_ => push_padded_lines(lines, &text, kind),
|
_ => push_padded_lines(lines, &text, kind),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user