From 0ada7837e454d70f04ac2d8fd5ee779ab5f3da65 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 15:55:13 -0500 Subject: [PATCH] feat(ui-tui): shimmer skeleton for the lazy tools section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy-loaded Available Tools section rendered a BLANK gap that popped when data landed. It now shows animated shimmer rows shaped like the real content (label block + value run, diagonal band sweep), and the summary line prints "… tools · … skills" instead of "0 tools · 0 skills" while counts load. components/loaders.tsx ships the primitives (shimmerSegments band math, Shimmer, useShimmerPhase, ShimmerRows) — colors are caller-owned theme tones, one interval per composition. Cherry-picked from the SDK-shape branch so the skeleton lands with this PR; the widget-SDK consumers stay in the follow-up. --- ui-tui/src/__tests__/loaders.test.ts | 48 ++++++++++++ ui-tui/src/components/branding.tsx | 24 +++++- ui-tui/src/components/loaders.tsx | 112 +++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/__tests__/loaders.test.ts create mode 100644 ui-tui/src/components/loaders.tsx diff --git a/ui-tui/src/__tests__/loaders.test.ts b/ui-tui/src/__tests__/loaders.test.ts new file mode 100644 index 000000000000..cc3d838a2602 --- /dev/null +++ b/ui-tui/src/__tests__/loaders.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { renderToScreen } from '../../packages/hermes-ink/src/ink/render-to-screen.js' +import { cellAtIndex } from '../../packages/hermes-ink/src/ink/screen.js' +import { ShimmerRows, shimmerSegments } from '../components/loaders.js' + +describe('ShimmerRows leniency (agent-authored calls)', () => { + it('accepts a bare row COUNT and derives widths — the generated-code shape', async () => { + const { createElement } = await import('react') + + const { screen, height } = renderToScreen( + createElement(ShimmerRows, { rows: 3, width: 20, t: { color: { completionBg: '#1a1a2e', label: '#DAA520', muted: '#B8860B' } } }), + 30 + ) + + expect(height).toBe(3) + // Row 0 renders block cells, not a crash. + expect(cellAtIndex(screen, 0).char).toBe('▁') + }) +}) + +describe('shimmerSegments', () => { + it('always partitions the full width', () => { + for (let phase = -40; phase < 80; phase++) { + const [pre, band, post] = shimmerSegments(20, phase) + + expect(pre + band + post).toBe(20) + expect(Math.min(pre, band, post)).toBeGreaterThanOrEqual(0) + } + }) + + it('sweeps: enters from the left edge, exits off the right, then wraps', () => { + const bandAt = (phase: number) => shimmerSegments(10, phase, 4) + + expect(bandAt(0)).toEqual([10, 0, 0]) // band fully off-left + expect(bandAt(1)).toEqual([0, 1, 9]) // entering + expect(bandAt(7)).toEqual([3, 4, 3]) // mid-sweep + expect(bandAt(13)).toEqual([9, 1, 0]) // exiting + expect(bandAt(14)).toEqual([10, 0, 0]) // gone → next cycle re-enters + expect(bandAt(15)).toEqual([0, 1, 9]) + }) + + it('negative phases (row stagger) wrap instead of vanishing', () => { + const [pre, band, post] = shimmerSegments(10, -3, 4) + + expect(pre + band + post).toBe(10) + }) +}) diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 2bafc0c6ae5a..e8495f717e4e 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 { ShimmerRows } from './loaders.js' import { WidgetGrid } from './widgetGrid.js' const LOADER_TICK_MS = 120 @@ -188,6 +189,20 @@ export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) { ) } +// ── Skeleton ───────────────────────────────────────────────────────── +// +// Lazy sections render shimmer rows shaped like the real content (label +// block + value run) instead of a blank gap that pops when data lands. +// Row widths mirror the typical toolsets listing. +const SKELETON_ROWS: readonly (readonly [number, number])[] = [ + [7, 30], + [7, 9], + [14, 12], + [12, 12], + [7, 7], + [10, 13] +] + // ── Collapsible helpers ────────────────────────────────────────────── function CollapseToggle({ @@ -299,6 +314,10 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { const mcpConnected = mcpServers.filter(s => s.connected).length const toolsBody = () => { + if (info.lazy && toolEntries.length === 0) { + return + } + const shown = toolEntries.slice(0, TOOLSETS_MAX) const overflow = toolEntries.length - TOOLSETS_MAX @@ -463,8 +482,9 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { - {toolsTotal} tools{' · '} - {skillsTotal} skills + {/* Lazy boot: never print "0 tools · 0 skills" while counts load. */} + {info.lazy && !toolsTotal ? '… ' : `${toolsTotal} `}tools{' · '} + {info.lazy && !skillsTotal ? '… ' : `${skillsTotal} `}skills {mcpConnected ? ` · ${mcpConnected} MCP` : ''} {' · '} /help for commands diff --git a/ui-tui/src/components/loaders.tsx b/ui-tui/src/components/loaders.tsx new file mode 100644 index 000000000000..ebe0bb8ce822 --- /dev/null +++ b/ui-tui/src/components/loaders.tsx @@ -0,0 +1,112 @@ +import { Box, Text } from '@hermes/ink' +import { useEffect, useState } from 'react' + +import { mix } from '../lib/color.js' + +/** + * Animated ASCII loaders — THE loading-state primitives (session panel + * skeleton, widget apps via the SDK). A highlight band sweeps across block + * runs; rows offset their phase for a diagonal shimmer. One interval per + * composition (the parent ticks, rows are pure), colors are caller-owned + * theme tones — never hardcoded. + */ + +const BAND = 7 + +/** Pure band math: [pre, band, post] cell widths for a sweep at `phase`. + * The band enters from off-left and exits off-right, wrapping. */ +export function shimmerSegments(width: number, phase: number, band = BAND): [number, number, number] { + const cycle = width + band + const start = (((phase % cycle) + cycle) % cycle) - band + const from = Math.max(0, start) + const to = Math.min(width, start + band) + + return to <= from ? [width, 0, 0] : [from, to - from, width - to] +} + +/** One shimmering run. Controlled: the parent owns `phase` so sibling rows + * stay in lockstep (offset it per row for the diagonal). */ +export function Shimmer({ + char = '▁', + color, + highlight, + phase, + width +}: { + char?: string + color: string + highlight: string + phase: number + width: number +}) { + const [pre, band, post] = shimmerSegments(width, phase) + + return ( + + {pre > 0 && {char.repeat(pre)}} + {band > 0 && {char.repeat(band)}} + {post > 0 && {char.repeat(post)}} + + ) +} + +/** Self-ticking phase for shimmer compositions. */ +export function useShimmerPhase(tickMs = 90): number { + const [phase, setPhase] = useState(0) + + useEffect(() => { + const id = setInterval(() => setPhase(p => p + 1), tickMs) + + id.unref?.() + + return () => clearInterval(id) + }, [tickMs]) + + return phase +} + +/** Skeleton rows shaped like `label: value` content, diagonal shimmer. + * + * Ergonomic for generated code (the primary author is an agent): + * - `rows` — explicit `[labelWidth, valueWidth][]` mirroring real layout, + * OR just a count (row widths derive from `width`, staggered). + * - colors — explicit `color`/`highlight`, OR pass a theme `t` and they + * derive (muted-toward-surface base, label highlight). */ +export function ShimmerRows({ + color, + highlight, + rows, + t, + width = 24 +}: { + color?: string + highlight?: string + rows: number | readonly (readonly [number, number])[] + t?: { color: { completionBg: string; label: string; muted: string } } + width?: number +}) { + const phase = useShimmerPhase() + const base = color ?? (t ? mix(t.color.muted, t.color.completionBg, 0.5) : '#808080') + const glow = highlight ?? t?.color.label ?? '#a0a0a0' + + const spec: readonly (readonly [number, number])[] = + typeof rows === 'number' + ? Array.from({ length: Math.max(1, rows) }, (_, i) => { + const label = Math.max(4, Math.round(width * 0.3) - (i % 3)) + + return [label, Math.max(4, width - label - 1)] as const + }) + : rows + + return ( + + {spec.map(([labelWidth, valueWidth], i) => ( + + + + + + ))} + + ) +}