summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/dashboard/model.go4
-rw-r--r--internal/tui/dashboard/model_test.go37
-rw-r--r--internal/tui/flamegraph/controls.go39
-rw-r--r--internal/tui/flamegraph/model.go119
-rw-r--r--internal/tui/flamegraph/model_test.go219
-rw-r--r--internal/tui/flamegraph/renderer.go4
-rw-r--r--internal/tui/tui.go106
-rw-r--r--internal/tui/tui_test.go100
8 files changed, 605 insertions, 23 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 7ec1362..b1d23bb 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -134,8 +134,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
var animCmd tea.Cmd
- if m.liveTrie != nil && !m.flamegraphModel.Paused() && (!m.flamegraphModel.HasSnapshot() || m.liveTrie.Version() != m.flamegraphModel.LastVersion()) {
- m.flamegraphModel.RefreshFromLiveTrie()
+ if m.liveTrie != nil && m.flamegraphModel.RefreshFromLiveTrie() {
animCmd = m.flamegraphModel.AnimationCmd()
}
if animCmd != nil {
@@ -368,6 +367,7 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) {
if m.width > 0 && m.height > 0 {
m.flamegraphModel.SetViewport(m.width, m.height)
}
+ m.flamegraphModel.RefreshFromLiveTrie()
}
// SetDarkMode updates dashboard child models for the active theme.
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 1dc1cc1..8904a2f 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -198,20 +198,45 @@ func TestFlameTickRefreshesFlamegraphModel(t *testing.T) {
}
}
-func TestFlameTickLoadsInitialSnapshotWithoutVersionChange(t *testing.T) {
+func TestSetLiveTriePreloadsInitialSnapshotWithoutVersionChange(t *testing.T) {
liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.SetLiveTrie(liveTrie)
m.activeTab = TabFlame
- if m.flamegraphModel.HasSnapshot() {
- t.Fatalf("expected fresh flame model to start without snapshot")
+ if !m.flamegraphModel.HasSnapshot() {
+ t.Fatalf("expected SetLiveTrie to preload a baseline snapshot")
}
next, _ := m.Update(flameTickMsg{})
model := next.(Model)
if !model.flamegraphModel.HasSnapshot() {
- t.Fatalf("expected flame tick to load initial snapshot even when trie version is unchanged")
+ t.Fatalf("expected flame tick to retain initial snapshot even when trie version is unchanged")
+ }
+}
+
+func TestFlameTickPausedContinuesBootstrapRefresh(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.SetLiveTrie(liveTrie)
+ m.activeTab = TabFlame
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ model := next.(Model)
+
+ next, _ = model.Update(flameTickMsg{})
+ model = next.(Model)
+ initialVersion := model.flamegraphModel.LastVersion()
+
+ liveTrie.Reset()
+ if liveTrie.Version() == initialVersion {
+ t.Fatalf("expected reset to advance trie version")
+ }
+
+ next, _ = model.Update(flameTickMsg{})
+ model = next.(Model)
+ if got, want := model.flamegraphModel.LastVersion(), liveTrie.Version(); got != want {
+ t.Fatalf("expected paused flame tick bootstrap to refresh version, got %d want %d", got, want)
}
}
@@ -359,10 +384,10 @@ func TestFlameTabReceivesResetAndPauseKeys(t *testing.T) {
m.width = 120
m.height = 30
- next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})})
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
model := next.(Model)
if !strings.Contains(model.View().Content, "[PAUSED]") {
- t.Fatalf("expected flame pause key to toggle paused state")
+ t.Fatalf("expected flame space key to toggle paused state")
}
next, cmd := model.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})})
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
index 959a5b0..f411a13 100644
--- a/internal/tui/flamegraph/controls.go
+++ b/internal/tui/flamegraph/controls.go
@@ -29,6 +29,7 @@ func (m *Model) resetBaseline() {
m.matchIndices = make(map[int]bool)
m.filterVisible = make(map[int]bool)
m.subtreeSet = make(map[int]bool)
+ m.hasNavigableSnapshot = false
m.statusMessage = "Baseline reset"
}
@@ -55,6 +56,7 @@ func (m *Model) cycleFieldOrder() {
m.matchIndices = make(map[int]bool)
m.filterVisible = make(map[int]bool)
m.subtreeSet = make(map[int]bool)
+ m.hasNavigableSnapshot = false
m.statusMessage = "Order: " + strings.Join(nextPreset, "/")
}
@@ -68,13 +70,16 @@ 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) | /:search | enter:zoom | u:undo | r:reset | p:pause", state, compactFramePath(m.currentRootPath()), order)
+ line := fmt.Sprintf("%s | view:%s | o:order(%s) | /:search | enter:zoom | u:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order)
if m.searchQuery != "" {
line += " | filter:" + m.searchQuery
}
if m.statusMessage != "" {
line += " | " + m.statusMessage
}
+ if m.lastKeyDebug != "" {
+ line += " | " + m.lastKeyDebug
+ }
width := m.width
if width <= 0 {
width = 80
@@ -87,10 +92,40 @@ func (m Model) helpOverlay() string {
if width <= 0 {
width = 80
}
- help := "Flame help: j/k depth h/l sibling enter zoom u/backspace undo esc reset / search n/N matches p pause r reset baseline o order ? help"
+ help := "Flame help: j/k depth h/l sibling enter zoom u/backspace undo esc reset / search n/N matches space/p pause r reset baseline o order ? help"
return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width))
}
+func (m Model) selectionStatusLine() string {
+ width := m.width
+ if width <= 0 {
+ width = 80
+ }
+ mode := "LIVE"
+ if m.paused {
+ mode = "PAUSED"
+ }
+ if len(m.frames) == 0 {
+ line := fmt.Sprintf("[%s] sel:none | arrows/hjkl navigate | enter zoom | / filter", mode)
+ return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width))
+ }
+ selIdx := m.selectedIdx
+ if selIdx < 0 || selIdx >= len(m.frames) {
+ selIdx = 0
+ }
+ frame := m.frames[selIdx]
+ systemShare := frame.Percent
+ if m.globalTotal > 0 {
+ systemShare = percentOfTotal(frame.Total, m.globalTotal)
+ }
+ line := fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total:%d | %.2f%% system",
+ mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, frame.Total, systemShare)
+ if m.searchQuery != "" {
+ line += " | filter:" + m.searchQuery
+ }
+ return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width))
+}
+
func (m Model) currentFieldPresetLabel() string {
if len(m.fieldPresets) == 0 {
return "n/a"
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index cca2fe5..b205d33 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -79,6 +79,7 @@ type Model struct {
subtreeSet map[int]bool
showHelp bool
statusMessage string
+ lastKeyDebug string
fieldPresets [][]string
fieldIndex int
@@ -86,8 +87,11 @@ type Model struct {
animation AnimationState
animating bool
paused bool
- isDark bool
- keys flameKeyMap
+ // hasNavigableSnapshot flips once we have at least one selectable non-root
+ // frame. Paused mode can still bootstrap snapshots until then.
+ hasNavigableSnapshot bool
+ isDark bool
+ keys flameKeyMap
}
// tuiFrame stores one terminal flamegraph frame cell.
@@ -159,56 +163,78 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.KeyPressMsg:
if m.searchActive {
+ handled := false
switch msg.String() {
case "esc":
+ handled = true
m.clearSearch()
+ m.recordKeyDebug(msg, handled, false)
return m, nil
case "enter":
+ handled = true
m.applySearchQuery(m.searchInput.Value())
m.searchActive = false
m.searchInput.Blur()
+ m.recordKeyDebug(msg, handled, false)
return m, nil
}
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
_ = cmd
+ m.recordKeyDebug(msg, true, false)
return m, nil
}
prev := m.selectedIdx
+ handled := false
switch {
case isSearchOpenKey(msg):
+ handled = true
m.openSearch()
case isNextMatchKey(msg):
+ handled = true
m.jumpMatch(1)
case isPrevMatchKey(msg):
+ handled = true
m.jumpMatch(-1)
case isPauseKey(msg):
+ handled = true
m.togglePause()
case isResetBaselineKey(msg):
+ handled = true
m.resetBaseline()
case isCycleOrderKey(msg):
+ handled = true
m.cycleFieldOrder()
case isHelpToggleKey(msg):
+ handled = true
m.toggleHelp()
case isZoomInKey(msg, m.keys):
+ handled = true
m.zoomIn()
case isZoomUndoKey(msg, m.keys):
+ handled = true
m.zoomUndo()
case isZoomResetKey(msg, m.keys):
+ handled = true
m.zoomReset()
case isMoveShallowerKey(msg, m.keys):
- m.moveVerticalWithFallback(-1, 1)
+ handled = true
+ m.moveVerticalWithFallback(-1, 1, -1)
case isMoveDeeperKey(msg, m.keys):
- m.moveVerticalWithFallback(1, -1)
+ handled = true
+ m.moveVerticalWithFallback(1, -1, 1)
case isPrevSiblingKey(msg, m.keys):
+ handled = true
m.moveSibling(-1)
case isNextSiblingKey(msg, m.keys):
+ handled = true
m.moveSibling(1)
}
if m.selectedIdx != prev {
m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
}
+ m.recordKeyDebug(msg, handled, m.selectedIdx != prev)
}
return m, nil
}
@@ -251,6 +277,7 @@ func (m Model) View() tea.View {
if m.snapshot != nil && len(m.frames) == 0 {
content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion))
}
+ content += "\n" + m.selectionStatusLine()
if m.showHelp {
content += "\n" + m.helpOverlay()
}
@@ -273,6 +300,7 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) {
m.filterVisible = make(map[int]bool)
m.animation = NewAnimationState(30, 6.0, 1.0)
m.animating = false
+ m.hasNavigableSnapshot = false
}
// RefreshFromLiveTrie loads a new snapshot when the source version changes.
@@ -280,7 +308,8 @@ func (m *Model) RefreshFromLiveTrie() bool {
if m.liveTrie == nil {
return false
}
- if m.paused {
+ // Keep bootstrapping while paused until we have a navigable snapshot.
+ if m.paused && m.snapshot != nil && m.hasNavigableSnapshot {
return false
}
version := m.liveTrie.Version()
@@ -359,6 +388,9 @@ func (m *Model) rebuildFrames(animate bool) {
m.animating = false
m.frames = append(m.frames[:0], m.targetFrames...)
}
+ if len(m.frames) > 1 {
+ m.hasNavigableSnapshot = true
+ }
m.clampSelection()
m.recomputeFilterState()
m.ensureSelectionNavigable()
@@ -451,27 +483,33 @@ func (m *Model) moveVertical(delta int) {
m.selectedIdx = best
}
-func (m *Model) moveVerticalWithFallback(primaryDelta, fallbackDelta int) {
+func (m *Model) moveVerticalWithFallback(primaryDelta, fallbackDelta, traversalDelta int) {
before := m.selectedIdx
m.moveVertical(primaryDelta)
if m.selectedIdx == before && fallbackDelta != 0 {
m.moveVertical(fallbackDelta)
}
+ if m.selectedIdx == before && traversalDelta != 0 {
+ m.moveTraversal(traversalDelta)
+ }
}
func (m *Model) moveSibling(delta int) {
if len(m.frames) == 0 {
return
}
+ before := m.selectedIdx
m.clampSelection()
m.ensureSelectionNavigable()
current := m.frames[m.selectedIdx]
siblings := m.framesAtDepth(current.Depth)
if len(siblings) <= 1 {
+ m.moveTraversal(delta)
return
}
pos := indexOf(siblings, m.selectedIdx)
if pos < 0 {
+ m.moveTraversal(delta)
return
}
next := pos + delta
@@ -482,6 +520,9 @@ func (m *Model) moveSibling(delta int) {
next = len(siblings) - 1
}
m.selectedIdx = siblings[next]
+ if m.selectedIdx == before {
+ m.moveTraversal(delta)
+ }
}
func framesAtDepth(frames []tuiFrame, depth int) []int {
@@ -602,6 +643,67 @@ func (m *Model) ensureSelectionNavigable() {
}
}
+func (m *Model) recordKeyDebug(msg tea.KeyPressMsg, handled, moved bool) {
+ keyID := keyString(msg)
+ if keyID == "" {
+ keyID = fmt.Sprintf("code:%d", msg.Code)
+ }
+ sel := "-"
+ selIdx := m.selectedIdx
+ if len(m.frames) > 0 && m.selectedIdx >= 0 && m.selectedIdx < len(m.frames) {
+ sel = compactFramePath(m.frames[m.selectedIdx].Path)
+ }
+ m.lastKeyDebug = fmt.Sprintf("dbg frames=%d idx=%d key=%q code=%d handled=%t moved=%t sel=%s", len(m.frames), selIdx, keyID, msg.Code, handled, moved, sel)
+}
+
+func (m *Model) moveTraversal(delta int) {
+ if len(m.frames) == 0 || delta == 0 {
+ return
+ }
+ order := m.visibleTraversalOrder()
+ if len(order) == 0 {
+ return
+ }
+ pos := indexOf(order, m.selectedIdx)
+ if pos < 0 {
+ pos = 0
+ }
+ next := pos + delta
+ if next < 0 {
+ next = 0
+ }
+ if next >= len(order) {
+ next = len(order) - 1
+ }
+ m.selectedIdx = order[next]
+}
+
+func (m Model) visibleTraversalOrder() []int {
+ indices := make([]int, 0, len(m.frames))
+ include := m.navigableFrameSet()
+ for idx := range m.frames {
+ if include != nil && !include[idx] {
+ continue
+ }
+ indices = append(indices, idx)
+ }
+ sort.Slice(indices, func(i, j int) bool {
+ left := m.frames[indices[i]]
+ right := m.frames[indices[j]]
+ if left.Depth != right.Depth {
+ return left.Depth < right.Depth
+ }
+ if left.Col != right.Col {
+ return left.Col < right.Col
+ }
+ if left.Row != right.Row {
+ return left.Row < right.Row
+ }
+ return indices[i] < indices[j]
+ })
+ return indices
+}
+
func keyString(msg tea.KeyPressMsg) string {
if s := msg.String(); s != "" {
return s
@@ -612,7 +714,10 @@ func keyString(msg tea.KeyPressMsg) string {
func isSearchOpenKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "/" }
func isNextMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "n" }
func isPrevMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "N" }
-func isPauseKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "p" }
+func isPauseKey(msg tea.KeyPressMsg) bool {
+ k := keyString(msg)
+ return k == "p" || k == " " || k == "space" || msg.Code == tea.KeySpace
+}
func isResetBaselineKey(msg tea.KeyPressMsg) bool {
return keyString(msg) == "r"
}
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index f58f890..7387ac6 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -2,6 +2,7 @@ package flamegraph
import (
"reflect"
+ "strings"
"testing"
coreflamegraph "ior/internal/flamegraph"
@@ -53,6 +54,59 @@ func TestRefreshFromLiveTrieTracksVersionAndSnapshot(t *testing.T) {
}
}
+func TestRefreshFromLiveTrieAllowsInitialLoadWhilePaused(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModel(trie)
+ m.paused = true
+
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected initial paused refresh to load first snapshot")
+ }
+ if m.snapshot == nil {
+ t.Fatalf("expected snapshot to be available after initial paused refresh")
+ }
+ if changed := m.RefreshFromLiveTrie(); changed {
+ t.Fatalf("expected subsequent paused refresh to be skipped once snapshot exists")
+ }
+}
+
+func TestRefreshFromLiveTriePausedBlocksAfterNavigableSnapshot(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModel(trie)
+ m.paused = true
+ m.snapshot = &snapshotNode{Name: "root", Total: 1}
+ m.frames = []tuiFrame{
+ {Name: "root", Path: "root"},
+ {Name: "child", Path: "root" + pathSeparator + "child"},
+ }
+ m.hasNavigableSnapshot = true
+ m.lastVersion = 1
+
+ if changed := m.RefreshFromLiveTrie(); changed {
+ t.Fatalf("expected paused refresh to remain frozen once navigable snapshot exists")
+ }
+ if got, want := m.lastVersion, uint64(1); got != want {
+ t.Fatalf("expected version to remain unchanged while paused, got %d want %d", got, want)
+ }
+}
+
+func TestRefreshFromLiveTriePausedKeepsBootstrappingWithoutNavigableSnapshot(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModel(trie)
+ m.paused = true
+ m.snapshot = &snapshotNode{Name: "root", Total: 1}
+ m.frames = []tuiFrame{{Name: "root", Path: "root"}}
+ m.hasNavigableSnapshot = false
+ m.lastVersion = 1
+
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected paused refresh to continue bootstrapping before navigation is possible")
+ }
+ if got, want := m.lastVersion, trie.Version(); got != want {
+ t.Fatalf("expected paused bootstrap refresh to track trie version, got %d want %d", got, want)
+ }
+}
+
func TestKeyboardNavigationDeepNarrowTree(t *testing.T) {
m := NewModel(nil)
m.frames = []tuiFrame{
@@ -106,6 +160,141 @@ func TestKeyboardNavigationShallowWideSiblings(t *testing.T) {
}
}
+func TestHorizontalTraversalFallbackFromRoot(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Path: "root"},
+ {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"},
+ {Name: "B", Depth: 1, Col: 30, Path: "root" + pathSeparator + "B"},
+ }
+ m.selectedIdx = 0
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected right arrow from root to move to first traversable frame, got idx %d", m.selectedIdx)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"})
+ if m.selectedIdx != 2 {
+ t.Fatalf("expected vi right key to move to next frame, got idx %d", m.selectedIdx)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected left arrow to move back to previous frame, got idx %d", m.selectedIdx)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'h'}[0], Text: "h"})
+ if m.selectedIdx != 0 {
+ t.Fatalf("expected vi left key to move back to root, got idx %d", m.selectedIdx)
+ }
+}
+
+func TestPausedStateStillAllowsNavigation(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Path: "root"},
+ {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"},
+ }
+ m.paused = true
+ m.selectedIdx = 0
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected navigation to work while paused, got idx %d", m.selectedIdx)
+ }
+}
+
+func TestStaticFixtureArrowTraversalVisitsAllFrames(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ coreflamegraph.SeedTestFlameData(trie)
+
+ m := NewModel(trie)
+ m.SetViewport(180, 40)
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected seeded fixture refresh to load frames")
+ }
+ if len(m.frames) < 2 {
+ t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames))
+ }
+
+ visited := map[int]bool{m.selectedIdx: true}
+ for i := 0; i < len(m.frames)*4; i++ {
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ visited[m.selectedIdx] = true
+ }
+ for i := 0; i < len(m.frames)*4; i++ {
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft})
+ visited[m.selectedIdx] = true
+ }
+
+ if got, want := len(visited), len(m.frames); got != want {
+ t.Fatalf("expected arrow traversal to visit all frames: visited=%d frames=%d", got, want)
+ }
+ if !strings.Contains(m.View().Content, "sel:") {
+ t.Fatalf("expected view to expose selected-frame status line")
+ }
+}
+
+func TestLiveFixtureArrowTraversalWhileStreamingVisitsAllFrames(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ coreflamegraph.SeedTestLiveFlameData(trie, 0)
+
+ m := NewModel(trie)
+ m.SetViewport(180, 40)
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected initial refresh to load frames")
+ }
+ if len(m.frames) < 2 {
+ t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames))
+ }
+
+ selectedPath := func(model Model) string {
+ if len(model.frames) == 0 || model.selectedIdx < 0 || model.selectedIdx >= len(model.frames) {
+ return ""
+ }
+ return model.frames[model.selectedIdx].Path
+ }
+
+ visitedPaths := map[string]bool{selectedPath(m): true}
+ moves := 0
+ for i := 0; i < len(m.frames)*4; i++ {
+ trie.Reset()
+ coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1))
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected refresh after synthetic live ingest at step %d", i)
+ }
+ before := selectedPath(m)
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ after := selectedPath(m)
+ if after != before {
+ moves++
+ }
+ visitedPaths[after] = true
+ }
+ for i := 0; i < len(m.frames)*4; i++ {
+ trie.Reset()
+ coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1+len(m.frames)*4))
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected refresh after synthetic live ingest (reverse) at step %d", i)
+ }
+ before := selectedPath(m)
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft})
+ after := selectedPath(m)
+ if after != before {
+ moves++
+ }
+ visitedPaths[after] = true
+ }
+
+ if moves == 0 {
+ t.Fatalf("expected live-stream navigation to change selection at least once")
+ }
+ if len(visitedPaths) < 8 {
+ t.Fatalf("expected traversal across live updates to reach multiple frame paths, got %d", len(visitedPaths))
+ }
+}
+
func TestKeyboardNavigationSingleNodeClamped(t *testing.T) {
m := NewModel(nil)
m.frames = []tuiFrame{{Name: "root", Depth: 0, Col: 0, Path: "root"}}
@@ -350,9 +539,17 @@ func TestControlPauseToggle(t *testing.T) {
if !m.paused {
t.Fatalf("expected pause to toggle on")
}
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ if m.paused {
+ t.Fatalf("expected space key to toggle pause off")
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ if !m.paused {
+ t.Fatalf("expected space key to toggle pause on")
+ }
m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"})
if m.paused {
- t.Fatalf("expected pause to toggle off")
+ t.Fatalf("expected p key to toggle pause off")
}
}
@@ -374,6 +571,26 @@ func TestControlResetBaseline(t *testing.T) {
}
}
+func TestViewIncludesSelectionStatusBar(t *testing.T) {
+ m := NewModel(nil)
+ m.width = 120
+ m.height = 20
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 120, Total: 100, Percent: 100, Path: "root"},
+ {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 60, Total: 40, Percent: 40, Path: "root" + pathSeparator + "child"},
+ }
+ m.selectedIdx = 1
+ m.globalTotal = 100
+
+ view := m.View().Content
+ if !strings.Contains(view, "[LIVE] sel:2/2 child") {
+ t.Fatalf("expected selection status bar to include selected frame info, got %q", view)
+ }
+ if !strings.Contains(view, "40.00% system") {
+ t.Fatalf("expected selection status bar to include selected share, got %q", view)
+ }
+}
+
func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) {
liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
m := NewModel(liveTrie)
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go
index 517929e..67ad66e 100644
--- a/internal/tui/flamegraph/renderer.go
+++ b/internal/tui/flamegraph/renderer.go
@@ -435,10 +435,10 @@ func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, mat
}
if isSelected {
- selectedBg := lipgloss.Color("99")
+ selectedBg := lipgloss.Color("129")
selectedFg := lipgloss.Color("15")
if !isDark {
- selectedBg = lipgloss.Color("93")
+ selectedBg = lipgloss.Color("129")
selectedFg = lipgloss.Color("15")
}
return base.Background(selectedBg).Foreground(selectedFg).Bold(true).Underline(true)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 7918c0f..0381784 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -166,6 +166,16 @@ func RunWithTraceStarter(starter TraceStarter) error {
return err
}
+// RunTestFlamesWithTraceStarter starts the TUI directly on dashboard/flame view
+// with a synthetic static flamegraph source.
+func RunTestFlamesWithTraceStarter(starter TraceStarter) error {
+ cfg := flags.Get()
+ model := newModelWithRuntimeConfig(1, 1, cfg.TUIExportEnable, starter)
+ program := tea.NewProgram(model)
+ _, err := program.Run()
+ return err
+}
+
// Model is the top-level Bubble Tea model that routes between PID picker and dashboard.
type Model struct {
screen Screen
@@ -195,6 +205,15 @@ type Model struct {
keyboardEnhancements tea.KeyboardEnhancementsMsg
keyboardEnhancementsKnown bool
+
+ lastKeyEventID string
+ lastKeyEventAt time.Time
+ lastKeyEventWasPress bool
+ // Some terminals emit release+press for a single physical key event.
+ // When we fallback-handle a release as a press, suppress the immediate
+ // matching press to avoid double-handling.
+ suppressPressKeyID string
+ suppressPressUntil time.Time
}
// NewModel creates the top-level TUI model.
@@ -269,6 +288,12 @@ func initialWindowSizeCmd() tea.Cmd {
// Update routes messages, transitions screens, and manages tracing startup state.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ normalizedMsg, ok := m.normalizeKeyEvent(msg)
+ if !ok {
+ return m, nil
+ }
+ msg = normalizedMsg
+
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
@@ -382,6 +407,87 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateActiveModel(msg)
}
+func (m *Model) normalizeKeyEvent(msg tea.Msg) (tea.Msg, bool) {
+ switch keyMsg := msg.(type) {
+ case tea.KeyPressMsg:
+ keyID := keyEventID(keyMsg)
+ if m.shouldSuppressPress(keyID) {
+ return nil, false
+ }
+ m.recordKeyEvent(keyMsg, true)
+ return keyMsg, true
+ case tea.KeyReleaseMsg:
+ pressMsg := tea.KeyPressMsg(keyMsg)
+ keyID := keyEventID(pressMsg)
+ if m.lastKeyEventWasPress && keyID != "" && keyID == m.lastKeyEventID && time.Since(m.lastKeyEventAt) <= 500*time.Millisecond {
+ // Some terminals emit both press+release; avoid handling release as a duplicate.
+ m.lastKeyEventWasPress = false
+ return nil, false
+ }
+ if !releaseHasIdentity(pressMsg) {
+ // Ignore release messages that don't carry enough identity information.
+ // Some terminals emit these before a usable press event.
+ return nil, false
+ }
+ // Fallback: treat release as press for terminals that only emit release events.
+ m.armPressSuppression(keyID)
+ m.recordKeyEvent(pressMsg, false)
+ return pressMsg, true
+ default:
+ return msg, true
+ }
+}
+
+func (m *Model) shouldSuppressPress(keyID string) bool {
+ if m.suppressPressKeyID == "" {
+ return false
+ }
+ if time.Now().After(m.suppressPressUntil) {
+ m.clearPressSuppression()
+ return false
+ }
+ if keyID == "" || keyID != m.suppressPressKeyID {
+ return false
+ }
+ m.clearPressSuppression()
+ return true
+}
+
+func (m *Model) armPressSuppression(keyID string) {
+ if keyID == "" {
+ return
+ }
+ // Keep this short so fast repeated key presses still work naturally.
+ m.suppressPressKeyID = keyID
+ m.suppressPressUntil = time.Now().Add(60 * time.Millisecond)
+}
+
+func (m *Model) clearPressSuppression() {
+ m.suppressPressKeyID = ""
+ m.suppressPressUntil = time.Time{}
+}
+
+func (m *Model) recordKeyEvent(msg tea.KeyPressMsg, wasPress bool) {
+ m.lastKeyEventID = keyEventID(msg)
+ m.lastKeyEventAt = time.Now()
+ m.lastKeyEventWasPress = wasPress
+}
+
+func keyEventID(msg tea.KeyPressMsg) string {
+ return fmt.Sprintf("code:%d/mod:%d", msg.Code, msg.Mod)
+}
+
+func releaseHasIdentity(msg tea.KeyPressMsg) bool {
+ if msg.Code != 0 {
+ return true
+ }
+ if msg.Text != "" {
+ return true
+ }
+ keyStr := msg.String()
+ return keyStr != "" && keyStr != "\x00"
+}
+
func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.screen {
case ScreenPIDPicker:
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 7d2a439..6cdc427 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -303,13 +303,107 @@ func TestFlamePauseKeyDoesNotTriggerPIDReselect(t *testing.T) {
m.width = 120
m.height = 30
- next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})})
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
updated := next.(Model)
if updated.screen != ScreenDashboard {
- t.Fatalf("expected flame pause key to keep dashboard screen, got %v", updated.screen)
+ t.Fatalf("expected flame space key to keep dashboard screen, got %v", updated.screen)
}
if !strings.Contains(updated.View().Content, "[PAUSED]") {
- t.Fatalf("expected flame pause key to toggle flame paused state")
+ t.Fatalf("expected flame space key to toggle flame paused state")
+ }
+}
+
+func TestFlameSpaceKeyReleaseFallbackTogglesPause(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.KeyReleaseMsg{Code: tea.KeySpace, Text: " "})
+ updated := next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected key release fallback to toggle flame paused state")
+ }
+}
+
+func TestFlameSpacePressReleaseDoesNotDoubleTogglePause(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: tea.KeySpace, Text: " "})
+ updated := next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected key press to pause flame")
+ }
+
+ next, _ = updated.Update(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "})
+ updated = next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected key release after key press to be ignored as duplicate")
+ }
+}
+
+func TestFlameSpaceReleasePressDoesNotDoubleTogglePause(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.KeyReleaseMsg{Code: tea.KeySpace, Text: " "})
+ updated := next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected key release fallback to pause flame")
+ }
+
+ next, _ = updated.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ updated = next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected immediate matching key press after release fallback to be ignored")
+ }
+}
+
+func TestNormalizeKeyEventReleaseFallbackSuppressesImmediatePressOnly(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+
+ normalized, ok := m.normalizeKeyEvent(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "})
+ if !ok {
+ t.Fatalf("expected release fallback to be handled")
+ }
+ if _, isPress := normalized.(tea.KeyPressMsg); !isPress {
+ t.Fatalf("expected release fallback to normalize to KeyPressMsg, got %T", normalized)
+ }
+
+ if normalized, ok = m.normalizeKeyEvent(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}); ok {
+ t.Fatalf("expected immediate matching press to be suppressed, got %T", normalized)
+ }
+
+ time.Sleep(70 * time.Millisecond)
+ if normalized, ok = m.normalizeKeyEvent(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}); !ok {
+ t.Fatalf("expected press to be accepted after suppression window")
+ }
+ if _, isPress := normalized.(tea.KeyPressMsg); !isPress {
+ t.Fatalf("expected accepted message to be KeyPressMsg, got %T", normalized)
+ }
+}
+
+func TestNormalizeKeyEventIgnoresUnidentifiedRelease(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+
+ if normalized, ok := m.normalizeKeyEvent(tea.KeyReleaseMsg{}); ok {
+ t.Fatalf("expected unidentified release to be ignored, got %T", normalized)
+ }
+
+ normalized, ok := m.normalizeKeyEvent(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ if !ok {
+ t.Fatalf("expected subsequent real key press to be handled")
+ }
+ if _, isPress := normalized.(tea.KeyPressMsg); !isPress {
+ t.Fatalf("expected normalized message to be KeyPressMsg, got %T", normalized)
}
}