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 <noreply@anthropic.com>

* Version bump and README update

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
Anthony Vincent Caracappa
2026-02-24 14:20:00 -05:00
committed by GitHub
co-authored by Claude Opus 4.5 Matthew Breedlove
parent b6426bf707
commit 3d43476d6b
6 changed files with 294 additions and 3 deletions
+3 -1
View File
@@ -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)
+1 -1
View File
@@ -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",
+2 -1
View File
@@ -29,7 +29,8 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
['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 {
+96
View File
@@ -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; }
}
+191
View File
@@ -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');
});
});
});
+1
View File
@@ -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';