@@ -235,7 +235,7 @@ function formatPreviewContent(
235235 scrollOffset : number ,
236236 previewWidth : number ,
237237 searchTerm ?: string
238- ) : { lines : string [ ] ; totalLines : number ; firstMatchDisplayLine ? : number } {
238+ ) : { lines : string [ ] ; totalLines : number ; matchDisplayLines : number [ ] } {
239239 const allLines = content . split ( "\n" ) ;
240240 const totalLines = allLines . length ;
241241
@@ -245,17 +245,17 @@ function formatPreviewContent(
245245
246246 // Build display lines with wrapping
247247 const displayLines : string [ ] = [ ] ;
248- let firstMatchDisplayLine : number | undefined ;
248+ const matchDisplayLines : number [ ] = [ ] ;
249249 const lowerSearch = searchTerm ?. toLowerCase ( ) ;
250250
251251 for ( let lineIdx = 0 ; lineIdx < allLines . length ; lineIdx ++ ) {
252252 const line = allLines [ lineIdx ] ! ;
253253 const lineNum = lineIdx + 1 ;
254254 const lineNumStr = String ( lineNum ) . padStart ( lineNumWidth , " " ) ;
255255
256- // Track display line of first match (before wrapping adds more lines)
257- if ( firstMatchDisplayLine === undefined && lowerSearch && line . toLowerCase ( ) . includes ( lowerSearch ) ) {
258- firstMatchDisplayLine = displayLines . length ;
256+ // Track display line of each match (before wrapping adds more lines)
257+ if ( lowerSearch && line . toLowerCase ( ) . includes ( lowerSearch ) ) {
258+ matchDisplayLines . push ( displayLines . length ) ;
259259 }
260260
261261 // Apply syntax highlighting
@@ -287,7 +287,7 @@ function formatPreviewContent(
287287 visibleLines . push ( "" ) ;
288288 }
289289
290- return { lines : visibleLines , totalLines : displayLines . length , firstMatchDisplayLine } ;
290+ return { lines : visibleLines , totalLines : displayLines . length , matchDisplayLines } ;
291291}
292292
293293// Cache for lowercase file content (avoid repeated toLowerCase calls)
@@ -434,6 +434,8 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
434434 mode : "name" ,
435435 } ) ;
436436 const { filter, mode : searchMode } = searchState ;
437+ // Current match index for Tab/Shift+Tab cycling in content mode
438+ const [ matchIndex , setMatchIndex ] = useState ( 0 ) ;
437439
438440 // Filter and sort files by match score (best matches first)
439441 const scoreFn = searchMode === "content" ? getContentMatchScore : getMatchScore ;
@@ -480,8 +482,29 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
480482 return ;
481483 }
482484
483- // Tab or Ctrl+E to edit the file in $EDITOR
484- if ( key . name === "tab" || ( key . ctrl && key . name === "e" ) ) {
485+ // Tab: In content mode, cycle to next match. Otherwise, edit file.
486+ if ( key . name === "tab" ) {
487+ if ( searchMode === "content" && filter ) {
488+ // Cycle to next match (will wrap in render based on match count)
489+ setMatchIndex ( matchIndex + 1 ) ;
490+ setPreviewScroll ( 0 ) ; // Reset scroll so auto-scroll takes effect
491+ } else if ( currentFile ) {
492+ done ( { action : "edit" , path : currentFile . path } ) ;
493+ }
494+ return ;
495+ }
496+
497+ // Shift+Tab: In content mode, cycle to previous match
498+ if ( extKey . shift && extKey . sequence === "\x1b[Z" ) {
499+ if ( searchMode === "content" && filter ) {
500+ setMatchIndex ( matchIndex - 1 ) ;
501+ setPreviewScroll ( 0 ) ;
502+ }
503+ return ;
504+ }
505+
506+ // Ctrl+E to edit the file in $EDITOR (works in all modes)
507+ if ( key . ctrl && key . name === "e" ) {
485508 if ( currentFile ) {
486509 done ( { action : "edit" , path : currentFile . path } ) ;
487510 }
@@ -522,13 +545,15 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
522545 setSearchState ( { ...searchState , filter : filter . slice ( 0 , - 1 ) } ) ;
523546 setCursor ( 0 ) ;
524547 setPreviewScroll ( 0 ) ;
548+ setMatchIndex ( 0 ) ;
525549 return ;
526550 }
527551
528552 // Escape: clear filter and exit content mode (single atomic update)
529553 if ( key . name === "escape" ) {
530554 if ( searchMode === "content" || filter ) {
531555 setSearchState ( { filter : "" , mode : "name" } ) ;
556+ setMatchIndex ( 0 ) ;
532557 if ( filter ) {
533558 setCursor ( 0 ) ;
534559 setPreviewScroll ( 0 ) ;
@@ -540,6 +565,7 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
540565 // "/" to toggle content search mode
541566 if ( extKey . sequence === "/" && ! filter ) {
542567 setSearchState ( { ...searchState , mode : searchMode === "content" ? "name" : "content" } ) ;
568+ setMatchIndex ( 0 ) ;
543569 return ;
544570 }
545571
@@ -550,6 +576,7 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
550576 setSearchState ( { ...searchState , filter : filter + char } ) ;
551577 setCursor ( 0 ) ;
552578 setPreviewScroll ( 0 ) ;
579+ setMatchIndex ( 0 ) ; // Reset to first match on filter change
553580 }
554581 }
555582 } ) ;
@@ -627,20 +654,26 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
627654 let previewHeader = "" ;
628655 let previewFooter = "" ;
629656 let totalLines = 0 ;
657+ let matchCount = 0 ;
658+ let currentMatchIdx = 0 ;
630659
631660 if ( currentFile ) {
632661 const content = readFileContentSync ( currentFile . path ) ;
633662 const searchTerm = searchMode === "content" && filter ? filter : undefined ;
634663 const previewContentHeight = contentHeight - 2 ; // Leave room for header and footer
635664
636- // Calculate effective scroll - auto-scroll to first match in content mode
665+ // Calculate effective scroll - auto-scroll to current match in content mode
637666 let effectiveScroll = previewScroll ;
638667 if ( searchTerm && previewScroll === 0 ) {
639- // First pass: find first match display line
668+ // First pass: get all match positions
640669 const firstPass = formatPreviewContent ( content , previewContentHeight , 0 , previewWidth , searchTerm ) ;
641- if ( firstPass . firstMatchDisplayLine !== undefined ) {
670+ matchCount = firstPass . matchDisplayLines . length ;
671+ if ( matchCount > 0 ) {
672+ // Wrap matchIndex to valid range
673+ currentMatchIdx = ( ( matchIndex % matchCount ) + matchCount ) % matchCount ;
674+ const targetLine = firstPass . matchDisplayLines [ currentMatchIdx ] ! ;
642675 // Scroll to show match with some context above (3 lines)
643- effectiveScroll = Math . max ( 0 , firstPass . firstMatchDisplayLine - 3 ) ;
676+ effectiveScroll = Math . max ( 0 , targetLine - 3 ) ;
644677 }
645678 }
646679
@@ -653,10 +686,19 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
653686 ) ;
654687 previewLines = formatted . lines ;
655688 totalLines = formatted . totalLines ;
689+ // Update match count from final pass (in case first pass was skipped)
690+ if ( searchTerm && matchCount === 0 ) {
691+ matchCount = formatted . matchDisplayLines . length ;
692+ }
656693
657- // Header: shortened path
694+ // Header: shortened path + match indicator in content mode
658695 const shortPath = shortenPath ( currentFile . path ) ;
659- previewHeader = `\x1b[1m\x1b[34m${ shortPath } \x1b[0m` ;
696+ const matchIndicator = searchTerm && matchCount > 0
697+ ? ` \x1b[33m[${ currentMatchIdx + 1 } /${ matchCount } ]\x1b[0m`
698+ : searchTerm && matchCount === 0
699+ ? ` \x1b[90m[no matches]\x1b[0m`
700+ : "" ;
701+ previewHeader = `\x1b[1m\x1b[34m${ shortPath } \x1b[0m${ matchIndicator } ` ;
660702
661703 // Footer: scroll position
662704 const scrollPct =
@@ -681,8 +723,8 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
681723 : searchMode === "content"
682724 ? `${ modeIndicator } \x1b[90mType to search file contents...\x1b[0m`
683725 : `\x1b[90mType to filter...\x1b[0m` ;
684- const matchCount = `\x1b[90m(${ filteredFiles . length } /${ files . length } )\x1b[0m` ;
685- outputLines . push ( `${ prefix } ${ config . message } ${ matchCount } ${ filterDisplay } ` ) ;
726+ const fileCountDisplay = `\x1b[90m(${ filteredFiles . length } /${ files . length } )\x1b[0m` ;
727+ outputLines . push ( `${ prefix } ${ config . message } ${ fileCountDisplay } ${ filterDisplay } ` ) ;
686728 outputLines . push ( "" ) ;
687729
688730 for ( let i = 0 ; i < contentHeight ; i ++ ) {
@@ -704,8 +746,12 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
704746 // Help line with styled keys (inverse video for keys)
705747 const k = ( t : string ) => `\x1b[7m ${ t } \x1b[27m` ;
706748 outputLines . push ( "" ) ;
749+ // Show different Tab hint in content mode (cycles matches vs edit)
750+ const tabHint = searchMode === "content" && filter
751+ ? `${ k ( "Tab" ) } Next ${ k ( "S-Tab" ) } Prev`
752+ : `${ k ( "Tab" ) } Edit` ;
707753 outputLines . push (
708- `${ k ( "↑↓" ) } Nav ${ k ( "Enter" ) } Run ${ k ( "^R" ) } Dry ${ k ( "Tab" ) } Edit ${ k ( "/" ) } Content ${ k ( "Esc" ) } Clear`
754+ `${ k ( "↑↓" ) } Nav ${ k ( "Enter" ) } Run ${ k ( "^R" ) } Dry ${ tabHint } ${ k ( "/" ) } Content ${ k ( "Esc" ) } Clear`
709755 ) ;
710756
711757 return outputLines . join ( "\n" ) ;
0 commit comments