From f0940c640215ba01f5d1652deda324a0558f23e8 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Fri, 20 Feb 2026 01:48:32 -0500 Subject: [PATCH 1/2] fix: surface Python errors during agentcore dev --- .../dev/__tests__/dev-server.test.ts | 101 ++++++++++++++++++ src/cli/operations/dev/dev-server.ts | 89 ++++++++++++++- src/cli/operations/dev/invoke.ts | 5 + src/cli/tui/hooks/useDevServer.ts | 56 ++++++++-- src/cli/tui/screens/dev/DevScreen.tsx | 26 ++++- 5 files changed, 264 insertions(+), 13 deletions(-) diff --git a/src/cli/operations/dev/__tests__/dev-server.test.ts b/src/cli/operations/dev/__tests__/dev-server.test.ts index b8338cf3..8f873849 100644 --- a/src/cli/operations/dev/__tests__/dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/dev-server.test.ts @@ -218,4 +218,105 @@ describe('DevServer', () => { expect(onExit).toHaveBeenCalledWith(0); }); }); + + describe('Python traceback detection', () => { + const TRACEBACK = [ + 'Traceback (most recent call last):', + ' File "/app/.venv/lib/python3.12/site-packages/uvicorn/server.py", line 86, in _serve', + ' config.load()', + ' File "/app/myagent/main.py", line 1, in ', + ' import nonexistent_package', + "ModuleNotFoundError: No module named 'nonexistent_package'", + ].join('\n'); + + it('emits only user-code frames and exception line for tracebacks', async () => { + await server.start(); + + mockChild.stderr.emit('data', Buffer.from(TRACEBACK)); + + const errorCalls = (onLog as ReturnType).mock.calls.filter((c: unknown[]) => c[0] === 'error'); + const errorMessages = errorCalls.map((c: unknown[]) => c[1]); + + // Should include user frame + code + exception, but NOT site-packages frame + expect(errorMessages).toContain(' File "/app/myagent/main.py", line 1, in '); + expect(errorMessages).toContain(' import nonexistent_package'); + expect(errorMessages).toContain("ModuleNotFoundError: No module named 'nonexistent_package'"); + // Should NOT include internal frames + expect(errorMessages).not.toContain( + ' File "/app/.venv/lib/python3.12/site-packages/uvicorn/server.py", line 86, in _serve' + ); + }); + + it('does not emit traceback lines as info', async () => { + await server.start(); + + mockChild.stderr.emit('data', Buffer.from(TRACEBACK)); + + const infoCalls = (onLog as ReturnType).mock.calls.filter((c: unknown[]) => c[0] === 'info'); + // None of the traceback lines should leak as info + for (const [, msg] of infoCalls) { + expect(msg).not.toContain('Traceback'); + expect(msg).not.toContain('nonexistent_package'); + } + }); + + it('resumes normal classification after traceback ends', async () => { + await server.start(); + + mockChild.stderr.emit('data', Buffer.from(TRACEBACK + '\nINFO: some normal log')); + + expect(onLog).toHaveBeenCalledWith('info', 'INFO: some normal log'); + }); + }); + + describe('stderr crash buffer', () => { + it('emits buffered stderr as errors on non-zero exit', async () => { + await server.start(); + + // Emit some non-traceback stderr lines + mockChild.stderr.emit('data', Buffer.from('some debug output')); + mockChild.stderr.emit('data', Buffer.from('another line')); + + mockChild.emit('exit', 1); + + const errorCalls = (onLog as ReturnType).mock.calls.filter((c: unknown[]) => c[0] === 'error'); + const errorMessages = errorCalls.map((c: unknown[]) => c[1]); + + expect(errorMessages).toContain('some debug output'); + expect(errorMessages).toContain('another line'); + expect(onExit).toHaveBeenCalledWith(1); + }); + + it('does not emit stderr buffer on clean exit (code 0)', async () => { + await server.start(); + + mockChild.stderr.emit('data', Buffer.from('some debug output')); + mockChild.emit('exit', 0); + + const errorCalls = (onLog as ReturnType).mock.calls.filter((c: unknown[]) => c[0] === 'error'); + expect(errorCalls).toHaveLength(0); + expect(onExit).toHaveBeenCalledWith(0); + }); + + it('clears stderr buffer after traceback to avoid duplication', async () => { + await server.start(); + + const traceback = [ + 'Traceback (most recent call last):', + ' File "/app/main.py", line 1, in ', + ' import bad', + "ModuleNotFoundError: No module named 'bad'", + ].join('\n'); + + mockChild.stderr.emit('data', Buffer.from(traceback)); + vi.mocked(onLog).mockClear(); + + // Now exit — stderr buffer should be empty since traceback cleared it + mockChild.emit('exit', 1); + + const errorCalls = (onLog as ReturnType).mock.calls.filter((c: unknown[]) => c[0] === 'error'); + expect(errorCalls).toHaveLength(0); + expect(onExit).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/src/cli/operations/dev/dev-server.ts b/src/cli/operations/dev/dev-server.ts index e55b6b89..64dee81c 100644 --- a/src/cli/operations/dev/dev-server.ts +++ b/src/cli/operations/dev/dev-server.ts @@ -26,8 +26,27 @@ export interface SpawnConfig { * Handles process spawning, output parsing, and lifecycle management. * Subclasses implement prepare() and getSpawnConfig() for mode-specific behavior. */ +const STDERR_BUFFER_SIZE = 20; + +/** Paths that indicate internal framework frames (not user code) */ +const INTERNAL_FRAME_PATTERNS = [ + '/site-packages/', + ' line.includes(p)); +} + export abstract class DevServer { protected child: ChildProcess | null = null; + private recentStderr: string[] = []; + private inTraceback = false; + private tracebackBuffer: string[] = []; constructor( protected readonly config: DevConfig, @@ -71,6 +90,41 @@ export abstract class DevServer { /** Returns the command, args, cwd, and environment for the child process. */ protected abstract getSpawnConfig(): SpawnConfig; + /** + * Emit a filtered Python traceback: only user code frames and the exception line. + * Internal frames (site-packages, frozen modules, asyncio, etc.) are stripped out. + */ + private emitFilteredTraceback(onLog: (level: LogLevel, message: string) => void): void { + const buf = this.tracebackBuffer; + if (buf.length === 0) return; + + // The last line is the exception (e.g., "ModuleNotFoundError: ...") + const exceptionLine = buf[buf.length - 1]!; + + // Collect user-code frames: a "File ..." line followed by its code line. + // Frames come in pairs: " File "path", line N, in func" + " code_line" + const userFrames: string[] = []; + for (let i = 0; i < buf.length - 1; i++) { + const frameLine = buf[i]!; + const trimmed = frameLine.trimStart(); + if (trimmed.startsWith('File ') && !isInternalFrame(frameLine)) { + userFrames.push(frameLine); + // Include the next line (source code) if it exists and is indented + const nextLine = buf[i + 1]; + if (nextLine && nextLine.startsWith(' ') && !nextLine.trimStart().startsWith('File ')) { + userFrames.push(nextLine); + } + } + } + + if (userFrames.length > 0) { + for (const frame of userFrames) { + onLog('error', frame); + } + } + onLog('error', exceptionLine); + } + /** Attach stdout/stderr/error/exit handlers to the child process. */ private attachHandlers(): void { const { onLog, onExit } = this.options.callbacks; @@ -88,6 +142,31 @@ export abstract class DevServer { if (!output) return; for (const line of output.split('\n')) { if (!line) continue; + // Buffer recent stderr for crash context + this.recentStderr.push(line); + if (this.recentStderr.length > STDERR_BUFFER_SIZE) { + this.recentStderr.shift(); + } + // Detect Python traceback blocks: buffer all lines, then emit a + // filtered version showing only user code frames + the exception. + if (line.startsWith('Traceback (most recent call last)')) { + this.inTraceback = true; + this.tracebackBuffer = []; + } + if (this.inTraceback) { + this.tracebackBuffer.push(line); + const isStackFrame = line.startsWith(' ') || line.startsWith('File '); + const isTracebackHeader = line.startsWith('Traceback '); + if (!isStackFrame && !isTracebackHeader) { + // Traceback ended — emit filtered summary and clear the + // stderr buffer so these lines aren't re-emitted on exit. + this.emitFilteredTraceback(onLog); + this.inTraceback = false; + this.tracebackBuffer = []; + this.recentStderr = []; + } + continue; + } const lower = line.toLowerCase(); if (lower.includes('warning')) onLog('warn', line); else if (lower.includes('error')) onLog('error', line); @@ -100,6 +179,14 @@ export abstract class DevServer { onExit(1); }); - this.child?.on('exit', code => onExit(code)); + this.child?.on('exit', code => { + if (code !== 0 && code !== null && this.recentStderr.length > 0) { + for (const line of this.recentStderr) { + onLog('error', line); + } + this.recentStderr = []; + } + onExit(code); + }); } } diff --git a/src/cli/operations/dev/invoke.ts b/src/cli/operations/dev/invoke.ts index 8026546f..ecd1eb91 100644 --- a/src/cli/operations/dev/invoke.ts +++ b/src/cli/operations/dev/invoke.ts @@ -100,6 +100,11 @@ export async function* invokeAgentStreaming( body: JSON.stringify({ prompt: msg }), }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Server returned ${res.status}`); + } + if (!res.body) { yield '(empty response)'; return; diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index 57b793c9..c6d1acd3 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -24,6 +24,8 @@ export interface LogEntry { export interface ConversationMessage { role: 'user' | 'assistant'; content: string; + isError?: boolean; + isHint?: boolean; } const MAX_LOG_ENTRIES = 50; @@ -45,6 +47,7 @@ export function useDevServer(options: { workingDir: string; port: number; agentN const serverRef = useRef(null); const loggerRef = useRef(null); + const logsRef = useRef([]); const onReadyRef = useRef(options.onReady); onReadyRef.current = options.onReady; // Track instance ID to ignore callbacks from stale server instances @@ -53,7 +56,11 @@ export function useDevServer(options: { workingDir: string; port: number; agentN const isRestartingRef = useRef(false); const addLog = (level: LogEntry['level'], message: string) => { - setLogs(prev => [...prev.slice(-MAX_LOG_ENTRIES), { level, message }]); + setLogs(prev => { + const next = [...prev.slice(-MAX_LOG_ENTRIES), { level, message }]; + logsRef.current = next; + return next; + }); // Also log to file (DevLogger filters to only important logs) loggerRef.current?.log(level, message); }; @@ -146,7 +153,12 @@ export function useDevServer(options: { workingDir: string; port: number; agentN } setStatus(code === 0 ? 'stopped' : 'error'); - addLog('system', `Server exited (code ${code})`); + addLog( + 'system', + code !== 0 && code !== null + ? `Server crashed (code ${code}) — check logs above for details` + : `Server exited (code ${code})` + ); }, }; @@ -202,17 +214,47 @@ export function useDevServer(options: { workingDir: string; port: number; agentN loggerRef.current?.log('system', `→ ${message}`); loggerRef.current?.log('response', responseContent); } catch (err) { - const errorMsg = `Failed: ${err instanceof Error ? err.message : 'Unknown error'}`; - addLog('error', errorMsg); - // Add error as assistant message - setConversation(prev => [...prev, { role: 'assistant', content: errorMsg }]); + const rawMsg = err instanceof Error ? err.message : 'Unknown error'; + const isServerOrConnectionError = + rawMsg.includes('fetch') || + rawMsg.includes('ECONNREFUSED') || + rawMsg.includes('Internal Server Error') || + rawMsg.includes('Server Error'); + + // If the server crashed or returned an error, show the Python error + // from stderr instead of the generic HTTP/connection error message. + let errorMsg: string; + if (isServerOrConnectionError) { + const recentErrors = logsRef.current + .filter(l => l.level === 'error') + .slice(-5) + .map(l => l.message); + if (recentErrors.length > 0) { + errorMsg = recentErrors.join('\n'); + } else { + errorMsg = `Server error: ${rawMsg}`; + } + } else { + errorMsg = `Failed: ${rawMsg}`; + } + + addLog('error', `Failed: ${rawMsg}`); + // Add error as assistant message, with a non-error hint to check logs + setConversation(prev => [ + ...prev, + { role: 'assistant', content: errorMsg, isError: true }, + { role: 'assistant', content: 'See logs for full stack trace.', isHint: true }, + ]); setStreamingResponse(null); } finally { setIsStreaming(false); } }; - const clearLogs = () => setLogs([]); + const clearLogs = () => { + setLogs([]); + logsRef.current = []; + }; const restart = () => { addLog('system', 'Restarting server...'); diff --git a/src/cli/tui/screens/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index 82f57e74..960590c7 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -18,6 +18,9 @@ interface DevScreenProps { /** * Render conversation as a single string for scrolling. */ +const ERROR_LINE_PREFIX = '\x00err\x00'; +const HINT_LINE_PREFIX = '\x00hint\x00'; + function formatConversation( conversation: ConversationMessage[], streamingResponse: string | null, @@ -28,6 +31,12 @@ function formatConversation( for (const msg of conversation) { if (msg.role === 'user') { lines.push(`> ${msg.content}`); + } else if (msg.isError) { + for (const errLine of msg.content.split('\n')) { + lines.push(`${ERROR_LINE_PREFIX}${errLine}`); + } + } else if (msg.isHint) { + lines.push(`${HINT_LINE_PREFIX}${msg.content}`); } else { lines.push(msg.content); } @@ -410,10 +419,10 @@ export function DevScreen(props: DevScreenProps) { )} )} - {status === 'error' && + {conversation.length === 0 && logs .filter(l => l.level === 'error') - .slice(-3) + .slice(-10) .map((l, i) => ( {l.message} @@ -440,11 +449,18 @@ export function DevScreen(props: DevScreenProps) { {(conversation.length > 0 || isStreaming) && ( {visibleLines.map((line, idx) => { - // Detect user messages (start with "> ") const isUserMessage = line.startsWith('> '); + const isErrorMessage = line.startsWith(ERROR_LINE_PREFIX); + const isHintMessage = line.startsWith(HINT_LINE_PREFIX); + const displayLine = isErrorMessage + ? line.slice(ERROR_LINE_PREFIX.length) + : isHintMessage + ? line.slice(HINT_LINE_PREFIX.length) + : line; + const color = isUserMessage ? 'blue' : isErrorMessage ? 'red' : isHintMessage ? 'cyan' : 'green'; return ( - - {line || ' '} + + {displayLine || ' '} ); })} From 74bb48954dfddd4037368e3ec04671953de30d75 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Fri, 20 Feb 2026 17:12:49 -0500 Subject: [PATCH 2/2] fix: address PR review comments --- src/cli/operations/dev/dev-server.ts | 3 ++ src/cli/operations/dev/index.ts | 2 +- src/cli/operations/dev/invoke.ts | 46 ++++++++++++++-- src/cli/tui/hooks/useDevServer.ts | 37 ++++++------- src/cli/tui/screens/dev/DevScreen.tsx | 76 ++++++++++++--------------- 5 files changed, 97 insertions(+), 67 deletions(-) diff --git a/src/cli/operations/dev/dev-server.ts b/src/cli/operations/dev/dev-server.ts index 64dee81c..a0d3c19d 100644 --- a/src/cli/operations/dev/dev-server.ts +++ b/src/cli/operations/dev/dev-server.ts @@ -26,6 +26,9 @@ export interface SpawnConfig { * Handles process spawning, output parsing, and lifecycle management. * Subclasses implement prepare() and getSpawnConfig() for mode-specific behavior. */ +/** Keep the last 20 stderr lines so we can surface them if the process crashes. + * 20 lines is enough to capture a typical Python traceback or error context + * without accumulating unbounded memory for long-running servers. */ const STDERR_BUFFER_SIZE = 20; /** Paths that indicate internal framework frames (not user code) */ diff --git a/src/cli/operations/dev/index.ts b/src/cli/operations/dev/index.ts index 85236271..4d9ee675 100644 --- a/src/cli/operations/dev/index.ts +++ b/src/cli/operations/dev/index.ts @@ -10,4 +10,4 @@ export { export { getDevConfig, getDevSupportedAgents, getAgentPort, loadProjectConfig, type DevConfig } from './config'; -export { invokeAgent, invokeAgentStreaming } from './invoke'; +export { ConnectionError, ServerError, invokeAgent, invokeAgentStreaming } from './invoke'; diff --git a/src/cli/operations/dev/invoke.ts b/src/cli/operations/dev/invoke.ts index ecd1eb91..8d45e7ea 100644 --- a/src/cli/operations/dev/invoke.ts +++ b/src/cli/operations/dev/invoke.ts @@ -1,3 +1,22 @@ +/** Error thrown when the dev server returns a non-OK HTTP response. */ +export class ServerError extends Error { + constructor( + public readonly statusCode: number, + body: string + ) { + super(body || `Server returned ${statusCode}`); + this.name = 'ServerError'; + } +} + +/** Error thrown when the connection to the dev server fails. */ +export class ConnectionError extends Error { + constructor(cause: Error) { + super(cause.message); + this.name = 'ConnectionError'; + } +} + /** Logger interface for SSE events and error logging */ export interface SSELogger { logSSEEvent(rawLine: string): void; @@ -102,7 +121,7 @@ export async function* invokeAgentStreaming( if (!res.ok) { const body = await res.text(); - throw new Error(body || `Server returned ${res.status}`); + throw new ServerError(res.status, body); } if (!res.body) { @@ -173,6 +192,12 @@ export async function* invokeAgentStreaming( return; } catch (err) { + // Re-throw ServerError directly — no retries for HTTP errors + if (err instanceof ServerError) { + logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`); + throw err; + } + lastError = err instanceof Error ? err : new Error(String(err)); const isConnectionError = lastError.message.includes('fetch') || lastError.message.includes('ECONNREFUSED'); @@ -193,8 +218,8 @@ export async function* invokeAgentStreaming( } // Log final failure after all retries exhausted with full details - const finalError = lastError ?? new Error('Failed to connect to dev server after retries'); - logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.stack ?? finalError.message}`); + const finalError = new ConnectionError(lastError ?? new Error('Failed to connect to dev server after retries')); + logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`); throw finalError; } @@ -228,6 +253,11 @@ export async function invokeAgent(portOrOptions: number | InvokeOptions, message body: JSON.stringify({ prompt: msg }), }); + if (!res.ok) { + const body = await res.text(); + throw new ServerError(res.status, body); + } + const text = await res.text(); if (!text) { return '(empty response)'; @@ -241,6 +271,12 @@ export async function invokeAgent(portOrOptions: number | InvokeOptions, message // Handle plain JSON response (non-streaming frameworks) return extractResult(text); } catch (err) { + // Re-throw ServerError directly — no retries for HTTP errors + if (err instanceof ServerError) { + logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`); + throw err; + } + lastError = err instanceof Error ? err : new Error(String(err)); const isConnectionError = lastError.message.includes('fetch') || lastError.message.includes('ECONNREFUSED'); @@ -261,7 +297,7 @@ export async function invokeAgent(portOrOptions: number | InvokeOptions, message } // Log final failure after all retries exhausted with full details - const finalError = lastError ?? new Error('Failed to connect to dev server after retries'); - logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.stack ?? finalError.message}`); + const finalError = new ConnectionError(lastError ?? new Error('Failed to connect to dev server after retries')); + logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`); throw finalError; } diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index c6d1acd3..9506cf9a 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -2,9 +2,11 @@ import { findConfigRoot, readEnvFile } from '../../../lib'; import type { AgentCoreProjectSpec } from '../../../schema'; import { DevLogger } from '../../logging/dev-logger'; import { + ConnectionError, type DevConfig, DevServer, type LogLevel, + ServerError, createDevServer, findAvailablePort, getDevConfig, @@ -215,36 +217,31 @@ export function useDevServer(options: { workingDir: string; port: number; agentN loggerRef.current?.log('response', responseContent); } catch (err) { const rawMsg = err instanceof Error ? err.message : 'Unknown error'; - const isServerOrConnectionError = - rawMsg.includes('fetch') || - rawMsg.includes('ECONNREFUSED') || - rawMsg.includes('Internal Server Error') || - rawMsg.includes('Server Error'); - - // If the server crashed or returned an error, show the Python error - // from stderr instead of the generic HTTP/connection error message. + let errorMsg: string; - if (isServerOrConnectionError) { + let showHint = false; + if (err instanceof ServerError) { + // HTTP error — use the response body directly (avoids stderr race condition) + errorMsg = err.message || `Server error (${err.statusCode})`; + showHint = true; + } else if (err instanceof ConnectionError) { + // Connection failed after retries — check stderr logs for crash context const recentErrors = logsRef.current .filter(l => l.level === 'error') .slice(-5) .map(l => l.message); - if (recentErrors.length > 0) { - errorMsg = recentErrors.join('\n'); - } else { - errorMsg = `Server error: ${rawMsg}`; - } + errorMsg = recentErrors.length > 0 ? recentErrors.join('\n') : `Connection failed: ${rawMsg}`; + showHint = recentErrors.length > 0; } else { errorMsg = `Failed: ${rawMsg}`; } addLog('error', `Failed: ${rawMsg}`); - // Add error as assistant message, with a non-error hint to check logs - setConversation(prev => [ - ...prev, - { role: 'assistant', content: errorMsg, isError: true }, - { role: 'assistant', content: 'See logs for full stack trace.', isHint: true }, - ]); + const messages: ConversationMessage[] = [{ role: 'assistant', content: errorMsg, isError: true }]; + if (showHint) { + messages.push({ role: 'assistant', content: 'See logs for full stack trace.', isHint: true }); + } + setConversation(prev => [...prev, ...messages]); setStreamingResponse(null); } finally { setIsStreaming(false); diff --git a/src/cli/tui/screens/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index 960590c7..619b0ca5 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -15,40 +15,43 @@ interface DevScreenProps { agentName?: string; } +interface ColoredLine { + text: string; + color: string; +} + /** - * Render conversation as a single string for scrolling. + * Render conversation as colored lines for scrolling. + * Each line carries its own color so that word-wrapping preserves it. */ -const ERROR_LINE_PREFIX = '\x00err\x00'; -const HINT_LINE_PREFIX = '\x00hint\x00'; - function formatConversation( conversation: ConversationMessage[], streamingResponse: string | null, isStreaming: boolean -): string { - const lines: string[] = []; +): ColoredLine[] { + const lines: ColoredLine[] = []; for (const msg of conversation) { if (msg.role === 'user') { - lines.push(`> ${msg.content}`); + lines.push({ text: `> ${msg.content}`, color: 'blue' }); } else if (msg.isError) { for (const errLine of msg.content.split('\n')) { - lines.push(`${ERROR_LINE_PREFIX}${errLine}`); + lines.push({ text: errLine, color: 'red' }); } } else if (msg.isHint) { - lines.push(`${HINT_LINE_PREFIX}${msg.content}`); + lines.push({ text: msg.content, color: 'cyan' }); } else { - lines.push(msg.content); + lines.push({ text: msg.content, color: 'green' }); } - lines.push(''); // blank line between messages + lines.push({ text: '', color: 'green' }); // blank line between messages } // Add streaming response if in progress if (isStreaming && streamingResponse) { - lines.push(streamingResponse); + lines.push({ text: streamingResponse, color: 'green' }); } - return lines.join('\n'); + return lines; } /** @@ -93,14 +96,16 @@ function wrapLine(line: string, maxWidth: number): string[] { } /** - * Wrap multi-line text to fit within maxWidth. + * Wrap colored lines to fit within maxWidth, preserving color on continuation lines. */ -function wrapText(text: string, maxWidth: number): string[] { - if (!text) return []; - const lines = text.split('\n'); - const wrapped: string[] = []; - for (const line of lines) { - wrapped.push(...wrapLine(line, maxWidth)); +function wrapColoredLines(lines: ColoredLine[], maxWidth: number): ColoredLine[] { + const wrapped: ColoredLine[] = []; + for (const { text, color } of lines) { + for (const subLine of text.split('\n')) { + for (const wrappedLine of wrapLine(subLine, maxWidth)) { + wrapped.push({ text: wrappedLine, color }); + } + } } return wrapped; } @@ -199,14 +204,14 @@ export function DevScreen(props: DevScreenProps) { const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight; const contentWidth = Math.max(40, terminalWidth - 4); - // Format conversation content - const conversationText = useMemo( + // Format conversation content into colored lines + const coloredLines = useMemo( () => formatConversation(conversation, streamingResponse, isStreaming), [conversation, streamingResponse, isStreaming] ); - // Wrap text for display - const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]); + // Wrap lines for display, preserving color on continuation lines + const lines = useMemo(() => wrapColoredLines(coloredLines, contentWidth), [coloredLines, contentWidth]); const totalLines = lines.length; const maxScroll = Math.max(0, totalLines - displayHeight); @@ -419,7 +424,7 @@ export function DevScreen(props: DevScreenProps) { )} )} - {conversation.length === 0 && + {(conversation.length === 0 || status === 'error') && logs .filter(l => l.level === 'error') .slice(-10) @@ -448,22 +453,11 @@ export function DevScreen(props: DevScreenProps) { {/* Conversation display - always visible when there's content */} {(conversation.length > 0 || isStreaming) && ( - {visibleLines.map((line, idx) => { - const isUserMessage = line.startsWith('> '); - const isErrorMessage = line.startsWith(ERROR_LINE_PREFIX); - const isHintMessage = line.startsWith(HINT_LINE_PREFIX); - const displayLine = isErrorMessage - ? line.slice(ERROR_LINE_PREFIX.length) - : isHintMessage - ? line.slice(HINT_LINE_PREFIX.length) - : line; - const color = isUserMessage ? 'blue' : isErrorMessage ? 'red' : isHintMessage ? 'cyan' : 'green'; - return ( - - {displayLine || ' '} - - ); - })} + {visibleLines.map((line, idx) => ( + + {line.text || ' '} + + ))} {/* Thinking indicator - shows while waiting for response to start */} {isStreaming && !streamingResponse && }