diff --git a/skills/productivity/tui-widgets/SKILL.md b/skills/productivity/tui-widgets/SKILL.md index 8c19acaac862..a18c81460a5f 100644 --- a/skills/productivity/tui-widgets/SKILL.md +++ b/skills/productivity/tui-widgets/SKILL.md @@ -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: diff --git a/ui-tui/src/__tests__/charts.test.ts b/ui-tui/src/__tests__/charts.test.ts new file mode 100644 index 000000000000..0f3880518ab4 --- /dev/null +++ b/ui-tui/src/__tests__/charts.test.ts @@ -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 + }) +}) diff --git a/ui-tui/src/components/accordion.tsx b/ui-tui/src/components/accordion.tsx new file mode 100644 index 000000000000..943259ee15bb --- /dev/null +++ b/ui-tui/src/components/accordion.tsx @@ -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 ( + + + {isOpen ? '▾ ' : '▸ '} + + {title} + + {typeof count === 'number' ? ({count}) : null} + {suffix ? {suffix} : null} + + + {isOpen ? children : null} + + ) +} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index fcc5f6f130d1..18fdf3332c73 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -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 ( - - {open ? '▾ ' : '▸ '} - - {title} - - {typeof count === 'number' ? ({count}) : null} - {suffix ? {suffix} : null} - - ) -} - // ── SessionPanel ───────────────────────────────────────────────────── const SKILLS_MAX = 8 @@ -431,49 +403,53 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { {/* ── Tools (expanded by default) ── */} - setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools" /> - {toolsOpen && toolsBody()} + setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools"> + {toolsBody()} + {/* ── Skills (collapsed by default) ── */} - setSkillsOpen(v => !v)} open={skillsOpen} suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined} t={t} title="Available Skills" - /> - {skillsOpen && skillsBody()} + > + {skillsBody()} + {/* ── System Prompt (collapsed by default) ── */} {sysPromptLen > 0 && ( - setSystemOpen(v => !v)} open={systemOpen} suffix={`— ${sysPromptLen.toLocaleString()} chars`} t={t} title="System Prompt" - /> - {systemOpen && systemBody()} + > + {systemBody()} + )} {/* ── MCP Servers (collapsed by default) ── */} {mcpServers.length > 0 && ( - setMcpOpen(v => !v)} open={mcpOpen} suffix="connected" t={t} title="MCP Servers" - /> - {mcpOpen && mcpBody()} + > + {mcpBody()} + )} diff --git a/ui-tui/src/components/gridStreamsDemo.tsx b/ui-tui/src/components/gridStreamsDemo.tsx index eaa1d2ec7d91..d94c85b957b2 100644 --- a/ui-tui/src/components/gridStreamsDemo.tsx +++ b/ui-tui/src/components/gridStreamsDemo.tsx @@ -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. */} - {focused ? '▸ ' : ' '} + {focused ? '▸ ' : ''} {title} {main ? ' ·' : ''} diff --git a/ui-tui/src/lib/charts.ts b/ui-tui/src/lib/charts.ts new file mode 100644 index 000000000000..d9eba4f1a20f --- /dev/null +++ b/ui-tui/src/lib/charts.ts @@ -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) + }) +} diff --git a/ui-tui/src/sdk/apps/ticker.tsx b/ui-tui/src/sdk/apps/ticker.tsx index 6c6aba70867d..ae6da2732129 100644 --- a/ui-tui/src/sdk/apps/ticker.tsx +++ b/ui-tui/src/sdk/apps/ticker.tsx @@ -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(() => { 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 - {spark(series)} + {sparkline(series)} ) } diff --git a/ui-tui/src/sdk/apps/weather.tsx b/ui-tui/src/sdk/apps/weather.tsx index daee3d482610..676a067c70a3 100644 --- a/ui-tui/src/sdk/apps/weather.tsx +++ b/ui-tui/src/sdk/apps/weather.tsx @@ -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({ return ( - {phase.kind === 'loading' && fetching wttr.in…} + {phase.kind === 'loading' && ( + + )} {phase.kind === 'error' && {phase.message}} {phase.kind === 'ready' && } diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts index 772948e57695..2ca567c53361 100644 --- a/ui-tui/src/sdk/index.ts +++ b/ui-tui/src/sdk/index.ts @@ -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 { diff --git a/ui-tui/src/sdk/userWidgets.ts b/ui-tui/src/sdk/userWidgets.ts index f423204e1f88..895f6e61e777 100644 --- a/ui-tui/src/sdk/userWidgets.ts +++ b/ui-tui/src/sdk/userWidgets.ts @@ -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