ucode-agent 1.4.0 → 1.6.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.
Files changed (76) hide show
  1. package/README.md +64 -4
  2. package/package.json +5 -2
  3. package/skills/build-app/SKILL.md +19 -1
  4. package/skills/ui-ux/SKILL.md +2 -2
  5. package/src/core/doctor.js +122 -0
  6. package/src/core/loop.js +303 -12
  7. package/src/core/provider.js +93 -10
  8. package/src/core/stuck.js +269 -0
  9. package/src/tools/browser.js +121 -59
  10. package/src/tools/deploy.js +283 -0
  11. package/src/tools/files.js +91 -8
  12. package/src/tools/index.js +65 -8
  13. package/src/tools/scaffold.js +190 -0
  14. package/src/tools/shell.js +89 -1
  15. package/src/ui/activity.js +203 -0
  16. package/src/ui/plain.js +22 -3
  17. package/src/ui/screen.js +65 -19
  18. package/templates/next-shadcn/AGENTS.md +9 -0
  19. package/templates/next-shadcn/README.md +9 -0
  20. package/templates/next-shadcn/TEMPLATE.md +83 -0
  21. package/templates/next-shadcn/_gitignore +41 -0
  22. package/templates/next-shadcn/_package-lock.json +11465 -0
  23. package/templates/next-shadcn/components.json +25 -0
  24. package/templates/next-shadcn/eslint.config.mjs +18 -0
  25. package/templates/next-shadcn/next-env.d.ts +5 -0
  26. package/templates/next-shadcn/next.config.ts +8 -0
  27. package/templates/next-shadcn/package.json +39 -0
  28. package/templates/next-shadcn/postcss.config.mjs +7 -0
  29. package/templates/next-shadcn/presets/citrus.json +77 -0
  30. package/templates/next-shadcn/presets/graphite.json +77 -0
  31. package/templates/next-shadcn/presets/grove.json +77 -0
  32. package/templates/next-shadcn/presets/ocean.json +78 -0
  33. package/templates/next-shadcn/presets/sunset.json +77 -0
  34. package/templates/next-shadcn/presets/violet.json +77 -0
  35. package/templates/next-shadcn/src/app/favicon.ico +0 -0
  36. package/templates/next-shadcn/src/app/globals.css +155 -0
  37. package/templates/next-shadcn/src/app/layout.tsx +42 -0
  38. package/templates/next-shadcn/src/app/page.tsx +14 -0
  39. package/templates/next-shadcn/src/components/theme-provider.tsx +7 -0
  40. package/templates/next-shadcn/src/components/theme-toggle.tsx +27 -0
  41. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  42. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +199 -0
  43. package/templates/next-shadcn/src/components/ui/avatar.tsx +111 -0
  44. package/templates/next-shadcn/src/components/ui/badge.tsx +48 -0
  45. package/templates/next-shadcn/src/components/ui/button.tsx +66 -0
  46. package/templates/next-shadcn/src/components/ui/calendar.tsx +222 -0
  47. package/templates/next-shadcn/src/components/ui/card.tsx +102 -0
  48. package/templates/next-shadcn/src/components/ui/checkbox.tsx +32 -0
  49. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  50. package/templates/next-shadcn/src/components/ui/command.tsx +195 -0
  51. package/templates/next-shadcn/src/components/ui/dialog.tsx +168 -0
  52. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +268 -0
  53. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  54. package/templates/next-shadcn/src/components/ui/input-group.tsx +156 -0
  55. package/templates/next-shadcn/src/components/ui/input.tsx +18 -0
  56. package/templates/next-shadcn/src/components/ui/label.tsx +23 -0
  57. package/templates/next-shadcn/src/components/ui/popover.tsx +88 -0
  58. package/templates/next-shadcn/src/components/ui/progress.tsx +30 -0
  59. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  60. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +54 -0
  61. package/templates/next-shadcn/src/components/ui/select.tsx +191 -0
  62. package/templates/next-shadcn/src/components/ui/separator.tsx +27 -0
  63. package/templates/next-shadcn/src/components/ui/sheet.tsx +147 -0
  64. package/templates/next-shadcn/src/components/ui/skeleton.tsx +13 -0
  65. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  66. package/templates/next-shadcn/src/components/ui/sonner.tsx +49 -0
  67. package/templates/next-shadcn/src/components/ui/switch.tsx +32 -0
  68. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  69. package/templates/next-shadcn/src/components/ui/tabs.tsx +89 -0
  70. package/templates/next-shadcn/src/components/ui/textarea.tsx +17 -0
  71. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  72. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  73. package/templates/next-shadcn/src/components/ui/tooltip.tsx +56 -0
  74. package/templates/next-shadcn/src/lib/utils.ts +6 -0
  75. package/templates/next-shadcn/tsconfig.json +34 -0
  76. package/ucode.js +8 -1
