ucode-agent 1.41.1 → 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.1",
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';
@@ -1348,6 +1348,7 @@ export class Agent {
1348
1348
  if (streaming) this.ui.streamEnd({ asNarration: narrating, closing });
1349
1349
  else if (reply.text && narrating && isLabel(reply.text)) this.ui.narrate(reply.text);
1350
1350
  else if (reply.text) this.ui.assistant(reply.text, { closing });
1351
+ if (closing && reply.text) this.checkClaims(reply.text);
1351
1352
 
1352
1353
  if (reply.toolCalls.length === 0) {
1353
1354
  // The answer stopped at the provider's output cap rather than at the
@@ -1697,6 +1698,34 @@ export class Agent {
1697
1698
  this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1698
1699
  }
1699
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
+
1700
1729
  /** Show a tool failure, hand it to the model, and say if it was bad arguments. */
1701
1730
  reportFailure(call, err) {
1702
1731
  if (!(err instanceof ToolFailure)) throw err;
@@ -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
+ });