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