class ClaudeParser { 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: [], statusBar: null }; } const statusBarIdx = this._findStatusBarIdx(lines); const statusBar = statusBarIdx >= 0 ? this._tryParseStatusBar(lines[statusBarIdx]) : null; // Content lines: exclude the status bar line const contentLines = statusBarIdx >= 0 ? lines.filter((_, i) => i !== statusBarIdx) : lines; const blocks = this._parseBlocks(contentLines); // Mark the last prompt as active (current input area) for (let i = blocks.length - 1; i >= 0; i--) { if (blocks[i].type === 'prompt') { blocks[i].isActive = true; break; } } return { blocks, statusBar }; } _findStatusBarIdx(lines) { // Last non-empty line only for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; if (!line || line.trim() === '') continue; if (this._tryParseStatusBar(line)) return i; return -1; // first non-empty from bottom isn't a status bar } return -1; } _tryParseStatusBar(line) { const stripped = this._stripAnsi(line).trim(); // Exclude prompt and spinner/thinking lines — not a status bar if (/^[\u276F\u2700-\u27BF]/.test(stripped)) return null; // New format: "⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt 9% until auto-compact" // "⏸ plan mode (shift+tab to cycle)" if (/[\u23F8\u23F5]/.test(stripped) && /shift\+tab/i.test(stripped)) { let mode = 'code'; if (/\u23F8/.test(stripped)) mode = 'plan'; else if (/\u23F5/.test(stripped)) mode = 'bypass'; const pctMatch = stripped.match(/(\d+)%\s+until\s+auto-compact/i); const stats = pctMatch ? `${pctMatch[1]}% context` : null; return { mode, branch: null, stats, rawLine: line }; } // Old format: "⏵ bypass · main · 3.2k tokens" if (!stripped.includes('·')) return null; const parts = stripped.split('·').map(p => p.trim()); if (parts.length < 2) return null; let mode = null; const firstPart = parts[0]; if (/\u23F8/.test(firstPart)) { mode = 'plan'; } else if (/\u23F5/.test(firstPart)) { mode = 'bypass'; } else { mode = 'code'; } // Branch: first part with no spaces that looks like a branch name let branch = null; for (const part of parts) { if (part && !part.includes(' ') && /^[\w][\w./_-]*$/.test(part) && part.length < 50) { branch = part; break; } } const branchIdx = parts.findIndex(p => p === branch); const statsParts = parts.filter((_, i) => i !== 0 && i !== branchIdx); const stats = statsParts.length > 0 ? statsParts.join(' · ') : null; return { mode, branch, stats, rawLine: line }; } _parseBlocks(lines) { const blocks = []; // Pre-scan for selection ranges near the bottom const selectionRanges = this._detectSelectionRanges(lines); const selectionLineSet = new Set(); selectionRanges.forEach(r => { for (let i = r.start; i <= r.end; i++) selectionLineSet.add(i); }); let i = 0; while (i < lines.length) { // Selection block if (selectionLineSet.has(i)) { const range = selectionRanges.find(r => i === r.start); if (range) { const selLines = lines.slice(range.start, range.end + 1); blocks.push({ type: 'selection', lines: selLines, toolName: null, options: this._parseOptions(selLines), }); i = range.end + 1; continue; } } const line = lines[i]; const s = this._stripAnsi(line); // Empty if (/^\s*$/.test(s)) { blocks.push({ type: 'empty', lines: [line], toolName: null, options: null }); i++; continue; } // Table block: lines using box-drawing table characters (│, ┃, ┌, ├, etc.) // Must be detected BEFORE _isSeparatorLine because border rows (├──┤) look like separators if (this._isTableRowLine(line) || this._isTableBorderLine(line)) { const bl = [line]; i++; while (i < lines.length) { if (this._isTableRowLine(lines[i]) || this._isTableBorderLine(lines[i])) { bl.push(lines[i++]); } else { break; } } blocks.push({ type: 'table', lines: bl, toolName: null, options: null }); continue; } // Skip separator lines (─/━/═ box-drawing horizontal rules used as UI chrome) if (this._isSeparatorLine(line)) { i++; continue; } // ❯ Prompt (U+276F) if (/^\s*\u276F/.test(s)) { const bl = [line]; i++; while (i < lines.length && !this._isBlockStart(lines[i]) && !/^\s*$/.test(lines[i])) { // Stop collecting at separator lines or selection option lines if (this._isSeparatorLine(lines[i])) break; if (selectionLineSet.has(i)) break; bl.push(lines[i++]); } // Skip empty prompt blocks — these are the input cursor, not submitted messages // Only check the first line's text (not continuation lines) const firstLineText = bl[0].replace(/^\s*\u276F\s*/, '').trim(); if (!firstLineText) continue; blocks.push({ type: 'prompt', lines: bl, toolName: null, options: null }); continue; } // ⏺/●/⬤ Tool-use (U+23FA / U+25CF / U+2B24) if (/^\s*[\u23FA\u25CF\u2B24]/.test(s)) { const toolName = this._extractToolName(line); const bl = [line]; i++; // Collect indented continuation lines (use stripped version for indent check) while (i < lines.length) { const cs = this._stripAnsi(lines[i]); if (/^ {2,}/.test(cs) && !this._isBlockStart(lines[i])) { bl.push(lines[i++]); } else break; } blocks.push({ type: 'tool-use', lines: bl, toolName, options: null }); continue; } // ⎿ Response output marker (U+23BF) if (/^\s*\u23BF/.test(s)) { // Skip tip/hint lines entirely const lineStripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); if (/Tip:/i.test(lineStripped)) { i++; continue; } const bl = [line]; i++; while (i < lines.length && /^\s*\u23BF/.test(this._stripAnsi(lines[i]))) { bl.push(lines[i++]); } blocks.push({ type: 'response', lines: bl, toolName: null, options: null }); continue; } // ╭ Tool-result box (U+256D) if (/^\s*\u256D/.test(s) || s.trimStart().startsWith('╭')) { const bl = [line]; i++; while (i < lines.length) { const l = lines[i]; bl.push(l); i++; // Stop at closing corner ╰ (U+2570) or ╯ (U+256F) if (/[\u2570\u256F]/.test(l)) break; } blocks.push({ type: 'tool-result', lines: bl, toolName: null, options: null }); continue; } // Diff: +/- lines (only when clearly a diff context) 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, toolName: null, options: null }); 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++]); // include bridging empty line } else { break; } } // Trim trailing empty lines while (bl.length > 0 && /^\s*$/.test(bl[bl.length - 1])) bl.pop(); if (bl.length > 0) { blocks.push({ type: 'code', lines: bl, toolName: null, options: null }); } continue; } // Text: fallback — collect consecutive non-special lines const bl = [line]; i++; while (i < lines.length && !this._isBlockStart(lines[i]) && !/^\s*$/.test(lines[i]) && !selectionLineSet.has(i)) { bl.push(lines[i++]); } blocks.push({ type: 'text', lines: bl, toolName: null, options: null }); } return blocks; } _stripAnsi(line) { // Strip CSI (\x1b[...X), OSC (\x1b]...BEL/ST), and other single-char ESC sequences // eslint-disable-next-line no-control-regex return line.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, ''); } _isBlockStart(line) { if (!line || /^\s*$/.test(line)) return false; const s = this._stripAnsi(line); return ( /^\s*\u276F/.test(s) || // ❯ prompt /^\s*[\u23FA\u25CF\u2B24]/.test(s) || // ⏺●⬤ tool-use /^\s*\u23BF/.test(s) || // ⎿ response /^\s*\u256D/.test(s) || // ╭ tool-result s.trimStart().startsWith('╭') ); } _extractToolName(line) { const m = line.match(/[\u23FA\u25CF\u2B24]\s*(\w[\w.]*)/); return m ? m[1] : null; } _isTableRowLine(line) { const s = this._stripAnsi(line).trimStart(); return /^[│┃║]/.test(s); } _isTableBorderLine(line) { const s = this._stripAnsi(line).trimStart(); return /^[┌┐└┘├┤┬┴┼╔╗╚╝╠╣╦╩╬┏┓┗┛┣┫┳┻╋]/.test(s); } _isSeparatorLine(line) { const stripped = this._stripAnsi(line).trim(); if (stripped.length < 3) return false; // Must consist mostly of horizontal box-drawing characters const sepChars = (stripped.match(/[\u2500\u2501\u2550\u254C\u254D\u2574\u2576\u2578\u257A\u2015\u2014]/g) || []).length; if (sepChars / stripped.length > 0.4) return true; // Also catch lines that are a single character repeated (any char) — e.g. ─────── or ──── 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); } _detectSelectionRanges(lines) { // Skip trailing empty lines (terminal padding) to find real content end let end = lines.length - 1; while (end >= 0 && /^\s*$/.test(this._stripAnsi(lines[end]))) end--; const BOTTOM = 30; const start = Math.max(0, end - BOTTOM + 1); const OPTION_RE = /^\s*(\d+|[a-z])[.)]\s+\S/; // ❯ N. text — cursor-prefixed option (the currently focused selection item) const CURSOR_OPTION_RE = /^\s*\u276F\s*(\d+|[a-z])[.)]\s+\S/; // Markdown formatting signals response text, not a real selection prompt const HAS_MARKDOWN = /\*\*|`[^\s`]/; const ranges = []; let rStart = -1; let count = 0; for (let i = start; i <= end; i++) { const stripped = this._stripAnsi(lines[i]); if ((OPTION_RE.test(stripped) || CURSOR_OPTION_RE.test(stripped)) && !HAS_MARKDOWN.test(stripped)) { if (rStart === -1) rStart = i; count++; } else { if (rStart !== -1 && count >= 2) { ranges.push({ start: rStart, end: i - 1 }); } rStart = -1; count = 0; } } if (rStart !== -1 && count >= 2) { ranges.push({ start: rStart, end }); } return ranges.filter(r => { // Allow only UI chrome after options: ❯ cursor, option-like hint lines ("4. Type here..."), // and empty lines. Reject if followed by real prose/tool output. const hasTrailingContent = lines.slice(r.end + 1).some(l => { const s = this._stripAnsi(l).trim(); if (!s) return false; // empty line — ignore if (/^\u276F/.test(s)) return false; // ❯ cursor — ignore if (OPTION_RE.test(s)) return false; // another option-like hint — ignore return true; }); if (hasTrailingContent) return false; // Option text should be reasonably short — real choices are concise. // Result descriptions are longer ("Updated 5 files in src/...", etc.) const optLines = lines.slice(r.start, r.end + 1); let totalLen = 0; for (const l of optLines) { const m = this._stripAnsi(l).match(/^\s*(?:\d+|[a-z])[.)]\s+(.+)/); if (m) totalLen += m[1].trim().length; } if (totalLen / optLines.length > 100) return false; return true; }); } _parseOptions(lines) { const opts = []; for (const line of lines) { const m = this._stripAnsi(line).match(/^\s*\u276F?\s*(\d+|[a-z])[.)]\s+(.+)/); if (m) opts.push({ index: m[1], label: m[2].trim(), key: m[1] }); } return opts; } } window.ClaudeParser = ClaudeParser;