diff options
| -rw-r--r-- | internal/eventloop.go | 2 | ||||
| -rw-r--r-- | internal/flags/flags.go | 10 | ||||
| -rw-r--r-- | internal/tui/common/keys.go | 29 | ||||
| -rw-r--r-- | internal/tui/common/keys_test.go | 39 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 7 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 44 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs_test.go | 6 | ||||
| -rw-r--r-- | internal/tui/messages/messages.go | 6 | ||||
| -rw-r--r-- | internal/tui/msg.go | 3 | ||||
| -rw-r--r-- | internal/tui/pidpicker/model.go | 96 | ||||
| -rw-r--r-- | internal/tui/pidpicker/model_test.go | 38 | ||||
| -rw-r--r-- | internal/tui/pidpicker/proclist.go | 129 | ||||
| -rw-r--r-- | internal/tui/pidpicker/proclist_test.go | 43 | ||||
| -rw-r--r-- | internal/tui/tui.go | 61 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 165 |
16 files changed, 561 insertions, 119 deletions
diff --git a/internal/eventloop.go b/internal/eventloop.go index 345150c..f2c5c08 100644 --- a/internal/eventloop.go +++ b/internal/eventloop.go @@ -105,7 +105,7 @@ func (e *eventLoop) run(ctx context.Context, rawCh <-chan []byte) { if flags.Get().PprofEnable { fmt.Println("Profiling, press Ctrl+C to stop") } - if !flags.Get().FlamegraphEnable && !flags.Get().PprofEnable { + if flags.Get().PlainMode && !flags.Get().FlamegraphEnable && !flags.Get().PprofEnable { fmt.Println(event.EventStreamHeader) } diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 4dd32cc..6eafa5e 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -19,10 +19,13 @@ var ( } once sync.Once pidFilter atomic.Int64 + tidFilter atomic.Int64 tuiExportEnable atomic.Bool ) func init() { + pidFilter.Store(-1) + tidFilter.Store(-1) tuiExportEnable.Store(true) } @@ -72,6 +75,7 @@ type Flags struct { func Get() Flags { out := singleton out.PidFilter = int(pidFilter.Load()) + out.TidFilter = int(tidFilter.Load()) out.TUIExportEnable = tuiExportEnable.Load() return out } @@ -81,6 +85,11 @@ func SetPidFilter(pid int) { pidFilter.Store(int64(pid)) } +// SetTidFilter updates the active TID filter used for subsequent tracing runs. +func SetTidFilter(tid int) { + tidFilter.Store(int64(tid)) +} + // SetTUIExportEnable toggles TUI snapshot export file writing. func SetTUIExportEnable(enabled bool) { tuiExportEnable.Store(enabled) @@ -118,6 +127,7 @@ func parse() { fmt.Sprintf("Count field to collapse, valid are: %v", validCollapsedCounts)) flag.Parse() pidFilter.Store(int64(singleton.PidFilter)) + tidFilter.Store(int64(singleton.TidFilter)) tuiExportEnable.Store(singleton.TUIExportEnable) singleton.TracepointsToAttach = extractTracepointFlags(*tracepointsToAttach) diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index 87c947c..acb066b 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -15,10 +15,10 @@ type KeyMap struct { Seven key.Binding DirGroup key.Binding SelectPID key.Binding + SelectTID key.Binding Probes key.Binding Export key.Binding Quit key.Binding - Help key.Binding Enter key.Binding Esc key.Binding Refresh key.Binding @@ -40,24 +40,35 @@ func DefaultKeyMap() KeyMap { Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "stream")), Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")), DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")), - SelectPID: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "select pid")), - Probes: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "probes")), + SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")), + SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")), + Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")), Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "export")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 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")), } } -// DashboardShortHelp returns compact bindings for dashboard help bars. -func (k KeyMap) DashboardShortHelp() []key.Binding { - bindings := []key.Binding{k.Tab, k.ShiftTab} +// DashboardStatusHelp returns expanded bindings for dashboard status bars. +func (k KeyMap) DashboardStatusHelp() []key.Binding { + bindings := []key.Binding{k.Tab, k.ShiftTab, k.One, k.Two, k.Three, k.Four, k.Five, k.Six} if help := k.Export.Help(); help.Key != "" || help.Desc != "" { bindings = append(bindings, k.Export) } - bindings = append(bindings, k.SelectPID, k.Probes, k.Help, k.Quit) + bindings = append(bindings, + k.DirGroup, + k.SelectPID, + k.SelectTID, + k.Probes, + k.Refresh, + k.Quit, + helpTextBinding("left/right", "tab"), + helpTextBinding("h/l", "tab"), + helpTextBinding("j/k", "scroll"), + helpTextBinding("up/down", "scroll"), + ) return bindings } @@ -67,7 +78,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.Probes, k.Refresh, k.Help, k.Quit) + controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit) return [][]key.Binding{ {k.One, k.Two, k.Three, k.Four, k.Five, k.Six}, diff --git a/internal/tui/common/keys_test.go b/internal/tui/common/keys_test.go index 3636107..42e47ab 100644 --- a/internal/tui/common/keys_test.go +++ b/internal/tui/common/keys_test.go @@ -10,14 +10,19 @@ func TestDefaultKeyMapIncludesDirGroupBinding(t *testing.T) { } probesHelp := keys.Probes.Help() - if probesHelp.Key != "p" || probesHelp.Desc != "probes" { + if probesHelp.Key != "o" || probesHelp.Desc != "probes" { t.Fatalf("unexpected probes binding help: key=%q desc=%q", probesHelp.Key, probesHelp.Desc) } selectHelp := keys.SelectPID.Help() - if selectHelp.Key != "s" || selectHelp.Desc != "select pid" { + if selectHelp.Key != "p" || selectHelp.Desc != "select pid" { t.Fatalf("unexpected select pid binding help: key=%q desc=%q", selectHelp.Key, selectHelp.Desc) } + + selectTIDHelp := keys.SelectTID.Help() + if selectTIDHelp.Key != "t" || selectTIDHelp.Desc != "select tid" { + t.Fatalf("unexpected select tid binding help: key=%q desc=%q", selectTIDHelp.Key, selectTIDHelp.Desc) + } } func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { @@ -42,7 +47,7 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { found = false for _, binding := range groups[1] { help := binding.Help() - if help.Key == "p" && help.Desc == "probes" { + if help.Key == "o" && help.Desc == "probes" { found = true break } @@ -54,7 +59,7 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { found = false for _, binding := range groups[1] { help := binding.Help() - if help.Key == "s" && help.Desc == "select pid" { + if help.Key == "p" && help.Desc == "select pid" { found = true break } @@ -62,20 +67,38 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { if !found { t.Fatalf("expected select pid binding in dashboard full help controls") } + + found = false + for _, binding := range groups[1] { + help := binding.Help() + if help.Key == "t" && help.Desc == "select tid" { + found = true + break + } + } + if !found { + t.Fatalf("expected select tid binding in dashboard full help controls") + } } -func TestDashboardShortHelpIncludesProbesBinding(t *testing.T) { +func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { keys := DefaultKeyMap() - short := keys.DashboardShortHelp() + short := keys.DashboardStatusHelp() found := false + foundSelectTID := false for _, binding := range short { help := binding.Help() - if help.Key == "p" && help.Desc == "probes" { + if help.Key == "o" && help.Desc == "probes" { found = true - break + } + if help.Key == "t" && help.Desc == "select tid" { + foundSelectTID = true } } if !found { t.Fatalf("expected probes binding in dashboard short help") } + if !foundSelectTID { + t.Fatalf("expected select tid binding in dashboard short help") + } } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 2ed53a1..026e63e 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -290,8 +290,6 @@ func (m Model) View() string { m.processesOffset, )) b.WriteString("\n") - b.WriteString(common.HighlightStyle.Render("Press ? for help")) - b.WriteString("\n") b.WriteString(renderHelpBar(m.keys, width)) return common.ScreenStyle.Render(b.String()) } diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 6baa62c..a0e0539 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -369,9 +369,6 @@ func TestViewRendersTabBarAndHelp(t *testing.T) { if !strings.Contains(out, "Overview") { t.Fatalf("expected overview label in view") } - if !strings.Contains(out, "Press ? for help") { - t.Fatalf("expected inline help hint in view") - } if !strings.Contains(out, "tab next tab") { t.Fatalf("expected help bar text in view") } @@ -408,7 +405,7 @@ func TestStreamTabViewKeepsTabAndHelpChromeVisible(t *testing.T) { if !strings.Contains(out, "1:Overview") { t.Fatalf("expected tab bar to remain visible in stream view") } - if !strings.Contains(out, "Press ? for help") { - t.Fatalf("expected help hint to remain visible in stream view") + if !strings.Contains(out, "tab next tab") { + t.Fatalf("expected help bar to remain visible in stream view") } } diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go index 7f1908a..99a3d5b 100644 --- a/internal/tui/dashboard/tabs.go +++ b/internal/tui/dashboard/tabs.go @@ -113,14 +113,15 @@ func renderTabBar(active Tab, width int) string { } func renderHelpBar(keys common.KeyMap, width int) string { - parts := make([]string, 0, len(keys.DashboardShortHelp())) - for _, binding := range keys.DashboardShortHelp() { + parts := make([]string, 0, len(keys.DashboardStatusHelp())) + for _, binding := range keys.DashboardStatusHelp() { help := binding.Help() parts = append(parts, help.Key+" "+help.Desc) } - text := strings.Join(parts, " • ") - if width > 0 { - text = truncatePlain(text, width) + line1, line2 := wrapHelpLines(parts, width) + text := line1 + if line2 != "" { + text += "\n" + line2 } if width > 0 && width < 90 { return text @@ -128,6 +129,39 @@ func renderHelpBar(keys common.KeyMap, width int) string { return common.HelpBarStyle.Width(width).Render(text) } +func wrapHelpLines(parts []string, width int) (string, string) { + if len(parts) == 0 { + return "", "" + } + if width <= 0 { + return strings.Join(parts, " • "), "" + } + max := width + lines := []string{"", ""} + line := 0 + for _, part := range parts { + token := part + if lines[line] != "" { + token = " • " + part + } + if utf8.RuneCountInString(lines[line]+token) <= max { + lines[line] += token + continue + } + if line == 0 { + line = 1 + if utf8.RuneCountInString(part) <= max { + lines[line] = part + } + continue + } + break + } + lines[0] = truncatePlain(lines[0], max) + lines[1] = truncatePlain(lines[1], max) + return lines[0], lines[1] +} + func tabLabel(tab Tab, short bool) string { if !short { return tab.String() diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go index bf96864..a457153 100644 --- a/internal/tui/dashboard/tabs_test.go +++ b/internal/tui/dashboard/tabs_test.go @@ -39,10 +39,10 @@ func TestRenderTabBarSmallWidthUsesSingleLine(t *testing.T) { } } -func TestRenderHelpBarSmallWidthUsesSingleLine(t *testing.T) { +func TestRenderHelpBarSmallWidthCanWrapToTwoLines(t *testing.T) { out := renderHelpBar(common.DefaultKeyMap(), 70) lines := strings.Split(out, "\n") - if len(lines) != 1 { - t.Fatalf("expected single-line help bar at width 70, got %d lines", len(lines)) + if len(lines) < 1 || len(lines) > 2 { + t.Fatalf("expected one or two help bar lines at width 70, got %d lines", len(lines)) } } diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index 35232b9..7d0273b 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -7,6 +7,12 @@ type PidSelectedMsg struct { Pid int } +// TidSelectedMsg is emitted when the user selects a TID from the thread table. +type TidSelectedMsg struct { + Pid int + Tid int +} + // StatsTickMsg carries a fresh immutable snapshot from the stats engine. type StatsTickMsg struct { Snap *statsengine.Snapshot diff --git a/internal/tui/msg.go b/internal/tui/msg.go index c69e806..47aacb1 100644 --- a/internal/tui/msg.go +++ b/internal/tui/msg.go @@ -5,6 +5,9 @@ import "ior/internal/tui/messages" // PidSelectedMsg is emitted when the user selects a PID from the process table. type PidSelectedMsg = messages.PidSelectedMsg +// TidSelectedMsg is emitted when the user selects a TID from the thread table. +type TidSelectedMsg = messages.TidSelectedMsg + // StatsTickMsg carries a fresh immutable snapshot from the stats engine. type StatsTickMsg = messages.StatsTickMsg diff --git a/internal/tui/pidpicker/model.go b/internal/tui/pidpicker/model.go index 37af257..73f21ae 100644 --- a/internal/tui/pidpicker/model.go +++ b/internal/tui/pidpicker/model.go @@ -13,6 +13,14 @@ import ( ) const allPIDsLabel = "All PIDs" +const allTIDsLabel = "All TIDs" + +type PickerMode int + +const ( + PickerModePID PickerMode = iota + PickerModeTID +) // KeyMap defines picker-specific key bindings. type KeyMap struct { @@ -53,6 +61,8 @@ type Model struct { processes []ProcessInfo filtered []ProcessInfo selectedIndex int + mode PickerMode + targetPID int width int height int keys KeyMap @@ -61,11 +71,16 @@ type Model struct { // New creates a PID picker model with default shared key bindings. func New() Model { - return NewWithKeys(DefaultKeyMap()) + return NewPIDWithKeys(DefaultKeyMap()) } // NewWithKeys creates a PID picker model with the provided key bindings. func NewWithKeys(keys KeyMap) Model { + return NewPIDWithKeys(keys) +} + +// NewPIDWithKeys creates a PID picker model with the provided key bindings. +func NewPIDWithKeys(keys KeyMap) Model { input := textinput.New() input.Prompt = "Filter: " input.Placeholder = "pid, comm, or cmdline" @@ -74,15 +89,26 @@ func NewWithKeys(keys KeyMap) Model { input.Width = 40 return Model{ - input: input, - keys: keys, - filtered: []ProcessInfo{}, + input: input, + keys: keys, + filtered: []ProcessInfo{}, + mode: PickerModePID, + targetPID: -1, } } +// NewTIDWithKeys creates a TID picker model scoped to one PID. +func NewTIDWithKeys(targetPID int, keys KeyMap) Model { + m := NewPIDWithKeys(keys) + m.mode = PickerModeTID + m.targetPID = targetPID + m.input.Placeholder = "tid, comm, or cmdline" + return m +} + // Init starts the initial process scan. func (m Model) Init() tea.Cmd { - return scanProcessesCmd + return m.scanCmd() } // Update handles key presses and async process-scan responses. @@ -113,7 +139,7 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Esc): return m, tea.Quit case msg.Type == tea.KeyCtrlR: - return m, scanProcessesCmd + return m, m.scanCmd() case key.Matches(msg, m.keys.Enter): return m, m.emitSelection() case msg.Type == tea.KeyUp: @@ -133,7 +159,7 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyRunes && !m.input.Focused() { if key.Matches(msg, m.keys.Refresh) { - return m, scanProcessesCmd + return m, m.scanCmd() } m.input.Focus() } @@ -145,6 +171,18 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m Model) emitSelection() tea.Cmd { + if m.mode == PickerModeTID { + if m.selectedIndex <= 0 { + return func() tea.Msg { return messages.TidSelectedMsg{Pid: 0, Tid: 0} } + } + idx := m.selectedIndex - 1 + if idx < 0 || idx >= len(m.filtered) { + return func() tea.Msg { return messages.TidSelectedMsg{Pid: 0, Tid: 0} } + } + thread := m.filtered[idx] + return func() tea.Msg { return messages.TidSelectedMsg{Pid: thread.ParentPID, Tid: thread.Pid} } + } + if m.selectedIndex <= 0 { return func() tea.Msg { return messages.PidSelectedMsg{Pid: 0} } } @@ -204,7 +242,15 @@ 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(headerStyle.Render("Select PID")) + if m.mode == PickerModeTID { + if m.targetPID > 0 { + b.WriteString(headerStyle.Render(fmt.Sprintf("Select TID for PID %d", m.targetPID))) + } else { + b.WriteString(headerStyle.Render("Select TID")) + } + } else { + b.WriteString(headerStyle.Render("Select PID")) + } b.WriteString("\n") b.WriteString(m.input.View()) b.WriteString("\n\n") @@ -224,7 +270,11 @@ func (m Model) View() string { func (m Model) renderRows() string { lines := make([]string, 0, len(m.filtered)+1) - lines = append(lines, m.renderRow(0, allPIDsLabel)) + allLabel := allPIDsLabel + if m.mode == PickerModeTID { + allLabel = allTIDsLabel + } + lines = append(lines, m.renderRow(0, allLabel)) for i, process := range m.filtered { label := formatProcess(process) lines = append(lines, m.renderRow(i+1, label)) @@ -284,6 +334,28 @@ func scanProcessesCmd() tea.Msg { } } +func (m Model) scanCmd() tea.Cmd { + if m.mode == PickerModeTID { + if m.targetPID <= 0 { + return func() tea.Msg { + processes, err := ScanAllThreads() + return processesLoadedMsg{ + processes: processes, + err: err, + } + } + } + return func() tea.Msg { + processes, err := ScanThreads(m.targetPID) + return processesLoadedMsg{ + processes: processes, + err: err, + } + } + } + return scanProcessesCmd +} + func clamp(v, min, max int) int { if v < min { return min @@ -295,6 +367,12 @@ func clamp(v, min, max int) int { } func formatProcess(process ProcessInfo) string { + if process.ParentPID > 0 && process.ParentPID != process.Pid { + if process.Cmdline == "" { + return fmt.Sprintf("%d (pid:%d) %s", process.Pid, process.ParentPID, process.Comm) + } + return fmt.Sprintf("%d (pid:%d) %s %s", process.Pid, process.ParentPID, process.Comm, process.Cmdline) + } if process.Cmdline == "" { return fmt.Sprintf("%d %s", process.Pid, process.Comm) } diff --git a/internal/tui/pidpicker/model_test.go b/internal/tui/pidpicker/model_test.go index 7347eca..2d76508 100644 --- a/internal/tui/pidpicker/model_test.go +++ b/internal/tui/pidpicker/model_test.go @@ -63,6 +63,44 @@ func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) { } } +func TestEnterEmitsAllTIDsAndSelectedTIDInTIDMode(t *testing.T) { + m := NewTIDWithKeys(42, DefaultKeyMap()) + m.processes = []ProcessInfo{ + {Pid: 7001, ParentPID: 42, Comm: "main"}, + {Pid: 7002, ParentPID: 42, Comm: "worker"}, + } + m.applyFilter() + + modelAny, cmdAny := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _ = modelAny + msgAny := cmdAny() + tidAny, ok := msgAny.(messages.TidSelectedMsg) + if !ok { + t.Fatalf("expected TidSelectedMsg for all-tids selection, got %T", msgAny) + } + if tidAny.Tid != 0 { + t.Fatalf("expected all-tids to emit tid 0, got %d", tidAny.Tid) + } + if tidAny.Pid != 0 { + t.Fatalf("expected all-tids to emit pid 0, got %d", tidAny.Pid) + } + + m.selectedIndex = 2 + modelOne, cmdOne := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _ = modelOne + msgOne := cmdOne() + tidOne, ok := msgOne.(messages.TidSelectedMsg) + if !ok { + t.Fatalf("expected TidSelectedMsg for concrete selection, got %T", msgOne) + } + if tidOne.Tid != 7002 { + t.Fatalf("expected selected tid 7002, got %d", tidOne.Tid) + } + if tidOne.Pid != 42 { + t.Fatalf("expected selected pid 42, got %d", tidOne.Pid) + } +} + func TestEscQuitsAndRefreshTriggersScan(t *testing.T) { m := NewWithKeys(DefaultKeyMap()) diff --git a/internal/tui/pidpicker/proclist.go b/internal/tui/pidpicker/proclist.go index 63306e9..20e580d 100644 --- a/internal/tui/pidpicker/proclist.go +++ b/internal/tui/pidpicker/proclist.go @@ -9,13 +9,15 @@ import ( "sort" "strconv" "strings" + "sync" ) // ProcessInfo is the metadata shown in the PID picker list. type ProcessInfo struct { - Pid int - Comm string - Cmdline string + Pid int + ParentPID int + Comm string + Cmdline string } // ScanProcesses returns process metadata from /proc. @@ -23,6 +25,16 @@ func ScanProcesses() ([]ProcessInfo, error) { return scanProcessesFrom("/proc") } +// ScanThreads returns thread metadata from /proc/<pid>/task for one process. +func ScanThreads(pid int) ([]ProcessInfo, error) { + return scanThreadsFrom("/proc", pid) +} + +// ScanAllThreads returns thread metadata from /proc/*/task. +func ScanAllThreads() ([]ProcessInfo, error) { + return scanAllThreadsFrom("/proc") +} + func scanProcessesFrom(procRoot string) ([]ProcessInfo, error) { entries, err := os.ReadDir(procRoot) if err != nil { @@ -72,9 +84,10 @@ func readProcessInfo(procRoot string, entry fs.DirEntry) (ProcessInfo, bool) { } return ProcessInfo{ - Pid: pid, - Comm: comm, - Cmdline: normalizeCmdline(cmdlineData), + Pid: pid, + ParentPID: pid, + Comm: comm, + Cmdline: normalizeCmdline(cmdlineData), }, true } @@ -112,3 +125,107 @@ func normalizeCmdline(raw []byte) string { } return strings.Join(out, " ") } + +func scanThreadsFrom(procRoot string, pid int) ([]ProcessInfo, error) { + taskRoot := filepath.Join(procRoot, strconv.Itoa(pid), "task") + entries, err := os.ReadDir(taskRoot) + if err != nil { + return nil, fmt.Errorf("read task root %q: %w", taskRoot, err) + } + + cmdlineData, _ := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "cmdline")) + cmdline := normalizeCmdline(cmdlineData) + + threads := make([]ProcessInfo, 0, len(entries)) + for _, entry := range entries { + thread, ok := readThreadInfo(taskRoot, entry, cmdline) + if !ok { + continue + } + threads = append(threads, thread) + } + + sort.Slice(threads, func(i, j int) bool { + return threads[i].Pid < threads[j].Pid + }) + return threads, nil +} + +func readThreadInfo(taskRoot string, entry fs.DirEntry, cmdline string) (ProcessInfo, bool) { + if !entry.IsDir() { + return ProcessInfo{}, false + } + + tid, err := strconv.Atoi(entry.Name()) + if err != nil { + return ProcessInfo{}, false + } + + commPath := filepath.Join(taskRoot, entry.Name(), "comm") + commData, err := os.ReadFile(commPath) + if err != nil { + return ProcessInfo{}, false + } + comm := strings.TrimSpace(string(commData)) + if comm == "" { + return ProcessInfo{}, false + } + + return ProcessInfo{ + Pid: tid, + ParentPID: extractPIDFromPath(taskRoot), + Comm: comm, + Cmdline: cmdline, + }, true +} + +func scanAllThreadsFrom(procRoot string) ([]ProcessInfo, error) { + processes, err := scanProcessesFrom(procRoot) + if err != nil { + return nil, err + } + + threads := make([]ProcessInfo, 0, len(processes)*8) + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, 16) + + for _, p := range processes { + pid := p.Pid + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + perProc, err := scanThreadsFrom(procRoot, pid) + if err != nil { + return + } + mu.Lock() + threads = append(threads, perProc...) + mu.Unlock() + }() + } + wg.Wait() + + sort.Slice(threads, func(i, j int) bool { + if threads[i].Pid == threads[j].Pid { + return threads[i].ParentPID < threads[j].ParentPID + } + return threads[i].Pid < threads[j].Pid + }) + return threads, nil +} + +func extractPIDFromPath(taskRoot string) int { + parts := strings.Split(filepath.Clean(taskRoot), string(os.PathSeparator)) + if len(parts) < 2 { + return -1 + } + pid, err := strconv.Atoi(parts[len(parts)-2]) + if err != nil { + return -1 + } + return pid +} diff --git a/internal/tui/pidpicker/proclist_test.go b/internal/tui/pidpicker/proclist_test.go index 060b3b0..d5f6cfd 100644 --- a/internal/tui/pidpicker/proclist_test.go +++ b/internal/tui/pidpicker/proclist_test.go @@ -25,6 +25,9 @@ func TestScanProcessesFrom(t *testing.T) { if processes[0].Pid != 12 || processes[0].Comm != "bash" || processes[0].Cmdline != "bash -l" { t.Fatalf("unexpected first process: %+v", processes[0]) } + if processes[0].ParentPID != 12 { + t.Fatalf("expected first process parent pid=12, got %d", processes[0].ParentPID) + } if processes[1].Pid != 99 || processes[1].Comm != "sshd" || processes[1].Cmdline != "" { t.Fatalf("unexpected second process: %+v", processes[1]) } @@ -56,6 +59,46 @@ func TestNormalizeCmdline(t *testing.T) { } } +func TestScanThreadsFrom(t *testing.T) { + root := t.TempDir() + procDir := filepath.Join(root, "12") + if err := os.MkdirAll(filepath.Join(procDir, "task", "12"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.MkdirAll(filepath.Join(procDir, "task", "120"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(procDir, "cmdline"), []byte("bash\x00-l\x00"), 0o644); err != nil { + t.Fatalf("write cmdline: %v", err) + } + if err := os.WriteFile(filepath.Join(procDir, "task", "12", "comm"), []byte("bash-main\n"), 0o644); err != nil { + t.Fatalf("write comm: %v", err) + } + if err := os.WriteFile(filepath.Join(procDir, "task", "120", "comm"), []byte("bash-worker\n"), 0o644); err != nil { + t.Fatalf("write comm: %v", err) + } + + threads, err := scanThreadsFrom(root, 12) + if err != nil { + t.Fatalf("scanThreadsFrom returned error: %v", err) + } + if len(threads) != 2 { + t.Fatalf("expected 2 threads, got %d", len(threads)) + } + if threads[0].Pid != 12 || threads[0].Comm != "bash-main" || threads[0].Cmdline != "bash -l" { + t.Fatalf("unexpected first thread: %+v", threads[0]) + } + if threads[0].ParentPID != 12 { + t.Fatalf("unexpected first thread parent pid: %d", threads[0].ParentPID) + } + if threads[1].Pid != 120 || threads[1].Comm != "bash-worker" || threads[1].Cmdline != "bash -l" { + t.Fatalf("unexpected second thread: %+v", threads[1]) + } + if threads[1].ParentPID != 12 { + t.Fatalf("unexpected second thread parent pid: %d", threads[1].ParentPID) + } +} + func mkProc(t *testing.T, root, pid, stat, cmdline string) { t.Helper() dir := filepath.Join(root, pid) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 032a27a..d585a0b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -150,7 +150,6 @@ type Model struct { attaching bool spin spinner.Model lastErr error - showHelp bool startTrace TraceStarter traceStop context.CancelFunc @@ -217,17 +216,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.stopTrace() return m, tea.Quit } - if !m.exporter.Visible() && !m.probeModal.Visible() && key.Matches(msg, m.keys.Help) { - m.showHelp = !m.showHelp - return m, nil - } - if !m.exporter.Visible() && !m.probeModal.Visible() && m.showHelp && key.Matches(msg, m.keys.Esc) { - m.showHelp = false - return m, nil - } - if !m.exporter.Visible() && !m.probeModal.Visible() && m.showHelp { - return m, nil - } if flags.Get().TUIExportEnable && m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() { m.exporter = m.exporter.Open() return m, nil @@ -239,6 +227,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectPID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() { return m.reselectPID() } + if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectTID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() { + return m.reselectTID() + } case tuiexport.RequestMsg: return m, runExportCmd(msg.Option, m.dashboard.LatestSnapshot()) case tuiexport.CompletedMsg: @@ -260,6 +251,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case PidSelectedMsg: return m.handlePidSelected(msg) + case TidSelectedMsg: + return m.handleTidSelected(msg) case TracingStartedMsg: m.attaching = false m.dashboard.SetStreamSource(getEventStreamSource()) @@ -322,6 +315,22 @@ func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) { pid := selectedPIDFilter(msg.Pid) m.stopTrace() flags.SetPidFilter(pid) + flags.SetTidFilter(-1) + m.screen = ScreenDashboard + m.attaching = true + m.lastErr = nil + return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) +} + +func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) { + tid := selectedPIDFilter(msg.Tid) + pid := flags.Get().PidFilter + if msg.Pid > 0 { + pid = msg.Pid + } + m.stopTrace() + flags.SetPidFilter(pid) + flags.SetTidFilter(tid) m.screen = ScreenDashboard m.attaching = true m.lastErr = nil @@ -333,7 +342,6 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) { m.screen = ScreenPIDPicker m.attaching = false m.lastErr = nil - m.showHelp = false m.exporter = tuiexport.NewModel() m.probeModal = probes.NewModel(getProbeManager()) m.pidPicker = pidpicker.New() @@ -348,6 +356,27 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) { return m, tea.Batch(sizeCmd, m.pidPicker.Init()) } +func (m Model) reselectTID() (tea.Model, tea.Cmd) { + pid := flags.Get().PidFilter + + m.stopTrace() + m.screen = ScreenPIDPicker + m.attaching = false + m.lastErr = nil + m.exporter = tuiexport.NewModel() + m.probeModal = probes.NewModel(getProbeManager()) + m.pidPicker = pidpicker.NewTIDWithKeys(pid, pidpicker.DefaultKeyMap()) + + var sizeCmd tea.Cmd + if m.width > 0 && m.height > 0 { + msg := tea.WindowSizeMsg{Width: m.width, Height: m.height} + next, cmd := m.pidPicker.Update(msg) + m.pidPicker = next.(pidpicker.Model) + sizeCmd = cmd + } + return m, tea.Batch(sizeCmd, m.pidPicker.Init()) +} + func selectedPIDFilter(pid int) int { if pid <= 0 { return -1 @@ -407,9 +436,6 @@ func (m Model) View() string { if m.exporter.Visible() { return placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base) } - if m.showHelp { - return placeToViewport(width, height, renderHelpOverlay(width, height, [][]key.Binding{m.keys.PickerShortHelp()})) - } return placeToViewport(width, height, base) case ScreenDashboard: base := m.dashboard.View() @@ -419,9 +445,6 @@ func (m Model) View() string { if m.exporter.Visible() { return placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base) } - if m.showHelp { - return placeToViewport(width, height, renderHelpOverlay(width, height, m.keys.DashboardFullHelp())) - } return placeToViewport(width, height, base) default: return "" diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 761ac0f..7fd909a 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -31,6 +31,7 @@ func (f fakeProbeManager) ActiveCount() (int, int) { return len(f.stat func TestPidSelectedTransitionsToDashboardAndSetsPIDFilter(t *testing.T) { flags.SetPidFilter(-1) + flags.SetTidFilter(99) m := NewModel(-1, func(context.Context) error { return nil }) next, cmd := m.Update(PidSelectedMsg{Pid: 42}) @@ -48,6 +49,9 @@ func TestPidSelectedTransitionsToDashboardAndSetsPIDFilter(t *testing.T) { if got := flags.Get().PidFilter; got != 42 { t.Fatalf("expected pid filter 42, got %d", got) } + if got := flags.Get().TidFilter; got != -1 { + t.Fatalf("expected tid filter reset to -1, got %d", got) + } } func TestInitialPIDSkipsPickerAndStartsTracing(t *testing.T) { @@ -290,7 +294,7 @@ func TestSelectPIDKeyReturnsToFreshPickerAndStopsTrace(t *testing.T) { stopped := false m.traceStop = func() { stopped = true } - next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) updated := next.(Model) if !stopped { @@ -310,6 +314,101 @@ func TestSelectPIDKeyReturnsToFreshPickerAndStopsTrace(t *testing.T) { } } +func TestSelectTIDKeyReturnsToPickerWhenPIDFilterIsAll(t *testing.T) { + flags.SetPidFilter(-1) + flags.SetTidFilter(-1) + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + stopped := false + m.traceStop = func() { stopped = true } + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}}) + updated := next.(Model) + if !stopped { + t.Fatalf("expected tracing stop before tid reselect") + } + if updated.screen != ScreenPIDPicker { + t.Fatalf("expected picker screen, got %v", updated.screen) + } + if cmd == nil { + t.Fatalf("expected picker init command") + } +} + +func TestSelectTIDKeyReturnsToPickerWhenSinglePIDSelected(t *testing.T) { + flags.SetPidFilter(1234) + flags.SetTidFilter(-1) + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + stopped := false + m.traceStop = func() { stopped = true } + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}}) + updated := next.(Model) + if !stopped { + t.Fatalf("expected tracing stop before tid reselect") + } + if updated.screen != ScreenPIDPicker { + t.Fatalf("expected picker screen, got %v", updated.screen) + } + if cmd == nil { + t.Fatalf("expected picker init command") + } +} + +func TestTidSelectedTransitionsToDashboardAndSetsTIDFilter(t *testing.T) { + flags.SetPidFilter(2222) + flags.SetTidFilter(-1) + m := NewModel(-1, func(context.Context) error { return nil }) + + next, cmd := m.Update(TidSelectedMsg{Pid: 0, Tid: 3333}) + 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().TidFilter; got != 3333 { + t.Fatalf("expected tid filter 3333, got %d", got) + } + if got := flags.Get().PidFilter; got != 2222 { + t.Fatalf("expected pid filter to remain 2222, got %d", got) + } +} + +func TestTidSelectedFromAllPIDModeSetsOwningPID(t *testing.T) { + flags.SetPidFilter(-1) + flags.SetTidFilter(-1) + m := NewModel(-1, func(context.Context) error { return nil }) + + next, cmd := m.Update(TidSelectedMsg{Pid: 4444, Tid: 5555}) + 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 got := flags.Get().PidFilter; got != 4444 { + t.Fatalf("expected pid filter switched to owning pid 4444, got %d", got) + } + if got := flags.Get().TidFilter; got != 5555 { + t.Fatalf("expected tid filter 5555, got %d", got) + } +} + func TestExportKeyIgnoredWhenExportDisabled(t *testing.T) { flags.SetTUIExportEnable(false) t.Cleanup(func() { flags.SetTUIExportEnable(true) }) @@ -381,76 +480,39 @@ func TestRunExportCmdCSVWritesFile(t *testing.T) { } } -func TestHelpKeyTogglesOverlay(t *testing.T) { +func TestHelpKeyDoesNotToggleOverlay(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) - if m.showHelp { - t.Fatalf("expected help hidden by default") - } - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) updated := next.(Model) - if !updated.showHelp { - t.Fatalf("expected help to be shown after ?") - } - - next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) - updated = next.(Model) - if updated.showHelp { - t.Fatalf("expected help to toggle off after second ?") + if updated.screen != ScreenPIDPicker { + t.Fatalf("expected ? to have no effect, got screen %v", updated.screen) } } -func TestViewShowsHelpOverlay(t *testing.T) { +func TestViewShowsDashboardWithoutHelpOverlay(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard - m.showHelp = true m.width = 100 m.height = 30 out := m.View() - if !strings.Contains(out, "Help") { - t.Fatalf("expected help title in overlay") - } if !strings.Contains(out, "tab next tab") { - t.Fatalf("expected keybinding text in overlay") - } - if strings.Contains(out, "Overview: waiting for stats") { - t.Fatalf("expected help overlay to render without stacking dashboard content") + t.Fatalf("expected status/help bar keybinding text in dashboard") } } -func TestHelpOverlayBlocksUnderlyingActions(t *testing.T) { +func TestQuestionMarkDoesNotBlockUnderlyingActions(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard - m.showHelp = true next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) updated := next.(Model) - if updated.exporter.Visible() { - t.Fatalf("expected export modal to stay closed while help overlay is active") - } -} - -func TestHelpOverlayUsesPickerBindingsOnPickerScreen(t *testing.T) { - m := NewModel(-1, func(context.Context) error { return nil }) - m.screen = ScreenPIDPicker - m.showHelp = true - m.width = 100 - m.height = 30 - - out := m.View() - if !strings.Contains(out, "enter select") || !strings.Contains(out, "r refresh") { - t.Fatalf("expected picker shortcuts in help overlay") - } - if strings.Contains(out, "e export") { - t.Fatalf("did not expect dashboard-only shortcut in picker help overlay") - } - if strings.Contains(out, "Select PID to trace") { - t.Fatalf("expected help overlay to render without stacking picker content") + if !updated.exporter.Visible() { + t.Fatalf("expected export modal to open; ? overlay is removed") } } -func TestHelpToggleDoesNotBreakExportModalInput(t *testing.T) { +func TestQuestionMarkDoesNotBreakExportModalInput(t *testing.T) { flags.SetTUIExportEnable(true) t.Cleanup(func() { flags.SetTUIExportEnable(true) }) @@ -465,8 +527,8 @@ func TestHelpToggleDoesNotBreakExportModalInput(t *testing.T) { next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) updated = next.(Model) - if updated.showHelp { - t.Fatalf("did not expect hidden help flag while export modal is open") + if !updated.exporter.Visible() { + t.Fatalf("expected export modal to remain open after ? key") } next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) @@ -476,19 +538,18 @@ func TestHelpToggleDoesNotBreakExportModalInput(t *testing.T) { } } -func TestHelpOverlayHidesExportBindingWhenExportDisabled(t *testing.T) { +func TestStatusBarHidesExportBindingWhenExportDisabled(t *testing.T) { flags.SetTUIExportEnable(false) t.Cleanup(func() { flags.SetTUIExportEnable(true) }) m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard - m.showHelp = true m.width = 100 m.height = 30 out := m.View() if strings.Contains(out, "e export") { - t.Fatalf("did not expect export shortcut in help overlay when export is disabled") + t.Fatalf("did not expect export shortcut in status bar when export is disabled") } } |
