diff --git a/app/playground/ansi-demo.tsx b/app/playground/ansi-demo.tsx new file mode 100644 index 0000000..3099d93 --- /dev/null +++ b/app/playground/ansi-demo.tsx @@ -0,0 +1,152 @@ +'use client' + +import { Terminal, TerminalCommand, TerminalOutput } from '@/components/terminal' +import { TerminalAnsi } from '@/components/terminal-ansi' + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const ESC = '\x1b[' +const R = `${ESC}0m` +const bold = (s: string) => `${ESC}1m${s}${R}` +const dim = (s: string) => `${ESC}2m${s}${R}` +const fg = (code: number, s: string) => `${ESC}${30 + code}m${s}${R}` +const fgBright = (code: number, s: string) => `${ESC}${90 + code}m${s}${R}` +const fg256 = (n: number, s: string) => `${ESC}38;5;${n}m${s}${R}` +const fgRgb = (r: number, g: number, b: number, s: string) => `${ESC}38;2;${r};${g};${b}m${s}${R}` +const bg = (code: number, s: string) => `${ESC}${40 + code}m${s}${R}` + +// ── Sample strings ──────────────────────────────────────────────────────────── + +/** + * Realistic pnpm/Vite build output with ANSI colours. + */ +const BUILD_LOG = [ + `${bold(fg(4, 'vite'))} v5.4.11 ${fg(2, 'building for production...')}`, + '', + ` ${fg(6, '✓')} 142 modules transformed.`, + ` ${fg(6, '✓')} built in ${fg(3, '1.42s')}`, + '', + ` ${dim('dist/index.html ')} ${fg(2, '1.23 kB')} │ gzip: ${fg(2, '0.62 kB')}`, + ` ${dim('dist/assets/index-DiwrgTda.js')} ${fg(3, '142.18 kB')} │ gzip: ${fg(2, '45.31 kB')}`, + ` ${dim('dist/assets/index-Bx9a8Z1E.css')} ${fg(2, '8.94 kB')} │ gzip: ${fg(2, '2.15 kB')}`, +].join('\n') + +/** + * Jest-style test runner output. + */ +const TEST_LOG = [ + ` ${fg(2, 'PASS')} src/utils/parseAnsi.test.ts ${dim('(1.2s)')}`, + ` ${fg(2, 'PASS')} src/components/TerminalAnsi.test.tsx`, + ` ${fg(1, 'FAIL')} src/utils/formatter.test.ts`, + '', + ` ${bold(fg(1, '● formatter › formatBytes › handles 0'))}`, + ` Expected: ${fg(2, '"0 B"')}`, + ` Received: ${fg(1, '"NaN B"')}`, + '', + ` ${fg(3, 'Test Suites')}: ${fg(1, bold('1 failed'))}, ${fg(2, '2 passed')}, 3 total`, + ` ${fg(3, 'Tests')}: ${fg(1, bold('1 failed'))}, ${fg(2, '14 passed')}, 15 total`, +].join('\n') + +/** + * Showcases all 16 standard ANSI colours. + */ +const COLOR_SWATCHES = [ + ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'].map((name, i) => + `${ESC}${30 + i}m${name}${R}` + ).join(' '), + ['bright-black', 'bright-red', 'bright-green', 'bright-yellow', 'bright-blue', 'bright-magenta', 'bright-cyan', 'bright-white'].map((name, i) => + `${ESC}${90 + i}m${name}${R}` + ).join(' '), +].join('\n') + +/** + * Showcases text decorations and dim. + */ +const STYLES_LINE = + `${bold('bold')} ` + + `${dim('dim')} ` + + `${ESC}3mitalic${R} ` + + `${ESC}4munderline${R} ` + + `${ESC}9mstrikethrough${R} ` + + `${ESC}7;32m inverted ${R}` + +/** + * 256-colour palette sampler — first 36 entries of the 6×6×6 cube. + */ +const PALETTE_256 = Array.from({ length: 36 }, (_, i) => + fg256(16 + i, '▄'), +).join('') + +/** + * True-colour (24-bit) horizontal gradient from cyan → magenta. + */ +const TRUE_COLOR_GRAD = Array.from({ length: 48 }, (_, i) => { + const t = i / 47 + const r = Math.round(0 + t * 236) + const g = Math.round(183 - t * 183) + const b = Math.round(212 + t * (236 - 212)) + return fgRgb(r, g, b, '█') +}).join('') + +/** + * Background colour demo. + */ +const BG_DEMO = + `${bg(1, ' error ')} ` + + `${bg(2, ' success ')} ` + + `${bg(3, ' warn ')} ` + + `${bg(4, ' info ')} ` + + `${fgBright(7, bg(0, ' on-black '))}` + +// ───────────────────────────────────────────────────────────────────────────── + +export function AnsiDemo() { + return ( +
+ + {/* Build output */} + + pnpm run build + + {BUILD_LOG} + + + + {/* Test runner */} + + pnpm test + + {TEST_LOG} + + + + {/* Standard colours + styles */} + + ./print-colors.sh + + {COLOR_SWATCHES} + + + {STYLES_LINE} + + + {BG_DEMO} + + + + {/* 256-colour + true-colour */} + + ./print-palette.sh + + 256-colour cube: + {PALETTE_256} + + + true-colour grad: + {TRUE_COLOR_GRAD} + + + +
+ ) +} diff --git a/app/playground/filter-demo.tsx b/app/playground/filter-demo.tsx new file mode 100644 index 0000000..a270f0a --- /dev/null +++ b/app/playground/filter-demo.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useMemo, useState } from 'react' +import { + Terminal, + TerminalCommand, + TerminalLog, + type LogEntry, +} from '@/components/terminal' +import { + TerminalFilterBar, + emptyFilterState, + filterEntries, + type FilterBarState, +} from '@/components/terminal-filter-bar' + +const ALL_ENTRIES: LogEntry[] = [ + { id: '1', level: 'info', timestamp: '10:23:44', source: 'server', message: 'Application starting up...' }, + { id: '2', level: 'info', timestamp: '10:23:45', source: 'server', message: 'Listening on :3000' }, + { id: '3', level: 'debug', timestamp: '10:23:45', source: 'cache', message: 'HIT packages/react@19.1.0' }, + { id: '4', level: 'warn', timestamp: '10:23:46', source: 'cache', message: 'MISS @openknots/terminal-ui — fetching from registry' }, + { id: '5', level: 'info', timestamp: '10:23:47', source: 'build', message: 'Compiling 42 modules...' }, + { id: '6', level: 'debug', timestamp: '10:23:47', source: 'build', message: 'Resolved tsconfig paths in 4ms' }, + { id: '7', level: 'success', timestamp: '10:23:48', source: 'build', message: 'Compiled in 1.2s — 0 errors' }, + { id: '8', level: 'info', timestamp: '10:23:49', source: 'test', message: 'Running 24 unit tests...' }, + { id: '9', level: 'error', timestamp: '10:23:50', source: 'test', message: 'FAIL src/utils.test.ts — 1 snapshot mismatch' }, + { id: '10', level: 'info', timestamp: '10:23:50', source: 'test', message: 'Retrying with --updateSnapshot...' }, + { id: '11', level: 'success', timestamp: '10:23:51', source: 'test', message: '24 / 24 tests passed — coverage 94%' }, + { id: '12', level: 'info', timestamp: '10:23:52', source: 'deploy', message: 'Uploading artifacts to CDN...' }, + { id: '13', level: 'warn', timestamp: '10:23:52', source: 'deploy', message: 'Edge cache warm-up taking longer than 5s' }, + { id: '14', level: 'success', timestamp: '10:23:53', source: 'deploy', message: 'Published to production — https://example.app' }, +] + +const SOURCES = [...new Set(ALL_ENTRIES.map((e) => e.source!))] + +export function FilterBarDemo() { + const [filter, setFilter] = useState(emptyFilterState) + + const visible = useMemo(() => filterEntries(ALL_ENTRIES, filter), [filter]) + + return ( +
+ + + pnpm run ci + + +

+ {visible.length} / {ALL_ENTRIES.length} entries shown +

+
+ ) +} diff --git a/app/playground/group-demo.tsx b/app/playground/group-demo.tsx new file mode 100644 index 0000000..ecd6164 --- /dev/null +++ b/app/playground/group-demo.tsx @@ -0,0 +1,201 @@ +'use client' + +import { useState } from 'react' +import { Terminal, TerminalCommand, TerminalGroup, TerminalLogLine } from '@/components/terminal' + +// ── Demo ────────────────────────────────────────────────────────────────────── + +/** + * Playground demo for TerminalGroup. + * + * Shows several groups in a single terminal window: + * - A success group (collapsed by default) — install step + * - A warn group (open) — test step with a warning + * - An error group (open) — build step with failures + * - An info group (open) — controlled open/close via external button + */ +export function GroupDemo() { + // Controlled group example + const [deployOpen, setDeployOpen] = useState(true) + + return ( + + pnpm run ci + + {/* ── Install step: collapsed by default ── */} +
+ + + + + + +
+ + {/* ── Lint step: info, collapsed by default ── */} +
+ + + + +
+ + {/* ── Test step: warn, open ── */} +
+ + + + + + +
+ + {/* ── Build step: error, open ── */} +
+ + + + + + +
+ + {/* ── Deploy step: controlled ── */} +
+ + + + + + + {/* External controlled toggle */} +
+ +
+
+
+ ) +} diff --git a/app/playground/log-demo.tsx b/app/playground/log-demo.tsx index 8441c4c..d17002d 100644 --- a/app/playground/log-demo.tsx +++ b/app/playground/log-demo.tsx @@ -1,7 +1,9 @@ 'use client' import { useEffect, useState } from 'react' -import { Terminal, TerminalCommand, TerminalLog } from '@/components/terminal' +import { Terminal, TerminalCommand, TerminalLog, type LogEntry } from '@/components/terminal' + +// ── String-mode demo (unchanged) ───────────────────────────────────────────── const STREAM_LINES = [ '[info] Connecting to build worker...', @@ -38,3 +40,60 @@ export function LogDemo() { ) } + +// ── Structured-mode demo ───────────────────────────────────────────────────── + +const STREAM_ENTRIES: LogEntry[] = [ + { level: 'info', timestamp: '10:23:45', source: 'server', message: 'Worker connected' }, + { level: 'debug', timestamp: '10:23:46', source: 'cache', message: 'HIT packages/react@19.1.0' }, + { level: 'warn', timestamp: '10:23:47', source: 'cache', message: 'MISS @openknots/terminal-ui' }, + { level: 'info', timestamp: '10:23:48', source: 'build', message: 'Compiling 42 modules...' }, + { level: 'success', timestamp: '10:23:49', source: 'build', message: 'Compiled in 1.2s' }, + { level: 'info', timestamp: '10:23:50', source: 'test', message: 'Running 24 unit tests...' }, + { + level: 'error', + timestamp: '10:23:51', + source: 'test', + message: 'FAIL src/utils.test.ts — 1 snapshot mismatch', + }, + { + level: 'info', + timestamp: '10:23:52', + source: 'test', + message: 'Retrying with --updateSnapshot...', + }, + { level: 'success', timestamp: '10:23:53', source: 'test', message: '24 / 24 tests passed' }, + { level: 'success', timestamp: '10:23:54', source: 'deploy', message: 'Published to production' }, +] + +export function StructuredLogDemo() { + const [entries, setEntries] = useState([ + { + id: 'boot-0', + level: 'info', + timestamp: '10:23:44', + source: 'server', + message: 'Starting pipeline...', + }, + ]) + + useEffect(() => { + const timer = window.setInterval(() => { + setEntries((current) => { + const next = STREAM_ENTRIES[current.length % STREAM_ENTRIES.length] + return [...current, { ...next, id: String(current.length) }] + }) + }, 900) + + return () => { + window.clearInterval(timer) + } + }, []) + + return ( + + pnpm run ci + + + ) +} diff --git a/app/playground/page.tsx b/app/playground/page.tsx index f6a7bde..3628616 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -1,10 +1,27 @@ import { TerminalApp } from '@/components/terminal-app' -import { Terminal, TerminalCommand, TerminalDiff, TerminalOutput, TerminalSpinner, TerminalBadge, ThemeSwitcher } from '@/components/terminal' +import { + Terminal, + TerminalCommand, + TerminalDiff, + TerminalOutput, + TerminalSpinner, + TerminalBadge, + TerminalMarker, + TerminalLogLine, + ThemeSwitcher, +} from '@/components/terminal' import { TerminalProgress } from '@/components/terminal-progress' -import { LogDemo } from './log-demo' +import { LogDemo, StructuredLogDemo } from './log-demo' +import { FeedDemo } from './feed-demo' +import { FilterBarDemo } from './filter-demo' +import { TerminalJsonLine } from '@/components/terminal' import { PromptDemo } from './prompt-demo' +import { GroupDemo } from './group-demo' +import { SearchDemo } from './search-demo' import { TreeDemo } from './tree-demo' import { TreeKeyboardDemo } from './tree-keyboard-demo' +import { StackTraceDemo } from './stack-trace-demo' +import { AnsiDemo } from './ansi-demo' export const metadata = { title: 'Playground', @@ -14,25 +31,19 @@ export default function PlaygroundPage() { return (
-

