Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
{ type: 'worktree-name', create: () => new widgets.GitWorktreeNameWidget() },
{ type: 'worktree-branch', create: () => new widgets.GitWorktreeBranchWidget() },
{ type: 'worktree-original-branch', create: () => new widgets.GitWorktreeOriginalBranchWidget() },
{ type: 'compaction-counter', create: () => new widgets.CompactionCounterWidget() }
{ type: 'compaction-counter', create: () => new widgets.CompactionCounterWidget() },
{ type: 'claude-memory', create: () => new widgets.ClaudeMemoryWidget() }
];

export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [
Expand Down
98 changes: 98 additions & 0 deletions src/widgets/ClaudeMemory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as fs from 'fs';
import * as path from 'path';

import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';

const MEMORY_INDEX_FILE = 'MEMORY.md';
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;

interface MemoryStats {
count: number;
newestMtimeMs: number;
}

function getMemoryStats(context: RenderContext): MemoryStats | null {
const transcriptPath = context.data?.transcript_path;
if (!transcriptPath) {
return null;
}

try {
const memoryDir = path.join(path.dirname(transcriptPath), 'memory');
let count = 0;
let newestMtimeMs = 0;

for (const name of fs.readdirSync(memoryDir)) {
if (!name.toLowerCase().endsWith('.md')) {
continue;
}

const stats = fs.statSync(path.join(memoryDir, name));
if (!stats.isFile()) {
continue;
}

if (name !== MEMORY_INDEX_FILE) {
count++;
}
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
}

if (count === 0) {
return null;
}

return { count, newestMtimeMs };
} catch {
return null;
}
}

function formatAge(ageMs: number): string {
if (ageMs < MINUTE_MS)
return '<1m';
if (ageMs < HOUR_MS)
return `${Math.floor(ageMs / MINUTE_MS)}m`;
if (ageMs < DAY_MS)
return `${Math.floor(ageMs / HOUR_MS)}h`;
return `${Math.floor(ageMs / DAY_MS)}d`;
}

export class ClaudeMemoryWidget implements Widget {
getDefaultColor(): string { return 'magenta'; }
getDescription(): string { return 'Shows the number of Claude memory files for this project and how recently they were updated'; }
getDisplayName(): string { return 'Claude Memory'; }
getCategory(): string { return 'Session'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return { displayText: this.getDisplayName() };
}

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
if (context.isPreview) {
return item.rawValue ? '3 (5m)' : '🧠 3 (5m)';
}

const stats = getMemoryStats(context);
if (!stats) {
return null;
}

const value = `${stats.count} (${formatAge(Date.now() - stats.newestMtimeMs)})`;
return item.rawValue ? value : `🧠 ${value}`;
}

getNumericValue(context: RenderContext, item: WidgetItem): number | null {
return getMemoryStats(context)?.count ?? null;
}

supportsRawValue(): boolean { return true; }
supportsColors(item: WidgetItem): boolean { return true; }
}
150 changes: 150 additions & 0 deletions src/widgets/__tests__/ClaudeMemory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import * as fs from 'fs';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';

import type {
RenderContext,
WidgetItem
} from '../../types';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import { ClaudeMemoryWidget } from '../ClaudeMemory';

const NOW = 1_800_000_000_000;
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const TRANSCRIPT_PATH = '/home/user/.claude/projects/-home-user-proj/session.jsonl';

function makeContext(overrides?: Partial<RenderContext>): RenderContext {
return {
data: { transcript_path: TRANSCRIPT_PATH },
...overrides
};
}

function render(itemOverrides?: Partial<WidgetItem>, context?: RenderContext): string | null {
const item: WidgetItem = { id: 'test', type: 'claude-memory', ...itemOverrides };
return new ClaudeMemoryWidget().render(item, context ?? makeContext(), DEFAULT_SETTINGS);
}

interface FakeEntry {
mtimeMs: number;
isDirectory?: boolean;
}

