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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
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, EVERY TIME: write one short line saying what you are about to do, then',
482
- 'make the tool calls. Never open a turn with a tool call and no words. Examples:',
483
- '"Right, the HTML structure first." / "Now the state and the render loop." /',
484
- '"That is the layout done - onto the animations." / "Let me see what is there."',
485
- 'One sentence, your own voice, before the actions - not after them, not instead',
486
- 'of them, and not a restatement of what was asked. The user is watching this',
487
- 'scroll past; without those lines it is a list of file operations and they cannot',
488
- 'tell what you are building. This matters as much as the code.',
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
- /(?:next build|npm run build|pnpm (?:run )?build|tsc)/.test(call.args?.command ?? '')) {
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) this.write(` ${doneLine(Date.now() - t.start, t.steps, { ok })}`);
266
- }
267
-
268
- // -- input ---------------------------------------------------------------
269
-
270
- nextLine() {
271
- if (this.queue.length) return Promise.resolve(this.queue.shift());
272
- if (this.closed) return Promise.resolve(null);
273
- return new Promise((resolve) => this.waiters.push(resolve));
274
- }
275
-
276
- promptWith(text) {
277
- this.rl.setPrompt(text);
278
- this.rl.prompt();
279
- return this.nextLine();
280
- }
281
-
282
- ask() {
283
- this.stopSpinner();
284
- return this.promptWith(`${blue('› ')}`);
285
- }
286
-
287
- async confirm({ action, detail, risk }) {
288
- this.stopSpinner();
289
- const badge = risk === 'command' ? ' shell ' : ' outside project ';
290
- this.output.write(`\n${chalk.inverse(theme.warn(badge))} ${chalk.white(action)}\n`);
291
- for (const line of String(detail ?? '').split('\n')) {
292
- if (line) this.output.write(dim(` ${line}\n`));
293
- }
294
-
295
- const answer = await this.promptWith(`${blue(' go ahead? ')}${dim('[y/N] ')}`);
296
- // End of input is a no: never run something nobody approved.
297
- const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
298
- this.output.write(dim(yes ? ' approved\n\n' : ' declined\n\n'));
299
- return yes;
300
- }
301
-
302
- async choose(prompt, items, { allowNone = true } = {}) {
303
- this.stopSpinner();
304
- items.forEach((item, i) => this.output.write(` ${blue(String(i + 1).padStart(2))}. ${item}\n`));
305
- if (allowNone) this.output.write(dim(' 0. none — start fresh\n'));
306
- this.output.write('\n');
307
-
308
- const answer = await this.promptWith(`${blue('')}${dim(`${prompt} `)}`);
309
- if (answer === null) return null;
310
-
311
- const trimmed = String(answer).trim();
312
- if (trimmed === '' || trimmed === '0') return null;
313
-
314
- const index = Number(trimmed);
315
- if (!Number.isInteger(index) || index < 1 || index > items.length) {
316
- this.write(theme.warn(` "${trimmed}" is not one of 1-${items.length}. Starting fresh.`));
317
- return null;
318
- }
319
- return index - 1;
320
- }
321
-
322
- error(err, { debug = false } = {}) {
323
- this.stopSpinner();
324
- const known = err && typeof err === 'object' && err.attempted;
325
-
326
- this.output.write('\n');
327
- if (known) {
328
- this.output.write(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}\n`);
329
- this.output.write(` ${err.failed}\n`);
330
- if (err.fix) this.output.write(` ${blue('')} ${err.fix}\n`);
331
- if (err.kind) this.output.write(dim(` (${err.kind})\n`));
332
- } else {
333
- this.output.write(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}\n`);
334
- this.output.write(` ${err?.message ?? String(err)}\n`);
335
- this.output.write(` ${blue('')} That is a bug in ucode rather than in your project. Re-run with --debug.\n`);
336
- }
337
-
338
- if (debug) {
339
- const stack = (known && err.cause?.stack) || err?.stack;
340
- if (stack) this.output.write(dim(`\n${stack}\n`));
341
- }
342
- this.output.write('\n');
343
- }
344
-
345
- close() {
346
- this.stopSpinner();
347
- this.rl.close();
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({ live: true });
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({ live: true });
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({ live = this.run?.live } = {}) {
358
+ paintRun() {
356
359
  if (!this.run) return;
357
- const text = asLabel(runLine(this.run));
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 first thing the model says, as soon as it has said it.
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
- * Written once, never rewritten. Rewriting it as more arrived was the
559
- * flicker an unfinished sentence showed as a single word, then jumped.
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(text = '') {
562
- if (this.thoughtSince === undefined) this.thoughtSince = Date.now();
563
- if (!text || this.openedWith) return;
552
+ thinkingDelta() {}
564
553
 
565
- this.thought = ((this.thought ?? '') + text).slice(0, 600);
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
- // A turn that stopped without finishing says so. Dropping the line entirely
916
- // left the transcript looking like the work was still going.
917
- if (a && Date.now() - a.start >= 2000) this.push(` ${doneLine(Date.now() - a.start, a.steps, { ok })}`);
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?.();