summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-26 23:17:42 +0200
committerPaul Buetow <paul@buetow.org>2026-02-26 23:17:42 +0200
commite5cb5db2292ae84680935767d455a777125e0fe9 (patch)
tree5210c1c4d6aaff8f6de459b9af4cffdaac11f20e /internal/tui
parentc3106802208b18f78d4ff4b22e1d889ac19f817f (diff)
tui: add stackable enter-filters with esc undo in stream
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/eventstream/filter.go7
-rw-r--r--internal/tui/eventstream/model.go161
-rw-r--r--internal/tui/eventstream/model_test.go171
3 files changed, 299 insertions, 40 deletions
diff --git a/internal/tui/eventstream/filter.go b/internal/tui/eventstream/filter.go
index 458ecdd..4e0daf7 100644
--- a/internal/tui/eventstream/filter.go
+++ b/internal/tui/eventstream/filter.go
@@ -33,6 +33,7 @@ type Filter struct {
File *StringFilter
PID *NumericFilter
TID *NumericFilter
+ FD *NumericFilter
LatencyNs *NumericFilter
GapNs *NumericFilter
Bytes *NumericFilter
@@ -62,6 +63,9 @@ func (f Filter) Matches(ev *StreamEvent) bool {
if !matchNumeric(f.TID, int64(ev.TID)) {
return false
}
+ if !matchNumeric(f.FD, int64(ev.FD)) {
+ return false
+ }
if !matchNumeric(f.LatencyNs, int64(ev.DurationNs)) {
return false
}
@@ -86,7 +90,7 @@ func (f Filter) IsActive() bool {
return true
}
}
- for _, nf := range []*NumericFilter{f.PID, f.TID, f.LatencyNs, f.GapNs, f.Bytes, f.RetVal} {
+ for _, nf := range []*NumericFilter{f.PID, f.TID, f.FD, f.LatencyNs, f.GapNs, f.Bytes, f.RetVal} {
if nf != nil {
return true
}
@@ -104,6 +108,7 @@ func (f Filter) Summary() string {
parts = appendStringSummary(parts, "file", f.File)
parts = appendNumericSummary(parts, "pid", f.PID, false)
parts = appendNumericSummary(parts, "tid", f.TID, false)
+ parts = appendNumericSummary(parts, "fd", f.FD, false)
parts = appendNumericSummary(parts, "latency", f.LatencyNs, true)
parts = appendNumericSummary(parts, "gap", f.GapNs, true)
parts = appendNumericSummary(parts, "bytes", f.Bytes, false)
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go
index d757209..3258954 100644
--- a/internal/tui/eventstream/model.go
+++ b/internal/tui/eventstream/model.go
@@ -7,7 +7,19 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
-const streamColumnCount = 10
+const (
+ streamColGap = iota
+ streamColLatency
+ streamColComm
+ streamColPID
+ streamColTID
+ streamColSyscall
+ streamColFD
+ streamColRet
+ streamColBytes
+ streamColFile
+ streamColumnCount
+)
type Model struct {
source *RingBuffer
@@ -23,11 +35,13 @@ type Model struct {
// can pause refresh temporarily without losing the user's prior state.
pauseBeforeFilter bool
- scrollOffset int
- autoScroll bool
- selectedIdx int
- selectedCol int
- fdTraceView fdTraceViewState
+ scrollOffset int
+ autoScroll bool
+ selectedIdx int
+ selectedCol int
+ fdTraceView fdTraceViewState
+ filterStack []Filter
+ filterActionStack []string
width int
height int
@@ -84,6 +98,8 @@ func (m *Model) HandleKey(keyStr string) bool {
m.filterModal = m.filterModal.Update(keyMsgFromString(keyStr))
if wasVisible && !m.filterModal.Visible() {
m.filter = m.filterModal.Filter()
+ m.filterStack = nil
+ m.filterActionStack = nil
m.paused = m.pauseBeforeFilter
m.applyFilter()
if !m.paused {
@@ -131,7 +147,7 @@ func (m *Model) HandleKey(keyStr string) bool {
switch keyStr {
case "enter":
if m.paused {
- return m.openFDTraceView()
+ return m.applyFilterFromSelectedCell()
}
return false
case " ", "space":
@@ -170,6 +186,8 @@ func (m *Model) HandleKey(keyStr string) bool {
return true
case "c":
m.filter = Filter{}
+ m.filterStack = nil
+ m.filterActionStack = nil
m.applyFilter()
return true
case "j", "down":
@@ -212,6 +230,11 @@ func (m *Model) HandleKey(keyStr string) bool {
m.scrollByLines(-m.pageStep())
}
return true
+ case "esc":
+ if m.paused {
+ return m.popFilter()
+ }
+ return false
default:
return false
}
@@ -285,9 +308,12 @@ func (m *Model) View(width, height int) string {
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.paused && m.selectedIdx >= 0 {
- status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | enter:fd-trace 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)
+ status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | enter:add-filter esc:undo(%d) 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))
}
out := base + "\n" + status
+ if len(m.filterActionStack) > 0 {
+ out += "\n" + "Stack: " + strings.Join(m.filterActionStack, " | ")
+ }
if m.filterModal.Visible() {
// While editing filters, show a dedicated modal screen to avoid
@@ -518,6 +544,125 @@ func (m *Model) moveSelectedColBy(delta int) {
m.selectedCol = clamp(m.selectedCol+delta, 0, streamColumnCount-1)
}
+func (m *Model) applyFilterFromSelectedCell() bool {
+ if m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) {
+ return false
+ }
+ ev := m.filtered[m.selectedIdx]
+ targetSeq := ev.Seq
+ next := cloneFilter(m.filter)
+ action := ""
+
+ switch m.selectedCol {
+ case streamColGap:
+ next.GapNs = &NumericFilter{Op: OpGte, Value: int64(ev.GapNs)}
+ action = fmt.Sprintf("gap>=%dns", ev.GapNs)
+ case streamColLatency:
+ next.LatencyNs = &NumericFilter{Op: OpGte, Value: int64(ev.DurationNs)}
+ action = fmt.Sprintf("latency>=%dns", ev.DurationNs)
+ case streamColComm:
+ next.Comm = &StringFilter{Pattern: ev.Comm}
+ action = fmt.Sprintf("comm~%s", ev.Comm)
+ case streamColPID:
+ next.PID = &NumericFilter{Op: OpEq, Value: int64(ev.PID)}
+ action = fmt.Sprintf("pid=%d", ev.PID)
+ case streamColTID:
+ next.TID = &NumericFilter{Op: OpEq, Value: int64(ev.TID)}
+ action = fmt.Sprintf("tid=%d", ev.TID)
+ case streamColSyscall:
+ next.Syscall = &StringFilter{Pattern: ev.Syscall}
+ action = fmt.Sprintf("syscall~%s", ev.Syscall)
+ case streamColFD:
+ next.FD = &NumericFilter{Op: OpEq, Value: int64(ev.FD)}
+ action = fmt.Sprintf("fd=%d", ev.FD)
+ case streamColRet:
+ next.RetVal = &NumericFilter{Op: OpEq, Value: ev.RetVal}
+ action = fmt.Sprintf("ret=%d", ev.RetVal)
+ case streamColBytes:
+ next.Bytes = &NumericFilter{Op: OpEq, Value: int64(ev.Bytes)}
+ action = fmt.Sprintf("bytes=%d", ev.Bytes)
+ case streamColFile:
+ next.File = &StringFilter{Pattern: ev.FileName}
+ action = fmt.Sprintf("file~%s", ev.FileName)
+ default:
+ return false
+ }
+
+ m.filterStack = append(m.filterStack, cloneFilter(m.filter))
+ m.filterActionStack = append(m.filterActionStack, action)
+ m.filter = next
+ m.applyFilter()
+ m.restoreSelectionBySeq(targetSeq)
+ return true
+}
+
+func (m *Model) popFilter() bool {
+ if len(m.filterStack) == 0 {
+ return false
+ }
+ targetSeq := m.currentSelectedSeq()
+ last := m.filterStack[len(m.filterStack)-1]
+ m.filterStack = m.filterStack[:len(m.filterStack)-1]
+ if len(m.filterActionStack) > 0 {
+ m.filterActionStack = m.filterActionStack[:len(m.filterActionStack)-1]
+ }
+ m.filter = cloneFilter(last)
+ m.applyFilter()
+ m.restoreSelectionBySeq(targetSeq)
+ return true
+}
+
+func (m *Model) currentSelectedSeq() uint64 {
+ if m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) {
+ return 0
+ }
+ return m.filtered[m.selectedIdx].Seq
+}
+
+func (m *Model) restoreSelectionBySeq(seq uint64) {
+ if !m.paused || seq == 0 || len(m.filtered) == 0 {
+ return
+ }
+ for i := range m.filtered {
+ if m.filtered[i].Seq == seq {
+ m.selectedIdx = i
+ m.centerSelection()
+ return
+ }
+ }
+}
+
+func cloneFilter(in Filter) Filter {
+ out := in
+ out.Syscall = cloneStringFilter(in.Syscall)
+ out.Comm = cloneStringFilter(in.Comm)
+ out.File = cloneStringFilter(in.File)
+ out.PID = cloneNumericFilter(in.PID)
+ out.TID = cloneNumericFilter(in.TID)
+ out.FD = cloneNumericFilter(in.FD)
+ out.LatencyNs = cloneNumericFilter(in.LatencyNs)
+ out.GapNs = cloneNumericFilter(in.GapNs)
+ out.Bytes = cloneNumericFilter(in.Bytes)
+ out.RetVal = cloneNumericFilter(in.RetVal)
+ return out
+}
+
+func cloneStringFilter(in *StringFilter) *StringFilter {
+ if in == nil {
+ return nil
+ }
+ out := *in
+ return &out
+}
+
+func cloneNumericFilter(in *NumericFilter) *NumericFilter {
+ if in == nil {
+ return nil
+ }
+ out := *in
+ return &out
+}
+
func (m *Model) clampSelection() {
if len(m.filtered) == 0 {
m.selectedIdx = -1
diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go
index e58144a..d55da61 100644
--- a/internal/tui/eventstream/model_test.go
+++ b/internal/tui/eventstream/model_test.go
@@ -414,63 +414,172 @@ func TestPausedSelectionMovesAcrossColumnsWithLeftRightAndHL(t *testing.T) {
}
}
-func TestPausedEnterOpensFDTraceViewScopedByPIDAndFD(t *testing.T) {
+func TestPausedEnterAddsStackableFiltersAndEscUndoes(t *testing.T) {
rb := NewRingBuffer()
- rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 3, Syscall: "read", FileName: "/a"})
- rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 3, Syscall: "write", FileName: "/a"}) // same pid/fd, different tid
- rb.Push(StreamEvent{Seq: 3, PID: 10, TID: 103, FD: 4, Syscall: "read", FileName: "/b"}) // different fd
- rb.Push(StreamEvent{Seq: 4, PID: 11, TID: 104, FD: 3, Syscall: "read", FileName: "/c"}) // different pid
+ rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, Comm: "firefox", Syscall: "read", FD: 5, RetVal: 10, Bytes: 10, GapNs: 50, DurationNs: 100, FileName: "/a"})
+ rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, Comm: "firefox", Syscall: "write", FD: 6, RetVal: 11, Bytes: 11, GapNs: 51, DurationNs: 101, FileName: "/b"})
+ rb.Push(StreamEvent{Seq: 3, PID: 11, TID: 201, Comm: "bash", Syscall: "read", FD: 7, RetVal: 12, Bytes: 12, GapNs: 52, DurationNs: 102, FileName: "/c"})
m := NewModel(rb)
m.height = 20
m.Refresh()
if !m.HandleKey("space") {
- t.Fatalf("space should pause")
+ t.Fatalf("space should toggle pause")
}
- // Pick the first row (pid=10, fd=3).
m.selectedIdx = 0
+ m.selectedCol = streamColComm
if !m.HandleKey("enter") {
- t.Fatalf("enter should open fd trace view")
+ t.Fatalf("enter should add filter from selected comm cell")
}
- if !m.fdTraceView.visible {
- t.Fatalf("expected fd trace view visible")
+ if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" {
+ t.Fatalf("expected comm filter firefox, got %+v", m.filter.Comm)
}
- if m.fdTraceView.pid != 10 || m.fdTraceView.fd != 3 {
- t.Fatalf("expected pid/fd 10/3, got %d/%d", m.fdTraceView.pid, m.fdTraceView.fd)
+ if len(m.filtered) != 2 {
+ t.Fatalf("expected 2 rows after comm filter, got %d", len(m.filtered))
}
- if len(m.fdTraceView.events) != 2 {
- t.Fatalf("expected 2 matching events, got %d", len(m.fdTraceView.events))
+ if len(m.filterStack) != 1 {
+ t.Fatalf("expected filter stack len 1, got %d", len(m.filterStack))
}
- for _, ev := range m.fdTraceView.events {
- if ev.PID != 10 || ev.FD != 3 {
- t.Fatalf("unexpected event in fd trace view: pid=%d fd=%d", ev.PID, ev.FD)
- }
+ if got := m.View(120, 24); !strings.Contains(got, "Stack: comm~firefox") {
+ t.Fatalf("expected stack status line with comm filter, got:\n%s", got)
+ }
+
+ m.selectedIdx = 1
+ m.selectedCol = streamColTID
+ if !m.HandleKey("enter") {
+ t.Fatalf("enter should add filter from selected tid cell")
+ }
+ if m.filter.TID == nil || m.filter.TID.Value != 102 {
+ t.Fatalf("expected tid filter 102, got %+v", m.filter.TID)
+ }
+ if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" {
+ t.Fatalf("expected comm filter preserved, got %+v", m.filter.Comm)
+ }
+ if len(m.filtered) != 1 {
+ t.Fatalf("expected 1 row after comm+tid filters, got %d", len(m.filtered))
+ }
+
+ if !m.HandleKey("esc") {
+ t.Fatalf("esc should undo last filter")
+ }
+ if m.filter.TID != nil {
+ t.Fatalf("expected tid filter cleared after undo")
+ }
+ if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" {
+ t.Fatalf("expected comm filter kept after first undo")
+ }
+ if len(m.filtered) != 2 {
+ t.Fatalf("expected 2 rows after undo tid filter, got %d", len(m.filtered))
+ }
+
+ if !m.HandleKey("esc") {
+ t.Fatalf("esc should undo previous filter")
+ }
+ if m.filter.IsActive() {
+ t.Fatalf("expected no active filters after second undo, got summary=%q", m.filter.Summary())
+ }
+ if len(m.filtered) != 3 {
+ t.Fatalf("expected all rows after second undo, got %d", len(m.filtered))
+ }
+ if got := m.View(120, 24); strings.Contains(got, "Stack:") {
+ t.Fatalf("did not expect stack status line after all undos, got:\n%s", got)
}
}
-func TestFDTraceViewRendersAndClosesOnEsc(t *testing.T) {
+func TestPausedEnterCanFilterLatencyAndGapColumns(t *testing.T) {
rb := NewRingBuffer()
- rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 5, Syscall: "read", FileName: "/x"})
- rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 5, Syscall: "write", FileName: "/x"})
+ 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.HandleKey("space")
+
m.selectedIdx = 0
- _ = m.HandleKey("enter")
+ m.selectedCol = streamColLatency
+ if !m.HandleKey("enter") {
+ t.Fatalf("expected enter to filter by latency")
+ }
+ if m.filter.LatencyNs == nil || m.filter.LatencyNs.Op != OpGte || m.filter.LatencyNs.Value != 100 {
+ t.Fatalf("expected latency filter >=100ns, got %+v", m.filter.LatencyNs)
+ }
+ if len(m.filtered) != 2 {
+ t.Fatalf("expected both rows after latency >=100 filter, got %d", len(m.filtered))
+ }
- view := m.View(120, 24)
- if !strings.Contains(view, "FD Trace (ring snapshot)") {
- t.Fatalf("expected fd trace header in view")
+ m.selectedCol = streamColGap
+ if !m.HandleKey("enter") {
+ t.Fatalf("expected enter to filter by gap")
}
- if !strings.Contains(view, "PID:10 FD:5 matched:2") {
- t.Fatalf("expected pid/fd summary in fd trace view")
+ if m.filter.GapNs == nil || m.filter.GapNs.Op != OpGte || m.filter.GapNs.Value != 5 {
+ t.Fatalf("expected gap filter >=5ns, got %+v", m.filter.GapNs)
}
- if !m.HandleKey("esc") {
- t.Fatalf("esc should close fd trace view")
+ if len(m.filtered) != 2 {
+ t.Fatalf("expected both rows after gap >=5 filter, got %d", len(m.filtered))
+ }
+}
+
+func TestPausedEnterKeepsSelectedRowCenteredAfterFilter(t *testing.T) {
+ rb := NewRingBuffer()
+ for i := 0; i < 300; i++ {
+ comm := "other"
+ if i >= 90 && i <= 210 {
+ comm = "match"
+ }
+ rb.Push(StreamEvent{
+ Seq: uint64(i + 1),
+ PID: 1000,
+ TID: uint32(2000 + i),
+ Comm: comm,
+ Syscall: "read",
+ FileName: "/tmp/f",
+ })
+ }
+
+ m := NewModel(rb)
+ m.height = 20
+ m.Refresh()
+ _ = m.HandleKey("space")
+ m.moveSelectionTo(150)
+ before := m.selectedIdx - m.scrollOffset
+ if before < 4 || before > 8 {
+ t.Fatalf("expected initial selected row near middle, got relative idx %d", before)
+ }
+
+ m.selectedCol = streamColComm
+ if !m.HandleKey("enter") {
+ t.Fatalf("expected enter to apply filter")
+ }
+ after := m.selectedIdx - m.scrollOffset
+ if after < 4 || after > 8 {
+ t.Fatalf("expected selected row to stay near middle after filter, got relative idx %d", after)
+ }
+}
+
+func TestPausedEnterCanFilterByFDColumn(t *testing.T) {
+ rb := NewRingBuffer()
+ rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 3, Syscall: "read", FileName: "/a"})
+ rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 3, Syscall: "write", FileName: "/a"})
+ rb.Push(StreamEvent{Seq: 3, PID: 10, TID: 103, FD: 4, Syscall: "read", FileName: "/b"})
+ rb.Push(StreamEvent{Seq: 4, PID: 11, TID: 104, FD: 3, Syscall: "read", FileName: "/c"})
+
+ m := NewModel(rb)
+ m.height = 20
+ m.Refresh()
+ if !m.HandleKey("space") {
+ t.Fatalf("space should pause")
+ }
+
+ m.selectedIdx = 0
+ m.selectedCol = streamColFD
+ if !m.HandleKey("enter") {
+ t.Fatalf("enter should add fd filter")
}
- if m.fdTraceView.visible {
- t.Fatalf("expected fd trace view closed")
+ if m.filter.FD == nil || m.filter.FD.Value != 3 {
+ t.Fatalf("expected fd filter 3, got %+v", m.filter.FD)
+ }
+ if len(m.filtered) != 3 {
+ t.Fatalf("expected 3 rows with fd=3, got %d", len(m.filtered))
}
}