Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/utils/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions src/widgets/SessionName.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 105 additions & 0 deletions src/widgets/__tests__/SessionName.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
3 changes: 2 additions & 1 deletion src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';