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 (
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