aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
221 lines • 7.74 kB
JavaScript
/**
* ScreenReader — PTY screen state parser for Orchestrator-over-PTY
*
* Parses raw PTY byte streams into structured, LLM-readable screen state.
* Uses @xterm/headless as the VT100/ANSI state machine so all escape
* sequences (colour, cursor movement, clear-screen, etc.) are handled
* correctly without a DOM.
*
* @issue #754
* @see src/serve/pty-bridge.ts — PTY WebSocket bridge
*/
import { EventEmitter } from 'events';
import { createRequire } from 'module';
// @xterm/headless is an optional dependency. Load it once at module level
// using createRequire so we can import the CJS bundle from ESM.
const _require = createRequire(import.meta.url);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let XtermTerminal;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const xterm = _require('@xterm/headless');
XtermTerminal = xterm.Terminal;
}
catch {
// Will be checked at construction time
}
// ============================================================
// Prompt detection patterns
// ============================================================
/** Patterns that indicate the terminal is waiting for input. */
const PROMPT_PATTERNS = [
/[$#>%]\s*$/, // bash/zsh/sh: ends with $ # > %
/^\s*\?\s+/, // inquirer: starts with ?
/\[y\/n\]/i, // yes/no confirmation
/\[y\/N\]/, // yes/no (default No)
/\[Y\/n\]/, // yes/no (default Yes)
/\(yes\/no\)/i, // SSH-style yes/no
/\(y\/n\)/i, // generic y/n
];
// ============================================================
// ScreenReader
// ============================================================
export class ScreenReader {
terminal;
_rows;
_cols;
emitter;
disposed = false;
constructor(opts) {
if (!XtermTerminal) {
throw new Error('@xterm/headless is required for ScreenReader. Install it: npm install @xterm/headless');
}
this._rows = opts?.rows ?? 24;
this._cols = opts?.cols ?? 80;
this.emitter = new EventEmitter();
// Suppress Node.js MaxListenersExceededWarning for tests with many awaitChange calls
this.emitter.setMaxListeners(100);
this.terminal = new XtermTerminal({
rows: this._rows,
cols: this._cols,
allowProposedApi: true,
scrollback: 1000,
});
}
/** Feed raw PTY data into the parser */
write(data) {
if (this.disposed)
return;
this.terminal.write(data, () => {
// Fired after xterm has processed the write through its state machine
this.emitter.emit('change');
});
}
/** Get current parsed screen state */
getState() {
const buf = this.terminal.buffer.active;
const viewportRows = this.terminal.rows;
const viewportCols = this.terminal.cols;
// ---- visible text grid ----
const text = [];
for (let r = 0; r < viewportRows; r++) {
const line = buf.getLine(buf.viewportY + r);
if (!line) {
text.push(Array(viewportCols).fill(''));
continue;
}
const row = [];
for (let c = 0; c < viewportCols; c++) {
const cell = line.getCell(c);
row.push(cell?.getChars() ?? '');
}
text.push(row);
}
// ---- cursor ----
const cursor = {
row: buf.cursorY,
col: buf.cursorX,
};
// ---- scrollback ----
// baseY is the line index of the first viewport row in the full buffer.
// Lines 0..(baseY-1) are scrollback content.
const scrollback = [];
const scrollbackStart = Math.max(0, buf.baseY - 50); // keep last 50 scrollback lines
for (let r = scrollbackStart; r < buf.baseY; r++) {
const line = buf.getLine(r);
if (line) {
const lineStr = line.translateToString(true);
scrollback.push(lineStr);
}
}
// ---- summary ----
const summary = buildSummary(text);
// ---- prompt detection ----
const { prompt_detected, prompt_text } = detectPrompt(text, cursor);
return { text, cursor, scrollback, summary, prompt_detected, prompt_text };
}
/**
* Wait for the next screen change, with timeout.
* Resolves with updated ScreenState when write() causes a change,
* or with the current state if the timeout elapses first.
*/
awaitChange(opts) {
const timeoutMs = opts?.timeout ?? 5000;
return new Promise((resolve) => {
if (this.disposed) {
resolve(this.getState());
return;
}
let timer = null;
const handler = () => {
if (timer)
clearTimeout(timer);
resolve(this.getState());
};
this.emitter.once('change', handler);
timer = setTimeout(() => {
this.emitter.removeListener('change', handler);
resolve(this.getState());
}, timeoutMs);
});
}
/**
* Flush all pending writes through xterm's internal write queue.
* Enqueues an empty write and resolves when its callback fires, which
* guarantees all previously queued writes have been processed.
*/
flush() {
return new Promise((resolve) => {
if (this.disposed) {
resolve();
return;
}
this.terminal.write('', () => resolve());
});
}
/** Get human-readable summary of visible screen */
getSummary() {
return buildSummary(this.getState().text);
}
/** Clean up resources */
dispose() {
if (this.disposed)
return;
this.disposed = true;
this.emitter.removeAllListeners();
try {
this.terminal.dispose();
}
catch {
// ignore errors during cleanup
}
}
}
// ============================================================
// Helpers
// ============================================================
/**
* Build a human-readable summary from the visible text grid.
* Trailing rows that are entirely empty are stripped.
*/
function buildSummary(text) {
const lines = text.map((row) => row.join('').trimEnd());
// Remove trailing blank lines
let lastNonEmpty = lines.length - 1;
while (lastNonEmpty >= 0 && lines[lastNonEmpty].trim() === '') {
lastNonEmpty--;
}
return lines.slice(0, lastNonEmpty + 1).join('\n');
}
/**
* Heuristic prompt detection.
* Examines the last non-empty visible line and the line at cursor position.
*/
function detectPrompt(text, cursor) {
// Gather candidate lines: the cursor row and the last non-empty row
const candidates = [];
// Line where cursor currently sits
if (cursor.row >= 0 && cursor.row < text.length) {
const cursorLine = text[cursor.row].join('').trimEnd();
if (cursorLine)
candidates.push(cursorLine);
}
// Last non-empty visible line
for (let r = text.length - 1; r >= 0; r--) {
const line = text[r].join('').trimEnd();
if (line) {
if (!candidates.includes(line))
candidates.push(line);
break;
}
}
for (const line of candidates) {
for (const pattern of PROMPT_PATTERNS) {
if (pattern.test(line)) {
return { prompt_detected: true, prompt_text: line };
}
}
}
return { prompt_detected: false };
}
//# sourceMappingURL=screen-reader.js.map