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 statusBarIndices = this._findStatusBarLines(lines); const statusBar = this._mergeStatusBars(lines, statusBarIndices); // Content lines: exclude all status bar lines const statusSet = new Set(statusBarIndices); const contentLines = statusBarIndices.length > 0 ? lines.filter((_, i) => !statusSet.has(i)) : lines; const blocks = this._parseBlocks(contentLines); return { blocks, statusBar }; } _findStatusBarLines(lines) { // Collect all status bar line indices in the bottom 5 non-empty lines const indices = []; let checked = 0; for (let i = lines.length - 1; i >= 0 && checked < 5; i--) { const line = lines[i]; if (!line || !line.trim()) continue; checked++; if (this._tryParseStatusBar(line)) indices.push(i); } return indices; } _mergeStatusBars(lines, indices) { if (indices.length === 0) return null; const parsed = indices.map(i => this._tryParseStatusBar(lines[i])).filter(Boolean); const merged = { mode: null, branch: null, model: null, stats: null, ctxPct: null, rawLine: parsed[0].rawLine }; for (const p of parsed) { if (p.mode) merged.mode = p.mode; if (p.model) merged.model = p.model; if (p.stats) merged.stats = p.stats; if (p.ctxPct != null) merged.ctxPct = p.ctxPct; if (p.branch) merged.branch = p.branch; } return merged; } _tryParseStatusBar(line) { if (!line) return null; // Strip ANSI then strip remaining C0/C1 control characters for robust matching const stripped = this._stripAnsi(line).replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim(); if (!stripped) return null; // 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)" // "⏸ plan mode (shift+tab to cycle)" // "⏵ accept edits on (shift+tab to cycle)" if (/[\u23F8\u23F5]/.test(stripped) && /shift\+tab/i.test(stripped)) { let mode = 'ask'; if (/\u23F8/.test(stripped) || /plan\s+mode/i.test(stripped)) mode = 'plan'; else if (/bypass/i.test(stripped)) mode = 'bypass'; else if (/accept\s+edits/i.test(stripped)) mode = 'accept'; // A custom statusline often shares this row ("… Opus 4.8 ctx:96% ⏵⏵ bypass // … shift+tab …"), so pull ctx%/compact% and model from the same line instead // of dropping them (the dedicated ctx:/compact branches below never run when // the mode glyph short-circuits here). const ctxMatch = stripped.match(/ctx:(\d+)%/i); const compactMatch = stripped.match(/(\d+)%\s+until\s+auto-compact/i); const modelMatch = stripped.match(/(Opus|Sonnet|Haiku)(?:\s+(\d+(?:\.\d+)?))?/i); const ctxPct = ctxMatch ? parseInt(ctxMatch[1], 10) : (compactMatch ? parseInt(compactMatch[1], 10) : null); const stats = ctxMatch ? `${ctxMatch[1]}%` : (compactMatch ? `${compactMatch[1]}% context` : null); const model = modelMatch ? (modelMatch[2] ? `${modelMatch[1]} ${modelMatch[2]}` : modelMatch[1]) : null; return { mode, branch: null, model, stats, ctxPct, rawLine: line }; } // Combined line: "user@host ... 11% until auto-compact" (tmux status + Claude compact %) const compactMatch = stripped.match(/(\d+)%\s+until\s+auto-compact/i); if (compactMatch) { const ctxPct = parseInt(compactMatch[1], 10); const modelMatch = stripped.match(/(Opus|Sonnet|Haiku)/i); const model = modelMatch ? modelMatch[1] : null; return { mode: null, branch: null, model, stats: `${ctxPct}% context`, ctxPct, rawLine: line }; } // Custom statusline: "user@host repo branch Sonnet 4.6 ctx:60%" // ctx: is the definitive signal — model name is optional secondary info const ctxMatch = stripped.match(/ctx:(\d+)%/i); if (ctxMatch) { const ctxPct = parseInt(ctxMatch[1], 10); const modelMatch = stripped.match(/(Opus|Sonnet|Haiku)\s+(\d+(?:\.\d+)?)/i); const model = modelMatch ? `${modelMatch[1]} ${modelMatch[2]}` : null; return { mode: null, branch: null, model, stats: `${ctxPct}%`, ctxPct, 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; // Require a leading mode glyph (\u23F8/\u23F5) \u2014 otherwise any prose or question line // containing a "\u00B7" middot (e.g. an inline options prompt) gets mis-detected as a // status bar and both eaten from the content and leaked into the status pill. const firstPart = parts[0]; let mode = null; if (/\u23F8/.test(firstPart)) { mode = 'plan'; } else if (/\u23F5/.test(firstPart)) { mode = 'bypass'; } else { return null; } // 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, model: null, stats, ctxPct: null, 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; } } // A real markdown table has a border row with column tees (┌─┬─┐ / ├─┼─┤). // Decorative boxes (welcome banner, notices, the input frame) use only │ // side borders / rounded corners and would be mangled into garbage cells by // the table renderer — render those as plain monospace text instead. if (this._hasTableStructure(bl)) { blocks.push({ type: 'table', lines: bl, toolName: null, options: null }); } else { blocks.push({ type: 'text', 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 (input cursor). Strip ANSI first so color codes // before ❯ don't prevent the match. Also skip when content is only separator // chars — Claude Code renders ❯ ────── on the active input line. const firstLineText = this._stripAnsi(bl[0]).replace(/^\s*❯\s*/, '').trim(); if (!firstLineText || this._isSeparatorLine(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; } // Thinking: lines with only spinner/dingbat characters (✻✳✢✦ U+2700-U+27BF) if (/^\s*[\u2700-\u27BF]+\s*$/.test(s)) { const bl = [line]; i++; while (i < lines.length) { const ns = this._stripAnsi(lines[i]); if (/^\s*[\u2700-\u27BF]+\s*$/.test(ns) || /^\s*$/.test(ns)) { bl.push(lines[i++]); } else break; } blocks.push({ type: 'thinking', 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 ANSI escape sequences, then C0/C1 control chars (Shift-In \x0f etc.) // that tmux can inject before prompt characters, breaking block detection. // eslint-disable-next-line no-control-regex return line .replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, '') .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/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('╭') || this._isTableRowLine(line) || // │ table row this._isTableBorderLine(line) // ┌├┤┼ table border ); } _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); } _hasTableStructure(blockLines) { // True only when some line is a horizontal border that splits into columns — // i.e. a box-drawing border char AND a column tee (┬ ┼ ┴ + heavy/double // variants). Distinguishes a data table from a single-column decorative box. for (const l of blockLines) { const s = this._stripAnsi(l).trimStart(); if (/^[┌┐└┘├┤┬┴┼┏┓┗┛┣┫┳┻╋╔╗╚╝╠╣╦╩╬]/.test(s) && /[┬┼┴╦╬╩┳╋┻]/.test(s)) { return true; } } return false; } _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) { // Strong code signals only. The old rule matched any ()/[]/= which turned log // output and ordinary indented prose-with-parentheses into code blocks. Require // braces/semicolons, an operator, a code keyword, or an indented call instead. return /[{};]/.test(line) || /=>|::|==|!=|<=|>=|&&|\|\|/.test(line) || /\b(const|let|var|function|class|import|export|return|def|async|await)\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 s = this._stripAnsi(line); const focused = /^\s*\u276F/.test(s); const m = s.match(/^\s*\u276F?\s*(\d+|[a-z])[.)]\s+(.+)/); if (m) opts.push({ index: m[1], label: m[2].trim(), key: m[1], focused }); } return opts; } } window.ClaudeParser = ClaudeParser;