ucode-agent 1.41.0 → 1.42.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/README.md CHANGED
@@ -27,7 +27,7 @@ corner:
27
27
  add a dark mode toggle that remembers the choice
28
28
 
29
29
 
30
- v1.41.0
30
+ v1.42.0
31
31
  ```
32
32
 
33
33
  A light crosses the wordmark once as it opens, and the three lines under the box
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.41.0",
3
+ "version": "1.42.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",
@@ -16,7 +16,8 @@
16
16
  "scripts": {
17
17
  "start": "node ucode.js",
18
18
  "test": "node test/run.js",
19
- "prepublishOnly": "node scripts/no-bundled-key.js && node test/run.js"
19
+ "prepublishOnly": "node scripts/no-bundled-key.js && node test/run.js",
20
+ "hooks": "node scripts/install-hooks.js"
20
21
  },
21
22
  "repository": {
22
23
  "type": "git",
@@ -108,6 +108,18 @@ first time; then the server route with the real integration; then the core
108
108
  loop UI wired to it; then every state — empty, loading, success, error,
109
109
  invalid input; then polish: motion, responsive, copy, title and metadata.
110
110
 
111
+ ## 5b. Tests, where there is a runner
112
+
113
+ `next-shadcn` ships vitest and one passing test, so `npm test` works from
114
+ the first minute — add cases for the core loop as you build it, not after, and
115
+ ucode will run the ones that touch whatever you change. Assert behaviour: that
116
+ adding an item puts it in the list, that the total is right, that an empty
117
+ input is refused.
118
+
119
+ `plain-html` has no runner and installs nothing, by design. Its test is the
120
+ browser check: ucode opens the app, types into the first field, presses Enter
121
+ and clicks the button that submits. Make sure that path is the one that works.
122
+
111
123
  ## 6. Prove it works, then report
112
124
 
113
125
  `npm run build` type-checks and lints — a build that fails is not done. Start
package/src/core/loop.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import path from 'node:path';
15
15
  import os from 'node:os';
16
- import { appendFileSync } from 'node:fs';
16
+ import { appendFileSync, existsSync } from 'node:fs';
17
17
  import { readFile, access, mkdir } from 'node:fs/promises';
18
18
  import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
19
19
  import { LogWatch } from './livelog.js';
@@ -1105,6 +1105,7 @@ export class Agent {
1105
1105
  this.lookedThisTurn = false;
1106
1106
  this.reads = new Map();
1107
1107
  this.declines = 0;
1108
+ this.apps = [];
1108
1109
  forgetReviews(); // a new request: its apps get a fresh design review
1109
1110
  const images = await this.attachImages(input);
1110
1111
  this.push(images.length
@@ -1347,6 +1348,7 @@ export class Agent {
1347
1348
  if (streaming) this.ui.streamEnd({ asNarration: narrating, closing });
1348
1349
  else if (reply.text && narrating && isLabel(reply.text)) this.ui.narrate(reply.text);
1349
1350
  else if (reply.text) this.ui.assistant(reply.text, { closing });
1351
+ if (closing && reply.text) this.checkClaims(reply.text);
1350
1352
 
1351
1353
  if (reply.toolCalls.length === 0) {
1352
1354
  // The answer stopped at the provider's output cap rather than at the
@@ -1682,6 +1684,13 @@ export class Agent {
1682
1684
  // a designer's review, and now the app actually driven — and it was the
1683
1685
  // quietest line on screen, saying only that it had happened. Its verdict
1684
1686
  // goes on the same line, the way a change carries its two numbers.
1687
+ // Which app folders this turn actually made, so a second one can be
1688
+ // refused before it is built and any leftovers can be counted at the end.
1689
+ if (call.name === 'create_app' && call.args?.folder) {
1690
+ const made = path.resolve(this.cwd, String(call.args.folder));
1691
+ if (!(this.apps ??= []).includes(made)) this.apps.push(made);
1692
+ }
1693
+
1685
1694
  if (call.name === 'look_at_app') {
1686
1695
  const found = /^(\d+) problem/.exec(out.summary ?? '');
1687
1696
  this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
@@ -1689,6 +1698,34 @@ export class Agent {
1689
1698
  this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1690
1699
  }
1691
1700
 
1701
+ /**
1702
+ * Files the closing message points at, checked rather than taken on trust.
1703
+ *
1704
+ * A build told the user it had removed two folders it had not removed. The
1705
+ * same habit sends someone to open a file that was never written. Saying a
1706
+ * thing is done when it is not is the one failure that costs the reader
1707
+ * their time rather than the writer's, and it is cheap to check: a path the
1708
+ * reply names in backticks either exists or it does not.
1709
+ *
1710
+ * Only paths with a file extension, only inside the project, and only a note
1711
+ * on screen — the turn is over by now, so this is for the person reading it,
1712
+ * not another round with the model.
1713
+ */
1714
+ checkClaims(text) {
1715
+ const named = [...String(text).matchAll(/`([\w./-]+\.[a-z]{1,5})`/gi)].map((m) => m[1]);
1716
+ const root = path.resolve(this.cwd);
1717
+
1718
+ const missing = [...new Set(named)].filter((f) => {
1719
+ const abs = path.resolve(root, f);
1720
+ return abs.startsWith(root) && !existsSync(abs);
1721
+ });
1722
+
1723
+ if (missing.length) {
1724
+ const is = missing.length === 1 ? 'is' : 'are';
1725
+ this.ui.note(`mentions ${missing.join(', ')} — ${is} not on disk`);
1726
+ }
1727
+ }
1728
+
1692
1729
  /** Show a tool failure, hand it to the model, and say if it was bad arguments. */
1693
1730
  reportFailure(call, err) {
1694
1731
  if (!(err instanceof ToolFailure)) throw err;
@@ -1784,6 +1821,30 @@ export class Agent {
1784
1821
  this.reads.set(key, seen + 1);
1785
1822
  }
1786
1823
 
1824
+ // Starting a second app instead of fixing the first.
1825
+ //
1826
+ // A traced build hit a problem in todo/, abandoned it and made todo-fixed/
1827
+ // — three create_app calls, two folders, one broken, the app typed twice.
1828
+ // Starting over is never the cheap way out of a problem in a file, and it
1829
+ // leaves the user to work out which folder is the real one.
1830
+ if (call.name === 'create_app' && call.args?.folder && (this.apps ?? []).length) {
1831
+ const wanted = path.resolve(this.cwd, String(call.args.folder));
1832
+ const already = this.apps.filter((f) => f !== wanted);
1833
+ if (already.length && !this.apps.includes(wanted)) {
1834
+ const show = already.map((f) => path.basename(f)).join(', ');
1835
+ throw new ToolFailure({
1836
+ kind: 'already_building',
1837
+ attempted: `creating ${call.args.folder}`,
1838
+ failed: `You already made ${show} this turn, and it is still there.`,
1839
+ fix:
1840
+ `Fix ${show} instead of starting again — whatever is wrong with it is a smaller `
1841
+ + 'job than writing the whole app a second time, and a half-finished folder left '
1842
+ + `beside the real one is worse than either. If ${show} genuinely cannot be saved, `
1843
+ + 'delete it first, then create this one.',
1844
+ });
1845
+ }
1846
+ }
1847
+
1787
1848
  if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
1788
1849
  if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
1789
1850
  if (call.name === 'delegate') return this.delegate(call.args?.tasks);
@@ -2082,6 +2143,23 @@ export class Agent {
2082
2143
  const live = await this.liveErrors();
2083
2144
  if (live) problems.push(live);
2084
2145
 
2146
+ // Two app folders, one of them abandoned.
2147
+ //
2148
+ // A build hit a problem in todo/, started todo-fixed/, then said in its
2149
+ // reply that it had removed the duplicate — and had not. Both were still
2150
+ // on disk for the user to sort out, and the claim that they were not is
2151
+ // the failure this whole checking pass exists to catch: saying a thing is
2152
+ // done when it is not. So it is checked rather than believed.
2153
+ const apps = this.apps ?? [];
2154
+ if (apps.length > 1) {
2155
+ const names = apps.map((f) => path.basename(f));
2156
+ problems.push(
2157
+ `There are ${apps.length} app folders here now: ${names.join(', ')}. Only one of them is `
2158
+ + 'the app. Delete the ones you are not shipping — actually delete them, do not just say '
2159
+ + 'you have — and make sure the one you keep is the one that works.',
2160
+ );
2161
+ }
2162
+
2085
2163
  // Then look at it, in the same pass that type-checks — not when the model
2086
2164
  // remembers to. A check that runs only when it is asked for reports
2087
2165
  // nothing on exactly the builds that needed it, and this is the only one
@@ -269,7 +269,25 @@ async function useTheApp(page) {
269
269
  }
270
270
 
271
271
  if (!tried.length) return null;
272
- return { tried, worked: moved(before, after) };
272
+ if (!moved(before, after)) return { tried, worked: false };
273
+
274
+ // It worked. Did any of it last?
275
+ //
276
+ // An app that writes to localStorage and never reads it back looks perfect
277
+ // for as long as you stay on the page, and loses everything the moment
278
+ // anyone refreshes. Only asked when the app actually stored something —
279
+ // otherwise no persistence was intended, and reporting its absence would be
280
+ // inventing a requirement nobody asked for.
281
+ if (after.stored > before.stored) {
282
+ try {
283
+ await page.reload({ waitUntil: 'load', timeout: 20_000 });
284
+ await page.waitForTimeout(400);
285
+ const reloaded = await snapshot();
286
+ if (reloaded.text <= before.text) return { tried, worked: true, lost: true };
287
+ } catch { /* a reload that will not happen is not evidence of anything */ }
288
+ }
289
+
290
+ return { tried, worked: true };
273
291
  }
274
292
 
275
293
  /**
@@ -415,8 +433,16 @@ export async function lookAtApp({ url, paths = ['/'] }) {
415
433
  ' everything else on this page is decoration until it works.',
416
434
  );
417
435
  problems++;
436
+ } else if (used?.lost) {
437
+ lines.push(
438
+ `- It works until you refresh. I ${used.tried.join(', then ')}, the page`,
439
+ ' responded, and it wrote to localStorage — but after a reload it was back to',
440
+ ' empty. Something is being saved and never read back at start-up. Load the',
441
+ ' stored state when the page boots, and check it survives a refresh.',
442
+ );
443
+ problems++;
418
444
  } else if (used) {
419
- lines.push(`- Core loop works: I ${used.tried.join(', then ')}, and the page responded.`);
445
+ lines.push(`- Core loop works: I ${used.tried.join(', then ')}, the page responded, and it survived a reload.`);
420
446
  }
421
447
  }
422
448
  if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
package/src/ui/screen.js CHANGED
@@ -594,7 +594,7 @@ export class Screen {
594
594
  narrate(text) {
595
595
  const line = asLabel(text);
596
596
  if (!line) return;
597
- this.push(dim(` ⋮ ${clip(line, this.width() - 6)}`));
597
+ this.push(`${dim('⋮')} ${dim(clip(line, this.width() - 4))}`);
598
598
  this.updateSpinner(line);
599
599
  }
600
600
 
package/src/ui/theme.js CHANGED
@@ -155,7 +155,68 @@ export const boxRow = (content, width, paint = blue) =>
155
155
 
156
156
  /** The string with its colour codes stripped — what the terminal actually shows. */
157
157
  export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
158
- export const visLen = (s) => bare(s).length;
158
+
159
+ /**
160
+ * How many columns one character occupies.
161
+ *
162
+ * Not every character is one cell wide, and counting them as though they were
163
+ * is how a box tears: the right border of a row holding CJK or an emoji lands
164
+ * one or two columns early, and every frame after it looks broken. It cost us
165
+ * a crooked credit line in the header for months — and any app whose name the
166
+ * model writes in Japanese would have done the same to the transcript.
167
+ *
168
+ * Three widths. Combining marks and the variation selectors hang off the
169
+ * character before them and take no room of their own. The wide ranges — CJK,
170
+ * Hangul, kana, fullwidth forms, and the emoji planes — are drawn two cells
171
+ * wide by every terminal worth supporting. Everything else is one.
172
+ *
173
+ * Ranges rather than a dependency: this is the whole of what a terminal needs,
174
+ * and a table of every Unicode width would be a megabyte to get the last
175
+ * fraction of a percent right.
176
+ */
177
+ export function charWidth(code) {
178
+ // Zero: combining marks, joiners, variation selectors.
179
+ if ((code >= 0x0300 && code <= 0x036f)
180
+ || (code >= 0x200b && code <= 0x200f)
181
+ || (code >= 0xfe00 && code <= 0xfe0f)
182
+ || (code >= 0xe0100 && code <= 0xe01ef)
183
+ || code === 0x200d) return 0;
184
+
185
+ // Two: the wide and fullwidth blocks, and the emoji planes.
186
+ if ((code >= 0x1100 && code <= 0x115f)
187
+ || (code >= 0x2e80 && code <= 0x303e)
188
+ || (code >= 0x3041 && code <= 0x33ff)
189
+ || (code >= 0x3400 && code <= 0x4dbf)
190
+ || (code >= 0x4e00 && code <= 0x9fff)
191
+ || (code >= 0xa000 && code <= 0xa4cf)
192
+ || (code >= 0xac00 && code <= 0xd7a3)
193
+ || (code >= 0xf900 && code <= 0xfaff)
194
+ || (code >= 0xfe30 && code <= 0xfe6f)
195
+ || (code >= 0xff00 && code <= 0xff60)
196
+ || (code >= 0xffe0 && code <= 0xffe6)
197
+ || (code >= 0x1f300 && code <= 0x1f64f)
198
+ || (code >= 0x1f680 && code <= 0x1f6ff)
199
+ || (code >= 0x1f900 && code <= 0x1f9ff)
200
+ || (code >= 0x20000 && code <= 0x3fffd)) return 2;
201
+
202
+ return 1;
203
+ }
204
+
205
+ /** The columns a string takes up once its colour codes are discounted. */
206
+ export function visLen(s) {
207
+ const text = bare(s);
208
+ let cells = 0;
209
+ for (let i = 0; i < text.length;) {
210
+ const cp = text.codePointAt(i);
211
+ const ch = String.fromCodePoint(cp);
212
+ i += ch.length;
213
+ // A variation selector turns the character before it into an emoji, and
214
+ // an emoji is two cells wide however narrow its text form was.
215
+ if (text.codePointAt(i) === 0xfe0f) { cells += 2; i += 1; continue; }
216
+ cells += charWidth(cp);
217
+ }
218
+ return cells;
219
+ }
159
220
 
160
221
  /** The first `width` visible characters, with escape sequences left intact. */
161
222
  export function sliceVis(s, width) {
@@ -166,9 +227,17 @@ export function sliceVis(s, width) {
166
227
  const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
167
228
  if (m) { out += m[0]; i += m[0].length - 1; continue; }
168
229
  }
169
- if (seen >= width) break;
170
- out += s[i];
171
- seen++;
230
+ // A wide character that would straddle the edge is left off entirely:
231
+ // half of one is a replacement glyph in most terminals and a torn border
232
+ // in the rest.
233
+ const cp = s.codePointAt(i);
234
+ const ch = String.fromCodePoint(cp);
235
+ const selector = s.codePointAt(i + ch.length) === 0xfe0f;
236
+ const w = selector ? 2 : charWidth(cp);
237
+ if (seen + w > width) break;
238
+ out += selector ? ch + String.fromCodePoint(0xfe0f) : ch;
239
+ i += (selector ? ch.length + 1 : ch.length) - 1;
240
+ seen += w;
172
241
  }
173
242
  return out;
174
243
  }
@@ -229,8 +298,11 @@ export function wrapAnsi(text, width) {
229
298
  }
230
299
  }
231
300
  if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
232
- line += text[i];
233
- seen++;
301
+ const cp = text.codePointAt(i);
302
+ const ch = String.fromCodePoint(cp);
303
+ line += ch;
304
+ i += ch.length - 1;
305
+ seen += charWidth(cp);
234
306
  if (seen >= width) {
235
307
  // Break at a word boundary unless that would leave a stub behind.
236
308
  if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
@@ -348,12 +420,16 @@ export function planRows(items) {
348
420
  const done = list.filter((i) => i?.done).length;
349
421
  const current = list.findIndex((i) => !i?.done);
350
422
 
351
- const rows = [` ${progressBar(done, list.length)} ${sky(`${done}/${list.length}`)}`];
423
+ // One left edge for the whole transcript: markers in column zero, every
424
+ // piece of content at column two. The plan used to sit at two and four, so
425
+ // three different margins ran down the page and the eye had no line to
426
+ // follow.
427
+ const rows = [`${progressBar(done, list.length)} ${sky(`${done}/${list.length}`)}`];
352
428
  list.forEach((item, i) => {
353
429
  const text = clip(String(item?.text ?? '').trim(), 64);
354
- if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
355
- else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
356
- else rows.push(` ${dim('○')} ${dim(text)}`);
430
+ if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
431
+ else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
432
+ else rows.push(` ${dim('○')} ${dim(text)}`);
357
433
  });
358
434
  return rows;
359
435
  }
@@ -73,6 +73,31 @@ useEffect(() => { localStorage.setItem("bill", bill); }, [bill]);
73
73
  // next-themes — there is no "next-themes/dist/types". Import from "next-themes".
74
74
  ```
