summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 23:14:09 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 23:14:09 +0200
commit106fcb3fe959966dec19d1242ff87df644a43fad (patch)
tree5152e1d4dadbf991040d0db069c8d76db889364d
parent013e46d7856a604d4890a880b8bbfb4b8c58202b (diff)
fix(tui): restore bubble modes and stabilize flame zoom lineage
-rw-r--r--internal/tui/dashboard/model.go6
-rw-r--r--internal/tui/dashboard/model_test.go12
-rw-r--r--internal/tui/flamegraph/controls.go2
-rw-r--r--internal/tui/flamegraph/model.go60
-rw-r--r--internal/tui/flamegraph/model_test.go108
-rw-r--r--internal/tui/tui.go50
-rw-r--r--internal/tui/tui_test.go82
7 files changed, 277 insertions, 43 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index fb509b0..5949755 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -728,12 +728,12 @@ func (m *Model) setTabVizMode(tab Tab, mode tabVizMode) {
func (m Model) allowedVizModes(tab Tab) []tabVizMode {
switch tab {
case TabSyscalls:
- return []tabVizMode{tabVizModeTable, tabVizModeTreemap}
+ return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap}
case TabProcesses:
- return []tabVizMode{tabVizModeTable, tabVizModeTreemap}
+ return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap}
case TabFiles:
if m.filesDirGrouped {
- return []tabVizMode{tabVizModeTable, tabVizModeTreemap}
+ return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap}
}
return []tabVizMode{tabVizModeTable}
default:
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 2e1ca17..8b03b2b 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -353,6 +353,12 @@ func TestVisualizationCycleForSyscallsTab(t *testing.T) {
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model := next.(Model)
+ if got := model.syscallsVizMode; got != tabVizModeBubbles {
+ t.Fatalf("expected syscalls bubbles mode enabled")
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
+ model = next.(Model)
if got := model.syscallsVizMode; got != tabVizModeTreemap {
t.Fatalf("expected syscalls treemap mode enabled")
}
@@ -419,6 +425,12 @@ func TestFilesTreemapRequiresDirectoryMode(t *testing.T) {
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model = next.(Model)
+ if got := model.filesVizMode; got != tabVizModeBubbles {
+ t.Fatalf("expected files bubbles mode enabled in directory mode")
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
+ model = next.(Model)
if got := model.filesVizMode; got != tabVizModeTreemap {
t.Fatalf("expected files treemap mode enabled in directory mode")
}
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
index e69d845..37b030a 100644
--- a/internal/tui/flamegraph/controls.go
+++ b/internal/tui/flamegraph/controls.go
@@ -89,7 +89,7 @@ func (m Model) toolbarLine() string {
if m.statusMessage != "" {
line += " | " + m.statusMessage
}
- if m.lastKeyDebug != "" {
+ if flameKeyDebugEnabled && m.lastKeyDebug != "" {
line += " | " + m.lastKeyDebug
}
width := m.width
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index 1d01f66..9c7bac5 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -26,6 +26,7 @@ type snapshotNode struct {
type animTickMsg struct{}
const animFrameDuration = 33 * time.Millisecond
+const flameKeyDebugEnabled = false
// LiveTrieSource is the minimal trie contract needed by the flamegraph TUI model.
type LiveTrieSource interface {
@@ -467,7 +468,17 @@ func (m *Model) rebuildFrames(animate bool) {
} else {
root = m.snapshot
}
- targetFrames := buildTerminalLayoutWithPath(root, m.width, m.height, rootPath)
+ layoutWidth := m.width
+ if m.zoomPath != "" {
+ fallbackLineWidth := m.zoomLineWidth
+ if fallbackLineWidth <= 0 {
+ fallbackLineWidth = layoutWidth
+ }
+ if _, gutter, ok := m.zoomLineageGeometry(fallbackLineWidth); ok {
+ layoutWidth = m.width - gutter
+ }
+ }
+ targetFrames := buildTerminalLayoutWithPath(root, layoutWidth, m.height, rootPath)
if m.zoomPath != "" {
targetFrames = m.withZoomLineage(targetFrames)
}
@@ -544,7 +555,7 @@ func (m *Model) zoomIn() {
m.zoomRoot = target
m.zoomPath = selectedPath
m.zoomLineWidth = selectedWidth
- m.rebuildFrames(true)
+ m.rebuildFrames(false)
m.statusMessage = "Zoom: " + compactFramePath(selectedPath)
}
@@ -564,7 +575,7 @@ func (m *Model) zoomUndo() {
m.zoomLineWidth = last.lineWidth
}
m.selectedIdx = last.previousSelectedIdx
- m.rebuildFrames(true)
+ m.rebuildFrames(false)
if m.zoomPath == "" {
m.statusMessage = "Zoom: root"
return
@@ -851,6 +862,9 @@ func (m *Model) ensureSelectionNavigable() {
}
func (m *Model) recordKeyDebug(msg tea.KeyPressMsg, handled, moved bool) {
+ if !flameKeyDebugEnabled {
+ return
+ }
keyID := keyString(msg)
if keyID == "" {
keyID = fmt.Sprintf("code:%d", msg.Code)
@@ -1137,6 +1151,28 @@ func (m Model) frameIndexAt(x, y int) int {
return best
}
+func (m Model) zoomLineageGeometry(fallbackLineWidth int) (lineWidth, gutter int, ok bool) {
+ if m.zoomPath == "" || m.width <= 0 {
+ return 0, 0, false
+ }
+ lineWidth = m.zoomLineWidth
+ if lineWidth <= 0 {
+ lineWidth = fallbackLineWidth
+ }
+ if lineWidth <= 0 {
+ lineWidth = m.width / 4
+ }
+ lineWidth = min(max(lineWidth, 3), max(3, m.width/3))
+ if lineWidth >= m.width-2 {
+ return 0, 0, false
+ }
+ gutter = lineWidth + 1
+ if m.width-gutter < minFlameWidth/2 {
+ return 0, 0, false
+ }
+ return lineWidth, gutter, true
+}
+
func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame {
if len(frames) == 0 || m.snapshot == nil {
return frames
@@ -1146,16 +1182,16 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame {
return frames
}
- lineWidth := m.zoomLineWidth
- if lineWidth <= 0 {
- lineWidth = frames[0].Width
+ fallbackLineWidth := 0
+ if len(frames) > 0 {
+ fallbackLineWidth = frames[0].Width
}
- lineWidth = min(max(lineWidth, 3), max(3, m.width/3))
- if lineWidth >= m.width-2 {
+ _, gutter, ok := m.zoomLineageGeometry(fallbackLineWidth)
+ if !ok {
return frames
}
- gutter := lineWidth + 1
- if m.width-gutter < minFlameWidth/2 {
+ lineageWidth := m.width - gutter
+ if lineageWidth < 1 {
return frames
}
@@ -1186,9 +1222,9 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame {
name := parts[depth]
out = append(out, tuiFrame{
Name: name,
- Col: 0,
+ Col: gutter,
Row: depth,
- Width: lineWidth,
+ Width: lineageWidth,
Total: total,
Percent: percent,
Fill: terminalFrameColor(name),
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index bbd2005..59130ec 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -276,7 +276,7 @@ func TestMouseClickOutsideBarsDoesNotChangeSelectionOrZoom(t *testing.T) {
}
}
-func TestZoomKeepsNarrowLineageRail(t *testing.T) {
+func TestZoomLineageSpansZoomViewportAndAlignsAtGutter(t *testing.T) {
m := newZoomModel()
targetPath := "root" + pathSeparator + "A"
targetIdx := mustFrameIndex(t, m.frames, targetPath)
@@ -289,14 +289,77 @@ func TestZoomKeepsNarrowLineageRail(t *testing.T) {
rootIdx := mustFrameIndex(t, m.frames, "root")
zoomIdx := mustFrameIndex(t, m.frames, targetPath)
- if m.frames[rootIdx].Width != expectedRailWidth {
- t.Fatalf("expected root lineage width %d, got %d", expectedRailWidth, m.frames[rootIdx].Width)
+ _, gutter, ok := m.zoomLineageGeometry(expectedRailWidth)
+ if !ok {
+ t.Fatalf("expected lineage geometry to be enabled for zoomed view")
+ }
+ expectedLineageWidth := m.width - gutter
+ if m.frames[rootIdx].Width != expectedLineageWidth {
+ t.Fatalf("expected root lineage width %d, got %d", expectedLineageWidth, m.frames[rootIdx].Width)
+ }
+ if m.frames[zoomIdx].Width != expectedLineageWidth {
+ t.Fatalf("expected zoom lineage width %d, got %d", expectedLineageWidth, m.frames[zoomIdx].Width)
+ }
+ if m.frames[rootIdx].Col != gutter || m.frames[zoomIdx].Col != gutter {
+ t.Fatalf("expected lineage rail at column %d, got root=%d zoom=%d", gutter, m.frames[rootIdx].Col, m.frames[zoomIdx].Col)
+ }
+}
+
+func TestZoomLineageKeepsAllFramesWithinViewportWidth(t *testing.T) {
+ m := newZoomModel()
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = settleFlameAnimation(t, m)
+
+ for _, frame := range m.frames {
+ if frame.Col+frame.Width > m.width {
+ t.Fatalf("frame exceeds viewport width %d: %+v", m.width, frame)
+ }
+ }
+}
+
+func TestZoomLineageAlignsWithZoomedSubtreeColumn(t *testing.T) {
+ m := newZoomModel()
+ rootPath := "root" + pathSeparator + "A"
+ childPath := rootPath + pathSeparator + "A1"
+ m.selectedIdx = mustFrameIndex(t, m.frames, rootPath)
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = settleFlameAnimation(t, m)
+
+ rootIdx := mustFrameIndex(t, m.frames, rootPath)
+ childIdx := mustFrameIndex(t, m.frames, childPath)
+ _, gutter, ok := m.zoomLineageGeometry(m.zoomLineWidth)
+ if !ok {
+ t.Fatalf("expected lineage geometry to be enabled")
+ }
+ if got := m.frames[rootIdx].Col; got != gutter {
+ t.Fatalf("expected zoom lineage root column %d, got %d", gutter, got)
}
- if m.frames[zoomIdx].Width != expectedRailWidth {
- t.Fatalf("expected zoom lineage width %d, got %d", expectedRailWidth, m.frames[zoomIdx].Width)
+ if got := m.frames[childIdx].Col; got != gutter {
+ t.Fatalf("expected first child column to align at %d, got %d", gutter, got)
}
- if m.frames[rootIdx].Col != 0 || m.frames[zoomIdx].Col != 0 {
- t.Fatalf("expected lineage rail at column 0, got root=%d zoom=%d", m.frames[rootIdx].Col, m.frames[zoomIdx].Col)
+}
+
+func TestZoomLineageParentsAreNeverNarrowerThanChildren(t *testing.T) {
+ m := newZoomModel()
+ rootPath := "root" + pathSeparator + "A"
+ m.selectedIdx = mustFrameIndex(t, m.frames, rootPath)
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = settleFlameAnimation(t, m)
+
+ for _, frame := range m.frames {
+ parentPath := parentFramePath(frame.Path)
+ if parentPath == "" {
+ continue
+ }
+ parentIdx := m.frameIndexByPath(parentPath)
+ if parentIdx < 0 {
+ continue
+ }
+ parent := m.frames[parentIdx]
+ if parent.Width < frame.Width {
+ t.Fatalf("expected parent %q width %d >= child %q width %d", parent.Path, parent.Width, frame.Path, frame.Width)
+ }
}
}
@@ -587,32 +650,19 @@ func TestZoomInOnCurrentRootSetsStatusMessage(t *testing.T) {
}
}
-func TestZoomTransitionAnimatesToNewLayout(t *testing.T) {
+func TestZoomTransitionAppliesNewLayoutImmediately(t *testing.T) {
m := newZoomModel()
pathA := "root" + pathSeparator + "A"
- preWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width
m.selectedIdx = mustFrameIndex(t, m.frames, pathA)
m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
- if !m.animating {
- t.Fatalf("expected zoom-in to start animation")
+ if m.animating {
+ t.Fatalf("expected zoom-in layout update to apply immediately")
}
currentWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width
targetWidth := m.targetFrames[mustFrameIndex(t, m.targetFrames, pathA)].Width
- if currentWidth == targetWidth {
- t.Fatalf("expected intermediate zoom frame width to differ from target (current=%d target=%d, pre=%d)", currentWidth, targetWidth, preWidth)
- }
-
- for i := 0; i < 180 && m.animating; i++ {
- next, _ := m.Update(animTickMsg{})
- m = next.(Model)
- }
- if m.animating {
- t.Fatalf("expected zoom animation to settle within 180 ticks")
- }
- finalWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width
- if finalWidth != targetWidth {
- t.Fatalf("expected final zoom width %d, got %d", targetWidth, finalWidth)
+ if currentWidth != targetWidth {
+ t.Fatalf("expected zoom width %d after immediate layout update, got %d", targetWidth, currentWidth)
}
}
@@ -1067,6 +1117,14 @@ func mustFrameIndex(t *testing.T, frames []tuiFrame, path string) int {
return -1
}
+func parentFramePath(path string) string {
+ lastSep := strings.LastIndex(path, pathSeparator)
+ if lastSep <= 0 {
+ return ""
+ }
+ return path[:lastSep]
+}
+
func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model {
t.Helper()
next, _ := m.Update(keyMsg)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index deb6150..c375057 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -230,6 +230,7 @@ type Model struct {
pidFilter int
tidFilter int
+ pickerReturn *pickerReturnState
exportEnabled bool
isDark bool
focused bool
@@ -247,6 +248,11 @@ type Model struct {
suppressPressUntil time.Time
}
+type pickerReturnState struct {
+ pidFilter int
+ tidFilter int
+}
+
// NewModel creates the top-level TUI model.
func NewModel(initialPID int, startTrace TraceStarter) Model {
return NewModelWithConfig(flags.Get(), initialPID, startTrace)
@@ -429,6 +435,12 @@ func (m Model) canQuitFromMainDashboard(msg tea.KeyPressMsg) bool {
!m.dashboard.BlocksGlobalShortcuts(msg)
}
+func (m Model) shouldCancelPickerToDashboard(msg tea.KeyPressMsg) bool {
+ return m.screen == ScreenPIDPicker &&
+ m.pickerReturn != nil &&
+ (isEscKey(msg) || key.Matches(msg, m.keys.Quit))
+}
+
func (m Model) shouldRouteQuitToEsc(msg tea.KeyPressMsg) bool {
if m.helpOverlayVisible {
return false
@@ -444,6 +456,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
}
return m, nil, true
}
+ if m.shouldCancelPickerToDashboard(msg) {
+ next, cmd := m.cancelPickerToDashboard()
+ return next, cmd, true
+ }
if key.Matches(msg, m.keys.Quit) {
if m.canQuitFromMainDashboard(msg) {
m.quitting = true
@@ -638,6 +654,7 @@ func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) {
m.stopTrace()
m.pidFilter = pid
m.tidFilter = -1
+ m.pickerReturn = nil
m.dashboard.SetPidFilter(pid)
m.screen = ScreenDashboard
m.attaching = true
@@ -654,6 +671,7 @@ func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) {
m.stopTrace()
m.pidFilter = pid
m.tidFilter = tid
+ m.pickerReturn = nil
m.dashboard.SetPidFilter(pid)
m.screen = ScreenDashboard
m.attaching = true
@@ -662,6 +680,10 @@ func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) {
}
func (m Model) reselectPID() (tea.Model, tea.Cmd) {
+ m.pickerReturn = &pickerReturnState{
+ pidFilter: m.pidFilter,
+ tidFilter: m.tidFilter,
+ }
m.stopTrace()
m.screen = ScreenPIDPicker
m.attaching = false
@@ -683,6 +705,10 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) {
func (m Model) reselectTID() (tea.Model, tea.Cmd) {
pid := m.pidFilter
+ m.pickerReturn = &pickerReturnState{
+ pidFilter: m.pidFilter,
+ tidFilter: m.tidFilter,
+ }
m.stopTrace()
m.screen = ScreenPIDPicker
m.attaching = false
@@ -708,6 +734,22 @@ func selectedPIDFilter(pid int) int {
return pid
}
+func (m Model) cancelPickerToDashboard() (tea.Model, tea.Cmd) {
+ if m.pickerReturn == nil {
+ return m, nil
+ }
+ returnState := *m.pickerReturn
+ m.pickerReturn = nil
+ m.stopTrace()
+ m.pidFilter = returnState.pidFilter
+ m.tidFilter = returnState.tidFilter
+ m.dashboard.SetPidFilter(m.pidFilter)
+ m.screen = ScreenDashboard
+ m.attaching = true
+ m.lastErr = nil
+ return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
+}
+
func (m *Model) beginTraceCmd() tea.Cmd {
ctx, cancel := context.WithCancel(context.Background())
m.traceStop = cancel
@@ -810,8 +852,12 @@ func isHelpOverlayOpenKey(msg tea.KeyPressMsg) bool {
return msg.String() == "H"
}
+func isEscKey(msg tea.KeyPressMsg) bool {
+ return msg.Code == tea.KeyEsc || msg.String() == "esc"
+}
+
func isHelpOverlayCloseKey(msg tea.KeyPressMsg) bool {
- return msg.Code == tea.KeyEsc || msg.String() == "esc" || msg.String() == "?"
+ return isEscKey(msg) || msg.String() == "?"
}
func isHelpOverlayQuitKey(msg tea.KeyPressMsg) bool {
@@ -930,7 +976,7 @@ func (m Model) helpSections() []helpSection {
{
title: "PID/TID Picker",
lines: []string{
- "enter select r refresh esc back",
+ "enter select r refresh esc/q back",
},
},
}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index e4bdbff..82fe9da 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -209,6 +209,88 @@ func TestQuitKeyDoesNotExitOnPIDPickerScreen(t *testing.T) {
}
}
+func TestQuitKeyOnReselectPIDPickerReturnsToDashboardLikeEsc(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+ m.pidFilter = 1111
+ m.tidFilter = 2222
+ m.dashboard.SetPidFilter(1111)
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
+ m = next.(Model)
+
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})})
+ m = next.(Model)
+ if m.screen != ScreenPIDPicker {
+ t.Fatalf("expected pid picker screen after reselect, got %v", m.screen)
+ }
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})})
+ updated := next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected q in reselect picker to return to dashboard and restart tracing")
+ }
+ if updated.screen != ScreenDashboard {
+ t.Fatalf("expected dashboard screen after q cancel, got %v", updated.screen)
+ }
+ if !updated.attaching {
+ t.Fatalf("expected attaching=true after q cancel")
+ }
+ if updated.quitting {
+ t.Fatalf("expected q in reselect picker to behave like esc, not quit")
+ }
+ if updated.pickerReturn != nil {
+ t.Fatalf("expected picker return context to clear after cancel")
+ }
+ if updated.pidFilter != 1111 || updated.tidFilter != 2222 {
+ t.Fatalf("expected previous pid/tid filters restored, got pid=%d tid=%d", updated.pidFilter, updated.tidFilter)
+ }
+}
+
+func TestEscOnReselectPIDPickerReturnsToDashboard(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+ m.pidFilter = 3333
+ m.tidFilter = 4444
+ m.dashboard.SetPidFilter(3333)
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
+ m = next.(Model)
+
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})})
+ m = next.(Model)
+ if m.screen != ScreenPIDPicker {
+ t.Fatalf("expected pid picker screen after reselect, got %v", m.screen)
+ }
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+ updated := next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected esc in reselect picker to return to dashboard and restart tracing")
+ }
+ if updated.screen != ScreenDashboard {
+ t.Fatalf("expected dashboard screen after esc cancel, got %v", updated.screen)
+ }
+ if !updated.attaching {
+ t.Fatalf("expected attaching=true after esc cancel")
+ }
+ if updated.quitting {
+ t.Fatalf("expected esc in reselect picker not to quit app")
+ }
+ if updated.pickerReturn != nil {
+ t.Fatalf("expected picker return context to clear after cancel")
+ }
+ if updated.pidFilter != 3333 || updated.tidFilter != 4444 {
+ t.Fatalf("expected previous pid/tid filters restored, got pid=%d tid=%d", updated.pidFilter, updated.tidFilter)
+ }
+}
+
func TestQuitKeyClosesProbeModalLikeEsc(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
m.screen = ScreenDashboard