From 0a6ecea676523d808d1f0657a8f1a80debba14f1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Apr 2026 23:48:07 -0500 Subject: [PATCH] fix(tui): hydrate lazy startup panel and use animated loaders The lazy startup panel could remain stuck on the placeholder when no first prompt was submitted because agent construction only started from _sess(). Keep session.create cheap, but schedule _start_agent_build shortly after returning the placeholder so tools/skills hydrate automatically. Also replace the ugly placeholder bar rows with compact unicode-animations braille loaders for the tools and skills sections. Tests: - python -m py_compile tui_gateway/server.py - cd ui-tui && npm run type-check && npm run build - cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts - scripts/run_tests.sh tests/tui_gateway/test_protocol.py::test_sess_found tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py --- tui_gateway/server.py | 11 +++++++ ui-tui/src/components/branding.tsx | 46 +++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e5b1447d76..2ba156587d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1726,6 +1726,17 @@ def _(rid, params: dict) -> dict: "transport": current_transport() or _stdio_transport, } + # Return the lightweight session immediately so Ink can paint the composer + # + skeleton panel, then build the real AIAgent just after this response is + # flushed. This keeps startup responsive while still hydrating tools/skills + # without requiring the user to submit a first prompt. + def _deferred_build() -> None: + session = _sessions.get(sid) + if session is not None: + _start_agent_build(sid, session) + + threading.Timer(0.05, _deferred_build).start() + return _ok( rid, { diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 0a7509f696..84e502aada 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -1,10 +1,32 @@ import { Box, Text, useStdout } from '@hermes/ink' +import { useEffect, useState } from 'react' +import unicodeSpinners from 'unicode-animations' import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js' import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' import type { PanelSection, SessionInfo } from '../types.js' +const LOADER_TICK_MS = 120 + +function InlineLoader({ label, t }: { label: string; t: Theme }) { + const [tick, setTick] = useState(0) + const spinner = unicodeSpinners.braille + const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋' + + useEffect(() => { + const id = setInterval(() => setTick(n => n + 1), Math.max(LOADER_TICK_MS, spinner.interval)) + + return () => clearInterval(id) + }, [spinner.interval]) + + return ( + + {frame} {label} + + ) +} + export function ArtLines({ lines }: { lines: [string, string][] }) { return ( <> @@ -64,7 +86,6 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) { } const section = (title: string, data: Record, max = 8, overflowLabel = 'more…') => { - const skeletonRows = title === 'Tools' ? ['browser', 'terminal', 'file'] : ['apple', 'creative', 'software-development'] const entries = Object.entries(data).sort() const shown = entries.slice(0, max) const overflow = entries.length - max @@ -76,19 +97,16 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) { Available {title} - {skeleton - ? skeletonRows.map(k => ( - - {k}: - ━━━━━━━━━━━━━━ - - )) - : shown.map(([k, vs]) => ( - - {strip(k)}: - {truncLine(strip(k) + ': ', vs)} - - ))} + {skeleton ? ( + + ) : ( + shown.map(([k, vs]) => ( + + {strip(k)}: + {truncLine(strip(k) + ': ', vs)} + + )) + )} {overflow > 0 && (