ucode-agent 1.11.0 → 1.12.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.11.0",
3
+ "version": "1.12.1",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
package/src/core/loop.js CHANGED
@@ -923,10 +923,12 @@ export class Agent {
923
923
  this.ui.stopSpinner();
924
924
  this.ui.stopTimer?.();
925
925
  this.activity = null;
926
- this.ui.turnEnd?.({ ok: finished });
927
926
  await this.persist();
928
927
  if (this.full) this.showHeader({ clear: false });
929
928
  if (finished) this.openWhenReady(turnStarted);
929
+ // Last, so "Done" is the last thing that happens rather than the last
930
+ // thing said before several more things happen.
931
+ this.ui.turnEnd?.({ ok: finished });
930
932
  }
931
933
  }
932
934
 
@@ -1292,8 +1294,16 @@ export class Agent {
1292
1294
  }
1293
1295
 
1294
1296
  /** A dev server came up during this turn: open it in the browser, once. */
1297
+ /**
1298
+ * Open the running app in a browser — only when asked.
1299
+ *
1300
+ * This used to happen on its own whenever a dev server came up. Something
1301
+ * seizing the screen mid-thought is startling at the best of times, and
1302
+ * during a demo it is worse. UCODE_OPEN=1 brings the old behaviour back for
1303
+ * anyone who liked it; otherwise the URL is on screen to click.
1304
+ */
1295
1305
  openWhenReady(since) {
1296
- if (!this.full || process.env.UCODE_OPEN === '0') return;
1306
+ if (!this.full || process.env.UCODE_OPEN !== '1') return;
1297
1307
  const server = serversReadySince(since).at(-1);
1298
1308
  if (!server || (this.opened ??= new Set()).has(server.url)) return;
1299
1309
  this.opened.add(server.url);
@@ -13,7 +13,7 @@ import { typeOf } from './types.js';
13
13
  import { runCommand, runCommands } from './shell.js';
14
14
  import { webSearch } from './web.js';
15
15
  import { lookAtApp } from './browser.js';
16
- import { createApp } from './scaffold.js';
16
+ import { createApp, TEMPLATE_NAMES, TEMPLATE_NOTES } from './scaffold.js';
17
17
  import { deploy } from './deploy.js';
18
18
  import { clip, READ_LINES } from './shared.js';
19
19
 
@@ -27,18 +27,28 @@ export const tools = [
27
27
  {
28
28
  name: 'create_app',
29
29
  description:
30
- 'Start a new Next.js + shadcn/ui app from the ready-made ucode starter. This is how every ' +
31
- 'Next.js app begins - never run create-next-app or shadcn init. It copies a project that ' +
32
- 'is already known to build (Next.js 16, TypeScript, Tailwind 4, shadcn with 33 common ' +
33
- 'components, light/dark mode, toasts, a design preset of colours and fonts) into a new empty folder, and ' +
34
- 'starts installing its packages in the background so you can write components at once. ' +
35
- 'The result lists everything included.',
30
+ 'Start a new app from a starter that already works. Two to choose between, and the ' +
31
+ 'choice matters. "plain-html": one index.html, one stylesheet, one ES module nothing ' +
32
+ 'to install, nothing to build, opens straight in a browser. Use it whenever the user ' +
33
+ 'asks for plain HTML/CSS/JS, or for a single page, a toy, a game or a visualisation. ' +
34
+ '"next-shadcn": Next.js 16, TypeScript, Tailwind 4 and shadcn with 33 components, light ' +
35
+ 'and dark, toasts, and a design preset — for anything with routes, data or many screens; ' +
36
+ 'its packages install in the background so you can write components at once. This is how ' +
37
+ 'every Next.js app begins - never run create-next-app or shadcn init. Do not reach for ' +
38
+ 'Next.js when a single HTML file is what was asked for.',
36
39
  parameters: {
37
40
  type: 'object',
38
41
  properties: {
39
42
  folder: str('A new, empty folder for the app, relative to the project root, e.g. "stride".'),
40
43
  name: str('The display name of the app, e.g. "Stride".'),
41
44
  description: str('One line about the app, used in the page metadata.'),
45
+ template: {
46
+ type: 'string',
47
+ enum: ['next-shadcn', 'plain-html'],
48
+ description:
49
+ 'Which starter. "plain-html" for plain HTML/CSS/JS, a single page, a toy or a game: ' +
50
+ 'no install, no build. "next-shadcn" (the default) for routes, data or many screens.',
51
+ },
42
52
  design: {
43
53
  type: 'string',
44
54
  enum: ['ocean', 'grove', 'sunset', 'graphite', 'violet', 'citrus'],
@@ -1,209 +1,221 @@
1
- /**
2
- * scaffold.js — starting an app from a starter that is known to work.
3
- *
4
- * Setting up a Next.js + shadcn project from nothing is four minutes of
5
- * create-next-app and shadcn CLI runs — measured at 116s and 130s — plus a
6
- * dozen model round trips to drive them and then theme the result. Every app
7
- * starts from the same place anyway, so ucode ships that place: a project
8
- * that has already been built and type-checked, copied in one step, with its
9
- * install starting in the background while the model writes the first
10
- * component.
11
- */
12
-
13
- import { promises as fs } from 'node:fs';
14
- import path from 'node:path';
15
- import { fileURLToPath } from 'node:url';
16
- import { ToolFailure } from '../core/failure.js';
17
- import { resolveIn, guard, result } from './shared.js';
18
- import { packageJsonWritten, installIn } from './shell.js';
19
- import { restore, populate } from './cache.js';
20
-
21
- const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
22
-
23
- /** npm silently drops these two names from published packages, so they ship renamed. */
24
- const RENAME = { _gitignore: '.gitignore', '_package-lock.json': 'package-lock.json' };
25
-
26
- /** Files the placeholders are filled into. Everything else is copied byte for byte. */
27
- const TEXT = /\.(?:json|md|mjs|css|tsx?)$/i;
28
-
29
- export const TEMPLATE_NAMES = ['next-shadcn'];
30
-
31
- function slug(name) {
32
- return String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
33
- }
34
-
35
- /** Text that is safe inside a JS string and a JSON string. */
36
- const plain = (s) => String(s ?? '').replace(/["'`\\<>]/g, '').replace(/\s+/g, ' ').trim();
37
-
38
- async function copyTree(from, to, fill) {
39
- await fs.mkdir(to, { recursive: true });
40
- const copied = [];
41
- for (const entry of await fs.readdir(from, { withFileTypes: true })) {
42
- const name = RENAME[entry.name] ?? entry.name;
43
- const src = path.join(from, entry.name);
44
- const dest = path.join(to, name);
45
- if (entry.isDirectory()) {
46
- copied.push(...(await copyTree(src, dest, fill)).map((f) => `${name}/${f}`));
47
- } else if (TEXT.test(entry.name) || entry.name in RENAME) {
48
- let text = await fs.readFile(src, 'utf8');
49
- for (const [token, value] of Object.entries(fill)) text = text.split(token).join(value);
50
- await fs.writeFile(dest, text, 'utf8');
51
- copied.push(name);
52
- } else {
53
- await fs.copyFile(src, dest);
54
- copied.push(name);
55
- }
56
- }
57
- return copied;
58
- }
59
-
60
- /**
61
- * @param {object} o
62
- * @param {string} o.folder new, empty folder for the app
63
- * @param {string} o.name display name, e.g. "Stride"
64
- * @param {string} [o.description]
65
- * @param {string} [o.template]
66
- * @param {boolean} [o.install] start the background install (tests turn it off)
67
- */
68
- /**
69
- * Give the new app its look: one of the hand-picked presets in the starter's
70
- * presets/ folder — a full light and dark palette and a font — written into
71
- * globals.css and layout.tsx. Apps stop looking like the same default blue.
72
- * Returns the preset used, or null when the starter has none.
73
- */
74
- export async function applyDesign(appDir, design) {
75
- const dir = path.join(appDir, 'presets');
76
- const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
77
- if (!names.length) return null;
78
- const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
79
- await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
80
- const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
81
-
82
- const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
83
- let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
84
- if (css !== null) {
85
- const retint = (selector, tokens) => {
86
- const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
87
- css = css.replace(block, (all, open, body, close) => {
88
- const seen = new Set();
89
- let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
90
- if (!(key in tokens)) return line;
91
- seen.add(key);
92
- return `${lead}--${key}: ${tokens[key]};`;
93
- });
94
- for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
95
- return open + next + close;
96
- });
97
- };
98
- retint(':root', { radius: preset.radius, ...preset.light });
99
- retint('\\.dark', preset.dark);
100
- await fs.writeFile(cssFile, css);
101
- }
102
-
103
- const sans = preset.fonts?.sans;
104
- const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
105
- if (sans && sans !== 'Geist') {
106
- const id = sans.replace(/\s+/g, '_');
107
- const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
108
- if (layout !== null) {
109
- await fs.writeFile(layoutFile, layout
110
- .replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
111
- .replace('const sans = Geist({', `const sans = ${id}({`));
112
- }
113
- }
114
-
115
- const guide = path.join(appDir, 'TEMPLATE.md');
116
- const text = await fs.readFile(guide, 'utf8').catch(() => null);
117
- if (text !== null) {
118
- await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
119
- `${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
120
- 'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
121
- 'so every screen stays in one look.\n');
122
- }
123
- return preset;
124
- }
125
-
126
- export async function createApp({ folder, name, description, template = 'next-shadcn', design, install = true }) {
127
- if (!TEMPLATE_NAMES.includes(template)) {
128
- throw new ToolFailure({
129
- kind: 'bad_args',
130
- attempted: 'creating an app',
131
- failed: `There is no starter called "${template}".`,
132
- fix: `Use one of: ${TEMPLATE_NAMES.join(', ')}.`,
133
- });
134
- }
135
-
136
- const target = resolveIn(folder, 'create_app', 'folder');
137
- const attempted = `creating an app in ${target.show}`;
138
- if (target.show === '.') {
139
- throw new ToolFailure({
140
- kind: 'bad_args',
141
- attempted,
142
- failed: 'The app needs its own folder, not the project root.',
143
- fix: 'Pass a new folder name, e.g. "stride".',
144
- });
145
- }
146
- await guard(target, `create an app in ${target.abs}`);
147
-
148
- let existing = [];
149
- try {
150
- existing = await fs.readdir(target.abs);
151
- } catch {
152
- existing = [];
153
- }
154
- if (existing.length) {
155
- throw new ToolFailure({
156
- kind: 'not_empty',
157
- attempted,
158
- failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
159
- fix: 'Pick a new folder name. If this folder is the app from an earlier attempt, work in it instead of creating it again.',
160
- });
161
- }
162
-
163
- const display = plain(name) || path.basename(target.abs);
164
- const fill = {
165
- __APP_NAME__: display,
166
- __APP_SLUG__: slug(display),
167
- __APP_DESCRIPTION__: plain(description) || display,
168
- };
169
-
170
- const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
171
- await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
172
- const look = await applyDesign(target.abs, design);
173
-
174
- // The starter has been installed on this machine before: hard-link that
175
- // tree in, which is seconds where npm is a minute. Otherwise install as
176
- // usual, and keep the result so the next app is instant.
177
- let linked = 0;
178
- if (install) {
179
- // Keyed on the starter's lockfile, which is the same for every app made
180
- // from it the app's own is rewritten by npm as it installs.
181
- const lockText = await fs
182
- .readFile(path.join(TEMPLATES, template, '_package-lock.json'), 'utf8')
183
- .catch(() => null);
184
- linked = await restore(target.abs, template, lockText);
185
- if (!linked) {
186
- const pkg = path.join(target.abs, 'package.json');
187
- packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
188
- installIn(target.abs)?.then((done) => {
189
- if (done?.code === 0) populate(target.abs, template, lockText);
190
- });
191
- }
192
- }
193
-
194
- const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
195
-
196
- return result(
197
- `Created ${target.show} from the ${template} starter — ${files.length} files, already known to build.\n` +
198
- (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
199
- (linked
200
- ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
201
- 'nothing to install: build and run straight away.\n'
202
- : install
203
- ? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
204
- `${target.show} waits for that install first, so there is no need to run npm install.\n`
205
- : '') +
206
- `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`,
207
- `${files.length} files${linked ? ' · packages ready' : install ? ' · installing in the background' : ''}`
208
- );
209
- }
1
+ /**
2
+ * scaffold.js — starting an app from a starter that is known to work.
3
+ *
4
+ * Setting up a Next.js + shadcn project from nothing is four minutes of
5
+ * create-next-app and shadcn CLI runs — measured at 116s and 130s — plus a
6
+ * dozen model round trips to drive them and then theme the result. Every app
7
+ * starts from the same place anyway, so ucode ships that place: a project
8
+ * that has already been built and type-checked, copied in one step, with its
9
+ * install starting in the background while the model writes the first
10
+ * component.
11
+ */
12
+
13
+ import { promises as fs } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { ToolFailure } from '../core/failure.js';
17
+ import { resolveIn, guard, result } from './shared.js';
18
+ import { packageJsonWritten, installIn } from './shell.js';
19
+ import { restore, populate } from './cache.js';
20
+
21
+ const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
22
+
23
+ /** npm silently drops these two names from published packages, so they ship renamed. */
24
+ const RENAME = { _gitignore: '.gitignore', '_package-lock.json': 'package-lock.json' };
25
+
26
+ /** Files the placeholders are filled into. Everything else is copied byte for byte. */
27
+ const TEXT = /\.(?:json|md|mjs|css|html|jsx?|tsx?)$/i;
28
+
29
+ export const TEMPLATE_NAMES = ['next-shadcn', 'plain-html'];
30
+
31
+ /** What each starter is for, so the choice is made on purpose. */
32
+ export const TEMPLATE_NOTES = {
33
+ 'next-shadcn': 'Next.js, TypeScript, Tailwind and shadcn/ui. For anything with routes, data or many components.',
34
+ 'plain-html': 'One index.html, one stylesheet, one module. No install, no build, opens straight in a browser.',
35
+ };
36
+
37
+ function slug(name) {
38
+ return String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
39
+ }
40
+
41
+ /** Text that is safe inside a JS string and a JSON string. */
42
+ const plain = (s) => String(s ?? '').replace(/["'`\\<>]/g, '').replace(/\s+/g, ' ').trim();
43
+
44
+ async function copyTree(from, to, fill) {
45
+ await fs.mkdir(to, { recursive: true });
46
+ const copied = [];
47
+ for (const entry of await fs.readdir(from, { withFileTypes: true })) {
48
+ const name = RENAME[entry.name] ?? entry.name;
49
+ const src = path.join(from, entry.name);
50
+ const dest = path.join(to, name);
51
+ if (entry.isDirectory()) {
52
+ copied.push(...(await copyTree(src, dest, fill)).map((f) => `${name}/${f}`));
53
+ } else if (TEXT.test(entry.name) || entry.name in RENAME) {
54
+ let text = await fs.readFile(src, 'utf8');
55
+ for (const [token, value] of Object.entries(fill)) text = text.split(token).join(value);
56
+ await fs.writeFile(dest, text, 'utf8');
57
+ copied.push(name);
58
+ } else {
59
+ await fs.copyFile(src, dest);
60
+ copied.push(name);
61
+ }
62
+ }
63
+ return copied;
64
+ }
65
+
66
+ /**
67
+ * @param {object} o
68
+ * @param {string} o.folder new, empty folder for the app
69
+ * @param {string} o.name display name, e.g. "Stride"
70
+ * @param {string} [o.description]
71
+ * @param {string} [o.template]
72
+ * @param {boolean} [o.install] start the background install (tests turn it off)
73
+ */
74
+ /**
75
+ * Give the new app its look: one of the hand-picked presets in the starter's
76
+ * presets/ folder a full light and dark palette and a font — written into
77
+ * globals.css and layout.tsx. Apps stop looking like the same default blue.
78
+ * Returns the preset used, or null when the starter has none.
79
+ */
80
+ export async function applyDesign(appDir, design) {
81
+ const dir = path.join(appDir, 'presets');
82
+ const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
83
+ if (!names.length) return null;
84
+ const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
85
+ await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
86
+ const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
87
+
88
+ const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
89
+ let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
90
+ if (css !== null) {
91
+ const retint = (selector, tokens) => {
92
+ const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
93
+ css = css.replace(block, (all, open, body, close) => {
94
+ const seen = new Set();
95
+ let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
96
+ if (!(key in tokens)) return line;
97
+ seen.add(key);
98
+ return `${lead}--${key}: ${tokens[key]};`;
99
+ });
100
+ for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
101
+ return open + next + close;
102
+ });
103
+ };
104
+ retint(':root', { radius: preset.radius, ...preset.light });
105
+ retint('\\.dark', preset.dark);
106
+ await fs.writeFile(cssFile, css);
107
+ }
108
+
109
+ const sans = preset.fonts?.sans;
110
+ const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
111
+ if (sans && sans !== 'Geist') {
112
+ const id = sans.replace(/\s+/g, '_');
113
+ const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
114
+ if (layout !== null) {
115
+ await fs.writeFile(layoutFile, layout
116
+ .replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
117
+ .replace('const sans = Geist({', `const sans = ${id}({`));
118
+ }
119
+ }
120
+
121
+ const guide = path.join(appDir, 'TEMPLATE.md');
122
+ const text = await fs.readFile(guide, 'utf8').catch(() => null);
123
+ if (text !== null) {
124
+ await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
125
+ `${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
126
+ 'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
127
+ 'so every screen stays in one look.\n');
128
+ }
129
+ return preset;
130
+ }
131
+
132
+ export async function createApp({ folder, name, description, template = 'next-shadcn', design, install = true }) {
133
+ if (!TEMPLATE_NAMES.includes(template)) {
134
+ throw new ToolFailure({
135
+ kind: 'bad_args',
136
+ attempted: 'creating an app',
137
+ failed: `There is no starter called "${template}".`,
138
+ fix: `Use one of: ${TEMPLATE_NAMES.join(', ')}.`,
139
+ });
140
+ }
141
+
142
+ const target = resolveIn(folder, 'create_app', 'folder');
143
+ const attempted = `creating an app in ${target.show}`;
144
+ if (target.show === '.') {
145
+ throw new ToolFailure({
146
+ kind: 'bad_args',
147
+ attempted,
148
+ failed: 'The app needs its own folder, not the project root.',
149
+ fix: 'Pass a new folder name, e.g. "stride".',
150
+ });
151
+ }
152
+ await guard(target, `create an app in ${target.abs}`);
153
+
154
+ let existing = [];
155
+ try {
156
+ existing = await fs.readdir(target.abs);
157
+ } catch {
158
+ existing = [];
159
+ }
160
+ if (existing.length) {
161
+ throw new ToolFailure({
162
+ kind: 'not_empty',
163
+ attempted,
164
+ failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
165
+ fix: 'Pick a new folder name. If this folder is the app from an earlier attempt, work in it instead of creating it again.',
166
+ });
167
+ }
168
+
169
+ const display = plain(name) || path.basename(target.abs);
170
+ const fill = {
171
+ __APP_NAME__: display,
172
+ __APP_SLUG__: slug(display),
173
+ __APP_DESCRIPTION__: plain(description) || display,
174
+ };
175
+
176
+ const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
177
+ // Next.js serves static files from public/; a plain page has no such place
178
+ // and an empty folder in a three-file app is clutter.
179
+ if (template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
180
+ const look = await applyDesign(target.abs, design);
181
+
182
+ // The starter has been installed on this machine before: hard-link that
183
+ // tree in, which is seconds where npm is a minute. Otherwise install as
184
+ // usual, and keep the result so the next app is instant.
185
+ let linked = 0;
186
+ const needsInstall = template !== 'plain-html';
187
+ if (install && needsInstall) {
188
+ // Keyed on the starter's lockfile, which is the same for every app made
189
+ // from it the app's own is rewritten by npm as it installs.
190
+ const lockText = await fs
191
+ .readFile(path.join(TEMPLATES, template, '_package-lock.json'), 'utf8')
192
+ .catch(() => null);
193
+ linked = await restore(target.abs, template, lockText);
194
+ if (!linked) {
195
+ const pkg = path.join(target.abs, 'package.json');
196
+ packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
197
+ installIn(target.abs)?.then((done) => {
198
+ if (done?.code === 0) populate(target.abs, template, lockText);
199
+ });
200
+ }
201
+ }
202
+
203
+ const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
204
+
205
+ return result(
206
+ `Created ${target.show} from the ${template} starter ${files.length} files, already known to build.\n` +
207
+ (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
208
+ (linked
209
+ ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
210
+ 'nothing to install: build and run straight away.\n'
211
+ : install && needsInstall
212
+ ? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
213
+ `${target.show} waits for that install first, so there is no need to run npm install.\n`
214
+ : '') +
215
+ (needsInstall
216
+ ? `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`
217
+ : `Nothing to install and nothing to build: open ${target.show}/index.html directly, or serve the ` +
218
+ `folder with "python -m http.server 8000" if it fetches anything.\n\n${guide}`),
219
+ `${files.length} files${linked ? ' · packages ready' : install && needsInstall ? ' · installing in the background' : ''}`
220
+ );
221
+ }
package/src/ui/screen.js CHANGED
@@ -92,6 +92,8 @@ const HIDE = `${ESC}[?25l`;
92
92
  const SHOW = `${ESC}[?25h`;
93
93
  const HOME = `${ESC}[H`;
94
94
  const CLEAR_LINE = `${ESC}[K`;
95
+ /** Written out rather than inline, so no edit can turn it into a real break. */
96
+ const NEWLINE = String.fromCharCode(10);
95
97
  const at = (row, col) => `${ESC}[${row};${col}H`;
96
98
  const title = (t) => `${ESC}]0;${t}\x07`;
97
99
 
@@ -603,15 +605,47 @@ export class Screen {
603
605
  // -- input box -----------------------------------------------------------
604
606
 
605
607
  /** The typed line, wrapped to the inside of a box `width` characters across. */
608
+ /**
609
+ * The typed text, laid out as rows inside the box.
610
+ *
611
+ * A line break in the buffer is a row of its own before any wrapping is
612
+ * considered. Slicing the text into fixed widths without looking for one
613
+ * put the newline into the frame instead, and the terminal obeyed it — the
614
+ * pasted text walked out of the box and over the transcript beside it.
615
+ *
616
+ * `starts` records where each row begins in the text, so the caret can be
617
+ * placed by looking up rather than by counting characters a second way and
618
+ * hoping the two agree.
619
+ */
606
620
  inputLines(width = this.inner()) {
607
621
  const prefix = this.pendingPrompt ? `${this.pendingPrompt} ` : '› ';
608
622
  const full = prefix + this.buffer;
609
623
 
610
624
  const rows = [];
611
- for (let i = 0; i < full.length; i += width) rows.push(full.slice(i, i + width));
612
- if (rows.length === 0) rows.push(prefix);
625
+ const starts = [];
626
+ let at = 0;
627
+
628
+ for (const para of full.split(NEWLINE)) {
629
+ let i = 0;
630
+ do {
631
+ rows.push(para.slice(i, i + width));
632
+ starts.push(at + i);
633
+ i += width;
634
+ } while (i < para.length);
635
+ at += para.length + 1; // the newline itself
636
+ }
637
+
638
+ if (rows.length === 0) { rows.push(prefix); starts.push(0); }
639
+ return { rows, prefix, width, starts };
640
+ }
613
641
 
614
- return { rows, prefix, width };
642
+ /** Which row the caret sits on, and how far along it. */
643
+ caretAt(width) {
644
+ const { rows, prefix, starts } = this.inputLines(width);
645
+ const index = prefix.length + this.cursor;
646
+ let row = 0;
647
+ while (row + 1 < starts.length && starts[row + 1] <= index) row++;
648
+ return { row, col: Math.min(index - starts[row], rows[row].length), rows };
615
649
  }
616
650
 
617
651
  viewportHeight() {
@@ -992,7 +1026,7 @@ export class Screen {
992
1026
  * messages. Inside the markers a newline is just a character.
993
1027
  */
994
1028
  onPaste(text) {
995
- const clean = String(text).replace(/\r\n?/g, '\n');
1029
+ const clean = String(text).replace(/\r\n?/g, '\n');
996
1030
  this.buffer = this.buffer.slice(0, this.cursor) + clean + this.buffer.slice(this.cursor);
997
1031
  this.cursor += clean.length;
998
1032
  this.render();
@@ -1064,6 +1098,13 @@ export class Screen {
1064
1098
  }
1065
1099
  rest += chunk.slice(index);
1066
1100
 
1101
+ // A chunk carrying a line break *and* other text did not come from a
1102
+ // keyboard: nobody types a newline in the middle of a burst. Many
1103
+ // terminals, Windows ones especially, send a paste with no markers at
1104
+ // all, so without this every newline in it reads as Enter and the paste
1105
+ // submits itself a line at a time.
1106
+ if (looksPasted(rest)) { this.onPaste(rest); return; }
1107
+
1067
1108
  for (const key of splitKeys(rest)) this.onKey(key);
1068
1109
  }
1069
1110
 
@@ -1248,17 +1289,13 @@ export class Screen {
1248
1289
  caret() {
1249
1290
  if (this.welcoming()) {
1250
1291
  const g = this.welcomeGeometry();
1251
- const { rows, prefix, width } = this.inputLines(g.boxWidth - 4);
1252
- const index = prefix.length + this.cursor;
1253
- const row = Math.min(Math.floor(index / width), rows.length - 1);
1292
+ const { row, col } = this.caretAt(g.boxWidth - 4);
1254
1293
  // g.boxTop is 0-based and the typed lines start one below the border.
1255
- return [g.boxTop + 2 + row, g.left + 3 + (index % width)];
1294
+ return [g.boxTop + 2 + row, g.left + 3 + col];
1256
1295
  }
1257
1296
 
1258
- const { rows, prefix, width } = this.inputLines();
1259
- const index = prefix.length + this.cursor;
1260
- const row = Math.min(Math.floor(index / width), rows.length - 1);
1261
- const col = 3 + (index % width);
1297
+ const { row, col: at, rows } = this.caretAt();
1298
+ const col = 3 + at;
1262
1299
  // Counting up from the bottom: the box border is the last row, the status
1263
1300
  // row is above it, then the blank row, then the typed lines.
1264
1301
  const firstRow = this.rows - 2 - rows.length;
@@ -1333,6 +1370,27 @@ export class Screen {
1333
1370
  * up arrow rather than ESC [ A. Both are normalised to the bracket form here
1334
1371
  * so the key handler only ever sees one of them.
1335
1372
  */
1373
+ /**
1374
+ * Did this arrive as a paste, judged by shape rather than by markers?
1375
+ *
1376
+ * Someone pressing Enter sends one carriage return on its own. A paste sends
1377
+ * a line break with text around it, in a single read. That difference is all
1378
+ * there is to go on when a terminal does not implement bracketed paste, and
1379
+ * it is enough.
1380
+ *
1381
+ * Anything carrying an escape sequence is left alone: that is a key or a
1382
+ * mouse report, and reading one as text would put gibberish in the input.
1383
+ */
1384
+ export function looksPasted(chunk) {
1385
+ const text = String(chunk ?? '');
1386
+ if (text.length < 2 || text.includes(ESC)) return false;
1387
+ const breaks = (text.match(/[\r\n]/g) ?? []).length;
1388
+ if (breaks === 0) return false;
1389
+ // One trailing break is someone finishing a line, not pasting one.
1390
+ if (breaks === 1 && /[\r\n]$/.test(text)) return false;
1391
+ return true;
1392
+ }
1393
+
1336
1394
  export function splitKeys(chunk) {
1337
1395
  const keys = [];
1338
1396
  let i = 0;
@@ -0,0 +1,25 @@
1
+ # __APP_NAME__
2
+
3
+ A plain HTML, CSS and JavaScript app. No framework, no build step, no install:
4
+ `index.html` is the whole program's front door.
5
+
6
+ ## The files
7
+
8
+ - `index.html` — the markup, and the only place scripts and styles are linked
9
+ - `styles.css` — the design tokens at the top, then the components
10
+ - `app.js` — an ES module; `state` holds everything, `render()` draws it
11
+
12
+ ## Working in it
13
+
14
+ Open `index.html` in a browser, or serve the folder if the app fetches
15
+ anything: `python -m http.server 8000`. There is nothing to install and
16
+ nothing to compile, so a change is visible on refresh.
17
+
18
+ ## Conventions worth keeping
19
+
20
+ - **One state object, one render.** Patching the DOM from several places is
21
+ where these apps become impossible to reason about.
22
+ - **Tokens in `:root`.** Colour, spacing and timing are defined once, so the
23
+ look can be changed without touching components.
24
+ - **Reduced motion is honoured** at the bottom of the stylesheet. Leave it in.
25
+ - Keep it to these three files until there is a real reason not to.
@@ -0,0 +1,2 @@
1
+ .DS_Store
2
+ node_modules/
@@ -0,0 +1,43 @@
1
+ /**
2
+ * __APP_NAME__
3
+ *
4
+ * Plain modules, no build step: this file is what the browser runs. Keep the
5
+ * state in one place and render from it, so there is one answer to "what is
6
+ * on screen" rather than a DOM that has been patched in six directions.
7
+ */
8
+
9
+ const app = document.querySelector('#app');
10
+
11
+ /** Everything the page knows. Change this, then call render(). */
12
+ const state = {
13
+ items: load(),
14
+ };
15
+
16
+ function load() {
17
+ try {
18
+ return JSON.parse(localStorage.getItem('__APP_SLUG__') ?? '[]');
19
+ } catch {
20
+ return []; // a corrupt store is an empty one, not a broken page
21
+ }
22
+ }
23
+
24
+ function save() {
25
+ try {
26
+ localStorage.setItem('__APP_SLUG__', JSON.stringify(state.items));
27
+ } catch {
28
+ // Private mode, or the quota is full. Losing the save is survivable;
29
+ // throwing here would take the whole page down with it.
30
+ }
31
+ }
32
+
33
+ function render() {
34
+ app.innerHTML = '';
35
+ app.append(
36
+ Object.assign(document.createElement('div'), {
37
+ className: 'card',
38
+ textContent: 'Edit app.js to build __APP_NAME__.',
39
+ })
40
+ );
41
+ }
42
+
43
+ render();
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>__APP_NAME__</title>
7
+ <meta name="description" content="__APP_DESCRIPTION__" />
8
+ <link rel="stylesheet" href="styles.css" />
9
+ </head>
10
+ <body>
11
+ <main class="shell">
12
+ <header class="head">
13
+ <h1>__APP_NAME__</h1>
14
+ <p class="sub">__APP_DESCRIPTION__</p>
15
+ </header>
16
+
17
+ <section id="app"></section>
18
+ </main>
19
+
20
+ <script type="module" src="app.js"></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,100 @@
1
+ :root {
2
+ /* One accent, a handful of greys, and a scale. Everything else is composed
3
+ from these, so the whole look changes by editing this block. */
4
+ --bg: #0b0d10;
5
+ --surface: #14171c;
6
+ --line: #232830;
7
+ --text: #e7eaf0;
8
+ --muted: #8b93a3;
9
+ --accent: #2dd4bf;
10
+
11
+ --r: 16px;
12
+ --s1: 8px;
13
+ --s2: 12px;
14
+ --s3: 16px;
15
+ --s4: 24px;
16
+ --s5: 40px;
17
+
18
+ --fast: 160ms cubic-bezier(0.2, 0, 0.2, 1);
19
+ --slow: 260ms cubic-bezier(0.2, 0, 0.2, 1);
20
+ }
21
+
22
+ * { box-sizing: border-box; }
23
+
24
+ html, body { height: 100%; }
25
+
26
+ body {
27
+ margin: 0;
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
31
+ -webkit-font-smoothing: antialiased;
32
+ }
33
+
34
+ .shell {
35
+ max-width: 720px;
36
+ margin: 0 auto;
37
+ padding: var(--s5) var(--s4) var(--s5);
38
+ }
39
+
40
+ .head { margin-bottom: var(--s4); }
41
+
42
+ h1 {
43
+ margin: 0;
44
+ font-size: 28px;
45
+ font-weight: 600;
46
+ letter-spacing: -0.02em;
47
+ }
48
+
49
+ .sub {
50
+ margin: var(--s1) 0 0;
51
+ color: var(--muted);
52
+ }
53
+
54
+ .card {
55
+ background: var(--surface);
56
+ border: 1px solid var(--line);
57
+ border-radius: var(--r);
58
+ padding: var(--s3);
59
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px rgb(0 0 0 / 0.2);
60
+ }
61
+
62
+ button {
63
+ font: inherit;
64
+ color: var(--bg);
65
+ background: var(--accent);
66
+ border: 0;
67
+ border-radius: 10px;
68
+ padding: 10px var(--s3);
69
+ cursor: pointer;
70
+ transition: transform var(--fast), filter var(--fast);
71
+ }
72
+
73
+ button:hover { filter: brightness(1.08); }
74
+ button:active { transform: translateY(1px); }
75
+
76
+ input, textarea {
77
+ font: inherit;
78
+ color: var(--text);
79
+ background: transparent;
80
+ border: 1px solid var(--line);
81
+ border-radius: 10px;
82
+ padding: 10px var(--s2);
83
+ width: 100%;
84
+ transition: border-color var(--fast);
85
+ }
86
+
87
+ input:focus, textarea:focus {
88
+ outline: none;
89
+ border-color: var(--accent);
90
+ }
91
+
92
+ /* Anyone who has asked their system not to animate things means it. */
93
+ @media (prefers-reduced-motion: reduce) {
94
+ * { animation: none !important; transition: none !important; }
95
+ }
96
+
97
+ @media (max-width: 480px) {
98
+ .shell { padding: var(--s4) var(--s3); }
99
+ h1 { font-size: 24px; }
100
+ }