diff options
| -rw-r--r-- | docs/tui-flamegraph-plan.md | 452 | ||||
| -rw-r--r-- | internal/tui/common/keys.go | 14 | ||||
| -rw-r--r-- | internal/tui/common/keys_test.go | 20 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 44 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 87 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 2 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 28 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 26 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 24 | ||||
| -rw-r--r-- | internal/tui/tui.go | 8 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 58 |
11 files changed, 704 insertions, 59 deletions
diff --git a/docs/tui-flamegraph-plan.md b/docs/tui-flamegraph-plan.md new file mode 100644 index 0000000..67e8653 --- /dev/null +++ b/docs/tui-flamegraph-plan.md @@ -0,0 +1,452 @@ +# TUI Flamegraph Tab - Full Design Plan + +## Overview + +Add a **7th dashboard tab** (`7:Flame`) that renders a live, interactive flamegraph +directly in the terminal using lipgloss for layout/styling and **Charm Harmonica** +for smooth spring-based animations on both zoom transitions and live data refresh. +The tab consumes data from an embedded `LiveTrie` (same as the web live mode) and +provides full feature parity with the browser version. + +## Architecture + +``` +BPF events -> eventLoop.printCb + | + +-> statsengine.Ingest() (existing tabs 1-5) + +-> eventstream.Push() (existing tab 6) + +-> LiveTrie.Ingest() (NEW: tab 7) +``` + +The `LiveTrie` is instantiated in the TUI startup path and published via +`runtimeBindings`, similar to how `SnapshotSource` and `RingBuffer` are already +wired. + +## New Files and Packages + +| File/Package | Purpose | +|---|---| +| `internal/tui/flamegraph/model.go` | Bubble Tea sub-model: state, Update, View | +| `internal/tui/flamegraph/renderer.go` | Converts LiveTrie snapshot -> terminal frame layout, renders with lipgloss | +| `internal/tui/flamegraph/animation.go` | Harmonica spring state for frame width interpolation and zoom transitions | +| `internal/tui/flamegraph/search.go` | Search/highlight: text input bubble, match filtering, highlight styling | +| `internal/tui/flamegraph/zoom.go` | Zoom stack management (zoom into subtree, undo zoom, reset zoom) | +| `internal/tui/flamegraph/controls.go` | Toolbar rendering (status line, field order, keybindings help) | + +## Detailed Design + +### 1. Data Wiring + +**Changes to existing files:** + +- `internal/tui/tui.go` -- Add `SetLiveTrie(*flamegraph.LiveTrie)` to + `TraceRuntimeBindings` interface and `runtimeBindings` struct. The trace starter + publishes the `LiveTrie` the same way it publishes the stats engine. +- `internal/ior.go` / trace setup -- When running in TUI mode, create a `LiveTrie` + alongside the stats engine. In the `eventLoop.printCb`, call + `liveTrie.Ingest(ep)` in addition to existing stats/stream ingestion. Publish via + `bindings.SetLiveTrie(lt)`. +- `internal/tui/dashboard/model.go` -- Add `liveTrie *flamegraph.LiveTrie` field, + `flamegraphModel flamegraphtui.Model` child model. Wire refresh tick to poll + `LiveTrie.Version()`. + +### 2. Tab Integration + +**Changes to `internal/tui/dashboard/tabs.go`:** + +```go +const ( + // ... existing tabs ... + TabFlame // new 7th tab +) + +var allTabs = []Tab{..., TabFlame} +``` + +Tab label: `"Flame"` (short: `"Flm"`). Key binding: `7`. + +**Changes to `internal/tui/dashboard/model.go`:** + +- Add `flamegraphModel` field of type `flamegraphtui.Model` +- In `Update()`, on `refreshTickMsg` or a dedicated `flameTickMsg` (200ms like + stream), poll `LiveTrie.Version()` and push snapshot data into the flamegraph + model +- In `handleKey()`, add `key.Matches(msg, m.keys.Seven)` -> `TabFlame` +- In `renderActiveTab()`, delegate to `flamegraphModel.View(width, height)` +- On `WindowSizeMsg`, propagate dimensions to `flamegraphModel.SetViewport(w, h)` + -- this triggers re-layout of all frames to fit new terminal size + +### 3. Flamegraph TUI Model (`internal/tui/flamegraph/model.go`) + +```go +type Model struct { + // Data + liveTrie *flamegraph.LiveTrie + lastVersion uint64 + snapshot *flamegraph.trieSnapshot // latest parsed snapshot + + // Layout + frames []tuiFrame // current rendered frames + targetFrames []tuiFrame // target frames (for animation lerp) + width, height int + + // Interaction + selectedIdx int // cursor/selected frame index + zoomStack []zoomState // zoom history for undo + zoomRoot *flamegraph.trieSnapshot // current zoom root (nil = full view) + + // Search + searchActive bool + searchInput textinput.Model // from bubbles/textinput + searchQuery string + matchIndices map[int]bool // frame indices matching search + + // Field ordering + fieldPresets [][]string + fieldIndex int + + // Animation + springs []frameSpring // per-frame Harmonica spring state + animTicker bool // whether animation tick is running + + // Flags + paused bool + isDark bool +} + +type tuiFrame struct { + Name string + Col int // column position (0-based, in terminal cells) + Row int // row from bottom + Width int // width in terminal cells + Total uint64 + Percent float64 + Fill lipgloss.Color + Depth int + Path string // full path for zoom identification +} +``` + +### 4. Rendering Strategy (`internal/tui/flamegraph/renderer.go`) + +Terminal flamegraphs use a **cell-based layout** rather than pixel coordinates: + +1. **BuildTerminalLayout(snapshot, width, height, zoomRoot)** converts trie snapshot + to `[]tuiFrame`: + - Width is terminal columns (not 1200px). Each frame width = + `floor(termWidth * (node.total / rootTotal))`. + - Height is terminal rows. Each frame is exactly **1 row tall** (not 16px). + - Rows grow bottom-to-top: root at the bottom, leaves at the top (classic + flamegraph orientation). If tree depth exceeds available rows, only show the + deepest `height-2` levels (toolbar + status take 2 rows). + - Frames narrower than 1 cell are culled (terminal equivalent of `minWidthPx`). + +2. **Frame rendering**: Each frame is a **colored block** of text: + - Use lipgloss background color fill with the existing `frameColor()` warm palette + - Frame text = truncated function/path name that fits within the frame width + - Selected frame gets a distinct border/highlight style (e.g., bold + inverted) + - Search-matched frames get a different highlight color (e.g., red background like + the web version's `matchColor`) + +3. **Compositing**: Use `lipgloss.Place()` or the new lipgloss v2 compositor/canvas + to layer frames at their (col, row) positions. Each row of the flamegraph is + assembled by joining frame cells horizontally with background fill for gaps. + +4. **Auto-resize**: On `WindowSizeMsg`, re-run `BuildTerminalLayout` with new + dimensions. All frame widths and row counts recalculate. Harmonica springs + animate from old positions/widths to new ones. + +### 5. Subtree Highlighting + +When the user selects (navigates to) a frame, the **entire subtree rooted at that +frame** is visually highlighted so the user can see exactly what would be zoomed +into on `enter`. + +**Visual states for any frame:** + +| State | Visual Treatment | +|---|---| +| **Selected frame** | Bold text + bright border/underline + slightly lightened background | +| **Selected subtree** (ancestors + descendants) | Full saturation, normal brightness -- "active" look | +| **Not in subtree** | **Dimmed**: reduced saturation / lower contrast background, muted text | +| **Search match** | Red/magenta background overlay (overrides dim but not selection) | + +Dimming the *non-subtree* frames makes the selected subtree "pop" naturally. + +**Ancestor vs Descendant Distinction:** + +| Relationship | Visual | +|---|---| +| **Selected frame** | Bold, inverted/bright border | +| **Descendants** | Full color, normal weight | +| **Ancestors** | Full color with subtle left-border indicator (breadcrumb trail) | +| **Unrelated** | Dimmed (lower contrast background, gray text) | + +**Subtree membership** computed via `Path` field (the `\x1f`-delimited ancestor +chain). A frame is in the subtree if: +- Its path is a **prefix** of the selected frame's path (ancestor), OR +- The selected frame's path is a **prefix** of its path (descendant), OR +- It **is** the selected frame + +O(n) scan over frames, recomputed each time selection moves. + +**Interaction with search**: Search matches outside subtree shown dimmed in match +color; inside subtree shown bright. Selected frame matching search uses selection +style. + +### 6. Animation with Harmonica (`internal/tui/flamegraph/animation.go`) + +```go +type frameSpring struct { + widthSpring harmonica.Spring + colSpring harmonica.Spring + currentW float64 + currentCol float64 + velocityW float64 + velocityCol float64 +} +``` + +**Two animation scenarios:** + +1. **Data refresh**: When `LiveTrie` version changes and new frame widths differ + from current, set new target widths. On each animation tick (~30fps = + `tea.Tick(33ms)`), call `spring.Update(current, velocity, target)` for each + frame's width and column. Render at interpolated values. Stop animation tick + when all frames reach target within epsilon. + +2. **Zoom transition**: When user zooms into a subtree, the target layout changes + (zoomed subtree expands to fill full width). Springs animate column positions + and widths from pre-zoom to post-zoom. Undo-zoom reverses this. + +**Spring configuration**: `harmonica.NewSpring(harmonica.FPS(30), 6.0, 1.0)` -- +critically damped for snappy transitions without oscillation. + +### 7. Keybindings + +| Key(s) | Action | +|---|---| +| `j` / `down` / `arrow-down` | Move selection to frame below (shallower depth) | +| `k` / `up` / `arrow-up` | Move selection to frame above (deeper / toward leaves) | +| `h` / `left` / `arrow-left` | Move selection to previous sibling at same depth | +| `l` / `right` / `arrow-right` | Move selection to next sibling at same depth | +| `enter` | Zoom into selected frame's subtree | +| `backspace` / `u` | Undo zoom (pop zoom stack) | +| `escape` (when zoomed) | Reset zoom to root | +| `/` | Open search input | +| `escape` (when searching) | Close search, clear highlights | +| `n` | Jump to next search match | +| `N` (shift+n) | Jump to previous search match | +| `p` | Toggle pause | +| `r` | Reset baseline | +| `o` | Cycle field order preset | +| `?` | Toggle flame-specific help overlay | + +Both vim-style (j/k/h/l) and regular cursor keys (arrow keys) are bound to the +same actions via `key.NewBinding(key.WithKeys("j", "down"))`. + +### 8. Search (`internal/tui/flamegraph/search.go`) + +- Uses `bubbles/textinput` for inline search input at the bottom of flame view +- On submit, iterate frames and mark matching indices (case-insensitive substring + match on frame name) +- Matching frames rendered with highlight color; non-matching frames dimmed +- `n`/`N` moves selection to next/previous match +- Show match count in status line (e.g. `3/12 matches`) + +### 8.1 Color Coding (Implemented) + +Flame frames now use semantic colors first, with hash-based fallback for unknown labels: + +| Category | Match rule | Color (RGBA / hex) | +|---|---|---| +| Read I/O | name contains `read`/`pread` | `78,132,201` (`#4E84C9`) | +| Write I/O | name contains `write`/`pwrite` | `222,122,58` (`#DE7A3A`) | +| Metadata I/O | name contains `open`, `close`, `stat`, `rename`, `link` | `196,168,72` (`#C4A848`) | +| Path-oriented nodes | starts with `/`, contains `/`, or `path:` | `88,156,84` (`#589C54`) | +| Process/thread labels | contains `pid` or `tid` | `67,151,149` (`#439795`) | +| Other syscall buckets | starts with `sys_` | `191,99,74` (`#BF634A`) | +| Fallback | anything else | deterministic hash palette | + +This keeps common I/O classes visually stable across refreshes while preserving +distinct colors for uncategorized frames. + +### 9. Zoom (`internal/tui/flamegraph/zoom.go`) + +- `zoomStack []zoomState` where `zoomState` holds the `path` string of the zoomed + node and the previous `selectedIdx` +- On zoom-in: push current state, find subtree node matching selected frame's path, + set as `zoomRoot`, rebuild layout with subtree as root +- On undo: pop stack, restore previous root +- On reset: clear stack, set `zoomRoot = nil` + +### 10. Field Order Cycling + +Same preset cycle as the web version: +```go +fieldPresets = [][]string{ + {"comm", "path", "tracepoint"}, + {"path", "tracepoint", "comm"}, + {"tracepoint", "comm", "path"}, + {"pid", "path", "tracepoint"}, +} +``` + +Pressing `o` calls `LiveTrie.Reconfigure(nextPreset)` which resets the trie and +starts fresh accumulation. + +### 11. Toolbar / Status Line (`internal/tui/flamegraph/controls.go`) + +Rendered as a single line above the flamegraph area: + +``` +[LIVE] | o:order(comm>path>tp) | /:search | enter:zoom | u:undo | r:reset | p:pause +``` + +When paused: `[PAUSED]` in red. When searching: shows search input and match count. + +Selected frame info line at the bottom: +``` +sys_read (1,234 calls, 45.2%) - /usr/bin/myapp > /dev/sda > sys_enter_read +``` + +### 12. Dependencies to Add + +- `github.com/charmbracelet/harmonica` -- spring animation +- `charm.land/bubbles/v2/textinput` -- search input (already transitive via bubbles v2) + +### 13. Changes to Existing Files (Summary) + +| File | Change | +|---|---| +| `internal/tui/dashboard/tabs.go` | Add `TabFlame`, update `allTabs`, `String()`, `tabLabel()` | +| `internal/tui/dashboard/model.go` | Add `flamegraphModel` field, wire refresh, handle key `7`, render in `renderActiveTab()` | +| `internal/tui/tui.go` | Add `SetLiveTrie()` to bindings interface, propagate to dashboard | +| `internal/tui/common/keys.go` | Add `Seven` key binding for tab 7 | +| `internal/ior.go` | Create `LiveTrie` in TUI mode, wire into eventLoop callback, publish via bindings | +| `internal/flags/flags.go` | Add `-fields` default propagation to TUI mode | +| `go.mod` | Add `github.com/charmbracelet/harmonica` dependency | + +### 14. Risks and Mitigations + +1. **Performance at high event rates**: The `LiveTrie.Ingest()` call adds overhead + to the hot path. Mitigation: already designed for production rates (used in + `-live` mode). TUI render is decoupled via version polling. + +2. **Terminal width too narrow**: Flamegraphs with many shallow frames may not + render meaningfully in 80-column terminals. Mitigation: cull frames below 1 cell, + show "terminal too narrow" message below ~60 columns. + +3. **Animation frame budget**: 30fps animation ticks in a terminal could cause + flicker on slow terminals. Mitigation: only run animation tick when springs are + active, stop when settled. + +4. **Color support**: Not all terminals support 24-bit color. Mitigation: lipgloss + v2 auto-downgrades. The warm flamegraph palette degrades gracefully to 256-color. + +--- + +## Benchmarking & Profiling Plan + +### Goals + +1. Quantify render performance at various terminal sizes and trie depths +2. Measure animation overhead of Harmonica spring ticks at 30fps with N springs +3. Detect regressions via baseline benchmarks running in CI alongside `mage bench` +4. Profile hot paths to identify allocations and CPU bottlenecks + +### Benchmark Suite + +New file: `internal/tui/flamegraph/bench_test.go` + +| Benchmark | What it measures | +|---|---| +| `BenchmarkBuildTerminalLayout` | trieSnapshot -> []tuiFrame at widths 80/120/200/300 and depths 10/50/100 | +| `BenchmarkRenderFrame` | Full View() render at 80x24, 120x40, 200x60 | +| `BenchmarkComputeSubtreeSet` | Subtree membership with 100/1000/5000 frames | +| `BenchmarkSearchHighlight` | Search match computation across N frames | +| `BenchmarkSpringUpdate` | harmonica spring.Update() across 100/500/2000 springs | +| `BenchmarkAnimationTick` | Full tick: update springs + rebuild render output | +| `BenchmarkZoomTransition` | Layout rebuild on zoom-in | +| `BenchmarkLiveTrieIngestAndSnapshot` | End-to-end: ingest N events + snapshot + layout | +| `BenchmarkResizeRelayout` | Layout rebuild at new terminal dimensions | + +### Benchmark Fixtures + +Synthetic trie generators in `internal/tui/flamegraph/testdata_test.go`: + +| Label | Depth | Breadth | Approximate frame count | +|---|---|---|---| +| `small` | 5 | 3 | ~120 | +| `medium` | 10 | 5 | ~2,500 | +| `large` | 15 | 8 | ~10,000+ | +| `deep` | 50 | 2 | ~100 (narrow but deep) | +| `wide` | 3 | 50 | ~5,000 (shallow but very wide) | + +### Performance Targets + +| Operation | Target | Rationale | +|---|---|---| +| `BuildTerminalLayout` (medium, 120-col) | < 500us | Well within one tick interval | +| `View()` full render (medium, 120x40) | < 2ms | 30fps = 33ms budget | +| `ComputeSubtreeSet` (1000 frames) | < 100us | Runs on every selection move | +| Single animation tick (500 springs) | < 1ms | 16ms frame budget headroom | +| `LiveTrie.Ingest` + `SnapshotJSON` | < 200us | Hot path performance | + +### Profiling Integration + +#### Built-in profiling flag + +When `-pprof` is set in TUI mode: +- Write `ior-tui-cpu.prof` during session +- Write `ior-tui-mem.prof` on quit +- Write `ior-tui-trace.out` for first 10 seconds + +#### Mage Targets + +| Target | Command | +|---|---| +| `mage benchFlame` | `go test ./internal/tui/flamegraph/ -bench=. -benchmem -count=5` | +| `mage benchFlameProf` | Same + `-cpuprofile` + `-memprofile` | +| `mage benchFlameCmp` | Compare against saved baseline via `benchstat` | + +### Allocation Targets + +| Hot path | Strategy | +|---|---| +| `BuildTerminalLayout` | Pre-allocate []tuiFrame, reuse across refreshes | +| `View()` render | strings.Builder with pre-estimated capacity, cache styles | +| `computeSubtreeSet` | Reuse map[int]bool (clear + repopulate) | +| Spring updates | Fixed-size []frameSpring, no per-tick allocs | + +Target: **zero allocs** in animation tick, **< 5 allocs/op** in full render. + +### Stress Tests + +New file: `internal/tui/flamegraph/stress_test.go` + +- **TestStressHighEventRate**: 100k events from 10 goroutines + concurrent render +- **TestStressRapidResize**: 100 WindowSizeMsg with random dimensions +- **TestStressZoomDuringRefresh**: Interleaved zoom/undo with data refresh ticks + +All run with `-race`. + +### Profiling Workflow (Manual) + +```bash +# Run TUI with profiling +sudo ior -pprof -pid 1234 + +# Analyze CPU profile +go tool pprof -http=:8080 ior-tui-cpu.prof + +# Analyze allocations +go tool pprof -http=:8080 -alloc_space ior-tui-mem.prof + +# Analyze execution trace +go tool trace ior-tui-trace.out + +# Benchmark-specific profiling +mage benchFlameProf +go tool pprof -http=:8080 flame-cpu.prof +``` diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index 6b0ae27..ab9865d 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -38,13 +38,13 @@ func DefaultKeyMap() KeyMap { return KeyMap{ Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")), - One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "overview")), - Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "syscalls")), - Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "files")), - Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "processes")), - Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "lat+gaps")), - Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "stream")), - Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "flame")), + One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")), + Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")), + Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")), + Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")), + Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")), + Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")), + Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")), DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")), SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")), SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")), diff --git a/internal/tui/common/keys_test.go b/internal/tui/common/keys_test.go index e043f9e..4284faf 100644 --- a/internal/tui/common/keys_test.go +++ b/internal/tui/common/keys_test.go @@ -24,8 +24,8 @@ func TestDefaultKeyMapIncludesDirGroupBinding(t *testing.T) { t.Fatalf("unexpected select tid binding help: key=%q desc=%q", selectTIDHelp.Key, selectTIDHelp.Desc) } - flameHelp := keys.Seven.Help() - if flameHelp.Key != "7" || flameHelp.Desc != "flame" { + flameHelp := keys.One.Help() + if flameHelp.Key != "1" || flameHelp.Desc != "flame" { t.Fatalf("unexpected flame binding help: key=%q desc=%q", flameHelp.Key, flameHelp.Desc) } } @@ -38,7 +38,7 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { } found := false - foundSeven := false + foundOne := false for _, binding := range groups[1] { help := binding.Help() if help.Key == "d" && help.Desc == "dir group" { @@ -52,12 +52,12 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { for _, binding := range groups[0] { help := binding.Help() - if help.Key == "7" && help.Desc == "flame" { - foundSeven = true + if help.Key == "1" && help.Desc == "flame" { + foundOne = true break } } - if !foundSeven { + if !foundOne { t.Fatalf("expected flame tab binding in dashboard full help tabs") } @@ -103,7 +103,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { short := keys.DashboardStatusHelp() found := false foundSelectTID := false - foundSeven := false + foundOne := false for _, binding := range short { help := binding.Help() if help.Key == "o" && help.Desc == "probes" { @@ -112,8 +112,8 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { if help.Key == "t" && help.Desc == "select tid" { foundSelectTID = true } - if help.Key == "7" && help.Desc == "flame" { - foundSeven = true + if help.Key == "1" && help.Desc == "flame" { + foundOne = true } } if !found { @@ -122,7 +122,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { if !foundSelectTID { t.Fatalf("expected select tid binding in dashboard short help") } - if !foundSeven { + if !foundOne { t.Fatalf("expected flame tab binding in dashboard short help") } } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 7807b31..24b6c8e 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -68,7 +68,7 @@ func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuf refreshMs = defaultRefreshMs } m := Model{ - activeTab: TabOverview, + activeTab: TabFlame, engine: engine, refreshEvery: time.Duration(refreshMs) * time.Millisecond, keys: keys, @@ -157,6 +157,11 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.showHelp = !m.showHelp return m, nil } + if m.activeTab == TabFlame && m.flamegraphModel.ConsumesKey(msg) { + next, flameCmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, flameCmd + } handled, scrollCmd := m.handleScrollKey(msg) if scrollCmd != nil { cmd = scrollCmd @@ -167,32 +172,32 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if !handled { switch { + case key.Matches(msg, m.keys.One): + m.activeTab = TabFlame + handled = true case key.Matches(msg, m.keys.Tab): m.activeTab = nextTab(m.activeTab) handled = true case key.Matches(msg, m.keys.ShiftTab): m.activeTab = prevTab(m.activeTab) handled = true - case key.Matches(msg, m.keys.One): - m.activeTab = TabOverview - handled = true case key.Matches(msg, m.keys.Two): - m.activeTab = TabSyscalls + m.activeTab = TabOverview handled = true case key.Matches(msg, m.keys.Three): - m.activeTab = TabFiles + m.activeTab = TabSyscalls handled = true case key.Matches(msg, m.keys.Four): - m.activeTab = TabProcesses + m.activeTab = TabFiles handled = true case key.Matches(msg, m.keys.Five): - m.activeTab = TabLatency + m.activeTab = TabProcesses handled = true case key.Matches(msg, m.keys.Six): - m.activeTab = TabStream + m.activeTab = TabLatency handled = true case key.Matches(msg, m.keys.Seven): - m.activeTab = TabFlame + m.activeTab = TabStream handled = true case key.Matches(msg, m.keys.Refresh): snap := m.snapshot() @@ -206,6 +211,11 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } } if !handled { + if m.activeTab == TabFlame { + next, flameCmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, flameCmd + } return m, nil } batch := make([]tea.Cmd, 0, 3) @@ -317,10 +327,16 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot { return m.latest } -// BlocksGlobalShortcuts reports whether modal UI in the active tab should -// suppress top-level shortcuts (for example global export key handling). -func (m Model) BlocksGlobalShortcuts() bool { - return m.activeTab == TabStream && (m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible()) +// BlocksGlobalShortcuts reports whether the active tab should suppress a +// top-level shortcut for the given key press. +func (m Model) BlocksGlobalShortcuts(msg tea.KeyPressMsg) bool { + if m.activeTab == TabStream { + return m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible() + } + if m.activeTab == TabFlame { + return m.flamegraphModel.ConsumesKey(msg) + } + return false } // SetStreamSource updates the live stream source used by the stream tab. diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 46f4944..6d35d5a 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -31,37 +31,38 @@ func TestKeySwitchingChangesActiveTab(t *testing.T) { next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) model := next.(Model) - if model.activeTab != TabSyscalls { - t.Fatalf("expected syscalls tab, got %v", model.activeTab) + if model.activeTab != TabOverview { + t.Fatalf("expected overview tab on key 2, got %v", model.activeTab) } next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) model = next.(Model) - if model.activeTab != TabFiles { - t.Fatalf("expected next tab to be files, got %v", model.activeTab) + if model.activeTab != TabSyscalls { + t.Fatalf("expected next tab to be syscalls, got %v", model.activeTab) } next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) model = next.(Model) - if model.activeTab != TabSyscalls { - t.Fatalf("expected previous tab to be syscalls, got %v", model.activeTab) + if model.activeTab != TabOverview { + t.Fatalf("expected previous tab to be overview, got %v", model.activeTab) } next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})}) model = next.(Model) - if model.activeTab != TabFlame { - t.Fatalf("expected flame tab on key 7, got %v", model.activeTab) + if model.activeTab != TabStream { + t.Fatalf("expected stream tab on key 7, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'6'}[0], Text: string([]rune{'6'})}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'1'}[0], Text: string([]rune{'1'})}) model = next.(Model) - if model.activeTab != TabStream { - t.Fatalf("expected stream tab on key 6, got %v", model.activeTab) + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab on key 1, got %v", model.activeTab) } } func TestArrowAndViKeysDoNotCycleTabs(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabOverview next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) model := next.(Model) @@ -303,6 +304,7 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) { snap := &statsengine.Snapshot{TotalSyscalls: 13} engine := &fakeSnapshotSource{snap: snap} m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabOverview next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) _ = next if cmd == nil { @@ -318,6 +320,63 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) { } } +func TestFlameTabReceivesSlashKey(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: string([]rune{'/'})}) + model := next.(Model) + if cmd != nil { + t.Fatalf("did not expect global command for flame search key") + } + if !strings.Contains(model.View().Content, "0/0 matches") { + t.Fatalf("expected flame search footer after pressing /") + } +} + +func TestFlameTabReceivesResetAndPauseKeys(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})}) + model := next.(Model) + if !strings.Contains(model.View().Content, "[PAUSED]") { + t.Fatalf("expected flame pause key to toggle paused state") + } + + next, cmd := model.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) + model = next.(Model) + if cmd != nil { + t.Fatalf("expected flame reset key to be handled by flame tab without global refresh command") + } + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab to stay active after reset key") + } +} + +func TestFlameSearchConsumesNumericTabKeys(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: string([]rune{'/'})}) + model := next.(Model) + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab to stay active after opening search") + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) + model = next.(Model) + if model.activeTab != TabFlame { + t.Fatalf("expected numeric key while searching to stay in flame tab") + } +} + func TestRefreshTickEmitsStatsTickMsg(t *testing.T) { snap := &statsengine.Snapshot{TotalSyscalls: 9} engine := &fakeSnapshotSource{snap: snap} @@ -386,8 +445,8 @@ func TestStatsTickClampsGroupedFilesOffset(t *testing.T) { func TestViewRendersTabBarAndHelp(t *testing.T) { m := NewModelWithConfig(nil, nil, 1000, common.DefaultKeyMap()) out := m.View().Content - if !strings.Contains(out, "Overview") { - t.Fatalf("expected overview label in view") + if !strings.Contains(out, "Flame") { + t.Fatalf("expected flame tab label in view") } if !strings.Contains(out, "press H for help") { t.Fatalf("expected help hint text in view") @@ -437,7 +496,7 @@ func TestStreamTabViewKeepsTabAndHelpChromeVisible(t *testing.T) { m.streamModel.Refresh() out := m.View().Content - if !strings.Contains(out, "1:Overview") { + if !strings.Contains(out, "1:Flame") { t.Fatalf("expected tab bar to remain visible in stream view") } if !strings.Contains(out, "press H for help") { diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go index 731e21f..85ce319 100644 --- a/internal/tui/dashboard/tabs.go +++ b/internal/tui/dashboard/tabs.go @@ -30,13 +30,13 @@ const ( ) var allTabs = []Tab{ + TabFlame, TabOverview, TabSyscalls, TabFiles, TabProcesses, TabLatency, TabStream, - TabFlame, } func (t Tab) String() string { diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 5f5a83c..5d101c2 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -208,6 +208,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 msg.String() == "/", + msg.String() == "n", + msg.String() == "N", + msg.String() == "p", + msg.String() == "r", + msg.String() == "o", + msg.String() == "?": + return true + case key.Matches(msg, m.keys.ZoomIn), + key.Matches(msg, m.keys.ZoomUndo), + key.Matches(msg, m.keys.ZoomReset), + key.Matches(msg, m.keys.MoveShallower), + key.Matches(msg, m.keys.MoveDeeper), + key.Matches(msg, m.keys.PrevSibling), + key.Matches(msg, m.keys.NextSibling): + return true + default: + return false + } +} + // View renders the flamegraph viewport. func (m Model) View() tea.View { content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.subtreeSet, m.matchIndices, m.isDark, m.searchActive, m.searchQuery) diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index ad74173..9f31023 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -102,6 +102,10 @@ func frameName(name string, depth int) string { } func terminalFrameColor(name string) color.Color { + if semantic, ok := semanticFrameColor(name); ok { + return semantic + } + hasher := fnv.New32a() _, _ = hasher.Write([]byte(name)) h := hasher.Sum32() @@ -113,6 +117,28 @@ func terminalFrameColor(name string) color.Color { } } +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 map[int]bool, isDark, searchActive bool, searchQuery string) string { if width < minFlameWidth { diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go index ca837fe..eb111b8 100644 --- a/internal/tui/flamegraph/renderer_test.go +++ b/internal/tui/flamegraph/renderer_test.go @@ -1,6 +1,7 @@ package flamegraph import ( + "image/color" "strings" "testing" ) @@ -108,6 +109,29 @@ func TestBuildTerminalLayoutUsesPathSeparatorAndColor(t *testing.T) { } } +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, true, false, "") if !strings.Contains(out, "terminal too narrow") { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ab719fb..328202e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -300,18 +300,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.stopTrace() return m, tea.Quit } - if m.exportEnabled && m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() { + if m.exportEnabled && m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) { m.exporter = m.exporter.Open() return m, nil } - if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Probes) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() { + if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Probes) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) { m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark).Open() return m, nil } - if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectPID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() { + if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectPID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) { return m.reselectPID() } - if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectTID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() { + if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectTID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) { return m.reselectTID() } case tuiexport.RequestMsg: diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index c801b24..876fe8f 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -270,7 +270,7 @@ func TestTracingStartedRebindsEventStreamSource(t *testing.T) { next, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) m = next.(Model) - next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'6'}[0], Text: string([]rune{'6'})}) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})}) m = next.(Model) next, _ = m.Update(messages.StatsTickMsg{}) m = next.(Model) @@ -295,6 +295,37 @@ func TestExportKeyOpensModalOnDashboard(t *testing.T) { } } +func TestFlamePauseKeyDoesNotTriggerPIDReselect(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})}) + updated := next.(Model) + if updated.screen != ScreenDashboard { + t.Fatalf("expected flame pause key to keep dashboard screen, got %v", updated.screen) + } + if !strings.Contains(updated.View().Content, "[PAUSED]") { + t.Fatalf("expected flame pause key to toggle flame paused state") + } +} + +func TestFlameOrderKeyDoesNotOpenProbeModal(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'o'}[0], Text: string([]rune{'o'})}) + updated := next.(Model) + if updated.probeModal.Visible() { + t.Fatalf("expected flame order key to stay in flame tab, not open probes modal") + } +} + func TestSelectPIDKeyReturnsToFreshPickerAndStopsTrace(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard @@ -304,6 +335,9 @@ func TestSelectPIDKeyReturnsToFreshPickerAndStopsTrace(t *testing.T) { stopped := false m.traceStop = func() { stopped = true } + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) + m = next.(Model) + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})}) updated := next.(Model) @@ -336,6 +370,9 @@ func TestSelectTIDKeyReturnsToPickerWhenPIDFilterIsAll(t *testing.T) { stopped := false m.traceStop = func() { stopped = true } + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) + m = next.(Model) + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'t'}[0], Text: string([]rune{'t'})}) updated := next.(Model) if !stopped { @@ -361,6 +398,9 @@ func TestSelectTIDKeyReturnsToPickerWhenSinglePIDSelected(t *testing.T) { stopped := false m.traceStop = func() { stopped = true } + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) + m = next.(Model) + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'t'}[0], Text: string([]rune{'t'})}) updated := next.(Model) if !stopped { @@ -444,7 +484,7 @@ func TestStreamFilterModalConsumesEKeyInsteadOfOpeningExport(t *testing.T) { m.width = 120 m.height = 30 - next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'6'}[0], Text: string([]rune{'6'})}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})}) m = next.(Model) next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})}) m = next.(Model) @@ -586,22 +626,22 @@ func TestDashboardTabKeysChangeActiveView(t *testing.T) { m.height = 30 out := m.View().Content - if !strings.Contains(out, "Overview: waiting for stats") { - t.Fatalf("expected overview waiting view by default") + if !strings.Contains(out, "Flame: waiting for data") { + t.Fatalf("expected flame waiting view by default") } next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) updated := next.(Model) out = updated.View().Content - if !strings.Contains(out, "Syscalls: waiting for stats") { - t.Fatalf("expected syscalls waiting view after pressing 2") + if !strings.Contains(out, "Overview: waiting for stats") { + t.Fatalf("expected overview waiting view after pressing 2") } next, _ = updated.Update(tea.KeyPressMsg{Code: tea.KeyTab}) updated = next.(Model) out = updated.View().Content - if !strings.Contains(out, "Files: waiting for stats") { - t.Fatalf("expected files waiting view after tab") + if !strings.Contains(out, "Syscalls: waiting for stats") { + t.Fatalf("expected syscalls waiting view after tab") } } @@ -619,7 +659,7 @@ func TestProbeModalViewDoesNotStackDashboardContent(t *testing.T) { if !strings.Contains(out, "Probes (") { t.Fatalf("expected probe modal content, got %q", out) } - if strings.Contains(out, "Overview: waiting for stats") { + if strings.Contains(out, "Flame: waiting for data") || strings.Contains(out, "Overview: waiting for stats") { t.Fatalf("expected probe modal to render as standalone view, got stacked dashboard content") } } |
