Add Session Name widget (#142)

* 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 <noreply@anthropic.com>

* fix: add category metadata to session name widget

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-02-21 11:34:10 -05:00
1 parent 0092c241a6
commit 62903d8183
5 files changed
+168 -2

No files matched your search

+1
View File
@@ -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
+2 -1
View File
@@ -27,7 +27,8 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
['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 {
+58
View File
@@ -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; }
}
+105
View File
@@ -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');
});
});
+2 -1
View File
@@ -18,4 +18,5 @@ export { CustomTextWidget } from './CustomText';
export { CustomCommandWidget } from './CustomCommand';
export { BlockTimerWidget } from './BlockTimer';
export { CurrentWorkingDirWidget } from './CurrentWorkingDir';
export { ClaudeSessionIdWidget } from './ClaudeSessionId';
export { ClaudeSessionIdWidget } from './ClaudeSessionId';
export { SessionNameWidget } from './SessionName';