mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-09-02 10:19:34 +00:00
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:
co-authored by
Claude Opus 4.5
Matthew Breedlove
parent
b6426bf707
commit
3d43476d6b
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user