mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
parent
c543c691fc
commit
0ada7837e4
3 changed files with 182 additions and 2 deletions
48
ui-tui/src/__tests__/loaders.test.ts
Normal file
48
ui-tui/src/__tests__/loaders.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
112
ui-tui/src/components/loaders.tsx
Normal file
112
ui-tui/src/components/loaders.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue