summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 19:35:08 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 19:35:08 +0200
commit013e46d7856a604d4890a880b8bbfb4b8c58202b (patch)
treef8b100ccd04a30b212f0fe728c91736087c60fc1
parent0b2d40cf7ff9b26bfd020488b537bdfdd6f852ae (diff)
feat(tui): add flamegraph click lineage undo and scope quit key
-rw-r--r--internal/tui/dashboard/model.go25
-rw-r--r--internal/tui/dashboard/model_test.go23
-rw-r--r--internal/tui/flamegraph/controls.go5
-rw-r--r--internal/tui/flamegraph/model.go185
-rw-r--r--internal/tui/flamegraph/model_test.go122
-rw-r--r--internal/tui/tui.go43
-rw-r--r--internal/tui/tui_test.go92
7 files changed, 482 insertions, 13 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index aae98b1..fb509b0 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -214,7 +214,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
if m.activeTab == TabFlame {
- next, cmd := m.flamegraphModel.Update(msg)
+ next, cmd := m.flamegraphModel.Update(translateFlamegraphMsg(msg))
m.flamegraphModel = next.(flamegraphtui.Model)
return m, cmd
}
@@ -835,3 +835,26 @@ func flameViewport(width, height int, showHelp bool) (int, int) {
}
return width, height
}
+
+func translateFlamegraphMsg(msg tea.Msg) tea.Msg {
+ switch mouse := msg.(type) {
+ case tea.MouseClickMsg:
+ m := mouse.Mouse()
+ m.Y -= dashboardTabBarRows
+ return tea.MouseClickMsg(m)
+ case tea.MouseReleaseMsg:
+ m := mouse.Mouse()
+ m.Y -= dashboardTabBarRows
+ return tea.MouseReleaseMsg(m)
+ case tea.MouseMotionMsg:
+ m := mouse.Mouse()
+ m.Y -= dashboardTabBarRows
+ return tea.MouseMotionMsg(m)
+ case tea.MouseWheelMsg:
+ m := mouse.Mouse()
+ m.Y -= dashboardTabBarRows
+ return tea.MouseWheelMsg(m)
+ default:
+ return msg
+ }
+}
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index fafcad3..2e1ca17 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -810,3 +810,26 @@ func TestHelpToggleWithH(t *testing.T) {
t.Fatalf("expected help hint after pressing h again")
}
}
+
+func TestTranslateFlamegraphMouseMsgOffsetsTabBarRow(t *testing.T) {
+ translated := translateFlamegraphMsg(tea.MouseClickMsg{
+ X: 17,
+ Y: 9,
+ Button: tea.MouseLeft,
+ })
+ click, ok := translated.(tea.MouseClickMsg)
+ if !ok {
+ t.Fatalf("expected translated message to stay mouse click, got %T", translated)
+ }
+ if click.X != 17 || click.Y != 8 {
+ t.Fatalf("expected click coordinates (17,8), got (%d,%d)", click.X, click.Y)
+ }
+}
+
+func TestTranslateFlamegraphMsgLeavesNonMouseUnchanged(t *testing.T) {
+ msg := messages.StatsTickMsg{}
+ translated := translateFlamegraphMsg(msg)
+ if _, ok := translated.(messages.StatsTickMsg); !ok {
+ t.Fatalf("expected non-mouse message to remain unchanged, got %T", translated)
+ }
+}
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
index 06e6d0d..e69d845 100644
--- a/internal/tui/flamegraph/controls.go
+++ b/internal/tui/flamegraph/controls.go
@@ -17,6 +17,7 @@ func (m *Model) clearSnapshotState(clearSearch bool) {
m.zoomRoot = nil
m.zoomPath = ""
m.zoomStack = nil
+ m.zoomLineWidth = 0
m.selectedIdx = 0
m.snapshot = nil
m.globalTotal = 0
@@ -81,7 +82,7 @@ func (m Model) toolbarLine() string {
state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]")
}
order := m.currentFieldPresetLabel()
- line := fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter:zoom | u/esc:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel())
+ line := fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter/click:zoom | click ancestor:undo | u/esc:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel())
if m.searchQuery != "" {
line += " | filter:" + m.searchQuery
}
@@ -103,7 +104,7 @@ func (m Model) helpOverlay() string {
if width <= 0 {
width = 80
}
- help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter zoom u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order b metric ? help"
+ help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter/click zoom click ancestor undo u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order b metric ? help"
return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width))
}
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index cc208ae..1d01f66 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -41,6 +41,7 @@ type LiveTrieSource interface {
type zoomState struct {
path string
previousSelectedIdx int
+ lineWidth int
}
type flameKeyMap struct {
@@ -81,10 +82,11 @@ type Model struct {
width int
height int
- selectedIdx int
- zoomStack []zoomState
- zoomRoot *snapshotNode
- zoomPath string
+ selectedIdx int
+ zoomStack []zoomState
+ zoomRoot *snapshotNode
+ zoomPath string
+ zoomLineWidth int
searchActive bool
searchInput textinput.Model
@@ -181,6 +183,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, animTickCmd()
}
return m, nil
+ case tea.MouseClickMsg:
+ _ = m.handleMouseClick(msg)
+ return m, nil
case tea.KeyPressMsg:
if m.searchActive {
handled := false
@@ -339,6 +344,7 @@ func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) {
m.zoomStack = nil
m.zoomRoot = nil
m.zoomPath = ""
+ m.zoomLineWidth = 0
m.subtreeSet = make(map[int]bool)
m.filterVisible = make(map[int]bool)
m.animation = NewAnimationState(30, 6.0, 1.0)
@@ -461,7 +467,11 @@ func (m *Model) rebuildFrames(animate bool) {
} else {
root = m.snapshot
}
- m.targetFrames = buildTerminalLayoutWithPath(root, m.width, m.height, rootPath)
+ targetFrames := buildTerminalLayoutWithPath(root, m.width, m.height, rootPath)
+ if m.zoomPath != "" {
+ targetFrames = m.withZoomLineage(targetFrames)
+ }
+ m.targetFrames = targetFrames
m.animation.SetTargets(m.targetFrames)
if animate && len(m.frames) > 0 && !m.animation.Settled() {
m.animating = true
@@ -522,13 +532,18 @@ func (m *Model) zoomIn() {
m.statusMessage = "Zoom failed: selected node is unavailable"
return
}
+ selectedWidth := m.frames[m.selectedIdx].Width
+ if selectedWidth < 1 {
+ selectedWidth = 1
+ }
m.zoomStack = append(m.zoomStack, zoomState{
path: m.zoomPath,
previousSelectedIdx: m.selectedIdx,
+ lineWidth: m.zoomLineWidth,
})
m.zoomRoot = target
m.zoomPath = selectedPath
- m.selectedIdx = 0
+ m.zoomLineWidth = selectedWidth
m.rebuildFrames(true)
m.statusMessage = "Zoom: " + compactFramePath(selectedPath)
}
@@ -543,8 +558,10 @@ func (m *Model) zoomUndo() {
m.zoomPath = last.path
if m.zoomPath == "" {
m.zoomRoot = nil
+ m.zoomLineWidth = 0
} else {
m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath)
+ m.zoomLineWidth = last.lineWidth
}
m.selectedIdx = last.previousSelectedIdx
m.rebuildFrames(true)
@@ -563,6 +580,7 @@ func (m *Model) zoomReset() {
m.zoomRoot = nil
m.zoomPath = ""
m.zoomStack = nil
+ m.zoomLineWidth = 0
m.rebuildFrames(false)
m.statusMessage = "Zoom reset to root"
}
@@ -1025,3 +1043,158 @@ func (m *Model) ensureSelectionVisible() {
m.selectedIdx = bestIdx
}
}
+
+func (m *Model) handleMouseClick(msg tea.MouseClickMsg) bool {
+ if msg.Button != tea.MouseLeft {
+ return false
+ }
+ idx := m.frameIndexAt(msg.X, msg.Y)
+ if idx < 0 {
+ return false
+ }
+ clickedPath := m.frames[idx].Path
+ currentRoot := m.currentRootPath()
+ if m.zoomPath != "" && (clickedPath == currentRoot || hasPathBoundaryPrefix(currentRoot, clickedPath)) {
+ for steps := 0; steps < len(m.zoomStack)+1 && m.currentRootPath() != clickedPath; steps++ {
+ m.zoomUndo()
+ }
+ if sel := m.frameIndexByPath(clickedPath); sel >= 0 {
+ m.selectedIdx = sel
+ }
+ m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ return true
+ }
+ m.selectedIdx = idx
+ m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ m.zoomIn()
+ return true
+}
+
+func (m Model) frameIndexAt(x, y int) int {
+ if len(m.frames) == 0 || m.width <= 0 || m.height <= 0 {
+ return -1
+ }
+ if x < 0 || x >= m.width || y < 0 {
+ return -1
+ }
+
+ extraLines := 1 // selection status line
+ if m.showHelp {
+ extraLines++
+ }
+ renderHeight := m.height - extraLines
+ if renderHeight < 3 {
+ renderHeight = 3
+ }
+ availableRows := renderHeight - 2 // flame toolbar + frame-status line
+ if availableRows < 1 {
+ return -1
+ }
+
+ // Row 0 is flame toolbar, rows 1..availableRows are bars, last row is status.
+ if y < 1 || y > availableRows {
+ return -1
+ }
+ dataRow := y - 1
+
+ maxRow := maxFrameRowForSet(m.frames, nil)
+ barHeight := computeBarHeight(availableRows, maxRow+1, maxBarVisualHeight)
+ visibleDepthRows := availableRows / barHeight
+ if visibleDepthRows < 1 {
+ visibleDepthRows = 1
+ }
+ rowOffset := 0
+ if maxRow+1 > visibleDepthRows {
+ rowOffset = maxRow + 1 - visibleDepthRows
+ }
+ renderedRows := (maxRow - rowOffset + 1) * barHeight
+ padTop := 0
+ if renderedRows < availableRows {
+ padTop = availableRows - renderedRows
+ }
+ if dataRow < padTop {
+ return -1
+ }
+
+ depthFromTop := (dataRow - padTop) / barHeight
+ targetRow := maxRow - depthFromTop
+
+ best := -1
+ bestWidth := int(^uint(0) >> 1)
+ for idx, frame := range m.frames {
+ if frame.Row != targetRow || frame.Col >= m.width {
+ continue
+ }
+ right := min(m.width, frame.Col+frame.Width)
+ if x < frame.Col || x >= right {
+ continue
+ }
+ if frame.Width < bestWidth {
+ best = idx
+ bestWidth = frame.Width
+ }
+ }
+ return best
+}
+
+func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame {
+ if len(frames) == 0 || m.snapshot == nil {
+ return frames
+ }
+ parts := strings.Split(m.zoomPath, pathSeparator)
+ if len(parts) <= 1 {
+ return frames
+ }
+
+ lineWidth := m.zoomLineWidth
+ if lineWidth <= 0 {
+ lineWidth = frames[0].Width
+ }
+ lineWidth = min(max(lineWidth, 3), max(3, m.width/3))
+ if lineWidth >= m.width-2 {
+ return frames
+ }
+ gutter := lineWidth + 1
+ if m.width-gutter < minFlameWidth/2 {
+ return frames
+ }
+
+ rowShift := len(parts) - 1
+ out := make([]tuiFrame, 0, len(frames)+len(parts))
+ for _, frame := range frames {
+ if frame.Path == m.zoomPath {
+ continue
+ }
+ frame.Col += gutter
+ frame.Row += rowShift
+ frame.Depth += rowShift
+ out = append(out, frame)
+ }
+
+ rootTotal := snapshotTotal(m.snapshot)
+ for depth := range parts {
+ path := strings.Join(parts[:depth+1], pathSeparator)
+ node := findNodeByPath(m.snapshot, path)
+ total := uint64(0)
+ if node != nil {
+ total = snapshotTotal(node)
+ }
+ percent := 0.0
+ if rootTotal > 0 {
+ percent = 100 * float64(total) / float64(rootTotal)
+ }
+ name := parts[depth]
+ out = append(out, tuiFrame{
+ Name: name,
+ Col: 0,
+ Row: depth,
+ Width: lineWidth,
+ Total: total,
+ Percent: percent,
+ Fill: terminalFrameColor(name),
+ Depth: depth,
+ Path: path,
+ })
+ }
+ return out
+}
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index 74ce8d9..bbd2005 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -239,6 +239,93 @@ func TestPausedStateStillAllowsNavigation(t *testing.T) {
}
}
+func TestMouseClickSelectsFrameAndZooms(t *testing.T) {
+ m := newZoomModel()
+ targetPath := "root" + pathSeparator + "A"
+ targetIdx := mustFrameIndex(t, m.frames, targetPath)
+
+ x, y, ok := firstClickablePointForFrame(m, targetIdx)
+ if !ok {
+ t.Fatalf("expected clickable point for %q", targetPath)
+ }
+
+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
+ m = next.(Model)
+
+ if got := m.zoomPath; got != targetPath {
+ t.Fatalf("expected mouse click to zoom into %q, got %q", targetPath, got)
+ }
+ if got := m.frames[m.selectedIdx].Path; got != targetPath {
+ t.Fatalf("expected clicked frame to remain selected after zoom, got %q", got)
+ }
+}
+
+func TestMouseClickOutsideBarsDoesNotChangeSelectionOrZoom(t *testing.T) {
+ m := newZoomModel()
+ beforeSelection := m.selectedIdx
+ beforeZoom := m.zoomPath
+
+ next, _ := m.Update(tea.MouseClickMsg{X: 1, Y: 0, Button: tea.MouseLeft}) // toolbar row
+ m = next.(Model)
+
+ if m.selectedIdx != beforeSelection {
+ t.Fatalf("expected toolbar click to preserve selection, got idx %d want %d", m.selectedIdx, beforeSelection)
+ }
+ if m.zoomPath != beforeZoom {
+ t.Fatalf("expected toolbar click to preserve zoom path, got %q want %q", m.zoomPath, beforeZoom)
+ }
+}
+
+func TestZoomKeepsNarrowLineageRail(t *testing.T) {
+ m := newZoomModel()
+ targetPath := "root" + pathSeparator + "A"
+ targetIdx := mustFrameIndex(t, m.frames, targetPath)
+ selectedWidth := m.frames[targetIdx].Width
+ expectedRailWidth := min(max(selectedWidth, 3), max(3, m.width/3))
+
+ m.selectedIdx = targetIdx
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = settleFlameAnimation(t, m)
+
+ 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)
+ }
+ if m.frames[zoomIdx].Width != expectedRailWidth {
+ t.Fatalf("expected zoom lineage width %d, got %d", expectedRailWidth, m.frames[zoomIdx].Width)
+ }
+ 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 TestMouseClickOnLineageAncestorUndoesToThatZoomLevel(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)
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = settleFlameAnimation(t, m)
+ if got, want := m.zoomPath, "root"+pathSeparator+"A"+pathSeparator+"A1"; got != want {
+ t.Fatalf("expected nested zoom path %q, got %q", want, got)
+ }
+
+ ancestorPath := "root" + pathSeparator + "A"
+ ancestorIdx := mustFrameIndex(t, m.frames, ancestorPath)
+ x, y, ok := firstClickablePointForFrame(m, ancestorIdx)
+ if !ok {
+ t.Fatalf("expected clickable lineage point for ancestor %q", ancestorPath)
+ }
+
+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
+ m = next.(Model)
+ if got := m.zoomPath; got != ancestorPath {
+ t.Fatalf("expected click on lineage ancestor to undo zoom to %q, got %q", ancestorPath, got)
+ }
+}
+
func TestStaticFixtureArrowTraversalVisitsAllFrames(t *testing.T) {
trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
coreflamegraph.SeedTestFlameData(trie)
@@ -985,3 +1072,38 @@ func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model {
next, _ := m.Update(keyMsg)
return next.(Model)
}
+
+func firstClickablePointForFrame(m Model, frameIdx int) (x, y int, ok bool) {
+ if frameIdx < 0 || frameIdx >= len(m.frames) {
+ return 0, 0, false
+ }
+ frame := m.frames[frameIdx]
+ left := frame.Col
+ right := min(m.width, frame.Col+frame.Width)
+ if left < 0 {
+ left = 0
+ }
+ if right <= left {
+ return 0, 0, false
+ }
+ for row := 0; row < m.height; row++ {
+ for col := left; col < right; col++ {
+ if m.frameIndexAt(col, row) == frameIdx {
+ return col, row, true
+ }
+ }
+ }
+ return 0, 0, false
+}
+
+func settleFlameAnimation(t *testing.T, m Model) Model {
+ t.Helper()
+ for i := 0; i < 240 && m.animating; i++ {
+ next, _ := m.Update(animTickMsg{})
+ m = next.(Model)
+ }
+ if m.animating {
+ t.Fatalf("expected flame animation to settle within 240 ticks")
+ }
+ return m
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 6b58ab3..deb6150 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -420,6 +420,23 @@ func (m Model) canHandleDashboardShortcut(msg tea.KeyPressMsg) bool {
!m.dashboard.BlocksGlobalShortcuts(msg)
}
+func (m Model) canQuitFromMainDashboard(msg tea.KeyPressMsg) bool {
+ return m.screen == ScreenDashboard &&
+ !m.attaching &&
+ m.lastErr == nil &&
+ !m.exporter.Visible() &&
+ !m.probeModal.Visible() &&
+ !m.dashboard.BlocksGlobalShortcuts(msg)
+}
+
+func (m Model) shouldRouteQuitToEsc(msg tea.KeyPressMsg) bool {
+ if m.helpOverlayVisible {
+ return false
+ }
+ return m.screen == ScreenDashboard &&
+ (m.exporter.Visible() || m.probeModal.Visible() || m.dashboard.BlocksGlobalShortcuts(msg))
+}
+
func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) {
if m.helpOverlayVisible {
if isHelpOverlayQuitKey(msg) || isHelpOverlayCloseKey(msg) || isHelpOverlayOpenKey(msg) {
@@ -428,9 +445,26 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
return m, nil, true
}
if key.Matches(msg, m.keys.Quit) {
- m.quitting = true
- m.stopTrace()
- return m, tea.Quit, true
+ if m.canQuitFromMainDashboard(msg) {
+ m.quitting = true
+ m.stopTrace()
+ return m, tea.Quit, true
+ }
+ if m.shouldRouteQuitToEsc(msg) {
+ esc := tea.KeyPressMsg{Code: tea.KeyEsc}
+ if m.probeModal.Visible() {
+ next, cmd := m.updateProbeModal(esc)
+ return next, cmd, true
+ }
+ if m.exporter.Visible() {
+ next, cmd := m.updateExportModal(esc)
+ return next, cmd, true
+ }
+ next, cmd := m.dashboard.Update(esc)
+ m.dashboard = next.(dashboardui.Model)
+ return m, cmd, true
+ }
+ return m, nil, true
}
if isHelpOverlayOpenKey(msg) && !m.attaching && m.lastErr == nil {
m.helpOverlayVisible = true
@@ -887,7 +921,7 @@ func (m Model) helpSections() []helpSection {
"sys/files/proc tables: j/k or up/down scroll",
"sys/proc: v bubbles b metric events/bytes",
"files: d dirs toggle v bubbles (dirs only) b metric",
- "flame: arrows/hjkl nav enter zoom u/bs/esc undo o order",
+ "flame: arrows/hjkl nav enter/click zoom click ancestor undo u/bs/esc undo o order",
"flame: / filter n/N match next/prev space/p pause b metric",
"stream: space pause f filter enter apply esc undo /? n/N",
"stream: j/k/pg scroll g/G top/tail h/l cols c x/X E open",
@@ -974,6 +1008,7 @@ func altScreenView(content, title string) tea.View {
view := tea.NewView(content)
view.AltScreen = true
view.ReportFocus = true
+ view.MouseMode = tea.MouseModeCellMotion
view.WindowTitle = title
view.KeyboardEnhancements.ReportEventTypes = true
return view
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 84d3632..e4bdbff 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -117,6 +117,8 @@ func TestViewShowsAttachingAndErrorStates(t *testing.T) {
func TestQuitKeySetsQuittingState(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})})
if cmd == nil {
@@ -135,6 +137,8 @@ func TestQuitKeySetsQuittingState(t *testing.T) {
func TestQuitKeyMatchesSingleBindingWithoutPanic(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
m.keys.Quit = key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "quit"))
+ m.screen = ScreenDashboard
+ m.attaching = false
_, _ = m.Update(tea.KeyPressMsg{Code: []rune{'z'}[0], Text: string([]rune{'z'})})
@@ -170,6 +174,8 @@ func TestStartTraceCmdEmitsErrorMsg(t *testing.T) {
func TestQuitInvokesTraceStop(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
done := make(chan struct{})
m.traceStop = func() {
close(done)
@@ -187,6 +193,85 @@ func TestQuitInvokesTraceStop(t *testing.T) {
}
}
+func TestQuitKeyDoesNotExitOnPIDPickerScreen(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ if m.screen != ScreenPIDPicker {
+ t.Fatalf("expected default screen to be PID picker")
+ }
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})})
+ updated := next.(Model)
+ if cmd != nil {
+ t.Fatalf("expected no quit command outside main dashboard")
+ }
+ if updated.quitting {
+ t.Fatalf("expected q outside main dashboard not to set quitting state")
+ }
+}
+
+func TestQuitKeyClosesProbeModalLikeEsc(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.probeModal = probes.NewModel(fakeProbeManager{
+ states: []probemanager.ProbeState{{Syscall: "read", Active: true}},
+ }).Open()
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})})
+ updated := next.(Model)
+ if cmd != nil {
+ _ = cmd()
+ }
+ if updated.probeModal.Visible() {
+ t.Fatalf("expected q to close probe modal like esc")
+ }
+ if updated.quitting {
+ t.Fatalf("expected q in probe modal not to quit app")
+ }
+}
+
+func TestQuitKeyClosesExportModalLikeEsc(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.exporter = m.exporter.Open()
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})})
+ updated := next.(Model)
+ if updated.exporter.Visible() {
+ t.Fatalf("expected q to close export modal like esc")
+ }
+ if updated.quitting {
+ t.Fatalf("expected q in export modal not to quit app")
+ }
+}
+
+func TestQuitKeyClosesFlameSearchLikeEsc(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{'/'}[0], Text: string([]rune{'/'})})
+ m = next.(Model)
+ if !strings.Contains(m.View().Content, "0/0 matches") {
+ t.Fatalf("expected flame search footer to open on /")
+ }
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})})
+ m = next.(Model)
+ if cmd != nil {
+ t.Fatalf("expected q in flame search to close search, not quit")
+ }
+ if m.quitting {
+ t.Fatalf("expected q in flame search not to set quitting state")
+ }
+ if strings.Contains(m.View().Content, "0/0 matches") {
+ t.Fatalf("expected q to close flame search like esc")
+ }
+}
+
type fakeDashboardSource struct {
snap *statsengine.Snapshot
}
@@ -998,6 +1083,13 @@ func TestViewSetsDynamicWindowTitle(t *testing.T) {
}
}
+func TestAltScreenViewEnablesMouseCellMotion(t *testing.T) {
+ view := altScreenView("test", "ior")
+ if view.MouseMode != tea.MouseModeCellMotion {
+ t.Fatalf("expected mouse mode cell motion, got %v", view.MouseMode)
+ }
+}
+
func TestRenderHelpOverlayUsesWideViewport(t *testing.T) {
groups := [][]key.Binding{{key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help"))}}
out := renderHelpOverlay(160, 40, groups)