@@ -0,0 +1,190 @@
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 } from './shell.js';
19
+
20
+ const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
21
+
22
+ /** npm silently drops these two names from published packages, so they ship renamed. */
23
+ const RENAME = { _gitignore: '.gitignore', '_package-lock.json': 'package-lock.json' };
24
+
25
+ /** Files the placeholders are filled into. Everything else is copied byte for byte. */
26
+ const TEXT = /\.(?:json|md|mjs|css|tsx?)$/i;
27
+
28
+ export const TEMPLATE_NAMES = ['next-shadcn'];
29
+
30
+ function slug(name) {
31
+ return String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
32
+ }
33
+
34
+ /** Text that is safe inside a JS string and a JSON string. */
35
+ const plain = (s) => String(s ?? '').replace(/["'`\\<>]/g, '').replace(/\s+/g, ' ').trim();
36
+
37
+ async function copyTree(from, to, fill) {
38
+ await fs.mkdir(to, { recursive: true });
39
+ const copied = [];
40
+ for (const entry of await fs.readdir(from, { withFileTypes: true })) {
41
+ const name = RENAME[entry.name] ?? entry.name;
42
+ const src = path.join(from, entry.name);
43
+ const dest = path.join(to, name);
44
+ if (entry.isDirectory()) {
45
+ copied.push(...(await copyTree(src, dest, fill)).map((f) => `${name}/${f}`));
46
+ } else if (TEXT.test(entry.name) || entry.name in RENAME) {
47
+ let text = await fs.readFile(src, 'utf8');
48
+ for (const [token, value] of Object.entries(fill)) text = text.split(token).join(value);
49
+ await fs.writeFile(dest, text, 'utf8');
50
+ copied.push(name);
51
+ } else {
52
+ await fs.copyFile(src, dest);
53
+ copied.push(name);
54
+ }
55
+ }
56
+ return copied;
57
+ }
58
+
59
+ /**
60
+ * @param {object} o
61
+ * @param {string} o.folder new, empty folder for the app
62
+ * @param {string} o.name display name, e.g. "Stride"
63
+ * @param {string} [o.description]
64
+ * @param {string} [o.template]
65
+ * @param {boolean} [o.install] start the background install (tests turn it off)
66
+ */
67
+ /**
68
+ * Give the new app its look: one of the hand-picked presets in the starter's
69
+ * presets/ folder — a full light and dark palette and a font — written into
70
+ * globals.css and layout.tsx. Apps stop looking like the same default blue.
71
+ * Returns the preset used, or null when the starter has none.
72
+ */
73
+ export async function applyDesign(appDir, design) {
74
+ const dir = path.join(appDir, 'presets');
75
+ const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
76
+ if (!names.length) return null;
77
+ const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
78
+ await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
79
+ const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
80
+
81
+ const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
82
+ let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
83
+ if (css !== null) {
84
+ const retint = (selector, tokens) => {
85
+ const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
86
+ css = css.replace(block, (all, open, body, close) => {
87
+ const seen = new Set();
88
+ let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
89
+ if (!(key in tokens)) return line;
90
+ seen.add(key);
91
+ return `${lead}--${key}: ${tokens[key]};`;
92
+ });
93
+ for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
94
+ return open + next + close;
95
+ });
96
+ };
97
+ retint(':root', { radius: preset.radius, ...preset.light });
98
+ retint('\\.dark', preset.dark);
99
+ await fs.writeFile(cssFile, css);
100
+ }
101
+
102
+ const sans = preset.fonts?.sans;
103
+ const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
104
+ if (sans && sans !== 'Geist') {
105
+ const id = sans.replace(/\s+/g, '_');
106
+ const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
107
+ if (layout !== null) {
108
+ await fs.writeFile(layoutFile, layout
109
+ .replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
110
+ .replace('const sans = Geist({', `const sans = ${id}({`));
111
+ }
112
+ }
113
+
114
+ const guide = path.join(appDir, 'TEMPLATE.md');
115
+ const text = await fs.readFile(guide, 'utf8').catch(() => null);
116
+ if (text !== null) {
117
+ await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
118
+ `${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
119
+ 'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
120
+ 'so every screen stays in one look.\n');
121
+ }
122
+ return preset;
123
+ }
124
+
125
+ export async function createApp({ folder, name, description, template = 'next-shadcn', design, install = true }) {
126
+ if (!TEMPLATE_NAMES.includes(template)) {
127
+ throw new ToolFailure({
128
+ kind: 'bad_args',
129
+ attempted: 'creating an app',
130
+ failed: `There is no starter called "${template}".`,
131
+ fix: `Use one of: ${TEMPLATE_NAMES.join(', ')}.`,
132
+ });
133
+ }
134
+
135
+ const target = resolveIn(folder, 'create_app', 'folder');
136
+ const attempted = `creating an app in ${target.show}`;
137
+ if (target.show === '.') {
138
+ throw new ToolFailure({
139
+ kind: 'bad_args',
140
+ attempted,
141
+ failed: 'The app needs its own folder, not the project root.',
142
+ fix: 'Pass a new folder name, e.g. "stride".',
143
+ });
144
+ }
145
+ await guard(target, `create an app in ${target.abs}`);
146
+
147
+ let existing = [];
148
+ try {
149
+ existing = await fs.readdir(target.abs);
150
+ } catch {
151
+ existing = [];
152
+ }
153
+ if (existing.length) {
154
+ throw new ToolFailure({
155
+ kind: 'not_empty',
156
+ attempted,
157
+ failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
158
+ fix: 'Pick a new folder name. If this folder is the app from an earlier attempt, work in it instead of creating it again.',
159
+ });
160
+ }
161
+
162
+ const display = plain(name) || path.basename(target.abs);
163
+ const fill = {
164
+ __APP_NAME__: display,
165
+ __APP_SLUG__: slug(display),
166
+ __APP_DESCRIPTION__: plain(description) || display,
167
+ };
168
+
169
+ const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
170
+ await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
171
+ const look = await applyDesign(target.abs, design);
172
+
173
+ if (install) {
174
+ const pkg = path.join(target.abs, 'package.json');
175
+ packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
176
+ }
177
+
178
+ const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
179
+
180
+ return result(
181
+ `Created ${target.show} from the ${template} starter — ${files.length} files, already known to build.\n` +
182
+ (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
183
+ (install
184
+ ? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
185
+ `${target.show} waits for that install first, so there is no need to run npm install.\n`
186
+ : '') +
187
+ `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`,
188
+ `${files.length} files${install ? ' · installing in the background' : ''}`
189
+ );
190
+ }
@@ -175,6 +175,14 @@ function stopHint(pid) {
175
175
  * is ready, it exits, or the wait runs out. Whichever comes first is reported
176
176
  * with the URL it is actually listening on.
177
177
  */
178
+ /** Dev servers that said they were ready, newest last — for opening the app when a turn ends. */
179
+ const readyServers = [];
180
+
181
+ /** Servers that became ready at or after `since` (epoch ms). */
182
+ export function serversReadySince(since = 0) {
183
+ return readyServers.filter((s) => s.at >= since);
184
+ }
185
+
178
186
  function startServer(command, workdir, { env } = {}) {
179
187
  return new Promise((resolve, reject) => {
180
188
  let log;
@@ -292,6 +300,7 @@ function startServer(command, workdir, { env } = {}) {
292
300
  if ((ready && url) || graceOver || (ready && Date.now() - started > 1500)) {
293
301
  finish(() => {
294
302
  const where = url ? tidyUrl(url) : null;
303
+ if (where) readyServers.push({ url: where, pid: child.pid, at: Date.now() });
295
304
  return result(
296
305
  describeRun([
297
306
  `Running in the background as PID ${child.pid}, ready after ${seconds}s.`,
@@ -460,6 +469,78 @@ async function awaitInstall(command, workdir, onOutput) {
460
469
  return out;
461
470
  }
462
471
 
472
+ // ---------------------------------------------------------------------------
473
+ // What a failed build is really asking for
474
+ // ---------------------------------------------------------------------------
475
+
476
+ /**
477
+ * Plain next steps for the build failures that send a model down a hole.
478
+ *
479
+ * Measured: a missing shadcn component failed the build, and instead of
480
+ * adding it the model spent a dozen steps listing node_modules, then deleted
481
+ * the app and started over. The error names exactly what is missing; this
482
+ * turns that into the one command that fixes it.
483
+ */
484
+ export function buildHints(output, dir = null) {
485
+ const hints = [];
486
+ const seen = new Set();
487
+ const add = (key, text) => {
488
+ if (seen.has(key)) return;
489
+ seen.add(key);
490
+ hints.push(text);
491
+ };
492
+
493
+ const UI = /(?:Can't resolve|Cannot find module) '@\/components\/ui\/([\w-]+)'/g;
494
+ for (const m of output.matchAll(UI)) {
495
+ add(`ui:${m[1]}`, `The shadcn component "${m[1]}" is not in this project. Add it with ` +
496
+ `\`npx shadcn@latest add ${m[1]} -y\` (cwd: the app folder), then build again. ` +
497
+ 'Do not look inside node_modules.');
498
+ }
499
+
500
+ const PKG = /(?:Can't resolve|Cannot find module) '((?:@[\w.-]+\/)?[\w.-]+)(\/[^']*)?'/g;
501
+ for (const m of output.matchAll(PKG)) {
502
+ const [, pkg, subpath] = m;
503
+ if (pkg.startsWith('.')) continue;
504
+ if (subpath && dir && installed(dir, pkg)) {
505
+ // Installed, but that path inside it does not exist: an import copied
506
+ // from an older version. Installing it again changes nothing.
507
+ add(`sub:${pkg}${subpath}`, `"${pkg}" is installed, but "${pkg}${subpath}" does not exist — ` +
508
+ `that path is from an older version. Import from "${pkg}" itself` +
509
+ (pkg === 'next-themes'
510
+ ? '; for the provider\'s props use `React.ComponentProps<typeof NextThemesProvider>`.'
511
+ : ', or check its package.json "exports" for the right path.') +
512
+ ' Do not reinstall it.');
513
+ continue;
514
+ }
515
+ add(`pkg:${pkg}`, `The package "${pkg}" is not installed. Install it with ` +
516
+ `\`npm install ${pkg}\` (cwd: the app folder), then build again.`);
517
+ }
518
+
519
+ if (/Parsing ecmascript source code failed|Expression expected|Unexpected token/.test(output)) {
520
+ add('syntax', 'A file does not parse. Open the file and line the error names, fix that ' +
521
+ 'syntax, then build again.');
522
+ }
523
+
524
+ return hints;
525
+ }
526
+
527
+ /** Is this package installed for the project at dir (or a folder above it)? */
528
+ function installed(dir, pkg) {
529
+ for (let at = path.resolve(dir); ; at = path.dirname(at)) {
530
+ try {
531
+ statSync(path.join(at, 'node_modules', pkg, 'package.json'));
532
+ return true;
533
+ } catch { /* not here */ }
534
+ if (path.dirname(at) === at) return false;
535
+ }
536
+ }
537
+
538
+ /** The folder a command really runs in: its cwd, moved by a leading `cd x &&`. */
539
+ function effectiveDir(command, workdir) {
540
+ const cd = /^\s*cd\s+(?:\/d\s+)?("?)([^"&|;]+?)\1\s*(?:&&|;)\s*/i.exec(command);
541
+ return cd ? path.resolve(workdir.abs, cd[2].trim()) : path.resolve(workdir.abs);
542
+ }
543
+
463
544
  // ---------------------------------------------------------------------------
464
545
  // run_command
465
546
  // ---------------------------------------------------------------------------
@@ -474,6 +555,11 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
474
555
  });
