@agentdeck/bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-code.d.ts +38 -0
- package/dist/adapters/claude-code.d.ts.map +1 -0
- package/dist/adapters/claude-code.js +184 -0
- package/dist/adapters/claude-code.js.map +1 -0
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +18 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/openclaw.d.ts +70 -0
- package/dist/adapters/openclaw.d.ts.map +1 -0
- package/dist/adapters/openclaw.js +664 -0
- package/dist/adapters/openclaw.js.map +1 -0
- package/dist/auth.d.ts +9 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +64 -0
- package/dist/auth.js.map +1 -0
- package/dist/check-deps.d.ts +5 -0
- package/dist/check-deps.d.ts.map +1 -0
- package/dist/check-deps.js +47 -0
- package/dist/check-deps.js.map +1 -0
- package/dist/diag-analyzer.d.ts +29 -0
- package/dist/diag-analyzer.d.ts.map +1 -0
- package/dist/diag-analyzer.js +145 -0
- package/dist/diag-analyzer.js.map +1 -0
- package/dist/event-journal.d.ts +22 -0
- package/dist/event-journal.d.ts.map +1 -0
- package/dist/event-journal.js +117 -0
- package/dist/event-journal.js.map +1 -0
- package/dist/hook-server.d.ts +31 -0
- package/dist/hook-server.d.ts.map +1 -0
- package/dist/hook-server.js +260 -0
- package/dist/hook-server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +796 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +11 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +27 -0
- package/dist/logger.js.map +1 -0
- package/dist/mdns.d.ts +9 -0
- package/dist/mdns.d.ts.map +1 -0
- package/dist/mdns.js +44 -0
- package/dist/mdns.js.map +1 -0
- package/dist/model-catalog.d.ts +29 -0
- package/dist/model-catalog.d.ts.map +1 -0
- package/dist/model-catalog.js +91 -0
- package/dist/model-catalog.js.map +1 -0
- package/dist/output-parser.d.ts +65 -0
- package/dist/output-parser.d.ts.map +1 -0
- package/dist/output-parser.js +1089 -0
- package/dist/output-parser.js.map +1 -0
- package/dist/pty-manager.d.ts +14 -0
- package/dist/pty-manager.d.ts.map +1 -0
- package/dist/pty-manager.js +93 -0
- package/dist/pty-manager.js.map +1 -0
- package/dist/pty-ringbuffer.d.ts +23 -0
- package/dist/pty-ringbuffer.d.ts.map +1 -0
- package/dist/pty-ringbuffer.js +65 -0
- package/dist/pty-ringbuffer.js.map +1 -0
- package/dist/session-registry.d.ts +17 -0
- package/dist/session-registry.d.ts.map +1 -0
- package/dist/session-registry.js +107 -0
- package/dist/session-registry.js.map +1 -0
- package/dist/state-machine.d.ts +48 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +470 -0
- package/dist/state-machine.js.map +1 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-api.d.ts +16 -0
- package/dist/usage-api.d.ts.map +1 -0
- package/dist/usage-api.js +84 -0
- package/dist/usage-api.js.map +1 -0
- package/dist/usage-tracker.d.ts +24 -0
- package/dist/usage-tracker.d.ts.map +1 -0
- package/dist/usage-tracker.js +89 -0
- package/dist/usage-tracker.js.map +1 -0
- package/dist/voice.d.ts +27 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +412 -0
- package/dist/voice.js.map +1 -0
- package/dist/whisper-server-manager.d.ts +19 -0
- package/dist/whisper-server-manager.d.ts.map +1 -0
- package/dist/whisper-server-manager.js +171 -0
- package/dist/whisper-server-manager.js.map +1 -0
- package/dist/ws-server.d.ts +18 -0
- package/dist/ws-server.d.ts.map +1 -0
- package/dist/ws-server.js +86 -0
- package/dist/ws-server.js.map +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
import stripAnsi from 'strip-ansi';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { debug } from './logger.js';
|
|
4
|
+
// Spinner animation characters (Claude Code specific — confirmed from PTY debug output)
|
|
5
|
+
// Note: · (U+00B7) removed — appears in status text "1m 0s · ↓ 1.9k tokens"
|
|
6
|
+
// Note: braille chars (⠋⠙⠹…) are used by other CLIs (npm, etc.), NOT Claude Code
|
|
7
|
+
const SPINNER_CHARS = /[✢✳✶✻✽]/;
|
|
8
|
+
const YES_NO_ALWAYS = /Yes,\s*allow once|No,\s*deny|Always allow/i;
|
|
9
|
+
const PERMISSION_YN = /\(Y\)es.*\/\(N\)o|\(y\/n\)/i;
|
|
10
|
+
const DIFF_PROMPT = /\(V\)iew diff.*\(A\)pply.*\(D\)eny|\(a\)pply.*\(d\)eny.*\(v\)iew/i;
|
|
11
|
+
// ANSI stripping can remove spaces (e.g. "❯3.Haiku" instead of "❯ 3. Haiku")
|
|
12
|
+
const OPTION_NUMBERED = /^\s*❯?\s*\d{1,2}[.)]\s*.+/m;
|
|
13
|
+
const OPTION_BULLET = /^\s*[►▸●○]\s+.+/m;
|
|
14
|
+
// Claude Code uses ❯ as its prompt char. May have \u00A0 (nbsp) or spaces around it.
|
|
15
|
+
// v2.1.49+: autocomplete suggestions appear on the same line (e.g. "❯ Try "refactor..."")
|
|
16
|
+
// so we can't require end-of-line. Just check for ❯ at start of line.
|
|
17
|
+
const IDLE_PROMPT = /^[❯>][ \t\u00A0]/m;
|
|
18
|
+
// Status line: "✳Finagling… (1m 0s · ↓ 1.9k tokens)"
|
|
19
|
+
const STATUS_LINE = /(\d+m\s*\d+s)\s*·\s*↓\s*([\d.]+)k?\s*tokens/;
|
|
20
|
+
// Project dir from Claude startup banner: "~/github/ProjectName"
|
|
21
|
+
const PROJECT_DIR = /[~\/][\w.\-\/]+\/(\w[\w.\-]*)\s*$/m;
|
|
22
|
+
// Remote Control URL detection:
|
|
23
|
+
// 1. claude.ai/code URLs — always capture (from /remote-control or /rc command)
|
|
24
|
+
// 2. General remote/tunnel URLs — keyword + URL on same line
|
|
25
|
+
const REMOTE_CLAUDE_URL = /https?:\/\/(?:claude\.ai|console\.anthropic\.com)\/code\S*/;
|
|
26
|
+
const REMOTE_KEYWORD_URL = /(?:remote|tunnel|server|listening|session\s*url)\s*:?\s*(https?:\/\/\S+)/i;
|
|
27
|
+
// Tool action: "⏺ ToolName(description)" — capture args inside parens
|
|
28
|
+
const TOOL_ACTION = /⏺\s+(\w+)\(([^)]*)\)/;
|
|
29
|
+
// User prompt echo: "❯ some text" — text the user typed
|
|
30
|
+
// Require at least one word character to avoid matching box-drawing lines (─────)
|
|
31
|
+
const USER_PROMPT = /^❯[ \t]+(\S.*\w.*\S|\w.*)$/m;
|
|
32
|
+
// /usage command output patterns
|
|
33
|
+
const USAGE_PERCENT = /(\d+)%\s*used/;
|
|
34
|
+
const USAGE_COST = /\$([0-9.]+)\s*\/\s*\$([0-9.]+)\s*spent/;
|
|
35
|
+
const USAGE_RESET_TIME = /Resets?\s+(\d+[ap]m)\s*\(([^)]+)\)/;
|
|
36
|
+
const USAGE_RESET_DATE = /Resets?\s+((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d+)\s*\(([^)]+)\)/;
|
|
37
|
+
// Additional usage patterns for improved parsing
|
|
38
|
+
const USAGE_SESSION_PERCENT = /(\d+)%\s*(?:of\s+)?(?:(?:5|3)\s*hour|session)\s*(?:limit)?/i;
|
|
39
|
+
const USAGE_TIME_REMAINING = /(\d+)\s*(?:min(?:utes?)?|hr|hours?)\s*(?:remaining|left)/i;
|
|
40
|
+
// Mode switch detection — ANSI stripping can remove inter-word spaces
|
|
41
|
+
// e.g. "⏵⏵ accept edits on" → "⏵⏵accepteditson"
|
|
42
|
+
const MODE_PLAN = /⏸\s*plan\s*mode\s*on/i;
|
|
43
|
+
const MODE_ACCEPT = /⏵⏵?\s*accept\s*edits?\s*on/i;
|
|
44
|
+
const MODE_DEFAULT = /\?\s*for\s*shortcuts/;
|
|
45
|
+
// Model info line: "Sonnet 4.6 · Claude Max" or "Claude 4 Sonnet (id) · api.anthropic.com"
|
|
46
|
+
// ANSI stripping can remove inter-word spaces (e.g. "Opus4.6·ClaudeMax")
|
|
47
|
+
const MODEL_INFO = /((?:Opus|Sonnet|Haiku)\s*[\d.]+|Claude\s*[\d.]+\s*(?:Opus|Sonnet|Haiku))(?:\s*(?:\([^)]+\))?\s*[·•]\s*(.+))?/i;
|
|
48
|
+
const SPINNER_DEBOUNCE_MS = 2000;
|
|
49
|
+
const IDLE_DEBOUNCE_MS = 300;
|
|
50
|
+
const OPTION_DEBOUNCE_MS = 150;
|
|
51
|
+
const SUGGESTION_DEBOUNCE_MS = 500;
|
|
52
|
+
// Ghost text: ANSI segment extractor — captures one or more consecutive SGR sequences
|
|
53
|
+
// followed by visible text. Handles stacked escapes like \x1b[38;2;r;g;bm\x1b[3mtext
|
|
54
|
+
const ANSI_TEXT_RE = /((?:\x1b\[[\d;]+m)+)([^\x1b\n\r]+)/g;
|
|
55
|
+
/**
|
|
56
|
+
* Check if any SGR parameter string in a list indicates gray foreground.
|
|
57
|
+
* Each element is from a separate \x1b[params;m escape in a stacked sequence.
|
|
58
|
+
* Also handles combined SGR codes (e.g. "2;90" = dim + bright black).
|
|
59
|
+
*/
|
|
60
|
+
function hasGrayForeground(paramsList) {
|
|
61
|
+
for (const params of paramsList) {
|
|
62
|
+
const nums = params.split(';').map(Number);
|
|
63
|
+
for (let i = 0; i < nums.length; i++) {
|
|
64
|
+
// SGR 2 (dim/faint) — Claude Code uses this for ghost text suggestions
|
|
65
|
+
if (nums[i] === 2)
|
|
66
|
+
return true;
|
|
67
|
+
// SGR 90 (bright black)
|
|
68
|
+
if (nums[i] === 90)
|
|
69
|
+
return true;
|
|
70
|
+
// 256-color foreground: 38;5;N (grays 230-255)
|
|
71
|
+
if (nums[i] === 38 && nums[i + 1] === 5) {
|
|
72
|
+
const n = nums[i + 2];
|
|
73
|
+
if (n >= 230 && n <= 255)
|
|
74
|
+
return true;
|
|
75
|
+
i += 2;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// 24-bit foreground: 38;2;R;G;B (near-gray, mid-brightness)
|
|
79
|
+
if (nums[i] === 38 && nums[i + 1] === 2) {
|
|
80
|
+
const r = nums[i + 2], g = nums[i + 3], b = nums[i + 4];
|
|
81
|
+
if (r != null && g != null && b != null) {
|
|
82
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
83
|
+
if ((max - min) <= 30 && max >= 60 && max <= 210)
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
i += 4;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
/** Check if a gray ANSI text segment is Claude Code UI chrome (not a ghost text suggestion) */
|
|
94
|
+
function isUiChrome(text) {
|
|
95
|
+
const t = text.trim();
|
|
96
|
+
if (!t)
|
|
97
|
+
return true;
|
|
98
|
+
return (/^Tip:|Did you know/i.test(t) ||
|
|
99
|
+
/ctrl[\+\-]|shift[\+\-]|⌘|⌥|⌃/i.test(t) ||
|
|
100
|
+
/^\(\d+[mhs]/i.test(t) ||
|
|
101
|
+
/\(thought\s+for\s/i.test(t) ||
|
|
102
|
+
/(?:✻|⏻)\s*.+\d+[smh]/i.test(t) ||
|
|
103
|
+
/(thought|cooked|thinking)\s+for\s+\d/i.test(t) ||
|
|
104
|
+
/^[?]\s|for\s+shortcuts|esc\s+to|enter\s+to/i.test(t) ||
|
|
105
|
+
/to\s+(expand|cycle|confirm|exit|edit\s+in)/i.test(t) ||
|
|
106
|
+
/^[─━═┄┅┈┉│┃┌┐└┘├┤┬┴┼╌╍╎╏\-_=.\s]+$/.test(t));
|
|
107
|
+
}
|
|
108
|
+
export class OutputParser extends EventEmitter {
|
|
109
|
+
buffer = '';
|
|
110
|
+
spinnerActive = false;
|
|
111
|
+
spinnerTimer = null;
|
|
112
|
+
idleTimer = null;
|
|
113
|
+
optionTimer = null;
|
|
114
|
+
projectName = null;
|
|
115
|
+
modelName = null;
|
|
116
|
+
// Don't trigger spinner until we've seen the first idle prompt
|
|
117
|
+
// This prevents Claude's startup banner (which contains ✻) from falsely triggering PROCESSING
|
|
118
|
+
seenFirstIdle = false;
|
|
119
|
+
// Track pending mode switch: after Shift+Tab, wait for mode confirmation or idle
|
|
120
|
+
pendingModeSwitch = false;
|
|
121
|
+
modeSwitchTimer = null;
|
|
122
|
+
// Ghost text suggestion detection
|
|
123
|
+
suggestedPromptTimer = null;
|
|
124
|
+
lastSuggestedPrompt = null;
|
|
125
|
+
// Cursor-only redraw detection for navigable option lists
|
|
126
|
+
lastNavigableEmit = false;
|
|
127
|
+
lastCursorIndex = 0;
|
|
128
|
+
pendingAnsi = '';
|
|
129
|
+
// Cooldown after emitting permission/diff prompt — suppresses false idle
|
|
130
|
+
// from user prompt echo (❯ text) in the same PTY batch
|
|
131
|
+
interactiveCooldown = null;
|
|
132
|
+
remoteUrl = null;
|
|
133
|
+
feed(rawData) {
|
|
134
|
+
const data = this.pendingAnsi + rawData;
|
|
135
|
+
this.pendingAnsi = '';
|
|
136
|
+
// Check for incomplete ANSI escape sequence at end of chunk
|
|
137
|
+
const lastEsc = data.lastIndexOf('\x1b');
|
|
138
|
+
if (lastEsc !== -1 && lastEsc >= data.length - 20) {
|
|
139
|
+
const tail = data.slice(lastEsc);
|
|
140
|
+
// CSI sequence: \x1b[ ... <final byte 0x40-0x7e> — incomplete if no final byte yet
|
|
141
|
+
// OSC sequence: \x1b] ... (terminated by ST or BEL) — incomplete if no terminator
|
|
142
|
+
// Bare ESC: just \x1b with nothing after
|
|
143
|
+
if (/^\x1b\[[\d;:]*$/.test(tail) || /^\x1b$/.test(tail) || /^\x1b\](?:(?!\x1b\\|\x07).)*$/.test(tail)) {
|
|
144
|
+
this.pendingAnsi = tail;
|
|
145
|
+
const complete = data.slice(0, lastEsc);
|
|
146
|
+
if (complete.length === 0)
|
|
147
|
+
return;
|
|
148
|
+
return this.processFeed(complete);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.processFeed(data);
|
|
152
|
+
}
|
|
153
|
+
processFeed(rawData) {
|
|
154
|
+
// Replace cursor movement sequences before stripping ANSI,
|
|
155
|
+
// so word spacing is preserved (Claude Code TUI uses cursor movement instead of spaces/newlines)
|
|
156
|
+
const spaced = rawData
|
|
157
|
+
.replace(/\x1b\[\d*C/g, ' ') // cursor forward → space (existing)
|
|
158
|
+
.replace(/\x1b\[\d*(?:;\d*)?[Hf]/g, '\n') // CUP/HVP → newline
|
|
159
|
+
.replace(/\x1b\[\d*[ABEF]/g, '\n'); // CUU/CUD/CNL/CPL → newline
|
|
160
|
+
const clean = stripAnsi(spaced);
|
|
161
|
+
this.buffer += clean;
|
|
162
|
+
if (this.buffer.length > 8192) {
|
|
163
|
+
this.buffer = this.buffer.slice(-4096);
|
|
164
|
+
}
|
|
165
|
+
const preview = clean.replace(/[\n\r]/g, '\\n').replace(/[\x00-\x1f]/g, '?').slice(0, 100);
|
|
166
|
+
if (preview.trim().length > 0) {
|
|
167
|
+
debug('Parser', `feed(${clean.length}): "${preview}"`);
|
|
168
|
+
}
|
|
169
|
+
this.detectPatterns(clean);
|
|
170
|
+
// Remote URL detection on raw data: cursor-forward sequences ([1C]) break URLs
|
|
171
|
+
// when replaced with spaces, so we strip them entirely for URL extraction
|
|
172
|
+
this.parseRemoteUrl(rawData);
|
|
173
|
+
// Detect ghost text from raw ANSI data (must run after detectPatterns
|
|
174
|
+
// which sets seenFirstIdle on the first ❯ prompt)
|
|
175
|
+
this.detectGhostText(rawData);
|
|
176
|
+
}
|
|
177
|
+
/** Detect ghost text (dim/gray ANSI-styled autocomplete suggestions) from raw PTY data */
|
|
178
|
+
detectGhostText(rawData) {
|
|
179
|
+
if (!this.seenFirstIdle)
|
|
180
|
+
return;
|
|
181
|
+
// Ghost text only appears at the idle prompt — skip during processing
|
|
182
|
+
if (this.spinnerActive)
|
|
183
|
+
return;
|
|
184
|
+
// Strategy 1 (high confidence): "Try ..." visible in clean text on the prompt line.
|
|
185
|
+
// Claude Code renders ghost text as `❯ Try "command"` — detectable without ANSI parsing.
|
|
186
|
+
// This handles the most common case and has zero false positives.
|
|
187
|
+
// Replace cursor-forward (\x1b[NC) with spaces before stripping ANSI so word spacing is preserved
|
|
188
|
+
// (Claude Code TUI uses cursor movement instead of literal spaces for layout).
|
|
189
|
+
const clean = stripAnsi(rawData.replace(/\x1b\[\d*C/g, ' '));
|
|
190
|
+
const tryLineMatch = clean.match(/^[❯>][ \t\u00A0]+Try\s+["\u201C](.+)["\u201D]/m);
|
|
191
|
+
if (tryLineMatch) {
|
|
192
|
+
debug('Parser', `ghostText strategy1 HIT: "${tryLineMatch[1].trim()}"`);
|
|
193
|
+
this.scheduleSuggestion(tryLineMatch[1].trim());
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Strategy 2 (ANSI gray): gray segments on the line containing ❯.
|
|
197
|
+
// Split by newlines; PTY may use \r to rewrite current line — split on \n only.
|
|
198
|
+
let promptLineRaw = rawData.split('\n').find(line => /[❯>][ \t\u00A0]/.test(stripAnsi(line)));
|
|
199
|
+
// Strategy 3 (cross-chunk): ghost text may arrive in a separate PTY chunk from ❯.
|
|
200
|
+
// If no ❯-line in current chunk and no \n (same terminal line continuation),
|
|
201
|
+
// check if the buffer's last visible line starts with ❯.
|
|
202
|
+
// Skip if chunk contains ⎿ (output fence) — that's Claude's response, not ghost text.
|
|
203
|
+
if (!promptLineRaw && !rawData.includes('\n') && !clean.includes('⎿')) {
|
|
204
|
+
const rawLastLine = this.buffer.split('\n').pop() ?? '';
|
|
205
|
+
const visibleLastLine = rawLastLine.split('\r').pop() ?? '';
|
|
206
|
+
if (/^[❯>][ \t\u00A0]/.test(visibleLastLine)) {
|
|
207
|
+
promptLineRaw = rawData;
|
|
208
|
+
debug('Parser', 'ghostText strategy3: cross-chunk ❯-line continuation');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!promptLineRaw) {
|
|
212
|
+
const hasPromptChar = clean.includes('❯') || clean.includes('>');
|
|
213
|
+
if (hasPromptChar && clean.length < 200) {
|
|
214
|
+
const escaped = rawData.replace(/\x1b/g, '\\e').replace(/[\n\r]/g, '\\n').slice(0, 300);
|
|
215
|
+
debug('Parser', `ghostText: prompt char found but no ❯-line match. raw=${escaped}`);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Extract gray text segments, filtering out UI chrome (tips, shortcut hints, status)
|
|
220
|
+
ANSI_TEXT_RE.lastIndex = 0;
|
|
221
|
+
const segments = [];
|
|
222
|
+
for (const m of promptLineRaw.matchAll(ANSI_TEXT_RE)) {
|
|
223
|
+
const ansiBlock = m[1];
|
|
224
|
+
const text = m[2];
|
|
225
|
+
// Extract all SGR param strings from stacked ANSI escapes
|
|
226
|
+
const sgrParams = [...ansiBlock.matchAll(/\x1b\[([\d;]+)m/g)].map(pm => pm[1]);
|
|
227
|
+
if (hasGrayForeground(sgrParams)) {
|
|
228
|
+
const trimmed = text.trim();
|
|
229
|
+
if (trimmed && !isUiChrome(trimmed)) {
|
|
230
|
+
segments.push(text); // preserve original spacing for join
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (segments.length === 0) {
|
|
235
|
+
const escaped = promptLineRaw.replace(/\x1b/g, '\\e').replace(/[\n\r]/g, '\\n').slice(0, 300);
|
|
236
|
+
debug('Parser', `ghostText: ❯-line found but no usable gray segments. raw=${escaped}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
debug('Parser', `ghostText strategy2 HIT: segments=${segments.length} "${segments.join('').trim().slice(0, 60)}"`);
|
|
240
|
+
this.scheduleSuggestion(segments.join('').trim());
|
|
241
|
+
}
|
|
242
|
+
/** Validate and debounce a candidate suggestion text */
|
|
243
|
+
scheduleSuggestion(text) {
|
|
244
|
+
if (!text || text.length < 3 || text.length > 200)
|
|
245
|
+
return;
|
|
246
|
+
// Reject pure numbers or digit+operator fragments (e.g. diff line numbers "65", "96 +")
|
|
247
|
+
if (/^[\d\s+\-*/=<>]+$/.test(text))
|
|
248
|
+
return;
|
|
249
|
+
// Reject text entirely enclosed in parentheses — placeholders/status, not actionable prompts
|
|
250
|
+
// e.g. "(no content)", "(loading...)", "(empty)"
|
|
251
|
+
if (/^\([^)]+\)$/.test(text))
|
|
252
|
+
return;
|
|
253
|
+
// Must contain at least one word sequence of 2+ characters (not just symbols/spaces)
|
|
254
|
+
// \p{L} matches Unicode letters including CJK (Korean, Japanese, Chinese)
|
|
255
|
+
if (!/\w{2,}/u.test(text) && !/\p{L}{2,}/u.test(text))
|
|
256
|
+
return;
|
|
257
|
+
// Filter out UI chrome fragments (defense-in-depth, also filtered at segment level)
|
|
258
|
+
if (/^[?]$|^esc\b|^shift\b|^ctrl\b|^enter\b|for\s+shortcuts/i.test(text))
|
|
259
|
+
return;
|
|
260
|
+
if (/^Tip:|Did you know/i.test(text))
|
|
261
|
+
return;
|
|
262
|
+
if (/ctrl[\+\-]|shift[\+\-]/i.test(text))
|
|
263
|
+
return;
|
|
264
|
+
if (/^\(\d+[mhs]/i.test(text))
|
|
265
|
+
return;
|
|
266
|
+
if (/\(thought\s+for\s/i.test(text))
|
|
267
|
+
return;
|
|
268
|
+
if (/(?:✻|⏻)\s*.+\d+[smh]/i.test(text))
|
|
269
|
+
return;
|
|
270
|
+
if (/(thought|cooked|thinking)\s+for\s+\d/i.test(text))
|
|
271
|
+
return;
|
|
272
|
+
if (/to\s+(expand|cycle|confirm|exit|edit\s+in)/i.test(text))
|
|
273
|
+
return;
|
|
274
|
+
// Filter out interrupt/status messages (gray text after ⎿ output fence)
|
|
275
|
+
if (/^Interrupted\b/i.test(text))
|
|
276
|
+
return;
|
|
277
|
+
// Filter out box-drawing / decorative lines (─━═ etc.)
|
|
278
|
+
if (/^[─━═┄┅┈┉│┃┌┐└┘├┤┬┴┼╌╍╎╏\-_=.\s]+$/.test(text))
|
|
279
|
+
return;
|
|
280
|
+
// Filter out text starting with box-drawing characters (e.g. "├─ │Initializing…❯")
|
|
281
|
+
if (/^[├┤┬┴┼│─┌┐└┘⎿]/.test(text))
|
|
282
|
+
return;
|
|
283
|
+
// Filter out prompt characters at end (e.g. "(thinking)❯")
|
|
284
|
+
if (/[❯>]$/.test(text))
|
|
285
|
+
return;
|
|
286
|
+
// Filter out Claude TUI markers (⎿ output fence, ⏺ tool use, ⏸⏵ mode indicators)
|
|
287
|
+
if (/[⏺⏸⏵]\s/.test(text))
|
|
288
|
+
return;
|
|
289
|
+
// Filter out token count fragments (e.g. "6.3k tokens · thought for")
|
|
290
|
+
if (/\d+\.?\d*k?\s*tokens/i.test(text))
|
|
291
|
+
return;
|
|
292
|
+
// Filter out agent progress indicators
|
|
293
|
+
if (/Initializing|Running \d/i.test(text))
|
|
294
|
+
return;
|
|
295
|
+
// Filter out numbered list items (but allow "Try ..." suggestions)
|
|
296
|
+
if (/^\d+\.\s*\S/.test(text) && !/^Try\s/i.test(text))
|
|
297
|
+
return;
|
|
298
|
+
// Filter out file paths (e.g. "/Users/foo/project" from PTY screen redraws)
|
|
299
|
+
if (/^[~/]/.test(text) && /\//.test(text))
|
|
300
|
+
return;
|
|
301
|
+
// Skip if same as last suggestion
|
|
302
|
+
if (text === this.lastSuggestedPrompt)
|
|
303
|
+
return;
|
|
304
|
+
// Debounce: rapid PTY updates may send partial ghost text
|
|
305
|
+
if (this.suggestedPromptTimer)
|
|
306
|
+
clearTimeout(this.suggestedPromptTimer);
|
|
307
|
+
this.suggestedPromptTimer = setTimeout(() => {
|
|
308
|
+
this.suggestedPromptTimer = null;
|
|
309
|
+
this.lastSuggestedPrompt = text;
|
|
310
|
+
debug('Parser', `EMIT suggested_prompt: "${text.slice(0, 60)}"`);
|
|
311
|
+
this.emit('suggested_prompt', { text });
|
|
312
|
+
}, SUGGESTION_DEBOUNCE_MS);
|
|
313
|
+
}
|
|
314
|
+
/** Clear any pending or active suggestion */
|
|
315
|
+
clearSuggestion() {
|
|
316
|
+
if (this.suggestedPromptTimer) {
|
|
317
|
+
clearTimeout(this.suggestedPromptTimer);
|
|
318
|
+
this.suggestedPromptTimer = null;
|
|
319
|
+
}
|
|
320
|
+
if (this.lastSuggestedPrompt !== null) {
|
|
321
|
+
this.lastSuggestedPrompt = null;
|
|
322
|
+
this.emit('suggested_prompt', { text: null });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
detectPatterns(chunk) {
|
|
326
|
+
// --- Always extract metadata ---
|
|
327
|
+
this.parseStatusLine(chunk);
|
|
328
|
+
this.parseToolAction(chunk);
|
|
329
|
+
this.parseProjectName(chunk);
|
|
330
|
+
// Skip model parsing when chunk contains numbered options — option labels
|
|
331
|
+
// like "Opus 4.6" match MODEL_INFO and overwrite the real model name
|
|
332
|
+
if (!OPTION_NUMBERED.test(chunk)) {
|
|
333
|
+
this.parseModelInfo(chunk);
|
|
334
|
+
}
|
|
335
|
+
this.parseUserPrompt(chunk);
|
|
336
|
+
this.parseUsageInfo(chunk);
|
|
337
|
+
this.parseModeSwitchLine(chunk);
|
|
338
|
+
// --- Pre-scan: idle prompt & interactive content detection ---
|
|
339
|
+
const hasIdlePrompt = IDLE_PROMPT.test(chunk);
|
|
340
|
+
const hasInteractive = DIFF_PROMPT.test(chunk) || YES_NO_ALWAYS.test(chunk) ||
|
|
341
|
+
PERMISSION_YN.test(chunk) ||
|
|
342
|
+
OPTION_NUMBERED.test(chunk) || OPTION_BULLET.test(chunk);
|
|
343
|
+
// --- Spinner + prompt handling ---
|
|
344
|
+
if (this.spinnerActive) {
|
|
345
|
+
if (hasInteractive) {
|
|
346
|
+
// Interactive prompt arrived during spinner (e.g. "❯ 1. Yes")
|
|
347
|
+
// Stop spinner but DON'T emit idle — fall through to prompt detection
|
|
348
|
+
debug('Parser', 'interactive prompt during spinner — stopping spinner');
|
|
349
|
+
this.resetSpinnerTimer();
|
|
350
|
+
this.spinnerActive = false;
|
|
351
|
+
this.seenFirstIdle = true;
|
|
352
|
+
this.emit('spinner_stop');
|
|
353
|
+
// Fall through to prompt detection below
|
|
354
|
+
}
|
|
355
|
+
else if (hasIdlePrompt) {
|
|
356
|
+
// Idle prompt during spinner — but ignore if chunk is large (screen redraw).
|
|
357
|
+
// Real idle prompts come in small chunks; screen redraws include ❯ in 200+ char chunks.
|
|
358
|
+
const nonWs = chunk.replace(/\s/g, '').length;
|
|
359
|
+
if (nonWs < 80) {
|
|
360
|
+
debug('Parser', 'idle prompt during spinner — stopping spinner, emitting idle');
|
|
361
|
+
this.resetSpinnerTimer();
|
|
362
|
+
this.spinnerActive = false;
|
|
363
|
+
this.seenFirstIdle = true;
|
|
364
|
+
this.emit('spinner_stop');
|
|
365
|
+
this.resetIdleTimer();
|
|
366
|
+
this.idleTimer = setTimeout(() => {
|
|
367
|
+
debug('Parser', 'EMIT idle');
|
|
368
|
+
this.emit('idle');
|
|
369
|
+
}, IDLE_DEBOUNCE_MS);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// Large chunk with ❯ — screen redraw, ignore idle signal
|
|
373
|
+
debug('Parser', `idle prompt in large chunk (${nonWs} non-ws) during spinner — ignoring`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// --- Spinner detection (only after first idle prompt seen) ---
|
|
378
|
+
if (this.seenFirstIdle && SPINNER_CHARS.test(chunk)) {
|
|
379
|
+
// Only treat as spinner if chunk is relatively short (< 80 chars of non-whitespace)
|
|
380
|
+
// This prevents matching spinner chars in large text blocks (responses, banners)
|
|
381
|
+
const nonWs = chunk.replace(/\s/g, '').length;
|
|
382
|
+
if (nonWs < 80) {
|
|
383
|
+
if (!this.spinnerActive) {
|
|
384
|
+
this.spinnerActive = true;
|
|
385
|
+
this.clearSuggestion();
|
|
386
|
+
debug('Parser', 'EMIT spinner_start');
|
|
387
|
+
this.emit('spinner_start');
|
|
388
|
+
}
|
|
389
|
+
this.resetSpinnerTimer();
|
|
390
|
+
this.spinnerTimer = setTimeout(() => {
|
|
391
|
+
if (this.spinnerActive) {
|
|
392
|
+
this.spinnerActive = false;
|
|
393
|
+
debug('Parser', 'EMIT spinner_stop (debounced)');
|
|
394
|
+
this.emit('spinner_stop');
|
|
395
|
+
}
|
|
396
|
+
}, SPINNER_DEBOUNCE_MS);
|
|
397
|
+
// Cancel idle & option timers — we're processing now
|
|
398
|
+
this.resetIdleTimer();
|
|
399
|
+
this.resetOptionTimer();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// While spinner is active, don't match other interactive patterns
|
|
404
|
+
if (this.spinnerActive)
|
|
405
|
+
return;
|
|
406
|
+
// --- Diff prompt ---
|
|
407
|
+
if (DIFF_PROMPT.test(chunk)) {
|
|
408
|
+
debug('Parser', 'EMIT diff_prompt');
|
|
409
|
+
this.lastNavigableEmit = false;
|
|
410
|
+
this.resetIdleTimer();
|
|
411
|
+
this.resetOptionTimer();
|
|
412
|
+
const parsed = this.parseDiffOptions(chunk);
|
|
413
|
+
const options = parsed.length > 0 ? parsed : [
|
|
414
|
+
{ index: 0, label: 'View diff', shortcut: 'v' },
|
|
415
|
+
{ index: 1, label: 'Apply', shortcut: 'a' },
|
|
416
|
+
{ index: 2, label: 'Deny', shortcut: 'd' },
|
|
417
|
+
];
|
|
418
|
+
this.emit('diff_prompt', { options });
|
|
419
|
+
this.startInteractiveCooldown();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// --- Permission: "Yes, allow once" / "No, deny" / "Always allow" ---
|
|
423
|
+
if (YES_NO_ALWAYS.test(chunk)) {
|
|
424
|
+
debug('Parser', 'EMIT permission_prompt (yes_no_always)');
|
|
425
|
+
this.lastNavigableEmit = false;
|
|
426
|
+
this.resetIdleTimer();
|
|
427
|
+
this.resetOptionTimer();
|
|
428
|
+
const parsed = this.parsePermissionOptions(chunk);
|
|
429
|
+
const options = parsed.length > 0 ? parsed : [
|
|
430
|
+
{ index: 0, label: 'Yes, allow once', shortcut: 'y' },
|
|
431
|
+
{ index: 1, label: 'No, deny', shortcut: 'n' },
|
|
432
|
+
{ index: 2, label: 'Always allow', shortcut: 'a' },
|
|
433
|
+
];
|
|
434
|
+
this.emit('permission_prompt', { options, promptType: 'yes_no_always' });
|
|
435
|
+
this.startInteractiveCooldown();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// --- Permission: (Y)es / (N)o ---
|
|
439
|
+
if (PERMISSION_YN.test(chunk)) {
|
|
440
|
+
debug('Parser', 'EMIT permission_prompt (yes_no)');
|
|
441
|
+
this.lastNavigableEmit = false;
|
|
442
|
+
this.resetIdleTimer();
|
|
443
|
+
this.resetOptionTimer();
|
|
444
|
+
this.emit('permission_prompt', {
|
|
445
|
+
options: [
|
|
446
|
+
{ index: 0, label: 'Yes', shortcut: 'y' },
|
|
447
|
+
{ index: 1, label: 'No', shortcut: 'n' },
|
|
448
|
+
],
|
|
449
|
+
promptType: 'yes_no',
|
|
450
|
+
});
|
|
451
|
+
this.startInteractiveCooldown();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// --- Option list (debounced — PTY chunks may split option data) ---
|
|
455
|
+
// Guard: real interactive option prompts arrive in small TUI redraws (<200 non-ws chars).
|
|
456
|
+
// Large chunks (≥200) are Claude's response text which may contain numbered lists
|
|
457
|
+
// (e.g. "1. First approach\n2. Second approach") — these are NOT interactive options.
|
|
458
|
+
// Exception: ❯ cursor before a numbered option is a definitive TUI indicator —
|
|
459
|
+
// Claude response text never contains "❯ 1." so this bypasses the size guard safely.
|
|
460
|
+
const chunkNonWs = chunk.replace(/\s/g, '').length;
|
|
461
|
+
const hasNavigableCursor = /^\s*❯\s*\d{1,2}[.)]/m.test(chunk);
|
|
462
|
+
if ((OPTION_NUMBERED.test(chunk) || OPTION_BULLET.test(chunk)) && (hasNavigableCursor || chunkNonWs < 200)) {
|
|
463
|
+
debug('Parser', 'option pattern detected — starting/resetting debounce');
|
|
464
|
+
this.resetIdleTimer();
|
|
465
|
+
this.resetOptionTimer();
|
|
466
|
+
this.optionTimer = setTimeout(() => {
|
|
467
|
+
this.optionTimer = null;
|
|
468
|
+
const parsed = this.parseOptions(this.buffer.slice(-2000));
|
|
469
|
+
if (parsed.options.length > 0) {
|
|
470
|
+
// Check if this looks like a permission prompt (Yes/No style from tool approval)
|
|
471
|
+
if (this.looksLikePermission(parsed.options) && !this.isCursorSelectionUI()) {
|
|
472
|
+
const options = parsed.options.map(opt => ({
|
|
473
|
+
...opt,
|
|
474
|
+
shortcut: opt.shortcut || this.inferShortcut(opt.label),
|
|
475
|
+
}));
|
|
476
|
+
debug('Parser', `EMIT permission_prompt (${options.length} options, navigable=${parsed.navigable}, cursor=${parsed.cursorIndex}, reclassified from numbered, debounced)`);
|
|
477
|
+
this.lastNavigableEmit = parsed.navigable;
|
|
478
|
+
this.lastCursorIndex = parsed.cursorIndex;
|
|
479
|
+
this.emit('permission_prompt', { options, promptType: 'yes_no_always', navigable: parsed.navigable, cursorIndex: parsed.cursorIndex });
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
this.lastNavigableEmit = parsed.navigable;
|
|
483
|
+
this.lastCursorIndex = parsed.cursorIndex;
|
|
484
|
+
debug('Parser', `EMIT option_prompt (${parsed.options.length} options, navigable=${parsed.navigable}, cursor=${parsed.cursorIndex}, debounced)`);
|
|
485
|
+
this.emit('option_prompt', {
|
|
486
|
+
options: parsed.options,
|
|
487
|
+
navigable: parsed.navigable,
|
|
488
|
+
cursorIndex: parsed.cursorIndex,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}, OPTION_DEBOUNCE_MS);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// --- Cursor-only redraw detection (navigable option state) ---
|
|
496
|
+
// ink's minimal redraw: only moves ❯ character. Chunk lacks digits, so
|
|
497
|
+
// OPTION_NUMBERED won't match. Re-parse buffer tail to detect cursor change.
|
|
498
|
+
// Note: IDLE_PROMPT falsely matches "❯ No" option text in cursor-move chunks.
|
|
499
|
+
// Genuine idle has only ❯ char as non-whitespace (nonWs=1, e.g. "❯ \n").
|
|
500
|
+
// Option cursor-move chunks always have ❯ + label text (nonWs≥2).
|
|
501
|
+
// Threshold < 2 separates the two cases (lowered from < 10 which failed
|
|
502
|
+
// for short option lists like Yes/No where the entire chunk was tiny).
|
|
503
|
+
if (this.lastNavigableEmit && chunk.includes('❯')) {
|
|
504
|
+
// Semantic idle check: genuine idle is exactly the prompt character with nothing else.
|
|
505
|
+
// "❯ \n" → nonWs "❯" (idle), "❯ No" → nonWs "❯No" (cursor move over option)
|
|
506
|
+
const nonWsContent = chunk.replace(/\s/g, '');
|
|
507
|
+
const isGenuineIdle = hasIdlePrompt && (nonWsContent === '❯' || nonWsContent === '>');
|
|
508
|
+
if (!isGenuineIdle) {
|
|
509
|
+
debug('Parser', 'cursor-only redraw detected — debouncing buffer re-parse');
|
|
510
|
+
this.resetIdleTimer();
|
|
511
|
+
this.resetOptionTimer();
|
|
512
|
+
this.optionTimer = setTimeout(() => {
|
|
513
|
+
this.optionTimer = null;
|
|
514
|
+
const parsed = this.parseOptions(this.buffer.slice(-2000));
|
|
515
|
+
if (parsed.navigable) {
|
|
516
|
+
// Options still present — emit cursor_update if index changed
|
|
517
|
+
if (parsed.cursorIndex !== this.lastCursorIndex) {
|
|
518
|
+
this.lastCursorIndex = parsed.cursorIndex;
|
|
519
|
+
debug('Parser', `EMIT cursor_update: cursorIndex=${parsed.cursorIndex}`);
|
|
520
|
+
this.emit('cursor_update', { cursorIndex: parsed.cursorIndex });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
// Options disappeared (Esc, selection made, etc.) — exit navigable state
|
|
525
|
+
this.lastNavigableEmit = false;
|
|
526
|
+
this.lastCursorIndex = 0;
|
|
527
|
+
debug('Parser', 'navigable options disappeared — emitting idle');
|
|
528
|
+
this.emit('idle');
|
|
529
|
+
}
|
|
530
|
+
}, OPTION_DEBOUNCE_MS);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// Genuine idle prompt (only ❯ char, no label text) — clear navigable state, fall through
|
|
534
|
+
this.lastNavigableEmit = false;
|
|
535
|
+
this.lastCursorIndex = 0;
|
|
536
|
+
}
|
|
537
|
+
// --- Cursor-only ANSI repositioning (no ❯ in chunk) ---
|
|
538
|
+
// ink may reposition cursor via ANSI sequences without rewriting ❯ character.
|
|
539
|
+
// Detect this during navigable state: small non-empty chunks that aren't response text.
|
|
540
|
+
if (this.lastNavigableEmit && !chunk.includes('❯') && chunkNonWs > 0 && chunkNonWs < 100) {
|
|
541
|
+
debug('Parser', 'ANSI cursor reposition detected (no ❯) — debouncing buffer re-parse');
|
|
542
|
+
this.resetIdleTimer();
|
|
543
|
+
this.resetOptionTimer();
|
|
544
|
+
this.optionTimer = setTimeout(() => {
|
|
545
|
+
this.optionTimer = null;
|
|
546
|
+
const parsed = this.parseOptions(this.buffer.slice(-2000));
|
|
547
|
+
if (parsed.navigable && parsed.cursorIndex !== this.lastCursorIndex) {
|
|
548
|
+
this.lastCursorIndex = parsed.cursorIndex;
|
|
549
|
+
debug('Parser', `EMIT cursor_update (ANSI reposition): cursorIndex=${parsed.cursorIndex}`);
|
|
550
|
+
this.emit('cursor_update', { cursorIndex: parsed.cursorIndex });
|
|
551
|
+
}
|
|
552
|
+
}, OPTION_DEBOUNCE_MS);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// --- Idle prompt ---
|
|
556
|
+
if (hasIdlePrompt) {
|
|
557
|
+
// If option timer is already pending, don't let idle override it.
|
|
558
|
+
// Screen redraws can contain both option prompts and ❯ in rapid succession.
|
|
559
|
+
if (this.optionTimer) {
|
|
560
|
+
debug('Parser', 'idle prompt ignored — option debounce pending');
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// After permission/diff emit, suppress false idle from user prompt echo
|
|
564
|
+
// (❯ text) in the same PTY batch (arrives within ~10ms).
|
|
565
|
+
if (this.interactiveCooldown) {
|
|
566
|
+
debug('Parser', 'idle prompt ignored — interactive cooldown active');
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (!this.seenFirstIdle) {
|
|
570
|
+
this.seenFirstIdle = true;
|
|
571
|
+
debug('Parser', 'first idle prompt seen — spinner detection now armed');
|
|
572
|
+
}
|
|
573
|
+
// If we had a pending mode switch and saw idle without a mode pattern,
|
|
574
|
+
// that means the mode cycled back to default
|
|
575
|
+
if (this.pendingModeSwitch) {
|
|
576
|
+
this.pendingModeSwitch = false;
|
|
577
|
+
debug('Parser', 'EMIT mode_change: default (idle after Shift+Tab, no mode banner)');
|
|
578
|
+
this.emit('mode_change', { mode: 'default' });
|
|
579
|
+
}
|
|
580
|
+
debug('Parser', 'idle prompt detected');
|
|
581
|
+
this.resetIdleTimer();
|
|
582
|
+
this.resetOptionTimer();
|
|
583
|
+
this.idleTimer = setTimeout(() => {
|
|
584
|
+
debug('Parser', 'EMIT idle');
|
|
585
|
+
this.emit('idle');
|
|
586
|
+
}, IDLE_DEBOUNCE_MS);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// --- IMPORTANT: Do NOT cancel idle timer for arbitrary chunks ---
|
|
590
|
+
// Keyboard echo characters (h, e, l, l, o) and status line updates
|
|
591
|
+
// arrive while Claude is idle. Cancelling the timer would prevent
|
|
592
|
+
// the idle event from ever firing.
|
|
593
|
+
// Only spinner detection and interactive prompts (above) cancel the idle timer.
|
|
594
|
+
}
|
|
595
|
+
parseStatusLine(chunk) {
|
|
596
|
+
const match = chunk.match(STATUS_LINE);
|
|
597
|
+
if (match) {
|
|
598
|
+
const dm = match[1].match(/(\d+)m\s*(\d+)s/);
|
|
599
|
+
if (dm) {
|
|
600
|
+
const sec = parseInt(dm[1], 10) * 60 + parseInt(dm[2], 10);
|
|
601
|
+
const tokens = Math.round(parseFloat(match[2]) * 1000);
|
|
602
|
+
this.emit('status_line', { durationSec: sec, tokens });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
parseToolAction(chunk) {
|
|
607
|
+
const match = chunk.match(TOOL_ACTION);
|
|
608
|
+
if (match) {
|
|
609
|
+
const toolArgs = match[2]?.trim() || null;
|
|
610
|
+
debug('Parser', `tool_action: ${match[1]}${toolArgs ? `(${toolArgs.slice(0, 60)})` : ''}`);
|
|
611
|
+
this.emit('tool_action', { toolName: match[1], toolArgs });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
parseRemoteUrl(rawData) {
|
|
615
|
+
if (this.remoteUrl)
|
|
616
|
+
return; // Only capture once per session
|
|
617
|
+
// Strip cursor movement sequences WITHOUT adding spaces (preserves URLs intact),
|
|
618
|
+
// then strip ANSI color/style sequences
|
|
619
|
+
const urlSafe = stripAnsi(rawData
|
|
620
|
+
.replace(/\x1b\[\d*[CABDEFGH]/g, '') // cursor movement → remove
|
|
621
|
+
.replace(/\x1b\[\d*(?:;\d*)?[Hf]/g, '') // CUP/HVP → remove
|
|
622
|
+
);
|
|
623
|
+
// Strategy 1: claude.ai/code URL — high confidence, always capture
|
|
624
|
+
const claudeMatch = urlSafe.match(REMOTE_CLAUDE_URL);
|
|
625
|
+
if (claudeMatch) {
|
|
626
|
+
const url = claudeMatch[0].replace(/[.,;)\]]+$/, '');
|
|
627
|
+
this.remoteUrl = url;
|
|
628
|
+
debug('Parser', `remote_url (claude.ai): ${url}`);
|
|
629
|
+
this.emit('remote_url', { url });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
// Strategy 2: keyword + URL pattern (remote/tunnel/server/session url)
|
|
633
|
+
const keywordMatch = urlSafe.match(REMOTE_KEYWORD_URL);
|
|
634
|
+
if (keywordMatch && keywordMatch[1]) {
|
|
635
|
+
const url = keywordMatch[1].replace(/[.,;)\]]+$/, '');
|
|
636
|
+
this.remoteUrl = url;
|
|
637
|
+
debug('Parser', `remote_url (keyword): ${url}`);
|
|
638
|
+
this.emit('remote_url', { url });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
parseProjectName(chunk) {
|
|
642
|
+
if (this.projectName)
|
|
643
|
+
return;
|
|
644
|
+
const match = chunk.match(PROJECT_DIR);
|
|
645
|
+
if (match && match[1]) {
|
|
646
|
+
this.projectName = match[1];
|
|
647
|
+
debug('Parser', `project_name: ${this.projectName}`);
|
|
648
|
+
this.emit('project_name', { name: this.projectName });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
parseModelInfo(chunk) {
|
|
652
|
+
const match = chunk.match(MODEL_INFO);
|
|
653
|
+
if (match && match[1]) {
|
|
654
|
+
const newModel = match[1].trim();
|
|
655
|
+
const plan = match[2]?.trim();
|
|
656
|
+
if (newModel !== this.modelName) {
|
|
657
|
+
this.modelName = newModel;
|
|
658
|
+
debug('Parser', `model_info: ${this.modelName}${plan ? ` (${plan})` : ''}`);
|
|
659
|
+
this.emit('model_info', { model: this.modelName, plan: plan || null });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
parseUserPrompt(chunk) {
|
|
664
|
+
// Don't match prompts before first idle — startup banner contains "❯ Try ..." suggestions
|
|
665
|
+
if (!this.seenFirstIdle)
|
|
666
|
+
return;
|
|
667
|
+
const match = chunk.match(USER_PROMPT);
|
|
668
|
+
if (match && match[1]) {
|
|
669
|
+
const text = match[1].trim();
|
|
670
|
+
// Filter out common false positives from Claude Code's TUI
|
|
671
|
+
if (text.length > 0 &&
|
|
672
|
+
text.length < 500 &&
|
|
673
|
+
!/^[─━═┄┅┈┉\-_=.·•\s]+$/.test(text) && // box-drawing / decorative lines
|
|
674
|
+
!/for\s+shortcuts/i.test(text) && // "? for shortcuts" hint
|
|
675
|
+
!/mode\s*on\b/i.test(text) && // "⏸ plan mode on" or "planmodeon"
|
|
676
|
+
!/accept\s*edits?\s*on\b/i.test(text) && // "⏵⏵ accept edits on" or "accepteditson"
|
|
677
|
+
!/shift\+tab\s*to\s*cycle/i.test(text) && // mode switcher hint
|
|
678
|
+
!/esc\s*to\s*interrupt/i.test(text) && // "esc to interrupt"
|
|
679
|
+
!/ctrl\+[a-z]\s+to\b/i.test(text) && // "ctrl+g to edit in VS Code"
|
|
680
|
+
!/^⏵|^⏸|^⏺/.test(text) && // UI indicator chars
|
|
681
|
+
!/^Try\s+["\u201C\u201D].+["\u201C\u201D]/i.test(text) && // autocomplete suggestion "Try \u201Crefactor...\u201D"
|
|
682
|
+
!/^\d+[.)]\s/.test(text) && // numbered option lines ("3. Haiku ✔ ...")
|
|
683
|
+
!/Enter\s*to\s*confirm/i.test(text) && // "Enter to confirm · Esc to exit"
|
|
684
|
+
!/Esc\s*to\s*exit/i.test(text) // option selector hint
|
|
685
|
+
) {
|
|
686
|
+
debug('Parser', `user_prompt: "${text.slice(0, 50)}"`);
|
|
687
|
+
this.emit('user_prompt', { text });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
parseUsageInfo(chunk) {
|
|
692
|
+
// Parse /usage command output for plan usage data
|
|
693
|
+
const pctMatch = chunk.match(USAGE_PERCENT);
|
|
694
|
+
const costMatch = chunk.match(USAGE_COST);
|
|
695
|
+
const sessionPctMatch = chunk.match(USAGE_SESSION_PERCENT);
|
|
696
|
+
const timeRemainingMatch = chunk.match(USAGE_TIME_REMAINING);
|
|
697
|
+
if (pctMatch || costMatch || sessionPctMatch || timeRemainingMatch) {
|
|
698
|
+
const info = {};
|
|
699
|
+
if (pctMatch) {
|
|
700
|
+
info.sessionPercent = parseInt(pctMatch[1], 10);
|
|
701
|
+
}
|
|
702
|
+
else if (sessionPctMatch) {
|
|
703
|
+
info.sessionPercent = parseInt(sessionPctMatch[1], 10);
|
|
704
|
+
}
|
|
705
|
+
if (costMatch) {
|
|
706
|
+
info.costSpent = parseFloat(costMatch[1]);
|
|
707
|
+
info.costLimit = parseFloat(costMatch[2]);
|
|
708
|
+
}
|
|
709
|
+
if (timeRemainingMatch) {
|
|
710
|
+
info.timeRemaining = timeRemainingMatch[0];
|
|
711
|
+
}
|
|
712
|
+
const resetTimeMatch = chunk.match(USAGE_RESET_TIME);
|
|
713
|
+
if (resetTimeMatch) {
|
|
714
|
+
info.resetTime = resetTimeMatch[1];
|
|
715
|
+
info.resetTimezone = resetTimeMatch[2];
|
|
716
|
+
}
|
|
717
|
+
const resetDateMatch = chunk.match(USAGE_RESET_DATE);
|
|
718
|
+
if (resetDateMatch) {
|
|
719
|
+
info.resetDate = resetDateMatch[1];
|
|
720
|
+
}
|
|
721
|
+
debug('Parser', `usage_info: ${JSON.stringify(info)}`);
|
|
722
|
+
this.emit('usage_info', info);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
parseModeSwitchLine(chunk) {
|
|
726
|
+
if (MODE_PLAN.test(chunk)) {
|
|
727
|
+
this.pendingModeSwitch = false;
|
|
728
|
+
if (this.modeSwitchTimer) {
|
|
729
|
+
clearTimeout(this.modeSwitchTimer);
|
|
730
|
+
this.modeSwitchTimer = null;
|
|
731
|
+
}
|
|
732
|
+
debug('Parser', 'EMIT mode_change: plan');
|
|
733
|
+
this.emit('mode_change', { mode: 'plan' });
|
|
734
|
+
}
|
|
735
|
+
else if (MODE_ACCEPT.test(chunk)) {
|
|
736
|
+
this.pendingModeSwitch = false;
|
|
737
|
+
if (this.modeSwitchTimer) {
|
|
738
|
+
clearTimeout(this.modeSwitchTimer);
|
|
739
|
+
this.modeSwitchTimer = null;
|
|
740
|
+
}
|
|
741
|
+
debug('Parser', 'EMIT mode_change: acceptEdits');
|
|
742
|
+
this.emit('mode_change', { mode: 'acceptEdits' });
|
|
743
|
+
}
|
|
744
|
+
else if (MODE_DEFAULT.test(chunk)) {
|
|
745
|
+
this.pendingModeSwitch = false;
|
|
746
|
+
if (this.modeSwitchTimer) {
|
|
747
|
+
clearTimeout(this.modeSwitchTimer);
|
|
748
|
+
this.modeSwitchTimer = null;
|
|
749
|
+
}
|
|
750
|
+
debug('Parser', 'EMIT mode_change: default');
|
|
751
|
+
this.emit('mode_change', { mode: 'default' });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/** Call this when Shift+Tab is sent, to arm the pending mode switch detection */
|
|
755
|
+
notifyModeSwitchSent() {
|
|
756
|
+
this.pendingModeSwitch = true;
|
|
757
|
+
if (this.modeSwitchTimer)
|
|
758
|
+
clearTimeout(this.modeSwitchTimer);
|
|
759
|
+
this.modeSwitchTimer = setTimeout(() => {
|
|
760
|
+
if (this.pendingModeSwitch) {
|
|
761
|
+
this.pendingModeSwitch = false;
|
|
762
|
+
debug('Parser', 'EMIT mode_change: default (timeout — no mode banner detected)');
|
|
763
|
+
this.emit('mode_change', { mode: 'default' });
|
|
764
|
+
}
|
|
765
|
+
this.modeSwitchTimer = null;
|
|
766
|
+
}, 2000);
|
|
767
|
+
}
|
|
768
|
+
/** Extract permission option labels from cursor-selection UI lines */
|
|
769
|
+
parsePermissionOptions(_chunk) {
|
|
770
|
+
// buffer already includes chunk (appended in feed() before detectPatterns)
|
|
771
|
+
const text = this.buffer.slice(-500);
|
|
772
|
+
const options = [];
|
|
773
|
+
for (const line of text.split('\n')) {
|
|
774
|
+
// Cursor selection lines: " ❯ Yes, allow once" or " No, deny"
|
|
775
|
+
const m = line.match(/^\s*❯?\s+(Yes[,\s].+|No[,\s].+|Always\s+.+)$/i);
|
|
776
|
+
if (m) {
|
|
777
|
+
const label = m[1].trim();
|
|
778
|
+
if (!options.some(o => o.label === label)) {
|
|
779
|
+
options.push({
|
|
780
|
+
index: options.length,
|
|
781
|
+
label,
|
|
782
|
+
shortcut: this.inferShortcut(label),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return options;
|
|
788
|
+
}
|
|
789
|
+
/** Extract diff option labels from inline (X)word patterns */
|
|
790
|
+
parseDiffOptions(_chunk) {
|
|
791
|
+
// buffer already includes chunk (appended in feed() before detectPatterns)
|
|
792
|
+
const text = this.buffer.slice(-500);
|
|
793
|
+
const options = [];
|
|
794
|
+
// Match "(V)iew diff", "(A)pply", "(D)eny" patterns
|
|
795
|
+
const re = /\(([A-Za-z])\)(\w+)(?:\s+(\w+))?/g;
|
|
796
|
+
let m;
|
|
797
|
+
while ((m = re.exec(text)) !== null) {
|
|
798
|
+
const shortcut = m[1].toLowerCase();
|
|
799
|
+
const word = m[1].toUpperCase() + m[2];
|
|
800
|
+
const label = m[3] ? `${word} ${m[3]}` : word;
|
|
801
|
+
if (!options.some(o => o.shortcut === shortcut)) {
|
|
802
|
+
options.push({ index: options.length, label: label.trim(), shortcut });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return options;
|
|
806
|
+
}
|
|
807
|
+
/** Infer keyboard shortcut from option label text */
|
|
808
|
+
inferShortcut(label) {
|
|
809
|
+
const lower = label.toLowerCase();
|
|
810
|
+
if (/^always\b/.test(lower))
|
|
811
|
+
return 'a';
|
|
812
|
+
if (/don['\u2019]t\s+ask\s+again/.test(lower))
|
|
813
|
+
return 'a';
|
|
814
|
+
if (/allow\s+all\s+sessions/.test(lower))
|
|
815
|
+
return 'a';
|
|
816
|
+
if (/^yes\b/.test(lower))
|
|
817
|
+
return 'y';
|
|
818
|
+
if (/^no\b/.test(lower) || /^deny\b/.test(lower))
|
|
819
|
+
return 'n';
|
|
820
|
+
if (/^view\b/.test(lower))
|
|
821
|
+
return 'v';
|
|
822
|
+
if (/^apply\b/.test(lower))
|
|
823
|
+
return 'a';
|
|
824
|
+
return lower.charAt(0);
|
|
825
|
+
}
|
|
826
|
+
/** Check if the current buffer indicates a cursor-navigable selection UI (Enter to confirm) */
|
|
827
|
+
isCursorSelectionUI() {
|
|
828
|
+
const tail = this.buffer.slice(-500);
|
|
829
|
+
return /Enter\s*to\s*confirm/i.test(tail);
|
|
830
|
+
}
|
|
831
|
+
/** Check if numbered options look like a permission prompt (Yes/No/Always style) */
|
|
832
|
+
looksLikePermission(options) {
|
|
833
|
+
const labels = options.map(o => o.label.toLowerCase());
|
|
834
|
+
const hasYes = labels.some(l => /^yes\b/.test(l));
|
|
835
|
+
const hasNo = labels.some(l => /^no\b/.test(l));
|
|
836
|
+
return hasYes && hasNo;
|
|
837
|
+
}
|
|
838
|
+
parseOptions(text) {
|
|
839
|
+
// ANSI cursor movement removal can leave numbered options concatenated without newlines.
|
|
840
|
+
// Insert a newline before number patterns that aren't preceded by one.
|
|
841
|
+
// (?![a-z\d]) prevents matching version numbers like "4.6" and file extensions like "_01.png"
|
|
842
|
+
const normalized = text.replace(/([^\n\d.\u276F])((?:\s*)❯?\s*\d{1,2}[.)](?![a-z\d]))/g, '$1\n$2');
|
|
843
|
+
// Backward scan: restrict to the last contiguous block of option lines.
|
|
844
|
+
// This prevents stale numbered list items (e.g. "5. Deploy") from earlier in the
|
|
845
|
+
// buffer being included as ghost options when a real option prompt follows.
|
|
846
|
+
const optLineRe = /^\s*❯?\s*\d{1,2}[.)]\s*.+|^\s*[►▸●○]\s+.+/;
|
|
847
|
+
const allLines = normalized.split('\n');
|
|
848
|
+
let blockEnd = allLines.length;
|
|
849
|
+
// Skip trailing non-option lines (footer like "ctrl-g to edit in VS Code")
|
|
850
|
+
while (blockEnd > 0 && !optLineRe.test(allLines[blockEnd - 1])) {
|
|
851
|
+
blockEnd--;
|
|
852
|
+
}
|
|
853
|
+
// Collect contiguous option lines scanning backward. Tolerates:
|
|
854
|
+
// - Blank/separator lines (unlimited — TUI redraws create variable blank runs)
|
|
855
|
+
// - Indented text lines (option descriptions, up to MAX_DESC_GAP between options)
|
|
856
|
+
// Breaks on unindented text (real content boundaries like "Would you like to proceed?")
|
|
857
|
+
let blockStart = blockEnd;
|
|
858
|
+
let foundOption = false;
|
|
859
|
+
let descGap = 0;
|
|
860
|
+
const MAX_DESC_GAP = 2; // max indented description lines between consecutive options
|
|
861
|
+
const sepRe = /^[\s\u2500-\u257F]*$/; // box-drawing characters + whitespace only
|
|
862
|
+
while (blockStart > 0) {
|
|
863
|
+
const line = allLines[blockStart - 1];
|
|
864
|
+
if (optLineRe.test(line)) {
|
|
865
|
+
blockStart--;
|
|
866
|
+
foundOption = true;
|
|
867
|
+
descGap = 0;
|
|
868
|
+
}
|
|
869
|
+
else if (line.trim() === '' || sepRe.test(line)) {
|
|
870
|
+
// Blank or separator line — always tolerate (TUI redraws create variable blank runs)
|
|
871
|
+
blockStart--;
|
|
872
|
+
}
|
|
873
|
+
else if (foundOption && /^\s/.test(line)) {
|
|
874
|
+
// Indented text line — likely option description, tolerate within limit
|
|
875
|
+
descGap++;
|
|
876
|
+
if (descGap > MAX_DESC_GAP)
|
|
877
|
+
break;
|
|
878
|
+
blockStart--;
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
// Unindented text or no options found yet — hard block boundary
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const lines = foundOption ? allLines.slice(blockStart, blockEnd) : allLines;
|
|
886
|
+
let navigable = false;
|
|
887
|
+
let cursorIndex = 0;
|
|
888
|
+
// Use a Map keyed by index so later (newer) lines overwrite earlier (stale) ones
|
|
889
|
+
const byIndex = new Map();
|
|
890
|
+
for (const line of lines) {
|
|
891
|
+
const hasCursor = /^\s*❯/.test(line);
|
|
892
|
+
const nm = line.match(/^\s*❯?\s*(\d{1,2})[.)]\s*(.+)/);
|
|
893
|
+
if (nm) {
|
|
894
|
+
const idx = parseInt(nm[1], 10) - 1;
|
|
895
|
+
if (hasCursor) {
|
|
896
|
+
navigable = true;
|
|
897
|
+
cursorIndex = idx;
|
|
898
|
+
}
|
|
899
|
+
let raw = nm[2].trim();
|
|
900
|
+
// Strip TUI footer text concatenated after last option (no newline from cursor positioning)
|
|
901
|
+
raw = raw.replace(/\s{2,}(?:Esc|Enter|ctrl\+\w)\s+to\s+.*/i, '');
|
|
902
|
+
raw = raw.trim();
|
|
903
|
+
// Skip file extension artifacts from tool call paths: "png)", "json)", "ts)" etc.
|
|
904
|
+
if (/^[a-z]{1,10}\)$/.test(raw))
|
|
905
|
+
continue;
|
|
906
|
+
const recommended = /\(recommended\)/i.test(raw);
|
|
907
|
+
const selected = /✔/.test(raw);
|
|
908
|
+
const label = this.cleanOptionLabel(raw);
|
|
909
|
+
debug('Parser', `option[${idx}]: "${label}"${recommended ? ' ★' : ''}${selected ? ' ✓' : ''}${hasCursor ? ' ❯' : ''}`);
|
|
910
|
+
const opt = { index: idx, label };
|
|
911
|
+
if (recommended)
|
|
912
|
+
opt.recommended = true;
|
|
913
|
+
if (selected)
|
|
914
|
+
opt.selected = true;
|
|
915
|
+
byIndex.set(idx, opt);
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const bm = line.match(/^\s*([►▸●○])\s+(.+)/);
|
|
919
|
+
if (bm) {
|
|
920
|
+
const idx = byIndex.size;
|
|
921
|
+
byIndex.set(idx, { index: idx, label: bm[2].trim() });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
// Fix TUI cursor-overwrite contamination.
|
|
925
|
+
// Claude Code's ink TUI sometimes draws the full command on the option line first
|
|
926
|
+
// (e.g. 'file "/Users/foo/bar"/* 2>/dev/null'), then sends a CUP-repositioned
|
|
927
|
+
// correction like ':* ' to overwrite with the short scope pattern.
|
|
928
|
+
// Our linear buffer appends both draws, so the option label gets contaminated.
|
|
929
|
+
// Detect the correction line and patch the affected label.
|
|
930
|
+
const correctionRe = /^(:\S+)\s{5,}/;
|
|
931
|
+
let correctionScope = null;
|
|
932
|
+
for (const line of allLines) {
|
|
933
|
+
const cm = line.match(correctionRe);
|
|
934
|
+
if (cm) {
|
|
935
|
+
correctionScope = cm[1];
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (correctionScope) {
|
|
940
|
+
for (const [idx, opt] of byIndex) {
|
|
941
|
+
// Match: "Yes, and don't ask again for: file /path/..." (contaminated)
|
|
942
|
+
const m = opt.label.match(/^(Yes,?\s+and\s+don['\u2019]t\s+ask\s+again\s+for:\s+)(\S+)\s+\S/i);
|
|
943
|
+
if (m) {
|
|
944
|
+
opt.label = m[1] + m[2] + correctionScope;
|
|
945
|
+
byIndex.set(idx, opt);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const sorted = Array.from(byIndex.values()).sort((a, b) => a.index - b.index);
|
|
950
|
+
// Filter to longest contiguous run to discard ghost options
|
|
951
|
+
// from stale buffer content (e.g. idx=98 from previous numbered lists).
|
|
952
|
+
// Uses longest run instead of 0-based to handle buffer truncation where
|
|
953
|
+
// option 0 may have been cut off.
|
|
954
|
+
let bestRun = [];
|
|
955
|
+
let currentRun = [];
|
|
956
|
+
for (const opt of sorted) {
|
|
957
|
+
if (currentRun.length === 0 || opt.index === currentRun[currentRun.length - 1].index + 1) {
|
|
958
|
+
currentRun.push(opt);
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
if (currentRun.length > bestRun.length)
|
|
962
|
+
bestRun = currentRun;
|
|
963
|
+
currentRun = [opt];
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (currentRun.length > bestRun.length)
|
|
967
|
+
bestRun = currentRun;
|
|
968
|
+
// Re-index to 0-based for downstream consumers
|
|
969
|
+
const contiguous = bestRun.map((opt, i) => ({ ...opt, index: i }));
|
|
970
|
+
const finalOptions = contiguous.length >= 2 ? contiguous : sorted;
|
|
971
|
+
return { options: finalOptions, navigable, cursorIndex };
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Clean an option label from TUI text that may have spaces stripped by ANSI cursor positioning.
|
|
975
|
+
* Uses · (U+00B7 middle dot) as a reliable delimiter — it survives ANSI stripping.
|
|
976
|
+
*/
|
|
977
|
+
cleanOptionLabel(raw) {
|
|
978
|
+
let text = stripAnsi(raw)
|
|
979
|
+
.replace(/\s*\(recommended\)/i, '')
|
|
980
|
+
.replace(/✔/g, ' ')
|
|
981
|
+
.trim();
|
|
982
|
+
// · (middle dot) separates identity from description in Claude Code TUI
|
|
983
|
+
const dotIdx = text.indexOf('\u00B7');
|
|
984
|
+
if (dotIdx > 0) {
|
|
985
|
+
const identity = text.slice(0, dotIdx).trim();
|
|
986
|
+
// Extract version number (e.g. "4.6")
|
|
987
|
+
const versionMatch = identity.match(/(\d+\.\d+)/);
|
|
988
|
+
const version = versionMatch ? versionMatch[1] : null;
|
|
989
|
+
let clean = identity.replace(/\d+\.\d+\S*/g, '').trim();
|
|
990
|
+
// Split words: use spaces if present, else CamelCase boundaries
|
|
991
|
+
let parts;
|
|
992
|
+
if (/\s/.test(clean)) {
|
|
993
|
+
parts = clean.split(/\s+/).filter(Boolean);
|
|
994
|
+
}
|
|
995
|
+
else if (clean.length > 1) {
|
|
996
|
+
parts = clean.split(/(?<=[a-z])(?=[A-Z])/).filter(Boolean);
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
parts = [clean];
|
|
1000
|
+
}
|
|
1001
|
+
// Deduplicate exact and fuzzy matches (e.g. "SonnetSonnet" → "Sonnet", "SonnetSonnt" → "Sonnet")
|
|
1002
|
+
const isFuzzyMatch = (a, b) => {
|
|
1003
|
+
const al = a.toLowerCase(), bl = b.toLowerCase();
|
|
1004
|
+
if (al === bl)
|
|
1005
|
+
return true;
|
|
1006
|
+
const [shorter, longer] = al.length <= bl.length ? [al, bl] : [bl, al];
|
|
1007
|
+
return longer.length - shorter.length <= 2 && longer.startsWith(shorter.slice(0, -1));
|
|
1008
|
+
};
|
|
1009
|
+
const deduped = [];
|
|
1010
|
+
for (const p of parts) {
|
|
1011
|
+
const matchIdx = deduped.findIndex(existing => isFuzzyMatch(existing, p));
|
|
1012
|
+
if (matchIdx === -1) {
|
|
1013
|
+
deduped.push(p);
|
|
1014
|
+
}
|
|
1015
|
+
else if (p.length > deduped[matchIdx].length) {
|
|
1016
|
+
// Keep the longer/more complete variant
|
|
1017
|
+
deduped[matchIdx] = p;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
const name = deduped[0] || clean || identity;
|
|
1021
|
+
const extra = deduped.slice(1);
|
|
1022
|
+
if (version)
|
|
1023
|
+
extra.push(version);
|
|
1024
|
+
// Double space separates main from subtitle for processLabel()
|
|
1025
|
+
return extra.length > 0 ? `${name} ${extra.join(' ')}` : name;
|
|
1026
|
+
}
|
|
1027
|
+
// No · — normal text with spaces preserved
|
|
1028
|
+
return text;
|
|
1029
|
+
}
|
|
1030
|
+
resetSpinnerTimer() {
|
|
1031
|
+
if (this.spinnerTimer) {
|
|
1032
|
+
clearTimeout(this.spinnerTimer);
|
|
1033
|
+
this.spinnerTimer = null;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
resetIdleTimer() {
|
|
1037
|
+
if (this.idleTimer) {
|
|
1038
|
+
clearTimeout(this.idleTimer);
|
|
1039
|
+
this.idleTimer = null;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
resetOptionTimer() {
|
|
1043
|
+
if (this.optionTimer) {
|
|
1044
|
+
clearTimeout(this.optionTimer);
|
|
1045
|
+
this.optionTimer = null;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/** Brief cooldown after permission/diff emit or navigation — prevents false idle from PTY echo */
|
|
1049
|
+
startInteractiveCooldown() {
|
|
1050
|
+
if (this.interactiveCooldown)
|
|
1051
|
+
clearTimeout(this.interactiveCooldown);
|
|
1052
|
+
this.interactiveCooldown = setTimeout(() => {
|
|
1053
|
+
this.interactiveCooldown = null;
|
|
1054
|
+
}, 200);
|
|
1055
|
+
}
|
|
1056
|
+
resetInteractiveCooldown() {
|
|
1057
|
+
if (this.interactiveCooldown) {
|
|
1058
|
+
clearTimeout(this.interactiveCooldown);
|
|
1059
|
+
this.interactiveCooldown = null;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
getProjectName() { return this.projectName; }
|
|
1063
|
+
getModelName() { return this.modelName; }
|
|
1064
|
+
reset() {
|
|
1065
|
+
this.buffer = '';
|
|
1066
|
+
this.pendingAnsi = '';
|
|
1067
|
+
this.spinnerActive = false;
|
|
1068
|
+
this.seenFirstIdle = false;
|
|
1069
|
+
this.pendingModeSwitch = false;
|
|
1070
|
+
this.projectName = null;
|
|
1071
|
+
this.modelName = null;
|
|
1072
|
+
this.lastSuggestedPrompt = null;
|
|
1073
|
+
this.lastNavigableEmit = false;
|
|
1074
|
+
this.lastCursorIndex = 0;
|
|
1075
|
+
this.resetSpinnerTimer();
|
|
1076
|
+
this.resetIdleTimer();
|
|
1077
|
+
this.resetOptionTimer();
|
|
1078
|
+
this.resetInteractiveCooldown();
|
|
1079
|
+
if (this.modeSwitchTimer) {
|
|
1080
|
+
clearTimeout(this.modeSwitchTimer);
|
|
1081
|
+
this.modeSwitchTimer = null;
|
|
1082
|
+
}
|
|
1083
|
+
if (this.suggestedPromptTimer) {
|
|
1084
|
+
clearTimeout(this.suggestedPromptTimer);
|
|
1085
|
+
this.suggestedPromptTimer = null;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
//# sourceMappingURL=output-parser.js.map
|