detached fae6c20

Merge pull request #151 from bnema/fix/session-search-substrings

fix(sessions): require contiguous search text
commit
fae6c20b063bf2c89b53dbb32d1d7f07b958515b
author
bnema · · 2026-08-19 09:46 UTC
committer
GitHub · · 2026-08-19 09:46 UTC
merge of
8b4e3af + ec65c3b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.22.0
4 files · +173 -46
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 first matching 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 first matching 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 +81 -44
@@ -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,62 @@ 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+ count, indexes := sessionSearchSubstringMatches(text, query)
567+ if count == 0 {
568+ continue
570569 }
571- segment := segments[match.Index]
572570 current := matches[segment.Path]
573- 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
571+ current.count += count
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 sessionSearchSubstringMatches(text, query string) (int, []int) {
586+ count := 0
587+ var firstIndexes []int
588+ for start := 0; start < len(text); {
589+ offset := strings.Index(text[start:], query)
590+ if offset < 0 {
591+ break
592+ }
593+ index := start + offset
594+ count++
595+ if firstIndexes == nil {
596+ firstIndexes = make([]int, 0, utf8.RuneCountInString(query))
597+ for end := index; end < index+len(query); {
598+ firstIndexes = append(firstIndexes, end)
599+ _, size := utf8.DecodeRuneInString(text[end:])
600+ end += size
601+ }
602+ }
603+ start = index + len(query)
604+ }
605+ return count, firstIndexes
606+}
607+
584608 func (d *sessionDialog) applySearchFilter() {
585- if d.query == "" {
609+ if !sessionSearchQuery(d.query) || !d.searchReady {
586610 d.sessions = append(d.sessions[:0], d.baseSessions...)
587611 } else {
588612 filtered := make([]core.SessionSummary, 0, len(d.baseSessions))
@@ -627,7 +651,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
627651 if d.allScope {
628652 message = "no previous sessions in this namespace"
629653 }
630- if d.query != "" && d.searchReady {
654+ if sessionSearchQuery(d.query) && d.searchReady {
631655 message = "no matching sessions"
632656 }
633657 lines = append(lines, th.FGColor(th.Muted, message))
@@ -648,9 +672,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
648672 return lines
649673 }
650674 hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel"
651- if d.searchEvents != nil {
675+ if d.searchRequested {
652676 hint = "search: " + d.query + " (esc clear · tab scope)"
653- if d.searching {
677+ if !sessionSearchQuery(d.query) {
678+ hint += " · type 2+ characters"
679+ } else if d.searching {
654680 hint += " · indexing"
655681 }
656682 }
@@ -685,8 +711,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
685711 }
686712 for i := d.viewTop; i < viewBot; i++ {
687713 s := d.sessions[i]
688- match := d.searchMatches[s.Path]
689- plain := " " + formatSessionSearchRowPlain(s, width-2, match.count)
714+ matchCount := 0
715+ if sessionSearchQuery(d.query) {
716+ matchCount = d.searchMatches[s.Path].count
717+ }
718+ plain := " " + formatSessionSearchRowPlain(s, width-2, matchCount)
690719 if i == d.cursor {
691720 lines = append(lines, th.PadHighlight(plain, width))
692721 } else {
@@ -702,7 +731,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
702731 cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD))
703732 lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd))
704733 }
705- if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
734+ if sessionSearchQuery(d.query) && d.cursor >= 0 && d.cursor < len(d.sessions) {
706735 if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {
707736 lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2))
708737 }
@@ -968,10 +997,11 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
968997 if k.Kind == tui.KeyTab {
969998 return sessionDialogAction{ToggleScope: true}
970999 }
971- if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" {
972- return sessionDialogAction{StartSearch: true}
1000+ if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" && !d.searchRequested {
1001+ d.searchRequested = true
1002+ return sessionDialogAction{}
9731003 }
974- if d.query != "" || d.searchEvents != nil {
1004+ if d.searchRequested {
9751005 switch k.Kind {
9761006 case tui.KeyEsc:
9771007 if d.query == "" {
@@ -987,9 +1017,16 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
9871017 if len(runes) > 0 {
9881018 d.query = string(runes[:len(runes)-1])
9891019 }
1020+ if !sessionSearchQuery(d.query) {
1021+ if d.matchCancel != nil {
1022+ d.matchCancel()
1023+ d.matchCancel = nil
1024+ }
1025+ d.searchMatches = nil
1026+ }
9901027 d.applySearchFilter()
9911028 d.scheduleSearchMatch()
992- return sessionDialogAction{}
1029+ return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
9931030 case tui.KeyPaste:
9941031 d.query += k.Paste
9951032 case tui.KeyRune:
@@ -1006,7 +1043,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
10061043 }
10071044 d.applySearchFilter()
10081045 d.scheduleSearchMatch()
1009- return sessionDialogAction{}
1046+ return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
10101047 }
10111048
10121049 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,62 @@ 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+ count, indexes := sessionSearchSubstringMatches(text, query)
562- if ctx.Err() != nil {567+ if count == 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 += 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 sessionSearchSubstringMatches(text, query string) (int, []int) {
586+ count := 0
587+ var firstIndexes []int
588+ for start := 0; start < len(text); {
589+ offset := strings.Index(text[start:], query)
590+ if offset < 0 {
591+ break
592+ }
593+ index := start + offset
594+ count++
595+ if firstIndexes == nil {
596+ firstIndexes = make([]int, 0, utf8.RuneCountInString(query))
597+ for end := index; end < index+len(query); {
598+ firstIndexes = append(firstIndexes, end)
599+ _, size := utf8.DecodeRuneInString(text[end:])
600+ end += size
601+ }
602+ }
603+ start = index + len(query)
604+ }
605+ return count, firstIndexes
606+}
607+
584 func (d *sessionDialog) applySearchFilter() {608 func (d *sessionDialog) applySearchFilter() {
585- if d.query == "" {609+ if !sessionSearchQuery(d.query) || !d.searchReady {
586 d.sessions = append(d.sessions[:0], d.baseSessions...)610 d.sessions = append(d.sessions[:0], d.baseSessions...)
587 } else {611 } else {
588 filtered := make([]core.SessionSummary, 0, len(d.baseSessions))612 filtered := make([]core.SessionSummary, 0, len(d.baseSessions))
@@ -627,7 +651,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
627 if d.allScope {651 if d.allScope {
628 message = "no previous sessions in this namespace"652 message = "no previous sessions in this namespace"
629 }653 }
630- if d.query != "" && d.searchReady {654+ if sessionSearchQuery(d.query) && d.searchReady {
631 message = "no matching sessions"655 message = "no matching sessions"
632 }656 }
633 lines = append(lines, th.FGColor(th.Muted, message))657 lines = append(lines, th.FGColor(th.Muted, message))
@@ -648,9 +672,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
648 return lines672 return lines
649 }673 }
650 hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel"674 hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel"
651- if d.searchEvents != nil {675+ if d.searchRequested {
652 hint = "search: " + d.query + " (esc clear · tab scope)"676 hint = "search: " + d.query + " (esc clear · tab scope)"
653- if d.searching {677+ if !sessionSearchQuery(d.query) {
678+ hint += " · type 2+ characters"
679+ } else if d.searching {
654 hint += " · indexing"680 hint += " · indexing"
655 }681 }
656 }682 }
@@ -685,8 +711,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
685 }711 }
686 for i := d.viewTop; i < viewBot; i++ {712 for i := d.viewTop; i < viewBot; i++ {
687 s := d.sessions[i]713 s := d.sessions[i]
688- match := d.searchMatches[s.Path]714+ matchCount := 0
689- plain := " " + formatSessionSearchRowPlain(s, width-2, match.count)715+ if sessionSearchQuery(d.query) {
716+ matchCount = d.searchMatches[s.Path].count
717+ }
718+ plain := " " + formatSessionSearchRowPlain(s, width-2, matchCount)
690 if i == d.cursor {719 if i == d.cursor {
691 lines = append(lines, th.PadHighlight(plain, width))720 lines = append(lines, th.PadHighlight(plain, width))
692 } else {721 } else {
@@ -702,7 +731,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
702 cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD))731 cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD))
703 lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd))732 lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd))
704 }733 }
705- if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {734+ if sessionSearchQuery(d.query) && d.cursor >= 0 && d.cursor < len(d.sessions) {
706 if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {735 if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {
707 lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2))736 lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2))
708 }737 }
@@ -968,10 +997,11 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
968 if k.Kind == tui.KeyTab {997 if k.Kind == tui.KeyTab {
969 return sessionDialogAction{ToggleScope: true}998 return sessionDialogAction{ToggleScope: true}
970 }999 }
971- if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" {1000+ if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" && !d.searchRequested {
972- return sessionDialogAction{StartSearch: true}1001+ d.searchRequested = true
1002+ return sessionDialogAction{}
973 }1003 }
974- if d.query != "" || d.searchEvents != nil {1004+ if d.searchRequested {
975 switch k.Kind {1005 switch k.Kind {
976 case tui.KeyEsc:1006 case tui.KeyEsc:
977 if d.query == "" {1007 if d.query == "" {
@@ -987,9 +1017,16 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
987 if len(runes) > 0 {1017 if len(runes) > 0 {
988 d.query = string(runes[:len(runes)-1])1018 d.query = string(runes[:len(runes)-1])
989 }1019 }
1020+ if !sessionSearchQuery(d.query) {
1021+ if d.matchCancel != nil {
1022+ d.matchCancel()
1023+ d.matchCancel = nil
1024+ }
1025+ d.searchMatches = nil
1026+ }
990 d.applySearchFilter()1027 d.applySearchFilter()
991 d.scheduleSearchMatch()1028 d.scheduleSearchMatch()
992- return sessionDialogAction{}1029+ return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
993 case tui.KeyPaste:1030 case tui.KeyPaste:
994 d.query += k.Paste1031 d.query += k.Paste
995 case tui.KeyRune:1032 case tui.KeyRune:
@@ -1006,7 +1043,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
1006 }1043 }
1007 d.applySearchFilter()1044 d.applySearchFilter()
1008 d.scheduleSearchMatch()1045 d.scheduleSearchMatch()
1009- return sessionDialogAction{}1046+ return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
1010 }1047 }
1011 1048
1012 if d.loading && len(d.sessions) == 0 {1049 if d.loading && len(d.sessions) == 0 {
M packages/agent/modes/session_dialog_test.go +87 -0
@@ -419,6 +419,93 @@ 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 TestSessionSearchCountsRepeatedSegmentMatches(t *testing.T) {
448+ matches := matchSessionSearchSegments(context.Background(), "sharm", []core.SessionSearchSegment{
449+ {Path: "repeated", Text: "sharm then sharm"},
450+ })
451+ match, ok := matches["repeated"]
452+ if !ok {
453+ t.Fatalf("matches = %#v, missing repeated session", matches)
454+ }
455+ if match.count != 2 {
456+ t.Fatalf("match count = %d, want 2", match.count)
457+ }
458+ if match.excerpt != "sharm then sharm" {
459+ t.Fatalf("match excerpt = %q", match.excerpt)
460+ }
461+ if want := []int{0, 1, 2, 3, 4}; 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] {
462+ t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
463+ }
464+}
465+
466+func TestSessionDialogDefersSearchUntilSecondCharacter(t *testing.T) {
467+ d := newSessionDialog()
468+ d.active = true
469+ d.baseSessions = []core.SessionSummary{{Path: "one"}, {Path: "two"}}
470+ d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
471+
472+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}); action.StartSearch || !d.searchRequested {
473+ t.Fatalf("slash action = %#v, search requested = %v", action, d.searchRequested)
474+ }
475+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 's'}); action.StartSearch || d.query != "s" {
476+ t.Fatalf("one-character action = %#v, query = %q", action, d.query)
477+ }
478+ if len(d.sessions) != len(d.baseSessions) {
479+ t.Fatalf("one-character search filtered sessions: %#v", d.sessions)
480+ }
481+ if events := d.StartSearch(context.Background()); events != nil || d.searchEvents != nil {
482+ t.Fatal("one-character search started the corpus reader")
483+ }
484+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'h'}); !action.StartSearch || d.query != "sh" {
485+ t.Fatalf("two-character action = %#v, query = %q", action, d.query)
486+ }
487+}
488+
489+func TestSessionDialogClearsMatchesBelowSearchMinimum(t *testing.T) {
490+ d := newSessionDialog()
491+ d.active = true
492+ d.searchRequested = true
493+ d.searchReady = true
494+ d.query = "sh"
495+ d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1, FirstUserText: "session"}}
496+ d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
497+ d.searchMatches = map[string]sessionSearchMatch{"one": {count: 1}}
498+
499+ d.HandleKey(tui.Key{Kind: tui.KeyBackspace})
500+ if d.searchMatches != nil {
501+ t.Fatalf("matches after shortening query = %#v, want nil", d.searchMatches)
502+ }
503+ rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
504+ if strings.Contains(rendered, "1 match") {
505+ t.Fatalf("one-character search rendered stale match count: %q", rendered)
506+ }
507+}
508+
422509 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
423510 first := newSessionDialog()
424511 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
@@ -419,6 +419,93 @@ 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 TestSessionSearchCountsRepeatedSegmentMatches(t *testing.T) {
448+ matches := matchSessionSearchSegments(context.Background(), "sharm", []core.SessionSearchSegment{
449+ {Path: "repeated", Text: "sharm then sharm"},
450+ })
451+ match, ok := matches["repeated"]
452+ if !ok {
453+ t.Fatalf("matches = %#v, missing repeated session", matches)
454+ }
455+ if match.count != 2 {
456+ t.Fatalf("match count = %d, want 2", match.count)
457+ }
458+ if match.excerpt != "sharm then sharm" {
459+ t.Fatalf("match excerpt = %q", match.excerpt)
460+ }
461+ if want := []int{0, 1, 2, 3, 4}; 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] {
462+ t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
463+ }
464+}
465+
466+func TestSessionDialogDefersSearchUntilSecondCharacter(t *testing.T) {
467+ d := newSessionDialog()
468+ d.active = true
469+ d.baseSessions = []core.SessionSummary{{Path: "one"}, {Path: "two"}}
470+ d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
471+
472+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}); action.StartSearch || !d.searchRequested {
473+ t.Fatalf("slash action = %#v, search requested = %v", action, d.searchRequested)
474+ }
475+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 's'}); action.StartSearch || d.query != "s" {
476+ t.Fatalf("one-character action = %#v, query = %q", action, d.query)
477+ }
478+ if len(d.sessions) != len(d.baseSessions) {
479+ t.Fatalf("one-character search filtered sessions: %#v", d.sessions)
480+ }
481+ if events := d.StartSearch(context.Background()); events != nil || d.searchEvents != nil {
482+ t.Fatal("one-character search started the corpus reader")
483+ }
484+ if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'h'}); !action.StartSearch || d.query != "sh" {
485+ t.Fatalf("two-character action = %#v, query = %q", action, d.query)
486+ }
487+}
488+
489+func TestSessionDialogClearsMatchesBelowSearchMinimum(t *testing.T) {
490+ d := newSessionDialog()
491+ d.active = true
492+ d.searchRequested = true
493+ d.searchReady = true
494+ d.query = "sh"
495+ d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1, FirstUserText: "session"}}
496+ d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
497+ d.searchMatches = map[string]sessionSearchMatch{"one": {count: 1}}
498+
499+ d.HandleKey(tui.Key{Kind: tui.KeyBackspace})
500+ if d.searchMatches != nil {
501+ t.Fatalf("matches after shortening query = %#v, want nil", d.searchMatches)
502+ }
503+ rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
504+ if strings.Contains(rendered, "1 match") {
505+ t.Fatalf("one-character search rendered stale match count: %q", rendered)
506+ }
507+}
508+
422 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {509 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
423 first := newSessionDialog()510 first := newSessionDialog()
424 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())511 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
M packages/agent/subagents/resident_manager_test.go +4 -1
@@ -482,7 +482,10 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) {
482482 if err != nil {
483483 t.Fatal(err)
484484 }
485- deadline := time.Now().Add(time.Second)
485+ // Creating and diffing a real Git worktree can exceed a second on Windows,
486+ // especially under filesystem scanning. Keep a bounded wait while allowing
487+ // the actual asynchronous work to complete.
488+ deadline := time.Now().Add(5 * time.Second)
486489 for child.State() != ResidentIdle {
487490 if time.Now().After(deadline) {
488491 t.Fatal("worktree turn did not finish")
@@ -482,7 +482,10 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) {
482 if err != nil {482 if err != nil {
483 t.Fatal(err)483 t.Fatal(err)
484 }484 }
485- deadline := time.Now().Add(time.Second)485+ // Creating and diffing a real Git worktree can exceed a second on Windows,
486+ // especially under filesystem scanning. Keep a bounded wait while allowing
487+ // the actual asynchronous work to complete.
488+ deadline := time.Now().Add(5 * time.Second)
486 for child.State() != ResidentIdle {489 for child.State() != ResidentIdle {
487 if time.Now().After(deadline) {490 if time.Now().After(deadline) {
488 t.Fatal("worktree turn did not finish")491 t.Fatal("worktree turn did not finish")

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 476

↑↓ moveenter openesc close