|
|
// Codex CLI terminal output parser
|
|
|
// Markers (confirmed from actual output):
|
|
|
// › U+203A — user prompt
|
|
|
// • U+2022 — working indicator / tool-use / assistant response
|
|
|
// ╭ U+256D — box-drawing box (splash screen)
|
|
|
// | pipe — markdown pipe table
|
|
|
|
|
|
class CodexParser {
|
|
|
constructor() {
|
|
|
this._lastLines = null;
|
|
|
this._lastDoc = null;
|
|
|
}
|
|
|
|
|
|
parse(lines) {
|
|
|
if (lines === this._lastLines) return this._lastDoc;
|
|
|
this._lastLines = lines;
|
|
|
this._lastDoc = this._doParse(lines);
|
|
|
return this._lastDoc;
|
|
|
}
|
|
|
|
|
|
_doParse(lines) {
|
|
|
if (!lines || lines.length === 0) return { blocks: [] };
|
|
|
const blocks = this._parseBlocks(lines);
|
|
|
return { blocks };
|
|
|
}
|
|
|
|
|
|
_parseBlocks(lines) {
|
|
|
const blocks = [];
|
|
|
let i = 0;
|
|
|
|
|
|
while (i < lines.length) {
|
|
|
const line = lines[i];
|
|
|
const s = this._stripAnsi(line);
|
|
|
|
|
|
// Empty
|
|
|
if (/^\s*$/.test(s)) {
|
|
|
blocks.push({ type: 'empty', lines: [line] });
|
|
|
i++;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// Separator: long run of ─ (U+2500) — skip
|
|
|
if (this._isSeparatorLine(line)) {
|
|
|
i++;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// Box-drawing box ╭...╰ (splash screen / info box)
|
|
|
if (/^\s*\u256D/.test(s) || s.trimStart().startsWith('╭')) {
|
|
|
const bl = [line]; i++;
|
|
|
while (i < lines.length) {
|
|
|
const l = lines[i];
|
|
|
bl.push(l); i++;
|
|
|
if (/[\u2570\u256F]/.test(this._stripAnsi(l))) break;
|
|
|
}
|
|
|
blocks.push({ type: 'tool-result', lines: bl });
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// Markdown pipe table: line starting with |
|
|
|
if (/^\s*\|/.test(s)) {
|
|
|
const bl = [line]; i++;
|
|
|
while (i < lines.length && /^\s*\|/.test(this._stripAnsi(lines[i]))) {
|
|
|
bl.push(lines[i++]);
|
|
|
}
|
|
|
blocks.push({ type: 'table', lines: bl });
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// › Prompt (U+203A user input)
|
|
|
if (/^\s*\u203A/.test(s)) {
|
|
|
const bl = [line]; i++;
|
|
|
while (i < lines.length) {
|
|
|
const ns = this._stripAnsi(lines[i]);
|
|
|
if (/^\s*$/.test(ns)) break;
|
|
|
if (this._isBlockStart(lines[i])) break;
|
|
|
bl.push(lines[i++]);
|
|
|
}
|
|
|
// Skip empty prompts
|
|
|
const firstText = s.replace(/^\s*\u203A\s*/, '').trim();
|
|
|
if (!firstText) { i = i; continue; }
|
|
|
blocks.push({ type: 'prompt', lines: bl });
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// • Working indicator (U+2022 + "Working" pattern)
|
|
|
if (/^\s*\u2022/.test(s)) {
|
|
|
const content = s.replace(/^\s*\u2022\s*/, '');
|
|
|
|
|
|
// Thinking/working spinner: "Working(Xs • esc to interrupt)"
|
|
|
if (/^Working[\s(]/.test(content)) {
|
|
|
const bl = [line]; i++;
|
|
|
while (i < lines.length) {
|
|
|
const ns = this._stripAnsi(lines[i]);
|
|
|
if (/^\s*\u2022\s*Working[\s(]/.test(ns) || /^\s*$/.test(ns)) {
|
|
|
if (/^\s*$/.test(ns)) break;
|
|
|
bl.push(lines[i++]);
|
|
|
} else break;
|
|
|
}
|
|
|
blocks.push({ type: 'thinking', lines: bl });
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// Tool-use completed: "• Explored" / "• Completed" etc. with └ sub-bullet
|
|
|
const isToolUse = /^(Explored|Completed|Applied|Created|Updated|Deleted|Read|Listed|Searched|Ran|Executed)\b/.test(content);
|
|
|
const nextLine = i + 1 < lines.length ? this._stripAnsi(lines[i + 1]) : '';
|
|
|
const hasSubBullet = /^\s+[└├]/.test(nextLine);
|
|
|
|
|
|
if (isToolUse || hasSubBullet) {
|
|
|
const bl = [line]; i++;
|
|
|
// Collect sub-bullets (indented └ ├ lines)
|
|
|
while (i < lines.length) {
|
|
|
const ns = this._stripAnsi(lines[i]);
|
|
|
if (/^\s+[└├─]/.test(ns)) {
|
|
|
bl.push(lines[i++]);
|
|
|
} else break;
|
|
|
}
|
|
|
blocks.push({ type: 'tool-use', lines: bl });
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// Response text: • plain prose
|
|
|
const bl = [line]; i++;
|
|
|
while (i < lines.length) {
|
|
|
const ns = this._stripAnsi(lines[i]);
|
|
|
if (/^\s*$/.test(ns)) break;
|
|
|
if (this._isBlockStart(lines[i])) break;
|
|
|
// Continuation: indented lines (list items, etc.)
|
|
|
if (/^ {2,}/.test(ns) || /^\s*[-*]/.test(ns)) {
|
|
|
bl.push(lines[i++]);
|
|
|
continue;
|
|
|
}
|
|
|
// Another • line → new block
|
|
|
if (/^\s*\u2022/.test(ns)) break;
|
|
|
bl.push(lines[i++]);
|
|
|
}
|
|
|
blocks.push({ type: 'response', lines: bl });
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// Diff: +/- lines
|
|
|
if (/^[+-](?![+-])/.test(s) || /^@@/.test(s)) {
|
|
|
const bl = [line]; i++;
|
|
|
while (i < lines.length) {
|
|
|
const cs = this._stripAnsi(lines[i]);
|
|
|
if (/^[+\- @]/.test(cs) && !this._isBlockStart(lines[i])) {
|
|
|
bl.push(lines[i++]);
|
|
|
} else break;
|
|
|
}
|
|
|
blocks.push({ type: 'diff', lines: bl });
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// Code: 4+ space indent with code-like content
|
|
|
if (/^ {4,}/.test(s) && this._isCodeLike(s)) {
|
|
|
const bl = [line]; i++;
|
|
|
while (i < lines.length) {
|
|
|
const cs = this._stripAnsi(lines[i]);
|
|
|
if (/^ {4,}/.test(cs)) {
|
|
|
bl.push(lines[i++]);
|
|
|
} else if (/^\s*$/.test(lines[i]) && i + 1 < lines.length && /^ {4,}/.test(this._stripAnsi(lines[i + 1]))) {
|
|
|
bl.push(lines[i++]);
|
|
|
} else {
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
while (bl.length > 0 && /^\s*$/.test(bl[bl.length - 1])) bl.pop();
|
|
|
if (bl.length > 0) blocks.push({ type: 'code', lines: bl });
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// Text: fallback
|
|
|
const bl = [line]; i++;
|
|
|
while (i < lines.length && !this._isBlockStart(lines[i]) && !/^\s*$/.test(this._stripAnsi(lines[i]))) {
|
|
|
bl.push(lines[i++]);
|
|
|
}
|
|
|
blocks.push({ type: 'text', lines: bl });
|
|
|
}
|
|
|
|
|
|
return blocks;
|
|
|
}
|
|
|
|
|
|
_isBlockStart(line) {
|
|
|
if (!line || /^\s*$/.test(line)) return false;
|
|
|
const s = this._stripAnsi(line);
|
|
|
return (
|
|
|
/^\s*\u203A/.test(s) || // › prompt
|
|
|
/^\s*\u2022/.test(s) || // • tool/response
|
|
|
/^\s*\u256D/.test(s) || // ╭ box
|
|
|
s.trimStart().startsWith('╭') ||
|
|
|
/^\s*\|/.test(s) || // | table
|
|
|
/^[+-](?![+-])/.test(s) || // diff
|
|
|
/^@@/.test(s)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
_stripAnsi(line) {
|
|
|
return line.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, '');
|
|
|
}
|
|
|
|
|
|
_isSeparatorLine(line) {
|
|
|
const stripped = this._stripAnsi(line).trim();
|
|
|
if (stripped.length < 3) return false;
|
|
|
const sepChars = (stripped.match(/[\u2500\u2501\u2550\u254C\u254D]/g) || []).length;
|
|
|
if (sepChars / stripped.length > 0.5) return true;
|
|
|
const uniqueChars = new Set(stripped.replace(/\s/g, '')).size;
|
|
|
return uniqueChars === 1 && stripped.length >= 3 && /[^\w\s]/.test(stripped);
|
|
|
}
|
|
|
|
|
|
_isCodeLike(line) {
|
|
|
return /[{}\[\]();=]/.test(line) ||
|
|
|
/\b(const|let|var|function|class|import|export|if|else|for|while|return|def|async)\b/.test(line) ||
|
|
|
/^\s+\w[\w.]*\s*[({]/.test(line);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
window.CodexParser = CodexParser;
|