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.

384 lines
13 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.

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;