mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-14 16:17:01 +00:00
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:
5 files changed
+168
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user