function mockMemoryDir(files: Record<string, FakeEntry>): void {
vi.spyOn(fs, 'readdirSync').mockImplementation(() => Object.keys(files) as unknown as ReturnType<typeof fs.readdirSync>);
vi.spyOn(fs, 'statSync').mockImplementation((statPath: fs.PathLike) => {
const name = String(statPath).split(/[\\/]/).pop() ?? '';
const entry = files[name];
if (!entry) {
throw new Error('ENOENT');
}

return {
isFile: () => !entry.isDirectory,
mtimeMs: entry.mtimeMs
} as fs.Stats;
});
}

describe('ClaudeMemoryWidget', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(NOW);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('shows memory file count excluding MEMORY.md and age of newest file', () => {
mockMemoryDir({
'MEMORY.md': { mtimeMs: NOW - 5 * MINUTE },
'user-prefs.md': { mtimeMs: NOW - 2 * HOUR },
'project-goal.md': { mtimeMs: NOW - 3 * HOUR }
});

expect(render()).toBe('🧠 2 (5m)');
});

it('reads the memory directory next to the transcript file', () => {
mockMemoryDir({ 'a.md': { mtimeMs: NOW - MINUTE } });

render();

expect(fs.readdirSync).toHaveBeenCalledWith('/home/user/.claude/projects/-home-user-proj/memory');
});

it('supports raw value without the icon prefix', () => {
mockMemoryDir({ 'a.md': { mtimeMs: NOW - 5 * MINUTE } });

expect(render({ rawValue: true })).toBe('1 (5m)');
});

it('formats ages as <1m, minutes, hours, and days', () => {
mockMemoryDir({ 'a.md': { mtimeMs: NOW - 30_000 } });
expect(render()).toBe('🧠 1 (<1m)');

mockMemoryDir({ 'a.md': { mtimeMs: NOW - 42 * MINUTE } });
expect(render()).toBe('🧠 1 (42m)');

mockMemoryDir({ 'a.md': { mtimeMs: NOW - 3 * HOUR - 20 * MINUTE } });
expect(render()).toBe('🧠 1 (3h)');

mockMemoryDir({ 'a.md': { mtimeMs: NOW - 2 * DAY - HOUR } });
expect(render()).toBe('🧠 1 (2d)');
});

it('ignores non-markdown files and .md directories', () => {
mockMemoryDir({
'a.md': { mtimeMs: NOW - 10 * MINUTE },
'notes.txt': { mtimeMs: NOW - MINUTE },
'folder.md': { mtimeMs: NOW - MINUTE, isDirectory: true }
});

expect(render()).toBe('🧠 1 (10m)');
});

it('hides when only the MEMORY.md index exists', () => {
mockMemoryDir({ 'MEMORY.md': { mtimeMs: NOW - MINUTE } });

expect(render()).toBeNull();
});

it('hides when the memory directory is missing', () => {
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
throw new Error('ENOENT');
});

expect(render()).toBeNull();
});

it('hides when there is no transcript path', () => {
mockMemoryDir({ 'a.md': { mtimeMs: NOW - MINUTE } });

expect(render(undefined, makeContext({ data: {} }))).toBeNull();
expect(render(undefined, makeContext({ data: undefined }))).toBeNull();
});

it('renders a fixed sample in preview mode', () => {
expect(render(undefined, makeContext({ isPreview: true }))).toBe('🧠 3 (5m)');
expect(render({ rawValue: true }, makeContext({ isPreview: true }))).toBe('3 (5m)');
});

it('exposes the memory count as numeric value', () => {
mockMemoryDir({
'MEMORY.md': { mtimeMs: NOW - MINUTE },
'a.md': { mtimeMs: NOW - MINUTE },
'b.md': { mtimeMs: NOW - MINUTE }
});

const widget = new ClaudeMemoryWidget();
expect(widget.getNumericValue(makeContext(), { id: 'test', type: 'claude-memory' })).toBe(2);
});
});
1 change: 1 addition & 0 deletions src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@ export { GitWorktreeOriginalBranchWidget } from './GitWorktreeOriginalBranch';
export { CompactionCounterWidget } from './CompactionCounter';
export { VoiceStatusWidget } from './VoiceStatus';
export { RemoteControlStatusWidget } from './RemoteControlStatus';
export { ClaudeMemoryWidget } from './ClaudeMemory';