perf(tui): defer agent construction until first prompt

Match classic CLI perceived startup behavior: show the TUI shell and composer
before constructing the full AIAgent. session.create now returns a lightweight
placeholder session with lazy=true and no longer starts _make_agent eagerly.
The first method that needs the agent triggers _start_agent_build() via _sess();
prompt.submit is routed through the RPC worker pool so that the initial wait for
agent construction does not block the stdio dispatcher.

The intro panel renders skeleton rows for tools/skills while the real
session.info payload is absent, then hydrates to the real tools/skills panel once
AIAgent initialization completes. Also skip the startup /voice status probe and
avoid the input.detect_drop RPC for ordinary plain-text prompts to keep early
startup/first-submit paths cheap.

Measurements on macOS Terminal.app:
- Previous full ready p50 after earlier PR commits: ~1537ms
- Lazy skeleton panel p50: ~794ms
- Original baseline full ready p50: ~1843ms

So the visible startup surface is now ~743ms faster than the prior PR state and
~1.05s faster than the original baseline. First prompt still pays the same agent
construction cost if it races the background/skeleton state, matching classic
CLI's deferred behavior.

Tests:
- python -m py_compile tui_gateway/server.py
- cd ui-tui && npm run type-check && npm run build
- 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
- cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts
This commit is contained in:
Brooklyn Nicholson
2026-04-28 23:32:02 -05:00
parent 9e398e1809
commit b66cbb7b4c
6 changed files with 148 additions and 136 deletions

View File

@@ -5,8 +5,7 @@ import type { GatewayClient } from '../gatewayClient.js'
import type {
ConfigFullResponse,
ConfigMtimeResponse,
ReloadMcpResponse,
VoiceToggleResponse
ReloadMcpResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
@@ -105,7 +104,11 @@ export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: U
return
}
quietRpc<VoiceToggleResponse>(gw, 'voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled))
// Keep startup cheap: voice.toggle status probes optional audio/STT deps and
// can run long enough to delay prompt.submit on the single stdio RPC pipe.
// Environment flags are enough to initialize the UI bit; the heavier status
// check still runs when the user opens /voice.
setVoiceEnabled(process.env.HERMES_VOICE === '1')
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
mtimeRef.current = Number(r?.mtime ?? 0)
})

View File

@@ -126,6 +126,13 @@ export function useSubmission(opts: UseSubmissionOptions) {
return sys('session not ready yet')
}
// Plain prompts are the common path and should not pay an extra RPC
// before prompt.submit. File-drop detection can still run for inputs
// that contain an absolute/tilde path or file:// URI.
if (!looksLikeSlashCommand(text) && !/(?:^|\s)(?:file:\/\/|~\/|\/)[^\s]+/.test(text)) {
return startSubmit(text, expand(text), showUserMessage)
}
gw.request<InputDetectDropResponse>('input.detect_drop', { session_id: sid, text })
.then(r => {
if (!r?.matched) {

View File

@@ -68,7 +68,7 @@ const TranscriptPane = memo(function TranscriptPane({
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
{row.msg.info?.version && <SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />}
{row.msg.info && <SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />}
</Box>
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />

View File

@@ -64,9 +64,11 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
}
const section = (title: string, data: Record<string, string[]>, 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
const skeleton = info.lazy && entries.length === 0
return (
<Box flexDirection="column" marginTop={1}>
@@ -74,12 +76,19 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
Available {title}
</Text>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{skeleton
? skeletonRows.map(k => (
<Text dimColor key={k} wrap="truncate">
<Text color={t.color.muted}>{k}: </Text>
<Text color={t.color.text}></Text>
</Text>
))
: shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && (
<Text color={t.color.muted}>

View File

@@ -143,11 +143,12 @@ export interface McpServerStatus {
export interface SessionInfo {
cwd?: string
fast?: boolean
lazy?: boolean
mcp_servers?: McpServerStatus[]
model: string
reasoning_effort?: string
service_tier?: string
release_date?: string
service_tier?: string
skills: Record<string, string[]>
tools: Record<string, string[]>
update_behind?: number | null