mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-30 20:39:46 +02:00
better execute command formatting
This commit is contained in:
parent
f4f2f78c68
commit
1c30d5ff89
3 changed files with 377 additions and 12 deletions
|
|
@ -38,6 +38,7 @@ import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/componen
|
||||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||||
|
import { TerminalOutput } from '@/components/terminal-output';
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||||
import { Suggestions } from '@/components/ai-elements/suggestions';
|
import { Suggestions } from '@/components/ai-elements/suggestions';
|
||||||
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
||||||
|
|
@ -3895,19 +3896,18 @@ function App() {
|
||||||
state={toToolState(item.status)}
|
state={toToolState(item.status)}
|
||||||
/>
|
/>
|
||||||
<ToolContent>
|
<ToolContent>
|
||||||
<ToolInput input={input} />
|
|
||||||
{item.streamingOutput && item.status === 'running' ? (
|
{item.streamingOutput && item.status === 'running' ? (
|
||||||
<div className="space-y-2 p-4">
|
<AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
<TerminalOutput raw={item.streamingOutput} />
|
||||||
Live Output
|
</AutoScrollPre>
|
||||||
</h4>
|
) : (
|
||||||
<AutoScrollPre className="max-h-80 overflow-auto rounded-md border bg-zinc-950 p-4 font-mono text-xs text-green-400 whitespace-pre-wrap">
|
<>
|
||||||
{item.streamingOutput}
|
<ToolInput input={input} />
|
||||||
</AutoScrollPre>
|
{output !== null ? (
|
||||||
</div>
|
<ToolOutput output={output} errorText={errorText} />
|
||||||
) : output !== null ? (
|
) : null}
|
||||||
<ToolOutput output={output} errorText={errorText} />
|
</>
|
||||||
) : null}
|
)}
|
||||||
</ToolContent>
|
</ToolContent>
|
||||||
</Tool>
|
</Tool>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
28
apps/x/apps/renderer/src/components/terminal-output.tsx
Normal file
28
apps/x/apps/renderer/src/components/terminal-output.tsx
Normal file
|
|
@ -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) => (
|
||||||
|
<React.Fragment key={lineIdx}>
|
||||||
|
{lineIdx > 0 && '\n'}
|
||||||
|
{line.spans.map((span, spanIdx) => {
|
||||||
|
const css = spanStyleToCSS(span.style)
|
||||||
|
return css ? (
|
||||||
|
<span key={spanIdx} style={css}>{span.text}</span>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={spanIdx}>{span.text}</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
337
apps/x/apps/renderer/src/lib/terminal-output.ts
Normal file
337
apps/x/apps/renderer/src/lib/terminal-output.ts
Normal file
|
|
@ -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<number, string> = {
|
||||||
|
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<number, string> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue