diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-27 00:00:48 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-27 00:00:48 +0200 |
| commit | 62e9fc030a7ad7c6522c2db1010609441818b0a9 (patch) | |
| tree | ca9e7715ee2223f3454bd1f83cffdf46bef79606 | |
| parent | 34e70c9cd76b0231cfff3910bb24708624d7c72d (diff) | |
tui: add stream regex search and unify help visibility
| -rw-r--r-- | internal/tui/common/keys.go | 60 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 6 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 23 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs_test.go | 10 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 157 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 60 | ||||
| -rw-r--r-- | internal/tui/eventstream/searchmodal.go | 121 |
7 files changed, 411 insertions, 26 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index 8c0b13b..ba17998 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -2,6 +2,12 @@ package common import "github.com/charmbracelet/bubbles/key" +// HelpSection groups related key bindings under a shared heading. +type HelpSection struct { + Title string + Bindings []key.Binding +} + // KeyMap groups all key bindings shared by TUI screens. type KeyMap struct { Tab key.Binding @@ -53,32 +59,62 @@ func DefaultKeyMap() KeyMap { // DashboardStatusHelp returns expanded bindings for dashboard status bars. func (k KeyMap) DashboardStatusHelp() []key.Binding { - bindings := []key.Binding{k.Tab, k.ShiftTab, k.One, k.Two, k.Three, k.Four, k.Five, k.Six} - if help := k.Export.Help(); help.Key != "" || help.Desc != "" { - bindings = append(bindings, k.Export) + sections := k.DashboardStatusHelpSections() + total := 0 + for _, section := range sections { + total += len(section.Bindings) } - bindings = append(bindings, - helpTextBinding("x", "stream export"), - helpTextBinding("X", "stream export as"), - helpTextBinding("E", "stream open last"), - helpTextBinding("esc", "stream undo filter"), - k.DirGroup, + bindings := make([]key.Binding, 0, total) + for _, section := range sections { + bindings = append(bindings, section.Bindings...) + } + return bindings +} + +// DashboardStatusHelpSections returns grouped bindings for dashboard status bars. +func (k KeyMap) DashboardStatusHelpSections() []HelpSection { + global := []key.Binding{ + helpTextBinding("H", "toggle help"), + k.Tab, + k.ShiftTab, + k.One, + k.Two, + k.Three, + k.Four, + k.Five, + k.Six, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit, + } + if help := k.Export.Help(); help.Key != "" || help.Desc != "" { + global = append(global, k.Export) + } + dashboard := []key.Binding{ + k.DirGroup, helpTextBinding("space", "stream pause"), helpTextBinding("f", "stream filter"), helpTextBinding("g/G", "stream top/tail"), helpTextBinding("c", "stream clear"), helpTextBinding("enter", "stream add filter"), + helpTextBinding("esc", "stream undo filter"), helpTextBinding("left/right", "stream col"), helpTextBinding("h/l", "stream col"), helpTextBinding("j/k", "scroll"), helpTextBinding("up/down", "scroll"), - ) - return bindings + helpTextBinding("/,?", "stream regex search"), + helpTextBinding("n/N", "stream search next/prev"), + helpTextBinding("x", "stream export"), + helpTextBinding("X", "stream export as"), + helpTextBinding("E", "stream open last"), + } + + return []HelpSection{ + {Title: "Global", Bindings: global}, + {Title: "Dashboard", Bindings: dashboard}, + } } // DashboardFullHelp returns grouped bindings for dashboard overlays. @@ -106,6 +142,8 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { helpTextBinding("x", "stream export"), helpTextBinding("X", "stream export as"), helpTextBinding("E", "stream open last"), + helpTextBinding("/,?", "stream regex search"), + helpTextBinding("n/N", "stream search next/prev"), }, } } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 0e850d4..c9c96c3 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -272,7 +272,7 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot { // BlocksGlobalShortcuts reports whether modal UI in the active tab should // suppress top-level shortcuts (for example global export key handling). func (m Model) BlocksGlobalShortcuts() bool { - return m.activeTab == TabStream && (m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible()) + return m.activeTab == TabStream && (m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible()) } // SetStreamSource updates the live stream source used by the stream tab. @@ -284,6 +284,8 @@ func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { func (m Model) View() string { width, height := common.EffectiveViewport(m.width, m.height) activeHeight := height + streamModel := m.streamModel + streamModel.SetFooterVisible(m.showHelp) if m.activeTab == TabStream { _, activeHeight = streamViewport(width, height) } @@ -294,7 +296,7 @@ func (m Model) View() string { b.WriteString(renderActiveTab( m.activeTab, m.latest, - &m.streamModel, + &streamModel, width, activeHeight, m.syscallsOffset, diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go index 4b9d339..df8f03e 100644 --- a/internal/tui/dashboard/tabs.go +++ b/internal/tui/dashboard/tabs.go @@ -113,16 +113,21 @@ func renderTabBar(active Tab, width int) string { } func renderHelpBar(keys common.KeyMap, width int) string { - parts := make([]string, 0, len(keys.DashboardStatusHelp())) - for _, binding := range keys.DashboardStatusHelp() { - help := binding.Help() - parts = append(parts, help.Key+" "+help.Desc) - } - line1, line2 := wrapHelpLines(parts, width) - text := line1 - if line2 != "" { - text += "\n" + line2 + sections := keys.DashboardStatusHelpSections() + lines := make([]string, 0, len(sections)) + for _, section := range sections { + parts := make([]string, 0, len(section.Bindings)) + for _, binding := range section.Bindings { + help := binding.Help() + parts = append(parts, help.Key+" "+help.Desc) + } + line := section.Title + ": " + strings.Join(parts, " • ") + if width > 0 { + line = truncatePlain(line, width) + } + lines = append(lines, line) } + text := strings.Join(lines, "\n") if width > 0 && width < 90 { return text } diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go index a457153..1148103 100644 --- a/internal/tui/dashboard/tabs_test.go +++ b/internal/tui/dashboard/tabs_test.go @@ -42,7 +42,13 @@ func TestRenderTabBarSmallWidthUsesSingleLine(t *testing.T) { func TestRenderHelpBarSmallWidthCanWrapToTwoLines(t *testing.T) { out := renderHelpBar(common.DefaultKeyMap(), 70) lines := strings.Split(out, "\n") - if len(lines) < 1 || len(lines) > 2 { - t.Fatalf("expected one or two help bar lines at width 70, got %d lines", len(lines)) + if len(lines) != 2 { + t.Fatalf("expected exactly two section lines at width 70, got %d lines", len(lines)) + } + if !strings.Contains(lines[0], "Global:") { + t.Fatalf("expected Global section line, got %q", lines[0]) + } + if !strings.Contains(lines[1], "Dashboard:") { + t.Fatalf("expected Dashboard section line, got %q", lines[1]) } } diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 7f67702..d9c4ee3 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -2,6 +2,8 @@ package eventstream import ( "fmt" + "regexp" + "strconv" "strings" tea "github.com/charmbracelet/bubbletea" @@ -43,6 +45,10 @@ type Model struct { filterStack []Filter filterActionStack []string exportModal ExportModal + searchModal SearchModal + searchPattern string + searchRegex *regexp.Regexp + searchDirection SearchDirection lastExportPath string pendingOpenPath string statusMessage string @@ -50,6 +56,8 @@ type Model struct { width int height int + + showFooter bool } type fdTraceViewState struct { @@ -65,10 +73,12 @@ func NewModel(source *RingBuffer) Model { source: source, filterModal: NewFilterModal(), exportModal: NewExportModal(), + searchModal: NewSearchModal(), autoScroll: true, selectedIdx: -1, selectedCol: 0, exportDir: ".", + showFooter: true, } } @@ -83,6 +93,11 @@ func (m *Model) SetViewport(width, height int) { } } +// SetFooterVisible controls whether stream footer/status lines are shown. +func (m *Model) SetFooterVisible(visible bool) { + m.showFooter = visible +} + // SetSource updates the backing ring buffer and refreshes visible rows. func (m *Model) SetSource(source *RingBuffer) { m.source = source @@ -99,12 +114,29 @@ func (m Model) ExportModalVisible() bool { return m.exportModal.Visible() } +// SearchModalVisible reports whether the stream search modal is currently open. +func (m Model) SearchModalVisible() bool { + return m.searchModal.Visible() +} + // Paused reports whether stream refresh is currently paused. func (m Model) Paused() bool { return m.paused } func (m *Model) HandleKey(keyStr string) bool { + if m.searchModal.Visible() { + m.statusMessage = "" + var ( + term string + submit bool + ) + m.searchModal, term, submit = m.searchModal.Update(keyMsgFromString(keyStr)) + if !submit { + return true + } + return m.submitSearch(term, m.searchModal.Direction()) + } if m.exportModal.Visible() { m.statusMessage = "" var ( @@ -176,11 +208,27 @@ func (m *Model) HandleKey(keyStr string) bool { } switch keyStr { + case "/": + m.openSearch(SearchForward) + return true + case "?": + m.openSearch(SearchBackward) + return true case "enter": if m.paused { return m.applyFilterFromSelectedCell() } return false + case "n": + if m.searchRegex == nil { + return false + } + return m.jumpSearch(m.searchDirection) + case "N": + if m.searchRegex == nil { + return false + } + return m.jumpSearch(-m.searchDirection) case "x": if !m.paused { return false @@ -369,9 +417,22 @@ func (m *Model) View(width, height int) string { selectedCol = m.selectedCol } base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx, selectedCol) - status := fmt.Sprintf("Row %d/%d | space:pause f:filter G:tail g:top c:clear j/k:scroll", rowNumber(start, len(m.filtered)), len(m.filtered)) + if !m.showFooter { + if m.filterModal.Visible() { + return m.filterModal.View(width, height) + } + if m.exportModal.Visible() { + return m.exportModal.View(width, height) + } + if m.searchModal.Visible() { + return m.searchModal.View(width, height) + } + return base + } + + status := fmt.Sprintf("Row %d/%d", rowNumber(start, len(m.filtered)), len(m.filtered)) if m.paused && m.selectedIdx >= 0 { - status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | enter:add-filter esc:undo(%d) x:export X:export-as E:open-last space:pause f:filter G:tail g:top c:clear j/k:row h/l:col", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount, len(m.filterStack)) + status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | Filters %d", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount, len(m.filterStack)) } out := base + "\n" + status if len(m.filterActionStack) > 0 { @@ -389,6 +450,9 @@ func (m *Model) View(width, height int) string { if m.exportModal.Visible() { return m.exportModal.View(width, height) } + if m.searchModal.Visible() { + return m.searchModal.View(width, height) + } return out } @@ -808,6 +872,95 @@ func (m *Model) SetStatusMessage(message string) { m.statusMessage = message } +func (m *Model) openSearch(direction SearchDirection) { + m.paused = true + m.ensureSelection() + m.ensureSelectedCol() + m.centerSelection() + m.searchModal = m.searchModal.Open(direction, m.searchPattern) +} + +func (m *Model) submitSearch(term string, direction SearchDirection) bool { + re, err := regexp.Compile(term) + if err != nil { + m.statusMessage = fmt.Sprintf("Invalid regex: %v", err) + return true + } + m.searchPattern = term + m.searchRegex = re + m.searchDirection = direction + return m.jumpSearch(direction) +} + +func (m *Model) jumpSearch(direction SearchDirection) bool { + if m.searchRegex == nil { + return false + } + if len(m.filtered) == 0 { + m.statusMessage = "Search: no rows" + return true + } + start := m.selectedIdx + if start < 0 || start >= len(m.filtered) { + if direction == SearchForward { + start = -1 + } else { + start = len(m.filtered) + } + } + next := m.findMatch(start, direction) + if next < 0 { + m.statusMessage = fmt.Sprintf("No match: %q", m.searchPattern) + return true + } + m.moveSelectionTo(next) + prefix := "/" + if direction == SearchBackward { + prefix = "?" + } + m.statusMessage = fmt.Sprintf("%s%s @ row %d/%d", prefix, m.searchPattern, next+1, len(m.filtered)) + return true +} + +func (m *Model) findMatch(start int, direction SearchDirection) int { + n := len(m.filtered) + if n == 0 { + return -1 + } + step := int(direction) + for offset := 1; offset <= n; offset++ { + idx := (start + step*offset + n) % n + if streamEventMatchesRegex(m.filtered[idx], m.searchRegex) { + return idx + } + } + return -1 +} + +func streamEventMatchesRegex(ev StreamEvent, re *regexp.Regexp) bool { + if re == nil { + return false + } + if re.MatchString(ev.Syscall) || re.MatchString(ev.Comm) || re.MatchString(ev.FileName) { + return true + } + if re.MatchString(strconv.FormatUint(ev.Seq, 10)) || + re.MatchString(strconv.FormatUint(ev.TimeNs, 10)) || + re.MatchString(strconv.FormatUint(uint64(ev.PID), 10)) || + re.MatchString(strconv.FormatUint(uint64(ev.TID), 10)) || + re.MatchString(strconv.FormatInt(int64(ev.FD), 10)) || + re.MatchString(strconv.FormatInt(ev.RetVal, 10)) || + re.MatchString(strconv.FormatUint(ev.Bytes, 10)) || + re.MatchString(strconv.FormatUint(ev.GapNs, 10)) || + re.MatchString(strconv.FormatUint(ev.DurationNs, 10)) { + return true + } + if ev.IsError && re.MatchString("error") { + return true + } + return false +} + func (m *Model) dumpVisibleForTest() string { rows := make([]string, 0, len(m.filtered)) for _, ev := range m.filtered { diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index 74bccb6..3925d26 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -680,6 +680,66 @@ func TestPausedOpenLastExportQueuesRequest(t *testing.T) { } } +func TestRegexSearchForwardBackwardAndRepeat(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, Comm: "alpha", PID: 10, TID: 100, Syscall: "read", FileName: "/tmp/a"}) + rb.Push(StreamEvent{Seq: 2, Comm: "beta", PID: 11, TID: 110, Syscall: "write", FileName: "/tmp/b"}) + rb.Push(StreamEvent{Seq: 3, Comm: "gamma", PID: 12, TID: 120, Syscall: "open", FileName: "/tmp/c"}) + rb.Push(StreamEvent{Seq: 4, Comm: "beta", PID: 13, TID: 130, Syscall: "close", FileName: "/tmp/d"}) + + m := NewModel(rb) + m.height = 20 + m.Refresh() + _ = m.HandleKey("space") + m.moveSelectionTo(0) + + if !m.HandleKey("/") { + t.Fatalf("/ should open search modal") + } + if !m.searchModal.Visible() { + t.Fatalf("expected search modal visible") + } + if !m.HandleKey("b") || !m.HandleKey("e") || !m.HandleKey("t") || !m.HandleKey("a") { + t.Fatalf("expected term typing keys handled") + } + if !m.HandleKey("enter") { + t.Fatalf("enter should submit search") + } + if m.selectedIdx != 1 { + t.Fatalf("expected first forward beta hit at idx 1, got %d", m.selectedIdx) + } + if m.searchDirection != SearchForward { + t.Fatalf("expected search direction forward") + } + + if !m.HandleKey("n") { + t.Fatalf("n should jump to next hit") + } + if m.selectedIdx != 3 { + t.Fatalf("expected next forward beta hit at idx 3, got %d", m.selectedIdx) + } + + if !m.HandleKey("N") { + t.Fatalf("N should jump opposite direction") + } + if m.selectedIdx != 1 { + t.Fatalf("expected opposite-direction beta hit at idx 1, got %d", m.selectedIdx) + } + + if !m.HandleKey("?") { + t.Fatalf("? should open backward search modal") + } + if !m.HandleKey("enter") { + t.Fatalf("enter should submit backward search") + } + if m.selectedIdx != 3 { + t.Fatalf("expected backward beta hit at idx 3, got %d", m.selectedIdx) + } + if m.searchDirection != SearchBackward { + t.Fatalf("expected search direction backward") + } +} + func readCSVRecords(t *testing.T, path string) [][]string { t.Helper() f, err := os.Open(path) diff --git a/internal/tui/eventstream/searchmodal.go b/internal/tui/eventstream/searchmodal.go new file mode 100644 index 0000000..f744d00 --- /dev/null +++ b/internal/tui/eventstream/searchmodal.go @@ -0,0 +1,121 @@ +package eventstream + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type SearchDirection int + +const ( + SearchForward SearchDirection = 1 + SearchBackward SearchDirection = -1 +) + +type SearchModal struct { + visible bool + textInput textinput.Model + err string + direction SearchDirection +} + +func NewSearchModal() SearchModal { + input := textinput.New() + input.Prompt = "" + input.CharLimit = 0 + input.Width = 44 + return SearchModal{textInput: input, direction: SearchForward} +} + +func (m SearchModal) Visible() bool { + return m.visible +} + +func (m SearchModal) Direction() SearchDirection { + return m.direction +} + +func (m SearchModal) Open(direction SearchDirection, defaultTerm string) SearchModal { + m.visible = true + m.err = "" + m.direction = direction + m.textInput.SetValue(defaultTerm) + m.textInput.CursorEnd() + m.textInput.Focus() + return m +} + +func (m SearchModal) Close() SearchModal { + m.visible = false + m.err = "" + m.textInput.Blur() + return m +} + +// Update returns updated modal, submitted term, and whether submit occurred. +func (m SearchModal) Update(msg tea.Msg) (SearchModal, string, bool) { + if !m.visible { + return m, "", false + } + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "esc": + return m.Close(), "", false + case "enter": + term := strings.TrimSpace(m.textInput.Value()) + if term == "" { + m.err = "search term is required" + return m, "", false + } + return m.Close(), term, true + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + _ = cmd + return m, "", false +} + +func (m SearchModal) View(width, height int) string { + if !m.visible { + return "" + } + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + modalWidth := 58 + if width < modalWidth+4 { + modalWidth = width - 4 + if modalWidth < 40 { + modalWidth = 40 + } + } + + prefix := "/" + if m.direction == SearchBackward { + prefix = "?" + } + lines := []string{ + "Regex Search", + "", + "Pattern:", + prefix + m.textInput.View(), + } + if m.err != "" { + lines = append(lines, "Error: "+m.err) + } + lines = append(lines, "", "Enter search • Esc cancel") + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(1, 2). + Width(modalWidth). + Render(strings.Join(lines, "\n")) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} |
