ucode-agent 1.28.1 → 1.29.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/README.md CHANGED
@@ -19,32 +19,37 @@ corner:
19
19
  ╭──────────────────────────────────────────────────────────────────────────────╮
20
20
  │ › Ask anything… │
21
21
  │ │
22
- │ ◆ Build · North Mini Code 0% │
22
+ │ BUILD North Mini Code 0% │
23
23
  ╰──────────────────────────────────────────────────────────────────────────────╯
24
24
 
25
+ try build me a landing page for a coffee shop
26
+ explain what this project does and how it fits together
27
+ add a dark mode toggle that remembers the choice
25
28
 
26
- v1.2.0
29
+
30
+ v1.29.0
27
31
  ```
28
32
 
29
- and once you are talking, each message you send is boxed in the same blue as
30
- the input, so your own words are easy to find in a long session:
33
+ A light crosses the wordmark once as it opens, and the three lines under the box
34
+ are there so an empty screen has something to say. Once you are talking, each
35
+ message you send is marked down its left edge in the same blue as the input, so
36
+ your own words are easy to find in a long session — and each step the agent
37
+ takes carries the shape of the work: a hollow diamond to look, a filled one to
38
+ change, an arrow to run.
31
39
 
32
40
  ```
33
- ╭──────────────────────────────────────────────────────────────────────────────────╮
34
- │ › build a notes dashboard │
35
- ╰──────────────────────────────────────────────────────────────────────────────────╯
36
- ● Writing index.html
37
- └ created · 148 lines
38
- 1 + <!doctype html>
39
- 2 + <html lang="en">
40
- … 146 more lines
41
- ● Running npm run dev
42
- └ ready · http://localhost:3000 · PID 4812
41
+ ▌ build a notes dashboard
42
+
43
+ ◇ Read 3 files
44
+ ◆ Writing index.html +148 -0
45
+ ▸ Running npm run dev
46
+
47
+ The dashboard is at http://localhost:3000, and `npm run dev` brings it back up.
43
48
 
44
49
  ╭──────────────────────────────────────────────────────────────────────────────────╮
45
50
  │ › now add a dark mode toggle │
46
51
  │ │
47
- │ ◆ Build · North Mini Code 4% │
52
+ │ BUILD North Mini Code 4% │
48
53
  ╰──────────────────────────────────────────────────────────────────────────────────╯
