From 8c399b6754642f311a04173cfc711e4d94faff88 Mon Sep 17 00:00:00 2001
From: Hayden Hargreaves
Date: Fri, 1 May 2026 14:51:00 -0700
Subject: [PATCH 1/3] feat: search functionality.
Bit rushed, but its on the site. Going to add a real larger prompt about
it.
---
internal/tui/model.go | 12 +-
internal/tui/model_update_test.go | 32 ++-
internal/tui/panes.go | 44 +++-
internal/tui/search_test.go | 260 ++++++++++++++++++++
internal/tui/update.go | 167 ++++++++++++-
internal/tui/view_split_panes_style_test.go | 41 ++-
6 files changed, 529 insertions(+), 27 deletions(-)
create mode 100644 internal/tui/search_test.go
diff --git a/internal/tui/model.go b/internal/tui/model.go
index 60e1e54..dd6fa8d 100644
--- a/internal/tui/model.go
+++ b/internal/tui/model.go
@@ -38,11 +38,12 @@ type Model struct {
width int
height int
- theme Theme
- showEvents bool
- showStd bool
- showSearch bool
- restarting bool
+ theme Theme
+ showEvents bool
+ showStd bool
+ showSearch bool
+ searchQuery string
+ restarting bool
now time.Time
}
@@ -69,6 +70,7 @@ func NewModel(ch <-chan model.Event, controls Controls) Model {
showEvents: false,
showStd: false,
showSearch: false,
+ searchQuery: "",
restarting: false,
theme: newTheme(),
}
diff --git a/internal/tui/model_update_test.go b/internal/tui/model_update_test.go
index 75d52cc..d778f00 100644
--- a/internal/tui/model_update_test.go
+++ b/internal/tui/model_update_test.go
@@ -29,6 +29,9 @@ func TestNewModelDefaults(t *testing.T) {
if m.showEvents || m.showStd || m.showSearch || m.restarting {
t.Fatal("toggle flags should initialize false")
}
+ if m.searchQuery != "" {
+ t.Fatal("search query should initialize empty")
+ }
}
func TestInitBatchesEventAndTick(t *testing.T) {
@@ -188,10 +191,33 @@ func TestUpdate(t *testing.T) {
t.Fatal("/ should enable search")
}
- next5, _ := next4.(Model).Update(tea.KeyMsg{Type: tea.KeyEsc})
+ nextSearch, _ := next4.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
+ if nextSearch.(Model).searchQuery != "x" {
+ t.Fatal("typed rune should update search query")
+ }
+
+ nextSearchSpace, _ := nextSearch.(Model).Update(tea.KeyMsg{Type: tea.KeySpace})
+ if nextSearchSpace.(Model).searchQuery != "x " {
+ t.Fatal("space should update search query")
+ }
+
+ nextSearch2, _ := nextSearchSpace.(Model).Update(tea.KeyMsg{Type: tea.KeyBackspace})
+ if nextSearch2.(Model).searchQuery != "x" {
+ t.Fatal("backspace should remove one character")
+ }
+
+ nextSearch3, _ := nextSearch2.(Model).Update(tea.KeyMsg{Type: tea.KeyBackspace})
+ if nextSearch3.(Model).searchQuery != "" {
+ t.Fatal("backspace should update search query")
+ }
+
+ next5, _ := nextSearch3.(Model).Update(tea.KeyMsg{Type: tea.KeyEsc})
if next5.(Model).showSearch {
t.Fatal("esc should disable search")
}
+ if next5.(Model).searchQuery != "" {
+ t.Fatal("esc should clear search query")
+ }
next6, cmd6 := next5.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
if cmd6 != nil {
@@ -310,7 +336,7 @@ func TestModelHelpers(t *testing.T) {
t.Run("pushEvent trims to maxEvents", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
- for i := 0; i < maxEvents+5; i++ {
+ for range maxEvents + 5 {
m.pushEvent(model.Event{Body: "x"})
}
if len(m.events) != maxEvents {
@@ -326,7 +352,7 @@ func TestModelHelpers(t *testing.T) {
t.Fatal("CONNECT request should be ignored")
}
- for i := 0; i < maxRequests+3; i++ {
+ for range maxRequests + 3 {
m.createRequest(model.Request{ID: uuid.New(), Method: http.MethodGet})
}
if len(m.requests) != maxRequests {
diff --git a/internal/tui/panes.go b/internal/tui/panes.go
index f88e0c5..3ef70b5 100644
--- a/internal/tui/panes.go
+++ b/internal/tui/panes.go
@@ -130,7 +130,7 @@ func (m Model) bottomStatusRight() string {
}
func (m Model) requestSelectionStats() (selected int, total int) {
- total = len(m.requests)
+ total = m.visibleRequestCount()
if total == 0 {
return 0, 0
}
@@ -139,11 +139,27 @@ func (m Model) requestSelectionStats() (selected int, total int) {
return selected, total
}
-// TODO: Implement
func (m Model) renderSearchPane(w, h int) []string {
+ if h <= 0 {
+ return nil
+ }
+
+ left := m.theme.TextMuted.Render(" / SEARCH ")
+ hint := m.theme.TextMuted.Render(" host/path method:get status:5xx ")
+ if strings.TrimSpace(m.searchQuery) != "" {
+ hint = m.theme.Text.Render(" " + m.searchQuery + " ")
+ }
+
+ line := left + hint
+ line = clampRendered(line, w)
+ if lipgloss.Width(line) < w {
+ line += m.theme.Text.Render(strings.Repeat(" ", w-lipgloss.Width(line)))
+ }
+
lines := make([]string, h)
- for y := range lines {
- lines[y] = strings.Repeat(" ", w)
+ lines[0] = line
+ for y := 1; y < h; y++ {
+ lines[y] = m.theme.Text.Render(strings.Repeat(" ", w))
}
return lines
}
@@ -165,9 +181,10 @@ func (m Model) renderRequestPane(w, h int) []string {
header := m.theme.TextMuted.Render(headerLeft + headerSpace + headerRight)
lines = append(lines, header)
- bodyLines := make([]string, 0, len(m.requests))
- for i, row := len(m.requests)-1, 0; i >= 0; i, row = i-1, row+1 {
- req := m.requests[i]
+ visible := m.filteredRequestIndices()
+ bodyLines := make([]string, 0, len(visible))
+ for i, row := len(visible)-1, 0; i >= 0; i, row = i-1, row+1 {
+ req := m.requests[visible[i]]
duration := req.Duration
if req.Pending && !req.StartTime.IsZero() {
duration = time.Since(req.StartTime)
@@ -532,7 +549,8 @@ func (m Model) detailsContentLineCount(w int) int {
}
func (m Model) selectedRequest() (model.Request, bool) {
- if len(m.requests) == 0 {
+ visible := m.filteredRequestIndices()
+ if len(visible) == 0 {
return model.Request{}, false
}
@@ -540,16 +558,16 @@ func (m Model) selectedRequest() (model.Request, bool) {
if cursor < 0 {
cursor = 0
}
- if cursor >= len(m.requests) {
- cursor = len(m.requests) - 1
+ if cursor >= len(visible) {
+ cursor = len(visible) - 1
}
- idx := len(m.requests) - cursor - 1
- if idx < 0 || idx >= len(m.requests) {
+ idx := len(visible) - cursor - 1
+ if idx < 0 || idx >= len(visible) {
return model.Request{}, false
}
- return m.requests[idx], true
+ return m.requests[visible[idx]], true
}
func formatBodyLines(body []byte, width int) []string {
diff --git a/internal/tui/search_test.go b/internal/tui/search_test.go
new file mode 100644
index 0000000..47839cf
--- /dev/null
+++ b/internal/tui/search_test.go
@@ -0,0 +1,260 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/google/uuid"
+ "termtap.dev/internal/model"
+)
+
+func TestParseStatusToken(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ want int
+ ok bool
+ }{
+ {name: "exact code", in: "404", want: 404, ok: true},
+ {name: "class code", in: "5xx", want: 5000, ok: true},
+ {name: "invalid class", in: "9xx", want: 0, ok: false},
+ {name: "non-number", in: "abc", want: 0, ok: false},
+ {name: "too low", in: "99", want: 0, ok: false},
+ {name: "too high", in: "600", want: 0, ok: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got, ok := parseStatusToken(tt.in)
+ if ok != tt.ok {
+ t.Fatalf("parseStatusToken(%q) ok = %v, want %v", tt.in, ok, tt.ok)
+ }
+ if got != tt.want {
+ t.Fatalf("parseStatusToken(%q) = %d, want %d", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseRequestSearchQuery(t *testing.T) {
+ t.Parallel()
+
+ q := parseRequestSearchQuery("api method:post status:5xx status:404 foo:bar")
+
+ if len(q.methods) != 1 || q.methods[0] != "post" {
+ t.Fatalf("methods = %#v, want [post]", q.methods)
+ }
+ if _, ok := q.statusHuns[5]; !ok {
+ t.Fatal("expected status class 5xx to be parsed")
+ }
+ if _, ok := q.statuses[404]; !ok {
+ t.Fatal("expected status 404 to be parsed")
+ }
+ if len(q.terms) != 2 || q.terms[0] != "api" || q.terms[1] != "foo:bar" {
+ t.Fatalf("terms = %#v, want [api foo:bar]", q.terms)
+ }
+}
+
+func TestParseRequestSearchQuery_Table(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input string
+ wantTerms []string
+ wantMethods []string
+ wantStatuses []int
+ wantClasses []int
+ }{
+ {
+ name: "normalizes case and spaces",
+ input: " API.Example.Com METHOD:GET STATUS:2xx ",
+ wantTerms: []string{"api.example.com"},
+ wantMethods: []string{"get"},
+ wantStatuses: nil,
+ wantClasses: []int{2},
+ },
+ {
+ name: "invalid status token falls back to free text",
+ input: "status:wat service",
+ wantTerms: []string{"status:wat", "service"},
+ wantMethods: nil,
+ wantStatuses: nil,
+ wantClasses: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ q := parseRequestSearchQuery(tt.input)
+
+ if len(q.terms) != len(tt.wantTerms) {
+ t.Fatalf("terms len = %d, want %d", len(q.terms), len(tt.wantTerms))
+ }
+ for i := range tt.wantTerms {
+ if q.terms[i] != tt.wantTerms[i] {
+ t.Fatalf("terms[%d] = %q, want %q", i, q.terms[i], tt.wantTerms[i])
+ }
+ }
+
+ if len(q.methods) != len(tt.wantMethods) {
+ t.Fatalf("methods len = %d, want %d", len(q.methods), len(tt.wantMethods))
+ }
+ for i := range tt.wantMethods {
+ if q.methods[i] != tt.wantMethods[i] {
+ t.Fatalf("methods[%d] = %q, want %q", i, q.methods[i], tt.wantMethods[i])
+ }
+ }
+
+ for _, status := range tt.wantStatuses {
+ if _, ok := q.statuses[status]; !ok {
+ t.Fatalf("expected status %d to exist", status)
+ }
+ }
+ for _, class := range tt.wantClasses {
+ if _, ok := q.statusHuns[class]; !ok {
+ t.Fatalf("expected status class %dxx to exist", class)
+ }
+ }
+ })
+ }
+}
+
+func TestRequestMatchesQuery(t *testing.T) {
+ t.Parallel()
+
+ req := model.Request{
+ Method: "POST",
+ Host: "api.example.com",
+ URL: "/v1/login",
+ RawURL: "https://api.example.com/v1/login",
+ Status: 502,
+ }
+
+ tests := []struct {
+ name string
+ query string
+ want bool
+ }{
+ {name: "free text host", query: "api.example.com", want: true},
+ {name: "free text path", query: "/v1/login", want: true},
+ {name: "method match", query: "method:post", want: true},
+ {name: "method mismatch", query: "method:get", want: false},
+ {name: "status class match", query: "status:5xx", want: true},
+ {name: "status exact mismatch", query: "status:200", want: false},
+ {name: "combined and match", query: "api method:post status:5xx", want: true},
+ {name: "combined and mismatch", query: "api method:post status:2xx", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ q := parseRequestSearchQuery(tt.query)
+ got := requestMatchesQuery(req, q)
+ if got != tt.want {
+ t.Fatalf("requestMatchesQuery(%q) = %v, want %v", tt.query, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSelectedRequestUsesFilteredResults(t *testing.T) {
+ t.Parallel()
+
+ m := NewModel(make(chan model.Event), Controls{})
+ m.requests = []model.Request{
+ {ID: uuid.New(), Method: "GET", Host: "a.test", URL: "/a", Status: 200},
+ {ID: uuid.New(), Method: "POST", Host: "b.test", URL: "/b", Status: 500},
+ {ID: uuid.New(), Method: "GET", Host: "c.test", URL: "/c", Status: 201},
+ }
+ m.searchQuery = "status:5xx"
+
+ req, ok := m.selectedRequest()
+ if !ok {
+ t.Fatal("selectedRequest should succeed with filtered results")
+ }
+ if req.Host != "b.test" {
+ t.Fatalf("selected request host = %q, want %q", req.Host, "b.test")
+ }
+
+ m.requestCursor = 9
+ m.clampRequestCursor()
+ if m.requestCursor != 0 {
+ t.Fatalf("requestCursor = %d, want 0 for single filtered row", m.requestCursor)
+ }
+}
+
+func TestFilteredRequestIndices_EmptyQueryReturnsAll(t *testing.T) {
+ t.Parallel()
+
+ m := NewModel(make(chan model.Event), Controls{})
+ m.requests = []model.Request{
+ {ID: uuid.New(), Host: "a", URL: "/a"},
+ {ID: uuid.New(), Host: "b", URL: "/b"},
+ }
+
+ m.searchQuery = ""
+ idx := m.filteredRequestIndices()
+ if len(idx) != 2 {
+ t.Fatalf("filteredRequestIndices len = %d, want 2", len(idx))
+ }
+ if idx[0] != 0 || idx[1] != 1 {
+ t.Fatalf("filtered indices = %#v, want [0 1]", idx)
+ }
+}
+
+func TestSearchEscClearsAndResetsSelectionState(t *testing.T) {
+ t.Parallel()
+
+ m := NewModel(make(chan model.Event), Controls{})
+ m.showSearch = true
+ m.searchQuery = "status:5xx"
+ m.requestCursor = 3
+ m.requestScroll = 2
+ m.detailsScroll = 4
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ got := next.(Model)
+
+ if got.showSearch {
+ t.Fatal("esc should close search mode")
+ }
+ if got.searchQuery != "" {
+ t.Fatal("esc should clear search query")
+ }
+ if got.requestCursor != 0 || got.requestScroll != 0 || got.detailsScroll != 0 {
+ t.Fatalf("scroll/cursor not reset: cursor=%d reqScroll=%d detScroll=%d", got.requestCursor, got.requestScroll, got.detailsScroll)
+ }
+}
+
+func TestSearchPaneUsesThemedBackgroundFill(t *testing.T) {
+ t.Parallel()
+
+ m := NewModel(make(chan model.Event), Controls{})
+ line := m.renderSearchPane(24, 1)[0]
+ if lipgloss.Width(line) != 24 {
+ t.Fatalf("search line width = %d, want 24", lipgloss.Width(line))
+ }
+ if line == " " {
+ t.Fatal("search pane should render styled content, not raw spaces")
+ }
+}
+
+func TestSearchPaneShowsQueryText(t *testing.T) {
+ t.Parallel()
+
+ m := NewModel(make(chan model.Event), Controls{})
+ m.searchQuery = "method:get status:2xx api"
+ line := m.renderSearchPane(64, 1)[0]
+ plain := ansi.Strip(line)
+ if !strings.Contains(plain, "method:get") || !strings.Contains(plain, "status:2xx") || !strings.Contains(plain, "api") {
+ t.Fatalf("search pane missing query text, got %q", plain)
+ }
+}
diff --git a/internal/tui/update.go b/internal/tui/update.go
index 3784163..8670eb0 100644
--- a/internal/tui/update.go
+++ b/internal/tui/update.go
@@ -2,6 +2,8 @@ package tui
import (
"fmt"
+ "strconv"
+ "strings"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -25,6 +27,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// TODO: Abstract the keymaps
case tea.KeyMsg:
+ if m.showSearch {
+ if m.handleSearchKey(msg) {
+ m.clampPaneScrolls()
+ return m, nil
+ }
+ }
+
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
@@ -66,6 +75,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.clampPaneScrolls()
case "esc":
m.showSearch = false
+ m.searchQuery = ""
m.clampPaneScrolls()
}
return m, nil
@@ -163,7 +173,8 @@ func (m *Model) moveRequestCursor(delta int) {
}
func (m *Model) clampRequestCursor() {
- if len(m.requests) == 0 {
+ total := m.visibleRequestCount()
+ if total == 0 {
m.requestCursor = 0
m.requestScroll = 0
return
@@ -173,8 +184,8 @@ func (m *Model) clampRequestCursor() {
m.requestCursor = 0
}
- if m.requestCursor >= len(m.requests) {
- m.requestCursor = len(m.requests) - 1
+ if m.requestCursor >= total {
+ m.requestCursor = total - 1
}
}
@@ -185,7 +196,7 @@ func (m *Model) ensureRequestCursorVisible() {
return
}
- maxScroll := max(0, len(m.requests)-viewHeight)
+ maxScroll := max(0, m.visibleRequestCount()-viewHeight)
if m.requestScroll < 0 {
m.requestScroll = 0
}
@@ -208,6 +219,154 @@ func (m *Model) ensureRequestCursorVisible() {
}
}
+func (m *Model) handleSearchKey(msg tea.KeyMsg) bool {
+ switch msg.Type {
+ case tea.KeyEsc:
+ m.showSearch = false
+ m.searchQuery = ""
+ m.requestCursor = 0
+ m.requestScroll = 0
+ m.detailsScroll = 0
+ return true
+ case tea.KeyBackspace, tea.KeyCtrlH:
+ if len(m.searchQuery) > 0 {
+ m.searchQuery = m.searchQuery[:len(m.searchQuery)-1]
+ }
+ m.requestCursor = 0
+ m.requestScroll = 0
+ m.detailsScroll = 0
+ return true
+ case tea.KeyRunes:
+ if len(msg.Runes) == 0 {
+ return false
+ }
+ m.searchQuery += string(msg.Runes)
+ m.requestCursor = 0
+ m.requestScroll = 0
+ m.detailsScroll = 0
+ return true
+ case tea.KeySpace:
+ m.searchQuery += " "
+ m.requestCursor = 0
+ m.requestScroll = 0
+ m.detailsScroll = 0
+ return true
+ case tea.KeyEnter:
+ return true
+ default:
+ return false
+ }
+}
+
+func (m Model) visibleRequestCount() int {
+ return len(m.filteredRequestIndices())
+}
+
+func (m Model) filteredRequestIndices() []int {
+ indices := make([]int, 0, len(m.requests))
+ query := parseRequestSearchQuery(m.searchQuery)
+
+ for i := range m.requests {
+ if requestMatchesQuery(m.requests[i], query) {
+ indices = append(indices, i)
+ }
+ }
+
+ return indices
+}
+
+type requestSearchQuery struct {
+ terms []string
+ methods []string
+ statuses map[int]struct{}
+ statusHuns map[int]struct{}
+}
+
+func parseRequestSearchQuery(input string) requestSearchQuery {
+ q := requestSearchQuery{
+ statuses: make(map[int]struct{}),
+ statusHuns: make(map[int]struct{}),
+ }
+
+ for _, token := range strings.Fields(strings.ToLower(strings.TrimSpace(input))) {
+ if value, ok := strings.CutPrefix(token, "method:"); ok {
+ if value != "" {
+ q.methods = append(q.methods, value)
+ continue
+ }
+ }
+
+ if value, ok := strings.CutPrefix(token, "status:"); ok {
+ if status, ok := parseStatusToken(value); ok {
+ if status >= 1000 {
+ q.statusHuns[status/1000] = struct{}{}
+ } else {
+ q.statuses[status] = struct{}{}
+ }
+ continue
+ }
+ }
+
+ q.terms = append(q.terms, token)
+ }
+
+ return q
+}
+
+func parseStatusToken(value string) (int, bool) {
+ if len(value) == 3 && strings.HasSuffix(value, "xx") {
+ h := int(value[0] - '0')
+ if h >= 1 && h <= 5 {
+ return h * 1000, true
+ }
+ }
+
+ code, err := strconv.Atoi(value)
+ if err != nil || code < 100 || code > 599 {
+ return 0, false
+ }
+
+ return code, true
+}
+
+func requestMatchesQuery(req model.Request, query requestSearchQuery) bool {
+ if len(query.methods) > 0 {
+ method := strings.ToLower(req.Method)
+ matched := false
+ for _, want := range query.methods {
+ if method == want {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ return false
+ }
+ }
+
+ if len(query.statuses) > 0 || len(query.statusHuns) > 0 {
+ status := req.Status
+ _, exact := query.statuses[status]
+ _, class := query.statusHuns[status/100]
+ if !exact && !class {
+ return false
+ }
+ }
+
+ if len(query.terms) == 0 {
+ return true
+ }
+
+ haystack := strings.ToLower(req.Host + " " + req.URL + " " + req.RawURL)
+ for _, term := range query.terms {
+ if !strings.Contains(haystack, term) {
+ return false
+ }
+ }
+
+ return true
+}
+
func (m *Model) moveDetailsTab(delta int) {
count := len(detailsTabNames)
if count == 0 {
diff --git a/internal/tui/view_split_panes_style_test.go b/internal/tui/view_split_panes_style_test.go
index 86a3934..7391d4d 100644
--- a/internal/tui/view_split_panes_style_test.go
+++ b/internal/tui/view_split_panes_style_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"time"
+ "github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"termtap.dev/internal/model"
)
@@ -184,8 +185,8 @@ func TestPaneRenderersAndStatusBar(t *testing.T) {
t.Fatalf("search pane len = %d, want 3", len(search))
}
for i, line := range search {
- if len(line) != 20 {
- t.Fatalf("search pane line %d len = %d, want %d", i, len(line), 20)
+ if lipgloss.Width(line) != 20 {
+ t.Fatalf("search pane line %d width = %d, want %d", i, lipgloss.Width(line), 20)
}
}
@@ -217,6 +218,42 @@ func TestPaneRenderersAndStatusBar(t *testing.T) {
}
}
+func TestRequestSearchFiltering(t *testing.T) {
+ t.Parallel()
+
+ m := NewModel(make(chan model.Event), Controls{})
+ m.requests = []model.Request{
+ {ID: uuid.New(), Method: "GET", Host: "api.example.com", URL: "/v1/users", Status: 200},
+ {ID: uuid.New(), Method: "POST", Host: "api.example.com", URL: "/v1/login", Status: 401},
+ {ID: uuid.New(), Method: "GET", Host: "cdn.example.com", URL: "/asset.js", Status: 304},
+ {ID: uuid.New(), Method: "DELETE", Host: "api.example.com", URL: "/v1/users/42", Status: 500},
+ }
+
+ m.searchQuery = "api.example.com method:get status:2xx"
+ idx := m.filteredRequestIndices()
+ if len(idx) != 1 {
+ t.Fatalf("filteredRequestIndices len = %d, want 1", len(idx))
+ }
+ if got := m.requests[idx[0]].URL; got != "/v1/users" {
+ t.Fatalf("filtered request URL = %q, want %q", got, "/v1/users")
+ }
+
+ m.searchQuery = "status:5xx method:delete"
+ idx = m.filteredRequestIndices()
+ if len(idx) != 1 {
+ t.Fatalf("filteredRequestIndices len = %d, want 1", len(idx))
+ }
+ if got := m.requests[idx[0]].Status; got != 500 {
+ t.Fatalf("filtered request status = %d, want 500", got)
+ }
+
+ m.searchQuery = "status:404"
+ idx = m.filteredRequestIndices()
+ if len(idx) != 0 {
+ t.Fatalf("filteredRequestIndices len = %d, want 0", len(idx))
+ }
+}
+
func TestRenderEventsPane_ErrorAndPIDBranches(t *testing.T) {
t.Parallel()
From 699a551549f65338381adbc92cec24a949c1cb5e Mon Sep 17 00:00:00 2001
From: Hayden Hargreaves
Date: Fri, 1 May 2026 15:00:33 -0700
Subject: [PATCH 2/3] fix: resolved some search related issues
---
internal/tui/search_test.go | 106 ++++++++++++++++++++++++++++++++++++
internal/tui/update.go | 57 +++++++++++++++++--
2 files changed, 158 insertions(+), 5 deletions(-)
diff --git a/internal/tui/search_test.go b/internal/tui/search_test.go
index 47839cf..eedab32 100644
--- a/internal/tui/search_test.go
+++ b/internal/tui/search_test.go
@@ -88,6 +88,14 @@ func TestParseRequestSearchQuery_Table(t *testing.T) {
wantStatuses: nil,
wantClasses: nil,
},
+ {
+ name: "supports spaced method and status filters",
+ input: "method: get status: 5xx",
+ wantTerms: nil,
+ wantMethods: []string{"get"},
+ wantStatuses: nil,
+ wantClasses: []int{5},
+ },
}
for _, tt := range tests {
@@ -234,6 +242,104 @@ func TestSearchEscClearsAndResetsSelectionState(t *testing.T) {
}
}
+func TestSearchBackspaceHandlesUnicodeRune(t *testing.T) {
+ t.Parallel()
+
+ m := NewModel(make(chan model.Event), Controls{})
+ m.showSearch = true
+ m.searchQuery = "a\u00e9"
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
+ got := next.(Model)
+ if got.searchQuery != "a" {
+ t.Fatalf("searchQuery after unicode backspace = %q, want %q", got.searchQuery, "a")
+ }
+}
+
+func TestCreateRequestKeepsSelectionInFilteredView(t *testing.T) {
+ t.Parallel()
+
+ selectedID := uuid.New()
+ m := NewModel(make(chan model.Event), Controls{})
+ m.requests = []model.Request{
+ {ID: uuid.New(), Method: "GET", Host: "api", URL: "/ok", Status: 200},
+ {ID: selectedID, Method: "GET", Host: "api", URL: "/err", Status: 500},
+ }
+ m.searchQuery = "status:5xx"
+ m.requestCursor = 0
+
+ before, ok := m.selectedRequest()
+ if !ok || before.ID != selectedID {
+ t.Fatal("expected selected filtered request before createRequest")
+ }
+
+ m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new-ok", Status: 201})
+ after, ok := m.selectedRequest()
+ if !ok || after.ID != selectedID {
+ t.Fatal("selection should stay on same request when new request does not match filter")
+ }
+ if m.requestCursor != 0 {
+ t.Fatalf("requestCursor = %d, want 0 with single visible row", m.requestCursor)
+ }
+
+ m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new-err", Status: 502})
+ afterMatch, ok := m.selectedRequest()
+ if !ok || afterMatch.ID != selectedID {
+ t.Fatal("selection should stay on same request when matching request arrives")
+ }
+ if m.requestCursor != 1 {
+ t.Fatalf("requestCursor = %d, want 1 to keep selected row anchored", m.requestCursor)
+ }
+}
+
+func TestCreateRequestWhenSelectedEntryEvictedByTrim(t *testing.T) {
+ t.Parallel()
+
+ m := NewModel(make(chan model.Event), Controls{})
+ m.searchQuery = "status:5xx"
+
+ for i := 0; i < maxRequests; i++ {
+ status := 200
+ if i%2 == 0 {
+ status = 500
+ }
+ m.requests = append(m.requests, model.Request{
+ ID: uuid.New(),
+ Method: "GET",
+ Host: "api",
+ URL: "/r",
+ Status: status,
+ })
+ }
+
+ visible := m.filteredRequestIndices()
+ if len(visible) == 0 {
+ t.Fatal("expected non-empty visible filtered set")
+ }
+
+ // Pick the oldest visible row so it gets evicted first.
+ m.requestCursor = len(visible) - 1
+ selectedBefore, ok := m.selectedRequest()
+ if !ok {
+ t.Fatal("expected selected request before trim")
+ }
+
+ for {
+ m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new", Status: 201})
+ selectedAfter, ok := m.selectedRequest()
+ if !ok {
+ t.Fatal("expected selection after trim")
+ }
+ if selectedAfter.ID != selectedBefore.ID {
+ break
+ }
+ }
+
+ if m.requestCursor < 0 || m.requestCursor >= m.visibleRequestCount() {
+ t.Fatalf("requestCursor out of bounds after eviction: %d of %d", m.requestCursor, m.visibleRequestCount())
+ }
+}
+
func TestSearchPaneUsesThemedBackgroundFill(t *testing.T) {
t.Parallel()
diff --git a/internal/tui/update.go b/internal/tui/update.go
index 8670eb0..da51da7 100644
--- a/internal/tui/update.go
+++ b/internal/tui/update.go
@@ -5,8 +5,10 @@ import (
"strconv"
"strings"
"time"
+ "unicode/utf8"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/google/uuid"
"termtap.dev/internal/model"
)
@@ -133,9 +135,7 @@ func (m *Model) createRequest(req model.Request) {
return
}
- if len(m.requests) > 0 && m.requestCursor > 0 {
- m.requestCursor++
- }
+ selectedReq, hadSelectedReq := m.selectedRequest()
m.requests = append(m.requests, req)
@@ -145,6 +145,12 @@ func (m *Model) createRequest(req model.Request) {
m.requests = m.requests[1:]
}
+ if hadSelectedReq {
+ if cursor, ok := m.cursorForRequestID(selectedReq.ID); ok {
+ m.requestCursor = cursor
+ }
+ }
+
m.clampRequestCursor()
}
@@ -230,7 +236,8 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) bool {
return true
case tea.KeyBackspace, tea.KeyCtrlH:
if len(m.searchQuery) > 0 {
- m.searchQuery = m.searchQuery[:len(m.searchQuery)-1]
+ _, size := utf8.DecodeLastRuneInString(m.searchQuery)
+ m.searchQuery = m.searchQuery[:len(m.searchQuery)-size]
}
m.requestCursor = 0
m.requestScroll = 0
@@ -275,6 +282,17 @@ func (m Model) filteredRequestIndices() []int {
return indices
}
+func (m Model) cursorForRequestID(id uuid.UUID) (int, bool) {
+ visible := m.filteredRequestIndices()
+ for row := len(visible) - 1; row >= 0; row-- {
+ if m.requests[visible[row]].ID == id {
+ return len(visible) - 1 - row, true
+ }
+ }
+
+ return 0, false
+}
+
type requestSearchQuery struct {
terms []string
methods []string
@@ -288,7 +306,20 @@ func parseRequestSearchQuery(input string) requestSearchQuery {
statusHuns: make(map[int]struct{}),
}
- for _, token := range strings.Fields(strings.ToLower(strings.TrimSpace(input))) {
+ tokens := strings.Fields(strings.ToLower(strings.TrimSpace(input)))
+ for i := 0; i < len(tokens); i++ {
+ token := tokens[i]
+
+ if token == "method:" {
+ if i+1 < len(tokens) && tokens[i+1] != "" {
+ q.methods = append(q.methods, tokens[i+1])
+ i++
+ continue
+ }
+ q.terms = append(q.terms, token)
+ continue
+ }
+
if value, ok := strings.CutPrefix(token, "method:"); ok {
if value != "" {
q.methods = append(q.methods, value)
@@ -296,6 +327,22 @@ func parseRequestSearchQuery(input string) requestSearchQuery {
}
}
+ if token == "status:" {
+ if i+1 < len(tokens) {
+ if status, ok := parseStatusToken(tokens[i+1]); ok {
+ if status >= 1000 {
+ q.statusHuns[status/1000] = struct{}{}
+ } else {
+ q.statuses[status] = struct{}{}
+ }
+ i++
+ continue
+ }
+ }
+ q.terms = append(q.terms, token)
+ continue
+ }
+
if value, ok := strings.CutPrefix(token, "status:"); ok {
if status, ok := parseStatusToken(value); ok {
if status >= 1000 {
From a1667dbf46df30a27c3edf693d967c3fe7bd8cc5 Mon Sep 17 00:00:00 2001
From: Hayden Hargreaves
Date: Fri, 1 May 2026 15:06:35 -0700
Subject: [PATCH 3/3] feat: added section to web app
---
web/src/pages/DocsPage.tsx | 48 +++++++++++++++++++++++++++++++++++++-
1 file changed, 47 insertions(+), 1 deletion(-)
diff --git a/web/src/pages/DocsPage.tsx b/web/src/pages/DocsPage.tsx
index 65c745e..57e86ba 100644
--- a/web/src/pages/DocsPage.tsx
+++ b/web/src/pages/DocsPage.tsx
@@ -8,6 +8,7 @@ const contents = [
{ id: 'examples', label: 'examples' },
{ id: 'https-certs', label: 'https & certs' },
{ id: 'keymaps', label: 'keymaps' },
+ { id: 'search', label: 'search' },
{ id: 'privacy', label: 'privacy' },
]
@@ -298,7 +299,52 @@ export function DocsPage() {
-
+
+
+ Press / to open search. Filtering is live as you type and
+ applies to the Requests pane.
+
+ Search supports free text and simple field filters.
+
+
+
+
+
+ | Pattern |
+ Meaning |
+
+
+
+
+ | users api.example.com |
+ Free text; matches host and URL path (AND between terms) |
+
+
+ | method:get |
+ Method equals GET (case-insensitive) |
+
+
+ | status:404 |
+ Exact response status code |
+
+
+ | status:5xx |
+ Status class match (1xx-5xx) |
+
+
+ | method: get status: 5xx login |
+ Mixed filters + terms, including spaced filter syntax |
+
+
+
+
+
+
+ Tip: press esc to close search and clear the active query.
+
+
+
+
Termtap is a local-only tool. It does not phone home, collect telemetry, or send any data anywhere.