ucode-agent 1.41.1 → 1.43.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 +1 -1
- package/package.json +3 -2
- package/skills/build-app/DIGEST.md +12 -0
- package/src/core/loop.js +30 -1
- package/src/tools/browser.js +28 -2
- package/src/tools/shell.js +19 -0
- package/src/ui/screen.js +1 -1
- package/src/ui/theme.js +86 -10
- package/templates/next-shadcn/TEMPLATE.md +25 -0
- package/templates/next-shadcn/package.json +4 -2
- package/templates/next-shadcn/src/lib/store.test.ts +15 -0
- package/ucode.js +10 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucode-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.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;
|
package/src/tools/browser.js
CHANGED
|
@@ -269,7 +269,25 @@ async function useTheApp(page) {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
if (!tried.length) return null;
|
|
272
|
-
return { tried, worked:
|
|
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 ')},
|
|
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/tools/shell.js
CHANGED
|
@@ -183,6 +183,25 @@ export function serversReadySince(since = 0) {
|
|
|
183
183
|
return readyServers.filter((s) => s.at >= since);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Shut down every dev server this session started.
|
|
188
|
+
*
|
|
189
|
+
* killTree existed and nothing ever called it, so a server outlived the
|
|
190
|
+
* session that started it — every build left one behind. Twenty-four builds
|
|
191
|
+
* in one afternoon left seventy-three node processes running, the oldest for
|
|
192
|
+
* three and a half hours, holding ports the next run then could not use.
|
|
193
|
+
*
|
|
194
|
+
* Best effort and never throws: a process that has already gone is the
|
|
195
|
+
* outcome we wanted anyway.
|
|
196
|
+
*/
|
|
197
|
+
export function stopServers() {
|
|
198
|
+
const stopped = readyServers.splice(0, readyServers.length);
|
|
199
|
+
for (const s of stopped) {
|
|
200
|
+
try { killTree(s.pid); } catch { /* already gone */ }
|
|
201
|
+
}
|
|
202
|
+
return stopped.length;
|
|
203
|
+
}
|
|
204
|
+
|
|
186
205
|
/** Every server started this session, for reading what they have logged. */
|
|
187
206
|
export function runningServers() {
|
|
188
207
|
return readyServers.slice();
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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(`
|
|
355
|
-
else if (i === current) rows.push(`
|
|
356
|
-
else rows.push(`
|
|
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
|
+
});
|
package/ucode.js
CHANGED
|
@@ -145,6 +145,16 @@ async function main() {
|
|
|
145
145
|
await agent.persist().catch(() => {});
|
|
146
146
|
agent.ui.close();
|
|
147
147
|
process.exitCode = 1;
|
|
148
|
+
} finally {
|
|
149
|
+
// Take the dev servers down with the session that started them, however it
|
|
150
|
+
// ended. killTree was written for this and nothing ever called it, so every
|
|
151
|
+
// build left its server running: twenty-four builds in an afternoon left
|
|
152
|
+
// seventy-three node processes alive, the oldest for three and a half
|
|
153
|
+
// hours, holding the ports the next run wanted.
|
|
154
|
+
try {
|
|
155
|
+
const { stopServers } = await import('./src/tools/shell.js');
|
|
156
|
+
stopServers();
|
|
157
|
+
} catch { /* nothing started, or already gone */ }
|
|
148
158
|
}
|
|
149
159
|
}
|
|
150
160
|
|