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.
- package/LICENSE +15 -0
- package/README.md +107 -0
- package/dist/app.d.ts +247 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +4971 -0
- package/dist/app.js.map +1 -0
- package/dist/buildInfo.d.ts +5 -0
- package/dist/buildInfo.d.ts.map +1 -0
- package/dist/buildInfo.js +2 -0
- package/dist/buildInfo.js.map +1 -0
- package/dist/caffeinate.d.ts +72 -0
- package/dist/caffeinate.d.ts.map +1 -0
- package/dist/caffeinate.js +258 -0
- package/dist/caffeinate.js.map +1 -0
- package/dist/claudePath.d.ts +10 -0
- package/dist/claudePath.d.ts.map +1 -0
- package/dist/claudePath.js +34 -0
- package/dist/claudePath.js.map +1 -0
- package/dist/clipboard.d.ts +44 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +442 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/config.d.ts +211 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +933 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +50 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +81 -0
- package/dist/constants.js.map +1 -0
- package/dist/contextBuilder.d.ts +38 -0
- package/dist/contextBuilder.d.ts.map +1 -0
- package/dist/contextBuilder.js +113 -0
- package/dist/contextBuilder.js.map +1 -0
- package/dist/dependencyDetector.d.ts +57 -0
- package/dist/dependencyDetector.d.ts.map +1 -0
- package/dist/dependencyDetector.js +505 -0
- package/dist/dependencyDetector.js.map +1 -0
- package/dist/executor.d.ts +83 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +583 -0
- package/dist/executor.js.map +1 -0
- package/dist/git.d.ts +85 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +283 -0
- package/dist/git.js.map +1 -0
- package/dist/imageManager.d.ts +161 -0
- package/dist/imageManager.d.ts.map +1 -0
- package/dist/imageManager.js +674 -0
- package/dist/imageManager.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +437 -0
- package/dist/index.js.map +1 -0
- package/dist/input-visual-test.d.ts +9 -0
- package/dist/input-visual-test.d.ts.map +1 -0
- package/dist/input-visual-test.js +108 -0
- package/dist/input-visual-test.js.map +1 -0
- package/dist/inputBox.d.ts +228 -0
- package/dist/inputBox.d.ts.map +1 -0
- package/dist/inputBox.js +966 -0
- package/dist/inputBox.js.map +1 -0
- package/dist/logger.d.ts +136 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +347 -0
- package/dist/logger.js.map +1 -0
- package/dist/orchestrator.d.ts +149 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +821 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/planner.d.ts +86 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +830 -0
- package/dist/planner.js.map +1 -0
- package/dist/pty-test-runner.d.ts +87 -0
- package/dist/pty-test-runner.d.ts.map +1 -0
- package/dist/pty-test-runner.js +721 -0
- package/dist/pty-test-runner.js.map +1 -0
- package/dist/screen.d.ts +44 -0
- package/dist/screen.d.ts.map +1 -0
- package/dist/screen.js +152 -0
- package/dist/screen.js.map +1 -0
- package/dist/taskQueue.d.ts +70 -0
- package/dist/taskQueue.d.ts.map +1 -0
- package/dist/taskQueue.js +282 -0
- package/dist/taskQueue.js.map +1 -0
- package/dist/tui-test-harness.d.ts +216 -0
- package/dist/tui-test-harness.d.ts.map +1 -0
- package/dist/tui-test-harness.js +527 -0
- package/dist/tui-test-harness.js.map +1 -0
- package/dist/types.d.ts +257 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +46 -0
- package/dist/types.js.map +1 -0
- package/dist/ui-visual-test.d.ts +15 -0
- package/dist/ui-visual-test.d.ts.map +1 -0
- package/dist/ui-visual-test.js +141 -0
- package/dist/ui-visual-test.js.map +1 -0
- package/dist/ui.d.ts +272 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +1531 -0
- package/dist/ui.js.map +1 -0
- package/dist/validator.d.ts +53 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +491 -0
- package/dist/validator.js.map +1 -0
- package/dist/versionCheck.d.ts +63 -0
- package/dist/versionCheck.d.ts.map +1 -0
- package/dist/versionCheck.js +261 -0
- package/dist/versionCheck.js.map +1 -0
- 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
|