@@ -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 )
0 commit comments