ucode-agent 1.12.1 → 1.14.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.12.1",
3
+ "version": "1.14.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
@@ -434,10 +434,24 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
434
434
  '',
435
435
  '## How to work',
436
436
  '',
437
- 'Say what you are about to do, in one short line, before you do it "now the',
438
- 'tests", "wiring this into the loop". A line like that before a group of actions is',
439
- 'what makes the work readable. Keep it to a sentence: the narration is not the',
440
- 'answer, and three sentences of intent before every step reads as stalling.',
437
+ 'FIRST, EVERY TIME: one short line saying what you are about to do, then the tool',
438
+ 'calls. Never open a turn with a tool call and no words. "Right, the HTML',
439
+ 'structure first." / "Now the state and the render loop." / "That is the layout',
440
+ 'done - onto the animations." / "Let me see what is there." One sentence, your own',
441
+ 'voice, before the actions - not after them, not instead of them, not a',
442
+ 'restatement of the request. The user watches this scroll past, and without those',
443
+ 'lines it is a list of file operations they cannot read intent from.',
444
+ '',
445
+ 'FIRST, EVERY TIME: write one short line saying what you are about to do, then',
446
+ 'make the tool calls. Never open a turn with a tool call and no words. Examples:',
447
+ '"Right, the HTML structure first." / "Now the state and the render loop." /',
448
+ '"That is the layout done - onto the animations." / "Let me see what is there."',
449
+ 'One sentence, your own voice, before the actions - not after them, not instead',
450
+ 'of them, and not a restatement of what was asked. The user is watching this',
451
+ 'scroll past; without those lines it is a list of file operations and they cannot',
452
+ 'tell what you are building. This matters as much as the code.',
453
+ '',
454
+
441
455
  '',
442
456
  'Before you guess at an API, ask: type_of gives the exact signature from the',
443
457
  'TypeScript this project has installed, and find_symbol says where something is declared without',
@@ -493,7 +507,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
493
507
  ' writing files. Running the install yourself afterwards just waits for that one.',
494
508
  '- When you finish, ucode type-checks what you changed and hands you the errors, so',
495
509
  ' there is no need to run tsc yourself.',
496
- '- Once the dev server is ready, run look_at_app on the pages you built. Fix what it',
510
+ '- Do not open or drive a browser. Checking the page in one is something the user',
511
+ ' asks for with /look; your job is to leave the app in a state worth looking at.',
497
512
  ' reports - errors, layout that overflows a phone, the review points worth fixing -',
498
513
  ' in one pass, then look once more. A clean second look means it is done: report',
499
514
  ' back instead of polishing in circles. Never call an interface finished unlooked at.',
