maistro 1.0.390

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 (111) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +107 -0
  3. package/dist/app.d.ts +247 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +4971 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/buildInfo.d.ts +5 -0
  8. package/dist/buildInfo.d.ts.map +1 -0
  9. package/dist/buildInfo.js +2 -0
  10. package/dist/buildInfo.js.map +1 -0
  11. package/dist/caffeinate.d.ts +72 -0
  12. package/dist/caffeinate.d.ts.map +1 -0
  13. package/dist/caffeinate.js +258 -0
  14. package/dist/caffeinate.js.map +1 -0
  15. package/dist/claudePath.d.ts +10 -0
  16. package/dist/claudePath.d.ts.map +1 -0
  17. package/dist/claudePath.js +34 -0
  18. package/dist/claudePath.js.map +1 -0
  19. package/dist/clipboard.d.ts +44 -0
  20. package/dist/clipboard.d.ts.map +1 -0
  21. package/dist/clipboard.js +442 -0
  22. package/dist/clipboard.js.map +1 -0
  23. package/dist/config.d.ts +211 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +933 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/constants.d.ts +50 -0
  28. package/dist/constants.d.ts.map +1 -0
  29. package/dist/constants.js +81 -0
  30. package/dist/constants.js.map +1 -0
  31. package/dist/contextBuilder.d.ts +38 -0
  32. package/dist/contextBuilder.d.ts.map +1 -0
  33. package/dist/contextBuilder.js +113 -0
  34. package/dist/contextBuilder.js.map +1 -0
  35. package/dist/dependencyDetector.d.ts +57 -0
  36. package/dist/dependencyDetector.d.ts.map +1 -0
  37. package/dist/dependencyDetector.js +505 -0
  38. package/dist/dependencyDetector.js.map +1 -0
  39. package/dist/executor.d.ts +83 -0
  40. package/dist/executor.d.ts.map +1 -0
  41. package/dist/executor.js +583 -0
  42. package/dist/executor.js.map +1 -0
  43. package/dist/git.d.ts +85 -0
  44. package/dist/git.d.ts.map +1 -0
  45. package/dist/git.js +283 -0
  46. package/dist/git.js.map +1 -0
  47. package/dist/imageManager.d.ts +161 -0
  48. package/dist/imageManager.d.ts.map +1 -0
  49. package/dist/imageManager.js +674 -0
  50. package/dist/imageManager.js.map +1 -0
  51. package/dist/index.d.ts +3 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +437 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/input-visual-test.d.ts +9 -0
  56. package/dist/input-visual-test.d.ts.map +1 -0
  57. package/dist/input-visual-test.js +108 -0
  58. package/dist/input-visual-test.js.map +1 -0
  59. package/dist/inputBox.d.ts +228 -0
  60. package/dist/inputBox.d.ts.map +1 -0
  61. package/dist/inputBox.js +966 -0
  62. package/dist/inputBox.js.map +1 -0
  63. package/dist/logger.d.ts +136 -0
  64. package/dist/logger.d.ts.map +1 -0
  65. package/dist/logger.js +347 -0
  66. package/dist/logger.js.map +1 -0
  67. package/dist/orchestrator.d.ts +149 -0
  68. package/dist/orchestrator.d.ts.map +1 -0
  69. package/dist/orchestrator.js +821 -0
  70. package/dist/orchestrator.js.map +1 -0
  71. package/dist/planner.d.ts +86 -0
  72. package/dist/planner.d.ts.map +1 -0
  73. package/dist/planner.js +830 -0
  74. package/dist/planner.js.map +1 -0
  75. package/dist/pty-test-runner.d.ts +87 -0
  76. package/dist/pty-test-runner.d.ts.map +1 -0
  77. package/dist/pty-test-runner.js +721 -0
  78. package/dist/pty-test-runner.js.map +1 -0
  79. package/dist/screen.d.ts +44 -0
  80. package/dist/screen.d.ts.map +1 -0
  81. package/dist/screen.js +152 -0
  82. package/dist/screen.js.map +1 -0
  83. package/dist/taskQueue.d.ts +70 -0
  84. package/dist/taskQueue.d.ts.map +1 -0
  85. package/dist/taskQueue.js +282 -0
  86. package/dist/taskQueue.js.map +1 -0
  87. package/dist/tui-test-harness.d.ts +216 -0
  88. package/dist/tui-test-harness.d.ts.map +1 -0
  89. package/dist/tui-test-harness.js +527 -0
  90. package/dist/tui-test-harness.js.map +1 -0
  91. package/dist/types.d.ts +257 -0
  92. package/dist/types.d.ts.map +1 -0
  93. package/dist/types.js +46 -0
  94. package/dist/types.js.map +1 -0
  95. package/dist/ui-visual-test.d.ts +15 -0
  96. package/dist/ui-visual-test.d.ts.map +1 -0
  97. package/dist/ui-visual-test.js +141 -0
  98. package/dist/ui-visual-test.js.map +1 -0
  99. package/dist/ui.d.ts +272 -0
  100. package/dist/ui.d.ts.map +1 -0
  101. package/dist/ui.js +1531 -0
  102. package/dist/ui.js.map +1 -0
  103. package/dist/validator.d.ts +53 -0
  104. package/dist/validator.d.ts.map +1 -0
  105. package/dist/validator.js +491 -0
  106. package/dist/validator.js.map +1 -0
  107. package/dist/versionCheck.d.ts +63 -0
  108. package/dist/versionCheck.d.ts.map +1 -0
  109. package/dist/versionCheck.js +261 -0
  110. package/dist/versionCheck.js.map +1 -0
  111. package/package.json +62 -0
