diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a7aeb1b7..d46dffc9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -38,6 +38,7 @@ import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/componen import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; @@ -3895,19 +3896,18 @@ function App() { state={toToolState(item.status)} /> - {item.streamingOutput && item.status === 'running' ? ( -
-

- Live Output -

- - {item.streamingOutput} - -
- ) : output !== null ? ( - - ) : null} + + + + ) : ( + <> + + {output !== null ? ( + + ) : null} + + )}
) diff --git a/apps/x/apps/renderer/src/components/terminal-output.tsx b/apps/x/apps/renderer/src/components/terminal-output.tsx new file mode 100644 index 00000000..37346a88 --- /dev/null +++ b/apps/x/apps/renderer/src/components/terminal-output.tsx @@ -0,0 +1,28 @@ +import React, { useMemo } from 'react' +import { processTerminalOutput, spanStyleToCSS } from '../lib/terminal-output' + +/** + * Renders raw terminal output with ANSI color support, carriage return handling, + * and other terminal control sequence processing — similar to how iTerm renders output. + */ +export function TerminalOutput({ raw }: { raw: string }) { + const lines = useMemo(() => processTerminalOutput(raw), [raw]) + + return ( + <> + {lines.map((line, lineIdx) => ( + + {lineIdx > 0 && '\n'} + {line.spans.map((span, spanIdx) => { + const css = spanStyleToCSS(span.style) + return css ? ( + {span.text} + ) : ( + {span.text} + ) + })} + + ))} + + ) +} diff --git a/apps/x/apps/renderer/src/lib/terminal-output.ts b/apps/x/apps/renderer/src/lib/terminal-output.ts new file mode 100644 index 00000000..6d991565 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/terminal-output.ts @@ -0,0 +1,337 @@ +/** + * Terminal output processor that handles ANSI escape sequences, carriage returns, + * and other terminal control characters to produce styled, terminal-like output. + */ + +export interface StyledSpan { + text: string + style: SpanStyle +} + +export interface SpanStyle { + bold?: boolean + dim?: boolean + italic?: boolean + underline?: boolean + strikethrough?: boolean + fg?: string + bg?: string +} + +export interface TerminalLine { + spans: StyledSpan[] +} + +const ANSI_COLORS_16: Record = { + 30: '#4e4e4e', 31: '#e06c75', 32: '#98c379', 33: '#e5c07b', + 34: '#61afef', 35: '#c678dd', 36: '#56b6c2', 37: '#dcdfe4', + 90: '#5c6370', 91: '#e06c75', 92: '#98c379', 93: '#e5c07b', + 94: '#61afef', 95: '#c678dd', 96: '#56b6c2', 97: '#ffffff', +} + +const ANSI_BG_COLORS_16: Record = { + 40: '#4e4e4e', 41: '#e06c75', 42: '#98c379', 43: '#e5c07b', + 44: '#61afef', 45: '#c678dd', 46: '#56b6c2', 47: '#dcdfe4', + 100: '#5c6370', 101: '#e06c75', 102: '#98c379', 103: '#e5c07b', + 104: '#61afef', 105: '#c678dd', 106: '#56b6c2', 107: '#ffffff', +} + +// 256-color palette (first 16 match the standard colors above) +function color256(n: number): string { + if (n < 8) return ANSI_COLORS_16[30 + n] ?? '#dcdfe4' + if (n < 16) return ANSI_COLORS_16[90 + (n - 8)] ?? '#dcdfe4' + if (n < 232) { + // 216 color cube: 16-231 + const idx = n - 16 + const r = Math.floor(idx / 36) + const g = Math.floor((idx % 36) / 6) + const b = idx % 6 + const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` + } + // Grayscale: 232-255 + const level = 8 + (n - 232) * 10 + const hex = level.toString(16).padStart(2, '0') + return `#${hex}${hex}${hex}` +} + +function parseSGR(params: number[], style: SpanStyle): SpanStyle { + const s = { ...style } + let i = 0 + while (i < params.length) { + const p = params[i] + if (p === 0) { + // Reset all + delete s.bold; delete s.dim; delete s.italic + delete s.underline; delete s.strikethrough + delete s.fg; delete s.bg + } else if (p === 1) s.bold = true + else if (p === 2) s.dim = true + else if (p === 3) s.italic = true + else if (p === 4) s.underline = true + else if (p === 9) s.strikethrough = true + else if (p === 22) { delete s.bold; delete s.dim } + else if (p === 23) delete s.italic + else if (p === 24) delete s.underline + else if (p === 29) delete s.strikethrough + else if (p >= 30 && p <= 37) s.fg = ANSI_COLORS_16[p] + else if (p === 38) { + // Extended foreground + if (params[i + 1] === 5 && params[i + 2] !== undefined) { + s.fg = color256(params[i + 2]); i += 2 + } else if (params[i + 1] === 2 && params[i + 4] !== undefined) { + const r = params[i + 2], g = params[i + 3], b = params[i + 4] + s.fg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` + i += 4 + } + } else if (p === 39) delete s.fg + else if (p >= 40 && p <= 47) s.bg = ANSI_BG_COLORS_16[p] + else if (p === 48) { + // Extended background + if (params[i + 1] === 5 && params[i + 2] !== undefined) { + s.bg = color256(params[i + 2]); i += 2 + } else if (params[i + 1] === 2 && params[i + 4] !== undefined) { + const r = params[i + 2], g = params[i + 3], b = params[i + 4] + s.bg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` + i += 4 + } + } else if (p === 49) delete s.bg + else if (p >= 90 && p <= 97) s.fg = ANSI_COLORS_16[p] + else if (p >= 100 && p <= 107) s.bg = ANSI_BG_COLORS_16[p] + i++ + } + return s +} + +/** + * A simple terminal-screen buffer that tracks lines, cursor position, and styles. + * Processes raw terminal output and produces styled lines. + */ +export function processTerminalOutput(raw: string): TerminalLine[] { + // Screen buffer: each line is an array of { char, style } cells + type Cell = { char: string; style: SpanStyle } + const lines: Cell[][] = [[]] + let cursorRow = 0 + let cursorCol = 0 + let currentStyle: SpanStyle = {} + + function ensureRow(row: number) { + while (lines.length <= row) lines.push([]) + } + + function ensureCol(row: number, col: number) { + ensureRow(row) + const line = lines[row] + while (line.length <= col) line.push({ char: ' ', style: {} }) + } + + let i = 0 + while (i < raw.length) { + const ch = raw[i] + + // ESC sequence + if (ch === '\x1b' && i + 1 < raw.length) { + const next = raw[i + 1] + + // CSI sequence: ESC [ + if (next === '[') { + i += 2 + // Parse params + let paramStr = '' + while (i < raw.length && raw[i] >= '\x20' && raw[i] <= '\x3f') { + paramStr += raw[i]; i++ + } + const finalByte = i < raw.length ? raw[i] : '' + i++ + + const params = paramStr.length > 0 + ? paramStr.split(';').map(s => parseInt(s, 10) || 0) + : [0] + + switch (finalByte) { + case 'm': // SGR + currentStyle = parseSGR(params, currentStyle) + break + case 'A': // Cursor up + cursorRow = Math.max(0, cursorRow - (params[0] || 1)) + break + case 'B': // Cursor down + cursorRow += (params[0] || 1) + ensureRow(cursorRow) + break + case 'C': // Cursor forward + cursorCol += (params[0] || 1) + break + case 'D': // Cursor back + cursorCol = Math.max(0, cursorCol - (params[0] || 1)) + break + case 'G': // Cursor horizontal absolute + cursorCol = Math.max(0, (params[0] || 1) - 1) + break + case 'H': // Cursor position + case 'f': + cursorRow = Math.max(0, (params[0] || 1) - 1) + cursorCol = Math.max(0, (params[1] || 1) - 1) + ensureRow(cursorRow) + break + case 'J': { // Erase in display + const mode = params[0] || 0 + if (mode === 2 || mode === 3) { + // Clear entire screen + lines.length = 0 + lines.push([]) + cursorRow = 0 + cursorCol = 0 + } else if (mode === 0) { + // Clear from cursor to end + ensureRow(cursorRow) + lines[cursorRow].length = cursorCol + for (let r = cursorRow + 1; r < lines.length; r++) lines[r] = [] + } else if (mode === 1) { + // Clear from beginning to cursor + for (let r = 0; r < cursorRow; r++) lines[r] = [] + ensureCol(cursorRow, cursorCol) + for (let c = 0; c <= cursorCol; c++) lines[cursorRow][c] = { char: ' ', style: {} } + } + break + } + case 'K': { // Erase in line + const mode = params[0] || 0 + ensureRow(cursorRow) + const line = lines[cursorRow] + if (mode === 0) { + // Clear from cursor to end of line + line.length = cursorCol + } else if (mode === 1) { + // Clear from beginning to cursor + ensureCol(cursorRow, cursorCol) + for (let c = 0; c <= cursorCol; c++) line[c] = { char: ' ', style: {} } + } else if (mode === 2) { + // Clear entire line + lines[cursorRow] = [] + } + break + } + // Ignore other CSI sequences (scroll, mode set, etc.) + default: + break + } + continue + } + + // OSC sequence: ESC ] ... BEL/ST - skip entirely + if (next === ']') { + i += 2 + while (i < raw.length && raw[i] !== '\x07' && !(raw[i] === '\x1b' && raw[i + 1] === '\\')) { + i++ + } + if (i < raw.length && raw[i] === '\x07') i++ + else if (i < raw.length) i += 2 // skip ESC \ + continue + } + + // Other ESC sequences (ESC (, ESC ), etc.) - skip 2 chars + i += 2 + continue + } + + // Carriage return + if (ch === '\r') { + cursorCol = 0 + i++ + continue + } + + // Newline + if (ch === '\n') { + cursorRow++ + cursorCol = 0 + ensureRow(cursorRow) + i++ + continue + } + + // Backspace + if (ch === '\b') { + cursorCol = Math.max(0, cursorCol - 1) + i++ + continue + } + + // Tab + if (ch === '\t') { + const nextTabStop = (Math.floor(cursorCol / 8) + 1) * 8 + while (cursorCol < nextTabStop) { + ensureCol(cursorRow, cursorCol) + lines[cursorRow][cursorCol] = { char: ' ', style: { ...currentStyle } } + cursorCol++ + } + i++ + continue + } + + // Skip other control characters + if (ch.charCodeAt(0) < 32) { + i++ + continue + } + + // Regular character — write at cursor position + ensureCol(cursorRow, cursorCol) + lines[cursorRow][cursorCol] = { char: ch, style: { ...currentStyle } } + cursorCol++ + i++ + } + + // Convert cell buffer to styled spans + return lines.map(cells => { + const spans: StyledSpan[] = [] + if (cells.length === 0) return { spans: [{ text: '', style: {} }] } + + // Trim trailing spaces + let end = cells.length + while (end > 0 && cells[end - 1].char === ' ' && Object.keys(cells[end - 1].style).length === 0) { + end-- + } + + let currentSpan: StyledSpan | null = null + for (let c = 0; c < end; c++) { + const cell = cells[c] + const sameStyle = currentSpan && styleEquals(currentSpan.style, cell.style) + if (sameStyle && currentSpan) { + currentSpan.text += cell.char + } else { + if (currentSpan) spans.push(currentSpan) + currentSpan = { text: cell.char, style: { ...cell.style } } + } + } + if (currentSpan) spans.push(currentSpan) + if (spans.length === 0) spans.push({ text: '', style: {} }) + return { spans } + }) +} + +function styleEquals(a: SpanStyle, b: SpanStyle): boolean { + return a.bold === b.bold + && a.dim === b.dim + && a.italic === b.italic + && a.underline === b.underline + && a.strikethrough === b.strikethrough + && a.fg === b.fg + && a.bg === b.bg +} + +export function spanStyleToCSS(style: SpanStyle): React.CSSProperties | undefined { + if (Object.keys(style).length === 0) return undefined + const css: React.CSSProperties = {} + if (style.fg) css.color = style.fg + if (style.bg) css.backgroundColor = style.bg + if (style.bold) css.fontWeight = 'bold' + if (style.dim) css.opacity = 0.6 + if (style.italic) css.fontStyle = 'italic' + if (style.underline) css.textDecoration = 'underline' + if (style.strikethrough) { + css.textDecoration = css.textDecoration ? `${css.textDecoration} line-through` : 'line-through' + } + return Object.keys(css).length > 0 ? css : undefined +}