diff --git a/.github/workflows/shell-quality-checks.yml b/.github/workflows/shell-quality-checks.yml index afbc3ed..0ac251e 100644 --- a/.github/workflows/shell-quality-checks.yml +++ b/.github/workflows/shell-quality-checks.yml @@ -57,15 +57,24 @@ jobs: - name: Build Universal Binary for disk analyzer run: ./scripts/build-analyze.sh + - name: Build Universal Binary for system status + run: ./scripts/build-status.sh + - name: Verify binary is valid run: | if [[ ! -x bin/analyze-go ]]; then echo "Error: bin/analyze-go is not executable" exit 1 fi + if [[ ! -x bin/status-go ]]; then + echo "Error: bin/status-go is not executable" + exit 1 + fi echo "Binary info:" file bin/analyze-go ls -lh bin/analyze-go + file bin/status-go + ls -lh bin/status-go echo "" echo "✓ Universal binary built successfully" @@ -74,11 +83,19 @@ jobs: run: | if git diff --quiet -- bin/analyze-go; then echo "bin/analyze-go unchanged; nothing to commit." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add bin/analyze-go + git commit -m "chore: update analyzer binary" + git push origin HEAD:${GITHUB_REF#refs/heads/} + fi + if git diff --quiet -- bin/status-go; then + echo "bin/status-go unchanged; nothing to commit." exit 0 fi - git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add bin/analyze-go - git commit -m "chore: update analyzer binary" + git add bin/status-go + git commit -m "chore: update status binary" git push origin HEAD:${GITHUB_REF#refs/heads/} diff --git a/.gitignore b/.gitignore index 582ee01..d55dc1b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ copilot-instructions.md # Go build artifacts cmd/analyze/analyze +cmd/status/status +/status diff --git a/README.md b/README.md index 591230d..d3ab84c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - **Thorough Uninstall** - Scans 22+ locations to remove app leftovers, not just the .app file - **System Optimization** - Rebuilds caches, resets services, and trims swap/network cruft with one run - **Interactive Disk Analyzer** - Navigate folders with arrow keys, find and delete large files quickly +- **System Status Dashboard** - Real-time health score with live CPU/GPU/Memory/Disk/Network/Battery metrics - **Fast & Lightweight** - Terminal-based with arrow-key navigation, pagination, and Touch ID support ## Quick Start @@ -49,6 +50,7 @@ mo clean --whitelist # Manage protected caches mo uninstall # Uninstall apps mo optimize # System optimization mo analyze # Disk analyzer +mo status # Live system status dashboard mo touchid # Configure Touch ID for sudo mo update # Update Mole @@ -145,6 +147,35 @@ Analyze Disk ~/Documents | Total: 156.8GB ↑↓←→ Navigate | O Open | F Reveal | ⌫ Delete | L Large(24) | Q Quit ``` +### Live System Status + +Real-time dashboard with system health score, hardware info, and performance metrics. + +```bash +$ mo status + +Mole Status Health ● 92 MacBook Pro · Apple M4 Pro · 32.0 GB · 460.4 GB · macOS 14.5 + +⚙ CPU ────────────────────── ▦ Memory ───────────────────── +Total ████████████░░░░░░ 45.2% Used ███████████░░░░░░ 58.4% +0.82 / 1.05 / 1.23 (8 cores) 14.2 GB / 24.0 GB total +Core1 ███████████████░░░ 78.3% Free ████████░░░░░░░░░ 41.6% +Core2 ████████████░░░░░░ 62.1% 9.8 GB available + +▤ Disk ────────────────────── ▮ Power ────────────────────── +Used █████████████░░░░░ 67.2% 100% ██████████████████ 100% +156.3 GB free Charged ⚡ +Read ▮▯▯▯▯ 2.1 MB/s Normal · 423 cycles +Write ▮▮▮▯▯ 18.3 MB/s 58°C · 1200 RPM + +⇅ Network ─────────────────── ▶ Processes ─────────────────── +Down ▮▮▯▯▯ 3.2 MB/s Code ▮▮▮▮▯ 42.1% +Up ▮▯▯▯▯ 0.8 MB/s Chrome ▮▮▮▯▯ 28.3% +Proxy: HTTP · 192.168.1.100 Terminal ▮▯▯▯▯ 12.5% +``` + +Health score is calculated from CPU usage, memory pressure, disk space, temperature, and I/O load. Color-coded: 90-100 green, 75-89 light green, 60-74 yellow, 40-59 orange, 0-39 red. + ## Quick Launchers Launch Mole commands instantly from Raycast or Alfred: @@ -153,7 +184,15 @@ Launch Mole commands instantly from Raycast or Alfred: curl -fsSL https://raw.githubusercontent.com/tw93/Mole/main/scripts/setup-quick-launchers.sh | bash ``` -Adds 4 commands: `clean`, `uninstall`, `optimize`, `analyze`. Auto-detects your terminal or set `MO_LAUNCHER_APP=` to override. +Adds 5 commands: `clean`, `uninstall`, `optimize`, `analyze`, `status`. Auto-detects your terminal or set `MO_LAUNCHER_APP=` to override. + +**Reload Raycast scripts after installation:** + +1. Open Raycast (⌘ Space) +2. Search for "Reload Script Directories" +3. Press Enter to activate new commands + +Alternatively, restart Raycast to load the new scripts. ## Support diff --git a/bin/analyze-go b/bin/analyze-go index f4c0ad5..57a92da 100755 Binary files a/bin/analyze-go and b/bin/analyze-go differ diff --git a/bin/status-go b/bin/status-go new file mode 100755 index 0000000..d7b35a8 Binary files /dev/null and b/bin/status-go differ diff --git a/bin/status.sh b/bin/status.sh new file mode 100755 index 0000000..8e51b4b --- /dev/null +++ b/bin/status.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Entry point for the Go-based system status panel bundled with Mole. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GO_BIN="$SCRIPT_DIR/status-go" +if [[ -x "$GO_BIN" ]]; then + exec "$GO_BIN" "$@" +fi + +echo "Bundled status binary not found. Please reinstall Mole or run mo update to restore it." >&2 +exit 1 diff --git a/cmd/status/main.go b/cmd/status/main.go new file mode 100644 index 0000000..cc8376c --- /dev/null +++ b/cmd/status/main.go @@ -0,0 +1,140 @@ +package main + +import ( + "fmt" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const refreshInterval = time.Second + +var ( + Version = "dev" + BuildTime = "" +) + +type tickMsg struct{} +type animTickMsg struct{} + +type metricsMsg struct { + data MetricsSnapshot + err error +} + +type model struct { + collector *Collector + width int + height int + metrics MetricsSnapshot + errMessage string + ready bool + lastUpdated time.Time + collecting bool + animFrame int +} + +func newModel() model { + return model{ + collector: NewCollector(), + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch(tickAfter(0), animTick()) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tickMsg: + if m.collecting { + return m, nil + } + m.collecting = true + return m, m.collectCmd() + case metricsMsg: + if msg.err != nil { + m.errMessage = msg.err.Error() + } else { + m.errMessage = "" + } + m.metrics = msg.data + m.lastUpdated = msg.data.CollectedAt + m.collecting = false + // Mark ready after first successful data collection + if !m.ready { + m.ready = true + } + return m, tickAfter(refreshInterval) + case animTickMsg: + m.animFrame++ + return m, animTickWithSpeed(m.metrics.CPU.Usage) + } + return m, nil +} + +func (m model) View() string { + if !m.ready { + return "Loading..." + } + + header := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width) + cardWidth := 0 + if m.width > 80 { + cardWidth = maxInt(24, m.width/2-4) + } + cards := buildCards(m.metrics, cardWidth) + + if m.width <= 80 { + var rendered []string + for _, c := range cards { + rendered = append(rendered, renderCard(c, cardWidth, 0)) + } + return header + "\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...) + } + + return header + "\n" + renderTwoColumns(cards, m.width) +} + +func (m model) collectCmd() tea.Cmd { + return func() tea.Msg { + data, err := m.collector.Collect() + return metricsMsg{data: data, err: err} + } +} + +func tickAfter(delay time.Duration) tea.Cmd { + return tea.Tick(delay, func(time.Time) tea.Msg { return tickMsg{} }) +} + +func animTick() tea.Cmd { + return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} }) +} + +func animTickWithSpeed(cpuUsage float64) tea.Cmd { + // Higher CPU = faster animation (50ms to 300ms) + interval := 300 - int(cpuUsage*2.5) + if interval < 50 { + interval = 50 + } + return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} }) +} + +func main() { + p := tea.NewProgram(newModel(), tea.WithAltScreen()) + if err := p.Start(); err != nil { + fmt.Fprintf(os.Stderr, "system status error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go new file mode 100644 index 0000000..3cc037a --- /dev/null +++ b/cmd/status/metrics.go @@ -0,0 +1,1244 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/load" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/net" +) + +type MetricsSnapshot struct { + CollectedAt time.Time + Host string + Platform string + Uptime string + Procs uint64 + Hardware HardwareInfo + HealthScore int // 0-100 system health score + HealthScoreMsg string // Brief explanation + + CPU CPUStatus + GPU []GPUStatus + Memory MemoryStatus + Disks []DiskStatus + DiskIO DiskIOStatus + Network []NetworkStatus + Proxy ProxyStatus + Batteries []BatteryStatus + Thermal ThermalStatus + Sensors []SensorReading + Bluetooth []BluetoothDevice + TopProcesses []ProcessInfo +} + +type HardwareInfo struct { + Model string // MacBook Pro 14-inch, 2021 + CPUModel string // Apple M1 Pro / Intel Core i7 + TotalRAM string // 16GB + DiskSize string // 512GB + OSVersion string // macOS Sonoma 14.5 +} + +type DiskIOStatus struct { + ReadRate float64 // MB/s + WriteRate float64 // MB/s +} + +type ProcessInfo struct { + Name string + CPU float64 + Memory float64 +} + +type CPUStatus struct { + Usage float64 + PerCore []float64 + Load1 float64 + Load5 float64 + Load15 float64 + CoreCount int + LogicalCPU int +} + +type GPUStatus struct { + Name string + Usage float64 + MemoryUsed float64 + MemoryTotal float64 + Note string +} + +type MemoryStatus struct { + Used uint64 + Total uint64 + UsedPercent float64 + SwapUsed uint64 + SwapTotal uint64 + Pressure string // macOS memory pressure: normal/warn/critical +} + +type DiskStatus struct { + Mount string + Used uint64 + Total uint64 + UsedPercent float64 + Fstype string +} + +type NetworkStatus struct { + Name string + RxRateMBs float64 + TxRateMBs float64 + IP string +} + +type ProxyStatus struct { + Enabled bool + Type string // HTTP, SOCKS, System + Host string +} + +type BatteryStatus struct { + Percent float64 + Status string + TimeLeft string + Health string + CycleCount int +} + +type ThermalStatus struct { + CPUTemp float64 + GPUTemp float64 + FanSpeed int + FanCount int +} + +type SensorReading struct { + Label string + Value float64 + Unit string + Note string +} + +type BluetoothDevice struct { + Name string + Connected bool + Battery string +} + +type Collector struct { + prevNet map[string]net.IOCountersStat + lastNetAt time.Time + lastBTAt time.Time + lastBT []BluetoothDevice + lastGPUAt time.Time + cachedGPU []GPUStatus + prevDiskIO disk.IOCountersStat + lastDiskAt time.Time +} + +const ( + bluetoothCacheTTL = 30 * time.Second + systemProfilerTimeout = 4 * time.Second + bluetoothctlTimeout = 1500 * time.Millisecond + macGPUInfoTTL = 10 * time.Minute +) + +var skipDiskMounts = map[string]bool{ + "/System/Volumes/VM": true, + "/System/Volumes/Preboot": true, + "/System/Volumes/Update": true, + "/System/Volumes/xarts": true, + "/System/Volumes/Hardware": true, + "/System/Volumes/Data": true, + "/dev": true, +} + +func NewCollector() *Collector { + return &Collector{ + prevNet: make(map[string]net.IOCountersStat), + } +} + +func (c *Collector) Collect() (MetricsSnapshot, error) { + now := time.Now() + hostInfo, _ := host.Info() + + cpuStats, cpuErr := collectCPU() + memStats, memErr := collectMemory() + diskStats, diskErr := collectDisks() + hwInfo := collectHardware(memStats.Total, diskStats) + diskIO := c.collectDiskIO(now) + netStats, netErr := c.collectNetwork(now) + proxyStats := collectProxy() + batteryStats, _ := collectBatteries() + thermalStats := collectThermal() + sensorStats, _ := collectSensors() + gpuStats, gpuErr := c.collectGPU(now) + btStats := c.collectBluetooth(now) + topProcs := collectTopProcesses() + + var mergeErr error + for _, e := range []error{cpuErr, memErr, diskErr, netErr, gpuErr} { + if e != nil { + if mergeErr == nil { + mergeErr = e + } else { + mergeErr = fmt.Errorf("%v; %w", mergeErr, e) + } + } + } + + // Calculate health score + score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats) + + return MetricsSnapshot{ + CollectedAt: now, + Host: hostInfo.Hostname, + Platform: fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion), + Uptime: formatUptime(hostInfo.Uptime), + Procs: hostInfo.Procs, + Hardware: hwInfo, + HealthScore: score, + HealthScoreMsg: scoreMsg, + CPU: cpuStats, + GPU: gpuStats, + Memory: memStats, + Disks: diskStats, + DiskIO: diskIO, + Network: netStats, + Proxy: proxyStats, + Batteries: batteryStats, + Thermal: thermalStats, + Sensors: sensorStats, + Bluetooth: btStats, + TopProcesses: topProcs, + }, mergeErr +} + +func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) { + // Start with perfect score + score := 100.0 + issues := []string{} + + // CPU Usage (30% weight) - deduct up to 30 points + // 0-30% CPU = 0 deduction, 30-70% = linear, 70-100% = heavy penalty + cpuPenalty := 0.0 + if cpu.Usage > 30 { + if cpu.Usage > 70 { + cpuPenalty = 30.0 * (cpu.Usage - 30) / 70.0 + } else { + cpuPenalty = 15.0 * (cpu.Usage - 30) / 40.0 + } + } + score -= cpuPenalty + if cpu.Usage > 70 { + issues = append(issues, "High CPU") + } + + // Memory Usage (25% weight) - deduct up to 25 points + // 0-50% = 0 deduction, 50-80% = linear, 80-100% = heavy penalty + memPenalty := 0.0 + if mem.UsedPercent > 50 { + if mem.UsedPercent > 80 { + memPenalty = 25.0 * (mem.UsedPercent - 50) / 50.0 + } else { + memPenalty = 12.5 * (mem.UsedPercent - 50) / 30.0 + } + } + score -= memPenalty + if mem.UsedPercent > 80 { + issues = append(issues, "High Memory") + } + + // Memory Pressure (extra penalty) + if mem.Pressure == "warn" { + score -= 5 + issues = append(issues, "Memory Pressure") + } else if mem.Pressure == "critical" { + score -= 15 + issues = append(issues, "Critical Memory") + } + + // Disk Usage (20% weight) - deduct up to 20 points + diskPenalty := 0.0 + if len(disks) > 0 { + diskUsage := disks[0].UsedPercent + if diskUsage > 70 { + if diskUsage > 90 { + diskPenalty = 20.0 * (diskUsage - 70) / 30.0 + } else { + diskPenalty = 10.0 * (diskUsage - 70) / 20.0 + } + } + score -= diskPenalty + if diskUsage > 90 { + issues = append(issues, "Disk Almost Full") + } + } + + // Thermal (15% weight) - deduct up to 15 points + thermalPenalty := 0.0 + if thermal.CPUTemp > 0 { + if thermal.CPUTemp > 60 { + if thermal.CPUTemp > 85 { + thermalPenalty = 15.0 + issues = append(issues, "Overheating") + } else { + thermalPenalty = 15.0 * (thermal.CPUTemp - 60) / 25.0 + } + } + score -= thermalPenalty + } + + // Disk IO (10% weight) - deduct up to 10 points + ioPenalty := 0.0 + totalIO := diskIO.ReadRate + diskIO.WriteRate + if totalIO > 50 { + if totalIO > 150 { + ioPenalty = 10.0 + issues = append(issues, "Heavy Disk IO") + } else { + ioPenalty = 10.0 * (totalIO - 50) / 100.0 + } + } + score -= ioPenalty + + // Ensure score is in valid range + if score < 0 { + score = 0 + } + if score > 100 { + score = 100 + } + + // Generate message + msg := "Excellent" + if score >= 90 { + msg = "Excellent" + } else if score >= 75 { + msg = "Good" + } else if score >= 60 { + msg = "Fair" + } else if score >= 40 { + msg = "Poor" + } else { + msg = "Critical" + } + + if len(issues) > 0 { + msg = msg + ": " + strings.Join(issues, ", ") + } + + return int(score), msg +} + +func formatUptime(secs uint64) string { + days := secs / 86400 + hours := (secs % 86400) / 3600 + mins := (secs % 3600) / 60 + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, mins) + } + return fmt.Sprintf("%dm", mins) +} + +func collectCPU() (CPUStatus, error) { + percents, err := cpu.Percent(0, true) + if err != nil { + return CPUStatus{}, err + } + if len(percents) == 0 { + return CPUStatus{}, errors.New("cannot read CPU utilization") + } + totalPercent := 0.0 + for _, v := range percents { + totalPercent += v + } + totalPercent /= float64(len(percents)) + + loadAvg, _ := load.Avg() + counts, _ := cpu.Counts(false) + logical, _ := cpu.Counts(true) + + return CPUStatus{ + Usage: totalPercent, + PerCore: percents, + Load1: loadAvg.Load1, + Load5: loadAvg.Load5, + Load15: loadAvg.Load15, + CoreCount: counts, + LogicalCPU: logical, + }, nil +} + +func collectMemory() (MemoryStatus, error) { + vm, err := mem.VirtualMemory() + if err != nil { + return MemoryStatus{}, err + } + + swap, _ := mem.SwapMemory() + pressure := getMemoryPressure() + + return MemoryStatus{ + Used: vm.Used, + Total: vm.Total, + UsedPercent: vm.UsedPercent, + SwapUsed: swap.Used, + SwapTotal: swap.Total, + Pressure: pressure, + }, nil +} + +func getMemoryPressure() string { + if runtime.GOOS != "darwin" { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + out, err := runCmd(ctx, "memory_pressure") + if err != nil { + return "" + } + lower := strings.ToLower(out) + if strings.Contains(lower, "critical") { + return "critical" + } + if strings.Contains(lower, "warn") { + return "warn" + } + if strings.Contains(lower, "normal") { + return "normal" + } + return "" +} + +func collectDisks() ([]DiskStatus, error) { + partitions, err := disk.Partitions(false) + if err != nil { + return nil, err + } + + var ( + disks []DiskStatus + seenDevice = make(map[string]bool) + seenVolume = make(map[string]bool) + ) + for _, part := range partitions { + if strings.HasPrefix(part.Device, "/dev/loop") { + continue + } + if skipDiskMounts[part.Mountpoint] { + continue + } + if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") { + continue + } + if seenDevice[part.Device] { + continue + } + usage, err := disk.Usage(part.Mountpoint) + if err != nil || usage.Total == 0 { + continue + } + // Skip small volumes (< 1GB) + if usage.Total < 1<<30 { + continue + } + volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total>>30) + if seenVolume[volKey] { + continue + } + disks = append(disks, DiskStatus{ + Mount: part.Mountpoint, + Used: usage.Used, + Total: usage.Total, + UsedPercent: usage.UsedPercent, + Fstype: part.Fstype, + }) + seenDevice[part.Device] = true + seenVolume[volKey] = true + } + + sort.Slice(disks, func(i, j int) bool { + return disks[i].Total > disks[j].Total + }) + + if len(disks) > 3 { + disks = disks[:3] + } + + return disks, nil +} + +func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { + counters, err := disk.IOCounters() + if err != nil || len(counters) == 0 { + return DiskIOStatus{} + } + + var total disk.IOCountersStat + for _, v := range counters { + total.ReadBytes += v.ReadBytes + total.WriteBytes += v.WriteBytes + } + + if c.lastDiskAt.IsZero() { + c.prevDiskIO = total + c.lastDiskAt = now + return DiskIOStatus{} + } + + elapsed := now.Sub(c.lastDiskAt).Seconds() + if elapsed <= 0 { + elapsed = 1 + } + + readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed + writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed + + c.prevDiskIO = total + c.lastDiskAt = now + + if readRate < 0 { + readRate = 0 + } + if writeRate < 0 { + writeRate = 0 + } + + return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate} +} + +func collectTopProcesses() []ProcessInfo { + if runtime.GOOS != "darwin" { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + // Use ps to get top processes by CPU + out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r") + if err != nil { + return nil + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + var procs []ProcessInfo + for i, line := range lines { + if i == 0 { // skip header + continue + } + if i > 5 { // top 5 + break + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + cpuVal, _ := strconv.ParseFloat(fields[0], 64) + memVal, _ := strconv.ParseFloat(fields[1], 64) + name := fields[len(fields)-1] + // Get just the process name without path + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + procs = append(procs, ProcessInfo{ + Name: name, + CPU: cpuVal, + Memory: memVal, + }) + } + return procs +} + +func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { + stats, err := net.IOCounters(true) + if err != nil { + return nil, err + } + + // Get IP addresses for interfaces + ifAddrs := getInterfaceIPs() + + if c.lastNetAt.IsZero() { + c.lastNetAt = now + for _, s := range stats { + c.prevNet[s.Name] = s + } + return nil, nil + } + + elapsed := now.Sub(c.lastNetAt).Seconds() + if elapsed <= 0 { + elapsed = 1 + } + + var result []NetworkStatus + for _, cur := range stats { + if isNoiseInterface(cur.Name) { + continue + } + prev, ok := c.prevNet[cur.Name] + if !ok { + continue + } + rx := float64(cur.BytesRecv-prev.BytesRecv) / 1024.0 / 1024.0 / elapsed + tx := float64(cur.BytesSent-prev.BytesSent) / 1024.0 / 1024.0 / elapsed + if rx < 0 { + rx = 0 + } + if tx < 0 { + tx = 0 + } + result = append(result, NetworkStatus{ + Name: cur.Name, + RxRateMBs: rx, + TxRateMBs: tx, + IP: ifAddrs[cur.Name], + }) + } + + c.lastNetAt = now + for _, s := range stats { + c.prevNet[s.Name] = s + } + + sort.Slice(result, func(i, j int) bool { + return result[i].RxRateMBs+result[i].TxRateMBs > result[j].RxRateMBs+result[j].TxRateMBs + }) + if len(result) > 3 { + result = result[:3] + } + + return result, nil +} + +func getInterfaceIPs() map[string]string { + result := make(map[string]string) + ifaces, err := net.Interfaces() + if err != nil { + return result + } + for _, iface := range ifaces { + for _, addr := range iface.Addrs { + // Only IPv4 + if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") { + ip := strings.Split(addr.Addr, "/")[0] + result[iface.Name] = ip + break + } + } + } + return result +} + +func isNoiseInterface(name string) bool { + lower := strings.ToLower(name) + noiseList := []string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"} + for _, prefix := range noiseList { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +func collectBatteries() (batts []BatteryStatus, err error) { + defer func() { + if r := recover(); r != nil { + // Swallow panics from platform-specific battery probes to keep the UI alive. + err = fmt.Errorf("battery collection failed: %v", r) + } + }() + + // macOS: pmset + if runtime.GOOS == "darwin" && commandExists("pmset") { + if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { + if batts := parsePMSet(out); len(batts) > 0 { + return batts, nil + } + } + } + + // Linux: /sys/class/power_supply + matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity") + for _, capFile := range matches { + statusFile := filepath.Join(filepath.Dir(capFile), "status") + capData, err := os.ReadFile(capFile) + if err != nil { + continue + } + statusData, _ := os.ReadFile(statusFile) + percentStr := strings.TrimSpace(string(capData)) + percent, _ := strconv.ParseFloat(percentStr, 64) + status := strings.TrimSpace(string(statusData)) + if status == "" { + status = "Unknown" + } + batts = append(batts, BatteryStatus{ + Percent: percent, + Status: status, + }) + } + if len(batts) > 0 { + return batts, nil + } + + return nil, errors.New("no battery data found") +} + +func collectSensors() ([]SensorReading, error) { + temps, err := host.SensorsTemperatures() + if err != nil { + return nil, err + } + sort.Slice(temps, func(i, j int) bool { + return temps[i].Temperature > temps[j].Temperature + }) + var out []SensorReading + for _, t := range temps { + if t.Temperature <= 0 || t.Temperature > 150 { + continue + } + out = append(out, SensorReading{ + Label: prettifyLabel(t.SensorKey), + Value: t.Temperature, + Unit: "°C", + }) + } + return out, nil +} + +func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) { + if runtime.GOOS == "darwin" { + if len(c.cachedGPU) > 0 && !c.lastGPUAt.IsZero() && now.Sub(c.lastGPUAt) < macGPUInfoTTL { + return c.cachedGPU, nil + } + if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 { + c.cachedGPU = gpus + c.lastGPUAt = now + return gpus, nil + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + + if !commandExists("nvidia-smi") { + return []GPUStatus{{ + Name: "No GPU metrics available", + Note: "Install nvidia-smi or use platform-specific metrics", + }}, nil + } + + out, err := runCmd(ctx, "nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,name", "--format=csv,noheader,nounits") + if err != nil { + return nil, err + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + var gpus []GPUStatus + for _, line := range lines { + fields := strings.Split(line, ",") + if len(fields) < 4 { + continue + } + util, _ := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64) + memUsed, _ := strconv.ParseFloat(strings.TrimSpace(fields[1]), 64) + memTotal, _ := strconv.ParseFloat(strings.TrimSpace(fields[2]), 64) + name := strings.TrimSpace(fields[3]) + + gpus = append(gpus, GPUStatus{ + Name: name, + Usage: util, + MemoryUsed: memUsed, + MemoryTotal: memTotal, + }) + } + + if len(gpus) == 0 { + return []GPUStatus{{ + Name: "GPU read failed", + Note: "Verify nvidia-smi availability", + }}, nil + } + + return gpus, nil +} + +func (c *Collector) collectBluetooth(now time.Time) []BluetoothDevice { + if len(c.lastBT) > 0 && !c.lastBTAt.IsZero() && now.Sub(c.lastBTAt) < bluetoothCacheTTL { + return c.lastBT + } + + if devs, err := readSystemProfilerBluetooth(); err == nil && len(devs) > 0 { + c.lastBTAt = now + c.lastBT = devs + return devs + } + + if devs, err := readBluetoothCTLDevices(); err == nil && len(devs) > 0 { + c.lastBTAt = now + c.lastBT = devs + return devs + } + + c.lastBTAt = now + if len(c.lastBT) == 0 { + c.lastBT = []BluetoothDevice{{Name: "No Bluetooth info", Connected: false}} + } + return c.lastBT +} + +func readSystemProfilerBluetooth() ([]BluetoothDevice, error) { + if runtime.GOOS != "darwin" || !commandExists("system_profiler") { + return nil, errors.New("system_profiler unavailable") + } + + ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPBluetoothDataType") + if err != nil { + return nil, err + } + return parseSPBluetooth(out), nil +} + +func readBluetoothCTLDevices() ([]BluetoothDevice, error) { + if !commandExists("bluetoothctl") { + return nil, errors.New("bluetoothctl unavailable") + } + + ctx, cancel := context.WithTimeout(context.Background(), bluetoothctlTimeout) + defer cancel() + + out, err := runCmd(ctx, "bluetoothctl", "info") + if err != nil { + return nil, err + } + return parseBluetoothctl(out), nil +} + +func readMacGPUInfo() ([]GPUStatus, error) { + ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout) + defer cancel() + + if !commandExists("system_profiler") { + return nil, errors.New("system_profiler unavailable") + } + + out, err := runCmd(ctx, "system_profiler", "-json", "SPDisplaysDataType") + if err != nil { + return nil, err + } + + var data struct { + Displays []struct { + Name string `json:"_name"` + VRAM string `json:"spdisplays_vram"` + Vendor string `json:"spdisplays_vendor"` + Metal string `json:"spdisplays_metal"` + } `json:"SPDisplaysDataType"` + } + if err := json.Unmarshal([]byte(out), &data); err != nil { + return nil, err + } + + var gpus []GPUStatus + for _, d := range data.Displays { + if d.Name == "" { + continue + } + noteParts := []string{} + if d.VRAM != "" { + noteParts = append(noteParts, "VRAM "+d.VRAM) + } + if d.Metal != "" { + noteParts = append(noteParts, d.Metal) + } + if d.Vendor != "" { + noteParts = append(noteParts, d.Vendor) + } + note := strings.Join(noteParts, " · ") + gpus = append(gpus, GPUStatus{ + Name: d.Name, + Usage: -1, + Note: note, + }) + } + + if len(gpus) == 0 { + return []GPUStatus{{ + Name: "GPU info unavailable", + Note: "Unable to parse system_profiler output", + }}, nil + } + + return gpus, nil +} + +func runCmd(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + output, err := cmd.Output() + if err != nil { + return "", err + } + return string(output), nil +} + +func commandExists(name string) bool { + if name == "" { + return false + } + defer func() { + if r := recover(); r != nil { + // If LookPath panics due to permissions or platform quirks, act as if the command is missing. + } + }() + _, err := exec.LookPath(name) + return err == nil +} + +func parseSPBluetooth(raw string) []BluetoothDevice { + lines := strings.Split(raw, "\n") + var devices []BluetoothDevice + var currentName string + var connected bool + var battery string + + for _, line := range lines { + trim := strings.TrimSpace(line) + if len(trim) == 0 { + continue + } + if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") { + // Reset at top-level sections + currentName = "" + connected = false + battery = "" + continue + } + if strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") { + if currentName != "" { + devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery}) + } + currentName = strings.TrimSuffix(trim, ":") + connected = false + battery = "" + continue + } + if strings.Contains(trim, "Connected:") { + connected = strings.Contains(trim, "Yes") + } + if strings.Contains(trim, "Battery Level:") { + battery = strings.TrimSpace(strings.TrimPrefix(trim, "Battery Level:")) + } + } + if currentName != "" { + devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery}) + } + if len(devices) == 0 { + return []BluetoothDevice{{Name: "No devices", Connected: false}} + } + return devices +} + +func parseBluetoothctl(raw string) []BluetoothDevice { + lines := strings.Split(raw, "\n") + var devices []BluetoothDevice + current := BluetoothDevice{} + for _, line := range lines { + trim := strings.TrimSpace(line) + if strings.HasPrefix(trim, "Device ") { + if current.Name != "" { + devices = append(devices, current) + } + current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false} + } + if strings.HasPrefix(trim, "Name:") { + current.Name = strings.TrimSpace(strings.TrimPrefix(trim, "Name:")) + } + if strings.HasPrefix(trim, "Connected:") { + current.Connected = strings.Contains(trim, "yes") + } + } + if current.Name != "" { + devices = append(devices, current) + } + if len(devices) == 0 { + return []BluetoothDevice{{Name: "No devices", Connected: false}} + } + return devices +} + +func parsePMSet(raw string) []BatteryStatus { + lines := strings.Split(raw, "\n") + var out []BatteryStatus + var timeLeft string + + for _, line := range lines { + // Check for time remaining + if strings.Contains(line, "remaining") { + // Extract time like "1:30 remaining" + parts := strings.Fields(line) + for i, p := range parts { + if p == "remaining" && i > 0 { + timeLeft = parts[i-1] + } + } + } + + if !strings.Contains(line, "%") { + continue + } + fields := strings.Fields(line) + var ( + percent float64 + found bool + status = "Unknown" + ) + for i, f := range fields { + if strings.Contains(f, "%") { + value := strings.TrimSuffix(strings.TrimSuffix(f, ";"), "%") + if p, err := strconv.ParseFloat(value, 64); err == nil { + percent = p + found = true + if i+1 < len(fields) { + status = strings.TrimSuffix(fields[i+1], ";") + } + } + break + } + } + if !found { + continue + } + + // Get battery health and cycle count + health, cycles := getBatteryHealth() + + out = append(out, BatteryStatus{ + Percent: percent, + Status: status, + TimeLeft: timeLeft, + Health: health, + CycleCount: cycles, + }) + } + return out +} + +func getBatteryHealth() (string, int) { + if runtime.GOOS != "darwin" { + return "", 0 + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPPowerDataType") + if err != nil { + return "", 0 + } + + var health string + var cycles int + + lines := strings.Split(out, "\n") + for _, line := range lines { + lower := strings.ToLower(line) + if strings.Contains(lower, "cycle count") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + cycles, _ = strconv.Atoi(strings.TrimSpace(parts[1])) + } + } + if strings.Contains(lower, "condition") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + health = strings.TrimSpace(parts[1]) + } + } + } + return health, cycles +} + +func collectThermal() ThermalStatus { + if runtime.GOOS != "darwin" { + return ThermalStatus{} + } + + var thermal ThermalStatus + + // Get fan info from system_profiler + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPPowerDataType") + if err == nil { + lines := strings.Split(out, "\n") + for _, line := range lines { + lower := strings.ToLower(line) + if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + // Extract number from string like "1200 RPM" + numStr := strings.TrimSpace(parts[1]) + numStr = strings.Split(numStr, " ")[0] + thermal.FanSpeed, _ = strconv.Atoi(numStr) + } + } + } + } + + // Try to get CPU temperature using sudo powermetrics (may not work without sudo) + // Fallback: use SMC reader or estimate from thermal pressure + ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel2() + + // Try thermal level as a proxy + out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level") + if err == nil { + level, _ := strconv.Atoi(strings.TrimSpace(out2)) + // Estimate temp: level 0-100 roughly maps to 40-100°C + if level >= 0 { + thermal.CPUTemp = 45 + float64(level)*0.5 + } + } + + return thermal +} + +func prettifyLabel(key string) string { + key = strings.TrimSpace(key) + key = strings.TrimPrefix(key, "TC") + key = strings.ReplaceAll(key, "_", " ") + return key +} + +func collectProxy() ProxyStatus { + // Check environment variables first + for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} { + if val := os.Getenv(env); val != "" { + proxyType := "HTTP" + if strings.HasPrefix(val, "socks") { + proxyType = "SOCKS" + } + // Extract host + host := val + if strings.Contains(host, "://") { + host = strings.SplitN(host, "://", 2)[1] + } + if idx := strings.Index(host, "@"); idx >= 0 { + host = host[idx+1:] + } + return ProxyStatus{Enabled: true, Type: proxyType, Host: host} + } + } + + // macOS: check system proxy via scutil + if runtime.GOOS == "darwin" { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + out, err := runCmd(ctx, "scutil", "--proxy") + if err == nil { + if strings.Contains(out, "HTTPEnable : 1") || strings.Contains(out, "HTTPSEnable : 1") { + return ProxyStatus{Enabled: true, Type: "System", Host: "System Proxy"} + } + if strings.Contains(out, "SOCKSEnable : 1") { + return ProxyStatus{Enabled: true, Type: "SOCKS", Host: "System Proxy"} + } + } + } + + return ProxyStatus{Enabled: false} +} + +func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo { + if runtime.GOOS != "darwin" { + return HardwareInfo{ + Model: "Unknown", + CPUModel: runtime.GOARCH, + TotalRAM: humanBytes(totalRAM), + DiskSize: "Unknown", + OSVersion: runtime.GOOS, + } + } + + // Get model and CPU from system_profiler + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + var model, cpuModel, osVersion string + + // Get hardware overview + out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType") + if err == nil { + lines := strings.Split(out, "\n") + for _, line := range lines { + lower := strings.ToLower(strings.TrimSpace(line)) + // Prefer "Model Name" over "Model Identifier" + if strings.Contains(lower, "model name:") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + model = strings.TrimSpace(parts[1]) + } + } + if strings.Contains(lower, "chip:") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + cpuModel = strings.TrimSpace(parts[1]) + } + } + if strings.Contains(lower, "processor name:") && cpuModel == "" { + parts := strings.Split(line, ":") + if len(parts) == 2 { + cpuModel = strings.TrimSpace(parts[1]) + } + } + } + } + + // Get macOS version + ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel2() + out2, err := runCmd(ctx2, "sw_vers", "-productVersion") + if err == nil { + osVersion = "macOS " + strings.TrimSpace(out2) + } + + // Get disk size + diskSize := "Unknown" + if len(disks) > 0 { + diskSize = humanBytes(disks[0].Total) + } + + return HardwareInfo{ + Model: model, + CPUModel: cpuModel, + TotalRAM: humanBytes(totalRAM), + DiskSize: diskSize, + OSVersion: osVersion, + } +} + diff --git a/cmd/status/view.go b/cmd/status/view.go new file mode 100644 index 0000000..1b256ac --- /dev/null +++ b/cmd/status/view.go @@ -0,0 +1,594 @@ +package main + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5FD7FF")).Bold(true) + subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C6C6C")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) + dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")) + lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4A4A4A")) +) + +const ( + colWidth = 38 + iconCPU = "⚙" + iconMemory = "▦" + iconGPU = "▣" + iconDisk = "▤" + iconNetwork = "⇅" + iconBattery = "▮" + iconSensors = "♨" + iconProcs = "▶" +) + +// Mole body frames (legs animate) +var moleBody = [][]string{ + { + ` /\_/\`, + ` ___/ o o \`, + `/___ =-= /`, + `\____)-m-m)`, + }, + { + ` /\_/\`, + ` ___/ o o \`, + `/___ =-= /`, + `\____)mm__)`, + }, + { + ` /\_/\`, + ` ___/ · · \`, + `/___ =-= /`, + `\___)-m__m)`, + }, + { + ` /\_/\`, + ` ___/ o o \`, + `/___ =-= /`, + `\____)-mm-)`, + }, +} + +// Generate frames with horizontal movement +func getMoleFrame(animFrame int, termWidth int) string { + bodyIdx := animFrame % len(moleBody) + body := moleBody[bodyIdx] + + // Calculate mole width (approximate) + moleWidth := 15 + // Move across terminal width + maxPos := termWidth - moleWidth + if maxPos < 0 { + maxPos = 0 + } + + // Move position: 0 -> maxPos -> 0 + cycleLength := maxPos * 2 + if cycleLength == 0 { + cycleLength = 1 + } + pos := animFrame % cycleLength + if pos > maxPos { + pos = cycleLength - pos + } + + padding := strings.Repeat(" ", pos) + var lines []string + for _, line := range body { + lines = append(lines, padding+line) + } + return strings.Join(lines, "\n") +} + +type cardData struct { + icon string + title string + lines []string +} + +func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string { + // Title + title := titleStyle.Render("Mole Status") + + // Health Score with color and label + scoreStyle := getScoreStyle(m.HealthScore) + scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore)) + + // Hardware info + infoParts := []string{} + if m.Hardware.Model != "" { + infoParts = append(infoParts, m.Hardware.Model) + } + if m.Hardware.CPUModel != "" { + infoParts = append(infoParts, m.Hardware.CPUModel) + } + if m.Hardware.TotalRAM != "" { + infoParts = append(infoParts, m.Hardware.TotalRAM) + } + if m.Hardware.DiskSize != "" { + infoParts = append(infoParts, m.Hardware.DiskSize) + } + if m.Hardware.OSVersion != "" { + infoParts = append(infoParts, m.Hardware.OSVersion) + } + + headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · ")) + + // Running mole animation + mole := getMoleFrame(animFrame, termWidth) + + if errMsg != "" { + return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render(errMsg), "") + } + return headerLine + "\n\n" + mole +} + +func getScoreStyle(score int) lipgloss.Style { + if score >= 90 { + // Excellent - Green + return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true) + } else if score >= 75 { + // Good - Light Green + return lipgloss.NewStyle().Foreground(lipgloss.Color("#AFD787")).Bold(true) + } else if score >= 60 { + // Fair - Yellow + return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) + } else if score >= 40 { + // Poor - Orange + return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true) + } else { + // Critical - Red + return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true) + } +} + +func buildCards(m MetricsSnapshot, _ int) []cardData { + // Row 1: CPU + Memory + // Row 2: Disk + Power + // Row 3: Top Processes + Network + cards := []cardData{ + renderCPUCard(m.CPU), + renderMemoryCard(m.Memory), + renderDiskCard(m.Disks, m.DiskIO), + renderBatteryCard(m.Batteries, m.Thermal), + renderProcessCard(m.TopProcesses), + renderNetworkCard(m.Network, m.Proxy), + } + // Only show GPU card if there are GPUs with usage data + if len(m.GPU) > 0 && m.GPU[0].Usage >= 0 { + cards = append(cards, renderGPUCard(m.GPU)) + } + // Only show sensors if we have valid temperature readings + if hasSensorData(m.Sensors) { + cards = append(cards, renderSensorsCard(m.Sensors)) + } + return cards +} + +func hasSensorData(sensors []SensorReading) bool { + for _, s := range sensors { + if s.Note == "" && s.Value > 0 { + return true + } + } + return false +} + +func renderCPUCard(cpu CPUStatus) cardData { + var lines []string + lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage)) + lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%d cores)", cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU))) + + // Show top 3 busiest cores + type coreUsage struct { + idx int + val float64 + } + var cores []coreUsage + for i, v := range cpu.PerCore { + cores = append(cores, coreUsage{i, v}) + } + sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val }) + + maxCores := 3 + if len(cores) < maxCores { + maxCores = len(cores) + } + for i := 0; i < maxCores; i++ { + c := cores[i] + lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val)) + } + + return cardData{icon: iconCPU, title: "CPU", lines: lines} +} + +func renderGPUCard(gpus []GPUStatus) cardData { + var lines []string + if len(gpus) == 0 { + lines = append(lines, subtleStyle.Render("No GPU detected")) + } else { + for _, g := range gpus { + name := shorten(g.Name, 12) + if g.Usage >= 0 { + lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, progressBar(g.Usage), g.Usage)) + } else { + lines = append(lines, name) + } + } + } + return cardData{icon: iconGPU, title: "GPU", lines: lines} +} + +func renderMemoryCard(mem MemoryStatus) cardData { + var lines []string + lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent)) + lines = append(lines, subtleStyle.Render(fmt.Sprintf("%s / %s total", humanBytes(mem.Used), humanBytes(mem.Total)))) + lines = append(lines, "") + // Show available memory + available := mem.Total - mem.Used + freePercent := 100 - mem.UsedPercent + lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent)) + lines = append(lines, subtleStyle.Render(fmt.Sprintf("%s available", humanBytes(available)))) + // Memory pressure + if mem.Pressure != "" { + pressureStyle := okStyle + pressureText := "Status " + mem.Pressure + if mem.Pressure == "warn" { + pressureStyle = warnStyle + } else if mem.Pressure == "critical" { + pressureStyle = dangerStyle + } + lines = append(lines, pressureStyle.Render(pressureText)) + } + return cardData{icon: iconMemory, title: "Memory", lines: lines} +} + +func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData { + var lines []string + // Show main disk + if len(disks) > 0 { + d := disks[0] + freeSpace := d.Total - d.Used + bar := diskBar(d.UsedPercent) + lines = append(lines, fmt.Sprintf("Used %s %4.0f%% (%s free)", bar, d.UsedPercent, humanBytes(freeSpace))) + } + // IO + readBar := ioBar(io.ReadRate) + writeBar := ioBar(io.WriteRate) + lines = append(lines, fmt.Sprintf("Read %s %.1f MB/s", readBar, io.ReadRate)) + lines = append(lines, fmt.Sprintf("Write %s %.1f MB/s", writeBar, io.WriteRate)) + return cardData{icon: iconDisk, title: "Disk", lines: lines} +} + +func diskBar(percent float64) string { + total := 16 + filled := int(percent / 100 * float64(total)) + if filled > total { + filled = total + } + bar := strings.Repeat("█", filled) + strings.Repeat("░", total-filled) + return colorizePercent(percent, bar) +} + +func ioBar(rate float64) string { + // Scale: 0-50 MB/s maps to 0-5 blocks + filled := int(rate / 10.0) + if filled > 5 { + filled = 5 + } + if filled < 0 { + filled = 0 + } + bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled) + if rate > 80 { + return dangerStyle.Render(bar) + } + if rate > 30 { + return warnStyle.Render(bar) + } + return okStyle.Render(bar) +} + +func renderProcessCard(procs []ProcessInfo) cardData { + var lines []string + maxProcs := 3 + for i, p := range procs { + if i >= maxProcs { + break + } + name := shorten(p.Name, 12) + cpuBar := miniBar(p.CPU) + lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, cpuBar, p.CPU)) + } + if len(lines) == 0 { + lines = append(lines, subtleStyle.Render("No data")) + } + return cardData{icon: iconProcs, title: "Processes", lines: lines} +} + +func miniBar(percent float64) string { + filled := int(percent / 20) // 5 chars max for 100% + if filled > 5 { + filled = 5 + } + if filled < 0 { + filled = 0 + } + return colorizePercent(percent, strings.Repeat("▮", filled)+strings.Repeat("▯", 5-filled)) +} + +func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData { + var lines []string + var totalRx, totalTx float64 + var primaryIP string + + for _, n := range netStats { + totalRx += n.RxRateMBs + totalTx += n.TxRateMBs + if primaryIP == "" && n.IP != "" && n.Name == "en0" { + primaryIP = n.IP + } + } + + if len(netStats) == 0 { + lines = []string{subtleStyle.Render("Collecting...")} + } else { + rxBar := netBar(totalRx) + txBar := netBar(totalTx) + lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx))) + lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx))) + // Proxy + IP + info := "" + if proxy.Enabled { + info = okStyle.Render("Proxy: " + proxy.Type) + } + if primaryIP != "" { + if info != "" { + info += " · " + } + info += primaryIP + } + if info != "" { + lines = append(lines, subtleStyle.Render(info)) + } + } + return cardData{icon: iconNetwork, title: "Network", lines: lines} +} + +func netBar(rate float64) string { + // Scale: 0-10 MB/s maps to 0-5 blocks + filled := int(rate / 2.0) + if filled > 5 { + filled = 5 + } + if filled < 0 { + filled = 0 + } + bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled) + if rate > 8 { + return dangerStyle.Render(bar) + } + if rate > 3 { + return warnStyle.Render(bar) + } + return okStyle.Render(bar) +} + +func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { + var lines []string + if len(batts) == 0 { + lines = append(lines, subtleStyle.Render("No battery")) + } else { + b := batts[0] + // Line 1: label + percentage + bar + lines = append(lines, fmt.Sprintf("Level %3.0f%% %s", b.Percent, progressBar(b.Percent))) + + // Line 2: status + statusIcon := "" + statusStyle := subtleStyle + statusLower := strings.ToLower(b.Status) + if statusLower == "charging" || statusLower == "charged" { + statusIcon = " ⚡" + statusStyle = okStyle + } else if b.Percent < 20 { + statusStyle = dangerStyle + } + // Capitalize first letter + statusText := b.Status + if len(statusText) > 0 { + statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:]) + } + if b.TimeLeft != "" { + statusText += " · " + b.TimeLeft + } + lines = append(lines, statusStyle.Render(statusText+statusIcon)) + + // Line 3: Health + cycles + healthParts := []string{} + if b.Health != "" { + healthParts = append(healthParts, b.Health) + } + if b.CycleCount > 0 { + healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount)) + } + if len(healthParts) > 0 { + lines = append(lines, subtleStyle.Render(strings.Join(healthParts, " · "))) + } + + // Line 4: Temp + Fan combined + var thermalParts []string + if thermal.CPUTemp > 0 { + tempStyle := okStyle + if thermal.CPUTemp > 80 { + tempStyle = dangerStyle + } else if thermal.CPUTemp > 60 { + tempStyle = warnStyle + } + thermalParts = append(thermalParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp))) + } + if thermal.FanSpeed > 0 { + thermalParts = append(thermalParts, fmt.Sprintf("%d RPM", thermal.FanSpeed)) + } + if len(thermalParts) > 0 { + lines = append(lines, strings.Join(thermalParts, " · ")) + } + } + return cardData{icon: iconBattery, title: "Power", lines: lines} +} + +func renderSensorsCard(sensors []SensorReading) cardData { + var lines []string + for _, s := range sensors { + if s.Note != "" { + continue + } + lines = append(lines, fmt.Sprintf("%-12s %s", shorten(s.Label, 12), colorizeTemp(s.Value)+s.Unit)) + } + if len(lines) == 0 { + lines = append(lines, subtleStyle.Render("No sensors")) + } + return cardData{icon: iconSensors, title: "Sensors", lines: lines} +} + + +func renderCard(data cardData, width int, height int) string { + titleText := data.icon + " " + data.title + lineLen := width - lipgloss.Width(titleText) - 1 + if lineLen < 4 { + lineLen = 4 + } + header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("─", lineLen)) + content := header + "\n" + strings.Join(data.lines, "\n") + "\n" + + // Pad to target height + lines := strings.Split(content, "\n") + for len(lines) < height { + lines = append(lines, "") + } + return strings.Join(lines, "\n") +} + +func progressBar(percent float64) string { + total := 18 + if percent < 0 { + percent = 0 + } + if percent > 100 { + percent = 100 + } + filled := int(percent / 100 * float64(total)) + if filled > total { + filled = total + } + + var builder strings.Builder + for i := 0; i < total; i++ { + if i < filled { + builder.WriteString("█") + } else { + builder.WriteString("░") + } + } + return colorizePercent(percent, builder.String()) +} + +func colorizePercent(percent float64, s string) string { + switch { + case percent >= 90: + return dangerStyle.Render(s) + case percent >= 70: + return warnStyle.Render(s) + default: + return okStyle.Render(s) + } +} + +func colorizeTemp(t float64) string { + switch { + case t >= 85: + return dangerStyle.Render(fmt.Sprintf("%.1f", t)) + case t >= 70: + return warnStyle.Render(fmt.Sprintf("%.1f", t)) + default: + return subtleStyle.Render(fmt.Sprintf("%.1f", t)) + } +} + +func formatRate(mb float64) string { + if mb < 0.01 { + return "0 MB/s" + } + if mb < 1 { + return fmt.Sprintf("%.2f MB/s", mb) + } + if mb < 10 { + return fmt.Sprintf("%.1f MB/s", mb) + } + return fmt.Sprintf("%.0f MB/s", mb) +} + +func humanBytes(v uint64) string { + switch { + case v > 1<<40: + return fmt.Sprintf("%.1f TB", float64(v)/(1<<40)) + case v > 1<<30: + return fmt.Sprintf("%.1f GB", float64(v)/(1<<30)) + case v > 1<<20: + return fmt.Sprintf("%.1f MB", float64(v)/(1<<20)) + case v > 1<<10: + return fmt.Sprintf("%.1f KB", float64(v)/(1<<10)) + default: + return strconv.FormatUint(v, 10) + " B" + } +} + +func shorten(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} + +func renderTwoColumns(cards []cardData, width int) string { + if len(cards) == 0 { + return "" + } + cw := colWidth + if width > 0 && width/2-2 > cw { + cw = width/2 - 2 + } + var rows []string + for i := 0; i < len(cards); i += 2 { + left := renderCard(cards[i], cw, 0) + right := "" + if i+1 < len(cards) { + right = renderCard(cards[i+1], cw, 0) + } + targetHeight := maxInt(lipgloss.Height(left), lipgloss.Height(right)) + left = renderCard(cards[i], cw, targetHeight) + if right != "" { + right = renderCard(cards[i+1], cw, targetHeight) + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)) + } else { + rows = append(rows, left) + } + } + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + diff --git a/go.mod b/go.mod index 3e2b2c7..6121a86 100644 --- a/go.mod +++ b/go.mod @@ -7,26 +7,34 @@ toolchain go1.24.6 require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/shirou/gopsutil/v3 v3.24.5 golang.org/x/sync v0.18.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/shoenig/go-m1cpu v0.1.7 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index ec34015..629db4e 100644 --- a/go.sum +++ b/go.sum @@ -14,10 +14,19 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -30,18 +39,43 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0= +github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mole b/mole index 882cfb6..3dc3986 100755 --- a/mole +++ b/mole @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/common.sh" # Version info -VERSION="1.9.20" +VERSION="1.10.0" MOLE_TAGLINE="can dig deep to clean your Mac." # Get latest version from remote repository @@ -169,6 +169,7 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall" "$NC" "Remove applications completely" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize" "$NC" "System health check & optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo analyze" "$NC" "Interactive disk space explorer" + printf " %s%-28s%s %s\n" "$GREEN" "mo status" "$NC" "System status dashboard" printf " %s%-28s%s %s\n" "$GREEN" "mo touchid" "$NC" "Configure Touch ID for sudo" printf " %s%-28s%s %s\n" "$GREEN" "mo update" "$NC" "Update Mole to the latest version" printf " %s%-28s%s %s\n" "$GREEN" "mo remove" "$NC" "Remove Mole from the system" @@ -503,6 +504,7 @@ show_main_menu() { printf '\r\033[2K%s\n' "$(show_menu_option 2 "Uninstall Apps - Remove applications completely" "$([[ $selected -eq 2 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 3 "Optimize Mac - System health & tuning" "$([[ $selected -eq 3 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 4 "Analyze Disk - Interactive space explorer" "$([[ $selected -eq 4 ]] && echo true || echo false)")" + printf '\r\033[2K%s\n' "$(show_menu_option 5 "System Status - Live CPU/GPU/memory" "$([[ $selected -eq 5 ]] && echo true || echo false)")" if [[ -t 0 ]]; then printf '\r\033[2K\n' @@ -566,7 +568,7 @@ interactive_main_menu() { case "$key" in "UP") ((current_option > 1)) && ((current_option--)) ;; - "DOWN") ((current_option < 4)) && ((current_option++)) ;; + "DOWN") ((current_option < 5)) && ((current_option++)) ;; "ENTER") show_cursor case $current_option in @@ -574,6 +576,7 @@ interactive_main_menu() { 2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;; 3) exec "$SCRIPT_DIR/bin/optimize.sh" ;; 4) exec "$SCRIPT_DIR/bin/analyze.sh" ;; + 5) exec "$SCRIPT_DIR/bin/status.sh" ;; esac ;; "CHAR:1") @@ -592,6 +595,10 @@ interactive_main_menu() { show_cursor exec "$SCRIPT_DIR/bin/analyze.sh" ;; + "CHAR:5") + show_cursor + exec "$SCRIPT_DIR/bin/status.sh" + ;; "HELP") show_cursor clear @@ -626,6 +633,9 @@ main() { "analyze") exec "$SCRIPT_DIR/bin/analyze.sh" "${@:2}" ;; + "status") + exec "$SCRIPT_DIR/bin/status.sh" "${@:2}" + ;; "touchid") exec "$SCRIPT_DIR/bin/touchid.sh" "${@:2}" ;; diff --git a/scripts/build-status.sh b/scripts/build-status.sh new file mode 100755 index 0000000..175df19 --- /dev/null +++ b/scripts/build-status.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Build Universal Binary for status-go +# Supports both Apple Silicon and Intel Macs + +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v go > /dev/null 2>&1; then + echo "Error: Go not installed" + echo "Install: brew install go" + exit 1 +fi + +echo "Building status-go for multiple architectures..." + +VERSION=$(git describe --tags --always --dirty 2> /dev/null || echo "dev") +BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') +LDFLAGS="-s -w -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME" + +echo " Version: $VERSION" +echo " Build time: $BUILD_TIME" +echo "" + +echo " → Building for arm64..." +GOARCH=arm64 go build -ldflags="$LDFLAGS" -trimpath -o bin/status-go-arm64 ./cmd/status + +echo " → Building for amd64..." +GOARCH=amd64 go build -ldflags="$LDFLAGS" -trimpath -o bin/status-go-amd64 ./cmd/status + +echo " → Creating Universal Binary..." +lipo -create bin/status-go-arm64 bin/status-go-amd64 -output bin/status-go + +rm bin/status-go-arm64 bin/status-go-amd64 + +echo "" +echo "✓ Build complete!" +echo "" +file bin/status-go +size_bytes=$(stat -f%z bin/status-go 2> /dev/null || echo 0) +size_mb=$((size_bytes / 1024 / 1024)) +printf "Size: %d MB (%d bytes)\n" "$size_mb" "$size_bytes" +echo "" +echo "Binary supports: arm64 (Apple Silicon) + x86_64 (Intel)"