detached ae3daad

fix(sessions): require contiguous search text

commit
ae3daada901de76a04de1b332b3ea366da1ab875
author
brice · · 2026-08-19 09:12 UTC
committer
brice · · 2026-08-19 09:21 UTC
parent
8b4e3af
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +140 -44
M README.md +1 -1
@@ -359,7 +359,7 @@ Type `!` followed by a command to run it directly without going through the mode
359359
360360 ### `/sessions`
361361
362-Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` to search an ephemeral in-memory corpus of persisted user and assistant text. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted best excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
362+Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` and type at least two characters to search contiguous text in an ephemeral in-memory corpus of persisted user and assistant messages. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted best excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
363363
364364 ### `/session`
365365
@@ -359,7 +359,7 @@ Type `!` followed by a command to run it directly without going through the mode
359 359
360 ### `/sessions`360 ### `/sessions`
361 361
362-Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` to search an ephemeral in-memory corpus of persisted user and assistant text. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted best excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.362+Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` and type at least two characters to search contiguous text in an ephemeral in-memory corpus of persisted user and assistant messages. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted best excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
363 363
364 ### `/session`364 ### `/session`
365 365
M packages/agent/modes/session_dialog.go +71 -43
@@ -10,7 +10,6 @@ import (
1010
1111 "github.com/bnema/zut/packages/core"
1212 "github.com/bnema/zut/packages/tui"
13- "github.com/sahilm/fuzzy"
1413 )
1514
1615 type sessionLoadEventKind uint8
@@ -41,7 +40,6 @@ type sessionSearchMatch struct {
4140 count int
4241 excerpt string
4342 indexes []int
44- score int
4543 }
4644
4745 type sessionLoadEvent struct {
@@ -63,7 +61,10 @@ type sessionLoadJob struct {
6361 path string
6462 }
6563
66-const maxSessionLoadWorkers = 8
64+const (
65+ maxSessionLoadWorkers = 8
66+ minSessionSearchQueryRunes = 2
67+)
6768
6869 // sessionDialog is the inline picker shown when the user runs /sessions.
6970 type sessionDialog struct {
@@ -79,6 +80,7 @@ type sessionDialog struct {
7980 cwd string
8081 paths []string
8182 query string
83+ searchRequested bool
8284 searching bool
8385 searchPending bool
8486 searchReady bool
@@ -173,6 +175,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
173175 d.allScope = len(allScope) > 0 && allScope[0]
174176 d.paths = nil
175177 d.query = ""
178+ d.searchRequested = false
176179 d.searching = false
177180 d.searchPending = false
178181 d.searchReady = false
@@ -371,7 +374,7 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) {
371374 continue
372375 }
373376 d.baseSessions = append(d.baseSessions, slot.summary)
374- if d.query == "" {
377+ if !sessionSearchQuery(d.query) || !d.searchReady {
375378 d.sessions = append(d.sessions, slot.summary)
376379 }
377380 }
@@ -428,6 +431,7 @@ func (d *sessionDialog) Close() {
428431 d.matchCancel = nil
429432 }
430433 d.loading = false
434+ d.searchRequested = false
431435 d.searching = false
432436 d.loadSlots = nil
433437 d.loadReadyThrough = 0
@@ -437,9 +441,13 @@ func (d *sessionDialog) Close() {
437441 d.active = false
438442 }
439443
440-// StartSearch starts one background corpus read for this picker opening. Query
441-// edits reuse the retained corpus and never reopen a session file.
444+// StartSearch starts one background corpus read after the query reaches the
445+// minimum length. Query edits reuse the retained corpus and never reopen a
446+// session file.
442447 func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearchEvent {
448+ if !sessionSearchQuery(d.query) {
449+ return nil
450+ }
443451 if d.searchEvents != nil {
444452 return d.searchEvents
445453 }
@@ -517,7 +525,7 @@ func (d *sessionDialog) ApplySearch(event sessionSearchEvent) {
517525 }
518526
519527 func (d *sessionDialog) scheduleSearchMatch() {
520- if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil {
528+ if !d.searchReady || !sessionSearchQuery(d.query) || d.searchEvents == nil || d.searchCancel == nil {
521529 return
522530 }
523531 if d.matchCancel != nil {
@@ -543,46 +551,53 @@ func (d *sessionDialog) scheduleSearchMatch() {
543551
544552 func matchSessionSearchSegments(ctx context.Context, query string, segments []core.SessionSearchSegment) map[string]sessionSearchMatch {
545553 query = core.NormalizeSessionSearchText(query)
546- if query == "" {
554+ if !sessionSearchQuery(query) || ctx.Err() != nil {
547555 return nil
548556 }
549- if ctx.Err() != nil {
550- return nil
551- }
552- texts := make([]string, len(segments))
553- for index, segment := range segments {
557+ matches := make(map[string]sessionSearchMatch)
558+ for _, segment := range segments {
554559 if ctx.Err() != nil {
555- return nil
560+ return matches
556561 }
557- texts[index] = segment.Normalized
558- if texts[index] == "" {
559- texts[index] = core.NormalizeSessionSearchText(segment.Text)
562+ text := segment.Normalized
563+ if text == "" {
564+ text = core.NormalizeSessionSearchText(segment.Text)
560565 }
561- }
562- if ctx.Err() != nil {
563- return nil
564- }
565- ranked := fuzzy.Find(query, texts)
566- matches := make(map[string]sessionSearchMatch)
567- for _, match := range ranked {
568- if ctx.Err() != nil || match.Index < 0 || match.Index >= len(segments) {
569- return matches
566+ indexes := sessionSearchSubstringIndexes(text, query)
567+ if len(indexes) == 0 {
568+ continue
570569 }
571- segment := segments[match.Index]
572570 current := matches[segment.Path]
573571 current.count++
574- if current.excerpt == "" || match.Score > current.score {
575- current.excerpt = texts[match.Index]
576- current.indexes = append([]int(nil), match.MatchedIndexes...)
577- current.score = match.Score
572+ if current.excerpt == "" {
573+ current.excerpt = text
574+ current.indexes = indexes
578575 }
579576 matches[segment.Path] = current
580577 }
581578 return matches
582579 }
583580
581+func sessionSearchQuery(query string) bool {
582+ return len([]rune(core.NormalizeSessionSearchText(query))) >= minSessionSearchQueryRunes
583+}
584+
585+func sessionSearchSubstringIndexes(text, query string) []int {
586+ start := strings.Index(text, query)
587+ if start < 0 {
588+ return nil
589+ }
590+ indexes := make([]int, 0, utf8.RuneCountInString(query))
591+ for index := start; index < start+len(query); {
592+ indexes = append(indexes, index)
593+ _, size := utf8.DecodeRuneInString(text[index:])
594+ index += size
595+ }
596+ return indexes
597+}
598+
584599 func (d *sessionDialog) applySearchFilter() {
585- if d.query == "" {
600+ if !sessionSearchQuery(d.query) || !d.searchReady {
586601 d.sessions = append(d.sessions[:0], d.baseSessions...)
587602 } else {
588603 filtered := make([]core.SessionSummary, 0, len(d.baseSessions))
@@ -627,7 +642,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
627642 if d.allScope {
628643 message = "no previous sessions in this namespace"
629644 }
630- if d.query != "" && d.searchReady {
645+ if sessionSearchQuery(d.query) && d.searchReady {
631646 message = "no matching sessions"
632647 }
633648 lines = append(lines, th.FGColor(th.Muted, message))
@@ -648,9 +663,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
648663 return lines
649664 }
650665 hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel"
651- if d.searchEvents != nil {
666+ if d.searchRequested {
652667 hint = "search: " + d.query + " (esc clear · tab scope)"
653- if d.searching {
668+ if !sessionSearchQuery(d.query) {
669+ hint += " · type 2+ characters"
670+ } else if d.searching {
654671 hint += " · indexing"
655672 }
656673 }
@@ -685,8 +702,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
685702 }
686703 for i := d.viewTop; i < viewBot; i++ {
687704 s := d.sessions[i]
688- match := d.searchMatches[s.Path]
689- plain := " " + formatSessionSearchRowPlain(s, width-2, match.count)
705+ matchCount := 0
706+ if sessionSearchQuery(d.query) {
707+ matchCount = d.searchMatches[s.Path].count
708+ }
709+ plain := " " + formatSessionSearchRowPlain(s, width-2, matchCount)
690710 if i == d.cursor {
691711 lines = append(lines, th.PadHighlight(plain, width))
692712 } else {
@@ -702,7 +722,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
702722 cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD))
703723 lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd))
704724 }
705- if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
725+ if sessionSearchQuery(d.query) && d.cursor >= 0 && d.cursor < len(d.sessions) {
706726 if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {
707727 lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2))
708728 }
@@ -968,10 +988,11 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
968988 if k.Kind == tui.KeyTab {
969989 return sessionDialogAction{ToggleScope: true}
970990 }
971- if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" {
972- return sessionDialogAction{StartSearch: true}
991+ if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" && !d.searchRequested {
992+ d.searchRequested = true
993+ return sessionDialogAction{}
973994 }
974- if d.query != "" || d.searchEvents != nil {
995+ if d.searchRequested {
975996 switch k.Kind {
976997 case tui.KeyEsc:
977998 if d.query == "" {
@@ -987,9 +1008,16 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
9871008 if len(runes) > 0 {
9881009 d.query = string(runes[:len(runes)-1])
9891010 }
1011+ if !sessionSearchQuery(d.query) {
1012+ if d.matchCancel != nil {
1013+ d.matchCancel()
1014+ d.matchCancel = nil
1015+ }
1016+ d.searchMatches = nil
1017+ }
9901018 d.applySearchFilter()
9911019 d.scheduleSearchMatch()
992- return sessionDialogAction{}
1020+ return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
9931021 case tui.KeyPaste:
9941022 d.query += k.Paste
9951023 case tui.KeyRune:
@@ -1006,7 +1034,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
10061034 }
10071035 d.applySearchFilter()
10081036 d.scheduleSearchMatch()
1009- return sessionDialogAction{}
1037+ return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
10101038 }
10111039
10121040 if d.loading && len(d.sessions) == 0 {
@@ -10,7 +10,6 @@ import (
10 10
11 "github.com/bnema/zut/packages/core"11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/tui"12 "github.com/bnema/zut/packages/tui"
13- "github.com/sahilm/fuzzy"
14 )13 )
15 14
16 type sessionLoadEventKind uint815 type sessionLoadEventKind uint8
@@ -41,7 +40,6 @@ type sessionSearchMatch struct {
41 count int40 count int
42 excerpt string41 excerpt string
43 indexes []int42 indexes []int
44- score int
45 }43 }
46 44
47 type sessionLoadEvent struct {45 type sessionLoadEvent struct {
@@ -63,7 +61,10 @@ type sessionLoadJob struct {
63 path string61 path string
64 }62 }
65 63
66-const maxSessionLoadWorkers = 864+const (
65+ maxSessionLoadWorkers = 8
66+ minSessionSearchQueryRunes = 2
67+)
67 68
68 // sessionDialog is the inline picker shown when the user runs /sessions.69 // sessionDialog is the inline picker shown when the user runs /sessions.
69 type sessionDialog struct {70 type sessionDialog struct {
@@ -79,6 +80,7 @@ type sessionDialog struct {
79 cwd string80 cwd string
80 paths []string81 paths []string
81 query string82 query string
83+ searchRequested bool
82 searching bool84 searching bool
83 searchPending bool85 searchPending bool
84 searchReady bool86 searchReady bool
@@ -173,6 +175,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
173 d.allScope = len(allScope) > 0 && allScope[0]175 d.allScope = len(allScope) > 0 && allScope[0]
174 d.paths = nil176 d.paths = nil
175 d.query = ""177 d.query = ""
178+ d.searchRequested = false
176 d.searching = false179 d.searching = false
177 d.searchPending = false180 d.searchPending = false
178 d.searchReady = false181 d.searchReady = false
@@ -371,7 +374,7 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) {
371 continue374 continue
372 }375 }
373 d.baseSessions = append(d.baseSessions, slot.summary)376 d.baseSessions = append(d.baseSessions, slot.summary)
374- if d.query == "" {377+ if !sessionSearchQuery(d.query) || !d.searchReady {
375 d.sessions = append(d.sessions, slot.summary)378 d.sessions = append(d.sessions, slot.summary)
376 }379 }
377 }380 }
@@ -428,6 +431,7 @@ func (d *sessionDialog) Close() {
428 d.matchCancel = nil431 d.matchCancel = nil
429 }432 }
430 d.loading = false433 d.loading = false
434+ d.searchRequested = false
431 d.searching = false435 d.searching = false
432 d.loadSlots = nil436 d.loadSlots = nil
433 d.loadReadyThrough = 0437 d.loadReadyThrough = 0
@@ -437,9 +441,13 @@ func (d *sessionDialog) Close() {
437 d.active = false441 d.active = false
438 }442 }
439 443
440-// StartSearch starts one background corpus read for this picker opening. Query444+// StartSearch starts one background corpus read after the query reaches the
441-// edits reuse the retained corpus and never reopen a session file.445+// minimum length. Query edits reuse the retained corpus and never reopen a
446+// session file.
442 func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearchEvent {447 func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearchEvent {
448+ if !sessionSearchQuery(d.query) {
449+ return nil
450+ }
443 if d.searchEvents != nil {451 if d.searchEvents != nil {
444 return d.searchEvents452 return d.searchEvents
445 }453 }
@@ -517,7 +525,7 @@ func (d *sessionDialog) ApplySearch(event sessionSearchEvent) {
517 }525 }
518 526
519 func (d *sessionDialog) scheduleSearchMatch() {527 func (d *sessionDialog) scheduleSearchMatch() {
520- if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil {528+ if !d.searchReady || !sessionSearchQuery(d.query) || d.searchEvents == nil || d.searchCancel == nil {
521 return529 return
522 }530 }
523 if d.matchCancel != nil {531 if d.matchCancel != nil {
@@ -543,46 +551,53 @@ func (d *sessionDialog) scheduleSearchMatch() {
543 551
544 func matchSessionSearchSegments(ctx context.Context, query string, segments []core.SessionSearchSegment) map[string]sessionSearchMatch {552 func matchSessionSearchSegments(ctx context.Context, query string, segments []core.SessionSearchSegment) map[string]sessionSearchMatch {
545 query = core.NormalizeSessionSearchText(query)553 query = core.NormalizeSessionSearchText(query)
546- if query == "" {554+ if !sessionSearchQuery(query) || ctx.Err() != nil {
547 return nil555 return nil
548 }556 }
549- if ctx.Err() != nil {557+ matches := make(map[string]sessionSearchMatch)
550- return nil558+ for _, segment := range segments {
551- }
552- texts := make([]string, len(segments))
553- for index, segment := range segments {
554 if ctx.Err() != nil {559 if ctx.Err() != nil {
555- return nil560+ return matches
556 }561 }
557- texts[index] = segment.Normalized562+ text := segment.Normalized
558- if texts[index] == "" {563+ if text == "" {
559- texts[index] = core.NormalizeSessionSearchText(segment.Text)564+ text = core.NormalizeSessionSearchText(segment.Text)
560 }565 }
561- }566+ indexes := sessionSearchSubstringIndexes(text, query)
562- if ctx.Err() != nil {567+ if len(indexes) == 0 {
563- return nil568+ continue
564- }
565- ranked := fuzzy.Find(query, texts)
566- matches := make(map[string]sessionSearchMatch)
567- for _, match := range ranked {
568- if ctx.Err() != nil || match.Index < 0 || match.Index >= len(segments) {
569- return matches
570 }569 }
571- segment := segments[match.Index]
572 current := matches[segment.Path]570 current := matches[segment.Path]
573 current.count++571 current.count++
574- if current.excerpt == "" || match.Score > current.score {572+ if current.excerpt == "" {
575- current.excerpt = texts[match.Index]573+ current.excerpt = text
576- current.indexes = append([]int(nil), match.MatchedIndexes...)574+ current.indexes = indexes
577- current.score = match.Score
578 }575 }
579 matches[segment.Path] = current576 matches[segment.Path] = current
580 }577 }
581 return matches578 return matches
582 }579 }
583 580
581+func sessionSearchQuery(query string) bool {
582+ return len([]rune(core.NormalizeSessionSearchText(query))) >= minSessionSearchQueryRunes
583+}
584+
585+func sessionSearchSubstringIndexes(text, query string) []int {
586+ start := strings.Index(text, query)
587+ if start < 0 {
588+ return nil
589+ }
590+ indexes := make([]int, 0, utf8.RuneCountInString(query))
591+ for index := start; index < start+len(query); {
592+ indexes = append(indexes, index)
593+ _, size := utf8.DecodeRuneInString(text[index:])
594+ index += size
595+ }
596+ return indexes
597+}
598+
584 func (d *sessionDialog) applySearchFilter() {599 func (d *sessionDialog) applySearchFilter() {
585- if d.query == "" {600+ if !sessionSearchQuery(d.query) || !d.searchReady {
586 d.sessions = append(d.sessions[:0], d.baseSessions...)601 d.sessions = append(d.sessions[:0], d.baseSessions...)
587 } else {602 } else {
588 filtered := make([]core.SessionSummary, 0, len(d.baseSessions))603 filtered := make([]core.SessionSummary, 0, len(d.baseSessions))
@@ -627,7 +642,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
627 if d.allScope {642 if d.allScope {
628 message = "no previous sessions in this namespace"643 message = "no previous sessions in this namespace"
629 }644 }
630- if d.query != "" && d.searchReady {645+ if sessionSearchQuery(d.query) && d.searchReady {
631 message = "no matching sessions"646 message = "no matching sessions"
632 }647 }
633 lines = append(lines, th.FGColor(th.Muted, message))648 lines = append(lines, th.FGColor(th.Muted, message))
@@ -648,9 +663,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
648 return lines663 return lines
649 }664 }
650 hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel"665 hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel"
651- if d.searchEvents != nil {666+ if d.searchRequested {
652 hint = "search: " + d.query + " (esc clear · tab scope)"667 hint = "search: " + d.query + " (esc clear · tab scope)"
653- if d.searching {668+ if !sessionSearchQuery(d.query) {
669+ hint += " · type 2+ characters"
670+ } else if d.searching {
654 hint += " · indexing"671 hint += " · indexing"
655 }672 }
656 }673 }
@@ -685,8 +702,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
685 }702 }
686 for i := d.viewTop; i < viewBot; i++ {703 for i := d.viewTop; i < viewBot; i++ {
687 s := d.sessions[i]704 s := d.sessions[i]
688- match := d.searchMatches[s.Path]705+ matchCount := 0
689- plain := " " + formatSessionSearchRowPlain(s, width-2, match.count)706+ if sessionSearchQuery(d.query) {
707+ matchCount = d.searchMatches[s.Path].count
708+ }
709+ plain := " " + formatSessionSearchRowPlain(s, width-2, matchCount)
690 if i == d.cursor {710 if i == d.cursor {
691 lines = append(lines, th.PadHighlight(plain, width))711 lines = append(lines, th.PadHighlight(plain, width))
692 } else {712 } else {
@@ -702,7 +722,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
702 cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD))722 cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD))
703 lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd))723 lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd))
704 }724 }
705- if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {725+ if sessionSearchQuery(d.query) && d.cursor >= 0 && d.cursor < len(d.sessions) {
706 if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {726 if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {
707 lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2))727 lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2))
708 }728 }
@@ -968,10 +988,11 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
968 if k.Kind == tui.KeyTab {988 if k.Kind == tui.KeyTab {
969 return sessionDialogAction{ToggleScope: true}989 return sessionDialogAction{ToggleScope: true}
970 }990 }
971- if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" {991+ if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" && !d.searchRequested {
972- return sessionDialogAction{StartSearch: true}992+ d.searchRequested = true
993+ return sessionDialogAction{}
973 }994 }
974- if d.query != "" || d.searchEvents != nil {995+ if d.searchRequested {
975 switch k.Kind {996 switch k.Kind {
976 case tui.KeyEsc:997 case tui.KeyEsc:
977 if d.query == "" {998 if d.query == "" {
@@ -987,9 +1008,16 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
987 if len(runes) > 0 {1008 if len(runes) > 0 {
988 d.query = string(runes[:len(runes)-1])1009 d.query = string(runes[:len(runes)-1])
989 }1010 }
1011+ if !sessionSearchQuery(d.query) {
1012+ if d.matchCancel != nil {
1013+ d.matchCancel()
1014+ d.matchCancel = nil
1015+ }
1016+ d.searchMatches = nil
1017+ }
990 d.applySearchFilter()1018 d.applySearchFilter()
991 d.scheduleSearchMatch()1019 d.scheduleSearchMatch()
992- return sessionDialogAction{}1020+ return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
993 case tui.KeyPaste:1021 case tui.KeyPaste:
994 d.query += k.Paste1022 d.query += k.Paste
995 case tui.KeyRune:1023 case tui.KeyRune:
@@ -1006,7 +1034,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
1006 }1034 }
1007 d.applySearchFilter()1035 d.applySearchFilter()
1008 d.scheduleSearchMatch()1036 d.scheduleSearchMatch()
1009- return sessionDialogAction{}1037+ return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
1010 }1038 }
1011 1039
1012 if d.loading && len(d.sessions) == 0 {1040 if d.loading && len(d.sessions) == 0 {
M packages/agent/modes/session_dialog_test.go +68 -0
@@ -419,6 +419,74 @@ func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) {
419419 }
420420 }
421421
422+func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) {
423+ segments := []core.SessionSearchSegment{
424+ {Path: "scattered", Text: "sizzle then harp, again remote"},
425+ {Path: "exact", Text: "resume the sharm project"},
426+ }
427+
428+ if matches := matchSessionSearchSegments(context.Background(), "s", segments); matches != nil {
429+ t.Fatalf("one-character search = %#v, want no search", matches)
430+ }
431+ matches := matchSessionSearchSegments(context.Background(), "sharm", segments)
432+ if len(matches) != 1 {
433+ t.Fatalf("matches = %#v, want only exact session", matches)
434+ }
435+ match, ok := matches["exact"]
436+ if !ok {
437+ t.Fatalf("matches = %#v, missing exact session", matches)
438+ }
439+ if match.count != 1 || match.excerpt != "resume the sharm project" {
440+ t.Fatalf("exact match = %#v", match)
441+ }
442+ if want := []int{11, 12, 13, 14, 15}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] {
443+ t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
444+ }
445+}
446+
447+func TestSessionDialogDefersSearchUntilSecondCharacter(t *testing.T) {
448+ d := newSessionDialog()
449+ d.active = true
450+ d.baseSessions = []core.SessionSummary{{Path: "one"}, {Path: "two"}}
451+ d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
452+
453+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}); action.StartSearch || !d.searchRequested {
454+ t.Fatalf("slash action = %#v, search requested = %v", action, d.searchRequested)
455+ }
456+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 's'}); action.StartSearch || d.query != "s" {
457+ t.Fatalf("one-character action = %#v, query = %q", action, d.query)
458+ }
459+ if len(d.sessions) != len(d.baseSessions) {
460+ t.Fatalf("one-character search filtered sessions: %#v", d.sessions)
461+ }
462+ if events := d.StartSearch(context.Background()); events != nil || d.searchEvents != nil {
463+ t.Fatal("one-character search started the corpus reader")
464+ }
465+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'h'}); !action.StartSearch || d.query != "sh" {
466+ t.Fatalf("two-character action = %#v, query = %q", action, d.query)
467+ }
468+}
469+
470+func TestSessionDialogClearsMatchesBelowSearchMinimum(t *testing.T) {
471+ d := newSessionDialog()
472+ d.active = true
473+ d.searchRequested = true
474+ d.searchReady = true
475+ d.query = "sh"
476+ d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1, FirstUserText: "session"}}
477+ d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
478+ d.searchMatches = map[string]sessionSearchMatch{"one": {count: 1}}
479+
480+ d.HandleKey(tui.Key{Kind: tui.KeyBackspace})
481+ if d.searchMatches != nil {
482+ t.Fatalf("matches after shortening query = %#v, want nil", d.searchMatches)
483+ }
484+ rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
485+ if strings.Contains(rendered, "1 match") {
486+ t.Fatalf("one-character search rendered stale match count: %q", rendered)
487+ }
488+}
489+
422490 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
423491 first := newSessionDialog()
424492 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
@@ -419,6 +419,74 @@ func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) {
419 }419 }
420 }420 }
421 421
422+func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) {
423+ segments := []core.SessionSearchSegment{
424+ {Path: "scattered", Text: "sizzle then harp, again remote"},
425+ {Path: "exact", Text: "resume the sharm project"},
426+ }
427+
428+ if matches := matchSessionSearchSegments(context.Background(), "s", segments); matches != nil {
429+ t.Fatalf("one-character search = %#v, want no search", matches)
430+ }
431+ matches := matchSessionSearchSegments(context.Background(), "sharm", segments)
432+ if len(matches) != 1 {
433+ t.Fatalf("matches = %#v, want only exact session", matches)
434+ }
435+ match, ok := matches["exact"]
436+ if !ok {
437+ t.Fatalf("matches = %#v, missing exact session", matches)
438+ }
439+ if match.count != 1 || match.excerpt != "resume the sharm project" {
440+ t.Fatalf("exact match = %#v", match)
441+ }
442+ if want := []int{11, 12, 13, 14, 15}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] {
443+ t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
444+ }
445+}
446+
447+func TestSessionDialogDefersSearchUntilSecondCharacter(t *testing.T) {
448+ d := newSessionDialog()
449+ d.active = true
450+ d.baseSessions = []core.SessionSummary{{Path: "one"}, {Path: "two"}}
451+ d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
452+
453+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}); action.StartSearch || !d.searchRequested {
454+ t.Fatalf("slash action = %#v, search requested = %v", action, d.searchRequested)
455+ }
456+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 's'}); action.StartSearch || d.query != "s" {
457+ t.Fatalf("one-character action = %#v, query = %q", action, d.query)
458+ }
459+ if len(d.sessions) != len(d.baseSessions) {
460+ t.Fatalf("one-character search filtered sessions: %#v", d.sessions)
461+ }
462+ if events := d.StartSearch(context.Background()); events != nil || d.searchEvents != nil {
463+ t.Fatal("one-character search started the corpus reader")
464+ }
465+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'h'}); !action.StartSearch || d.query != "sh" {
466+ t.Fatalf("two-character action = %#v, query = %q", action, d.query)
467+ }
468+}
469+
470+func TestSessionDialogClearsMatchesBelowSearchMinimum(t *testing.T) {
471+ d := newSessionDialog()
472+ d.active = true
473+ d.searchRequested = true
474+ d.searchReady = true
475+ d.query = "sh"
476+ d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1, FirstUserText: "session"}}
477+ d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
478+ d.searchMatches = map[string]sessionSearchMatch{"one": {count: 1}}
479+
480+ d.HandleKey(tui.Key{Kind: tui.KeyBackspace})
481+ if d.searchMatches != nil {
482+ t.Fatalf("matches after shortening query = %#v, want nil", d.searchMatches)
483+ }
484+ rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
485+ if strings.Contains(rendered, "1 match") {
486+ t.Fatalf("one-character search rendered stale match count: %q", rendered)
487+ }
488+}
489+
422 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {490 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
423 first := newSessionDialog()491 first := newSessionDialog()
424 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())492 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close