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 +38 -14
- package/package.json +1 -1
- package/src/core/loop.js +88 -0
- package/src/core/undo.js +81 -0
- package/src/tools/browser.js +37 -0
- package/src/tools/files.js +12 -6
- package/src/tools/scaffold.js +118 -109
- package/src/ui/screen.js +14 -1
- package/src/ui/theme.js +8 -2
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.
|
|
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
|
-
**
|
|
111
|
-
requests" is routine
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
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'],
|
package/src/core/undo.js
ADDED
|
@@ -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
|
+
}
|
package/src/tools/browser.js
CHANGED
|
@@ -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}/`)) {
|
package/src/tools/files.js
CHANGED
|
@@ -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
|
-
|
|
457
|
-
|
|
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
|
|
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
|
}
|
package/src/tools/scaffold.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)];
|