ucode-agent 1.5.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.
- package/README.md +53 -1
- package/package.json +3 -1
- package/skills/ui-ux/SKILL.md +2 -2
- package/src/core/doctor.js +122 -0
- package/src/core/loop.js +301 -12
- package/src/core/provider.js +93 -10
- package/src/core/stuck.js +269 -0
- package/src/tools/browser.js +121 -59
- package/src/tools/deploy.js +283 -0
- package/src/tools/files.js +91 -8
- package/src/tools/index.js +44 -10
- package/src/tools/scaffold.js +61 -1
- package/src/tools/shell.js +89 -1
- package/src/ui/activity.js +203 -0
- package/src/ui/plain.js +22 -3
- package/src/ui/screen.js +65 -19
- package/templates/next-shadcn/TEMPLATE.md +53 -9
- package/templates/next-shadcn/_package-lock.json +1335 -148
- package/templates/next-shadcn/components.json +1 -1
- package/templates/next-shadcn/next.config.ts +2 -1
- package/templates/next-shadcn/package.json +4 -2
- package/templates/next-shadcn/presets/citrus.json +77 -0
- package/templates/next-shadcn/presets/graphite.json +77 -0
- package/templates/next-shadcn/presets/grove.json +77 -0
- package/templates/next-shadcn/presets/ocean.json +78 -0
- package/templates/next-shadcn/presets/sunset.json +77 -0
- package/templates/next-shadcn/presets/violet.json +77 -0
- package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
- package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
- package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
- package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
- package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
- package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
- package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
- package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
- package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
- package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
- package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
- package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
- package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
- package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
- package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
- package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
- package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
- package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
- package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
- package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
- package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
- package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
- package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
- package/templates/next-shadcn/src/lib/utils.ts +6 -1
- package/ucode.js +8 -1
package/src/tools/shell.js
CHANGED
|
@@ -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
|
|
231
|
-
const
|
|
232
|
-
|
|
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() {
|
package/src/ui/screen.js
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
|
|
42
42
|
shortenPath, asLabel, ensureColour, planLine, bare,
|
|
43
43
|
} from './theme.js';
|
|
44
|
+
import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
|
|
44
45
|
import { renderer, render, polish } from './markdown.js';
|
|
45
46
|
import { VERSION } from '../core/version.js';
|
|
46
47
|
|
|
@@ -58,6 +59,7 @@ export function isLabel(text) {
|
|
|
58
59
|
export const COMMANDS = [
|
|
59
60
|
'/help', '/model', '/models', '/session', '/sessions', '/resume',
|
|
60
61
|
'/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
|
|
62
|
+
'/stats', '/doctor', '/deploy',
|
|
61
63
|
];
|
|
62
64
|
|
|
63
65
|
// ANSI ----------------------------------------------------------------------
|
|
@@ -135,6 +137,8 @@ export class Screen {
|
|
|
135
137
|
this.onInterrupt = null;
|
|
136
138
|
this.onModeChange = null;
|
|
137
139
|
this.spinTimer = null;
|
|
140
|
+
this.activity = null; // the turn in flight: when it began, how many steps
|
|
141
|
+
this.tick = 0; // animation frames painted, for the spinner
|
|
138
142
|
this.pendingPrompt = null;
|
|
139
143
|
|
|
140
144
|
this.cols = output.columns || 80;
|
|
@@ -164,7 +168,9 @@ export class Screen {
|
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
stop() {
|
|
171
|
+
this.activity = null;
|
|
167
172
|
this.stopSpinner();
|
|
173
|
+
this.stopTimer();
|
|
168
174
|
this.output.off?.('resize', this.onResize);
|
|
169
175
|
this.input.setRawMode?.(false);
|
|
170
176
|
this.input.pause();
|
|
@@ -633,14 +639,23 @@ export class Screen {
|
|
|
633
639
|
let middle = '';
|
|
634
640
|
if (this.flashText) {
|
|
635
641
|
middle = dim(clip(this.flashText, between - 2));
|
|
636
|
-
} else if (this.status.busy) {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
642
|
+
} else if (this.status.busy || this.activity) {
|
|
643
|
+
// The whole turn, not just the current tool: the timer and step count
|
|
644
|
+
// keep going through the gaps between calls, so a long build never
|
|
645
|
+
// looks like it has stopped.
|
|
646
|
+
const now = Date.now();
|
|
647
|
+
const a = this.activity;
|
|
648
|
+
const since = a?.start ?? this.status.since ?? now;
|
|
649
|
+
const meta = [];
|
|
650
|
+
if (a?.steps) meta.push({ text: `step ${a.steps}`, paint: stepPaint(now - a.movedAt < 900) });
|
|
651
|
+
if (now - since >= 1000) meta.push({ text: formatDuration(now - since), keep: true });
|
|
652
|
+
middle = fitActivity({
|
|
653
|
+
glyph: spinnerGlyph(this.tick, now),
|
|
654
|
+
label: this.status.busy ? this.status.text : 'working',
|
|
655
|
+
meta,
|
|
656
|
+
hint: 'esc to stop',
|
|
657
|
+
paint: (s) => shimmer(s, now),
|
|
658
|
+
}, between - 3);
|
|
644
659
|
}
|
|
645
660
|
|
|
646
661
|
// The percentage is pinned to the right border whatever is in the middle,
|
|
@@ -700,13 +715,7 @@ export class Screen {
|
|
|
700
715
|
// `since` is what makes a long think legible: the label may not change for
|
|
701
716
|
// a minute, so the seconds beside it are the proof it is still alive.
|
|
702
717
|
this.status = { busy: true, text: asLabel(text), frame: 0, since: Date.now() };
|
|
703
|
-
|
|
704
|
-
this.spinTimer = setInterval(() => {
|
|
705
|
-
this.status.frame = (this.status.frame + 1) % SPINNER.length;
|
|
706
|
-
this.paintStatus();
|
|
707
|
-
}, 80);
|
|
708
|
-
this.spinTimer.unref?.();
|
|
709
|
-
}
|
|
718
|
+
this.startTimer();
|
|
710
719
|
this.paintStatus();
|
|
711
720
|
}
|
|
712
721
|
|
|
@@ -717,16 +726,53 @@ export class Screen {
|
|
|
717
726
|
}
|
|
718
727
|
|
|
719
728
|
stopSpinner() {
|
|
720
|
-
if (this.
|
|
721
|
-
clearInterval(this.spinTimer);
|
|
722
|
-
this.spinTimer = null;
|
|
723
|
-
}
|
|
729
|
+
if (!this.activity) this.stopTimer();
|
|
724
730
|
if (this.status.busy) {
|
|
725
731
|
this.status = { busy: false, text: '', frame: 0, since: 0 };
|
|
726
732
|
this.paintStatus();
|
|
727
733
|
}
|
|
728
734
|
}
|
|
729
735
|
|
|
736
|
+
// -- the turn in flight ----------------------------------------------------
|
|
737
|
+
|
|
738
|
+
/** A turn begins: the timer and step count run until turnEnd(). */
|
|
739
|
+
turnStart() {
|
|
740
|
+
this.activity = { start: Date.now(), steps: 0, movedAt: 0 };
|
|
741
|
+
this.startTimer();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** One more model step in this turn. */
|
|
745
|
+
step() {
|
|
746
|
+
if (!this.activity) return;
|
|
747
|
+
this.activity.steps++;
|
|
748
|
+
this.activity.movedAt = Date.now();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** The turn is over: leave "✓ Done in 6m 12s · 25 steps" under the answer. */
|
|
752
|
+
turnEnd({ ok = true } = {}) {
|
|
753
|
+
const a = this.activity;
|
|
754
|
+
this.activity = null;
|
|
755
|
+
if (!this.status.busy) this.stopTimer();
|
|
756
|
+
if (a && ok && Date.now() - a.start >= 2000) this.push(` ${doneLine(Date.now() - a.start, a.steps)}`);
|
|
757
|
+
this.paintStatus();
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** The animation clock: only the status row repaints, about twelve times a second. */
|
|
761
|
+
startTimer() {
|
|
762
|
+
if (this.spinTimer) return;
|
|
763
|
+
this.spinTimer = setInterval(() => {
|
|
764
|
+
this.tick++;
|
|
765
|
+
this.paintStatus();
|
|
766
|
+
}, FRAME_MS);
|
|
767
|
+
this.spinTimer.unref?.();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
stopTimer() {
|
|
771
|
+
if (!this.spinTimer) return;
|
|
772
|
+
clearInterval(this.spinTimer);
|
|
773
|
+
this.spinTimer = null;
|
|
774
|
+
}
|
|
775
|
+
|
|
730
776
|
// -- input ---------------------------------------------------------------
|
|
731
777
|
|
|
732
778
|
nextLine() {
|
|
@@ -6,16 +6,16 @@ Do not re-run create-next-app or `shadcn init` — everything below is in place.
|
|
|
6
6
|
## Stack
|
|
7
7
|
|
|
8
8
|
- Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4
|
|
9
|
-
- shadcn/ui (
|
|
10
|
-
- next-themes (light / dark / system), sonner toasts, date-fns
|
|
9
|
+
- shadcn/ui on Radix (the standard shadcn you know — `asChild` works), lucide-react icons
|
|
10
|
+
- next-themes (light / dark / system), sonner toasts, date-fns, react-day-picker
|
|
11
11
|
|
|
12
|
-
Next 16 changed APIs. `AGENTS.md` explains, and `node_modules/next/dist/docs/`
|
|
12
|
+
Next 16 changed some APIs. `AGENTS.md` explains, and `node_modules/next/dist/docs/`
|
|
13
13
|
has the guides — check them before using an API you are unsure of.
|
|
14
14
|
|
|
15
15
|
## Already wired
|
|
16
16
|
|
|
17
17
|
- `src/app/layout.tsx` — font (Geist, registered as `--font-sans`), `ThemeProvider`,
|
|
18
|
-
`TooltipProvider`, and `<Toaster />`.
|
|
18
|
+
`TooltipProvider`, and `<Toaster />`.
|
|
19
19
|
- `src/components/theme-toggle.tsx` — a light/dark button, ready to place.
|
|
20
20
|
- `src/app/globals.css` — the design tokens. The palette is a starting point:
|
|
21
21
|
re-tint `--primary`, `--accent` and the neutrals for this app's direction.
|
|
@@ -23,17 +23,61 @@ has the guides — check them before using an API you are unsure of.
|
|
|
23
23
|
(`bg-success`, `text-warning`, ...).
|
|
24
24
|
- `src/app/page.tsx` — a placeholder. Replace it.
|
|
25
25
|
|
|
26
|
+
`layout.tsx`, `theme-provider.tsx`, `theme-toggle.tsx`, `src/lib/utils.ts` and
|
|
27
|
+
everything in `src/components/ui/` are finished and build. Use them; do not
|
|
28
|
+
rewrite them — a rewrite from memory brings back APIs that no longer exist.
|
|
29
|
+
|
|
26
30
|
## Components in `src/components/ui/`
|
|
27
31
|
|
|
28
|
-
alert-dialog, avatar, badge, button, calendar, card, checkbox, command, dialog,
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
accordion, alert-dialog, avatar, badge, button, calendar, card, checkbox, collapsible, command, dialog, dropdown-menu, hover-card, input-group, input, label, popover, progress, radio-group, scroll-area, select, separator, sheet, skeleton, slider, sonner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
|
|
33
|
+
|
|
34
|
+
Anything else: `npx shadcn@latest add <name> -y` with cwd set to this folder.
|
|
35
|
+
|
|
36
|
+
## APIs that are easy to get wrong
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
// Toasts — sonner. A message first, options second. There is no toast({ title }).
|
|
40
|
+
import { toast } from "sonner";
|
|
41
|
+
toast.success("Saved");
|
|
42
|
+
toast("Task deleted", { description: "Buy milk", action: { label: "Undo", onClick: () => restore() } });
|
|
43
|
+
|
|
44
|
+
// Slider — value is an ARRAY, even for one thumb.
|
|
45
|
+
<Slider value={[tip]} onValueChange={([v]) => setTip(v)} min={0} max={30} step={1} />
|
|
46
|
+
|
|
47
|
+
// Select — value is a string.
|
|
48
|
+
<Select value={list} onValueChange={(v: string) => setList(v)}>
|
|
49
|
+
<SelectTrigger><SelectValue placeholder="Pick a list" /></SelectTrigger>
|
|
50
|
+
<SelectContent><SelectItem value="inbox">Inbox</SelectItem></SelectContent>
|
|
51
|
+
</Select>
|
|
52
|
+
|
|
53
|
+
// Date picker — Calendar inside a Popover.
|
|
54
|
+
<Popover>
|
|
55
|
+
<PopoverTrigger asChild><Button variant="outline">{date ? format(date, "PPP") : "Pick a date"}</Button></PopoverTrigger>
|
|
56
|
+
<PopoverContent className="w-auto p-0"><Calendar mode="single" selected={date} onSelect={setDate} /></PopoverContent>
|
|
57
|
+
</Popover>
|
|
58
|
+
|
|
59
|
+
// Toggle group — value is a string for type="single".
|
|
60
|
+
<ToggleGroup type="single" value={preset} onValueChange={(v) => v && setPreset(v)}>
|
|
61
|
+
<ToggleGroupItem value="15">15%</ToggleGroupItem>
|
|
62
|
+
</ToggleGroup>
|
|
63
|
+
|
|
64
|
+
// Anything using useState, events, localStorage or browser APIs needs "use client"
|
|
65
|
+
// as the first line of its file.
|
|
66
|
+
|
|
67
|
+
// localStorage — read it in useEffect, never in render or a useState initializer:
|
|
68
|
+
// the page is also rendered on the server, where localStorage does not exist.
|
|
69
|
+
const [bill, setBill] = useState("");
|
|
70
|
+
useEffect(() => { setBill(localStorage.getItem("bill") ?? ""); }, []);
|
|
71
|
+
useEffect(() => { localStorage.setItem("bill", bill); }, [bill]);
|
|
31
72
|
|
|
32
|
-
|
|
73
|
+
// next-themes — there is no "next-themes/dist/types". Import from "next-themes".
|
|
74
|
+
```
|
|
33
75
|
|
|
34
76
|
## Conventions
|
|
35
77
|
|
|
78
|
+
- Components are named exports — `export function BillInput()` — imported with
|
|
79
|
+
braces: `import { BillInput } from "@/components/calculator/bill-input"`.
|
|
80
|
+
Only `page.tsx` and `layout.tsx` use `export default`.
|
|
36
81
|
- One component per file, grouped by feature: `src/components/<feature>/`.
|
|
37
82
|
- Shared types in `src/lib/types.ts`, helpers in `src/lib/`.
|
|
38
|
-
- `"use client"` only on components that need state or events.
|
|
39
83
|
- `cn()` from `@/lib/utils` to merge class names.
|