ucode-agent 1.12.0 → 1.13.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.0",
3
+ "version": "1.13.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,12 @@ 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
+ '- Say what you are about to do before you do it, in one short line, every time you',
438
+ ' pick up a new piece of work: "Right, the HTML structure first.", "Now the state',
439
+ ' and the render loop.", "That is the layout done - onto the animations." Without',
440
+ ' it the screen is a list of file operations and the user cannot tell what you are',
441
+ ' building or why. One sentence, in your own voice, then the actions. Not three',
442
+ ' sentences, and not a restatement of the request.',
441
443
  '',
442
444
  'Before you guess at an API, ask: type_of gives the exact signature from the',
443
445
  'TypeScript this project has installed, and find_symbol says where something is declared without',
@@ -493,7 +495,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
493
495
  ' writing files. Running the install yourself afterwards just waits for that one.',
494
496
  '- When you finish, ucode type-checks what you changed and hands you the errors, so',
495
497
  ' 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',
498
+ '- Do not open or drive a browser. Checking the page in one is something the user',
499
+ ' asks for with /look; your job is to leave the app in a state worth looking at.',
497
500
  ' reports - errors, layout that overflows a phone, the review points worth fixing -',
498
501
  ' in one pass, then look once more. A clean second look means it is done: report',
499
502
  ' back instead of polishing in circles. Never call an interface finished unlooked at.',
@@ -1815,6 +1818,7 @@ export class Agent {
1815
1818
  case '/copy': return this.cmdCopy();
1816
1819
  case '/stats': return this.cmdStats();
1817
1820
  case '/doctor': return this.cmdDoctor();
1821
+ case '/look': return this.cmdLook(arg);
1818
1822
  case '/deploy': return this.cmdDeploy(arg);
1819
1823
  case '/exit':
1820
1824
  case '/quit': return 'exit';
@@ -1825,11 +1829,42 @@ export class Agent {
1825
1829
  }
1826
1830
  }
1827
1831
 
