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
2 changes: 1 addition & 1 deletion src/cli/_lib/stream-renderer-orchestrator-emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ export function emitMarkdown(text: string, out: Writer): void {
*/
export function emitErrorBox(err: Error, out: Writer): void {
const stackTrace = err.stack?.split('\n').slice(1).join('\n');
const box = errorCard({ body: err.message, hint: stackTrace });
const box = errorCard({ body: err.message, stack: stackTrace });
for (const line of box.split('\n')) {
out.line(line);
}
Expand Down
30 changes: 30 additions & 0 deletions src/cli/render/error-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,36 @@ describe('errorCard', () => {
expect(stripAnsi(result)).toContain('Oops');
});

it('renders stack when provided', () => {
const result = stripAnsi(
errorCard({
body: 'Unexpected failure',
stack: 'at foo (bar.ts:10:5)\nat baz (qux.ts:20:3)',
}),
);
expect(result).toContain('Unexpected failure');
expect(result).toContain('at foo (bar.ts:10:5)');
expect(result).toContain('at baz (qux.ts:20:3)');
});

it('renders hint and stack independently', () => {
const result = stripAnsi(
errorCard({
body: 'Connection refused',
hint: 'Check that the server is running',
stack: 'at connect (net.ts:5:1)',
}),
);
expect(result).toContain('Connection refused');
expect(result).toContain('Check that the server is running');
expect(result).toContain('at connect (net.ts:5:1)');
});

it('omits stack section when not provided', () => {
const result = stripAnsi(errorCard({ body: 'Oops', hint: 'Try again' }));
expect(result).not.toContain('at ');
});

it('has bordered output (rounded corners)', () => {
const result = errorCard({ body: 'test' });
expect(result).toContain('╭');
Expand Down
30 changes: 28 additions & 2 deletions src/cli/render/error-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,31 @@ import { palette } from '../palette.js';
// ─── Error Card ──────────────────────────────────────────────────────────────

/**
* Render a structured error card with an optional recovery hint.
* Render a structured error card with an optional recovery hint and/or stack
* trace.
*
* Visual output:
* Visual output (hint only):
* ╭─ ERROR ──────────────────────────────╮
* │ Rate limit exceeded (429) │
* │ │
* │ Retrying in 12s… │
* ╰─────────────────────────────────────╯
*
* Visual output (hint + stack):
* ╭─ ERROR ──────────────────────────────╮
* │ Unexpected error │
* │ │
* │ Try restarting the session │
* │ │
* │ at foo (bar.ts:10:5) │
* │ at baz (qux.ts:20:3) │
* ╰─────────────────────────────────────╯
*
* Replaces the retired `errorBox()` with a richer, consistent format.
* The hint line (dim, italic) surfaces recovery guidance directly in the
* error card — reducing the "red box then silence" failure mode.
* The stack field (dim, monospace-like) renders raw stack traces separately
* from human-readable recovery hints.
*
* @param spec - Error card configuration.
* @returns Multi-line ANSI string.
Expand All @@ -31,6 +44,13 @@ export function errorCard(spec: ErrorCardSpec): string {
content.push(palette.dim(palette.italic(spec.hint)));
}

if (spec.stack) {
content.push('');
for (const line of spec.stack.split('\n')) {
content.push(palette.dim(line));
}
}

return drawBox(content, {
border: palette.error,
title,
Expand All @@ -46,4 +66,10 @@ export interface ErrorCardSpec {
body: string | string[];
/** Optional recovery hint — rendered dim/italic below the body. */
hint?: string;
/**
* Optional raw stack trace — rendered dim below the hint (or body when no
* hint is present). Structurally separate from `hint` so callers never mix
* machine-readable stack frames with human-readable recovery messages.
*/
stack?: string;
}
Loading