feat(ui-tui): widget primitives — charts, accordion, shimmer, stable streams

Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/
hbars chart helpers (dimension-stable so live updates never resize the card),
an Accordion for expand/collapse sections, animated shimmer loaders, and a
streams demo that no longer reserves a phantom icon column on unfocused titles.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 23:13:58 -05:00
parent ca0b1b130c
commit ed93d6afe0
10 changed files with 269 additions and 87 deletions

View file

@ -62,7 +62,35 @@ export default function register(sdk) {
`sdk` contents: `defineWidgetApp`, `openWidget`, `updateWidget`, `isCtrl`,
`React`, `h` (createElement — no JSX in .mjs), components `Box`, `Text`,
`Dialog`, `Overlay`, `WidgetGrid`, `GridAreas`.
`Dialog`, `Overlay`, `WidgetGrid`, `GridAreas`, and loaders `Shimmer`,
`ShimmerRows`, `useShimmerPhase` — use `ShimmerRows` for loading phases
instead of a bare "loading…" line.
Expand/collapse: `sdk.Accordion` — the same primitive the session panel's
tool/skill sections use. `h(Accordion, { t, title: 'details', count: 3,
defaultOpen: false }, body)` toggles on CLICK (works in ambient widgets,
which receive no keys); modal apps may pass `open` + `onToggle` to drive it
from reducer state instead.
Stable sizing (cards must NEVER resize while ticking):
- Give `Dialog` an explicit `width`; charts already return exactly the
`width` you ask for (short series pad-left while history warms up).
- Pad dynamic numbers: `String(v).padStart(6)``51 ms``112 ms` must
not change the line length.
- Keep row counts constant per phase; swap content, not structure.
Charts (pure string builders — color the result with theme tones):
- `sdk.sparkline(series, width?)``▂▃▅▇█▆` one-row trend
- `sdk.sparkRows(series, width, rows)` → multi-row column chart (top line
first) — the mission-control panel look; taller cells gain resolution
- `sdk.gauge(ratio, width)``█████░░░` fill bar for a 0..1 value
- `sdk.hbars(values, width)` → horizontal bar chart, one bar per value,
eighth-block tips, scaled to the max
Keep a rolling series in component state (push per tick, cap ~120 samples)
and render `sparkRows` for dashboard panels, `sparkline` for one-liners.
Contract essentials:

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js'
describe('chart primitives', () => {
it('sparkline spans the block ramp and respects width', () => {
const line = sparkline([0, 1, 2, 3, 4, 5, 6, 7], 8)
expect(line).toBe('▁▂▃▄▅▆▇█')
expect(sparkline([1, 2, 3, 4], 2)).toHaveLength(2) // window = last N
})
it('is dimension-stable: short/empty series pad to exactly width', () => {
// Warm-up must never resize the card — latest sample pins right.
expect(sparkline([5], 6)).toHaveLength(6)
expect(sparkline([5], 6).endsWith('▁')).toBe(true) // flat series → bottom block, right-pinned
expect(sparkline([], 6)).toBe(' ')
expect(sparkRows([7], 5, 2).every(row => row.length === 5)).toBe(true)
expect(sparkRows([], 5, 2)).toEqual([' ', ' '])
expect(hbars([1, 4], 8).every(bar => bar.length === 8)).toBe(true)
})
it('sparkRows partitions each column across rows (top line first)', () => {
const rows = sparkRows([0, 8, 4], 3, 2)
expect(rows).toHaveLength(2)
expect(rows.every(r => r.length === 3)).toBe(true)
// Max value fills the top row cell; min value leaves it blank.
expect(rows[0]![1]).toBe('█')
expect(rows[0]![0]).toBe(' ')
})
it('gauge clamps and fills proportionally', () => {
expect(gauge(0.5, 8)).toBe('████░░░░')
expect(gauge(-1, 4)).toBe('░░░░')
expect(gauge(9, 4)).toBe('████')
})
it('hbars scales to the max with eighth-block tips', () => {
const [half, full] = hbars([4, 8], 8)
expect(full).toBe('████████')
expect(half).toBe('████ ')
expect(hbars([3, 8], 8)[0]).toBe('███ ') // 3/8 of 8 cells, padded
expect(hbars([1, 2], 3)[0]).toMatch(/^█?[▏▎▍▌▋▊▉█] *$/) // fractional tip, padded
})
})

View file

@ -0,0 +1,58 @@
import { Box, Text } from '@hermes/ink'
import { type ReactNode, useState } from 'react'
import type { Theme } from '../theme.js'
/**
* THE expand/collapse primitive the session panel's tool/skill sections
* and widget-app accordions are the same component. Click the header to
* toggle (mouse works even in ambient widgets, which receive no keys);
* modal apps may instead drive `open` from reducer state (controlled).
* Uncontrolled by default: pass `defaultOpen` and forget it.
*/
export function Accordion({
children,
count,
defaultOpen = false,
onToggle,
open,
suffix,
t,
title
}: {
children: ReactNode
count?: number
defaultOpen?: boolean
/** Controlled open state; omit for internal (click-toggled) state. */
open?: boolean
onToggle?: () => void
suffix?: string
t: Theme
title: string
}) {
const [uncontrolled, setUncontrolled] = useState(defaultOpen)
const isOpen = open ?? uncontrolled
const toggle = () => {
onToggle?.()
if (open === undefined) {
setUncontrolled(v => !v)
}
}
return (
<Box flexDirection="column">
<Box onClick={toggle}>
<Text color={t.color.accent}>{isOpen ? '▾ ' : '▸ '}</Text>
<Text bold color={t.color.accent}>
{title}
</Text>
{typeof count === 'number' ? <Text color={t.color.muted}> ({count})</Text> : null}
{suffix ? <Text color={t.color.muted}> {suffix}</Text> : null}
</Box>
{isOpen ? children : null}
</Box>
)
}

View file

@ -8,6 +8,7 @@ import { flat } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { PanelSection, SessionInfo } from '../types.js'
import { Accordion } from './accordion.js'
import { ShimmerRows } from './loaders.js'
import { WidgetGrid } from './widgetGrid.js'
@ -203,35 +204,6 @@ const SKELETON_ROWS: readonly (readonly [number, number])[] = [
[10, 13]
]
// ── Collapsible helpers ──────────────────────────────────────────────
function CollapseToggle({
count,
open,
suffix,
t,
title,
onToggle
}: {
count?: number
open: boolean
suffix?: string
t: Theme
title: string
onToggle: () => void
}) {
return (
<Box onClick={onToggle}>
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
<Text bold color={t.color.accent}>
{title}
</Text>
{typeof count === 'number' ? <Text color={t.color.muted}> ({count})</Text> : null}
{suffix ? <Text color={t.color.muted}> {suffix}</Text> : null}
</Box>
)
}
// ── SessionPanel ─────────────────────────────────────────────────────
const SKILLS_MAX = 8
@ -431,49 +403,53 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
{/* ── Tools (expanded by default) ── */}
<Box flexDirection="column" marginTop={1}>
<CollapseToggle onToggle={() => setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools" />
{toolsOpen && toolsBody()}
<Accordion onToggle={() => setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools">
{toolsBody()}
</Accordion>
</Box>
{/* ── Skills (collapsed by default) ── */}
<Box flexDirection="column" marginTop={1}>
<CollapseToggle
<Accordion
count={skillsTotal}
onToggle={() => setSkillsOpen(v => !v)}
open={skillsOpen}
suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined}
t={t}
title="Available Skills"
/>
{skillsOpen && skillsBody()}
>
{skillsBody()}
</Accordion>
</Box>
{/* ── System Prompt (collapsed by default) ── */}
{sysPromptLen > 0 && (
<Box flexDirection="column" marginTop={1}>
<CollapseToggle
<Accordion
onToggle={() => setSystemOpen(v => !v)}
open={systemOpen}
suffix={`${sysPromptLen.toLocaleString()} chars`}
t={t}
title="System Prompt"
/>
{systemOpen && systemBody()}
>
{systemBody()}
</Accordion>
</Box>
)}
{/* ── MCP Servers (collapsed by default) ── */}
{mcpServers.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<CollapseToggle
<Accordion
count={mcpConnected}
onToggle={() => setMcpOpen(v => !v)}
open={mcpOpen}
suffix="connected"
t={t}
title="MCP Servers"
/>
{mcpOpen && mcpBody()}
>
{mcpBody()}
</Accordion>
</Box>
)}

View file

@ -1,6 +1,7 @@
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'
@ -18,8 +19,6 @@ const HEADER_ROWS = 3
const STREAM_ROWS = 3
const GRID_HEIGHT = HEADER_ROWS + STREAM_ROWS * 6
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
interface StreamDef {
id: string
render: (inner: { height: number; t: Theme; width: number }) => ReactNode
@ -57,37 +56,6 @@ const useHistory = (tick: number, sample: () => number, cap = 240) => {
return historyRef.current
}
/**
* Render history as a column chart `rows` lines tall (top line first). Each
* column gets `rows * 8` vertical levels full blocks below the value, a
* partial eighth-block at the value row, spaces above so a promoted (taller)
* cell genuinely gains chart resolution rather than just whitespace.
*/
const sparkRows = (history: number[], width: number, rows: number): string[] => {
const window = history.slice(-Math.max(1, width))
if (!window.length) {
return Array.from({ length: rows }, () => '')
}
const min = Math.min(...window)
const max = Math.max(...window)
const range = max - min || 1
const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8)))
return Array.from({ length: rows }, (_, lineIdx) => {
const rowFromBottom = rows - 1 - lineIdx
return levels
.map(level => {
const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8))
return filled === 0 ? ' ' : SPARK_CHARS[filled - 1]
})
.join('')
})
}
// ── stream panels ───────────────────────────────────────────────────────────
const TOKEN_WORDS = (
@ -311,8 +279,10 @@ function StreamPanel({
paddingX={1}
width={cell.width}
>
{/* No phantom icon column: unfocused titles sit flush left the
appears (and shifts the title) only while focused. */}
<Text bold={focused} color={focused ? t.color.primary : t.color.label} wrap="truncate">
{focused ? '▸ ' : ' '}
{focused ? '▸ ' : ''}
{title}
{main ? ' ·' : ''}
</Text>

80
ui-tui/src/lib/charts.ts Normal file
View file

@ -0,0 +1,80 @@
/**
* Chart primitives pure string builders (no React), THE charting layer for
* widget apps and demos alike. Everything returns plain strings the caller
* colors with theme tones; everything auto-scales to the series' min/max.
*/
const BLOCKS = '▁▂▃▄▅▆▇█'
const normalize = (series: number[], window: number): { min: number; range: number; window: number[] } => {
const view = series.slice(-Math.max(1, window))
const min = Math.min(...view)
return { min, range: Math.max(...view) - min || 1, window: view }
}
/** One-row sparkline: ``, last `width` samples. ALWAYS exactly
* `width` cells short series pad-left so the card never resizes while
* history warms up (latest sample stays pinned to the right edge). */
export function sparkline(series: number[], width = series.length): string {
if (!series.length) {
return ' '.repeat(Math.max(0, width))
}
const { min, range, window } = normalize(series, width)
return window
.map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(((v - min) / range) * BLOCKS.length))])
.join('')
.padStart(width)
}
/**
* Multi-row column chart, top line first the streams-demo panel chart.
* Each column resolves to `rows * 8` vertical levels (full blocks below the
* value, a partial eighth-block at it), so taller cells genuinely gain
* resolution.
*/
export function sparkRows(series: number[], width: number, rows: number): string[] {
if (!series.length) {
return Array.from({ length: rows }, () => ' '.repeat(width))
}
const { min, range, window } = normalize(series, width)
const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8)))
return Array.from({ length: rows }, (_, lineIdx) => {
const rowFromBottom = rows - 1 - lineIdx
return levels
.map(level => {
const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8))
return filled === 0 ? ' ' : BLOCKS[filled - 1]
})
.join('')
.padStart(width) // warm-up pads left: chart grows from the right, card never resizes
})
}
/** Horizontal fill gauge: `█████░░░` for a 0..1 ratio. */
export function gauge(ratio: number, width: number): string {
const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
}
/** Horizontal bar chart: one ``-style bar per value, scaled to the max,
* each padded to exactly `width` (stable card sizing). Eighth-block tips
* keep adjacent values distinguishable. */
export function hbars(values: number[], width: number): string[] {
const max = Math.max(...values, 0) || 1
return values.map(v => {
const cells = (Math.min(max, Math.max(0, v)) / max) * width
const full = Math.floor(cells)
const rest = Math.round((cells - full) * 8)
return ('█'.repeat(full) + (rest > 0 ? '▏▎▍▌▋▊▉█'[rest - 1] : '')).padEnd(width)
})
}