package/dist/ui.js ADDED
@@ -0,0 +1,1531 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { EventEmitter } from 'node:events';
3
+ export class InteractiveUI extends EventEmitter {
4
+ rl = null;
5
+ isRawMode = false;
6
+ // Injectable streams for testing
7
+ _stdin;
8
+ _stdout;
9
+ constructor(stdin, stdout) {
10
+ super();
11
+ this._stdin = stdin || process.stdin;
12
+ this._stdout = stdout || process.stdout;
13
+ }
14
+ /**
15
+ * Get the stdin stream (for testing access)
16
+ */
17
+ get stdin() {
18
+ return this._stdin;
19
+ }
20
+ /**
21
+ * Get the stdout stream (for testing access)
22
+ */
23
+ get stdout() {
24
+ return this._stdout;
25
+ }
26
+ /**
27
+ * Initialize the UI
28
+ */
29
+ init() {
30
+ this.rl = createInterface({
31
+ input: process.stdin,
32
+ output: process.stdout,
33
+ });
34
+ // Note: Don't handle 'close' event here as it can be triggered unexpectedly
35
+ // during raw mode operations. The app manages its own lifecycle.
36
+ }
37
+ /**
38
+ * Enable raw mode for key-by-key input
39
+ */
40
+ enableRawMode() {
41
+ if (this._stdin.isTTY) {
42
+ this._stdin.setRawMode?.(true);
43
+ this._stdin.resume?.(); // Always resume stdin (might have been paused externally)
44
+ this.isRawMode = true;
45
+ }
46
+ }
47
+ /**
48
+ * Disable raw mode
49
+ */
50
+ disableRawMode() {
51
+ if (this._stdin.isTTY && this.isRawMode) {
52
+ this._stdin.setRawMode?.(false);
53
+ this.isRawMode = false;
54
+ }
55
+ }
56
+ /**
57
+ * Clear the screen and scrollback buffer
58
+ */
59
+ clear() {
60
+ this._stdout.write('\x1b[2J\x1b[3J\x1b[H');
61
+ }
62
+ /**
63
+ * Move cursor up N lines
64
+ */
65
+ cursorUp(n) {
66
+ this._stdout.write(`\x1b[${n}A`);
67
+ }
68
+ /**
69
+ * Clear from cursor to end of screen
70
+ */
71
+ clearToEnd() {
72
+ this._stdout.write('\x1b[J');
73
+ }
74
+ /**
75
+ * Print styled text
76
+ */
77
+ print(text) {
78
+ console.log(text);
79
+ }
80
+ /**
81
+ * Print with color
82
+ */
83
+ printColored(text, color) {
84
+ const colors = {
85
+ green: '\x1b[32m',
86
+ yellow: '\x1b[33m',
87
+ red: '\x1b[31m',
88
+ cyan: '\x1b[36m',
89
+ gray: '\x1b[90m',
90
+ bold: '\x1b[1m',
91
+ };
92
+ const reset = '\x1b[0m';
93
+ console.log(`${colors[color]}${text}${reset}`);
94
+ }
95
+ /**
96
+ * Show a quick-pick menu with command input support
97
+ * @param items Menu items to display
98
+ * @param prompt Header text for the menu
99
+ * @param commandParser Optional callback for custom command parsing
100
+ * @param options Additional options
101
+ * @param options.showInput Whether to show the text input field (default: true if commandParser provided, false otherwise)
102
+ */
103
+ async quickPick(items, prompt = 'Select an action', commandParser, options) {
104
+ return new Promise((resolve) => {
105
+ let selectedIndex = 0;
106
+ let inputBuffer = '';
107
+ let isTypingCommand = false;
108
+ let isFirstRender = true;
109
+ let escTimeout = null;
110
+ let lastLineCount = 0;
111
+ // Determine if we should show the input bar
112
+ // Default: show if commandParser is provided (custom input possible), hide otherwise
113
+ const showInput = options?.showInput ?? !!commandParser;
114
+ const render = (showEscHint = false) => {
115
+ if (isFirstRender) {
116
+ isFirstRender = false;
117
+ }
118
+ else {
119
+ // Move cursor up to the start of our previous output
120
+ // When we write N lines with join('\n'), cursor ends on line N-1 (0-indexed)
121
+ // So to get back to line 0, we move up (N-1) lines
122
+ if (lastLineCount > 1) {
123
+ this._stdout.write(`\x1b[${lastLineCount - 1}A`); // Move up
124
+ }
125
+ this._stdout.write('\x1b[G'); // Move to column 1
126
+ this._stdout.write('\x1b[J'); // Clear from cursor to end
127
+ }
128
+ const lines = [];
129
+ lines.push(`\x1b[1m${prompt}\x1b[0m`);
130
+ lines.push('');
131
+ items.forEach((item, index) => {
132
+ const prefix = index === selectedIndex ? '\x1b[36m❯\x1b[0m' : ' ';
133
+ const label = index === selectedIndex ? `\x1b[1m${item.label}\x1b[0m` : item.label;
134
+ const desc = item.description ? ` \x1b[90m${item.description}\x1b[0m` : '';
135
+ lines.push(`${prefix} \x1b[33m${item.key}\x1b[0m ${label}${desc}`);
136
+ });
137
+ lines.push('');
138
+ const termWidth = this._stdout.columns || 80;
139
+ if (showInput) {
140
+ // Show command input line in Claude Code style
141
+ const hint = '↵ send';
142
+ lines.push(`\x1b[90m${'─'.repeat(termWidth)}\x1b[0m`);
143
+ if (isTypingCommand) {
144
+ lines.push(`\x1b[90m>\x1b[0m ${inputBuffer}\x1b[7m \x1b[0m`);
145
+ }
146
+ else {
147
+ const placeholder = 'type command...';
148
+ lines.push(`\x1b[90m> \x1b[0m\x1b[7m \x1b[0m\x1b[90m${placeholder}\x1b[0m`);
149
+ }
150
+ lines.push(`\x1b[90m${'─'.repeat(termWidth)}\x1b[0m`);
151
+ // Hint outside the lines (right-aligned)
152
+ const hintPadding = termWidth - hint.length;
153
+ lines.push(`${' '.repeat(hintPadding)}\x1b[90m${hint}\x1b[0m`);
154
+ // Show escape hint if pending clear
155
+ if (showEscHint) {
156
+ lines.push(`\x1b[90mEsc to clear again\x1b[0m`);
157
+ }
158
+ }
159
+ else {
160
+ // Simplified hint for navigation-only mode (right-aligned)
161
+ const hint = '↑↓ navigate · ↵ select';
162
+ const hintPadding = Math.max(0, termWidth - hint.length);
163
+ lines.push(`${' '.repeat(hintPadding)}\x1b[90m${hint}\x1b[0m`);
164
+ }
165
+ // Print all lines and track count for next clear
166
+ this._stdout.write(lines.join('\n'));
167
+ lastLineCount = lines.length;
168
+ };
169
+ // Render final state showing the selected option before exiting
170
+ const renderFinal = (selectedKey) => {
171
+ // Move cursor up to the start of our previous output
172
+ // When we wrote N lines with join('\n'), cursor is on line N-1, so move up N-1
173
+ if (lastLineCount > 1) {
174
+ this._stdout.write(`\x1b[${lastLineCount - 1}A`); // Move up
175
+ }
176
+ if (lastLineCount > 0) {
177
+ this._stdout.write('\x1b[G'); // Move to column 1
178
+ }
179
+ this._stdout.write('\x1b[J'); // Clear from cursor to end
180
+ const lines = [];
181
+ lines.push(`\x1b[1m${prompt}\x1b[0m`);
182
+ lines.push('');
183
+ items.forEach((item, index) => {
184
+ const isSelected = item.key === selectedKey;
185
+ const prefix = isSelected ? '\x1b[36m❯\x1b[0m' : ' ';
186
+ const label = isSelected ? `\x1b[1m${item.label}\x1b[0m` : item.label;
187
+ const desc = item.description ? ` \x1b[90m${item.description}\x1b[0m` : '';
188
+ lines.push(`${prefix} \x1b[33m${item.key}\x1b[0m ${label}${desc}`);
189
+ });
190
+ lines.push('');
191
+ const termWidth = this._stdout.columns || 80;
192
+ if (showInput) {
193
+ lines.push(`\x1b[90m${'─'.repeat(termWidth)}\x1b[0m`);
194
+ // Show the selected key in the input bar
195
+ const hint = '↵ send';
196
+ const contentWidth = termWidth - 4;
197
+ const padding = Math.max(0, contentWidth - selectedKey.length - hint.length - 3);
198
+ lines.push(`\x1b[90m❯\x1b[0m ${selectedKey}${' '.repeat(padding + 1)}\x1b[90m${hint}\x1b[0m`);
199
+ lines.push(`\x1b[90m${'─'.repeat(termWidth)}\x1b[0m`);
200
+ }
201
+ // When showInput is false, just show the selected item without input bar
202
+ this._stdout.write(lines.join('\n') + '\n');
203
+ };
204
+ // Initial render
205
+ render();
206
+ this.enableRawMode();
207
+ const handleKey = (key) => {
208
+ const char = key.toString();
209
+ // Escape - select cancel/no option
210
+ if (char === '\x1b' && !char.startsWith('\x1b[')) {
211
+ // Look for a cancel-like item (key 'n' or label 'No', 'Cancel', 'Exit', etc.)
212
+ const cancelItem = items.find(item => {
213
+ const lowerKey = item.key.toLowerCase();
214
+ const lowerLabel = item.label.toLowerCase();
215
+ return lowerKey === 'n' || lowerLabel === 'no' || lowerLabel === 'cancel' || lowerLabel === 'exit';
216
+ });
217
+ if (cancelItem) {
218
+ renderFinal(cancelItem.key);
219
+ cleanup();
220
+ resolve(cancelItem.key);
221
+ return;
222
+ }
223
+ // No cancel item found, resolve with null
224
+ cleanup();
225
+ resolve(null);
226
+ return;
227
+ }
228
+ // Ctrl+C
229
+ if (char === '\x03') {
230
+ cleanup();
231
+ resolve(null);
232
+ return;
233
+ }
234
+ // Colon starts command input mode (only if input is shown)
235
+ if (char === ':' && !isTypingCommand && showInput) {
236
+ isTypingCommand = true;
237
+ inputBuffer = '';
238
+ render();
239
+ return;
240
+ }
241
+ // If typing command (only possible when showInput is true)
242
+ if (isTypingCommand && showInput) {
243
+ // Backspace
244
+ if (char === '\x7f' || char === '\b') {
245
+ if (inputBuffer.length > 0) {
246
+ inputBuffer = inputBuffer.slice(0, -1);
247
+ }
248
+ else {
249
+ isTypingCommand = false;
250
+ }
251
+ render();
252
+ return;
253
+ }
254
+ // Enter - execute command
255
+ if (char === '\r' || char === '\n') {
256
+ if (inputBuffer) {
257
+ // First check if commandParser handles it
258
+ if (commandParser) {
259
+ const cmd = commandParser(inputBuffer);
260
+ if (cmd) {
261
+ renderFinal(inputBuffer);
262
+ cleanup();
263
+ resolve(cmd);
264
+ return;
265
+ }
266
+ }
267
+ // Check if input matches any menu item (key or label, case-insensitive)
268
+ const lowerInput = inputBuffer.toLowerCase();
269
+ const matchedItem = items.find(item => item.key.toLowerCase() === lowerInput ||
270
+ item.label.toLowerCase() === lowerInput);
271
+ if (matchedItem) {
272
+ renderFinal(matchedItem.key);
273
+ cleanup();
274
+ resolve(matchedItem.key);
275
+ return;
276
+ }
277
+ }
278
+ // Invalid command, clear
279
+ inputBuffer = '';
280
+ isTypingCommand = false;
281
+ render();
282
+ return;
283
+ }
284
+ // Add character to buffer
285
+ if (char.length === 1 && char >= ' ') {
286
+ inputBuffer += char;
287
+ render();
288
+ }
289
+ return;
290
+ }
291
+ // Enter - select current item
292
+ if (char === '\r' || char === '\n') {
293
+ renderFinal(items[selectedIndex].key);
294
+ cleanup();
295
+ resolve(items[selectedIndex].key);
296
+ return;
297
+ }
298
+ // Arrow up (also handle Shift+Up to prevent history interference)
299
+ if (char === '\x1b[A' || char === '\x1b[1;2A') {
300
+ selectedIndex = (selectedIndex - 1 + items.length) % items.length;
301
+ render();
302
+ return;
303
+ }
304
+ // Arrow down (also handle Shift+Down to prevent history interference)
305
+ if (char === '\x1b[B' || char === '\x1b[1;2B') {
306
+ selectedIndex = (selectedIndex + 1) % items.length;
307
+ render();
308
+ return;
309
+ }
310
+ // Ignore backspace when not typing (nothing to delete)
311
+ if (char === '\x7f' || char === '\b') {
312
+ return;
313
+ }
314
+ // Any printable character starts typing (only if input is shown)
315
+ if (showInput && char.length === 1 && char >= ' ' && char < '\x7f') {
316
+ isTypingCommand = true;
317
+ inputBuffer = char;
318
+ render();
319
+ }
320
+ };
321
+ const cleanup = () => {
322
+ if (escTimeout)
323
+ clearTimeout(escTimeout);
324
+ this._stdin.removeListener('data', handleKey);
325
+ this.disableRawMode();
326
+ };
327
+ this._stdin.on('data', handleKey);
328
+ });
329
+ }
330
+ /**
331
+ * Prompt for text input
332
+ */
333
+ async prompt(question) {
334
+ return new Promise((resolve) => {
335
+ this.disableRawMode();
336
+ this.rl?.question(question, (answer) => {
337
+ resolve(answer.trim());
338
+ });
339
+ });
340
+ }
341
+ /**
342
+ * Prompt for text input with Escape support
343
+ * Returns null if Escape is pressed
344
+ * @param prefix - The prompt prefix to display
345
+ * @param placeholder - Optional placeholder text shown when input is empty (displayed in gray)
346
+ */
347
+ async promptWithEscape(prefix, placeholder) {
348
+ return new Promise((resolve) => {
349
+ let inputBuffer = '';
350
+ let resolved = false;
351
+ let cursorIndex = 0; // Position within inputBuffer
352
+ let isFirstRender = true;
353
+ let lastTargetRow = 0; // Track cursor row (editing position) at end of last render
354
+ let lastLineCount = 1; // Track line count from last render for scroll compensation
355
+ // Calculate visible length of prefix (strip ANSI codes)
356
+ const visiblePrefixLength = prefix.replace(/\x1b\[[0-9;]*m/g, '').length;
357
+ // Render the current input state with cursor at correct position
358
+ const render = () => {
359
+ // Prefer COLUMNS env var (set by PTY/expect), then stdout.columns, then default
360
+ const termWidth = parseInt(process.env.COLUMNS || '', 10) || this._stdout.columns || 80;
361
+ const totalContentLength = visiblePrefixLength + inputBuffer.length;
362
+ // Calculate how many lines content will take
363
+ const lineCount = totalContentLength === 0 ? 1 : Math.ceil(totalContentLength / termWidth);
364
+ // Calculate cursor position for desired cursorIndex
365
+ const cursorPos = visiblePrefixLength + cursorIndex;
366
+ const targetRow = Math.floor(cursorPos / termWidth);
367
+ const targetCol = cursorPos % termWidth;
368
+ // After writing content, cursor ends up at the position AFTER the last character
369
+ // If content exactly fills the line (totalContentLength % termWidth == 0), cursor wraps to next line
370
+ // This is CRITICAL: if we don't account for the wrap, we'll move up by wrong amount
371
+ // and fail to clear previous content, causing duplication
372
+ const cursorWrapped = totalContentLength > 0 && totalContentLength % termWidth === 0;
373
+ const cursorRowAfterWrite = cursorWrapped ? lineCount : lineCount - 1;
374
+ if (isFirstRender) {
375
+ isFirstRender = false;
376
+ // Hide cursor, go to col 0, clear line and below
377
+ this._stdout.write('\x1b[?25l\r\x1b[2K\x1b[J');
378
+ }
379
+ else {
380
+ // Move up to the START of content area and then clear.
381
+ //
382
+ // BUG FIX: When content wraps and terminal scrolls, old content shifts up
383
+ // but cursor stays at the bottom. We need to clear more rows to account
384
+ // for the shifted content.
385
+ //
386
+ // Using max(lastTargetRow, lastLineCount - 1) ensures:
387
+ // 1. We clear all content rows when cursor is at an earlier row (e.g., after backspace)
388
+ // 2. We account for scroll shifts when cursor was at or past the last line
389
+ //
390
+ // lastLineCount - 1 = the max row index of previous content (0-indexed)
391
+ // lastTargetRow = where cursor was positioned (content row index)
392
+ const rowsToMoveUp = Math.max(lastTargetRow, lastLineCount);
393
+ this._stdout.write('\x1b[?25l'); // Hide cursor
394
+ if (rowsToMoveUp > 0) {
395
+ this._stdout.write(`\x1b[${rowsToMoveUp}A`);
396
+ }
397
+ // Clear from cursor to end of screen
398
+ this._stdout.write('\r\x1b[2K\x1b[J');
399
+ }
400
+ // Write new content (show placeholder in gray if input is empty)
401
+ if (inputBuffer.length === 0 && placeholder) {
402
+ this._stdout.write(`${prefix}\x1b[90m${placeholder}\x1b[0m`);
403
+ }
404
+ else {
405
+ this._stdout.write(`${prefix}${inputBuffer}`);
406
+ }
407
+ // Position cursor at targetRow, targetCol for editing
408
+ // IMPORTANT: Some terminals have "pending wrap" behavior where cursor doesn't actually
409
+ // wrap when content exactly fills a line. To handle this reliably:
410
+ // 1. Move cursor to row 0 first (move up by cursorRowAfterWrite)
411
+ // 2. Then move down to targetRow
412
+ // This ensures we end up at the right position regardless of pending wrap state
413
+ if (cursorRowAfterWrite > 0) {
414
+ this._stdout.write(`\x1b[${cursorRowAfterWrite}A`);
415
+ }
416
+ this._stdout.write('\r'); // Now at row 0, col 0
417
+ if (targetRow > 0) {
418
+ this._stdout.write(`\x1b[${targetRow}B`); // Move down to targetRow
419
+ }
420
+ if (targetCol > 0) {
421
+ this._stdout.write(`\x1b[${targetCol}C`);
422
+ }
423
+ this._stdout.write('\x1b[?25h'); // Show cursor at edit position
424
+ // Track cursor position and line count for next render
425
+ lastTargetRow = targetRow;
426
+ lastLineCount = lineCount;
427
+ };
428
+ let escapeTimeout = null;
429
+ let escapeBuffer = '';
430
+ const cleanup = () => {
431
+ if (resolved)
432
+ return;
433
+ resolved = true;
434
+ if (escapeTimeout) {
435
+ clearTimeout(escapeTimeout);
436
+ escapeTimeout = null;
437
+ }
438
+ this._stdin.removeListener('data', handleKey);
439
+ this.disableRawMode();
440
+ this._stdout.write('\n');
441
+ };
442
+ const handleKey = (key) => {
443
+ if (resolved)
444
+ return;
445
+ let char = key.toString();
446
+ // Handle escape sequence buffering
447
+ // If we have a pending escape, combine with new input
448
+ if (escapeBuffer) {
449
+ char = escapeBuffer + char;
450
+ escapeBuffer = '';
451
+ if (escapeTimeout) {
452
+ clearTimeout(escapeTimeout);
453
+ escapeTimeout = null;
454
+ }
455
+ }
456
+ // If this is just escape, wait briefly for more input (could be start of sequence)
457
+ if (char === '\x1b') {
458
+ escapeBuffer = char;
459
+ escapeTimeout = setTimeout(() => {
460
+ // Bare escape - cancel input
461
+ escapeBuffer = '';
462
+ cleanup();
463
+ resolve(null);
464
+ }, 50);
465
+ return;
466
+ }
467
+ // Ctrl+C
468
+ if (char === '\x03') {
469
+ cleanup();
470
+ resolve(null);
471
+ return;
472
+ }
473
+ // Enter - submit
474
+ if (char === '\r' || char === '\n') {
475
+ cleanup();
476
+ resolve(inputBuffer.trim());
477
+ return;
478
+ }
479
+ // Left arrow
480
+ if (char === '\x1b[D') {
481
+ if (cursorIndex > 0) {
482
+ cursorIndex--;
483
+ render();
484
+ }
485
+ return;
486
+ }
487
+ // Right arrow
488
+ if (char === '\x1b[C') {
489
+ if (cursorIndex < inputBuffer.length) {
490
+ cursorIndex++;
491
+ render();
492
+ }
493
+ return;
494
+ }
495
+ // Home key (various terminal sequences) or Ctrl+A - go to start
496
+ if (char === '\x1b[H' || char === '\x1b[1~' || char === '\x1bOH' || char === '\x01' || char === '\x1b[1;2H') {
497
+ cursorIndex = 0;
498
+ render();
499
+ return;
500
+ }
501
+ // End key (various terminal sequences) or Ctrl+E - go to end
502
+ if (char === '\x1b[F' || char === '\x1b[4~' || char === '\x1bOF' || char === '\x05' || char === '\x1b[1;2F') {
503
+ cursorIndex = inputBuffer.length;
504
+ render();
505
+ return;
506
+ }
507
+ // Backspace - delete char before cursor
508
+ if (char === '\x7f' || char === '\b') {
509
+ if (cursorIndex > 0) {
510
+ inputBuffer = inputBuffer.slice(0, cursorIndex - 1) + inputBuffer.slice(cursorIndex);
511
+ cursorIndex--;
512
+ render();
513
+ }
514
+ return;
515
+ }
516
+ // Delete key - delete char at cursor
517
+ if (char === '\x1b[3~') {
518
+ if (cursorIndex < inputBuffer.length) {
519
+ inputBuffer = inputBuffer.slice(0, cursorIndex) + inputBuffer.slice(cursorIndex + 1);
520
+ render();
521
+ }
522
+ return;
523
+ }
524
+ // Up arrow - move cursor up one visual row (by terminal width)
525
+ if (char === '\x1b[A') {
526
+ // Use same termWidth calculation as render() for consistency
527
+ const termWidth = parseInt(process.env.COLUMNS || '', 10) || this._stdout.columns || 80;
528
+ const cursorPos = visiblePrefixLength + cursorIndex;
529
+ // Can only move up if we're not on the first row
530
+ if (cursorPos >= termWidth) {
531
+ // Move up by terminal width, but don't go before input start
532
+ const newCursorPos = cursorPos - termWidth;
533
+ cursorIndex = Math.max(0, newCursorPos - visiblePrefixLength);
534
+ render();
535
+ }
536
+ return;
537
+ }
538
+ // Down arrow - move cursor down one visual row (by terminal width)
539
+ if (char === '\x1b[B') {
540
+ // Use same termWidth calculation as render() for consistency
541
+ const termWidth = parseInt(process.env.COLUMNS || '', 10) || this._stdout.columns || 80;
542
+ const cursorPos = visiblePrefixLength + cursorIndex;
543
+ const totalLen = visiblePrefixLength + inputBuffer.length;
544
+ const maxRow = Math.floor((totalLen - 1) / termWidth);
545
+ const currentRow = Math.floor(cursorPos / termWidth);
546
+ // Can only move down if we're not on the last row
547
+ if (currentRow < maxRow) {
548
+ // Move down by terminal width, but don't go past input end
549
+ const newCursorPos = Math.min(cursorPos + termWidth, totalLen);
550
+ cursorIndex = Math.min(inputBuffer.length, newCursorPos - visiblePrefixLength);
551
+ render();
552
+ }
553
+ return;
554
+ }
555
+ // Ignore other escape sequences
556
+ if (char.startsWith('\x1b[') || char.startsWith('\x1bO')) {
557
+ return;
558
+ }
559
+ // Regular printable characters - insert at cursor position
560
+ // Handle both single chars and multi-char paste operations
561
+ let charsToInsert = '';
562
+ for (const c of char) {
563
+ // Accept printable ASCII and extended characters (for pastes with special chars)
564
+ if (c >= ' ' && c !== '\x7f') {
565
+ charsToInsert += c;
566
+ }
567
+ // Convert newlines to spaces to keep input on single line
568
+ if (c === '\n' || c === '\r') {
569
+ charsToInsert += ' ';
570
+ }
571
+ }
572
+ if (charsToInsert.length > 0) {
573
+ inputBuffer = inputBuffer.slice(0, cursorIndex) + charsToInsert + inputBuffer.slice(cursorIndex);
574
+ cursorIndex += charsToInsert.length;
575
+ render();
576
+ }
577
+ };
578
+ // Enable raw mode (like quickPick does)
579
+ this.enableRawMode();
580
+ // Set up the data listener
581
+ this._stdin.on('data', handleKey);
582
+ // Initial render
583
+ render();
584
+ });
585
+ }
586
+ /**
587
+ * Listen for escape key during execution
588
+ */
589
+ onEscape(callback) {
590
+ this.enableRawMode();
591
+ this._stdin.resume?.();
592
+ const handleKey = (key) => {
593
+ const char = key.toString();
594
+ // Escape or Ctrl+C
595
+ if (char === '\x1b' || char === '\x03') {
596
+ callback();
597
+ }
598
+ };
599
+ this._stdin.on('data', handleKey);
600
+ return () => {
601
+ this._stdin.removeListener('data', handleKey);
602
+ this.disableRawMode();
603
+ };
604
+ }
605
+ /**
606
+ * Show a spinner during async operation
607
+ */
608
+ spinner(message) {
609
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
610
+ let i = 0;
611
+ let stopped = false;
612
+ const interval = setInterval(() => {
613
+ if (stopped)
614
+ return;
615
+ process.stdout.write(`\r\x1b[36m${frames[i]}\x1b[0m ${message}`);
616
+ i = (i + 1) % frames.length;
617
+ }, 80);
618
+ return {
619
+ stop: (finalMessage) => {
620
+ stopped = true;
621
+ clearInterval(interval);
622
+ process.stdout.write('\r\x1b[K'); // Clear line
623
+ if (finalMessage) {
624
+ console.log(finalMessage);
625
+ }
626
+ },
627
+ };
628
+ }
629
+ /**
630
+ * Wait for any key press
631
+ */
632
+ async waitForKey(message = 'Press any key to continue...') {
633
+ return new Promise((resolve) => {
634
+ console.log(`\x1b[90m${message}\x1b[0m`);
635
+ this.enableRawMode();
636
+ const handleKey = (key) => {
637
+ this._stdin.removeListener('data', handleKey);
638
+ this.disableRawMode();
639
+ resolve();
640
+ };
641
+ this._stdin.on('data', handleKey);
642
+ });
643
+ }
644
+ /**
645
+ * Close the UI
646
+ */
647
+ close() {
648
+ this.disableRawMode();
649
+ this.rl?.close();
650
+ }
651
+ }
652
+ /**
653
+ * Format task status with symbol.
654
+ * For failed/skipped tasks, optionally accepts failureType to show different symbols:
655
+ * - execution failure: red (code was never written)
656
+ * - acceptance failure: yellow/orange (code written but validation failed)
657
+ */
658
+ /**
659
+ * Get the status icon only (for task lists where name is shown separately)
660
+ */
661
+ export function formatTaskStatusIcon(status, failureType) {
662
+ if (status === 'failed') {
663
+ if (failureType === 'acceptance') {
664
+ return '\x1b[33m⚠\x1b[0m'; // Yellow warning - validation failed
665
+ }
666
+ return '\x1b[31m✗\x1b[0m'; // Red X - execution failed
667
+ }
668
+ if (status === 'skipped') {
669
+ if (failureType === 'acceptance') {
670
+ return '\x1b[33m◌\x1b[0m'; // Yellow empty - skipped after validation fail
671
+ }
672
+ if (failureType === 'execution') {
673
+ return '\x1b[31m◌\x1b[0m'; // Red empty - skipped after execution fail
674
+ }
675
+ return '\x1b[90m◌\x1b[0m'; // Gray empty - manually skipped
676
+ }
677
+ const symbols = {
678
+ pending: '\x1b[33m○\x1b[0m',
679
+ in_progress: '\x1b[36m◐\x1b[0m',
680
+ completed: '\x1b[32m●\x1b[0m',
681
+ };
682
+ return symbols[status] || '○';
683
+ }
684
+ /**
685
+ * Get the human-readable status name with color
686
+ */
687
+ export function formatStatusName(status, failureType) {
688
+ if (status === 'failed') {
689
+ if (failureType === 'acceptance') {
690
+ return '\x1b[33mFailed\x1b[0m';
691
+ }
692
+ return '\x1b[31mFailed\x1b[0m';
693
+ }
694
+ if (status === 'skipped') {
695
+ if (failureType === 'acceptance') {
696
+ return '\x1b[33mSkipped\x1b[0m';
697
+ }
698
+ if (failureType === 'execution') {
699
+ return '\x1b[31mSkipped\x1b[0m';
700
+ }
701
+ return '\x1b[90mSkipped\x1b[0m';
702
+ }
703
+ const names = {
704
+ pending: '\x1b[33mPending\x1b[0m',
705
+ in_progress: '\x1b[36mIn Progress\x1b[0m',
706
+ completed: '\x1b[32mCompleted\x1b[0m',
707
+ };
708
+ return names[status] || status;
709
+ }
710
+ /**
711
+ * Format task status with icon and name (for status detail views)
712
+ */
713
+ export function formatTaskStatus(status, failureType) {
714
+ return `${formatTaskStatusIcon(status, failureType)} ${formatStatusName(status, failureType)}`;
715
+ }
716
+ /**
717
+ * Format blocked task status symbol
718
+ */
719
+ export function formatBlockedStatus() {
720
+ return '\x1b[31m⊘\x1b[0m'; // Red blocked symbol
721
+ }
722
+ /**
723
+ * Format progress bar with optional skipped tasks display
724
+ * @param completed Number of completed tasks
725
+ * @param total Total number of tasks
726
+ * @param width Width of the progress bar
727
+ * @param skipped Number of skipped tasks (shown in yellow)
728
+ */
729
+ export function progressBar(completed, total, width = 20, skipped = 0) {
730
+ // Show completed (green) + skipped (yellow) + remaining (gray)
731
+ const completedPercent = total > 0 ? completed / total : 0;
732
+ const skippedPercent = total > 0 ? skipped / total : 0;
733
+ const filledCompleted = Math.round(width * completedPercent);
734
+ const filledSkipped = Math.round(width * skippedPercent);
735
+ const empty = width - filledCompleted - filledSkipped;
736
+ const bar = '\x1b[32m' + '█'.repeat(filledCompleted) +
737
+ '\x1b[33m' + '▓'.repeat(filledSkipped) +
738
+ '\x1b[90m' + '░'.repeat(Math.max(0, empty)) + '\x1b[0m';
739
+ // Show counts: completed/total or completed+skipped/total if skipped > 0
740
+ const countStr = skipped > 0
741
+ ? `${completed}+${skipped}/${total}`
742
+ : `${completed}/${total}`;
743
+ return `${bar} ${countStr}`;
744
+ }
745
+ /**
746
+ * Animated progress display for task execution with output streaming and input bar
747
+ */
748
+ export class ProgressDisplay {
749
+ interval = null;
750
+ startTime = 0;
751
+ taskStartTime = 0;
752
+ spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
753
+ frameIndex = 0;
754
+ lineCount = 0;
755
+ maxLineCount = 0; // Track max lines ever rendered for safer clearing
756
+ isFirstRender = true;
757
+ // Indeterminate progress animation (Knight Rider style)
758
+ bouncePos = 0;
759
+ bounceDir = 1;
760
+ bounceWidth = 6; // Width of the moving segment
761
+ // Current state
762
+ currentTask = '';
763
+ currentTaskId = '';
764
+ currentTaskIndex = 0;
765
+ totalTasks = 0;
766
+ completedTasks = 0;
767
+ skippedTasks = 0;
768
+ phase = 'executing';
769
+ isRunning = false;
770
+ // Duration tracking
771
+ completedDurationMs = 0; // Sum of all completed task durations
772
+ // Output streaming
773
+ lastOutputLines = [];
774
+ maxOutputLines = 10; // Show more Claude Code output
775
+ // Input bar
776
+ inputBuffer = '';
777
+ inputCursorIndex = 0;
778
+ onInputCallback = null;
779
+ keyHandler = null;
780
+ resizeHandler = null;
781
+ // Double-escape confirmation state
782
+ escPendingPause = false;
783
+ escTimeout = null;
784
+ // Concurrent planning mode state
785
+ planningMode = false;
786
+ planningTasks = [];
787
+ planningSelectedIndex = 0;
788
+ planModificationCallback = null;
789
+ planningMessage = ''; // Feedback message for planning actions
790
+ // Header renderer callback (called on each render to show header above progress)
791
+ headerRenderer = null;
792
+ /**
793
+ * Set a header renderer callback that will be called on each render
794
+ * @param renderer Function that writes header content to stdout
795
+ */
796
+ setHeaderRenderer(renderer) {
797
+ this.headerRenderer = renderer;
798
+ }
799
+ /**
800
+ * Start the animated display
801
+ * @param totalTasks Total number of tasks
802
+ * @param onInput Callback for user input
803
+ * @param initialCompletedDurationMs Duration of already-completed tasks (from previous runs)
804
+ * @param initialSkippedTasks Number of already-skipped tasks
805
+ * @param planModification Optional callbacks for concurrent plan modification
806
+ */
807
+ start(totalTasks, onInput, initialCompletedDurationMs = 0, initialSkippedTasks = 0, planModification) {
808
+ this.totalTasks = totalTasks;
809
+ this.skippedTasks = initialSkippedTasks;
810
+ this.startTime = Date.now();
811
+ this.isRunning = true;
812
+ this.lineCount = 0;
813
+ this.maxLineCount = 0;
814
+ this.isFirstRender = true;
815
+ this.lastOutputLines = [];
816
+ this.inputBuffer = '';
817
+ this.inputCursorIndex = 0;
818
+ this.onInputCallback = onInput || null;
819
+ this.completedDurationMs = initialCompletedDurationMs;
820
+ this.planModificationCallback = planModification || null;
821
+ this.planningMode = false;
822
+ this.planningTasks = [];
823
+ this.planningSelectedIndex = 0;
824
+ this.planningMessage = '';
825
+ // Set up input handling
826
+ if (process.stdin.isTTY) {
827
+ process.stdin.setRawMode(true);
828
+ process.stdin.resume();
829
+ this.keyHandler = (key) => {
830
+ const char = key.toString();
831
+ // In planning mode, handle keys differently
832
+ if (this.planningMode) {
833
+ this.handlePlanningKey(char);
834
+ return;
835
+ }
836
+ // Escape - require double-press to pause (to avoid accidental stops)
837
+ if (char === '\x1b' && char.length === 1) {
838
+ if (this.escPendingPause) {
839
+ // Second escape within timeout - actually pause
840
+ if (this.escTimeout) {
841
+ clearTimeout(this.escTimeout);
842
+ this.escTimeout = null;
843
+ }
844
+ this.escPendingPause = false;
845
+ this.onInputCallback?.('__ESC__');
846
+ }
847
+ else {
848
+ // First escape - show warning and wait for confirmation
849
+ this.escPendingPause = true;
850
+ this.render(); // Re-render to show "Esc again to pause" hint
851
+ this.escTimeout = setTimeout(() => {
852
+ this.escPendingPause = false;
853
+ this.escTimeout = null;
854
+ this.render(); // Re-render to restore normal hint
855
+ }, 1500); // 1.5 seconds to confirm
856
+ }
857
+ return;
858
+ }
859
+ // Ctrl+C - immediate pause (emergency stop)
860
+ if (char === '\x03') {
861
+ if (this.escTimeout) {
862
+ clearTimeout(this.escTimeout);
863
+ this.escTimeout = null;
864
+ }
865
+ this.escPendingPause = false;
866
+ this.onInputCallback?.('__ESC__');
867
+ return;
868
+ }
869
+ // Any other input cancels the escape confirmation
870
+ if (this.escPendingPause) {
871
+ if (this.escTimeout) {
872
+ clearTimeout(this.escTimeout);
873
+ this.escTimeout = null;
874
+ }
875
+ this.escPendingPause = false;
876
+ // Don't return - let the key be processed normally and re-render below
877
+ }
878
+ // Enter - submit input
879
+ if (char === '\r' || char === '\n') {
880
+ if (this.inputBuffer.trim()) {
881
+ const input = this.inputBuffer.trim().toLowerCase();
882
+ // Check if user wants to enter planning mode
883
+ if (input === 'plan' && this.planModificationCallback) {
884
+ this.enterPlanningMode();
885
+ this.inputBuffer = '';
886
+ this.inputCursorIndex = 0;
887
+ return;
888
+ }
889
+ this.onInputCallback?.(this.inputBuffer.trim());
890
+ this.inputBuffer = '';
891
+ this.inputCursorIndex = 0;
892
+ this.render();
893
+ }
894
+ return;
895
+ }
896
+ // Arrow keys
897
+ if (char === '\x1b[D') { // Left arrow
898
+ if (this.inputCursorIndex > 0) {
899
+ this.inputCursorIndex--;
900
+ this.render();
901
+ }
902
+ return;
903
+ }
904
+ if (char === '\x1b[C') { // Right arrow
905
+ if (this.inputCursorIndex < this.inputBuffer.length) {
906
+ this.inputCursorIndex++;
907
+ this.render();
908
+ }
909
+ return;
910
+ }
911
+ // Home key
912
+ if (char === '\x1b[H' || char === '\x1b[1~' || char === '\x01') { // Home or Ctrl+A
913
+ if (this.inputCursorIndex > 0) {
914
+ this.inputCursorIndex = 0;
915
+ this.render();
916
+ }
917
+ return;
918
+ }
919
+ // End key
920
+ if (char === '\x1b[F' || char === '\x1b[4~' || char === '\x05') { // End or Ctrl+E
921
+ if (this.inputCursorIndex < this.inputBuffer.length) {
922
+ this.inputCursorIndex = this.inputBuffer.length;
923
+ this.render();
924
+ }
925
+ return;
926
+ }
927
+ // Delete key
928
+ if (char === '\x1b[3~') {
929
+ if (this.inputCursorIndex < this.inputBuffer.length) {
930
+ this.inputBuffer = this.inputBuffer.slice(0, this.inputCursorIndex) +
931
+ this.inputBuffer.slice(this.inputCursorIndex + 1);
932
+ this.render();
933
+ }
934
+ return;
935
+ }
936
+ // Backspace
937
+ if (char === '\x7f' || char === '\b') {
938
+ if (this.inputCursorIndex > 0) {
939
+ this.inputBuffer = this.inputBuffer.slice(0, this.inputCursorIndex - 1) +
940
+ this.inputBuffer.slice(this.inputCursorIndex);
941
+ this.inputCursorIndex--;
942
+ this.render();
943
+ }
944
+ return;
945
+ }
946
+ // Regular character - check for printable ASCII or non-ASCII (like Unicode)
947
+ const code = char.charCodeAt(0);
948
+ if (char.length >= 1 && ((code >= 32 && code < 127) || code >= 128)) {
949
+ this.inputBuffer = this.inputBuffer.slice(0, this.inputCursorIndex) +
950
+ char +
951
+ this.inputBuffer.slice(this.inputCursorIndex);
952
+ this.inputCursorIndex += char.length;
953
+ this.render();
954
+ }
955
+ };
956
+ process.stdin.on('data', this.keyHandler);
957
+ }
958
+ // Handle terminal resize
959
+ this.resizeHandler = () => {
960
+ // Clear screen and reset render state
961
+ process.stdout.write('\x1b[2J\x1b[H');
962
+ this.isFirstRender = true;
963
+ this.render();
964
+ };
965
+ process.stdout.on('resize', this.resizeHandler);
966
+ // Initial render
967
+ this.render();
968
+ // Start animation loop
969
+ this.interval = setInterval(() => {
970
+ this.frameIndex = (this.frameIndex + 1) % this.spinnerFrames.length;
971
+ // Update bounce position for indeterminate progress
972
+ this.bouncePos += this.bounceDir;
973
+ const maxPos = 30 - this.bounceWidth; // barWidth - bounceWidth
974
+ if (this.bouncePos >= maxPos) {
975
+ this.bouncePos = maxPos;
976
+ this.bounceDir = -1;
977
+ }
978
+ else if (this.bouncePos <= 0) {
979
+ this.bouncePos = 0;
980
+ this.bounceDir = 1;
981
+ }
982
+ this.render();
983
+ }, 80);
984
+ }
985
+ /**
986
+ * Add output line from Claude Code
987
+ */
988
+ addOutput(line) {
989
+ // Clean up the line - remove ANSI codes for length calculation but keep for display
990
+ const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, '');
991
+ // Truncate if too long
992
+ const maxWidth = process.stdout.columns ? process.stdout.columns - 4 : 76;
993
+ let displayLine = line;
994
+ if (cleanLine.length > maxWidth) {
995
+ displayLine = cleanLine.slice(0, maxWidth - 3) + '...';
996
+ }
997
+ this.lastOutputLines.push(displayLine);
998
+ // Keep only the last N lines
999
+ if (this.lastOutputLines.length > this.maxOutputLines) {
1000
+ this.lastOutputLines.shift();
1001
+ }
1002
+ }
1003
+ /**
1004
+ * Update when starting a new task
1005
+ */
1006
+ startTask(taskName, taskIndex, taskId) {
1007
+ this.currentTask = taskName;
1008
+ this.currentTaskIndex = taskIndex;
1009
+ this.currentTaskId = taskId || '';
1010
+ this.taskStartTime = Date.now();
1011
+ this.phase = 'executing';
1012
+ }
1013
+ /**
1014
+ * Update phase (executing -> validating -> committing)
1015
+ */
1016
+ setPhase(phase) {
1017
+ this.phase = phase;
1018
+ }
1019
+ /**
1020
+ * Mark a task as complete
1021
+ * @param durationMs Optional duration of the completed task in milliseconds.
1022
+ * If not provided, calculates from taskStartTime.
1023
+ */
1024
+ completeTask(durationMs) {
1025
+ this.completedTasks++;
1026
+ // Use provided duration, or calculate from taskStartTime
1027
+ const taskDuration = durationMs ?? (this.taskStartTime > 0 ? Date.now() - this.taskStartTime : 0);
1028
+ this.completedDurationMs += taskDuration;
1029
+ }
1030
+ /**
1031
+ * Stop the animated display
1032
+ */
1033
+ stop(finalMessage) {
1034
+ this.isRunning = false;
1035
+ if (this.interval) {
1036
+ clearInterval(this.interval);
1037
+ this.interval = null;
1038
+ }
1039
+ // Clean up escape confirmation timeout
1040
+ if (this.escTimeout) {
1041
+ clearTimeout(this.escTimeout);
1042
+ this.escTimeout = null;
1043
+ }
1044
+ this.escPendingPause = false;
1045
+ // Clean up input handling
1046
+ if (this.keyHandler) {
1047
+ process.stdin.removeListener('data', this.keyHandler);
1048
+ this.keyHandler = null;
1049
+ }
1050
+ if (process.stdin.isTTY) {
1051
+ process.stdin.setRawMode(false);
1052
+ }
1053
+ // Clean up resize handler
1054
+ if (this.resizeHandler) {
1055
+ process.stdout.removeListener('resize', this.resizeHandler);
1056
+ this.resizeHandler = null;
1057
+ }
1058
+ // Clear the display (restore cursor and clear)
1059
+ if (!this.isFirstRender) {
1060
+ process.stdout.write('\x1b8'); // Restore to saved position
1061
+ process.stdout.write('\x1b[J'); // Clear from cursor to end of screen
1062
+ }
1063
+ if (finalMessage) {
1064
+ console.log(finalMessage);
1065
+ }
1066
+ }
1067
+ /**
1068
+ * Enter planning mode - show task list overlay while execution continues
1069
+ */
1070
+ async enterPlanningMode() {
1071
+ if (!this.planModificationCallback?.getTasks) {
1072
+ return;
1073
+ }
1074
+ this.planningMode = true;
1075
+ this.planningMessage = '';
1076
+ // Load current tasks
1077
+ try {
1078
+ this.planningTasks = await this.planModificationCallback.getTasks();
1079
+ // Find first pending task (skip completed and current in_progress)
1080
+ const pendingIndex = this.planningTasks.findIndex(t => t.status === 'pending' && t.id !== this.currentTaskId);
1081
+ this.planningSelectedIndex = pendingIndex >= 0 ? pendingIndex : 0;
1082
+ }
1083
+ catch {
1084
+ this.planningTasks = [];
1085
+ this.planningSelectedIndex = 0;
1086
+ }
1087
+ this.render();
1088
+ }
1089
+ /**
1090
+ * Exit planning mode and return to normal execution view
1091
+ */
1092
+ exitPlanningMode() {
1093
+ this.planningMode = false;
1094
+ this.planningTasks = [];
1095
+ this.planningMessage = '';
1096
+ this.inputBuffer = '';
1097
+ this.inputCursorIndex = 0;
1098
+ this.render();
1099
+ }
1100
+ /**
1101
+ * Handle keyboard input in planning mode
1102
+ */
1103
+ handlePlanningKey(char) {
1104
+ // Escape or 'q' - exit planning mode
1105
+ if (char === '\x1b' || char === 'q' || char === 'Q') {
1106
+ this.exitPlanningMode();
1107
+ return;
1108
+ }
1109
+ // Ctrl+C - exit planning mode (don't pause execution)
1110
+ if (char === '\x03') {
1111
+ this.exitPlanningMode();
1112
+ return;
1113
+ }
1114
+ // Arrow up - navigate up
1115
+ if (char === '\x1b[A') {
1116
+ if (this.planningSelectedIndex > 0) {
1117
+ this.planningSelectedIndex--;
1118
+ this.render();
1119
+ }
1120
+ return;
1121
+ }
1122
+ // Arrow down - navigate down
1123
+ if (char === '\x1b[B') {
1124
+ if (this.planningSelectedIndex < this.planningTasks.length - 1) {
1125
+ this.planningSelectedIndex++;
1126
+ this.render();
1127
+ }
1128
+ return;
1129
+ }
1130
+ // Left arrow - move cursor left within input
1131
+ if (char === '\x1b[D') {
1132
+ if (this.inputCursorIndex > 0) {
1133
+ this.inputCursorIndex--;
1134
+ this.render();
1135
+ }
1136
+ return;
1137
+ }
1138
+ // Right arrow - move cursor right within input
1139
+ if (char === '\x1b[C') {
1140
+ if (this.inputCursorIndex < this.inputBuffer.length) {
1141
+ this.inputCursorIndex++;
1142
+ this.render();
1143
+ }
1144
+ return;
1145
+ }
1146
+ // Home key
1147
+ if (char === '\x1b[H' || char === '\x1b[1~' || char === '\x01') {
1148
+ if (this.inputCursorIndex > 0) {
1149
+ this.inputCursorIndex = 0;
1150
+ this.render();
1151
+ }
1152
+ return;
1153
+ }
1154
+ // End key
1155
+ if (char === '\x1b[F' || char === '\x1b[4~' || char === '\x05') {
1156
+ if (this.inputCursorIndex < this.inputBuffer.length) {
1157
+ this.inputCursorIndex = this.inputBuffer.length;
1158
+ this.render();
1159
+ }
1160
+ return;
1161
+ }
1162
+ // Delete key
1163
+ if (char === '\x1b[3~') {
1164
+ if (this.inputCursorIndex < this.inputBuffer.length) {
1165
+ this.inputBuffer = this.inputBuffer.slice(0, this.inputCursorIndex) +
1166
+ this.inputBuffer.slice(this.inputCursorIndex + 1);
1167
+ this.render();
1168
+ }
1169
+ return;
1170
+ }
1171
+ // Enter - submit command or perform default action on selected task
1172
+ if (char === '\r' || char === '\n') {
1173
+ if (this.inputBuffer.trim()) {
1174
+ this.executePlanningCommand(this.inputBuffer.trim());
1175
+ this.inputBuffer = '';
1176
+ this.inputCursorIndex = 0;
1177
+ }
1178
+ return;
1179
+ }
1180
+ // Backspace
1181
+ if (char === '\x7f' || char === '\b') {
1182
+ if (this.inputCursorIndex > 0) {
1183
+ this.inputBuffer = this.inputBuffer.slice(0, this.inputCursorIndex - 1) +
1184
+ this.inputBuffer.slice(this.inputCursorIndex);
1185
+ this.inputCursorIndex--;
1186
+ this.render();
1187
+ }
1188
+ return;
1189
+ }
1190
+ // 's' - skip selected task (shortcut)
1191
+ if ((char === 's' || char === 'S') && !this.inputBuffer) {
1192
+ this.executePlanningCommand('skip');
1193
+ return;
1194
+ }
1195
+ // Regular character - check for printable ASCII or non-ASCII (like Unicode)
1196
+ const code = char.charCodeAt(0);
1197
+ if (char.length >= 1 && ((code >= 32 && code < 127) || code >= 128)) {
1198
+ this.inputBuffer = this.inputBuffer.slice(0, this.inputCursorIndex) +
1199
+ char +
1200
+ this.inputBuffer.slice(this.inputCursorIndex);
1201
+ this.inputCursorIndex += char.length;
1202
+ this.render();
1203
+ }
1204
+ }
1205
+ /**
1206
+ * Execute a planning command
1207
+ */
1208
+ async executePlanningCommand(cmd) {
1209
+ const selectedTask = this.planningTasks[this.planningSelectedIndex];
1210
+ if (!selectedTask) {
1211
+ this.planningMessage = '\x1b[31mNo task selected\x1b[0m';
1212
+ this.render();
1213
+ return;
1214
+ }
1215
+ const lowerCmd = cmd.toLowerCase().trim();
1216
+ // 'done', 'back', 'exit' - exit planning mode
1217
+ if (lowerCmd === 'done' || lowerCmd === 'back' || lowerCmd === 'exit') {
1218
+ this.exitPlanningMode();
1219
+ return;
1220
+ }
1221
+ // 'skip' - skip selected task
1222
+ if (lowerCmd === 'skip') {
1223
+ if (selectedTask.status !== 'pending') {
1224
+ this.planningMessage = `\x1b[33mCan only skip pending tasks\x1b[0m`;
1225
+ this.render();
1226
+ return;
1227
+ }
1228
+ if (selectedTask.id === this.currentTaskId) {
1229
+ this.planningMessage = `\x1b[33mCannot skip currently running task\x1b[0m`;
1230
+ this.render();
1231
+ return;
1232
+ }
1233
+ try {
1234
+ await this.planModificationCallback?.onSkipTask?.(selectedTask.id);
1235
+ this.planningMessage = `\x1b[32m✓ Skipped: ${selectedTask.title}\x1b[0m`;
1236
+ // Refresh task list
1237
+ if (this.planModificationCallback?.getTasks) {
1238
+ this.planningTasks = await this.planModificationCallback.getTasks();
1239
+ }
1240
+ }
1241
+ catch (err) {
1242
+ this.planningMessage = `\x1b[31mFailed to skip task\x1b[0m`;
1243
+ }
1244
+ this.render();
1245
+ return;
1246
+ }
1247
+ // 'up' - move task up in order
1248
+ if (lowerCmd === 'up') {
1249
+ if (selectedTask.status !== 'pending') {
1250
+ this.planningMessage = `\x1b[33mCan only reorder pending tasks\x1b[0m`;
1251
+ this.render();
1252
+ return;
1253
+ }
1254
+ try {
1255
+ await this.planModificationCallback?.onMoveTaskUp?.(selectedTask.id);
1256
+ this.planningMessage = `\x1b[32m✓ Moved up: ${selectedTask.title}\x1b[0m`;
1257
+ // Refresh task list and adjust selection
1258
+ if (this.planModificationCallback?.getTasks) {
1259
+ this.planningTasks = await this.planModificationCallback.getTasks();
1260
+ // Find the task's new position
1261
+ const newIndex = this.planningTasks.findIndex(t => t.id === selectedTask.id);
1262
+ if (newIndex >= 0) {
1263
+ this.planningSelectedIndex = newIndex;
1264
+ }
1265
+ }
1266
+ }
1267
+ catch (err) {
1268
+ this.planningMessage = `\x1b[31mFailed to move task\x1b[0m`;
1269
+ }
1270
+ this.render();
1271
+ return;
1272
+ }
1273
+ // 'down' - move task down in order
1274
+ if (lowerCmd === 'down') {
1275
+ if (selectedTask.status !== 'pending') {
1276
+ this.planningMessage = `\x1b[33mCan only reorder pending tasks\x1b[0m`;
1277
+ this.render();
1278
+ return;
1279
+ }
1280
+ try {
1281
+ await this.planModificationCallback?.onMoveTaskDown?.(selectedTask.id);
1282
+ this.planningMessage = `\x1b[32m✓ Moved down: ${selectedTask.title}\x1b[0m`;
1283
+ // Refresh task list and adjust selection
1284
+ if (this.planModificationCallback?.getTasks) {
1285
+ this.planningTasks = await this.planModificationCallback.getTasks();
1286
+ // Find the task's new position
1287
+ const newIndex = this.planningTasks.findIndex(t => t.id === selectedTask.id);
1288
+ if (newIndex >= 0) {
1289
+ this.planningSelectedIndex = newIndex;
1290
+ }
1291
+ }
1292
+ }
1293
+ catch (err) {
1294
+ this.planningMessage = `\x1b[31mFailed to move task\x1b[0m`;
1295
+ }
1296
+ this.render();
1297
+ return;
1298
+ }
1299
+ // Unknown command
1300
+ this.planningMessage = `\x1b[33mUnknown command: ${cmd}\x1b[0m`;
1301
+ this.render();
1302
+ }
1303
+ clearLines() {
1304
+ if (this.isFirstRender) {
1305
+ this.isFirstRender = false;
1306
+ }
1307
+ else {
1308
+ // Move cursor up by the max number of lines we've ever rendered, then clear to end
1309
+ // Using maxLineCount ensures we clear everything even when output lines grow between renders
1310
+ const linesToClear = Math.max(this.lineCount, this.maxLineCount);
1311
+ if (linesToClear > 0) {
1312
+ process.stdout.write(`\x1b[${linesToClear}A`); // Move cursor up
1313
+ }
1314
+ process.stdout.write('\x1b[J'); // Clear from cursor to end of screen
1315
+ }
1316
+ }
1317
+ formatTime(ms) {
1318
+ const seconds = Math.floor(ms / 1000);
1319
+ const minutes = Math.floor(seconds / 60);
1320
+ const secs = seconds % 60;
1321
+ if (minutes > 0) {
1322
+ return `${minutes}m ${secs}s`;
1323
+ }
1324
+ return `${secs}s`;
1325
+ }
1326
+ /**
1327
+ * Get the total working time (sum of completed task durations + current task elapsed)
1328
+ */
1329
+ getElapsedTime() {
1330
+ const currentTaskElapsed = this.currentTask ? (Date.now() - this.taskStartTime) : 0;
1331
+ return this.formatTime(this.completedDurationMs + currentTaskElapsed);
1332
+ }
1333
+ /**
1334
+ * Get the total working time in milliseconds
1335
+ */
1336
+ getTotalDurationMs() {
1337
+ const currentTaskElapsed = this.currentTask ? (Date.now() - this.taskStartTime) : 0;
1338
+ return this.completedDurationMs + currentTaskElapsed;
1339
+ }
1340
+ /**
1341
+ * Estimate task progress based on phase and elapsed time
1342
+ */
1343
+ getTaskPercent() {
1344
+ if (!this.currentTask)
1345
+ return 0;
1346
+ const elapsed = Date.now() - this.taskStartTime;
1347
+ const EXPECTED_PHASE_MS = 60000; // 1 min expected per phase
1348
+ const phaseProgress = Math.min(elapsed / EXPECTED_PHASE_MS, 0.95); // Cap at 95% within phase
1349
+ switch (this.phase) {
1350
+ case 'executing':
1351
+ return Math.round(phaseProgress * 60); // 0-57%
1352
+ case 'validating':
1353
+ return 60 + Math.round(phaseProgress * 25); // 60-84%
1354
+ case 'committing':
1355
+ return 85 + Math.round(phaseProgress * 15); // 85-99%
1356
+ default:
1357
+ return 0;
1358
+ }
1359
+ }
1360
+ render() {
1361
+ if (!this.isRunning)
1362
+ return;
1363
+ // Full-screen redraw: clear screen, render header, then progress
1364
+ process.stdout.write('\x1b[2J\x1b[H'); // Clear screen and move to top
1365
+ // Render header if available
1366
+ if (this.headerRenderer) {
1367
+ this.headerRenderer();
1368
+ }
1369
+ const lines = [];
1370
+ const barWidth = 30;
1371
+ // Section title
1372
+ lines.push('\x1b[1m── Execution ──\x1b[0m');
1373
+ lines.push('');
1374
+ // Output lines from Claude Code (above progress bars)
1375
+ if (this.lastOutputLines.length > 0) {
1376
+ lines.push('\x1b[90m── Claude Code Output ──\x1b[0m');
1377
+ for (const outputLine of this.lastOutputLines) {
1378
+ lines.push(` ${outputLine}`); // Keep original formatting
1379
+ }
1380
+ lines.push('');
1381
+ }
1382
+ lines.push('');
1383
+ // Task progress bar (only show when there's a current task)
1384
+ // Uses indeterminate "Knight Rider" style animation since we don't know when Claude Code finishes
1385
+ if (this.currentTask) {
1386
+ const taskElapsed = this.formatTime(Date.now() - this.taskStartTime);
1387
+ // Build indeterminate progress bar with bouncing segment
1388
+ let taskBar = '';
1389
+ for (let i = 0; i < barWidth; i++) {
1390
+ if (i >= this.bouncePos && i < this.bouncePos + this.bounceWidth) {
1391
+ taskBar += '\x1b[36m█\x1b[0m'; // Cyan filled segment
1392
+ }
1393
+ else {
1394
+ taskBar += '\x1b[90m░\x1b[0m'; // Gray empty
1395
+ }
1396
+ }
1397
+ const phaseLabel = {
1398
+ executing: '\x1b[36mExecuting\x1b[0m',
1399
+ validating: '\x1b[33mValidating\x1b[0m',
1400
+ committing: '\x1b[32mCommitting\x1b[0m',
1401
+ }[this.phase];
1402
+ lines.push(`${taskBar} 📋 \x1b[1mTask ${this.currentTaskIndex + 1}\x1b[0m ${phaseLabel} \x1b[90m(${taskElapsed})\x1b[0m`);
1403
+ }
1404
+ // Overall project progress (completed + skipped = done)
1405
+ const doneCount = this.completedTasks + this.skippedTasks;
1406
+ const overallPercent = this.totalTasks > 0
1407
+ ? Math.round((doneCount / this.totalTasks) * 100)
1408
+ : 0;
1409
+ // Total working time = completed tasks + current task elapsed
1410
+ const currentTaskElapsed = this.currentTask ? (Date.now() - this.taskStartTime) : 0;
1411
+ const totalWorkingMs = this.completedDurationMs + currentTaskElapsed;
1412
+ const elapsed = this.formatTime(totalWorkingMs);
1413
+ // Progress bar: green for completed, yellow for skipped, gray for remaining
1414
+ const completedWidth = Math.round(barWidth * (this.completedTasks / this.totalTasks));
1415
+ const skippedWidth = Math.round(barWidth * (this.skippedTasks / this.totalTasks));
1416
+ const emptyWidth = barWidth - completedWidth - skippedWidth;
1417
+ const bar = '\x1b[32m' + '█'.repeat(completedWidth) +
1418
+ '\x1b[33m' + '▓'.repeat(skippedWidth) +
1419
+ '\x1b[90m' + '░'.repeat(Math.max(0, emptyWidth)) + '\x1b[0m';
1420
+ const overallPercentStr = `${overallPercent}%`.padStart(4);
1421
+ // Show count: completed+skipped/total if there are skipped, otherwise just completed/total
1422
+ const countStr = this.skippedTasks > 0
1423
+ ? `${this.completedTasks}+${this.skippedTasks}/${this.totalTasks}`
1424
+ : `${this.completedTasks}/${this.totalTasks}`;
1425
+ lines.push(`${bar} ${overallPercentStr} 📊 \x1b[1mProject\x1b[0m \x1b[90m(${countStr} tasks • ${elapsed})\x1b[0m`);
1426
+ lines.push('');
1427
+ // Current task name with spinner
1428
+ if (this.currentTask) {
1429
+ const spinner = this.spinnerFrames[this.frameIndex];
1430
+ lines.push(`\x1b[36m${spinner}\x1b[0m ${this.currentTask}`);
1431
+ }
1432
+ const termWidth = process.stdout.columns || 80;
1433
+ // Planning mode overlay - show task list for modification
1434
+ if (this.planningMode && this.planningTasks.length > 0) {
1435
+ lines.push('');
1436
+ lines.push('\x1b[1m\x1b[36m── Plan (concurrent mode) ──\x1b[0m \x1b[90mExecution continues in background\x1b[0m');
1437
+ lines.push('');
1438
+ // Show task list with selection indicator - cap at 20 items max
1439
+ const maxVisibleTasks = Math.min(20, this.planningTasks.length);
1440
+ const startIndex = Math.max(0, this.planningSelectedIndex - Math.floor(maxVisibleTasks / 2));
1441
+ const endIndex = Math.min(this.planningTasks.length, startIndex + maxVisibleTasks);
1442
+ for (let i = startIndex; i < endIndex; i++) {
1443
+ const task = this.planningTasks[i];
1444
+ const isSelected = i === this.planningSelectedIndex;
1445
+ const isCurrent = task.id === this.currentTaskId;
1446
+ // Status icon
1447
+ let icon = formatTaskStatusIcon(task.status, task.failureType);
1448
+ // Build task line
1449
+ let line = isSelected ? '\x1b[36m❯\x1b[0m ' : ' ';
1450
+ line += icon + ' ';
1451
+ // Task title with truncation
1452
+ const maxTitleWidth = termWidth - 20;
1453
+ let title = task.title;
1454
+ if (title.length > maxTitleWidth) {
1455
+ title = title.slice(0, maxTitleWidth - 3) + '...';
1456
+ }
1457
+ if (isCurrent) {
1458
+ line += `\x1b[36m${title}\x1b[0m \x1b[90m(running)\x1b[0m`;
1459
+ }
1460
+ else if (isSelected) {
1461
+ line += `\x1b[1m${title}\x1b[0m`;
1462
+ }
1463
+ else {
1464
+ line += title;
1465
+ }
1466
+ lines.push(line);
1467
+ }
1468
+ // Show scroll indicator if needed
1469
+ if (this.planningTasks.length > maxVisibleTasks) {
1470
+ const scrollInfo = `\x1b[90m(${this.planningSelectedIndex + 1}/${this.planningTasks.length})\x1b[0m`;
1471
+ lines.push(scrollInfo);
1472
+ }
1473
+ // Show feedback message if any
1474
+ if (this.planningMessage) {
1475
+ lines.push('');
1476
+ lines.push(this.planningMessage);
1477
+ }
1478
+ lines.push('');
1479
+ }
1480
+ // Input bar at the bottom - unified structure with hint OUTSIDE the box
1481
+ let hint;
1482
+ if (this.planningMode) {
1483
+ hint = '↑↓ navigate · skip · up/down · done to exit';
1484
+ }
1485
+ else if (this.escPendingPause) {
1486
+ hint = '\x1b[33mEsc again to pause\x1b[0m';
1487
+ }
1488
+ else {
1489
+ hint = 'Esc pause · type to discuss';
1490
+ }
1491
+ // Top border
1492
+ lines.push(`\x1b[90m${'─'.repeat(termWidth)}\x1b[0m`);
1493
+ // Input line (no hint inside) with cursor at correct position
1494
+ if (this.inputBuffer) {
1495
+ const beforeCursor = this.inputBuffer.slice(0, this.inputCursorIndex);
1496
+ const afterCursor = this.inputBuffer.slice(this.inputCursorIndex);
1497
+ // Show cursor as inverted space at cursor position
1498
+ lines.push(`\x1b[90m❯\x1b[0m ${beforeCursor}\x1b[7m${afterCursor.charAt(0) || ' '}\x1b[0m${afterCursor.slice(1)}`);
1499
+ }
1500
+ else {
1501
+ const placeholder = this.planningMode ? 'type command...' : 'type to interact...';
1502
+ lines.push(`\x1b[90m❯ ${placeholder}\x1b[0m`);
1503
+ }
1504
+ // Bottom border
1505
+ lines.push(`\x1b[90m${'─'.repeat(termWidth)}\x1b[0m`);
1506
+ // Hint OUTSIDE the box, right-aligned
1507
+ const hintPadding = Math.max(0, termWidth - hint.length);
1508
+ lines.push(`${' '.repeat(hintPadding)}\x1b[90m${hint}\x1b[0m`);
1509
+ // Print all lines
1510
+ const output = lines.join('\n');
1511
+ process.stdout.write(output);
1512
+ this.lineCount = lines.length;
1513
+ this.maxLineCount = Math.max(this.maxLineCount, this.lineCount);
1514
+ }
1515
+ /**
1516
+ * Log a message without disrupting the display
1517
+ */
1518
+ log(message) {
1519
+ // Clear display (move cursor up by line count and clear)
1520
+ if (!this.isFirstRender && this.lineCount > 0) {
1521
+ process.stdout.write(`\x1b[${this.lineCount}A`); // Move cursor up
1522
+ process.stdout.write('\x1b[J'); // Clear from cursor to end of screen
1523
+ }
1524
+ // Print message
1525
+ console.log(message);
1526
+ // Reset for next render
1527
+ this.isFirstRender = true;
1528
+ this.render();
1529
+ }
1530
+ }
1531
+ //# sourceMappingURL=ui.js.map