99 "sort"
1010 "strings"
1111 "time"
12+ "unicode/utf8"
1213
1314 tea "charm.land/bubbletea/v2"
1415 "github.com/entireio/cli/cmd/entire/cli/api"
@@ -125,6 +126,7 @@ branch:<name>, repo:<owner/name>, and repo:* to search all accessible repos.`,
125126 query : codeQuery ,
126127 repoFilters : codeRepos ,
127128 limit : limitFlag ,
129+ limitExplicit : cmd .Flags ().Changed ("limit" ),
128130 caseSensitive : caseSensitive ,
129131 jsonOutput : jsonOutput ,
130132 insecureHTTP : insecureHTTPAuth ,
@@ -375,6 +377,7 @@ type codeSearchOpts struct {
375377 repoFilters []string
376378 resolvedRepoIDs []string // ULIDs resolved from repoFilters via repo index
377379 limit int
380+ limitExplicit bool // user passed --limit; don't override for text display
378381 caseSensitive bool
379382 jsonOutput bool
380383 insecureHTTP bool
@@ -476,6 +479,14 @@ func runCodeSearch(ctx context.Context, cmd *cobra.Command, opts codeSearchOpts)
476479 }
477480
478481 w := cmd .OutOrStdout ()
482+ textOutput := ! opts .jsonOutput && interactive .IsTerminalWriter (w )
483+
484+ // Text output shows up to maxCodeSearchFiles files with a few matches
485+ // each, so fetch a deeper result set than the default --limit (which is
486+ // tuned for flat JSON output) unless the user asked for a specific limit.
487+ if textOutput && ! opts .limitExplicit {
488+ opts .limit = codeSearchTextFetchLimit
489+ }
479490
480491 // Always fan out via searchAllCells — it fetches the repo index,
481492 // resolves slugs to ULIDs, and handles single- vs multi-jurisdiction.
@@ -484,12 +495,11 @@ func runCodeSearch(ctx context.Context, cmd *cobra.Command, opts codeSearchOpts)
484495 return err
485496 }
486497
487- isTerminal := interactive .IsTerminalWriter (w )
488- if opts .jsonOutput || ! isTerminal {
498+ if ! textOutput {
489499 return writeCodeSearchJSON (w , resp )
490500 }
491501
492- writeCodeSearchText (w , resp )
502+ writeCodeSearchText (w , resp , newStatusStyles ( w ), opts . caseSensitive )
493503 return nil
494504}
495505
@@ -803,8 +813,20 @@ func writeCodeSearchJSON(w io.Writer, resp *codesearch.SearchResponse) error {
803813// with an ellipsis so that JSONL/minified files don't blow up the terminal.
804814const maxContextLineLen = 200
805815
806- // writeCodeSearchText renders code search results in grep-style format.
807- func writeCodeSearchText (w io.Writer , resp * codesearch.SearchResponse ) {
816+ // Text output display caps: show breadth (files) over depth (in-file matches).
817+ // The fetch limit leaves headroom beyond files×matches so per-file overflow
818+ // ("+ N matches") counts have data to count.
819+ const (
820+ maxCodeSearchFiles = 10 // files shown in text output
821+ maxCodeSearchFileMatches = 3 // matches shown per file
822+ codeSearchTextFetchLimit = 100 // results fetched for text display
823+ )
824+
825+ // writeCodeSearchText renders code search results grouped by file (ripgrep
826+ // style): a colored "repo:path" header per file, indented line-numbered
827+ // matches beneath it, and a dimmed stats footer. Colors are applied only when
828+ // the writer supports them (styles.colorEnabled); piped output stays plain.
829+ func writeCodeSearchText (w io.Writer , resp * codesearch.SearchResponse , styles statusStyles , caseSensitive bool ) {
808830 if len (resp .Results ) == 0 {
809831 if len (resp .FailedJurisdictions ) > 0 {
810832 fmt .Fprintf (w , "No code search results found (some regions failed: %s)\n " ,
@@ -814,26 +836,120 @@ func writeCodeSearchText(w io.Writer, resp *codesearch.SearchResponse) {
814836 }
815837 return
816838 }
839+
840+ // Group results by repo:path, preserving first-appearance order so the
841+ // best-scored file stays on top (results arrive globally score-sorted).
842+ type fileGroup struct {
843+ key string
844+ results []codesearch.Result
845+ }
846+ var groups []fileGroup
847+ idx := make (map [string ]int , len (resp .Results ))
817848 for _ , r := range resp .Results {
818- line := r .ContextLine
819- runes := []rune (line )
820- if len (runes ) > maxContextLineLen {
821- line = string (runes [:maxContextLineLen ]) + "…"
849+ key := r .Repo + ":" + r .Path
850+ i , ok := idx [key ]
851+ if ! ok {
852+ i = len (groups )
853+ idx [key ] = i
854+ groups = append (groups , fileGroup {key : key })
822855 }
823- fmt . Fprintf ( w , "%s:%s:%d: %s \n " , r . Repo , r . Path , r . Line , line )
856+ groups [ i ]. results = append ( groups [ i ]. results , r )
824857 }
825- shown := len (resp .Results )
858+
859+ shown := 0
860+ for gi , g := range groups {
861+ if gi == maxCodeSearchFiles {
862+ break
863+ }
864+ fmt .Fprintln (w )
865+ fmt .Fprintln (w , styles .render (styles .cyan , g .key ))
866+ for mi , r := range g .results {
867+ if mi == maxCodeSearchFileMatches {
868+ break
869+ }
870+ // Truncate before highlighting but append the ellipsis after,
871+ // so the non-ASCII "…" doesn't disable case-insensitive
872+ // highlighting (isASCII) for the rest of the line.
873+ line := r .ContextLine
874+ ellipsis := ""
875+ if runes := []rune (line ); len (runes ) > maxContextLineLen {
876+ line = string (runes [:maxContextLineLen ])
877+ ellipsis = "…"
878+ }
879+ lineNo := styles .render (styles .dim , fmt .Sprintf ("%d:" , r .Line ))
880+ fmt .Fprintf (w , " %s %s%s\n " , lineNo , highlightCodeMatches (line , resp .Query , styles , caseSensitive ), ellipsis )
881+ shown ++
882+ }
883+ // ponytail: overflow counts only what this page fetched (peregrine
884+ // has no per-file totals); a hot file shows "+ 97 matches" at most.
885+ if extra := len (g .results ) - maxCodeSearchFileMatches ; extra > 0 {
886+ label := "matches"
887+ if extra == 1 {
888+ label = "match"
889+ }
890+ fmt .Fprintln (w , styles .render (styles .dim , fmt .Sprintf (" + %d %s" , extra , label )))
891+ }
892+ }
893+
894+ var summary string
826895 if resp .Stats .TotalMatches > shown {
827- fmt .Fprintf ( w , " \n Showing %d of %d matches across %d files in %d repos (%.0fms)\n " ,
896+ summary = fmt .Sprintf ( "Showing %d of %d matches across %d files in %d repos (%.0fms)" ,
828897 shown , resp .Stats .TotalMatches , resp .Stats .TotalFiles , resp .Stats .ReposSearched , resp .Stats .DurationMs )
829898 } else {
830- fmt .Fprintf ( w , " \n %d matches across %d files in %d repos (%.0fms)\n " ,
899+ summary = fmt .Sprintf ( " %d matches across %d files in %d repos (%.0fms)" ,
831900 resp .Stats .TotalMatches , resp .Stats .TotalFiles , resp .Stats .ReposSearched , resp .Stats .DurationMs )
832901 }
902+ fmt .Fprintf (w , "\n %s\n " , styles .render (styles .dim , summary ))
833903 if len (resp .FailedJurisdictions ) > 0 {
834- fmt .Fprintf ( w , "Warning: results may be incomplete (failed jurisdictions: %s)\n " ,
904+ warning := fmt .Sprintf ( "Warning: results may be incomplete (failed jurisdictions: %s)" ,
835905 strings .Join (resp .FailedJurisdictions , ", " ))
906+ fmt .Fprintln (w , styles .render (styles .yellow , warning ))
907+ }
908+ }
909+
910+ // highlightCodeMatches bold-red highlights occurrences of query in line
911+ // (grep convention). Matching mirrors the search: case-insensitive unless
912+ // caseSensitive is set. Case folding is only applied when both strings are
913+ // pure ASCII, since Unicode case mappings can change byte widths and
914+ // misalign offsets against the original line; non-ASCII input falls back to
915+ // exact matching. Returns line unchanged when color is disabled or there's
916+ // nothing to highlight.
917+ func highlightCodeMatches (line , query string , styles statusStyles , caseSensitive bool ) string {
918+ if ! styles .colorEnabled || query == "" {
919+ return line
920+ }
921+ haystack , needle := line , query
922+ if ! caseSensitive && isASCII (line ) && isASCII (query ) {
923+ haystack , needle = strings .ToLower (line ), strings .ToLower (query )
924+ }
925+ matchStyle := styles .red .Bold (true )
926+ var b strings.Builder
927+ i := 0
928+ for {
929+ j := strings .Index (haystack [i :], needle )
930+ if j < 0 {
931+ break
932+ }
933+ j += i
934+ b .WriteString (line [i :j ])
935+ b .WriteString (matchStyle .Render (line [j : j + len (needle )]))
936+ i = j + len (needle )
937+ }
938+ if i == 0 {
939+ return line // no matches; skip the builder copy
940+ }
941+ b .WriteString (line [i :])
942+ return b .String ()
943+ }
944+
945+ // isASCII reports whether s contains only ASCII bytes.
946+ func isASCII (s string ) bool {
947+ for i := range len (s ) {
948+ if s [i ] >= utf8 .RuneSelf {
949+ return false
950+ }
836951 }
952+ return true
837953}
838954
839955// writeSearchJSON writes client-side paginated search results as JSON.
0 commit comments