49
54
  ```
50
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.28.1",
3
+ "version": "1.29.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",
@@ -0,0 +1,65 @@
1
+ /**
2
+ * git.js — which branch you are on, read off the disk rather than shelled out.
3
+ *
4
+ * The header wants one word. Spawning `git` to get it would cost a process at
5
+ * startup and fail differently on every machine, where `.git/HEAD` is a single
6
+ * line in a documented format that has not changed in twenty years.
7
+ */
8
+
9
+ import { readFileSync, statSync } from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ /** `ref: refs/heads/main` → `main`; a bare sha → its first seven characters. */
13
+ export function parseHead(text) {
14
+ const head = String(text ?? '').trim();
15
+ if (!head) return '';
16
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
17
+ if (ref) return ref[1];
18
+ // A detached head is the commit itself, which is worth saying as such.
19
+ return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 7) : '';
20
+ }
21
+
22
+ /**
23
+ * The repository directory for a folder, walking up until one is found.
24
+ *
25
+ * In a worktree or a submodule `.git` is a file pointing elsewhere, so the
26
+ * pointer is followed — otherwise every worktree would report no branch.
27
+ */
28
+ function repoDir(start) {
29
+ let dir = path.resolve(start);
30
+ for (let up = 0; up < 40; up++) {
31
+ const dot = path.join(dir, '.git');
32
+ try {
33
+ const stat = statSync(dot);
34
+ if (stat.isDirectory()) return dot;
35
+ if (stat.isFile()) {
36
+ const link = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, 'utf8'));
37
+ if (link) return path.resolve(dir, link[1].trim());
38
+ return '';
39
+ }
40
+ } catch {
41
+ // Not here; keep walking up.
42
+ }
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) return '';
45
+ dir = parent;
46
+ }
47
+ return '';
48
+ }
49
+
50
+ /**
51
+ * The branch name for a folder, or '' when it is not a repository.
52
+ *
53
+ * Never throws. A header that cannot be drawn because a permission check
54
+ * failed on a folder above the project would be a worse bug than a missing
55
+ * word, so every failure here is the same as "not a repository".
56
+ */
57
+ export function gitBranch(cwd) {
58
+ try {
59
+ const dir = repoDir(cwd);
60
+ if (!dir) return '';
61
+ return parseHead(readFileSync(path.join(dir, 'HEAD'), 'utf8'));
62
+ } catch {
63
+ return '';
64
+ }
65
+ }
@@ -1,206 +1,259 @@
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 = 46;
61
-
62
- /** Characters' worth of dark between one pass and the next. */
63
- const PAUSE = 10;
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, { ok = true } = {}) {
196
- // The step count is bookkeeping: it tells the reader nothing about whether
197
- // the thing they asked for exists. /stats has it for anyone who wants it.
198
- void steps;
199
- if (!ok) return `${theme.warn('!')} ${dim(`Stopped after ${formatDuration(ms)} without finishing`)}`;
200
- return `${theme.ok('✓')} ${dim(`Done in ${formatDuration(ms)}`)}`;
201
- }
202
-
203
- /** The step count, brighter for a moment right after it goes up. */
204
- export function stepPaint(justMoved) {
205
- return justMoved ? sky : dim;
206
- }
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, bannerRGB, bannerPaint } 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 = 46;
61
+
62
+ /** Characters' worth of dark between one pass and the next. */
63
+ const PAUSE = 10;
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 wordmark with a light passing across it, once, at launch.
113
+ *
114
+ * The first thing anyone sees of a program is the half second before they can
115
+ * type, and ucode was spending it showing a finished picture. A band of light
116
+ * crossing the mark left to right in that same half second costs nothing, is
117
+ * over before it can annoy anyone, and is the difference between a logo that
118
+ * was printed and one that arrived.
119
+ *
120
+ * Every row is swept from the same clock, so the light is a vertical bar
121
+ * travelling across the whole wordmark rather than six separate glints. It
122
+ * blends out of the row's own gradient colour, not out of a flat blue, so the
123
+ * moment it passes the mark is exactly what it will look like at rest.
124
+ *
125
+ * Below 256 colours there are no in-between shades to fade through, so the
126
+ * mark is simply drawn finished — a two-colour "sweep" is a flicker.
127
+ */
128
+ export const SWEEP_MS = 620;
129
+
130
+ /** Half the width of the travelling band, in characters. */
131
+ const SWEEP_BAND = 7;
132
+
133
+ export function bannerSweep(line, row, rows, elapsed, { level = chalk.level } = {}) {
134
+ const text = String(line ?? '');
135
+ const rest = bannerRGB(row, rows);
136
+ const progress = SWEEP_MS > 0 ? elapsed / SWEEP_MS : 1;
137
+ if (level < 2 || !text || progress >= 1 || progress < 0) return bannerPaint(row, rows)(text);
138
+
139
+ // The centre starts off the left edge and ends off the right, so the band
140
+ // enters and leaves rather than appearing in the middle of the letters.
141
+ const centre = progress * (text.length + SWEEP_BAND * 2) - SWEEP_BAND;
142
+
143
+ let out = '';
144
+ let run = '';
145
+ let runStep = -1;
146
+ const flush = () => {
147
+ if (!run) return;
148
+ const [r, g, b] = mix(rest, PEAK_RGB, runStep / STEPS);
149
+ out += painter(level).rgb(r, g, b)(run);
150
+ run = '';
151
+ };
152
+
153
+ for (let i = 0; i < text.length; i++) {
154
+ const distance = Math.abs(i - centre);
155
+ const k = distance < SWEEP_BAND ? (Math.cos((Math.PI * distance) / SWEEP_BAND) + 1) / 2 : 0;
156
+ const step = Math.round(k * STEPS);
157
+ if (step !== runStep) { flush(); runStep = step; }
158
+ run += text[i];
159
+ }
160
+ flush();
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * The spinner glyph for a frame, breathing slowly between two blues.
166
+ *
167
+ * The pulse is slow — a little over a second a breath — so it reads as the
168
+ * glyph being alive rather than as a blink.
169
+ */
170
+ export function spinnerGlyph(frame, t, { level = chalk.level } = {}) {
171
+ const glyph = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
172
+ if (level < 2) return theme.blue(glyph);
173
+ const k = (Math.sin((Math.max(0, t) / 1300) * Math.PI * 2) + 1) / 2;
174
+ const [r, g, b] = mix([0x4d, 0x8d, 0xff], [0x9f, 0xc6, 0xff], k);
175
+ return painter(level).rgb(r, g, b)(glyph);
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Fitting it into the room there is
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /** Shorter than this, a label is a stub that says nothing, so it goes entirely. */
183
+ const MIN_LABEL = 10;
184
+
185
+ /**
186
+ * The middle of the status row, fitted to `room` columns.
187
+ *
188
+ * Parts, in the order they are given up when the terminal is too narrow for
189
+ * all of them:
190
+ *
191
+ * 1. the "esc to stop" hint — useful once, known after that
192
+ * 2. the end of the label — clipped with an ellipsis, down to a stub
193
+ * 3. the step count
194
+ * 4. the label itself
195
+ * 5. the elapsed time
196
+ *
197
+ * The spinner is the last thing standing: even with a single column left the
198
+ * row still shows that something is happening.
199
+ *
200
+ * `meta` is a list of { text, paint, keep } — keep marks the one that survives
201
+ * the longest (the timer). `paint` colours the label, which is where the
202
+ * shimmer comes in.
203
+ */
204
+ export function fitActivity({ glyph, label = '', meta = [], hint = '', paint = dim }, room) {
205
+ if (room < 1) return '';
206
+ const items = meta.filter((m) => m && m.text);
207
+ const kept = items.filter((m) => m.keep);
208
+ const text = String(label ?? '');
209
+
210
+ const width = (labelLen, list, withHint) =>
211
+ 1 +
212
+ (labelLen ? 1 + labelLen : 0) +
213
+ (list.length ? (labelLen ? 3 : 1) + list.map((m) => m.text).join(' · ').length : 0) +
214
+ (withHint && hint ? 2 + hint.length : 0);
215
+
216
+ const build = (labelText, list, withHint) => {
217
+ let out = glyph;
218
+ if (labelText) out += ` ${paint(labelText)}`;
219
+ if (list.length) {
220
+ out += labelText ? dim(' · ') : ' ';
221
+ out += list.map((m) => (m.paint ?? dim)(m.text)).join(dim(' · '));
222
+ }
223
+ if (withHint && hint) out += ` ${dim(hint)}`;
224
+ return out;
225
+ };
226
+
227
+ if (text) {
228
+ if (width(text.length, items, true) <= room) return build(text, items, true);
229
+ if (width(text.length, items, false) <= room) return build(text, items, false);
230
+ for (const list of [items, kept]) {
231
+ const labelRoom = room - width(0, list, false) - 1 - (list.length ? 2 : 0);
232
+ if (labelRoom >= MIN_LABEL) return build(clip(text, labelRoom), list, false);
233
+ }
234
+ }
235
+ for (const list of [items, kept, []]) {
236
+ if (width(0, list, false) <= room) return build('', list, false);
237
+ }
238
+ return glyph;
239
+ }
240
+
241
+ /**
242
+ * The line a finished turn leaves in the transcript: "✓ Done in 6m 12s · 25 steps".
243
+ *
244
+ * Green for the tick, because green means done and nothing else in this
245
+ * theme; the rest dim, because it is a footnote to the answer above it rather
246
+ * than something to read first.
247
+ */
248
+ export function doneLine(ms, steps, { ok = true } = {}) {
249
+ // The step count is bookkeeping: it tells the reader nothing about whether
250
+ // the thing they asked for exists. /stats has it for anyone who wants it.
251
+ void steps;
252
+ if (!ok) return `${theme.warn('!')} ${dim(`Stopped after ${formatDuration(ms)} without finishing`)}`;
253
+ return `${theme.ok('✓')} ${dim(`Done in ${formatDuration(ms)}`)}`;
254
+ }
255
+
256
+ /** The step count, brighter for a moment right after it goes up. */
257
+ export function stepPaint(justMoved) {
258
+ return justMoved ? sky : dim;
259
+ }
package/src/ui/plain.js CHANGED
@@ -13,7 +13,7 @@ import readline from 'node:readline';
13
13
  import chalk from 'chalk';
14
14
  import {
15
15
  theme, blue, sky, dim, boxTop, boxBottom, boxRow,
16
- BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen, planLine, bannerPaint,
16
+ BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen, planLine, bannerPaint, modeChip, narrationMark, groupKind,
17
17
  tidyReply, trimAnswer,
18
18
  } from './theme.js';
19
19
  import { formatDuration, doneLine } from './activity.js';
@@ -132,8 +132,8 @@ export class Plain {
132
132
  /** The same three facts the full screen shows, on the row under the header. */
133
133
  statusRow() {
134
134
  const inner = this.width() - 2;
135
- const chip = this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
136
- const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
135
+ const chip = modeChip(this.mode);
136
+ const left = ` ${chip} ${chalk.white(this.model || '—')}`;
137
137
  const percent = Math.round(this.percent ?? 0);
138
138
  const right = `${percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`)} `;
139
139
  const pad = Math.max(1, inner - visLen(left) - visLen(right));
@@ -142,7 +142,7 @@ export class Plain {
142
142
 
143
143
  toolCall(label) {
144
144
  this.stopSpinner();
145
- this.output.write(`${blue('●')} ${asLabel(label)}\n`);
145
+ this.output.write(`${narrationMark(groupKind(label))} ${asLabel(label)}\n`);
146
146
  }
147
147
 
148
148
  plan(items) {
package/src/ui/screen.js CHANGED
@@ -40,8 +40,9 @@ import {
40
40
  theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
41
41
  boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
42
  shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer,
43
- bannerPaint, RAIL } from './theme.js';
44
- import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
43
+ bannerPaint, RAIL, modeChip } from './theme.js';
44
+ import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint, bannerSweep, SWEEP_MS } from './activity.js';
45
+ import { gitBranch } from '../core/git.js';
45
46
  import { renderer, render, polish } from './markdown.js';
46
47
  import { VERSION } from '../core/version.js';
47
48
 
@@ -129,6 +130,28 @@ const WORKING_HINT = 'Working… type to queue your next message';
129
130
  const WORKING_HINT_SHORT = 'Working…';
130
131
  const WORKING_HINT_NEEDS = WORKING_HINT.length + 8;
131
132
 
133
+ /**
134
+ * Three things to try, under the box, on a screen with nothing on it yet.
135
+ *
136
+ * A wordmark over an empty field is handsome and tells you nothing you can act
137
+ * on — the first thing a new user has to do is guess what this accepts. Three
138
+ * greyed lines answer that in one glance, and they say something about the
139
+ * range of it too: build something new, understand something that exists,
140
+ * change something small. They are dim, and they are gone the moment anything
141
+ * is on the screen.
142
+ */
143
+ const SUGGESTIONS = [
144
+ 'build me a landing page for a coffee shop',
145
+ 'explain what this project does and how it fits together',
146
+ 'add a dark mode toggle that remembers the choice',
147
+ ];
148
+
149
+ /** The width of the `try` label, so the three lines share one left edge. */
150
+ const SUGGEST_LABEL = 6;
151
+
152
+ /** Rows the suggestions occupy under the box: one of air, then the three. */
153
+ const SUGGEST_ROWS = SUGGESTIONS.length + 1;
154
+
132
155
  export class Screen {
133
156
  constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
134
157
  this.cwd = cwd;
@@ -160,7 +183,11 @@ export class Screen {
160
183
  this.paintedBusy = false; // whose turn the frame on screen was drawn for
161
184
  this.activity = null; // the turn in flight: when it began, how many steps
162
185
  this.tick = 0; // animation frames painted, for the spinner
186
+ this.intro = 0; // when the launch sweep began, 0 once it is over
187
+ this.introTimer = null;
163
188
  this.pendingPrompt = null;
189
+ this.lastPrompt = ''; // the last thing the user said, for the echo check
190
+ this.facts.branch = gitBranch(cwd);
164
191
 
165
192
  this.cols = output.columns || 80;
166
193
  this.rows = output.rows || 24;
@@ -186,10 +213,39 @@ export class Screen {
186
213
  this.output.on('resize', this.onResize);
187
214
 
188
215
  this.render();
216
+ this.startIntro();
217
+ }
218
+
219
+ /**
220
+ * The light that crosses the wordmark once, at launch.
221
+ *
222
+ * Half a second, on the start screen only, and abandoned the instant there is
223
+ * anything else to look at. Below 256 colours there are no shades to fade
224
+ * through, so it is skipped rather than flickered.
225
+ */
226
+ startIntro() {
227
+ if (!this.output.isTTY || chalk.level < 2 || !this.welcoming()) return;
228
+ this.intro = Date.now();
229
+ this.introTimer = setInterval(() => {
230
+ if (this.closed || !this.welcoming() || Date.now() - this.intro >= SWEEP_MS) this.stopIntro();
231
+ else this.render();
232
+ }, FRAME_MS);
233
+ this.introTimer.unref?.();
234
+ }
235
+
236
+ stopIntro() {
237
+ if (this.introTimer) clearInterval(this.introTimer);
238
+ this.introTimer = null;
239
+ if (!this.intro) return;
240
+ this.intro = 0;
241
+ if (!this.closed) this.render();
189
242
  }
190
243
 
191
244
  stop() {
192
245
  this.activity = null;
246
+ if (this.introTimer) clearInterval(this.introTimer);
247
+ this.introTimer = null;
248
+ this.intro = 0;
193
249
  this.stopSpinner();
194
250
  this.stopTimer();
195
251
  this.output.off?.('resize', this.onResize);
@@ -285,7 +341,8 @@ export class Screen {
285
341
  */
286
342
  assistant(text, { closing = false } = {}) {
287
343
  if (!text?.trim()) return;
288
- const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
344
+ const tidy = tidyReply(text, 4, this.lastPrompt);
345
+ const body = closing ? trimAnswer(tidy) : tidy;
289
346
  if (!body.trim()) return;
290
347
  this.endRun();
291
348
  this.add('');
@@ -312,6 +369,9 @@ export class Screen {
312
369
  * loud as the input box, none of them saying anything the rail does not.
313
370
  */
314
371
  userMessage(text) {
372
+ // Kept so the reply can be checked against it: an answer that opens by
373
+ // saying the request back is repeating the line directly above it.
374
+ this.lastPrompt = String(text ?? '');
315
375
  const room = Math.max(8, this.width() - 2); // the rail and the space after it
316
376
 
317
377
  const rows = [];
@@ -361,7 +421,7 @@ export class Screen {
361
421
  kind, count: 1, at: 0, label: clean,
362
422
  targets: [groupTarget(clean)], added: 0, removed: 0,
363
423
  };
364
- this.push(`${narrationMark()} ${runLine(fresh)}`);
424
+ this.push(`${narrationMark(kind)} ${runLine(fresh)}`);
365
425
  fresh.at = this.lines.length - 1;
366
426
  this.run = fresh;
367
427
  this.segment.set(kind, fresh);
@@ -402,7 +462,7 @@ export class Screen {
402
462
  */
403
463
  paintRun() {
404
464
  if (!this.run) return;
405
- this.lines[this.run.at] = `${narrationMark()} ${runLine(this.run)}`;
465
+ this.lines[this.run.at] = `${narrationMark(this.run.kind)} ${runLine(this.run)}`;
406
466
  this.render();
407
467
  }
408
468
 
@@ -628,6 +688,7 @@ export class Screen {
628
688
 
629
689
  /** Same shape as the plain UI's header(), so the loop needs no branch. */
630
690
  header({ cwd, model, used, limit, title: sessionTitle }) {
691
+ if (cwd && cwd !== this.facts.cwd) this.facts.branch = gitBranch(cwd);
631
692
  this.setFacts({
632
693
  cwd,
633
694
  model,
@@ -662,30 +723,53 @@ export class Screen {
662
723
 
663
724
  // Two spaces of padding, the wordmark, a gap, then the facts column.
664
725
  //
665
- // Only what you cannot work out by looking: where you are, and how to get
666
- // help. How full the window is belongs on the status row next to the model
667
- // it describes, and the session title is already the terminal's own window
668
- // title — repeating either here is a second place to keep in sync for no
669
- // reader who needed it.
726
+ // Only what you cannot work out by looking, and never what the status row
727
+ // already carries: the model and how full the window is live down there,
728
+ // next to each other, and a second copy up here would be a second place to
729
+ // keep in sync for no reader who needed it. What is left is where you are,
730
+ // which branch that is on, which build you are running, and the two keys
731
+ // worth knowing before you have typed anything.
732
+ //
733
+ // Every row has something on it. Three blank rows and a credit floating
734
+ // under them read as a column that was meant to be filled and was not.
670
735
  const room = Math.max(8, inner - BANNER_WIDTH - 6);
736
+ const value = (v) => clip(String(v), Math.max(4, room - 10));
737
+ const branch = this.facts.branch;
738
+
739
+ // The facts run together from the top with nothing between them, the credit
740
+ // sits on the last row, and whatever is left over is the gap between the
741
+ // two. Holding a row empty in the middle of the list — which is what a
742
+ // fixed six-row layout did when a fact was missing — reads as a line that
743
+ // failed to draw rather than as spacing.
671
744
  const facts = [
672
- ['dir', shortenPath(this.facts.cwd ?? this.cwd, room - 9)],
673
- ['keys', '/help · esc interrupts'],
674
- ['', ''],
675
- ['', ''],
676
- ['', ''],
677
- ['', 'made with ❤️ by om dixit'],
678
- ];
745
+ ['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
746
+ branch && ['branch', value(branch)],
747
+ VERSION && ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)],
748
+ ['keys', value('/help · esc interrupts · ctrl+b plan')],
749
+ ].filter(Boolean).slice(0, BANNER.length - 1);
750
+
751
+ while (facts.length < BANNER.length - 1) facts.push(['', '']);
752
+ facts.push(['', 'made with ❤️ by om dixit']);
679
753
 
680
754
  const rows = BANNER.map((art, i) => {
681
- const [label, value] = facts[i] ?? ['', ''];
755
+ const [label, text] = facts[i] ?? ['', ''];
682
756
  const right = label
683
- ? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
684
- : (value ? dim(value) : '');
757
+ ? `${dim(label.padEnd(10))}${chalk.white(text)}`
758
+ : (text ? dim(text) : '');
685
759
  return ` ${bannerPaint(i)(art)} ${right}`;
686
760
  });
687
761
 
688
- return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
762
+ // The frame is lit the way the wordmark inside it is: brightest along the
763
+ // top rule, settling to deep at the bottom. Eight rows of box for six of
764
+ // banner, so the borders take the two ends of the same ramp and the box
765
+ // reads as one object with a light above it rather than as a rule someone
766
+ // drew around a picture.
767
+ const depth = BANNER.length + 2;
768
+ return [
769
+ boxTop(width, bannerPaint(0, depth)),
770
+ ...rows.map((r, i) => boxRow(r, width, bannerPaint(i + 1, depth))),
771
+ boxBottom(width, bannerPaint(depth - 1, depth)),
772
+ ];
689
773
  }
690
774
 
691
775
  // -- input box -----------------------------------------------------------
@@ -809,7 +893,7 @@ export class Screen {
809
893
  // -- status row ----------------------------------------------------------
810
894
 
811
895
  modeChip() {
812
- return this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
896
+ return modeChip(this.mode);
813
897
  }
814
898
 
815
899
  /**
@@ -837,7 +921,7 @@ export class Screen {
837
921
  statusRow(width = this.width()) {
838
922
  const inner = width - 2; // the space between the two borders
839
923
  const chip = this.modeChip();
840
- const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
924
+ const left = ` ${chip} ${chalk.white(this.model || '—')}`;
841
925
  const right = `${this.percentChip()} `;
842
926
 
843
927
  // Where a click on the bottom row still counts as hitting the mode chip.
@@ -1457,10 +1541,12 @@ export class Screen {
1457
1541
  const big = cols >= BANNER_WIDTH + 4 && this.rows >= 18;
1458
1542
  const art = big ? BANNER : ['u c o d e'];
1459
1543
  const inputRows = this.inputLines(boxWidth - 4).rows.length;
1460
- const block = art.length + 2 + inputRows + 4; // wordmark, gap, box
1544
+ const boxRows = inputRows + 4; // borders, typed rows, gap, status
1545
+ const suggest = this.rows >= boxRows + art.length + SUGGEST_ROWS + 6;
1546
+ const block = art.length + 2 + boxRows + (suggest ? SUGGEST_ROWS : 0);
1461
1547
  // A touch above true centre reads as centred; exact centre looks low.
1462
1548
  const top = Math.max(0, Math.floor((this.rows - block) / 2) - 1);
1463
- return { cols, boxWidth, left, big, art, inputRows, top, boxTop: top + art.length + 2 };
1549
+ return { cols, boxWidth, left, big, art, inputRows, boxRows, suggest, top, boxTop: top + art.length + 2 };
1464
1550
  }
1465
1551
 
1466
1552
  renderWelcome() {
@@ -1471,9 +1557,12 @@ export class Screen {
1471
1557
  // rows. Across the rows rather than along them — a name split down its
1472
1558
  // middle reads as two words, where a name that fades downward reads as
1473
1559
  // one object with a light on it.
1560
+ const elapsed = this.intro ? Date.now() - this.intro : Infinity;
1474
1561
  g.art.forEach((line, i) => {
1475
1562
  const pad = ' '.repeat(Math.max(0, Math.floor((g.cols - line.length) / 2)));
1476
- frame[g.top + i] = pad + (g.big ? bannerPaint(i, g.art.length)(line) : blue.bold(line));
1563
+ frame[g.top + i] = pad + (g.big
1564
+ ? bannerSweep(line, i, g.art.length, elapsed)
1565
+ : blue.bold(line));
1477
1566
  });
1478
1567
 
1479
1568
  const indent = ' '.repeat(g.left);
@@ -1481,6 +1570,15 @@ export class Screen {
1481
1570
  frame[g.boxTop + i] = indent + row;
1482
1571
  });
1483
1572
 
1573
+ // Three things to try, aligned with the text inside the box above them.
1574
+ if (g.suggest) {
1575
+ const at = g.boxTop + g.boxRows + 1;
1576
+ SUGGESTIONS.forEach((text, i) => {
1577
+ const label = i === 0 ? 'try'.padEnd(SUGGEST_LABEL) : ' '.repeat(SUGGEST_LABEL);
1578
+ frame[at + i] = `${indent} ${dim(sky(label))}${dim(clip(text, Math.max(8, g.boxWidth - SUGGEST_LABEL - 2)))}`;
1579
+ });
1580
+ }
1581
+
1484
1582
  // The version, in the corner, and nothing else on the screen.
1485
1583
  if (VERSION) {
1486
1584
  const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
package/src/ui/theme.js CHANGED
@@ -90,12 +90,13 @@ export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
90
90
  const GRADIENT_TOP = [0x8f, 0xbc, 0xff]; // sky, at the crown
91
91
  const GRADIENT_BOTTOM = [0x2f, 0x6f, 0xe0]; // deep, in the shadow
92
92
 
93
- export function bannerPaint(row, rows = BANNER.length) {
93
+ export function bannerRGB(row, rows = BANNER.length) {
94
94
  const t = rows > 1 ? Math.min(1, Math.max(0, row / (rows - 1))) : 0;
95
- const hex = GRADIENT_TOP
96
- .map((from, i) => Math.round(from + (GRADIENT_BOTTOM[i] - from) * t))
97
- .map((v) => v.toString(16).padStart(2, '0'))
98
- .join('');
95
+ return GRADIENT_TOP.map((from, i) => Math.round(from + (GRADIENT_BOTTOM[i] - from) * t));
96
+ }
97
+
98
+ export function bannerPaint(row, rows = BANNER.length) {
99
+ const hex = bannerRGB(row, rows).map((v) => v.toString(16).padStart(2, '0')).join('');
99
100
  return chalk.hex(`#${hex}`);
100
101
  }
101
102
 
@@ -108,6 +109,24 @@ export function bannerPaint(row, rows = BANNER.length) {
108
109
  */
109
110
  export const RAIL = '▌';
110
111
 
112
+ /**
113
+ * Which mode is live, as a filled pill.
114
+ *
115
+ * A glyph and a word is a label; a block of colour with the word knocked out
116
+ * of it is a control, and the mode is the one thing on the status row you can
117
+ * actually change. Build is the solid blue — it may edit and run. Plan is the
118
+ * same shape muted, because a read-only mode should not look armed.
119
+ *
120
+ * Small enough not to be the background painting that was taken out of here
121
+ * once: it is the width of the word, the way a diff's tint is the width of the
122
+ * line it marks.
123
+ */
124
+ export const BUILD_CHIP = chalk.bgHex('#4d8dff').hex('#0b1220').bold;
125
+ export const PLAN_CHIP = chalk.bgHex('#24344f').hex('#8fbcff').bold;
126
+
127
+ export const modeChip = (mode) =>
128
+ mode === 'plan' ? PLAN_CHIP(' PLAN ') : BUILD_CHIP(' BUILD ');
129
+
111
130
  /** The spinner. Braille dots, because they animate in place without jitter. */
112
131
  export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
113
132
 
@@ -361,8 +380,32 @@ export function planLine(items) {
361
380
  */
362
381
  export const narration = (text) => chalk.dim(text);
363
382
 
364
- /** The bullet beside a narration line: present, not loud. */
365
- export const narrationMark = () => chalk.dim(deep('●'));
383
+ /**
384
+ * The bullet beside a narration line: present, not loud.
385
+ *
386
+ * Three shapes rather than one dot repeated. Every step drawn identically made
387
+ * a long transcript a column of the same mark forty times over, which reads as
388
+ * output rather than as work — and the shape is free, where a fourth colour
389
+ * would not be. A diamond is hollow when the agent is only looking at
390
+ * something and filled when it changes it, and a run of a command points
391
+ * forward. Anything unmapped keeps the original dot.
392
+ *
393
+ * All of them stay dim: the glyph carries the kind, the weight still says this
394
+ * is scaffolding and the answer below is the thing to read.
395
+ */
396
+ const MARKS = {
397
+ Reading: ['◇', deep], Listing: ['◇', deep], Looking: ['◇', deep],
398
+ Asking: ['◇', deep], Mapping: ['◇', deep], Searching: ['◇', deep],
399
+ Finding: ['◇', deep],
400
+ Writing: ['◆', blue], Editing: ['◆', blue], Adding: ['◆', blue],
401
+ Renaming: ['◆', blue],
402
+ Running: ['▸', sky], Checking: ['▸', sky],
403
+ };
404
+
405
+ export const narrationMark = (kind) => {
406
+ const [glyph, paint] = MARKS[kind] ?? ['●', deep];
407
+ return chalk.dim(paint(glyph));
408
+ };
366
409
 
367
410
  /**
368
411
  * The file or command a step is about, lit so the line can be scanned.
@@ -492,7 +535,7 @@ export function withoutCodeBlocks(text, keepLines = 4) {
492
535
  * colon pointing at nothing reads as the reply having been cut off. A short
493
536
  * block stays: three lines showing a command to run belong in an answer.
494
537
  */
495
- export function tidyReply(text, keepLines = 4) {
538
+ export function tidyReply(text, keepLines = 4, prompt = '') {
496
539
  const MARK = "\u0000CUT\u0000";
497
540
  const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
498
541
 
@@ -502,11 +545,76 @@ export function tidyReply(text, keepLines = 4) {
502
545
  });
503
546
 
504
547
  const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
505
- return marked
548
+ const body = marked
506
549
  .replace(leadIn, "\n")
507
550
  .split(MARK).join("")
508
551
  .replace(new RegExp("\\n{3,}", "g"), "\n\n")
509
552
  .trim();
553
+ return withoutRestatement(body, prompt);
554
+ }
555
+
556
+ /**
557
+ * The words of a line worth comparing: lowercase, no punctuation, and nothing
558
+ * short enough to turn up in any sentence at all.
559
+ */
560
+ const significant = (s) => String(s ?? '')
561
+ .toLowerCase()
562
+ .replace(/[^a-z0-9\s]+/g, ' ')
563
+ .split(/\s+/)
564
+ .filter((w) => w.length >= 3);
565
+
566
+ /**
567
+ * Is this line the request handed back?
568
+ *
569
+ * A list of phrases catches the openings a model reaches for out of habit, but
570
+ * the commonest way of repeating a request is simply saying it again in the
571
+ * asker's own words — "A Next.js habit tracker with a clean dashboard, coming
572
+ * right up" — and no list will ever match that. So the line is compared with
573
+ * what was actually typed.
574
+ *
575
+ * Three guards keep it off real answers. A prompt of four significant words or
576
+ * fewer is never matched, because at that length an overlap means nothing. A
577
+ * line much longer than the prompt is saying more than the prompt did, so it
578
+ * is content. And the bar is four fifths of the prompt's words rather than a
579
+ * majority: an answer naturally shares nouns with the request that prompted
580
+ * it, and only something repeating nearly all of it is a repetition.
581
+ */
582
+ export function echoesPrompt(line, prompt) {
583
+ const want = [...new Set(significant(prompt))];
584
+ if (want.length < 4) return false;
585
+
586
+ const text = String(line ?? '');
587
+ if (text.length > String(prompt ?? '').length * 2.5) return false;
588
+
589
+ const have = new Set(significant(text));
590
+ return want.filter((w) => have.has(w)).length / want.length >= 0.8;
591
+ }
592
+
593
+ /**
594
+ * The reply with any opening that reads the request back taken off the front.
595
+ *
596
+ * This ran only on the closing message before, and it belongs on every one. A
597
+ * model that answers "You asked me to add a dark mode toggle — done" has spent
598
+ * its first line telling someone something they typed themselves, and the line
599
+ * directly above it on screen is already their own message, in their own
600
+ * words, against a rail. Two copies of the request and one of the answer is
601
+ * the wrong ratio.
602
+ *
603
+ * It never returns nothing. A reply that is only a restatement is still the
604
+ * whole of the reply, and an empty answer on screen reads as a crash.
605
+ */
606
+ export function withoutRestatement(text, prompt = '') {
607
+ const rows = String(text ?? '').replace(/\r/g, '').split('\n');
608
+
609
+ let start = 0;
610
+ while (start < rows.length) {
611
+ const line = rows[start].trim();
612
+ if (!line) { start++; continue; }
613
+ if (!RESTATED.test(line) && !echoesPrompt(line, prompt)) break;
614
+ start++;
615
+ }
616
+
617
+ return rows.slice(start).join('\n').trim() || String(text ?? '').trim();
510
618
  }
511
619
 
512
620
  /**
@@ -525,7 +633,7 @@ export function tidyReply(text, keepLines = 4) {
525
633
  export const ANSWER_LINES = 8;
526
634
  const ANSWER_ROOM = 600; // eight wrapped lines of prose, for a reply with no line breaks in it
527
635
 
528
- const RESTATED = /^(?:you (?:asked|wanted|requested|said)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
636
+ export const RESTATED = /^(?:(?:sure|ok|okay|got it|understood|alright|right)\b[\s,!.—-]*)?(?:you(?:'ve| have)? (?:asked|want|wanted|requested|said|would like|need)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|i(?:'ll| will|'m going to| am going to) (?:build|create|make|add|write|implement)\b|let(?:'s| us) (?:build|create|make|add|write|implement)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
529
637
  const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did not|isn't|is not|doesn't|does not|not (?:yet|wired|working|done|implemented)|missing|unfinished|except)\b/i;
530
638
  const ACTIONABLE = /\b(?:open|run|serve|visit|try|start|npm|npx|node|pnpm|yarn)\b|https?:\/\/|\.(?:html?|css|jsx?|tsx?|md|json|py|rs|go)\b/i;
531
639
  const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;