import { Box, Text } from '@hermes/ink' import { memo, type ReactNode, useEffect, useRef, useState } from 'react' import { sparkRows } from '../lib/charts.js' import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js' import type { GridTestState } from '../sdk/apps/gridTestState.js' import type { Theme } from '../theme.js' import { GridAreas, type GridAreaWidget } from './widgetGrid.js' // The streams demo tiles six independently-ticking panels through // `layoutGridAreas`: one panel owns a promoted 2x2 slot, the rest auto-place // densely around it. Promoting a different panel reorders/respans the widget // list — but cells are keyed by id, so every stream keeps its history while // the grid reshapes around it. That relayout-without-reset is the point of // the demo. const HEADER_ROWS = 3 const STREAM_ROWS = 3 const GRID_HEIGHT = HEADER_ROWS + STREAM_ROWS * 6 interface StreamDef { id: string render: (inner: { height: number; t: Theme; width: number }) => ReactNode title: string } // ── tick + history hooks ──────────────────────────────────────────────────── const useTick = (ms: number) => { const [tick, setTick] = useState(0) useEffect(() => { const timer = setInterval(() => setTick(v => v + 1), ms) return () => clearInterval(timer) }, [ms]) return tick } /** Ring-buffered sample history: one `sample()` per tick, capped at `cap`. */ const useHistory = (tick: number, sample: () => number, cap = 240) => { const historyRef = useRef([]) useEffect(() => { historyRef.current.push(sample()) if (historyRef.current.length > cap) { historyRef.current.splice(0, historyRef.current.length - cap) } // The sampler is intentionally re-run per tick, not per identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [tick]) return historyRef.current } // ── stream panels ─────────────────────────────────────────────────────────── const TOKEN_WORDS = ( `Hermes streams tokens into the promoted cell while the grid reshapes around it. ` + `Cells are keyed by id, so promotion never resets a panel — history, cursors and ` + `tickers all survive the relayout. Row and column tracks re-solve to integer ` + `terminal cells on every change, spans bridge the gaps they cross, and dense ` + `auto-placement backfills the holes the promoted panel leaves behind. ` ).split(' ') function TokenStream({ height, t, width }: { height: number; t: Theme; width: number }) { const tick = useTick(90) const wordCount = tick % (TOKEN_WORDS.length * 4) // Keep roughly enough trailing text to fill the cell, on word boundaries. const budget = Math.max(16, width * height) const words: string[] = [] let used = 0 for (let i = wordCount; i >= 0 && used < budget; i--) { const word = TOKEN_WORDS[i % TOKEN_WORDS.length]! words.unshift(word) used += word.length + 1 } return ( {words.join(' ')} ) } function SparkStream({ color, format, height, interval, sample, t, width }: { color: string format: (value: number) => string height: number interval: number sample: () => number t: Theme width: number }) { const tick = useTick(interval) const history = useHistory(tick, sample) const current = history[history.length - 1] ?? 0 // Chart height grows with the cell — a promoted cell gets a taller chart. const rows = Math.max(1, height - 1) return ( {format(current)} {sparkRows(history, width, rows).map((line, idx) => ( {line} ))} ) } const FAKE_TOOLS = [ '┊ read_file src/app/chat.tsx', '┊ terminal npm run typecheck', '┊ search_files "GridAreas"', '┊ patch ui-tui/src/lib/widgetGrid.ts', '┊ web_search yoga absolute layout', '┊ delegate_task refactor sparkline', '┊ terminal scripts/run_tests.sh', '┊ vision_analyze screenshot.png' ] function ToolTicker({ height, t }: { height: number; t: Theme; width: number }) { const tick = useTick(600) const visible = Math.max(1, height) const lines = Array.from({ length: Math.min(visible, tick + 1) }, (_, idx) => { const line = FAKE_TOOLS[(tick - idx) % FAKE_TOOLS.length]! return { line, recent: idx === 0 } }).reverse() return ( {lines.map(({ line, recent }, idx) => ( {line} ))} ) } function MetaPanel({ t }: { height: number; t: Theme; width: number }) { const tick = useTick(1000) const startedRef = useRef(Date.now()) const uptime = Math.floor((Date.now() - startedRef.current) / 1000) return ( {new Date().toLocaleTimeString()} {`up ${Math.floor(uptime / 60)}m ${uptime % 60}s`} {`${tick} ticks`} ) } // ── stream registry ───────────────────────────────────────────────────────── const sineSampler = () => (Math.sin(Date.now() / 700) + 1) / 2 + Math.random() * 0.15 let walkValue = 0.5 const walkSampler = () => { walkValue = Math.min(1, Math.max(0, walkValue + (Math.random() - 0.5) * 0.2)) return walkValue } /** Exported so tests can assert the count matches GRID_STREAM_COUNT (focus wraps mod it). */ export const STREAM_DEFS: StreamDef[] = [ { id: 'tokens', render: ({ height, t, width }) => , title: 'token stream' }, { id: 'throughput', render: ({ height, t, width }) => ( `${Math.round(20 + v * 40)} tok/s`} height={height} interval={150} sample={sineSampler} t={t} width={width} /> ), title: 'throughput' }, { id: 'heap', render: ({ height, t, width }) => ( `heap ${(v / 1024 / 1024).toFixed(1)} MB`} height={height} interval={500} sample={() => process.memoryUsage().heapUsed} t={t} width={width} /> ), title: 'memory' }, { id: 'latency', render: ({ height, t, width }) => ( `${Math.round(20 + v * 180)} ms`} height={height} interval={250} sample={walkSampler} t={t} width={width} /> ), title: 'latency' }, { id: 'tools', render: ({ height, t, width }) => , title: 'tool feed' }, { id: 'meta', render: ({ height, t, width }) => , title: 'session' } ] // ── the demo surface ──────────────────────────────────────────────────────── function StreamPanel({ cell, focused, main, t, title, children }: { cell: GridAreaCell children: (inner: { height: number; t: Theme; width: number }) => ReactNode focused: boolean main: boolean t: Theme title: string }) { const innerWidth = Math.max(1, cell.width - 4) const innerHeight = Math.max(1, cell.height - 3) const borderColor = focused ? t.color.primary : main ? t.color.accent : t.color.border return ( {/* No phantom icon column: unfocused titles sit flush left — the ▸ appears (and shifts the title) only while focused. */} {focused ? '▸ ' : ''} {title} {main ? ' ·' : ''} {children({ height: innerHeight, t, width: innerWidth })} ) } export const GridStreamsDemo = memo(function GridStreamsDemo({ cols, state, t }: { cols: number state: GridTestState t: Theme }) { const columnTracks: GridTrackSize[] = [{ fr: 1 }, { fr: 1 }, { fr: 1 }] const main = STREAM_DEFS[state.streamMain % STREAM_DEFS.length]! // Promoted panel first so dense placement gives it the top-left 2x2; the // rest backfill in definition order. Ids never change, so React reconciles // each panel across promotions and its ticking state survives. const ordered = [main, ...STREAM_DEFS.filter(def => def.id !== main.id)] const widgets: GridAreaWidget[] = [ { colSpan: 3, id: 'stream-header', render: cell => ( hermes mission control {`main: ${main.title}`} ) }, ...ordered.map((def, idx) => ({ colSpan: idx === 0 ? 2 : 1, id: `stream-${def.id}`, render: (cell: GridAreaCell) => ( {def.render} ), rowSpan: idx === 0 ? 2 : 1 })) ] return ( ) })