diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index c09bd4ee96..fbcc069dd5 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -293,6 +293,27 @@ describe('createGatewayEventHandler', () => { expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' }) }) + it('annotates gateway.start_timeout with stderr tail lines so users can diagnose without /logs', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + onEvent({ + payload: { + cwd: '/repo', + python: '/opt/venv/bin/python', + stderr_tail: + '[startup] timed out\nModuleNotFoundError: No module named openai\nFileNotFoundError: ~/.hermes/config.yaml' + }, + type: 'gateway.start_timeout' + } as any) + + const messages = getTurnState().activity.map(a => a.text) + + expect(messages.some(m => m.includes('gateway startup timed out'))).toBe(true) + expect(messages.some(m => m.includes('ModuleNotFoundError'))).toBe(true) + expect(messages.some(m => m.includes('FileNotFoundError'))).toBe(true) + }) + it('anchors inline_diff as its own segment where the edit happened', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 267bf8c166..d36faa336c 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -321,12 +321,30 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'gateway.start_timeout': { - const { cwd, python } = ev.payload ?? {} + const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {} const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : '' setStatus('gateway startup timeout') turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error') + // Surface the most useful stderr lines inline so users can tell + // "wrong python", "missing dep", and "config parse failure" + // apart without leaving the TUI. Filter blank rows BEFORE + // taking the last N so trailing empty lines in the buffer + // don't crowd out actual content; truncate to match the + // 120-char clip used for `gateway.stderr` activity entries. + const STDERR_LINE_CAP = 120 + const STDERR_LINES_MAX = 8 + const tailLines = (stderrTail ?? '') + .split('\n') + .map(l => l.trim()) + .filter(Boolean) + .slice(-STDERR_LINES_MAX) + + for (const line of tailLines) { + turnController.pushActivity(line.slice(0, STDERR_LINE_CAP), 'error') + } + return } diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 9bf681f8b2..838bf31fbc 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -117,8 +117,18 @@ export class GatewayClient extends EventEmitter { return } + // Append the most recent gateway stderr/log lines to the timeout + // event so users can tell apart "wrong python", "missing dep", + // and "config parse failure" from one glance instead of having + // to dig through `/logs`. Capped to keep the activity feed + // readable on slow boots. + const stderrTail = this.getLogTail(20) + this.pushLog(`[startup] timed out waiting for gateway.ready (python=${python}, cwd=${cwd})`) - this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } }) + this.publish({ + type: 'gateway.start_timeout', + payload: { cwd, python, stderr_tail: stderrTail } + }) }, STARTUP_TIMEOUT_MS) this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 605d51213f..5a7f8d8ad1 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -415,7 +415,7 @@ export type GatewayEvent = | { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } | { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' } | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } - | { payload?: { cwd?: string; python?: string }; session_id?: string; type: 'gateway.start_timeout' } + | { payload?: { cwd?: string; python?: string; stderr_tail?: string }; session_id?: string; type: 'gateway.start_timeout' } | { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' } | { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' } | { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }