Skip to content

Commit d3e3222

Browse files
committed
🧹 improve shell history
1 parent 5873365 commit d3e3222

File tree

1 file changed

+166
-18
lines changed

1 file changed

+166
-18
lines changed

cli/shell/model.go

Lines changed: 166 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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
260266
func (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
575680
func 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
735877
func (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

Comments
 (0)