475
556
  }
476
557
 
558
+ // `sleep 3 && curl localhost:3000` after the server already reported ready
559
+ // is a wait for nothing. Measured on a real build; the sleep is dropped.
560
+ const napping = /^\s*(?:sleep\s+\d+(?:\.\d+)?|timeout\s+\/t\s+\d+(?:\s+\/nobreak)?)\s*(?:&&|;)\s*/i.exec(command);
561
+ if (napping && readyServers.length) command = command.slice(napping[0].length);
562
+
477
563
  if (KILLS_EVERYTHING.test(command)) {
478
564
  throw new ToolFailure({
479
565
  kind: 'suicidal_command',
@@ -618,8 +704,10 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
618
704
  }
619
705
 
620
706
  const count = captured.trim() ? captured.trim().split(/\r?\n/).length : 0;
707
+ const hints = code !== 0 ? buildHints(captured, effectiveDir(command, workdir)) : [];
708
+ const advice = hints.length ? `\n\nWhat to do:\n${hints.map((h) => `- ${h}`).join('\n')}` : '';
621
709
  const out = result(
622
- `exit code: ${code}\n\n${body}`,
710
+ `exit code: ${code}\n\n${body}${advice}`,
623
711
  `exit ${code} · ${count} line${count === 1 ? '' : 's'}`
624
712
  );
625
713
  out.exitCode = code;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * activity.js — what the status row shows while ucode is working.
3
+ *
4
+ * A long turn is minutes of the agent doing things the user did not type and
5
+ * cannot see coming. The status row is the one place that says it is still
6
+ * going, so it has to look alive at a glance without asking to be read: a
7
+ * spinner that turns, a soft band of light passing across the label, the
8
+ * step count ticking up, and the time the turn has taken so far.
9
+ *
10
+ * Everything here is a pure function of the text and the clock, so it can be
11
+ * tested without a terminal and painted at any frame rate.
12
+ */
13
+
14
+ import chalk, { Chalk } from 'chalk';
15
+ import { dim, sky, theme, clip, SPINNER } from './theme.js';
16
+
17
+ /** One painter per colour level, so a test can ask for truecolour on a pipe. */
18
+ const painters = new Map();
19
+ const painter = (level) => {
20
+ if (!painters.has(level)) painters.set(level, new Chalk({ level }));
21
+ return painters.get(level);
22
+ };
23
+
24
+ /** One frame every 85ms — just under twelve a second, smooth without being busy. */
25
+ export const FRAME_MS = 85;
26
+
27
+ /**
28
+ * A duration as a person says it: 0.4s, 14s, 2m 04s, 1h 07m.
29
+ *
30
+ * Seconds are zero-padded once there are minutes, so the text after the timer
31
+ * does not shift sideways every time the seconds roll from 9 to 10.
32
+ */
33
+ export function formatDuration(ms) {
34
+ const value = Math.max(0, Number(ms) || 0);
35
+ if (value < 1000) return `${(value / 1000).toFixed(1)}s`;
36
+ const total = Math.floor(value / 1000);
37
+ if (total < 60) return `${total}s`;
38
+ const minutes = Math.floor(total / 60);
39
+ if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, '0')}s`;
40
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // The shimmer
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * The two ends of the shimmer, both blue. The resting colour is muted enough
49
+ * to read as secondary text beside the model name; the peak is almost white,
50
+ * so the band reads as light passing over the words rather than a second
51
+ * colour arriving.
52
+ */
53
+ const REST_RGB = [0x7a, 0x96, 0xc8];
54
+ const PEAK_RGB = [0xe6, 0xf0, 0xff];
55
+
56
+ /** Half the width of the band of light, in characters. */
57
+ const BAND = 3;
58
+
59
+ /** How fast the band travels, in characters a second. */
60
+ const SPEED = 24;
61
+
62
+ /** Characters' worth of dark between one pass and the next. */
63
+ const PAUSE = 18;
64
+
65
+ /** Brightness steps. Neighbouring letters that land on the same step share one escape code. */
66
+ const STEPS = 8;
67
+
68
+ const mix = (a, b, k) => a.map((v, i) => Math.round(v + (b[i] - v) * k));
69
+
70
+ /**
71
+ * The text with a soft band of light passing across it, left to right, then a
72
+ * short rest, then again.
73
+ *
74
+ * `t` is milliseconds on any clock; the band's position is a function of it,
75
+ * so a slow frame skips ahead rather than slowing the sweep down.
76
+ *
77
+ * Needs 256 colours or more. With 16 there are no in-between blues to fade
78
+ * through, and a band that jumps between two colours reads as flicker rather
79
+ * than light — so below that the label is simply dim, and never moves.
80
+ */
81
+ export function shimmer(text, t, { level = chalk.level } = {}) {
82
+ const s = String(text ?? '');
83
+ if (!s || level < 2) return dim(s);
84
+
85
+ const cycle = s.length + BAND * 2 + PAUSE;
86
+ const centre = ((Math.max(0, t) / 1000) * SPEED) % cycle - BAND;
87
+
88
+ let out = '';
89
+ let run = '';
90
+ let runStep = -1;
91
+ const flush = () => {
92
+ if (!run) return;
93
+ const [r, g, b] = mix(REST_RGB, PEAK_RGB, runStep / STEPS);
94
+ out += painter(level).rgb(r, g, b)(run);
95
+ run = '';
96
+ };
97
+
98
+ for (let i = 0; i < s.length; i++) {
99
+ const distance = Math.abs(i - centre);
100
+ // A cosine falloff: brightest at the centre, fading smoothly to nothing
101
+ // at the edge of the band, so the light has no hard edge to it.
102
+ const k = distance < BAND ? (Math.cos((Math.PI * distance) / BAND) + 1) / 2 : 0;
103
+ const step = Math.round(k * STEPS);
104
+ if (step !== runStep) { flush(); runStep = step; }
105
+ run += s[i];
106
+ }
107
+ flush();
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * The spinner glyph for a frame, breathing slowly between two blues.
113
+ *
114
+ * The pulse is slow — a little over a second a breath — so it reads as the
115
+ * glyph being alive rather than as a blink.
116
+ */
117
+ export function spinnerGlyph(frame, t, { level = chalk.level } = {}) {
118
+ const glyph = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
119
+ if (level < 2) return theme.blue(glyph);
120
+ const k = (Math.sin((Math.max(0, t) / 1300) * Math.PI * 2) + 1) / 2;
121
+ const [r, g, b] = mix([0x4d, 0x8d, 0xff], [0x9f, 0xc6, 0xff], k);
122
+ return painter(level).rgb(r, g, b)(glyph);
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Fitting it into the room there is
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /** Shorter than this, a label is a stub that says nothing, so it goes entirely. */
130
+ const MIN_LABEL = 10;
131
+
132
+ /**
133
+ * The middle of the status row, fitted to `room` columns.
134
+ *
135
+ * Parts, in the order they are given up when the terminal is too narrow for
136
+ * all of them:
137
+ *
138
+ * 1. the "esc to stop" hint — useful once, known after that
139
+ * 2. the end of the label — clipped with an ellipsis, down to a stub
140
+ * 3. the step count
141
+ * 4. the label itself
142
+ * 5. the elapsed time
143
+ *
144
+ * The spinner is the last thing standing: even with a single column left the
145
+ * row still shows that something is happening.
146
+ *
147
+ * `meta` is a list of { text, paint, keep } — keep marks the one that survives
148
+ * the longest (the timer). `paint` colours the label, which is where the
149
+ * shimmer comes in.
150
+ */
151
+ export function fitActivity({ glyph, label = '', meta = [], hint = '', paint = dim }, room) {
152
+ if (room < 1) return '';
153
+ const items = meta.filter((m) => m && m.text);
154
+ const kept = items.filter((m) => m.keep);
155
+ const text = String(label ?? '');
156
+
157
+ const width = (labelLen, list, withHint) =>
158
+ 1 +
159
+ (labelLen ? 1 + labelLen : 0) +
160
+ (list.length ? (labelLen ? 3 : 1) + list.map((m) => m.text).join(' · ').length : 0) +
161
+ (withHint && hint ? 2 + hint.length : 0);
162
+
163
+ const build = (labelText, list, withHint) => {
164
+ let out = glyph;
165
+ if (labelText) out += ` ${paint(labelText)}`;
166
+ if (list.length) {
167
+ out += labelText ? dim(' · ') : ' ';
168
+ out += list.map((m) => (m.paint ?? dim)(m.text)).join(dim(' · '));
169
+ }
170
+ if (withHint && hint) out += ` ${dim(hint)}`;
171
+ return out;
172
+ };
173
+
174
+ if (text) {
175
+ if (width(text.length, items, true) <= room) return build(text, items, true);
176
+ if (width(text.length, items, false) <= room) return build(text, items, false);
177
+ for (const list of [items, kept]) {
178
+ const labelRoom = room - width(0, list, false) - 1 - (list.length ? 2 : 0);
179
+ if (labelRoom >= MIN_LABEL) return build(clip(text, labelRoom), list, false);
180
+ }
181
+ }
182
+ for (const list of [items, kept, []]) {
183
+ if (width(0, list, false) <= room) return build('', list, false);
184
+ }
185
+ return glyph;
186
+ }
187
+
188
+ /**
189
+ * The line a finished turn leaves in the transcript: "✓ Done in 6m 12s · 25 steps".
190
+ *
191
+ * Green for the tick, because green means done and nothing else in this
192
+ * theme; the rest dim, because it is a footnote to the answer above it rather
193
+ * than something to read first.
194
+ */
195
+ export function doneLine(ms, steps) {
196
+ const count = steps > 0 ? ` · ${steps} step${steps === 1 ? '' : 's'}` : '';
197
+ return `${theme.ok('✓')} ${dim(`Done in ${formatDuration(ms)}${count}`)}`;
198
+ }
199
+
200
+ /** The step count, brighter for a moment right after it goes up. */
201
+ export function stepPaint(justMoved) {
202
+ return justMoved ? sky : dim;
203
+ }
package/src/ui/plain.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  theme, blue, sky, dim, boxTop, boxBottom, boxRow,
16
16
  BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen, planLine,
17
17
  } from './theme.js';
18
+ import { formatDuration, doneLine } from './activity.js';
18
19
  import { renderer, render } from './markdown.js';
19
20
 
20
21
  const COMMANDS = [
@@ -227,9 +228,11 @@ export class Plain {
227
228
  }
228
229
 
229
230
  paintSpinner() {
230
- const secs = Math.round((Date.now() - this.since) / 1000);
231
- const line = ` ${blue(SPINNER[this.frame])} ${dim(this.spinnerText)}` +
232
- (secs >= 2 ? dim(` ${secs}s`) : '');
231
+ const since = this.turn?.start ?? this.since;
232
+ const secs = Math.round((Date.now() - since) / 1000);
233
+ const meta = [this.turn?.steps ? `step ${this.turn.steps}` : '', secs >= 2 ? formatDuration(secs * 1000) : '']
234
+ .filter(Boolean).join(' · ');
235
+ const line = ` ${blue(SPINNER[this.frame])} ${dim(this.spinnerText)}` + (meta ? dim(` · ${meta}`) : '');
233
236
  this.output.write(`\r\x1b[K${padVis(line, this.width() - 1)}`);
234
237
  }
235
238
 
@@ -246,6 +249,22 @@ export class Plain {
246
249
  this.output.write('\r\x1b[K');
247
250
  }
248
251
 
252
+ // -- the turn in flight ----------------------------------------------------
253
+
254
+ turnStart() {
255
+ this.turn = { start: Date.now(), steps: 0 };
256
+ }
257
+
258
+ step() {
259
+ if (this.turn) this.turn.steps++;
260
+ }
261
+
262
+ turnEnd({ ok = true } = {}) {
263
+ const t = this.turn;
264
+ this.turn = null;
265
+ if (t && ok && Date.now() - t.start >= 2000) this.write(` ${doneLine(Date.now() - t.start, t.steps)}`);
266
+ }
267
+
249
268
  // -- input ---------------------------------------------------------------
250
269
 
251
270
  nextLine() {