@@ -70,6 +70,12 @@ type shellModel struct {
7070 historyDraft string
7171 historyPath string
7272
73+ // History search (ctrl+r)
74+ searchMode bool
75+ searchQuery string
76+ searchMatches []int // indices into history that match
77+ searchIdx int // current index into searchMatches
78+
7379 // Layout
7480 width int
7581 height int
@@ -258,6 +264,11 @@ func (m *shellModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
258264
259265// handleKeyMsg processes keyboard input
260266func (m * shellModel ) handleKeyMsg (msg tea.KeyMsg ) (tea.Model , tea.Cmd ) {
267+ // Handle history search mode (ctrl+r)
268+ if m .searchMode {
269+ return m .handleSearchKey (msg )
270+ }
271+
261272 // Handle pasted content - let textarea handle it but adjust height after
262273 if msg .Paste {
263274 m .showPopup = false
@@ -327,6 +338,18 @@ func (m *shellModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
327338 // Show asset information
328339 return m , m .showAssetInfo ()
329340
341+ case "ctrl+r" :
342+ // Enter history search mode
343+ if len (m .history ) > 0 {
344+ m .searchMode = true
345+ m .searchQuery = ""
346+ m .searchMatches = nil
347+ m .searchIdx = 0
348+ // Save current input as draft
349+ m .historyDraft = m .input .Value ()
350+ }
351+ return m , nil
352+
330353 case "ctrl+j" :
331354 // Insert a newline for manual multiline input
332355 m .showPopup = false
@@ -337,22 +360,6 @@ func (m *shellModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
337360
338361 case "enter" :
339362 return m .handleSubmit ()
340-
341- case "up" :
342- // History navigation only when input is empty or single-line on first line
343- isMultiline := strings .Contains (m .input .Value (), "\n " )
344- if m .input .Value () == "" || (! isMultiline && m .input .Line () == 0 ) {
345- return m .navigateHistory (- 1 )
346- }
347- // For multi-line, let textarea handle cursor movement
348-
349- case "down" :
350- // History navigation only when input is empty or single-line while browsing history
351- isMultiline := strings .Contains (m .input .Value (), "\n " )
352- if m .input .Value () == "" || (! isMultiline && m .historyIdx < len (m .history )) {
353- return m .navigateHistory (1 )
354- }
355- // For multi-line, let textarea handle cursor movement
356363 }
357364
358365 // Let textarea handle all other keys
@@ -568,9 +575,107 @@ func (m *shellModel) navigateHistory(direction int) (tea.Model, tea.Cmd) {
568575 m .input .SetValue (m .history [m .historyIdx ])
569576 }
570577
578+ // Update height for multi-line history entries
579+ m .updateInputHeight ()
580+
581+ // Move cursor to start so first line is visible
582+ m .input .CursorStart ()
583+
571584 return m , nil
572585}
573586
587+ // handleSearchKey processes key input during history search mode
588+ func (m * shellModel ) handleSearchKey (msg tea.KeyMsg ) (tea.Model , tea.Cmd ) {
589+ switch msg .String () {
590+ case "ctrl+r" :
591+ // Find next match (go backwards in history)
592+ if len (m .searchMatches ) > 0 {
593+ m .searchIdx ++
594+ if m .searchIdx >= len (m .searchMatches ) {
595+ m .searchIdx = 0 // wrap around
596+ }
597+ m .applySearchMatch ()
598+ }
599+ return m , nil
600+
601+ case "ctrl+c" , "esc" :
602+ // Cancel search, restore original input
603+ m .searchMode = false
604+ m .input .SetValue (m .historyDraft )
605+ m .updateInputHeight ()
606+ return m , nil
607+
608+ case "enter" :
609+ // Accept current match and exit search mode
610+ m .searchMode = false
611+ // Keep the current input value (already set by search)
612+ return m , nil
613+
614+ case "backspace" :
615+ // Remove last character from search query
616+ if len (m .searchQuery ) > 0 {
617+ m .searchQuery = m .searchQuery [:len (m .searchQuery )- 1 ]
618+ m .updateSearchMatches ()
619+ }
620+ return m , nil
621+
622+ case "ctrl+g" :
623+ // Abort search (like in emacs)
624+ m .searchMode = false
625+ m .input .SetValue (m .historyDraft )
626+ m .updateInputHeight ()
627+ return m , nil
628+
629+ default :
630+ // Add typed characters to search query
631+ if len (msg .Runes ) > 0 {
632+ for _ , r := range msg .Runes {
633+ m .searchQuery += string (r )
634+ }
635+ m .updateSearchMatches ()
636+ }
637+ return m , nil
638+ }
639+ }
640+
641+ // updateSearchMatches finds all history entries matching the search query
642+ func (m * shellModel ) updateSearchMatches () {
643+ m .searchMatches = nil
644+ m .searchIdx = 0
645+
646+ if m .searchQuery == "" {
647+ m .input .SetValue ("" )
648+ m .updateInputHeight ()
649+ return
650+ }
651+
652+ query := strings .ToLower (m .searchQuery )
653+
654+ // Search backwards through history (most recent first)
655+ for i := len (m .history ) - 1 ; i >= 0 ; i -- {
656+ if strings .Contains (strings .ToLower (m .history [i ]), query ) {
657+ m .searchMatches = append (m .searchMatches , i )
658+ }
659+ }
660+
661+ m .applySearchMatch ()
662+ }
663+
664+ // applySearchMatch applies the current search match to the input
665+ func (m * shellModel ) applySearchMatch () {
666+ if len (m .searchMatches ) == 0 {
667+ m .input .SetValue ("" )
668+ m .updateInputHeight ()
669+ return
670+ }
671+
672+ idx := m .searchMatches [m .searchIdx ]
673+ m .input .SetValue (m .history [idx ])
674+ m .historyIdx = idx
675+ m .updateInputHeight ()
676+ m .input .CursorStart ()
677+ }
678+
574679// isBuiltinCommand checks if the input is a built-in shell command
575680func isBuiltinCommand (input string ) bool {
576681 trimmed := strings .TrimSpace (input )
@@ -668,6 +773,9 @@ func (m *shellModel) View() string {
668773 if m .executing {
669774 b .WriteString (m .spinner .View ())
670775 b .WriteString (" Executing query..." )
776+ } else if m .searchMode {
777+ // Show search interface
778+ b .WriteString (m .renderSearchView ())
671779 } else {
672780 // Render textarea input
673781 b .WriteString (m .input .View ())
@@ -731,11 +839,51 @@ func (m *shellModel) showAssetInfo() tea.Cmd {
731839 }
732840}
733841
842+ // renderSearchView renders the history search interface
843+ func (m * shellModel ) renderSearchView () string {
844+ var b strings.Builder
845+
846+ // Show the search prompt
847+ searchPrompt := m .theme .Secondary .Render ("(reverse-i-search)`" ) +
848+ m .theme .HelpKey .Render (m .searchQuery ) +
849+ m .theme .Secondary .Render ("': " )
850+
851+ b .WriteString (searchPrompt )
852+
853+ // Show current match or empty
854+ if len (m .searchMatches ) > 0 {
855+ // Show the matched command (first line only for preview)
856+ match := m.history [m.searchMatches [m.searchIdx ]]
857+ lines := strings .Split (match , "\n " )
858+ preview := lines [0 ]
859+ if len (lines ) > 1 {
860+ preview += m .theme .Disabled .Render (" ..." )
861+ }
862+ b .WriteString (preview )
863+ } else if m .searchQuery != "" {
864+ b .WriteString (m .theme .Disabled .Render ("(no match)" ))
865+ }
866+
867+ // Show match count
868+ if len (m .searchMatches ) > 0 {
869+ b .WriteString ("\n " )
870+ b .WriteString (m .theme .HelpText .Render (fmt .Sprintf (" [%d/%d matches]" , m .searchIdx + 1 , len (m .searchMatches ))))
871+ }
872+
873+ return b .String ()
874+ }
875+
734876// renderHelpBar renders the help bar with available key bindings
735877func (m * shellModel ) renderHelpBar () string {
736878 var items []string
737879
738- if m .showPopup {
880+ if m .searchMode {
881+ items = []string {
882+ m .theme .HelpKey .Render ("ctrl+r" ) + m .theme .HelpText .Render (" next" ),
883+ m .theme .HelpKey .Render ("enter" ) + m .theme .HelpText .Render (" select" ),
884+ m .theme .HelpKey .Render ("esc" ) + m .theme .HelpText .Render (" cancel" ),
885+ }
886+ } else if m .showPopup {
739887 items = []string {
740888 m .theme .HelpKey .Render ("↑↓" ) + m .theme .HelpText .Render (" navigate" ),
741889 m .theme .HelpKey .Render ("tab" ) + m .theme .HelpText .Render (" select" ),
@@ -748,8 +896,8 @@ func (m *shellModel) renderHelpBar() string {
748896 } else {
749897 items = []string {
750898 m .theme .HelpKey .Render ("enter" ) + m .theme .HelpText .Render (" run" ),
899+ m .theme .HelpKey .Render ("ctrl+r" ) + m .theme .HelpText .Render (" search" ),
751900 m .theme .HelpKey .Render ("ctrl+o" ) + m .theme .HelpText .Render (" info" ),
752- m .theme .HelpKey .Render ("ctrl+j" ) + m .theme .HelpText .Render (" newline" ),
753901 m .theme .HelpKey .Render ("ctrl+d" ) + m .theme .HelpText .Render (" exit" ),
754902 }
755903 }
0 commit comments