From 931d90b28c810f86e91162aaf2b31a5d13d4b330 Mon Sep 17 00:00:00 2001 From: Moustafa Ahmed Date: Sun, 8 Feb 2026 17:09:28 +0100 Subject: [PATCH] feat: add Ctrl+R history search to chat editor Signed-off-by: Moustafa Ahmed --- pkg/history/history.go | 45 +++ pkg/history/history_test.go | 230 +++++++++++++ pkg/tui/components/editor/editor.go | 166 +++++++++- .../components/editor/historysearch_test.go | 309 ++++++++++++++++++ pkg/tui/page/chat/chat.go | 6 + pkg/tui/page/chat/input_handlers.go | 14 + 6 files changed, 769 insertions(+), 1 deletion(-) create mode 100644 pkg/tui/components/editor/historysearch_test.go diff --git a/pkg/history/history.go b/pkg/history/history.go index 0a6f58337..1200de810 100644 --- a/pkg/history/history.go +++ b/pkg/history/history.go @@ -142,6 +142,51 @@ func (h *History) LatestMatch(prefix string) string { return "" } +// FindPrevContains searches backward through history for a message containing query. +// from is an exclusive upper bound index. Pass len(Messages) to start from the most recent. +// Returns the matched message, its index, and whether a match was found. +// An empty query matches any entry. +func (h *History) FindPrevContains(query string, from int) (msg string, idx int, ok bool) { + if len(h.Messages) == 0 { + return "", -1, false + } + + start := min(from-1, len(h.Messages)-1) + + query = strings.ToLower(query) + for i := start; i >= 0; i-- { + if query == "" || strings.Contains(strings.ToLower(h.Messages[i]), query) { + return h.Messages[i], i, true + } + } + + return "", -1, false +} + +// FindNextContains searches forward through history for a message containing query. +// from is an exclusive lower bound index. Pass -1 to start from the oldest. +// Returns the matched message, its index, and whether a match was found. +func (h *History) FindNextContains(query string, from int) (msg string, idx int, ok bool) { + if len(h.Messages) == 0 { + return "", -1, false + } + + start := max(from+1, 0) + + query = strings.ToLower(query) + for i := start; i < len(h.Messages); i++ { + if query == "" || strings.Contains(strings.ToLower(h.Messages[i]), query) { + return h.Messages[i], i, true + } + } + + return "", -1, false +} + +func (h *History) SetCurrent(i int) { + h.current = i +} + func (h *History) append(message string) error { if err := os.MkdirAll(filepath.Dir(h.path), 0o755); err != nil { return err diff --git a/pkg/history/history_test.go b/pkg/history/history_test.go index 884283373..d12b9cc95 100644 --- a/pkg/history/history_test.go +++ b/pkg/history/history_test.go @@ -187,3 +187,233 @@ func TestHistory_LatestMatch(t *testing.T) { // Exact match doesn't count (must extend prefix) assert.Empty(t, h.LatestMatch("goodbye")) } + +func TestHistory_FindPrevContains(t *testing.T) { + t.Parallel() + + t.Run("empty history", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + msg, idx, ok := h.FindPrevContains("test", len(h.Messages)) + assert.False(t, ok) + assert.Empty(t, msg) + assert.Equal(t, -1, idx) + }) + + t.Run("empty query matches latest", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("first")) + require.NoError(t, h.Add("second")) + require.NoError(t, h.Add("third")) + + msg, idx, ok := h.FindPrevContains("", len(h.Messages)) + assert.True(t, ok) + assert.Equal(t, "third", msg) + assert.Equal(t, 2, idx) + }) + + t.Run("substring match", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("deploy staging")) + require.NoError(t, h.Add("run tests")) + require.NoError(t, h.Add("deploy production")) + + msg, idx, ok := h.FindPrevContains("deploy", len(h.Messages)) + assert.True(t, ok) + assert.Equal(t, "deploy production", msg) + assert.Equal(t, 2, idx) + }) + + t.Run("case insensitive match", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("Deploy Staging")) + require.NoError(t, h.Add("run tests")) + + msg, idx, ok := h.FindPrevContains("deploy", len(h.Messages)) + assert.True(t, ok) + assert.Equal(t, "Deploy Staging", msg) + assert.Equal(t, 0, idx) + }) + + t.Run("cycling through matches", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("deploy v1")) + require.NoError(t, h.Add("run tests")) + require.NoError(t, h.Add("deploy v2")) + require.NoError(t, h.Add("check logs")) + require.NoError(t, h.Add("deploy v3")) + + // First match: most recent + msg, idx, ok := h.FindPrevContains("deploy", len(h.Messages)) + assert.True(t, ok) + assert.Equal(t, "deploy v3", msg) + assert.Equal(t, 4, idx) + + // Cycle to next older match + msg, idx, ok = h.FindPrevContains("deploy", idx) + assert.True(t, ok) + assert.Equal(t, "deploy v2", msg) + assert.Equal(t, 2, idx) + + // Cycle to oldest match + msg, idx, ok = h.FindPrevContains("deploy", idx) + assert.True(t, ok) + assert.Equal(t, "deploy v1", msg) + assert.Equal(t, 0, idx) + + // No more matches + _, _, ok = h.FindPrevContains("deploy", idx) + assert.False(t, ok) + }) + + t.Run("no match", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("hello")) + require.NoError(t, h.Add("world")) + + msg, idx, ok := h.FindPrevContains("xyz", len(h.Messages)) + assert.False(t, ok) + assert.Empty(t, msg) + assert.Equal(t, -1, idx) + }) + + t.Run("from out of bounds", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("hello")) + + msg, idx, ok := h.FindPrevContains("hello", 100) + assert.True(t, ok) + assert.Equal(t, "hello", msg) + assert.Equal(t, 0, idx) + }) +} + +func TestHistory_FindNextContains(t *testing.T) { + t.Parallel() + + t.Run("basic forward search", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("deploy v1")) + require.NoError(t, h.Add("run tests")) + require.NoError(t, h.Add("deploy v2")) + + msg, idx, ok := h.FindNextContains("deploy", -1) + assert.True(t, ok) + assert.Equal(t, "deploy v1", msg) + assert.Equal(t, 0, idx) + }) + + t.Run("sequential forward search", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("echo 1")) + require.NoError(t, h.Add("echo 2")) + require.NoError(t, h.Add("echo 3")) + + msg, idx, ok := h.FindNextContains("echo", -1) + assert.True(t, ok) + assert.Equal(t, "echo 1", msg) + assert.Equal(t, 0, idx) + + msg, idx, ok = h.FindNextContains("echo", idx) + assert.True(t, ok) + assert.Equal(t, "echo 2", msg) + assert.Equal(t, 1, idx) + + msg, idx, ok = h.FindNextContains("echo", idx) + assert.True(t, ok) + assert.Equal(t, "echo 3", msg) + assert.Equal(t, 2, idx) + + _, _, ok = h.FindNextContains("echo", idx) + assert.False(t, ok) + }) + + t.Run("no match", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("hello")) + + msg, idx, ok := h.FindNextContains("xyz", -1) + assert.False(t, ok) + assert.Empty(t, msg) + assert.Equal(t, -1, idx) + }) + + t.Run("empty history", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + _, _, ok := h.FindNextContains("test", -1) + assert.False(t, ok) + }) + + t.Run("case insensitive", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("Deploy Staging")) + + msg, _, ok := h.FindNextContains("deploy", -1) + assert.True(t, ok) + assert.Equal(t, "Deploy Staging", msg) + }) +} + +func TestHistory_SetCurrent(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + h, err := New(WithBaseDir(tmpDir)) + require.NoError(t, err) + + require.NoError(t, h.Add("first")) + require.NoError(t, h.Add("second")) + require.NoError(t, h.Add("third")) + + h.SetCurrent(1) + assert.Equal(t, "first", h.Previous()) + + h.SetCurrent(2) + assert.Empty(t, h.Next()) +} diff --git a/pkg/tui/components/editor/editor.go b/pkg/tui/components/editor/editor.go index 75f0cf0e7..9b3d44bb5 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -12,6 +12,7 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/atotto/clipboard" @@ -81,6 +82,10 @@ type Editor interface { SetRecording(recording bool) tea.Cmd // IsRecording returns true if the editor is in recording mode IsRecording() bool + // IsHistorySearchActive returns true if the editor is in history search mode + IsHistorySearchActive() bool + // EnterHistorySearch activates incremental history search + EnterHistorySearch() (layout.Model, tea.Cmd) // SendContent triggers sending the current editor content SendContent() tea.Cmd } @@ -92,6 +97,17 @@ type fileLoadResultMsg struct { isFullLoad bool // true for full load, false for initial shallow load } +// historySearchState holds the state for incremental history search. +type historySearchState struct { + active bool + query string + origTextValue string + origTextPlaceholderValue string + match string + matchIndex int + failing bool +} + // editor implements [Editor] type editor struct { textarea textarea.Model @@ -134,6 +150,11 @@ type editor struct { fileFullLoadStarted bool // fileLoadCancel cancels any in-progress file loading fileLoadCancel context.CancelFunc + + // historySearch holds state for history search mode + historySearch historySearchState + // searchInput is the input field for history search queries + searchInput textinput.Model } // New creates a new editor component @@ -148,8 +169,21 @@ func New(a *app.App, hist *history.History) Editor { ta.Focus() ta.ShowLineNumbers = false + si := textinput.New() + si.Prompt = "" + si.Placeholder = "Type to search..." + + // Customize styles for search input + s := styles.DialogInputStyle + s.Focused.Text = styles.MutedStyle + s.Focused.Placeholder = styles.MutedStyle + s.Blurred.Text = styles.MutedStyle + s.Blurred.Placeholder = styles.MutedStyle + si.SetStyles(s) + e := &editor{ textarea: ta, + searchInput: si, hist: hist, completions: completions.Completions(a), keyboardEnhancementsSupported: false, @@ -700,6 +734,10 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { } return e, nil case tea.KeyPressMsg: + if e.historySearch.active { + return e.handleHistorySearchKey(msg) + } + if key.Matches(msg, e.textarea.KeyMap.Paste) { return e.handleClipboardPaste() } @@ -1120,10 +1158,13 @@ func (e *editor) View() string { bannerView := e.banner.View() if bannerView != "" { - // Banner is shown - no extra top padding needed view = lipgloss.JoinVertical(lipgloss.Left, bannerView, view) } + if e.historySearch.active { + view = lipgloss.JoinVertical(lipgloss.Left, view, e.searchInput.View()) + } + return styles.RenderComposite(styles.EditorStyle.MarginBottom(1), view) } @@ -1133,6 +1174,7 @@ func (e *editor) SetSize(width, height int) tea.Cmd { e.height = max(height, 1) e.textarea.SetWidth(max(width, 10)) + e.searchInput.SetWidth(max(width, 10)) e.updateTextareaHeight() return nil @@ -1143,6 +1185,9 @@ func (e *editor) updateTextareaHeight() { if e.banner != nil { available -= e.banner.Height() } + if e.historySearch.active { + available-- + } available = max(available, 1) @@ -1362,6 +1407,11 @@ func (e *editor) IsRecording() bool { return e.recording } +// IsHistorySearchActive returns true if the editor is in history search mode +func (e *editor) IsHistorySearchActive() bool { + return e.historySearch.active +} + // SendContent triggers sending the current editor content func (e *editor) SendContent() tea.Cmd { value := e.textarea.Value() @@ -1444,3 +1494,117 @@ func createPasteAttachment(content string, num int) (attachment, error) { isTemp: true, }, nil } + +func (e *editor) EnterHistorySearch() (layout.Model, tea.Cmd) { + e.historySearch = historySearchState{ + active: true, + origTextValue: e.textarea.Value(), + origTextPlaceholderValue: e.textarea.Placeholder, + matchIndex: -1, + } + + e.searchInput.SetValue("") + e.textarea.SetValue("") + e.textarea.Placeholder = "" + e.textarea.Blur() + e.clearSuggestion() + return e, tea.Batch( + e.searchInput.Focus(), + core.CmdHandler(completion.CloseMsg{}), + ) +} + +func (e *editor) handleHistorySearchKey(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) { + switch { + case key.Matches(msg, e.searchInput.KeyMap.PrevSuggestion): + e.cycleMatch(e.hist.FindPrevContains, len(e.hist.Messages)) + return e, nil + + case key.Matches(msg, e.searchInput.KeyMap.NextSuggestion): + e.cycleMatch(e.hist.FindNextContains, -1) + return e, nil + + case msg.String() == "enter": + value := e.textarea.Value() + matchIdx := e.historySearch.matchIndex + cmd := e.exitHistorySearch() + if value != "" { + e.textarea.SetValue(value) + e.textarea.MoveToEnd() + if matchIdx >= 0 { + e.hist.SetCurrent(matchIdx) + } + e.userTyped = false + } + e.refreshSuggestion() + return e, tea.Batch(cmd, core.CmdHandler(completion.CloseMsg{})) + + case msg.String() == "esc" || msg.String() == "ctrl+g": + cmd := e.exitHistorySearch() + e.refreshSuggestion() + return e, tea.Batch(cmd, core.CmdHandler(completion.CloseMsg{})) + } + + var cmd tea.Cmd + e.searchInput, cmd = e.searchInput.Update(msg) + + newQuery := e.searchInput.Value() + if newQuery != e.historySearch.query { + e.historySearch.query = newQuery + e.historySearchComputeMatch() + } + + return e, cmd +} + +// cycleMatch searches history using findFn starting from the current match. +// If no match is found, it wraps around using wrapFrom as the starting point. +func (e *editor) cycleMatch(findFn func(string, int) (string, int, bool), wrapFrom int) { + if e.historySearch.matchIndex < 0 { + return + } + m, idx, ok := findFn(e.historySearch.query, e.historySearch.matchIndex) + if !ok { + m, idx, ok = findFn(e.historySearch.query, wrapFrom) + } + if ok { + e.historySearch.match = m + e.historySearch.matchIndex = idx + e.historySearch.failing = false + e.textarea.SetValue(m) + e.textarea.MoveToEnd() + } +} + +func (e *editor) historySearchComputeMatch() { + if e.historySearch.query == "" { + e.historySearch.match = "" + e.historySearch.matchIndex = -1 + e.historySearch.failing = false + e.textarea.SetValue("") + e.textarea.Placeholder = "" + return + } + + m, idx, ok := e.hist.FindPrevContains(e.historySearch.query, len(e.hist.Messages)) + if ok { + e.historySearch.match = m + e.historySearch.matchIndex = idx + e.historySearch.failing = false + e.textarea.SetValue(m) + e.textarea.MoveToEnd() + } else { + e.historySearch.failing = true + e.historySearch.match = "" + e.historySearch.matchIndex = -1 + e.textarea.SetValue("") + e.textarea.Placeholder = "No matching entry in history" + } +} + +func (e *editor) exitHistorySearch() tea.Cmd { + e.textarea.SetValue(e.historySearch.origTextValue) + e.textarea.Placeholder = e.historySearch.origTextPlaceholderValue + e.historySearch = historySearchState{matchIndex: -1} + return e.textarea.Focus() +} diff --git a/pkg/tui/components/editor/historysearch_test.go b/pkg/tui/components/editor/historysearch_test.go new file mode 100644 index 000000000..0fda5bfed --- /dev/null +++ b/pkg/tui/components/editor/historysearch_test.go @@ -0,0 +1,309 @@ +package editor + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/pkg/app" + "github.com/docker/cagent/pkg/history" +) + +func enterSearch(t *testing.T, e *editor) *editor { + t.Helper() + m, _ := e.EnterHistorySearch() + return m.(*editor) +} + +func TestHistorySearch(t *testing.T) { + t.Parallel() + + setupEditor := func(t *testing.T, messages []string) *editor { + t.Helper() + tmpDir := t.TempDir() + h, err := history.New(history.WithBaseDir(tmpDir)) + require.NoError(t, err) + + for _, msg := range messages { + require.NoError(t, h.Add(msg)) + } + + e := New(&app.App{}, h).(*editor) + e.textarea.SetWidth(80) + return e + } + + press := func(t *testing.T, e *editor, msg tea.Msg) *editor { + t.Helper() + m, _ := e.Update(msg) + return m.(*editor) + } + + esc := tea.KeyPressMsg{Code: tea.KeyEscape} + ctrlG := tea.KeyPressMsg{Code: 'g', Mod: tea.ModCtrl} + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + up := tea.KeyPressMsg{Code: tea.KeyUp} + down := tea.KeyPressMsg{Code: tea.KeyDown} + backspace := tea.KeyPressMsg{Code: tea.KeyBackspace} + + typeStr := func(t *testing.T, e *editor, s string) *editor { + t.Helper() + for _, r := range s { + e = press(t, e, tea.KeyPressMsg{Text: string(r)}) + } + return e + } + + t.Run("enter and exit search mode", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"cmd1", "cmd2"}) + + assert.False(t, e.historySearch.active) + + e = enterSearch(t, e) + assert.True(t, e.historySearch.active) + assert.Empty(t, e.historySearch.query) + assert.Empty(t, e.historySearch.match) + assert.Empty(t, e.Value()) + + e = press(t, e, esc) + assert.False(t, e.historySearch.active) + assert.Empty(t, e.Value()) + }) + + t.Run("search query matching", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"deploy staging", "run tests", "deploy production"}) + + e = enterSearch(t, e) + + e = typeStr(t, e, "te") + assert.Equal(t, "te", e.historySearch.query) + assert.Equal(t, "run tests", e.historySearch.match) + assert.False(t, e.historySearch.failing) + + e = press(t, e, backspace) + assert.Equal(t, "t", e.historySearch.query) + assert.Equal(t, "deploy production", e.historySearch.match) + }) + + t.Run("cycling older matches with up arrow", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"echo 1", "echo 2", "echo 3"}) + + e = enterSearch(t, e) + e = typeStr(t, e, "echo") + assert.Equal(t, "echo 3", e.historySearch.match) + + e = press(t, e, up) + assert.Equal(t, "echo 2", e.historySearch.match) + + e = press(t, e, up) + assert.Equal(t, "echo 1", e.historySearch.match) + }) + + t.Run("cycling newer matches with down arrow", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"echo 1", "echo 2", "echo 3"}) + + e = enterSearch(t, e) + e = typeStr(t, e, "echo") + assert.Equal(t, "echo 3", e.historySearch.match) + + e = press(t, e, up) + e = press(t, e, up) + assert.Equal(t, "echo 1", e.historySearch.match) + + e = press(t, e, down) + assert.Equal(t, "echo 2", e.historySearch.match) + + e = press(t, e, down) + assert.Equal(t, "echo 3", e.historySearch.match) + }) + + t.Run("wrap around when cycling past oldest match", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"echo 1", "echo 2", "echo 3"}) + + e = enterSearch(t, e) + e = typeStr(t, e, "echo") + assert.Equal(t, "echo 3", e.historySearch.match) + + e = press(t, e, up) + e = press(t, e, up) + assert.Equal(t, "echo 1", e.historySearch.match) + + e = press(t, e, up) + assert.Equal(t, "echo 3", e.historySearch.match) + }) + + t.Run("wrap around when cycling past newest match", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"echo 1", "echo 2", "echo 3"}) + + e = enterSearch(t, e) + e = typeStr(t, e, "echo") + assert.Equal(t, "echo 3", e.historySearch.match) + + e = press(t, e, down) + assert.Equal(t, "echo 1", e.historySearch.match) + }) + + t.Run("accept match", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"cmd1", "cmd2", "cmd3"}) + e.SetValue("partial input") + + e = enterSearch(t, e) + e = typeStr(t, e, "cmd2") + assert.Equal(t, "cmd2", e.historySearch.match) + + e = press(t, e, enter) + assert.False(t, e.historySearch.active) + assert.Equal(t, "cmd2", e.Value()) + + e = press(t, e, up) + assert.Equal(t, "cmd1", e.Value()) + + e = press(t, e, down) + assert.Equal(t, "cmd2", e.Value()) + e = press(t, e, down) + assert.Equal(t, "cmd3", e.Value()) + }) + + t.Run("cancel restores original input", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"history"}) + e.SetValue("original input") + + e = enterSearch(t, e) + assert.Empty(t, e.textarea.Value()) + + e = press(t, e, esc) + assert.False(t, e.historySearch.active) + assert.Equal(t, "original input", e.textarea.Value()) + }) + + t.Run("ctrl+g cancels search", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"history"}) + e.SetValue("original input") + + e = enterSearch(t, e) + e = typeStr(t, e, "hist") + assert.Equal(t, "history", e.historySearch.match) + + e = press(t, e, ctrlG) + assert.False(t, e.historySearch.active) + assert.Equal(t, "original input", e.textarea.Value()) + }) + + t.Run("failing search status", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"foo"}) + + e = enterSearch(t, e) + assert.False(t, e.historySearch.failing) + + e = typeStr(t, e, "z") + assert.True(t, e.historySearch.failing) + assert.Empty(t, e.textarea.Value()) + }) + + t.Run("empty history", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{}) + + e = enterSearch(t, e) + assert.True(t, e.historySearch.active) + assert.False(t, e.historySearch.failing) + assert.Empty(t, e.historySearch.match) + }) + + t.Run("case insensitive matching", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"Deploy Staging", "run tests"}) + + e = enterSearch(t, e) + e = typeStr(t, e, "deploy") + assert.Equal(t, "Deploy Staging", e.historySearch.match) + assert.False(t, e.historySearch.failing) + }) + + t.Run("backspace when query is empty", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"foo"}) + + e = enterSearch(t, e) + e = typeStr(t, e, "f") + assert.Equal(t, "foo", e.historySearch.match) + + e = press(t, e, backspace) + assert.Empty(t, e.historySearch.query) + assert.Empty(t, e.historySearch.match) + assert.False(t, e.historySearch.failing) + }) + + t.Run("enter while failing restores original", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"foo", "bar"}) + e.SetValue("original") + + e = enterSearch(t, e) + e = typeStr(t, e, "zzz") + assert.True(t, e.historySearch.failing) + + e = press(t, e, enter) + assert.False(t, e.historySearch.active) + assert.Equal(t, "original", e.Value()) + }) + + t.Run("cancel does not change history pointer", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"first", "second", "third"}) + + e = enterSearch(t, e) + e = typeStr(t, e, "first") + assert.Equal(t, "first", e.historySearch.match) + + e = press(t, e, esc) + assert.False(t, e.historySearch.active) + + e = press(t, e, up) + assert.Equal(t, "third", e.Value()) + }) + + t.Run("state fully reset on exit", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"hello"}) + + e = enterSearch(t, e) + e = typeStr(t, e, "hel") + + e = press(t, e, enter) + assert.False(t, e.historySearch.active) + assert.Empty(t, e.historySearch.query) + assert.Empty(t, e.historySearch.match) + assert.Equal(t, -1, e.historySearch.matchIndex) + assert.False(t, e.historySearch.failing) + assert.Empty(t, e.historySearch.origTextValue) + }) + + t.Run("re-enter search after exiting", func(t *testing.T) { + t.Parallel() + e := setupEditor(t, []string{"aaa", "bbb"}) + + e = enterSearch(t, e) + e = typeStr(t, e, "aaa") + e = press(t, e, enter) + assert.Equal(t, "aaa", e.Value()) + + e = enterSearch(t, e) + assert.True(t, e.historySearch.active) + assert.Empty(t, e.historySearch.query) + assert.Equal(t, "aaa", e.historySearch.origTextValue) + assert.Empty(t, e.historySearch.match) + }) +} diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 73039781f..e6e6aee2a 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -243,6 +243,7 @@ type KeyMap struct { ExternalEditor key.Binding ToggleSplitDiff key.Binding ToggleSidebar key.Binding + HistorySearch key.Binding } // getEditorDisplayNameFromEnv returns a friendly display name for the configured editor. @@ -344,6 +345,10 @@ func defaultKeyMap() KeyMap { key.WithKeys("ctrl+b"), key.WithHelp("Ctrl+b", "toggle sidebar"), ), + HistorySearch: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("Ctrl+r", "history search"), + ), } } @@ -785,6 +790,7 @@ func (p *chatPage) Bindings() []key.Binding { bindings = append(bindings, p.keyMap.ShiftNewline, p.keyMap.ExternalEditor, + p.keyMap.HistorySearch, ) } diff --git a/pkg/tui/page/chat/input_handlers.go b/pkg/tui/page/chat/input_handlers.go index a7078c338..3c9b56e84 100644 --- a/pkg/tui/page/chat/input_handlers.go +++ b/pkg/tui/page/chat/input_handlers.go @@ -38,6 +38,13 @@ func (p *chatPage) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd, b } } + // History search is a modal state — capture all keys before normal routing + if p.focusedPanel == PanelEditor && p.editor.IsHistorySearchActive() { + model, cmd := p.editor.Update(msg) + p.editor = model.(editor.Editor) + return p, cmd, true + } + switch { case key.Matches(msg, p.keyMap.Tab): if p.focusedPanel == PanelEditor { @@ -65,6 +72,13 @@ func (p *chatPage) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd, b p.sidebar.ToggleCollapsed() cmd := p.SetSize(p.width, p.height) return p, cmd, true + + case key.Matches(msg, p.keyMap.HistorySearch): + if p.focusedPanel == PanelEditor && !p.working && !p.editor.IsRecording() { + model, cmd := p.editor.EnterHistorySearch() + p.editor = model.(editor.Editor) + return p, cmd, true + } } // Route other keys to focused component