ucode-agent 1.35.0 → 1.37.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.29.0
30
+ v1.36.0
31
31
  ```
32
32
 
33
33
  A light crosses the wordmark once as it opens, and the three lines under the box
@@ -107,12 +107,14 @@ of what ucode is asked to do, and it answers far sooner than the big reasoning
107
107
  models. Switch to Ultra when a problem needs the million-token window more than
108
108
  the speed.
109
109
 
110
- **A busy model never stops a build.** Free endpoints are shared, and "too many
111
- requests" is routine. ucode waits it out with growing pauses, and if the model
112
- stays busy it carries on with the next one — North Mini Code, then Nemotron 3.5
113
- Lightning, Super, Ultra — from exactly where it was, and tells you it switched.
114
- If every model is busy at once it waits a minute and goes round again. Your
115
- chosen model gets another go a few minutes later.
110
+ **The model you chose is the model you keep.** Free endpoints are shared and
111
+ "too many requests" is routine, so ucode waits it out with growing pauses and
112
+ comes back to the same model. It does not quietly hand your build to a
113
+ different one: a build that starts on one model and finishes on another
114
+ finishes to a different standard, and the swap lands exactly when you are least
115
+ placed to work out why the output changed. Set `UCODE_FALLBACK=1` if you would
116
+ rather it moved down the list — North Mini Code, Nemotron 3.5 Lightning, Super,
117
+ Ultra — when a model stays busy.
116
118
 
117
119
  ## What it does
118
120
 
@@ -211,6 +213,13 @@ same link. Needs a token from vercel.com/account/tokens in `~/.ucode/.env` as
211
213
  sunset, graphite, violet or citrus — each a full light and dark palette with its
212
214
  own font, so apps stop looking like the same default blue.
213
215
 
216
+ **Every turn can be taken back.** `/undo` puts back every file the last turn
217
+ changed — a rewritten file returns byte for byte, a file that did not exist
218
+ before is removed again. Each write keeps the original the first time that turn
219
+ touches it, so what comes back is the state before the turn rather than before
220
+ the last of six edits to the same file. An agent that writes to your disk on
221
+ its own should be able to take it back, whether or not the project has git.
222
+
214
223
  **It notices when it is going round in circles.** The same failing edit, an edit
215
224
  that changes nothing, or a build failing on the same errors three times gets a
216
225
  firm, specific note; if that does not work, the turn moves to another model.
@@ -238,13 +247,23 @@ written, its install starts in the background while the rest of the app is
238
247
  still being written. An install the model asks for later waits for that one
239
248
  instead of running twice, and anything run in that folder waits for it too.
240
249
 
241
- **It looks at what it built.** `look_at_app` opens the running app in a real
242
- browser — the Edge or Chrome already on your machine, so there is nothing extra
243
- to download — at 375px and 1440px. It reports console errors, failed requests,
244
- content that spills off a phone screen, broken images and unlabeled controls,
245
- saves screenshots to `.ucode/screenshots`, and has Nemotron Nano Omni review them
246
- the way a designer would. The model fixes what it finds before calling the app
247
- done. Both widths load at once, and the designer review — the slow part — runs
250
+ **It opens what it built and uses it.** Every app build ends with a look — not
251
+ when the model remembers to ask for one, but as part of the same pass that
252
+ type-checks. It opens the app in a real browser (the Edge or Chrome already on
253
+ your machine, so there is nothing extra to download) at 375px and 1440px, and
254
+ serves the folder itself when there is no dev server to point at, which is how
255
+ a three-file app gets checked at all.
256
+
257
+ Then it uses the app. It types into the first field, presses Enter, and clicks
258
+ the button that submits — and if the page gains no elements, changes no text
259
+ and stores nothing, that is reported as the thing to fix before anything else.
260
+ A page that renders and has no working behaviour passes a type check, a syntax
261
+ check and a screenshot; the only way to find out is to press something.
262
+
263
+ It also reports console errors, failed requests, content that spills off a
264
+ phone screen, broken images and unlabeled controls, saves screenshots to
265
+ `.ucode/screenshots`, and has Nemotron Nano Omni review them the way a designer
266
+ would. The model fixes what it finds before calling the app done. Both widths load at once, and the designer review — the slow part — runs
248
267
  on the first look at an app in each request and is skipped, not waited on, when
249
268
  the vision model is busy. The look after the fixes re-runs only the fast checks:
250
269
  a few seconds.
@@ -355,6 +374,11 @@ Everything after the frontmatter is the instruction.
355
374
  | `/session delete 2,5` | delete saved conversations by number (or `d d` in the list) |
356
375
  | `/new` | save this one and start fresh |
357
376
  | `/remember <note>` | add a standing note to this project's `UCODE.md` |
377
+ | `/undo` | put back every file the last turn changed |
378
+ | `/look [url]` | open the running app and report what is on the page |
379
+ | `/deploy [folder]` | put the app online and get its link |
380
+ | `/stats` | time, steps and tokens this session |
381
+ | `/doctor` | check that everything ucode needs is working |
358
382
  | `/skills` | what it knows how to do, and what is loaded |
359
383
  | `/search <query>` | look something up on the web |
360
384
  | `/copy` | last reply to the clipboard |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.35.0",
3
+ "version": "1.37.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
@@ -19,6 +19,7 @@ import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
19
19
  import { LogWatch } from './livelog.js';
20
20
  import { checkHtml } from './htmlcheck.js';
21
21
  import { runningServers } from '../tools/shell.js';
22
+ import { beginTurn, undoTurn, changedCount } from './undo.js';
22
23
  import { spawn } from 'node:child_process';
23
24
 
24
25
  import {
@@ -1076,6 +1077,10 @@ export class Agent {
1076
1077
  }
1077
1078
 
1078
1079
  async turn(input) {
1080
+ // From here every file this turn writes keeps a copy of how it was, so
1081
+ // /undo can put the whole turn back.
1082
+ beginTurn();
1083
+ this.lookedThisTurn = false;
1079
1084
  forgetReviews(); // a new request: its apps get a fresh design review
1080
1085
  const images = await this.attachImages(input);
1081
1086
  this.push(images.length
@@ -1642,6 +1647,14 @@ export class Agent {
1642
1647
  // are still built by the tool — the model reads them in the result — they
1643
1648
  // simply do not go on screen.
1644
1649
  if (out.diff?.length) this.ui.diffStat?.(countDiff(out.diff));
1650
+ // A look is the most thorough thing ucode runs — two widths, screenshots,
1651
+ // a designer's review, and now the app actually driven — and it was the
1652
+ // quietest line on screen, saying only that it had happened. Its verdict
1653
+ // goes on the same line, the way a change carries its two numbers.
1654
+ if (call.name === 'look_at_app') {
1655
+ const found = /^(\d+) problem/.exec(out.summary ?? '');
1656
+ this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
1657
+ }
1645
1658
  this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1646
1659
  }
1647
1660
 
@@ -1978,9 +1991,59 @@ export class Agent {
1978
1991
  const live = await this.liveErrors();
1979
1992
  if (live) problems.push(live);
1980
1993
 
1994
+ // Then look at it, in the same pass that type-checks — not when the model
1995
+ // remembers to. A check that runs only when it is asked for reports
1996
+ // nothing on exactly the builds that needed it, and this is the only one
1997
+ // that opens the page, presses its buttons and finds out whether any of it
1998
+ // actually works.
1999
+ if (!problems.length) {
2000
+ const seen = await this.lookOnceThisTurn(root, changed);
2001
+ if (seen) problems.push(seen);
2002
+ }
2003
+
1981
2004
  return problems.length ? problems.join('\n\n') : null;
1982
2005
  }
1983
2006
 
2007
+ /**
2008
+ * Open what was just built and report what is wrong with it, once a turn.
2009
+ *
2010
+ * Points at a dev server when one is running; otherwise serves the folder
2011
+ * holding the page that changed, which is the only way the default
2012
+ * three-file starter gets looked at at all — it has no server to point at.
2013
+ * Only for a turn that touched something with a page in it: there is nothing
2014
+ * to open after a change to a utility module.
2015
+ *
2016
+ * Never throws. A browser that will not start is a reason to skip the look,
2017
+ * never a reason to fail the turn that built the app.
2018
+ */
2019
+ async lookOnceThisTurn(root, changed) {
2020
+ if (this.lookedThisTurn) return null;
2021
+
2022
+ const pages = [...changed].filter((f) => /\.(?:html?|tsx|jsx)$/i.test(f));
2023
+ if (!pages.length) return null;
2024
+ this.lookedThisTurn = true;
2025
+
2026
+ try {
2027
+ const { lookAtApp, withStaticServer } = await import('../tools/browser.js');
2028
+ const look = async (url) => {
2029
+ this.ui.toolCall(`Looking at ${url} on a phone and a desktop`);
2030
+ const out = await lookAtApp({ url });
2031
+ const found = /^(\d+) problem/.exec(out.summary ?? '');
2032
+ this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
2033
+ return found ? `I opened the app and looked at it:\n\n${out.content}` : null;
2034
+ };
2035
+
2036
+ const server = runningServers().at(-1);
2037
+ if (server?.url) return await look(server.url);
2038
+
2039
+ const html = pages.find((f) => /\.html?$/i.test(f));
2040
+ if (!html) return null;
2041
+ return await withStaticServer(path.dirname(path.resolve(root, html)), look);
2042
+ } catch {
2043
+ return null;
2044
+ }
2045
+ }
2046
+
1984
2047
  /**
1985
2048
  * Anything the running app has complained about since the last look. A dev
1986
2049
  * server knows about a broken import the moment it happens; without this
@@ -2145,6 +2208,7 @@ export class Agent {
2145
2208
  case '/search': return this.cmdSearch(arg);
2146
2209
  case '/copy': return this.cmdCopy();
2147
2210
  case '/stats': return this.cmdStats();
2211
+ case '/undo': return this.cmdUndo();
2148
2212
  case '/doctor': return this.cmdDoctor();
2149
2213
  case '/look': return this.cmdLook(arg);
2150
2214
  case '/deploy': return this.cmdDeploy(arg);
@@ -2187,9 +2251,33 @@ ${out.content}` });
2187
2251
  }
