summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-27 00:00:48 +0200
committerPaul Buetow <paul@buetow.org>2026-02-27 00:00:48 +0200
commit62e9fc030a7ad7c6522c2db1010609441818b0a9 (patch)
treeca9e7715ee2223f3454bd1f83cffdf46bef79606
parent34e70c9cd76b0231cfff3910bb24708624d7c72d (diff)
tui: add stream regex search and unify help visibility
-rw-r--r--internal/tui/common/keys.go60
-rw-r--r--internal/tui/dashboard/model.go6
-rw-r--r--internal/tui/dashboard/tabs.go23
-rw-r--r--internal/tui/dashboard/tabs_test.go10
-rw-r--r--internal/tui/eventstream/model.go157
-rw-r--r--internal/tui/eventstream/model_test.go60
-rw-r--r--internal/tui/eventstream/searchmodal.go121
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)
+}