diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index 103208b..1ee928d 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -83,7 +83,8 @@ type MemoryStatus struct { UsedPercent float64 SwapUsed uint64 SwapTotal uint64 - Pressure string // macOS memory pressure: normal/warn/critical + Cached uint64 // File cache that can be freed if needed + Pressure string // macOS memory pressure: normal/warn/critical } type DiskStatus struct { @@ -115,6 +116,7 @@ type BatteryStatus struct { TimeLeft string Health string CycleCount int + Capacity int // Maximum capacity percentage (e.g., 85 means 85% of original) } type ThermalStatus struct { diff --git a/cmd/status/metrics_battery.go b/cmd/status/metrics_battery.go index bbf4f73..ecdb463 100644 --- a/cmd/status/metrics_battery.go +++ b/cmd/status/metrics_battery.go @@ -32,9 +32,9 @@ func collectBatteries() (batts []BatteryStatus, err error) { // macOS: pmset for real-time percentage/status. if runtime.GOOS == "darwin" && commandExists("pmset") { if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { - // Health/cycles from cached system_profiler. - health, cycles := getCachedPowerData() - if batts := parsePMSet(out, health, cycles); len(batts) > 0 { + // Health/cycles/capacity from cached system_profiler. + health, cycles, capacity := getCachedPowerData() + if batts := parsePMSet(out, health, cycles, capacity); len(batts) > 0 { return batts, nil } } @@ -67,7 +67,7 @@ func collectBatteries() (batts []BatteryStatus, err error) { return nil, errors.New("no battery data found") } -func parsePMSet(raw string, health string, cycles int) []BatteryStatus { +func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus { lines := strings.Split(raw, "\n") var out []BatteryStatus var timeLeft string @@ -115,16 +115,17 @@ func parsePMSet(raw string, health string, cycles int) []BatteryStatus { TimeLeft: timeLeft, Health: health, CycleCount: cycles, + Capacity: capacity, }) } return out } -// getCachedPowerData returns condition and cycles from cached system_profiler. -func getCachedPowerData() (health string, cycles int) { +// getCachedPowerData returns condition, cycles, and capacity from cached system_profiler. +func getCachedPowerData() (health string, cycles int, capacity int) { out := getSystemPowerOutput() if out == "" { - return "", 0 + return "", 0, 0 } lines := strings.Split(out, "\n") @@ -140,8 +141,15 @@ func getCachedPowerData() (health string, cycles int) { health = strings.TrimSpace(after) } } + if strings.Contains(lower, "maximum capacity") { + if _, after, found := strings.Cut(line, ":"); found { + capacityStr := strings.TrimSpace(after) + capacityStr = strings.TrimSuffix(capacityStr, "%") + capacity, _ = strconv.Atoi(strings.TrimSpace(capacityStr)) + } + } } - return health, cycles + return health, cycles, capacity } func getSystemPowerOutput() string { diff --git a/cmd/status/metrics_memory.go b/cmd/status/metrics_memory.go index e23c258..5851dd5 100644 --- a/cmd/status/metrics_memory.go +++ b/cmd/status/metrics_memory.go @@ -3,6 +3,7 @@ package main import ( "context" "runtime" + "strconv" "strings" "time" @@ -18,16 +19,62 @@ func collectMemory() (MemoryStatus, error) { swap, _ := mem.SwapMemory() pressure := getMemoryPressure() + // On macOS, vm.Cached is 0, so we calculate from file-backed pages. + cached := vm.Cached + if runtime.GOOS == "darwin" && cached == 0 { + cached = getFileBackedMemory() + } + return MemoryStatus{ Used: vm.Used, Total: vm.Total, UsedPercent: vm.UsedPercent, SwapUsed: swap.Used, SwapTotal: swap.Total, + Cached: cached, Pressure: pressure, }, nil } +func getFileBackedMemory() uint64 { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + out, err := runCmd(ctx, "vm_stat") + if err != nil { + return 0 + } + + // Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)" + var pageSize uint64 = 4096 // Default + lines := strings.Split(out, "\n") + if len(lines) > 0 { + firstLine := lines[0] + if strings.Contains(firstLine, "page size of") { + if _, after, found := strings.Cut(firstLine, "page size of "); found { + if before, _, found := strings.Cut(after, " bytes"); found { + if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil { + pageSize = size + } + } + } + } + } + + // Parse "File-backed pages: 388975." + for _, line := range lines { + if strings.Contains(line, "File-backed pages:") { + if _, after, found := strings.Cut(line, ":"); found { + numStr := strings.TrimSpace(after) + numStr = strings.TrimSuffix(numStr, ".") + if pages, err := strconv.ParseUint(numStr, 10, 64); err == nil { + return pages * pageSize + } + } + } + } + return 0 +} + func getMemoryPressure() string { if runtime.GOOS != "darwin" { return "" diff --git a/cmd/status/view.go b/cmd/status/view.go index bead680..2a18fa5 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -129,7 +129,7 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int infoParts = append(infoParts, m.Hardware.OSVersion) } - headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · ")) + headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ") mole := getMoleFrame(animFrame, termWidth) @@ -181,14 +181,6 @@ func renderCPUCard(cpu CPUStatus) cardData { var lines []string lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage)) - if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { - lines = append(lines, subtleStyle.Render(fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)", - cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount))) - } else { - lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%d cores)", - cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU))) - } - if cpu.PerCoreEstimated { lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)")) } else if len(cpu.PerCore) > 0 { @@ -212,6 +204,15 @@ func renderCPUCard(cpu CPUStatus) cardData { } } + // Load line at the end + if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { + lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)", + cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount)) + } else { + lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%d cores)", + cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU)) + } + return cardData{icon: iconCPU, title: "CPU", lines: lines} } @@ -226,9 +227,9 @@ func renderGPUCard(gpus []GPUStatus) cardData { } coreInfo := "" if g.CoreCount > 0 { - coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount) + coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount) } - lines = append(lines, subtleStyle.Render(g.Name+coreInfo)) + lines = append(lines, g.Name+coreInfo) if g.Usage < 0 { lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics")) } @@ -238,22 +239,48 @@ func renderGPUCard(gpus []GPUStatus) cardData { } func renderMemoryCard(mem MemoryStatus) cardData { + // Check if swap is being used (or at least allocated). + hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0 + var lines []string + // Line 1: Used 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)))) - available := mem.Total - mem.Used + + // Line 2: Free 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)))) - if mem.SwapTotal > 0 || mem.SwapUsed > 0 { + + if hasSwap { + // Layout with Swap: + // 3. Swap (progress bar + text) + // 4. Total + // 5. Avail var swapPercent float64 if mem.SwapTotal > 0 { swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0 } - swapText := subtleStyle.Render(fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal))) + swapText := fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal)) lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText)) + + lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) + lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency } else { - lines = append(lines, fmt.Sprintf("Swap %s", subtleStyle.Render("not in use"))) + // Layout without Swap: + // 3. Total + // 4. Cached (if > 0) + // 5. Avail + lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) + + if mem.Cached > 0 { + lines = append(lines, fmt.Sprintf("Cached %s", humanBytes(mem.Cached))) + } + // Calculate available if not provided directly, or use Total-Used as proxy if needed, + // but typically available is more nuanced. Using what we have. + // Re-calculating available based on logic if needed, but mem.Total - mem.Used is often "Avail" + // in simple terms for this view or we could use the passed definition. + // Original code calculated: available := mem.Total - mem.Used + available := mem.Total - mem.Used + lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(available))) } // Memory pressure status. if mem.Pressure != "" { @@ -400,7 +427,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData { infoParts = append(infoParts, primaryIP) } if len(infoParts) > 0 { - lines = append(lines, subtleStyle.Render(strings.Join(infoParts, " · "))) + lines = append(lines, strings.Join(infoParts, " · ")) } } return cardData{icon: iconNetwork, title: "Network", lines: lines} @@ -437,6 +464,17 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { } lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText)) + // Add capacity line if available. + if b.Capacity > 0 { + capacityText := fmt.Sprintf("%5d%%", b.Capacity) + if b.Capacity < 70 { + capacityText = dangerStyle.Render(capacityText) + } else if b.Capacity < 85 { + capacityText = warnStyle.Render(capacityText) + } + lines = append(lines, fmt.Sprintf("Health %s %s", batteryProgressBar(float64(b.Capacity)), capacityText)) + } + statusIcon := "" statusStyle := subtleStyle if statusLower == "charging" || statusLower == "charged" { @@ -473,13 +511,13 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { } if thermal.CPUTemp > 0 { - tempStyle := subtleStyle + tempText := fmt.Sprintf("%.0f°C", thermal.CPUTemp) if thermal.CPUTemp > 80 { - tempStyle = dangerStyle + tempText = dangerStyle.Render(tempText) } else if thermal.CPUTemp > 60 { - tempStyle = warnStyle + tempText = warnStyle.Render(tempText) } - healthParts = append(healthParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp))) + healthParts = append(healthParts, tempText) } if thermal.FanSpeed > 0 { @@ -487,7 +525,7 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { } if len(healthParts) > 0 { - lines = append(lines, subtleStyle.Render(strings.Join(healthParts, " · "))) + lines = append(lines, strings.Join(healthParts, " · ")) } }