feat: regex filtering + cursor filter navigation

This commit is contained in:
2026-03-14 23:26:00 +02:00
parent 0f67db342f
commit 31330513f8
2 changed files with 485 additions and 10 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os/exec"
"regexp"
"runtime"
"strings"
"time"
@@ -46,7 +47,10 @@ type model struct {
cursor int // cursor position in filtered list
offset int // scroll offset for visible window
filter string
filterCursor int // cursor position within filter string
filterMode bool
filterRegex bool // true when filter is in regex mode
filterRegexErr error // non-nil when regex pattern is invalid
showPreview bool
showHelp bool // help overlay visible
width int
@@ -343,21 +347,79 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyEsc:
m.filterMode = false
m.filter = ""
m.filterCursor = 0
m.filterRegex = false
m.filterRegexErr = nil
m.updateFiltered()
return m, nil
case tea.KeyEnter:
m.filterMode = false
return m, nil
case tea.KeyLeft:
if msg.Alt {
// Alt+Left: move to previous word boundary
pos := m.filterCursor
for pos > 0 && m.filter[pos-1] == ' ' {
pos--
}
for pos > 0 && m.filter[pos-1] != ' ' {
pos--
}
m.filterCursor = pos
} else if m.filterCursor > 0 {
m.filterCursor--
}
return m, nil
case tea.KeyRight:
if msg.Alt {
// Alt+Right: move to next word boundary
pos := m.filterCursor
for pos < len(m.filter) && m.filter[pos] != ' ' {
pos++
}
for pos < len(m.filter) && m.filter[pos] == ' ' {
pos++
}
m.filterCursor = pos
} else if m.filterCursor < len(m.filter) {
m.filterCursor++
}
return m, nil
case tea.KeyBackspace:
if len(m.filter) > 0 {
m.filter = m.filter[:len(m.filter)-1]
if msg.Alt {
// Alt+Backspace: delete word behind cursor
if m.filterCursor > 0 {
pos := m.filterCursor
// Skip trailing spaces
for pos > 0 && m.filter[pos-1] == ' ' {
pos--
}
// Skip word characters
for pos > 0 && m.filter[pos-1] != ' ' {
pos--
}
m.filter = m.filter[:pos] + m.filter[m.filterCursor:]
m.filterCursor = pos
m.updateFiltered()
}
} else if m.filterCursor > 0 {
m.filter = m.filter[:m.filterCursor-1] + m.filter[m.filterCursor:]
m.filterCursor--
m.updateFiltered()
}
return m, nil
default:
if len(msg.Runes) > 0 {
m.filter += string(msg.Runes)
m.updateFiltered()
s := string(msg.Runes)
if s == "/" && m.filter == "" {
m.filterRegex = !m.filterRegex
m.filterRegexErr = nil
m.updateFiltered()
} else {
m.filter = m.filter[:m.filterCursor] + s + m.filter[m.filterCursor:]
m.filterCursor += len(s)
m.updateFiltered()
}
}
return m, nil
}
@@ -370,8 +432,11 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, tea.Quit
case "esc":
// Clear filter if active, otherwise quit
if m.filter != "" {
if m.filter != "" || m.filterRegex {
m.filter = ""
m.filterCursor = 0
m.filterRegex = false
m.filterRegexErr = nil
m.updateFiltered()
return m, nil
}
@@ -426,6 +491,7 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "/":
m.filterMode = true
m.filter = ""
m.filterCursor = 0
case "?":
m.showHelp = true
case "y":
@@ -567,11 +633,29 @@ func wrapText(s string, width int) []string {
func (m *model) updateFiltered() {
m.filtered = []int{}
m.filterRegexErr = nil
filter := strings.ToLower(m.filter)
for i, line := range m.lines {
if m.filter == "" || strings.Contains(strings.ToLower(line.Content), filter) {
m.filtered = append(m.filtered, i)
if m.filterRegex && m.filter != "" {
re, err := regexp.Compile("(?i)" + m.filter)
if err != nil {
m.filterRegexErr = err
// Show all lines when regex is invalid
for i := range m.lines {
m.filtered = append(m.filtered, i)
}
} else {
for i, line := range m.lines {
if re.MatchString(line.Content) {
m.filtered = append(m.filtered, i)
}
}
}
} else {
filter := strings.ToLower(m.filter)
for i, line := range m.lines {
if m.filter == "" || strings.Contains(strings.ToLower(line.Content), filter) {
m.filtered = append(m.filtered, i)
}
}
}
@@ -620,6 +704,7 @@ func (m model) renderHelpOverlay() (box string, boxWidth, boxHeight int) {
{"", ""},
{"p", "Toggle preview pane"},
{"/", "Enter filter mode"},
{"//", "Toggle regex filter mode"},
{"Esc", "Exit filter / clear"},
{"", ""},
{"r / Ctrl+r", "Reload command"},
@@ -882,6 +967,12 @@ func (m model) renderMainView() string {
filterStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("11"))
filterRegexStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("13"))
filterErrStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("9"))
// Inner width (excluding border characters)
innerWidth := m.width - 2
@@ -963,8 +1054,21 @@ func (m model) renderMainView() string {
// Build prompt line (will go at bottom)
var promptLine string
switch {
case m.filterMode && m.filterRegex:
label := filterRegexStyle.Render("regex/")
before := m.filter[:m.filterCursor]
after := m.filter[m.filterCursor:]
input := filterStyle.Render(before) + "█" + filterStyle.Render(after)
promptLine = label + input
if m.filterRegexErr != nil {
promptLine += " " + filterErrStyle.Render("(invalid regex)")
}
case m.filterMode:
promptLine = filterStyle.Render(fmt.Sprintf("/%s█", m.filter))
before := m.filter[:m.filterCursor]
after := m.filter[m.filterCursor:]
promptLine = filterStyle.Render("/"+before) + "█" + filterStyle.Render(after)
case m.filter != "" && m.filterRegex:
promptLine = promptStyle.Render(fmt.Sprintf("%s (regex: %s)", m.config.Prompt, m.filter))
case m.filter != "":
promptLine = promptStyle.Render(fmt.Sprintf("%s (filter: %s)", m.config.Prompt, m.filter))
default:

View File

@@ -416,6 +416,377 @@ func TestModelRefreshGeneration(t *testing.T) {
}
}
func testModel(cfg Config) *model {
m := initialModel(cfg)
return &m
}
func TestFilterCursorMovement(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "hello"
m.filterCursor = 5
// Left arrow moves cursor left
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4 after left, got %d", m.filterCursor)
}
// Left again
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 3 {
t.Errorf("expected filterCursor 3 after second left, got %d", m.filterCursor)
}
// Left doesn't go below 0
m.filterCursor = 0
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor)
}
// Right arrow moves cursor right
m.filterCursor = 2
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 3 {
t.Errorf("expected filterCursor 3 after right, got %d", m.filterCursor)
}
// Right doesn't go past end
m.filterCursor = 5
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 5 {
t.Errorf("expected filterCursor 5 (clamped), got %d", m.filterCursor)
}
}
func TestFilterAltLeftRight(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
t.Run("alt+left jumps to previous word boundary", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar baz"
m.filterCursor = 11 // end
keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 8 {
t.Errorf("expected filterCursor 8, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
// Already at start, stays at 0
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor)
}
})
t.Run("alt+right jumps to next word boundary", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar baz"
m.filterCursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 8 {
t.Errorf("expected filterCursor 8, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 11 {
t.Errorf("expected filterCursor 11, got %d", m.filterCursor)
}
// Already at end, stays at 11
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 11 {
t.Errorf("expected filterCursor 11 (clamped), got %d", m.filterCursor)
}
})
t.Run("alt+left skips trailing spaces", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar"
m.filterCursor = 6 // middle of spaces, before "bar"
keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
})
t.Run("alt+right skips trailing spaces", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar"
m.filterCursor = 3 // end of "foo"
keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 6 {
t.Errorf("expected filterCursor 6, got %d", m.filterCursor)
}
})
}
func TestFilterInsertAtCursor(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "helo"
m.filterCursor = 3
// Insert 'l' at position 3 -> "hello"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "hello" {
t.Errorf("expected filter 'hello', got %q", m.filter)
}
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4, got %d", m.filterCursor)
}
}
func TestFilterBackspaceAtCursor(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "hello"
m.filterCursor = 3
// Backspace at position 3 -> "helo"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "helo" {
t.Errorf("expected filter 'helo', got %q", m.filter)
}
if m.filterCursor != 2 {
t.Errorf("expected filterCursor 2, got %d", m.filterCursor)
}
// Backspace at position 0 does nothing
m.filterCursor = 0
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "helo" {
t.Errorf("expected filter 'helo' (unchanged), got %q", m.filter)
}
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
}
func TestFilterAltBackspace(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
tests := []struct {
name string
filter string
cursor int
expectedFilter string
expectedCursor int
}{
{"delete last word", "hello world", 11, "hello ", 6},
{"delete middle word", "foo bar baz", 7, "foo baz", 4},
{"delete first word", "hello world", 5, " world", 0},
{"delete with trailing spaces", "hello ", 8, "", 0},
{"cursor at start", "hello", 0, "hello", 0},
{"single word", "hello", 5, "", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = tt.filter
m.filterCursor = tt.cursor
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
newModel := result.(*model)
if newModel.filter != tt.expectedFilter {
t.Errorf("expected filter %q, got %q", tt.expectedFilter, newModel.filter)
}
if newModel.filterCursor != tt.expectedCursor {
t.Errorf("expected filterCursor %d, got %d", tt.expectedCursor, newModel.filterCursor)
}
})
}
}
func TestFilterRegexToggle(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = ""
m.filterCursor = 0
// Type '/' on empty filter toggles regex mode on
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if !m.filterRegex {
t.Error("expected filterRegex to be true after typing /")
}
if m.filter != "" {
t.Errorf("expected empty filter, got %q", m.filter)
}
// Type '/' again on empty filter toggles regex mode off
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterRegex {
t.Error("expected filterRegex to be false after second /")
}
// Type '/' when filter is non-empty adds it to filter
m.filterRegex = true
m.filter = "abc"
m.filterCursor = 3
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "abc/" {
t.Errorf("expected filter 'abc/', got %q", m.filter)
}
if !m.filterRegex {
t.Error("expected filterRegex to remain true")
}
}
func TestFilterRegexMatching(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := initialModel(cfg)
m.lines = []runner.Line{
{Number: 1, Content: "hello world"},
{Number: 2, Content: "foo bar"},
{Number: 3, Content: "hello foo"},
{Number: 4, Content: "baz 123 qux"},
}
// Regex filter matching
m.filterRegex = true
m.filter = "hello.*foo"
m.updateFiltered()
if len(m.filtered) != 1 {
t.Errorf("expected 1 match for regex 'hello.*foo', got %d", len(m.filtered))
}
if len(m.filtered) > 0 && m.filtered[0] != 2 {
t.Errorf("expected match at index 2, got %d", m.filtered[0])
}
// Regex with character class
m.filter = "\\d+"
m.updateFiltered()
if len(m.filtered) != 1 {
t.Errorf("expected 1 match for regex '\\d+', got %d", len(m.filtered))
}
// Regex is case insensitive
m.filter = "HELLO"
m.updateFiltered()
if len(m.filtered) != 2 {
t.Errorf("expected 2 matches for case-insensitive regex 'HELLO', got %d", len(m.filtered))
}
}
func TestFilterRegexInvalid(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := initialModel(cfg)
m.lines = []runner.Line{
{Number: 1, Content: "hello world"},
{Number: 2, Content: "foo bar"},
}
m.filterRegex = true
m.filter = "[invalid"
m.updateFiltered()
// Should have an error
if m.filterRegexErr == nil {
t.Error("expected filterRegexErr to be non-nil for invalid regex")
}
// Should show all lines when regex is invalid
if len(m.filtered) != 2 {
t.Errorf("expected all 2 lines shown for invalid regex, got %d", len(m.filtered))
}
// Valid regex clears the error
m.filter = "hello"
m.updateFiltered()
if m.filterRegexErr != nil {
t.Errorf("expected filterRegexErr to be nil for valid regex, got %v", m.filterRegexErr)
}
}
func TestFilterEscClearsRegex(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "test"
m.filterCursor = 4
m.filterRegex = true
// Esc in filter mode clears everything
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterMode {
t.Error("expected filterMode to be false")
}
if m.filter != "" {
t.Errorf("expected empty filter, got %q", m.filter)
}
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
if m.filterRegex {
t.Error("expected filterRegex to be false")
}
}
func TestStopCommandKeybinding(t *testing.T) {
cfg := Config{
Command: "echo test",