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) => (
+
+
+
+
+
+ ))}
+
+ )
+}