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