@@ -74,6 +74,23 @@ export type TreeEntry = {
7474
7575const stripAnsi = ( s : string ) => s . replace ( / \x1B \[ \d + (?: ; \d + ) * m / g, "" )
7676
77+ function truncateAnsi ( s : string , maxWidth : number ) : string {
78+ let visible = 0
79+ let i = 0
80+ while ( i < s . length && visible < maxWidth ) {
81+ if ( s [ i ] === "\x1B" && s [ i + 1 ] === "[" ) {
82+ const end = s . indexOf ( "m" , i )
83+ if ( end !== - 1 ) {
84+ i = end + 1
85+ continue
86+ }
87+ }
88+ visible ++
89+ i ++
90+ }
91+ return s . slice ( 0 , i ) + "\x1B[0m"
92+ }
93+
7794type TreeNode = {
7895 name : string
7996 path : string
@@ -372,7 +389,12 @@ export function interactivePicker(
372389 resolve ( [ ] )
373390 process . exit ( 0 )
374391 }
375- const onResize = ( ) => render ( )
392+ let inPreview = false
393+ let previewRenderer : ( ( ) => void ) | null = null
394+ const onResize = ( ) => {
395+ if ( inPreview && previewRenderer ) previewRenderer ( )
396+ else render ( )
397+ }
376398 process . on ( "exit" , onExit )
377399 process . on ( "SIGINT" , onSigint )
378400 stream . on ( "resize" , onResize )
@@ -490,10 +512,12 @@ export function interactivePicker(
490512 }
491513
492514 async function showPreview ( node : TreeNode ) {
493- const filePath = path . join (
494- basePath ! ,
495- node . type === "symlink" ? node . linkTarget . replace ( / \/ $ / , "" ) : node . path ,
496- )
515+ // Remove stdin listener immediately to prevent race during async highlight
516+ stdin . removeListener ( "data" , onKey )
517+
518+ const resolvedPath =
519+ node . type === "symlink" ? resolveSymlinkPath ( node . path , node . linkTarget ) : node . path
520+ const filePath = path . join ( basePath ! , resolvedPath )
497521 let content : string
498522 try {
499523 const stat = fs . statSync ( filePath )
@@ -552,11 +576,13 @@ export function interactivePicker(
552576 const lineIdx = previewScrollOffset + i
553577 const isCursorLine = lineIdx === previewCursor
554578 const lineNum = dim ( ` ${ String ( lineIdx + 1 ) . padStart ( lineNumWidth ) } ` )
555- const lineContent = lines [ lineIdx ] . slice ( 0 , cols - lineNumWidth - 5 )
579+ const lineContent = truncateAnsi ( lines [ lineIdx ] , cols - lineNumWidth - 5 )
556580 let line = `${ lineNum } ${ lineContent } `
557581 if ( isCursorLine ) {
558- const pad = Math . max ( 0 , cols - stripAnsi ( line ) . length )
559- line = `\x1B[48;5;236m${ line } ${ " " . repeat ( pad ) } \x1B[49m`
582+ // Strip trailing resets so bg extends fully
583+ const cleaned = line . replace ( / \x1B \[ 0 m / g, "" )
584+ const pad = Math . max ( 0 , cols - stripAnsi ( cleaned ) . length )
585+ line = `\x1B[48;5;236m${ cleaned } ${ " " . repeat ( pad ) } \x1B[0m`
560586 }
561587 out += line + "\n"
562588 }
@@ -582,7 +608,21 @@ export function interactivePicker(
582608 function onPreviewKey ( buf : Buffer ) {
583609 const key = buf . toString ( )
584610
611+ // Ctrl-C in preview
612+ if ( key === "\x03" ) {
613+ inPreview = false
614+ previewRenderer = null
615+ stdin . removeListener ( "data" , onPreviewKey )
616+ cleanup ( )
617+ process . removeListener ( "exit" , onExit )
618+ process . removeListener ( "SIGINT" , onSigint )
619+ resolve ( [ ] )
620+ return
621+ }
622+
585623 if ( key === "\x1B" || key === "q" || key === "Q" || key === "\r" ) {
624+ inPreview = false
625+ previewRenderer = null
586626 stdin . removeListener ( "data" , onPreviewKey )
587627 stdin . on ( "data" , onKey )
588628 render ( )
@@ -599,7 +639,8 @@ export function interactivePicker(
599639 renderPreview ( )
600640 }
601641
602- stdin . removeListener ( "data" , onKey )
642+ inPreview = true
643+ previewRenderer = renderPreview
603644 stdin . on ( "data" , onPreviewKey )
604645 renderPreview ( )
605646 }
0 commit comments