1832
+ /**
1833
+ * Look at the running app, because the user asked to.
1834
+ *
1835
+ * This used to happen on its own, which meant a browser being driven while
1836
+ * someone was reading, and a window taking the screen mid-thought. It is
1837
+ * the same check as before; the difference is who starts it.
1838
+ */
1839
+ async cmdLook(url) {
1840
+ const { lookAtApp } = await import('../tools/browser.js');
1841
+ const server = runningServers().at(-1);
1842
+ const at = (url ?? '').trim() || server?.url;
1843
+ if (!at) {
1844
+ this.ui.write(theme.warn(' nothing is running to look at.'));
1845
+ this.ui.note('start the app first, or pass a URL: /look http://localhost:3000');
1846
+ return;
1847
+ }
1848
+ this.ui.toolCall(`Looking at ${at}`);
1849
+ try {
1850
+ const out = await lookAtApp({ url: at });
1851
+ this.ui.write(out.content);
1852
+ // The model gets it too, so the next thing it says is about what is
1853
+ // actually on the page rather than what it believes it built.
1854
+ this.push({ role: 'user', content: `I looked at ${at}. This is what is there:
1855
+
1856
+ ${out.content}` });
1857
+ } catch (err) {
1858
+ this.ui.write(theme.error(` ${err.failed ?? err.message}`));
1859
+ }
1860
+ }
1861
+
1828
1862
  cmdHelp() {
1829
1863
  const rows = [
1830
1864
  ['/help', 'this list'],
1831
1865
  ['/stats', 'time, steps and tokens this session'],
1832
1866
  ['/doctor', 'check that everything ucode needs is working'],
1867
+ ['/look [url]', 'open the running app and report what is on the page'],
1833
1868
  ['/deploy [folder]', 'put the app online and get its link'],
1834
1869
  ['/model', 'show the models and switch between them'],
1835
1870
  ['/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 ----------------------------------------------------------------------
@@ -92,6 +92,8 @@ const HIDE = `${ESC}[?25l`;
92
92
  const SHOW = `${ESC}[?25h`;
93
93
  const HOME = `${ESC}[H`;
94
94
  const CLEAR_LINE = `${ESC}[K`;
95
+ /** Written out rather than inline, so no edit can turn it into a real break. */
96
+ const NEWLINE = String.fromCharCode(10);
95
97
  const at = (row, col) => `${ESC}[${row};${col}H`;
96
98
  const title = (t) => `${ESC}]0;${t}\x07`;
97
99
 
@@ -345,8 +347,10 @@ export class Screen {
345
347
 
346
348
  /** The checklist, when the model updates it. One line, wrapped if it must. */
347
349
  plan(items) {
348
- const line = planLine(items);
349
- if (line) this.push(line);
350
+ const rows = planRows(items);
351
+ if (!rows.length) return;
352
+ this.endRun(); // a plan is not another step of whatever came before
353
+ for (const row of rows) this.push(row);
350
354
  }
351
355
 
352
356
  /**
@@ -506,7 +510,10 @@ export class Screen {
506
510
  thinkingEnd() {
507
511
  if (this.thoughtSince === undefined) return;
508
512
  const seconds = Math.round((Date.now() - this.thoughtSince) / 1000);
509
- if (seconds >= 2) this.push(dim(` ⋮ thought for ${seconds}s`));
513
+ // How long it thought is not what the reader is here for, and a line of it
514
+ // between every step broke every run of steps into singletons — which is
515
+ // why nothing folded. The time is still on the status row while it runs.
516
+ void seconds;
510
517
  this.thoughtSince = undefined;
511
518
  }
512
519
 
@@ -603,15 +610,47 @@ export class Screen {
603
610
  // -- input box -----------------------------------------------------------
604
611
 
605
612
  /** The typed line, wrapped to the inside of a box `width` characters across. */
613
+ /**
614
+ * The typed text, laid out as rows inside the box.
615
+ *
616
+ * A line break in the buffer is a row of its own before any wrapping is
617
+ * considered. Slicing the text into fixed widths without looking for one
618
+ * put the newline into the frame instead, and the terminal obeyed it — the
619
+ * pasted text walked out of the box and over the transcript beside it.
620
+ *
621
+ * `starts` records where each row begins in the text, so the caret can be
622
+ * placed by looking up rather than by counting characters a second way and
623
+ * hoping the two agree.
624
+ */
606
625
  inputLines(width = this.inner()) {
607
626
  const prefix = this.pendingPrompt ? `${this.pendingPrompt} ` : '› ';
608
627
  const full = prefix + this.buffer;
609
628
 
610
629
  const rows = [];
611
- for (let i = 0; i < full.length; i += width) rows.push(full.slice(i, i + width));
612
- if (rows.length === 0) rows.push(prefix);
630
+ const starts = [];
631
+ let at = 0;
632
+
633
+ for (const para of full.split(NEWLINE)) {
634
+ let i = 0;
635
+ do {
636
+ rows.push(para.slice(i, i + width));
637
+ starts.push(at + i);
638
+ i += width;
639
+ } while (i < para.length);
640
+ at += para.length + 1; // the newline itself
641
+ }
613
642
 
614
- return { rows, prefix, width };
643
+ if (rows.length === 0) { rows.push(prefix); starts.push(0); }
644
+ return { rows, prefix, width, starts };
645
+ }
646
+
647
+ /** Which row the caret sits on, and how far along it. */
648
+ caretAt(width) {
649
+ const { rows, prefix, starts } = this.inputLines(width);
650
+ const index = prefix.length + this.cursor;
651
+ let row = 0;
652
+ while (row + 1 < starts.length && starts[row + 1] <= index) row++;
653
+ return { row, col: Math.min(index - starts[row], rows[row].length), rows };
615
654
  }
616
655
 
617
656
  viewportHeight() {
@@ -1255,17 +1294,13 @@ export class Screen {
1255
1294
  caret() {
1256
1295
  if (this.welcoming()) {
1257
1296
  const g = this.welcomeGeometry();
1258
- const { rows, prefix, width } = this.inputLines(g.boxWidth - 4);
1259
- const index = prefix.length + this.cursor;
1260
- const row = Math.min(Math.floor(index / width), rows.length - 1);
1297
+ const { row, col } = this.caretAt(g.boxWidth - 4);
1261
1298
  // g.boxTop is 0-based and the typed lines start one below the border.
1262
- return [g.boxTop + 2 + row, g.left + 3 + (index % width)];
1299
+ return [g.boxTop + 2 + row, g.left + 3 + col];
1263
1300
  }
1264
1301
 
1265
- const { rows, prefix, width } = this.inputLines();
1266
- const index = prefix.length + this.cursor;
1267
- const row = Math.min(Math.floor(index / width), rows.length - 1);
1268
- const col = 3 + (index % width);
1302
+ const { row, col: at, rows } = this.caretAt();
1303
+ const col = 3 + at;
1269
1304
  // Counting up from the bottom: the box border is the last row, the status
1270
1305
  // row is above it, then the blank row, then the typed lines.
1271
1306
  const firstRow = this.rows - 2 - rows.length;
@@ -1354,10 +1389,10 @@ export class Screen {
1354
1389
  export function looksPasted(chunk) {
1355
1390
  const text = String(chunk ?? '');
1356
1391
  if (text.length < 2 || text.includes(ESC)) return false;
1357
- const breaks = (text.match(/[\r\n]/g) ?? []).length;
1392
+ const breaks = (text.match(/[\r\n]/g) ?? []).length;
1358
1393
  if (breaks === 0) return false;
1359
1394
  // One trailing break is someone finishing a line, not pasting one.
1360
- if (breaks === 1 && /[\r\n]$/.test(text)) return false;
1395
+ if (breaks === 1 && /[\r\n]$/.test(text)) return false;
1361
1396
  return true;
1362
1397
  }
1363
1398
 
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
  /**