Skip to content

Commit 4018f4e

Browse files
author
Yogthos
committed
ui: closed pills, opencode-style diff bg, fix mouse-report leak
Address every issue from the visual review: ## Closed pills (panel + tool chambers) Every framed region now closes properly on the right + bottom: - **Panel sections**: `╭─ MCP ─────╮` ... `│ items │` ... `╰───────────╯`. The right border + bottom border were missing before; sections felt unfinished. Each item row pads to the panel's inner width so the right `│` aligns vertically. - **Tool chambers**: top `╭─ READ ─ /path ──────────╮` extends with dashes out to the frame width, content rows `│ line │` pad/truncate to fit, bottom `╰────────╯` matches. - Chamber inner content longer than the frame is truncated with `…` instead of soft-wrapping out of the box. Frame width caps at 120 cols so wide terminals don't sprawl. Two helpers carry the logic: `chamber_widths(renderer)` returns `(frame_w, inner)` based on `renderer.line_width().min(120)`; `chamber_row(content, inner)` produces a single `│ ... │` row. Tool-call header, normal output, edit diffs, and truncation markers all route through the same helpers. ## Opencode-style diff backgrounds The edit-tool diff renderer now matches opencode's tinted-bg approach (`tint(bg, green, 0.15)` etc.): - `+` lines: background palette 22 (dim green) across the inner chamber width, fg bright green. - `-` lines: background palette 52 (dim red), fg bright red. - `--- ` / `+++ ` header lines: no bg, fg cyan. - `@@` hunk markers: no bg, fg dark cyan. - Context lines: no bg, fg dim phosphor. Backgrounds embed raw SGR escapes (`\x1b[48;5;Nm…\x1b[49m`) inside the row so the chamber's left/right borders stay in their normal color while the diff tint fills the inner width. New `chamber_row_with_bg(content, inner, bg_idx)` helper. ## Color fixes - **User input text** (what the user types into the input box) is now rendered in `theme::user()` (bright green) instead of the terminal's default tone, which read as off-white/grey. - **Assistant reply body** (markdown-rendered prose) now uses `theme::agent()` (bright green) instead of `Color::White`. All 13 `Color::White` references in `src/ui/markdown.rs` swept to `theme::agent()`; under `plain` theme the helper resolves back to White so the legacy look is preserved. ## Defensive mouse-report sanitization Bug from the screenshot where `[<65;79;32M[<65;79;32M…` smeared across tool output. SGR mouse reports (`\x1b[<btn;col;row(M|m)`) were leaking into the rendered text without their leading `\x1b` — most likely from a shell command in the agent's bash tool capturing terminal input bytes. `sanitize_output` now runs a pre-pass (`strip_orphan_mouse_reports`) that walks the text matching `[<digits;digits;digits(M|m)` and drops matched runs. Genuine `[` characters that don't start a mouse-report pattern pass through unchanged. ## Test plan - [x] `cargo test --features plugin` -> 599 pass, 0 fail. - [x] Both build profiles -> 0 warnings. - [ ] Eyeball: panel sections render as closed cards, tool chambers close on both sides, diff `+`/`-` lines show tinted bg strips, no `[<…M` smear in tool output.
1 parent 91e9b4c commit 4018f4e

4 files changed

Lines changed: 239 additions & 77 deletions

File tree

src/ui/events.rs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,16 @@ fn render_banner(renderer: &mut Renderer, provider: &str, model: &str) -> anyhow
194194
}
195195

