@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.
Files changed (93) hide show
  1. package/dist/adapters/claude-code.d.ts +38 -0
  2. package/dist/adapters/claude-code.d.ts.map +1 -0
  3. package/dist/adapters/claude-code.js +184 -0
  4. package/dist/adapters/claude-code.js.map +1 -0
  5. package/dist/adapters/index.d.ts +8 -0
  6. package/dist/adapters/index.d.ts.map +1 -0
  7. package/dist/adapters/index.js +18 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/openclaw.d.ts +70 -0
  10. package/dist/adapters/openclaw.d.ts.map +1 -0
  11. package/dist/adapters/openclaw.js +664 -0
  12. package/dist/adapters/openclaw.js.map +1 -0
  13. package/dist/auth.d.ts +9 -0
  14. package/dist/auth.d.ts.map +1 -0
  15. package/dist/auth.js +64 -0
  16. package/dist/auth.js.map +1 -0
  17. package/dist/check-deps.d.ts +5 -0
  18. package/dist/check-deps.d.ts.map +1 -0
  19. package/dist/check-deps.js +47 -0
  20. package/dist/check-deps.js.map +1 -0
  21. package/dist/diag-analyzer.d.ts +29 -0
  22. package/dist/diag-analyzer.d.ts.map +1 -0
  23. package/dist/diag-analyzer.js +145 -0
  24. package/dist/diag-analyzer.js.map +1 -0
  25. package/dist/event-journal.d.ts +22 -0
  26. package/dist/event-journal.d.ts.map +1 -0
  27. package/dist/event-journal.js +117 -0
  28. package/dist/event-journal.js.map +1 -0
  29. package/dist/hook-server.d.ts +31 -0
  30. package/dist/hook-server.d.ts.map +1 -0
  31. package/dist/hook-server.js +260 -0
  32. package/dist/hook-server.js.map +1 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +796 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/logger.d.ts +11 -0
  38. package/dist/logger.d.ts.map +1 -0
  39. package/dist/logger.js +27 -0
  40. package/dist/logger.js.map +1 -0
  41. package/dist/mdns.d.ts +9 -0
  42. package/dist/mdns.d.ts.map +1 -0
  43. package/dist/mdns.js +44 -0
  44. package/dist/mdns.js.map +1 -0
  45. package/dist/model-catalog.d.ts +29 -0
  46. package/dist/model-catalog.d.ts.map +1 -0
  47. package/dist/model-catalog.js +91 -0
  48. package/dist/model-catalog.js.map +1 -0
  49. package/dist/output-parser.d.ts +65 -0
  50. package/dist/output-parser.d.ts.map +1 -0
  51. package/dist/output-parser.js +1089 -0
  52. package/dist/output-parser.js.map +1 -0
  53. package/dist/pty-manager.d.ts +14 -0
  54. package/dist/pty-manager.d.ts.map +1 -0
  55. package/dist/pty-manager.js +93 -0
  56. package/dist/pty-manager.js.map +1 -0
  57. package/dist/pty-ringbuffer.d.ts +23 -0
  58. package/dist/pty-ringbuffer.d.ts.map +1 -0
  59. package/dist/pty-ringbuffer.js +65 -0
  60. package/dist/pty-ringbuffer.js.map +1 -0
  61. package/dist/session-registry.d.ts +17 -0
  62. package/dist/session-registry.d.ts.map +1 -0
  63. package/dist/session-registry.js +107 -0
  64. package/dist/session-registry.js.map +1 -0
  65. package/dist/state-machine.d.ts +48 -0
  66. package/dist/state-machine.d.ts.map +1 -0
  67. package/dist/state-machine.js +470 -0
  68. package/dist/state-machine.js.map +1 -0
  69. package/dist/types.d.ts +28 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +4 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/usage-api.d.ts +16 -0
  74. package/dist/usage-api.d.ts.map +1 -0
  75. package/dist/usage-api.js +84 -0
  76. package/dist/usage-api.js.map +1 -0
  77. package/dist/usage-tracker.d.ts +24 -0
  78. package/dist/usage-tracker.d.ts.map +1 -0
  79. package/dist/usage-tracker.js +89 -0
  80. package/dist/usage-tracker.js.map +1 -0
  81. package/dist/voice.d.ts +27 -0
  82. package/dist/voice.d.ts.map +1 -0
  83. package/dist/voice.js +412 -0
  84. package/dist/voice.js.map +1 -0
  85. package/dist/whisper-server-manager.d.ts +19 -0
  86. package/dist/whisper-server-manager.d.ts.map +1 -0
  87. package/dist/whisper-server-manager.js +171 -0
  88. package/dist/whisper-server-manager.js.map +1 -0
  89. package/dist/ws-server.d.ts +18 -0
  90. package/dist/ws-server.d.ts.map +1 -0
  91. package/dist/ws-server.js +86 -0
  92. package/dist/ws-server.js.map +1 -0
  93. 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