Skip to content

Commit faa893f

Browse files
committed
fix(tui): finally fixed rustle looking right animation
1 parent 5f7a35d commit faa893f

2 files changed

Lines changed: 138 additions & 13 deletions

File tree

src-rust/crates/tui/src/dialogs.rs

Lines changed: 119 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use ratatui::buffer::Buffer;
55
use ratatui::layout::Rect;
66
use ratatui::style::{Color, Modifier, Style};
77
use ratatui::text::{Line, Span};
8-
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
8+
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
99
use ratatui::Frame;
1010

1111
// ---------------------------------------------------------------------------
@@ -306,16 +306,65 @@ fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
306306
}
307307
}
308308

309+
/// Wrap `text` to fit within `width` display columns, preferring whitespace
310+
/// breaks but falling back to a hard character break when a single token is
311+
/// longer than `width`. Without the hard-break fallback, long unbreakable
312+
/// tokens (Windows paths, base64 blobs, URLs, …) overflow the dialog border.
309313
fn word_wrap(text: &str, width: usize) -> Vec<String> {
310-
use unicode_width::UnicodeWidthStr;
311-
if width == 0 || UnicodeWidthStr::width(text) <= width {
314+
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
315+
if width == 0 {
312316
return vec![text.to_string()];
313317
}
318+
if UnicodeWidthStr::width(text) <= width {
319+
return vec![text.to_string()];
320+
}
321+
322+
// Hard-break a token that doesn't fit on a line of `width` columns,
323+
// returning the chunks each ≤ `width` cells wide. Splits at character
324+
// boundaries — never inside a grapheme cluster.
325+
fn break_long_token(token: &str, width: usize) -> Vec<String> {
326+
let mut chunks: Vec<String> = Vec::new();
327+
let mut current = String::new();
328+
let mut current_w = 0usize;
329+
for ch in token.chars() {
330+
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
331+
if current_w + cw > width && !current.is_empty() {
332+
chunks.push(std::mem::take(&mut current));
333+
current_w = 0;
334+
}
335+
current.push(ch);
336+
current_w += cw;
337+
}
338+
if !current.is_empty() {
339+
chunks.push(current);
340+
}
341+
chunks
342+
}
343+
314344
let mut result = Vec::new();
315345
let mut current_line = String::new();
316346
let mut current_width = 0usize;
317347
for word in text.split_whitespace() {
318348
let word_w = UnicodeWidthStr::width(word);
349+
350+
// Long unbreakable token — flush the current line then hard-break the
351+
// token across multiple lines.
352+
if word_w > width {
353+
if !current_line.is_empty() {
354+
result.push(std::mem::take(&mut current_line));
355+
current_width = 0;
356+
}
357+
let mut chunks = break_long_token(word, width);
358+
if let Some(last) = chunks.pop() {
359+
for chunk in chunks {
360+
result.push(chunk);
361+
}
362+
current_width = UnicodeWidthStr::width(last.as_str());
363+
current_line = last;
364+
}
365+
continue;
366+
}
367+
319368
if current_width == 0 {
320369
current_line.push_str(word);
321370
current_width = word_w;
@@ -366,22 +415,37 @@ fn word_wrap(text: &str, width: usize) -> Vec<String> {
366415
/// For `FileRead`, only 3 options (once / session / deny).
367416
/// For `FileWrite`, 4 options (once / session / project / deny).
368417
pub fn render_permission_dialog(frame: &mut Frame, pr: &PermissionRequest, area: Rect) {
369-
let inner_width = 62u16;
370-
let dialog_width = inner_width.min(area.width.saturating_sub(4));
418+
// Scale dialog width with the terminal: minimum 40 cols for narrow screens,
419+
// maximum 80 cols on wide ones, otherwise leave a 4-col margin on each side.
420+
// Without this the dialog was pinned at 62 cols, which made long commands
421+
// (Windows paths, multi-segment shell pipelines) overflow even when the
422+
// terminal had plenty of room.
423+
let dialog_width = area
424+
.width
425+
.saturating_sub(8)
426+
.clamp(40, 80)
427+
.min(area.width.saturating_sub(4));
371428
let text_width = (dialog_width as usize).saturating_sub(4); // 2 border + 2 padding
372429

373430
// Build a command block for Bash / PowerShell dialogs to prominently display the command.
431+
// The chevron-prefix is only painted on the FIRST wrapped line; continuation
432+
// lines align under the command body so the eye can scan the full command
433+
// without the prompt-arrow repeating on every row.
374434
let bash_command_lines: Option<Vec<Line>> = match &pr.kind {
375435
PermissionDialogKind::Bash { command, .. }
376436
| PermissionDialogKind::PowerShell { command } => {
377-
let wrapped = word_wrap(command, text_width.saturating_sub(4));
437+
let cmd_indent = " ";
438+
let wrap_width = text_width.saturating_sub(cmd_indent.len());
439+
let wrapped = word_wrap(command, wrap_width);
378440
Some(
379441
wrapped
380442
.into_iter()
381-
.map(|line| {
443+
.enumerate()
444+
.map(|(i, line)| {
445+
let prefix = if i == 0 { " \u{276F} " } else { cmd_indent };
382446
Line::from(vec![
383447
Span::styled(
384-
" \u{276F} ",
448+
prefix,
385449
Style::default()
386450
.fg(Color::Green)
387451
.add_modifier(Modifier::BOLD),
@@ -551,7 +615,13 @@ pub fn render_permission_dialog(frame: &mut Frame, pr: &PermissionRequest, area:
551615
))
552616
.border_style(Style::default().fg(border_color));
553617

554-
let para = Paragraph::new(lines).block(block);
618+
// `Wrap { trim: false }` is a defensive safety net: word_wrap already
619+
// breaks every span to fit, but if a future change introduces an
620+
// un-wrapped line (e.g. a tool-emitted preview), ratatui will still wrap
621+
// it at the dialog border instead of letting it bleed past the right edge.
622+
let para = Paragraph::new(lines)
623+
.block(block)
624+
.wrap(Wrap { trim: false });
555625
frame.render_widget(para, dialog_area);
556626
}
557627

@@ -615,7 +685,6 @@ pub fn handle_permission_key(pr: &mut PermissionRequest, key: KeyEvent) -> bool
615685
// ---------------------------------------------------------------------------
616686

617687
use ratatui::layout::{Constraint, Direction, Layout};
618-
use ratatui::widgets::Wrap;
619688

620689
/// Which tool-specific permission dialog is active.
621690
#[derive(Debug, Clone)]
@@ -1320,10 +1389,49 @@ mod tests {
13201389

13211390
#[test]
13221391
fn word_wrap_long_text_splits() {
1392+
use unicode_width::UnicodeWidthStr;
13231393
let text = "one two three four five six seven eight";
13241394
let wrapped = word_wrap(text, 10);
13251395
for line in &wrapped {
1326-
assert!(line.len() <= 10, "Line too long: {:?}", line);
1396+
assert!(
1397+
UnicodeWidthStr::width(line.as_str()) <= 10,
1398+
"Line too long: {:?}",
1399+
line
1400+
);
1401+
}
1402+
}
1403+
1404+
#[test]
1405+
fn word_wrap_hard_breaks_token_longer_than_width() {
1406+
use unicode_width::UnicodeWidthStr;
1407+
// A single token wider than the available width must be hard-broken at
1408+
// character boundaries — otherwise it overflows the dialog border (the
1409+
// bug that produced `~X~:~\~B~i~g~g~e~r~…`-style wrapping reports).
1410+
let path = "'X:\\Bigger-Projects\\some-very-long-directory-name'";
1411+
let wrapped = word_wrap(path, 16);
1412+
assert!(wrapped.len() >= 2, "expected hard-break, got: {wrapped:?}");
1413+
for line in &wrapped {
1414+
assert!(
1415+
UnicodeWidthStr::width(line.as_str()) <= 16,
1416+
"hard-broken chunk too wide: {line:?}"
1417+
);
1418+
}
1419+
// Round-trip: concatenating chunks should rebuild the token verbatim.
1420+
assert_eq!(wrapped.join(""), path);
1421+
}
1422+
1423+
#[test]
1424+
fn word_wrap_mixed_short_and_long_tokens() {
1425+
use unicode_width::UnicodeWidthStr;
1426+
// The realistic shape that broke claurst dialogs: a normal command
1427+
// followed by a path longer than the column budget.
1428+
let cmd = "git diff 'X:\\Bigger-Projects\\Claurst\\very\\deep\\nested\\path.rs'";
1429+
let wrapped = word_wrap(cmd, 24);
1430+
for line in &wrapped {
1431+
assert!(
1432+
UnicodeWidthStr::width(line.as_str()) <= 24,
1433+
"line wider than width: {line:?}"
1434+
);
13271435
}
13281436
}
13291437

src-rust/crates/tui/src/rustle.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,30 @@ fn eye_spans(s: &'static str) -> Vec<Span<'static>> {
5757
let mut buf_is_eyeball = false;
5858

5959
for ch in s.chars() {
60-
let is_eyeball = ch == '▘' || ch == '▝' || ch == '▀' || ch == '▄' || ch == '▖';
60+
let is_eyeball =
61+
ch == '▘'
62+
|| ch == '▝'
63+
|| ch == '▀'
64+
|| ch == '▄'
65+
|| ch == '▖'
66+
|| ch == '▌'
67+
|| ch == '▐';
68+
6169
if is_eyeball != buf_is_eyeball && !buf.is_empty() {
6270
let style = if buf_is_eyeball { eyeball_style() } else { eye_bg_style() };
6371
spans.push(Span::styled(buf.clone(), style));
6472
buf.clear();
6573
}
74+
6675
buf_is_eyeball = is_eyeball;
6776
buf.push(ch);
6877
}
78+
6979
if !buf.is_empty() {
7080
let style = if buf_is_eyeball { eyeball_style() } else { eye_bg_style() };
7181
spans.push(Span::styled(buf, style));
7282
}
83+
7384
spans
7485
}
7586

@@ -173,7 +184,7 @@ pub fn rustle_lines(pose: &RustlePose) -> [Line<'static>; 5] {
173184
),
174185
RustlePose::LookRight => (
175186
"█▄█",
176-
"▝ █ ▝", // single-pixel upper-right quarter blocks = eyes shifted right
187+
" ▀█ ▀", // mirror of Default: upper-half blocks, shifted right
177188
"█▄█",
178189
),
179190
RustlePose::LookDown => (
@@ -240,4 +251,10 @@ mod tests {
240251
let lines = rustle_lines(&RustlePose::ArmsUp);
241252
assert_eq!(line_text(&lines[1]), "█▀█▀ █▀ █▀█");
242253
}
254+
255+
#[test]
256+
fn look_right_pose_eyes_match_default_size() {
257+
let lines = rustle_lines(&RustlePose::LookRight);
258+
assert_eq!(line_text(&lines[1]), "█▄█ ▀█ ▀█▄█");
259+
}
243260
}

0 commit comments

Comments
 (0)