196196
pub fn sanitize_output(text: &str) -> CompactString {
197-
let mut result = String::with_capacity(text.len());
198-
let mut chars = text.chars();
197+
// Two-pass: first strip orphan SGR mouse reports of the form
198+
// `[<digits;digits;digits(M|m)` (no leading escape). These can
199+
// leak into tool output when a shell command captures terminal
200+
// input bytes, and without this guard they smear `[<65;79;32M…`
201+
// through the chamber. Then run the regular ANSI/control-char
202+
// sanitizer over the cleaned text.
203+
let stripped = strip_orphan_mouse_reports(text);
204+
205+
let mut result = String::with_capacity(stripped.len());
206+
let mut chars = stripped.chars();
199207
while let Some(c) = chars.next() {
200208
if c == '\x1b' {
201209
match chars.next() {
@@ -217,3 +225,46 @@ pub fn sanitize_output(text: &str) -> CompactString {
217225
}
218226
CompactString::from(result)
219227
}
228+
229+
/// Strip orphan SGR mouse-report sequences (e.g. `[<65;79;32M`) that
230+
/// arrive without their leading `\x1b`. Walks the input scanning for
231+
/// the literal pattern `[<` followed by digits and semicolons ending
232+
/// in `M` or `m`; matched runs are dropped. Anything else passes
233+
/// through unchanged.
234+
fn strip_orphan_mouse_reports(text: &str) -> String {
235+
let bytes: Vec<char> = text.chars().collect();
236+
let mut out = String::with_capacity(text.len());
237+
let mut i = 0;
238+
while i < bytes.len() {
239+
if bytes[i] == '[' && i + 1 < bytes.len() && bytes[i + 1] == '<' {
240+
// Try to match `[<digits;digits;digits(M|m)`.
241+
let mut j = i + 2;
242+
let mut saw_digit_or_semi = false;
243+
while j < bytes.len() {
244+
let c = bytes[j];
245+
if c.is_ascii_digit() || c == ';' {
246+
saw_digit_or_semi = true;
247+
j += 1;
248+
} else if (c == 'M' || c == 'm') && saw_digit_or_semi {
249+
i = j + 1;
250+
break;
251+
} else {
252+
// Not a mouse report — pass `[` through and resume
253+
// scanning at the next position.
254+
out.push(bytes[i]);
255+
i += 1;
256+
break;
257+
}
258+
}
259+
if j >= bytes.len() {
260+
// Truncated input — pass through what we have.
261+
out.push(bytes[i]);
262+
i += 1;
263+
}
264+
} else {
265+
out.push(bytes[i]);
266+
i += 1;
267+
}
268+
}
269+
out
270+
}

src/ui/markdown.rs

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,17 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
8787
Event::Start(tag) => match tag {
8888
Tag::Paragraph => {}
8989
Tag::Heading { level: _, .. } => {
90-
flush_acc(&acc, Color::White, max_width, &mut result);
90+
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
9191
acc.clear();
9292
in_heading = true;
9393
}
9494
Tag::CodeBlock(_kind) => {
95-
flush_acc(&acc, Color::White, max_width, &mut result);
95+
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
9696
acc.clear();
9797
in_code_block = true;
9898
}
9999
Tag::BlockQuote(_) => {
100-
flush_acc(&acc, Color::White, max_width, &mut result);
100+
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
101101
acc.clear();
102102
in_blockquote = true;
103103
}
@@ -106,7 +106,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
106106
list_item_count = 0;
107107
}
108108
Tag::Item => {
109-
flush_acc(&acc, Color::White, max_width, &mut result);
109+
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
110110
acc.clear();
111111
list_item_count += 1;
112112
}
@@ -122,7 +122,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
122122
let color = if in_blockquote {
123123
Color::DarkGrey
124124
} else {
125-
Color::White
125+
crate::ui::theme::agent()
126126
};
127127
flush_acc(&acc, color, max_width, &mut result);
128128
acc.clear();
@@ -133,7 +133,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
133133
in_heading = false;
134134
result.push(LineEntry {
135135
text: CompactString::new(""),
136-
color: Color::White,
136+
color: crate::ui::theme::agent(),
137137
});
138138
}
139139
TagEnd::CodeBlock => {
@@ -155,7 +155,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
155155
in_code_block = false;
156156
result.push(LineEntry {
157157
text: CompactString::new(""),
158-
color: Color::White,
158+
color: crate::ui::theme::agent(),
159159
});
160160
}
161161
TagEnd::BlockQuote(_) => {
@@ -182,14 +182,14 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
182182
in_blockquote = false;
183183
result.push(LineEntry {
184184
text: CompactString::new(""),
185-
color: Color::White,
185+
color: crate::ui::theme::agent(),
186186
});
187187
}
188188
TagEnd::Item => {
189189
let color = if in_blockquote {
190190
Color::DarkGrey
191191
} else {
192-
Color::White
192+
crate::ui::theme::agent()
193193
};
194194
let bullet = if ordered_list {
195195
format!(" {}. ", list_item_count)
@@ -225,7 +225,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
225225
list_item_count = 0;
226226
result.push(LineEntry {
227227
text: CompactString::new(""),
228-
color: Color::White,
228+
color: crate::ui::theme::agent(),
229229
});
230230
}
231231
TagEnd::FootnoteDefinition => {}
@@ -257,7 +257,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
257257
}
258258
}
259259
Event::Rule => {
260-
flush_acc(&acc, Color::White, max_width, &mut result);
260+
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
261261
acc.clear();
262262
let rule: String = std::iter::repeat('─').take(max_width.min(40)).collect();
263263
result.push(LineEntry {
@@ -266,7 +266,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
266266
});
267267
result.push(LineEntry {
268268
text: CompactString::new(""),
269-
color: Color::White,
269+
color: crate::ui::theme::agent(),
270270
});
271271
}
272272
Event::Html(t) => {
@@ -297,7 +297,7 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
297297
} else if in_heading {
298298
Color::Cyan
299299
} else {
300-
Color::White
300+
crate::ui::theme::agent()
301301
};
302302
flush_acc(&acc, color, max_width, &mut result);
303303
}

