From 2d7436090e40079d8d3a1743ba1e0c6fbc80adb3 Mon Sep 17 00:00:00 2001 From: a3351 <3351163616@qq.com> Date: Sun, 19 Jul 2026 01:17:22 +0800 Subject: [PATCH] feat(widgets): add Claude memory widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a claude-memory widget that surfaces the project's Claude Code memory state: the number of memory files and how recently they were updated (e.g. "🧠 5 (2h)"). Reads the memory directory next to the transcript path. Hidden when there is no transcript path, no memory directory, or zero memory files; supports rawValue and custom colors. --- src/utils/widget-manifest.ts | 3 +- src/widgets/ClaudeMemory.ts | 98 ++++++++++++++ src/widgets/__tests__/ClaudeMemory.test.ts | 150 +++++++++++++++++++++ src/widgets/index.ts | 1 + 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/widgets/ClaudeMemory.ts create mode 100644 src/widgets/__tests__/ClaudeMemory.test.ts diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 86cbad71..d49a4601 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -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[] = [ diff --git a/src/widgets/ClaudeMemory.ts b/src/widgets/ClaudeMemory.ts new file mode 100644 index 00000000..7fc252ae --- /dev/null +++ b/src/widgets/ClaudeMemory.ts @@ -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; } +} diff --git a/src/widgets/__tests__/ClaudeMemory.test.ts b/src/widgets/__tests__/ClaudeMemory.test.ts new file mode 100644 index 00000000..c2c74320 --- /dev/null +++ b/src/widgets/__tests__/ClaudeMemory.test.ts @@ -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 { + return { + data: { transcript_path: TRANSCRIPT_PATH }, + ...overrides + }; +} + +function render(itemOverrides?: Partial, 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): void { + vi.spyOn(fs, 'readdirSync').mockImplementation(() => Object.keys(files) as unknown as ReturnType); + 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); + }); +}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 518e3c1d..d00fc1f8 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -81,3 +81,4 @@ export { GitWorktreeOriginalBranchWidget } from './GitWorktreeOriginalBranch'; export { CompactionCounterWidget } from './CompactionCounter'; export { VoiceStatusWidget } from './VoiceStatus'; export { RemoteControlStatusWidget } from './RemoteControlStatus'; +export { ClaudeMemoryWidget } from './ClaudeMemory';