summaryrefslogtreecommitdiff
path: root/internal/tui/flamegraph
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 17:32:24 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 17:32:24 +0200
commit1561987330cb898f5ff64383a9c78e7e6559f118 (patch)
tree69a823e8f98dce572566c97e6879c11c9d591bda /internal/tui/flamegraph
parent96225fb6159212a8851043a08d781aba721b4e78 (diff)
parent110a193e04b81abb8d8e159abd73f9f6ed1acd7e (diff)
Merge branch 'feat/bubbletea-v2-migration'
Diffstat (limited to 'internal/tui/flamegraph')
-rw-r--r--internal/tui/flamegraph/animation.go145
-rw-r--r--internal/tui/flamegraph/animation_test.go50
-rw-r--r--internal/tui/flamegraph/bench_test.go401
-rw-r--r--internal/tui/flamegraph/controls.go173
-rw-r--r--internal/tui/flamegraph/doc.go2
-rw-r--r--internal/tui/flamegraph/model.go1027
-rw-r--r--internal/tui/flamegraph/model_test.go987
-rw-r--r--internal/tui/flamegraph/renderer.go708
-rw-r--r--internal/tui/flamegraph/renderer_test.go368
-rw-r--r--internal/tui/flamegraph/search.go141
-rw-r--r--internal/tui/flamegraph/stress_race_disabled_test.go7
-rw-r--r--internal/tui/flamegraph/stress_race_enabled_test.go7
-rw-r--r--internal/tui/flamegraph/stress_test.go236
-rw-r--r--internal/tui/flamegraph/testdata_fixture_test.go39
-rw-r--r--internal/tui/flamegraph/testdata_test.go185
-rw-r--r--internal/tui/flamegraph/zoom.go39
16 files changed, 4515 insertions, 0 deletions
diff --git a/internal/tui/flamegraph/animation.go b/internal/tui/flamegraph/animation.go
new file mode 100644
index 0000000..103d43b
--- /dev/null
+++ b/internal/tui/flamegraph/animation.go
@@ -0,0 +1,145 @@
+package flamegraph
+
+import (
+ "math"
+
+ "github.com/charmbracelet/harmonica"
+)
+
+const springEpsilon = 0.01
+
+type frameSpring struct {
+ path string
+ base tuiFrame
+ widthSpring harmonica.Spring
+ colSpring harmonica.Spring
+
+ currentW float64
+ currentCol float64
+ velocityW float64
+ velocityCol float64
+
+ targetW float64
+ targetCol float64
+}
+
+// AnimationState stores per-frame spring interpolation state.
+type AnimationState struct {
+ springs []frameSpring
+ frames []tuiFrame
+ settled bool
+
+ fps int
+ angularVelocity float64
+ damping float64
+}
+
+// NewAnimationState builds a spring animation state with the provided parameters.
+func NewAnimationState(fps int, angularVelocity, damping float64) AnimationState {
+ if fps <= 0 {
+ fps = 30
+ }
+ return AnimationState{
+ fps: fps,
+ angularVelocity: angularVelocity,
+ damping: damping,
+ settled: true,
+ }
+}
+
+// SetTargets sets new frame targets, preserving spring motion for matching paths.
+func (a *AnimationState) SetTargets(targets []tuiFrame) {
+ existing := make(map[string]frameSpring, len(a.springs))
+ for _, spring := range a.springs {
+ existing[spring.path] = spring
+ }
+
+ next := make([]frameSpring, 0, len(targets))
+ for _, target := range targets {
+ spring, ok := existing[target.Path]
+ if !ok {
+ spring = frameSpring{
+ path: target.Path,
+ currentW: float64(target.Width),
+ currentCol: float64(target.Col),
+ }
+ }
+ spring.base = target
+ spring.targetW = float64(target.Width)
+ spring.targetCol = float64(target.Col)
+ spring.widthSpring = harmonica.NewSpring(harmonica.FPS(a.fps), a.angularVelocity, a.damping)
+ spring.colSpring = harmonica.NewSpring(harmonica.FPS(a.fps), a.angularVelocity, a.damping)
+ next = append(next, spring)
+ }
+ a.springs = next
+ if cap(a.frames) < len(a.springs) {
+ a.frames = make([]tuiFrame, len(a.springs))
+ } else {
+ a.frames = a.frames[:len(a.springs)]
+ }
+ a.settled = len(a.springs) == 0
+ for _, spring := range a.springs {
+ if !isSpringSettled(spring) {
+ a.settled = false
+ break
+ }
+ }
+}
+
+// Tick advances springs by delta seconds and returns true while animation is active.
+func (a *AnimationState) Tick(delta float64) bool {
+ if len(a.springs) == 0 {
+ a.settled = true
+ return false
+ }
+ baseDelta := harmonica.FPS(a.fps)
+ if delta <= 0 {
+ delta = baseDelta
+ }
+
+ active := false
+ for idx := range a.springs {
+ spring := &a.springs[idx]
+ if delta != baseDelta {
+ spring.widthSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping)
+ spring.colSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping)
+ }
+ spring.currentW, spring.velocityW = spring.widthSpring.Update(spring.currentW, spring.velocityW, spring.targetW)
+ spring.currentCol, spring.velocityCol = spring.colSpring.Update(spring.currentCol, spring.velocityCol, spring.targetCol)
+ if !isSpringSettled(*spring) {
+ active = true
+ }
+ }
+ a.settled = !active
+ return active
+}
+
+// CurrentFrames returns interpolated frames for the current animation step.
+func (a *AnimationState) CurrentFrames() []tuiFrame {
+ for idx, spring := range a.springs {
+ frame := spring.base
+ frame.Col = maxInt(0, int(math.Round(spring.currentCol)))
+ frame.Width = maxInt(1, int(math.Round(spring.currentW)))
+ a.frames[idx] = frame
+ }
+ return a.frames
+}
+
+// Settled reports whether all active springs are at rest.
+func (a AnimationState) Settled() bool {
+ return a.settled
+}
+
+func isSpringSettled(s frameSpring) bool {
+ return math.Abs(s.currentW-s.targetW) < springEpsilon &&
+ math.Abs(s.currentCol-s.targetCol) < springEpsilon &&
+ math.Abs(s.velocityW) < springEpsilon &&
+ math.Abs(s.velocityCol) < springEpsilon
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/internal/tui/flamegraph/animation_test.go b/internal/tui/flamegraph/animation_test.go
new file mode 100644
index 0000000..94272e2
--- /dev/null
+++ b/internal/tui/flamegraph/animation_test.go
@@ -0,0 +1,50 @@
+package flamegraph
+
+import "testing"
+
+func TestAnimationStateConvergesToTarget(t *testing.T) {
+ state := NewAnimationState(30, 6.0, 1.0)
+ state.SetTargets([]tuiFrame{{Path: "root", Col: 0, Width: 10}})
+ state.SetTargets([]tuiFrame{{Path: "root", Col: 100, Width: 50}})
+
+ active := true
+ for i := 0; i < 180 && active; i++ {
+ active = state.Tick(0)
+ }
+ if active {
+ t.Fatalf("expected springs to settle within 180 ticks")
+ }
+
+ frames := state.CurrentFrames()
+ if len(frames) != 1 {
+ t.Fatalf("expected one interpolated frame, got %d", len(frames))
+ }
+ if frames[0].Col != 100 || frames[0].Width != 50 {
+ t.Fatalf("expected settled frame at col=100 width=50, got col=%d width=%d", frames[0].Col, frames[0].Width)
+ }
+ if state.Tick(0) {
+ t.Fatalf("expected settled animation to remain inactive")
+ }
+}
+
+func TestAnimationStateHandlesAddedAndRemovedFrames(t *testing.T) {
+ state := NewAnimationState(30, 6.0, 1.0)
+ state.SetTargets([]tuiFrame{
+ {Path: "root", Col: 0, Width: 20},
+ {Path: "root\x1fchild", Col: 20, Width: 20},
+ })
+ if got := len(state.CurrentFrames()); got != 2 {
+ t.Fatalf("expected 2 frames after initial targets, got %d", got)
+ }
+
+ state.SetTargets([]tuiFrame{
+ {Path: "root\x1fchild", Col: 40, Width: 30},
+ })
+ frames := state.CurrentFrames()
+ if len(frames) != 1 {
+ t.Fatalf("expected removed frame to be dropped, got %d frames", len(frames))
+ }
+ if frames[0].Path != "root\x1fchild" {
+ t.Fatalf("expected remaining frame path root\\x1fchild, got %q", frames[0].Path)
+ }
+}
diff --git a/internal/tui/flamegraph/bench_test.go b/internal/tui/flamegraph/bench_test.go
new file mode 100644
index 0000000..33d77d1
--- /dev/null
+++ b/internal/tui/flamegraph/bench_test.go
@@ -0,0 +1,401 @@
+package flamegraph
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ coreflamegraph "ior/internal/flamegraph"
+ "ior/internal/types"
+
+ "github.com/charmbracelet/harmonica"
+)
+
+var (
+ benchFramesSink []tuiFrame
+ benchStringSink string
+ benchIntSink int
+ benchFloatSink float64
+)
+
+func BenchmarkBuildTerminalLayout(b *testing.B) {
+ // Performance target: medium_120col should remain below 500us/op.
+ fixtures := []struct {
+ label string
+ depth int
+ breadth int
+ }{
+ {label: "small", depth: fixtureSmallDepth, breadth: fixtureSmallBreadth},
+ {label: "medium", depth: fixtureMediumDepth, breadth: fixtureMediumBreadth},
+ {label: "large", depth: fixtureLargeDepth, breadth: fixtureLargeBreadth},
+ {label: "deep", depth: fixtureDeepDepth, breadth: fixtureDeepBreadth},
+ {label: "wide", depth: fixtureWideDepth, breadth: fixtureWideBreadth},
+ }
+ widths := []int{80, 120, 200, 300}
+ const height = 40
+
+ snapshots := make(map[string]*snapshotNode, len(fixtures))
+ for _, fixture := range fixtures {
+ snapshots[fixture.label] = generateTestSnapshot(fixture.depth, fixture.breadth)
+ }
+
+ for _, fixture := range fixtures {
+ snapshot := snapshots[fixture.label]
+ for _, width := range widths {
+ name := fmt.Sprintf("%s_%dcol", fixture.label, width)
+ b.Run(name, func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ benchFramesSink = BuildTerminalLayout(snapshot, width, height)
+ }
+ if len(benchFramesSink) == 0 {
+ b.Fatal("layout returned no frames")
+ }
+ })
+ }
+ }
+}
+
+func BenchmarkRenderFrame(b *testing.B) {
+ // Performance target: medium_120x40 should remain below 2ms/op.
+ // Allocation target: run with -benchmem and keep render path below 5 allocs/op.
+ fixtures := []struct {
+ label string
+ snapshot *snapshotNode
+ }{
+ {label: "medium", snapshot: generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth)},
+ {label: "large", snapshot: generateTestSnapshot(fixtureLargeDepth, fixtureLargeBreadth)},
+ }
+ viewports := []struct {
+ width int
+ height int
+ }{
+ {width: 80, height: 24},
+ {width: 120, height: 40},
+ {width: 200, height: 60},
+ }
+
+ for _, fixture := range fixtures {
+ for _, viewport := range viewports {
+ name := fmt.Sprintf("%s_%dx%d", fixture.label, viewport.width, viewport.height)
+ b.Run(name, func(b *testing.B) {
+ model := NewModel(nil)
+ model.width = viewport.width
+ model.height = viewport.height
+ model.snapshot = fixture.snapshot
+ model.rebuildFrames(false)
+ if len(model.frames) == 0 {
+ b.Fatal("render benchmark requires non-empty frame layout")
+ }
+
+ for idx := range model.frames {
+ switch idx % 12 {
+ case 0:
+ model.frames[idx].Name = "sys_enter_read"
+ case 1:
+ model.frames[idx].Name = "sys_enter_write"
+ }
+ }
+ model.selectedIdx = midDepthFrameIndex(model.frames)
+ model.subtreeSet = computeSubtreeSetInto(model.frames, model.selectedIdx, model.subtreeSet)
+ model.applySearchQuery("sys_")
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ benchStringSink = model.View().Content
+ }
+ })
+ }
+ }
+}
+
+func BenchmarkComputeSubtreeSet(b *testing.B) {
+ // Performance target: 1000-frame subtree membership should remain below 100us/op.
+ // Allocation target: zero allocs/op by reusing map storage.
+ cases := []struct {
+ label string
+ frameCount int
+ }{
+ {label: "100frames", frameCount: 100},
+ {label: "1000frames", frameCount: 1000},
+ {label: "5000frames", frameCount: 5000},
+ }
+
+ for _, tc := range cases {
+ frames := benchmarkFramesForCount(tc.frameCount)
+ if len(frames) == 0 {
+ b.Fatalf("%s produced no frames", tc.label)
+ }
+ selectedIdx := midDepthFrameIndex(frames)
+ reuse := make(map[int]bool, len(frames))
+
+ b.Run(tc.label, func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ subtree := computeSubtreeSetInto(frames, selectedIdx, reuse)
+ benchIntSink = len(subtree)
+ }
+ })
+ }
+}
+
+func BenchmarkSearchHighlight(b *testing.B) {
+ // Performance target: 1000-frame search should remain below 200us/op.
+ cases := []struct {
+ label string
+ frameCount int
+ }{
+ {label: "100frames", frameCount: 100},
+ {label: "1000frames", frameCount: 1000},
+ {label: "5000frames", frameCount: 5000},
+ }
+ queries := []string{"read", "sys_", "/srv/app"}
+
+ for _, tc := range cases {
+ frames := benchmarkFramesForCount(tc.frameCount)
+ if len(frames) == 0 {
+ b.Fatalf("%s produced no frames", tc.label)
+ }
+ decorateFramesForSearch(frames)
+
+ model := NewModel(nil)
+ model.frames = frames
+ model.selectedIdx = midDepthFrameIndex(frames)
+ model.subtreeSet = computeSubtreeSetInto(model.frames, model.selectedIdx, model.subtreeSet)
+
+ b.Run(tc.label, func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ model.applySearchQuery(queries[i%len(queries)])
+ benchIntSink = len(model.matchIndices)
+ }
+ })
+ }
+}
+
+func BenchmarkSpringUpdate(b *testing.B) {
+ // Performance target: 500 active springs should update in < 1ms per tick.
+ counts := []int{100, 500, 2000}
+ const (
+ angularVelocity = 6.0
+ damping = 1.0
+ )
+
+ for _, count := range counts {
+ b.Run(fmt.Sprintf("%d_springs", count), func(b *testing.B) {
+ springs := make([]harmonica.Spring, count)
+ current := make([]float64, count)
+ velocity := make([]float64, count)
+ target := make([]float64, count)
+
+ for idx := range springs {
+ springs[idx] = harmonica.NewSpring(harmonica.FPS(30), angularVelocity, damping)
+ current[idx] = float64(idx)
+ target[idx] = float64(idx + 8)
+ }
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ for idx := range springs {
+ current[idx], velocity[idx] = springs[idx].Update(current[idx], velocity[idx], target[idx])
+ }
+ benchFloatSink = current[count-1]
+ }
+ })
+ }
+}
+
+func BenchmarkAnimationTick(b *testing.B) {
+ // Performance target: 500 animated frames should complete in < 1ms per tick.
+ // Allocation target: zero allocs/op in the tick + CurrentFrames path.
+ counts := []int{100, 500, 2000}
+
+ for _, count := range counts {
+ b.Run(fmt.Sprintf("%d_frames", count), func(b *testing.B) {
+ state := NewAnimationState(30, 6.0, 1.0)
+ base := linearFrames(count, 0, 10)
+ target := linearFrames(count, 5, 20)
+ state.SetTargets(base)
+ state.SetTargets(target)
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ if !state.Tick(0) {
+ for idx := range state.springs {
+ state.springs[idx].targetCol += 3
+ state.springs[idx].targetW += 2
+ }
+ state.settled = false
+ }
+ frames := state.CurrentFrames()
+ benchIntSink = frames[len(frames)-1].Width
+ }
+ })
+ }
+}
+
+func BenchmarkZoomTransition(b *testing.B) {
+ // Performance target: zoom-in transition should stay below 1ms/op.
+ snapshot := generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth)
+ model := NewModel(nil)
+ model.width = 120
+ model.height = 40
+ model.snapshot = snapshot
+ model.rebuildFrames(false)
+ if len(model.frames) == 0 {
+ b.Fatal("zoom benchmark requires non-empty initial layout")
+ }
+ zoomPath := model.frames[midDepthFrameIndex(model.frames)].Path
+
+ b.Run("zoom_in", func(b *testing.B) {
+ benchModel := model
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ benchModel.zoomReset()
+ benchModel.selectedIdx = frameIndexByPath(benchModel.frames, zoomPath)
+ benchModel.zoomIn()
+ benchIntSink = len(benchModel.targetFrames)
+ }
+ })
+
+ b.Run("undo_zoom", func(b *testing.B) {
+ benchModel := model
+ benchModel.selectedIdx = frameIndexByPath(benchModel.frames, zoomPath)
+ benchModel.zoomIn()
+ if len(benchModel.zoomStack) == 0 {
+ b.Fatal("undo benchmark requires an active zoom stack")
+ }
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ benchModel.zoomUndo()
+ benchIntSink = len(benchModel.frames)
+
+ benchModel.selectedIdx = frameIndexByPath(benchModel.frames, zoomPath)
+ benchModel.zoomIn()
+ }
+ })
+}
+
+func BenchmarkLiveTrieIngestAndSnapshot(b *testing.B) {
+ // Performance target: ingest+snapshot pipeline should remain below 200us/op for small/medium cycles.
+ counts := []int{100, 1000, 10000}
+ for _, count := range counts {
+ b.Run(fmt.Sprintf("%d_events", count), func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ for eventIdx := 0; eventIdx < count; eventIdx++ {
+ traceID := types.SYS_ENTER_READ
+ if eventIdx%2 == 0 {
+ traceID = types.SYS_ENTER_WRITE
+ }
+ pair := newBenchmarkPair(
+ fmt.Sprintf("worker-%d", eventIdx%4),
+ traceID,
+ uint32(1000+(eventIdx%64)),
+ uint32(200000+eventIdx),
+ buildBenchmarkPath(8, 6, eventIdx),
+ )
+ liveTrie.Ingest(pair)
+ pair.Recycle()
+ }
+
+ payload, _ := liveTrie.SnapshotJSON()
+ var snapshot snapshotNode
+ if err := json.Unmarshal(payload, &snapshot); err != nil {
+ b.Fatalf("snapshot decode failed: %v", err)
+ }
+ benchFramesSink = BuildTerminalLayout(&snapshot, 120, 40)
+ }
+ })
+ }
+}
+
+func BenchmarkResizeRelayout(b *testing.B) {
+ // Performance target: resize relayout cost should match BuildTerminalLayout (< 500us medium@120col).
+ snapshot := generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ frames120 := BuildTerminalLayout(snapshot, 120, 40)
+ frames80 := BuildTerminalLayout(snapshot, 80, 24)
+ benchFramesSink = BuildTerminalLayout(snapshot, 120, 40)
+ benchIntSink = len(frames120) + len(frames80) + len(benchFramesSink)
+ }
+}
+
+func benchmarkFramesForCount(frameCount int) []tuiFrame {
+ var snapshot *snapshotNode
+ switch frameCount {
+ case 100:
+ snapshot = generateTestSnapshot(fixtureDeepDepth, fixtureDeepBreadth)
+ case 1000:
+ snapshot = generateTestSnapshot(20, 5)
+ case 5000:
+ snapshot = generateTestSnapshot(fixtureWideDepth, fixtureWideBreadth)
+ default:
+ snapshot = generateTestSnapshot(10, 5)
+ }
+ return BuildTerminalLayout(snapshot, 200, 80)
+}
+
+func decorateFramesForSearch(frames []tuiFrame) {
+ for idx := range frames {
+ switch idx % 6 {
+ case 0:
+ frames[idx].Name = "sys_enter_read"
+ case 1:
+ frames[idx].Name = "sys_enter_write"
+ case 2:
+ frames[idx].Name = "read_cache_buffer"
+ case 3:
+ frames[idx].Name = "path:/srv/app/api"
+ case 4:
+ frames[idx].Name = "worker_loop"
+ default:
+ frames[idx].Name = "io_wait"
+ }
+ }
+}
+
+func midDepthFrameIndex(frames []tuiFrame) int {
+ if len(frames) == 0 {
+ return 0
+ }
+ maxDepth := 0
+ for _, frame := range frames {
+ if frame.Depth > maxDepth {
+ maxDepth = frame.Depth
+ }
+ }
+ targetDepth := maxDepth / 2
+ indices := framesAtDepth(frames, targetDepth)
+ if len(indices) == 0 {
+ return len(frames) / 2
+ }
+ return indices[len(indices)/2]
+}
+
+func frameIndexByPath(frames []tuiFrame, path string) int {
+ for idx, frame := range frames {
+ if frame.Path == path {
+ return idx
+ }
+ }
+ return 0
+}
+
+func linearFrames(count, colOffset, width int) []tuiFrame {
+ frames := make([]tuiFrame, count)
+ for idx := 0; idx < count; idx++ {
+ path := fmt.Sprintf("root%snode-%d", pathSeparator, idx)
+ frames[idx] = tuiFrame{
+ Name: fmt.Sprintf("node-%d", idx),
+ Path: path,
+ Col: colOffset + idx,
+ Row: idx % 8,
+ Width: width,
+ }
+ }
+ return frames
+}
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
new file mode 100644
index 0000000..06e6d0d
--- /dev/null
+++ b/internal/tui/flamegraph/controls.go
@@ -0,0 +1,173 @@
+package flamegraph
+
+import (
+ "fmt"
+ "strings"
+
+ common "ior/internal/tui/common"
+
+ "charm.land/lipgloss/v2"
+)
+
+func (m *Model) togglePause() {
+ m.paused = !m.paused
+}
+
+func (m *Model) clearSnapshotState(clearSearch bool) {
+ m.zoomRoot = nil
+ m.zoomPath = ""
+ m.zoomStack = nil
+ m.selectedIdx = 0
+ m.snapshot = nil
+ m.globalTotal = 0
+ m.frames = nil
+ m.targetFrames = nil
+ m.matchIndices = make(map[int]bool)
+ m.filterVisible = make(map[int]bool)
+ m.subtreeSet = make(map[int]bool)
+ m.hasNavigableSnapshot = false
+ if clearSearch {
+ m.searchQuery = ""
+ }
+}
+
+func (m *Model) resetBaseline() {
+ if m.liveTrie != nil {
+ m.liveTrie.Reset()
+ }
+ m.clearSnapshotState(true)
+ m.statusMessage = "Baseline reset"
+}
+
+func (m *Model) cycleFieldOrder() {
+ if len(m.fieldPresets) == 0 {
+ return
+ }
+ m.fieldIndex = (m.fieldIndex + 1) % len(m.fieldPresets)
+ nextPreset := m.fieldPresets[m.fieldIndex]
+ if m.liveTrie != nil {
+ if err := m.liveTrie.Reconfigure(nextPreset); err != nil {
+ m.statusMessage = "Field order error: " + err.Error()
+ return
+ }
+ }
+ m.clearSnapshotState(false)
+ m.statusMessage = "Order: " + strings.Join(nextPreset, "/")
+}
+
+func (m *Model) toggleCountField() {
+ next := "bytes"
+ if m.countField == "bytes" {
+ next = "count"
+ }
+ if m.liveTrie != nil {
+ if err := m.liveTrie.SetCountField(next); err != nil {
+ m.statusMessage = "Metric toggle error: " + err.Error()
+ return
+ }
+ }
+ m.countField = next
+ m.clearSnapshotState(false)
+ m.statusMessage = "Metric: " + m.countFieldLabel() + " (new baseline)"
+}
+
+func (m *Model) toggleHelp() {
+ m.showHelp = !m.showHelp
+}
+
+func (m Model) toolbarLine() string {
+ state := lipgloss.NewStyle().Foreground(common.ColorPrimary).Render("[LIVE]")
+ if m.paused {
+ 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())
+ 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
+ }
+ return padOrTrim(line, width)
+}
+
+func (m Model) helpOverlay() string {
+ width := m.width
+ 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"
+ 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)
+ }
+ metric := m.countFieldLabel()
+ shareLabel := fmt.Sprintf("%.2f%% of total %s", systemShare, metric)
+ if strings.TrimSpace(m.searchQuery) != "" && len(m.matchIndices) > 0 {
+ filterTotal, _ := filterCoverageTotals(m.frames, m.matchIndices, m.globalTotal)
+ if filterTotal > 0 {
+ selectedFilterTotal := filterCoverageTotalForPath(m.frames, m.matchIndices, frame.Path)
+ filterShare := percentOfTotal(selectedFilterTotal, filterTotal)
+ shareLabel = fmt.Sprintf("%.2f%% of filtered %s", filterShare, metric)
+ }
+ }
+ line := fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total(%s):%d | %s",
+ mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, m.countFieldLabel(), frame.Total, shareLabel)
+ 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"
+ }
+ idx := m.fieldIndex
+ if idx < 0 {
+ idx = 0
+ }
+ if idx >= len(m.fieldPresets) {
+ idx = len(m.fieldPresets) - 1
+ }
+ return strings.Join(m.fieldPresets[idx], "/")
+}
+
+func (m Model) countFieldLabel() string {
+ switch m.countField {
+ case "count":
+ return "events"
+ case "bytes":
+ return "bytes"
+ default:
+ return m.countField
+ }
+}
diff --git a/internal/tui/flamegraph/doc.go b/internal/tui/flamegraph/doc.go
new file mode 100644
index 0000000..7982ae9
--- /dev/null
+++ b/internal/tui/flamegraph/doc.go
@@ -0,0 +1,2 @@
+// Package flamegraph renders the interactive terminal flamegraph dashboard tab.
+package flamegraph
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
new file mode 100644
index 0000000..cc208ae
--- /dev/null
+++ b/internal/tui/flamegraph/model.go
@@ -0,0 +1,1027 @@
+package flamegraph
+
+import (
+ "encoding/json"
+ "fmt"
+ "image/color"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+
+ common "ior/internal/tui/common"
+
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+)
+
+type snapshotNode struct {
+ Name string `json:"n"`
+ Value uint64 `json:"v"`
+ Total uint64 `json:"t"`
+ Children []*snapshotNode `json:"c,omitempty"`
+}
+
+type animTickMsg struct{}
+
+const animFrameDuration = 33 * time.Millisecond
+
+// LiveTrieSource is the minimal trie contract needed by the flamegraph TUI model.
+type LiveTrieSource interface {
+ Fields() []string
+ CountField() string
+ Reconfigure([]string) error
+ SetCountField(string) error
+ Reset()
+ Version() uint64
+ SnapshotJSON() ([]byte, uint64)
+}
+
+type zoomState struct {
+ path string
+ previousSelectedIdx int
+}
+
+type flameKeyMap struct {
+ MoveShallower key.Binding
+ MoveDeeper key.Binding
+ PrevSibling key.Binding
+ NextSibling key.Binding
+ JumpTop key.Binding
+ JumpRoot key.Binding
+ ZoomIn key.Binding
+ ZoomUndo key.Binding
+ ZoomReset key.Binding
+}
+
+func defaultFlameKeyMap() flameKeyMap {
+ return flameKeyMap{
+ MoveShallower: key.NewBinding(key.WithKeys("j", "down")),
+ MoveDeeper: key.NewBinding(key.WithKeys("k", "up")),
+ PrevSibling: key.NewBinding(key.WithKeys("h", "left")),
+ NextSibling: key.NewBinding(key.WithKeys("l", "right")),
+ JumpTop: key.NewBinding(key.WithKeys("pgup", "pageup")),
+ JumpRoot: key.NewBinding(key.WithKeys("pgdown", "pgdn", "pagedown")),
+ ZoomIn: key.NewBinding(key.WithKeys("enter")),
+ ZoomUndo: key.NewBinding(key.WithKeys("backspace", "u", "esc")),
+ ZoomReset: key.NewBinding(),
+ }
+}
+
+// Model is the Bubble Tea model for the TUI flamegraph tab.
+type Model struct {
+ liveTrie LiveTrieSource
+ lastVersion uint64
+ snapshot *snapshotNode
+ globalTotal uint64
+
+ frames []tuiFrame
+ targetFrames []tuiFrame
+ width int
+ height int
+
+ selectedIdx int
+ zoomStack []zoomState
+ zoomRoot *snapshotNode
+ zoomPath string
+
+ searchActive bool
+ searchInput textinput.Model
+ searchQuery string
+ matchIndices map[int]bool
+ filterVisible map[int]bool
+ subtreeSet map[int]bool
+ showHelp bool
+ statusMessage string
+ lastKeyDebug string
+
+ fieldPresets [][]string
+ fieldIndex int
+ countField string
+
+ animation AnimationState
+ animating bool
+ paused bool
+ // hasNavigableSnapshot flips once we have at least one selectable non-root frame.
+ hasNavigableSnapshot bool
+ isDark bool
+ keys flameKeyMap
+}
+
+// tuiFrame stores one terminal flamegraph frame cell.
+type tuiFrame struct {
+ Name string
+ Col int
+ Row int
+ Width int
+ Total uint64
+ Percent float64
+ Fill color.Color
+ Depth int
+ Path string
+}
+
+// NewModel constructs a flamegraph tab model with default state.
+func NewModel(liveTrie LiveTrieSource) Model {
+ searchInput := textinput.New()
+ searchInput.Prompt = "/"
+ searchInput.CharLimit = 0
+ searchInput.SetWidth(32)
+ searchInput.SetStyles(textinput.DefaultStyles(true))
+
+ m := Model{
+ liveTrie: liveTrie,
+ matchIndices: make(map[int]bool),
+ filterVisible: make(map[int]bool),
+ subtreeSet: make(map[int]bool),
+ searchInput: searchInput,
+ fieldPresets: [][]string{
+ {"comm", "tracepoint", "path"},
+ {"path", "tracepoint", "comm"},
+ {"tracepoint", "comm", "path"},
+ {"pid", "tracepoint", "path"},
+ {"comm", "path", "tracepoint"},
+ },
+ isDark: true,
+ keys: defaultFlameKeyMap(),
+ animation: NewAnimationState(30, 6.0, 1.0),
+ countField: "count",
+ }
+ m.syncFieldPresetToTrie()
+ m.syncCountFieldToTrie()
+ return m
+}
+
+// Init starts the flamegraph model.
+func (m Model) Init() tea.Cmd {
+ return nil
+}
+
+// Update handles incoming messages.
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case animTickMsg:
+ if !m.animating {
+ return m, nil
+ }
+ m.animating = m.animation.Tick(0)
+ m.frames = m.animation.CurrentFrames()
+ m.clampSelection()
+ m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ if m.animating {
+ return m, animTickCmd()
+ }
+ return m, nil
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ m.rebuildFrames(true)
+ if m.animating {
+ return m, animTickCmd()
+ }
+ 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 isCycleMetricKey(msg):
+ handled = true
+ m.toggleCountField()
+ 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):
+ handled = true
+ m.moveVerticalWithFallback(-1, 1, -1)
+ case isMoveDeeperKey(msg, m.keys):
+ 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)
+ case isJumpTopKey(msg, m.keys):
+ handled = true
+ m.jumpToTop()
+ case isJumpRootKey(msg, m.keys):
+ handled = true
+ m.jumpToRoot()
+ }
+ if m.selectedIdx != prev {
+ m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ }
+ m.recordKeyDebug(msg, handled, m.selectedIdx != prev)
+ }
+ return m, nil
+}
+
+// ConsumesKey reports whether the flamegraph should handle a key press before
+// dashboard- or app-level shortcuts.
+func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool {
+ if m.searchActive {
+ return true
+ }
+ switch {
+ case isSearchOpenKey(msg),
+ isNextMatchKey(msg),
+ isPrevMatchKey(msg),
+ isPauseKey(msg),
+ isResetBaselineKey(msg),
+ isCycleOrderKey(msg),
+ isCycleMetricKey(msg),
+ isHelpToggleKey(msg):
+ return true
+ case isZoomInKey(msg, m.keys),
+ isZoomUndoKey(msg, m.keys),
+ isZoomResetKey(msg, m.keys),
+ isMoveShallowerKey(msg, m.keys),
+ isMoveDeeperKey(msg, m.keys),
+ isPrevSiblingKey(msg, m.keys),
+ isNextSiblingKey(msg, m.keys),
+ isJumpTopKey(msg, m.keys),
+ isJumpRootKey(msg, m.keys):
+ return true
+ default:
+ return false
+ }
+}
+
+// View renders the flamegraph viewport.
+func (m Model) View() tea.View {
+ extraLines := 1 // selection status line
+ if m.showHelp {
+ extraLines++
+ }
+ renderHeight := m.height - extraLines
+ if renderHeight < 3 {
+ renderHeight = 3
+ }
+
+ content := RenderTerminalView(m.frames, m.width, renderHeight, m.selectedIdx, m.subtreeSet, m.matchIndices, m.filterVisible, m.globalTotal, m.countFieldLabel(), m.isDark, m.searchActive, m.searchQuery)
+ content = replaceHeaderLine(content, m.toolbarLine())
+ if m.searchActive {
+ content = replaceFooterLine(content, m.searchFooter())
+ }
+ 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()
+ }
+ return tea.NewView(content)
+}
+
+// SetLiveTrie updates the data source used by the flamegraph model.
+func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) {
+ m.liveTrie = liveTrie
+ m.syncFieldPresetToTrie()
+ m.syncCountFieldToTrie()
+ m.lastVersion = 0
+ m.snapshot = nil
+ m.globalTotal = 0
+ m.selectedIdx = 0
+ m.frames = nil
+ m.targetFrames = nil
+ m.zoomStack = nil
+ m.zoomRoot = nil
+ m.zoomPath = ""
+ m.subtreeSet = make(map[int]bool)
+ m.filterVisible = make(map[int]bool)
+ m.animation = NewAnimationState(30, 6.0, 1.0)
+ m.animating = false
+ m.hasNavigableSnapshot = false
+}
+
+func (m *Model) syncFieldPresetToTrie() {
+ if m.liveTrie == nil {
+ m.fieldIndex = 0
+ return
+ }
+ fields := m.liveTrie.Fields()
+ if len(fields) == 0 {
+ m.fieldIndex = 0
+ return
+ }
+ for idx, preset := range m.fieldPresets {
+ if slices.Equal(preset, fields) {
+ m.fieldIndex = idx
+ return
+ }
+ }
+ custom := slices.Clone(fields)
+ m.fieldPresets = append([][]string{custom}, m.fieldPresets...)
+ m.fieldIndex = 0
+}
+
+func (m *Model) syncCountFieldToTrie() {
+ if m.liveTrie == nil {
+ m.countField = "count"
+ return
+ }
+ field := strings.TrimSpace(m.liveTrie.CountField())
+ if field == "" {
+ field = "count"
+ }
+ m.countField = field
+}
+
+// RefreshFromLiveTrie loads a new snapshot when the source version changes.
+func (m *Model) RefreshFromLiveTrie() bool {
+ if m.liveTrie == nil {
+ return false
+ }
+ // Once a snapshot exists, paused mode must freeze it regardless of current
+ // navigability so selection and percentages remain stable.
+ if m.paused && m.snapshot != nil {
+ return false
+ }
+ version := m.liveTrie.Version()
+ if version == m.lastVersion && m.snapshot != nil {
+ return false
+ }
+
+ payload, version := m.liveTrie.SnapshotJSON()
+ var snapshot snapshotNode
+ if err := json.Unmarshal(payload, &snapshot); err != nil {
+ return false
+ }
+ m.snapshot = &snapshot
+ m.globalTotal = snapshotTotal(m.snapshot)
+ if m.zoomPath != "" {
+ m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath)
+ } else {
+ m.zoomRoot = nil
+ }
+ m.rebuildFrames(true)
+ m.lastVersion = version
+ return true
+}
+
+// LastVersion returns the latest snapshot version loaded into the model.
+func (m Model) LastVersion() uint64 {
+ return m.lastVersion
+}
+
+// HasSnapshot reports whether the flamegraph model has loaded at least one snapshot.
+func (m Model) HasSnapshot() bool {
+ return m.snapshot != nil
+}
+
+// AnimationCmd returns a frame animation tick command when animation is active.
+func (m Model) AnimationCmd() tea.Cmd {
+ if !m.animating {
+ return nil
+ }
+ return animTickCmd()
+}
+
+// Paused reports whether live refresh is paused.
+func (m Model) Paused() bool {
+ return m.paused
+}
+
+// SetViewport updates model render dimensions.
+func (m *Model) SetViewport(width, height int) {
+ m.width = width
+ m.height = height
+ m.rebuildFrames(true)
+}
+
+// SetDarkMode sets the active color theme mode.
+func (m *Model) SetDarkMode(isDark bool) {
+ m.isDark = isDark
+ m.searchInput.SetStyles(textinput.DefaultStyles(isDark))
+}
+
+func (m *Model) rebuildFrames(animate bool) {
+ prevPath := ""
+ if len(m.frames) > 0 && m.selectedIdx >= 0 && m.selectedIdx < len(m.frames) {
+ prevPath = m.frames[m.selectedIdx].Path
+ }
+
+ var root *snapshotNode
+ rootPath := ""
+ if m.zoomRoot != nil {
+ root = m.zoomRoot
+ rootPath = m.zoomPath
+ } else {
+ root = m.snapshot
+ }
+ m.targetFrames = buildTerminalLayoutWithPath(root, m.width, m.height, rootPath)
+ m.animation.SetTargets(m.targetFrames)
+ if animate && len(m.frames) > 0 && !m.animation.Settled() {
+ m.animating = true
+ m.frames = m.animation.CurrentFrames()
+ } else {
+ m.animating = false
+ m.frames = append(m.frames[:0], m.targetFrames...)
+ }
+ if len(m.frames) > 1 {
+ m.hasNavigableSnapshot = true
+ }
+ m.restoreSelectionByPath(prevPath)
+ m.clampSelection()
+ m.recomputeFilterState()
+ m.ensureSelectionNavigable()
+ m.ensureSelectionVisible()
+ m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+}
+
+func (m *Model) restoreSelectionByPath(path string) {
+ if path == "" || len(m.frames) == 0 {
+ return
+ }
+ if idx := m.frameIndexByPath(path); idx >= 0 {
+ m.selectedIdx = idx
+ return
+ }
+ for idx, frame := range m.frames {
+ if hasPathBoundaryPrefix(path, frame.Path) || hasPathBoundaryPrefix(frame.Path, path) {
+ m.selectedIdx = idx
+ return
+ }
+ }
+}
+
+func (m Model) frameIndexByPath(path string) int {
+ for idx, frame := range m.frames {
+ if frame.Path == path {
+ return idx
+ }
+ }
+ return -1
+}
+
+func (m *Model) zoomIn() {
+ if len(m.frames) == 0 || m.snapshot == nil {
+ m.statusMessage = "Zoom unavailable: no frame selected"
+ return
+ }
+ m.clampSelection()
+ selectedPath := m.frames[m.selectedIdx].Path
+ if selectedPath == m.currentRootPath() {
+ m.statusMessage = "Zoom unchanged: selected frame is current view root"
+ return
+ }
+ target := findNodeByPath(m.snapshot, selectedPath)
+ if target == nil {
+ m.statusMessage = "Zoom failed: selected node is unavailable"
+ return
+ }
+ m.zoomStack = append(m.zoomStack, zoomState{
+ path: m.zoomPath,
+ previousSelectedIdx: m.selectedIdx,
+ })
+ m.zoomRoot = target
+ m.zoomPath = selectedPath
+ m.selectedIdx = 0
+ m.rebuildFrames(true)
+ m.statusMessage = "Zoom: " + compactFramePath(selectedPath)
+}
+
+func (m *Model) zoomUndo() {
+ if len(m.zoomStack) == 0 || m.snapshot == nil {
+ m.statusMessage = "Zoom undo unavailable"
+ return
+ }
+ last := m.zoomStack[len(m.zoomStack)-1]
+ m.zoomStack = m.zoomStack[:len(m.zoomStack)-1]
+ m.zoomPath = last.path
+ if m.zoomPath == "" {
+ m.zoomRoot = nil
+ } else {
+ m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath)
+ }
+ m.selectedIdx = last.previousSelectedIdx
+ m.rebuildFrames(true)
+ if m.zoomPath == "" {
+ m.statusMessage = "Zoom: root"
+ return
+ }
+ m.statusMessage = "Zoom: " + compactFramePath(m.zoomPath)
+}
+
+func (m *Model) zoomReset() {
+ if m.zoomRoot == nil && len(m.zoomStack) == 0 {
+ m.statusMessage = "Zoom already at root"
+ return
+ }
+ m.zoomRoot = nil
+ m.zoomPath = ""
+ m.zoomStack = nil
+ m.rebuildFrames(false)
+ m.statusMessage = "Zoom reset to root"
+}
+
+func (m *Model) moveVertical(delta int) {
+ if len(m.frames) == 0 {
+ return
+ }
+ m.clampSelection()
+ m.ensureSelectionNavigable()
+ current := m.frames[m.selectedIdx]
+ targetDepth := current.Depth + delta
+ targets := m.framesAtDepth(targetDepth)
+ if len(targets) == 0 {
+ return
+ }
+ best := targets[0]
+ bestDist := abs(m.frames[best].Col - current.Col)
+ for _, idx := range targets[1:] {
+ dist := abs(m.frames[idx].Col - current.Col)
+ if dist < bestDist {
+ best = idx
+ bestDist = dist
+ }
+ }
+ m.selectedIdx = best
+}
+
+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
+ if next < 0 {
+ next = 0
+ }
+ if next >= len(siblings) {
+ next = len(siblings) - 1
+ }
+ m.selectedIdx = siblings[next]
+ if m.selectedIdx == before {
+ m.moveTraversal(delta)
+ }
+}
+
+func (m *Model) jumpToTop() {
+ if len(m.frames) == 0 {
+ return
+ }
+ m.clampSelection()
+ m.ensureSelectionNavigable()
+
+ include := m.navigableFrameSet()
+ currentCol := m.frames[m.selectedIdx].Col
+ bestIdx := -1
+ bestDepth := -1
+ bestDist := int(^uint(0) >> 1)
+
+ for idx, frame := range m.frames {
+ if include != nil && !include[idx] {
+ continue
+ }
+ dist := abs(frame.Col - currentCol)
+ if frame.Depth > bestDepth {
+ bestDepth = frame.Depth
+ bestIdx = idx
+ bestDist = dist
+ continue
+ }
+ if frame.Depth == bestDepth {
+ if dist < bestDist || (dist == bestDist && frame.Col < m.frames[bestIdx].Col) {
+ bestIdx = idx
+ bestDist = dist
+ }
+ }
+ }
+ if bestIdx >= 0 {
+ m.selectedIdx = bestIdx
+ }
+}
+
+func (m *Model) jumpToRoot() {
+ if len(m.frames) == 0 {
+ return
+ }
+ m.clampSelection()
+ m.ensureSelectionNavigable()
+
+ rootPath := m.currentRootPath()
+ if rootPath != "" {
+ if idx := m.frameIndexByPath(rootPath); idx >= 0 {
+ if !m.filterActive() || m.frameNavigable(idx) {
+ m.selectedIdx = idx
+ return
+ }
+ }
+ }
+
+ include := m.navigableFrameSet()
+ currentCol := m.frames[m.selectedIdx].Col
+ bestIdx := -1
+ bestDepth := int(^uint(0) >> 1)
+ bestDist := int(^uint(0) >> 1)
+ for idx, frame := range m.frames {
+ if include != nil && !include[idx] {
+ continue
+ }
+ dist := abs(frame.Col - currentCol)
+ if frame.Depth < bestDepth {
+ bestDepth = frame.Depth
+ bestDist = dist
+ bestIdx = idx
+ continue
+ }
+ if frame.Depth == bestDepth {
+ if dist < bestDist || (dist == bestDist && frame.Col < m.frames[bestIdx].Col) {
+ bestDist = dist
+ bestIdx = idx
+ }
+ }
+ }
+ if bestIdx >= 0 {
+ m.selectedIdx = bestIdx
+ }
+}
+
+func framesAtDepth(frames []tuiFrame, depth int) []int {
+ return framesAtDepthFiltered(frames, depth, nil)
+}
+
+func framesAtDepthFiltered(frames []tuiFrame, depth int, include map[int]bool) []int {
+ if depth < 0 {
+ return nil
+ }
+ indices := make([]int, 0)
+ for idx, frame := range frames {
+ if include != nil && !include[idx] {
+ continue
+ }
+ if frame.Depth == depth {
+ indices = append(indices, idx)
+ }
+ }
+ sort.Slice(indices, func(i, j int) bool {
+ return frames[indices[i]].Col < frames[indices[j]].Col
+ })
+ return indices
+}
+
+func indexOf(values []int, target int) int {
+ for idx, value := range values {
+ if value == target {
+ return idx
+ }
+ }
+ return -1
+}
+
+func (m *Model) clampSelection() {
+ if len(m.frames) == 0 {
+ m.selectedIdx = 0
+ return
+ }
+ if m.selectedIdx < 0 {
+ m.selectedIdx = 0
+ }
+ if m.selectedIdx >= len(m.frames) {
+ m.selectedIdx = len(m.frames) - 1
+ }
+}
+
+func abs(v int) int {
+ if v < 0 {
+ return -v
+ }
+ return v
+}
+
+func animTickCmd() tea.Cmd {
+ return tea.Tick(animFrameDuration, func(time.Time) tea.Msg { return animTickMsg{} })
+}
+
+func (m Model) currentRootPath() string {
+ if m.zoomPath != "" {
+ return m.zoomPath
+ }
+ if len(m.frames) == 0 {
+ return ""
+ }
+ return m.frames[0].Path
+}
+
+func (m Model) filterActive() bool {
+ return strings.TrimSpace(m.searchQuery) != ""
+}
+
+func (m Model) navigableFrameSet() map[int]bool {
+ if !m.filterActive() {
+ return nil
+ }
+ return m.filterVisible
+}
+
+func (m Model) framesAtDepth(depth int) []int {
+ return framesAtDepthFiltered(m.frames, depth, m.navigableFrameSet())
+}
+
+func (m Model) frameNavigable(idx int) bool {
+ if idx < 0 || idx >= len(m.frames) {
+ return false
+ }
+ if !m.filterActive() {
+ return true
+ }
+ return m.filterVisible[idx]
+}
+
+func (m *Model) ensureSelectionNavigable() {
+ if len(m.frames) == 0 {
+ m.selectedIdx = 0
+ return
+ }
+ m.clampSelection()
+ if m.frameNavigable(m.selectedIdx) {
+ return
+ }
+
+ if len(m.matchIndices) > 0 {
+ for _, idx := range orderedMatchIndices(m.matchIndices) {
+ if m.frameNavigable(idx) {
+ m.selectedIdx = idx
+ return
+ }
+ }
+ }
+
+ for idx := range m.frames {
+ if m.frameNavigable(idx) {
+ m.selectedIdx = idx
+ return
+ }
+ }
+}
+
+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
+ }
+ return msg.Text
+}
+
+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 {
+ k := keyString(msg)
+ return k == "p" || k == " " || k == "space" || msg.Code == tea.KeySpace
+}
+func isResetBaselineKey(msg tea.KeyPressMsg) bool {
+ return keyString(msg) == "r"
+}
+func isCycleOrderKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "o" }
+func isCycleMetricKey(msg tea.KeyPressMsg) bool {
+ return keyString(msg) == "b"
+}
+func isHelpToggleKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "?" }
+
+func isZoomInKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ return key.Matches(msg, keys.ZoomIn) || msg.Code == tea.KeyEnter || strings.EqualFold(keyString(msg), "enter")
+}
+
+func isZoomUndoKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ return key.Matches(msg, keys.ZoomUndo) || msg.Code == tea.KeyBackspace || msg.Code == tea.KeyEsc
+}
+
+func isZoomResetKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ return key.Matches(msg, keys.ZoomReset)
+}
+
+func isMoveShallowerKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := keyString(msg)
+ return key.Matches(msg, keys.MoveShallower) || msg.Code == tea.KeyDown || keyMatchesDirection(k, "down", 'B')
+}
+
+func isMoveDeeperKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := keyString(msg)
+ return key.Matches(msg, keys.MoveDeeper) || msg.Code == tea.KeyUp || keyMatchesDirection(k, "up", 'A')
+}
+
+func isPrevSiblingKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := keyString(msg)
+ return key.Matches(msg, keys.PrevSibling) || msg.Code == tea.KeyLeft || keyMatchesDirection(k, "left", 'D')
+}
+
+func isNextSiblingKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := keyString(msg)
+ return key.Matches(msg, keys.NextSibling) || msg.Code == tea.KeyRight || keyMatchesDirection(k, "right", 'C')
+}
+
+func isJumpTopKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := strings.ToLower(keyString(msg))
+ return key.Matches(msg, keys.JumpTop) || msg.Code == tea.KeyPgUp || k == "pgup" || k == "pageup"
+}
+
+func isJumpRootKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := strings.ToLower(keyString(msg))
+ return key.Matches(msg, keys.JumpRoot) || msg.Code == tea.KeyPgDown || k == "pgdown" || k == "pgdn" || k == "pagedown"
+}
+
+func keyMatchesDirection(keyName, plain string, ansiFinal byte) bool {
+ if keyName == plain || strings.HasSuffix(keyName, "+"+plain) {
+ return true
+ }
+ return isArrowEscapeSequence(keyName, ansiFinal)
+}
+
+func isArrowEscapeSequence(value string, ansiFinal byte) bool {
+ if len(value) < 3 || value[0] != '\x1b' {
+ return false
+ }
+ last := value[len(value)-1]
+ if last != ansiFinal {
+ return false
+ }
+ return value[1] == '[' || value[1] == 'O'
+}
+
+func (m Model) visibleRowOffset() int {
+ if len(m.frames) == 0 {
+ return 0
+ }
+ availableRows := m.height - 2 // toolbar + status
+ if availableRows <= 0 {
+ return 0
+ }
+ maxRow := maxFrameRowForSet(m.frames, m.navigableFrameSet())
+ if maxRow+1 <= availableRows {
+ return 0
+ }
+ return maxRow + 1 - availableRows
+}
+
+func (m *Model) ensureSelectionVisible() {
+ if len(m.frames) == 0 {
+ return
+ }
+ m.clampSelection()
+ m.ensureSelectionNavigable()
+ if !m.frameNavigable(m.selectedIdx) {
+ return
+ }
+ rowOffset := m.visibleRowOffset()
+ selected := m.frames[m.selectedIdx]
+ if selected.Row >= rowOffset {
+ return
+ }
+
+ bestIdx := -1
+ bestScore := int(^uint(0) >> 1)
+ for idx, frame := range m.frames {
+ if !m.frameNavigable(idx) {
+ continue
+ }
+ if frame.Row < rowOffset {
+ continue
+ }
+ score := abs(frame.Row-rowOffset)*1000 + abs(frame.Col-selected.Col)
+ if score < bestScore {
+ bestIdx = idx
+ bestScore = score
+ }
+ }
+ if bestIdx >= 0 {
+ m.selectedIdx = bestIdx
+ }
+}
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
new file mode 100644
index 0000000..74ce8d9
--- /dev/null
+++ b/internal/tui/flamegraph/model_test.go
@@ -0,0 +1,987 @@
+package flamegraph
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ coreflamegraph "ior/internal/flamegraph"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+func TestNewModelDefaults(t *testing.T) {
+ m := NewModel(nil)
+ if m.liveTrie != nil {
+ t.Fatalf("expected nil liveTrie when constructor input is nil")
+ }
+ if m.matchIndices == nil {
+ t.Fatalf("expected matchIndices map to be initialized")
+ }
+ if len(m.fieldPresets) == 0 {
+ t.Fatalf("expected default field presets to be initialized")
+ }
+ if got, want := m.fieldPresets[0], []string{"comm", "tracepoint", "path"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("default field preset[0] = %v, want %v", got, want)
+ }
+ if !m.isDark {
+ t.Fatalf("expected dark mode enabled by default")
+ }
+}
+
+func TestSetViewportAndDarkMode(t *testing.T) {
+ m := NewModel(nil)
+ m.SetViewport(120, 40)
+ m.SetDarkMode(false)
+ if m.width != 120 || m.height != 40 {
+ t.Fatalf("expected viewport 120x40, got %dx%d", m.width, m.height)
+ }
+ if m.isDark {
+ t.Fatalf("expected dark mode to be disabled")
+ }
+}
+
+func TestRefreshFromLiveTrieTracksVersionAndSnapshot(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModel(trie)
+
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected first refresh to load baseline snapshot")
+ }
+ if m.snapshot == nil {
+ t.Fatalf("expected snapshot to be populated after refresh")
+ }
+
+ if changed := m.RefreshFromLiveTrie(); changed {
+ t.Fatalf("expected no refresh when version is unchanged")
+ }
+}
+
+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 TestRefreshFromLiveTriePausedBlocksAfterAnySnapshot(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 freeze after first snapshot even when non-navigable")
+ }
+ if got, want := m.lastVersion, uint64(1); got != want {
+ t.Fatalf("expected paused refresh to keep existing snapshot version, got %d want %d", got, want)
+ }
+}
+
+func TestKeyboardNavigationDeepNarrowTree(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Path: "root"},
+ {Name: "child", Depth: 1, Col: 0, Path: "root" + pathSeparator + "child"},
+ {Name: "leaf", Depth: 2, Col: 0, Path: "root" + pathSeparator + "child" + pathSeparator + "leaf"},
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected selection to move deeper to idx 1, got %d", m.selectedIdx)
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"})
+ if m.selectedIdx != 2 {
+ t.Fatalf("expected selection to move deeper to idx 2, got %d", m.selectedIdx)
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'j'}[0], Text: "j"})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected selection to move shallower to idx 1, got %d", m.selectedIdx)
+ }
+}
+
+func TestKeyboardNavigationShallowWideSiblings(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"},
+ {Name: "C", Depth: 1, Col: 60, Path: "root" + pathSeparator + "C"},
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected first deeper frame to be A, got idx %d", m.selectedIdx)
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"})
+ if m.selectedIdx != 2 {
+ t.Fatalf("expected next sibling B, got idx %d", m.selectedIdx)
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"})
+ if m.selectedIdx != 3 {
+ t.Fatalf("expected next sibling C, got idx %d", m.selectedIdx)
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"})
+ if m.selectedIdx != 3 {
+ t.Fatalf("expected selection to clamp at last sibling, got idx %d", m.selectedIdx)
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'h'}[0], Text: "h"})
+ if m.selectedIdx != 2 {
+ t.Fatalf("expected previous sibling B, got idx %d", m.selectedIdx)
+ }
+}
+
+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 TestPageUpJumpsSelectionToTopMostDepth(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: 40, Path: "root" + pathSeparator + "B"},
+ {Name: "A1", Depth: 2, Col: 0, Path: "root" + pathSeparator + "A" + pathSeparator + "A1"},
+ {Name: "B1", Depth: 2, Col: 40, Path: "root" + pathSeparator + "B" + pathSeparator + "B1"},
+ {Name: "A2", Depth: 3, Col: 0, Path: "root" + pathSeparator + "A" + pathSeparator + "A1" + pathSeparator + "A2"},
+ {Name: "B2", Depth: 3, Col: 40, Path: "root" + pathSeparator + "B" + pathSeparator + "B1" + pathSeparator + "B2"},
+ }
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"B"+pathSeparator+"B1")
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp})
+ if got, want := m.frames[m.selectedIdx].Path, "root"+pathSeparator+"B"+pathSeparator+"B1"+pathSeparator+"B2"; got != want {
+ t.Fatalf("expected pgup to jump to deepest top frame %q, got %q", want, got)
+ }
+}
+
+func TestPageDownJumpsSelectionToCurrentViewRoot(t *testing.T) {
+ m := newZoomModel()
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1")
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyPgDown})
+ if got, want := m.frames[m.selectedIdx].Path, "root"+pathSeparator+"A"; got != want {
+ t.Fatalf("expected pgdn to jump to current zoom root %q, got %q", want, got)
+ }
+}
+
+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 TestSelectionRestoresByPathAcrossLiveRefresh(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")
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ selected := m.frames[m.selectedIdx].Path
+ if selected == "" || selected == "root" {
+ t.Fatalf("expected selection to move off root, got %q", selected)
+ }
+
+ trie.Reset()
+ coreflamegraph.SeedTestLiveFlameData(trie, 2)
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected refresh after live update")
+ }
+ if got := m.frames[m.selectedIdx].Path; got != selected {
+ t.Fatalf("expected selection path to persist across refresh, got %q want %q", got, selected)
+ }
+}
+
+func TestKeyboardNavigationSingleNodeClamped(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{{Name: "root", Depth: 0, Col: 0, Path: "root"}}
+
+ keys := []tea.KeyPressMsg{
+ {Code: []rune{'j'}[0], Text: "j"},
+ {Code: []rune{'k'}[0], Text: "k"},
+ {Code: []rune{'h'}[0], Text: "h"},
+ {Code: []rune{'l'}[0], Text: "l"},
+ {Code: tea.KeyDown},
+ {Code: tea.KeyUp},
+ {Code: tea.KeyLeft},
+ {Code: tea.KeyRight},
+ }
+ for _, keyMsg := range keys {
+ m = pressFlameKey(t, m, keyMsg)
+ if m.selectedIdx != 0 {
+ t.Fatalf("expected single-node selection to stay at idx 0, got %d", m.selectedIdx)
+ }
+ }
+}
+
+func TestArrowDownFallsBackToVisibleDepthFromRoot(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Path: "root"},
+ {Name: "child", Depth: 1, Col: 0, Path: "root" + pathSeparator + "child"},
+ }
+ m.selectedIdx = 0
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected down arrow to move selection to child when root has no shallower row, got %d", m.selectedIdx)
+ }
+}
+
+func TestArrowEscapeSequencesAreRecognized(t *testing.T) {
+ tests := []struct {
+ key string
+ dir string
+ ansiCode byte
+ }{
+ {key: "\x1b[A", dir: "up", ansiCode: 'A'},
+ {key: "\x1b[B", dir: "down", ansiCode: 'B'},
+ {key: "\x1b[C", dir: "right", ansiCode: 'C'},
+ {key: "\x1b[D", dir: "left", ansiCode: 'D'},
+ {key: "\x1bOA", dir: "up", ansiCode: 'A'}, // application mode
+ {key: "\x1bOB", dir: "down", ansiCode: 'B'}, // application mode
+ {key: "\x1b[1;2A", dir: "up", ansiCode: 'A'},
+ }
+ for _, tc := range tests {
+ if !keyMatchesDirection(tc.key, tc.dir, tc.ansiCode) {
+ t.Fatalf("expected key %q to match %s", tc.key, tc.dir)
+ }
+ }
+}
+
+func TestFilteredNavigationSkipsHiddenBranches(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Row: 0, Path: "root"},
+ {Name: "keep", Depth: 1, Col: 0, Row: 1, Path: "root" + pathSeparator + "keep"},
+ {Name: "drop", Depth: 1, Col: 40, Row: 1, Path: "root" + pathSeparator + "drop"},
+ }
+ m.searchQuery = "keep"
+ m.recomputeFilterState()
+ m.selectedIdx = 1
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected sibling navigation to stay on visible filtered branch, got idx %d", m.selectedIdx)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
+ if m.selectedIdx != 0 {
+ t.Fatalf("expected down key to move to visible root ancestor, got idx %d", m.selectedIdx)
+ }
+}
+
+func TestZoomInUndoSingleLevelAndNestedEsc(t *testing.T) {
+ m := newZoomModel()
+
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want {
+ t.Fatalf("expected zoomPath %q, got %q", want, got)
+ }
+ if len(m.zoomStack) != 1 || m.zoomStack[0].path != "" {
+ t.Fatalf("expected one zoom stack entry from root, got %#v", m.zoomStack)
+ }
+ if m.zoomRoot == nil || m.zoomRoot.Name != "A" {
+ t.Fatalf("expected zoomRoot A, got %+v", m.zoomRoot)
+ }
+
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if got, want := m.zoomPath, "root"+pathSeparator+"A"+pathSeparator+"A1"; got != want {
+ t.Fatalf("expected nested zoomPath %q, got %q", want, got)
+ }
+ if len(m.zoomStack) != 2 || m.zoomStack[1].path != "root"+pathSeparator+"A" {
+ t.Fatalf("expected nested zoom stack to preserve parent path, got %#v", m.zoomStack)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
+ if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want {
+ t.Fatalf("expected zoomPath after esc undo %q, got %q", want, got)
+ }
+ if len(m.zoomStack) != 1 {
+ t.Fatalf("expected one stack entry after esc undo, got %d", len(m.zoomStack))
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
+ if m.zoomPath != "" || m.zoomRoot != nil || len(m.zoomStack) != 0 {
+ t.Fatalf("expected second esc undo to return to root state, got path=%q root=%+v stack=%d", m.zoomPath, m.zoomRoot, len(m.zoomStack))
+ }
+}
+
+func TestZoomResetToRoot(t *testing.T) {
+ m := newZoomModel()
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if m.zoomPath == "" || len(m.zoomStack) == 0 {
+ t.Fatalf("expected nested zoom before reset")
+ }
+
+ m.zoomReset()
+ if m.zoomPath != "" || m.zoomRoot != nil || len(m.zoomStack) != 0 {
+ t.Fatalf("expected explicit zoom reset to clear zoom stack, got path=%q root=%+v stack=%d", m.zoomPath, m.zoomRoot, len(m.zoomStack))
+ }
+}
+
+func TestZoomInOnCurrentRootSetsStatusMessage(t *testing.T) {
+ m := newZoomModel()
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root")
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if m.zoomPath != "" {
+ t.Fatalf("expected zoom path to remain root, got %q", m.zoomPath)
+ }
+ if m.statusMessage != "Zoom unchanged: selected frame is current view root" {
+ t.Fatalf("unexpected status message: %q", m.statusMessage)
+ }
+}
+
+func TestZoomTransitionAnimatesToNewLayout(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")
+ }
+ 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)
+ }
+}
+
+func TestSearchLifecycleAndMatchNavigation(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "alpha", Path: "root" + pathSeparator + "alpha"},
+ {Name: "beta", Path: "root" + pathSeparator + "beta"},
+ {Name: "alphabet", Path: "root" + pathSeparator + "alphabet"},
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"})
+ if !m.searchActive {
+ t.Fatalf("expected search mode to activate on '/'")
+ }
+ for _, r := range []rune{'a', 'l', 'p'} {
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+
+ if m.searchActive {
+ t.Fatalf("expected search mode to close on enter")
+ }
+ if got := len(m.matchIndices); got != 2 {
+ t.Fatalf("expected 2 matches for 'alp', got %d", got)
+ }
+ first := m.selectedIdx
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'n'}[0], Text: "n"})
+ if m.selectedIdx == first {
+ t.Fatalf("expected 'n' to jump to next match")
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'N'}[0], Text: "N"})
+ if m.selectedIdx != first {
+ t.Fatalf("expected 'N' to jump back to previous match")
+ }
+}
+
+func TestSearchEscapeClearsState(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{{Name: "alpha", Path: "root" + pathSeparator + "alpha"}}
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'a'}[0], Text: "a"})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
+
+ if m.searchActive {
+ t.Fatalf("expected search mode to close on escape")
+ }
+ if m.searchQuery != "" || len(m.matchIndices) != 0 {
+ t.Fatalf("expected search state to reset on escape, got query=%q matches=%d", m.searchQuery, len(m.matchIndices))
+ }
+ if m.statusMessage != "Filter cleared" {
+ t.Fatalf("expected filter cleared status message, got %q", m.statusMessage)
+ }
+}
+
+func TestSearchSubmitSetsFilterStatusMessage(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "alpha", Path: "root" + pathSeparator + "alpha"},
+ {Name: "beta", Path: "root" + pathSeparator + "beta"},
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'a'}[0], Text: "a"})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if m.statusMessage != `Filter "a": 2 matches` {
+ t.Fatalf("unexpected status after applying filter: %q", m.statusMessage)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"})
+ for _, r := range []rune{'z', 'z'} {
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if m.statusMessage != `Filter "zz": no matches` {
+ t.Fatalf("unexpected status for unmatched filter: %q", m.statusMessage)
+ }
+}
+
+func TestControlPauseToggle(t *testing.T) {
+ m := NewModel(nil)
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"})
+ 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 p key to toggle pause off")
+ }
+}
+
+func TestControlResetBaseline(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ m := NewModel(liveTrie)
+ m.snapshot = &snapshotNode{Name: "root", Total: 10}
+ m.frames = []tuiFrame{{Name: "root", Path: "root"}}
+ m.zoomPath = "root"
+ m.zoomStack = []zoomState{{path: "", previousSelectedIdx: 0}}
+ m.selectedIdx = 3
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'r'}[0], Text: "r"})
+ if m.snapshot != nil || len(m.frames) != 0 || len(m.zoomStack) != 0 || m.zoomPath != "" {
+ t.Fatalf("expected baseline reset to clear snapshot/layout/zoom state")
+ }
+ if m.statusMessage != "Baseline reset" {
+ t.Fatalf("expected reset status message, got %q", m.statusMessage)
+ }
+}
+
+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% of total events") {
+ t.Fatalf("expected selection status bar to include selected share, got %q", view)
+ }
+}
+
+func TestViewSelectionStatusUsesBytesLabelInBytesMode(t *testing.T) {
+ m := NewModel(nil)
+ m.width = 120
+ m.height = 20
+ m.countField = "bytes"
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 120, Total: 200, Percent: 100, Path: "root"},
+ {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 60, Total: 80, Percent: 40, Path: "root" + pathSeparator + "child"},
+ }
+ m.selectedIdx = 1
+ m.globalTotal = 200
+
+ view := m.View().Content
+ if !strings.Contains(view, "40.00% of total bytes") {
+ t.Fatalf("expected bytes-based selection share label, got %q", view)
+ }
+}
+
+func TestViewFitsViewportHeightAndKeepsSearchFooterVisible(t *testing.T) {
+ m := NewModel(nil)
+ m.width = 100
+ m.height = 12
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 100, Total: 100, Percent: 100, Path: "root"},
+ {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 80, Total: 80, Percent: 80, Path: "root" + pathSeparator + "child"},
+ }
+ m.selectedIdx = 1
+ m.globalTotal = 100
+ m.searchActive = true
+ m.searchInput.SetValue("child")
+
+ view := m.View().Content
+ lines := strings.Split(view, "\n")
+ if got, max := len(lines), m.height; got > max {
+ t.Fatalf("expected flame view to fit viewport height <=%d, got %d lines", max, got)
+ }
+ if !strings.Contains(view, "matches") {
+ t.Fatalf("expected search footer to remain visible in viewport, got %q", view)
+ }
+ if !strings.Contains(view, "[LIVE] sel:2/2 child") {
+ t.Fatalf("expected selection status line to remain visible, got %q", view)
+ }
+}
+
+func TestViewFilterSelectionStatusUsesFilteredTotalAndKeepsContextVisible(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {
+ Name: "keep",
+ Total: 60,
+ Children: []*snapshotNode{
+ {Name: "needle", Total: 60},
+ },
+ },
+ {
+ Name: "drop",
+ Total: 40,
+ Children: []*snapshotNode{
+ {Name: "noise", Total: 40},
+ },
+ },
+ },
+ }
+ m := NewModel(nil)
+ m.width = 220
+ m.height = 12
+ m.frames = BuildTerminalLayout(snapshot, m.width, m.height)
+ m.globalTotal = 100
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"keep"+pathSeparator+"needle")
+ m.searchQuery = "needle"
+ m.recomputeFilterState()
+
+ view := m.View().Content
+ if !strings.Contains(view, "100.00% of filtered events") {
+ t.Fatalf("expected filtered selection share in status line, got %q", view)
+ }
+ if !strings.Contains(view, "drop") || !strings.Contains(view, "noise") {
+ t.Fatalf("expected non-matching branches to remain visible while filtering, got %q", view)
+ }
+}
+
+func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ m := NewModel(liveTrie)
+ initial := append([]string(nil), m.fieldPresets[m.fieldIndex]...)
+ expectedNextIdx := (m.fieldIndex + 1) % len(m.fieldPresets)
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'o'}[0], Text: "o"})
+ if m.fieldIndex != expectedNextIdx {
+ t.Fatalf("expected field index to advance to %d, got %d", expectedNextIdx, m.fieldIndex)
+ }
+ next := m.fieldPresets[m.fieldIndex]
+ if reflect.DeepEqual(initial, next) {
+ t.Fatalf("expected next field preset to differ from initial")
+ }
+ if got := liveTrie.Fields(); !reflect.DeepEqual(got, next) {
+ t.Fatalf("expected live trie fields %v, got %v", next, got)
+ }
+}
+
+func TestControlMetricToggleReconfiguresLiveTrieCountField(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ m := NewModel(liveTrie)
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"})
+ if got, want := m.countField, "bytes"; got != want {
+ t.Fatalf("expected model count field %q, got %q", want, got)
+ }
+ if got, want := liveTrie.CountField(), "bytes"; got != want {
+ t.Fatalf("expected live trie count field %q, got %q", want, got)
+ }
+ if got, want := m.statusMessage, "Metric: bytes (new baseline)"; got != want {
+ t.Fatalf("expected metric toggle status %q, got %q", want, got)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"})
+ if got, want := m.countField, "count"; got != want {
+ t.Fatalf("expected model count field %q after second toggle, got %q", want, got)
+ }
+ if got, want := liveTrie.CountField(), "count"; got != want {
+ t.Fatalf("expected live trie count field %q after second toggle, got %q", want, got)
+ }
+}
+
+func TestNewModelAlignsPresetIndexToLiveTrieFields(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ m := NewModel(liveTrie)
+ if got, want := m.fieldPresets[m.fieldIndex], []string{"comm", "path", "tracepoint"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("expected model field preset to align with trie fields, got %v want %v", got, want)
+ }
+}
+
+func TestNewModelAlignsCountFieldToLiveTrie(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "bytes")
+ m := NewModel(liveTrie)
+ if got, want := m.countField, "bytes"; got != want {
+ t.Fatalf("expected model count field to align with trie field, got %q want %q", got, want)
+ }
+}
+
+func TestControlHelpToggle(t *testing.T) {
+ m := NewModel(nil)
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"})
+ if !m.showHelp {
+ t.Fatalf("expected help overlay to toggle on")
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"})
+ if m.showHelp {
+ t.Fatalf("expected help overlay to toggle off")
+ }
+}
+
+func TestDataRefreshAnimationConvergesOverTicks(t *testing.T) {
+ m := NewModel(nil)
+ m.width = 120
+ m.height = 20
+ m.snapshot = &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {Name: "A", Total: 60},
+ {Name: "B", Total: 40},
+ },
+ }
+ m.rebuildFrames(false)
+ initial := append([]tuiFrame(nil), m.frames...)
+
+ m.snapshot = &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {Name: "A", Total: 20},
+ {Name: "B", Total: 80},
+ },
+ }
+ m.rebuildFrames(true)
+ if !m.animating {
+ t.Fatalf("expected animation to start after animated rebuild")
+ }
+
+ next, _ := m.Update(animTickMsg{})
+ m = next.(Model)
+ if len(m.frames) != len(initial) {
+ t.Fatalf("expected frame count to remain stable during animation")
+ }
+
+ for i := 0; i < 180 && m.animating; i++ {
+ next, _ = m.Update(animTickMsg{})
+ m = next.(Model)
+ }
+ if m.animating {
+ t.Fatalf("expected animation to settle within 180 ticks")
+ }
+ if len(m.frames) != len(m.targetFrames) {
+ t.Fatalf("expected settled frame count to match targets")
+ }
+ for i := range m.frames {
+ if m.frames[i].Width != m.targetFrames[i].Width || m.frames[i].Col != m.targetFrames[i].Col {
+ t.Fatalf("frame %d did not converge to target: got col=%d width=%d want col=%d width=%d",
+ i, m.frames[i].Col, m.frames[i].Width, m.targetFrames[i].Col, m.targetFrames[i].Width)
+ }
+ }
+}
+
+func TestRebuildKeepsSelectionOnVisibleRowsWhenTruncated(t *testing.T) {
+ m := NewModel(nil)
+ m.width = 80
+ m.height = 4 // only 2 render rows remain after toolbar+status
+ m.snapshot = &snapshotNode{
+ Name: "root",
+ Children: []*snapshotNode{
+ {
+ Name: "a",
+ Children: []*snapshotNode{
+ {
+ Name: "b",
+ Children: []*snapshotNode{
+ {Name: "c", Total: 5},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ m.rebuildFrames(false)
+ if len(m.frames) == 0 {
+ t.Fatalf("expected rebuilt frames")
+ }
+ rowOffset := m.visibleRowOffset()
+ if m.frames[m.selectedIdx].Row < rowOffset {
+ t.Fatalf("expected selected frame row %d to be visible (offset=%d)", m.frames[m.selectedIdx].Row, rowOffset)
+ }
+}
+
+func TestResizeRecalculatesLayoutAndCullsNarrowFrames(t *testing.T) {
+ m := NewModel(nil)
+ m.width = 120
+ m.height = 40
+ m.snapshot = &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {
+ Name: "big",
+ Total: 99,
+ Children: []*snapshotNode{
+ {Name: "deep", Total: 99},
+ },
+ },
+ {Name: "tiny", Total: 1},
+ },
+ }
+ m.rebuildFrames(false)
+ _ = mustFrameIndex(t, m.frames, "root"+pathSeparator+"tiny")
+
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ m = next.(Model)
+ for i := 0; i < 180 && m.animating; i++ {
+ next, _ = m.Update(animTickMsg{})
+ m = next.(Model)
+ }
+
+ for _, frame := range m.frames {
+ if frame.Col+frame.Width > 80 {
+ t.Fatalf("frame exceeds resized width: %+v", frame)
+ }
+ if frame.Row >= 24 {
+ t.Fatalf("frame row exceeds resized height: %+v", frame)
+ }
+ }
+ for _, frame := range m.frames {
+ if frame.Path == "root"+pathSeparator+"tiny" {
+ t.Fatalf("expected tiny frame to be culled at width 80")
+ }
+ }
+}
+
+func newZoomModel() Model {
+ m := NewModel(nil)
+ m.width = 120
+ m.height = 30
+ m.snapshot = &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {
+ Name: "A",
+ Total: 60,
+ Children: []*snapshotNode{
+ {Name: "A1", Total: 30},
+ {Name: "A2", Total: 30},
+ },
+ },
+ {Name: "B", Total: 40},
+ },
+ }
+ m.rebuildFrames(false)
+ return m
+}
+
+func mustFrameIndex(t *testing.T, frames []tuiFrame, path string) int {
+ t.Helper()
+ for idx, frame := range frames {
+ if frame.Path == path {
+ return idx
+ }
+ }
+ t.Fatalf("frame path %q not found", path)
+ return -1
+}
+
+func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model {
+ t.Helper()
+ next, _ := m.Update(keyMsg)
+ return next.(Model)
+}
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go
new file mode 100644
index 0000000..e4c4043
--- /dev/null
+++ b/internal/tui/flamegraph/renderer.go
@@ -0,0 +1,708 @@
+package flamegraph
+
+import (
+ "fmt"
+ "hash/fnv"
+ "image/color"
+ "math"
+ "sort"
+ "strings"
+ "unicode/utf8"
+
+ common "ior/internal/tui/common"
+
+ "charm.land/lipgloss/v2"
+)
+
+const pathSeparator = "\x1f"
+const pathSeparatorByte = '\x1f'
+const minFlameWidth = 60
+const maxBarVisualHeight = 3
+
+// BuildTerminalLayout converts a live trie snapshot into terminal frame cells.
+func BuildTerminalLayout(snapshot *snapshotNode, width, height int) []tuiFrame {
+ return buildTerminalLayoutWithPath(snapshot, width, height, "")
+}
+
+func buildTerminalLayoutWithPath(snapshot *snapshotNode, width, height int, rootPath string) []tuiFrame {
+ if snapshot == nil || width <= 0 || height <= 0 {
+ return nil
+ }
+ rootTotal := snapshotTotal(snapshot)
+ if rootTotal == 0 {
+ return nil
+ }
+
+ rootName := frameName(snapshot.Name, 0)
+ if rootPath != "" {
+ rootName = rootPath
+ }
+ frames := make([]tuiFrame, 0, len(snapshot.Children)+1)
+ collectTerminalLayout(&frames, snapshot, rootTotal, height, 0, 0, rootName, width)
+ return frames
+}
+
+func collectTerminalLayout(out *[]tuiFrame, node *snapshotNode, rootTotal uint64, height, depth, col int, path string, span int) {
+ if node == nil || depth >= height {
+ return
+ }
+ total := snapshotTotal(node)
+ if total == 0 || span < 1 {
+ return
+ }
+
+ name := frameName(node.Name, depth)
+ *out = append(*out, tuiFrame{
+ Name: name,
+ Col: col,
+ Row: depth,
+ Width: span,
+ Total: total,
+ Percent: 100 * float64(total) / float64(rootTotal),
+ Fill: terminalFrameColor(name),
+ Depth: depth,
+ Path: path,
+ })
+
+ if len(node.Children) == 0 {
+ return
+ }
+
+ childWidths := allocateChildWidths(node.Children, total, span)
+ cursor := col
+ for idx, child := range node.Children {
+ childWidth := childWidths[idx]
+ if childWidth < 1 {
+ continue
+ }
+ childName := frameName(child.Name, depth+1)
+ childPath := strings.Join([]string{path, childName}, pathSeparator)
+ collectTerminalLayout(out, child, rootTotal, height, depth+1, cursor, childPath, childWidth)
+ cursor += childWidth
+ }
+}
+
+func allocateChildWidths(children []*snapshotNode, parentTotal uint64, span int) []int {
+ widths := make([]int, len(children))
+ if span <= 0 || parentTotal == 0 || len(children) == 0 {
+ return widths
+ }
+
+ type childWidth struct {
+ idx int
+ total uint64
+ raw float64
+ }
+ items := make([]childWidth, 0, len(children))
+ used := 0
+ for idx, child := range children {
+ total := snapshotTotal(child)
+ if total == 0 {
+ continue
+ }
+ raw := float64(span) * (float64(total) / float64(parentTotal))
+ width := int(math.Floor(raw))
+ if width > 0 {
+ widths[idx] = width
+ used += width
+ }
+ items = append(items, childWidth{idx: idx, total: total, raw: raw})
+ }
+ if len(items) == 0 {
+ return widths
+ }
+
+ // If proportional rounding culled every child, surface top contributors so
+ // the user can still navigate beyond the root frame.
+ if used == 0 {
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].total == items[j].total {
+ return items[i].idx < items[j].idx
+ }
+ return items[i].total > items[j].total
+ })
+ visible := min(span, len(items))
+ for i := 0; i < visible; i++ {
+ widths[items[i].idx] = 1
+ }
+ }
+ return widths
+}
+
+func snapshotTotal(node *snapshotNode) uint64 {
+ if node == nil {
+ return 0
+ }
+ total := node.Value
+ for _, child := range node.Children {
+ total += snapshotTotal(child)
+ }
+ if node.Total > total {
+ return node.Total
+ }
+ return total
+}
+
+func frameName(name string, depth int) string {
+ if name != "" {
+ return name
+ }
+ if depth == 0 {
+ return "root"
+ }
+ return "(unknown)"
+}
+
+func terminalFrameColor(name string) color.Color {
+ if semantic, ok := semanticFrameColor(name); ok {
+ return semantic
+ }
+
+ hasher := fnv.New32a()
+ _, _ = hasher.Write([]byte(name))
+ h := hasher.Sum32()
+ return color.RGBA{
+ R: uint8(200 + int(h%35)),
+ G: uint8(80 + int((h>>8)%120)),
+ B: uint8(40 + int((h>>16)%90)),
+ A: 255,
+ }
+}
+
+func semanticFrameColor(name string) (color.Color, bool) {
+ label := strings.ToLower(strings.TrimSpace(name))
+ switch {
+ case label == "":
+ return nil, false
+ case strings.Contains(label, "read"), strings.Contains(label, "pread"):
+ return color.RGBA{R: 78, G: 132, B: 201, A: 255}, true // read I/O: blue
+ case strings.Contains(label, "write"), strings.Contains(label, "pwrite"):
+ return color.RGBA{R: 222, G: 122, B: 58, A: 255}, true // write I/O: orange
+ case strings.Contains(label, "open"), strings.Contains(label, "close"), strings.Contains(label, "stat"), strings.Contains(label, "rename"), strings.Contains(label, "link"):
+ return color.RGBA{R: 196, G: 168, B: 72, A: 255}, true // metadata I/O: amber
+ case strings.HasPrefix(label, "/"), strings.Contains(label, "path:"), strings.Contains(label, "/"):
+ return color.RGBA{R: 88, G: 156, B: 84, A: 255}, true // file paths: green
+ case strings.Contains(label, "pid"), strings.Contains(label, "tid"):
+ return color.RGBA{R: 67, G: 151, B: 149, A: 255}, true // process/thread dimensions: teal
+ case strings.HasPrefix(label, "sys_"):
+ return color.RGBA{R: 191, G: 99, B: 74, A: 255}, true // other syscall buckets: rust
+ default:
+ return nil, false
+ }
+}
+
+// RenderTerminalView renders a terminal flamegraph viewport from laid out frames.
+func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet, filterSet map[int]bool, globalTotal uint64, metricLabel string, isDark, searchActive bool, searchQuery string) string {
+ if width < minFlameWidth {
+ return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)")
+ }
+ if height < 3 {
+ return common.PanelStyle.Render("Flame: viewport too short")
+ }
+ if len(frames) == 0 {
+ return common.PanelStyle.Render("Flame: waiting for data...")
+ }
+ if strings.TrimSpace(metricLabel) == "" {
+ metricLabel = "events"
+ }
+
+ filterActive := strings.TrimSpace(searchQuery) != ""
+ if filterActive {
+ if filterSet == nil {
+ filterSet = computeFilterVisibleSetInto(frames, matchSet, nil)
+ }
+ if len(filterSet) == 0 {
+ return common.PanelStyle.Render(fmt.Sprintf("Flame: no frames match filter %q", searchQuery))
+ }
+ } else {
+ filterSet = nil
+ }
+
+ selectedIdx = normalizeSelectedIndex(frames, selectedIdx, filterSet)
+ selected := frames[selectedIdx]
+ viewPath := compactFramePath(frames[0].Path)
+ if subtreeSet == nil {
+ subtreeSet = computeSubtreeSet(frames, selectedIdx)
+ }
+
+ availableRows := height - 2 // toolbar + status
+ maxRow := maxFrameRowForSet(frames, nil)
+ totalDepthRows := maxRow + 1
+ barHeight := computeBarHeight(availableRows, totalDepthRows, maxBarVisualHeight)
+ visibleDepthRows := availableRows / barHeight
+ if visibleDepthRows < 1 {
+ visibleDepthRows = 1
+ }
+ rowOffset := 0
+ truncated := false
+ if maxRow+1 > visibleDepthRows {
+ rowOffset = maxRow + 1 - visibleDepthRows
+ truncated = true
+ }
+
+ visibleFrames := countVisibleFrames(frames, nil)
+ toolbar := fmt.Sprintf("Flame | view:%s | frames:%d", viewPath, visibleFrames)
+ toolbar += fmt.Sprintf(" | rows:%d", availableRows)
+ if truncated {
+ toolbar += " | showing deepest levels"
+ }
+ toolbar = padOrTrim(toolbar, width)
+ selectedSystemShare := selected.Percent
+ if globalTotal > 0 {
+ selectedSystemShare = percentOfTotal(selected.Total, globalTotal)
+ }
+ if filterActive {
+ filterCoveredTotal, filterBaseTotal := filterCoverageTotals(frames, matchSet, globalTotal)
+ filterSystemShare := percentOfTotal(filterCoveredTotal, filterBaseTotal)
+ selectedFilterShare := 0.0
+ if filterCoveredTotal > 0 {
+ selectedMatchTotal := filterCoverageTotalForPath(frames, matchSet, selected.Path)
+ selectedFilterShare = percentOfTotal(selectedMatchTotal, filterCoveredTotal)
+ }
+ matches := orderedMatchIndices(matchSet)
+ pos := 0
+ if len(matches) > 0 {
+ if idx := indexOf(matches, selectedIdx); idx >= 0 {
+ pos = idx + 1
+ }
+ }
+ frameCoverage := 0.0
+ if len(frames) > 0 {
+ frameCoverage = 100 * float64(visibleFrames) / float64(len(frames))
+ }
+ status := fmt.Sprintf("Filter %q: %.1f%% %s (%d/%d matches, %.1f%% frames shown) | Selected: %s total(%s)=%d depth=%d %.2f%% filtered %s",
+ searchQuery, filterSystemShare, metricLabel, pos, len(matches), frameCoverage,
+ selected.Name, metricLabel, selected.Total, selected.Depth, selectedFilterShare, metricLabel)
+ return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, barHeight, availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width)
+ } else {
+ status := fmt.Sprintf("Selected: %s [%s] total(%s)=%d depth=%d col=%d width=%d share=%.2f%% %s",
+ selected.Name, compactFramePath(selected.Path), metricLabel, selected.Total, selected.Depth, selected.Col, selected.Width, selectedSystemShare, metricLabel)
+ return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, barHeight, availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width)
+ }
+}
+
+func rowsForRender(frames []tuiFrame, width, rowOffset, maxRow, barHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string {
+ return buildRenderRows(frames, width, rowOffset, maxRow, barHeight, availableRows, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive)
+}
+
+func renderViewRows(toolbar, status string, rows []string, width int) string {
+ status = padOrTrim(status, width)
+ var b strings.Builder
+ b.Grow((width + 1) * (len(rows) + 2))
+ b.WriteString(toolbar)
+ for _, row := range rows {
+ b.WriteString("\n")
+ b.WriteString(row)
+ }
+ b.WriteString("\n")
+ b.WriteString(status)
+ return b.String()
+}
+
+type indexedFrame struct {
+ idx int
+ frame tuiFrame
+}
+
+func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow, barHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string {
+ rowsByDepth := make(map[int][]indexedFrame)
+ for idx, frame := range frames {
+ if frame.Row < rowOffset || frame.Row > maxRow {
+ continue
+ }
+ rowsByDepth[frame.Row] = append(rowsByDepth[frame.Row], indexedFrame{idx: idx, frame: frame})
+ }
+
+ if barHeight < 1 {
+ barHeight = 1
+ }
+
+ rows := make([]string, 0, (maxRow-rowOffset+1)*barHeight)
+ for row := maxRow; row >= rowOffset; row-- {
+ framesAtRow := rowsByDepth[row]
+ sort.Slice(framesAtRow, func(i, j int) bool {
+ return framesAtRow[i].frame.Col < framesAtRow[j].frame.Col
+ })
+ for repeat := 0; repeat < barHeight; repeat++ {
+ showLabels := repeat == barHeight/2
+ rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive, showLabels))
+ }
+ }
+
+ if availableRows > 0 {
+ if len(rows) > availableRows {
+ rows = rows[:availableRows]
+ }
+ if len(rows) < availableRows {
+ blank := strings.Repeat(" ", width)
+ pad := make([]string, 0, availableRows)
+ for i := 0; i < availableRows-len(rows); i++ {
+ pad = append(pad, blank)
+ }
+ pad = append(pad, rows...)
+ rows = pad
+ }
+ }
+ return rows
+}
+
+func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive, showLabels bool) string {
+ if len(frames) == 0 {
+ return strings.Repeat(" ", width)
+ }
+ var b strings.Builder
+ b.Grow(width + 8)
+ cursor := 0
+ for _, item := range frames {
+ frame := item.frame
+ if frame.Col >= width {
+ continue
+ }
+ if frame.Col > cursor {
+ gap := frame.Col - cursor
+ b.WriteString(strings.Repeat(" ", gap))
+ cursor += gap
+ }
+
+ cellWidth := frame.Width
+ if frame.Col+cellWidth > width {
+ cellWidth = width - frame.Col
+ }
+ if cellWidth <= 0 {
+ continue
+ }
+ label := strings.Repeat(" ", cellWidth)
+ if showLabels {
+ label = frameLabel(frame.Name, cellWidth, item.idx == selectedIdx, matchSet != nil && matchSet[item.idx])
+ }
+ style := styleForFrame(item.idx, frame, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive)
+ cell := style.Render(label)
+ b.WriteString(cell)
+ cursor = frame.Col + cellWidth
+ }
+ if cursor < width {
+ b.WriteString(strings.Repeat(" ", width-cursor))
+ }
+ return b.String()
+}
+
+func computeSubtreeSet(frames []tuiFrame, selectedIdx int) map[int]bool {
+ return computeSubtreeSetInto(frames, selectedIdx, nil)
+}
+
+func computeSubtreeSetInto(frames []tuiFrame, selectedIdx int, subtree map[int]bool) map[int]bool {
+ if subtree == nil {
+ subtree = make(map[int]bool)
+ } else {
+ for idx := range subtree {
+ delete(subtree, idx)
+ }
+ }
+ if selectedIdx < 0 || selectedIdx >= len(frames) {
+ return subtree
+ }
+
+ selectedPath := frames[selectedIdx].Path
+ for idx, frame := range frames {
+ path := frame.Path
+ if path == selectedPath ||
+ hasPathBoundaryPrefix(path, selectedPath) ||
+ hasPathBoundaryPrefix(selectedPath, path) {
+ subtree[idx] = true
+ }
+ }
+ return subtree
+}
+
+func hasPathBoundaryPrefix(value, prefix string) bool {
+ if len(value) <= len(prefix) {
+ return false
+ }
+ if !strings.HasPrefix(value, prefix) {
+ return false
+ }
+ return value[len(prefix)] == pathSeparatorByte
+}
+
+func computeFilterVisibleSetInto(frames []tuiFrame, matchSet, visible map[int]bool) map[int]bool {
+ if visible == nil {
+ visible = make(map[int]bool)
+ } else {
+ for idx := range visible {
+ delete(visible, idx)
+ }
+ }
+ if len(matchSet) == 0 {
+ return visible
+ }
+
+ matchPaths := make([]string, 0, len(matchSet))
+ for idx := range matchSet {
+ if idx >= 0 && idx < len(frames) {
+ matchPaths = append(matchPaths, frames[idx].Path)
+ }
+ }
+ for idx, frame := range frames {
+ for _, matchPath := range matchPaths {
+ // Show matching frames and their full ancestry to root.
+ if frame.Path == matchPath || hasPathBoundaryPrefix(matchPath, frame.Path) {
+ visible[idx] = true
+ break
+ }
+ }
+ }
+ return visible
+}
+
+func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) lipgloss.Style {
+ _ = searchActive
+ base := lipgloss.NewStyle().
+ Foreground(common.ColorBackground).
+ Background(frame.Fill)
+
+ isSelected := idx == selectedIdx
+ inSubtree := subtreeSet[idx]
+ isMatch := matchSet != nil && matchSet[idx]
+
+ matchColor := lipgloss.Color("160")
+ if !isDark {
+ matchColor = lipgloss.Color("124")
+ }
+
+ if isSelected {
+ selectedBg := lipgloss.Color("129")
+ selectedFg := lipgloss.Color("15")
+ if !isDark {
+ selectedBg = lipgloss.Color("129")
+ selectedFg = lipgloss.Color("15")
+ }
+ return base.Background(selectedBg).Foreground(selectedFg).Bold(true).Underline(true)
+ }
+
+ if isMatch {
+ style := base.Background(matchColor).Foreground(lipgloss.Color("15"))
+ if inSubtree {
+ return style.Bold(true)
+ }
+ return style.Faint(true)
+ }
+
+ if filterActive {
+ return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true)
+ }
+
+ if inSubtree {
+ if frameRelation(frame.Path, selectedPath) == relationAncestor {
+ return base.BorderLeft(true).BorderForeground(common.ColorAccent)
+ }
+ return base
+ }
+
+ return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true)
+}
+
+func frameLabel(name string, width int, isSelected, isMatch bool) string {
+ if width <= 0 {
+ return ""
+ }
+ if isSelected {
+ if width == 1 {
+ return ">"
+ }
+ return ">" + padOrTrim(name, width-2) + "<"
+ }
+ if isMatch {
+ if width == 1 {
+ return "*"
+ }
+ return "*" + padOrTrim(name, width-1)
+ }
+ return padOrTrim(name, width)
+}
+
+func compactFramePath(path string) string {
+ if path == "" {
+ return "root"
+ }
+ parts := strings.Split(path, pathSeparator)
+ if len(parts) <= 3 {
+ return strings.Join(parts, "/")
+ }
+ return strings.Join([]string{parts[0], "...", parts[len(parts)-1]}, "/")
+}
+
+type relation int
+
+const (
+ relationNone relation = iota
+ relationAncestor
+ relationDescendant
+)
+
+func frameRelation(path, selectedPath string) relation {
+ if path == selectedPath {
+ return relationDescendant
+ }
+ if strings.HasPrefix(selectedPath, path+pathSeparator) {
+ return relationAncestor
+ }
+ if strings.HasPrefix(path, selectedPath+pathSeparator) {
+ return relationDescendant
+ }
+ return relationNone
+}
+
+func maxFrameRow(frames []tuiFrame) int {
+ return maxFrameRowForSet(frames, nil)
+}
+
+func maxFrameRowForSet(frames []tuiFrame, include map[int]bool) int {
+ maxRow := 0
+ for idx, frame := range frames {
+ if include != nil && !include[idx] {
+ continue
+ }
+ if frame.Row > maxRow {
+ maxRow = frame.Row
+ }
+ }
+ return maxRow
+}
+
+func countVisibleFrames(frames []tuiFrame, include map[int]bool) int {
+ if include == nil {
+ return len(frames)
+ }
+ count := 0
+ for idx := range frames {
+ if include[idx] {
+ count++
+ }
+ }
+ return count
+}
+
+func normalizeSelectedIndex(frames []tuiFrame, selectedIdx int, include map[int]bool) int {
+ if len(frames) == 0 {
+ return 0
+ }
+ if selectedIdx >= 0 && selectedIdx < len(frames) && (include == nil || include[selectedIdx]) {
+ return selectedIdx
+ }
+ if include != nil {
+ for idx := range frames {
+ if include[idx] {
+ return idx
+ }
+ }
+ }
+ return 0
+}
+
+func filterSampleCoverage(frames []tuiFrame, matchSet map[int]bool, totalBase uint64) float64 {
+ coveredTotal, rootTotal := filterCoverageTotals(frames, matchSet, totalBase)
+ return percentOfTotal(coveredTotal, rootTotal)
+}
+
+func computeBarHeight(availableRows, depthRows, maxHeight int) int {
+ if availableRows <= 0 || depthRows <= 0 {
+ return 1
+ }
+ height := availableRows / depthRows
+ if height < 1 {
+ height = 1
+ }
+ if maxHeight > 0 && height > maxHeight {
+ height = maxHeight
+ }
+ return height
+}
+
+func filterCoverageTotals(frames []tuiFrame, matchSet map[int]bool, totalBase uint64) (coveredTotal uint64, rootTotal uint64) {
+ if len(frames) == 0 || len(matchSet) == 0 {
+ return 0, 0
+ }
+ rootTotal = totalBase
+ if rootTotal == 0 {
+ rootTotal = frames[0].Total
+ }
+ if rootTotal == 0 {
+ return 0, 0
+ }
+ roots := compactMatchRoots(frames, matchSet)
+ for _, root := range roots {
+ coveredTotal += root.total
+ }
+ return coveredTotal, rootTotal
+}
+
+func filterCoverageTotalForPath(frames []tuiFrame, matchSet map[int]bool, path string) uint64 {
+ if path == "" || len(frames) == 0 || len(matchSet) == 0 {
+ return 0
+ }
+ roots := compactMatchRoots(frames, matchSet)
+ var coveredTotal uint64
+ for _, root := range roots {
+ if root.path == path || hasPathBoundaryPrefix(root.path, path) {
+ coveredTotal += root.total
+ }
+ }
+ return coveredTotal
+}
+
+type matchRoot struct {
+ path string
+ total uint64
+}
+
+func compactMatchRoots(frames []tuiFrame, matchSet map[int]bool) []matchRoot {
+ roots := make([]matchRoot, 0, len(matchSet))
+ for idx := range matchSet {
+ if idx < 0 || idx >= len(frames) {
+ continue
+ }
+ roots = append(roots, matchRoot{
+ path: frames[idx].Path,
+ total: frames[idx].Total,
+ })
+ }
+ sort.Slice(roots, func(i, j int) bool {
+ return len(roots[i].path) < len(roots[j].path)
+ })
+ merged := make([]matchRoot, 0, len(roots))
+ for _, candidate := range roots {
+ covered := false
+ for _, root := range merged {
+ if candidate.path == root.path || hasPathBoundaryPrefix(candidate.path, root.path) {
+ covered = true
+ break
+ }
+ }
+ if covered {
+ continue
+ }
+ merged = append(merged, candidate)
+ }
+ return merged
+}
+
+func percentOfTotal(value, total uint64) float64 {
+ if total == 0 {
+ return 0
+ }
+ return 100 * float64(value) / float64(total)
+}
+
+func padOrTrim(s string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ if utf8.RuneCountInString(s) <= width {
+ return s + strings.Repeat(" ", width-utf8.RuneCountInString(s))
+ }
+ if width == 1 {
+ return "…"
+ }
+ r := []rune(s)
+ return string(r[:width-1]) + "…"
+}
diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go
new file mode 100644
index 0000000..c546200
--- /dev/null
+++ b/internal/tui/flamegraph/renderer_test.go
@@ -0,0 +1,368 @@
+package flamegraph
+
+import (
+ "image/color"
+ "strings"
+ "testing"
+)
+
+func TestBuildTerminalLayoutWidthScaling(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {
+ Name: "A",
+ Total: 60,
+ Children: []*snapshotNode{
+ {Name: "A1", Total: 30},
+ {Name: "A2", Total: 30},
+ },
+ },
+ {Name: "B", Total: 40},
+ },
+ }
+
+ tests := []struct {
+ width int
+ wantA int
+ wantB int
+ wantA1 int
+ wantA2 int
+ wantAll int
+ }{
+ {width: 80, wantA: 48, wantB: 32, wantA1: 24, wantA2: 24, wantAll: 5},
+ {width: 120, wantA: 72, wantB: 48, wantA1: 36, wantA2: 36, wantAll: 5},
+ {width: 200, wantA: 120, wantB: 80, wantA1: 60, wantA2: 60, wantAll: 5},
+ }
+
+ for _, tc := range tests {
+ frames := BuildTerminalLayout(snapshot, tc.width, 10)
+ if len(frames) != tc.wantAll {
+ t.Fatalf("width %d: expected %d frames, got %d", tc.width, tc.wantAll, len(frames))
+ }
+ root := mustFindFrame(t, frames, "root")
+ if root.Width != tc.width || root.Row != 0 || root.Col != 0 {
+ t.Fatalf("width %d: unexpected root frame %+v", tc.width, root)
+ }
+ a := mustFindFrame(t, frames, "root"+pathSeparator+"A")
+ b := mustFindFrame(t, frames, "root"+pathSeparator+"B")
+ a1 := mustFindFrame(t, frames, "root"+pathSeparator+"A"+pathSeparator+"A1")
+ a2 := mustFindFrame(t, frames, "root"+pathSeparator+"A"+pathSeparator+"A2")
+
+ if a.Width != tc.wantA || b.Width != tc.wantB {
+ t.Fatalf("width %d: unexpected child widths A=%d B=%d", tc.width, a.Width, b.Width)
+ }
+ if a1.Width != tc.wantA1 || a2.Width != tc.wantA2 {
+ t.Fatalf("width %d: unexpected grandchild widths A1=%d A2=%d", tc.width, a1.Width, a2.Width)
+ }
+ if b.Col != a.Col+a.Width {
+ t.Fatalf("width %d: expected B col %d, got %d", tc.width, a.Col+a.Width, b.Col)
+ }
+ }
+}
+
+func TestBuildTerminalLayoutCullsSubCellFramesAndRespectsHeight(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {
+ Name: "big",
+ Total: 99,
+ Children: []*snapshotNode{
+ {Name: "deep", Total: 99},
+ },
+ },
+ {Name: "tiny", Total: 1},
+ },
+ }
+
+ frames := BuildTerminalLayout(snapshot, 80, 2)
+ if hasFrame(frames, "root"+pathSeparator+"tiny") {
+ t.Fatalf("expected tiny frame to be culled (<1 terminal cell)")
+ }
+ if hasFrame(frames, "root"+pathSeparator+"big"+pathSeparator+"deep") {
+ t.Fatalf("expected deep frame to be omitted due height limit")
+ }
+ if !hasFrame(frames, "root"+pathSeparator+"big") {
+ t.Fatalf("expected big frame to be present")
+ }
+}
+
+func TestBuildTerminalLayoutKeepsChildrenVisibleWhenRoundingWouldCullAll(t *testing.T) {
+ children := make([]*snapshotNode, 0, 200)
+ for i := 0; i < 200; i++ {
+ children = append(children, &snapshotNode{Name: "c", Total: 1})
+ }
+ snapshot := &snapshotNode{Name: "root", Children: children}
+
+ frames := BuildTerminalLayout(snapshot, 120, 6)
+ depthOne := 0
+ for _, frame := range frames {
+ if frame.Depth == 1 {
+ depthOne++
+ }
+ }
+ if depthOne == 0 {
+ t.Fatalf("expected at least one visible depth-1 frame, got none")
+ }
+}
+
+func TestBuildTerminalLayoutUsesPathSeparatorAndColor(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 10,
+ Children: []*snapshotNode{
+ {Name: "child", Total: 10},
+ },
+ }
+
+ frames := BuildTerminalLayout(snapshot, 80, 4)
+ child := mustFindFrame(t, frames, "root"+pathSeparator+"child")
+ if !strings.Contains(child.Path, pathSeparator) {
+ t.Fatalf("expected path %q to contain separator %q", child.Path, pathSeparator)
+ }
+ if child.Fill == nil {
+ t.Fatalf("expected frame color to be set")
+ }
+}
+
+func TestTerminalFrameColorSemanticPalette(t *testing.T) {
+ tests := []struct {
+ name string
+ label string
+ want color.RGBA
+ }{
+ {name: "read", label: "sys_enter_read", want: color.RGBA{R: 78, G: 132, B: 201, A: 255}},
+ {name: "write", label: "sys_enter_write", want: color.RGBA{R: 222, G: 122, B: 58, A: 255}},
+ {name: "metadata", label: "sys_enter_openat", want: color.RGBA{R: 196, G: 168, B: 72, A: 255}},
+ {name: "path", label: "/var/log/app.log", want: color.RGBA{R: 88, G: 156, B: 84, A: 255}},
+ {name: "pid", label: "pid=1234", want: color.RGBA{R: 67, G: 151, B: 149, A: 255}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := terminalFrameColor(tc.label)
+ if got != tc.want {
+ t.Fatalf("unexpected semantic color for %q: got=%v want=%v", tc.label, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) {
+ out := RenderTerminalView(nil, 50, 10, 0, nil, nil, nil, 0, "events", true, false, "")
+ if !strings.Contains(out, "terminal too narrow") {
+ t.Fatalf("expected narrow terminal warning, got %q", out)
+ }
+}
+
+func TestComputeBarHeightCappedAtThree(t *testing.T) {
+ if got := computeBarHeight(30, 4, 3); got != 3 {
+ t.Fatalf("expected bar height cap at 3, got %d", got)
+ }
+ if got := computeBarHeight(5, 10, 3); got != 1 {
+ t.Fatalf("expected bar height minimum 1 when depth exceeds rows, got %d", got)
+ }
+}
+
+func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 10,
+ Children: []*snapshotNode{
+ {Name: "child", Total: 10},
+ },
+ }
+ frames := BuildTerminalLayout(snapshot, 80, 6)
+
+ out := RenderTerminalView(frames, 80, 6, 1, nil, nil, nil, 0, "events", true, false, "")
+ if !strings.Contains(out, "Flame | view:root | frames:2") {
+ t.Fatalf("expected toolbar to include frame count, got %q", out)
+ }
+ if !strings.Contains(out, "Selected: child") {
+ t.Fatalf("expected status line to show selected frame, got %q", out)
+ }
+}
+
+func TestRenderTerminalViewFillsAvailableHeightForShallowTree(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 10,
+ Children: []*snapshotNode{
+ {Name: "child", Total: 10},
+ },
+ }
+ frames := BuildTerminalLayout(snapshot, 100, 20)
+
+ out := RenderTerminalView(frames, 100, 20, 1, nil, nil, nil, 0, "events", true, false, "")
+ lines := strings.Split(out, "\n")
+ if got, want := len(lines), 20; got != want {
+ t.Fatalf("expected render to fill viewport height (%d lines), got %d", want, got)
+ }
+}
+
+func TestFrameLabelAddsSelectionAndMatchMarkers(t *testing.T) {
+ if got := frameLabel("child", 7, true, false); got != ">child<" {
+ t.Fatalf("expected selected marker label, got %q", got)
+ }
+ if got := frameLabel("child", 6, false, true); got != "*child" {
+ t.Fatalf("expected match marker label, got %q", got)
+ }
+}
+
+func TestRenderTerminalViewShowsPersistentFilterContext(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 10,
+ Children: []*snapshotNode{
+ {Name: "child", Total: 10},
+ },
+ }
+ frames := BuildTerminalLayout(snapshot, 80, 6)
+ matchSet := map[int]bool{1: true}
+
+ out := RenderTerminalView(frames, 140, 6, 1, nil, matchSet, nil, 0, "events", true, false, "child")
+ if !strings.Contains(out, `Filter "child"`) {
+ t.Fatalf("expected filter context in status line, got %q", out)
+ }
+}
+
+func TestRenderTerminalViewFilterKeepsNonMatchingBranchesVisible(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {
+ Name: "keep",
+ Total: 60,
+ Children: []*snapshotNode{
+ {Name: "needle", Total: 60},
+ },
+ },
+ {
+ Name: "drop",
+ Total: 40,
+ Children: []*snapshotNode{
+ {Name: "noise", Total: 40},
+ },
+ },
+ },
+ }
+ frames := BuildTerminalLayout(snapshot, 80, 8)
+ needleIdx := frameIndexByPathRenderer(frames, "root"+pathSeparator+"keep"+pathSeparator+"needle")
+ if needleIdx < 0 {
+ t.Fatalf("expected needle frame in layout")
+ }
+ matchSet := map[int]bool{needleIdx: true}
+
+ out := RenderTerminalView(frames, 180, 8, needleIdx, nil, matchSet, nil, 100, "bytes", true, false, "needle")
+ if !strings.Contains(out, `Filter "needle": 60.0% bytes`) {
+ t.Fatalf("expected filter status to report 60.0%% bytes share, got %q", out)
+ }
+ if !strings.Contains(out, "keep") || !strings.Contains(out, "needle") {
+ t.Fatalf("expected matching branch to remain visible, got %q", out)
+ }
+ if !strings.Contains(out, "drop") || !strings.Contains(out, "noise") {
+ t.Fatalf("expected non-matching branch to remain visible (greyed), got %q", out)
+ }
+ if !strings.Contains(out, "100.00% filtered bytes") {
+ t.Fatalf("expected selected match share to be computed against filtered total, got %q", out)
+ }
+}
+
+func TestFilterSampleCoverageAvoidsDoubleCountingNestedMatches(t *testing.T) {
+ frames := []tuiFrame{
+ {Path: "root", Total: 100},
+ {Path: "root" + pathSeparator + "A", Total: 60},
+ {Path: "root" + pathSeparator + "A" + pathSeparator + "A1", Total: 30},
+ {Path: "root" + pathSeparator + "B", Total: 40},
+ }
+ matchSet := map[int]bool{
+ 1: true, // A
+ 2: true, // A1 (nested under A)
+ }
+ if got := filterSampleCoverage(frames, matchSet, 100); got != 60 {
+ t.Fatalf("expected nested matches to count once at 60%%, got %.1f%%", got)
+ }
+}
+
+func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 4,
+ Children: []*snapshotNode{
+ {
+ Name: "a",
+ Total: 4,
+ Children: []*snapshotNode{
+ {
+ Name: "b",
+ Total: 4,
+ Children: []*snapshotNode{
+ {
+ Name: "c",
+ Total: 4,
+ Children: []*snapshotNode{
+ {Name: "d", Total: 4},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ frames := BuildTerminalLayout(snapshot, 80, 10)
+ out := RenderTerminalView(frames, 80, 4, 0, nil, nil, nil, 0, "events", true, false, "")
+ if !strings.Contains(out, "showing deepest levels") {
+ t.Fatalf("expected truncation hint in toolbar, got %q", out)
+ }
+}
+
+func TestComputeSubtreeSetIncludesAncestorsAndDescendants(t *testing.T) {
+ frames := []tuiFrame{
+ {Path: "root"},
+ {Path: "root" + pathSeparator + "A"},
+ {Path: "root" + pathSeparator + "A" + pathSeparator + "A1"},
+ {Path: "root" + pathSeparator + "B"},
+ }
+
+ set := computeSubtreeSet(frames, 1)
+ if !set[0] || !set[1] || !set[2] {
+ t.Fatalf("expected root/A/A1 to be in selected subtree: %#v", set)
+ }
+ if set[3] {
+ t.Fatalf("did not expect sibling branch B in subtree: %#v", set)
+ }
+}
+
+func mustFindFrame(t *testing.T, frames []tuiFrame, path string) tuiFrame {
+ t.Helper()
+ for _, frame := range frames {
+ if frame.Path == path {
+ return frame
+ }
+ }
+ t.Fatalf("frame with path %q not found", path)
+ return tuiFrame{}
+}
+
+func hasFrame(frames []tuiFrame, path string) bool {
+ for _, frame := range frames {
+ if frame.Path == path {
+ return true
+ }
+ }
+ return false
+}
+
+func frameIndexByPathRenderer(frames []tuiFrame, path string) int {
+ for idx, frame := range frames {
+ if frame.Path == path {
+ return idx
+ }
+ }
+ return -1
+}
diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go
new file mode 100644
index 0000000..6bedc3e
--- /dev/null
+++ b/internal/tui/flamegraph/search.go
@@ -0,0 +1,141 @@
+package flamegraph
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+func (m *Model) openSearch() {
+ m.searchActive = true
+ m.searchInput.SetValue(m.searchQuery)
+ m.searchInput.CursorEnd()
+ m.searchInput.Focus()
+}
+
+func (m *Model) clearSearch() {
+ m.searchActive = false
+ m.searchQuery = ""
+ clearBoolMap(m.matchIndices)
+ clearBoolMap(m.filterVisible)
+ m.searchInput.SetValue("")
+ m.searchInput.Blur()
+ m.statusMessage = "Filter cleared"
+}
+
+func (m *Model) applySearchQuery(raw string) {
+ m.searchQuery = strings.ToLower(strings.TrimSpace(raw))
+ m.recomputeFilterState()
+ query := m.searchQuery
+ if query == "" {
+ m.ensureSelectionNavigable()
+ m.statusMessage = "Filter cleared"
+ return
+ }
+
+ if len(m.matchIndices) > 0 {
+ m.jumpMatch(1)
+ m.statusMessage = fmt.Sprintf("Filter %q: %d matches", query, len(m.matchIndices))
+ return
+ }
+ m.statusMessage = fmt.Sprintf("Filter %q: no matches", query)
+}
+
+func (m *Model) jumpMatch(direction int) {
+ matches := orderedMatchIndices(m.matchIndices)
+ if len(matches) == 0 {
+ return
+ }
+ currentPos := indexOf(matches, m.selectedIdx)
+ if currentPos == -1 {
+ if direction < 0 {
+ m.selectedIdx = matches[len(matches)-1]
+ } else {
+ m.selectedIdx = matches[0]
+ }
+ m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ return
+ }
+
+ next := currentPos + direction
+ if next < 0 {
+ next = len(matches) - 1
+ }
+ if next >= len(matches) {
+ next = 0
+ }
+ m.selectedIdx = matches[next]
+ m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+}
+
+func (m *Model) recomputeFilterState() {
+ if m.matchIndices == nil {
+ m.matchIndices = make(map[int]bool)
+ } else {
+ clearBoolMap(m.matchIndices)
+ }
+ if m.filterVisible == nil {
+ m.filterVisible = make(map[int]bool)
+ } else {
+ clearBoolMap(m.filterVisible)
+ }
+ if m.searchQuery == "" {
+ return
+ }
+
+ for idx, frame := range m.frames {
+ if strings.Contains(strings.ToLower(frame.Name), m.searchQuery) {
+ m.matchIndices[idx] = true
+ }
+ }
+ m.filterVisible = computeFilterVisibleSetInto(m.frames, m.matchIndices, m.filterVisible)
+}
+
+func orderedMatchIndices(matchSet map[int]bool) []int {
+ matches := make([]int, 0, len(matchSet))
+ for idx := range matchSet {
+ matches = append(matches, idx)
+ }
+ sort.Ints(matches)
+ return matches
+}
+
+func (m Model) searchFooter() string {
+ matches := orderedMatchIndices(m.matchIndices)
+ pos := 0
+ if len(matches) > 0 {
+ idx := indexOf(matches, m.selectedIdx)
+ if idx >= 0 {
+ pos = idx + 1
+ }
+ }
+ return fmt.Sprintf("%s %d/%d matches", m.searchInput.View(), pos, len(matches))
+}
+
+func replaceFooterLine(content, footer string) string {
+ if content == "" {
+ return footer
+ }
+ lastNewline := strings.LastIndexByte(content, '\n')
+ if lastNewline == -1 {
+ return footer
+ }
+ return content[:lastNewline+1] + footer
+}
+
+func replaceHeaderLine(content, header string) string {
+ if content == "" {
+ return header
+ }
+ firstNewline := strings.IndexByte(content, '\n')
+ if firstNewline == -1 {
+ return header
+ }
+ return header + content[firstNewline:]
+}
+
+func clearBoolMap[K comparable](values map[K]bool) {
+ for key := range values {
+ delete(values, key)
+ }
+}
diff --git a/internal/tui/flamegraph/stress_race_disabled_test.go b/internal/tui/flamegraph/stress_race_disabled_test.go
new file mode 100644
index 0000000..c9769fd
--- /dev/null
+++ b/internal/tui/flamegraph/stress_race_disabled_test.go
@@ -0,0 +1,7 @@
+//go:build !race
+
+package flamegraph
+
+func stressBudgetMultiplier() int {
+ return 1
+}
diff --git a/internal/tui/flamegraph/stress_race_enabled_test.go b/internal/tui/flamegraph/stress_race_enabled_test.go
new file mode 100644
index 0000000..30338f4
--- /dev/null
+++ b/internal/tui/flamegraph/stress_race_enabled_test.go
@@ -0,0 +1,7 @@
+//go:build race
+
+package flamegraph
+
+func stressBudgetMultiplier() int {
+ return 3
+}
diff --git a/internal/tui/flamegraph/stress_test.go b/internal/tui/flamegraph/stress_test.go
new file mode 100644
index 0000000..e53e4d5
--- /dev/null
+++ b/internal/tui/flamegraph/stress_test.go
@@ -0,0 +1,236 @@
+package flamegraph
+
+import (
+ "encoding/json"
+ "fmt"
+ "math/rand"
+ "sync"
+ "testing"
+ "time"
+
+ coreflamegraph "ior/internal/flamegraph"
+ "ior/internal/types"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+func TestStressHighEventRate(t *testing.T) {
+ t.Parallel()
+
+ const (
+ workerCount = 10
+ eventsPerWorker = 10000
+ testDuration = 5 * time.Second
+ renderFPS = 30
+ frameBudget = time.Second / renderFPS
+ )
+ allowedBudget := frameBudget * time.Duration(stressBudgetMultiplier())
+
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ var ingestWG sync.WaitGroup
+
+ type renderMetrics struct {
+ err error
+ samples int
+ total time.Duration
+ maxDuration time.Duration
+ }
+ renderDone := make(chan renderMetrics, 1)
+
+ go func() {
+ ticker := time.NewTicker(frameBudget)
+ defer ticker.Stop()
+ deadline := time.NewTimer(testDuration)
+ defer deadline.Stop()
+
+ metrics := renderMetrics{}
+ for {
+ select {
+ case <-ticker.C:
+ start := time.Now()
+ payload, _ := liveTrie.SnapshotJSON()
+ var snapshot snapshotNode
+ if err := json.Unmarshal(payload, &snapshot); err != nil {
+ metrics.err = fmt.Errorf("decode snapshot: %w", err)
+ renderDone <- metrics
+ return
+ }
+ frames := BuildTerminalLayout(&snapshot, 120, 40)
+ _ = frames
+
+ elapsed := time.Since(start)
+ metrics.samples++
+ metrics.total += elapsed
+ if elapsed > metrics.maxDuration {
+ metrics.maxDuration = elapsed
+ }
+ case <-deadline.C:
+ renderDone <- metrics
+ return
+ }
+ }
+ }()
+
+ for worker := 0; worker < workerCount; worker++ {
+ worker := worker
+ ingestWG.Add(1)
+ go func() {
+ defer ingestWG.Done()
+ for i := 0; i < eventsPerWorker; i++ {
+ seed := worker*eventsPerWorker + i
+ traceID := types.SYS_ENTER_READ
+ if seed%2 == 0 {
+ traceID = types.SYS_ENTER_WRITE
+ }
+ pair := newBenchmarkPair(
+ fmt.Sprintf("worker-%d", worker),
+ traceID,
+ uint32(1000+worker),
+ uint32(200000+seed),
+ buildBenchmarkPath(6, 3, seed),
+ )
+ liveTrie.Ingest(pair)
+ pair.Recycle()
+ }
+ }()
+ }
+
+ ingestWG.Wait()
+ metrics := <-renderDone
+
+ if metrics.err != nil {
+ t.Fatalf("render loop failed: %v", metrics.err)
+ }
+ if metrics.samples == 0 {
+ t.Fatal("render loop produced no samples")
+ }
+ avg := metrics.total / time.Duration(metrics.samples)
+ if avg > allowedBudget {
+ t.Fatalf("average render latency exceeded frame budget: avg=%s budget=%s samples=%d", avg, allowedBudget, metrics.samples)
+ }
+ if metrics.maxDuration > allowedBudget*6 {
+ t.Fatalf("max render latency too high: max=%s budget=%s", metrics.maxDuration, allowedBudget)
+ }
+}
+
+func TestStressRapidResize(t *testing.T) {
+ t.Parallel()
+
+ model := NewModel(nil)
+ model.width = 120
+ model.height = 40
+ model.snapshot = generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth)
+ model.rebuildFrames(false)
+ if len(model.frames) == 0 {
+ t.Fatal("expected initial medium fixture frames")
+ }
+
+ rng := rand.New(rand.NewSource(42))
+ lastWidth, lastHeight := model.width, model.height
+ for i := 0; i < 100; i++ {
+ lastWidth = 60 + rng.Intn(241) // [60, 300]
+ lastHeight = 20 + rng.Intn(61) // [20, 80]
+ next, _ := model.Update(tea.WindowSizeMsg{Width: lastWidth, Height: lastHeight})
+ model = next.(Model)
+ model = settleStressAnimation(model, 180)
+
+ assertFramesWithinBounds(t, model.frames, lastWidth, lastHeight)
+ if len(model.frames) > 0 && (model.selectedIdx < 0 || model.selectedIdx >= len(model.frames)) {
+ t.Fatalf("invalid selectedIdx after resize %d: idx=%d frames=%d", i, model.selectedIdx, len(model.frames))
+ }
+ }
+
+ if model.width != lastWidth || model.height != lastHeight {
+ t.Fatalf("final viewport mismatch: got %dx%d want %dx%d", model.width, model.height, lastWidth, lastHeight)
+ }
+ assertFramesWithinBounds(t, model.frames, lastWidth, lastHeight)
+}
+
+func TestStressZoomDuringRefresh(t *testing.T) {
+ t.Parallel()
+
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ ingestStressEvents(liveTrie, 200, 0)
+
+ model := NewModel(liveTrie)
+ model.SetViewport(120, 40)
+ if changed := model.RefreshFromLiveTrie(); !changed {
+ t.Fatal("expected initial live trie refresh")
+ }
+ if len(model.frames) == 0 {
+ t.Fatal("expected initial frames after refresh")
+ }
+
+ for i := 0; i < 50; i++ {
+ ingestStressEvents(liveTrie, 20, 1000+i*20)
+ _ = model.RefreshFromLiveTrie()
+ model = settleStressAnimation(model, 180)
+ if len(model.frames) == 0 {
+ t.Fatalf("expected frames after refresh tick %d", i)
+ }
+
+ prevDepth := len(model.zoomStack)
+ model.selectedIdx = midDepthFrameIndex(model.frames)
+ model.zoomIn()
+ model = settleStressAnimation(model, 180)
+ if len(model.zoomStack) != prevDepth+1 {
+ t.Fatalf("zoom stack did not grow after zoom-in at iteration %d: got=%d want=%d", i, len(model.zoomStack), prevDepth+1)
+ }
+
+ model.zoomUndo()
+ model = settleStressAnimation(model, 180)
+ if len(model.zoomStack) != prevDepth {
+ t.Fatalf("zoom stack depth mismatch after undo at iteration %d: got=%d want=%d", i, len(model.zoomStack), prevDepth)
+ }
+ if model.zoomPath != "" {
+ if findNodeByPath(model.snapshot, model.zoomPath) == nil {
+ t.Fatalf("zoomPath became invalid after undo at iteration %d: %q", i, model.zoomPath)
+ }
+ }
+ assertFramesWithinBounds(t, model.frames, model.width, model.height)
+ }
+}
+
+func settleStressAnimation(model Model, maxTicks int) Model {
+ for i := 0; i < maxTicks && model.animating; i++ {
+ next, _ := model.Update(animTickMsg{})
+ model = next.(Model)
+ }
+ return model
+}
+
+func assertFramesWithinBounds(t *testing.T, frames []tuiFrame, width, height int) {
+ t.Helper()
+ for _, frame := range frames {
+ if frame.Col < 0 || frame.Width <= 0 {
+ t.Fatalf("invalid frame geometry: %+v", frame)
+ }
+ if frame.Col+frame.Width > width {
+ t.Fatalf("frame exceeds width %d: %+v", width, frame)
+ }
+ if frame.Row < 0 || frame.Row >= height {
+ t.Fatalf("frame row outside height %d: %+v", height, frame)
+ }
+ }
+}
+
+func ingestStressEvents(liveTrie *coreflamegraph.LiveTrie, count, seedBase int) {
+ for i := 0; i < count; i++ {
+ seed := seedBase + i
+ traceID := types.SYS_ENTER_READ
+ if seed%3 == 0 {
+ traceID = types.SYS_ENTER_OPENAT
+ } else if seed%2 == 0 {
+ traceID = types.SYS_ENTER_WRITE
+ }
+ pair := newBenchmarkPair(
+ fmt.Sprintf("stress-%d", seed%8),
+ traceID,
+ uint32(1200+(seed%64)),
+ uint32(300000+seed),
+ buildBenchmarkPath(9, 5, seed),
+ )
+ liveTrie.Ingest(pair)
+ pair.Recycle()
+ }
+}
diff --git a/internal/tui/flamegraph/testdata_fixture_test.go b/internal/tui/flamegraph/testdata_fixture_test.go
new file mode 100644
index 0000000..1f22c26
--- /dev/null
+++ b/internal/tui/flamegraph/testdata_fixture_test.go
@@ -0,0 +1,39 @@
+package flamegraph
+
+import "testing"
+
+func TestFixtureSnapshotsHaveApproximateFrameCounts(t *testing.T) {
+ fixtures := []struct {
+ name string
+ depth int
+ breadth int
+ expect int
+ }{
+ {name: "small", depth: fixtureSmallDepth, breadth: fixtureSmallBreadth, expect: 121},
+ {name: "medium", depth: fixtureMediumDepth, breadth: fixtureMediumBreadth, expect: 2500},
+ {name: "large", depth: fixtureLargeDepth, breadth: fixtureLargeBreadth, expect: 12000},
+ {name: "deep", depth: fixtureDeepDepth, breadth: fixtureDeepBreadth, expect: 100},
+ {name: "wide", depth: fixtureWideDepth, breadth: fixtureWideBreadth, expect: 5000},
+ }
+
+ for _, fixture := range fixtures {
+ t.Run(fixture.name, func(t *testing.T) {
+ snap := generateTestSnapshot(fixture.depth, fixture.breadth)
+ got := snapshotNodeCount(snap)
+ if !approxEqualCount(got, fixture.expect) {
+ t.Fatalf("%s fixture nodes=%d, expected approximately %d", fixture.name, got, fixture.expect)
+ }
+ })
+ }
+}
+
+func TestGenerateTestTrieProducesSnapshotData(t *testing.T) {
+ lt := generateTestTrie(fixtureSmallDepth, fixtureSmallBreadth)
+ snap, err := decodeTrieSnapshot(lt)
+ if err != nil {
+ t.Fatalf("decode trie snapshot: %v", err)
+ }
+ if snap.Total == 0 {
+ t.Fatalf("expected generated trie snapshot to contain data")
+ }
+}
diff --git a/internal/tui/flamegraph/testdata_test.go b/internal/tui/flamegraph/testdata_test.go
new file mode 100644
index 0000000..c7d97b0
--- /dev/null
+++ b/internal/tui/flamegraph/testdata_test.go
@@ -0,0 +1,185 @@
+package flamegraph
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+
+ "ior/internal/event"
+ "ior/internal/file"
+ coreflamegraph "ior/internal/flamegraph"
+ "ior/internal/types"
+)
+
+const (
+ fixtureSmallDepth = 5
+ fixtureSmallBreadth = 3
+
+ fixtureMediumDepth = 10
+ fixtureMediumBreadth = 5
+
+ fixtureLargeDepth = 15
+ fixtureLargeBreadth = 8
+
+ fixtureDeepDepth = 50
+ fixtureDeepBreadth = 2
+
+ fixtureWideDepth = 3
+ fixtureWideBreadth = 50
+)
+
+func generateTestTrie(depth, breadthPerLevel int) *coreflamegraph.LiveTrie {
+ lt := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ comms := []string{"api", "db", "worker", "cache"}
+ traceIDs := []types.TraceId{
+ types.SYS_ENTER_READ,
+ types.SYS_ENTER_WRITE,
+ types.SYS_ENTER_OPENAT,
+ types.SYS_ENTER_CLOSE,
+ }
+
+ totalEvents := maxInt(100, fixtureTargetFrames(depth, breadthPerLevel)/2)
+ for i := 0; i < totalEvents; i++ {
+ comm := comms[i%len(comms)]
+ traceID := traceIDs[i%len(traceIDs)]
+ path := buildBenchmarkPath(depth, breadthPerLevel, i)
+ lt.Ingest(newBenchmarkPair(comm, traceID, uint32(1000+(i%256)), uint32(200000+i), path))
+ }
+ return lt
+}
+
+func generateTestSnapshot(depth, breadthPerLevel int) *snapshotNode {
+ targetFrames := fixtureTargetFrames(depth, breadthPerLevel)
+ if targetFrames < 1 {
+ targetFrames = 1
+ }
+
+ root := &snapshotNode{Name: "root", Value: 1}
+ type qItem struct {
+ node *snapshotNode
+ depth int
+ }
+ queue := []qItem{{node: root, depth: 0}}
+ created := 1
+
+ for len(queue) > 0 && created < targetFrames {
+ item := queue[0]
+ queue = queue[1:]
+ if item.depth >= depth {
+ continue
+ }
+ remaining := targetFrames - created
+ branchCount := breadthPerLevel
+ if branchCount > remaining {
+ branchCount = remaining
+ }
+ for i := 0; i < branchCount; i++ {
+ child := &snapshotNode{
+ Name: fmt.Sprintf("d%d-n%d", item.depth+1, created+i),
+ Value: 1,
+ }
+ item.node.Children = append(item.node.Children, child)
+ queue = append(queue, qItem{node: child, depth: item.depth + 1})
+ }
+ created += branchCount
+ }
+
+ computeSnapshotTotals(root)
+ return root
+}
+
+func fixtureTargetFrames(depth, breadth int) int {
+ switch {
+ case depth == fixtureSmallDepth && breadth == fixtureSmallBreadth:
+ return 121
+ case depth == fixtureMediumDepth && breadth == fixtureMediumBreadth:
+ return 2500
+ case depth == fixtureLargeDepth && breadth == fixtureLargeBreadth:
+ return 12000
+ case depth == fixtureDeepDepth && breadth == fixtureDeepBreadth:
+ return 100
+ case depth == fixtureWideDepth && breadth == fixtureWideBreadth:
+ return 5000
+ default:
+ return maxInt(1, depth*breadth*10)
+ }
+}
+
+func computeSnapshotTotals(node *snapshotNode) uint64 {
+ if node == nil {
+ return 0
+ }
+ total := node.Value
+ for _, child := range node.Children {
+ total += computeSnapshotTotals(child)
+ }
+ node.Total = total
+ return total
+}
+
+func buildBenchmarkPath(depth, breadth, seed int) string {
+ if depth < 1 {
+ depth = 1
+ }
+ if breadth < 1 {
+ breadth = 1
+ }
+ path := "/bench"
+ value := seed
+ for level := 0; level < depth; level++ {
+ slot := value % breadth
+ path += fmt.Sprintf("/l%d-b%d", level, slot)
+ value = value / breadth
+ }
+ return path
+}
+
+func newBenchmarkPair(comm string, traceID types.TraceId, pid, tid uint32, path string) *event.Pair {
+ enter := &types.OpenEvent{
+ TraceId: traceID,
+ Pid: pid,
+ Tid: tid,
+ }
+ exit := &types.RetEvent{
+ TraceId: types.SYS_EXIT_OPENAT,
+ Pid: pid,
+ Tid: tid,
+ }
+ pair := event.NewPair(enter)
+ pair.ExitEv = exit
+ pair.File = file.NewFd(3, path, 0)
+ pair.Comm = comm
+ pair.Duration = 1
+ pair.DurationToPrev = 1
+ pair.Bytes = 64
+ return pair
+}
+
+func snapshotNodeCount(node *snapshotNode) int {
+ if node == nil {
+ return 0
+ }
+ total := 1
+ for _, child := range node.Children {
+ total += snapshotNodeCount(child)
+ }
+ return total
+}
+
+func approxEqualCount(got, want int) bool {
+ if got == want {
+ return true
+ }
+ const tolerance = 0.2
+ diff := math.Abs(float64(got-want)) / float64(want)
+ return diff <= tolerance
+}
+
+func decodeTrieSnapshot(lt *coreflamegraph.LiveTrie) (*snapshotNode, error) {
+ payload, _ := lt.SnapshotJSON()
+ var snap snapshotNode
+ if err := json.Unmarshal(payload, &snap); err != nil {
+ return nil, err
+ }
+ return &snap, nil
+}
diff --git a/internal/tui/flamegraph/zoom.go b/internal/tui/flamegraph/zoom.go
new file mode 100644
index 0000000..7a3aa42
--- /dev/null
+++ b/internal/tui/flamegraph/zoom.go
@@ -0,0 +1,39 @@
+package flamegraph
+
+import "strings"
+
+func findNodeByPath(root *snapshotNode, path string) *snapshotNode {
+ if root == nil {
+ return nil
+ }
+ if path == "" {
+ return root
+ }
+ parts := strings.Split(path, pathSeparator)
+ if len(parts) == 0 {
+ return root
+ }
+ rootName := frameName(root.Name, 0)
+ if parts[0] == rootName {
+ parts = parts[1:]
+ }
+
+ node := root
+ for _, part := range parts {
+ next := findChildByName(node, part)
+ if next == nil {
+ return nil
+ }
+ node = next
+ }
+ return node
+}
+
+func findChildByName(node *snapshotNode, name string) *snapshotNode {
+ for _, child := range node.Children {
+ if child.Name == name || frameName(child.Name, 1) == name {
+ return child
+ }
+ }
+ return nil
+}