ucode-agent 1.22.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.22.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
@@ -484,15 +484,18 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
484
484
  'steps already show on screen, and repeating them in words buries the few',
485
485
  'sentences worth reading.',
486
486
  '',
487
- 'FIRST, EVERY TIME: write one short line saying what you are about to do, then',
488
- 'make the tool calls. Never open a turn with a tool call and no words. Examples:',
489
- '"Right, the HTML structure first." / "Now the state and the render loop." /',
490
- '"That is the layout done - onto the animations." / "Let me see what is there."',
491
- 'One sentence, your own voice, before the actions - not after them, not instead',
492
- 'of them, and not a restatement of what was asked. The user is watching this',
493
- 'scroll past; without those lines it is a list of file operations and they cannot',
494
- 'tell what you are building. This matters as much as the code.',
495
- '',
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
+ '',
496
499
 
497
500
  '',
498
501
  'Before you guess at an API, ask: type_of gives the exact signature from the',
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
@@ -541,44 +541,17 @@ export class Screen {
541
541
  // and the transcript gets one line afterwards saying how long it took.
542
542
 
543
543
  /**
544
- * The first thing the model says, as soon as it has said it.
544
+ * The model's reasoning does not go on screen.
545
545
  *
546
- * Models reach for a tool before writing any reply, so nothing appeared for
547
- * the first minute of a step. The reasoning channel streams from the first
548
- * moment, so its opening sentence goes up as one line and stays there: what
549
- * it is setting out to do, which is the thing worth knowing while you wait.
550
- *
551
- * Written once, never rewritten. Rewriting it as more arrived was the
552
- * 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.
553
551
  */
554
- thinkingDelta(text = '') {
555
- if (this.thoughtSince === undefined) this.thoughtSince = Date.now();
556
- if (!text || this.openedWith) return;
557
-
558
- this.thought = ((this.thought ?? '') + text).slice(0, 600);
559
- const tidy = this.thought.replace(/\s+/g, ' ').trim();
560
- const finished = /^(.+?[.!?])(?:\s|$)/.exec(tidy);
561
- if (!finished) return;
552
+ thinkingDelta() {}
562
553
 
563
- const line = finished[1].trim();
564
- if (line.length < 12) return; // "Okay." tells nobody anything
565
- // Reasoning models open by restating the request to themselves — "The user
566
- // wants a tasks app called Tide" — which the user wrote, is looking at, and
567
- // does not need read back. Only a sentence about what is being done is
568
- // worth the line.
569
- if (RESTATEMENT.test(line)) { this.thought = ''; return; }
570
-
571
- this.openedWith = line;
572
- // Full strength: this is the model talking, and it is the thing on the page
573
- // worth reading. The dimmed lines around it are the machinery.
574
- this.push(' ' + chalk.white(clip(line, Math.max(30, this.width() - 6))));
575
- }
576
-
577
- thinkingEnd() {
578
- this.thought = '';
579
- this.openedWith = undefined;
580
- this.thoughtSince = undefined;
581
- }
554
+ thinkingEnd() {}
582
555
 
583
556
  error(err, { debug = false } = {}) {
584
557
  const known = err && typeof err === 'object' && err.attempted;
@@ -910,9 +883,13 @@ export class Screen {
910
883
  const a = this.activity;
911
884
  this.activity = null;
912
885
  if (!this.status.busy) this.stopTimer();
913
- // A turn that stopped without finishing says so. Dropping the line entirely
914
- // left the transcript looking like the work was still going.
915
- 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
+ }
916
893
  this.paintStatus();
917
894
  }
918
895