2188
2252
  }
2189
2253
 
2254
+ /**
2255
+ * Put every file the last turn wrote back the way it was.
2256
+ *
2257
+ * The one thing an agent that edits your files on its own has to have. It
2258
+ * covers the last turn only — the state you want back is almost always the
2259
+ * one that just happened — and it says how many files it touched rather than
2260
+ * listing them, the way everything else here reports work.
2261
+ */
2262
+ async cmdUndo() {
2263
+ const count = changedCount();
2264
+ if (!count) {
2265
+ this.ui.note('nothing to undo — the last turn changed no files.');
2266
+ return;
2267
+ }
2268
+
2269
+ const { restored, removed, failed } = await undoTurn();
2270
+ const parts = [];
2271
+ if (restored.length) parts.push(`${restored.length} file${restored.length === 1 ? '' : 's'} put back`);
2272
+ if (removed.length) parts.push(`${removed.length} removed`);
2273
+ this.ui.write(` ${theme.ok('✓')} ${parts.join(', ') || 'nothing to do'}`);
2274
+ for (const f of failed) this.ui.write(theme.error(` could not undo ${f}`));
2275
+ }
2276
+
2190
2277
  cmdHelp() {
2191
2278
  const rows = [
2192
2279
  ['/help', 'this list'],
2280
+ ['/undo', 'put back every file the last turn changed'],
2193
2281
  ['/stats', 'time, steps and tokens this session'],
2194
2282
  ['/doctor', 'check that everything ucode needs is working'],
2195
2283
  ['/look [url]', 'open the running app and report what is on the page'],
@@ -0,0 +1,81 @@
1
+ /**
2
+ * undo.js — what every file looked like before this turn touched it.
3
+ *
4
+ * ucode edits files on its own. Until now there was no way back from that: a
5
+ * turn that went wrong left the work in whatever state it reached, and the
6
+ * only recovery was git, if the project had git and the user had committed.
7
+ * An agent that writes to your disk without an undo is asking for a kind of
8
+ * trust it has not earned.
9
+ *
10
+ * So the original of every file is kept the first time a turn writes to it —
11
+ * the first time only, because the point of an undo is the state before the
12
+ * turn, not before the last of six edits to the same file. A file that did not
13
+ * exist is remembered as absent, and undoing removes it again.
14
+ *
15
+ * Kept in memory, for one turn. Persisting it would be a different feature
16
+ * with a different set of questions (how many turns, where, how big), and the
17
+ * turn you want back is almost always the one that just happened.
18
+ */
19
+
20
+ import fs from 'node:fs/promises';
21
+ import path from 'node:path';
22
+
23
+ /** abs path -> the contents before this turn, or null when it did not exist. */
24
+ let before = new Map();
25
+ let armed = false;
26
+
27
+ /** A new turn: nothing remembered yet, and from here on writes are recorded. */
28
+ export function beginTurn() {
29
+ before = new Map();
30
+ armed = true;
31
+ }
32
+
33
+ /**
34
+ * Remember a file as it is now, if this turn has not already seen it.
35
+ *
36
+ * Reading before every write costs one stat and one read on files that are
37
+ * about to be rewritten anyway. Never throws: failing to record an undo is a
38
+ * reason to have no undo, never a reason to fail the write.
39
+ */
40
+ export async function remember(abs) {
41
+ if (!armed || before.has(abs)) return;
42
+ try {
43
+ before.set(abs, await fs.readFile(abs, 'utf8'));
44
+ } catch {
45
+ before.set(abs, null); // did not exist, so undoing means deleting it
46
+ }
47
+ }
48
+
49
+ /** How many files this turn has changed so far. */
50
+ export function changedCount() {
51
+ return before.size;
52
+ }
53
+
54
+ /**
55
+ * Put every file back the way it was, and say what was done.
56
+ *
57
+ * Returns { restored, removed, failed } rather than throwing, because a
58
+ * partial undo is still worth reporting: knowing four of five files went back
59
+ * is the difference between fixing one thing by hand and wondering.
60
+ */
61
+ export async function undoTurn() {
62
+ const out = { restored: [], removed: [], failed: [] };
63
+
64
+ for (const [abs, text] of before) {
65
+ try {
66
+ if (text === null) {
67
+ await fs.rm(abs, { force: true });
68
+ out.removed.push(abs);
69
+ } else {
70
+ await fs.mkdir(path.dirname(abs), { recursive: true }).catch(() => {});
71
+ await fs.writeFile(abs, text, 'utf8');
72
+ out.restored.push(abs);
73
+ }
74
+ } catch (err) {
75
+ out.failed.push(`${abs}: ${err.message}`);
76
+ }
77
+ }
78
+
79
+ before = new Map();
80
+ return out;
81
+ }
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { promises as fs } from 'node:fs';
17
+ import fsSync from 'node:fs';
17
18
  import path from 'node:path';
18
19
  import { ToolFailure } from '../core/failure.js';
19
20
  import { ask } from '../core/provider.js';
@@ -271,6 +272,42 @@ async function useTheApp(page) {
271
272
  return { tried, worked: moved(before, after) };
272
273
  }
273
274
 
275
+ /**
276
+ * Serve a folder over http just long enough to look at it.
277
+ *
278
+ * The default starter has no dev server — three files that open straight from
279
+ * disk — so there was nothing for the checker to point at, and the most
280
+ * thorough thing ucode runs could not run on the apps it makes most often. A
281
+ * static server on an ephemeral port costs nothing and closes again the
282
+ * moment the look is done.
283
+ */
284
+ export async function withStaticServer(dir, fn) {
285
+ const http = await import('node:http');
286
+ const root = path.resolve(dir);
287
+ const types = {
288
+ '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
289
+ '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
290
+ };
291
+
292
+ const server = http.createServer((req, res) => {
293
+ const rel = (req.url === '/' ? '/index.html' : req.url).split('?')[0];
294
+ const file = path.join(root, decodeURIComponent(rel));
295
+ if (!file.startsWith(root)) { res.writeHead(403); res.end(); return; }
296
+ fsSync.readFile(file, (err, buf) => {
297
+ if (err) { res.writeHead(404); res.end('not found'); return; }
298
+ res.writeHead(200, { 'content-type': types[path.extname(file).toLowerCase()] ?? 'text/plain' });
299
+ res.end(buf);
300
+ });
301
+ });
302
+
303
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
304
+ try {
305
+ return await fn(`http://127.0.0.1:${server.address().port}`);
306
+ } finally {
307
+ server.close();
308
+ }
309
+ }
310
+
274
311
  export async function lookAtApp({ url, paths = ['/'] }) {
275
312
  const base = String(url ?? '').trim().replace(/\/+$/, '');
276
313
  if (!LOCAL.test(`${base}/`)) {
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { promises as fs } from 'node:fs';
11
+ import { remember } from '../core/undo.js';
11
12
  import path from 'node:path';
12
13
  import { ToolFailure } from '../core/failure.js';
13
14
  import {
@@ -297,6 +298,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
297
298
 
298
299
  try {
299
300
  await fs.mkdir(path.dirname(target.abs), { recursive: true });
301
+ await remember(target.abs);
300
302
  await fs.writeFile(target.abs, content, 'utf8');
301
303
  } catch (err) {
302
304
  throw fsFailure(err, attempted, target.show);
@@ -452,12 +454,13 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
452
454
  fix: 'edit_file replaces existing text. Use write_file to create a file.',
453
455
  });
454
456
  }
457
+ // An edit whose two halves are the same asks for the file to stay as it is,
458
+ // which it will. Refusing that was a hard failure, and the model answered it
459
+ // by sending the same edit again — the stuck detector carries a special case
460
+ // for exactly this loop. Saying "already done" ends it in one step.
455
461
  if (old_string === new_string) {
456
- throw new ToolFailure({
457
- kind: 'bad_args', attempted,
458
- failed: `${prefix}old_string and new_string are identical, so the edit would change nothing.`,
459
- fix: 'Set new_string to the text you actually want there.',
460
- });
462
+ const found = text.indexOf(old_string);
463
+ return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length, loose: false };
461
464
  }
462
465
 
463
466
  // Models write \n. A file checked out on Windows is often \r\n, and then an
@@ -555,6 +558,7 @@ export async function editFile({ path: p, old_string, new_string }) {
555
558
  });
556
559
 
557
560
  try {
561
+ await remember(target.abs);
558
562
  await fs.writeFile(target.abs, text, 'utf8');
559
563
  } catch (err) {
560
564
  throw fsFailure(err, attempted, target.show);
@@ -628,6 +632,7 @@ export async function multiEdit({ path: p, edits }) {
628
632
  }
629
633
 
630
634
  try {
635
+ await remember(target.abs);
631
636
  await fs.writeFile(target.abs, text, 'utf8');
632
637
  } catch (err) {
633
638
  throw fsFailure(err, attempted, target.show);
@@ -716,7 +721,8 @@ export async function editFiles({ files }) {
716
721
 
717
722
  for (const { target, text } of planned) {
718
723
  try {
719
- await fs.writeFile(target.abs, text, 'utf8');
724
+ await remember(target.abs);
725
+ await fs.writeFile(target.abs, text, 'utf8');
720
726
  } catch (err) {
721
727
  throw fsFailure(err, `editing ${target.show}`, target.show);
722
728
  }
@@ -108,88 +108,88 @@ async function copyTree(from, to, fill) {
108
108
  * globals.css and layout.tsx. Apps stop looking like the same default blue.
109
109
  * Returns the preset used, or null when the starter has none.
110
110
  */
111
- /**
112
- * The file in each starter that carries the design, not just some of the rules.
113
- *
114
- * next-shadcn has globals.css, which applyDesign re-tints. plain-html has one
115
- * stylesheet and it is the whole design system: the palette, a spacing scale,
116
- * radii, motion timings, focus rings, a reduced-motion rule and a breakpoint.
117
- */
118
- const TOKEN_FILE = { 'plain-html': 'styles.css' };
119
-
120
- /**
121
- * Put the starter's token block back when the app wrote over it without one.
122
- *
123
- * The stylesheet in plain-html is a design, not a placeholder — but `files`
124
- * lands straight on top of the starter, so a model passing its own styles.css
125
- * replaces the scale, the palette and the timings with whatever it typed. What
126
- * comes back is hand-rolled CSS with no system behind it, and an app built on
127
- * raw pixel values has uneven spacing everywhere for the rest of its life.
128
- *
129
- * A replacement that declares its own custom properties is left alone: that is
130
- * a model doing the job properly, and second-guessing it would be worse. Only
131
- * a stylesheet with no :root variables at all gets the starter's block put
132
- * back above it, where every rule underneath can reach it.
133
- */
134
- const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
135
-
136
- /**
137
- * Take the app's own folder back out of its own links.
138
- *
139
- * create_app wants paths relative to the project root — "todo/index.html" —
140
- * because that is where every other file tool works from. The model then
141
- * carries the same prefix into the markup and writes
142
- * <link href="todo/styles.css"> inside todo/index.html, where it resolves to
143
- * todo/todo/styles.css and 404s. The page comes up as bare markup with no
144
- * stylesheet and no script: no design, no behaviour, nothing in the console
145
- * but two failed requests.
146
- *
147
- * It is the tool's own convention leaking into the file, so the tool takes it
148
- * back out. Only a leading "<folder>/" on a relative reference, which inside
149
- * that folder is always wrong — absolute paths, URLs, data: and anchors are
150
- * left exactly as they are.
151
- */
152
- async function unprefixOwnFolder(appDir, written) {
153
- const prefix = `${path.basename(appDir)}/`;
154
- const fixed = [];
155
-
156
- for (const abs of written) {
157
- if (!/\.html?$/i.test(abs)) continue;
158
- const text = await fs.readFile(abs, 'utf8').catch(() => null);
159
- if (text === null) continue;
160
-
161
- let hits = 0;
162
- const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
163
- if (!value.startsWith(prefix)) return all;
164
- hits++;
165
- return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
166
- });
167
-
168
- if (hits) {
169
- await fs.writeFile(abs, next, 'utf8');
170
- fixed.push(`${path.relative(appDir, abs).split(path.sep).join('/')} (${hits})`);
171
- }
172
- }
173
- return fixed;
174
- }
175
-
176
- async function keepDesignTokens(appDir, template, written, starterCss) {
177
- const rel = TOKEN_FILE[template];
178
- if (!rel || !starterCss) return null;
179
-
180
- const abs = path.join(appDir, rel);
181
- if (!written.has(abs)) return null; // never overwritten
182
-
183
- const now = await fs.readFile(abs, 'utf8').catch(() => null);
184
- if (now === null || /:root\s*\{[^}]*--/.test(now)) return null;
185
-
186
- const block = /:root\s*\{[\s\S]*?\n\}/.exec(starterCss);
187
- if (!block) return null;
188
-
189
- await fs.writeFile(abs, `${block[0]}\n\n${now}`, 'utf8');
190
- return rel;
191
- }
192
-
111
+ /**
112
+ * The file in each starter that carries the design, not just some of the rules.
113
+ *
114
+ * next-shadcn has globals.css, which applyDesign re-tints. plain-html has one
115
+ * stylesheet and it is the whole design system: the palette, a spacing scale,
116
+ * radii, motion timings, focus rings, a reduced-motion rule and a breakpoint.
117
+ */
118
+ const TOKEN_FILE = { 'plain-html': 'styles.css' };
119
+
120
+ /**
121
+ * Put the starter's token block back when the app wrote over it without one.
122
+ *
123
+ * The stylesheet in plain-html is a design, not a placeholder — but `files`
124
+ * lands straight on top of the starter, so a model passing its own styles.css
125
+ * replaces the scale, the palette and the timings with whatever it typed. What
126
+ * comes back is hand-rolled CSS with no system behind it, and an app built on
127
+ * raw pixel values has uneven spacing everywhere for the rest of its life.
128
+ *
129
+ * A replacement that declares its own custom properties is left alone: that is
130
+ * a model doing the job properly, and second-guessing it would be worse. Only
131
+ * a stylesheet with no :root variables at all gets the starter's block put
132
+ * back above it, where every rule underneath can reach it.
133
+ */
134
+ const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
135
+
136
+ /**
137
+ * Take the app's own folder back out of its own links.
138
+ *
139
+ * create_app wants paths relative to the project root — "todo/index.html" —
140
+ * because that is where every other file tool works from. The model then
141
+ * carries the same prefix into the markup and writes
142
+ * <link href="todo/styles.css"> inside todo/index.html, where it resolves to
143
+ * todo/todo/styles.css and 404s. The page comes up as bare markup with no
144
+ * stylesheet and no script: no design, no behaviour, nothing in the console
145
+ * but two failed requests.
146
+ *
147
+ * It is the tool's own convention leaking into the file, so the tool takes it
148
+ * back out. Only a leading "<folder>/" on a relative reference, which inside
149
+ * that folder is always wrong — absolute paths, URLs, data: and anchors are
150
+ * left exactly as they are.
151
+ */
152
+ async function unprefixOwnFolder(appDir, written) {
153
+ const prefix = `${path.basename(appDir)}/`;
154
+ const fixed = [];
155
+
156
+ for (const abs of written) {
157
+ if (!/\.html?$/i.test(abs)) continue;
158
+ const text = await fs.readFile(abs, 'utf8').catch(() => null);
159
+ if (text === null) continue;
160
+
161
+ let hits = 0;
162
+ const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
163
+ if (!value.startsWith(prefix)) return all;
164
+ hits++;
165
+ return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
166
+ });
167
+
168
+ if (hits) {
169
+ await fs.writeFile(abs, next, 'utf8');
170
+ fixed.push(`${path.relative(appDir, abs).split(path.sep).join('/')} (${hits})`);
171
+ }
172
+ }
173
+ return fixed;
174
+ }
175
+
176
+ async function keepDesignTokens(appDir, template, written, starterCss) {
177
+ const rel = TOKEN_FILE[template];
178
+ if (!rel || !starterCss) return null;
179
+
180
+ const abs = path.join(appDir, rel);
181
+ if (!written.has(abs)) return null; // never overwritten
182
+
183
+ const now = await fs.readFile(abs, 'utf8').catch(() => null);
184
+ if (now === null || /:root\s*\{[^}]*--/.test(now)) return null;
185
+
186
+ const block = /:root\s*\{[\s\S]*?\n\}/.exec(starterCss);
187
+ if (!block) return null;
188
+
189
+ await fs.writeFile(abs, `${block[0]}\n\n${now}`, 'utf8');
190
+ return rel;
191
+ }
192
+
193
193
  export async function applyDesign(appDir, design) {
194
194
  const dir = path.join(appDir, 'presets');
195
195
  const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
@@ -304,12 +304,21 @@ export async function createApp({ folder, name, description, template = 'plain-h
304
304
  } catch {
305
305
  existing = [];
306
306
  }
307
- if (existing.length) {
307
+ // A folder with something already in it, and the whole app passed in
308
+ // alongside: that is the second attempt at a build that half happened.
309
+ // Refusing it is how a Next.js build spent two calls being told no and then
310
+ // started over from nothing. The starter is already on disk — take the files
311
+ // and write them into it.
312
+ //
313
+ // With no files it is still a refusal, because then there is nothing to do
314
+ // but copy a starter over work that is already there.
315
+ const adopt = existing.length > 0 && given.length > 0;
316
+ if (existing.length && !adopt) {
308
317
  throw new ToolFailure({
309
318
  kind: 'not_empty',
310
319
  attempted,
311
320
  failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
312
- fix: 'Pick a new folder name. If this folder is the app from an earlier attempt, work in it instead of creating it again.',
321
+ fix: 'Pick a new folder name, or pass the app in "files" to write it into the folder that is already there.',
313
322
  });
314
323
  }
315
324
 
@@ -320,23 +329,23 @@ export async function createApp({ folder, name, description, template = 'plain-h
320
329
  __APP_DESCRIPTION__: plain(description) || display,
321
330
  };
322
331
 
323
- const copied = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
332
+ const copied = adopt ? [] : await copyTree(path.join(TEMPLATES, template), target.abs, fill);
324
333
  // Next.js serves static files from public/; a plain page has no such place
325
334
  // and an empty folder in a three-file app is clutter.
326
- if (template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
327
- const look = await applyDesign(target.abs, design);
328
-
329
- // Read before the app's own files land on top of it, so the tokens can be
330
- // put back if the replacement arrives without any.
331
- const starterCss = TOKEN_FILE[template]
332
- ? await fs.readFile(path.join(target.abs, TOKEN_FILE[template]), 'utf8').catch(() => null)
335
+ if (!adopt && template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
336
+ const look = await applyDesign(target.abs, design);
337
+
338
+ // Read before the app's own files land on top of it, so the tokens can be
339
+ // put back if the replacement arrives without any.
340
+ const starterCss = TOKEN_FILE[template]
341
+ ? await fs.readFile(path.join(target.abs, TOKEN_FILE[template]), 'utf8').catch(() => null)
333
342
  : null;
334
343
 
335
344
  // The starter has been installed on this machine before: hard-link that
336
345
  // tree in, which is seconds where npm is a minute. Otherwise install as
337
346
  // usual, and keep the result so the next app is instant.
338
347
  let linked = 0;
339
- const needsInstall = template !== 'plain-html';
348
+ const needsInstall = !adopt && template !== 'plain-html';
340
349
  if (install && needsInstall) {
341
350
  // Keyed on the starter's lockfile, which is the same for every app made
342
351
  // from it — the app's own is rewritten by npm as it installs.
@@ -359,8 +368,8 @@ export async function createApp({ folder, name, description, template = 'plain-h
359
368
  // one, and round trips are nearly all of the time a build takes.
360
369
  const mine = given;
361
370
  const wrote = mine.length ? await batchWrite({ files: mine }) : null;
362
- const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
363
- const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
371
+ const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
372
+ const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
364
373
  const relinked = await unprefixOwnFolder(target.abs, written);
365
374
 
366
375
  // The starter's own files, in full, so there is never a reason to read them
@@ -377,20 +386,20 @@ export async function createApp({ folder, name, description, template = 'plain-h
377
386
  const out = result(
378
387
  `Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
379
388
  (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
380
- (wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
381
- (relinked.length
382
- ? `\nFixed in ${relinked.join(', ')}: links that began with "${path.basename(target.abs)}/". ` +
383
- 'Paths in "files" are relative to the project root, but a link inside a page is ' +
384
- 'relative to that page — so "index.html" beside "styles.css" links to it as ' +
385
- '"styles.css", never "app/styles.css". Written that way the stylesheet and the ' +
386
- 'script 404 and the page comes up as bare markup.\n'
387
- : '') +
388
- (keptTokens
389
- ? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
390
- 'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +
391
- 'Build the rest of the stylesheet out of those variables: spacing that comes from a ' +
392
- 'scale is the difference between a designed page and an arranged one. Re-tint the ' +
393
- 'values to suit this app; do not go back to raw pixels.\n'
389
+ (wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
390
+ (relinked.length
391
+ ? `\nFixed in ${relinked.join(', ')}: links that began with "${path.basename(target.abs)}/". ` +
392
+ 'Paths in "files" are relative to the project root, but a link inside a page is ' +
393
+ 'relative to that page — so "index.html" beside "styles.css" links to it as ' +
394
+ '"styles.css", never "app/styles.css". Written that way the stylesheet and the ' +
395
+ 'script 404 and the page comes up as bare markup.\n'
396
+ : '') +
397
+ (keptTokens
398
+ ? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
399
+ 'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +
400
+ 'Build the rest of the stylesheet out of those variables: spacing that comes from a ' +
401
+ 'scale is the difference between a designed page and an arranged one. Re-tint the ' +
402
+ 'values to suit this app; do not go back to raw pixels.\n'
394
403
  : '') +
395
404
  (linked
396
405
  ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
package/src/ui/screen.js CHANGED
@@ -60,7 +60,7 @@ export function isLabel(text) {
60
60
  export const COMMANDS = [
61
61
  '/help', '/model', '/models', '/session', '/sessions', '/resume',
62
62
  '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
63
- '/stats', '/doctor', '/deploy', '/look',
63
+ '/stats', '/doctor', '/deploy', '/look', '/undo',
64
64
  ];
65
65
 
66
66
  // ANSI ----------------------------------------------------------------------
@@ -473,6 +473,19 @@ export class Screen {
473
473
  * there buries the answer under a copy of something already on disk, so
474
474
  * what is kept is the shape of the change: how much arrived, how much left.
475
475
  */
476
+ /**
477
+ * A one-word verdict on the step that just ran — "clean", "3 to fix".
478
+ *
479
+ * Same rule as diffStat: it goes on the line that named the step. Nothing
480
+ * goes underneath a bullet, and a result line per tool doubles the height of
481
+ * the transcript to say "ok".
482
+ */
483
+ runStat(text) {
484
+ if (!this.run || !text) return;
485
+ this.run.stat = text;
486
+ this.paintRun();
487
+ }
488
+
476
489
  diffStat({ added = 0, removed = 0 } = {}) {
477
490
  if (!this.run) return;
478
491
  this.run.added += added;
package/src/ui/theme.js CHANGED
@@ -481,10 +481,16 @@ export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).sl
481
481
  * asLabel() already: run that over this and its regexes would be reading
482
482
  * escape sequences instead of the last word.
483
483
  */
484
- export function runLine({ label, count = 1, targets = [], added = 0, removed = 0 }) {
484
+ export function runLine({ label, count = 1, targets = [], added = 0, removed = 0, stat = '' }) {
485
+ // What came of the step, in the same place a change puts its two numbers:
486
+ // on the line that named the step, never underneath it. A look that found
487
+ // three things to fix is the one fact worth carrying out of a look, and
488
+ // without it the most thorough check ucode runs is the quietest thing on
489
+ // screen — it opens the app at two widths, screenshots both and has them
490
+ // reviewed, and said nothing about any of it.
485
491
  const counts = added || removed
486
492
  ? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
487
- : '';
493
+ : (stat ? ` ${sky(stat)}` : '');
488
494
  if (count <= 1) return `${paintStep(label)}${counts}`;
489
495
 
490
496
  const g = GROUPS[groupKind(label)];