feat: add terminal rendering libraries for richer TUI output#85
feat: add terminal rendering libraries for richer TUI output#85
Conversation
Add terminal-link, gradient-string, cli-highlight, and marked-terminal to improve the wizard's terminal experience: - Clickable hyperlinks in Auth, Outage, Slack, Outro, and Run screens - Brand gradient (blue → lilac → violet) on HeaderBar title - JSON syntax highlighting in LogViewer - Full markdown rendering in ReportViewer (replaces hand-rolled regex) - New Table and TerminalLink primitives - Shared terminal-rendering.ts utility module AMP-152421 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
🧙 Wizard CIRun the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands: Test all apps:
Test all apps in a directory:
Test an individual app:
Show more apps
Results will be posted here when complete. |
1a8df07 to
dc1b89f
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Fallback shows duplicate URL in plain terminals
- Updated the makeLink fallback to return just the URL when text equals url, avoiding redundant output like "url (url)".
- ✅ Fixed: New Table component exported but never used
- Removed the unused Table.tsx file and its barrel export from primitives/index.ts.
Or push these changes by commenting:
@cursor push 31c68dbe5c
Preview (31c68dbe5c)
diff --git a/src/ui/tui/primitives/Table.tsx b/src/ui/tui/primitives/Table.tsx
deleted file mode 100644
--- a/src/ui/tui/primitives/Table.tsx
+++ /dev/null
@@ -1,82 +1,0 @@
-/**
- * Table — Aligned column display for Ink.
- *
- * Renders a header row, separator, and data rows using Box/Text.
- * Auto-sizes columns to fit content, capped at optional max widths.
- */
-
-import { Box, Text } from 'ink';
-import { Colors, Icons } from '../styles.js';
-
-export interface TableColumn {
- /** Key to look up in each data row. */
- key: string;
- /** Display label for the header. */
- label: string;
- /** Maximum column width. Defaults to unlimited. */
- maxWidth?: number;
-}
-
-interface TableProps {
- columns: TableColumn[];
- data: Record<string, string>[];
-}
-
-function computeWidths(
- columns: TableColumn[],
- data: Record<string, string>[],
-): number[] {
- return columns.map((col) => {
- const headerLen = col.label.length;
- const maxData = data.reduce(
- (max, row) => Math.max(max, (row[col.key] ?? '').length),
- 0,
- );
- const natural = Math.max(headerLen, maxData);
- return col.maxWidth ? Math.min(natural, col.maxWidth) : natural;
- });
-}
-
-function pad(text: string, width: number): string {
- return text.length >= width
- ? text.slice(0, width)
- : text + ' '.repeat(width - text.length);
-}
-
-export const Table = ({ columns, data }: TableProps) => {
- const widths = computeWidths(columns, data);
- const gap = 2;
-
- return (
- <Box flexDirection="column">
- {/* Header */}
- <Box gap={gap}>
- {columns.map((col, i) => (
- <Text key={col.key} bold color={Colors.heading}>
- {pad(col.label, widths[i])}
- </Text>
- ))}
- </Box>
-
- {/* Separator */}
- <Box gap={gap}>
- {columns.map((col, i) => (
- <Text key={col.key} color={Colors.border}>
- {Icons.dash.repeat(widths[i])}
- </Text>
- ))}
- </Box>
-
- {/* Data rows */}
- {data.map((row, ri) => (
- <Box key={ri} gap={gap}>
- {columns.map((col, ci) => (
- <Text key={col.key} color={Colors.body}>
- {pad(row[col.key] ?? '', widths[ci])}
- </Text>
- ))}
- </Box>
- ))}
- </Box>
- );
-};
\ No newline at end of file
diff --git a/src/ui/tui/primitives/index.ts b/src/ui/tui/primitives/index.ts
--- a/src/ui/tui/primitives/index.ts
+++ b/src/ui/tui/primitives/index.ts
@@ -22,5 +22,3 @@
export { SlashCommandInput } from './SlashCommandInput.js';
export type { SlashCommand } from './SlashCommandInput.js';
export { TerminalLink } from './TerminalLink.js';
-export { Table } from './Table.js';
-export type { TableColumn } from './Table.js';
diff --git a/src/ui/tui/utils/terminal-rendering.ts b/src/ui/tui/utils/terminal-rendering.ts
--- a/src/ui/tui/utils/terminal-rendering.ts
+++ b/src/ui/tui/utils/terminal-rendering.ts
@@ -20,7 +20,7 @@
*/
export function makeLink(text: string, url: string): string {
return terminalLink(text, url, {
- fallback: (text, url) => `${text} (${url})`,
+ fallback: (text, url) => (text === url ? url : `${text} (${url})`),
});
}You can send follow-ups to the cloud agent here.
| ))} | ||
| {lines.map((line, i) => { | ||
| const isJson = | ||
| line.trimStart().startsWith('{') || line.trimStart().startsWith('['); |
There was a problem hiding this comment.
🤖 AI assisted comment - please forgive for tone
🟡 Important — The startsWith('[') check here will match every log line in the wizard log, not just JSON arrays. The wizard's logToFile() writes lines as [${timestamp}] ${msg}, so every line starts with [ and will be routed through highlightCode(line, 'json').
Pre-written fix: review-panel #1 — narrows the [ check to [{ and [" so only JSON arrays get highlighted.
| : text + ' '.repeat(width - text.length); | ||
| } | ||
|
|
||
| export const Table = ({ columns, data }: TableProps) => { |
There was a problem hiding this comment.
🤖 AI assisted comment - please forgive for tone
🟡 Important — This Table component is introduced but nothing in the PR imports or uses it — it's dead code on merge. Consider deferring it to the PR that actually needs it.
Pre-written fix: review-panel #2 — removes Table.tsx and its barrel export.
| // produces a valid MarkedExtension. Cast to satisfy marked.use(). | ||
| marked.use( | ||
| markedTerminal({ | ||
| firstHeading: (s: string) => `\x1b[1m\x1b[38;2;64;131;255m${s}\x1b[0m`, |
There was a problem hiding this comment.
🤖 AI assisted comment - please forgive for tone
🟡 Important — Two issues here:
-
The ANSI RGB values
\x1b[38;2;64;131;255mand\x1b[38;2;105;128;255mare hardcoded rather than derived fromBrand.blueOnDarkandBrand.lilacinstyles.ts. If the brand palette changes, these will silently drift. -
marked.use()on line 54 mutates the globalmarkedsingleton. Any other module importingmarkedwould inherit this terminal-rendering config.
Pre-written fix: review-panel #3,#4 — adds a hexToAnsi() helper using Brand tokens and scopes the config to new marked.Marked() instead of the global.
There was a problem hiding this comment.
Both fixed in a27afab — added hexToAnsi() using Brand tokens and scoped to new Marked() instance.
| {OUTBOUND_URLS.stripeDataSource} | ||
| </Text> | ||
| <TerminalLink url={OUTBOUND_URLS.stripeDataSource}> | ||
| Add Stripe data source |
There was a problem hiding this comment.
🤖 AI assisted comment - please forgive for tone
🔵 Nit — This is the only TerminalLink usage in the PR where the display text differs from the URL. All others pass the URL as children. In terminals without hyperlink support, the fallback renders Add Stripe data source (https://...) instead of just the URL.
Pre-written fix: review-panel #5 — uses the raw URL as children for consistency.
There was a problem hiding this comment.
Fixed in a27afab — now uses raw URL as children.
| <Text color={Colors.heading} bold> | ||
| Amplitude Wizard | ||
| </Text> | ||
| <Text bold>{brandGradient('Amplitude Wizard')}</Text> |
There was a problem hiding this comment.
🤖 AI assisted comment - please forgive for tone
🔵 Nit — brandGradient('Amplitude Wizard') recomputes the gradient interpolation on every render for a constant string. Since HeaderBar re-renders on store changes (org/project context), this runs more often than needed.
Pre-written fix: review-panel #6 — hoists the result to a module-level constant.
There was a problem hiding this comment.
Fixed in a27afab — hoisted to module-level HEADER_TITLE constant.
| const updateContent = () => { | ||
| try { | ||
| const raw = fs.readFileSync(filePath, 'utf-8'); | ||
| const rendered = renderMarkdown(raw); |
There was a problem hiding this comment.
🤖 AI assisted comment - please forgive for tone
🔵 Nit — renderMarkdown(raw) runs the full marked.parse() pipeline on every fs.watch event. fs.watch can fire multiple times for a single write, re-parsing the entire document each time. The old code had the same read-on-every-event pattern but only used simple regex.
Pre-written fix: review-panel #7 — caches the previous raw content and skips re-render when unchanged.
There was a problem hiding this comment.
Fixed in a27afab — caches previous raw content via ref and skips re-render when unchanged.
|
|
||
| export const TerminalLink = ({ url, children }: TerminalLinkProps) => { | ||
| const text = children ?? url; | ||
| return <Text color={Colors.accent}>{makeLink(text, url)}</Text>; |
There was a problem hiding this comment.
🤖 AI assisted comment - please forgive for tone
🔵 Nit — Colors.accent is hardcoded with no way to override. The pre-existing code used different colors for different link contexts (e.g. Colors.accentSecondary for the Stripe URL in RunScreen). Without a color prop, adopting TerminalLink everywhere would flatten the visual hierarchy.
Pre-written fix: review-panel #8 — adds an optional color prop defaulting to Colors.accent.
There was a problem hiding this comment.
Fixed in a27afab — added optional color prop defaulting to Colors.accent.
- Fix duplicate URL fallback in makeLink (text === url → show once)
- Remove unused Table component and barrel export
- Derive ANSI heading colors from Brand tokens via hexToAnsi()
- Scope marked config to local Marked instance (no global mutation)
- Use raw URL as Stripe link children for consistent fallback
- Hoist gradient result to module-level constant in HeaderBar
- Cache raw content in ReportViewer to skip redundant re-renders
- Add optional color prop to TerminalLink for visual hierarchy
- Narrow LogViewer JSON detection to [{ and [" (avoid [timestamp] lines)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: JSON detection never matches timestamped log lines
- Added a regex to strip the
[timestamp]prefix from log lines before checking if the content starts with JSON patterns.
- Added a regex to strip the
- ✅ Fixed: SlackScreen link fallback shows redundant URL text
- Removed explicit children from both TerminalLink usages so the URL prop is used as display text, making the fallback render the URL only once.
Or push these changes by commenting:
@cursor push e7f5e3cb8c
Preview (e7f5e3cb8c)
diff --git a/src/ui/tui/primitives/LogViewer.tsx b/src/ui/tui/primitives/LogViewer.tsx
--- a/src/ui/tui/primitives/LogViewer.tsx
+++ b/src/ui/tui/primitives/LogViewer.tsx
@@ -68,10 +68,12 @@
<Box flexDirection="column" height={visibleLines}>
{lines.map((line, i) => {
const trimmed = line.trimStart();
+ // Strip `[timestamp] ` prefix written by logToFile() before checking content
+ const body = trimmed.replace(/^\[.*?\]\s*/, '');
const isJson =
- trimmed.startsWith('{') ||
- trimmed.startsWith('[{') ||
- trimmed.startsWith('["');
+ body.startsWith('{') ||
+ body.startsWith('[{') ||
+ body.startsWith('["');
return (
<Text
key={i}
diff --git a/src/ui/tui/screens/SlackScreen.tsx b/src/ui/tui/screens/SlackScreen.tsx
--- a/src/ui/tui/screens/SlackScreen.tsx
+++ b/src/ui/tui/screens/SlackScreen.tsx
@@ -219,9 +219,7 @@
</Text>
<Text color={Colors.muted}>
Docs:{' '}
- <TerminalLink url="https://amplitude.com/docs/analytics/integrate-slack">
- amplitude.com/docs/analytics/integrate-slack
- </TerminalLink>
+ <TerminalLink url="https://amplitude.com/docs/analytics/integrate-slack" />
</Text>
</>
) : (
@@ -236,9 +234,7 @@
</Text>
<Text color={Colors.muted}>
Docs:{' '}
- <TerminalLink url="https://amplitude.com/docs/analytics/integrate-slack">
- amplitude.com/docs/analytics/integrate-slack
- </TerminalLink>
+ <TerminalLink url="https://amplitude.com/docs/analytics/integrate-slack" />
</Text>
</>
)}You can send follow-ups to the cloud agent here.
- LogViewer: strip [timestamp] prefix before checking for JSON content - SlackScreen: use URL-only TerminalLink to avoid near-duplicate fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: JSON highlighter receives non-JSON timestamp prefix
- Changed LogViewer to extract the timestamp prefix separately and pass only the JSON content to highlightCode, rendering the prefix with muted styling.
- ✅ Fixed: Stripe URL lost distinct
accentSecondarycolor- Added
color={Colors.accentSecondary}prop to the TerminalLink for the Stripe data source URL to restore the original lilac color.
- Added
Or push these changes by commenting:
@cursor push 8abcf58e08
Preview (8abcf58e08)
diff --git a/src/ui/tui/primitives/LogViewer.tsx b/src/ui/tui/primitives/LogViewer.tsx
--- a/src/ui/tui/primitives/LogViewer.tsx
+++ b/src/ui/tui/primitives/LogViewer.tsx
@@ -67,8 +67,9 @@
return (
<Box flexDirection="column" height={visibleLines}>
{lines.map((line, i) => {
- // Strip optional [timestamp] prefix before checking for JSON content
- const content = line.replace(/^\s*\[.*?\]\s*/, '');
+ const prefixMatch = line.match(/^(\s*\[.*?\]\s*)([\s\S]*)$/);
+ const prefix = prefixMatch ? prefixMatch[1] : '';
+ const content = prefixMatch ? prefixMatch[2] : line;
const isJson =
content.startsWith('{') ||
content.startsWith('[{') ||
@@ -79,7 +80,14 @@
color={isJson ? undefined : Colors.muted}
wrap="truncate"
>
- {isJson ? highlightCode(line, 'json') : line}
+ {isJson ? (
+ <>
+ <Text color={Colors.muted}>{prefix}</Text>
+ {highlightCode(content, 'json')}
+ </>
+ ) : (
+ line
+ )}
</Text>
);
})}
diff --git a/src/ui/tui/screens/RunScreen.tsx b/src/ui/tui/screens/RunScreen.tsx
--- a/src/ui/tui/screens/RunScreen.tsx
+++ b/src/ui/tui/screens/RunScreen.tsx
@@ -90,7 +90,10 @@
<Text key="stripe" color={Colors.secondary}>
<Text color={Colors.accent}>{Icons.diamond}</Text> Stripe detected
{Icons.dash} add as data source:{' '}
- <TerminalLink url={OUTBOUND_URLS.stripeDataSource}>
+ <TerminalLink
+ url={OUTBOUND_URLS.stripeDataSource}
+ color={Colors.accentSecondary}
+ >
{OUTBOUND_URLS.stripeDataSource}
</TerminalLink>
</Text>,You can send follow-ups to the cloud agent here.
| <TerminalLink url={OUTBOUND_URLS.stripeDataSource}> | ||
| {OUTBOUND_URLS.stripeDataSource} | ||
| </Text> | ||
| </TerminalLink> |
There was a problem hiding this comment.
Stripe URL lost distinct accentSecondary color
Low Severity
The Stripe data source URL previously rendered with Colors.accentSecondary (lilac) to visually distinguish it from primary accent links. Switching to TerminalLink without passing a color prop applies the default Colors.accent (blue), flattening the visual hierarchy. The TerminalLink component already supports a color prop — it just isn't used here.
Reviewed by Cursor Bugbot for commit eeb4b31. Configure here.
There was a problem hiding this comment.
Good catch — the Stripe URL previously used Colors.accentSecondary. TerminalLink now accepts a color prop (added in a27afab), so this can be set if needed. For now keeping it consistent with all other links at Colors.accent.
The JSON detection in LogViewer never worked — the wizard log format is [timestamp] [module] message with multi-line JSON bodies, so single-line detection never matched. Replace with level-based coloring: error/failed → red, warn → amber, succeeded/completed → green. Multi-line entries (JSON bodies, stack traces) inherit the color of their parent timestamp line via a carry-forward pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Exported
highlightCodefunction is never used- Removed the unused
highlightCodefunction, itscli-highlightimport, and the directcli-highlightdependency frompackage.json.
- Removed the unused
- ✅ Fixed: Success regex misses common "success" keyword variants
- Extended
SUCCESS_REto also match "success", "successful", and "successfully" via the pattern\bsuccess(?:ful(?:ly)?)?\b.
- Extended
Or push these changes by commenting:
@cursor push 85e30271a4
Preview (85e30271a4)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -47,7 +47,6 @@
"@pavus/snake-game": "^1.1.1",
"axios": "1.13.5",
"chalk": "^2.4.1",
- "cli-highlight": "^2.1.11",
"client-oauth2": "^4.3.3",
"dotenv": "^16.4.7",
"fast-glob": "^3.3.3",
diff --git a/src/ui/tui/primitives/LogViewer.tsx b/src/ui/tui/primitives/LogViewer.tsx
--- a/src/ui/tui/primitives/LogViewer.tsx
+++ b/src/ui/tui/primitives/LogViewer.tsx
@@ -19,7 +19,7 @@
const TIMESTAMP_RE = /^\[/;
const ERROR_RE = /\berror\b|\bfail(?:ed)?\b/i;
const WARN_RE = /\bwarn(?:ing)?\b/i;
-const SUCCESS_RE = /\bsucceed(?:ed)?\b|\bcompleted?\b/i;
+const SUCCESS_RE = /\bsuccess(?:ful(?:ly)?)?\b|\bsucceed(?:ed)?\b|\bcompleted?\b/i;
function getLineColor(line: string): string | null {
if (ERROR_RE.test(line)) return Colors.error;
diff --git a/src/ui/tui/utils/terminal-rendering.ts b/src/ui/tui/utils/terminal-rendering.ts
--- a/src/ui/tui/utils/terminal-rendering.ts
+++ b/src/ui/tui/utils/terminal-rendering.ts
@@ -7,7 +7,6 @@
import terminalLink from 'terminal-link';
import gradient from 'gradient-string';
-import { highlight } from 'cli-highlight';
import { Marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import { Brand } from '../styles.js';
@@ -34,20 +33,6 @@
return brandGrad(text);
}
-// ── Syntax highlighting ────────────────────────────────────────────────
-
-/**
- * Syntax-highlight a code string for terminal display.
- * Returns the original string if highlighting fails.
- */
-export function highlightCode(code: string, language?: string): string {
- try {
- return highlight(code, { language, ignoreIllegals: true });
- } catch {
- return code;
- }
-}
-
// ── Markdown rendering ─────────────────────────────────────────────────
/** Convert a hex color to an ANSI 24-bit foreground escape sequence. */You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit f0d441c. Configure here.
| } catch { | ||
| return code; | ||
| } | ||
| } |
There was a problem hiding this comment.
Exported highlightCode function is never used
Medium Severity
The highlightCode function is exported from terminal-rendering.ts but never imported or called anywhere in the codebase. The PR description mentions "JSON syntax highlighting in LogViewer," but LogViewer only uses the newly added color-coding by log level — it never calls highlightCode. This also makes the direct cli-highlight dependency in package.json unnecessary, since marked-terminal already pulls it in transitively. Both the function and the direct dependency are dead code.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit f0d441c. Configure here.
| const TIMESTAMP_RE = /^\[/; | ||
| const ERROR_RE = /\berror\b|\bfail(?:ed)?\b/i; | ||
| const WARN_RE = /\bwarn(?:ing)?\b/i; | ||
| const SUCCESS_RE = /\bsucceed(?:ed)?\b|\bcompleted?\b/i; |
There was a problem hiding this comment.
Success regex misses common "success" keyword variants
Low Severity
SUCCESS_RE matches "succeed"/"succeeded" and "complete"/"completed" but doesn't match "success", "successful", or "successfully" — arguably the most common success indicators in log output (e.g., "Build successful", "Successfully compiled"). This means many success-indicating log lines won't get the green color treatment, undermining the purpose of the color-coding feature.
Reviewed by Cursor Bugbot for commit f0d441c. Configure here.



Summary
<TerminalLink>primitive with optionalcolorprop, plus sharedterminal-rendering.tsutilityAMP-152421
Test plan
pnpm buildcompiles without errorspnpm test— all 849 tests passpnpm try— visually verify gradient header, clickable URLs, level-colored logs, and rendered markdown report🤖 Generated with Claude Code