fix(render): keep hud to one line (#105)

This commit is contained in:
Jarrod Watts
2026-02-03 12:13:32 +11:00
committed by GitHub
parent 0ba87bae2b
commit c4582e9831
7 changed files with 179 additions and 54 deletions

View File

@@ -62,6 +62,8 @@ Claude HUD gives you better insights into what's happening in your Claude Code s
## What Each Line Shows
The HUD is rendered as a single statusline; the sections below are shown inline, separated by `|`.
### Session Info
```
[Opus | Pro] █████░░░░░ 45% | my-project git:(main) | 2 CLAUDE.md | 5h: 25% | ⏱️ 5m
@@ -187,17 +189,14 @@ To disable usage display, set `display.showUsage` to `false` in your config.
### Layout Options
**Default layout**All info on first line:
**Default layout**Single-line HUD:
```
[Opus] ████░░░░░░ 42% | my-project git:(main) | 2 rules | ⏱️ 5m
✓ Read ×3 | ✓ Edit ×1
[Opus] ████░░░░░░ 42% | my-project git:(main) | 2 rules | ⏱️ 5m | ✓ Read ×3 | ✓ Edit ×1
```
**Separators layout**Visual separator below header when activity exists:
**Separators layout**Inline separator before activity:
```
[Opus] ████░░░░░░ 42% | my-project git:(main) | 2 rules | ⏱️ 5m
──────────────────────────────────────────────────────────────
✓ Read ×3 | ✓ Edit ×1
[Opus] ████░░░░░░ 42% | my-project git:(main) | 2 rules | ⏱️ 5m | --- | ✓ Read ×3 | ✓ Edit ×1
```
### Example Configuration
@@ -290,4 +289,4 @@ MIT — see [LICENSE](LICENSE)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=jarrodwatts/claude-hud&type=Date)](https://star-history.com/#jarrodwatts/claude-hud&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=jarrodwatts/claude-hud&type=Date)](https://star-history.com/#jarrodwatts/claude-hud&Date)

View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/render/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA4FjD,wBAAgB,MAAM,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAuB/C"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/render/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA+IjD,wBAAgB,MAAM,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAuC/C"}

94
dist/render/index.js vendored
View File

@@ -4,12 +4,58 @@ import { renderAgentsLine } from './agents-line.js';
import { renderTodosLine } from './todos-line.js';
import { renderIdentityLine, renderProjectLine, renderEnvironmentLine, renderUsageLine, } from './lines/index.js';
import { dim, RESET } from './colors.js';
function visualLength(str) {
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
return str.replace(/\x1b\[[0-9;]*m/g, '');
}
function makeSeparator(length) {
return dim('─'.repeat(Math.max(length, 20)));
function visualLength(str) {
return stripAnsi(str).length;
}
function getTerminalWidth() {
const columns = process.stdout.columns;
if (typeof columns === 'number' && Number.isFinite(columns) && columns > 0) {
return columns;
}
const envColumns = Number.parseInt(process.env.COLUMNS ?? '', 10);
if (Number.isFinite(envColumns) && envColumns > 0) {
return envColumns;
}
return null;
}
function truncateLine(line, maxWidth) {
if (maxWidth <= 0)
return '';
if (maxWidth <= 3)
return '.'.repeat(maxWidth);
if (visualLength(line) <= maxWidth)
return line;
const limit = Math.max(0, maxWidth - 3);
let visible = 0;
let result = '';
const ansiPattern = /\x1b\[[0-9;]*m/g;
let lastIndex = 0;
let match;
while ((match = ansiPattern.exec(line)) !== null) {
const chunk = line.slice(lastIndex, match.index);
for (const char of chunk) {
if (visible >= limit) {
return result + '...';
}
result += char;
visible += 1;
}
result += match[0];
lastIndex = ansiPattern.lastIndex;
}
const remaining = line.slice(lastIndex);
for (const char of remaining) {
if (visible >= limit) {
return result + '...';
}
result += char;
visible += 1;
}
return result + '...';
}
function collectActivityLines(ctx) {
const activityLines = [];
@@ -70,19 +116,37 @@ function renderExpanded(ctx) {
export function render(ctx) {
const lineLayout = ctx.config?.lineLayout ?? 'expanded';
const showSeparators = ctx.config?.showSeparators ?? false;
const headerLines = lineLayout === 'expanded'
? renderExpanded(ctx)
: renderCompact(ctx);
const headerLines = lineLayout === 'expanded' ? renderExpanded(ctx) : renderCompact(ctx);
const activityLines = collectActivityLines(ctx);
const lines = [...headerLines];
if (showSeparators && activityLines.length > 0) {
const maxWidth = Math.max(...headerLines.map(visualLength), 20);
lines.push(makeSeparator(maxWidth));
const headerSegments = [];
for (const line of headerLines) {
if (!line)
continue;
const split = line.split('\n').filter((part) => part.length > 0);
headerSegments.push(...split);
}
lines.push(...activityLines);
for (const line of lines) {
const outputLine = `${RESET}${line.replace(/ /g, '\u00A0')}`;
console.log(outputLine);
const activitySegments = [];
for (const line of activityLines) {
if (!line)
continue;
const split = line.split('\n').filter((part) => part.length > 0);
activitySegments.push(...split);
}
const segments = [...headerSegments];
if (showSeparators && headerSegments.length > 0 && activitySegments.length > 0) {
segments.push(dim('---'));
}
segments.push(...activitySegments);
if (segments.length === 0) {
return;
}
// Keep HUD to a single terminal line to avoid focusable rows in the UI.
let line = segments.join(' | ');
const maxWidth = getTerminalWidth();
if (maxWidth) {
line = truncateLine(line, maxWidth);
}
const outputLine = `${RESET}${line}${RESET}`.replace(/ /g, '\u00A0');
console.log(outputLine);
}
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -11,13 +11,64 @@ import {
} from './lines/index.js';
import { dim, RESET } from './colors.js';
function visualLength(str: string): number {
function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
return str.replace(/\x1b\[[0-9;]*m/g, '');
}
function makeSeparator(length: number): string {
return dim('─'.repeat(Math.max(length, 20)));
function visualLength(str: string): number {
return stripAnsi(str).length;
}
function getTerminalWidth(): number | null {
const columns = process.stdout.columns;
if (typeof columns === 'number' && Number.isFinite(columns) && columns > 0) {
return columns;
}
const envColumns = Number.parseInt(process.env.COLUMNS ?? '', 10);
if (Number.isFinite(envColumns) && envColumns > 0) {
return envColumns;
}
return null;
}
function truncateLine(line: string, maxWidth: number): string {
if (maxWidth <= 0) return '';
if (maxWidth <= 3) return '.'.repeat(maxWidth);
if (visualLength(line) <= maxWidth) return line;
const limit = Math.max(0, maxWidth - 3);
let visible = 0;
let result = '';
const ansiPattern = /\x1b\[[0-9;]*m/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = ansiPattern.exec(line)) !== null) {
const chunk = line.slice(lastIndex, match.index);
for (const char of chunk) {
if (visible >= limit) {
return result + '...';
}
result += char;
visible += 1;
}
result += match[0];
lastIndex = ansiPattern.lastIndex;
}
const remaining = line.slice(lastIndex);
for (const char of remaining) {
if (visible >= limit) {
return result + '...';
}
result += char;
visible += 1;
}
return result + '...';
}
function collectActivityLines(ctx: RenderContext): string[] {
@@ -93,24 +144,40 @@ function renderExpanded(ctx: RenderContext): string[] {
export function render(ctx: RenderContext): void {
const lineLayout = ctx.config?.lineLayout ?? 'expanded';
const showSeparators = ctx.config?.showSeparators ?? false;
const headerLines = lineLayout === 'expanded'
? renderExpanded(ctx)
: renderCompact(ctx);
const headerLines = lineLayout === 'expanded' ? renderExpanded(ctx) : renderCompact(ctx);
const activityLines = collectActivityLines(ctx);
const lines: string[] = [...headerLines];
if (showSeparators && activityLines.length > 0) {
const maxWidth = Math.max(...headerLines.map(visualLength), 20);
lines.push(makeSeparator(maxWidth));
const headerSegments: string[] = [];
for (const line of headerLines) {
if (!line) continue;
const split = line.split('\n').filter((part) => part.length > 0);
headerSegments.push(...split);
}
lines.push(...activityLines);
for (const line of lines) {
const outputLine = `${RESET}${line.replace(/ /g, '\u00A0')}`;
console.log(outputLine);
const activitySegments: string[] = [];
for (const line of activityLines) {
if (!line) continue;
const split = line.split('\n').filter((part) => part.length > 0);
activitySegments.push(...split);
}
const segments: string[] = [...headerSegments];
if (showSeparators && headerSegments.length > 0 && activitySegments.length > 0) {
segments.push(dim('---'));
}
segments.push(...activitySegments);
if (segments.length === 0) {
return;
}
// Keep HUD to a single terminal line to avoid focusable rows in the UI.
let line = segments.join(' | ');
const maxWidth = getTerminalWidth();
if (maxWidth) {
line = truncateLine(line, maxWidth);
}
const outputLine = `${RESET}${line}${RESET}`.replace(/ /g, '\u00A0');
console.log(outputLine);
}

View File

@@ -1,5 +1 @@
[Opus] █████░░░░░ 45%
my-project
◐ Edit: .../authentication.ts | ✓ Read ×1
✓ explore [haiku]: Finding auth code (<1s)
▸ Add tests (1/2)
[Opus] █████░░░░░ 45% | my-project | ◐ Edit: .../authentication.ts | ✓ Read ×1 | ✓ explore [haiku]: Finding auth code (<1s) | ▸ Add tests (1/2)

View File

@@ -536,8 +536,8 @@ test('render adds separator line when showSeparators is true and activity exists
console.log = originalLog;
}
assert.ok(logs.length >= 2, 'should have at least 2 lines');
assert.ok(logs.some(l => l.includes('')), 'should include separator character');
assert.equal(logs.length, 1, 'should render a single line');
assert.ok(logs.some(l => l.includes('---')), 'should include separator marker');
});
test('render omits separator when showSeparators is true but no activity', () => {
@@ -553,8 +553,8 @@ test('render omits separator when showSeparators is true but no activity', () =>
console.log = originalLog;
}
assert.equal(logs.length, 1, 'should only have session line');
assert.ok(!logs.some(l => l.includes('')), 'should not include separator');
assert.equal(logs.length, 1, 'should render a single line');
assert.ok(!logs.some(l => l.includes('---')), 'should not include separator');
});
// fileStats tests
@@ -634,4 +634,3 @@ test('renderSessionLine combines showFileStats with showDirty and showAheadBehin
assert.ok(line.includes('!3'), 'expected modified count');
assert.ok(line.includes('✘1'), 'expected deleted count');
});