summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-23 23:39:22 +0200
committerPaul Buetow <paul@buetow.org>2026-02-23 23:39:22 +0200
commit570b7b5d9283b9e443e7da25661e9f2098cc2305 (patch)
tree22386c756c6d9a588e97af103574f711c98f5bdc /internal
parente68102bc79ac26f93959c3d5676bcdca86c94623 (diff)
tui: add pid picker model and /proc scanner
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/pidpicker/model.go273
-rw-r--r--internal/tui/pidpicker/model_test.go116
-rw-r--r--internal/tui/pidpicker/proclist.go114
-rw-r--r--internal/tui/pidpicker/proclist_test.go71
4 files changed, 574 insertions, 0 deletions
diff --git a/internal/tui/pidpicker/model.go b/internal/tui/pidpicker/model.go
new file mode 100644
index 0000000..4e63429
--- /dev/null
+++ b/internal/tui/pidpicker/model.go
@@ -0,0 +1,273 @@
+package pidpicker
+
+import (
+ "fmt"
+ "ior/internal/tui"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+const allPIDsLabel = "All PIDs"
+
+type processesLoadedMsg struct {
+ processes []ProcessInfo
+ err error
+}
+
+// Model is the Bubble Tea model for the PID picker screen.
+type Model struct {
+ input textinput.Model
+ processes []ProcessInfo
+ filtered []ProcessInfo
+ selectedIndex int
+ width int
+ height int
+ keys tui.KeyMap
+ lastErr error
+}
+
+// New creates a PID picker model with default shared key bindings.
+func New() Model {
+ return NewWithKeys(tui.Keys)
+}
+
+// NewWithKeys creates a PID picker model with the provided key bindings.
+func NewWithKeys(keys tui.KeyMap) Model {
+ input := textinput.New()
+ input.Prompt = "Filter: "
+ input.Placeholder = "pid, comm, or cmdline"
+ input.Focus()
+ input.CharLimit = 0
+ input.Width = 40
+
+ return Model{
+ input: input,
+ keys: keys,
+ filtered: []ProcessInfo{},
+ }
+}
+
+// Init starts the initial process scan.
+func (m Model) Init() tea.Cmd {
+ return scanProcessesCmd
+}
+
+// Update handles key presses and async process-scan responses.
+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
+ m.input.Width = clamp(msg.Width-16, 10, 100)
+ return m, nil
+ case processesLoadedMsg:
+ m.processes = msg.processes
+ m.lastErr = msg.err
+ m.applyFilter()
+ return m, nil
+ case tea.KeyMsg:
+ return m.updateKey(msg)
+ }
+
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ m.applyFilter()
+ return m, cmd
+}
+
+func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch {
+ case key.Matches(msg, m.keys.Esc):
+ return m, tea.Quit
+ case msg.Type == tea.KeyCtrlR:
+ return m, scanProcessesCmd
+ case key.Matches(msg, m.keys.Enter):
+ return m, m.emitSelection()
+ case msg.Type == tea.KeyUp:
+ if m.selectedIndex > 0 {
+ m.selectedIndex--
+ }
+ m.input.Blur()
+ return m, nil
+ case msg.Type == tea.KeyDown:
+ maxIndex := len(m.filtered)
+ if m.selectedIndex < maxIndex {
+ m.selectedIndex++
+ }
+ m.input.Blur()
+ return m, nil
+ }
+
+ if msg.Type == tea.KeyRunes && !m.input.Focused() {
+ if key.Matches(msg, m.keys.Refresh) {
+ return m, scanProcessesCmd
+ }
+ m.input.Focus()
+ }
+
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ m.applyFilter()
+ return m, cmd
+}
+
+func (m Model) emitSelection() tea.Cmd {
+ if m.selectedIndex <= 0 {
+ return func() tea.Msg { return tui.PidSelectedMsg{Pid: 0} }
+ }
+
+ idx := m.selectedIndex - 1
+ if idx < 0 || idx >= len(m.filtered) {
+ return func() tea.Msg { return tui.PidSelectedMsg{Pid: 0} }
+ }
+
+ pid := m.filtered[idx].Pid
+ return func() tea.Msg { return tui.PidSelectedMsg{Pid: pid} }
+}
+
+func (m *Model) applyFilter() {
+ query := strings.TrimSpace(strings.ToLower(m.input.Value()))
+ if query == "" {
+ m.filtered = cloneProcesses(m.processes)
+ if m.selectedIndex > len(m.filtered) {
+ m.selectedIndex = len(m.filtered)
+ }
+ return
+ }
+
+ filtered := make([]ProcessInfo, 0, len(m.processes))
+ for _, process := range m.processes {
+ if matchesQuery(process, query) {
+ filtered = append(filtered, process)
+ }
+ }
+
+ m.filtered = filtered
+ if m.selectedIndex > len(m.filtered) {
+ m.selectedIndex = len(m.filtered)
+ }
+}
+
+func matchesQuery(process ProcessInfo, query string) bool {
+ pidStr := fmt.Sprintf("%d", process.Pid)
+ if strings.Contains(strings.ToLower(pidStr), query) {
+ return true
+ }
+ if strings.Contains(strings.ToLower(process.Comm), query) {
+ return true
+ }
+ return strings.Contains(strings.ToLower(process.Cmdline), query)
+}
+
+func cloneProcesses(in []ProcessInfo) []ProcessInfo {
+ if len(in) == 0 {
+ return []ProcessInfo{}
+ }
+ out := make([]ProcessInfo, len(in))
+ copy(out, in)
+ return out
+}
+
+// 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("\n")
+ b.WriteString(m.input.View())
+ b.WriteString("\n\n")
+
+ rows := m.renderRows()
+ b.WriteString(rows)
+
+ if m.lastErr != nil {
+ b.WriteString("\n")
+ b.WriteString(tui.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())
+}
+
+func (m Model) renderRows() string {
+ lines := make([]string, 0, len(m.filtered)+1)
+ lines = append(lines, m.renderRow(0, allPIDsLabel))
+ for i, process := range m.filtered {
+ label := formatProcess(process)
+ lines = append(lines, m.renderRow(i+1, label))
+ }
+
+ maxRows := m.visibleRows()
+ if maxRows > 0 && len(lines) > maxRows {
+ start := m.selectedIndex - (maxRows / 2)
+ if start < 0 {
+ start = 0
+ }
+ limit := len(lines) - maxRows
+ if start > limit {
+ start = limit
+ }
+ lines = lines[start : start+maxRows]
+ }
+ return strings.Join(lines, "\n")
+}
+
+func (m Model) renderRow(index int, label string) string {
+ prefix := " "
+ style := lipgloss.NewStyle()
+ if index == m.selectedIndex {
+ prefix = "> "
+ style = tui.HighlightStyle
+ }
+ return style.Render(prefix + label)
+}
+
+func (m Model) visibleRows() int {
+ if m.height <= 0 {
+ return 0
+ }
+ const reservedLines = 6
+ rows := m.height - reservedLines
+ if rows < 1 {
+ return 1
+ }
+ return rows
+}
+
+func renderHelp(bindings []key.Binding) string {
+ parts := make([]string, 0, len(bindings))
+ for _, binding := range bindings {
+ help := binding.Help()
+ parts = append(parts, fmt.Sprintf("%s %s", help.Key, help.Desc))
+ }
+ return strings.Join(parts, " • ")
+}
+
+func scanProcessesCmd() tea.Msg {
+ processes, err := ScanProcesses()
+ return processesLoadedMsg{
+ processes: processes,
+ err: err,
+ }
+}
+
+func clamp(v, min, max int) int {
+ if v < min {
+ return min
+ }
+ if v > max {
+ return max
+ }
+ return v
+}
+
+func formatProcess(process ProcessInfo) string {
+ if process.Cmdline == "" {
+ return fmt.Sprintf("%d %s", process.Pid, process.Comm)
+ }
+ return fmt.Sprintf("%d %s %s", process.Pid, process.Comm, process.Cmdline)
+}
diff --git a/internal/tui/pidpicker/model_test.go b/internal/tui/pidpicker/model_test.go
new file mode 100644
index 0000000..c8e59af
--- /dev/null
+++ b/internal/tui/pidpicker/model_test.go
@@ -0,0 +1,116 @@
+package pidpicker
+
+import (
+ "ior/internal/tui"
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestApplyFilterByPIDCommAndCmdline(t *testing.T) {
+ m := NewWithKeys(tui.DefaultKeyMap())
+ m.processes = []ProcessInfo{
+ {Pid: 100, Comm: "bash", Cmdline: "bash -l"},
+ {Pid: 200, Comm: "sshd", Cmdline: "/usr/sbin/sshd -D"},
+ }
+
+ m.input.SetValue("200")
+ m.applyFilter()
+ if len(m.filtered) != 1 || m.filtered[0].Pid != 200 {
+ t.Fatalf("expected pid filter to keep only 200, got %+v", m.filtered)
+ }
+
+ m.input.SetValue("BASH")
+ m.applyFilter()
+ if len(m.filtered) != 1 || m.filtered[0].Pid != 100 {
+ t.Fatalf("expected comm filter to keep only 100, got %+v", m.filtered)
+ }
+
+ m.input.SetValue("/usr/sbin")
+ m.applyFilter()
+ if len(m.filtered) != 1 || m.filtered[0].Pid != 200 {
+ t.Fatalf("expected cmdline filter to keep only 200, got %+v", m.filtered)
+ }
+}
+
+func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) {
+ m := NewWithKeys(tui.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)
+ if !ok {
+ t.Fatalf("expected PidSelectedMsg for all-pids selection, got %T", msgAny)
+ }
+ if pidAny.Pid != 0 {
+ t.Fatalf("expected all-pids to emit pid 0, got %d", pidAny.Pid)
+ }
+
+ m.selectedIndex = 2
+ modelOne, cmdOne := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ _ = modelOne
+ msgOne := cmdOne()
+ pidOne, ok := msgOne.(tui.PidSelectedMsg)
+ if !ok {
+ t.Fatalf("expected PidSelectedMsg for concrete selection, got %T", msgOne)
+ }
+ if pidOne.Pid != 9 {
+ t.Fatalf("expected selected pid 9, got %d", pidOne.Pid)
+ }
+}
+
+func TestEscQuitsAndRefreshTriggersScan(t *testing.T) {
+ m := NewWithKeys(tui.DefaultKeyMap())
+
+ _, escCmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if escCmd == nil {
+ t.Fatalf("expected esc to return quit cmd")
+ }
+ if msg := escCmd(); msg != (tea.QuitMsg{}) {
+ t.Fatalf("expected quit msg from esc, got %T", msg)
+ }
+
+ _, refreshCmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
+ if refreshCmd == nil {
+ t.Fatalf("expected refresh cmd")
+ }
+ if _, ok := refreshCmd().(processesLoadedMsg); !ok {
+ t.Fatalf("expected refresh to emit processesLoadedMsg")
+ }
+}
+
+func TestRuneRDoesNotTriggerRefreshWhileFilterFocused(t *testing.T) {
+ m := NewWithKeys(tui.DefaultKeyMap())
+
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
+ if cmd == nil {
+ t.Fatalf("expected textinput update cmd")
+ }
+
+ updated := next.(Model)
+ if got := updated.input.Value(); got != "r" {
+ t.Fatalf("expected filter input to contain typed r, got %q", got)
+ }
+}
+
+func TestRenderRowsKeepsSelectionVisible(t *testing.T) {
+ m := NewWithKeys(tui.DefaultKeyMap())
+ m.height = 8 // visible rows == 2
+ m.processes = []ProcessInfo{
+ {Pid: 1, Comm: "p1"},
+ {Pid: 2, Comm: "p2"},
+ {Pid: 3, Comm: "p3"},
+ {Pid: 4, Comm: "p4"},
+ }
+ m.applyFilter()
+ m.selectedIndex = 4
+
+ rows := m.renderRows()
+ if !strings.Contains(rows, "> 4 p4") {
+ t.Fatalf("expected selected row to remain visible, got:\n%s", rows)
+ }
+}
diff --git a/internal/tui/pidpicker/proclist.go b/internal/tui/pidpicker/proclist.go
new file mode 100644
index 0000000..63306e9
--- /dev/null
+++ b/internal/tui/pidpicker/proclist.go
@@ -0,0 +1,114 @@
+package pidpicker
+
+import (
+ "bytes"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// ProcessInfo is the metadata shown in the PID picker list.
+type ProcessInfo struct {
+ Pid int
+ Comm string
+ Cmdline string
+}
+
+// ScanProcesses returns process metadata from /proc.
+func ScanProcesses() ([]ProcessInfo, error) {
+ return scanProcessesFrom("/proc")
+}
+
+func scanProcessesFrom(procRoot string) ([]ProcessInfo, error) {
+ entries, err := os.ReadDir(procRoot)
+ if err != nil {
+ return nil, fmt.Errorf("read proc root %q: %w", procRoot, err)
+ }
+
+ processes := make([]ProcessInfo, 0, len(entries))
+ for _, entry := range entries {
+ process, ok := readProcessInfo(procRoot, entry)
+ if !ok {
+ continue
+ }
+ processes = append(processes, process)
+ }
+
+ sort.Slice(processes, func(i, j int) bool {
+ return processes[i].Pid < processes[j].Pid
+ })
+ return processes, nil
+}
+
+func readProcessInfo(procRoot string, entry fs.DirEntry) (ProcessInfo, bool) {
+ if !entry.IsDir() {
+ return ProcessInfo{}, false
+ }
+
+ pid, err := strconv.Atoi(entry.Name())
+ if err != nil {
+ return ProcessInfo{}, false
+ }
+
+ statPath := filepath.Join(procRoot, entry.Name(), "stat")
+ statData, err := os.ReadFile(statPath)
+ if err != nil {
+ return ProcessInfo{}, false
+ }
+
+ comm, err := parseCommFromStat(string(statData))
+ if err != nil {
+ return ProcessInfo{}, false
+ }
+
+ cmdlinePath := filepath.Join(procRoot, entry.Name(), "cmdline")
+ cmdlineData, err := os.ReadFile(cmdlinePath)
+ if err != nil {
+ cmdlineData = nil
+ }
+
+ return ProcessInfo{
+ Pid: pid,
+ Comm: comm,
+ Cmdline: normalizeCmdline(cmdlineData),
+ }, true
+}
+
+func parseCommFromStat(statLine string) (string, error) {
+ open := strings.IndexByte(statLine, '(')
+ close := strings.LastIndexByte(statLine, ')')
+ if open < 0 || close < 0 || close <= open+1 {
+ return "", fmt.Errorf("invalid stat line")
+ }
+
+ comm := statLine[open+1 : close]
+ if strings.TrimSpace(comm) == "" {
+ return "", fmt.Errorf("empty comm in stat line")
+ }
+ return comm, nil
+}
+
+func normalizeCmdline(raw []byte) string {
+ if len(raw) == 0 {
+ return ""
+ }
+
+ trimmed := bytes.TrimRight(raw, "\x00")
+ if len(trimmed) == 0 {
+ return ""
+ }
+
+ parts := bytes.Split(trimmed, []byte{0})
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if len(part) == 0 {
+ continue
+ }
+ out = append(out, string(part))
+ }
+ return strings.Join(out, " ")
+}
diff --git a/internal/tui/pidpicker/proclist_test.go b/internal/tui/pidpicker/proclist_test.go
new file mode 100644
index 0000000..060b3b0
--- /dev/null
+++ b/internal/tui/pidpicker/proclist_test.go
@@ -0,0 +1,71 @@
+package pidpicker
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestScanProcessesFrom(t *testing.T) {
+ root := t.TempDir()
+
+ mkProc(t, root, "12", "12 (bash) S 1 1 1 0", "bash\x00-l\x00")
+ mkProc(t, root, "99", "99 (sshd) S 1 1 1 0", "")
+ mkProc(t, root, "bad", "bad data", "ignored")
+
+ processes, err := scanProcessesFrom(root)
+ if err != nil {
+ t.Fatalf("scanProcessesFrom returned error: %v", err)
+ }
+
+ if len(processes) != 2 {
+ t.Fatalf("expected 2 valid processes, got %d", len(processes))
+ }
+
+ if processes[0].Pid != 12 || processes[0].Comm != "bash" || processes[0].Cmdline != "bash -l" {
+ t.Fatalf("unexpected first process: %+v", processes[0])
+ }
+ if processes[1].Pid != 99 || processes[1].Comm != "sshd" || processes[1].Cmdline != "" {
+ t.Fatalf("unexpected second process: %+v", processes[1])
+ }
+}
+
+func TestParseCommFromStatInvalid(t *testing.T) {
+ cases := []string{
+ "",
+ "100 no-parens",
+ "100 () S 1 1 1 0",
+ "100 (unterminated S 1 1 1 0",
+ }
+
+ for _, tc := range cases {
+ if _, err := parseCommFromStat(tc); err == nil {
+ t.Fatalf("expected parseCommFromStat to fail for %q", tc)
+ }
+ }
+}
+
+func TestNormalizeCmdline(t *testing.T) {
+ got := normalizeCmdline([]byte("python\x00main.py\x00"))
+ if got != "python main.py" {
+ t.Fatalf("unexpected normalized cmdline: %q", got)
+ }
+
+ if normalizeCmdline(nil) != "" {
+ t.Fatalf("expected empty cmdline for nil bytes")
+ }
+}
+
+func mkProc(t *testing.T, root, pid, stat, cmdline string) {
+ t.Helper()
+ dir := filepath.Join(root, pid)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("mkdir %s: %v", dir, err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644); err != nil {
+ t.Fatalf("write stat: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644); err != nil {
+ t.Fatalf("write cmdline: %v", err)
+ }
+}