summaryrefslogtreecommitdiff
path: root/internal/tui/pidpicker
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-26 22:59:16 +0200
committerPaul Buetow <paul@buetow.org>2026-02-26 22:59:16 +0200
commitdc7478d7dadf544787a9718608f11312bd2ea944 (patch)
treedc445798ab132e08d8885672fcca0a37facd25ea /internal/tui/pidpicker
parent39a11ed5997a3829751dfbe4b666d3568d466276 (diff)
tui: revamp status keys and add pid/tid reselection flow
Diffstat (limited to 'internal/tui/pidpicker')
-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
4 files changed, 291 insertions, 15 deletions
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)