ucode-agent 1.21.0 → 1.23.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/package.json +1 -1
- package/src/core/loop.js +19 -10
- package/src/ui/plain.js +351 -349
- package/src/ui/screen.js +26 -49
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -459,6 +459,12 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
459
459
|
'already contains it. Do not re-check work the checks have already reported on.',
|
|
460
460
|
'Fast is not sloppy: it is the same work with the waiting taken out.',
|
|
461
461
|
'',
|
|
462
|
+
'Never repeat the request back. Not as a summary, not as a restatement, not as',
|
|
463
|
+
'a list of what was asked for. They wrote it and it is on the screen above you.',
|
|
464
|
+
'Do not narrate your planning either - which files you will make, what order you',
|
|
465
|
+
'will do them in, what you are about to consider. Say what you are building and',
|
|
466
|
+
'then build it.',
|
|
467
|
+
'',
|
|
462
468
|
'SPEAK AS "I", NEVER "WE". You are doing this, not a committee: "I will build',
|
|
463
469
|
'Tide as a single HTML file", not "we have created the file".',
|
|
464
470
|
'',
|
|
@@ -478,15 +484,18 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
478
484
|
'steps already show on screen, and repeating them in words buries the few',
|
|
479
485
|
'sentences worth reading.',
|
|
480
486
|
'',
|
|
481
|
-
'FIRST,
|
|
482
|
-
'
|
|
483
|
-
'
|
|
484
|
-
'
|
|
485
|
-
'
|
|
486
|
-
'
|
|
487
|
-
'
|
|
488
|
-
'
|
|
489
|
-
'',
|
|
487
|
+
'YOUR FIRST WORDS, BEFORE ANY TOOL CALL, EVERY TIME. Nothing else appears on',
|
|
488
|
+
'screen while you work, so if you say nothing the user watches a blank page.',
|
|
489
|
+
'Open with one line: I will build Tide for you - a tasks app in a single HTML',
|
|
490
|
+
'file. Then one line each time you start a new piece of the work: Now the',
|
|
491
|
+
'components. / Now making the filter row. / Onto the animations. Then one',
|
|
492
|
+
'line at the end: what it does and how to try it.',
|
|
493
|
+
'',
|
|
494
|
+
'Those lines are the whole of what the user reads. They are not thinking out',
|
|
495
|
+
'loud: never "I need to", never "Let me", never "The user wants", never a plan',
|
|
496
|
+
'of which files you will touch. Say what you are doing, the way you would to',
|
|
497
|
+
'someone watching over your shoulder.',
|
|
498
|
+
'',
|
|
490
499
|
|
|
491
500
|
'',
|
|
492
501
|
'Before you guess at an API, ask: type_of gives the exact signature from the',
|
|
@@ -1411,7 +1420,7 @@ export class Agent {
|
|
|
1411
1420
|
// A build or type check that just passed already verified everything
|
|
1412
1421
|
// changed so far; the automatic check at the end would only repeat it.
|
|
1413
1422
|
if (call.name === 'run_command' && out.exitCode === 0 &&
|
|
1414
|
-
|
|
1423
|
+
/(?:next build|npm run build|pnpm (?:run )?build|tsc)/.test(call.args?.command ?? '')) {
|
|
1415
1424
|
this.sinceCheck?.clear();
|
|
1416
1425
|
}
|
|
1417
1426
|
if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
|
package/src/ui/plain.js
CHANGED
|
@@ -1,349 +1,351 @@
|
|
|
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 { formatDuration, doneLine } from './activity.js';
|
|
19
|
-
import { renderer, render } from './markdown.js';
|
|
20
|
-
|
|
21
|
-
const COMMANDS = [
|
|
22
|
-
'/help', '/model', '/models', '/session', '/sessions', '/resume',
|
|
23
|
-
'/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
export class Plain {
|
|
27
|
-
constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
|
|
28
|
-
this.cwd = cwd;
|
|
29
|
-
this.output = output;
|
|
30
|
-
this.closed = false;
|
|
31
|
-
this.mode = 'build'; // no way to toggle without a keyboard; stays here
|
|
32
|
-
this.model = '';
|
|
33
|
-
this.timer = null;
|
|
34
|
-
this.frame = 0;
|
|
35
|
-
this.md = renderer(output.columns || 80);
|
|
36
|
-
|
|
37
|
-
this.rl = readline.createInterface({
|
|
38
|
-
input,
|
|
39
|
-
output,
|
|
40
|
-
historySize: 200,
|
|
41
|
-
completer(line) {
|
|
42
|
-
if (!line.startsWith('/')) return [[], line];
|
|
43
|
-
const hits = COMMANDS.filter((c) => c.startsWith(line));
|
|
44
|
-
return [hits.length ? hits : COMMANDS, line];
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
// Input is queued rather than read with rl.question(). When stdin is a
|
|
49
|
-
// pipe, readline emits every buffered line at once, so a question-per-turn
|
|
50
|
-
// loop would drop all but the first. Queueing behaves the same way
|
|
51
|
-
// interactively and makes piping work.
|
|
52
|
-
this.queue = [];
|
|
53
|
-
this.waiters = [];
|
|
54
|
-
|
|
55
|
-
this.rl.on('line', (line) => {
|
|
56
|
-
const clean = line.replace(/^/, ''); // strip a BOM on the first line
|
|
57
|
-
const waiter = this.waiters.shift();
|
|
58
|
-
if (waiter) waiter(clean);
|
|
59
|
-
else this.queue.push(clean);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
this.rl.on('close', () => {
|
|
63
|
-
this.closed = true;
|
|
64
|
-
while (this.waiters.length) this.waiters.shift()(null);
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
width() {
|
|
69
|
-
return Math.max(30, this.output.columns || 80);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// -- output --------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
write(text = '') {
|
|
75
|
-
this.stopSpinner();
|
|
76
|
-
this.output.write(`${text}\n`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
blank() { this.write(''); }
|
|
80
|
-
note(text) { this.write(dim(` ${text}`)); }
|
|
81
|
-
|
|
82
|
-
clearScreen() {
|
|
83
|
-
this.stopSpinner();
|
|
84
|
-
this.output.write('\x1B[2J\x1B[3J\x1B[H');
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
header({ cwd, model, used, limit, title }) {
|
|
88
|
-
this.stopSpinner();
|
|
89
|
-
this.model = model || this.model;
|
|
90
|
-
this.percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
|
91
|
-
|
|
92
|
-
const width = this.width();
|
|
93
|
-
const room = Math.max(8, width - BANNER_WIDTH - 8);
|
|
94
|
-
|
|
95
|
-
const facts = [
|
|
96
|
-
['dir', shortenPath(cwd, room - 9)],
|
|
97
|
-
['keys', '/help'],
|
|
98
|
-
['', ''],
|
|
99
|
-
['', ''],
|
|
100
|
-
['', 'made with ❤️ by om dixit'],
|
|
101
|
-
];
|
|
102
|
-
|
|
103
|
-
// There is no input box to hang the status off here, so it goes on the
|
|
104
|
-
// last row inside the header box — still framed, still the same three
|
|
105
|
-
// facts, just attached to the only box this interface has.
|
|
106
|
-
const rows = width >= BANNER_WIDTH + 30
|
|
107
|
-
? BANNER.map((art, i) => {
|
|
108
|
-
const [label, value] = facts[i] ?? ['', ''];
|
|
109
|
-
const right = label
|
|
110
|
-
? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
|
|
111
|
-
: (value ? dim(value) : '');
|
|
112
|
-
return ` ${blue(art)} ${right}`;
|
|
113
|
-
})
|
|
114
|
-
: [
|
|
115
|
-
` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
|
|
116
|
-
...facts
|
|
117
|
-
.filter(([label]) => label)
|
|
118
|
-
.map(([label, value]) => ` ${dim(label.padEnd(9))}${chalk.white(clip(value, width - 16))}`),
|
|
119
|
-
];
|
|
120
|
-
|
|
121
|
-
this.write('');
|
|
122
|
-
this.write(boxTop(width));
|
|
123
|
-
for (const row of rows) this.write(boxRow(row, width));
|
|
124
|
-
this.write(boxRow(this.statusRow(), width));
|
|
125
|
-
this.write(boxBottom(width));
|
|
126
|
-
this.write('');
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
setFacts() { /* nothing to repaint without a frame */ }
|
|
130
|
-
|
|
131
|
-
/** The same three facts the full screen shows, on the row under the header. */
|
|
132
|
-
statusRow() {
|
|
133
|
-
const inner = this.width() - 2;
|
|
134
|
-
const chip = this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
|
|
135
|
-
const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
|
|
136
|
-
const percent = Math.round(this.percent ?? 0);
|
|
137
|
-
const right = `${percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`)} `;
|
|
138
|
-
const pad = Math.max(1, inner - visLen(left) - visLen(right));
|
|
139
|
-
return padVis(left + ' '.repeat(pad) + right, inner);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
toolCall(label) {
|
|
143
|
-
this.stopSpinner();
|
|
144
|
-
this.output.write(`${blue('●')} ${asLabel(label)}\n`);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
plan(items) {
|
|
148
|
-
const line = planLine(items);
|
|
149
|
-
if (line) this.write(line);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
toolResult(summary) {
|
|
153
|
-
this.stopSpinner();
|
|
154
|
-
this.output.write(dim(` └ ${summary}\n`));
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
toolFailed(summary) {
|
|
158
|
-
this.stopSpinner();
|
|
159
|
-
this.output.write(`${dim(' └ ')}${theme.error(summary)}\n`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** The change, with the same line-number gutter the full screen uses. */
|
|
163
|
-
diff(lines) {
|
|
164
|
-
this.stopSpinner();
|
|
165
|
-
for (const line of lines) {
|
|
166
|
-
if (line.startsWith('~')) {
|
|
167
|
-
this.output.write(` ${sky(line.slice(1))}\n`);
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
const added = line.startsWith('+');
|
|
171
|
-
const rest = line.slice(1);
|
|
172
|
-
const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
|
|
173
|
-
if (!parsed) {
|
|
174
|
-
this.output.write(` ${dim(rest)}\n`);
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
const [, number, body] = parsed;
|
|
178
|
-
const paint = added ? theme.ok : theme.error;
|
|
179
|
-
this.output.write(` ${dim(number.padStart(6))} ${paint(`${added ? '+' : '-'} ${body}`)}\n`);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
commandOutput(lines) {
|
|
184
|
-
this.stopSpinner();
|
|
185
|
-
for (const line of lines) this.output.write(` ${dim(line)}\n`);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
assistant(text) {
|
|
189
|
-
const out = render(this.md, text);
|
|
190
|
-
if (!out) return;
|
|
191
|
-
this.stopSpinner();
|
|
192
|
-
this.output.write(`\n${out}\n\n`);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
narrate(text) {
|
|
196
|
-
const line = asLabel(text);
|
|
197
|
-
if (!line) return;
|
|
198
|
-
this.stopSpinner();
|
|
199
|
-
this.write(dim(` ⋮ ${line}`));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
progress(lines) {
|
|
203
|
-
const last = lines[lines.length - 1]?.trim();
|
|
204
|
-
if (last) this.updateSpinner(last);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Streaming has nowhere to repaint here, so the reply is printed whole when
|
|
208
|
-
// it is finished. The loop only streams into a real terminal anyway.
|
|
209
|
-
streamBegin() {}
|
|
210
|
-
streamDelta() {}
|
|
211
|
-
streamEnd() { return ''; }
|
|
212
|
-
thinkingDelta() {}
|
|
213
|
-
thinkingEnd() {}
|
|
214
|
-
|
|
215
|
-
// -- spinner -------------------------------------------------------------
|
|
216
|
-
|
|
217
|
-
startSpinner(text = 'thinking') {
|
|
218
|
-
this.stopSpinner();
|
|
219
|
-
if (!this.output.isTTY) return; // a pipe does not want animation frames
|
|
220
|
-
this.spinnerText = asLabel(text);
|
|
221
|
-
this.since = Date.now();
|
|
222
|
-
this.timer = setInterval(() => {
|
|
223
|
-
this.frame = (this.frame + 1) % SPINNER.length;
|
|
224
|
-
this.paintSpinner();
|
|
225
|
-
}, 100);
|
|
226
|
-
this.timer.unref?.();
|
|
227
|
-
this.paintSpinner();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
paintSpinner() {
|
|
231
|
-
const since = this.turn?.start ?? this.since;
|
|
232
|
-
const secs = Math.round((Date.now() - since) / 1000);
|
|
233
|
-
const meta = [this.turn?.steps ? `step ${this.turn.steps}` : '', secs >= 2 ? formatDuration(secs * 1000) : '']
|
|
234
|
-
.filter(Boolean).join(' · ');
|
|
235
|
-
const line = ` ${blue(SPINNER[this.frame])} ${dim(this.spinnerText)}` + (meta ? dim(` · ${meta}`) : '');
|
|
236
|
-
this.output.write(`\r\x1b[K${padVis(line, this.width() - 1)}`);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
updateSpinner(text) {
|
|
240
|
-
if (!this.timer) return;
|
|
241
|
-
this.spinnerText = asLabel(text);
|
|
242
|
-
this.paintSpinner();
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
stopSpinner() {
|
|
246
|
-
if (!this.timer) return;
|
|
247
|
-
clearInterval(this.timer);
|
|
248
|
-
this.timer = null;
|
|
249
|
-
this.output.write('\r\x1b[K');
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// -- the turn in flight ----------------------------------------------------
|
|
253
|
-
|
|
254
|
-
turnStart() {
|
|
255
|
-
this.turn = { start: Date.now(), steps: 0 };
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
step() {
|
|
259
|
-
if (this.turn) this.turn.steps++;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
turnEnd({ ok = true } = {}) {
|
|
263
|
-
const t = this.turn;
|
|
264
|
-
this.turn = null;
|
|
265
|
-
if (t && Date.now() - t.start >= 2000)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
this.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
this.output.write(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
this.output.write(
|
|
334
|
-
|
|
335
|
-
this.output.write(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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 { formatDuration, doneLine } from './activity.js';
|
|
19
|
+
import { renderer, render } from './markdown.js';
|
|
20
|
+
|
|
21
|
+
const COMMANDS = [
|
|
22
|
+
'/help', '/model', '/models', '/session', '/sessions', '/resume',
|
|
23
|
+
'/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export class Plain {
|
|
27
|
+
constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
|
|
28
|
+
this.cwd = cwd;
|
|
29
|
+
this.output = output;
|
|
30
|
+
this.closed = false;
|
|
31
|
+
this.mode = 'build'; // no way to toggle without a keyboard; stays here
|
|
32
|
+
this.model = '';
|
|
33
|
+
this.timer = null;
|
|
34
|
+
this.frame = 0;
|
|
35
|
+
this.md = renderer(output.columns || 80);
|
|
36
|
+
|
|
37
|
+
this.rl = readline.createInterface({
|
|
38
|
+
input,
|
|
39
|
+
output,
|
|
40
|
+
historySize: 200,
|
|
41
|
+
completer(line) {
|
|
42
|
+
if (!line.startsWith('/')) return [[], line];
|
|
43
|
+
const hits = COMMANDS.filter((c) => c.startsWith(line));
|
|
44
|
+
return [hits.length ? hits : COMMANDS, line];
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Input is queued rather than read with rl.question(). When stdin is a
|
|
49
|
+
// pipe, readline emits every buffered line at once, so a question-per-turn
|
|
50
|
+
// loop would drop all but the first. Queueing behaves the same way
|
|
51
|
+
// interactively and makes piping work.
|
|
52
|
+
this.queue = [];
|
|
53
|
+
this.waiters = [];
|
|
54
|
+
|
|
55
|
+
this.rl.on('line', (line) => {
|
|
56
|
+
const clean = line.replace(/^/, ''); // strip a BOM on the first line
|
|
57
|
+
const waiter = this.waiters.shift();
|
|
58
|
+
if (waiter) waiter(clean);
|
|
59
|
+
else this.queue.push(clean);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.rl.on('close', () => {
|
|
63
|
+
this.closed = true;
|
|
64
|
+
while (this.waiters.length) this.waiters.shift()(null);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
width() {
|
|
69
|
+
return Math.max(30, this.output.columns || 80);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// -- output --------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
write(text = '') {
|
|
75
|
+
this.stopSpinner();
|
|
76
|
+
this.output.write(`${text}\n`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
blank() { this.write(''); }
|
|
80
|
+
note(text) { this.write(dim(` ${text}`)); }
|
|
81
|
+
|
|
82
|
+
clearScreen() {
|
|
83
|
+
this.stopSpinner();
|
|
84
|
+
this.output.write('\x1B[2J\x1B[3J\x1B[H');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
header({ cwd, model, used, limit, title }) {
|
|
88
|
+
this.stopSpinner();
|
|
89
|
+
this.model = model || this.model;
|
|
90
|
+
this.percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
|
91
|
+
|
|
92
|
+
const width = this.width();
|
|
93
|
+
const room = Math.max(8, width - BANNER_WIDTH - 8);
|
|
94
|
+
|
|
95
|
+
const facts = [
|
|
96
|
+
['dir', shortenPath(cwd, room - 9)],
|
|
97
|
+
['keys', '/help'],
|
|
98
|
+
['', ''],
|
|
99
|
+
['', ''],
|
|
100
|
+
['', 'made with ❤️ by om dixit'],
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// There is no input box to hang the status off here, so it goes on the
|
|
104
|
+
// last row inside the header box — still framed, still the same three
|
|
105
|
+
// facts, just attached to the only box this interface has.
|
|
106
|
+
const rows = width >= BANNER_WIDTH + 30
|
|
107
|
+
? BANNER.map((art, i) => {
|
|
108
|
+
const [label, value] = facts[i] ?? ['', ''];
|
|
109
|
+
const right = label
|
|
110
|
+
? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
|
|
111
|
+
: (value ? dim(value) : '');
|
|
112
|
+
return ` ${blue(art)} ${right}`;
|
|
113
|
+
})
|
|
114
|
+
: [
|
|
115
|
+
` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
|
|
116
|
+
...facts
|
|
117
|
+
.filter(([label]) => label)
|
|
118
|
+
.map(([label, value]) => ` ${dim(label.padEnd(9))}${chalk.white(clip(value, width - 16))}`),
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
this.write('');
|
|
122
|
+
this.write(boxTop(width));
|
|
123
|
+
for (const row of rows) this.write(boxRow(row, width));
|
|
124
|
+
this.write(boxRow(this.statusRow(), width));
|
|
125
|
+
this.write(boxBottom(width));
|
|
126
|
+
this.write('');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setFacts() { /* nothing to repaint without a frame */ }
|
|
130
|
+
|
|
131
|
+
/** The same three facts the full screen shows, on the row under the header. */
|
|
132
|
+
statusRow() {
|
|
133
|
+
const inner = this.width() - 2;
|
|
134
|
+
const chip = this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
|
|
135
|
+
const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
|
|
136
|
+
const percent = Math.round(this.percent ?? 0);
|
|
137
|
+
const right = `${percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`)} `;
|
|
138
|
+
const pad = Math.max(1, inner - visLen(left) - visLen(right));
|
|
139
|
+
return padVis(left + ' '.repeat(pad) + right, inner);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
toolCall(label) {
|
|
143
|
+
this.stopSpinner();
|
|
144
|
+
this.output.write(`${blue('●')} ${asLabel(label)}\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
plan(items) {
|
|
148
|
+
const line = planLine(items);
|
|
149
|
+
if (line) this.write(line);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
toolResult(summary) {
|
|
153
|
+
this.stopSpinner();
|
|
154
|
+
this.output.write(dim(` └ ${summary}\n`));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
toolFailed(summary) {
|
|
158
|
+
this.stopSpinner();
|
|
159
|
+
this.output.write(`${dim(' └ ')}${theme.error(summary)}\n`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The change, with the same line-number gutter the full screen uses. */
|
|
163
|
+
diff(lines) {
|
|
164
|
+
this.stopSpinner();
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
if (line.startsWith('~')) {
|
|
167
|
+
this.output.write(` ${sky(line.slice(1))}\n`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const added = line.startsWith('+');
|
|
171
|
+
const rest = line.slice(1);
|
|
172
|
+
const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
|
|
173
|
+
if (!parsed) {
|
|
174
|
+
this.output.write(` ${dim(rest)}\n`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const [, number, body] = parsed;
|
|
178
|
+
const paint = added ? theme.ok : theme.error;
|
|
179
|
+
this.output.write(` ${dim(number.padStart(6))} ${paint(`${added ? '+' : '-'} ${body}`)}\n`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
commandOutput(lines) {
|
|
184
|
+
this.stopSpinner();
|
|
185
|
+
for (const line of lines) this.output.write(` ${dim(line)}\n`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
assistant(text) {
|
|
189
|
+
const out = render(this.md, text);
|
|
190
|
+
if (!out) return;
|
|
191
|
+
this.stopSpinner();
|
|
192
|
+
this.output.write(`\n${out}\n\n`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
narrate(text) {
|
|
196
|
+
const line = asLabel(text);
|
|
197
|
+
if (!line) return;
|
|
198
|
+
this.stopSpinner();
|
|
199
|
+
this.write(dim(` ⋮ ${line}`));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
progress(lines) {
|
|
203
|
+
const last = lines[lines.length - 1]?.trim();
|
|
204
|
+
if (last) this.updateSpinner(last);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Streaming has nowhere to repaint here, so the reply is printed whole when
|
|
208
|
+
// it is finished. The loop only streams into a real terminal anyway.
|
|
209
|
+
streamBegin() {}
|
|
210
|
+
streamDelta() {}
|
|
211
|
+
streamEnd() { return ''; }
|
|
212
|
+
thinkingDelta() {}
|
|
213
|
+
thinkingEnd() {}
|
|
214
|
+
|
|
215
|
+
// -- spinner -------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
startSpinner(text = 'thinking') {
|
|
218
|
+
this.stopSpinner();
|
|
219
|
+
if (!this.output.isTTY) return; // a pipe does not want animation frames
|
|
220
|
+
this.spinnerText = asLabel(text);
|
|
221
|
+
this.since = Date.now();
|
|
222
|
+
this.timer = setInterval(() => {
|
|
223
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
224
|
+
this.paintSpinner();
|
|
225
|
+
}, 100);
|
|
226
|
+
this.timer.unref?.();
|
|
227
|
+
this.paintSpinner();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
paintSpinner() {
|
|
231
|
+
const since = this.turn?.start ?? this.since;
|
|
232
|
+
const secs = Math.round((Date.now() - since) / 1000);
|
|
233
|
+
const meta = [this.turn?.steps ? `step ${this.turn.steps}` : '', secs >= 2 ? formatDuration(secs * 1000) : '']
|
|
234
|
+
.filter(Boolean).join(' · ');
|
|
235
|
+
const line = ` ${blue(SPINNER[this.frame])} ${dim(this.spinnerText)}` + (meta ? dim(` · ${meta}`) : '');
|
|
236
|
+
this.output.write(`\r\x1b[K${padVis(line, this.width() - 1)}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
updateSpinner(text) {
|
|
240
|
+
if (!this.timer) return;
|
|
241
|
+
this.spinnerText = asLabel(text);
|
|
242
|
+
this.paintSpinner();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
stopSpinner() {
|
|
246
|
+
if (!this.timer) return;
|
|
247
|
+
clearInterval(this.timer);
|
|
248
|
+
this.timer = null;
|
|
249
|
+
this.output.write('\r\x1b[K');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -- the turn in flight ----------------------------------------------------
|
|
253
|
+
|
|
254
|
+
turnStart() {
|
|
255
|
+
this.turn = { start: Date.now(), steps: 0 };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
step() {
|
|
259
|
+
if (this.turn) this.turn.steps++;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
turnEnd({ ok = true } = {}) {
|
|
263
|
+
const t = this.turn;
|
|
264
|
+
this.turn = null;
|
|
265
|
+
if (t && !ok && Date.now() - t.start >= 2000) {
|
|
266
|
+
this.write(` ${doneLine(Date.now() - t.start, t.steps, { ok })}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// -- input ---------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
nextLine() {
|
|
273
|
+
if (this.queue.length) return Promise.resolve(this.queue.shift());
|
|
274
|
+
if (this.closed) return Promise.resolve(null);
|
|
275
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
promptWith(text) {
|
|
279
|
+
this.rl.setPrompt(text);
|
|
280
|
+
this.rl.prompt();
|
|
281
|
+
return this.nextLine();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
ask() {
|
|
285
|
+
this.stopSpinner();
|
|
286
|
+
return this.promptWith(`${blue('› ')}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async confirm({ action, detail, risk }) {
|
|
290
|
+
this.stopSpinner();
|
|
291
|
+
const badge = risk === 'command' ? ' shell ' : ' outside project ';
|
|
292
|
+
this.output.write(`\n${chalk.inverse(theme.warn(badge))} ${chalk.white(action)}\n`);
|
|
293
|
+
for (const line of String(detail ?? '').split('\n')) {
|
|
294
|
+
if (line) this.output.write(dim(` ${line}\n`));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const answer = await this.promptWith(`${blue(' go ahead? ')}${dim('[y/N] ')}`);
|
|
298
|
+
// End of input is a no: never run something nobody approved.
|
|
299
|
+
const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
|
|
300
|
+
this.output.write(dim(yes ? ' approved\n\n' : ' declined\n\n'));
|
|
301
|
+
return yes;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async choose(prompt, items, { allowNone = true } = {}) {
|
|
305
|
+
this.stopSpinner();
|
|
306
|
+
items.forEach((item, i) => this.output.write(` ${blue(String(i + 1).padStart(2))}. ${item}\n`));
|
|
307
|
+
if (allowNone) this.output.write(dim(' 0. none — start fresh\n'));
|
|
308
|
+
this.output.write('\n');
|
|
309
|
+
|
|
310
|
+
const answer = await this.promptWith(`${blue('› ')}${dim(`${prompt} `)}`);
|
|
311
|
+
if (answer === null) return null;
|
|
312
|
+
|
|
313
|
+
const trimmed = String(answer).trim();
|
|
314
|
+
if (trimmed === '' || trimmed === '0') return null;
|
|
315
|
+
|
|
316
|
+
const index = Number(trimmed);
|
|
317
|
+
if (!Number.isInteger(index) || index < 1 || index > items.length) {
|
|
318
|
+
this.write(theme.warn(` "${trimmed}" is not one of 1-${items.length}. Starting fresh.`));
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
return index - 1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
error(err, { debug = false } = {}) {
|
|
325
|
+
this.stopSpinner();
|
|
326
|
+
const known = err && typeof err === 'object' && err.attempted;
|
|
327
|
+
|
|
328
|
+
this.output.write('\n');
|
|
329
|
+
if (known) {
|
|
330
|
+
this.output.write(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}\n`);
|
|
331
|
+
this.output.write(` ${err.failed}\n`);
|
|
332
|
+
if (err.fix) this.output.write(` ${blue('→')} ${err.fix}\n`);
|
|
333
|
+
if (err.kind) this.output.write(dim(` (${err.kind})\n`));
|
|
334
|
+
} else {
|
|
335
|
+
this.output.write(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}\n`);
|
|
336
|
+
this.output.write(` ${err?.message ?? String(err)}\n`);
|
|
337
|
+
this.output.write(` ${blue('→')} That is a bug in ucode rather than in your project. Re-run with --debug.\n`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (debug) {
|
|
341
|
+
const stack = (known && err.cause?.stack) || err?.stack;
|
|
342
|
+
if (stack) this.output.write(dim(`\n${stack}\n`));
|
|
343
|
+
}
|
|
344
|
+
this.output.write('\n');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
close() {
|
|
348
|
+
this.stopSpinner();
|
|
349
|
+
this.rl.close();
|
|
350
|
+
}
|
|
351
|
+
}
|
package/src/ui/screen.js
CHANGED
|
@@ -106,6 +106,9 @@ const CHROME_BELOW = 6;
|
|
|
106
106
|
/** How long one sentence of reasoning holds the line before the next takes it. */
|
|
107
107
|
const THOUGHT_HOLD_MS = 1100;
|
|
108
108
|
|
|
109
|
+
/** Reasoning that is about the request rather than about the work. */
|
|
110
|
+
const RESTATEMENT = /^(?:the user|they|so the user|user)|^(?:i (?:need|should|will need) to (?:understand|figure|work out|check what))|^(?:let me (?:understand|re-?read|look at the (?:request|prompt)))|^(?:the (?:request|prompt|task) (?:is|asks|says))/i;
|
|
111
|
+
|
|
109
112
|
/** The wordmark only earns its place with room for the facts column beside it. */
|
|
110
113
|
const WORDMARK_NEEDS = BANNER_WIDTH + 30;
|
|
111
114
|
|
|
@@ -316,7 +319,7 @@ export class Screen {
|
|
|
316
319
|
run.label = label;
|
|
317
320
|
run.targets.push(groupTarget(label));
|
|
318
321
|
this.run = run;
|
|
319
|
-
this.paintRun(
|
|
322
|
+
this.paintRun();
|
|
320
323
|
} else {
|
|
321
324
|
this.push(`${narrationMark()} ${narration(asLabel(label))}`);
|
|
322
325
|
this.run = {
|
|
@@ -324,7 +327,7 @@ export class Screen {
|
|
|
324
327
|
targets: [groupTarget(label)], added: 0, removed: 0,
|
|
325
328
|
};
|
|
326
329
|
this.segment.set(kind, this.run);
|
|
327
|
-
this.paintRun(
|
|
330
|
+
this.paintRun();
|
|
328
331
|
}
|
|
329
332
|
this.updateSpinner(label);
|
|
330
333
|
}
|
|
@@ -352,19 +355,12 @@ export class Screen {
|
|
|
352
355
|
* lines are gone. It settles to plain dim the moment the step finishes, so
|
|
353
356
|
* the finished ones above stay quiet.
|
|
354
357
|
*/
|
|
355
|
-
paintRun(
|
|
358
|
+
paintRun() {
|
|
356
359
|
if (!this.run) return;
|
|
357
|
-
|
|
358
|
-
this.run.live = live;
|
|
359
|
-
this.lines[this.run.at] = `${narrationMark()} ${live ? shimmer(text, this.tick * FRAME_MS) : narration(text)}`;
|
|
360
|
+
this.lines[this.run.at] = `${narrationMark()} ${narration(asLabel(runLine(this.run)))}`;
|
|
360
361
|
this.render();
|
|
361
362
|
}
|
|
362
363
|
|
|
363
|
-
/** Let the line in flight animate, one frame per tick. */
|
|
364
|
-
paintLiveRun() {
|
|
365
|
-
if (this.run?.live && this.lines[this.run.at] !== undefined) this.paintRun({ live: true });
|
|
366
|
-
}
|
|
367
|
-
|
|
368
364
|
/**
|
|
369
365
|
* A change, as its two numbers.
|
|
370
366
|
*
|
|
@@ -395,10 +391,7 @@ export class Screen {
|
|
|
395
391
|
* already names the step, and a change adds its numbers to that same line.
|
|
396
392
|
* Only a failure earns a line of its own.
|
|
397
393
|
*/
|
|
398
|
-
toolResult() {
|
|
399
|
-
// The step is over: the line stops moving and joins the quiet ones above.
|
|
400
|
-
if (this.run) this.paintRun({ live: false });
|
|
401
|
-
}
|
|
394
|
+
toolResult() {}
|
|
402
395
|
|
|
403
396
|
/**
|
|
404
397
|
* Something went wrong, and the model is the one who can do anything about it.
|
|
@@ -548,39 +541,17 @@ export class Screen {
|
|
|
548
541
|
// and the transcript gets one line afterwards saying how long it took.
|
|
549
542
|
|
|
550
543
|
/**
|
|
551
|
-
* The
|
|
552
|
-
*
|
|
553
|
-
* Models reach for a tool before writing any reply, so nothing appeared for
|
|
554
|
-
* the first minute of a step. The reasoning channel streams from the first
|
|
555
|
-
* moment, so its opening sentence goes up as one line and stays there: what
|
|
556
|
-
* it is setting out to do, which is the thing worth knowing while you wait.
|
|
544
|
+
* The model's reasoning does not go on screen.
|
|
557
545
|
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
546
|
+
* It was surfaced here to fill the wait before the first tool call, and what
|
|
547
|
+
* it actually filled it with was the model talking to itself: "I need to
|
|
548
|
+
* build this", "The user wants a tasks app". Nobody needs their own request
|
|
549
|
+
* read back to them, and half-formed working-out is not something to publish.
|
|
550
|
+
* What the model *says* is its reply, and that is the only thing shown.
|
|
560
551
|
*/
|
|
561
|
-
thinkingDelta(
|
|
562
|
-
if (this.thoughtSince === undefined) this.thoughtSince = Date.now();
|
|
563
|
-
if (!text || this.openedWith) return;
|
|
552
|
+
thinkingDelta() {}
|
|
564
553
|
|
|
565
|
-
|
|
566
|
-
const tidy = this.thought.replace(/\s+/g, ' ').trim();
|
|
567
|
-
const finished = /^(.+?[.!?])(?:\s|$)/.exec(tidy);
|
|
568
|
-
if (!finished) return;
|
|
569
|
-
|
|
570
|
-
const line = finished[1].trim();
|
|
571
|
-
if (line.length < 12) return; // "Okay." tells nobody anything
|
|
572
|
-
|
|
573
|
-
this.openedWith = line;
|
|
574
|
-
// Full strength: this is the model talking, and it is the thing on the page
|
|
575
|
-
// worth reading. The dimmed lines around it are the machinery.
|
|
576
|
-
this.push(' ' + chalk.white(clip(line, Math.max(30, this.width() - 6))));
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
thinkingEnd() {
|
|
580
|
-
this.thought = '';
|
|
581
|
-
this.openedWith = undefined;
|
|
582
|
-
this.thoughtSince = undefined;
|
|
583
|
-
}
|
|
554
|
+
thinkingEnd() {}
|
|
584
555
|
|
|
585
556
|
error(err, { debug = false } = {}) {
|
|
586
557
|
const known = err && typeof err === 'object' && err.attempted;
|
|
@@ -912,9 +883,13 @@ export class Screen {
|
|
|
912
883
|
const a = this.activity;
|
|
913
884
|
this.activity = null;
|
|
914
885
|
if (!this.status.busy) this.stopTimer();
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
|
|
886
|
+
// Nothing is written when a turn finishes. The reply is the end of the
|
|
887
|
+
// turn, and a timing line under it is bookkeeping the reader did not ask
|
|
888
|
+
// for. A turn that stopped *without* finishing still says so, because
|
|
889
|
+
// silence there is indistinguishable from a crash.
|
|
890
|
+
if (a && !ok && Date.now() - a.start >= 2000) {
|
|
891
|
+
this.push(` ${doneLine(Date.now() - a.start, a.steps, { ok })}`);
|
|
892
|
+
}
|
|
918
893
|
this.paintStatus();
|
|
919
894
|
}
|
|
920
895
|
|
|
@@ -922,8 +897,10 @@ export class Screen {
|
|
|
922
897
|
startTimer() {
|
|
923
898
|
if (this.spinTimer) return;
|
|
924
899
|
this.spinTimer = setInterval(() => {
|
|
900
|
+
// Only the status row repaints on a tick. Animating a transcript line
|
|
901
|
+
// meant redrawing the whole frame twelve times a second, and the input
|
|
902
|
+
// box was being rebuilt under the user's cursor as they typed.
|
|
925
903
|
this.tick++;
|
|
926
|
-
this.paintLiveRun();
|
|
927
904
|
this.paintStatus();
|
|
928
905
|
}, FRAME_MS);
|
|
929
906
|
this.spinTimer.unref?.();
|