Files
oh-my-pi/packages/coding-agent/src/tools/terminal-output.ts
T
can1357 4c1c5f40d8 feat(launch): rendered launch logs from daemon terminal byte streams
- Changed daemon log reads to return both sanitized display text and a raw `terminalText` slice, and included it on log RPC responses for PTY runs when grep was not used.
- Extended the logs result contract and launch tool rendering to consume `terminalText`, reconstruct terminal output, and display it in framed, preview-capped sections.
- Kept terminal row layout stable by writing space characters for empty cells when reading rows, preserving spacing during output reconstruction.
2026-07-13 19:21:45 +02:00

142 lines
4.3 KiB
TypeScript

import { sanitizeText } from "@oh-my-pi/pi-utils";
import type { Terminal as XtermTerminal } from "@xterm/headless";
const RESET = "\x1b[0m";
const SGR = /\x1b\[([0-9;]*)m/g;
interface TerminalCell {
getChars(): string;
getWidth(): number;
getFgColor(): number;
getBgColor(): number;
isBold(): number;
isDim(): number;
isItalic(): number;
isUnderline(): number;
isInverse(): number;
isStrikethrough(): number;
isOverline(): number;
isFgRGB(): boolean;
isBgRGB(): boolean;
isFgPalette(): boolean;
isBgPalette(): boolean;
}
function addColor(codes: number[], cell: TerminalCell, foreground: boolean): void {
const rgb = foreground ? cell.isFgRGB() : cell.isBgRGB();
const palette = foreground ? cell.isFgPalette() : cell.isBgPalette();
if (!rgb && !palette) return;
const color = foreground ? cell.getFgColor() : cell.getBgColor();
codes.push(foreground ? 38 : 48);
if (rgb) {
codes.push(2, (color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff);
} else {
codes.push(5, color);
}
}
function cellStyle(cell: TerminalCell): string {
const codes: number[] = [];
if (cell.isBold() !== 0) codes.push(1);
if (cell.isDim() !== 0) codes.push(2);
if (cell.isItalic() !== 0) codes.push(3);
if (cell.isUnderline() !== 0) codes.push(4);
if (cell.isInverse() !== 0) codes.push(7);
if (cell.isStrikethrough() !== 0) codes.push(9);
if (cell.isOverline() !== 0) codes.push(53);
addColor(codes, cell, true);
addColor(codes, cell, false);
return codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
}
function isSafeStyle(codes: readonly number[]): boolean {
let index = 0;
while (index < codes.length) {
const code = codes[index++];
if (code === 1 || code === 2 || code === 3 || code === 4 || code === 7 || code === 9 || code === 53) continue;
if (code !== 38 && code !== 48) return false;
const mode = codes[index++];
if (mode === 5) {
const color = codes[index++];
if (color === undefined || color < 0 || color > 255) return false;
continue;
}
if (mode !== 2) return false;
for (let channel = 0; channel < 3; channel++) {
const color = codes[index++];
if (color === undefined || color < 0 || color > 255) return false;
}
}
return true;
}
/** Applies the active tool-output color while preserving safe styles from a virtual terminal row. */
export function styleTerminalRow(row: string, baseForeground: string): string {
let output = baseForeground;
let offset = 0;
let hasText = false;
for (const match of row.matchAll(SGR)) {
const index = match.index ?? 0;
const text = sanitizeText(row.slice(offset, index));
output += text;
hasText ||= text.length > 0;
const codes = match[1].split(";").map(Number);
if (match[1] === "0") output += `${RESET}${baseForeground}`;
else if (codes.length > 0 && codes.every(Number.isInteger) && isSafeStyle(codes)) output += match[0];
offset = index + match[0].length;
}
const text = sanitizeText(row.slice(offset));
output += text;
hasText ||= text.length > 0;
return hasText ? `${output}${RESET}` : "";
}
/** Reads terminal screen rows as sanitized text plus only the styles the TUI may replay. */
export function readTerminalRows(terminal: XtermTerminal, startRow: number, rowCount: number): string[] {
const buffer = terminal.buffer.active;
const reusableCell = buffer.getNullCell();
const rows: string[] = [];
const endRow = Math.min(buffer.length, Math.max(0, startRow) + Math.max(0, rowCount));
for (let row = Math.max(0, startRow); row < endRow; row++) {
const line = buffer.getLine(row);
if (!line) {
rows.push("");
continue;
}
const cells: Array<{ chars: string; style: string }> = [];
let lastContent = -1;
for (let column = 0; column < line.length; ) {
const cell = line.getCell(column, reusableCell);
if (!cell) break;
const chars = cell.getChars();
const width = Math.max(1, cell.getWidth());
cells.push({ chars: chars || " ", style: cellStyle(cell) });
if (chars && chars !== " ") lastContent = cells.length - 1;
column += width;
}
if (lastContent < 0) {
rows.push("");
continue;
}
let rendered = "";
let previousStyle: string | undefined;
for (let index = 0; index <= lastContent; index++) {
const cell = cells[index]!;
if (cell.style !== previousStyle) {
rendered += `${RESET}${cell.style}`;
previousStyle = cell.style;
}
rendered += cell.chars;
}
rows.push(rendered);
}
return rows;
}