summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 22:03:01 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 22:03:01 +0200
commit0d1492291a3e20665d8a3a6b16d2eb4e13938cee (patch)
treeec09f7d660403478d23841cf541bdfa7f33aa70f
parentd84902555621cc10b16a9641274b088e495f3714 (diff)
tui: restore global filter stack and anchored matches
-rw-r--r--internal/eventloop.go3
-rw-r--r--internal/eventloop_error_handling_test.go29
-rw-r--r--internal/globalfilter/filter.go20
-rw-r--r--internal/globalfilter/filter_test.go25
-rw-r--r--internal/tui/common/keys.go93
-rw-r--r--internal/tui/common/keys_test.go24
-rw-r--r--internal/tui/dashboard/model.go19
-rw-r--r--internal/tui/eventstream/filter_test.go17
-rw-r--r--internal/tui/eventstream/model.go132
-rw-r--r--internal/tui/eventstream/model_test.go50
-rw-r--r--internal/tui/eventstream/render.go9
-rw-r--r--internal/tui/eventstream/render_test.go19
-rw-r--r--internal/tui/messages/messages.go14
-rw-r--r--internal/tui/tracefilter/model.go38
-rw-r--r--internal/tui/tracefilter/model_test.go17
-rw-r--r--internal/tui/tui.go151
-rw-r--r--internal/tui/tui_test.go179
17 files changed, 741 insertions, 98 deletions
diff --git a/internal/eventloop.go b/internal/eventloop.go
index 6500508..278387f 100644
--- a/internal/eventloop.go
+++ b/internal/eventloop.go
@@ -595,8 +595,7 @@ func (e *eventLoop) tracepointEntered(enterEv event.Event) {
if _, ok := e.cachedComm(tid); ok {
e.enterEvs[tid] = event.NewPair(enterEv)
} else {
- // Probably not an issue.
- fmt.Println("WARN: No comm name for", enterEv, "process probably already vanished?")
+ e.notifyWarning(fmt.Sprintf("No comm name for %v process probably already vanished?", enterEv))
}
}
}
diff --git a/internal/eventloop_error_handling_test.go b/internal/eventloop_error_handling_test.go
index 7f2c572..e025343 100644
--- a/internal/eventloop_error_handling_test.go
+++ b/internal/eventloop_error_handling_test.go
@@ -144,3 +144,32 @@ func TestProcessRawEventMalformedKnownTypeDoesNotPanicAndNotifies(t *testing.T)
t.Fatalf("expected warning notification")
}
}
+
+func TestTracepointEnteredMissingCommWithCommFilterNotifies(t *testing.T) {
+ el := mustNewEventLoop(t, eventLoopConfig{commFilter: "system"})
+ warnings := make(chan string, 1)
+ el.warningCb = func(message string) { warnings <- message }
+
+ _, enterRaw := makeEnterFdEvent(t, defaulTime, defaultPid, defaultTid, 20, types.SYS_ENTER_WRITE)
+
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("tracepointEntered panicked: %v", r)
+ }
+ }()
+
+ el.tracepointEntered(types.NewFdEvent(enterRaw))
+
+ select {
+ case msg := <-warnings:
+ if msg == "" {
+ t.Fatalf("expected non-empty warning message")
+ }
+ default:
+ t.Fatalf("expected warning notification")
+ }
+
+ if _, ok := el.enterEvs[defaultTid]; ok {
+ t.Fatalf("expected no enter event to be stored for tid %d", defaultTid)
+ }
+}
diff --git a/internal/globalfilter/filter.go b/internal/globalfilter/filter.go
index 24cae7c..aaac35b 100644
--- a/internal/globalfilter/filter.go
+++ b/internal/globalfilter/filter.go
@@ -213,7 +213,25 @@ func matchString(sf *StringFilter, value string) bool {
if pattern == "" {
return true
}
- return strings.Contains(strings.ToLower(value), pattern)
+ value = strings.ToLower(value)
+ anchoredStart := strings.HasPrefix(pattern, "^")
+ anchoredEnd := strings.HasSuffix(pattern, "$")
+ if anchoredStart {
+ pattern = pattern[1:]
+ }
+ if anchoredEnd && len(pattern) > 0 {
+ pattern = pattern[:len(pattern)-1]
+ }
+ switch {
+ case anchoredStart && anchoredEnd:
+ return value == pattern
+ case anchoredStart:
+ return strings.HasPrefix(value, pattern)
+ case anchoredEnd:
+ return strings.HasSuffix(value, pattern)
+ default:
+ return strings.Contains(value, pattern)
+ }
}
func matchNumeric(nf *NumericFilter, value int64) bool {
diff --git a/internal/globalfilter/filter_test.go b/internal/globalfilter/filter_test.go
index a7adad4..d081f7d 100644
--- a/internal/globalfilter/filter_test.go
+++ b/internal/globalfilter/filter_test.go
@@ -80,6 +80,31 @@ func TestFilterStringAndNumericMatching(t *testing.T) {
}
}
+func TestFilterStringAnchorsSupportExactPrefixAndSuffix(t *testing.T) {
+ candidate := testCandidate()
+
+ if !(Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(candidate) {
+ t.Fatalf("expected ^read$ to exactly match read")
+ }
+ if !(Filter{Syscall: &StringFilter{Pattern: "^re"}}).Matches(candidate) {
+ t.Fatalf("expected ^re to match read by prefix")
+ }
+ if !(Filter{File: &StringFilter{Pattern: ".log$"}}).Matches(candidate) {
+ t.Fatalf("expected .log$ to match by suffix")
+ }
+
+ candidate.syscall = "readlink"
+ if (Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(candidate) {
+ t.Fatalf("expected ^read$ not to match readlink")
+ }
+ if !(Filter{Syscall: &StringFilter{Pattern: "^read"}}).Matches(candidate) {
+ t.Fatalf("expected ^read to match readlink by prefix")
+ }
+ if !(Filter{Syscall: &StringFilter{Pattern: "link$"}}).Matches(candidate) {
+ t.Fatalf("expected link$ to match readlink by suffix")
+ }
+}
+
func TestFilterErrorsOnlyAndClone(t *testing.T) {
filter := Filter{
ErrorsOnly: true,
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index fd7bef1..f2e1271 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -10,27 +10,28 @@ type HelpSection struct {
// KeyMap groups all key bindings shared by TUI screens.
type KeyMap struct {
- Tab key.Binding
- ShiftTab key.Binding
- One key.Binding
- Two key.Binding
- Three key.Binding
- Four key.Binding
- Five key.Binding
- Six key.Binding
- Seven key.Binding
- Visualize key.Binding
- Metric key.Binding
- DirGroup key.Binding
- SelectPID key.Binding
- SelectTID key.Binding
- Probes key.Binding
- Filter key.Binding
- Export key.Binding
- Quit key.Binding
- Enter key.Binding
- Esc key.Binding
- Refresh key.Binding
+ Tab key.Binding
+ ShiftTab key.Binding
+ One key.Binding
+ Two key.Binding
+ Three key.Binding
+ Four key.Binding
+ Five key.Binding
+ Six key.Binding
+ Seven key.Binding
+ Visualize key.Binding
+ Metric key.Binding
+ DirGroup key.Binding
+ SelectPID key.Binding
+ SelectTID key.Binding
+ Probes key.Binding
+ Filter key.Binding
+ FilterUndo key.Binding
+ Export key.Binding
+ Quit key.Binding
+ Enter key.Binding
+ Esc key.Binding
+ Refresh key.Binding
}
// Keys contains the default shared key map.
@@ -39,27 +40,28 @@ var Keys = DefaultKeyMap()
// DefaultKeyMap builds the default key bindings used by models.
func DefaultKeyMap() KeyMap {
return KeyMap{
- Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")),
- ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")),
- One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")),
- Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")),
- Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")),
- Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")),
- Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")),
- Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")),
- Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")),
- Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")),
- Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")),
- DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")),
- SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")),
- SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")),
- Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")),
- Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")),
- Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "snapshot export")),
- Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
- Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
- Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
- Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reset baseline")),
+ Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")),
+ ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")),
+ One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")),
+ Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")),
+ Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")),
+ Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")),
+ Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")),
+ Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")),
+ Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")),
+ Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")),
+ Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")),
+ DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")),
+ SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")),
+ SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")),
+ Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")),
+ Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")),
+ FilterUndo: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "undo filter")),
+ Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "snapshot export")),
+ Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
+ Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
+ Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
+ Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reset baseline")),
}
}
@@ -93,6 +95,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
k.Visualize,
k.Metric,
k.Filter,
+ k.FilterUndo,
k.SelectPID,
k.SelectTID,
k.Probes,
@@ -107,6 +110,8 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
k.Visualize,
k.Metric,
helpTextBinding("space", "stream pause"),
+ helpTextBinding("enter", "stream push filter"),
+ helpTextBinding("esc", "stream undo filter"),
helpTextBinding("g/G", "stream top/tail"),
helpTextBinding("left/right", "stream col"),
helpTextBinding("h/l", "stream col"),
@@ -132,13 +137,15 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding {
controls = append(controls, k.Export)
}
controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit)
- controls = append(controls, k.Visualize, k.Metric, k.Filter)
+ controls = append(controls, k.Visualize, k.Metric, k.Filter, k.FilterUndo)
return [][]key.Binding{
{k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven},
controls,
{
helpTextBinding("space", "stream pause"),
+ helpTextBinding("enter", "stream push filter"),
+ helpTextBinding("esc", "stream undo filter"),
helpTextBinding("g/G", "stream top/tail"),
helpTextBinding("left/right", "stream col"),
helpTextBinding("h/l", "stream col"),
diff --git a/internal/tui/common/keys_test.go b/internal/tui/common/keys_test.go
index a2b5940..43f4b8b 100644
--- a/internal/tui/common/keys_test.go
+++ b/internal/tui/common/keys_test.go
@@ -38,6 +38,11 @@ func TestDefaultKeyMapIncludesDirGroupBinding(t *testing.T) {
if metricHelp.Key != "b" || metricHelp.Desc != "metric" {
t.Fatalf("unexpected metric binding help: key=%q desc=%q", metricHelp.Key, metricHelp.Desc)
}
+
+ undoHelp := keys.FilterUndo.Help()
+ if undoHelp.Key != "F" || undoHelp.Desc != "undo filter" {
+ t.Fatalf("unexpected filter undo binding help: key=%q desc=%q", undoHelp.Key, undoHelp.Desc)
+ }
}
func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) {
@@ -130,6 +135,18 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) {
if !found {
t.Fatalf("expected metric binding in dashboard full help controls")
}
+
+ found = false
+ for _, binding := range groups[1] {
+ help := binding.Help()
+ if help.Key == "F" && help.Desc == "undo filter" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected undo filter binding in dashboard full help controls")
+ }
}
func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
@@ -138,6 +155,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
found := false
foundSelectTID := false
foundOne := false
+ foundUndo := false
for _, binding := range short {
help := binding.Help()
if help.Key == "o" && help.Desc == "probes" {
@@ -149,6 +167,9 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
if help.Key == "1" && help.Desc == "flame" {
foundOne = true
}
+ if help.Key == "F" && help.Desc == "undo filter" {
+ foundUndo = true
+ }
}
if !found {
t.Fatalf("expected probes binding in dashboard short help")
@@ -159,4 +180,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
if !foundOne {
t.Fatalf("expected flame tab binding in dashboard short help")
}
+ if !foundUndo {
+ t.Fatalf("expected undo filter binding in dashboard short help")
+ }
}
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index c5effe2..22d09c3 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -65,6 +65,7 @@ type Model struct {
refreshEvery time.Duration
keys common.KeyMap
globalFilter globalfilter.Filter
+ filterStack []string
pidFilter int
syscallsOffset int
syscallsTreemapSelection int
@@ -418,6 +419,12 @@ func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
streamWidth, streamHeight := streamViewport(m.width, m.height)
m.streamModel.SetViewport(streamWidth, streamHeight)
handled := m.streamModel.HandleTeaKey(msg)
+ if m.streamModel.ConsumeGlobalFilterUndoRequest() {
+ return true, func() tea.Msg { return messages.GlobalFilterUndoRequestedMsg{} }
+ }
+ if filter, action, ok := m.streamModel.ConsumeGlobalFilterRequest(); ok {
+ return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} }
+ }
if path, ok := m.streamModel.ConsumeOpenEditorRequest(); ok {
editorCmd, err := eventstream.EditorCommandForPath(path)
if err != nil {
@@ -543,6 +550,12 @@ func (m *Model) SetGlobalFilter(filter globalfilter.Filter) {
m.streamModel.SetFilter(eventstream.Filter(filter))
}
+// SetFilterStack forwards the shared global filter stack into dashboard views.
+func (m *Model) SetFilterStack(stack []string) {
+ m.filterStack = append(m.filterStack[:0], stack...)
+ m.streamModel.SetFilterStack(stack)
+}
+
// SetLiveTrie updates the live trie source used by the flamegraph tab.
func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) {
m.liveTrie = liveTrie
@@ -612,7 +625,11 @@ func (m Model) View() tea.View {
}
func (m Model) filterSummary() string {
- return "filter: " + m.globalFilter.Summary()
+ summary := "filter: " + m.globalFilter.Summary()
+ if len(m.filterStack) == 0 {
+ return summary
+ }
+ return summary + " | stack: " + strings.Join(m.filterStack, " | ")
}
func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model) string {
diff --git a/internal/tui/eventstream/filter_test.go b/internal/tui/eventstream/filter_test.go
index 556d4ef..0413904 100644
--- a/internal/tui/eventstream/filter_test.go
+++ b/internal/tui/eventstream/filter_test.go
@@ -55,6 +55,23 @@ func TestFilterStringDimensions(t *testing.T) {
}
}
+func TestFilterStringDimensionsSupportAnchors(t *testing.T) {
+ ev := sampleEvent()
+ if !(Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(&ev) {
+ t.Fatalf("expected ^read$ to exactly match read")
+ }
+ ev.Syscall = "readlink"
+ if (Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(&ev) {
+ t.Fatalf("expected ^read$ not to match readlink")
+ }
+ if !(Filter{Syscall: &StringFilter{Pattern: "^read"}}).Matches(&ev) {
+ t.Fatalf("expected ^read to match readlink")
+ }
+ if !(Filter{Syscall: &StringFilter{Pattern: "link$"}}).Matches(&ev) {
+ t.Fatalf("expected link$ to match readlink")
+ }
+}
+
func TestFilterNumericDimensions(t *testing.T) {
ev := sampleEvent()
cases := []struct {
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go
index 467c137..600d40d 100644
--- a/internal/tui/eventstream/model.go
+++ b/internal/tui/eventstream/model.go
@@ -36,25 +36,30 @@ type Model struct {
allEvents []StreamEvent
filtered []StreamEvent
- filter Filter
+ filter Filter
+ filterStack []string
paused bool
- scrollOffset int
- autoScroll bool
- selectedIdx int
- selectedCol int
- fdTraceView fdTraceViewState
- exportModal ExportModal
- searchModal SearchModal
- searchPattern string
- searchRegex *regexp.Regexp
- searchDirection SearchDirection
- lastExportPath string
- pendingOpenPath string
- statusMessage string
- exportDir string
- isDark bool
+ scrollOffset int
+ autoScroll bool
+ selectedIdx int
+ selectedCol int
+ fdTraceView fdTraceViewState
+ exportModal ExportModal
+ searchModal SearchModal
+ searchPattern string
+ searchRegex *regexp.Regexp
+ searchDirection SearchDirection
+ lastExportPath string
+ pendingOpenPath string
+ pendingFilter Filter
+ pendingFilterAction string
+ hasPendingFilter bool
+ pendingUndo bool
+ statusMessage string
+ exportDir string
+ isDark bool
width int
height int
@@ -135,6 +140,11 @@ func (m *Model) SetFilter(filter Filter) {
m.restoreSelectionBySeq(targetSeq)
}
+// SetFilterStack updates the visible shared filter stack summary.
+func (m *Model) SetFilterStack(stack []string) {
+ m.filterStack = append(m.filterStack[:0], stack...)
+}
+
// SetDarkMode updates stream modal text input styles for the active theme.
func (m *Model) SetDarkMode(isDark bool) {
m.isDark = isDark
@@ -231,6 +241,23 @@ func (m *Model) HandleKey(keyStr string) bool {
}
switch keyStr {
+ case "enter":
+ if m.paused {
+ return m.requestGlobalFilterFromSelectedCell()
+ }
+ return false
+ case "F":
+ if len(m.filterStack) == 0 {
+ return false
+ }
+ m.pendingUndo = true
+ return true
+ case "esc":
+ if m.paused && len(m.filterStack) > 0 {
+ m.pendingUndo = true
+ return true
+ }
+ return false
case "/":
m.openSearch(SearchForward)
return true
@@ -458,7 +485,7 @@ func (m *Model) View(width, height int) string {
if m.paused && selectedVisibleIdx >= 0 {
selectedCol = m.selectedCol
}
- base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx, selectedCol)
+ base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, m.filterStack, visible, selectedVisibleIdx, selectedCol)
if !m.showFooter {
if m.exportModal.Visible() {
return m.exportModal.View(width, height)
@@ -471,7 +498,7 @@ func (m *Model) View(width, height int) string {
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", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount)
+ status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | Enter push-filter | Esc/F undo", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount)
}
out := base + "\n" + status
if m.statusMessage != "" {
@@ -699,6 +726,53 @@ func (m *Model) moveSelectedColBy(delta int) {
m.selectedCol = clamp(m.selectedCol+delta, 0, streamColumnCount-1)
}
+func (m *Model) requestGlobalFilterFromSelectedCell() bool {
+ if m.fdTraceView.visible || m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) {
+ return false
+ }
+ ev := m.filtered[m.selectedIdx]
+ next := m.filter.Clone()
+
+ switch m.selectedCol {
+ case streamColGap:
+ next.GapNs = &NumericFilter{Op: OpGte, Value: int64(ev.GapNs)}
+ m.pendingFilterAction = fmt.Sprintf("gap>=%s", formatDurationNs(ev.GapNs))
+ case streamColLatency:
+ next.LatencyNs = &NumericFilter{Op: OpGte, Value: int64(ev.DurationNs)}
+ m.pendingFilterAction = fmt.Sprintf("latency>=%s", formatDurationNs(ev.DurationNs))
+ case streamColComm:
+ next.Comm = &StringFilter{Pattern: ev.Comm}
+ m.pendingFilterAction = "comm~" + ev.Comm
+ case streamColPID:
+ next.PID = &NumericFilter{Op: OpEq, Value: int64(ev.PID)}
+ m.pendingFilterAction = fmt.Sprintf("pid=%d", ev.PID)
+ case streamColTID:
+ next.TID = &NumericFilter{Op: OpEq, Value: int64(ev.TID)}
+ m.pendingFilterAction = fmt.Sprintf("tid=%d", ev.TID)
+ case streamColSyscall:
+ next.Syscall = &StringFilter{Pattern: ev.Syscall}
+ m.pendingFilterAction = "syscall~" + ev.Syscall
+ case streamColFD:
+ next.FD = &NumericFilter{Op: OpEq, Value: int64(ev.FD)}
+ m.pendingFilterAction = fmt.Sprintf("fd=%d", ev.FD)
+ case streamColRet:
+ next.RetVal = &NumericFilter{Op: OpEq, Value: ev.RetVal}
+ m.pendingFilterAction = fmt.Sprintf("ret=%d", ev.RetVal)
+ case streamColBytes:
+ next.Bytes = &NumericFilter{Op: OpEq, Value: int64(ev.Bytes)}
+ m.pendingFilterAction = fmt.Sprintf("bytes=%d", ev.Bytes)
+ case streamColFile:
+ next.File = &StringFilter{Pattern: ev.FileName}
+ m.pendingFilterAction = "file~" + ev.FileName
+ default:
+ return false
+ }
+
+ m.pendingFilter = next
+ m.hasPendingFilter = true
+ return true
+}
+
func (m *Model) currentSelectedSeq() uint64 {
if m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) {
return 0
@@ -790,6 +864,28 @@ func (m *Model) ConsumeOpenEditorRequest() (string, bool) {
return path, true
}
+// ConsumeGlobalFilterRequest returns the pending global-filter request once.
+func (m *Model) ConsumeGlobalFilterRequest() (Filter, string, bool) {
+ if !m.hasPendingFilter {
+ return Filter{}, "", false
+ }
+ filter := m.pendingFilter.Clone()
+ action := m.pendingFilterAction
+ m.pendingFilter = Filter{}
+ m.pendingFilterAction = ""
+ m.hasPendingFilter = false
+ return filter, action, true
+}
+
+// ConsumeGlobalFilterUndoRequest returns the pending undo request once.
+func (m *Model) ConsumeGlobalFilterUndoRequest() bool {
+ if !m.pendingUndo {
+ return false
+ }
+ m.pendingUndo = false
+ return true
+}
+
// SetStatusMessage updates the stream footer status line.
func (m *Model) SetStatusMessage(message string) {
m.statusMessage = message
diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go
index 9848042..a97fbea 100644
--- a/internal/tui/eventstream/model_test.go
+++ b/internal/tui/eventstream/model_test.go
@@ -377,30 +377,68 @@ func TestPausedSelectionMovesAcrossColumnsWithLeftRightAndHL(t *testing.T) {
}
}
-func TestPausedEnterDoesNotMutateGlobalFilter(t *testing.T) {
+func TestPausedEnterQueuesGlobalFilterRequestFromSelectedCell(t *testing.T) {
rb := NewRingBuffer()
rb.Push(StreamEvent{Seq: 1, PID: 1, TID: 1, Comm: "a", DurationNs: 100, GapNs: 5})
rb.Push(StreamEvent{Seq: 2, PID: 1, TID: 2, Comm: "b", DurationNs: 200, GapNs: 6})
m := NewModel(rb)
m.height = 20
m.Refresh()
+ m.SetFilter(Filter{PID: &NumericFilter{Op: OpEq, Value: 1}})
if !m.HandleKey("space") {
t.Fatalf("space should pause")
}
m.selectedIdx = 0
- m.selectedCol = streamColLatency
- if m.HandleKey("enter") {
- t.Fatalf("expected enter not to mutate filters in paused stream mode")
+ m.selectedCol = streamColComm
+ if !m.HandleKey("enter") {
+ t.Fatalf("expected enter to queue a global filter request in paused stream mode")
+ }
+ if m.filter.Comm != nil {
+ t.Fatalf("expected local stream filter state to remain unchanged until parent applies it")
+ }
+ req, action, ok := m.ConsumeGlobalFilterRequest()
+ if !ok {
+ t.Fatalf("expected pending global filter request")
+ }
+ if action != "comm~a" {
+ t.Fatalf("expected action label comm~a, got %q", action)
}
- if m.filter.IsActive() {
- t.Fatalf("expected enter to leave global filter unchanged")
+ if req.PID == nil || req.PID.Op != OpEq || req.PID.Value != 1 {
+ t.Fatalf("expected existing pid filter preserved, got %+v", req.PID)
+ }
+ if req.Comm == nil || req.Comm.Pattern != "a" {
+ t.Fatalf("expected selected comm folded into global filter, got %+v", req.Comm)
+ }
+ if _, _, ok := m.ConsumeGlobalFilterRequest(); ok {
+ t.Fatalf("expected global filter request to be one-shot")
}
if m.HandleKey("esc") {
t.Fatalf("expected esc not to act as local filter undo anymore")
}
}
+func TestPausedEscQueuesGlobalFilterUndoWhenStackPresent(t *testing.T) {
+ rb := NewRingBuffer()
+ rb.Push(StreamEvent{Seq: 1, PID: 1, TID: 1, Comm: "a"})
+ m := NewModel(rb)
+ m.height = 20
+ m.Refresh()
+ m.SetFilterStack([]string{"comm~a"})
+ if !m.HandleKey("space") {
+ t.Fatalf("space should pause")
+ }
+ if !m.HandleKey("esc") {
+ t.Fatalf("expected esc to queue undo when a global filter stack exists")
+ }
+ if !m.ConsumeGlobalFilterUndoRequest() {
+ t.Fatalf("expected pending global filter undo request")
+ }
+ if m.ConsumeGlobalFilterUndoRequest() {
+ t.Fatalf("expected global filter undo request to be one-shot")
+ }
+}
+
func TestSetFilterKeepsPausedSelectionCentered(t *testing.T) {
rb := NewRingBuffer()
for i := 0; i < 300; i++ {
diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go
index 3ec4d65..819b6f4 100644
--- a/internal/tui/eventstream/render.go
+++ b/internal/tui/eventstream/render.go
@@ -33,7 +33,7 @@ var selectedCellStyle = lipgloss.NewStyle().
Foreground(common.ColorBackground).
Background(common.ColorAccent)
-func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, events []StreamEvent, selectedVisibleIdx int, selectedCol int) string {
+func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, filterStack []string, events []StreamEvent, selectedVisibleIdx int, selectedCol int) string {
if width <= 0 {
width = 100
}
@@ -42,6 +42,9 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer
lines := make([]string, 0, len(events)+3)
lines = append(lines, renderStatusLine(paused, totalCount, filteredCount, bufferLen, bufferCap))
lines = append(lines, renderFilterLine(filter))
+ if len(filterStack) > 0 {
+ lines = append(lines, renderFilterStackLine(filterStack))
+ }
lines = append(lines, renderColumnHeader(contentWidth))
for i, ev := range events {
col := -1
@@ -91,6 +94,10 @@ func renderFilterLine(filter Filter) string {
return common.HeaderStyle.Render("Filter:") + " " + summary
}
+func renderFilterStackLine(filterStack []string) string {
+ return common.HeaderStyle.Render("Stack:") + " " + strings.Join(filterStack, " | ")
+}
+
func renderColumnHeader(width int) string {
cols := computeColumnLayout(width)
header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %s",
diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go
index 6240c69..d5b2af5 100644
--- a/internal/tui/eventstream/render_test.go
+++ b/internal/tui/eventstream/render_test.go
@@ -10,7 +10,7 @@ import (
func TestRenderStatusAndFilterLines(t *testing.T) {
events := []StreamEvent{{Syscall: "read", Comm: "nginx", PID: 1, TID: 2, DurationNs: 1200, GapNs: 300, Bytes: 64, FileName: "/tmp/a", RetVal: 64}}
f := Filter{Syscall: &StringFilter{Pattern: "read"}, PID: &NumericFilter{Op: OpEq, Value: 1}}
- out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, events, -1, -1)
+ out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, nil, events, -1, -1)
for _, want := range []string{"LIVE", "total:100", "filtered:1", "buffer:100/10000", "Filter:", "syscall~read", "pid=1"} {
if !strings.Contains(out, want) {
@@ -21,7 +21,7 @@ func TestRenderStatusAndFilterLines(t *testing.T) {
func TestRenderPausedAndErrorRow(t *testing.T) {
events := []StreamEvent{{Syscall: "write", Comm: "worker", PID: 1, TID: 2, DurationNs: 1000000, GapNs: 5000, Bytes: 32, FileName: "/tmp/b", RetVal: -1, IsError: true}}
- out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, events, -1, -1)
+ out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, nil, events, -1, -1)
if !strings.Contains(out, "PAUSED") {
t.Fatalf("expected PAUSED indicator\n%s", out)
@@ -36,7 +36,7 @@ func TestRenderPausedAndErrorRow(t *testing.T) {
func TestRenderShowsFDWhenPresent(t *testing.T) {
events := []StreamEvent{{Syscall: "read", Comm: "worker", PID: 1, TID: 2, FD: 9, DurationNs: 10, GapNs: 1, Bytes: 8, FileName: "/tmp/b", RetVal: 8}}
- out := RenderStreamTable(120, false, 1, 1, 1, 10000, Filter{}, events, -1, -1)
+ out := RenderStreamTable(120, false, 1, 1, 1, 10000, Filter{}, nil, events, -1, -1)
if !strings.Contains(out, "FD") || !strings.Contains(out, " 9 ") {
t.Fatalf("expected FD column/value in output\n%s", out)
}
@@ -54,7 +54,7 @@ func TestRenderHeaderAndTruncate(t *testing.T) {
FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log",
RetVal: 1,
}}
- out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, events, -1, -1)
+ out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, nil, events, -1, -1)
for _, col := range []string{"Gap", "Latency", "Comm", "PID", "TID", "Syscall", "FD", "Ret", "Bytes", "File"} {
if !strings.Contains(out, col) {
@@ -112,7 +112,7 @@ func TestComputeColumnLayoutGivesFileMoreSpace(t *testing.T) {
}
func TestRenderStreamTableFitsRequestedWidth(t *testing.T) {
- out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, []StreamEvent{
+ out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, nil, []StreamEvent{
{
Syscall: "read",
Comm: "worker",
@@ -133,6 +133,15 @@ func TestRenderStreamTableFitsRequestedWidth(t *testing.T) {
}
}
+func TestRenderShowsFilterStackLine(t *testing.T) {
+ out := RenderStreamTable(100, true, 3, 1, 3, 10000, Filter{Comm: &StringFilter{Pattern: "system"}}, []string{"comm~system", "fd=20"}, []StreamEvent{{Comm: "systemd", Syscall: "write"}}, -1, -1)
+ for _, want := range []string{"Stack:", "comm~system", "fd=20"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("expected stack output missing %q\n%s", want, out)
+ }
+ }
+}
+
func TestRenderFDTraceTableShowsHeaderAndScope(t *testing.T) {
out := RenderFDTraceTable(100, 123, 7, 2, []StreamEvent{
{Syscall: "read", PID: 123, TID: 1, FD: 7, FileName: "/tmp/a"},
diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go
index 7d0273b..9826e25 100644
--- a/internal/tui/messages/messages.go
+++ b/internal/tui/messages/messages.go
@@ -1,6 +1,9 @@
package messages
-import "ior/internal/statsengine"
+import (
+ "ior/internal/globalfilter"
+ "ior/internal/statsengine"
+)
// PidSelectedMsg is emitted when the user selects a PID from the process table.
type PidSelectedMsg struct {
@@ -21,6 +24,15 @@ type StatsTickMsg struct {
// ExportRequestMsg requests an export of the current UI state.
type ExportRequestMsg struct{}
+// GlobalFilterRequestedMsg requests applying a new shared TUI filter.
+type GlobalFilterRequestedMsg struct {
+ Filter globalfilter.Filter
+ Action string
+}
+
+// GlobalFilterUndoRequestedMsg requests popping the latest shared filter layer.
+type GlobalFilterUndoRequestedMsg struct{}
+
// TracingStartedMsg signals that tracing started successfully.
type TracingStartedMsg struct{}
diff --git a/internal/tui/tracefilter/model.go b/internal/tui/tracefilter/model.go
index a9f2c08..caef948 100644
--- a/internal/tui/tracefilter/model.go
+++ b/internal/tui/tracefilter/model.go
@@ -104,11 +104,32 @@ func (m Model) Update(msg tea.Msg) Model {
}
if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
- switch keyMsg.String() {
- case "esc":
- if m.editing {
+ if m.editing {
+ switch keyMsg.String() {
+ case "esc":
+ m.commitEdit()
+ m.filter = m.buildFilterFromFields()
+ return m.Close()
+ case "enter":
+ if m.fields[m.activeField].fieldKey == fieldErrorsOnly {
+ if strings.TrimSpace(m.fields[m.activeField].value) == "true" {
+ m.fields[m.activeField].value = "false"
+ } else {
+ m.fields[m.activeField].value = "true"
+ }
+ return m
+ }
m.commitEdit()
+ return m