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/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..dc51a66 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -1,8 +1,20 @@ 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 { PromptDemo } from './prompt-demo' +import { SearchDemo } from './search-demo' import { TreeDemo } from './tree-demo' import { TreeKeyboardDemo } from './tree-keyboard-demo' @@ -14,25 +26,19 @@ export default function PlaygroundPage() { return (
-

- Playground -

+

Playground

-

- Terminal App -

+

Terminal App

-

- TerminalPrompt -

+

TerminalPrompt

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

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

- TerminalProgress -

+

TerminalProgress

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

- TerminalSpinner -

+

TerminalSpinner

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

- TerminalLog + TerminalLog — string mode

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

- TerminalTree -

+

TerminalTree

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

@@ -138,16 +147,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 +169,93 @@ 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 + + + + + + + + + +
+ +
+

TerminalSearch

+

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

+ +
+ +
+

Typing Animation

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

TerminalFilterBar

+

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

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