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..9b6ea1d 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -1,10 +1,25 @@ 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 { 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' export const metadata = { title: 'Playground', @@ -14,25 +29,19 @@ export default function PlaygroundPage() { return (
-

- Playground -

+

Playground

-

- Terminal App -

+

Terminal App

-

- TerminalPrompt -

+

TerminalPrompt

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

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

- TerminalProgress -

+

TerminalProgress

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

- TerminalSpinner -

+

TerminalSpinner

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

- TerminalLog + TerminalLog — string mode

Simulated streaming logs with capped history and auto-scroll. @@ -74,19 +79,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 +138,7 @@ export default function PlaygroundPage() {
-

- TerminalTree -

+

TerminalTree

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

@@ -138,16 +150,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 +172,101 @@ 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 + + + + + + + + + +
+ +
+

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 +277,77 @@ export default function PlaygroundPage() {
+ +
+

+ 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/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 */} +