View file

@ -2,6 +2,7 @@ import { Box, Text } from '@hermes/ink'
import { useEffect, useState } from 'react'
import { Dialog } from '../../components/overlay.js'
import { sparkline } from '../../lib/charts.js'
import type { Theme } from '../../theme.js'
import { defineWidgetApp } from '../registry.js'
import { isCtrl } from '../types.js'
@ -13,7 +14,6 @@ import { isCtrl } from '../types.js'
*/
const USAGE = 'usage: /ticker [symbol]'
const BLOCKS = '▁▂▃▄▅▆▇█'
const POINTS = 26
const TICK_MS = 250
const PIP = 0.0001
@ -22,13 +22,6 @@ export interface TickerState {
symbol: string
}
const spark = (series: number[]): string => {
const min = Math.min(...series)
const span = Math.max(...series) - min || 1
return series.map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(((v - min) / span) * BLOCKS.length))]).join('')
}
function Chart({ symbol, t }: { symbol: string; t: Theme }) {
const [series, setSeries] = useState<number[]>(() => {
const seed = 1.1 + Math.random() * 0.4
@ -67,7 +60,7 @@ function Chart({ symbol, t }: { symbol: string; t: Theme }) {
{Math.abs(delta / PIP).toFixed(1)}p
</Text>
</Box>
<Text color={dir}>{spark(series)}</Text>
<Text color={dir}>{sparkline(series)}</Text>
</Box>
)
}

View file

@ -1,6 +1,8 @@
import { Box, Text } from '@hermes/ink'
import { ShimmerRows } from '../../components/loaders.js'
import { Dialog } from '../../components/overlay.js'
import { mix } from '../../lib/color.js'
import type { Theme } from '../../theme.js'
import { updateWidget } from '../host.js'
import { defineWidgetApp } from '../registry.js'
@ -15,6 +17,14 @@ import { isCtrl } from '../types.js'
const USAGE = 'usage: /weather [location] (blank = geolocate by IP)'
// Skeleton mirrors the ready layout: art column + four stat lines.
const LOADING_ROWS: readonly (readonly [number, number])[] = [
[13, 12],
[13, 16],
[13, 14],
[13, 11]
]
type Phase = { kind: 'error'; message: string } | { kind: 'loading' } | { kind: 'ready'; report: Report }
export interface WeatherState {
@ -154,7 +164,13 @@ export const weatherApp = defineWidgetApp<WeatherState>({
return (
<Dialog title={title} width={Math.min(42, cols - 4)}>
{phase.kind === 'loading' && <Text color={t.color.muted}>fetching wttr.in</Text>}
{phase.kind === 'loading' && (
<ShimmerRows
color={mix(t.color.muted, t.color.completionBg, 0.5)}
highlight={t.color.label}
rows={LOADING_ROWS}
/>
)}
{phase.kind === 'error' && <Text color={t.color.error}>{phase.message}</Text>}
{phase.kind === 'ready' && <ReadyBody report={phase.report} t={t} />}
</Dialog>

View file

@ -11,9 +11,11 @@
* See `sdk/apps/` for the reference apps (`/grid-test`, `/dialog-test`).
*/
// Theme + chrome primitives
export { Accordion } from '../components/accordion.js'
export { Shimmer, ShimmerRows, shimmerSegments, useShimmerPhase } from '../components/loaders.js'
// Layout components + overlay primitives
export { Dialog, Overlay, type OverlayZone } from '../components/overlay.js'
// Theme + chrome primitives
export { OverlayHint, windowItems } from '../components/overlayControls.js'
export {
ActionRow,
@ -26,6 +28,7 @@ export {
export { GridAreas, WidgetGrid } from '../components/widgetGrid.js'
export { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js'
export { contrastRatio, liftForContrast, mix, relativeLuminance } from '../lib/color.js'
// Layout engine
export {

View file

@ -7,8 +7,11 @@ import { pathToFileURL } from 'url'
import { Box, Text } from '@hermes/ink'
import * as React from 'react'
import { Accordion } from '../components/accordion.js'
import { Shimmer, ShimmerRows, useShimmerPhase } from '../components/loaders.js'
import { Dialog, Overlay } from '../components/overlay.js'
import { GridAreas, WidgetGrid } from '../components/widgetGrid.js'
import { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js'
import { recordParentLifecycle } from '../lib/parentLog.js'
import { openWidget, updateWidget } from './host.js'
@ -31,18 +34,26 @@ import { isCtrl } from './types.js'
/** Everything a user widget may touch, passed INTO its register() user
* files have no resolvable import path to the bundle. */
export const widgetSdk = {
Accordion,
Box,
Dialog,
GridAreas,
Overlay,
React,
Shimmer,
ShimmerRows,
Text,
WidgetGrid,
defineWidgetApp,
gauge,
h: React.createElement,
hbars,
isCtrl,
openWidget,
updateWidget
sparkRows,
sparkline,
updateWidget,
useShimmerPhase
} as const
export type WidgetSdk = typeof widgetSdk