mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-04-16 06:32:39 +00:00
feat: show session name in statusline (#155)
* feat: show session name in statusline Reads the session slug (auto-generated) and custom title (set via /rename) from the transcript JSONL and displays it in dim text after the project/git info on both expanded and compact layouts. Custom title takes priority over auto-generated slug when both exist. * test: add session name coverage and harden integration spawn --------- Co-authored-by: Jarrod Watts <jarrod@cubelabs.xyz>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { RenderContext } from '../../types.js';
|
||||
import { getModelName, getProviderLabel } from '../../stdin.js';
|
||||
import { cyan, magenta, yellow, red } from '../colors.js';
|
||||
import { cyan, dim, magenta, yellow, red } from '../colors.js';
|
||||
|
||||
export function renderProjectLine(ctx: RenderContext): string | null {
|
||||
const display = ctx.config?.display;
|
||||
@@ -60,6 +60,10 @@ export function renderProjectLine(ctx: RenderContext): string | null {
|
||||
parts.push(`${yellow(projectPath)}${gitPart}`);
|
||||
}
|
||||
|
||||
if (ctx.transcript.sessionName) {
|
||||
parts.push(dim(ctx.transcript.sessionName));
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,11 @@ export function renderSessionLine(ctx: RenderContext): string {
|
||||
parts.push(`${yellow(projectPath)}${gitPart}`);
|
||||
}
|
||||
|
||||
// Session name (custom title from /rename, or auto-generated slug)
|
||||
if (ctx.transcript.sessionName) {
|
||||
parts.push(dim(ctx.transcript.sessionName));
|
||||
}
|
||||
|
||||
// Config counts (respects environmentThreshold)
|
||||
if (display?.showConfigCounts !== false) {
|
||||
const totalCounts = ctx.claudeMdCount + ctx.rulesCount + ctx.mcpCount + ctx.hooksCount;
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { TranscriptData, ToolEntry, AgentEntry, TodoItem } from './types.js
|
||||
|
||||
interface TranscriptLine {
|
||||
timestamp?: string;
|
||||
type?: string;
|
||||
slug?: string;
|
||||
customTitle?: string;
|
||||
message?: {
|
||||
content?: ContentBlock[];
|
||||
};
|
||||
@@ -33,6 +36,8 @@ export async function parseTranscript(transcriptPath: string): Promise<Transcrip
|
||||
const agentMap = new Map<string, AgentEntry>();
|
||||
let latestTodos: TodoItem[] = [];
|
||||
const taskIdToIndex = new Map<string, number>();
|
||||
let latestSlug: string | undefined;
|
||||
let customTitle: string | undefined;
|
||||
|
||||
try {
|
||||
const fileStream = fs.createReadStream(transcriptPath);
|
||||
@@ -46,6 +51,11 @@ export async function parseTranscript(transcriptPath: string): Promise<Transcrip
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line) as TranscriptLine;
|
||||
if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') {
|
||||
customTitle = entry.customTitle;
|
||||
} else if (typeof entry.slug === 'string') {
|
||||
latestSlug = entry.slug;
|
||||
}
|
||||
processEntry(entry, toolMap, agentMap, taskIdToIndex, latestTodos, result);
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
@@ -58,6 +68,7 @@ export async function parseTranscript(transcriptPath: string): Promise<Transcrip
|
||||
result.tools = Array.from(toolMap.values()).slice(-20);
|
||||
result.agents = Array.from(agentMap.values()).slice(-10);
|
||||
result.todos = latestTodos;
|
||||
result.sessionName = customTitle ?? latestSlug;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface TranscriptData {
|
||||
agents: AgentEntry[];
|
||||
todos: TodoItem[];
|
||||
sessionStart?: Date;
|
||||
sessionName?: string;
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
|
||||
@@ -186,6 +186,43 @@ test('parseTranscript aggregates tools, agents, and todos', async () => {
|
||||
assert.equal(result.sessionStart?.toISOString(), '2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
test('parseTranscript prefers custom title over slug for session name', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
|
||||
const filePath = path.join(dir, 'session-name-custom-title.jsonl');
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'user', slug: 'auto-slug-1' }),
|
||||
JSON.stringify({ type: 'custom-title', customTitle: 'My Renamed Session' }),
|
||||
JSON.stringify({ type: 'assistant', slug: 'auto-slug-2' }),
|
||||
];
|
||||
|
||||
await writeFile(filePath, lines.join('\n'), 'utf8');
|
||||
|
||||
try {
|
||||
const result = await parseTranscript(filePath);
|
||||
assert.equal(result.sessionName, 'My Renamed Session');
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('parseTranscript falls back to latest slug when custom title is missing', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
|
||||
const filePath = path.join(dir, 'session-name-slug.jsonl');
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'user', slug: 'auto-slug-1' }),
|
||||
JSON.stringify({ type: 'assistant', slug: 'auto-slug-2' }),
|
||||
];
|
||||
|
||||
await writeFile(filePath, lines.join('\n'), 'utf8');
|
||||
|
||||
try {
|
||||
const result = await parseTranscript(filePath);
|
||||
assert.equal(result.sessionName, 'auto-slug-2');
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('parseTranscript returns empty result when file is missing', async () => {
|
||||
const result = await parseTranscript('/tmp/does-not-exist.jsonl');
|
||||
assert.equal(result.tools.length, 0);
|
||||
|
||||
@@ -14,7 +14,15 @@ function stripAnsi(text) {
|
||||
);
|
||||
}
|
||||
|
||||
test('CLI renders expected output for a basic transcript', async () => {
|
||||
function skipIfSpawnBlocked(result, t) {
|
||||
if (result.error?.code === 'EPERM') {
|
||||
t.skip('spawnSync is blocked by sandbox policy in this environment');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
test('CLI renders expected output for a basic transcript', async (t) => {
|
||||
const fixturePath = fileURLToPath(new URL('./fixtures/transcript-render.jsonl', import.meta.url));
|
||||
const expectedPath = fileURLToPath(new URL('./fixtures/expected/render-basic.txt', import.meta.url));
|
||||
const expected = readFileSync(expectedPath, 'utf8').trimEnd();
|
||||
@@ -41,6 +49,9 @@ test('CLI renders expected output for a basic transcript', async () => {
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
});
|
||||
|
||||
if (skipIfSpawnBlocked(result, t)) return;
|
||||
|
||||
assert.equal(result.error, undefined, result.error?.message);
|
||||
assert.equal(result.status, 0, result.stderr || 'non-zero exit');
|
||||
const normalized = stripAnsi(result.stdout).replace(/\u00A0/g, ' ').trimEnd();
|
||||
if (process.env.UPDATE_SNAPSHOTS === '1') {
|
||||
@@ -53,13 +64,16 @@ test('CLI renders expected output for a basic transcript', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI prints initializing message on empty stdin', () => {
|
||||
test('CLI prints initializing message on empty stdin', (t) => {
|
||||
const result = spawnSync('node', ['dist/index.js'], {
|
||||
cwd: path.resolve(process.cwd()),
|
||||
input: '',
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (skipIfSpawnBlocked(result, t)) return;
|
||||
|
||||
assert.equal(result.error, undefined, result.error?.message);
|
||||
assert.equal(result.status, 0, result.stderr || 'non-zero exit');
|
||||
const normalized = stripAnsi(result.stdout).replace(/\u00A0/g, ' ').trimEnd();
|
||||
assert.equal(normalized, '[claude-hud] Initializing...');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { render } from '../dist/render/index.js';
|
||||
import { renderSessionLine } from '../dist/render/session-line.js';
|
||||
import { renderProjectLine } from '../dist/render/lines/project.js';
|
||||
import { renderToolsLine } from '../dist/render/tools-line.js';
|
||||
import { renderAgentsLine } from '../dist/render/agents-line.js';
|
||||
import { renderTodosLine } from '../dist/render/todos-line.js';
|
||||
@@ -141,6 +142,22 @@ test('renderSessionLine omits project name when cwd is undefined', () => {
|
||||
assert.ok(line.includes('[Opus]'));
|
||||
});
|
||||
|
||||
test('renderSessionLine includes session name when present', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.transcript.sessionName = 'Renamed Session';
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('Renamed Session'));
|
||||
});
|
||||
|
||||
test('renderProjectLine includes session name when present', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.transcript.sessionName = 'Renamed Session';
|
||||
const line = renderProjectLine(ctx);
|
||||
assert.ok(line?.includes('Renamed Session'));
|
||||
});
|
||||
|
||||
test('renderSessionLine displays git branch when present', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
|
||||
Reference in New Issue
Block a user