summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-23 23:52:55 +0200
committerPaul Buetow <paul@buetow.org>2026-02-23 23:52:55 +0200
commitb79a868fbc85cd7fb2829e978174629ab8a9c986 (patch)
tree6d80b9b1a45a43b8d251a518f3538817c34e3165 /internal
parent570b7b5d9283b9e443e7da25661e9f2098cc2305 (diff)
tui: add top-level model and run entrypoint
Diffstat (limited to 'internal')
-rw-r--r--internal/flags/flags.go12
-rw-r--r--internal/tui/messages/messages.go24
-rw-r--r--internal/tui/msg.go18
-rw-r--r--internal/tui/pidpicker/model.go55
-rw-r--r--internal/tui/pidpicker/model_test.go16
-rw-r--r--internal/tui/tui.go233
-rw-r--r--internal/tui/tui_test.go166
7 files changed, 491 insertions, 33 deletions
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 6010b51..8760d33 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -8,6 +8,7 @@ import (
"slices"
"strings"
"sync"
+ "sync/atomic"
bpf "github.com/aquasecurity/libbpfgo"
)
@@ -15,6 +16,7 @@ import (
var (
singleton Flags
once sync.Once
+ pidFilter atomic.Int64
)
const flamegraphToolDefault = "$HOME/git/FlameGraph/flamegraph.pl"
@@ -64,7 +66,14 @@ type Flags struct {
}
func Get() Flags {
- return singleton
+ out := singleton
+ out.PidFilter = int(pidFilter.Load())
+ return out
+}
+
+// SetPidFilter updates the active PID filter used for subsequent tracing runs.
+func SetPidFilter(pid int) {
+ pidFilter.Store(int64(pid))
}
func Parse() {
@@ -100,6 +109,7 @@ func parse() {
flag.StringVar(&singleton.FlamegraphTool, "flamegraphTool",
"", "Path to the flamegraph tool (e.g. flamegraph.pl or inferno-flamegraph)")
flag.Parse()
+ pidFilter.Store(int64(singleton.PidFilter))
if singleton.FlamegraphTool == "" {
singleton.FlamegraphTool = flamegraphToolDefault
diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go
new file mode 100644
index 0000000..35232b9
--- /dev/null
+++ b/internal/tui/messages/messages.go
@@ -0,0 +1,24 @@
+package messages
+
+import "ior/internal/statsengine"
+
+// PidSelectedMsg is emitted when the user selects a PID from the process table.
+type PidSelectedMsg struct {
+ Pid int
+}
+
+// StatsTickMsg carries a fresh immutable snapshot from the stats engine.
+type StatsTickMsg struct {
+ Snap *statsengine.Snapshot
+}
+
+// ExportRequestMsg requests an export of the current UI state.
+type ExportRequestMsg struct{}
+
+// TracingStartedMsg signals that tracing started successfully.
+type TracingStartedMsg struct{}
+
+// TracingErrorMsg reports an error while starting or running tracing.
+type TracingErrorMsg struct {
+ Err error
+}
diff --git a/internal/tui/msg.go b/internal/tui/msg.go
index ba2ec53..c69e806 100644
--- a/internal/tui/msg.go
+++ b/internal/tui/msg.go
@@ -1,24 +1,18 @@
package tui
-import "ior/internal/statsengine"
+import "ior/internal/tui/messages"
// PidSelectedMsg is emitted when the user selects a PID from the process table.
-type PidSelectedMsg struct {
- Pid int
-}
+type PidSelectedMsg = messages.PidSelectedMsg
// StatsTickMsg carries a fresh immutable snapshot from the stats engine.
-type StatsTickMsg struct {
- Snap *statsengine.Snapshot
-}
+type StatsTickMsg = messages.StatsTickMsg
// ExportRequestMsg requests an export of the current UI state.
-type ExportRequestMsg struct{}
+type ExportRequestMsg = messages.ExportRequestMsg
// TracingStartedMsg signals that tracing started successfully.
-type TracingStartedMsg struct{}
+type TracingStartedMsg = messages.TracingStartedMsg
// TracingErrorMsg reports an error while starting or running tracing.
-type TracingErrorMsg struct {
- Err error
-}
+type TracingErrorMsg = messages.TracingErrorMsg
diff --git a/internal/tui/pidpicker/model.go b/internal/tui/pidpicker/model.go
index 4e63429..34da674 100644
--- a/internal/tui/pidpicker/model.go
+++ b/internal/tui/pidpicker/model.go
@@ -2,7 +2,7 @@ package pidpicker
import (
"fmt"
- "ior/internal/tui"
+ "ior/internal/tui/messages"
"strings"
"github.com/charmbracelet/bubbles/key"
@@ -13,6 +13,37 @@ import (
const allPIDsLabel = "All PIDs"
+// KeyMap defines picker-specific key bindings.
+type KeyMap struct {
+ Enter key.Binding
+ Esc key.Binding
+ Refresh key.Binding
+}
+
+// DefaultKeyMap returns picker defaults.
+func DefaultKeyMap() KeyMap {
+ return KeyMap{
+ Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
+ Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
+ Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
+ }
+}
+
+func (k KeyMap) PickerShortHelp() []key.Binding {
+ return []key.Binding{k.Enter, k.Refresh, k.Esc}
+}
+
+var (
+ screenStyle = lipgloss.NewStyle()
+ headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("75"))
+ helpBarStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("246")).
+ BorderTop(true).
+ BorderForeground(lipgloss.Color("238"))
+ highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("222"))
+ errorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("203"))
+)
+
type processesLoadedMsg struct {
processes []ProcessInfo
err error
@@ -26,17 +57,17 @@ type Model struct {
selectedIndex int
width int
height int
- keys tui.KeyMap
+ keys KeyMap
lastErr error
}
// New creates a PID picker model with default shared key bindings.
func New() Model {
- return NewWithKeys(tui.Keys)
+ return NewWithKeys(DefaultKeyMap())
}
// NewWithKeys creates a PID picker model with the provided key bindings.
-func NewWithKeys(keys tui.KeyMap) Model {
+func NewWithKeys(keys KeyMap) Model {
input := textinput.New()
input.Prompt = "Filter: "
input.Placeholder = "pid, comm, or cmdline"
@@ -117,16 +148,16 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) emitSelection() tea.Cmd {
if m.selectedIndex <= 0 {
- return func() tea.Msg { return tui.PidSelectedMsg{Pid: 0} }
+ return func() tea.Msg { return messages.PidSelectedMsg{Pid: 0} }
}
idx := m.selectedIndex - 1
if idx < 0 || idx >= len(m.filtered) {
- return func() tea.Msg { return tui.PidSelectedMsg{Pid: 0} }
+ return func() tea.Msg { return messages.PidSelectedMsg{Pid: 0} }
}
pid := m.filtered[idx].Pid
- return func() tea.Msg { return tui.PidSelectedMsg{Pid: pid} }
+ return func() tea.Msg { return messages.PidSelectedMsg{Pid: pid} }
}
func (m *Model) applyFilter() {
@@ -175,7 +206,7 @@ func cloneProcesses(in []ProcessInfo) []ProcessInfo {
// View renders the PID picker with filter input, list, and help bar.
func (m Model) View() string {
var b strings.Builder
- b.WriteString(tui.HeaderStyle.Render("Select PID"))
+ b.WriteString(headerStyle.Render("Select PID"))
b.WriteString("\n")
b.WriteString(m.input.View())
b.WriteString("\n\n")
@@ -185,12 +216,12 @@ func (m Model) View() string {
if m.lastErr != nil {
b.WriteString("\n")
- b.WriteString(tui.ErrorStyle.Render("scan error: " + m.lastErr.Error()))
+ b.WriteString(errorStyle.Render("scan error: " + m.lastErr.Error()))
}
b.WriteString("\n")
- b.WriteString(tui.HelpBarStyle.Render(renderHelp(m.keys.PickerShortHelp())))
- return tui.ScreenStyle.Render(b.String())
+ b.WriteString(helpBarStyle.Render(renderHelp(m.keys.PickerShortHelp())))
+ return screenStyle.Render(b.String())
}
func (m Model) renderRows() string {
@@ -221,7 +252,7 @@ func (m Model) renderRow(index int, label string) string {
style := lipgloss.NewStyle()
if index == m.selectedIndex {
prefix = "> "
- style = tui.HighlightStyle
+ style = highlightStyle
}
return style.Render(prefix + label)
}
diff --git a/internal/tui/pidpicker/model_test.go b/internal/tui/pidpicker/model_test.go
index c8e59af..7347eca 100644
--- a/internal/tui/pidpicker/model_test.go
+++ b/internal/tui/pidpicker/model_test.go
@@ -1,7 +1,7 @@
package pidpicker
import (
- "ior/internal/tui"
+ "ior/internal/tui/messages"
"strings"
"testing"
@@ -9,7 +9,7 @@ import (
)
func TestApplyFilterByPIDCommAndCmdline(t *testing.T) {
- m := NewWithKeys(tui.DefaultKeyMap())
+ m := NewWithKeys(DefaultKeyMap())
m.processes = []ProcessInfo{
{Pid: 100, Comm: "bash", Cmdline: "bash -l"},
{Pid: 200, Comm: "sshd", Cmdline: "/usr/sbin/sshd -D"},
@@ -35,14 +35,14 @@ func TestApplyFilterByPIDCommAndCmdline(t *testing.T) {
}
func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) {
- m := NewWithKeys(tui.DefaultKeyMap())
+ m := NewWithKeys(DefaultKeyMap())
m.processes = []ProcessInfo{{Pid: 7, Comm: "vim"}, {Pid: 9, Comm: "top"}}
m.applyFilter()
modelAny, cmdAny := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
_ = modelAny
msgAny := cmdAny()
- pidAny, ok := msgAny.(tui.PidSelectedMsg)
+ pidAny, ok := msgAny.(messages.PidSelectedMsg)
if !ok {
t.Fatalf("expected PidSelectedMsg for all-pids selection, got %T", msgAny)
}
@@ -54,7 +54,7 @@ func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) {
modelOne, cmdOne := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
_ = modelOne
msgOne := cmdOne()
- pidOne, ok := msgOne.(tui.PidSelectedMsg)
+ pidOne, ok := msgOne.(messages.PidSelectedMsg)
if !ok {
t.Fatalf("expected PidSelectedMsg for concrete selection, got %T", msgOne)
}
@@ -64,7 +64,7 @@ func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) {
}
func TestEscQuitsAndRefreshTriggersScan(t *testing.T) {
- m := NewWithKeys(tui.DefaultKeyMap())
+ m := NewWithKeys(DefaultKeyMap())
_, escCmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
if escCmd == nil {
@@ -84,7 +84,7 @@ func TestEscQuitsAndRefreshTriggersScan(t *testing.T) {
}
func TestRuneRDoesNotTriggerRefreshWhileFilterFocused(t *testing.T) {
- m := NewWithKeys(tui.DefaultKeyMap())
+ m := NewWithKeys(DefaultKeyMap())
next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
if cmd == nil {
@@ -98,7 +98,7 @@ func TestRuneRDoesNotTriggerRefreshWhileFilterFocused(t *testing.T) {
}
func TestRenderRowsKeepsSelectionVisible(t *testing.T) {
- m := NewWithKeys(tui.DefaultKeyMap())
+ m := NewWithKeys(DefaultKeyMap())
m.height = 8 // visible rows == 2
m.processes = []ProcessInfo{
{Pid: 1, Comm: "p1"},
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
new file mode 100644
index 0000000..758213d
--- /dev/null
+++ b/internal/tui/tui.go
@@ -0,0 +1,233 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "ior/internal/flags"
+ "ior/internal/tui/pidpicker"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// Screen identifies the currently active TUI screen.
+type Screen int
+
+const (
+ // ScreenPIDPicker is the PID selection screen.
+ ScreenPIDPicker Screen = iota
+ // ScreenDashboard is the runtime dashboard screen.
+ ScreenDashboard
+)
+
+// TraceStarter starts tracing and returns when startup succeeds or fails.
+// Long-lived tracing work should continue in background goroutines.
+type TraceStarter func(context.Context) error
+
+// Run starts the TUI program in alternate screen mode.
+func Run() error {
+ model := NewModel(flags.Get().PidFilter, defaultTraceStarter)
+ program := tea.NewProgram(model, tea.WithAltScreen())
+ _, err := program.Run()
+ return err
+}
+
+// Model is the top-level Bubble Tea model that routes between PID picker and dashboard.
+type Model struct {
+ screen Screen
+ pidPicker pidpicker.Model
+ dashboard dashboardModel
+
+ keys KeyMap
+
+ width int
+ height int
+ quitting bool
+
+ attaching bool
+ spin spinner.Model
+ lastErr error
+
+ startTrace TraceStarter
+ traceStop context.CancelFunc
+}
+
+// NewModel creates the top-level TUI model.
+func NewModel(initialPID int, startTrace TraceStarter) Model {
+ spin := spinner.New()
+ spin.Spinner = spinner.MiniDot
+ if startTrace == nil {
+ startTrace = defaultTraceStarter
+ }
+
+ model := Model{
+ screen: ScreenPIDPicker,
+ pidPicker: pidpicker.New(),
+ dashboard: newDashboardModel(),
+ keys: Keys,
+ spin: spin,
+ startTrace: startTrace,
+ }
+
+ if initialPID > 0 {
+ flags.SetPidFilter(initialPID)
+ model.dashboard.selectedPID = initialPID
+ model.screen = ScreenDashboard
+ model.attaching = true
+ }
+
+ return model
+}
+
+// Init initializes the active child model and optional tracing startup command.
+func (m Model) Init() tea.Cmd {
+ if m.screen == ScreenDashboard && m.attaching {
+ return tea.Batch(m.spin.Tick, m.beginTraceCmd())
+ }
+ return m.pidPicker.Init()
+}
+
+// Update routes messages, transitions screens, and manages tracing startup state.
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ return m.updateActiveModel(msg)
+ case tea.KeyMsg:
+ if key.Matches(msg, m.keys.Quit) {
+ m.quitting = true
+ m.stopTrace()
+ return m, tea.Quit
+ }
+ case PidSelectedMsg:
+ return m.handlePidSelected(msg)
+ case TracingStartedMsg:
+ m.attaching = false
+ return m, nil
+ case TracingErrorMsg:
+ m.attaching = false
+ m.lastErr = msg.Err
+ return m, nil
+ }
+
+ if m.attaching {
+ var cmd tea.Cmd
+ m.spin, cmd = m.spin.Update(msg)
+ return m, cmd
+ }
+
+ return m.updateActiveModel(msg)
+}
+
+func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch m.screen {
+ case ScreenPIDPicker:
+ next, cmd := m.pidPicker.Update(msg)
+ m.pidPicker = next.(pidpicker.Model)
+ return m, cmd
+ case ScreenDashboard:
+ next, cmd := m.dashboard.Update(msg)
+ m.dashboard = next.(dashboardModel)
+ return m, cmd
+ default:
+ return m, nil
+ }
+}
+
+func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) {
+ pid := selectedPIDFilter(msg.Pid)
+ m.stopTrace()
+ flags.SetPidFilter(pid)
+ m.dashboard.selectedPID = pid
+ m.screen = ScreenDashboard
+ m.attaching = true
+ m.lastErr = nil
+ return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
+}
+
+func selectedPIDFilter(pid int) int {
+ if pid <= 0 {
+ return -1
+ }
+ return pid
+}
+
+func (m *Model) beginTraceCmd() tea.Cmd {
+ ctx, cancel := context.WithCancel(context.Background())
+ m.traceStop = cancel
+ return startTraceCmd(m.startTrace, ctx)
+}
+
+func startTraceCmd(starter TraceStarter, ctx context.Context) tea.Cmd {
+ return func() tea.Msg {
+ if err := starter(ctx); err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil
+ }
+ return TracingErrorMsg{Err: err}
+ }
+ return TracingStartedMsg{}
+ }
+}
+
+func defaultTraceStarter(context.Context) error {
+ return nil
+}
+
+func (m *Model) stopTrace() {
+ if m.traceStop != nil {
+ m.traceStop()
+ m.traceStop = nil
+ }
+}
+
+// View renders the currently active screen and startup overlay state.
+func (m Model) View() string {
+ if m.quitting {
+ return ""
+ }
+
+ if m.attaching {
+ line := fmt.Sprintf("%s Attaching tracepoints...", m.spin.View())
+ return ScreenStyle.Render(PanelStyle.Render(line))
+ }
+
+ if m.lastErr != nil {
+ return ScreenStyle.Render(ErrorStyle.Render(m.lastErr.Error()))
+ }
+
+ switch m.screen {
+ case ScreenPIDPicker:
+ return m.pidPicker.View()
+ case ScreenDashboard:
+ return m.dashboard.View()
+ default:
+ return ""
+ }
+}
+
+type dashboardModel struct {
+ selectedPID int
+}
+
+func newDashboardModel() dashboardModel {
+ return dashboardModel{selectedPID: -1}
+}
+
+func (d dashboardModel) Init() tea.Cmd {
+ return nil
+}
+
+func (d dashboardModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+ return d, nil
+}
+
+func (d dashboardModel) View() string {
+ if d.selectedPID > 0 {
+ return PanelStyle.Render(fmt.Sprintf("Dashboard (PID %d)", d.selectedPID))
+ }
+ return PanelStyle.Render("Dashboard (All PIDs)")
+}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
new file mode 100644
index 0000000..3801813
--- /dev/null
+++ b/internal/tui/tui_test.go
@@ -0,0 +1,166 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "ior/internal/flags"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestPidSelectedTransitionsToDashboardAndSetsPIDFilter(t *testing.T) {
+ flags.SetPidFilter(-1)
+ m := NewModel(-1, func(context.Context) error { return nil })
+
+ next, cmd := m.Update(PidSelectedMsg{Pid: 42})
+ if cmd == nil {
+ t.Fatalf("expected tracing start command")
+ }
+
+ updated := next.(Model)
+ if updated.screen != ScreenDashboard {
+ t.Fatalf("expected dashboard screen, got %v", updated.screen)
+ }
+ if !updated.attaching {
+ t.Fatalf("expected attaching state to be true")
+ }
+ if got := flags.Get().PidFilter; got != 42 {
+ t.Fatalf("expected pid filter 42, got %d", got)
+ }
+}
+
+func TestInitialPIDSkipsPickerAndStartsTracing(t *testing.T) {
+ flags.SetPidFilter(-1)
+ m := NewModel(7, func(context.Context) error { return nil })
+
+ if m.screen != ScreenDashboard {
+ t.Fatalf("expected initial screen dashboard, got %v", m.screen)
+ }
+
+ cmd := m.Init()
+ if cmd == nil {
+ t.Fatalf("expected init command when initial pid is set")
+ }
+}
+
+func TestPidSelectedAllSetsNoFilter(t *testing.T) {
+ flags.SetPidFilter(999)
+ m := NewModel(-1, func(context.Context) error { return nil })
+
+ next, _ := m.Update(PidSelectedMsg{Pid: 0})
+ updated := next.(Model)
+
+ if got := flags.Get().PidFilter; got != -1 {
+ t.Fatalf("expected pid filter -1 for all pids, got %d", got)
+ }
+ if updated.dashboard.selectedPID != -1 {
+ t.Fatalf("expected dashboard selected pid -1, got %d", updated.dashboard.selectedPID)
+ }
+}
+
+func TestTracingErrorMessageClearsAttachingState(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.attaching = true
+
+ next, _ := m.Update(TracingErrorMsg{Err: errors.New("boom")})
+ updated := next.(Model)
+ if updated.attaching {
+ t.Fatalf("expected attaching to be false after tracing error")
+ }
+ if updated.lastErr == nil || updated.lastErr.Error() != "boom" {
+ t.Fatalf("expected tracing error to be stored")
+ }
+}
+
+func TestViewShowsAttachingAndErrorStates(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.attaching = true
+ attachingView := m.View()
+ if !strings.Contains(attachingView, "Attaching tracepoints...") {
+ t.Fatalf("expected attaching view, got %q", attachingView)
+ }
+
+ m.attaching = false
+ m.lastErr = errors.New("failed")
+ errorView := m.View()
+ if !strings.Contains(errorView, "failed") {
+ t.Fatalf("expected error view, got %q", errorView)
+ }
+}
+
+func TestQuitKeySetsQuittingState(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
+ if cmd == nil {
+ t.Fatalf("expected quit cmd")
+ }
+ if _, ok := cmd().(tea.QuitMsg); !ok {
+ t.Fatalf("expected tea.QuitMsg")
+ }
+
+ updated := next.(Model)
+ if !updated.quitting {
+ t.Fatalf("expected quitting state")
+ }
+}
+
+func TestQuitKeyMatchesSingleBindingWithoutPanic(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.keys.Quit = key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "quit"))
+
+ _, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+ if cmd == nil {
+ t.Fatalf("expected quit cmd")
+ }
+ updated := next.(Model)
+ if !updated.quitting {
+ t.Fatalf("expected quitting state")
+ }
+}
+
+func TestStartTraceCmdLaunchesBeforeStarterReturns(t *testing.T) {
+ cmd := startTraceCmd(func(context.Context) error { return nil }, context.Background())
+ msg := cmd()
+ if _, ok := msg.(TracingStartedMsg); !ok {
+ t.Fatalf("expected TracingStartedMsg, got %T", msg)
+ }
+}
+
+func TestStartTraceCmdEmitsErrorMsg(t *testing.T) {
+ cmd := startTraceCmd(func(context.Context) error { return errors.New("trace failed") }, context.Background())
+ msg := cmd()
+ traceErr, ok := msg.(TracingErrorMsg)
+ if !ok {
+ t.Fatalf("expected TracingErrorMsg, got %T", msg)
+ }
+ if traceErr.Err == nil || traceErr.Err.Error() != "trace failed" {
+ t.Fatalf("unexpected trace error message: %+v", traceErr)
+ }
+}
+
+func TestQuitInvokesTraceStop(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ done := make(chan struct{})
+ m.traceStop = func() {
+ close(done)
+ }
+
+ _, quitCmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
+ if quitCmd == nil {
+ t.Fatalf("expected quit command")
+ }
+
+ select {
+ case <-done:
+ case <-time.After(200 * time.Millisecond):
+ t.Fatalf("expected stopTrace to be invoked on quit")
+ }
+}