From 55cd8d3466c7f2c456111baf406763c75a1d6bff Mon Sep 17 00:00:00 2001 From: Arjun Lall Date: Fri, 23 Jan 2026 23:04:54 -0800 Subject: [PATCH 1/2] Add Session Name widget Adds a native widget to display the Claude Code session name set via the /rename command. The widget reads the transcript file and extracts the customTitle from custom-title entries. - New SessionName widget with rawValue support - 9 unit tests - README documentation Co-Authored-By: Claude Opus 4.5 --- README.md | 1 + src/utils/widgets.ts | 3 +- src/widgets/SessionName.ts | 57 ++++++++++++++ src/widgets/__tests__/SessionName.test.ts | 96 +++++++++++++++++++++++ src/widgets/index.ts | 3 +- 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 src/widgets/SessionName.ts create mode 100644 src/widgets/__tests__/SessionName.test.ts 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 15a2510..1c5f6dc 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..c758d6f --- /dev/null +++ b/src/widgets/SessionName.ts @@ -0,0 +1,57 @@ +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'; } + 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..b3d378c --- /dev/null +++ b/src/widgets/__tests__/SessionName.test.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { SessionNameWidget } from '../SessionName'; + +vi.mock('fs'); + +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 + }; + + if (fileContent !== null) { + vi.mocked(fs.readFileSync).mockReturnValue(fileContent); + } else { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('File not found'); + }); + } + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('SessionNameWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + 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 From 476c0ca75950ec4dc66a8b2354413bcded691587 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Sat, 21 Feb 2026 11:33:40 -0500 Subject: [PATCH 2/2] fix: add category metadata to session name widget --- src/widgets/SessionName.ts | 1 + src/widgets/__tests__/SessionName.test.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/widgets/SessionName.ts b/src/widgets/SessionName.ts index c758d6f..a5a4358 100644 --- a/src/widgets/SessionName.ts +++ b/src/widgets/SessionName.ts @@ -12,6 +12,7 @@ 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() }; } diff --git a/src/widgets/__tests__/SessionName.test.ts b/src/widgets/__tests__/SessionName.test.ts index b3d378c..3ccaad7 100644 --- a/src/widgets/__tests__/SessionName.test.ts +++ b/src/widgets/__tests__/SessionName.test.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import { + afterEach, beforeEach, describe, expect, @@ -14,8 +15,6 @@ import type { import { DEFAULT_SETTINGS } from '../../types/Settings'; import { SessionNameWidget } from '../SessionName'; -vi.mock('fs'); - function render(transcriptPath: string | undefined, fileContent: string | null, rawValue = false, isPreview = false) { const widget = new SessionNameWidget(); const context: RenderContext = { @@ -28,10 +27,11 @@ function render(transcriptPath: string | undefined, fileContent: string | null, rawValue }; + const readFileSyncSpy = vi.spyOn(fs, 'readFileSync'); if (fileContent !== null) { - vi.mocked(fs.readFileSync).mockReturnValue(fileContent); + readFileSyncSpy.mockImplementation(() => fileContent as never); } else { - vi.mocked(fs.readFileSync).mockImplementation(() => { + readFileSyncSpy.mockImplementation(() => { throw new Error('File not found'); }); } @@ -44,6 +44,15 @@ describe('SessionNameWidget', () => { 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');