summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/eventloop.go2
-rw-r--r--internal/flags/flags.go10
-rw-r--r--internal/tui/common/keys.go29
-rw-r--r--internal/tui/common/keys_test.go39
-rw-r--r--internal/tui/dashboard/model.go2
-rw-r--r--internal/tui/dashboard/model_test.go7
-rw-r--r--internal/tui/dashboard/tabs.go44
-rw-r--r--internal/tui/dashboard/tabs_test.go6
-rw-r--r--internal/tui/messages/messages.go6
-rw-r--r--internal/tui/msg.go3
-rw-r--r--internal/tui/pidpicker/model.go96
-rw-r--r--internal/tui/pidpicker/model_test.go38
-rw-r--r--internal/tui/pidpicker/proclist.go129
-rw-r--r--internal/tui/pidpicker/proclist_test.go43
-rw-r--r--internal/tui/tui.go61
-rw-r--r--internal/tui/tui_test.go165
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")
}
}