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 */}
+
+ setDeployOpen((v) => !v)}
+ className="font-mono text-xs text-(--term-fg-dim) underline underline-offset-2
+ hover:text-(--term-fg) transition-colors"
+ >
+ {deployOpen ? '▲ collapse deploy (controlled)' : '▼ expand deploy (controlled)'}
+
+
+
+
+ )
+}
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 `` and ` `).
+ *
+ * @param state - Controlled filter state
+ * @param onChange - Callback fired on every change
+ * @param sources - Available source labels rendered as toggle buttons
+ * @param className - Additional CSS classes
+ */
+export function TerminalFilterBar({
+ state,
+ onChange,
+ sources = [],
+ className = '',
+}: TerminalFilterBarProps) {
+ const inputId = useId()
+
+ function toggleLevel(level: LogLevel) {
+ const next = state.levels.includes(level)
+ ? state.levels.filter((l) => l !== level)
+ : [...state.levels, level]
+ onChange({ ...state, levels: next })
+ }
+
+ function toggleSource(source: string) {
+ const next = state.sources.includes(source)
+ ? state.sources.filter((s) => s !== source)
+ : [...state.sources, source]
+ onChange({ ...state, sources: next })
+ }
+
+ function clearText() {
+ onChange({ ...state, text: '' })
+ }
+
+ const hasActiveFilters =
+ state.levels.length > 0 || state.sources.length > 0 || state.text.trim().length > 0
+
+ return (
+
+ {/* Row 1: level toggles + text input */}
+
+ {/* Level toggles */}
+
level:
+ {LEVELS.map((level) => {
+ const active = state.levels.includes(level)
+ return (
+
toggleLevel(level)}
+ className={[
+ 'inline-flex items-center rounded border px-1.5 py-px font-mono text-[10px] font-semibold leading-none tracking-wider transition-opacity',
+ active
+ ? levelActiveClasses[level]
+ : 'border-(--glass-border) text-(--term-fg-dim) bg-transparent opacity-40 hover:opacity-70',
+ ].join(' ')}
+ >
+ {levelLabel[level]}
+
+ )
+ })}
+
+ {/* Separator */}
+
+
+ {/* Text search */}
+
+ Filter by text
+
+
+ {/* Search icon — inline SVG avoids lucide React-version type conflicts */}
+
+
+
+
+ onChange({ ...state, text: e.target.value })}
+ placeholder="filter…"
+ spellCheck={false}
+ className="h-5 w-36 rounded border border-(--glass-border) bg-transparent pl-6 pr-5 text-xs text-(--term-fg) placeholder:text-(--term-fg-dim) focus:outline-none focus:border-(--term-blue)/60 transition-colors"
+ />
+ {state.text && (
+
+ {/* Clear icon — inline SVG */}
+
+
+
+
+
+ )}
+
+
+ {/* Clear-all pill — only shown when any filter is active */}
+ {hasActiveFilters && (
+
onChange(emptyFilterState())}
+ className="ml-auto shrink-0 rounded border border-(--glass-border) px-1.5 py-px text-[10px] text-(--term-fg-dim) hover:text-(--term-fg) hover:border-(--term-fg-dim)/60 transition-colors"
+ >
+ clear
+
+ )}
+
+
+ {/* Row 2: source toggles (only when sources provided) */}
+ {sources.length > 0 && (
+
+ source:
+ {sources.map((src) => {
+ const active = state.sources.includes(src)
+ return (
+ toggleSource(src)}
+ className={[
+ 'inline-flex items-center rounded border px-1.5 py-px text-[10px] leading-none transition-opacity',
+ active
+ ? 'border-(--glass-border) text-(--term-fg) bg-[rgba(255,255,255,0.06)] opacity-100'
+ : 'border-(--glass-border) text-(--term-fg-dim) bg-transparent opacity-40 hover:opacity-70',
+ ].join(' ')}
+ >
+ {src}
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/components/terminal-group.tsx b/components/terminal-group.tsx
new file mode 100644
index 0000000..ba0bfb1
--- /dev/null
+++ b/components/terminal-group.tsx
@@ -0,0 +1,203 @@
+'use client'
+
+import { useId, useState, type ReactNode } from 'react'
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type GroupVariant = 'default' | 'info' | 'warn' | 'error' | 'success'
+
+export interface TerminalGroupProps {
+ /**
+ * Header title displayed next to the expand/collapse caret.
+ * Keep it short — it sits in a monospace row alongside summary and count.
+ */
+ title: string
+ /**
+ * Optional one-line description shown after the title in dim foreground.
+ * Ideal for a status message, step description, or abbreviated output.
+ */
+ summary?: string
+ /**
+ * Hint label rendered as a pill on the right of the header row.
+ * Typical use: `"42 lines"`, `"3 errors"`, `"1.2s"`.
+ */
+ countLabel?: string
+ /**
+ * Left-border accent colour (default: `'default'` — no accent).
+ * Maps to the same CSS custom properties as `TerminalLogLine` level badges.
+ */
+ variant?: GroupVariant
+ /**
+ * Uncontrolled initial open state (default: `true`).
+ * Ignored when `open` is provided.
+ */
+ defaultOpen?: boolean
+ /**
+ * Controlled open state. Must be paired with `onOpenChange`.
+ */
+ open?: boolean
+ /**
+ * Called when the user toggles the group header.
+ */
+ onOpenChange?: (open: boolean) => void
+ /** Content rendered inside the collapsible region. */
+ children: ReactNode
+ /** Additional wrapper classes for layout overrides. */
+ className?: string
+}
+
+// ── Variant tokens ────────────────────────────────────────────────────────────
+
+const variantTokens: Record<
+ GroupVariant,
+ { border: string; headerHover: string; countBadge: string }
+> = {
+ default: {
+ border: 'border-(--glass-border)',
+ headerHover: 'hover:bg-[rgba(255,255,255,0.03)]',
+ countBadge: 'border-(--glass-border) text-(--term-fg-dim) bg-[rgba(255,255,255,0.04)]',
+ },
+ info: {
+ border: 'border-[var(--term-blue)]/30',
+ headerHover: 'hover:bg-[color-mix(in_oklab,var(--term-blue)_5%,transparent)]',
+ countBadge:
+ 'border-[var(--term-blue)]/30 text-(--term-blue) bg-[color-mix(in_oklab,var(--term-blue)_10%,transparent)]',
+ },
+ warn: {
+ border: 'border-[var(--term-yellow)]/30',
+ headerHover: 'hover:bg-[color-mix(in_oklab,var(--term-yellow)_5%,transparent)]',
+ countBadge:
+ 'border-[var(--term-yellow)]/30 text-(--term-yellow) bg-[color-mix(in_oklab,var(--term-yellow)_10%,transparent)]',
+ },
+ error: {
+ border: 'border-[var(--term-red)]/30',
+ headerHover: 'hover:bg-[color-mix(in_oklab,var(--term-red)_5%,transparent)]',
+ countBadge:
+ 'border-[var(--term-red)]/30 text-(--term-red) bg-[color-mix(in_oklab,var(--term-red)_10%,transparent)]',
+ },
+ success: {
+ border: 'border-[var(--term-green)]/30',
+ headerHover: 'hover:bg-[color-mix(in_oklab,var(--term-green)_5%,transparent)]',
+ countBadge:
+ 'border-[var(--term-green)]/30 text-(--term-green) bg-[color-mix(in_oklab,var(--term-green)_10%,transparent)]',
+ },
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+/**
+ * A collapsible command/output group for terminal-style log feeds.
+ *
+ * Renders a single-row header (caret · title · summary · count pill) above a
+ * content region that can be toggled open or closed. Supports both
+ * **uncontrolled** (`defaultOpen`) and **controlled** (`open` + `onOpenChange`)
+ * modes.
+ *
+ * ```
+ * ▾ Install dependencies Resolving packages… 42 lines
+ * │ [INF] 10:23:45 npm Fetching react@19.2.0
+ * │ [INF] 10:23:46 npm Fetching typescript@5.8.2
+ * │ …
+ * ```
+ *
+ * ARIA: the toggle `` carries `aria-expanded` and `aria-controls`;
+ * the content region has `role="region"` and a matching `aria-labelledby`.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function TerminalGroup({
+ title,
+ summary,
+ countLabel,
+ variant = 'default',
+ defaultOpen = true,
+ open: controlledOpen,
+ onOpenChange,
+ children,
+ className = '',
+}: TerminalGroupProps) {
+ const regionId = useId()
+ const headerId = useId()
+
+ // Support both controlled and uncontrolled modes.
+ const isControlled = controlledOpen !== undefined
+ const [internalOpen, setInternalOpen] = useState(defaultOpen)
+ const isOpen = isControlled ? controlledOpen : internalOpen
+
+ function toggle() {
+ const next = !isOpen
+ if (!isControlled) setInternalOpen(next)
+ onOpenChange?.(next)
+ }
+
+ const tokens = variantTokens[variant]
+
+ return (
+
+ {/* ── Header row ── */}
+
+
+ {/* ── Collapsible content region ── */}
+
+ {children}
+
+
+ )
+}
diff --git a/components/terminal-json-line.tsx b/components/terminal-json-line.tsx
new file mode 100644
index 0000000..0d670f3
--- /dev/null
+++ b/components/terminal-json-line.tsx
@@ -0,0 +1,190 @@
+'use client'
+
+import { useId, useMemo, useState } from 'react'
+import Prism from 'prismjs'
+import 'prismjs/components/prism-json'
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export interface TerminalJsonLineProps {
+ /**
+ * The data to render. Accepts:
+ * - A plain JS object / array / primitive — serialised automatically.
+ * - A raw JSON string — parsed and validated; shown as invalid if malformed.
+ */
+ payload: unknown
+ /**
+ * Optional label rendered before the collapsed summary (e.g. a field name or event type).
+ */
+ label?: string
+ /** Start in expanded state (default: false). */
+ defaultExpanded?: boolean
+ /** Additional CSS classes for layout overrides. */
+ className?: string
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+interface ParseResult {
+ value: unknown
+ pretty: string
+ invalid: boolean
+ /** Short one-line summary for the collapsed row. */
+ summary: string
+}
+
+function parse(payload: unknown): ParseResult {
+ // If the caller passed a raw string, try to parse it as JSON.
+ if (typeof payload === 'string') {
+ try {
+ const parsed = JSON.parse(payload)
+ const pretty = JSON.stringify(parsed, null, 2)
+ return { value: parsed, pretty, invalid: false, summary: summarise(parsed) }
+ } catch {
+ // Not valid JSON — treat as opaque string.
+ const truncated = payload.length > 60 ? payload.slice(0, 60) + '…' : payload
+ return {
+ value: payload,
+ pretty: payload,
+ invalid: true,
+ summary: `"${truncated}"`,
+ }
+ }
+ }
+
+ // Already a JS value — serialise directly.
+ try {
+ const pretty = JSON.stringify(payload, null, 2)
+ return { value: payload, pretty, invalid: false, summary: summarise(payload) }
+ } catch {
+ return { value: payload, pretty: String(payload), invalid: true, summary: String(payload) }
+ }
+}
+
+function summarise(value: unknown): string {
+ if (value === null) return 'null'
+ if (Array.isArray(value)) return `[ ${value.length} item${value.length === 1 ? '' : 's'} ]`
+ if (typeof value === 'object') {
+ const keys = Object.keys(value as object).length
+ return `{ ${keys} key${keys === 1 ? '' : 's'} }`
+ }
+ // Primitives — show inline, truncated.
+ const str = JSON.stringify(value) ?? String(value)
+ return str.length > 60 ? str.slice(0, 60) + '…' : str
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+/**
+ * A collapsible JSON payload renderer for terminal log feeds.
+ *
+ * Renders a compact one-line summary by default. Clicking the row (or pressing
+ * Enter / Space) toggles a fully expanded, Prism-highlighted pretty-print view.
+ *
+ * Handles invalid JSON gracefully — shows the raw value with a warning indicator
+ * instead of throwing. Large payloads are constrained to a scrollable code block.
+ *
+ * @param payload - JS value or raw JSON string to display
+ * @param label - Optional label prefix shown before the summary
+ * @param defaultExpanded - Start in expanded state (default: false)
+ * @param className - Additional CSS classes
+ *
+ * @example
+ * ```tsx
+ * // Object payload
+ *
+ *
+ * // Raw JSON string
+ *
+ *
+ * // Invalid JSON — rendered safely
+ *
+ *
+ * // Start expanded
+ *
+ * ```
+ */
+export function TerminalJsonLine({
+ payload,
+ label,
+ defaultExpanded = false,
+ className = '',
+}: TerminalJsonLineProps) {
+ const id = useId()
+ const [expanded, setExpanded] = useState(defaultExpanded)
+
+ const parsed = useMemo(() => parse(payload), [payload])
+
+ const highlightedHtml = useMemo(() => {
+ if (parsed.invalid) return null
+ const grammar = Prism.languages['json']
+ if (!grammar) return null
+ try {
+ return Prism.highlight(parsed.pretty, grammar, 'json')
+ } catch {
+ return null
+ }
+ }, [parsed])
+
+ function toggle() {
+ setExpanded((v) => !v)
+ }
+
+ return (
+
+ {/* ── Collapsed row (always visible) ── */}
+
+ {/* Chevron */}
+
+ ▶
+
+
+ {/* Label */}
+ {label && (
+ {label}:
+ )}
+
+ {/* Invalid indicator */}
+ {parsed.invalid && (
+
+ INVALID
+
+ )}
+
+ {/* Summary */}
+
+ {parsed.summary}
+
+
+
+ {/* ── Expanded view ── */}
+ {expanded && (
+
+ {highlightedHtml ? (
+
+ ) : (
+
+ {parsed.pretty}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/components/terminal-log-line.tsx b/components/terminal-log-line.tsx
new file mode 100644
index 0000000..c82d751
--- /dev/null
+++ b/components/terminal-log-line.tsx
@@ -0,0 +1,124 @@
+'use client'
+
+import { ReactNode } from 'react'
+
+export interface TerminalLogLineProps {
+ /** Primary log message content. */
+ message: ReactNode
+ /** Severity level — controls the label color (default: 'info'). */
+ level?: 'debug' | 'info' | 'warn' | 'error' | 'success'
+ /** Optional timestamp string (e.g. "10:23:45" or ISO). */
+ timestamp?: string
+ /** Optional source / subsystem label (e.g. "server", "db"). */
+ source?: string
+ /** Additional classes for layout overrides. */
+ className?: string
+}
+
+/**
+ * Per-level color tokens — border, background tint, and text colors.
+ *
+ * `badge` — the [LEVEL] indicator pill (border + bg tint, consistent with TerminalBadge)
+ * `message` — foreground for the message body (dimmed for debug, red for error)
+ */
+const levelClasses: Record<
+ NonNullable,
+ { badge: string; message: string }
+> = {
+ debug: {
+ badge: 'border-(--glass-border) text-(--term-fg-dim) bg-[rgba(255,255,255,0.03)]',
+ message: 'text-(--term-fg-dim)',
+ },
+ info: {
+ badge:
+ 'border-[var(--term-blue)]/40 text-(--term-blue) bg-[color-mix(in_oklab,var(--term-blue)_10%,transparent)]',
+ message: 'text-(--term-fg)',
+ },
+ warn: {
+ badge:
+ 'border-[var(--term-yellow)]/40 text-(--term-yellow) bg-[color-mix(in_oklab,var(--term-yellow)_10%,transparent)]',
+ message: 'text-(--term-fg)',
+ },
+ error: {
+ badge:
+ 'border-[var(--term-red)]/40 text-(--term-red) bg-[color-mix(in_oklab,var(--term-red)_10%,transparent)]',
+ message: 'text-(--term-red)',
+ },
+ success: {
+ badge:
+ 'border-[var(--term-green)]/40 text-(--term-green) bg-[color-mix(in_oklab,var(--term-green)_10%,transparent)]',
+ message: 'text-(--term-fg)',
+ },
+}
+
+/**
+ * 3-character uppercase level labels.
+ * All entries are exactly 3 chars so the badge pill stays a consistent width
+ * across levels in a monospace font.
+ */
+const levelLabel: Record, string> = {
+ debug: 'DBG',
+ info: 'INF',
+ warn: 'WRN',
+ error: 'ERR',
+ success: 'OK',
+}
+
+/**
+ * A single structured log row primitive for terminal-style feeds.
+ *
+ * Renders a monospace row with an optional timestamp, a compact level badge,
+ * an optional source label, and the log message. All color tokens are derived
+ * from CSS custom properties, making the component fully theme-aware.
+ *
+ * Layout (left → right):
+ * ```
+ * [timestamp] [LVL] source message
+ * ```
+ *
+ * @param message - Log message body (string or any React node)
+ * @param level - Severity (debug | info | warn | error | success); default 'info'
+ * @param timestamp - Optional timestamp string shown in dim foreground
+ * @param source - Optional subsystem / service label shown after the badge
+ * @param className - Additional CSS classes
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function TerminalLogLine({
+ message,
+ level = 'info',
+ timestamp,
+ source,
+ className = '',
+}: TerminalLogLineProps) {
+ const cls = levelClasses[level]
+
+ return (
+
+ {/* Timestamp */}
+ {timestamp && {timestamp} }
+
+ {/* Level badge — fixed width keeps columns aligned in monospace feeds */}
+
+ {levelLabel[level]}
+
+
+ {/* Source */}
+ {source && {source} }
+
+ {/* Message */}
+ {message}
+
+ )
+}
diff --git a/components/terminal-log.tsx b/components/terminal-log.tsx
index e3b8a4a..a5a0233 100644
--- a/components/terminal-log.tsx
+++ b/components/terminal-log.tsx
@@ -1,10 +1,40 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
+import { TerminalLogLine, type TerminalLogLineProps } from './terminal-log-line'
+
+// ── Structured entry type ─────────────────────────────────────────────────────
+
+/**
+ * A structured log entry for use with the `entries` prop on `TerminalLog`.
+ * All fields except `message` are optional to keep the type lightweight.
+ */
+export interface LogEntry {
+ /** Unique key for React reconciliation. Falls back to index when omitted. */
+ id?: string
+ /** Log message body. */
+ message: string
+ /** Severity level — controls badge color (default: 'info'). */
+ level?: TerminalLogLineProps['level']
+ /** Optional timestamp string (e.g. "10:23:45"). */
+ timestamp?: string
+ /** Optional source / subsystem label (e.g. "server", "db"). */
+ source?: string
+}
+
+// ── Props ─────────────────────────────────────────────────────────────────────
export interface TerminalLogProps {
- /** Log lines displayed in the terminal stream. */
- lines: string[]
+ /**
+ * Plain string lines (backward-compatible path).
+ * Ignored when `entries` is also provided.
+ */
+ lines?: string[]
+ /**
+ * Structured log entries rendered via `TerminalLogLine` with level badges,
+ * timestamps, and source labels. Takes precedence over `lines`.
+ */
+ entries?: LogEntry[]
/** Maximum number of lines rendered (default: 200). */
maxLines?: number
/** Auto-scroll to the newest line when new logs arrive (default: false). */
@@ -13,21 +43,38 @@ export interface TerminalLogProps {
className?: string
}
+// ── Component ─────────────────────────────────────────────────────────────────
+
/**
* Displays a terminal-style scrolling log buffer.
*
- * @param lines - Current log lines to render
- * @param maxLines - Maximum number of visible lines kept in view
- * @param autoScroll - Whether to stick to the latest line on updates
- * @param className - Additional wrapper classes
+ * Supports two rendering modes:
*
- * @example
+ * **String mode** (backward-compatible):
* ```tsx
*
* ```
+ *
+ * **Structured mode** (uses `TerminalLogLine` internally):
+ * ```tsx
+ * const entries: LogEntry[] = [
+ * { id: '1', level: 'info', timestamp: '10:23:45', source: 'server', message: 'Listening on :3000' },
+ * { id: '2', level: 'error', timestamp: '10:23:48', source: 'db', message: 'Connection refused' },
+ * ]
+ *
+ * ```
+ *
+ * When both props are supplied, `entries` takes precedence.
+ *
+ * @param lines - Plain string lines (existing API, fully backward-compatible)
+ * @param entries - Structured log entries with optional level/timestamp/source
+ * @param maxLines - Maximum number of visible lines kept in view (default: 200)
+ * @param autoScroll - Stick to the latest line on updates (default: false)
+ * @param className - Additional wrapper classes
*/
export function TerminalLog({
- lines,
+ lines = [],
+ entries,
maxLines = 200,
autoScroll = false,
className = '',
@@ -35,32 +82,56 @@ export function TerminalLog({
const containerRef = useRef(null)
const safeMaxLines = Math.max(1, Math.floor(maxLines))
+ // Determine render mode once per render.
+ const structured = entries !== undefined
+
+ const visibleEntries = useMemo(
+ () =>
+ structured
+ ? (entries as LogEntry[]).slice(Math.max(0, (entries as LogEntry[]).length - safeMaxLines))
+ : null,
+ [structured, entries, safeMaxLines]
+ )
+
const visibleLines = useMemo(
- () => lines.slice(Math.max(0, lines.length - safeMaxLines)),
- [lines, safeMaxLines],
+ () => (structured ? null : lines.slice(Math.max(0, lines.length - safeMaxLines))),
+ [structured, lines, safeMaxLines]
)
- useEffect(() => {
- if (!autoScroll || !containerRef.current) {
- return
- }
+ // Scroll sentinel: depend on whichever array is active.
+ const scrollDep = structured ? visibleEntries : visibleLines
+ useEffect(() => {
+ if (!autoScroll || !containerRef.current) return
containerRef.current.scrollTop = containerRef.current.scrollHeight
- }, [autoScroll, visibleLines])
+ }, [autoScroll, scrollDep])
return (
- {visibleLines.map((line, index) => (
-
- {line}
-
- ))}
+ {structured
+ ? visibleEntries!.map((entry, index) => (
+
+ ))
+ : visibleLines!.map((line, index) => (
+
+ {line}
+
+ ))}
)
}
diff --git a/components/terminal-marker.tsx b/components/terminal-marker.tsx
new file mode 100644
index 0000000..bc44621
--- /dev/null
+++ b/components/terminal-marker.tsx
@@ -0,0 +1,92 @@
+'use client'
+
+export interface TerminalMarkerProps {
+ /** Label text displayed as the phase marker. */
+ label: string
+ /** Optional timestamp to display after the label. */
+ timestamp?: string
+ /** Visual style variant (default: 'neutral'). */
+ variant?: 'info' | 'success' | 'warning' | 'error' | 'neutral'
+ /** Additional classes for layout tweaks. */
+ className?: string
+}
+
+/**
+ * Per-variant Tailwind classes for border, background tint, label, and timestamp.
+ * Background tints use `color-mix` for consistent translucency across themes.
+ */
+const variantClasses: Record<
+ NonNullable,
+ { root: string; label: string; timestamp: string }
+> = {
+ neutral: {
+ root: 'border-[var(--glass-border)] bg-[rgba(255,255,255,0.03)]',
+ label: 'text-[var(--term-fg)]',
+ timestamp: 'text-[var(--term-fg-dim)]',
+ },
+ info: {
+ root: 'border-[var(--term-blue)]/40 bg-[color-mix(in_oklab,var(--term-blue)_8%,transparent)]',
+ label: 'text-[var(--term-blue)]',
+ timestamp: 'text-[var(--term-blue)]/60',
+ },
+ success: {
+ root: 'border-[var(--term-green)]/40 bg-[color-mix(in_oklab,var(--term-green)_8%,transparent)]',
+ label: 'text-[var(--term-green)]',
+ timestamp: 'text-[var(--term-green)]/60',
+ },
+ warning: {
+ root: 'border-[var(--term-yellow)]/40 bg-[color-mix(in_oklab,var(--term-yellow)_8%,transparent)]',
+ label: 'text-[var(--term-yellow)]',
+ timestamp: 'text-[var(--term-yellow)]/60',
+ },
+ error: {
+ root: 'border-[var(--term-red)]/40 bg-[color-mix(in_oklab,var(--term-red)_8%,transparent)]',
+ label: 'text-[var(--term-red)]',
+ timestamp: 'text-[var(--term-red)]/60',
+ },
+}
+
+/**
+ * Displays a terminal-style phase separator for visual boundaries in feeds.
+ * Used to mark different phases like "Build", "Test", "Deploy" in sequential outputs.
+ *
+ * Renders a left-bordered row with a semantic background tint, a bold phase label,
+ * and an optional timestamp. Fully theme-aware via CSS custom properties and
+ * naturally responsive at any width.
+ *
+ * @param label - Phase label text (e.g., "Build", "Test", "Deploy")
+ * @param timestamp - Optional timestamp to display after the label
+ * @param variant - Visual style for semantic coloring (default: 'neutral')
+ * @param className - Additional CSS classes for layout overrides
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function TerminalMarker({
+ label,
+ timestamp,
+ variant = 'neutral',
+ className = '',
+}: TerminalMarkerProps) {
+ const cls = variantClasses[variant]
+
+ return (
+
+
+ {label}
+
+ {timestamp && (
+ {timestamp}
+ )}
+
+ )
+}
diff --git a/components/terminal-search.tsx b/components/terminal-search.tsx
new file mode 100644
index 0000000..e7b4ca4
--- /dev/null
+++ b/components/terminal-search.tsx
@@ -0,0 +1,304 @@
+'use client'
+
+import { useCallback, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'
+import { ChevronDown, ChevronUp, Search, X } from 'lucide-react'
+
+// ── useTerminalSearch hook ────────────────────────────────────────────────────
+
+export interface UseTerminalSearchOptions {
+ /** Case-sensitive matching (default: false). */
+ caseSensitive?: boolean
+}
+
+export interface UseTerminalSearchResult {
+ query: string
+ setQuery: (q: string) => void
+ /** Indices of `items` entries whose text contains the query. */
+ matchIndices: number[]
+ /** Total number of matching entries. */
+ matchCount: number
+ /** 0-based position within `matchIndices` (−1 when no query / no matches). */
+ currentIndex: number
+ /** 1-based match number for display ("2 of 5"), or 0 when no match. */
+ currentMatchNumber: number
+ /** Advance to the next match (wraps around). */
+ next: () => void
+ /** Go to the previous match (wraps around). */
+ prev: () => void
+ /** Returns true when `itemIndex` is any matched entry. */
+ isMatch: (itemIndex: number) => boolean
+ /** Returns true when `itemIndex` is the *current* highlighted entry. */
+ isCurrentMatch: (itemIndex: number) => boolean
+}
+
+/**
+ * Stateful hook that drives in-feed search over a flat string array.
+ *
+ * Each item in `items` is compared against the controlled `query`.
+ * For structured log entries, map them to strings before passing in:
+ *
+ * ```ts
+ * const searchItems = entries.map(e => [e.message, e.source ?? ''].join(' '))
+ * const search = useTerminalSearch(searchItems)
+ * ```
+ *
+ * @param items - Flat array of strings to search within.
+ * @param options - Optional configuration.
+ */
+export function useTerminalSearch(
+ items: string[],
+ options: UseTerminalSearchOptions = {}
+): UseTerminalSearchResult {
+ const { caseSensitive = false } = options
+ const [query, setQueryRaw] = useState('')
+ const [currentIndex, setCurrentIndex] = useState(0)
+
+ const matchIndices = useMemo(() => {
+ const q = caseSensitive ? query : query.toLowerCase()
+ if (!q) return []
+ return items.reduce((acc, item, i) => {
+ const haystack = caseSensitive ? item : item.toLowerCase()
+ if (haystack.includes(q)) acc.push(i)
+ return acc
+ }, [])
+ }, [items, query, caseSensitive])
+
+ // Clamp currentIndex whenever matchIndices changes.
+ const safeCurrentIndex =
+ matchIndices.length === 0 ? -1 : Math.min(currentIndex, matchIndices.length - 1)
+
+ const setQuery = useCallback((q: string) => {
+ setQueryRaw(q)
+ setCurrentIndex(0)
+ }, [])
+
+ const next = useCallback(() => {
+ if (matchIndices.length === 0) return
+ setCurrentIndex((prev) => (prev + 1) % matchIndices.length)
+ }, [matchIndices.length])
+
+ const prev = useCallback(() => {
+ if (matchIndices.length === 0) return
+ setCurrentIndex((prev) => (prev - 1 + matchIndices.length) % matchIndices.length)
+ }, [matchIndices.length])
+
+ const matchSet = useMemo(() => new Set(matchIndices), [matchIndices])
+
+ const isMatch = useCallback((itemIndex: number) => matchSet.has(itemIndex), [matchSet])
+
+ const isCurrentMatch = useCallback(
+ (itemIndex: number) => safeCurrentIndex >= 0 && matchIndices[safeCurrentIndex] === itemIndex,
+ [safeCurrentIndex, matchIndices]
+ )
+
+ return {
+ query,
+ setQuery,
+ matchIndices,
+ matchCount: matchIndices.length,
+ currentIndex: safeCurrentIndex,
+ currentMatchNumber: safeCurrentIndex >= 0 ? safeCurrentIndex + 1 : 0,
+ next,
+ prev,
+ isMatch,
+ isCurrentMatch,
+ }
+}
+
+// ── TerminalSearch component ──────────────────────────────────────────────────
+
+export interface TerminalSearchProps {
+ /** Controlled query value. */
+ query: string
+ /** Called when the user edits the query. */
+ onQueryChange: (q: string) => void
+ /**
+ * Total number of matches across the feed.
+ * When `undefined` the match counter is hidden.
+ */
+ matchCount?: number
+ /**
+ * 1-based index of the currently focused match (e.g. `2` → "2 / 5").
+ * When `undefined` or `0` only `matchCount` is shown.
+ */
+ currentMatch?: number
+ /** Navigate to the next match. Bound to `Enter` / ↓ button. */
+ onNext?: () => void
+ /** Navigate to the previous match. Bound to `Shift+Enter` / ↑ button. */
+ onPrev?: () => void
+ /** Input placeholder text (default: "Search logs…"). */
+ placeholder?: string
+ /** Additional wrapper classes for layout overrides. */
+ className?: string
+}
+
+/**
+ * In-feed search bar for terminal log viewers.
+ *
+ * Keyboard bindings (when the input is focused):
+ * - **Enter** → next match
+ * - **Shift+Enter** → previous match
+ * - **Escape** → clear query
+ *
+ * Pair with `useTerminalSearch` to drive the match state:
+ *
+ * ```tsx
+ * const search = useTerminalSearch(items)
+ *
+ *
+ * ```
+ */
+export function TerminalSearch({
+ query,
+ onQueryChange,
+ matchCount,
+ currentMatch,
+ onNext,
+ onPrev,
+ placeholder = 'Search logs…',
+ className = '',
+}: TerminalSearchProps) {
+ const inputId = useId()
+ const inputRef = useRef(null)
+
+ // Derived state
+ const hasQuery = query.length > 0
+ const noMatches = hasQuery && matchCount !== undefined && matchCount === 0
+ const showCounter = hasQuery && matchCount !== undefined && matchCount > 0
+
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ if (e.shiftKey) {
+ onPrev?.()
+ } else {
+ onNext?.()
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ onQueryChange('')
+ inputRef.current?.blur()
+ }
+ }
+
+ function handleClear() {
+ onQueryChange('')
+ inputRef.current?.focus()
+ }
+
+ return (
+
+ {/* Search icon */}
+
+
+ Search logs
+
+
+ {/* Input */}
+
onQueryChange(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ autoComplete="off"
+ spellCheck={false}
+ aria-label="Search logs"
+ aria-live="polite"
+ aria-atomic="true"
+ className={`min-w-0 flex-1 bg-transparent py-1.5 outline-none placeholder:text-(--term-fg-dim)/50
+ ${noMatches ? 'text-(--term-red)' : 'text-(--term-fg)'}`}
+ />
+
+ {/* Match counter */}
+ {showCounter && (
+
+ {currentMatch ? `${currentMatch} / ${matchCount}` : matchCount}
+
+ )}
+
+ {/* No-match label */}
+ {noMatches && (
+
+ no match
+
+ )}
+
+ {/* Prev / Next navigation buttons */}
+ {(onPrev || onNext) && (
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Clear button */}
+ {hasQuery && (
+
+
+
+ )}
+
+ )
+}
diff --git a/components/terminal-stack-trace.tsx b/components/terminal-stack-trace.tsx
new file mode 100644
index 0000000..f499b91
--- /dev/null
+++ b/components/terminal-stack-trace.tsx
@@ -0,0 +1,348 @@
+'use client'
+
+import { useState, useCallback, useId, type KeyboardEvent } from 'react'
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export interface TerminalStackTraceProps {
+ /**
+ * Raw stack trace string (e.g. `error.stack`).
+ *
+ * Expected format:
+ * ```
+ * ErrorType: message
+ * at functionName (file:line:col)
+ * at ...
+ * ```
+ */
+ stack: string
+ /**
+ * Hide `node_modules` frames by default.
+ * A toggle button lets users reveal them.
+ * @default true
+ */
+ hideNodeModules?: boolean
+ /**
+ * Start with all frames collapsed (only the error header visible).
+ * @default false
+ */
+ defaultCollapsed?: boolean
+ /** Optional className for outer wrapper. */
+ className?: string
+}
+
+interface StackFrame {
+ /** Original raw line as it appears in the stack string. */
+ raw: string
+ /** Whether this frame originates from node_modules. */
+ isNodeModules: boolean
+ /**
+ * Parsed function / method name, e.g. `"App.render"`.
+ * `null` when the function location could not be determined.
+ */
+ fn: string | null
+ /**
+ * File path + line + column string, e.g. `"/src/App.tsx:42:15"`.
+ * `null` when the location could not be determined.
+ */
+ location: string | null
+}
+
+// ─── Parser ──────────────────────────────────────────────────────────────────
+
+/**
+ * Splits a raw stack string into a header block and a list of parsed frames.
+ *
+ * Handles both V8/Node.js (`at …`) and Firefox (`functionName@file:line:col`)
+ * frame formats. Lines that cannot be parsed as frames are folded into the
+ * header block.
+ */
+function parseStack(stack: string): { header: string; frames: StackFrame[] } {
+ const lines = stack.replace(/\r\n/g, '\n').split('\n')
+ const headerLines: string[] = []
+ const frameLines: string[] = []
+ let inFrames = false
+
+ for (const line of lines) {
+ const t = line.trim()
+ // V8/Node.js frames start with "at "
+ // Firefox frames match "name@file:line:col"
+ const isFrameLine = t.startsWith('at ') || /^.+@.+:\d+:\d+$/.test(t)
+ if (!inFrames && isFrameLine) inFrames = true
+ if (inFrames && isFrameLine) {
+ frameLines.push(line)
+ } else if (!inFrames) {
+ headerLines.push(line)
+ }
+ // Lines between frames (blank separators etc.) are silently dropped.
+ }
+
+ const frames: StackFrame[] = frameLines.map((line): StackFrame => {
+ const t = line.trim()
+ const isNodeModules = t.includes('node_modules')
+
+ // V8: "at FnName (file:line:col)"
+ const v8WithFn = t.match(/^at\s+(.+?)\s+\((.+)\)$/)
+ if (v8WithFn) {
+ return { raw: line, isNodeModules, fn: v8WithFn[1], location: v8WithFn[2] }
+ }
+
+ // V8: "at file:line:col" (anonymous / top-level)
+ const v8Bare = t.match(/^at\s+(.+)$/)
+ if (v8Bare) {
+ return { raw: line, isNodeModules, fn: null, location: v8Bare[1] }
+ }
+
+ // Firefox: "name@file:line:col"
+ const firefox = t.match(/^(.+)@(.+)$/)
+ if (firefox) {
+ return { raw: line, isNodeModules, fn: firefox[1] || null, location: firefox[2] }
+ }
+
+ return { raw: line, isNodeModules, fn: null, location: null }
+ })
+
+ return { header: headerLines.join('\n'), frames }
+}
+
+// ─── Sub-components ──────────────────────────────────────────────────────────
+
+interface FrameRowProps {
+ frame: StackFrame
+ index: number
+}
+
+/**
+ * A single foldable stack frame row.
+ *
+ * Collapsed → shows only the function name (or a placeholder).
+ * Expanded → additionally shows the full file path.
+ *
+ * Keyboard: Space / Enter toggles the frame.
+ */
+function FrameRow({ frame, index }: FrameRowProps) {
+ const [open, setOpen] = useState(true)
+ const toggle = useCallback(() => setOpen((v) => !v), [])
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault()
+ toggle()
+ }
+ },
+ [toggle]
+ )
+
+ const fnLabel = frame.fn ?? ''
+ const hasLocation = frame.location !== null
+
+ return (
+
+
+ {/* Frame index gutter */}
+
+ {index}
+
+
+ {/* Toggle chevron */}
+
+ {hasLocation ? (open ? '▾' : '▸') : '·'}
+
+
+ {/* Function name */}
+
+ at{' '}
+
+ {fnLabel}
+
+
+
+
+ {/* Location — hidden when frame is collapsed */}
+ {open && hasLocation && (
+
+ {frame.location}
+
+ )}
+
+ )
+}
+
+// ─── Main component ──────────────────────────────────────────────────────────
+
+/**
+ * Renders a styled, interactive stack trace for terminal-style error output.
+ *
+ * Features:
+ * - Parses raw `Error.stack` strings (V8/Node.js and Firefox formats).
+ * - Global collapse/expand toggle (Space or Enter on the header button).
+ * - Per-frame collapse/expand to hide the noisy file-path line.
+ * - Optional `node_modules` frame filtering (hidden by default).
+ * - Fully keyboard-navigable and screen-reader labelled.
+ * - Uses the same CSS custom-property color tokens as every other terminal-ui component.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function TerminalStackTrace({
+ stack,
+ hideNodeModules = true,
+ defaultCollapsed = false,
+ className = '',
+}: TerminalStackTraceProps) {
+ const id = useId()
+ const frameListId = `${id}-frames`
+
+ const [framesOpen, setFramesOpen] = useState(!defaultCollapsed)
+ const [showNodeModules, setShowNodeModules] = useState(!hideNodeModules)
+
+ const toggleFrames = useCallback(() => setFramesOpen((v) => !v), [])
+ const toggleNodeModules = useCallback(() => setShowNodeModules((v) => !v), [])
+
+ const handleHeaderKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault()
+ toggleFrames()
+ }
+ },
+ [toggleFrames]
+ )
+
+ const { header, frames } = parseStack(stack)
+
+ const userFrames = frames.filter((f) => !f.isNodeModules)
+ const nodeModulesFrames = frames.filter((f) => f.isNodeModules)
+ const hiddenCount = nodeModulesFrames.length
+
+ // Frames visible in the list
+ const visibleFrames = showNodeModules ? frames : frames.filter((f) => !f.isNodeModules)
+
+ return (
+
+ {/* ── Error header ───────────────────────────────────────────────────── */}
+
+ {/* Error icon */}
+
+ ✗
+
+
+ {/* Error message block */}
+
+ {header || '(no error message)'}
+
+
+ {/* Global frames toggle */}
+ {frames.length > 0 && (
+
+ {framesOpen ? '▾' : '▸'}
+
+ {framesOpen ? 'collapse' : `${frames.length} frames`}
+
+
+ )}
+
+
+ {/* ── Frame list ─────────────────────────────────────────────────────── */}
+ {framesOpen && frames.length > 0 && (
+
+ {visibleFrames.map((frame, i) => {
+ // Preserve original index across the full frame list for gutter numbering
+ const originalIndex = frames.indexOf(frame)
+ return (
+
+
+
+ )
+ })}
+
+ {/* ── node_modules toggle ──────────────────────────────────────── */}
+ {hiddenCount > 0 && (
+
+
+
+ {showNodeModules ? '▾' : '▸'}
+
+
+ {showNodeModules
+ ? `hide ${hiddenCount} node_modules frame${hiddenCount !== 1 ? 's' : ''}`
+ : `${hiddenCount} hidden node_modules frame${hiddenCount !== 1 ? 's' : ''}`}
+
+
+
+ )}
+
+ {/* ── Summary footer ───────────────────────────────────────────── */}
+
+ {userFrames.length} user frame{userFrames.length !== 1 ? 's' : ''}
+ {hiddenCount > 0 && ` · ${hiddenCount} node_modules`}
+
+
+ )}
+
+ )
+}
diff --git a/components/terminal.tsx b/components/terminal.tsx
index 56c9e1f..6631531 100644
--- a/components/terminal.tsx
+++ b/components/terminal.tsx
@@ -18,12 +18,12 @@ const TerminalPromptContext = createContext('$')
/**
* Displays a terminal window with macOS-style chrome and content area.
* Renders a terminal emulator UI with title bar, window controls, and monospace content.
- *
+ *
* @param children - Terminal content (TerminalCommand, TerminalOutput, TerminalSpinner components)
* @param title - Window title shown in the chrome (default: 'Terminal')
* @param prompt - Command prompt symbol (default: '$')
* @param className - Additional CSS classes to apply to the container
- *
+ *
* @example
* ```tsx
*
@@ -32,7 +32,12 @@ const TerminalPromptContext = createContext('$')
*
* ```
*/
-export function Terminal({ children, title = 'Terminal', prompt = '$', className = '' }: TerminalProps) {
+export function Terminal({
+ children,
+ title = 'Terminal',
+ prompt = '$',
+ className = '',
+}: TerminalProps) {
const contentRef = useRef(null)
const timeoutRef = useRef | null>(null)
const [copied, setCopied] = useState(false)
@@ -60,7 +65,9 @@ export function Terminal({ children, title = 'Terminal', prompt = '$', className
}
return (
-
+
{/* Window Chrome */}
@@ -68,9 +75,7 @@ export function Terminal({ children, title = 'Terminal', prompt = '$', className
-
- {title}
-
+
{title}
:
}
-
+
{/* Terminal Content */}
-
@@ -98,10 +106,10 @@ interface TerminalCommandProps {
* Displays a command line in the terminal with a prompt symbol.
* Renders text with a leading prompt indicator (typically '$' or '#').
* Inherits prompt from parent Terminal component via context if not specified.
- *
+ *
* @param children - The command text to display
* @param prompt - The prompt symbol to display before the command (inherited from Terminal if omitted)
- *
+ *
* @example
* ```tsx
*
ls -la
@@ -134,13 +142,13 @@ interface TerminalOutputProps {
* Displays output text with semantic coloring based on message type.
* Uses theme colors to indicate success (green), error (red), info (blue), or warning (yellow).
* Supports optional typing animation for string children and Prism.js syntax highlighting.
- *
+ *
* @param children - The output text to display
* @param type - The type of output message (default: 'normal')
* @param animate - Enable typing animation (default: false)
* @param delay - Milliseconds per character when animating (default: 35)
* @param language - Language for syntax highlighting (e.g. 'json', 'typescript')
- *
+ *
* @example
* ```tsx
*
Build completed successfully
@@ -207,7 +215,11 @@ export function TerminalOutput({
{highlightedHtml ? (
- ) : animate && textContent !== null ? typedText : children}
+ ) : animate && textContent !== null ? (
+ typedText
+ ) : (
+ children
+ )}
)
}
@@ -219,9 +231,9 @@ interface TerminalSpinnerProps {
/**
* Displays an animated spinner with optional text for loading states.
* Uses Unicode braille characters for smooth animation.
- *
+ *
* @param text - Optional text to display next to the spinner
- *
+ *
* @example
* ```tsx
*
@@ -252,7 +264,8 @@ export { TerminalProgress } from './terminal-progress'
export { TerminalPrompt } from './terminal-prompt'
export { TerminalTree } from './terminal-tree'
export type { TreeNode, TreeRenderContext, TerminalTreeProps } from './terminal-tree'
-export { TerminalLog } from './terminal-log'
+export { TerminalLog, type LogEntry } from './terminal-log'
+export { TerminalFeed, type TerminalFeedProps, type FeedEntry } from './terminal-feed'
export { TerminalTable, type ColumnAlign } from './terminal-table'
export { TerminalBarChart, TerminalSparkline, type BarChartVariant } from './terminal-chart'
export { TerminalSelect } from './terminal-select'
@@ -260,7 +273,41 @@ export { TerminalAlert } from './terminal-alert'
export { TerminalTabs } from './terminal-tabs'
export { TerminalSplit } from './terminal-split'
export { TerminalDiff } from './terminal-diff'
-export { TerminalAutocomplete, useAutocomplete, COMMON_COMMANDS, COMMON_FLAGS, filterSuggestions, type TerminalAutocompleteProps, type AutocompleteSuggestion } from './terminal-autocomplete'
+export {
+ TerminalAutocomplete,
+ useAutocomplete,
+ COMMON_COMMANDS,
+ COMMON_FLAGS,
+ filterSuggestions,
+ type TerminalAutocompleteProps,
+ type AutocompleteSuggestion,
+} from './terminal-autocomplete'
export { TerminalGhosttyTheme, GhosttyThemePicker } from './terminal-ghostty'
export { ThemeSwitcher } from './theme-switcher'
export { TerminalBadge } from './terminal-badge'
+export { TerminalMarker } from './terminal-marker'
+export { TerminalLogLine, type TerminalLogLineProps } from './terminal-log-line'
+export { TerminalGroup, type TerminalGroupProps, type GroupVariant } from './terminal-group'
+export {
+ TerminalSearch,
+ useTerminalSearch,
+ type TerminalSearchProps,
+ type UseTerminalSearchOptions,
+ type UseTerminalSearchResult,
+} from './terminal-search'
+export {
+ TerminalFilterBar,
+ filterEntries,
+ emptyFilterState,
+ type TerminalFilterBarProps,
+ type FilterBarState,
+ type LogLevel,
+} from './terminal-filter-bar'
+export { TerminalStackTrace, type TerminalStackTraceProps } from './terminal-stack-trace'
+export { TerminalJsonLine, type TerminalJsonLineProps } from './terminal-json-line'
+export {
+ TerminalAnsi,
+ parseAnsi,
+ type TerminalAnsiProps,
+ type AnsiSpan,
+} from './terminal-ansi'