Skip to content

Commit 4a98d40

Browse files
jongioCopilot
andauthored
Add search query history recall (#201)
* Add search query history recall Keep a short in-memory ring of recently submitted search queries. While the search box is focused, Up recalls the previous query and Down walks back toward the most recent, clearing the box past the newest entry. Submitting a query moves a repeat to the most recent slot instead of duplicating it. The ring is capped at 20 entries and is not persisted across restarts. Closes #191 Co-authored-by: Copilot App <[email protected]> * fix: remove unused itoa helper in search history test The golangci-lint unused linter flagged func itoa as dead code; the test uses strconv.Itoa instead. Removing it unblocks the CI test job. Co-authored-by: Copilot App <[email protected]> --------- Co-authored-by: Copilot App <[email protected]>
1 parent 39add1f commit 4a98d40

4 files changed

Lines changed: 248 additions & 23 deletions

File tree

internal/tui/components/help.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ func (h HelpOverlay) View() string {
9090
sb.WriteByte('\n')
9191
sb.WriteString(shortcutRow("/", "Search", "Esc", "Clear"))
9292
sb.WriteByte('\n')
93+
sb.WriteString(shortcutRow("↑/↓", "Search history", "", ""))
94+
sb.WriteByte('\n')
9395
sb.WriteString(shortcutRow("f", "Filter dirs", "Space", "Toggle item"))
9496

9597
// Multi-Select

internal/tui/components/searchbar.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ func (s *SearchBar) SetValue(v string) {
6464
s.input.SetValue(v)
6565
}
6666

67+
// CursorEnd moves the input cursor to the end of the current value.
68+
func (s *SearchBar) CursorEnd() {
69+
s.input.CursorEnd()
70+
}
71+
6772
// SetResultCount updates the displayed result count.
6873
func (s *SearchBar) SetResultCount(n int) {
6974
s.resultCount = n

internal/tui/model.go

Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,98 @@ type searchState struct {
153153
copilotSearchCancel context.CancelFunc // cancels the in-flight SDK search
154154
aiSessionIDs map[string]struct{} // session IDs found by SDK search
155155
lastRawInput string // last raw search bar text (for change detection)
156+
history []string // committed search queries, oldest last (for up/down recall)
157+
historyIdx int // recall cursor into history; == len(history) means not navigating
158+
}
159+
160+
// maxSearchHistory caps how many recent queries are retained for up/down recall.
161+
const maxSearchHistory = 20
162+
163+
// pushHistory records a committed query for later recall. Blank queries are
164+
// ignored. Duplicates are collapsed so the same query never appears twice, and
165+
// the most recent entry is kept at the end. The recall cursor is reset to the
166+
// end (not navigating) after every push.
167+
func (s *searchState) pushHistory(q string) {
168+
q = strings.TrimSpace(q)
169+
if q == "" {
170+
s.historyIdx = len(s.history)
171+
return
172+
}
173+
// Drop any earlier identical entry so recall order stays useful.
174+
for i, h := range s.history {
175+
if h == q {
176+
s.history = append(s.history[:i], s.history[i+1:]...)
177+
break
178+
}
179+
}
180+
s.history = append(s.history, q)
181+
if len(s.history) > maxSearchHistory {
182+
s.history = s.history[len(s.history)-maxSearchHistory:]
183+
}
184+
s.historyIdx = len(s.history)
185+
}
186+
187+
// recallPrev steps to the previous (older) history entry and returns it. The
188+
// bool is false when there is no history to recall.
189+
func (s *searchState) recallPrev() (string, bool) {
190+
if len(s.history) == 0 {
191+
return "", false
192+
}
193+
if s.historyIdx > 0 {
194+
s.historyIdx--
195+
}
196+
return s.history[s.historyIdx], true
197+
}
198+
199+
// recallNext steps to the next (newer) history entry and returns it. When the
200+
// cursor moves past the newest entry it returns an empty string so the caller
201+
// can clear the input. The bool is false when there is no history to recall.
202+
func (s *searchState) recallNext() (string, bool) {
203+
if len(s.history) == 0 {
204+
return "", false
205+
}
206+
if s.historyIdx < len(s.history) {
207+
s.historyIdx++
208+
}
209+
if s.historyIdx >= len(s.history) {
210+
return "", true
211+
}
212+
return s.history[s.historyIdx], true
213+
}
214+
215+
// triggerSearch reacts to a new search bar value: it records the raw input,
216+
// parses structured tokens, kicks off the quick reload, and schedules the
217+
// debounced deep and Copilot SDK searches. inputCmd, when non-nil, is the
218+
// command returned by the text input update and is batched in first.
219+
func (m *Model) triggerSearch(inputCmd tea.Cmd) tea.Cmd {
220+
newQuery := m.searchBar.Value()
221+
m.search.lastRawInput = newQuery
222+
// Parse structured tokens from the input.
223+
m.searchFilter = ParseSearchTokens(newQuery)
224+
m.applySearchTokens()
225+
m.filter.DeepSearch = false
226+
// Quick search fires immediately; schedule deep search.
227+
m.search.deepSearchVersion++
228+
m.search.deepSearchPending = true
229+
m.searchBar.SetSearching(true)
230+
231+
cmds := make([]tea.Cmd, 0, 4)
232+
if inputCmd != nil {
233+
cmds = append(cmds, inputCmd)
234+
}
235+
cmds = append(cmds, m.loadSessionsCmd(), m.scheduleDeepSearch(m.search.deepSearchVersion))
236+
237+
// Copilot SDK search is gated by config flag.
238+
if m.cfg.AISearch {
239+
m.search.copilotSearchVersion++
240+
m.searchBar.SetAISearching(false) // reset until tick fires
241+
m.searchBar.SetAIResults(0) // clear stale count
242+
m.search.aiSessionIDs = nil
243+
m.sessionList.SetAISessions(nil)
244+
cmds = append(cmds, m.scheduleCopilotSearch(m.search.copilotSearchVersion))
245+
}
246+
247+
return tea.Batch(cmds...)
156248
}
157249

158250
// workStatusState groups fields related to work status scanning.
@@ -927,12 +1019,14 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
9271019
// sort, pivot) continue to honour the search term. To clear the
9281020
// search, press Escape again from the session list.
9291021
if m.filter.Query != "" || m.searchFilter.HasTokens() {
1022+
m.search.pushHistory(m.searchBar.Value())
9301023
m.filter.DeepSearch = true
9311024
return m, m.loadSessionsCmd()
9321025
}
9331026
return m, nil
9341027
case key.Matches(msg, keys.Enter):
9351028
m.searchBar.Blur()
1029+
m.search.pushHistory(m.searchBar.Value())
9361030
// If deep search hasn't run yet, trigger it now.
9371031
if m.search.deepSearchPending && (m.filter.Query != "" || m.searchFilter.HasTokens()) {
9381032
m.search.deepSearchPending = false
@@ -945,6 +1039,24 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
9451039
m.filter.DeepSearch = true
9461040
}
9471041
return m, nil
1042+
case msg.String() == "up":
1043+
// Recall an older query. Only the literal arrow key triggers
1044+
// history; the k alias for Up is left to be typed normally.
1045+
if q, ok := m.search.recallPrev(); ok {
1046+
m.searchBar.SetValue(q)
1047+
m.searchBar.CursorEnd()
1048+
return m, m.triggerSearch(nil)
1049+
}
1050+
return m, nil
1051+
case msg.String() == "down":
1052+
// Recall a newer query, clearing the input when moving past the
1053+
// most recent entry. The j alias for Down is left to typing.
1054+
if q, ok := m.search.recallNext(); ok {
1055+
m.searchBar.SetValue(q)
1056+
m.searchBar.CursorEnd()
1057+
return m, m.triggerSearch(nil)
1058+
}
1059+
return m, nil
9481060
default:
9491061
// All other keys (including j/k which alias Up/Down) are
9501062
// forwarded to the search text input so they appear as typed
@@ -955,29 +1067,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
9551067
m.searchBar = sb
9561068
newQuery := m.searchBar.Value()
9571069
if newQuery != m.search.lastRawInput {
958-
m.search.lastRawInput = newQuery
959-
// Parse structured tokens from the input.
960-
m.searchFilter = ParseSearchTokens(newQuery)
961-
m.applySearchTokens()
962-
m.filter.DeepSearch = false
963-
// Quick search fires immediately; schedule deep search.
964-
m.search.deepSearchVersion++
965-
m.search.deepSearchPending = true
966-
m.searchBar.SetSearching(true)
967-
968-
cmds := []tea.Cmd{cmd, m.loadSessionsCmd(), m.scheduleDeepSearch(m.search.deepSearchVersion)}
969-
970-
// Copilot SDK search is gated by config flag.
971-
if m.cfg.AISearch {
972-
m.search.copilotSearchVersion++
973-
m.searchBar.SetAISearching(false) // reset until tick fires
974-
m.searchBar.SetAIResults(0) // clear stale count
975-
m.search.aiSessionIDs = nil
976-
m.sessionList.SetAISessions(nil)
977-
cmds = append(cmds, m.scheduleCopilotSearch(m.search.copilotSearchVersion))
978-
}
979-
980-
return m, tea.Batch(cmds...)
1070+
return m, m.triggerSearch(cmd)
9811071
}
9821072
return m, cmd
9831073
}
@@ -1006,6 +1096,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
10061096
m.filter.DeepSearch = false
10071097
m.searchFilter = SearchFilter{}
10081098
m.search.lastRawInput = ""
1099+
m.search.historyIdx = len(m.search.history)
10091100
m.clearSearchTokenFilters()
10101101
m.searchBar.SetValue("")
10111102
m.searchBar.SetSearching(false)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package tui
2+
3+
import (
4+
"strconv"
5+
"testing"
6+
7+
tea "charm.land/bubbletea/v2"
8+
)
9+
10+
func upKeyMsg() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyUp} }
11+
func downKeyMsg() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyDown} }
12+
13+
func TestSearchStateHistory(t *testing.T) {
14+
var s searchState
15+
16+
// No history yet: recall is a no-op.
17+
if _, ok := s.recallPrev(); ok {
18+
t.Fatal("recallPrev on empty history should return false")
19+
}
20+
if _, ok := s.recallNext(); ok {
21+
t.Fatal("recallNext on empty history should return false")
22+
}
23+
24+
// Blank queries are ignored.
25+
s.pushHistory(" ")
26+
if len(s.history) != 0 {
27+
t.Fatalf("blank query should not be recorded, history=%v", s.history)
28+
}
29+
30+
s.pushHistory("alpha")
31+
s.pushHistory("beta")
32+
s.pushHistory("gamma")
33+
34+
// Recall walks backwards from newest to oldest.
35+
if q, _ := s.recallPrev(); q != "gamma" {
36+
t.Errorf("first recallPrev = %q, want gamma", q)
37+
}
38+
if q, _ := s.recallPrev(); q != "beta" {
39+
t.Errorf("second recallPrev = %q, want beta", q)
40+
}
41+
if q, _ := s.recallPrev(); q != "alpha" {
42+
t.Errorf("third recallPrev = %q, want alpha", q)
43+
}
44+
// Past the oldest it stays on the oldest entry.
45+
if q, _ := s.recallPrev(); q != "alpha" {
46+
t.Errorf("recallPrev past oldest = %q, want alpha", q)
47+
}
48+
49+
// Recall forward walks toward newest, then clears past the end.
50+
if q, _ := s.recallNext(); q != "beta" {
51+
t.Errorf("recallNext = %q, want beta", q)
52+
}
53+
if q, _ := s.recallNext(); q != "gamma" {
54+
t.Errorf("recallNext = %q, want gamma", q)
55+
}
56+
if q, ok := s.recallNext(); !ok || q != "" {
57+
t.Errorf("recallNext past newest = (%q,%v), want (\"\",true)", q, ok)
58+
}
59+
}
60+
61+
func TestSearchStateHistoryDedupAndCap(t *testing.T) {
62+
var s searchState
63+
64+
// Re-submitting an existing query moves it to the newest slot without
65+
// creating a duplicate.
66+
s.pushHistory("one")
67+
s.pushHistory("two")
68+
s.pushHistory("one")
69+
if len(s.history) != 2 {
70+
t.Fatalf("dedup failed, history=%v", s.history)
71+
}
72+
if q, _ := s.recallPrev(); q != "one" {
73+
t.Errorf("most recent after re-submit = %q, want one", q)
74+
}
75+
76+
// The ring is capped at maxSearchHistory entries.
77+
var s2 searchState
78+
for i := 0; i < maxSearchHistory+10; i++ {
79+
s2.pushHistory("q-" + strconv.Itoa(i))
80+
}
81+
if len(s2.history) != maxSearchHistory {
82+
t.Errorf("history len = %d, want %d", len(s2.history), maxSearchHistory)
83+
}
84+
}
85+
86+
func TestSearchHistoryRecallViaKeys(t *testing.T) {
87+
m := newTestModel()
88+
89+
// Commit "foo".
90+
m.searchBar.Focus()
91+
m.searchBar.SetValue("foo")
92+
m.filter.Query = "foo"
93+
r, _ := m.Update(enterKeyMsg())
94+
m = r.(Model)
95+
96+
// Commit "bar".
97+
m.searchBar.Focus()
98+
m.searchBar.SetValue("bar")
99+
m.filter.Query = "bar"
100+
r, _ = m.Update(enterKeyMsg())
101+
m = r.(Model)
102+
103+
// Up recalls the most recent query, then the older one.
104+
m.searchBar.Focus()
105+
r, _ = m.Update(upKeyMsg())
106+
m = r.(Model)
107+
if got := m.searchBar.Value(); got != "bar" {
108+
t.Fatalf("first Up recall = %q, want bar", got)
109+
}
110+
r, _ = m.Update(upKeyMsg())
111+
m = r.(Model)
112+
if got := m.searchBar.Value(); got != "foo" {
113+
t.Fatalf("second Up recall = %q, want foo", got)
114+
}
115+
116+
// Down walks back toward newest, then clears past the newest entry.
117+
r, _ = m.Update(downKeyMsg())
118+
m = r.(Model)
119+
if got := m.searchBar.Value(); got != "bar" {
120+
t.Fatalf("Down recall = %q, want bar", got)
121+
}
122+
r, _ = m.Update(downKeyMsg())
123+
m = r.(Model)
124+
if got := m.searchBar.Value(); got != "" {
125+
t.Fatalf("Down past newest = %q, want empty", got)
126+
}
127+
}

0 commit comments

Comments
 (0)