summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 20:26:39 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 20:26:39 +0200
commit8236891a2c3a774a3eee2401980c10080aa85da6 (patch)
treee617c9866d769ae523a537c14143cd851a47b30b
parentcfd1319f06725df4e2501cdfc67983b1a44e7e16 (diff)
task 371: wire global filter modal into top-level TUI
-rw-r--r--internal/tui/common/keys.go2
-rw-r--r--internal/tui/tui.go65
-rw-r--r--internal/tui/tui_test.go58
3 files changed, 115 insertions, 10 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index 02fee2b..bf356ee 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -25,6 +25,7 @@ type KeyMap struct {
SelectPID key.Binding
SelectTID key.Binding
Probes key.Binding
+ Filter key.Binding
Export key.Binding
Quit key.Binding
Enter key.Binding
@@ -53,6 +54,7 @@ func DefaultKeyMap() KeyMap {
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")),
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 510a275..991a9d6 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -22,6 +22,7 @@ import (
"ior/internal/tui/messages"
"ior/internal/tui/pidpicker"
"ior/internal/tui/probes"
+ tracefilterui "ior/internal/tui/tracefilter"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
@@ -206,12 +207,13 @@ func RunTestFlamesWithTraceStarterConfig(cfg flags.Config, starter TraceStarter)
// Model is the top-level Bubble Tea model that routes between PID picker and dashboard.
type Model struct {
- screen Screen
- pidPicker pidpicker.Model
- dashboard dashboardui.Model
- exporter tuiexport.Model
- probeModal probes.Model
- runtime *runtimeBindings
+ screen Screen
+ pidPicker pidpicker.Model
+ dashboard dashboardui.Model
+ exporter tuiexport.Model
+ probeModal probes.Model
+ filterModal tracefilterui.Model
+ runtime *runtimeBindings
keys KeyMap
@@ -297,6 +299,7 @@ func newModelWithRuntimeConfig(initialPID int, startupFilter globalfilter.Filter
dashboard: dashboard,
exporter: tuiexport.NewModel(),
probeModal: probes.NewModel(runtime.currentProbeManager()).SetDarkMode(true),
+ filterModal: tracefilterui.NewModel().SetDarkMode(true),
runtime: runtime,
keys: keys,
spin: spin,
@@ -422,6 +425,7 @@ func (m Model) canHandleDashboardShortcut(msg tea.KeyPressMsg) bool {
return m.screen == ScreenDashboard &&
!m.attaching &&
m.lastErr == nil &&
+ !m.filterModal.Visible() &&
!m.exporter.Visible() &&
!m.probeModal.Visible() &&
!m.dashboard.BlocksGlobalShortcuts(msg)
@@ -431,6 +435,7 @@ func (m Model) canQuitFromMainDashboard(msg tea.KeyPressMsg) bool {
return m.screen == ScreenDashboard &&
!m.attaching &&
m.lastErr == nil &&
+ !m.filterModal.Visible() &&
!m.exporter.Visible() &&
!m.probeModal.Visible() &&
!m.dashboard.BlocksGlobalShortcuts(msg)
@@ -447,7 +452,7 @@ func (m Model) shouldRouteQuitToEsc(msg tea.KeyPressMsg) bool {
return false
}
return m.screen == ScreenDashboard &&
- (m.exporter.Visible() || m.probeModal.Visible() || m.dashboard.BlocksGlobalShortcuts(msg))
+ (m.filterModal.Visible() || m.exporter.Visible() || m.probeModal.Visible() || m.dashboard.BlocksGlobalShortcuts(msg))
}
func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) {
@@ -473,6 +478,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
next, cmd := m.updateProbeModal(esc)
return next, cmd, true
}
+ if m.filterModal.Visible() {
+ next, cmd := m.updateFilterModal(esc)
+ return next, cmd, true
+ }
if m.exporter.Visible() {
next, cmd := m.updateExportModal(esc)
return next, cmd, true
@@ -495,6 +504,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark).Open()
return m, nil, true
}
+ if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.Filter) {
+ m.filterModal = m.filterModal.Open(m.globalFilter)
+ return m, nil, true
+ }
if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.SelectPID) {
next, cmd := m.reselectPID()
return next, cmd, true
@@ -522,6 +535,16 @@ func (m Model) updateProbeModal(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(dashboardCmd, cmd)
}
+func (m Model) updateFilterModal(msg tea.Msg) (tea.Model, tea.Cmd) {
+ m, dashboardCmd := m.updateDashboardForModal(msg)
+ wasVisible := m.filterModal.Visible()
+ m.filterModal = m.filterModal.Update(msg)
+ if wasVisible && !m.filterModal.Visible() {
+ m.setGlobalFilter(m.filterModal.Filter())
+ }
+ return m, dashboardCmd
+}
+
func (m Model) updateExportModal(msg tea.Msg) (tea.Model, tea.Cmd) {
m, dashboardCmd := m.updateDashboardForModal(msg)
var cmd tea.Cmd
@@ -535,6 +558,10 @@ func (m Model) handleModalDispatch(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
m.spin, cmd = m.spin.Update(msg)
return m, cmd, true
}
+ if m.filterModal.Visible() {
+ next, cmd := m.updateFilterModal(msg)
+ return next, cmd, true
+ }
if m.probeModal.Visible() {
next, cmd := m.updateProbeModal(msg)
return next, cmd, true
@@ -687,6 +714,7 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) {
m.lastErr = nil
m.exporter = tuiexport.NewModel()
m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark)
+ m.filterModal = tracefilterui.NewModel().SetDarkMode(m.isDark)
m.pidPicker = pidpicker.New().SetDarkMode(m.isDark)
var sizeCmd tea.Cmd
@@ -712,6 +740,7 @@ func (m Model) reselectTID() (tea.Model, tea.Cmd) {
m.lastErr = nil
m.exporter = tuiexport.NewModel()
m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark)
+ m.filterModal = tracefilterui.NewModel().SetDarkMode(m.isDark)
m.pidPicker = pidpicker.NewTIDWithKeys(pid, pidpicker.DefaultKeyMap()).SetDarkMode(m.isDark)
var sizeCmd tea.Cmd
@@ -804,6 +833,22 @@ func (m *Model) setProcessFilters(pid, tid int) {
m.dashboard.SetPidFilter(pid)
}
+func (m *Model) setGlobalFilter(filter globalfilter.Filter) {
+ m.globalFilter = filter.Clone()
+ pid, _ := eqNumericFilterValue(m.globalFilter.PID)
+ tid, _ := eqNumericFilterValue(m.globalFilter.TID)
+ m.pidFilter = pid
+ m.tidFilter = tid
+ m.dashboard.SetPidFilter(pid)
+}
+
+func eqNumericFilterValue(filter *globalfilter.NumericFilter) (int, bool) {
+ if filter == nil || filter.Op != globalfilter.OpEq || filter.Value <= 0 {
+ return -1, false
+ }
+ return int(filter.Value), true
+}
+
func (m *Model) stopTrace() {
if m.traceStop != nil {
m.traceStop()
@@ -821,6 +866,7 @@ func (m *Model) applyTheme(isDark bool) {
m.dashboard.SetDarkMode(isDark)
m.pidPicker = m.pidPicker.SetDarkMode(isDark)
m.probeModal = m.probeModal.SetDarkMode(isDark)
+ m.filterModal = m.filterModal.SetDarkMode(isDark)
}
func (m Model) windowTitle() string {
@@ -866,6 +912,9 @@ func (m Model) View() tea.View {
return altScreenView(placeToViewport(width, height, base), title)
case ScreenDashboard:
base := m.dashboard.View().Content
+ if m.filterModal.Visible() {
+ return altScreenView(placeToViewport(width, height, m.filterModal.View(width, height)), title)
+ }
if m.probeModal.Visible() {
return altScreenView(placeToViewport(width, height, m.probeModal.View(width, height)), title)
}
@@ -979,7 +1028,7 @@ type helpSection struct {
func (m Model) helpSections() []helpSection {
globalLines := []string{
"H help esc/? close help q quit",
- "p pid picker t tid picker o probes",
+ "f filter p pid picker t tid picker o probes",
}
if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" {
globalLines[1] += " e snapshot export"
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 608ba41..faacdc3 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -884,8 +884,8 @@ func TestStreamFilterModalConsumesEKeyInsteadOfOpeningExport(t *testing.T) {
if m.exporter.Visible() {
t.Fatalf("expected export modal to remain closed while stream filter modal handles typing")
}
- if !strings.Contains(m.View().Content, "syscall~ope") {
- t.Fatalf("expected typed syscall filter to be applied")
+ if m.globalFilter.Syscall == nil || m.globalFilter.Syscall.Pattern != "ope" {
+ t.Fatalf("expected typed syscall filter to be stored globally, got %+v", m.globalFilter.Syscall)
}
}
@@ -1004,6 +1004,60 @@ func TestHelpOverlayCanOpenFromPIDPicker(t *testing.T) {
}
}
+func TestGlobalFilterModalOpensFromDashboardShortcut(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})})
+ m = next.(Model)
+ if !m.filterModal.Visible() {
+ t.Fatalf("expected global filter modal to open on f")
+ }
+}
+
+func TestQuitClosesGlobalFilterModalWithoutQuitting(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.filterModal = m.filterModal.Open(m.globalFilter)
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})})
+ m = next.(Model)
+ if cmd != nil {
+ t.Fatalf("expected no quit command while closing filter modal")
+ }
+ if m.filterModal.Visible() {
+ t.Fatalf("expected q to close global filter modal")
+ }
+ if m.quitting {
+ t.Fatalf("expected q in filter modal not to set quitting state")
+ }
+}
+
+func TestGlobalFilterModalUpdatesStoredFilterState(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})})
+ m = next.(Model)
+ if !m.filterModal.Visible() {
+ t.Fatalf("expected global filter modal to open")
+ }
+
+ 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)
+
+ if m.filterModal.Visible() {
+ t.Fatalf("expected global filter modal to close after esc")
+ }
+ if m.globalFilter.Syscall == nil || m.globalFilter.Syscall.Pattern != "read" {
+ t.Fatalf("expected stored global filter updated from modal, got %+v", m.globalFilter.Syscall)
+ }
+}
+
func TestQuestionMarkDoesNotBlockUnderlyingActions(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
m.screen = ScreenDashboard