ucode-agent 1.62.4 → 1.62.6
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 +464 -464
- package/package.json +1 -1
- package/src/core/attach.js +118 -0
- package/src/core/loop.js +34 -32
- package/src/core/opener.js +44 -0
- package/src/core/scope.js +23 -0
- package/src/tools/browser.js +58 -0
- package/src/tools/scaffold.js +20 -1
- package/src/ui/screen.js +45 -9
- package/src/ui/theme.js +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* attach.js — the "+ file" button: choosing reference files and turning them
|
|
3
|
+
* into something the model can use.
|
|
4
|
+
*
|
|
5
|
+
* The chooser is the computer's own (Finder on macOS, the Open dialog on
|
|
6
|
+
* Windows, zenity on Linux), so a sketch on the Desktop or a brief in
|
|
7
|
+
* Downloads is two clicks away. Where there is none, the caller falls back to
|
|
8
|
+
* a list of the project's files.
|
|
9
|
+
*
|
|
10
|
+
* Images go to the model as pictures; text files ride on the request as
|
|
11
|
+
* reference, under their own name. Anything else is refused with a reason.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { promises as fs } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const IMAGE = { png: 'png', jpg: 'jpeg', jpeg: 'jpeg', gif: 'gif', webp: 'webp' };
|
|
19
|
+
|
|
20
|
+
/** Bigger than this and a picture is refused rather than sent. */
|
|
21
|
+
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/** Bigger than this and a text file is refused: it would crowd out the conversation. */
|
|
24
|
+
export const MAX_TEXT_BYTES = 100 * 1024;
|
|
25
|
+
|
|
26
|
+
/** Folders never listed in the fallback picker. */
|
|
27
|
+
const SKIP = new Set(['node_modules', '.git', '.next', '.ucode', 'dist', 'build', '.turbo', '.vercel']);
|
|
28
|
+
|
|
29
|
+
/** The native "choose a file" command for this platform, or null when there is none to try. */
|
|
30
|
+
function chooserFor(platform) {
|
|
31
|
+
if (platform === 'darwin') {
|
|
32
|
+
return ['osascript', ['-e', 'POSIX path of (choose file with prompt "Add a file for ucode")']];
|
|
33
|
+
}
|
|
34
|
+
if (platform === 'win32') {
|
|
35
|
+
const script =
|
|
36
|
+
'Add-Type -AssemblyName System.Windows.Forms;' +
|
|
37
|
+
'$d = New-Object System.Windows.Forms.OpenFileDialog;' +
|
|
38
|
+
'$d.Title = "Add a file for ucode";' +
|
|
39
|
+
'if ($d.ShowDialog() -eq "OK") { [Console]::Out.Write($d.FileName) }';
|
|
40
|
+
return ['powershell.exe', ['-NoProfile', '-STA', '-Command', script]];
|
|
41
|
+
}
|
|
42
|
+
return ['zenity', ['--file-selection', '--title=Add a file for ucode']];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ask the user for a file with the system's own dialog.
|
|
47
|
+
* Resolves to a path, null when they cancelled, or undefined when there is no dialog here.
|
|
48
|
+
*/
|
|
49
|
+
export function chooseFile({ platform = process.platform, run = execFile } = {}) {
|
|
50
|
+
const [cmd, args] = chooserFor(platform);
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
run(cmd, args, { timeout: 10 * 60_000, windowsHide: false }, (err, stdout) => {
|
|
53
|
+
const picked = String(stdout ?? '').trim();
|
|
54
|
+
if (picked) return resolve(picked);
|
|
55
|
+
// The program could not start (ENOENT, EACCES: a string code) means no
|
|
56
|
+
// dialog here; a non-zero exit from one that ran is a cancel.
|
|
57
|
+
resolve(typeof err?.code === 'string' ? undefined : null);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Files under `root` for the fallback picker, nearest first, at most `limit`. */
|
|
63
|
+
export async function projectFiles(root, limit = 200) {
|
|
64
|
+
const found = [];
|
|
65
|
+
const queue = [''];
|
|
66
|
+
while (queue.length && found.length < limit) {
|
|
67
|
+
const dir = queue.shift();
|
|
68
|
+
const entries = await fs.readdir(path.join(root, dir), { withFileTypes: true }).catch(() => []);
|
|
69
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
70
|
+
const rel = path.join(dir, e.name);
|
|
71
|
+
if (e.isDirectory()) { if (!SKIP.has(e.name) && !e.name.startsWith('.')) queue.push(rel); }
|
|
72
|
+
else if (e.isFile() && found.length < limit) found.push(rel);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read chosen files into what a request carries.
|
|
80
|
+
* Returns { images: [dataUrl], text: string to append to the request, notes: [line for the user] }.
|
|
81
|
+
*/
|
|
82
|
+
export async function loadAttachments(files) {
|
|
83
|
+
const images = [];
|
|
84
|
+
const blocks = [];
|
|
85
|
+
const notes = [];
|
|
86
|
+
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
const name = path.basename(file);
|
|
89
|
+
let buf;
|
|
90
|
+
try {
|
|
91
|
+
buf = await fs.readFile(file);
|
|
92
|
+
} catch {
|
|
93
|
+
notes.push(`could not read ${name} — skipped`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const kind = IMAGE[path.extname(name).slice(1).toLowerCase()];
|
|
98
|
+
if (kind) {
|
|
99
|
+
if (buf.length > MAX_IMAGE_BYTES) { notes.push(`${name} is over 10 MB — skipped`); continue; }
|
|
100
|
+
images.push(`data:image/${kind};base64,${buf.toString('base64')}`);
|
|
101
|
+
notes.push(`attached ${name}`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (buf.subarray(0, 8192).includes(0)) {
|
|
106
|
+
notes.push(`${name} is not a picture or a text file — skipped`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (buf.length > MAX_TEXT_BYTES) { notes.push(`${name} is over 100 KB of text — skipped`); continue; }
|
|
110
|
+
const text = buf.toString('utf8').trimEnd();
|
|
111
|
+
// A fence longer than any run of backticks inside, so the file cannot close it early.
|
|
112
|
+
const fence = '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((run) => run.length + 1)));
|
|
113
|
+
blocks.push(`Reference file ${name}, attached by the user:\n${fence}\n${text}\n${fence}`);
|
|
114
|
+
notes.push(`attached ${name}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { images, text: blocks.length ? `\n\n${blocks.join('\n\n')}` : '', notes };
|
|
118
|
+
}
|
package/src/core/loop.js
CHANGED
|
@@ -51,6 +51,9 @@ import { StuckWatch, eventFor, describeHit } from './stuck.js';
|
|
|
51
51
|
import { serversReadySince } from '../tools/shell.js';
|
|
52
52
|
import { formatDuration } from '../ui/activity.js';
|
|
53
53
|
import { MAX_FILE_OUTPUT } from '../tools/shared.js';
|
|
54
|
+
import { openInBrowser } from './opener.js';
|
|
55
|
+
import { withScope } from './scope.js';
|
|
56
|
+
import { chooseFile, projectFiles, loadAttachments } from './attach.js';
|
|
54
57
|
import { runDoctor } from './doctor.js';
|
|
55
58
|
import { JS_LOGIC } from './jslogic.js';
|
|
56
59
|
import { deploy } from '../tools/deploy.js';
|
|
@@ -110,19 +113,6 @@ const SILENT = new Set(['update_plan']);
|
|
|
110
113
|
/** How many rounds of "the type check found errors, fix them" one turn may take. */
|
|
111
114
|
const MAX_FIX_ROUNDS = 3;
|
|
112
115
|
|
|
113
|
-
/**
|
|
114
|
-
* Rides on a new app's request, the last thing the model reads. In the system
|
|
115
|
-
* prompt and the skill alone, Flash-Lite still built "make me a tasks app" as a
|
|
116
|
-
* tasks app plus pomodoro timer, kanban board and analytics — twice running.
|
|
117
|
-
*/
|
|
118
|
-
const SCOPE_NOTE =
|
|
119
|
-
'(From ucode: if this asks for a new app, build it complete and well made, but as the one app asked ' +
|
|
120
|
-
'for - no extra views or tools such as timers, calendars, kanban boards, analytics, stats or an ' +
|
|
121
|
-
'editor for making your own, unless the request names them.)';
|
|
122
|
-
|
|
123
|
-
/** A request that may be for a new app. Loose on purpose: the note it adds says "if". */
|
|
124
|
-
const BUILD_ASK = /\b(?:make|build|create|design|code|write|generate|develop)\b|\bi\s+(?:want|need)\b|\b(?:app|game|website|site|page|tracker|calculator|quiz)\b/i;
|
|
125
|
-
|
|
126
116
|
/** A file up to this many lines is sent whole on its first read, whatever slice was asked for. */
|
|
127
117
|
const WHOLE_READ_LINES = 1500;
|
|
128
118
|
|
|
@@ -1032,6 +1022,7 @@ export class Agent {
|
|
|
1032
1022
|
}
|
|
1033
1023
|
};
|
|
1034
1024
|
this.ui.onModeChange = () => this.showHeader({ clear: false });
|
|
1025
|
+
this.ui.onAttach = () => { this.attachFile().catch(() => {}); };
|
|
1035
1026
|
}
|
|
1036
1027
|
|
|
1037
1028
|
for (const problem of this.skills.problems ?? []) {
|
|
@@ -1243,12 +1234,18 @@ export class Agent {
|
|
|
1243
1234
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1244
1235
|
setRequest(input); // create_app checks this before choosing a starter
|
|
1245
1236
|
const images = await this.attachImages(input);
|
|
1237
|
+
// Files added with the "+ file" button: pictures go as pictures, text as reference.
|
|
1238
|
+
const added = await loadAttachments(this.ui.takeAttachments?.() ?? []);
|
|
1239
|
+
for (const line of added.notes) this.ui.note(line);
|
|
1240
|
+
images.push(...added.images);
|
|
1246
1241
|
// The skill goes in ahead of the request, so the request is the last
|
|
1247
1242
|
// thing the model reads. After it, a page of house rules was what the
|
|
1248
1243
|
// model answered: asked for a dark mode toggle on its second turn, a live
|
|
1249
1244
|
// run re-read two files and repeated its first turn's summary instead.
|
|
1250
1245
|
this.autoLoad(input);
|
|
1251
|
-
const request = images.length
|
|
1246
|
+
const request = images.length
|
|
1247
|
+
? { role: 'user', content: input + added.text, images }
|
|
1248
|
+
: { role: 'user', content: input + added.text };
|
|
1252
1249
|
this.push(request);
|
|
1253
1250
|
|
|
1254
1251
|
if (!this.session.title || this.session.title === 'Untitled') {
|
|
@@ -1262,7 +1259,7 @@ export class Agent {
|
|
|
1262
1259
|
// Nothing to look up in an empty folder, so those tools do not go out with
|
|
1263
1260
|
// the request. Decided per turn: the moment there is code, they are back.
|
|
1264
1261
|
this.fresh = !hasCode(this.map);
|
|
1265
|
-
|
|
1262
|
+
request.content = withScope(input, { fresh: this.fresh }) + added.text;
|
|
1266
1263
|
this.wantsWeb = WANTS_WEB.test(input);
|
|
1267
1264
|
await this.persist();
|
|
1268
1265
|
|
|
@@ -1796,25 +1793,30 @@ export class Agent {
|
|
|
1796
1793
|
}
|
|
1797
1794
|
|
|
1798
1795
|
/**
|
|
1799
|
-
*
|
|
1800
|
-
*
|
|
1801
|
-
* A file goes to explorer on Windows, not `cmd /c start`: the folder name
|
|
1802
|
-
* comes from the model, and an & in it would be a second command to cmd.
|
|
1796
|
+
* The "+ file" button: choose a file with the computer's own dialog — or,
|
|
1797
|
+
* where there is none, from the project's files — to go with the next message.
|
|
1803
1798
|
*/
|
|
1799
|
+
async attachFile() {
|
|
1800
|
+
if (this.attaching) return; // a second click while the dialog is open
|
|
1801
|
+
this.attaching = true;
|
|
1802
|
+
try {
|
|
1803
|
+
let file = await chooseFile();
|
|
1804
|
+
if (file === undefined) {
|
|
1805
|
+
const files = await projectFiles(this.cwd);
|
|
1806
|
+
if (!files.length) { this.ui.note('no files in this folder to add'); return; }
|
|
1807
|
+
const index = await this.ui.pick(files, { hint: 'enter to add · esc to cancel' });
|
|
1808
|
+
file = Number.isInteger(index) ? path.join(this.cwd, files[index]) : null;
|
|
1809
|
+
}
|
|
1810
|
+
if (file) this.ui.addAttachment(path.resolve(this.cwd, file));
|
|
1811
|
+
} finally {
|
|
1812
|
+
this.attaching = false;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** Show a URL or a page in the project in the desktop browser (see opener.js). UCODE_OPEN=0 turns it off. */
|
|
1804
1817
|
openInBrowser(target) {
|
|
1805
1818
|
if (!this.full || process.env.UCODE_OPEN === '0') return false;
|
|
1806
|
-
|
|
1807
|
-
// Only pages inside the project: a UNC path would have explorer reach out to another machine.
|
|
1808
|
-
const rel = web ? '' : path.relative(this.cwd, target);
|
|
1809
|
-
if (!web && (!rel || rel.startsWith('..') || path.isAbsolute(rel))) return false;
|
|
1810
|
-
const [cmd, args] = process.platform === 'win32'
|
|
1811
|
-
? (web ? ['cmd', ['/c', 'start', '', target]] : ['explorer.exe', [target]])
|
|
1812
|
-
: [process.platform === 'darwin' ? 'open' : 'xdg-open', [target]];
|
|
1813
|
-
try {
|
|
1814
|
-
// A missing opener (no xdg-open) fails later, as an event; unheard, it would crash ucode.
|
|
1815
|
-
spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).on('error', () => {}).unref();
|
|
1816
|
-
return true;
|
|
1817
|
-
} catch { return false; /* no browser to open — the link is in the answer */ }
|
|
1819
|
+
return openInBrowser(target, { root: this.cwd });
|
|
1818
1820
|
}
|
|
1819
1821
|
|
|
1820
1822
|
cmdStats() {
|
|
@@ -3032,7 +3034,7 @@ ${out.content}` });
|
|
|
3032
3034
|
}
|
|
3033
3035
|
this.ui.blank();
|
|
3034
3036
|
this.ui.write(dim(' /models, /session and /sessions do the same as /model and /resume.'));
|
|
3035
|
-
this.ui.write(dim(' ctrl+b swaps plan and build · esc stops a running turn · ctrl+d quits'));
|
|
3037
|
+
this.ui.write(dim(' ctrl+b swaps plan and build · + file (or ctrl+o) adds a picture or file · esc stops a running turn · ctrl+d quits'));
|
|
3036
3038
|
this.ui.blank();
|
|
3037
3039
|
}
|
|
3038
3040
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opener.js — showing a finished page or a running server in the user's own
|
|
3
|
+
* browser.
|
|
4
|
+
*
|
|
5
|
+
* Kept apart from the loop because it is the one place ucode starts a program
|
|
6
|
+
* on a path the model chose, and that deserves to be read on its own.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
/** Is this a web address rather than a file on disk? */
|
|
13
|
+
const isWeb = (target) => /^https?:\/\//.test(target);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Only pages inside the project are opened. A folder name comes from the
|
|
17
|
+
* model, and a UNC path (\\host\share) would have explorer reach out to
|
|
18
|
+
* another machine.
|
|
19
|
+
*/
|
|
20
|
+
export function insideRoot(target, root) {
|
|
21
|
+
const rel = path.relative(root, target);
|
|
22
|
+
return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open a URL or a page inside `root`. Returns whether an opener was started.
|
|
27
|
+
*
|
|
28
|
+
* A file goes to explorer on Windows, not `cmd /c start`: an & in a folder
|
|
29
|
+
* name would be a second command to cmd.
|
|
30
|
+
*/
|
|
31
|
+
export function openInBrowser(target, { root }) {
|
|
32
|
+
const web = isWeb(target);
|
|
33
|
+
if (!web && !insideRoot(target, root)) return false;
|
|
34
|
+
const [cmd, args] = process.platform === 'win32'
|
|
35
|
+
? (web ? ['cmd', ['/c', 'start', '', target]] : ['explorer.exe', [target]])
|
|
36
|
+
: [process.platform === 'darwin' ? 'open' : 'xdg-open', [target]];
|
|
37
|
+
try {
|
|
38
|
+
// A missing opener (no xdg-open) fails later, as an event; unheard, it would crash ucode.
|
|
39
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).on('error', () => {}).unref();
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false; // no browser to open — the link is in the answer
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope.js — keeping a new app to what was asked for.
|
|
3
|
+
*
|
|
4
|
+
* In the system prompt and the build-app skill alone, Flash-Lite still built
|
|
5
|
+
* "make me a tasks app" as a tasks app plus a pomodoro timer, a kanban board
|
|
6
|
+
* and analytics — twice running — and the extra views were where the builds
|
|
7
|
+
* broke. So the rule also rides on the request itself, the last thing the
|
|
8
|
+
* model reads before it starts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SCOPE_NOTE =
|
|
12
|
+
'(From ucode: if this asks for a new app, build it complete and well made, but as the one app asked ' +
|
|
13
|
+
'for - no extra views or tools such as timers, calendars, kanban boards, analytics, stats or an ' +
|
|
14
|
+
'editor for making your own, unless the request names them.)';
|
|
15
|
+
|
|
16
|
+
/** A request that may be for a new app. Loose on purpose: the note it adds says "if". */
|
|
17
|
+
export const BUILD_ASK =
|
|
18
|
+
/\b(?:make|build|create|design|code|write|generate|develop)\b|\bi\s+(?:want|need)\b|\b(?:app|game|website|site|page|tracker|calculator|quiz)\b/i;
|
|
19
|
+
|
|
20
|
+
/** The request as the model sees it: one in an empty folder, or shaped like a build, carries the note. */
|
|
21
|
+
export function withScope(input, { fresh = false } = {}) {
|
|
22
|
+
return fresh || BUILD_ASK.test(input) ? `${input}\n\n${SCOPE_NOTE}` : input;
|
|
23
|
+
}
|
package/src/tools/browser.js
CHANGED
|
@@ -232,6 +232,63 @@ const NOT_ADD = /search|filter|find|sort|query/i;
|
|
|
232
232
|
const LIST_HINT = /task|todo|to-do|item|note|add|new|entry|expense|habit|title|what needs|remind/i;
|
|
233
233
|
const ADD_BUTTON = /^\s*(?:\+|add|new|create|save|submit|post|done)\b|\b(?:add|create|save)\s/i;
|
|
234
234
|
|
|
235
|
+
/** A value a form will take for a field of this type, or null to leave the field alone. */
|
|
236
|
+
function sampleFor(type, today) {
|
|
237
|
+
switch (type) {
|
|
238
|
+
case 'number': return '42';
|
|
239
|
+
case 'date': return today;
|
|
240
|
+
case 'time': return '12:00';
|
|
241
|
+
case 'datetime-local': return `${today}T12:00`;
|
|
242
|
+
case 'month': return today.slice(0, 7);
|
|
243
|
+
case 'email': return 'check@example.com';
|
|
244
|
+
case 'url': return 'https://example.com';
|
|
245
|
+
case 'tel': return '5550100';
|
|
246
|
+
case 'text': case 'textarea': case 'search': case '': return PROBE_TEXT;
|
|
247
|
+
default: return null; // checkboxes, radios, files, colours: nothing to guess
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fill in the rest of the form the probe text went into, the way a person
|
|
253
|
+
* would before pressing Add.
|
|
254
|
+
*
|
|
255
|
+
* Traced: a budget tracker's form also needs an amount. Only the description
|
|
256
|
+
* was typed, the browser refused the submit, and a working app was reported as
|
|
257
|
+
* "ADDING DOES NOT WORK" — the model then spent its fix rounds on code that
|
|
258
|
+
* worked. Filled: required fields, empty number fields (amounts are often
|
|
259
|
+
* checked in script, not marked required) and dropdowns left on a blank choice.
|
|
260
|
+
*/
|
|
261
|
+
async function fillTheRest(page, field) {
|
|
262
|
+
const marked = await field.evaluate((e) => {
|
|
263
|
+
if (!e.form) return false;
|
|
264
|
+
e.form.setAttribute('data-ucode-probe', '');
|
|
265
|
+
return true;
|
|
266
|
+
}).catch(() => false);
|
|
267
|
+
if (!marked) return;
|
|
268
|
+
|
|
269
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
270
|
+
const all = page.locator('form[data-ucode-probe] :is(input, select, textarea)');
|
|
271
|
+
const n = Math.min(await all.count().catch(() => 0), 20);
|
|
272
|
+
for (let i = 0; i < n; i++) {
|
|
273
|
+
const el = all.nth(i);
|
|
274
|
+
const info = await el.evaluate((e) => ({
|
|
275
|
+
tag: e.tagName.toLowerCase(),
|
|
276
|
+
type: (e.getAttribute('type') || (e.tagName === 'TEXTAREA' ? 'textarea' : '')).toLowerCase(),
|
|
277
|
+
empty: !e.value,
|
|
278
|
+
required: e.required,
|
|
279
|
+
})).catch(() => null);
|
|
280
|
+
if (!info?.empty || !(await el.isVisible().catch(() => false))) continue;
|
|
281
|
+
if (info.tag === 'select') {
|
|
282
|
+
await el.selectOption({ index: 1 }, { timeout: 2_000 }).catch(() => {});
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (!info.required && info.type !== 'number') continue;
|
|
286
|
+
const value = sampleFor(info.type, today);
|
|
287
|
+
if (value !== null) await el.fill(value, { timeout: 2_000 }).catch(() => {});
|
|
288
|
+
}
|
|
289
|
+
await page.locator('form[data-ucode-probe]').evaluate((f) => f.removeAttribute('data-ucode-probe')).catch(() => {});
|
|
290
|
+
}
|
|
291
|
+
|
|
235
292
|
async function tryAdd(page) {
|
|
236
293
|
const shows = () => page.evaluate((t) => document.body.innerText.includes(t), PROBE_TEXT).catch(() => false);
|
|
237
294
|
const settle = () => page.waitForTimeout(400);
|
|
@@ -279,6 +336,7 @@ async function tryAdd(page) {
|
|
|
279
336
|
}
|
|
280
337
|
const ok = await field.el.fill(PROBE_TEXT, { timeout: 2_000 }).then(() => true).catch(() => false);
|
|
281
338
|
if (!ok) return tried.length ? { tried, added: false, listApp } : null;
|
|
339
|
+
await fillTheRest(page, field.el);
|
|
282
340
|
await field.el.press('Enter', { timeout: 2_000 }).catch(() => {});
|
|
283
341
|
await settle();
|
|
284
342
|
tried.push(`typed "${PROBE_TEXT}" into ${field.hint ? `"${field.hint.slice(0, 30)}"` : 'the field'} and pressed Enter`);
|
package/src/tools/scaffold.js
CHANGED
|
@@ -67,6 +67,24 @@ export function normaliseFiles(files) {
|
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Every file passed to create_app belongs in the app's folder.
|
|
72
|
+
*
|
|
73
|
+
* Traced: a build from a sketch passed "index.html", "style.css" and
|
|
74
|
+
* "main.js" with no folder. They landed beside the app, the folder kept the
|
|
75
|
+
* blank starter, and nothing was checked or handed over. A path already under
|
|
76
|
+
* the folder, absolute, or reaching out with .. is left as it was.
|
|
77
|
+
*/
|
|
78
|
+
export function intoFolder(files, folder) {
|
|
79
|
+
const prefix = `${folder}/`;
|
|
80
|
+
return files.map((f) => {
|
|
81
|
+
if (!f || typeof f.path !== 'string') return f;
|
|
82
|
+
const p = path.posix.normalize(f.path.replace(/\\/g, '/'));
|
|
83
|
+
if (path.isAbsolute(p) || p.startsWith('..') || p.startsWith(prefix)) return f;
|
|
84
|
+
return { ...f, path: prefix + p };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
/** What each starter is for, so the choice is made on purpose. */
|
|
71
89
|
export const TEMPLATE_NOTES = {
|
|
72
90
|
'next-shadcn': 'Next.js, TypeScript, Tailwind and shadcn/ui. For anything with routes, data or many components.',
|
|
@@ -431,7 +449,8 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
431
449
|
// The app's own files, written in this same call. Two round trips become
|
|
432
450
|
// one, and round trips are nearly all of the time a build takes.
|
|
433
451
|
// Next.js only: paths that have missed src/ are corrected before they land.
|
|
434
|
-
const
|
|
452
|
+
const inFolder = intoFolder(given, target.show);
|
|
453
|
+
const placed = template === 'next-shadcn' ? placeForNext(inFolder, path.basename(target.abs)) : { files: inFolder, moved: [] };
|
|
435
454
|
const mine = placed.files;
|
|
436
455
|
const wrote = mine.length ? await batchWrite({ files: mine }) : null;
|
|
437
456
|
const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
|
package/src/ui/screen.js
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
|
|
41
41
|
boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
|
|
42
42
|
shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer,
|
|
43
|
-
bannerPaint, RAIL, modeChip, asNarrationLine } from './theme.js';
|
|
43
|
+
bannerPaint, RAIL, modeChip, ADD_CHIP, asNarrationLine } from './theme.js';
|
|
44
44
|
import { FRAME_MS, spinnerGlyph, formatDuration, doneLine, workingLine, bannerSweep, SWEEP_MS } from './activity.js';
|
|
45
45
|
import { gitBranch } from '../core/git.js';
|
|
46
46
|
import { renderer, render } from './markdown.js';
|
|
@@ -209,6 +209,12 @@ export class Screen {
|
|
|
209
209
|
// the chip is clickable wherever the terminal forwards clicks.
|
|
210
210
|
this.mode = 'build';
|
|
211
211
|
this.chipTo = 0;
|
|
212
|
+
// The "+ file" button: its columns on the status row, and the files it has
|
|
213
|
+
// added so far, which go with the next message. Ctrl+O does the same.
|
|
214
|
+
this.plusFrom = 0;
|
|
215
|
+
this.plusTo = 0;
|
|
216
|
+
this.attachments = [];
|
|
217
|
+
this.onAttach = null;
|
|
212
218
|
this.onInterrupt = null;
|
|
213
219
|
this.onModeChange = null;
|
|
214
220
|
this.spinTimer = null;
|
|
@@ -802,7 +808,7 @@ export class Screen {
|
|
|
802
808
|
['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
|
|
803
809
|
branch && ['branch', value(branch)],
|
|
804
810
|
VERSION && ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)],
|
|
805
|
-
['keys', value('/help · esc interrupts · ctrl+b plan')],
|
|
811
|
+
['keys', value('/help · esc interrupts · ctrl+b plan · ctrl+o file')],
|
|
806
812
|
].filter(Boolean).slice(0, BANNER.length - 1);
|
|
807
813
|
|
|
808
814
|
while (facts.length < BANNER.length - 1) facts.push(['', '']);
|
|
@@ -980,7 +986,7 @@ export class Screen {
|
|
|
980
986
|
statusRow(width = this.width()) {
|
|
981
987
|
const inner = width - 2; // the space between the two borders
|
|
982
988
|
const chip = this.modeChip();
|
|
983
|
-
const left = ` ${chip} ${chalk.white(this.model || '—')}`;
|
|
989
|
+
const left = ` ${chip} ${ADD_CHIP} ${chalk.white(this.model || '—')}`;
|
|
984
990
|
|
|
985
991
|
// How long the turn has taken, back in the box beside the other two facts
|
|
986
992
|
// about the session. It is not on the live line: that line says what is
|
|
@@ -993,9 +999,13 @@ export class Screen {
|
|
|
993
999
|
|
|
994
1000
|
// Where a click on the bottom row still counts as hitting the mode chip.
|
|
995
1001
|
this.chipTo = 2 + visLen(chip);
|
|
1002
|
+
this.plusFrom = this.chipTo + 3; // after the two spaces
|
|
1003
|
+
this.plusTo = this.plusFrom + visLen(ADD_CHIP) - 1;
|
|
996
1004
|
|
|
997
1005
|
const between = Math.max(1, inner - visLen(left) - visLen(right));
|
|
998
|
-
const
|
|
1006
|
+
const files = this.attachments.map((f) => path.basename(f)).join(', ');
|
|
1007
|
+
const middle = this.flashText ? dim(clip(this.flashText, between - 2))
|
|
1008
|
+
: files ? sky(clip(`+ ${files}`, between - 2)) : '';
|
|
999
1009
|
|
|
1000
1010
|
const tail = middle ? `${middle} ` : '';
|
|
1001
1011
|
const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
|
|
@@ -1417,11 +1427,29 @@ export class Screen {
|
|
|
1417
1427
|
if (this.welcoming()) {
|
|
1418
1428
|
const g = this.welcomeGeometry();
|
|
1419
1429
|
const statusRow = g.boxTop + g.inputRows + 3; // 1-based
|
|
1420
|
-
if (row
|
|
1430
|
+
if (row !== statusRow) return;
|
|
1431
|
+
if (col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
|
|
1432
|
+
else if (col >= g.left + this.plusFrom && col <= g.left + this.plusTo) this.onAttach?.();
|
|
1421
1433
|
return;
|
|
1422
1434
|
}
|
|
1423
|
-
// The mode chip, at the left of the bottom row.
|
|
1424
|
-
if (row
|
|
1435
|
+
// The mode chip and the "+ file" button, at the left of the bottom row.
|
|
1436
|
+
if (row !== this.rows - 1) return;
|
|
1437
|
+
if (col >= 2 && col <= this.chipTo) this.toggleMode();
|
|
1438
|
+
else if (col >= this.plusFrom && col <= this.plusTo) this.onAttach?.();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/** A file chosen with "+ file", to go with the next message. */
|
|
1442
|
+
addAttachment(file) {
|
|
1443
|
+
if (!this.attachments.includes(file)) this.attachments.push(file);
|
|
1444
|
+
this.render();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/** The files for the message being sent; the button starts empty again. */
|
|
1448
|
+
takeAttachments() {
|
|
1449
|
+
const files = this.attachments;
|
|
1450
|
+
this.attachments = [];
|
|
1451
|
+
this.render();
|
|
1452
|
+
return files;
|
|
1425
1453
|
}
|
|
1426
1454
|
|
|
1427
1455
|
onKey(key) {
|
|
@@ -1453,7 +1481,10 @@ export class Screen {
|
|
|
1453
1481
|
this.history.unshift(text);
|
|
1454
1482
|
// Answers to a y/N or a numbered pick are not messages, so they are
|
|
1455
1483
|
// not echoed: the prompt reports its own outcome.
|
|
1456
|
-
if (!this.pendingPrompt)
|
|
1484
|
+
if (!this.pendingPrompt) {
|
|
1485
|
+
this.userMessage(text);
|
|
1486
|
+
for (const f of this.attachments) this.push(dim(` + ${path.basename(f)}`));
|
|
1487
|
+
}
|
|
1457
1488
|
}
|
|
1458
1489
|
this.render();
|
|
1459
1490
|
this.submit(text);
|
|
@@ -1481,7 +1512,12 @@ export class Screen {
|
|
|
1481
1512
|
this.toggleMode();
|
|
1482
1513
|
return;
|
|
1483
1514
|
|
|
1484
|
-
case '\
|
|
1515
|
+
case '\x0f': // ctrl+o — the "+ file" button, for terminals that send no clicks
|
|
1516
|
+
this.onAttach?.();
|
|
1517
|
+
return;
|
|
1518
|
+
|
|
1519
|
+
case '\x15': // ctrl+u — clear the line; on an empty line, the attached files
|
|
1520
|
+
if (!this.buffer) this.attachments = [];
|
|
1485
1521
|
this.buffer = this.buffer.slice(this.cursor);
|
|
1486
1522
|
this.cursor = 0;
|
|
1487
1523
|
break;
|
package/src/ui/theme.js
CHANGED
|
@@ -127,6 +127,9 @@ export const PLAN_CHIP = chalk.bgHex('#24344f').hex('#8fbcff').bold;
|
|
|
127
127
|
export const modeChip = (mode) =>
|
|
128
128
|
mode === 'plan' ? PLAN_CHIP(' PLAN ') : BUILD_CHIP(' BUILD ');
|
|
129
129
|
|
|
130
|
+
/** The "+ file" button beside the mode chip, in the quieter of the two chip styles. */
|
|
131
|
+
export const ADD_CHIP = PLAN_CHIP(' + file ');
|
|
132
|
+
|
|
130
133
|
/** The spinner. Braille dots, because they animate in place without jitter. */
|
|
131
134
|
export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
132
135
|
|