ucode-agent 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -17
- package/package.json +3 -2
- package/skills/build-app/SKILL.md +4 -2
- package/skills/ui-ux/SKILL.md +5 -1
- package/src/core/context.js +151 -0
- package/src/core/loop.js +550 -40
- package/src/core/provider.js +58 -15
- package/src/core/updater.js +95 -0
- package/src/tools/browser.js +258 -0
- package/src/tools/files.js +173 -16
- package/src/tools/index.js +472 -394
- package/src/tools/shell.js +149 -1
- package/src/ui/plain.js +330 -325
- package/src/ui/screen.js +27 -7
- package/src/ui/theme.js +18 -0
package/src/ui/plain.js
CHANGED
|
@@ -1,325 +1,330 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* plain.js — the interface for when there is no terminal to draw on.
|
|
3
|
-
*
|
|
4
|
-
* Piped input, CI, `echo "fix the test" | ucode`, a shell wrapper that hands
|
|
5
|
-
* over a pipe instead of a keyboard. There is no frame to repaint here, so
|
|
6
|
-
* output is simply printed in order and the prompt is one readline line.
|
|
7
|
-
*
|
|
8
|
-
* It carries the same method names as screen.js on purpose: the agent loop
|
|
9
|
-
* talks to one interface and never asks which one it got.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import readline from 'node:readline';
|
|
13
|
-
import chalk from 'chalk';
|
|
14
|
-
import {
|
|
15
|
-
theme, blue, sky, dim, boxTop, boxBottom, boxRow,
|
|
16
|
-
BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen,
|
|
17
|
-
} from './theme.js';
|
|
18
|
-
import { renderer, render } from './markdown.js';
|
|
19
|
-
|
|
20
|
-
const COMMANDS = [
|
|
21
|
-
'/help', '/model', '/models', '/session', '/sessions', '/resume',
|
|
22
|
-
'/new', '/skills', '/clear', '/search', '/copy', '/exit',
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
export class Plain {
|
|
26
|
-
constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
|
|
27
|
-
this.cwd = cwd;
|
|
28
|
-
this.output = output;
|
|
29
|
-
this.closed = false;
|
|
30
|
-
this.mode = 'build'; // no way to toggle without a keyboard; stays here
|
|
31
|
-
this.model = '';
|
|
32
|
-
this.timer = null;
|
|
33
|
-
this.frame = 0;
|
|
34
|
-
this.md = renderer(output.columns || 80);
|
|
35
|
-
|
|
36
|
-
this.rl = readline.createInterface({
|
|
37
|
-
input,
|
|
38
|
-
output,
|
|
39
|
-
historySize: 200,
|
|
40
|
-
completer(line) {
|
|
41
|
-
if (!line.startsWith('/')) return [[], line];
|
|
42
|
-
const hits = COMMANDS.filter((c) => c.startsWith(line));
|
|
43
|
-
return [hits.length ? hits : COMMANDS, line];
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// Input is queued rather than read with rl.question(). When stdin is a
|
|
48
|
-
// pipe, readline emits every buffered line at once, so a question-per-turn
|
|
49
|
-
// loop would drop all but the first. Queueing behaves the same way
|
|
50
|
-
// interactively and makes piping work.
|
|
51
|
-
this.queue = [];
|
|
52
|
-
this.waiters = [];
|
|
53
|
-
|
|
54
|
-
this.rl.on('line', (line) => {
|
|
55
|
-
const clean = line.replace(/^/, ''); // strip a BOM on the first line
|
|
56
|
-
const waiter = this.waiters.shift();
|
|
57
|
-
if (waiter) waiter(clean);
|
|
58
|
-
else this.queue.push(clean);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
this.rl.on('close', () => {
|
|
62
|
-
this.closed = true;
|
|
63
|
-
while (this.waiters.length) this.waiters.shift()(null);
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
width() {
|
|
68
|
-
return Math.max(30, this.output.columns || 80);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// -- output --------------------------------------------------------------
|
|
72
|
-
|
|
73
|
-
write(text = '') {
|
|
74
|
-
this.stopSpinner();
|
|
75
|
-
this.output.write(`${text}\n`);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
blank() { this.write(''); }
|
|
79
|
-
note(text) { this.write(dim(` ${text}`)); }
|
|
80
|
-
|
|
81
|
-
clearScreen() {
|
|
82
|
-
this.stopSpinner();
|
|
83
|
-
this.output.write('\x1B[2J\x1B[3J\x1B[H');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
header({ cwd, model, used, limit, title }) {
|
|
87
|
-
this.stopSpinner();
|
|
88
|
-
this.model = model || this.model;
|
|
89
|
-
this.percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
|
90
|
-
|
|
91
|
-
const width = this.width();
|
|
92
|
-
const room = Math.max(8, width - BANNER_WIDTH - 8);
|
|
93
|
-
|
|
94
|
-
const facts = [
|
|
95
|
-
['dir', shortenPath(cwd, room - 9)],
|
|
96
|
-
['keys', '/help'],
|
|
97
|
-
['', ''],
|
|
98
|
-
['', ''],
|
|
99
|
-
['', 'made with ❤️ by om dixit'],
|
|
100
|
-
];
|
|
101
|
-
|
|
102
|
-
// There is no input box to hang the status off here, so it goes on the
|
|
103
|
-
// last row inside the header box — still framed, still the same three
|
|
104
|
-
// facts, just attached to the only box this interface has.
|
|
105
|
-
const rows = width >= BANNER_WIDTH + 30
|
|
106
|
-
? BANNER.map((art, i) => {
|
|
107
|
-
const [label, value] = facts[i] ?? ['', ''];
|
|
108
|
-
const right = label
|
|
109
|
-
? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
|
|
110
|
-
: (value ? dim(value) : '');
|
|
111
|
-
return ` ${blue(art)} ${right}`;
|
|
112
|
-
})
|
|
113
|
-
: [
|
|
114
|
-
` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
|
|
115
|
-
...facts
|
|
116
|
-
.filter(([label]) => label)
|
|
117
|
-
.map(([label, value]) => ` ${dim(label.padEnd(9))}${chalk.white(clip(value, width - 16))}`),
|
|
118
|
-
];
|
|
119
|
-
|
|
120
|
-
this.write('');
|
|
121
|
-
this.write(boxTop(width));
|
|
122
|
-
for (const row of rows) this.write(boxRow(row, width));
|
|
123
|
-
this.write(boxRow(this.statusRow(), width));
|
|
124
|
-
this.write(boxBottom(width));
|
|
125
|
-
this.write('');
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
setFacts() { /* nothing to repaint without a frame */ }
|
|
129
|
-
|
|
130
|
-
/** The same three facts the full screen shows, on the row under the header. */
|
|
131
|
-
statusRow() {
|
|
132
|
-
const inner = this.width() - 2;
|
|
133
|
-
const chip = this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
|
|
134
|
-
const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
|
|
135
|
-
const percent = Math.round(this.percent ?? 0);
|
|
136
|
-
const right = `${percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`)} `;
|
|
137
|
-
const pad = Math.max(1, inner - visLen(left) - visLen(right));
|
|
138
|
-
return padVis(left + ' '.repeat(pad) + right, inner);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
toolCall(label) {
|
|
142
|
-
this.stopSpinner();
|
|
143
|
-
this.output.write(`${blue('●')} ${asLabel(label)}\n`);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
this.
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
this.stopSpinner();
|
|
153
|
-
this.output.write(
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
this.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
this.
|
|
221
|
-
this.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
this.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
this.
|
|
254
|
-
this.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
this.
|
|
260
|
-
return this.
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
this.stopSpinner();
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
this.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
this.output.write(`${theme.error('✗')} ${chalk.white(
|
|
310
|
-
this.output.write(` ${err
|
|
311
|
-
this.output.write(` ${blue('→')}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
this.
|
|
324
|
-
}
|
|
325
|
-
|
|
1
|
+
/**
|
|
2
|
+
* plain.js — the interface for when there is no terminal to draw on.
|
|
3
|
+
*
|
|
4
|
+
* Piped input, CI, `echo "fix the test" | ucode`, a shell wrapper that hands
|
|
5
|
+
* over a pipe instead of a keyboard. There is no frame to repaint here, so
|
|
6
|
+
* output is simply printed in order and the prompt is one readline line.
|
|
7
|
+
*
|
|
8
|
+
* It carries the same method names as screen.js on purpose: the agent loop
|
|
9
|
+
* talks to one interface and never asks which one it got.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import readline from 'node:readline';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import {
|
|
15
|
+
theme, blue, sky, dim, boxTop, boxBottom, boxRow,
|
|
16
|
+
BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen, planLine,
|
|
17
|
+
} from './theme.js';
|
|
18
|
+
import { renderer, render } from './markdown.js';
|
|
19
|
+
|
|
20
|
+
const COMMANDS = [
|
|
21
|
+
'/help', '/model', '/models', '/session', '/sessions', '/resume',
|
|
22
|
+
'/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export class Plain {
|
|
26
|
+
constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
|
|
27
|
+
this.cwd = cwd;
|
|
28
|
+
this.output = output;
|
|
29
|
+
this.closed = false;
|
|
30
|
+
this.mode = 'build'; // no way to toggle without a keyboard; stays here
|
|
31
|
+
this.model = '';
|
|
32
|
+
this.timer = null;
|
|
33
|
+
this.frame = 0;
|
|
34
|
+
this.md = renderer(output.columns || 80);
|
|
35
|
+
|
|
36
|
+
this.rl = readline.createInterface({
|
|
37
|
+
input,
|
|
38
|
+
output,
|
|
39
|
+
historySize: 200,
|
|
40
|
+
completer(line) {
|
|
41
|
+
if (!line.startsWith('/')) return [[], line];
|
|
42
|
+
const hits = COMMANDS.filter((c) => c.startsWith(line));
|
|
43
|
+
return [hits.length ? hits : COMMANDS, line];
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Input is queued rather than read with rl.question(). When stdin is a
|
|
48
|
+
// pipe, readline emits every buffered line at once, so a question-per-turn
|
|
49
|
+
// loop would drop all but the first. Queueing behaves the same way
|
|
50
|
+
// interactively and makes piping work.
|
|
51
|
+
this.queue = [];
|
|
52
|
+
this.waiters = [];
|
|
53
|
+
|
|
54
|
+
this.rl.on('line', (line) => {
|
|
55
|
+
const clean = line.replace(/^/, ''); // strip a BOM on the first line
|
|
56
|
+
const waiter = this.waiters.shift();
|
|
57
|
+
if (waiter) waiter(clean);
|
|
58
|
+
else this.queue.push(clean);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.rl.on('close', () => {
|
|
62
|
+
this.closed = true;
|
|
63
|
+
while (this.waiters.length) this.waiters.shift()(null);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
width() {
|
|
68
|
+
return Math.max(30, this.output.columns || 80);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// -- output --------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
write(text = '') {
|
|
74
|
+
this.stopSpinner();
|
|
75
|
+
this.output.write(`${text}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
blank() { this.write(''); }
|
|
79
|
+
note(text) { this.write(dim(` ${text}`)); }
|
|
80
|
+
|
|
81
|
+
clearScreen() {
|
|
82
|
+
this.stopSpinner();
|
|
83
|
+
this.output.write('\x1B[2J\x1B[3J\x1B[H');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
header({ cwd, model, used, limit, title }) {
|
|
87
|
+
this.stopSpinner();
|
|
88
|
+
this.model = model || this.model;
|
|
89
|
+
this.percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
|
90
|
+
|
|
91
|
+
const width = this.width();
|
|
92
|
+
const room = Math.max(8, width - BANNER_WIDTH - 8);
|
|
93
|
+
|
|
94
|
+
const facts = [
|
|
95
|
+
['dir', shortenPath(cwd, room - 9)],
|
|
96
|
+
['keys', '/help'],
|
|
97
|
+
['', ''],
|
|
98
|
+
['', ''],
|
|
99
|
+
['', 'made with ❤️ by om dixit'],
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// There is no input box to hang the status off here, so it goes on the
|
|
103
|
+
// last row inside the header box — still framed, still the same three
|
|
104
|
+
// facts, just attached to the only box this interface has.
|
|
105
|
+
const rows = width >= BANNER_WIDTH + 30
|
|
106
|
+
? BANNER.map((art, i) => {
|
|
107
|
+
const [label, value] = facts[i] ?? ['', ''];
|
|
108
|
+
const right = label
|
|
109
|
+
? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
|
|
110
|
+
: (value ? dim(value) : '');
|
|
111
|
+
return ` ${blue(art)} ${right}`;
|
|
112
|
+
})
|
|
113
|
+
: [
|
|
114
|
+
` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
|
|
115
|
+
...facts
|
|
116
|
+
.filter(([label]) => label)
|
|
117
|
+
.map(([label, value]) => ` ${dim(label.padEnd(9))}${chalk.white(clip(value, width - 16))}`),
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
this.write('');
|
|
121
|
+
this.write(boxTop(width));
|
|
122
|
+
for (const row of rows) this.write(boxRow(row, width));
|
|
123
|
+
this.write(boxRow(this.statusRow(), width));
|
|
124
|
+
this.write(boxBottom(width));
|
|
125
|
+
this.write('');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setFacts() { /* nothing to repaint without a frame */ }
|
|
129
|
+
|
|
130
|
+
/** The same three facts the full screen shows, on the row under the header. */
|
|
131
|
+
statusRow() {
|
|
132
|
+
const inner = this.width() - 2;
|
|
133
|
+
const chip = this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
|
|
134
|
+
const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
|
|
135
|
+
const percent = Math.round(this.percent ?? 0);
|
|
136
|
+
const right = `${percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`)} `;
|
|
137
|
+
const pad = Math.max(1, inner - visLen(left) - visLen(right));
|
|
138
|
+
return padVis(left + ' '.repeat(pad) + right, inner);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
toolCall(label) {
|
|
142
|
+
this.stopSpinner();
|
|
143
|
+
this.output.write(`${blue('●')} ${asLabel(label)}\n`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
plan(items) {
|
|
147
|
+
const line = planLine(items);
|
|
148
|
+
if (line) this.write(line);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
toolResult(summary) {
|
|
152
|
+
this.stopSpinner();
|
|
153
|
+
this.output.write(dim(` └ ${summary}\n`));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
toolFailed(summary) {
|
|
157
|
+
this.stopSpinner();
|
|
158
|
+
this.output.write(`${dim(' └ ')}${theme.error(summary)}\n`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The change, with the same line-number gutter the full screen uses. */
|
|
162
|
+
diff(lines) {
|
|
163
|
+
this.stopSpinner();
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
if (line.startsWith('~')) {
|
|
166
|
+
this.output.write(` ${sky(line.slice(1))}\n`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const added = line.startsWith('+');
|
|
170
|
+
const rest = line.slice(1);
|
|
171
|
+
const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
|
|
172
|
+
if (!parsed) {
|
|
173
|
+
this.output.write(` ${dim(rest)}\n`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const [, number, body] = parsed;
|
|
177
|
+
const paint = added ? theme.ok : theme.error;
|
|
178
|
+
this.output.write(` ${dim(number.padStart(6))} ${paint(`${added ? '+' : '-'} ${body}`)}\n`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
commandOutput(lines) {
|
|
183
|
+
this.stopSpinner();
|
|
184
|
+
for (const line of lines) this.output.write(` ${dim(line)}\n`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
assistant(text) {
|
|
188
|
+
const out = render(this.md, text);
|
|
189
|
+
if (!out) return;
|
|
190
|
+
this.stopSpinner();
|
|
191
|
+
this.output.write(`\n${out}\n\n`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
narrate(text) {
|
|
195
|
+
const line = asLabel(text);
|
|
196
|
+
if (!line) return;
|
|
197
|
+
this.stopSpinner();
|
|
198
|
+
this.write(dim(` ⋮ ${line}`));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
progress(lines) {
|
|
202
|
+
const last = lines[lines.length - 1]?.trim();
|
|
203
|
+
if (last) this.updateSpinner(last);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Streaming has nowhere to repaint here, so the reply is printed whole when
|
|
207
|
+
// it is finished. The loop only streams into a real terminal anyway.
|
|
208
|
+
streamBegin() {}
|
|
209
|
+
streamDelta() {}
|
|
210
|
+
streamEnd() { return ''; }
|
|
211
|
+
thinkingDelta() {}
|
|
212
|
+
thinkingEnd() {}
|
|
213
|
+
|
|
214
|
+
// -- spinner -------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
startSpinner(text = 'thinking') {
|
|
217
|
+
this.stopSpinner();
|
|
218
|
+
if (!this.output.isTTY) return; // a pipe does not want animation frames
|
|
219
|
+
this.spinnerText = asLabel(text);
|
|
220
|
+
this.since = Date.now();
|
|
221
|
+
this.timer = setInterval(() => {
|
|
222
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
223
|
+
this.paintSpinner();
|
|
224
|
+
}, 100);
|
|
225
|
+
this.timer.unref?.();
|
|
226
|
+
this.paintSpinner();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
paintSpinner() {
|
|
230
|
+
const secs = Math.round((Date.now() - this.since) / 1000);
|
|
231
|
+
const line = ` ${blue(SPINNER[this.frame])} ${dim(this.spinnerText)}` +
|
|
232
|
+
(secs >= 2 ? dim(` ${secs}s`) : '');
|
|
233
|
+
this.output.write(`\r\x1b[K${padVis(line, this.width() - 1)}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
updateSpinner(text) {
|
|
237
|
+
if (!this.timer) return;
|
|
238
|
+
this.spinnerText = asLabel(text);
|
|
239
|
+
this.paintSpinner();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
stopSpinner() {
|
|
243
|
+
if (!this.timer) return;
|
|
244
|
+
clearInterval(this.timer);
|
|
245
|
+
this.timer = null;
|
|
246
|
+
this.output.write('\r\x1b[K');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// -- input ---------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
nextLine() {
|
|
252
|
+
if (this.queue.length) return Promise.resolve(this.queue.shift());
|
|
253
|
+
if (this.closed) return Promise.resolve(null);
|
|
254
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
promptWith(text) {
|
|
258
|
+
this.rl.setPrompt(text);
|
|
259
|
+
this.rl.prompt();
|
|
260
|
+
return this.nextLine();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
ask() {
|
|
264
|
+
this.stopSpinner();
|
|
265
|
+
return this.promptWith(`${blue('› ')}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async confirm({ action, detail, risk }) {
|
|
269
|
+
this.stopSpinner();
|
|
270
|
+
const badge = risk === 'command' ? ' shell ' : ' outside project ';
|
|
271
|
+
this.output.write(`\n${chalk.inverse(theme.warn(badge))} ${chalk.white(action)}\n`);
|
|
272
|
+
for (const line of String(detail ?? '').split('\n')) {
|
|
273
|
+
if (line) this.output.write(dim(` ${line}\n`));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const answer = await this.promptWith(`${blue(' go ahead? ')}${dim('[y/N] ')}`);
|
|
277
|
+
// End of input is a no: never run something nobody approved.
|
|
278
|
+
const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
|
|
279
|
+
this.output.write(dim(yes ? ' approved\n\n' : ' declined\n\n'));
|
|
280
|
+
return yes;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async choose(prompt, items, { allowNone = true } = {}) {
|
|
284
|
+
this.stopSpinner();
|
|
285
|
+
items.forEach((item, i) => this.output.write(` ${blue(String(i + 1).padStart(2))}. ${item}\n`));
|
|
286
|
+
if (allowNone) this.output.write(dim(' 0. none — start fresh\n'));
|
|
287
|
+
this.output.write('\n');
|
|
288
|
+
|
|
289
|
+
const answer = await this.promptWith(`${blue('› ')}${dim(`${prompt} `)}`);
|
|
290
|
+
if (answer === null) return null;
|
|
291
|
+
|
|
292
|
+
const trimmed = String(answer).trim();
|
|
293
|
+
if (trimmed === '' || trimmed === '0') return null;
|
|
294
|
+
|
|
295
|
+
const index = Number(trimmed);
|
|
296
|
+
if (!Number.isInteger(index) || index < 1 || index > items.length) {
|
|
297
|
+
this.write(theme.warn(` "${trimmed}" is not one of 1-${items.length}. Starting fresh.`));
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
return index - 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
error(err, { debug = false } = {}) {
|
|
304
|
+
this.stopSpinner();
|
|
305
|
+
const known = err && typeof err === 'object' && err.attempted;
|
|
306
|
+
|
|
307
|
+
this.output.write('\n');
|
|
308
|
+
if (known) {
|
|
309
|
+
this.output.write(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}\n`);
|
|
310
|
+
this.output.write(` ${err.failed}\n`);
|
|
311
|
+
if (err.fix) this.output.write(` ${blue('→')} ${err.fix}\n`);
|
|
312
|
+
if (err.kind) this.output.write(dim(` (${err.kind})\n`));
|
|
313
|
+
} else {
|
|
314
|
+
this.output.write(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}\n`);
|
|
315
|
+
this.output.write(` ${err?.message ?? String(err)}\n`);
|
|
316
|
+
this.output.write(` ${blue('→')} That is a bug in ucode rather than in your project. Re-run with --debug.\n`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (debug) {
|
|
320
|
+
const stack = (known && err.cause?.stack) || err?.stack;
|
|
321
|
+
if (stack) this.output.write(dim(`\n${stack}\n`));
|
|
322
|
+
}
|
|
323
|
+
this.output.write('\n');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
close() {
|
|
327
|
+
this.stopSpinner();
|
|
328
|
+
this.rl.close();
|
|
329
|
+
}
|
|
330
|
+
}
|