@@ -328,6 +328,162 @@ describe("MultilineTextInput", () => {
328328 } )
329329} )
330330
331+ describe ( "word-boundary line wrapping" , ( ) => {
332+ // Represents a visual row after wrapping a logical line
333+ interface VisualRow {
334+ text : string
335+ logicalLineIndex : number
336+ isFirstRowOfLine : boolean
337+ startCol : number
338+ }
339+
340+ /**
341+ * Wrap a logical line into visual rows based on available width.
342+ * Uses word-boundary wrapping: prefers to break at spaces rather than
343+ * in the middle of words.
344+ */
345+ function wrapLine ( lineText : string , logicalLineIndex : number , availableWidth : number ) : VisualRow [ ] {
346+ if ( availableWidth <= 0 || lineText . length <= availableWidth ) {
347+ return [
348+ {
349+ text : lineText ,
350+ logicalLineIndex,
351+ isFirstRowOfLine : true ,
352+ startCol : 0 ,
353+ } ,
354+ ]
355+ }
356+
357+ const rows : VisualRow [ ] = [ ]
358+ let remaining = lineText
359+ let startCol = 0
360+ let isFirst = true
361+
362+ while ( remaining . length > 0 ) {
363+ if ( remaining . length <= availableWidth ) {
364+ rows . push ( {
365+ text : remaining ,
366+ logicalLineIndex,
367+ isFirstRowOfLine : isFirst ,
368+ startCol,
369+ } )
370+ break
371+ }
372+
373+ // Find a good break point - prefer breaking at a space
374+ let breakPoint = availableWidth
375+
376+ // Look backwards from availableWidth for a space
377+ const searchStart = Math . min ( availableWidth , remaining . length )
378+ let spaceIndex = - 1
379+ for ( let i = searchStart - 1 ; i >= 0 ; i -- ) {
380+ if ( remaining [ i ] === " " ) {
381+ spaceIndex = i
382+ break
383+ }
384+ }
385+
386+ if ( spaceIndex > 0 ) {
387+ // Found a space - break after it (include the space in this row)
388+ breakPoint = spaceIndex + 1
389+ }
390+ // else: no space found, break at availableWidth (mid-word break as fallback)
391+
392+ const chunk = remaining . slice ( 0 , breakPoint )
393+ rows . push ( {
394+ text : chunk ,
395+ logicalLineIndex,
396+ isFirstRowOfLine : isFirst ,
397+ startCol,
398+ } )
399+
400+ remaining = remaining . slice ( breakPoint )
401+ startCol += breakPoint
402+ isFirst = false
403+ }
404+
405+ return rows
406+ }
407+
408+ it ( "should not wrap text shorter than available width" , ( ) => {
409+ const rows = wrapLine ( "hello world" , 0 , 20 )
410+ expect ( rows ) . toHaveLength ( 1 )
411+ expect ( rows [ 0 ] ! . text ) . toBe ( "hello world" )
412+ expect ( rows [ 0 ] ! . isFirstRowOfLine ) . toBe ( true )
413+ expect ( rows [ 0 ] ! . startCol ) . toBe ( 0 )
414+ } )
415+
416+ it ( "should wrap at word boundary when possible" , ( ) => {
417+ const rows = wrapLine ( "hello world foo" , 0 , 10 )
418+ expect ( rows ) . toHaveLength ( 2 )
419+ expect ( rows [ 0 ] ! . text ) . toBe ( "hello " )
420+ expect ( rows [ 0 ] ! . isFirstRowOfLine ) . toBe ( true )
421+ expect ( rows [ 0 ] ! . startCol ) . toBe ( 0 )
422+ expect ( rows [ 1 ] ! . text ) . toBe ( "world foo" )
423+ expect ( rows [ 1 ] ! . isFirstRowOfLine ) . toBe ( false )
424+ expect ( rows [ 1 ] ! . startCol ) . toBe ( 6 ) // "hello " is 6 chars
425+ } )
426+
427+ it ( "should break mid-word when no space found" , ( ) => {
428+ const rows = wrapLine ( "superlongwordwithoutspaces" , 0 , 10 )
429+ expect ( rows ) . toHaveLength ( 3 )
430+ // Falls back to breaking at availableWidth when no space is found
431+ expect ( rows [ 0 ] ! . text ) . toBe ( "superlongw" )
432+ expect ( rows [ 1 ] ! . text ) . toBe ( "ordwithout" )
433+ expect ( rows [ 2 ] ! . text ) . toBe ( "spaces" )
434+ } )
435+
436+ it ( "should handle multiple word wraps" , ( ) => {
437+ const rows = wrapLine ( "one two three four five six" , 0 , 8 )
438+ expect ( rows ) . toHaveLength ( 4 )
439+ expect ( rows [ 0 ] ! . text ) . toBe ( "one two " )
440+ expect ( rows [ 1 ] ! . text ) . toBe ( "three " )
441+ expect ( rows [ 2 ] ! . text ) . toBe ( "four " )
442+ expect ( rows [ 3 ] ! . text ) . toBe ( "five six" )
443+ } )
444+
445+ it ( "should preserve logical line index" , ( ) => {
446+ const rows = wrapLine ( "hello world" , 2 , 6 )
447+ expect ( rows . every ( ( r ) => r . logicalLineIndex === 2 ) ) . toBe ( true )
448+ } )
449+
450+ it ( "should handle empty string" , ( ) => {
451+ const rows = wrapLine ( "" , 0 , 10 )
452+ expect ( rows ) . toHaveLength ( 1 )
453+ expect ( rows [ 0 ] ! . text ) . toBe ( "" )
454+ } )
455+
456+ it ( "should handle string that exactly matches width" , ( ) => {
457+ const rows = wrapLine ( "hello" , 0 , 5 )
458+ expect ( rows ) . toHaveLength ( 1 )
459+ expect ( rows [ 0 ] ! . text ) . toBe ( "hello" )
460+ } )
461+
462+ it ( "should track correct startCol for wrapped rows" , ( ) => {
463+ const rows = wrapLine ( "aa bb cc dd" , 0 , 5 )
464+ // "aa bb cc dd" = 11 chars, width = 5
465+ // "aa bb cc dd": a(0) a(1) ' '(2) b(3) b(4) ' '(5) c(6) c(7) ' '(8) d(9) d(10)
466+ // Search backwards from index 4:
467+ // index 4='b', 3='b', 2=' ' -> space at 2, breakPoint=3
468+ // Row 0: "aa " (3 chars), startCol=0
469+ // Remaining: "bb cc dd" (8 chars), startCol=3
470+ // Search backwards from index 4:
471+ // "bb cc dd": b(0) b(1) ' '(2) c(3) c(4)...
472+ // index 4='c', 3='c', 2=' ' -> space at 2, breakPoint=3
473+ // Row 1: "bb " (3 chars), startCol=3
474+ // Remaining: "cc dd" (5 chars), startCol=6
475+ // 5 <= 5, fits in one row
476+ // Row 2: "cc dd", startCol=6
477+ expect ( rows ) . toHaveLength ( 3 )
478+ expect ( rows [ 0 ] ! . text ) . toBe ( "aa " )
479+ expect ( rows [ 0 ] ! . startCol ) . toBe ( 0 )
480+ expect ( rows [ 1 ] ! . text ) . toBe ( "bb " )
481+ expect ( rows [ 1 ] ! . startCol ) . toBe ( 3 )
482+ expect ( rows [ 2 ] ! . text ) . toBe ( "cc dd" )
483+ expect ( rows [ 2 ] ! . startCol ) . toBe ( 6 )
484+ } )
485+ } )
486+
331487describe ( "multi-line history integration" , ( ) => {
332488 it ( "should store multi-line entries with newlines" , ( ) => {
333489 const entry = "foo\nbar\nbaz"
0 commit comments