@@ -1367,9 +1382,13 @@ export class Agent {
1367
1382
  reportFailure(call, err) {
1368
1383
  if (!(err instanceof ToolFailure)) throw err;
1369
1384
 
1370
- this.ui.toolFailed(
1371
- err instanceof Declined ? 'declined' : `${err.kind}: ${err.failed}`
1372
- );
1385
+ // Bad arguments are the model talking to itself. "old_string and new_string
1386
+ // are identical" is a correction it will make on the next step, and it means
1387
+ // nothing to whoever is watching except that something went wrong. It goes
1388
+ // to the model, which can act on it, and not to the screen. A refusal the
1389
+ // user made, and anything that actually failed, still shows.
1390
+ if (err instanceof Declined) this.ui.toolFailed('declined');
1391
+ else if (err.kind !== 'bad_args') this.ui.toolFailed(`${err.kind}: ${err.failed}`);
1373
1392
  this.push({
1374
1393
  role: 'tool',
1375
1394
  toolCallId: call.id,
@@ -1815,6 +1834,7 @@ export class Agent {
1815
1834
  case '/copy': return this.cmdCopy();
1816
1835
  case '/stats': return this.cmdStats();
1817
1836
  case '/doctor': return this.cmdDoctor();
1837
+ case '/look': return this.cmdLook(arg);
1818
1838
  case '/deploy': return this.cmdDeploy(arg);
1819
1839
  case '/exit':
1820
1840
  case '/quit': return 'exit';
@@ -1825,11 +1845,42 @@ export class Agent {
1825
1845
  }
1826
1846
  }
1827
1847
 
1848
+ /**
1849
+ * Look at the running app, because the user asked to.
1850
+ *
1851
+ * This used to happen on its own, which meant a browser being driven while
1852
+ * someone was reading, and a window taking the screen mid-thought. It is
1853
+ * the same check as before; the difference is who starts it.
1854
+ */
1855
+ async cmdLook(url) {
1856
+ const { lookAtApp } = await import('../tools/browser.js');
1857
+ const server = runningServers().at(-1);
1858
+ const at = (url ?? '').trim() || server?.url;
1859
+ if (!at) {
1860
+ this.ui.write(theme.warn(' nothing is running to look at.'));
1861
+ this.ui.note('start the app first, or pass a URL: /look http://localhost:3000');
1862
+ return;
1863
+ }
1864
+ this.ui.toolCall(`Looking at ${at}`);
1865
+ try {
1866
+ const out = await lookAtApp({ url: at });
1867
+ this.ui.write(out.content);
1868
+ // The model gets it too, so the next thing it says is about what is
1869
+ // actually on the page rather than what it believes it built.
1870
+ this.push({ role: 'user', content: `I looked at ${at}. This is what is there:
1871
+
1872
+ ${out.content}` });
1873
+ } catch (err) {
1874
+ this.ui.write(theme.error(` ${err.failed ?? err.message}`));
1875
+ }
1876
+ }
1877
+
1828
1878
  cmdHelp() {
1829
1879
  const rows = [
1830
1880
  ['/help', 'this list'],
1831
1881
  ['/stats', 'time, steps and tokens this session'],
1832
1882
  ['/doctor', 'check that everything ucode needs is working'],
1883
+ ['/look [url]', 'open the running app and report what is on the page'],
1833
1884
  ['/deploy [folder]', 'put the app online and get its link'],
1834
1885
  ['/model', 'show the models and switch between them'],
1835
1886
  ['/resume', 'pick up an earlier conversation'],
@@ -23,6 +23,37 @@ const str = (description) => ({ type: 'string', description });
23
23
  const int = (description) => ({ type: 'integer', description });
24
24
  const bool = (description) => ({ type: 'boolean', description });
25
25
 
26
+ /**
27
+ * The browser check is deliberately not in `tools`.
28
+ *
29
+ * It used to run itself whenever a dev server came up, which meant a window
30
+ * opening mid-thought and a page being driven while the user was reading. It
31
+ * is now something asked for: /look runs it, and nothing else does.
32
+ */
33
+ export const lookAtAppTool = {
34
+ name: 'look_at_app',
35
+ description:
36
+ 'Open the running app in a real browser at a phone width (375px) and a desktop width ' +
37
+ '(1440px) and report what a person would run into: console errors, failed requests, ' +
38
+ 'content that spills off the side of the screen, broken images, unlabeled buttons and ' +
39
+ 'fields. The first look at an app also brings a designer-style review of the ' +
40
+ 'screenshots; later looks re-run only the fast checks. Use it once the dev server is ' +
41
+ 'ready, fix what it reports, then look once more to confirm. Screenshots are saved ' +
42
+ 'under .ucode/screenshots.',
43
+ parameters: {
44
+ type: 'object',
45
+ properties: {
46
+ url: str('The local URL the dev server reported, e.g. http://localhost:3000'),
47
+ paths: {
48
+ type: 'array',
49
+ description: 'Pages to open, e.g. ["/", "/settings"]. Defaults to ["/"]. Up to 4.',
50
+ items: { type: 'string' },
51
+ },
52
+ },
53
+ required: ['url'],
54
+ },
55
+ };
56
+
26
57
  export const tools = [
27
58
  {
28
59
  name: 'create_app',
@@ -416,29 +447,6 @@ export const tools = [
416
447
  required: ['commands'],
417
448
  },
418
449
  },
419
- {
420
- name: 'look_at_app',
421
- description:
422
- 'Open the running app in a real browser at a phone width (375px) and a desktop width ' +
423
- '(1440px) and report what a person would run into: console errors, failed requests, ' +
424
- 'content that spills off the side of the screen, broken images, unlabeled buttons and ' +
425
- 'fields. The first look at an app also brings a designer-style review of the ' +
426
- 'screenshots; later looks re-run only the fast checks. Use it once the dev server is ' +
427
- 'ready, fix what it reports, then look once more to confirm. Screenshots are saved ' +
428
- 'under .ucode/screenshots.',
429
- parameters: {
430
- type: 'object',
431
- properties: {
432
- url: str('The local URL the dev server reported, e.g. http://localhost:3000'),
433
- paths: {
434
- type: 'array',
435
- description: 'Pages to open, e.g. ["/", "/settings"]. Defaults to ["/"]. Up to 4.',
436
- items: { type: 'string' },
437
- },
438
- },
439
- required: ['url'],
440
- },
441
- },
442
450
  {
443
451
  name: 'web_search',
444
452
  description:
@@ -578,28 +586,20 @@ export async function runTool(name, args = {}, opts = {}) {
578
586
  export function describe(name, args = {}) {
579
587
  switch (name) {
580
588
  case 'read_file':
581
- return `Reading ${clip(args.path)}${args.offset > 1 ? ` from line ${args.offset}` : ''}`;
582
- case 'read_files': {
583
- const names = (args.paths ?? []).map((p) => String(p));
584
- const joined = names.join(', ');
585
- return names.length && joined.length <= 60 ? `Reading ${joined}` : `Reading ${names.length} files`;
586
- }
589
+ case 'read_files':
590
+ // Which file is in the diff and in the result; the transcript only has to
591
+ // say what kind of work is going on, so a run of them folds into one line.
592
+ return 'Reading files';
587
593
  case 'write_file':
588
- return `Writing ${clip(args.path)}`;
589
- case 'batch_write': {
590
- const n = args.files?.length ?? 0;
591
- const first = args.files?.[0]?.path;
592
- return n === 1 && first ? `Writing ${clip(first)}` : `Writing ${n} files`;
593
- }
594
+ return 'Writing app';
595
+ case 'batch_write':
596
+ return 'Writing app';
594
597
  case 'edit_file':
595
- return `Editing ${clip(args.path)}`;
598
+ return 'Writing app';
596
599
  case 'multi_edit':
597
- return `Editing ${clip(args.path)}, ${args.edits?.length ?? 0} changes`;
598
- case 'edit_files': {
599
- const n = args.files?.length ?? 0;
600
- const first = args.files?.[0]?.path;
601
- return n === 1 && first ? `Editing ${clip(first)}` : `Editing ${n} files`;
602
- }
600
+ return 'Writing app';
601
+ case 'edit_files':
602
+ return 'Writing app';
603
603
  case 'update_plan':
604
604
  return 'Updating the plan';
605
605
  case 'delegate':
package/src/ui/screen.js CHANGED
@@ -39,7 +39,7 @@ import chalk from 'chalk';
39
39
  import {
40
40
  theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
41
41
  boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
- shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine } from './theme.js';
42
+ shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows } from './theme.js';
43
43
  import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
44
44
  import { renderer, render, polish } from './markdown.js';
45
45
  import { VERSION } from '../core/version.js';
@@ -58,7 +58,7 @@ export function isLabel(text) {
58
58
  export const COMMANDS = [
59
59
  '/help', '/model', '/models', '/session', '/sessions', '/resume',
60
60
  '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
61
- '/stats', '/doctor', '/deploy',
61
+ '/stats', '/doctor', '/deploy', '/look',
62
62
  ];
63
63
 
64
64
  // ANSI ----------------------------------------------------------------------
@@ -297,40 +297,66 @@ export class Screen {
297
297
  // U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
298
298
  // Terminal draws as a white circle on a blue tile.
299
299
  const kind = groupKind(label);
300
- const run = this.run;
301
- // The run's own line is either the last one, or the last but one with its
302
- // result underneath. Anything further down means something else was said
303
- // in between, and the run is over.
304
- const gap = run ? this.lines.length - 1 - run.at : Infinity;
305
-
306
- // A second step of the same kind rewrites the line the first one wrote,
307
- // rather than adding another almost-identical one beneath it.
308
- if (run && run.kind === kind && gap <= 1) {
309
- if (gap === 1) this.lines.pop(); // its single result line, now counted
300
+ // One line per kind of work for as long as the model is working on one
301
+ // thing. Reading, writing and reading again used to draw six lines that
302
+ // said three things; now the "Reading files" line it already has is the
303
+ // one that counts up, wherever it sits.
304
+ const run = (this.segment ??= new Map()).get(kind);
305
+
306
+ if (run && this.lines[run.at] !== undefined) {
310
307
  run.count++;
311
308
  run.label = label;
312
309
  run.targets.push(groupTarget(label));
313
- this.paintRun();
310
+ this.run = run;
311
+ this.paintRun({ live: true });
314
312
  } else {
315
313
  this.push(`${narrationMark()} ${narration(asLabel(label))}`);
316
314
  this.run = {
317
315
  kind, count: 1, at: this.lines.length - 1, label,
318
316
  targets: [groupTarget(label)], added: 0, removed: 0,
319
317
  };
318
+ this.segment.set(kind, this.run);
319
+ this.paintRun({ live: true });
320
320
  }
321
321
  this.updateSpinner(label);
322
322
  }
323
323
 
324
- /** Anything that is not another step of the same kind ends the run. */
325
- endRun() { this.run = null; }
324
+ /**
325
+ * The model speaking or a plan, or a failure — ends the segment.
326
+ *
327
+ * Up to that point a kind of work keeps one line and counts up on it. After
328
+ * it, the next read is a new piece of work and deserves its own line, which
329
+ * is what makes the transcript read as a sequence of things done rather
330
+ * than a set of running totals.
331
+ */
332
+ endRun() {
333
+ if (this.run) this.paintRun({ live: false });
334
+ this.run = null;
335
+ this.segment = new Map();
336
+ }
326
337
 
327
338
  /** Redraw the run's single line from what it has accumulated. */
328
- paintRun() {
339
+ /**
340
+ * Redraw the run's single line from what it has accumulated.
341
+ *
342
+ * While its step is still running the text shimmers, which is the only
343
+ * thing on screen saying "this is happening now" once the per-step result
344
+ * lines are gone. It settles to plain dim the moment the step finishes, so
345
+ * the finished ones above stay quiet.
346
+ */
347
+ paintRun({ live = this.run?.live } = {}) {
329
348
  if (!this.run) return;
330
- this.lines[this.run.at] = `${narrationMark()} ${narration(asLabel(runLine(this.run)))}`;
349
+ const text = asLabel(runLine(this.run));
350
+ this.run.live = live;
351
+ this.lines[this.run.at] = `${narrationMark()} ${live ? shimmer(text, this.tick * FRAME_MS) : narration(text)}`;
331
352
  this.render();
332
353
  }
333
354
 
355
+ /** Let the line in flight animate, one frame per tick. */
356
+ paintLiveRun() {
357
+ if (this.run?.live && this.lines[this.run.at] !== undefined) this.paintRun({ live: true });
358
+ }
359
+
334
360
  /**
335
361
  * A change, as its two numbers.
336
362
  *
@@ -347,8 +373,10 @@ export class Screen {
347
373
 
348
374
  /** The checklist, when the model updates it. One line, wrapped if it must. */
349
375
  plan(items) {
350
- const line = planLine(items);
351
- if (line) this.push(line);
376
+ const rows = planRows(items);
377
+ if (!rows.length) return;
378
+ this.endRun(); // a plan is not another step of whatever came before
379
+ for (const row of rows) this.push(row);
352
380
  }
353
381
 
354
382
  /**
@@ -359,7 +387,10 @@ export class Screen {
359
387
  * already names the step, and a change adds its numbers to that same line.
360
388
  * Only a failure earns a line of its own.
361
389
  */
362
- toolResult() {}
390
+ toolResult() {
391
+ // The step is over: the line stops moving and joins the quiet ones above.
392
+ if (this.run) this.paintRun({ live: false });
393
+ }
363
394
 
364
395
  toolFailed(summary) {
365
396
  // A failure is never folded away.
@@ -508,7 +539,10 @@ export class Screen {
508
539
  thinkingEnd() {
509
540
  if (this.thoughtSince === undefined) return;
510
541
  const seconds = Math.round((Date.now() - this.thoughtSince) / 1000);
511
- if (seconds >= 2) this.push(dim(` ⋮ thought for ${seconds}s`));
542
+ // How long it thought is not what the reader is here for, and a line of it
543
+ // between every step broke every run of steps into singletons — which is
544
+ // why nothing folded. The time is still on the status row while it runs.
545
+ void seconds;
512
546
  this.thoughtSince = undefined;
513
547
  }
514
548
 
@@ -851,6 +885,7 @@ export class Screen {
851
885
  if (this.spinTimer) return;
852
886
  this.spinTimer = setInterval(() => {
853
887
  this.tick++;
888
+ this.paintLiveRun();
854
889
  this.paintStatus();
855
890
  }, FRAME_MS);
856
891
  this.spinTimer.unref?.();
package/src/ui/theme.js CHANGED
@@ -263,18 +263,41 @@ export function asLabel(text) {
263
263
  * The model's checklist, as one short line — done ticked, the current item
264
264
  * marked, the rest dim — so progress is visible without taking over the screen.
265
265
  */
266
+ /**
267
+ * The plan, as a block rather than a sentence.
268
+ *
269
+ * Six steps joined with separators made one line far wider than any terminal,
270
+ * so it wrapped — and a wrapped checklist has its ticks in the middle of the
271
+ * text, which is unreadable. Down the page each step keeps its own row, its
272
+ * mark stays in the left column, and the eye can find the one in progress
273
+ * without reading any of the others.
274
+ *
275
+ * Returns the rows; the caller pushes them.
276
+ */
277
+ export function planRows(items) {
278
+ const list = (Array.isArray(items) ? items : []).slice(0, 8);
279
+ if (!list.length) return [];
280
+ const done = list.filter((i) => i?.done).length;
281
+ const current = list.findIndex((i) => !i?.done);
282
+
283
+ const rows = [` ${sky(`plan ${done}/${list.length}`)}`];
284
+ list.forEach((item, i) => {
285
+ const text = clip(String(item?.text ?? '').trim(), 64);
286
+ if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
287
+ else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
288
+ else rows.push(` ${dim('○')} ${dim(text)}`);
289
+ });
290
+ return rows;
291
+ }
292
+
293
+ /** Kept for the plain interface, which has one line to work with. */
266
294
  export function planLine(items) {
267
295
  const list = (Array.isArray(items) ? items : []).slice(0, 6);
268
296
  if (!list.length) return '';
269
297
  const done = list.filter((i) => i?.done).length;
270
298
  const current = list.findIndex((i) => !i?.done);
271
- const parts = list.map((item, i) => {
272
- const text = clip(String(item?.text ?? '').trim(), 30);
273
- if (item?.done) return `${theme.ok('✓')} ${dim(text)}`;
274
- if (i === current) return `${blue('▸')} ${chalk.white(text)}`;
275
- return dim(`○ ${text}`);
276
- });
277
- return ` ${sky(`plan ${done}/${list.length}`)} ${parts.join(dim(' · '))}`;
299
+ const now = current === -1 ? 'done' : clip(String(list[current]?.text ?? '').trim(), 40);
300
+ return ` ${sky(`plan ${done}/${list.length}`)} ${chalk.white(now)}`;
278
301
  }
279
302
 
280
303
  /**