diff --git a/README.md b/README.md index ff6b4b6..c920895 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,7 @@ Once configured, ccstatusline automatically formats your Claude Code status line - **Git Worktree** - Shows the name of the current git worktree - **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m") - **Session Cost** - Shows total session cost in USD (e.g., "$1.23") +- **Session Name** - Shows the session name set via `/rename` command in Claude Code - **Block Timer** - Shows time elapsed in current 5-hour block or progress bar - **Current Working Directory** - Shows current working directory with configurable path segments - **Version** - Shows Claude Code version diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 62c59db..123d3a7 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -27,7 +27,8 @@ const widgetRegistry = new Map([ ['version', new widgets.VersionWidget()], ['custom-text', new widgets.CustomTextWidget()], ['custom-command', new widgets.CustomCommandWidget()], - ['claude-session-id', new widgets.ClaudeSessionIdWidget()] + ['claude-session-id', new widgets.ClaudeSessionIdWidget()], + ['session-name', new widgets.SessionNameWidget()] ]); export function getWidget(type: WidgetItemType): Widget | null { diff --git a/src/widgets/SessionName.ts b/src/widgets/SessionName.ts new file mode 100644 index 0000000..a5a4358 --- /dev/null +++ b/src/widgets/SessionName.ts @@ -0,0 +1,58 @@ +import * as fs from 'fs'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class SessionNameWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the session name set via /rename command in Claude Code'; } + getDisplayName(): string { return 'Session Name'; } + 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 ? 'my-session' : 'Session: my-session'; + } + + const transcriptPath = context.data?.transcript_path; + if (!transcriptPath) { + return null; + } + + try { + const content = fs.readFileSync(transcriptPath, 'utf-8'); + const lines = content.split('\n'); + + // Find the most recent custom-title entry (search from end) + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]?.trim(); + if (!line) + continue; + + try { + const entry = JSON.parse(line) as { type?: string; customTitle?: string }; + if (entry.type === 'custom-title' && entry.customTitle) { + return item.rawValue ? entry.customTitle : `Session: ${entry.customTitle}`; + } + } catch { + // Skip malformed lines + } + } + } catch { + // File not readable + } + + return null; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/SessionName.test.ts b/src/widgets/__tests__/SessionName.test.ts new file mode 100644 index 0000000..3ccaad7 --- /dev/null +++ b/src/widgets/__tests__/SessionName.test.ts @@ -0,0 +1,105 @@ +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 { SessionNameWidget } from '../SessionName'; + +function render(transcriptPath: string | undefined, fileContent: string | null, rawValue = false, isPreview = false) { + const widget = new SessionNameWidget(); + const context: RenderContext = { + data: transcriptPath ? { transcript_path: transcriptPath } : undefined, + isPreview + }; + const item: WidgetItem = { + id: 'session-name', + type: 'session-name', + rawValue + }; + + const readFileSyncSpy = vi.spyOn(fs, 'readFileSync'); + if (fileContent !== null) { + readFileSyncSpy.mockImplementation(() => fileContent as never); + } else { + readFileSyncSpy.mockImplementation(() => { + throw new Error('File not found'); + }); + } + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('SessionNameWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have session category', () => { + const widget = new SessionNameWidget(); + expect(widget.getCategory()).toBe('Session'); + }); + + it('should return preview text when in preview mode', () => { + const result = render(undefined, null, false, true); + expect(result).toBe('Session: my-session'); + }); + + it('should return raw preview text when in preview mode with rawValue', () => { + const result = render(undefined, null, true, true); + expect(result).toBe('my-session'); + }); + + it('should return null when no transcript_path', () => { + const result = render(undefined, null); + expect(result).toBeNull(); + }); + + it('should return null when file is not readable', () => { + const result = render('/some/path/session.jsonl', null); + expect(result).toBeNull(); + }); + + it('should return null when no custom-title entry exists', () => { + const content = '{"type":"message","text":"hello"}\n{"type":"response","text":"hi"}'; + const result = render('/some/path/session.jsonl', content); + expect(result).toBeNull(); + }); + + it('should extract session name from custom-title entry', () => { + const content = '{"type":"message","text":"hello"}\n{"type":"custom-title","customTitle":"My Project"}'; + const result = render('/some/path/session.jsonl', content); + expect(result).toBe('Session: My Project'); + }); + + it('should return raw session name when rawValue is true', () => { + const content = '{"type":"custom-title","customTitle":"My Project"}'; + const result = render('/some/path/session.jsonl', content, true); + expect(result).toBe('My Project'); + }); + + it('should use most recent custom-title when multiple exist', () => { + const content = '{"type":"custom-title","customTitle":"Old Name"}\n{"type":"message"}\n{"type":"custom-title","customTitle":"New Name"}'; + const result = render('/some/path/session.jsonl', content); + expect(result).toBe('Session: New Name'); + }); + + it('should skip malformed JSON lines', () => { + const content = 'not valid json\n{"type":"custom-title","customTitle":"Valid Title"}'; + const result = render('/some/path/session.jsonl', content); + expect(result).toBe('Session: Valid Title'); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index faaa705..70003ef 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -18,4 +18,5 @@ export { CustomTextWidget } from './CustomText'; export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; -export { ClaudeSessionIdWidget } from './ClaudeSessionId'; \ No newline at end of file +export { ClaudeSessionIdWidget } from './ClaudeSessionId'; +export { SessionNameWidget } from './SessionName'; \ No newline at end of file