75
75
 
76
+ ## Where a file goes
77
+
78
+ Routing is folder-based and it is **not** flexible: a page is only a route if
79
+ it sits at `src/app/<segment>/page.tsx`. A `page.tsx` anywhere else is an
80
+ ordinary file that nothing ever renders.
81
+
82
+ ```
83
+ src/
84
+ app/
85
+ layout.tsx the shell, already written
86
+ page.tsx /
87
+ globals.css the design tokens — re-tint these
88
+ expenses/page.tsx /expenses
89
+ api/expenses/route.ts GET/POST /api/expenses
90
+ components/
91
+ expenses/list.tsx feature components, one per file
92
+ ui/ the 33 shadcn primitives, already here
93
+ lib/
94
+ store.ts data, schemas, anything the server owns
95
+ ```
96
+
97
+ Wrong, and it silently does nothing: `expenses/page.tsx` at the project root,
98
+ or `components/` beside `src/` instead of inside it. The app builds, the
99
+ route 404s, and nothing tells you why.
100
+
76
101
  ## Conventions
77
102
 
78
103
  - Components are named exports — `export function BillInput()` — imported with
@@ -6,7 +6,8 @@
6
6
  "dev": "next dev",
7
7
  "build": "next build",
8
8
  "start": "next start",
9
- "lint": "eslint"
9
+ "lint": "eslint",
10
+ "test": "vitest run"
10
11
  },
11
12
  "dependencies": {
12
13
  "class-variance-authority": "^0.7.1",
@@ -34,6 +35,7 @@
34
35
  "eslint": "^9",
35
36
  "eslint-config-next": "16.3.4",
36
37
  "tailwindcss": "^4",
37
- "typescript": "^5"
38
+ "typescript": "^5",
39
+ "vitest": "^3.2.4"
38
40
  }
39
41
  }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ /**
4
+ * The starter ships one passing test so there is a runner to add to rather
5
+ * than a decision to make. `npm test` works from the first minute, and
6
+ * ucode runs the tests that touch whatever it just changed — which only finds
7
+ * anything if tests exist.
8
+ *
9
+ * Delete this once there is something real to assert.
10
+ */
11
+ describe('__APP_NAME__', () => {
12
+ it('has a test runner wired up', () => {
13
+ expect(true).toBe(true);
14
+ });
15
+ });