@@ -5,7 +5,7 @@ use ratatui::buffer::Buffer;
55use ratatui:: layout:: Rect ;
66use ratatui:: style:: { Color , Modifier , Style } ;
77use ratatui:: text:: { Line , Span } ;
8- use ratatui:: widgets:: { Block , Borders , Clear , Paragraph , Widget } ;
8+ use ratatui:: widgets:: { Block , Borders , Clear , Paragraph , Widget , Wrap } ;
99use 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.
309313fn 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).
368417pub 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
617687use 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
0 commit comments