theniceboy/agent-tracker/cmd/agent/activity_monitor_bubbletea.go
2026-03-27 21:04:47 -07:00

1012 lines
29 KiB
Go

package main
import (
"fmt"
"os"
"os/exec"
"sort"
"strings"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type activityMonitorBT struct {
windowID string
embedded bool
sortKey activitySortKey
sortDescending bool
selectedPID int
selectedRow int
rowOffset int
lockSelection bool
showAllProcesses bool
processes map[int]*activityProcess
rows []activityRow
memoryByPID map[int]activityMemory
networkByPID map[int]activityNetwork
portsByPID map[int][]string
tmuxByPanePID map[int]*activityTmuxLocation
lastProcessLoad time.Time
lastMemoryLoad time.Time
lastNetworkLoad time.Time
lastPortLoad time.Time
lastTmuxLoad time.Time
refreshedAt time.Time
status string
statusUntil time.Time
confirmKillPID int
copyOptions []activityCopyOption
copyOptionIndex int
width int
height int
refreshInFlight bool
pendingRefresh bool
pendingForce bool
showAltHints bool
requestBack bool
requestClose bool
styles activityStyles
}
type activityStyles struct {
title lipgloss.Style
meta lipgloss.Style
header lipgloss.Style
headerActive lipgloss.Style
row lipgloss.Style
rowSelected lipgloss.Style
cell lipgloss.Style
cellSelected lipgloss.Style
detailTitle lipgloss.Style
detailLabel lipgloss.Style
detailValue lipgloss.Style
muted lipgloss.Style
footer lipgloss.Style
status lipgloss.Style
statusBad lipgloss.Style
divider lipgloss.Style
shortcutKey lipgloss.Style
shortcutText lipgloss.Style
modal lipgloss.Style
modalTitle lipgloss.Style
modalBody lipgloss.Style
modalHint lipgloss.Style
}
type activityTickMsg struct{}
type activityRefreshMsg struct {
snapshot *activitySnapshot
err error
}
type activityCopyOption struct {
Label string
Value string
}
var activityClipboardWriter = writeActivityClipboard
func newActivityStyles() activityStyles {
accent := lipgloss.Color("223")
cyan := lipgloss.Color("117")
selected := lipgloss.Color("238")
text := lipgloss.Color("252")
muted := lipgloss.Color("245")
bright := lipgloss.Color("230")
warning := lipgloss.Color("203")
success := lipgloss.Color("150")
return activityStyles{
title: lipgloss.NewStyle().Bold(true).Foreground(bright),
meta: lipgloss.NewStyle().Foreground(muted),
header: lipgloss.NewStyle().Bold(true).Foreground(cyan),
headerActive: lipgloss.NewStyle().Bold(true).Foreground(accent).Background(selected),
row: lipgloss.NewStyle().Padding(0, 1),
rowSelected: lipgloss.NewStyle().Background(selected).Padding(0, 1),
cell: lipgloss.NewStyle().Foreground(text),
cellSelected: lipgloss.NewStyle().Foreground(bright).Background(selected),
detailTitle: lipgloss.NewStyle().Bold(true).Foreground(accent),
detailLabel: lipgloss.NewStyle().Foreground(cyan),
detailValue: lipgloss.NewStyle().Foreground(text),
muted: lipgloss.NewStyle().Foreground(muted),
footer: lipgloss.NewStyle().Foreground(lipgloss.Color("216")),
status: lipgloss.NewStyle().Foreground(success),
statusBad: lipgloss.NewStyle().Foreground(warning),
divider: lipgloss.NewStyle().Foreground(lipgloss.Color("239")),
shortcutKey: lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(accent).Padding(0, 1).Bold(true),
shortcutText: lipgloss.NewStyle().Foreground(muted),
modal: lipgloss.NewStyle().Border(paletteModalBorder).BorderForeground(accent).Padding(1, 2).Background(lipgloss.Color("235")),
modalTitle: lipgloss.NewStyle().Bold(true).Foreground(warning),
modalBody: lipgloss.NewStyle().Foreground(text),
modalHint: lipgloss.NewStyle().Foreground(muted),
}
}
func newActivityMonitorModel(windowID string, embedded bool) *activityMonitorBT {
model := &activityMonitorBT{
windowID: strings.TrimSpace(windowID),
embedded: embedded,
sortKey: activitySortCPU,
sortDescending: true,
showAllProcesses: true,
processes: map[int]*activityProcess{},
memoryByPID: map[int]activityMemory{},
networkByPID: map[int]activityNetwork{},
portsByPID: map[int][]string{},
tmuxByPanePID: map[int]*activityTmuxLocation{},
styles: newActivityStyles(),
}
model.setStatus("Loading...", 0)
return model
}
func runBubbleTeaActivityMonitor(windowID string) error {
model := newActivityMonitorModel(windowID, false)
_, err := tea.NewProgram(model).Run()
return err
}
func (m *activityMonitorBT) Init() tea.Cmd {
return tea.Batch(
activityRequestRefreshBT(true, true, m),
activityTickCmd(),
)
}
func (m *activityMonitorBT) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case activityTickMsg:
return m, tea.Batch(
activityTickCmd(),
activityRequestRefreshBT(false, false, m),
)
case activityRefreshMsg:
m.refreshInFlight = false
if msg.err != nil {
m.setStatus(msg.err.Error(), 5*time.Second)
} else if msg.snapshot != nil {
m.applySnapshot(msg.snapshot)
if strings.TrimSpace(m.status) == "Loading..." {
m.setStatus("", 0)
}
}
if m.pendingForce || m.pendingRefresh {
force := m.pendingForce
m.pendingForce = false
m.pendingRefresh = false
return m, activityRequestRefreshBT(force, false, m)
}
return m, nil
case tea.KeyMsg:
if isAltFooterToggleKey(msg) {
m.showAltHints = !m.showAltHints
return m, nil
}
m.showAltHints = false
key := msg.String()
if key == "alt+s" {
if m.embedded {
m.requestClose = true
return m, nil
}
return m, tea.Quit
}
if len(m.copyOptions) > 0 {
return m.updateCopyMenu(key)
}
if m.confirmKillPID != 0 {
return m.updateConfirmKill(key)
}
return m.updateNormal(key)
}
return m, nil
}
func (m *activityMonitorBT) updateConfirmKill(key string) (tea.Model, tea.Cmd) {
if key == "esc" || key == "n" || key == "N" {
m.confirmKillPID = 0
return m, nil
}
if key == "y" || key == "Y" || key == "enter" {
pid := m.confirmKillPID
m.confirmKillPID = 0
if err := m.killProcess(pid); err != nil {
m.setStatus(err.Error(), 5*time.Second)
}
return m, activityRequestRefreshBT(true, false, m)
}
m.confirmKillPID = 0
return m, nil
}
func (m *activityMonitorBT) updateCopyMenu(key string) (tea.Model, tea.Cmd) {
if len(m.copyOptions) == 0 {
return m, nil
}
switch key {
case "esc", "n", "N":
m.copyOptions = nil
m.copyOptionIndex = 0
return m, nil
case "u", "up", "ctrl+u":
m.copyOptionIndex = clampInt(m.copyOptionIndex-1, 0, len(m.copyOptions)-1)
return m, nil
case "e", "down", "ctrl+e":
m.copyOptionIndex = clampInt(m.copyOptionIndex+1, 0, len(m.copyOptions)-1)
return m, nil
case "enter", "y", "Y":
option := m.copyOptions[clampInt(m.copyOptionIndex, 0, len(m.copyOptions)-1)]
if err := activityClipboardWriter(option.Value); err != nil {
m.setStatus(err.Error(), 5*time.Second)
return m, nil
}
m.copyOptions = nil
m.copyOptionIndex = 0
m.setStatus("Copied "+strings.ToLower(strings.TrimSpace(option.Label)), 4*time.Second)
return m, nil
}
if idx, ok := activityCopyIndexForKey(key, len(m.copyOptions)); ok {
option := m.copyOptions[idx]
if err := activityClipboardWriter(option.Value); err != nil {
m.setStatus(err.Error(), 5*time.Second)
return m, nil
}
m.copyOptions = nil
m.copyOptionIndex = 0
m.setStatus("Copied "+strings.ToLower(strings.TrimSpace(option.Label)), 4*time.Second)
}
return m, nil
}
func (m *activityMonitorBT) updateNormal(key string) (tea.Model, tea.Cmd) {
if key == "esc" || key == "ctrl+c" {
if m.embedded {
m.requestBack = true
return m, nil
}
return m, tea.Quit
}
switch key {
case "u", "up":
m.moveSelection(-1)
case "e", "down":
m.moveSelection(1)
case "n", "left":
m.shiftSort(-1)
case "i", "right":
m.shiftSort(1)
case "a":
m.showAllProcesses = !m.showAllProcesses
m.resortRows()
return m, activityRequestRefreshBT(true, false, m)
case "l":
m.lockSelection = !m.lockSelection
if !m.lockSelection {
m.resortRows()
}
case "r":
m.sortDescending = !m.sortDescending
m.resortRows()
case "c":
m.setSort(activitySortCPU)
case "m":
m.setSort(activitySortMemory)
case "j":
m.setSort(activitySortDownload)
case "k":
m.setSort(activitySortUpload)
case "o":
m.setSort(activitySortPorts)
case "t", "w":
m.setSort(activitySortLocation)
case "f":
m.setSort(activitySortCommand)
case "enter", "g":
proc := m.selectedProcess()
if proc == nil || proc.Tmux == nil {
m.setStatus("Not in tmux", 3*time.Second)
return m, nil
}
if err := focusActivityTmuxLocation(proc.Tmux); err != nil {
m.setStatus(err.Error(), 4*time.Second)
return m, nil
}
if m.embedded {
m.requestClose = true
return m, nil
}
return m, tea.Quit
case "d", "D":
if proc := m.selectedProcess(); proc != nil {
m.confirmKillPID = proc.PID
}
case "y", "Y":
proc := m.selectedProcess()
if proc == nil {
m.setStatus("No process selected", 3*time.Second)
return m, nil
}
m.copyOptions = activityCopyOptions(proc)
m.copyOptionIndex = 0
if len(m.copyOptions) == 0 {
m.setStatus("Nothing available to copy", 3*time.Second)
}
}
return m, nil
}
func (m *activityMonitorBT) View() string {
w := m.width
h := m.height
if w < 72 || h < 14 {
return m.styles.title.Render("Window too small")
}
title := fmt.Sprintf("Activity Monitor %d processes", len(m.rows))
headerLine := m.styles.title.Render(title)
scope := "tmux"
if m.showAllProcesses {
scope = "all"
}
dir := "asc"
if m.sortDescending {
dir = "desc"
}
follow := "row"
if m.lockSelection {
follow = "item"
}
metaLine := m.styles.meta.Render(fmt.Sprintf("Scope %s Sort %s %s Follow %s", scope, m.sortKey.label(), dir, follow))
tableX := 1
tableY := 3
contentH := h - tableY - 1
tableW := w - 2
previewX := 0
previewW := 0
showPreview := w >= 108
if showPreview {
tableW = maxInt(70, (w-3)*68/100)
previewX = tableX + tableW + 1
previewW = w - previewX - 1
}
tableContent := m.renderTable(tableW, contentH)
left := lipgloss.NewStyle().Width(tableW).Height(contentH).Render(tableContent)
body := left
if showPreview {
divider := m.renderVerticalDivider(contentH)
rightContent := m.renderDetails(previewW, contentH)
right := lipgloss.NewStyle().Width(previewW).Height(contentH).Render(rightContent)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, divider, right)
}
footer := m.renderFooter(w)
view := lipgloss.JoinVertical(lipgloss.Left,
headerLine,
metaLine,
"",
body,
"",
footer,
)
result := lipgloss.NewStyle().Width(w).Height(h).Padding(0, 1).Render(view)
if m.confirmKillPID != 0 {
procName := fmt.Sprintf("PID %d", m.confirmKillPID)
if proc := m.processes[m.confirmKillPID]; proc != nil {
procName = fmt.Sprintf("%s (PID %d)", proc.ShortCommand, proc.PID)
}
modal := lipgloss.JoinVertical(lipgloss.Left,
m.styles.modalTitle.Render("Kill process"),
m.styles.modalBody.Render(procName),
"",
m.styles.modalHint.Render("y confirm n cancel"),
)
box := m.styles.modal.Render(modal)
result = lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box, lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceBackground(lipgloss.Color("235")))
} else if len(m.copyOptions) > 0 {
rows := make([]string, 0, len(m.copyOptions))
for idx, option := range m.copyOptions {
prefix := fmt.Sprintf("%d ", idx+1)
labelStyle := m.styles.modalBody
rowStyle := lipgloss.NewStyle()
if idx == clampInt(m.copyOptionIndex, 0, len(m.copyOptions)-1) {
rowStyle = rowStyle.Background(lipgloss.Color("238"))
labelStyle = labelStyle.Copy().Background(lipgloss.Color("238")).Foreground(lipgloss.Color("230"))
}
rows = append(rows, rowStyle.Render(prefix+labelStyle.Render(option.Label)))
}
modal := lipgloss.JoinVertical(lipgloss.Left,
m.styles.modalTitle.Render("Copy"),
m.styles.modalBody.Render("Choose a field from the selected process"),
"",
strings.Join(rows, "\n"),
"",
m.styles.modalHint.Render("u/e move enter copy 1-9 direct esc cancel"),
)
box := m.styles.modal.Width(minInt(48, maxInt(30, w-10))).Render(modal)
result = lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box, lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceBackground(lipgloss.Color("235")))
}
return result
}
func (m *activityMonitorBT) renderTable(width, height int) string {
cpuW, memW, downW, upW, procW, sessionW, windowW, portW := activityColumnWidths(width)
headers := []struct {
key activitySortKey
label string
w int
}{
{activitySortCPU, "CPU", cpuW},
{activitySortMemory, "MEM", memW},
{activitySortDownload, "DOWN", downW},
{activitySortUpload, "UP", upW},
{activitySortCommand, "PROCESS", procW},
{activitySortLocation, "SESSION", sessionW},
{activitySortLocation, "WIN", windowW},
{activitySortPorts, "PORTS", portW},
}
headerParts := make([]string, len(headers))
for i, h := range headers {
style := m.styles.header
label := h.label
if h.key == m.sortKey {
style = m.styles.headerActive
if m.sortDescending {
label += " ▼"
} else {
label += " ▲"
}
}
headerParts[i] = style.Width(h.w).Render(truncate(label, h.w))
}
headerLine := lipgloss.JoinHorizontal(lipgloss.Left, headerParts...)
visibleRows := maxInt(1, height-1)
selectedIndex := m.selectedIndex()
if selectedIndex < 0 {
selectedIndex = 0
}
offset := stableListOffset(m.rowOffset, selectedIndex, visibleRows, len(m.rows))
m.rowOffset = offset
rowLines := []string{}
for row := 0; row < visibleRows; row++ {
idx := offset + row
if idx >= len(m.rows) {
break
}
info := m.rows[idx]
proc := m.processes[info.PID]
if proc == nil {
continue
}
selected := proc.PID == m.selectedPID
rowStyle := m.styles.row
cellStyle := m.styles.cell
if selected {
rowStyle = m.styles.rowSelected
cellStyle = m.styles.cellSelected
}
cpuStyle := cellStyle
if proc.CPU >= 50 {
cpuStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Background(cellStyle.GetBackground())
} else if proc.CPU >= 20 {
cpuStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("216")).Background(cellStyle.GetBackground())
}
memStyle := cellStyle
totalMem := activityTotalMemoryMB(proc)
if totalMem >= 1024 {
memStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Background(cellStyle.GetBackground())
} else if totalMem >= 512 {
memStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("216")).Background(cellStyle.GetBackground())
}
cells := []string{
cpuStyle.Width(cpuW).Render(truncate(fmt.Sprintf("%.1f", proc.CPU), cpuW)),
memStyle.Width(memW).Render(truncate(formatActivityMB(totalMem), memW)),
cellStyle.Width(downW).Render(truncate(formatActivitySpeed(proc.DownKBps), downW)),
cellStyle.Width(upW).Render(truncate(formatActivitySpeed(proc.UpKBps), upW)),
cellStyle.Width(procW).Render(truncate(activityProcessLabel(proc), procW)),
cellStyle.Width(sessionW).Render(truncate(activitySessionLabel(proc.Tmux), sessionW)),
cellStyle.Width(windowW).Render(truncate(activityWindowLabel(proc.Tmux, m.windowID), windowW)),
cellStyle.Width(portW).Render(truncate(formatActivityPorts(proc.Ports), portW)),
}
rowLines = append(rowLines, rowStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, cells...)))
}
if len(rowLines) == 0 {
msg := "No tmux processes"
if m.showAllProcesses {
msg = "No processes"
}
return headerLine + "\n" + m.styles.muted.Width(width).Render(msg)
}
return headerLine + "\n" + strings.Join(rowLines, "\n")
}
func (m *activityMonitorBT) renderDetails(width, height int) string {
lines := []string{m.styles.detailTitle.Render("Details")}
proc := m.selectedProcess()
if proc == nil {
lines = append(lines, m.styles.muted.Render("No process selected"))
return strings.Join(lines, "\n")
}
lines = append(lines,
m.renderDetailRow("PID", fmt.Sprintf("%d", proc.PID), width),
m.renderDetailRow("PPID", fmt.Sprintf("%d", proc.PPID), width),
m.renderDetailRow("CPU", fmt.Sprintf("%.1f%%", proc.CPU), width),
m.renderDetailRow("MEM", formatActivityMB(activityTotalMemoryMB(proc)), width),
m.renderDetailRow("Download", formatActivitySpeed(proc.DownKBps), width),
m.renderDetailRow("Upload", formatActivitySpeed(proc.UpKBps), width),
)
if proc.ResidentMB > 0 || proc.CompressedMB > 0 {
lines = append(lines, m.renderDetailRow("Resident", formatActivityMB(proc.ResidentMB), width))
if proc.CompressedMB > 0 {
lines = append(lines, m.renderDetailRow("Compressed", formatActivityMB(proc.CompressedMB), width))
}
}
lines = append(lines,
m.renderDetailRow("State", blankIfEmpty(proc.State, "-"), width),
m.renderDetailRow("Elapsed", blankIfEmpty(proc.Elapsed, "-"), width),
)
if proc.Tmux == nil {
lines = append(lines, m.renderDetailRow("Tmux", "outside tmux", width))
} else {
lines = append(lines,
m.renderDetailRow("Session", blankIfEmpty(proc.Tmux.SessionName, proc.Tmux.SessionID), width),
m.renderDetailRow("Window", activityWindowLabel(proc.Tmux, m.windowID), width),
m.renderDetailRow("Pane", proc.Tmux.PaneIndex, width),
)
}
if len(proc.Ports) > 0 {
lines = append(lines, m.renderDetailRow("Ports", strings.Join(proc.Ports, ", "), width))
} else {
lines = append(lines, m.renderDetailRow("Ports", "none", width))
}
lines = append(lines, "", m.styles.detailTitle.Render("Command"))
cmdLines := wrapText(blankIfEmpty(proc.Command, proc.ShortCommand), width)
for _, l := range cmdLines {
lines = append(lines, m.styles.cell.Render(truncate(l, width)))
}
return clampActivityLines(lines, height, width, m.styles.muted)
}
func clampActivityLines(lines []string, height, width int, muted lipgloss.Style) string {
if height <= 0 {
return ""
}
if len(lines) <= height {
return strings.Join(lines, "\n")
}
if height == 1 {
return muted.Render(truncate("...", width))
}
clipped := append([]string(nil), lines[:height]...)
clipped[height-1] = muted.Render(truncate("...", width))
return strings.Join(clipped, "\n")
}
func (m *activityMonitorBT) renderDetailRow(label, value string, width int) string {
labelW := 10
return m.styles.detailLabel.Width(labelW).Render(label+":") + " " + m.styles.detailValue.Render(truncate(value, maxInt(10, width-labelW-2)))
}
func (m *activityMonitorBT) renderVerticalDivider(height int) string {
lines := make([]string, maxInt(1, height))
for i := range lines {
lines[i] = m.styles.divider.Render("│")
}
return strings.Join(lines, "\n")
}
func (m *activityMonitorBT) renderFooter(width int) string {
status := m.currentStatus()
if status != "" {
style := m.styles.status
lower := strings.ToLower(status)
if strings.Contains(lower, "error") || strings.Contains(lower, "failed") || strings.Contains(lower, "refusing") {
style = m.styles.statusBad
}
return style.Width(width).Render(truncate(status, width))
}
renderSegments := func(pairs [][2]string) string {
return renderShortcutPairs(func(v string) string { return m.styles.shortcutKey.Render(v) }, func(v string) string { return m.styles.shortcutText.Render(v) }, " ", pairs)
}
footer := ""
if m.showAltHints {
footer = pickRenderedShortcutFooter(width, renderSegments,
[][2]string{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
[][2]string{{"Alt-S", "close"}},
)
} else {
footer = pickRenderedShortcutFooter(width, renderSegments,
[][2]string{{"u/e", "move"}, {"n/i", "sort"}, {"j/k", "net"}, {"a", "scope"}, {"l", "follow"}, {"r", "reverse"}, {"Enter", "tmux"}, {"d", "kill"}, {"y", "copy"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"n/i", "sort"}, {"j/k", "net"}, {"a", "scope"}, {"Enter", "tmux"}, {"d", "kill"}, {"y", "copy"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "tmux"}, {"y", "copy"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
)
}
return lipgloss.NewStyle().Width(width).Render(footer)
}
func (m *activityMonitorBT) moveSelection(delta int) {
if len(m.rows) == 0 {
m.selectedPID = 0
m.selectedRow = 0
return
}
m.ensureSelection()
idx := m.selectedRow + delta
if idx < 0 {
idx = 0
}
if idx >= len(m.rows) {
idx = len(m.rows) - 1
}
m.selectedRow = idx
m.selectedPID = m.rows[m.selectedRow].PID
}
func (m *activityMonitorBT) shiftSort(delta int) {
index := 0
for idx, key := range activitySortKeys {
if key == m.sortKey {
index = idx
break
}
}
index += delta
if index < 0 {
index = len(activitySortKeys) - 1
}
if index >= len(activitySortKeys) {
index = 0
}
key := activitySortKeys[index]
if key == m.sortKey {
return
}
m.sortKey = key
m.sortDescending = defaultActivitySortDescending(key)
m.resortRows()
}
func (m *activityMonitorBT) killProcess(pid int) error {
if pid <= 0 {
return fmt.Errorf("invalid pid")
}
if pid == os.Getpid() {
return fmt.Errorf("refusing to kill self")
}
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return err
}
m.setStatus(fmt.Sprintf("Sent SIGTERM to %d", pid), 4*time.Second)
return nil
}
func (m *activityMonitorBT) setSort(key activitySortKey) {
if m.sortKey == key {
m.sortDescending = !m.sortDescending
} else {
m.sortKey = key
m.sortDescending = defaultActivitySortDescending(key)
}
m.resortRows()
}
func (m *activityMonitorBT) resortRows() {
previousPID := m.selectedPID
previousRow := m.selectedRow
rows := make([]activityRow, 0, len(m.processes))
for _, proc := range m.sortedProcesses(m.visibleProcesses()) {
rows = append(rows, activityRow{PID: proc.PID})
}
m.rows = rows
m.restoreSelection(previousPID, previousRow)
}
func (m *activityMonitorBT) visibleProcesses() []*activityProcess {
visible := make([]*activityProcess, 0, len(m.processes))
for _, proc := range m.processes {
if !m.showAllProcesses && proc.Tmux == nil {
continue
}
visible = append(visible, proc)
}
return visible
}
func (m *activityMonitorBT) sortedProcesses(values []*activityProcess) []*activityProcess {
cloned := append([]*activityProcess(nil), values...)
sort.SliceStable(cloned, func(i, j int) bool {
return m.less(cloned[i], cloned[j])
})
return cloned
}
func (m *activityMonitorBT) less(left, right *activityProcess) bool {
cmp := 0
switch m.sortKey {
case activitySortCPU:
cmp = compareFloat64(left.CPU, right.CPU)
case activitySortMemory:
cmp = compareFloat64(activityTotalMemoryMB(left), activityTotalMemoryMB(right))
case activitySortDownload:
cmp = compareFloat64(left.DownKBps, right.DownKBps)
case activitySortUpload:
cmp = compareFloat64(left.UpKBps, right.UpKBps)
case activitySortPorts:
cmp = compareInt(len(left.Ports), len(right.Ports))
if cmp == 0 {
cmp = strings.Compare(strings.Join(left.Ports, ","), strings.Join(right.Ports, ","))
}
case activitySortLocation:
cmp = compareActivityLocation(left.Tmux, right.Tmux)
case activitySortCommand:
cmp = strings.Compare(strings.ToLower(blankIfEmpty(left.ShortCommand, left.Command)), strings.ToLower(blankIfEmpty(right.ShortCommand, right.Command)))
}
if cmp == 0 {
cmp = compareFloat64(left.CPU, right.CPU)
}
if cmp == 0 {
cmp = compareFloat64(activityTotalMemoryMB(left), activityTotalMemoryMB(right))
}
if cmp == 0 {
cmp = compareInt(left.PID, right.PID)
}
if m.sortDescending {
return cmp > 0
}
return cmp < 0
}
func (m *activityMonitorBT) ensureSelection() {
if len(m.rows) == 0 {
m.selectedPID = 0
m.selectedRow = 0
return
}
if m.lockSelection && m.selectedPID != 0 {
for idx, row := range m.rows {
if row.PID == m.selectedPID {
m.selectedRow = idx
return
}
}
}
if m.selectedRow < 0 {
m.selectedRow = 0
}
if m.selectedRow >= len(m.rows) {
m.selectedRow = len(m.rows) - 1
}
m.selectedPID = m.rows[m.selectedRow].PID
}
func (m *activityMonitorBT) restoreSelection(previousPID, previousRow int) {
if len(m.rows) == 0 {
m.selectedPID = 0
m.selectedRow = 0
return
}
if m.lockSelection && previousPID != 0 {
for idx, row := range m.rows {
if row.PID == previousPID {
m.selectedPID = previousPID
m.selectedRow = idx
return
}
}
}
if previousRow < 0 {
previousRow = 0
}
if previousRow >= len(m.rows) {
previousRow = len(m.rows) - 1
}
m.selectedRow = previousRow
m.selectedPID = m.rows[m.selectedRow].PID
}
func (m *activityMonitorBT) selectedIndex() int {
m.ensureSelection()
if len(m.rows) == 0 {
return -1
}
return m.selectedRow
}
func (m *activityMonitorBT) selectedProcess() *activityProcess {
m.ensureSelection()
if m.selectedPID == 0 {
return nil
}
return m.processes[m.selectedPID]
}
func (m *activityMonitorBT) setStatus(text string, duration time.Duration) {
m.status = strings.TrimSpace(text)
if duration > 0 {
m.statusUntil = time.Now().Add(duration)
} else {
m.statusUntil = time.Time{}
}
}
func (m *activityMonitorBT) currentStatus() string {
if m.status == "" {
return ""
}
if !m.statusUntil.IsZero() && time.Now().After(m.statusUntil) {
m.status = ""
m.statusUntil = time.Time{}
return ""
}
return m.status
}
func (m *activityMonitorBT) applySnapshot(snapshot *activitySnapshot) {
if snapshot == nil {
return
}
m.processes = snapshot.Processes
m.memoryByPID = snapshot.MemoryByPID
m.networkByPID = snapshot.NetworkByPID
m.portsByPID = snapshot.PortsByPID
m.tmuxByPanePID = snapshot.TmuxByPanePID
m.lastProcessLoad = snapshot.LastProcessLoad
m.lastMemoryLoad = snapshot.LastMemoryLoad
m.lastNetworkLoad = snapshot.LastNetworkLoad
m.lastPortLoad = snapshot.LastPortLoad
m.lastTmuxLoad = snapshot.LastTmuxLoad
m.refreshedAt = snapshot.RefreshedAt
m.resortRows()
if strings.TrimSpace(snapshot.Status) != "" && m.currentStatus() == "" {
m.setStatus(snapshot.Status, 4*time.Second)
}
}
func activityTickCmd() tea.Cmd {
return tea.Tick(time.Second, func(time.Time) tea.Msg {
return activityTickMsg{}
})
}
func activityRequestRefreshBT(force, initial bool, m *activityMonitorBT) tea.Cmd {
if m.refreshInFlight {
if force || initial {
m.pendingForce = true
} else {
m.pendingRefresh = true
}
return nil
}
m.refreshInFlight = true
input := activityRefreshInput{
Force: force,
Initial: initial,
ShowAll: m.showAllProcesses,
Processes: cloneActivityProcesses(m.processes),
MemoryByPID: cloneActivityMemoryMap(m.memoryByPID),
NetworkByPID: cloneActivityNetworkMap(m.networkByPID),
PortsByPID: cloneActivityPortsMap(m.portsByPID),
TmuxByPanePID: cloneActivityTmuxMap(m.tmuxByPanePID),
LastProcessLoad: m.lastProcessLoad,
LastMemoryLoad: m.lastMemoryLoad,
LastNetworkLoad: m.lastNetworkLoad,
LastPortLoad: m.lastPortLoad,
LastTmuxLoad: m.lastTmuxLoad,
RefreshedAt: m.refreshedAt,
}
return func() tea.Msg {
snapshot, err := collectActivitySnapshot(input)
return activityRefreshMsg{snapshot: snapshot, err: err}
}
}
func activityCopyOptions(proc *activityProcess) []activityCopyOption {
if proc == nil {
return nil
}
options := []activityCopyOption{
{Label: "PID", Value: fmt.Sprintf("%d", proc.PID)},
{Label: "Parent PID", Value: fmt.Sprintf("%d", proc.PPID)},
{Label: "Process", Value: blankIfEmpty(proc.ShortCommand, proc.Command)},
{Label: "Command", Value: blankIfEmpty(proc.Command, proc.ShortCommand)},
{Label: "Download", Value: formatActivitySpeed(proc.DownKBps)},
{Label: "Upload", Value: formatActivitySpeed(proc.UpKBps)},
}
if proc.Tmux != nil {
session := strings.TrimSpace(blankIfEmpty(proc.Tmux.SessionName, proc.Tmux.SessionID))
if session != "" {
if strings.TrimSpace(proc.Tmux.SessionID) != "" && session != strings.TrimSpace(proc.Tmux.SessionID) {
session += " (" + strings.TrimSpace(proc.Tmux.SessionID) + ")"
}
options = append(options, activityCopyOption{Label: "Session", Value: session})
}
window := activityWindowLabel(proc.Tmux, "")
if strings.TrimSpace(window) != "-" && strings.TrimSpace(window) != "" {
if strings.TrimSpace(proc.Tmux.WindowID) != "" {
window += " (" + strings.TrimSpace(proc.Tmux.WindowID) + ")"
}
options = append(options, activityCopyOption{Label: "Window", Value: window})
}
if pane := strings.TrimSpace(proc.Tmux.PaneID); pane != "" {
options = append(options, activityCopyOption{Label: "Pane", Value: pane})
}
}
if len(proc.Ports) > 0 {
options = append(options, activityCopyOption{Label: "Ports", Value: strings.Join(proc.Ports, ", ")})
}
filtered := make([]activityCopyOption, 0, len(options))
for _, option := range options {
if strings.TrimSpace(option.Value) == "" || strings.TrimSpace(option.Value) == "-" {
continue
}
filtered = append(filtered, option)
}
return filtered
}
func activityCopyIndexForKey(key string, total int) (int, bool) {
if total <= 0 {
return 0, false
}
runes := []rune(strings.TrimSpace(key))
if len(runes) != 1 {
return 0, false
}
if runes[0] < '1' || runes[0] > '9' {
return 0, false
}
idx := int(runes[0] - '1')
if idx < 0 || idx >= total {
return 0, false
}
return idx, true
}
func writeActivityClipboard(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return fmt.Errorf("nothing to copy")
}
cmd := exec.Command("pbcopy")
cmd.Stdin = strings.NewReader(value)
if output, err := cmd.CombinedOutput(); err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
return err
}
return fmt.Errorf("clipboard copy failed: %s", message)
}
return nil
}