src/ui/mod.rs

Lines changed: 126 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,17 +1440,27 @@ pub async fn run_interactive(
14401440
}
14411441
response_buf.clear();
14421442
response_start_line = None;
1443-
// Tool-call line: rounded chamber header with
1444-
// the tool name on the border. Output lines
1445-
// below get `│ ` chamber prefixes; the chamber
1446-
// is closed with `╰─` after the ToolResult.
1443+
// Tool-call line: rounded chamber TOP border
1444+
// with the tool name on it. Output lines below
1445+
// get `│ ` chamber rows; the chamber is closed
1446+
// by `╰────╯` after the ToolResult. Header
1447+
// border pads with dashes out to the frame
1448+
// width so it visually mates with the closing
1449+
// bottom border (matching btop's framed cards).
14471450
let upper = name.to_ascii_uppercase();
14481451
let summary = format_tool_call_summary(&name, &args);
14491452
let trimmed = summary
14501453
.strip_prefix(&format!("{} ", name))
14511454
.unwrap_or(&summary);
1452-
let line = format!("╭─ {} ─ {}", upper, trimmed);
1453-
renderer.write_line(&sanitize_output(&line), c_tool())?;
1455+
let pre = format!("╭─ {} ─ {} ", upper, trimmed);
1456+
let pre_clean = sanitize_output(&pre).into_string();
1457+
let (frame_w, _) = chamber_widths(&renderer);
1458+
let pre_len = pre_clean.chars().count();
1459+
let dashes = frame_w
1460+
.saturating_sub(pre_len + 1) // 1 for closing ╮
1461+
.max(0);
1462+
let header = format!("{}{}╮", pre_clean, "─".repeat(dashes));
1463+
renderer.write_line(&header, c_tool())?;
14541464

