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:
myaiexp
2026-03-03 04:54:01 +02:00
committed by GitHub
parent 883b281df4
commit bdfa4454b3
7 changed files with 92 additions and 3 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -72,6 +72,7 @@ export interface TranscriptData {
agents: AgentEntry[];
todos: TodoItem[];
sessionStart?: Date;
sessionName?: string;
}
export interface RenderContext {

View File

@@ -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);

View File

@@ -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...');

View File

@@ -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';