From 3d43476d6baf9bb9b28eb078b2548dc83effa35d Mon Sep 17 00:00:00 2001 From: Anthony Vincent Caracappa Date: Tue, 24 Feb 2026 14:20:00 -0500 Subject: [PATCH] Free Memory Widget (#155) * FreeMemory widget: Description: Shows system memory usage (used/total) in a compact format like Mem: 12.4G/16.0G Features: - On macOS: Uses vm_stat to calculate memory like htop (Active + Wired pages) - On other platforms: Falls back to Node.js os.freemem()/os.totalmem() - Smart byte formatting (G, M, K, B) - Supports raw value mode (omits "Mem:" prefix) - Default color: cyan Co-Authored-By: Claude Opus 4.5 * Version bump and README update --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Matthew Breedlove --- README.md | 4 +- package.json | 2 +- src/utils/widgets.ts | 3 +- src/widgets/FreeMemory.ts | 96 ++++++++++++ src/widgets/__tests__/FreeMemory.test.ts | 191 +++++++++++++++++++++++ src/widgets/index.ts | 1 + 6 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 src/widgets/FreeMemory.ts create mode 100644 src/widgets/__tests__/FreeMemory.test.ts diff --git a/README.md b/README.md index 3230d27..2363d0a 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ ## 🆕 Recent Updates -### v2.0.26 - v2.0.28 - Performance, git internals, and workflow improvements +### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements +- **🧠 Memory Usage widget (v2.0.29)** - Added a new widget that shows current system memory usage (`Mem: used/total`). - **⚡ Block timer cache (v2.0.28)** - Cache block timer metrics to reduce JSONL parsing on every render, with per-config hashed cache files and automatic 5-hour block invalidation. - **🧱 Git widget command refactor (v2.0.28)** - Refactored git widgets to use shared git command helpers and expanded coverage for failure and edge-case tests. - **🪟 Windows UTF-8 piped output fix (v2.0.28)** - Sets the Windows UTF-8 code page for piped status line rendering. @@ -381,6 +382,7 @@ Once configured, ccstatusline automatically formats your Claude Code status line - **Context Percentage** - Shows percentage of context limit used (dynamic: 1M for Sonnet 4.5 with `[1m]` suffix, 200k otherwise) - **Context Percentage (usable)** - Shows percentage of usable context (dynamic: 800k for Sonnet 4.5 with `[1m]` suffix, 160k otherwise, accounting for auto-compact at 80%) - **Terminal Width** - Shows detected terminal width (for debugging) +- **Memory Usage** - Shows system memory usage (used/total, e.g., "Mem: 12.4G/16.0G") - **Custom Text** - Add your own custom text to the status line - **Custom Command** - Execute shell commands and display their output (refreshes whenever the statusline is updated by Claude Code) - **Separator** - Visual divider between widgets (customizable: |, -, comma, space) diff --git a/package.json b/package.json index dbb0749..edd7f0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.0.28", + "version": "2.0.29", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 9d013ac..0e2e226 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -29,7 +29,8 @@ const widgetRegistry = new Map([ ['custom-text', new widgets.CustomTextWidget()], ['custom-command', new widgets.CustomCommandWidget()], ['claude-session-id', new widgets.ClaudeSessionIdWidget()], - ['session-name', new widgets.SessionNameWidget()] + ['session-name', new widgets.SessionNameWidget()], + ['free-memory', new widgets.FreeMemoryWidget()] ]); export function getWidget(type: WidgetItemType): Widget | null { diff --git a/src/widgets/FreeMemory.ts b/src/widgets/FreeMemory.ts new file mode 100644 index 0000000..6cffbc5 --- /dev/null +++ b/src/widgets/FreeMemory.ts @@ -0,0 +1,96 @@ +import { execSync } from 'child_process'; +import os from 'os'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +function formatBytes(bytes: number): string { + const GB = 1024 ** 3; + const MB = 1024 ** 2; + const KB = 1024; + + if (bytes >= GB) + return `${(bytes / GB).toFixed(1)}G`; + if (bytes >= MB) + return `${(bytes / MB).toFixed(0)}M`; + if (bytes >= KB) + return `${(bytes / KB).toFixed(0)}K`; + return `${bytes}B`; +} + +// Get memory usage like htop does on macOS (Active + Wired) +function getUsedMemoryMacOS(): number | null { + try { + const output = execSync('vm_stat', { encoding: 'utf8' }); + const lines = output.split('\n'); + + // Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)" + const firstLine = lines[0]; + if (!firstLine) + return null; + + const pageSizeMatch = /page size of (\d+) bytes/.exec(firstLine); + const pageSizeString = pageSizeMatch?.[1]; + if (!pageSizeString) + return null; + const pageSize = parseInt(pageSizeString, 10); + + // Parse page counts + let activePages = 0; + let wiredPages = 0; + + for (const line of lines) { + const activeMatch = /Pages active:\s+(\d+)/.exec(line); + const activeValue = activeMatch?.[1]; + if (activeValue) + activePages = parseInt(activeValue, 10); + const wiredMatch = /Pages wired down:\s+(\d+)/.exec(line); + const wiredValue = wiredMatch?.[1]; + if (wiredValue) + wiredPages = parseInt(wiredValue, 10); + } + + return (activePages + wiredPages) * pageSize; + } catch { + return null; + } +} + +export class FreeMemoryWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows system memory usage (used/total)'; } + getDisplayName(): string { return 'Memory Usage'; } + getCategory(): string { return 'Environment'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? '12.4G/16.0G' : 'Mem: 12.4G/16.0G'; + } + + const total = os.totalmem(); + let used: number; + + if (os.platform() === 'darwin') { + // Use htop-style calculation on macOS + used = getUsedMemoryMacOS() ?? (total - os.freemem()); + } else { + // Fallback for other platforms + used = total - os.freemem(); + } + + const value = `${formatBytes(used)}/${formatBytes(total)}`; + + return item.rawValue ? value : `Mem: ${value}`; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/FreeMemory.test.ts b/src/widgets/__tests__/FreeMemory.test.ts new file mode 100644 index 0000000..21b5ae8 --- /dev/null +++ b/src/widgets/__tests__/FreeMemory.test.ts @@ -0,0 +1,191 @@ +import * as childProcess from 'child_process'; +import os from 'os'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { FreeMemoryWidget } from '../FreeMemory'; + +describe('FreeMemoryWidget', () => { + const widget = new FreeMemoryWidget(); + let totalmemSpy = vi.spyOn(os, 'totalmem'); + let freememSpy = vi.spyOn(os, 'freemem'); + let platformSpy = vi.spyOn(os, 'platform'); + let execSyncSpy = vi.spyOn(childProcess, 'execSync'); + + beforeEach(() => { + totalmemSpy = vi.spyOn(os, 'totalmem'); + freememSpy = vi.spyOn(os, 'freemem'); + platformSpy = vi.spyOn(os, 'platform'); + execSyncSpy = vi.spyOn(childProcess, 'execSync'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('metadata', () => { + it('should return correct display name', () => { + expect(widget.getDisplayName()).toBe('Memory Usage'); + }); + + it('should return correct description', () => { + expect(widget.getDescription()).toBe('Shows system memory usage (used/total)'); + }); + + it('should return cyan as default color', () => { + expect(widget.getDefaultColor()).toBe('cyan'); + }); + + it('should support raw value', () => { + expect(widget.supportsRawValue()).toBe(true); + }); + + it('should support colors', () => { + const item: WidgetItem = { id: 'mem', type: 'free-memory' }; + expect(widget.supportsColors(item)).toBe(true); + }); + }); + + describe('preview mode', () => { + it('should return mock data with label in preview mode', () => { + const context: RenderContext = { isPreview: true }; + const item: WidgetItem = { id: 'mem', type: 'free-memory' }; + + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('Mem: 12.4G/16.0G'); + }); + + it('should return mock data without label in preview mode when rawValue is true', () => { + const context: RenderContext = { isPreview: true }; + const item: WidgetItem = { id: 'mem', type: 'free-memory', rawValue: true }; + + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('12.4G/16.0G'); + }); + }); + + describe('render on macOS (vm_stat)', () => { + beforeEach(() => { + platformSpy.mockReturnValue('darwin'); + totalmemSpy.mockReturnValue(16 * 1024 ** 3); // 16GB total + }); + + it('should calculate used memory from vm_stat (active + wired)', () => { + // Page size 16384, active 500000 pages, wired 100000 pages + // Used = (500000 + 100000) * 16384 = 9,830,400,000 bytes ≈ 9.2G + execSyncSpy.mockReturnValue(`Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages free: 100000. +Pages active: 500000. +Pages inactive: 200000. +Pages speculative: 10000. +Pages throttled: 0. +Pages wired down: 100000. +Pages purgeable: 5000. +`); + + const context: RenderContext = {}; + const item: WidgetItem = { id: 'mem', type: 'free-memory' }; + + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('Mem: 9.2G/16.0G'); + }); + + it('should show raw value without label', () => { + execSyncSpy.mockReturnValue(`Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages free: 100000. +Pages active: 500000. +Pages inactive: 200000. +Pages wired down: 100000. +`); + + const context: RenderContext = {}; + const item: WidgetItem = { id: 'mem', type: 'free-memory', rawValue: true }; + + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('9.2G/16.0G'); + }); + + it('should fallback to os.freemem if vm_stat fails', () => { + execSyncSpy.mockImplementation(() => { + throw new Error('command not found'); + }); + freememSpy.mockReturnValue(8 * 1024 ** 3); // 8GB free -> 8GB used + + const context: RenderContext = {}; + const item: WidgetItem = { id: 'mem', type: 'free-memory', rawValue: true }; + + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('8.0G/16.0G'); + }); + + it('should fallback if vm_stat output is malformed', () => { + execSyncSpy.mockReturnValue('garbage output'); + freememSpy.mockReturnValue(4 * 1024 ** 3); // 4GB free -> 12GB used + + const context: RenderContext = {}; + const item: WidgetItem = { id: 'mem', type: 'free-memory', rawValue: true }; + + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('12.0G/16.0G'); + }); + }); + + describe('render on non-macOS (os.freemem fallback)', () => { + beforeEach(() => { + platformSpy.mockReturnValue('linux'); + }); + + it('should use total - free calculation on Linux', () => { + freememSpy.mockReturnValue(8 * 1024 ** 3); // 8GB free + totalmemSpy.mockReturnValue(16 * 1024 ** 3); // 16GB total -> 8GB used + + const context: RenderContext = {}; + const item: WidgetItem = { id: 'mem', type: 'free-memory' }; + + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('Mem: 8.0G/16.0G'); + }); + + it('should handle fractional gigabytes', () => { + freememSpy.mockReturnValue(4.5 * 1024 ** 3); // 4.5GB free + totalmemSpy.mockReturnValue(32 * 1024 ** 3); // 32GB total -> 27.5GB used + + const context: RenderContext = {}; + const item: WidgetItem = { id: 'mem', type: 'free-memory', rawValue: true }; + + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('27.5G/32.0G'); + }); + + it('should handle megabyte values', () => { + freememSpy.mockReturnValue(512 * 1024 ** 2); // 512MB free + totalmemSpy.mockReturnValue(1024 * 1024 ** 2); // 1GB total -> 512MB used + + const context: RenderContext = {}; + const item: WidgetItem = { id: 'mem', type: 'free-memory', rawValue: true }; + + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('512M/1.0G'); + }); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index aa7e7b4..78e8d49 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -20,4 +20,5 @@ export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; export { ClaudeSessionIdWidget } from './ClaudeSessionId'; +export { FreeMemoryWidget } from './FreeMemory'; export { SessionNameWidget } from './SessionName'; \ No newline at end of file