ucode-agent 1.34.0 → 1.36.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/package.json +1 -1
- package/src/core/loop.js +88 -0
- package/src/core/provider.js +40 -2
- package/src/core/undo.js +81 -0
- package/src/tools/browser.js +61 -7
- package/src/tools/files.js +6 -1
- package/src/tools/scaffold.js +51 -1
- package/src/ui/screen.js +14 -1
- package/src/ui/theme.js +8 -2
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/provider.js
CHANGED
|
@@ -765,6 +765,36 @@ async function streamed(request, opts, id) {
|
|
|
765
765
|
* "path", then "content", then either the next file or the end. Splitting on
|
|
766
766
|
* that shape and escaping the stray quotes gets every file back.
|
|
767
767
|
*/
|
|
768
|
+
/**
|
|
769
|
+
* Which calls can be rebuilt out of broken JSON.
|
|
770
|
+
*
|
|
771
|
+
* All three carry file contents — hundreds of lines of HTML, CSS and
|
|
772
|
+
* JavaScript escaped into a JSON string — which is the one argument shape a
|
|
773
|
+
* model gets wrong often enough to matter. Everything else is short enough
|
|
774
|
+
* that a parse failure is a real mistake, worth reporting rather than guessing
|
|
775
|
+
* around.
|
|
776
|
+
*/
|
|
777
|
+
const SALVAGEABLE = new Set(['write_file', 'batch_write', 'create_app']);
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The plain string arguments sitting beside a files array, read off the raw
|
|
781
|
+
* text when the object as a whole will not parse.
|
|
782
|
+
*
|
|
783
|
+
* Only the keys create_app needs, and only from before the files begin, so a
|
|
784
|
+
* "name" belonging to something nested inside a file cannot be mistaken for
|
|
785
|
+
* the app's own.
|
|
786
|
+
*/
|
|
787
|
+
function scalarArgs(text) {
|
|
788
|
+
const at = text.search(/"files"\s*:/);
|
|
789
|
+
const head = at > 0 ? text.slice(0, at) : text;
|
|
790
|
+
const out = {};
|
|
791
|
+
for (const key of ['folder', 'name', 'description', 'template', 'design']) {
|
|
792
|
+
const hit = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(head);
|
|
793
|
+
if (hit) out[key] = hit[1].replace(/\\(["\\/])/g, '$1');
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
|
|
768
798
|
export function salvageWrites(text) {
|
|
769
799
|
const heads = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"content"\s*:\s*"/g)];
|
|
770
800
|
if (!heads.length) return null;
|
|
@@ -837,9 +867,17 @@ export function readCall({ id, name, raw, cutOff = false }) {
|
|
|
837
867
|
}
|
|
838
868
|
} catch { /* beyond general repair — try the file-write shape next */ }
|
|
839
869
|
|
|
840
|
-
const salvaged = (name
|
|
870
|
+
const salvaged = SALVAGEABLE.has(name) ? salvageWrites(text) : null;
|
|
841
871
|
if (salvaged) {
|
|
842
|
-
|
|
872
|
+
if (name === 'write_file') call.args = salvaged[0];
|
|
873
|
+
// create_app carries the app in the same { path, content } shape, with
|
|
874
|
+
// a few plain strings beside it. Losing the entire call because one of
|
|
875
|
+
// several hundred lines of HTML held a raw newline is how a build ends
|
|
876
|
+
// with no files at all — which is what it did: refused three times,
|
|
877
|
+
// the folder never created, the turn over in sixty-six seconds having
|
|
878
|
+
// produced nothing. The scalars are read back off the same text.
|
|
879
|
+
else if (name === 'create_app') call.args = { ...scalarArgs(text), files: salvaged };
|
|
880
|
+
else call.args = { files: salvaged };
|
|
843
881
|
call.repaired = true;
|
|
844
882
|
return call;
|
|
845
883
|
}
|
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';
|
|
@@ -238,15 +239,32 @@ async function useTheApp(page) {
|
|
|
238
239
|
let after = await snapshot().catch(() => before);
|
|
239
240
|
if (tried.length && moved(before, after)) return { tried, worked: true };
|
|
240
241
|
|
|
241
|
-
// Nothing moved, so try the other half of the same pattern
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
242
|
+
// Nothing moved, so try the other half of the same pattern — the button that
|
|
243
|
+
// submits, before any other. Taking simply the first button in the document
|
|
244
|
+
// finds the theme toggle in the header, clicks it, and reports a working app
|
|
245
|
+
// as dead because switching to dark mode adds no elements.
|
|
246
|
+
const candidates = [
|
|
247
|
+
page.locator('form button[type="submit"], form input[type="submit"], button[type="submit"], input[type="submit"]'),
|
|
248
|
+
page.locator('form button:not([disabled])'),
|
|
249
|
+
page.locator('button:not([disabled]), [role="button"]'),
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
const pressed = new Set();
|
|
253
|
+
for (const group of candidates) {
|
|
254
|
+
const count = Math.min(await group.count().catch(() => 0), 3);
|
|
255
|
+
for (let i = 0; i < count; i++) {
|
|
256
|
+
const button = group.nth(i);
|
|
257
|
+
const label = (await button.innerText().catch(() => '') || '').trim().replace(/\s+/g, ' ').slice(0, 24);
|
|
258
|
+
if (pressed.has(label || `#${i}`)) continue;
|
|
259
|
+
pressed.add(label || `#${i}`);
|
|
260
|
+
|
|
261
|
+
const ok = await button.click({ timeout: 2_000 }).then(() => true).catch(() => false);
|
|
262
|
+
if (!ok) continue;
|
|
247
263
|
await page.waitForTimeout(300);
|
|
248
|
-
tried.push(`clicked ${label ? `"${label}"` : '
|
|
264
|
+
tried.push(`clicked ${label ? `"${label}"` : 'a button'}`);
|
|
265
|
+
|
|
249
266
|
after = await snapshot().catch(() => after);
|
|
267
|
+
if (moved(before, after)) return { tried, worked: true };
|
|
250
268
|
}
|
|
251
269
|
}
|
|
252
270
|
|
|
@@ -254,6 +272,42 @@ async function useTheApp(page) {
|
|
|
254
272
|
return { tried, worked: moved(before, after) };
|
|
255
273
|
}
|
|
256
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
|
+
|
|
257
311
|
export async function lookAtApp({ url, paths = ['/'] }) {
|
|
258
312
|
const base = String(url ?? '').trim().replace(/\/+$/, '');
|
|
259
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);
|
|
@@ -555,6 +557,7 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
555
557
|
});
|
|
556
558
|
|
|
557
559
|
try {
|
|
560
|
+
await remember(target.abs);
|
|
558
561
|
await fs.writeFile(target.abs, text, 'utf8');
|
|
559
562
|
} catch (err) {
|
|
560
563
|
throw fsFailure(err, attempted, target.show);
|
|
@@ -628,6 +631,7 @@ export async function multiEdit({ path: p, edits }) {
|
|
|
628
631
|
}
|
|
629
632
|
|
|
630
633
|
try {
|
|
634
|
+
await remember(target.abs);
|
|
631
635
|
await fs.writeFile(target.abs, text, 'utf8');
|
|
632
636
|
} catch (err) {
|
|
633
637
|
throw fsFailure(err, attempted, target.show);
|
|
@@ -716,7 +720,8 @@ export async function editFiles({ files }) {
|
|
|
716
720
|
|
|
717
721
|
for (const { target, text } of planned) {
|
|
718
722
|
try {
|
|
719
|
-
await
|
|
723
|
+
await remember(target.abs);
|
|
724
|
+
await fs.writeFile(target.abs, text, 'utf8');
|
|
720
725
|
} catch (err) {
|
|
721
726
|
throw fsFailure(err, `editing ${target.show}`, target.show);
|
|
722
727
|
}
|
package/src/tools/scaffold.js
CHANGED
|
@@ -131,6 +131,48 @@ const TOKEN_FILE = { 'plain-html': 'styles.css' };
|
|
|
131
131
|
* a stylesheet with no :root variables at all gets the starter's block put
|
|
132
132
|
* back above it, where every rule underneath can reach it.
|
|
133
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
|
+
|
|
134
176
|
async function keepDesignTokens(appDir, template, written, starterCss) {
|
|
135
177
|
const rel = TOKEN_FILE[template];
|
|
136
178
|
if (!rel || !starterCss) return null;
|
|
@@ -318,7 +360,8 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
318
360
|
const mine = given;
|
|
319
361
|
const wrote = mine.length ? await batchWrite({ files: mine }) : null;
|
|
320
362
|
const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
|
|
321
|
-
const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
|
|
363
|
+
const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
|
|
364
|
+
const relinked = await unprefixOwnFolder(target.abs, written);
|
|
322
365
|
|
|
323
366
|
// The starter's own files, in full, so there is never a reason to read them
|
|
324
367
|
// back — and only the ones this call did not already write over. A read is
|
|
@@ -335,6 +378,13 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
335
378
|
`Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
|
|
336
379
|
(look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
|
|
337
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
|
+
: '') +
|
|
338
388
|
(keptTokens
|
|
339
389
|
? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
|
|
340
390
|
'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +
|
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)];
|