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.

438 lines
16 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 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';
const pctMatch = stripped.match(/(\d+)%\s+until\s+auto-compact/i);
const stats = pctMatch ? `${pctMatch[1]}% context` : null;
return { mode, branch: null, model: null, stats, ctxPct: null, 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;
let mode = null;
const firstPart = parts[0];
if (/\u23F8/.test(firstPart)) {
mode = 'plan';
} else if (/\u23F5/.test(firstPart)) {
mode = 'bypass';
} else {
mode = 'ask';
}
// 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;
}
}
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 (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);
}
_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 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;