summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 22:12:40 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 22:12:40 +0200
commitb8cbe6878e7313589f44ee6922593cdb8825421b (patch)
tree13d61da56620a649474ddc9f6b0c576f03e86419 /internal/tui
parent0d1492291a3e20665d8a3a6b16d2eb4e13938cee (diff)
tui: add process-tab enter filter push
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/dashboard/model.go108
-rw-r--r--internal/tui/dashboard/model_test.go28
-rw-r--r--internal/tui/dashboard/processes.go2
-rw-r--r--internal/tui/tui_test.go47
4 files changed, 184 insertions, 1 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 22d09c3..1ffb6e8 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -1,6 +1,8 @@
package dashboard
import (
+ "fmt"
+ "slices"
"strings"
"time"
@@ -261,6 +263,9 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
cmd = streamTickCmd()
}
if !handled {
+ handled, cmd = m.handleEnterKey(msg)
+ }
+ if !handled {
handled, cmd = m.handleShortcutKey(msg)
}
if !handled {
@@ -269,6 +274,22 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, m.postKeyTransitionCmd(prevActiveTab, cmd)
}
+func (m Model) handleEnterKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ if !key.Matches(msg, m.keys.Enter) {
+ return false, nil
+ }
+ switch m.activeTab {
+ case TabProcesses:
+ filter, action, ok := m.selectedProcessFilter()
+ if !ok {
+ return false, nil
+ }
+ return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} }
+ default:
+ return false, nil
+ }
+}
+
func (m Model) handleHelpToggleKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
if msg.String() != "H" {
return false, m, nil
@@ -353,6 +374,93 @@ func (m Model) handleUnhandledKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, flameCmd
}
+func (m Model) selectedProcessFilter() (globalfilter.Filter, string, bool) {
+ proc, ok := m.selectedProcessSnapshot()
+ if !ok || proc.PID == 0 {
+ return globalfilter.Filter{}, "", false
+ }
+ filter := m.globalFilter.Clone()
+ filter.PID = &globalfilter.NumericFilter{Op: globalfilter.OpEq, Value: int64(proc.PID)}
+ action := fmt.Sprintf("pid=%d", proc.PID)
+ return filter, action, true
+}
+
+func (m Model) selectedProcessSnapshot() (statsengine.ProcessSnapshot, bool) {
+ if m.latest == nil {
+ return statsengine.ProcessSnapshot{}, false
+ }
+ rows := m.latest.Processes()
+ if len(rows) == 0 {
+ return statsengine.ProcessSnapshot{}, false
+ }
+
+ switch {
+ case m.processesVizMode == tabVizModeTreemap:
+ return indexedProcessSnapshot(sortedProcessSnapshots(rows, m.processesChart.Metric(), maxSyscallTreemapItems), m.processesOffset)
+ case m.processesVizMode == tabVizModeBubbles:
+ return indexedProcessSnapshot(sortedProcessSnapshots(rows, m.processesChart.Metric(), bubbleMaxItems), m.processesChart.selected)
+ default:
+ return indexedProcessSnapshot(rows, m.processesOffset)
+ }
+}
+
+func indexedProcessSnapshot(rows []statsengine.ProcessSnapshot, index int) (statsengine.ProcessSnapshot, bool) {
+ if len(rows) == 0 {
+ return statsengine.ProcessSnapshot{}, false
+ }
+ index = clampOffset(index, len(rows))
+ if index < 0 || index >= len(rows) {
+ return statsengine.ProcessSnapshot{}, false
+ }
+ return rows[index], true
+}
+
+func sortedProcessSnapshots(rows []statsengine.ProcessSnapshot, metric bubbleMetric, limit int) []statsengine.ProcessSnapshot {
+ if len(rows) == 0 {
+ return nil
+ }
+ sorted := slices.Clone(rows)
+ slices.SortFunc(sorted, func(left, right statsengine.ProcessSnapshot) int {
+ lv := processMetricValue(left, metric)
+ rv := processMetricValue(right, metric)
+ switch {
+ case lv > rv:
+ return -1
+ case lv < rv:
+ return 1
+ }
+ llabel := processSelectionLabel(left)
+ rlabel := processSelectionLabel(right)
+ switch {
+ case llabel < rlabel:
+ return -1
+ case llabel > rlabel:
+ return 1
+ default:
+ return 0
+ }
+ })
+ if limit > 0 && len(sorted) > limit {
+ sorted = sorted[:limit]
+ }
+ return sorted
+}
+
+func processMetricValue(proc statsengine.ProcessSnapshot, metric bubbleMetric) uint64 {
+ if metric == bubbleMetricBytes {
+ return proc.Bytes
+ }
+ return proc.Syscalls
+}
+
+func processSelectionLabel(proc statsengine.ProcessSnapshot) string {
+ label := fmt.Sprintf("%d", proc.PID)
+ if comm := strings.TrimSpace(proc.Comm); comm != "" {
+ label = fmt.Sprintf("%d:%s", proc.PID, comm)
+ }
+ return label
+}
+
func (m Model) postKeyTransitionCmd(prevActiveTab Tab, cmd tea.Cmd) tea.Cmd {
cmds := make([]tea.Cmd, 0, 4)
cmds = append(cmds, cmd)
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index f01a701..cfeb5c8 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -142,6 +142,34 @@ func TestProcessesTabScrollsWithJK(t *testing.T) {
}
}
+func TestProcessesTabEnterEmitsGlobalFilterRequest(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabProcesses
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{
+ {PID: 111, Comm: "alpha", Syscalls: 9},
+ {PID: 222, Comm: "beta", Syscalls: 4},
+ }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.processesOffset = 1
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected enter on processes tab to emit a filter request")
+ }
+ msg := cmd()
+ req, ok := msg.(messages.GlobalFilterRequestedMsg)
+ if !ok {
+ t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg)
+ }
+ if req.Filter.PID == nil || req.Filter.PID.Value != 222 {
+ t.Fatalf("expected pid=222 filter, got %+v", req.Filter.PID)
+ }
+ if req.Action != "pid=222" {
+ t.Fatalf("expected action pid=222, got %q", req.Action)
+ }
+}
+
func TestFilesTabScrollsWithJK(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabFiles
diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go
index 9988ea4..b71b22c 100644
--- a/internal/tui/dashboard/processes.go
+++ b/internal/tui/dashboard/processes.go
@@ -43,7 +43,7 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset
cursor := clampOffset(offset, len(rows))
tbl.SetCursor(cursor)
- out := tbl.View() + fmt.Sprintf("\nRow %d/%d [v:mode] [b:metric]", cursor+1, len(rows))
+ out := tbl.View() + fmt.Sprintf("\nRow %d/%d [enter:filter] [v:mode] [b:metric]", cursor+1, len(rows))
if pidFilter > 0 {
out += "\n" + "Note: this tab is most useful with All PIDs."
}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index ab8b5db..2d5419a 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -1363,6 +1363,53 @@ func TestDashboardFooterShowsGlobalFilterStack(t *testing.T) {
}
}
+func TestProcessesTabEnterAppliesSelectedProcessAsGlobalFilter(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 }
+
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{
+ {PID: 111, Comm: "alpha", Syscalls: 9},
+ {PID: 222, Comm: "beta", Syscalls: 4},
+ }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+
+ next, _ := m.Update(messages.StatsTickMsg{Snap: &snap})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'5'}[0], Text: string([]rune{'5'})})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})})
+ m = next.(Model)
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected enter on processes tab to emit a filter request")
+ }
+
+ next, cmd = m.Update(cmd())
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected selected process filter to restart tracing")
+ }
+ if m.globalFilter.PID == nil || m.globalFilter.PID.Value != 222 {
+ t.Fatalf("expected selected process pid applied globally, got %+v", m.globalFilter.PID)
+ }
+ if len(m.filterStack) != 1 || m.filterStack[0] != "pid=222" {
+ t.Fatalf("expected pid filter pushed to stack, got %+v", m.filterStack)
+ }
+ if !stopped {
+ t.Fatalf("expected selected process filter to stop the active trace")
+ }
+ if !m.attaching {
+ t.Fatalf("expected selected process filter to restart tracing")
+ }
+}
+
func TestGlobalFilterApplyPreservesActiveDashboardTab(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
m.screen = ScreenDashboard