mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-05-21 15:52:37 +00:00
fix(render): keep hud to one line (#105)
This commit is contained in:
15
README.md
15
README.md
@@ -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
|
||||
|
||||
[](https://star-history.com/#jarrodwatts/claude-hud&Date)
|
||||
[](https://star-history.com/#jarrodwatts/claude-hud&Date)
|
||||
|
||||
2
dist/render/index.d.ts.map
vendored
2
dist/render/index.d.ts.map
vendored
@@ -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
94
dist/render/index.js
vendored
@@ -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
|
||||
2
dist/render/index.js.map
vendored
2
dist/render/index.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
|
||||
6
tests/fixtures/expected/render-basic.txt
vendored
6
tests/fixtures/expected/render-basic.txt
vendored
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user