- Playground -

+

Playground

-

- Terminal App -

+

Terminal App

-

- TerminalPrompt -

+

TerminalPrompt

Interactive command input with history navigation (up / down).

@@ -40,9 +51,7 @@ export default function PlaygroundPage() {
-

- TerminalProgress -

+

TerminalProgress

pnpm install @@ -53,9 +62,7 @@ export default function PlaygroundPage() {
-

- TerminalSpinner -

+

TerminalSpinner

pnpm run build @@ -64,7 +71,7 @@ export default function PlaygroundPage() {

- TerminalLog + TerminalLog — string mode

Simulated streaming logs with capped history and auto-scroll. @@ -74,19 +81,28 @@ export default function PlaygroundPage() {

- Copy Button + TerminalLog — structured mode

+

+ Structured entries with level badges, timestamps, and source labels via{' '} + entries prop. +

+ +
+ +
+

Copy Button

pnpm run build Compiled successfully in 1.2s - Click the copy icon in the header to copy this output. + + Click the copy icon in the header to copy this output. +
-

- TerminalDiff -

+

TerminalDiff

git diff -- src/config.ts Unified @@ -124,9 +140,7 @@ export default function PlaygroundPage() {
-

- TerminalTree -

+

TerminalTree

Expandable tree with custom icon, label, and row render props.

@@ -138,16 +152,14 @@ export default function PlaygroundPage() { Tree Keyboard Navigation

- Arrow keys to navigate, Enter/Space to toggle, ArrowRight to expand/enter, ArrowLeft to collapse/parent. + Arrow keys to navigate, Enter/Space to toggle, ArrowRight to expand/enter, ArrowLeft to + collapse/parent.

-
-

- TerminalBadge -

+

TerminalBadge

pnpm run release @@ -162,9 +174,110 @@ export default function PlaygroundPage() {
-

- Typing Animation -

+

TerminalMarker

+

+ Phase separators for visual boundaries in terminal feeds. +

+ + npm run deploy:full + + ✓ Compiled 42 modules + dist/main.js 124 KB + + ✓ 24 tests passed + coverage: 94% + + → Deploying to production... + ✓ Deployed successfully + + +
+ +
+

TerminalLogLine

+

+ Structured log row primitive with level badge, timestamp, source, and message. +

+ + pnpm run start + + + + + + + + + +
+ +
+

TerminalFeed

+

+ High-performance log feed. Compare standard (DOM-bounded) vs virtualized (constant node + count) modes. Hit ⚡ burst to stress-test. +

+ +
+ +
+

TerminalGroup

+

+ Collapsible command/output sections with header, summary, count pill, and variant accent. +

+ +
+ +
+

TerminalSearch

+

+ In-feed search with next / prev navigation (Enter / Shift+Enter) and match highlighting. +

+ +
+ +
+

Typing Animation

npm run deploy @@ -175,6 +288,86 @@ export default function PlaygroundPage() {
+ +
+

TerminalAnsi

+

+ ANSI SGR escape-code renderer — standard/bright/256/true-colour, bold, dim, italic, + underline, strikethrough, invert. No dangerouslySetInnerHTML. +

+ +
+ +
+

+ TerminalStackTrace +

+

+ Foldable stack trace viewer with per-frame collapse, node_modules filtering, and + keyboard-accessible toggles. +

+ +
+ +
+

TerminalFilterBar

+

+ Controlled filter bar — level toggles, text search, and source toggles. Pair with{' '} + filterEntries() to apply. +

+ +
+ +
+

TerminalJsonLine

+

+ Collapsible JSON payload renderer — click to expand, handles invalid JSON safely. +

+ + tail -f logs/events.log + + + + + + +
) } diff --git a/app/playground/search-demo.tsx b/app/playground/search-demo.tsx new file mode 100644 index 0000000..2f24278 --- /dev/null +++ b/app/playground/search-demo.tsx @@ -0,0 +1,243 @@ +'use client' + +import { useEffect, useRef, type ReactNode } from 'react' +import { + Terminal, + TerminalCommand, + TerminalLogLine, + TerminalSearch, + useTerminalSearch, + type LogEntry, +} from '@/components/terminal' +import { TerminalLog } from '@/components/terminal-log' + +// ── Static log fixture ──────────────────────────────────────────────────────── + +const SEARCH_ENTRIES: LogEntry[] = [ + { + id: '0', + level: 'info', + timestamp: '12:00:01', + source: 'server', + message: 'HTTP server listening on :3000', + }, + { + id: '1', + level: 'info', + timestamp: '12:00:02', + source: 'db', + message: 'PostgreSQL connection pool ready (max: 10)', + }, + { + id: '2', + level: 'debug', + timestamp: '12:00:03', + source: 'cache', + message: 'Redis connected at 127.0.0.1:6379', + }, + { + id: '3', + level: 'info', + timestamp: '12:00:04', + source: 'server', + message: 'GET /api/health → 200 OK (3ms)', + }, + { + id: '4', + level: 'warn', + timestamp: '12:00:05', + source: 'db', + message: 'Slow query detected: SELECT * FROM sessions (450ms)', + }, + { + id: '5', + level: 'info', + timestamp: '12:00:06', + source: 'server', + message: 'POST /api/login → 200 OK (22ms)', + }, + { + id: '6', + level: 'debug', + timestamp: '12:00:07', + source: 'cache', + message: 'MISS sessions:user:42', + }, + { + id: '7', + level: 'info', + timestamp: '12:00:08', + source: 'db', + message: 'Inserted session for user:42', + }, + { + id: '8', + level: 'error', + timestamp: '12:00:09', + source: 'server', + message: 'POST /api/upload → 413 Payload Too Large', + }, + { + id: '9', + level: 'warn', + timestamp: '12:00:10', + source: 'server', + message: 'Rate limit exceeded for IP 203.0.113.7', + }, + { + id: '10', + level: 'info', + timestamp: '12:00:11', + source: 'server', + message: 'GET /api/users → 200 OK (11ms)', + }, + { + id: '11', + level: 'debug', + timestamp: '12:00:12', + source: 'cache', + message: 'HIT sessions:user:42', + }, + { + id: '12', + level: 'success', + timestamp: '12:00:13', + source: 'deploy', + message: 'Rolling update complete — 4/4 instances healthy', + }, + { + id: '13', + level: 'error', + timestamp: '12:00:14', + source: 'db', + message: 'Connection timeout after 5000ms — retrying…', + }, + { + id: '14', + level: 'success', + timestamp: '12:00:15', + source: 'db', + message: 'Reconnected to PostgreSQL successfully', + }, + { + id: '15', + level: 'info', + timestamp: '12:00:16', + source: 'server', + message: 'DELETE /api/sessions/user:42 → 204 No Content', + }, +] + +// ── Highlight helper ────────────────────────────────────────────────────────── + +/** + * Wraps every case-insensitive occurrence of `query` within `text` in a + * highlight `` span. Returns the original string when `query` is empty. + */ +function highlight(text: string, query: string): ReactNode { + if (!query) return text + const lc = text.toLowerCase() + const lcQ = query.toLowerCase() + const nodes: ReactNode[] = [] + let cursor = 0 + + while (cursor < text.length) { + const found = lc.indexOf(lcQ, cursor) + if (found === -1) { + nodes.push(text.slice(cursor)) + break + } + if (found > cursor) nodes.push(text.slice(cursor, found)) + nodes.push( + + {text.slice(found, found + query.length)} + + ) + cursor = found + query.length + } + + return nodes.length === 1 ? nodes[0] : <>{nodes} +} + +// ── Demo component ──────────────────────────────────────────────────────────── + +/** + * Playground demo for TerminalSearch + useTerminalSearch. + * + * Displays a static log feed with an in-line search bar. + * Matching rows are outlined; the focused match is scrolled into view. + */ +export function SearchDemo() { + // Build the flat string array the hook needs. + const searchItems = SEARCH_ENTRIES.map((e) => + [e.message, e.source ?? '', e.level ?? ''].join(' ') + ) + + const search = useTerminalSearch(searchItems) + + // Scroll current match into view. + const rowRefs = useRef<(HTMLDivElement | null)[]>([]) + useEffect(() => { + if (search.currentIndex < 0) return + const matchItemIndex = search.matchIndices[search.currentIndex] + rowRefs.current[matchItemIndex]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }, [search.currentIndex, search.matchIndices]) + + return ( + + tail -f logs/server.log | grep … + + {/* ── Search bar ── */} +
+ +
+ + {/* ── Log feed ── */} +
+ {SEARCH_ENTRIES.map((entry, index) => { + const isCurrent = search.isCurrentMatch(index) + const isAnyMatch = search.isMatch(index) + + return ( +
{ + rowRefs.current[index] = el + }} + className={`rounded transition-colors + ${ + isCurrent + ? 'ring-1 ring-(--term-yellow)/50 bg-[color-mix(in_oklab,var(--term-yellow)_6%,transparent)]' + : isAnyMatch + ? 'ring-1 ring-(--term-yellow)/20 bg-[color-mix(in_oklab,var(--term-yellow)_3%,transparent)]' + : '' + }`} + > + +
+ ) + })} +
+
+ ) +} diff --git a/app/playground/stack-trace-demo.tsx b/app/playground/stack-trace-demo.tsx new file mode 100644 index 0000000..bb890b0 --- /dev/null +++ b/app/playground/stack-trace-demo.tsx @@ -0,0 +1,45 @@ +'use client' + +import { Terminal, TerminalCommand, TerminalOutput } from '@/components/terminal' +import { TerminalStackTrace } from '@/components/terminal-stack-trace' + +// ── Sample stack traces ────────────────────────────────────────────────────── + +const NODE_STACK = `TypeError: Cannot read properties of undefined (reading 'map') + at ProductList (/app/src/components/ProductList.tsx:42:15) + at renderWithHooks (/app/node_modules/react-dom/cjs/react-dom.development.js:14985:18) + at mountIndeterminateComponent (/app/node_modules/react-dom/cjs/react-dom.development.js:17811:13) + at beginWork (/app/node_modules/react-dom/cjs/react-dom.development.js:19049:16) + at App (/app/src/App.tsx:18:5) + at callRenderFunction (/app/node_modules/react-dom/cjs/react-dom.development.js:3991:14) + at performWork (/app/node_modules/react-dom/cjs/react-dom.development.js:6422:7)`.trim() + +const ASYNC_STACK = `Error: ENOENT: no such file or directory, open '/etc/secrets/api.key' + at Object.openSync (node:fs:596:3) + at Object.readFileSync (node:fs:464:35) + at loadSecrets (/app/src/config/secrets.ts:12:18) + at bootstrap (/app/src/index.ts:7:3) + at processTicksAndRejections (node:internal/process/task_queues:95:5) + at async start (/app/src/server.ts:23:3)`.trim() + +const MINIMAL_STACK = `RangeError: Maximum call stack size exceeded + at fibonacci (/app/src/utils/math.ts:8:12) + at fibonacci (/app/src/utils/math.ts:9:10) + at fibonacci (/app/src/utils/math.ts:9:10) + at fibonacci (/app/src/utils/math.ts:9:10)`.trim() + +// ───────────────────────────────────────────────────────────────────────────── + +export function StackTraceDemo() { + return ( + + pnpm run dev + Unhandled runtime error — see trace below +
+ + + +
+
+ ) +} diff --git a/components/terminal-ansi.tsx b/components/terminal-ansi.tsx new file mode 100644 index 0000000..ed5be34 --- /dev/null +++ b/components/terminal-ansi.tsx @@ -0,0 +1,382 @@ +'use client' + +import { useMemo, type CSSProperties } from 'react' + +// ─── ANSI Color Tables ──────────────────────────────────────────────────────── + +/** + * Standard 4-bit ANSI color palette (indices 0–15). + * + * Indices 0–7 → normal colors (SGR fg 30–37, bg 40–47) + * Indices 8–15 → bright colors (SGR fg 90–97, bg 100–107) + * + * Colors that correspond to existing theme tokens use CSS custom-property + * references so the component stays fully theme-aware. Bright shades that + * have no direct token use literal hex values matching the project palette. + */ +const ANSI_16: readonly string[] = [ + /* 0 black */ 'var(--term-bg)', + /* 1 red */ 'var(--term-red)', + /* 2 green */ 'var(--term-green)', + /* 3 yellow */ 'var(--term-yellow)', + /* 4 blue */ 'var(--term-blue)', + /* 5 magenta */ 'var(--term-purple)', + /* 6 cyan */ 'var(--term-cyan)', + /* 7 white */ 'var(--term-fg)', + /* 8 bright black */ 'var(--term-fg-dim)', + /* 9 bright red */ '#f87171', + /* 10 bright green */ '#34d399', + /* 11 bright yellow */ '#fde047', + /* 12 bright blue */ '#60a5fa', + /* 13 bright magenta*/ '#c084fc', + /* 14 bright cyan */ '#22d3ee', + /* 15 bright white */ '#ffffff', +] as const + +// ─── Internal Types ────────────────────────────────────────────────────────── + +interface AnsiStyle { + /** CSS color string for the foreground, or undefined for the terminal default. */ + fg?: string + /** CSS color string for the background, or undefined for transparent. */ + bg?: string + bold: boolean + dim: boolean + italic: boolean + underline: boolean + strikethrough: boolean + /** Swap fg/bg (SGR 7). */ + invert: boolean +} + +const RESET_STYLE: AnsiStyle = { + bold: false, + dim: false, + italic: false, + underline: false, + strikethrough: false, + invert: false, +} + +/** A contiguous run of text that shares a single style. */ +export interface AnsiSpan { + /** Plain text content — never contains escape sequences. */ + text: string + /** Resolved style for this span. */ + style: AnsiStyle +} + +// ─── 256-color Resolver ────────────────────────────────────────────────────── + +/** + * Converts an xterm 256-colour palette index to a CSS colour string. + * + * - 0–15 → mapped to the same 16 entries as standard + bright ANSI colours. + * - 16–231 → 6×6×6 RGB colour cube. + * - 232–255 → 24-step greyscale ramp. + */ +function ansi256Color(code: number): string { + if (code < 16) return ANSI_16[code] + + if (code < 232) { + const idx = code - 16 + const b = idx % 6 + const g = Math.floor(idx / 6) % 6 + const r = Math.floor(idx / 36) + const v = (n: number) => (n === 0 ? 0 : n * 40 + 55) + return `rgb(${v(r)}, ${v(g)}, ${v(b)})` + } + + const gray = (code - 232) * 10 + 8 + return `rgb(${gray}, ${gray}, ${gray})` +} + +// ─── SGR Parameter Processor ───────────────────────────────────────────────── + +/** + * Applies a semicolon-delimited SGR parameter string to a current style and + * returns the updated style. Does **not** mutate `current`. + * + * Handles: + * - `0` / empty — reset + * - `1` bold, `2` dim, `3` italic, `4` underline, `7` invert, `9` strikethrough + * - `22` unset bold/dim, `23` unset italic, `24` unset underline, + * `27` unset invert, `29` unset strikethrough + * - `30–37` standard fg, `39` default fg + * - `38;5;n` 256-colour fg, `38;2;r;g;b` true-colour fg + * - `40–47` standard bg, `49` default bg + * - `48;5;n` 256-colour bg, `48;2;r;g;b` true-colour bg + * - `90–97` bright fg, `100–107` bright bg + */ +function applyParams(current: AnsiStyle, paramStr: string): AnsiStyle { + // Empty param string or bare "0" is a full reset. + if (!paramStr || paramStr === '0') return { ...RESET_STYLE } + + const next: AnsiStyle = { ...current } + const params = paramStr.split(';').map(Number) + let i = 0 + + while (i < params.length) { + const p = params[i] + + if (p === 0) { Object.assign(next, RESET_STYLE) } + else if (p === 1) { next.bold = true } + else if (p === 2) { next.dim = true } + else if (p === 3) { next.italic = true } + else if (p === 4) { next.underline = true } + else if (p === 7) { next.invert = true } + else if (p === 9) { next.strikethrough = true } + else if (p === 22) { next.bold = false; next.dim = false } + else if (p === 23) { next.italic = false } + else if (p === 24) { next.underline = false } + else if (p === 27) { next.invert = false } + else if (p === 29) { next.strikethrough = false } + + // Standard foreground 30–37 + else if (p >= 30 && p <= 37) { next.fg = ANSI_16[p - 30] } + + // Extended foreground: 256-colour or true-colour + else if (p === 38) { + if (params[i + 1] === 5 && params[i + 2] !== undefined) { + next.fg = ansi256Color(params[i + 2]) + i += 2 + } else if (params[i + 1] === 2 && params[i + 4] !== undefined) { + next.fg = `rgb(${params[i + 2]}, ${params[i + 3]}, ${params[i + 4]})` + i += 4 + } + } + + else if (p === 39) { next.fg = undefined } + + // Standard background 40–47 + else if (p >= 40 && p <= 47) { next.bg = ANSI_16[p - 40] } + + // Extended background: 256-colour or true-colour + else if (p === 48) { + if (params[i + 1] === 5 && params[i + 2] !== undefined) { + next.bg = ansi256Color(params[i + 2]) + i += 2 + } else if (params[i + 1] === 2 && params[i + 4] !== undefined) { + next.bg = `rgb(${params[i + 2]}, ${params[i + 3]}, ${params[i + 4]})` + i += 4 + } + } + + else if (p === 49) { next.bg = undefined } + + // Bright foreground 90–97 + else if (p >= 90 && p <= 97) { next.fg = ANSI_16[p - 90 + 8] } + + // Bright background 100–107 + else if (p >= 100 && p <= 107) { next.bg = ANSI_16[p - 100 + 8] } + + i++ + } + + return next +} + +// ─── Style → CSSProperties ─────────────────────────────────────────────────── + +function styleToCss(s: AnsiStyle): CSSProperties { + const css: CSSProperties = {} + + let fg = s.fg + let bg = s.bg + + if (s.invert) { + // Swap fg ↔ bg, defaulting to terminal background/foreground tokens. + const tmpFg = bg ?? 'var(--term-bg)' + const tmpBg = fg ?? 'var(--term-fg)' + fg = tmpFg + bg = tmpBg + } + + if (fg) css.color = fg + if (bg) css.backgroundColor = bg + + if (s.bold) css.fontWeight = 700 + if (s.dim) css.opacity = 0.5 + if (s.italic) css.fontStyle = 'italic' + + const dec: string[] = [] + if (s.underline) dec.push('underline') + if (s.strikethrough) dec.push('line-through') + if (dec.length) css.textDecoration = dec.join(' ') + + return css +} + +// ─── Parser ────────────────────────────────────────────────────────────────── + +/** Regex that matches any ANSI SGR escape sequence: ESC [ m */ +const SGR_RE = /\x1b\[([0-9;]*)m/g + +/** + * Strips non-SGR ANSI escape sequences (cursor movement, erase, etc.) from a + * text segment so they don't appear as raw garbage characters in the output. + */ +const NON_SGR_RE = /\x1b\[[^m]*[A-Za-ln-z]/g + +/** + * Parses a raw string that may contain ANSI SGR escape sequences and returns + * an array of `AnsiSpan` objects, each containing a plain-text fragment and + * the resolved `AnsiStyle` to apply. + * + * This is a **pure function** — safe to call outside of React, unit-testable, + * and free of any side effects. + * + * Behaviour contract: + * - Plain text (no escape sequences) → single span with reset style. + * - Empty string → empty array. + * - Trailing reset (`\x1b[0m`) with no following text → no trailing span. + * - Consecutive spans with identical effective styles are **not** merged (kept + * simple; callers can merge if needed). + * - Unrecognised SGR codes are silently ignored; the style is otherwise + * unchanged. + * - Non-SGR escape sequences (e.g. cursor movement) are stripped from all + * text segments to prevent raw garbage characters appearing in output. + * - 256-colour and true-colour (24-bit) sequences are resolved to `rgb(…)` + * CSS values. + * + * @example + * ```ts + * parseAnsi('\x1b[32mhello\x1b[0m world') + * // → [ + * // { text: 'hello', style: { fg: 'var(--term-green)', bold: false, ... } }, + * // { text: ' world', style: { bold: false, ... } }, + * // ] + * ``` + * + * @example + * ```ts + * // Bold red then reset + * parseAnsi('\x1b[1;31mERROR\x1b[0m: file not found') + * // → [ + * // { text: 'ERROR', style: { fg: 'var(--term-red)', bold: true, ...} }, + * // { text: ': file not found', style: { bold: false, ... } }, + * // ] + * ``` + * + * @example + * ```ts + * // 256-colour + * parseAnsi('\x1b[38;5;214morange\x1b[0m') + * // → [{ text: 'orange', style: { fg: 'rgb(255, 175, 0)', ... } }] + * ``` + * + * @example + * ```ts + * // True-colour RGB + * parseAnsi('\x1b[38;2;255;128;0mbright orange\x1b[0m') + * // → [{ text: 'bright orange', style: { fg: 'rgb(255, 128, 0)', ... } }] + * ``` + * + * @example + * ```ts + * // Plain text — no escape sequences + * parseAnsi('hello world') + * // → [{ text: 'hello world', style: { bold: false, dim: false, ... } }] + * ``` + */ +export function parseAnsi(raw: string): AnsiSpan[] { + const spans: AnsiSpan[] = [] + let cursor = 0 + let style: AnsiStyle = { ...RESET_STYLE } + + SGR_RE.lastIndex = 0 + + let match: RegExpExecArray | null + while ((match = SGR_RE.exec(raw)) !== null) { + // Emit text segment before this escape sequence. + if (match.index > cursor) { + const text = raw.slice(cursor, match.index).replace(NON_SGR_RE, '') + if (text) spans.push({ text, style: { ...style } }) + } + + // Advance style from the SGR params embedded in this escape. + style = applyParams(style, match[1]) + cursor = match.index + match[0].length + } + + // Emit any remaining text after the last escape sequence. + if (cursor < raw.length) { + const text = raw.slice(cursor).replace(NON_SGR_RE, '') + if (text) spans.push({ text, style: { ...style } }) + } + + return spans +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export interface TerminalAnsiProps { + /** + * Raw string that may contain ANSI SGR escape sequences. + * Plain strings (no escapes) render correctly without any overhead. + */ + children: string + /** + * Render with a block (`div`) wrapper rather than inline (`span`). + * Useful when rendering multi-line ANSI output (e.g. build logs, test + * runners). The wrapper also gets `whitespace-pre-wrap` so newlines inside + * the string are preserved. + * @default false + */ + block?: boolean + /** Optional extra class names applied to the wrapper element. */ + className?: string +} + +/** + * Renders a string that may contain ANSI SGR escape sequences as styled React + * spans — without any `dangerouslySetInnerHTML`, so output is always safe from + * HTML-injection attacks. + * + * Supports: + * - Standard & bright foreground/background colors (mapped to theme CSS vars + * where possible). + * - 256-colour (`38;5;n` / `48;5;n`) and true-colour RGB (`38;2;r;g;b` / + * `48;2;r;g;b`) sequences. + * - Bold, dim, italic, underline, strikethrough, invert (SGR 1–9 and resets). + * - Clean stripping of non-SGR escape sequences (cursor moves etc.). + * + * @example + * ```tsx + * // Inline inside a terminal output line + * + * {'\x1b[32m✓\x1b[0m build passed'} + * + * ``` + * + * @example + * ```tsx + * // Multi-line block (e.g. piped build output) + * {rawBuildLog} + * ``` + */ +export function TerminalAnsi({ children, block = false, className = '' }: TerminalAnsiProps) { + const spans = useMemo(() => parseAnsi(children), [children]) + + const wrapperClass = ['font-mono text-sm', block ? 'whitespace-pre-wrap' : '', className] + .filter(Boolean) + .join(' ') + + const content = spans.map((span, i) => { + const css = styleToCss(span.style) + const hasStyle = Object.keys(css).length > 0 + return hasStyle ? ( + + {span.text} + + ) : ( + {span.text} + ) + }) + + if (block) { + return
{content}
+ } + + return {content} +} diff --git a/components/terminal-filter-bar.tsx b/components/terminal-filter-bar.tsx new file mode 100644 index 0000000..6409ee6 --- /dev/null +++ b/components/terminal-filter-bar.tsx @@ -0,0 +1,292 @@ +'use client' + +import { useId } from 'react' +import type { TerminalLogLineProps } from './terminal-log-line' +import type { LogEntry } from './terminal-log' + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type LogLevel = NonNullable + +/** + * The controlled state for `TerminalFilterBar`. + * + * - `levels` — set of active levels. Empty array = all levels visible. + * - `text` — case-insensitive substring filter applied to `message` and `source`. + * - `sources` — set of active sources. Empty array = all sources visible. + */ +export interface FilterBarState { + levels: LogLevel[] + text: string + sources: string[] +} + +/** Returns a `FilterBarState` with all filters cleared (show everything). */ +export function emptyFilterState(): FilterBarState { + return { levels: [], text: '', sources: [] } +} + +export interface TerminalFilterBarProps { + /** Current filter state (controlled). */ + state: FilterBarState + /** Called whenever the user changes any filter value. */ + onChange: (next: FilterBarState) => void + /** + * Available source options to show as toggle buttons. + * When empty or omitted the source row is hidden. + */ + sources?: string[] + /** Additional CSS classes for layout overrides. */ + className?: string +} + +// ── Level metadata ──────────────────────────────────────────────────────────── + +const LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error', 'success'] + +/** Active (toggled-on) Tailwind classes per level — mirrors TerminalLogLine badge palette. */ +const levelActiveClasses: Record = { + debug: 'border-(--glass-border) text-(--term-fg-dim) bg-[rgba(255,255,255,0.06)]', + info: 'border-[var(--term-blue)]/40 text-(--term-blue) bg-[color-mix(in_oklab,var(--term-blue)_14%,transparent)]', + warn: 'border-[var(--term-yellow)]/40 text-(--term-yellow) bg-[color-mix(in_oklab,var(--term-yellow)_14%,transparent)]', + error: + 'border-[var(--term-red)]/40 text-(--term-red) bg-[color-mix(in_oklab,var(--term-red)_14%,transparent)]', + success: + 'border-[var(--term-green)]/40 text-(--term-green) bg-[color-mix(in_oklab,var(--term-green)_14%,transparent)]', +} + +const levelLabel: Record = { + debug: 'DBG', + info: 'INF', + warn: 'WRN', + error: 'ERR', + success: 'OK', +} + +// ── Utility ─────────────────────────────────────────────────────────────────── + +/** + * Pure filtering helper — apply a `FilterBarState` to a `LogEntry[]`. + * + * Rules: + * - If `state.levels` is empty, all levels pass. + * - If `state.sources` is empty, all sources pass. + * - `state.text` is trimmed and matched case-insensitively against `message` and `source`. + * + * @example + * ```tsx + * const visible = filterEntries(entries, filterState) + * + * ``` + */ +export function filterEntries(entries: LogEntry[], state: FilterBarState): LogEntry[] { + const needle = state.text.trim().toLowerCase() + + return entries.filter((entry) => { + // Level filter + if (state.levels.length > 0) { + const entryLevel: LogLevel = entry.level ?? 'info' + if (!state.levels.includes(entryLevel)) return false + } + + // Source filter + if (state.sources.length > 0) { + if (!entry.source || !state.sources.includes(entry.source)) return false + } + + // Text filter + if (needle) { + const msg = (typeof entry.message === 'string' ? entry.message : '').toLowerCase() + const src = (entry.source ?? '').toLowerCase() + if (!msg.includes(needle) && !src.includes(needle)) return false + } + + return true + }) +} + +// ── Component ───────────────────────────────────────────────────────────────── + +/** + * A compact controlled filter bar for terminal feed views. + * + * Provides three composable filters: + * 1. **Level toggles** — click to include/exclude `debug | info | warn | error | success`. + * No levels selected = all levels shown. + * 2. **Text search** — case-insensitive substring match across `message` and `source`. + * 3. **Source toggles** — only rendered when the `sources` prop is non-empty. + * No sources selected = all sources shown. + * + * Pair with the exported `filterEntries` helper to apply the state: + * ```tsx + * const [filter, setFilter] = useState(emptyFilterState) + * const visible = filterEntries(entries, filter) + * + * + * + * ``` + * + * All controls are keyboard-accessible (native ` + ) + })} + + {/* Separator */} +