summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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
}
+ var cmd tea.Cmd
+ m.textInput, cmd = m.textInput.Update(msg)
+ _ = cmd
+ return m
+ }
+
+ switch keyMsg.String() {
+ case "esc":
m.filter = m.buildFilterFromFields()
return m.Close()
case "c":
@@ -147,20 +168,10 @@ func (m Model) Update(msg tea.Msg) Model {
}
return m
}
- if m.editing {
- m.commitEdit()
- return m
- }
m.startEdit()
return m
}
}
-
- if m.editing {
- var cmd tea.Cmd
- m.textInput, cmd = m.textInput.Update(msg)
- _ = cmd
- }
return m
}
@@ -192,6 +203,7 @@ func (m Model) View(width, height int) string {
lines = append(lines, prefix+m.renderField(field, i == m.activeField))
}
lines = append(lines, "", "j/k move • Enter edit/apply • Tab op • Space toggle errors • c clear • Esc apply+close")
+ lines = append(lines, "strings: substring by default, use ^prefix, suffix$, or ^exact$")
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
diff --git a/internal/tui/tracefilter/model_test.go b/internal/tui/tracefilter/model_test.go
index 83754b5..5e03deb 100644
--- a/internal/tui/tracefilter/model_test.go
+++ b/internal/tui/tracefilter/model_test.go
@@ -100,3 +100,20 @@ func TestModelClearAll(t *testing.T) {
t.Fatalf("expected cleared filter to be inactive: %+v", filter)
}
}
+
+func TestModelEditingAllowsPrintableHotkeyRunes(t *testing.T) {
+ model := NewModel().Open(globalfilter.Filter{})
+
+ model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ for _, r := range []rune("codexjk") {
+ model = model.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+
+ filter := model.Filter()
+ if filter.Comm == nil || filter.Comm.Pattern != "codexjk" {
+ t.Fatalf("expected printable runes preserved while editing, got %+v", filter.Comm)
+ }
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 6972e98..fe23a49 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
+ "strconv"
"strings"
"sync"
"time"
@@ -255,6 +256,8 @@ type Model struct {
pidFilter int
tidFilter int
globalFilter globalfilter.Filter
+ filterHistory []globalfilter.Filter
+ filterStack []string
pickerReturn *pickerReturnState
exportEnabled bool
isDark bool
@@ -431,6 +434,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.attaching = false
m.lastErr = msg.Err
return m, nil
+ case messages.GlobalFilterRequestedMsg:
+ return m.applyGlobalFilter(msg.Filter, msg.Action)
+ case messages.GlobalFilterUndoRequestedMsg:
+ return m.undoGlobalFilter()
}
if next, cmd, handled := m.handleModalDispatch(msg); handled {
@@ -531,6 +538,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
m.filterModal = m.filterModal.Open(m.globalFilter)
return m, nil, true
}
+ if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.FilterUndo) {
+ next, cmd := m.undoGlobalFilter()
+ return next, cmd, true
+ }
if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.SelectPID) {
next, cmd := m.reselectPID()
return next, cmd, true
@@ -563,7 +574,7 @@ func (m Model) updateFilterModal(msg tea.Msg) (tea.Model, tea.Cmd) {
wasVisible := m.filterModal.Visible()
m.filterModal = m.filterModal.Update(msg)
if wasVisible && !m.filterModal.Visible() {
- next, cmd := m.applyGlobalFilter(m.filterModal.Filter())
+ next, cmd := m.applyGlobalFilter(m.filterModal.Filter(), "")
return next, tea.Batch(dashboardCmd, cmd)
}
return m, dashboardCmd
@@ -854,10 +865,11 @@ func eqNumericFilter(value int) *globalfilter.NumericFilter {
func (m *Model) setProcessFilters(pid, tid int) {
m.pidFilter = pid
m.tidFilter = tid
- m.globalFilter.PID = eqNumericFilter(pid)
- m.globalFilter.TID = eqNumericFilter(tid)
- m.dashboard.SetPidFilter(pid)
- m.dashboard.SetGlobalFilter(m.globalFilter)
+ m.globalFilter = applyProcessFilters(m.globalFilter, pid, tid)
+ for i := range m.filterHistory {
+ m.filterHistory[i] = applyProcessFilters(m.filterHistory[i], pid, tid)
+ }
+ m.syncDashboardFilterState()
}
func (m *Model) setGlobalFilter(filter globalfilter.Filter) {
@@ -866,13 +878,29 @@ func (m *Model) setGlobalFilter(filter globalfilter.Filter) {
tid, _ := eqNumericFilterValue(m.globalFilter.TID)
m.pidFilter = pid
m.tidFilter = tid
- m.dashboard.SetPidFilter(pid)
+ m.syncDashboardFilterState()
+}
+
+func (m *Model) syncDashboardFilterState() {
+ m.dashboard.SetPidFilter(m.pidFilter)
m.dashboard.SetGlobalFilter(m.globalFilter)
+ m.dashboard.SetFilterStack(m.filterStack)
}
-func (m Model) applyGlobalFilter(filter globalfilter.Filter) (tea.Model, tea.Cmd) {
+func applyProcessFilters(filter globalfilter.Filter, pid, tid int) globalfilter.Filter {
+ out := filter.Clone()
+ out.PID = eqNumericFilter(pid)
+ out.TID = eqNumericFilter(tid)
+ return out
+}
+
+func (m Model) applyGlobalFilter(filter globalfilter.Filter, action string) (tea.Model, tea.Cmd) {
nextFilter := filter.Clone()
changed := !m.globalFilter.Equal(nextFilter)
+ if changed {
+ m.filterHistory = append(m.filterHistory, m.globalFilter.Clone())
+ m.filterStack = append(m.filterStack, globalFilterActionLabel(m.globalFilter, nextFilter, action))
+ }
m.setGlobalFilter(nextFilter)
if !changed || m.screen != ScreenDashboard {
return m, nil
@@ -885,6 +913,27 @@ func (m Model) applyGlobalFilter(filter globalfilter.Filter) (tea.Model, tea.Cmd
return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
}
+func (m Model) undoGlobalFilter() (tea.Model, tea.Cmd) {
+ if len(m.filterHistory) == 0 {
+ return m, nil
+ }
+ prev := m.filterHistory[len(m.filterHistory)-1]
+ m.filterHistory = m.filterHistory[:len(m.filterHistory)-1]
+ if len(m.filterStack) > 0 {
+ m.filterStack = m.filterStack[:len(m.filterStack)-1]
+ }
+ m.setGlobalFilter(prev)
+ if m.screen != ScreenDashboard {
+ return m, nil
+ }
+
+ m.stopTrace()
+ m.dashboard.PrepareForTraceRestart()
+ m.attaching = true
+ m.lastErr = nil
+ return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
+}
+
func eqNumericFilterValue(filter *globalfilter.NumericFilter) (int, bool) {
if filter == nil || filter.Op != globalfilter.OpEq || filter.Value <= 0 {
return -1, false
@@ -892,6 +941,94 @@ func eqNumericFilterValue(filter *globalfilter.NumericFilter) (int, bool) {
return int(filter.Value), true
}
+func globalFilterActionLabel(prev, next globalfilter.Filter, action string) string {
+ if strings.TrimSpace(action) != "" {
+ return action
+ }
+ parts := make([]string, 0, 10)
+ if prev.ErrorsOnly != next.ErrorsOnly {
+ if next.ErrorsOnly {
+ parts = append(parts, "errors")
+ } else {
+ parts = append(parts, "clear errors")
+ }
+ }
+ parts = appendStringFilterChange(parts, "syscall", prev.Syscall, next.Syscall)
+ parts = appendStringFilterChange(parts, "comm", prev.Comm, next.Comm)
+ parts = appendStringFilterChange(parts, "file", prev.File, next.File)
+ parts = appendNumericFilterChange(parts, "pid", prev.PID, next.PID, false)
+ parts = appendNumericFilterChange(parts, "tid", prev.TID, next.TID, false)
+ parts = appendNumericFilterChange(parts, "fd", prev.FD, next.FD, false)
+ parts = appendNumericFilterChange(parts, "latency", prev.LatencyNs, next.LatencyNs, true)
+ parts = appendNumericFilterChange(parts, "gap", prev.GapNs, next.GapNs, true)
+ parts = appendNumericFilterChange(parts, "bytes", prev.Bytes, next.Bytes, false)
+ parts = appendNumericFilterChange(parts, "ret", prev.RetVal, next.RetVal, false)
+ if len(parts) == 0 {
+ return next.Summary()
+ }
+ return strings.Join(parts, " ")
+}
+
+func appendStringFilterChange(parts []string, name string, prev, next *globalfilter.StringFilter) []string {
+ if sameStringFilter(prev, next) {
+ return parts
+ }
+ if next == nil || strings.TrimSpace(next.Pattern) == "" {
+ return append(parts, "clear "+name)
+ }
+ return append(parts, fmt.Sprintf("%s~%s", name, strings.TrimSpace(next.Pattern)))
+}
+
+func appendNumericFilterChange(parts []string, name string, prev, next *globalfilter.NumericFilter, duration bool) []string {
+ if sameNumericFilter(prev, next) {
+ return parts
+ }
+ if next == nil {
+ return append(parts, "clear "+name)
+ }
+ value := strconv.FormatInt(next.Value, 10)
+ if duration {
+ value = time.Duration(next.Value).String()
+ }
+ return append(parts, fmt.Sprintf("%s%s%s", name, compareOpSymbol(next.Op), value))
+}
+
+func sameStringFilter(a, b *globalfilter.StringFilter) bool {
+ if a == nil || strings.TrimSpace(a.Pattern) == "" {
+ return b == nil || strings.TrimSpace(b.Pattern) == ""
+ }
+ if b == nil {
+ return false
+ }
+ return strings.TrimSpace(a.Pattern) == strings.TrimSpace(b.Pattern)
+}
+
+func sameNumericFilter(a, b *globalfilter.NumericFilter) bool {
+ if a == nil || b == nil {
+ return a == nil && b == nil
+ }
+ return a.Op == b.Op && a.Value == b.Value
+}
+
+func compareOpSymbol(op globalfilter.CompareOp) string {
+ switch op {
+ case globalfilter.OpEq:
+ return "="
+ case globalfilter.OpNeq:
+ return "!="
+ case globalfilter.OpGt:
+ return ">"
+ case globalfilter.OpGte:
+ return ">="
+ case globalfilter.OpLt:
+ return "<"
+ case globalfilter.OpLte:
+ return "<="
+ default:
+ return "="
+ }
+}
+
func (m *Model) stopTrace() {
if m.traceStop != nil {
m.traceStop()
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 513de4c..ab8b5db 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -1184,6 +1184,185 @@ func TestGlobalFilterCloseWithoutChangesDoesNotRestartTrace(t *testing.T) {
}
}
+func TestPausedStreamEnterAppliesSelectedCellAsGlobalFilter(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+
+ stopped := false
+ m.traceStop = func() { stopped = true }
+
+ rb := eventstream.NewRingBuffer()
+ rb.Push(eventstream.StreamEvent{
+ Seq: 1,
+ Syscall: "write",
+ Comm: "systemd",
+ PID: 3655,
+ TID: 4862,
+ FileName: "/var/lib/clickhouse/data",
+ DurationNs: 1234,
+ GapNs: 44,
+ FD: 20,
+ })
+ m.dashboard.SetStreamSource(rb)
+
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ m = next.(Model)
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected enter on paused stream selection to emit a global filter request")
+ }
+
+ next, cmd = m.Update(cmd())
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected applying selected-cell global filter to restart tracing")
+ }
+ if m.globalFilter.Comm == nil || m.globalFilter.Comm.Pattern != "systemd" {
+ t.Fatalf("expected selected comm applied globally, got %+v", m.globalFilter.Comm)
+ }
+ if !stopped {
+ t.Fatalf("expected selected-cell global filter to stop the active trace")
+ }
+ if !m.attaching {
+ t.Fatalf("expected selected-cell global filter to restart tracing")
+ }
+ if len(m.filterStack) != 1 || m.filterStack[0] != "comm~systemd" {
+ t.Fatalf("expected selected-cell action pushed to filter stack, got %+v", m.filterStack)
+ }
+}
+
+func TestGlobalFilterUndoKeyPopsLatestStackEntry(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+ m = next.(Model)
+
+ m.attaching = false
+ stopped := false
+ m.traceStop = func() { stopped = true }
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'F'}[0], Text: string([]rune{'F'})})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected F to trigger global filter undo")
+ }
+ if m.globalFilter.IsActive() {
+ t.Fatalf("expected undo to restore the previous all-filter state, got %+v", m.globalFilter)
+ }
+ if len(m.filterStack) != 0 || len(m.filterHistory) != 0 {
+ t.Fatalf("expected filter stack/history cleared after undo, got stack=%+v history=%d", m.filterStack, len(m.filterHistory))
+ }
+ if !stopped {
+ t.Fatalf("expected undo to stop the active trace")
+ }
+ if !m.attaching {
+ t.Fatalf("expected undo to restart tracing")
+ }
+}
+
+func TestPausedStreamEscUndoesLatestGlobalFilter(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+ m = next.(Model)
+
+ rb := eventstream.NewRingBuffer()
+ rb.Push(eventstream.StreamEvent{Seq: 1, Syscall: "read", Comm: "systemd", PID: 1, TID: 2})
+ m.dashboard.SetStreamSource(rb)
+ m.attaching = false
+ stopped := false
+ m.traceStop = func() { stopped = true }
+
+ next, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ m = next.(Model)
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected esc in paused stream to undo one global filter layer")
+ }
+ next, cmd = m.Update(cmd())
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected esc undo to restart tracing")
+ }
+ if m.globalFilter.IsActive() {
+ t.Fatalf("expected esc undo to restore all-filter state, got %+v", m.globalFilter)
+ }
+ if len(m.filterStack) != 0 {
+ t.Fatalf("expected filter stack cleared after esc undo, got %+v", m.filterStack)
+ }
+ if !stopped {
+ t.Fatalf("expected esc undo to stop the active trace")
+ }
+ if !m.attaching {
+ t.Fatalf("expected esc undo to restart tracing")
+ }
+}
+
+func TestDashboardFooterShowsGlobalFilterStack(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 140
+ m.height = 35
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+ m = next.(Model)
+
+ m.attaching = false
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'4'}[0], Text: string([]rune{'4'})})
+ m = next.(Model)
+
+ view := m.View().Content
+ for _, want := range []string{"filter: syscall~read", "stack: syscall~read"} {
+ if !strings.Contains(view, want) {
+ t.Fatalf("expected dashboard footer to show %q\n%s", want, view)
+ }
+ }
+}
+
func TestGlobalFilterApplyPreservesActiveDashboardTab(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
m.screen = ScreenDashboard