From bab716a6a5931c211fad2f17ee2f67e8d182a7fc Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 19:20:18 +0200 Subject: feat(tui): migrate Bubble Tea stack to charm.land v2 --- internal/tui/dashboard/files.go | 2 +- internal/tui/dashboard/histogram_test.go | 2 +- internal/tui/dashboard/model.go | 8 ++-- internal/tui/dashboard/model_test.go | 78 ++++++++++++++++---------------- internal/tui/dashboard/overview.go | 2 +- internal/tui/dashboard/overview_test.go | 2 +- internal/tui/dashboard/processes.go | 2 +- internal/tui/dashboard/syscalls.go | 2 +- internal/tui/dashboard/tabs.go | 2 +- 9 files changed, 50 insertions(+), 50 deletions(-) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index 80e3037..0393553 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -7,7 +7,7 @@ import ( "sort" "strconv" - "github.com/charmbracelet/bubbles/table" + "charm.land/bubbles/v2/table" ) type DirSnapshot struct { 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/model.go b/internal/tui/dashboard/model.go index fc9caf6..39150e8 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -8,8 +8,8 @@ import ( "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 @@ -288,7 +288,7 @@ func (m *Model) SetPidFilter(pid int) { } // 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 streamModel := m.streamModel @@ -319,7 +319,7 @@ 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 { diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 87b60e3..642c702 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -12,7 +12,7 @@ import ( "ior/internal/tui/eventstream" "ior/internal/tui/messages" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) type fakeSnapshotSource struct { @@ -28,31 +28,31 @@ 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) } - 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) } - 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) } - 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{'6'}[0], Text: string([]rune{'6'})}) model = next.(Model) if model.activeTab != TabStream { t.Fatalf("expected stream tab on key 6, got %v", model.activeTab) @@ -62,25 +62,25 @@ func TestKeySwitchingChangesActiveTab(t *testing.T) { func TestArrowAndViKeysDoNotCycleTabs(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) - 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 +93,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 +112,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 +131,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 +155,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,7 +171,7 @@ 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") @@ -200,34 +200,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 +251,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 +272,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 +284,7 @@ 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'}}) + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) _ = next if cmd == nil { t.Fatalf("expected refresh command") @@ -366,7 +366,7 @@ func TestStatsTickClampsGroupedFilesOffset(t *testing.T) { func TestViewRendersTabBarAndHelp(t *testing.T) { m := NewModelWithConfig(nil, nil, 1000, common.DefaultKeyMap()) - out := m.View() + out := m.View().Content if !strings.Contains(out, "Overview") { t.Fatalf("expected overview label in view") } @@ -405,7 +405,7 @@ func TestStreamTabViewKeepsTabAndHelpChromeVisible(t *testing.T) { m.streamModel.SetSource(rb) m.streamModel.Refresh() - out := m.View() + out := m.View().Content if !strings.Contains(out, "1:Overview") { t.Fatalf("expected tab bar to remain visible in stream view") } @@ -416,21 +416,21 @@ func TestStreamTabViewKeepsTabAndHelpChromeVisible(t *testing.T) { func TestHelpToggleWithH(t *testing.T) { m := NewModelWithConfig(nil, nil, 1000, common.DefaultKeyMap()) - out := m.View() + out := m.View().Content if !strings.Contains(out, "press H for help") { t.Fatalf("expected default help hint") } - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'H'}[0], Text: string([]rune{'H'})}) m = next.(Model) - out = m.View() + out = m.View().Content if !strings.Contains(out, "tab next tab") { t.Fatalf("expected expanded help after pressing h") } - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}}) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'H'}[0], Text: string([]rune{'H'})}) m = next.(Model) - out = m.View() + out = m.View().Content if !strings.Contains(out, "press H for help") { t.Fatalf("expected help hint after pressing h again") } diff --git a/internal/tui/dashboard/overview.go b/internal/tui/dashboard/overview.go index 5b8fab8..3ddeaf6 100644 --- a/internal/tui/dashboard/overview.go +++ b/internal/tui/dashboard/overview.go @@ -8,7 +8,7 @@ import ( "time" "unicode/utf8" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func renderOverview(snap *statsengine.Snapshot, width, height int) string { diff --git a/internal/tui/dashboard/overview_test.go b/internal/tui/dashboard/overview_test.go index 9895490..7de411c 100644 --- a/internal/tui/dashboard/overview_test.go +++ b/internal/tui/dashboard/overview_test.go @@ -7,7 +7,7 @@ import ( "ior/internal/statsengine" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func TestRenderOverviewIncludesCoreMetrics(t *testing.T) { diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go index 281a86a..a9de382 100644 --- a/internal/tui/dashboard/processes.go +++ b/internal/tui/dashboard/processes.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/charmbracelet/bubbles/table" + "charm.land/bubbles/v2/table" ) func renderProcesses(snap *statsengine.Snapshot, width, height int) string { diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go index 23fe37c..31f719e 100644 --- a/internal/tui/dashboard/syscalls.go +++ b/internal/tui/dashboard/syscalls.go @@ -6,7 +6,7 @@ import ( "strconv" "time" - "github.com/charmbracelet/bubbles/table" + "charm.land/bubbles/v2/table" ) func renderSyscalls(snap *statsengine.Snapshot, width, height int) string { diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go index df8f03e..62c7762 100644 --- a/internal/tui/dashboard/tabs.go +++ b/internal/tui/dashboard/tabs.go @@ -6,7 +6,7 @@ import ( "strings" "unicode/utf8" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // Tab is a dashboard tab identifier. -- cgit v1.2.3 From c3adb86fda302eeb90c2b07e56aae5abaea469ce Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 19:25:09 +0200 Subject: refactor(tui): migrate source key handling to KeyPressMsg --- internal/tui/dashboard/model.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 39150e8..ac61982 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -103,7 +103,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 { @@ -114,7 +114,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.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() @@ -182,7 +182,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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: -- cgit v1.2.3 From 5fe164e91e40e8a3f749f4143f7562f940bf9f67 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 19:34:01 +0200 Subject: feat(tui): detect terminal theme and apply palettes --- internal/tui/dashboard/model.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index ac61982..a4cd4a5 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -47,6 +47,7 @@ type Model struct { processesOffset int streamModel eventstream.Model showHelp bool + isDark bool } // NewModel creates a dashboard model with default refresh cadence. @@ -59,14 +60,17 @@ func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuf if refreshMs <= 0 { refreshMs = defaultRefreshMs } - return Model{ + m := Model{ activeTab: TabOverview, engine: engine, refreshEvery: time.Duration(refreshMs) * time.Millisecond, keys: keys, pidFilter: -1, streamModel: eventstream.NewModel(streamSource), + isDark: true, } + m.SetDarkMode(true) + return m } // Init starts periodic refresh ticks. @@ -282,6 +286,12 @@ func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { m.streamModel.SetSource(source) } +// SetDarkMode updates dashboard child models for the active theme. +func (m *Model) SetDarkMode(isDark bool) { + m.isDark = isDark + m.streamModel.SetDarkMode(isDark) +} + // SetPidFilter updates the active PID filter used by tab render hints. func (m *Model) SetPidFilter(pid int) { m.pidFilter = pid -- cgit v1.2.3 From 1318a3a927ea3bae77a7560e149070583f215982 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 19:42:22 +0200 Subject: feat(tui): pause dashboard refresh on terminal blur --- internal/tui/dashboard/model.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index a4cd4a5..f097da7 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -48,6 +48,7 @@ type Model struct { streamModel eventstream.Model showHelp bool isDark bool + focused bool } // NewModel creates a dashboard model with default refresh cadence. @@ -68,6 +69,7 @@ func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuf pidFilter: -1, streamModel: eventstream.NewModel(streamSource), isDark: true, + focused: true, } m.SetDarkMode(true) return m @@ -88,6 +90,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streamModel.SetViewport(streamWidth, streamHeight) return m, nil case refreshTickMsg: + if !m.focused { + return m, nil + } snap := m.snapshot() return m, tea.Batch( tickCmd(m.refreshEvery), @@ -292,6 +297,17 @@ func (m *Model) SetDarkMode(isDark bool) { m.streamModel.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 -- cgit v1.2.3 From a4298701546b09fccb15ce30db7c7e3f4070525c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 21:50:58 +0200 Subject: fix(tui): stabilize full-width layout and sparkline rendering --- internal/tui/dashboard/histogram.go | 9 ++-- internal/tui/dashboard/layout.go | 4 -- internal/tui/dashboard/overview.go | 38 ++++++-------- internal/tui/dashboard/overview_test.go | 31 ++++++++---- internal/tui/dashboard/sparkline.go | 85 +++++++++++++++++++------------- internal/tui/dashboard/sparkline_test.go | 82 ++++++++++++++++++------------ 6 files changed, 144 insertions(+), 105 deletions(-) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go index 7613230..3511dfb 100644 --- a/internal/tui/dashboard/histogram.go +++ b/internal/tui/dashboard/histogram.go @@ -14,9 +14,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 +28,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 +55,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 +96,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/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/overview.go b/internal/tui/dashboard/overview.go index 3ddeaf6..866c5bb 100644 --- a/internal/tui/dashboard/overview.go +++ b/internal/tui/dashboard/overview.go @@ -33,6 +33,7 @@ func renderOverview(snap *statsengine.Snapshot, width, height int) string { trendWithArrow(snap.ThroughputTrend), ) + panelW := panelWidth(width) panelInner := panelInnerWidth(width) labelWidth := maxLabelWidth("Latency:", "Gap:", "Throughput:") latencySpark := renderOverviewSparklineAligned("Latency:", snap.LatencySeriesNs(), panelInner, labelWidth) @@ -44,8 +45,8 @@ func renderOverview(snap *statsengine.Snapshot, width, height int) string { latencyHist := "Latency buckets: " + summarizeHistogramBrief(snap.LatencyHistogram) gapHist := "Gap buckets: " + summarizeHistogramBrief(snap.GapHistogram) - panel := common.PanelStyle.Width(panelInner) - sparkPanel := panel.Render(strings.Join([]string{latencySpark, "", gapSpark, "", throughputSpark}, "\n")) + panel := common.PanelStyle.Width(panelW) + sparkPanel := panel.Render(strings.Join([]string{latencySpark, gapSpark, throughputSpark}, "\n")) topPanel := panel.Render(strings.Join([]string{topSyscalls, topFiles, topProcesses}, "\n")) histPanel := panel.Render(strings.Join([]string{latencyHist, gapHist}, "\n")) @@ -73,7 +74,7 @@ func renderSyscallBox(snap *statsengine.Snapshot, width int) string { snap.SyscallRatePerSec, generatedAt, ) - return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) + return common.PanelStyle.Width(width).Height(5).Render(content) } func renderBytesBox(snap *statsengine.Snapshot, width int) string { @@ -83,7 +84,7 @@ func renderBytesBox(snap *statsengine.Snapshot, width int) string { formatBytes(snap.WriteBytesPerSec), formatBytes(float64(snap.TotalBytes)), ) - return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) + return common.PanelStyle.Width(width).Height(5).Render(content) } func renderErrorBox(snap *statsengine.Snapshot, width int) string { @@ -99,7 +100,7 @@ func renderErrorBox(snap *statsengine.Snapshot, width int) string { snap.LatencyMeanNs, snap.GapMeanNs, ) - return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) + return common.PanelStyle.Width(width).Height(5).Render(content) } func trendWithArrow(trend statsengine.Trend) string { @@ -212,19 +213,8 @@ func summaryBoxWidth(width int) int { return w } -func summaryBoxInnerWidth(width int) int { - inner := width - panelHorizontalChrome - if inner < 14 { - return 14 - } - return inner -} - func renderOverviewSparkline(label string, data []float64, panelInner int) string { w := panelInner - utf8.RuneCountInString(label) - 1 - sparklineSafetyMargin - if w > sparklineMaxWidth { - w = sparklineMaxWidth - } if w < 8 { w = 8 } @@ -234,9 +224,6 @@ func renderOverviewSparkline(label string, data []float64, panelInner int) strin func renderOverviewSparklineAligned(label string, data []float64, panelInner int, labelWidth int) string { paddedLabel := padLabelRight(label, labelWidth) w := panelInner - labelWidth - 1 - sparklineSafetyMargin - if w > sparklineMaxWidth { - w = sparklineMaxWidth - } if w < 8 { w = 8 } @@ -262,13 +249,20 @@ func padLabelRight(label string, width int) string { return label + strings.Repeat(" ", pad) } -func panelInnerWidth(width int) int { +func panelWidth(width int) int { if width <= 0 { width = 80 } - inner := width - panelHorizontalChrome - if inner < 20 { + if width < 20 { return 20 } + return width +} + +func panelInnerWidth(width int) int { + inner := panelWidth(width) - panelHorizontalChrome + if inner < 16 { + return 16 + } return inner } diff --git a/internal/tui/dashboard/overview_test.go b/internal/tui/dashboard/overview_test.go index 7de411c..6ac3704 100644 --- a/internal/tui/dashboard/overview_test.go +++ b/internal/tui/dashboard/overview_test.go @@ -6,6 +6,7 @@ import ( "time" "ior/internal/statsengine" + common "ior/internal/tui/common" "charm.land/lipgloss/v2" ) @@ -121,23 +122,22 @@ func TestRenderOverviewDoesNotOverflowWidth(t *testing.T) { func TestRenderOverviewSparklineHasSafetyMargin(t *testing.T) { const panelInner = 80 out := renderOverviewSparkline("Latency:", []float64{1, 2, 3, 4, 5}, panelInner) - lines := strings.Split(out, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2-line sparkline, got %q", out) + if strings.Contains(out, "\n") { + t.Fatalf("expected single-line sparkline, got %q", out) } - if got, max := lipgloss.Width(lines[0]), panelInner-sparklineSafetyMargin; got > max { + if got, max := lipgloss.Width(out), panelInner-sparklineSafetyMargin; got > max { t.Fatalf("expected sparkline width <= %d with safety margin, got %d", max, got) } } -func TestRenderOverviewSparklineCapsWidth(t *testing.T) { +func TestRenderOverviewSparklineUsesAvailableWidth(t *testing.T) { out := renderOverviewSparkline("Latency:", make([]float64, 120), 400) - lines := strings.Split(out, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2-line sparkline, got %q", out) + if strings.Contains(out, "\n") { + t.Fatalf("expected single-line sparkline, got %q", out) } - if got := lipgloss.Width(lines[0]) - len("Latency: "); got > sparklineMaxWidth { - t.Fatalf("expected capped sparkline width <= %d, got %d", sparklineMaxWidth, got) + want := 400 - len("Latency:") - 1 - sparklineSafetyMargin + if got := lipgloss.Width(out) - len("Latency: "); got != want { + t.Fatalf("expected sparkline width %d, got %d", want, got) } } @@ -164,3 +164,14 @@ func TestRenderOverviewSparklineAlignedUsesSameSparkStartColumn(t *testing.T) { t.Fatalf("unexpected throughput prefix: %q", thrTop) } } + +func TestRenderOverviewSparklineAlignedFitsSinglePanelRow(t *testing.T) { + panelW := panelWidth(220) + panelInner := panelInnerWidth(220) + labelWidth := maxLabelWidth("Latency:", "Gap:", "Throughput:") + line := renderOverviewSparklineAligned("Latency:", []float64{0, 10, 5, 10, 0}, panelInner, labelWidth) + rendered := common.PanelStyle.Width(panelW).Render(line) + if got := len(strings.Split(rendered, "\n")); got != 3 { + t.Fatalf("expected sparkline to fit one panel row (3 total lines with border), got %d lines", got) + } +} diff --git a/internal/tui/dashboard/sparkline.go b/internal/tui/dashboard/sparkline.go index 2ce8c90..ab78cce 100644 --- a/internal/tui/dashboard/sparkline.go +++ b/internal/tui/dashboard/sparkline.go @@ -1,9 +1,8 @@ package dashboard import "math" -import "strings" -var sparkRowChars = []rune(" ▁▂▃▄▅▆▇█") +var sparkChars = []rune("▁▂▃▄▅▆▇█") func renderSparkline(data []float64, width int) string { if len(data) == 0 || width <= 0 { @@ -11,23 +10,15 @@ func renderSparkline(data []float64, width int) string { } samples := sampleForWidth(data, width) - leftPad := 0 - if len(samples) < width { - leftPad = width - len(samples) - } min, max := minMax(samples) if min == max { - top := repeatRune(' ', width) - bottom := repeatRune(' ', leftPad) + repeatRune('█', len(samples)) - return top + "\n" + bottom + if min == 0 { + return repeatRune(' ', width) + } + return repeatRune('▁', width) } - top := make([]rune, width) - bottom := make([]rune, width) - for i := 0; i < leftPad; i++ { - top[i] = ' ' - bottom[i] = ' ' - } + row := make([]rune, width) scale := 16.0 denom := max - min for i, value := range samples { @@ -39,20 +30,17 @@ func renderSparkline(data []float64, width int) string { level = 16 } - topLevel := level - 8 - if topLevel < 0 { - topLevel = 0 + // Collapse the previous two-row 0..16 scale to a single-row 0..7 scale. + oneRow := level / 2 + if oneRow < 0 { + oneRow = 0 } - bottomLevel := level - if bottomLevel > 8 { - bottomLevel = 8 + if oneRow > 7 { + oneRow = 7 } - - col := leftPad + i - top[col] = sparkRowChars[topLevel] - bottom[col] = sparkRowChars[bottomLevel] + row[i] = sparkChars[oneRow] } - return string(top) + "\n" + string(bottom) + return string(row) } func renderLabeledSparkline(label string, data []float64, width int) string { @@ -60,20 +48,47 @@ func renderLabeledSparkline(label string, data []float64, width int) string { if spark == "" { return label } - lines := strings.Split(spark, "\n") - if len(lines) == 1 { - return label + " " + lines[0] - } - pad := repeatRune(' ', len([]rune(label))+1) - return label + " " + lines[0] + "\n" + pad + lines[1] + return label + " " + spark } func sampleForWidth(data []float64, width int) []float64 { - if width >= len(data) { + if width <= 0 || len(data) == 0 { + return nil + } + + if width < len(data) { + start := len(data) - width + return append([]float64(nil), data[start:]...) + } + + if width == len(data) { return append([]float64(nil), data...) } - start := len(data) - width - return append([]float64(nil), data[start:]...) + + if len(data) == 1 { + out := make([]float64, width) + for i := range out { + out[i] = data[0] + } + return out + } + + out := make([]float64, width) + srcLast := len(data) - 1 + dstLast := width - 1 + for i := 0; i < width; i++ { + // Nearest-neighbor upsampling preserves the original series shape + // without introducing interpolated spikes between samples. + srcIdx := int(math.Round(float64(i) * float64(srcLast) / float64(dstLast))) + if srcIdx < 0 { + srcIdx = 0 + } + if srcIdx > srcLast { + srcIdx = srcLast + } + out[i] = data[srcIdx] + } + return out } func minMax(values []float64) (float64, float64) { diff --git a/internal/tui/dashboard/sparkline_test.go b/internal/tui/dashboard/sparkline_test.go index d7acd33..6f549d1 100644 --- a/internal/tui/dashboard/sparkline_test.go +++ b/internal/tui/dashboard/sparkline_test.go @@ -16,37 +16,52 @@ func TestRenderSparklineEmptyOrInvalidWidth(t *testing.T) { func TestRenderSparklineSingleValue(t *testing.T) { got := renderSparkline([]float64{10}, 8) - if got != " \n █" { - t.Fatalf("expected two-line constant sparkline, got %q", got) + if got != "▁▁▁▁▁▁▁▁" { + t.Fatalf("expected single-line constant sparkline, got %q", got) } } func TestRenderSparklineAllEqualValues(t *testing.T) { got := renderSparkline([]float64{5, 5, 5, 5}, 4) - if got != " \n████" { - t.Fatalf("expected two-line flat sparkline, got %q", got) + if got != "▁▁▁▁" { + t.Fatalf("expected single-line flat sparkline, got %q", got) } } -func TestRenderSparklineRightAlignsShortHistory(t *testing.T) { +func TestRenderSparklineAllZeroValuesRendersBlank(t *testing.T) { + got := renderSparkline([]float64{0, 0, 0}, 5) + if got != " " { + t.Fatalf("expected blank sparkline for all-zero series, got %q", got) + } +} + +func TestRenderSparklineLeftAlignsShortHistory(t *testing.T) { got := renderSparkline([]float64{1, 2, 3}, 6) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) + first := strings.IndexFunc(got, func(r rune) bool { return r != ' ' }) + last := strings.LastIndexFunc(got, func(r rune) bool { return r != ' ' }) + if first < 0 || last < 0 { + t.Fatalf("expected visible sparkline cells, got %q", got) + } + if strings.HasPrefix(got, " ") { + t.Fatalf("expected sparkline not to use old right-aligned padding, got %q", got) } - if !strings.HasPrefix(lines[1], " ") { - t.Fatalf("expected left padding for short history, got %q", lines[1]) +} + +func TestRenderSparklineUsesRightmostColumn(t *testing.T) { + got := renderSparkline([]float64{1, 3, 2, 5}, 20) + row := []rune(got) + if len(row) != 20 { + t.Fatalf("expected 20 columns, got %d", len(row)) + } + if row[19] == ' ' { + t.Fatalf("expected rightmost column to contain sparkline data, got %q", got) } } func TestRenderSparklineRespectsWidthTruncation(t *testing.T) { got := renderSparkline([]float64{1, 2, 3, 4, 5, 6, 7, 8}, 4) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) - } - if len([]rune(lines[0])) != 4 || len([]rune(lines[1])) != 4 { - t.Fatalf("expected 4 runes per line, got %q", got) + if len([]rune(got)) != 4 { + t.Fatalf("expected 4 runes, got %q", got) } } @@ -63,27 +78,32 @@ func TestSampleForWidthUsesRecentTail(t *testing.T) { } } +func TestSampleForWidthUpsamplesToFullWidth(t *testing.T) { + got := sampleForWidth([]float64{10, 20, 30}, 7) + if len(got) != 7 { + t.Fatalf("expected 7 samples, got %d", len(got)) + } + if got[0] != 10 { + t.Fatalf("expected first sample to preserve series start, got %v", got[0]) + } + if got[len(got)-1] != 30 { + t.Fatalf("expected last sample to preserve series end, got %v", got[len(got)-1]) + } +} + func TestRenderSparklineSpansLowToHigh(t *testing.T) { got := renderSparkline([]float64{0, 10}, 2) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) - } - if !strings.Contains(got, "█") { - t.Fatalf("expected high bar, got %q", got) + if got != "▁█" { + t.Fatalf("expected low-to-high one-row sparkline, got %q", got) } } -func TestRenderLabeledSparklineAlignsSecondRow(t *testing.T) { +func TestRenderLabeledSparklineSingleLine(t *testing.T) { got := renderLabeledSparkline("Latency:", []float64{0, 10}, 2) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) - } - if !strings.HasPrefix(lines[0], "Latency: ") { - t.Fatalf("expected label prefix on first row, got %q", lines[0]) + if strings.Contains(got, "\n") { + t.Fatalf("expected single-line labeled sparkline, got %q", got) } - if !strings.HasPrefix(lines[1], " ") { - t.Fatalf("expected padding on second row to align sparkline, got %q", lines[1]) + if !strings.HasPrefix(got, "Latency: ") { + t.Fatalf("expected label prefix, got %q", got) } } -- cgit v1.2.3 From 043bbbb884560a0f91f5e12d0b7851ad60121d5a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 22:18:08 +0200 Subject: task 350: add TabFlame tab infrastructure --- internal/tui/dashboard/model.go | 4 +++- internal/tui/dashboard/model_test.go | 4 ++-- internal/tui/dashboard/tabs.go | 7 +++++++ internal/tui/dashboard/tabs_test.go | 15 +++++++++------ 4 files changed, 21 insertions(+), 9 deletions(-) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index f097da7..0a9915b 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -166,7 +166,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.activeTab = TabStream handled = true case key.Matches(msg, m.keys.Seven): - m.activeTab = TabStream + m.activeTab = TabFlame handled = true case key.Matches(msg, m.keys.Refresh): snap := m.snapshot() @@ -378,6 +378,8 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstre return renderProcessesWithOffset(snap, width, height, processesOffset, pidFilter) case TabLatency: return renderLatencyGapsTab(snap, width, height) + case TabFlame: + return common.PanelStyle.Render("Flame: waiting for model...") default: return common.PanelStyle.Render("Unknown tab") } diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 642c702..c9a1cb9 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -48,8 +48,8 @@ func TestKeySwitchingChangesActiveTab(t *testing.T) { 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) + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab on key 7, got %v", model.activeTab) } next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'6'}[0], Text: string([]rune{'6'})}) diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go index 62c7762..731e21f 100644 --- a/internal/tui/dashboard/tabs.go +++ b/internal/tui/dashboard/tabs.go @@ -25,6 +25,8 @@ const ( TabLatency // TabStream is the live event stream tab. TabStream + // TabFlame is the live flamegraph tab. + TabFlame ) var allTabs = []Tab{ @@ -34,6 +36,7 @@ var allTabs = []Tab{ TabProcesses, TabLatency, TabStream, + TabFlame, } func (t Tab) String() string { @@ -50,6 +53,8 @@ func (t Tab) String() string { return "Latency+Gaps" case TabStream: return "Stream" + case TabFlame: + return "Flame" default: return "Unknown" } @@ -192,6 +197,8 @@ func tabLabel(tab Tab, short bool) string { return "Lat" case TabStream: return "Str" + case TabFlame: + return "Flm" default: return "Unk" } diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go index 1148103..16f8b76 100644 --- a/internal/tui/dashboard/tabs_test.go +++ b/internal/tui/dashboard/tabs_test.go @@ -11,17 +11,20 @@ func TestTabNavigationWraps(t *testing.T) { if got := nextTab(TabLatency); got != TabStream { t.Fatalf("expected next after latency+gaps to be stream, got %v", got) } - if got := nextTab(TabStream); got != TabOverview { - t.Fatalf("expected wrap to overview from stream, got %v", got) + if got := nextTab(TabStream); got != TabFlame { + t.Fatalf("expected next after stream to be flame, got %v", got) } - if got := prevTab(TabOverview); got != TabStream { - t.Fatalf("expected wrap to stream, got %v", got) + if got := nextTab(TabFlame); got != TabOverview { + t.Fatalf("expected wrap to overview from flame, got %v", got) + } + if got := prevTab(TabOverview); got != TabFlame { + t.Fatalf("expected wrap to flame, got %v", got) } } func TestRenderTabBarContainsLabels(t *testing.T) { out := renderTabBar(TabOverview, 100) - for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency+Gaps", "Stream"} { + for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency+Gaps", "Stream", "Flame"} { if !strings.Contains(out, label) { t.Fatalf("expected tab label %q in tab bar", label) } @@ -34,7 +37,7 @@ func TestRenderTabBarSmallWidthUsesSingleLine(t *testing.T) { if len(lines) != 1 { t.Fatalf("expected single-line tab bar at width 70, got %d lines", len(lines)) } - if strings.Contains(out, "6:Strea") { + if strings.Contains(out, "7:Flam") { t.Fatalf("tab label should not be wrapped/split in small width output") } } -- cgit v1.2.3 From 270c4b422cfc5e7588b7045276588e9f043f85e3 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 22:27:06 +0200 Subject: task 354: wire dashboard flame tab to LiveTrie --- internal/tui/dashboard/model.go | 83 ++++++++++++++++++++++++++++-------- internal/tui/dashboard/model_test.go | 21 ++++++++- 2 files changed, 85 insertions(+), 19 deletions(-) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 0a9915b..e8c1cb2 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -1,9 +1,11 @@ package dashboard import ( + coreflamegraph "ior/internal/flamegraph" "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" + flamegraphtui "ior/internal/tui/flamegraph" "ior/internal/tui/messages" "strings" "time" @@ -14,6 +16,7 @@ import ( const defaultRefreshMs = 1000 const streamRefreshMs = 200 +const flameRefreshMs = 200 const streamChromeRows = 4 // SnapshotSource is the dashboard data source. @@ -23,6 +26,7 @@ type SnapshotSource interface { type refreshTickMsg struct{} type streamTickMsg struct{} +type flameTickMsg struct{} type streamEditorDoneMsg struct { err error } @@ -31,8 +35,9 @@ type streamEditorDoneMsg struct { type Model struct { activeTab Tab - engine SnapshotSource - latest *statsengine.Snapshot + engine SnapshotSource + latest *statsengine.Snapshot + liveTrie *coreflamegraph.LiveTrie width int height int @@ -46,6 +51,7 @@ type Model struct { filesDirOffset int processesOffset int streamModel eventstream.Model + flamegraphModel flamegraphtui.Model showHelp bool isDark bool focused bool @@ -62,14 +68,15 @@ func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuf refreshMs = defaultRefreshMs } m := Model{ - activeTab: TabOverview, - engine: engine, - refreshEvery: time.Duration(refreshMs) * time.Millisecond, - keys: keys, - pidFilter: -1, - streamModel: eventstream.NewModel(streamSource), - isDark: true, - focused: true, + activeTab: TabOverview, + 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 @@ -88,6 +95,7 @@ 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) + m.flamegraphModel.SetViewport(msg.Width, msg.Height) return m, nil case refreshTickMsg: if !m.focused { @@ -104,6 +112,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.streamModel.Refresh() return m, streamTickCmd() + case flameTickMsg: + if m.activeTab != TabFlame { + return m, nil + } + if m.liveTrie != nil && m.liveTrie.Version() != m.flamegraphModel.LastVersion() { + m.flamegraphModel.RefreshFromLiveTrie() + } + return m, flameTickCmd() case messages.StatsTickMsg: m.latest = msg.Snap m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows()) @@ -182,13 +198,24 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if !handled { 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.KeyPressMsg) (bool, tea.Cmd) { @@ -291,10 +318,20 @@ func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { m.streamModel.SetSource(source) } +// SetLiveTrie updates the live trie source used by the flamegraph tab. +func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { + m.liveTrie = liveTrie + m.flamegraphModel.SetLiveTrie(liveTrie) + if m.width > 0 && m.height > 0 { + m.flamegraphModel.SetViewport(m.width, m.height) + } +} + // 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. @@ -330,6 +367,7 @@ func (m Model) View() tea.View { m.activeTab, m.latest, &streamModel, + &m.flamegraphModel, width, activeHeight, m.pidFilter, @@ -352,13 +390,20 @@ 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...") @@ -378,8 +423,6 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstre return renderProcessesWithOffset(snap, width, height, processesOffset, pidFilter) case TabLatency: return renderLatencyGapsTab(snap, width, height) - case TabFlame: - return common.PanelStyle.Render("Flame: waiting for model...") default: return common.PanelStyle.Render("Unknown tab") } @@ -389,6 +432,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 diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index c9a1cb9..be31297 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + coreflamegraph "ior/internal/flamegraph" "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" @@ -178,6 +179,24 @@ func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) { } } +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 TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) { rb := eventstream.NewRingBuffer() for i := 0; i < 300; i++ { @@ -386,7 +405,7 @@ func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) { statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}, ) - out := renderActiveTab(TabFiles, &snap, nil, 120, 30, -1, 0, 0, true, 0, 0) + out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, true, 0, 0) if !strings.Contains(out, "Directory") { t.Fatalf("expected grouped directory files view header, got %q", out) } -- cgit v1.2.3 From 1943f4395d477c9a0f9dad4ce78339b7f1163862 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 22:44:34 +0200 Subject: task 361: add flamegraph control actions and toolbar --- internal/tui/dashboard/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index e8c1cb2..a1aaf89 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -116,7 +116,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.activeTab != TabFlame { return m, nil } - if m.liveTrie != nil && m.liveTrie.Version() != m.flamegraphModel.LastVersion() { + if m.liveTrie != nil && !m.flamegraphModel.Paused() && m.liveTrie.Version() != m.flamegraphModel.LastVersion() { m.flamegraphModel.RefreshFromLiveTrie() } return m, flameTickCmd() -- cgit v1.2.3 From 476cfb1aa569b42e7bab6cbfd5e548c9e85ee07c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 22:49:20 +0200 Subject: task 363: animate flamegraph data refresh transitions --- internal/tui/dashboard/model.go | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index a1aaf89..7807b31 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -116,8 +116,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.activeTab != TabFlame { return m, nil } + var animCmd tea.Cmd if m.liveTrie != nil && !m.flamegraphModel.Paused() && m.liveTrie.Version() != m.flamegraphModel.LastVersion() { m.flamegraphModel.RefreshFromLiveTrie() + animCmd = m.flamegraphModel.AnimationCmd() + } + if animCmd != nil { + return m, tea.Batch(flameTickCmd(), animCmd) } return m, flameTickCmd() case messages.StatsTickMsg: @@ -136,6 +141,11 @@ 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 } -- cgit v1.2.3 From 9744b3ce9e7b33b8843d1c260c3dfa3431391f43 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 23:06:46 +0200 Subject: task 366: validate flamegraph tab integration checkpoint --- internal/tui/dashboard/model_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'internal/tui/dashboard') diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index be31297..46f4944 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -397,6 +397,18 @@ func TestViewRendersTabBarAndHelp(t *testing.T) { } } +func TestFlameTabRendersWaitingForDataPlaceholder(t *testing.T) { + m := NewModelWithConfig(nil, nil, 1000, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + out := m.View().Content + if !strings.Contains(out, "Flame: waiting for data...") { + t.Fatalf("expected flame waiting placeholder, got %q", out) + } +} + func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) { snap := statsengine.NewSnapshot( nil, nil, nil, nil, -- cgit v1.2.3 From 77310af6f292004fbdd11eaa0bcfeaff812a365d Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 23:47:19 +0200 Subject: Make flame tab default and fix flame hotkey routing --- internal/tui/dashboard/model.go | 44 ++++++++++++------ internal/tui/dashboard/model_test.go | 87 ++++++++++++++++++++++++++++++------ internal/tui/dashboard/tabs.go | 2 +- 3 files changed, 104 insertions(+), 29 deletions(-) (limited to 'internal/tui/dashboard') 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 @@ -3