ucode-agent 1.30.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/loop.js +48 -6
- package/src/ui/activity.js +44 -22
- package/src/ui/screen.js +36 -20
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -556,6 +556,34 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
556
556
|
'already contains it. Do not re-check work the checks have already reported on.',
|
|
557
557
|
'Fast is not sloppy: it is the same work with the waiting taken out.',
|
|
558
558
|
'',
|
|
559
|
+
'DESIGN IT BEFORE YOU TYPE IT. Fast means fewer round trips. It does not mean a',
|
|
560
|
+
'default theme, and an app that goes out in the palette its starter came with is',
|
|
561
|
+
'not a fast build, it is an undesigned one. There is no design pass after the',
|
|
562
|
+
'create_app call, because there is no after — so the decisions happen before it.',
|
|
563
|
+
'',
|
|
564
|
+
'Three of them, and you hold them for the whole build:',
|
|
565
|
+
' - A TONE, one word you commit to: clinical, warm, editorial, technical,',
|
|
566
|
+
' playful, industrial, calm, dense. "Modern and clean" is not a tone.',
|
|
567
|
+
' - An ACCENT that is not the one the starter shipped with.',
|
|
568
|
+
' - ONE memorable thing this app has that other apps do not: a colour, a type',
|
|
569
|
+
' move, a texture, one interaction. Exactly one. It is the difference between',
|
|
570
|
+
' a design and a theme.',
|
|
571
|
+
'Name the tone and the accent in your opening line, so they are settled before any',
|
|
572
|
+
'file exists: "I will build Tide - a tasks app in one HTML file, calm, warm grey',
|
|
573
|
+
'with a single amber accent." That is not narrating a plan, that is the decision.',
|
|
574
|
+
'',
|
|
575
|
+
'Then the tokens are the first thing in the file, in the same call as everything',
|
|
576
|
+
'else: the type scale, the space scale, neutrals that carry a hue, the accent and',
|
|
577
|
+
'its semantics. Nothing after that uses a raw value. IN next-shadcn THAT MEANS',
|
|
578
|
+
'globals.css IS RE-TINTED IN THAT SAME create_app CALL. Shipping the palette the',
|
|
579
|
+
'starter came with is the commonest way a build looks generated, and it is the',
|
|
580
|
+
'first thing anyone notices. A blocked-out page in the colours this app chose',
|
|
581
|
+
'beats a finished page in the ones it was handed.',
|
|
582
|
+
'',
|
|
583
|
+
'add_block gives you structure, never a look. A block arrives with no opinion',
|
|
584
|
+
'about this app and is yours to tint the moment it lands. Assembling blocks and',
|
|
585
|
+
'shipping them as they came is the quick way to something nobody designed.',
|
|
586
|
+
'',
|
|
559
587
|
'Never repeat the request back. Not as a summary, not as a restatement, not as',
|
|
560
588
|
'a list of what was asked for. They wrote it and it is on the screen above you.',
|
|
561
589
|
'Do not narrate your planning either - which files you will make, what order you',
|
|
@@ -665,6 +693,11 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
665
693
|
' reports - errors, layout that overflows a phone, the review points worth fixing -',
|
|
666
694
|
' in one pass, then look once more. A clean second look means it is done: report',
|
|
667
695
|
' back instead of polishing in circles. Never call an interface finished unlooked at.',
|
|
696
|
+
'- ANYTHING THAT NEEDS A SERVER IS LEFT RUNNING. If the app has a dev server -',
|
|
697
|
+
' Next.js, Vite, anything with an npm run dev - start it and leave it up when you',
|
|
698
|
+
' finish. ucode opens it in the browser for the user as soon as it is ready, so a',
|
|
699
|
+
' build that ends with the server stopped ends with nothing to look at. A one-page',
|
|
700
|
+
' app with no server needs none of this: the file is the app.',
|
|
668
701
|
' name, handles keys and returns the live link. Build locally first.',
|
|
669
702
|
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
670
703
|
' would ask a question, or it fails instead of waiting: create-next-app --yes,',
|
|
@@ -1034,6 +1067,11 @@ export class Agent {
|
|
|
1034
1067
|
if (message.short) this.short.add(skill.name);
|
|
1035
1068
|
this.push(message);
|
|
1036
1069
|
this.ui.note(`${skill.name} skill loaded for this${message.short ? ' (short form)' : ''}`);
|
|
1070
|
+
// A clear row under it. This is bookkeeping about the turn, not part of
|
|
1071
|
+
// it, and against the first line of thinking the two read as one block —
|
|
1072
|
+
// the notice looks like the opening of the answer rather than a note
|
|
1073
|
+
// about how the answer is being reached.
|
|
1074
|
+
this.ui.blank();
|
|
1037
1075
|
}
|
|
1038
1076
|
}
|
|
1039
1077
|
|
|
@@ -1535,15 +1573,19 @@ export class Agent {
|
|
|
1535
1573
|
|
|
1536
1574
|
/** A dev server came up during this turn: open it in the browser, once. */
|
|
1537
1575
|
/**
|
|
1538
|
-
* Open the running app in a browser
|
|
1576
|
+
* Open the running app in a browser as soon as a dev server is up.
|
|
1577
|
+
*
|
|
1578
|
+
* This was turned off once, on the grounds that a window seizing the screen
|
|
1579
|
+
* mid-thought is startling and worse during a demo. It only fires at the end
|
|
1580
|
+
* of a finished turn, though, not mid-thought — and the thing the user asked
|
|
1581
|
+
* for is a running app, not a URL they then have to go and click. Being
|
|
1582
|
+
* handed a link to the thing you asked to be built is the last step of the
|
|
1583
|
+
* job left undone.
|
|
1539
1584
|
*
|
|
1540
|
-
*
|
|
1541
|
-
* seizing the screen mid-thought is startling at the best of times, and
|
|
1542
|
-
* during a demo it is worse. UCODE_OPEN=1 brings the old behaviour back for
|
|
1543
|
-
* anyone who liked it; otherwise the URL is on screen to click.
|
|
1585
|
+
* UCODE_OPEN=0 turns it off for anyone who wants the link and nothing else.
|
|
1544
1586
|
*/
|
|
1545
1587
|
openWhenReady(since) {
|
|
1546
|
-
if (!this.full || process.env.UCODE_OPEN
|
|
1588
|
+
if (!this.full || process.env.UCODE_OPEN === '0') return;
|
|
1547
1589
|
const server = serversReadySince(since).at(-1);
|
|
1548
1590
|
if (!server || (this.opened ??= new Set()).has(server.url)) return;
|
|
1549
1591
|
this.opened.add(server.url);
|
package/src/ui/activity.js
CHANGED
|
@@ -109,26 +109,36 @@ export function shimmer(text, t, { level = chalk.level } = {}) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
* The wordmark
|
|
112
|
+
* The wordmark being lit, once, at launch.
|
|
113
113
|
*
|
|
114
|
-
* The first thing anyone sees of a program is the
|
|
115
|
-
*
|
|
116
|
-
* crossing the mark left to right
|
|
117
|
-
*
|
|
118
|
-
* was printed and one that arrived.
|
|
114
|
+
* The first thing anyone sees of a program is the moment before they can type,
|
|
115
|
+
* and ucode was spending it showing a finished picture. A band of light
|
|
116
|
+
* crossing the mark left to right costs nothing and is the difference between
|
|
117
|
+
* a logo that was printed and one that arrived.
|
|
119
118
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
119
|
+
* The light does not pass over a finished wordmark — it paints it. Ahead of
|
|
120
|
+
* the crest the letters sit almost unlit, a silhouette dark enough to read as
|
|
121
|
+
* "not yet" and light enough not to look like a hole in the screen. At the
|
|
122
|
+
* crest they flare to near-white. Behind it they settle into the row's own
|
|
123
|
+
* resting gradient and stay there. So the mark is drawn on by the light rather
|
|
124
|
+
* than glinting under it, and the instant the crest leaves a column that
|
|
125
|
+
* column is already exactly what it will look like for the rest of the
|
|
126
|
+
* session.
|
|
127
|
+
*
|
|
128
|
+
* Every row is lit from the same clock, so the crest is one bar standing
|
|
129
|
+
* upright and travelling across the whole wordmark rather than six separate
|
|
130
|
+
* glints, and the band is wide enough that the edge of it is never a line.
|
|
124
131
|
*
|
|
125
132
|
* Below 256 colours there are no in-between shades to fade through, so the
|
|
126
133
|
* mark is simply drawn finished — a two-colour "sweep" is a flicker.
|
|
127
134
|
*/
|
|
128
|
-
export const SWEEP_MS =
|
|
135
|
+
export const SWEEP_MS = 1300;
|
|
129
136
|
|
|
130
137
|
/** Half the width of the travelling band, in characters. */
|
|
131
|
-
const SWEEP_BAND =
|
|
138
|
+
const SWEEP_BAND = 9;
|
|
139
|
+
|
|
140
|
+
/** The wordmark before the light reaches it: present, not yet lit. */
|
|
141
|
+
const UNLIT_RGB = [0x16, 0x23, 0x3d];
|
|
132
142
|
|
|
133
143
|
export function bannerSweep(line, row, rows, elapsed, { level = chalk.level } = {}) {
|
|
134
144
|
const text = String(line ?? '');
|
|
@@ -142,19 +152,30 @@ export function bannerSweep(line, row, rows, elapsed, { level = chalk.level } =
|
|
|
142
152
|
|
|
143
153
|
let out = '';
|
|
144
154
|
let run = '';
|
|
145
|
-
let
|
|
155
|
+
let runKey = '';
|
|
156
|
+
let runRGB = null;
|
|
146
157
|
const flush = () => {
|
|
147
158
|
if (!run) return;
|
|
148
|
-
const [r, g, b] =
|
|
159
|
+
const [r, g, b] = runRGB;
|
|
149
160
|
out += painter(level).rgb(r, g, b)(run);
|
|
150
161
|
run = '';
|
|
151
162
|
};
|
|
152
163
|
|
|
153
164
|
for (let i = 0; i < text.length; i++) {
|
|
154
|
-
const
|
|
165
|
+
const offset = i - centre;
|
|
166
|
+
// Behind the crest the column is finished; ahead of it, still waiting.
|
|
167
|
+
const base = offset > 0 ? UNLIT_RGB : rest;
|
|
168
|
+
const distance = Math.abs(offset);
|
|
155
169
|
const k = distance < SWEEP_BAND ? (Math.cos((Math.PI * distance) / SWEEP_BAND) + 1) / 2 : 0;
|
|
170
|
+
|
|
171
|
+
// Quantised so neighbours landing on the same shade share one escape code.
|
|
156
172
|
const step = Math.round(k * STEPS);
|
|
157
|
-
|
|
173
|
+
const key = `${offset > 0 ? 'a' : 'b'}${step}`;
|
|
174
|
+
if (key !== runKey) {
|
|
175
|
+
flush();
|
|
176
|
+
runKey = key;
|
|
177
|
+
runRGB = mix(base, PEAK_RGB, step / STEPS);
|
|
178
|
+
}
|
|
158
179
|
run += text[i];
|
|
159
180
|
}
|
|
160
181
|
flush();
|
|
@@ -220,10 +241,11 @@ export function indeterminate(cells, t, { level = chalk.level } = {}) {
|
|
|
220
241
|
* The whole live line: what is happening, that it is still happening, how long
|
|
221
242
|
* it has been happening, and how to stop it.
|
|
222
243
|
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
244
|
+
* Two things move on it, and they say different things. The bar is the steady
|
|
245
|
+
* pulse that means the program is alive at all; the band of light crossing the
|
|
246
|
+
* label is on the label, so it is the thing being done right now that looks
|
|
247
|
+
* live. How long the turn has taken is not here — that is a fact about the
|
|
248
|
+
* session, and it belongs on the status row with the other two.
|
|
227
249
|
*
|
|
228
250
|
* Things are given up from the least useful end as the terminal narrows: the
|
|
229
251
|
* hint first, then the bar shortens, then it goes, then the label is clipped.
|
|
@@ -240,7 +262,7 @@ export function workingLine({
|
|
|
240
262
|
const shortTail = elapsed || '';
|
|
241
263
|
|
|
242
264
|
const draw = (cells, tailText, labelRoom) => {
|
|
243
|
-
let out = `${glyph} ${
|
|
265
|
+
let out = `${glyph} ${shimmer(clip(text, labelRoom), t, { level })}`;
|
|
244
266
|
if (cells) out += ` ${indeterminate(cells, t, { level })}`;
|
|
245
267
|
if (tailText) out += ` ${dim(tailText)}`;
|
|
246
268
|
return out;
|
|
@@ -254,7 +276,7 @@ export function workingLine({
|
|
|
254
276
|
if (labelRoom >= Math.min(MIN_LABEL, text.length)) return draw(cells, tailText, labelRoom);
|
|
255
277
|
}
|
|
256
278
|
|
|
257
|
-
return `${glyph} ${
|
|
279
|
+
return `${glyph} ${shimmer(clip(text, Math.max(1, room - 2)), t, { level })}`;
|
|
258
280
|
}
|
|
259
281
|
|
|
260
282
|
/**
|
package/src/ui/screen.js
CHANGED
|
@@ -100,16 +100,10 @@ const at = (row, col) => `${ESC}[${row};${col}H`;
|
|
|
100
100
|
const title = (t) => `${ESC}]0;${t}\x07`;
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* Fixed rows below the header: the gap under it, the
|
|
104
|
-
* the input box
|
|
105
|
-
* status row.
|
|
106
|
-
*
|
|
107
|
-
* The live line's row is held whether anything is running or not. Adding it
|
|
108
|
-
* only while busy would move the whole transcript up a row at the start of
|
|
109
|
-
* every turn and back down at the end of it, which reads as the screen
|
|
110
|
-
* flinching each time you press return.
|
|
103
|
+
* Fixed rows below the header: the gap under it, the gap above the input box,
|
|
104
|
+
* the input box's two borders, the blank row inside it, and the status row.
|
|
111
105
|
*/
|
|
112
|
-
const CHROME_BELOW =
|
|
106
|
+
const CHROME_BELOW = 6;
|
|
113
107
|
|
|
114
108
|
/** How long one sentence of reasoning holds the line before the next takes it. */
|
|
115
109
|
const THOUGHT_HOLD_MS = 1100;
|
|
@@ -930,7 +924,15 @@ export class Screen {
|
|
|
930
924
|
const inner = width - 2; // the space between the two borders
|
|
931
925
|
const chip = this.modeChip();
|
|
932
926
|
const left = ` ${chip} ${chalk.white(this.model || '—')}`;
|
|
933
|
-
|
|
927
|
+
|
|
928
|
+
// How long the turn has taken, back in the box beside the other two facts
|
|
929
|
+
// about the session. It is not on the live line: that line says what is
|
|
930
|
+
// being done, and a clock ticking in the middle of it competes with the
|
|
931
|
+
// words for no reason. Under a second there is no number worth reading.
|
|
932
|
+
const now = Date.now();
|
|
933
|
+
const since = this.activity?.start ?? this.status.since ?? 0;
|
|
934
|
+
const running = this.busy() && since && now - since >= 1000;
|
|
935
|
+
const right = `${running ? `${dim(formatDuration(now - since))} ` : ''}${this.percentChip()} `;
|
|
934
936
|
|
|
935
937
|
// Where a click on the bottom row still counts as hitting the mode chip.
|
|
936
938
|
this.chipTo = 2 + visLen(chip);
|
|
@@ -956,20 +958,27 @@ export class Screen {
|
|
|
956
958
|
activityLine(width = this.width()) {
|
|
957
959
|
if (!this.busy()) return '';
|
|
958
960
|
const now = Date.now();
|
|
959
|
-
const since = this.activity?.start ?? this.status.since ?? now;
|
|
960
961
|
return workingLine({
|
|
961
962
|
glyph: spinnerGlyph(this.tick, now),
|
|
962
963
|
label: this.status.busy ? this.status.text : 'working',
|
|
963
|
-
elapsed: now - since >= 1000 ? formatDuration(now - since) : '',
|
|
964
964
|
hint: 'esc to stop',
|
|
965
965
|
room: Math.max(4, width),
|
|
966
966
|
t: now,
|
|
967
967
|
});
|
|
968
968
|
}
|
|
969
969
|
|
|
970
|
-
/**
|
|
970
|
+
/**
|
|
971
|
+
* Which row the live line is painted on, 1-based, or 0 when it is not shown.
|
|
972
|
+
*
|
|
973
|
+
* It is the last line of the conversation, so its row moves as the
|
|
974
|
+
* conversation grows and stops moving once the viewport is full. Scrolled
|
|
975
|
+
* back, or with the picker open, it is not on screen at all and the cheap
|
|
976
|
+
* repaint has nothing to do.
|
|
977
|
+
*/
|
|
971
978
|
activityRowAt() {
|
|
972
|
-
|
|
979
|
+
if (this.scroll > 0 || this.picker) return 0;
|
|
980
|
+
const index = Math.min(this.lines.length + 1, this.viewportHeight()) - 1;
|
|
981
|
+
return index < 0 ? 0 : this.headerHeight() + 2 + index;
|
|
973
982
|
}
|
|
974
983
|
|
|
975
984
|
/**
|
|
@@ -988,9 +997,10 @@ export class Screen {
|
|
|
988
997
|
}
|
|
989
998
|
const [row, col] = this.caret();
|
|
990
999
|
const width = this.width();
|
|
1000
|
+
const liveAt = this.activityRowAt();
|
|
991
1001
|
this.output.write(
|
|
992
1002
|
HIDE +
|
|
993
|
-
at(
|
|
1003
|
+
(liveAt ? at(liveAt, 1) + CLEAR_LINE + padVis(this.activityLine(width), width) : '') +
|
|
994
1004
|
at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(width), width, this.borderPaint()) +
|
|
995
1005
|
at(row, col) + SHOW
|
|
996
1006
|
);
|
|
@@ -1481,18 +1491,24 @@ export class Screen {
|
|
|
1481
1491
|
const width = this.width();
|
|
1482
1492
|
const height = this.viewportHeight();
|
|
1483
1493
|
|
|
1484
|
-
|
|
1494
|
+
// The live line is the last line of the conversation, not a fixture above
|
|
1495
|
+
// the input box. Pinned down there it sat at the bottom of the screen while
|
|
1496
|
+
// the message that started it was at the top, with the empty middle of the
|
|
1497
|
+
// viewport between them — so the thing being done looked unrelated to the
|
|
1498
|
+
// thing that had been asked. On the end of the transcript it arrives
|
|
1499
|
+
// directly under the prompt, which is where the eye already is.
|
|
1500
|
+
const live = this.activityLine(width);
|
|
1501
|
+
const said = live ? [...this.lines, live] : this.lines;
|
|
1502
|
+
|
|
1503
|
+
const end = Math.max(0, said.length - this.scroll);
|
|
1485
1504
|
const start = Math.max(0, end - height);
|
|
1486
|
-
const window = this.picker ? this.pickerLines(height) :
|
|
1505
|
+
const window = this.picker ? this.pickerLines(height) : said.slice(start, end);
|
|
1487
1506
|
while (window.length < height) window.push('');
|
|
1488
1507
|
|
|
1489
1508
|
const frame = [
|
|
1490
1509
|
...this.headerLines(),
|
|
1491
1510
|
'',
|
|
1492
1511
|
...window,
|
|
1493
|
-
// What is happening right now, in the same column as the steps above it,
|
|
1494
|
-
// because it is the next one of those. Empty between turns.
|
|
1495
|
-
this.activityLine(width),
|
|
1496
1512
|
// Always one clear row between the last thing said and the box you type
|
|
1497
1513
|
// in. Without it the newest line of output sits against the border and
|
|
1498
1514
|
// reads as part of the input rather than as the answer above it.
|