14551465
// Note: on-tool-start fires from HookedToolDyn now,
14561466
// around the actual tool invocation. The UI no
@@ -1480,38 +1490,58 @@ pub async fn run_interactive(
14801490
.iter()
14811491
.position(|l| l.starts_with("--- a/"));
14821492
if let Some(pre) = diff_start {
1483-
// Show non-diff prefix in the
1484-
// chamber so it sits visually
1485-
// attached to the tool-call banner.
1493+
let (frame_w, inner) = chamber_widths(&renderer);
1494+
// Pre-diff prose (the edit tool's
1495+
// header line, etc.) renders in
1496+
// the chamber's standard tone.
14861497
for l in &lines[..pre] {
14871498
if !l.is_empty() {
1499+
let txt = sanitize_output(l).into_string();
14881500
renderer.write_line(
1489-
&format!("│ {}", sanitize_output(l)),
1501+
&chamber_row(&txt, inner),
14901502
theme::result(),
14911503
)?;
14921504
}
14931505
}
1494-
// Show colorized diff. Each line gets
1495-
// the `│ ` chamber prefix so the diff
1496-
// sits inside the tool's frame.
1506+
// Colorized diff with opencode-style
1507+
// tinted backgrounds: + lines get a
1508+
// dim-green bg (palette 22), - lines
1509+
// get a dim-red bg (palette 52).
1510+
// Header (`--- ` / `+++ ` / `@@`) and
1511+
// context lines have no bg.
14971512
for l in &lines[pre..] {
1498-
let painted = if l.starts_with("--- ") || l.starts_with("+++ ") {
1499-
(Color::Cyan, sanitize_output(l).into_string())
1513+
let txt = sanitize_output(l).into_string();
1514+
if l.starts_with("--- ") || l.starts_with("+++ ") {
1515+
renderer.write_line(
1516+
&chamber_row(&txt, inner),
1517+
Color::Cyan,
1518+
)?;
15001519
} else if l.starts_with("@@") {
1501-
(Color::DarkCyan, sanitize_output(l).into_string())
1520+
renderer.write_line(
1521+
&chamber_row(&txt, inner),
1522+
Color::DarkCyan,
1523+
)?;
15021524
} else if l.starts_with('+') {
1503-
(Color::Green, sanitize_output(l).into_string())
1525+
renderer.write_line(
1526+
&chamber_row_with_bg(&txt, inner, 22),
1527+
Color::Green,
1528+
)?;
15041529
} else if l.starts_with('-') {
1505-
(Color::Red, sanitize_output(l).into_string())
1530+
renderer.write_line(
1531+
&chamber_row_with_bg(&txt, inner, 52),
1532+
Color::Red,
1533+
)?;
15061534
} else {
1507-
(theme::dim(), sanitize_output(l).into_string())
1508-
};
1509-
renderer.write_line(
1510-
&format!("│ {}", painted.1),
1511-
painted.0,
1512-
)?;
1535+
renderer.write_line(
1536+
&chamber_row(&txt, inner),
1537+
theme::dim(),
1538+
)?;
1539+
}
15131540
}
1514-
renderer.write_line("╰─", theme::dim())?;
1541+
renderer.write_line(
1542+
&chamber_bottom(frame_w),
1543+
theme::dim(),
1544+
)?;
15151545
} else {
15161546
// No diff section found, show normally
15171547
render_tool_output(
@@ -2610,21 +2640,85 @@ fn render_tool_output(
26102640
} else {
26112641
sanitized.chars().take(max_chars).collect()
26122642
};
2613-
// Tool output renders inside a rounded chamber attached to the
2614-
// tool-call header (`╭─ NAME ─ args` above). Every line gets a
2615-
// `│ ` chamber prefix; the chamber is closed by a `╰─` footer
2616-
// so the eye can scan tool boundaries top-to-bottom.
2643+
// Tool output renders inside a closed rounded chamber:
2644+
// ╭─ READ ─ /path
2645+
// │ contents ... │
2646+
// ╰─────────────────────────────────╯
2647+
// Lines are padded/truncated to a fixed inner width so the right
2648+
// border stays aligned across the chamber.
2649+
let (frame_w, inner) = chamber_widths(renderer);
26172650
for line in body.lines() {
2618-
renderer.write_line(&format!("│ {}", line), theme::result())?;
2651+
renderer.write_line(&chamber_row(line, inner), theme::result())?;
26192652
}
26202653
if char_count > max_chars {
26212654
let remaining = char_count - max_chars;
2622-
renderer.write_line(&format!("│ ░ +{} chars truncated", remaining), theme::dim())?;
2655+
let note = format!("░ +{} chars truncated", remaining);
2656+
renderer.write_line(&chamber_row(&note, inner), theme::dim())?;
26232657
}
2624-
renderer.write_line("╰─", theme::dim())?;
2658+
renderer.write_line(&chamber_bottom(frame_w), theme::dim())?;
26252659
Ok(())
26262660
}
26272661

2662+
/// Standard tool-chamber widths derived from the renderer's content
2663+
/// area. Capped at 120 so very wide terminals don't produce sprawling
2664+
/// chambers that overwhelm the content.
2665+
fn chamber_widths(renderer: &Renderer) -> (usize, usize) {
2666+
let term_w = renderer.line_width().max(20);
2667+
let frame_w = term_w.min(120);
2668+
let inner = frame_w.saturating_sub(4); // `│ ` + ` │`
2669+
(frame_w, inner)
2670+
}
2671+
2672+
/// `╰────────────╯` footer of a tool chamber, sized to `frame_w`.
2673+
fn chamber_bottom(frame_w: usize) -> String {
2674+
format!("╰{}╯", "─".repeat(frame_w.saturating_sub(2)))
2675+
}
2676+
2677+
/// `│ content (truncated/padded to inner) │` row of a tool chamber.
2678+
fn chamber_row(content: &str, inner: usize) -> String {
2679+
let chars: Vec<char> = content.chars().collect();
2680+
let trimmed: String = if chars.len() <= inner {
2681+
chars.iter().collect()
2682+
} else if inner == 0 {
2683+
String::new()
2684+
} else {
2685+
let mut out: String = chars[..inner.saturating_sub(1)].iter().collect();
2686+
out.push('…');
2687+
out
2688+
};
2689+
let pad = inner.saturating_sub(trimmed.chars().count());
2690+
format!("│ {}{} │", trimmed, " ".repeat(pad))
2691+
}
2692+
2693+
/// Background-tinted chamber row for diff `+`/`-` lines. Emits raw
2694+
/// SGR `48;5;{bg}` background sequence inside the row so the diff
2695+
/// tint fills the inner width; the left + right border glyphs sit
2696+
/// outside the bg span so they keep the chamber color.
2697+
///
2698+
/// Opencode uses subtly-tinted backgrounds (`tint(bg, green, 0.15)`
2699+
/// etc.) to mark added/removed lines without overwhelming the
2700+
/// scanability. We approximate that with the 256-color palette:
2701+
/// dim green (22) for adds, dim red (52) for removes.
2702+
fn chamber_row_with_bg(content: &str, inner: usize, bg_idx: u8) -> String {
2703+
let chars: Vec<char> = content.chars().collect();
2704+
let trimmed: String = if chars.len() <= inner {
2705+
chars.iter().collect()
2706+
} else if inner == 0 {
2707+
String::new()
2708+
} else {
2709+
let mut out: String = chars[..inner.saturating_sub(1)].iter().collect();
2710+
out.push('…');
2711+
out
2712+
};
2713+
let pad = inner.saturating_sub(trimmed.chars().count());
2714+
format!(
2715+
"│ \x1b[48;5;{}m{}{}\x1b[49m │",
2716+
bg_idx,
2717+
trimmed,
2718+
" ".repeat(pad),
2719+
)
2720+
}
2721+
26282722
fn update_search(renderer: &Renderer, query: &str, matches: &mut Vec<usize>, selected: &mut usize) {
26292723
if query.is_empty() {
26302724
matches.clear();

0 commit comments

Comments
 (0)