feat(ui-tui): shimmer skeleton for the lazy tools section

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-21 15:55:13 -05:00
parent c543c691fc
commit 0ada7837e4
3 changed files with 182 additions and 2 deletions

View file

@ -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)
})
})

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 { 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 <ShimmerRows color={listFade} highlight={t.color.label} rows={SKELETON_ROWS} />
}
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) {
<Text />
<Text color={t.color.text}>
{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` : ''}
{' · '}
<Text color={t.color.muted}>/help for commands</Text>

View file

@ -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 (
<Text>
{pre > 0 && <Text color={color}>{char.repeat(pre)}</Text>}
{band > 0 && <Text color={highlight}>{char.repeat(band)}</Text>}
{post > 0 && <Text color={color}>{char.repeat(post)}</Text>}
</Text>
)
}
/** 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 (
<Box flexDirection="column">
{spec.map(([labelWidth, valueWidth], i) => (
<Text key={i}>
<Shimmer color={base} highlight={glow} phase={phase - i * 2} width={labelWidth} />
<Text> </Text>
<Shimmer color={base} highlight={glow} phase={phase - i * 2 - labelWidth} width={valueWidth} />
</Text>
))}
</Box>
)
}