summaryrefslogtreecommitdiff
path: root/internal/tui/dashboard
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/dashboard
parent96225fb6159212a8851043a08d781aba721b4e78 (diff)
parent110a193e04b81abb8d8e159abd73f9f6ed1acd7e (diff)
Merge branch 'feat/bubbletea-v2-migration'
Diffstat (limited to 'internal/tui/dashboard')
-rw-r--r--internal/tui/dashboard/doc.go2
-rw-r--r--internal/tui/dashboard/files.go5
-rw-r--r--internal/tui/dashboard/histogram.go14
-rw-r--r--internal/tui/dashboard/histogram_test.go2
-rw-r--r--internal/tui/dashboard/layout.go4
-rw-r--r--internal/tui/dashboard/model.go218
-rw-r--r--internal/tui/dashboard/model_test.go234
-rw-r--r--internal/tui/dashboard/overview.go45
-rw-r--r--internal/tui/dashboard/overview_test.go33
-rw-r--r--internal/tui/dashboard/processes.go5
-rw-r--r--internal/tui/dashboard/sparkline.go85
-rw-r--r--internal/tui/dashboard/sparkline_test.go82
-rw-r--r--internal/tui/dashboard/syscalls.go5
-rw-r--r--internal/tui/dashboard/tabs.go12
-rw-r--r--internal/tui/dashboard/tabs_test.go15
15 files changed, 543 insertions, 218 deletions
diff --git a/internal/tui/dashboard/doc.go b/internal/tui/dashboard/doc.go
new file mode 100644
index 0000000..b9bc30e
--- /dev/null
+++ b/internal/tui/dashboard/doc.go
@@ -0,0 +1,2 @@
+// Package dashboard implements the multi-tab runtime dashboard used in TUI mode.
+package dashboard
diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go
index 80e3037..d43e215 100644
--- a/internal/tui/dashboard/files.go
+++ b/internal/tui/dashboard/files.go
@@ -2,12 +2,13 @@ package dashboard
import (
"fmt"
- "ior/internal/statsengine"
"path/filepath"
"sort"
"strconv"
- "github.com/charmbracelet/bubbles/table"
+ "ior/internal/statsengine"
+
+ "charm.land/bubbles/v2/table"
)
type DirSnapshot struct {
diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go
index 7613230..28f5b2b 100644
--- a/internal/tui/dashboard/histogram.go
+++ b/internal/tui/dashboard/histogram.go
@@ -2,11 +2,12 @@ package dashboard
import (
"fmt"
- "ior/internal/statsengine"
- common "ior/internal/tui/common"
"math"
"strconv"
"strings"
+
+ "ior/internal/statsengine"
+ common "ior/internal/tui/common"
)
func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string {
@@ -14,9 +15,10 @@ func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string {
return common.PanelStyle.Render("Latency: waiting for stats...")
}
+ panelW := panelWidth(width)
panelInner := panelInnerWidth(width)
hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height)
- spark := common.PanelStyle.Width(panelInner).Render(
+ spark := common.PanelStyle.Width(panelW).Render(
renderOverviewSparkline("Latency sparkline:", snap.LatencySeriesNs(), panelInner),
)
return strings.Join([]string{hist, spark}, "\n")
@@ -27,9 +29,10 @@ func renderGapsTab(snap *statsengine.Snapshot, width, height int) string {
return common.PanelStyle.Render("Gaps: waiting for stats...")
}
+ panelW := panelWidth(width)
panelInner := panelInnerWidth(width)
hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height)
- spark := common.PanelStyle.Width(panelInner).Render(
+ spark := common.PanelStyle.Width(panelW).Render(
renderOverviewSparkline("Gap sparkline:", snap.GapSeriesNs(), panelInner),
)
return strings.Join([]string{hist, spark}, "\n")
@@ -53,6 +56,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he
if width <= 0 {
width = 80
}
+ panelW := panelWidth(width)
panelInner := panelInnerWidth(width)
if height > 0 {
@@ -93,7 +97,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he
}
lines = append(lines, "Scale: █▓▒░")
- return common.PanelStyle.Width(panelInner).Render(strings.Join(lines, "\n"))
+ return common.PanelStyle.Width(panelW).Render(strings.Join(lines, "\n"))
}
func renderHistogramBar(count, maxCount uint64, width int) string {
diff --git a/internal/tui/dashboard/histogram_test.go b/internal/tui/dashboard/histogram_test.go
index 7790394..48297a2 100644
--- a/internal/tui/dashboard/histogram_test.go
+++ b/internal/tui/dashboard/histogram_test.go
@@ -6,7 +6,7 @@ import (
"ior/internal/statsengine"
- "github.com/charmbracelet/lipgloss"
+ "charm.land/lipgloss/v2"
)
func TestRenderHistogramNoBuckets(t *testing.T) {
diff --git a/internal/tui/dashboard/layout.go b/internal/tui/dashboard/layout.go
index 0035a9d..75cbafb 100644
--- a/internal/tui/dashboard/layout.go
+++ b/internal/tui/dashboard/layout.go
@@ -4,7 +4,3 @@ const panelHorizontalChrome = 4
// Keep a small guard so sparkline rows never soft-wrap in panel cells.
const sparklineSafetyMargin = 3
-
-// Stats engine currently provides 120 time-series slots; cap rendering width
-// so wide terminals don't introduce wrap/placement artifacts.
-const sparklineMaxWidth = 120
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index fc9caf6..d10a91a 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -1,20 +1,26 @@
package dashboard
import (
+ "strings"
+ "time"
+
"ior/internal/statsengine"
common "ior/internal/tui/common"
"ior/internal/tui/eventstream"
+ flamegraphtui "ior/internal/tui/flamegraph"
"ior/internal/tui/messages"
- "strings"
- "time"
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
)
const defaultRefreshMs = 1000
const streamRefreshMs = 200
+const flameRefreshMs = 200
const streamChromeRows = 4
+const dashboardHelpHintRows = 1
+const dashboardExpandedHelpRows = 2
+const dashboardTabBarRows = 1
// SnapshotSource is the dashboard data source.
type SnapshotSource interface {
@@ -23,6 +29,7 @@ type SnapshotSource interface {
type refreshTickMsg struct{}
type streamTickMsg struct{}
+type flameTickMsg struct{}
type streamEditorDoneMsg struct {
err error
}
@@ -31,8 +38,9 @@ type streamEditorDoneMsg struct {
type Model struct {
activeTab Tab
- engine SnapshotSource
- latest *statsengine.Snapshot
+ engine SnapshotSource
+ latest *statsengine.Snapshot
+ liveTrie flamegraphtui.LiveTrieSource
width int
height int
@@ -46,32 +54,50 @@ type Model struct {
filesDirOffset int
processesOffset int
streamModel eventstream.Model
+ flamegraphModel flamegraphtui.Model
showHelp bool
+ isDark bool
+ focused bool
}
// NewModel creates a dashboard model with default refresh cadence.
-func NewModel(engine SnapshotSource, streamSource *eventstream.RingBuffer) Model {
+func NewModel(engine SnapshotSource, streamSource eventstream.Source) Model {
return NewModelWithConfig(engine, streamSource, defaultRefreshMs, common.Keys)
}
// NewModelWithConfig creates a dashboard model with explicit refresh and keys.
-func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuffer, refreshMs int, keys common.KeyMap) Model {
+func NewModelWithConfig(engine SnapshotSource, streamSource eventstream.Source, refreshMs int, keys common.KeyMap) Model {
if refreshMs <= 0 {
refreshMs = defaultRefreshMs
}
- return Model{
- activeTab: TabOverview,
- engine: engine,
- refreshEvery: time.Duration(refreshMs) * time.Millisecond,
- keys: keys,
- pidFilter: -1,
- streamModel: eventstream.NewModel(streamSource),
+ m := Model{
+ activeTab: TabFlame,
+ engine: engine,
+ refreshEvery: time.Duration(refreshMs) * time.Millisecond,
+ keys: keys,
+ pidFilter: -1,
+ streamModel: eventstream.NewModel(streamSource),
+ flamegraphModel: flamegraphtui.NewModel(nil),
+ isDark: true,
+ focused: true,
}
+ m.SetDarkMode(true)
+ return m
}
// Init starts periodic refresh ticks.
func (m Model) Init() tea.Cmd {
- return tickCmd(m.refreshEvery)
+ cmds := []tea.Cmd{tickCmd(m.refreshEvery)}
+ switch m.activeTab {
+ case TabStream:
+ cmds = append(cmds, streamTickCmd())
+ case TabFlame:
+ cmds = append(cmds, flameTickCmd())
+ }
+ if len(cmds) == 1 {
+ return cmds[0]
+ }
+ return tea.Batch(cmds...)
}
// Update handles ticks, snapshots, tab changes, and resize events.
@@ -82,19 +108,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
streamWidth, streamHeight := streamViewport(msg.Width, msg.Height)
m.streamModel.SetViewport(streamWidth, streamHeight)
+ flameWidth, flameHeight := flameViewport(msg.Width, msg.Height, m.showHelp)
+ m.flamegraphModel.SetViewport(flameWidth, flameHeight)
return m, nil
case refreshTickMsg:
+ if !m.focused {
+ return m, nil
+ }
snap := m.snapshot()
return m, tea.Batch(
tickCmd(m.refreshEvery),
func() tea.Msg { return messages.StatsTickMsg{Snap: snap} },
)
case streamTickMsg:
+ if !m.focused {
+ return m, nil
+ }
if m.activeTab != TabStream {
return m, nil
}
m.streamModel.Refresh()
return m, streamTickCmd()
+ case flameTickMsg:
+ if !m.focused {
+ return m, nil
+ }
+ if m.activeTab != TabFlame {
+ return m, nil
+ }
+ var animCmd tea.Cmd
+ if m.liveTrie != nil && m.flamegraphModel.RefreshFromLiveTrie() {
+ animCmd = m.flamegraphModel.AnimationCmd()
+ }
+ if animCmd != nil {
+ return m, tea.Batch(flameTickCmd(), animCmd)
+ }
+ return m, flameTickCmd()
case messages.StatsTickMsg:
m.latest = msg.Snap
m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows())
@@ -103,7 +152,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows())
m.streamModel.Refresh()
return m, nil
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
return m.handleKey(msg)
case streamEditorDoneMsg:
if msg.err != nil {
@@ -111,17 +160,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
}
+ if m.activeTab == TabFlame {
+ next, cmd := m.flamegraphModel.Update(msg)
+ m.flamegraphModel = next.(flamegraphtui.Model)
+ return m, cmd
+ }
return m, nil
}
-func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
prevActiveTab := m.activeTab
var cmd tea.Cmd
keyStr := msg.String()
if keyStr == "H" {
m.showHelp = !m.showHelp
+ flameWidth, flameHeight := flameViewport(m.width, m.height, m.showHelp)
+ m.flamegraphModel.SetViewport(flameWidth, flameHeight)
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
@@ -132,29 +193,29 @@ func (m Model) handleKey(msg tea.KeyMsg) (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 = TabStream
@@ -171,18 +232,34 @@ func (m Model) handleKey(msg tea.KeyMsg) (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)
+ if cmd != nil {
+ batch = append(batch, cmd)
+ }
if prevActiveTab != TabStream && m.activeTab == TabStream {
- if cmd == nil {
- return m, streamTickCmd()
- }
- return m, tea.Batch(cmd, streamTickCmd())
+ batch = append(batch, streamTickCmd())
+ }
+ if prevActiveTab != TabFlame && m.activeTab == TabFlame {
+ batch = append(batch, flameTickCmd())
+ }
+ switch len(batch) {
+ case 0:
+ return m, nil
+ case 1:
+ return m, batch[0]
+ default:
+ return m, tea.Batch(batch...)
}
- return m, cmd
}
-func (m *Model) handleScrollKey(msg tea.KeyMsg) (bool, tea.Cmd) {
+func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
keyStr := msg.String()
switch m.activeTab {
case TabSyscalls:
@@ -271,26 +348,60 @@ 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.
-func (m *Model) SetStreamSource(source *eventstream.RingBuffer) {
+func (m *Model) SetStreamSource(source eventstream.Source) {
m.streamModel.SetSource(source)
}
+// SetLiveTrie updates the live trie source used by the flamegraph tab.
+func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) {
+ m.liveTrie = liveTrie
+ m.flamegraphModel.SetLiveTrie(liveTrie)
+ if m.width > 0 && m.height > 0 {
+ m.flamegraphModel.SetViewport(m.width, m.height)
+ }
+ m.flamegraphModel.RefreshFromLiveTrie()
+}
+
+// SetDarkMode updates dashboard child models for the active theme.
+func (m *Model) SetDarkMode(isDark bool) {
+ m.isDark = isDark
+ m.streamModel.SetDarkMode(isDark)
+ m.flamegraphModel.SetDarkMode(isDark)
+}
+
+// SetFocused controls whether periodic refresh ticks are processed.
+func (m *Model) SetFocused(focused bool) {
+ m.focused = focused
+}
+
+// SnapshotCmd returns a command that fetches and emits a fresh dashboard snapshot.
+func (m Model) SnapshotCmd() tea.Cmd {
+ snap := m.snapshot()
+ return func() tea.Msg { return messages.StatsTickMsg{Snap: snap} }
+}
+
// SetPidFilter updates the active PID filter used by tab render hints.
func (m *Model) SetPidFilter(pid int) {
m.pidFilter = pid
}
// View renders the tab bar, active tab scaffold, and help bar.
-func (m Model) View() string {
+func (m Model) View() tea.View {
width, height := common.EffectiveViewport(m.width, m.height)
- activeHeight := height
+ _, activeHeight := flameViewport(width, height, m.showHelp)
streamModel := m.streamModel
streamModel.SetFooterVisible(m.showHelp)
if m.activeTab == TabStream {
@@ -304,6 +415,7 @@ func (m Model) View() string {
m.activeTab,
m.latest,
&streamModel,
+ &m.flamegraphModel,
width,
activeHeight,
m.pidFilter,
@@ -319,20 +431,27 @@ func (m Model) View() string {
} else {
b.WriteString(renderHelpHint(width))
}
- return common.ScreenStyle.Render(b.String())
+ return tea.NewView(common.ScreenStyle.Render(b.String()))
}
func tickCmd(d time.Duration) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg { return refreshTickMsg{} })
}
-func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string {
+func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string {
if tab == TabStream {
if streamModel == nil {
return common.PanelStyle.Render("Stream: waiting for source...")
}
return streamModel.View(width, height)
}
+ if tab == TabFlame {
+ if flameModel == nil {
+ return common.PanelStyle.Render("Flame: waiting for model...")
+ }
+ flameModel.SetViewport(width, height)
+ return flameModel.View().Content
+ }
if snap == nil {
return common.PanelStyle.Render(tab.String() + ": waiting for stats...")
@@ -361,6 +480,10 @@ func streamTickCmd() tea.Cmd {
return tea.Tick(streamRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return streamTickMsg{} })
}
+func flameTickCmd() tea.Cmd {
+ return tea.Tick(flameRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return flameTickMsg{} })
+}
+
func streamViewport(width, height int) (int, int) {
width, height = common.EffectiveViewport(width, height)
height -= streamChromeRows
@@ -369,3 +492,16 @@ func streamViewport(width, height int) (int, int) {
}
return width, height
}
+
+func flameViewport(width, height int, showHelp bool) (int, int) {
+ width, height = common.EffectiveViewport(width, height)
+ chromeRows := dashboardTabBarRows + dashboardHelpHintRows
+ if showHelp {
+ chromeRows = dashboardTabBarRows + dashboardExpandedHelpRows
+ }
+ height -= chromeRows
+ if height < 1 {
+ height = 1
+ }
+ return width, height
+}
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 87b60e3..d5b78e0 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -7,12 +7,13 @@ import (
"strings"
"testing"
+ coreflamegraph "ior/internal/flamegraph"
"ior/internal/statsengine"
common "ior/internal/tui/common"
"ior/internal/tui/eventstream"
"ior/internal/tui/messages"
- tea "github.com/charmbracelet/bubbletea"
+ tea "charm.land/bubbletea/v2"
)
type fakeSnapshotSource struct {
@@ -28,59 +29,60 @@ func (f *fakeSnapshotSource) Snapshot() *statsengine.Snapshot {
func TestKeySwitchingChangesActiveTab(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}})
+ 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.KeyMsg{Type: tea.KeyTab})
+ 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.KeyMsg{Type: tea.KeyShiftTab})
+ 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.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'7'}})
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})})
model = next.(Model)
if model.activeTab != TabStream {
t.Fatalf("expected stream tab on key 7, got %v", model.activeTab)
}
- next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []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.KeyMsg{Type: tea.KeyRight})
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
model := next.(Model)
if model.activeTab != TabOverview {
t.Fatalf("expected right arrow not to change tabs, got %v", model.activeTab)
}
- next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'l'}[0], Text: string([]rune{'l'})})
model = next.(Model)
if model.activeTab != TabOverview {
t.Fatalf("expected l not to change tabs, got %v", model.activeTab)
}
- next, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft})
+ next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
model = next.(Model)
if model.activeTab != TabOverview {
t.Fatalf("expected left arrow not to change tabs, got %v", model.activeTab)
}
- next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'h'}[0], Text: string([]rune{'h'})})
model = next.(Model)
if model.activeTab != TabOverview {
t.Fatalf("expected h not to change tabs, got %v", model.activeTab)
@@ -93,13 +95,13 @@ func TestSyscallsTabScrollsWithJK(t *testing.T) {
snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{{Name: "read", Count: 1}, {Name: "write", Count: 1}}, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
m.latest = &snap
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})})
model := next.(Model)
if model.syscallsOffset != 1 {
t.Fatalf("expected offset 1 after j, got %d", model.syscallsOffset)
}
- next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})})
model = next.(Model)
if model.syscallsOffset != 0 {
t.Fatalf("expected offset 0 after k, got %d", model.syscallsOffset)
@@ -112,13 +114,13 @@ func TestProcessesTabScrollsWithJK(t *testing.T) {
snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{{PID: 1}, {PID: 2}}, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
m.latest = &snap
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})})
model := next.(Model)
if model.processesOffset != 1 {
t.Fatalf("expected processes offset 1 after j, got %d", model.processesOffset)
}
- next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})})
model = next.(Model)
if model.processesOffset != 0 {
t.Fatalf("expected processes offset 0 after k, got %d", model.processesOffset)
@@ -131,13 +133,13 @@ func TestFilesTabScrollsWithJK(t *testing.T) {
snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{{Path: "/a"}, {Path: "/b"}}, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
m.latest = &snap
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})})
model := next.(Model)
if model.filesOffset != 1 {
t.Fatalf("expected files offset 1 after j, got %d", model.filesOffset)
}
- next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})})
model = next.(Model)
if model.filesOffset != 0 {
t.Fatalf("expected files offset 0 after k, got %d", model.filesOffset)
@@ -155,7 +157,7 @@ func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) {
}, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
m.latest = &snap
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})})
model := next.(Model)
if model.filesDirOffset != 1 {
t.Fatalf("expected grouped dir offset 1 after j, got %d", model.filesDirOffset)
@@ -171,13 +173,73 @@ func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) {
m.activeTab = TabStream
m.streamModel.HandleKey("space") // pause
- next, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeySpace})
_ = next
if cmd == nil {
t.Fatalf("expected stream tick command when unpausing stream")
}
}
+func TestFlameTickRefreshesFlamegraphModel(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ liveTrie.Reset()
+
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.SetLiveTrie(liveTrie)
+ m.activeTab = TabFlame
+
+ next, cmd := m.Update(flameTickMsg{})
+ model := next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected flame tick to schedule next tick command")
+ }
+ if got, want := model.flamegraphModel.LastVersion(), liveTrie.Version(); got != want {
+ t.Fatalf("expected flame model version %d, got %d", want, got)
+ }
+}
+
+func TestSetLiveTriePreloadsInitialSnapshotWithoutVersionChange(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.SetLiveTrie(liveTrie)
+ m.activeTab = TabFlame
+ if !m.flamegraphModel.HasSnapshot() {
+ t.Fatalf("expected SetLiveTrie to preload a baseline snapshot")
+ }
+
+ next, _ := m.Update(flameTickMsg{})
+ model := next.(Model)
+ if !model.flamegraphModel.HasSnapshot() {
+ t.Fatalf("expected flame tick to retain initial snapshot even when trie version is unchanged")
+ }
+}
+
+func TestFlameTickPausedFreezesAfterInitialSnapshot(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.SetLiveTrie(liveTrie)
+ m.activeTab = TabFlame
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ model := next.(Model)
+
+ next, _ = model.Update(flameTickMsg{})
+ model = next.(Model)
+ initialVersion := model.flamegraphModel.LastVersion()
+
+ liveTrie.Reset()
+ if liveTrie.Version() == initialVersion {
+ t.Fatalf("expected reset to advance trie version")
+ }
+
+ next, _ = model.Update(flameTickMsg{})
+ model = next.(Model)
+ if got, want := model.flamegraphModel.LastVersion(), initialVersion; got != want {
+ t.Fatalf("expected paused flame tick to freeze version at %d, got %d", want, got)
+ }
+}
+
func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) {
rb := eventstream.NewRingBuffer()
for i := 0; i < 300; i++ {
@@ -200,34 +262,34 @@ func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) {
m.streamModel.Refresh()
_ = m.View()
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) // pause
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) // pause
m = next.(Model)
- before := rowFromStreamView(t, m.View())
+ before := rowFromStreamView(t, m.View().Content)
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})})
m = next.(Model)
- afterK := rowFromStreamView(t, m.View())
+ afterK := rowFromStreamView(t, m.View().Content)
if afterK >= before {
t.Fatalf("expected k to scroll up while paused: before=%d afterK=%d", before, afterK)
}
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
m = next.(Model)
- afterDown := rowFromStreamView(t, m.View())
+ afterDown := rowFromStreamView(t, m.View().Content)
if afterDown <= afterK {
t.Fatalf("expected down arrow to scroll down while paused: afterK=%d afterDown=%d", afterK, afterDown)
}
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgUp})
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp})
m = next.(Model)
- afterPgUp := rowFromStreamView(t, m.View())
+ afterPgUp := rowFromStreamView(t, m.View().Content)
if afterPgUp >= afterDown {
t.Fatalf("expected pgup to scroll up while paused: afterDown=%d afterPgUp=%d", afterDown, afterPgUp)
}
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown})
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown})
m = next.(Model)
- afterPgDown := rowFromStreamView(t, m.View())
+ afterPgDown := rowFromStreamView(t, m.View().Content)
if afterPgDown <= afterPgUp {
t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown)
}
@@ -251,14 +313,14 @@ func TestDirGroupKeyTogglesOnlyOnFilesTab(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabFiles
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})})
model := next.(Model)
if !model.filesDirGrouped {
t.Fatalf("expected filesDirGrouped to toggle on files tab")
}
model.activeTab = TabOverview
- next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})})
model = next.(Model)
if !model.filesDirGrouped {
t.Fatalf("expected filesDirGrouped unchanged outside files tab")
@@ -272,7 +334,7 @@ func TestScrollOffsetDoesNotGrowUnbounded(t *testing.T) {
m.latest = &snap
for i := 0; i < 50; i++ {
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})})
m = next.(Model)
}
if m.syscallsOffset != 1 {
@@ -284,7 +346,8 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) {
snap := &statsengine.Snapshot{TotalSyscalls: 13}
engine := &fakeSnapshotSource{snap: snap}
m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap())
- next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
+ m.activeTab = TabOverview
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})})
_ = next
if cmd == nil {
t.Fatalf("expected refresh command")
@@ -299,6 +362,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: tea.KeySpace, Text: " "})
+ model := next.(Model)
+ if !strings.Contains(model.View().Content, "[PAUSED]") {
+ t.Fatalf("expected flame space 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")
+ }
+