summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-13 07:23:24 +0200
committerPaul Buetow <paul@buetow.org>2026-03-13 07:23:24 +0200
commita83156ac084fdaf97e6cefc76ea3ddfd3f6a993a (patch)
tree6f586234e616d57ed0a938c98fa3b0aef9e20f6b
parentc9d22e32dc9d8d0447beb4ffa78f47a03d0cddc4 (diff)
feat: add tui parquet recording controls
-rw-r--r--internal/tui/common/keys.go5
-rw-r--r--internal/tui/dashboard/model.go15
-rw-r--r--internal/tui/help.go4
-rw-r--r--internal/tui/recordingmodal.go122
-rw-r--r--internal/tui/tui.go149
-rw-r--r--internal/tui/tui_test.go101
6 files changed, 391 insertions, 5 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index bdfa5e2..1dd2833 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -30,6 +30,7 @@ type KeyMap struct {
Filter key.Binding
FilterUndo key.Binding
Export key.Binding
+ Record key.Binding
Quit key.Binding
Enter key.Binding
Esc key.Binding
@@ -66,6 +67,7 @@ func DefaultKeyMap() KeyMap {
Filter: keyBinding("filter", "f"),
FilterUndo: keyBinding("undo filter", "F"),
Export: keyBinding("stream export", "e"),
+ Record: keyBinding("parquet rec", "R"),
Quit: keyBinding("quit", "q", "ctrl+c"),
Enter: keyBinding("select", "enter"),
Esc: keyBinding("back", "esc"),
@@ -109,6 +111,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
k.SelectPID,
k.SelectTID,
k.Probes,
+ k.Record,
k.Refresh,
k.Quit,
}
@@ -149,7 +152,7 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding {
if help := k.Export.Help(); help.Key != "" || help.Desc != "" {
controls = append(controls, k.Export)
}
- controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit)
+ controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Record, k.Refresh, k.Quit)
controls = append(controls, k.Visualize, k.Metric, k.Sort, k.ReverseSort, k.Filter, k.FilterUndo)
return [][]key.Binding{
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 72290e6..850a483 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -68,6 +68,7 @@ type Model struct {
keys common.KeyMap
globalFilter globalfilter.Filter
filterStack []string
+ recordingStatus string
pidFilter int
syscallsOffset int
syscallsCol int
@@ -936,6 +937,11 @@ func (m *Model) SetFilterStack(stack []string) {
m.streamModel.SetFilterStack(stack)
}
+// SetRecordingStatus updates the visible recording state summary rendered in the dashboard chrome.
+func (m *Model) SetRecordingStatus(status string) {
+ m.recordingStatus = status
+}
+
// SetLiveTrie updates the live trie source used by the flamegraph tab.
func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) {
m.liveTrie = liveTrie
@@ -1012,9 +1018,16 @@ func (m Model) View() tea.View {
func (m Model) filterSummary() string {
summary := "filter: " + m.globalFilter.Summary()
if len(m.filterStack) == 0 {
+ if m.recordingStatus == "" {
+ return summary
+ }
+ return summary + " | " + m.recordingStatus
+ }
+ summary += " | stack: " + strings.Join(m.filterStack, " | ")
+ if m.recordingStatus == "" {
return summary
}
- return summary + " | stack: " + strings.Join(m.filterStack, " | ")
+ return summary + " | " + m.recordingStatus
}
func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model, flameModel *flamegraphtui.Model) string {
diff --git a/internal/tui/help.go b/internal/tui/help.go
index db8d9b2..d63c198 100644
--- a/internal/tui/help.go
+++ b/internal/tui/help.go
@@ -49,7 +49,7 @@ type helpSection struct {
func (m Model) helpSections() []helpSection {
globalLines := []string{
"H help esc/? close help q quit",
- "f filter p pid picker t tid picker o probes",
+ "f filter p pid picker t tid picker o probes R parquet rec",
}
if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" {
globalLines[1] += " e stream export"
@@ -63,7 +63,7 @@ func (m Model) helpSections() []helpSection {
{
title: "Dashboard Tabs",
lines: []string{
- "tab/shift+tab tabs 1..7 jump tab r reset baseline",
+ "tab/shift+tab tabs 1..7 jump tab r reset baseline R parquet rec",
"sys/files/proc/stream tables: arrows or hjkl move pgup/pgdown page g/G top/bottom",
"sys/files/proc tables: s sort S reverse sort",
"sys/proc: v bubbles b metric events/bytes",
diff --git a/internal/tui/recordingmodal.go b/internal/tui/recordingmodal.go
new file mode 100644
index 0000000..eff23fc
--- /dev/null
+++ b/internal/tui/recordingmodal.go
@@ -0,0 +1,122 @@
+package tui
+
+import (
+ "strings"
+
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+)
+
+type recordingModal struct {
+ visible bool
+ textInput textinput.Model
+ err string
+}
+
+func newRecordingModal() recordingModal {
+ input := textinput.New()
+ input.Prompt = ""
+ input.CharLimit = 0
+ input.SetWidth(44)
+ input.SetStyles(textinput.DefaultStyles(true))
+ return recordingModal{textInput: input}
+}
+
+func (m recordingModal) Visible() bool {
+ return m.visible
+}
+
+func (m recordingModal) SetDarkMode(isDark bool) recordingModal {
+ m.textInput.SetStyles(textinput.DefaultStyles(isDark))
+ return m
+}
+
+func (m recordingModal) Open(defaultPath string) recordingModal {
+ m.visible = true
+ m.err = ""
+ m.textInput.SetValue(defaultPath)
+ m.textInput.CursorEnd()
+ m.textInput.Focus()
+ return m
+}
+
+func (m recordingModal) Close() recordingModal {
+ m.visible = false
+ m.err = ""
+ m.textInput.Blur()
+ return m
+}
+
+func (m recordingModal) SetError(err error) recordingModal {
+ if err == nil {
+ m.err = ""
+ return m
+ }
+ m.err = err.Error()
+ m.visible = true
+ m.textInput.Focus()
+ return m
+}
+
+func (m recordingModal) Update(msg tea.Msg) (recordingModal, string, bool) {
+ if !m.visible {
+ return m, "", false
+ }
+ if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
+ switch keyMsg.String() {
+ case "esc":
+ return m.Close(), "", false
+ case "enter":
+ path := strings.TrimSpace(m.textInput.Value())
+ if path == "" {
+ m.err = "filename is required"
+ return m, "", false
+ }
+ return m, path, true
+ }
+ }
+ var cmd tea.Cmd
+ m.textInput, cmd = m.textInput.Update(msg)
+ _ = cmd
+ return m, "", false
+}
+
+func (m recordingModal) View(width, height int) string {
+ if !m.visible {
+ return ""
+ }
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 24
+ }
+
+ modalWidth := 74
+ if width < modalWidth+4 {
+ modalWidth = width - 4
+ if modalWidth < 44 {
+ modalWidth = 44
+ }
+ }
+
+ lines := []string{
+ "Start Parquet Recording",
+ "",
+ "Filename:",
+ m.textInput.View(),
+ }
+ if m.err != "" {
+ lines = append(lines, "Error: "+m.err)
+ }
+ lines = append(lines, "", "Enter start • Esc cancel")
+
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ Padding(1, 2).
+ Width(modalWidth).
+ Render(strings.Join(lines, "\n"))
+
+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 29d1fc8..ef94748 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
+ "os"
"strconv"
"strings"
"sync"
@@ -270,6 +271,7 @@ type Model struct {
exporter tuiexport.Model
probeModal probes.Model
filterModal tracefilterui.Model
+ recordModal recordingModal
runtime *runtimeBindings
keys KeyMap
@@ -359,6 +361,7 @@ func newModelWithRuntimeConfig(initialPID int, startupFilter globalfilter.Filter
exporter: tuiexport.NewModel(),
probeModal: probes.NewModel(runtime.currentProbeManager()).SetDarkMode(true),
filterModal: tracefilterui.NewModel().SetDarkMode(true),
+ recordModal: newRecordingModal().SetDarkMode(true),
runtime: runtime,
keys: keys,
spin: spin,
@@ -460,6 +463,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.dashboard.SetStreamSource(m.runtime.eventStreamSource())
m.dashboard.SetLiveTrie(m.runtime.liveTrie())
m.dashboard.SetGlobalFilter(m.globalFilter)
+ m.syncDashboardFilterState()
width, height := common.EffectiveViewport(m.width, m.height)
next, sizeCmd := m.dashboard.Update(tea.WindowSizeMsg{Width: width, Height: height})
m.dashboard = next.(dashboardui.Model)
@@ -491,6 +495,7 @@ func (m Model) canHandleDashboardShortcut(msg tea.KeyPressMsg) bool {
m.lastErr == nil &&
!m.filterModal.Visible() &&
!m.exporter.Visible() &&
+ !m.recordModal.Visible() &&
!m.probeModal.Visible() &&
!m.dashboard.BlocksGlobalShortcuts(msg)
}
@@ -501,6 +506,7 @@ func (m Model) canQuitFromMainDashboard(msg tea.KeyPressMsg) bool {
m.lastErr == nil &&
!m.filterModal.Visible() &&
!m.exporter.Visible() &&
+ !m.recordModal.Visible() &&
!m.probeModal.Visible() &&
!m.dashboard.BlocksGlobalShortcuts(msg)
}
@@ -516,7 +522,7 @@ func (m Model) shouldRouteQuitToEsc(msg tea.KeyPressMsg) bool {
return false
}
return m.screen == ScreenDashboard &&
- (m.filterModal.Visible() || m.exporter.Visible() || m.probeModal.Visible() || m.dashboard.BlocksGlobalShortcuts(msg))
+ (m.filterModal.Visible() || m.exporter.Visible() || m.recordModal.Visible() || m.probeModal.Visible() || m.dashboard.BlocksGlobalShortcuts(msg))
}
func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) {
@@ -532,6 +538,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
}
if key.Matches(msg, m.keys.Quit) {
if m.canQuitFromMainDashboard(msg) {
+ if err := m.stopRecording(); err != nil {
+ m.lastErr = err
+ return m, nil, true
+ }
m.quitting = true
m.stopTrace()
return m, tea.Quit, true
@@ -546,6 +556,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
next, cmd := m.updateFilterModal(esc)
return next, cmd, true
}
+ if m.recordModal.Visible() {
+ next, cmd := m.updateRecordModal(esc)
+ return next, cmd, true
+ }
if m.exporter.Visible() {
next, cmd := m.updateExportModal(esc)
return next, cmd, true
@@ -564,6 +578,16 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
m.exporter = m.exporter.Open()
return m, nil, true
}
+ if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.Record) {
+ if m.recordingActive() {
+ if err := m.stopRecording(); err != nil {
+ m.lastErr = err
+ }
+ return m, nil, true
+ }
+ m.recordModal = m.recordModal.Open(defaultParquetRecordingFilename())
+ return m, nil, true
+ }
if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.Probes) {
m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark).Open()
return m, nil, true
@@ -621,6 +645,24 @@ func (m Model) updateExportModal(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(dashboardCmd, cmd)
}
+func (m Model) updateRecordModal(msg tea.Msg) (tea.Model, tea.Cmd) {
+ m, dashboardCmd := m.updateDashboardForModal(msg)
+ var (
+ path string
+ submit bool
+ )
+ m.recordModal, path, submit = m.recordModal.Update(msg)
+ if !submit {
+ return m, dashboardCmd
+ }
+ if err := m.startRecording(path); err != nil {
+ m.recordModal = m.recordModal.SetError(err)
+ return m, dashboardCmd
+ }
+ m.recordModal = m.recordModal.Close()
+ return m, dashboardCmd
+}
+
func (m Model) handleModalDispatch(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
if m.attaching {
var cmd tea.Cmd
@@ -631,6 +673,10 @@ func (m Model) handleModalDispatch(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
next, cmd := m.updateFilterModal(msg)
return next, cmd, true
}
+ if m.recordModal.Visible() {
+ next, cmd := m.updateRecordModal(msg)
+ return next, cmd, true
+ }
if m.probeModal.Visible() {
next, cmd := m.updateProbeModal(msg)
return next, cmd, true
@@ -659,6 +705,10 @@ func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) {
pid := selectedPIDFilter(msg.Pid)
+ if err := m.stopRecording(); err != nil {
+ m.lastErr = err
+ return m, nil
+ }
m.stopTrace()
m.runtime.resetStreamBuffer()
m.setProcessFilters(pid, -1)
@@ -675,6 +725,10 @@ func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) {
if msg.Pid > 0 {
pid = msg.Pid
}
+ if err := m.stopRecording(); err != nil {
+ m.lastErr = err
+ return m, nil
+ }
m.stopTrace()
m.runtime.resetStreamBuffer()
m.setProcessFilters(pid, tid)
@@ -686,6 +740,10 @@ func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) {
}
func (m Model) reselectPID() (tea.Model, tea.Cmd) {
+ if err := m.stopRecording(); err != nil {
+ m.lastErr = err
+ return m, nil
+ }
m.pickerReturn = &pickerReturnState{
pidFilter: m.pidFilter,
tidFilter: m.tidFilter,
@@ -697,6 +755,7 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) {
m.exporter = tuiexport.NewModel()
m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark)
m.filterModal = tracefilterui.NewModel().SetDarkMode(m.isDark)
+ m.recordModal = newRecordingModal().SetDarkMode(m.isDark)
m.pidPicker = pidpicker.New().SetDarkMode(m.isDark)
var sizeCmd tea.Cmd
@@ -712,6 +771,10 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) {
func (m Model) reselectTID() (tea.Model, tea.Cmd) {
pid := m.pidFilter
+ if err := m.stopRecording(); err != nil {
+ m.lastErr = err
+ return m, nil
+ }
m.pickerReturn = &pickerReturnState{
pidFilter: m.pidFilter,
tidFilter: m.tidFilter,
@@ -723,6 +786,7 @@ func (m Model) reselectTID() (tea.Model, tea.Cmd) {
m.exporter = tuiexport.NewModel()
m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark)
m.filterModal = tracefilterui.NewModel().SetDarkMode(m.isDark)
+ m.recordModal = newRecordingModal().SetDarkMode(m.isDark)
m.pidPicker = pidpicker.NewTIDWithKeys(pid, pidpicker.DefaultKeyMap()).SetDarkMode(m.isDark)
var sizeCmd tea.Cmd
@@ -746,6 +810,10 @@ func (m Model) cancelPickerToDashboard() (tea.Model, tea.Cmd) {
if m.pickerReturn == nil {
return m, nil
}
+ if err := m.stopRecording(); err != nil {
+ m.lastErr = err
+ return m, nil
+ }
returnState := *m.pickerReturn
m.pickerReturn = nil
m.stopTrace()
@@ -830,6 +898,7 @@ func (m *Model) syncDashboardFilterState() {
m.dashboard.SetPidFilter(m.pidFilter)
m.dashboard.SetGlobalFilter(m.globalFilter)
m.dashboard.SetFilterStack(m.filterStack)
+ m.dashboard.SetRecordingStatus(m.recordingStatus())
}
func applyProcessFilters(filter globalfilter.Filter, pid, tid int) globalfilter.Filter {
@@ -975,6 +1044,7 @@ func (m *Model) applyTheme(isDark bool) {
m.pidPicker = m.pidPicker.SetDarkMode(isDark)
m.probeModal = m.probeModal.SetDarkMode(isDark)
m.filterModal = m.filterModal.SetDarkMode(isDark)
+ m.recordModal = m.recordModal.SetDarkMode(isDark)
}
func (m Model) windowTitle() string {
@@ -1023,6 +1093,9 @@ func (m Model) View() tea.View {
if m.filterModal.Visible() {
return altScreenView(placeToViewport(width, height, m.filterModal.View(width, height)), title)
}
+ if m.recordModal.Visible() {
+ return altScreenView(placeToViewport(width, height, m.recordModal.View(width, height)), title)
+ }
if m.probeModal.Visible() {
return altScreenView(placeToViewport(width, height, m.probeModal.View(width, height)), title)
}
@@ -1069,6 +1142,80 @@ func runExportCmd(exportEnabled bool, option tuiexport.Option, dashboard dashboa
}
}
+func (m *Model) startRecording(path string) error {
+ recorder := m.runtime.Recorder()
+ if recorder == nil {
+ return errors.New("recording runtime is unavailable")
+ }
+ if err := recorder.Start(path, parquet.StartOptions{Metadata: tuiParquetMetadata()}); err != nil {
+ m.syncDashboardFilterState()
+ return err
+ }
+ m.syncDashboardFilterState()
+ return nil
+}
+
+func (m *Model) stopRecording() error {
+ recorder := m.runtime.Recorder()
+ if recorder == nil {
+ return nil
+ }
+ if !m.recordingActive() {
+ m.syncDashboardFilterState()
+ return nil
+ }
+ err := recorder.Stop()
+ m.syncDashboardFilterState()
+ return err
+}
+
+func (m Model) recordingActive() bool {
+ recorder := m.runtime.Recorder()
+ if recorder == nil {
+ return false
+ }
+ return recorder.Status().Active
+}
+
+func (m Model) recordingStatus() string {
+ recorder := m.runtime.Recorder()
+ if recorder == nil {
+ return "rec: unavailable"
+ }
+ status := recorder.Status()
+ if status.Active {
+ return "rec: " + shortenRecordingPath(status.Path)
+ }
+ if status.LastError != nil {
+ return "rec err: " + status.LastError.Error()
+ }
+ return "rec: off"
+}
+
+func defaultParquetRecordingFilename() string {
+ return fmt.Sprintf("ior-recording-%s.parquet", time.Now().Format("20060102-150405"))
+}
+
+func tuiParquetMetadata() parquet.FileMetadata {
+ meta := parquet.FileMetadata{
+ StartedAtUnixNano: uint64(time.Now().UnixNano()),
+ Mode: "tui",
+ IORVersion: flags.Version,
+ }
+ if hostname, err := os.Hostname(); err == nil {
+ meta.Hostname = hostname
+ }
+ return meta
+}
+
+func shortenRecordingPath(path string) string {
+ const maxLen = 36
+ if len(path) <= maxLen {
+ return path
+ }
+ return "..." + path[len(path)-maxLen+3:]
+}
+
type lateBoundDashboardSource struct {
runtime *runtimeBindings
}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 70e2b5b..70552fb 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -5,6 +5,7 @@ import (
"encoding/csv"
"errors"
"os"
+ "path/filepath"
"regexp"
"strings"
"testing"
@@ -709,6 +710,106 @@ func TestExportKeyOpensModalOnDashboard(t *testing.T) {
}
}
+func TestRecordKeyOpensRecordingModalOnDashboard(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'R'}[0], Text: "R"})
+ updated := next.(Model)
+ if !updated.recordModal.Visible() {
+ t.Fatalf("expected recording modal to open on R key")
+ }
+}
+
+func TestStartRecordingUpdatesDashboardStatus(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+
+ path := filepath.Join(t.TempDir(), "capture.parquet")
+ if err := m.startRecording(path); err != nil {
+ t.Fatalf("startRecording() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := m.stopRecording(); err != nil {
+ t.Fatalf("stopRecording() cleanup error = %v", err)
+ }
+ })
+
+ status := m.runtime.Recorder().Status()
+ if !status.Active {
+ t.Fatalf("expected recorder to be active after startRecording()")
+ }
+
+ view := m.View().Content
+ if !strings.Contains(view, "rec:") || !strings.Contains(view, "capture") {
+ t.Fatalf("expected dashboard view to show recording status, got %q", view)
+ }
+}
+
+func TestRecordKeyStopsActiveRecording(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+
+ path := filepath.Join(t.TempDir(), "capture.parquet")
+ if err := m.startRecording(path); err != nil {
+ t.Fatalf("startRecording() error = %v", err)
+ }
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'R'}[0], Text: "R"})
+ updated := next.(Model)
+ if updated.runtime.Recorder().Status().Active {
+ t.Fatalf("expected R key to stop active recording")
+ }
+}
+
+func TestQuitStopsActiveRecording(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+
+ path := filepath.Join(t.TempDir(), "capture.parquet")
+ if err := m.startRecording(path); err != nil {
+ t.Fatalf("startRecording() error = %v", err)
+ }
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: "q"})
+ updated := next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected quit command")
+ }
+ if updated.runtime.Recorder().Status().Active {
+ t.Fatalf("expected quit to stop active recording")
+ }
+}
+
+func TestSelectPIDStopsActiveRecording(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+
+ path := filepath.Join(t.TempDir(), "capture.parquet")
+ if err := m.startRecording(path); err != nil {
+ t.Fatalf("startRecording() error = %v", err)
+ }
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"})
+ updated := next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected picker init command")
+ }
+ if updated.screen != ScreenPIDPicker {
+ t.Fatalf("expected p to switch to pid picker, got %v", updated.screen)
+ }
+ if updated.runtime.Recorder().Status().Active {
+ t.Fatalf("expected pid reselect to stop active recording")
+ }
+}
+
func TestFlamePauseKeyDoesNotTriggerPIDReselect(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
m.screen = ScreenDashboard