You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

218 lines
6.9 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 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;