ucode-agent 1.28.0 → 1.29.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 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.0",
3
+ "version": "1.29.0",
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, MAX_WIDTH,
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';
@@ -66,13 +66,8 @@ export class Plain {
66
66
  });
67
67
  }
68
68
 
69
- /**
70
- * The same cap the full screen draws to, for the same reason: a header box
71
- * ruled across two hundred columns is a line, not a frame. Both surfaces are
72
- * the one product and should not disagree about how wide it is.
73
- */
74
69
  width() {
75
- return Math.max(30, Math.min(this.output.columns || 80, MAX_WIDTH));
70
+ return Math.max(30, this.output.columns || 80);
76
71
  }
77
72
 
78
73
  // -- output --------------------------------------------------------------
@@ -137,8 +132,8 @@ export class Plain {
137
132
  /** The same three facts the full screen shows, on the row under the header. */
138
133
  statusRow() {
139
134
  const inner = this.width() - 2;
140
- const chip = this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
141
- const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
135
+ const chip = modeChip(this.mode);
136
+ const left = ` ${chip} ${chalk.white(this.model || '—')}`;
142
137
  const percent = Math.round(this.percent ?? 0);
143
138
  const right = `${percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`)} `;
144
139
  const pad = Math.max(1, inner - visLen(left) - visLen(right));
@@ -147,7 +142,7 @@ export class Plain {
147
142
 
148
143
  toolCall(label) {
149
144
  this.stopSpinner();
150
- this.output.write(`${blue('●')} ${asLabel(label)}\n`);
145
+ this.output.write(`${narrationMark(groupKind(label))} ${asLabel(label)}\n`);
151
146
  }
152
147
 
153
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, answerMark, RAIL, MAX_WIDTH } 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,10 @@ 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.facts.branch = gitBranch(cwd);
164
190
 
165
191
  this.cols = output.columns || 80;
166
192
  this.rows = output.rows || 24;
@@ -186,10 +212,39 @@ export class Screen {
186
212
  this.output.on('resize', this.onResize);
187
213
 
188
214
  this.render();
215
+ this.startIntro();
216
+ }
217
+
218
+ /**
219
+ * The light that crosses the wordmark once, at launch.
220
+ *
221
+ * Half a second, on the start screen only, and abandoned the instant there is
222
+ * anything else to look at. Below 256 colours there are no shades to fade
223
+ * through, so it is skipped rather than flickered.
224
+ */
225
+ startIntro() {
226
+ if (!this.output.isTTY || chalk.level < 2 || !this.welcoming()) return;
227
+ this.intro = Date.now();
228
+ this.introTimer = setInterval(() => {
229
+ if (this.closed || !this.welcoming() || Date.now() - this.intro >= SWEEP_MS) this.stopIntro();
230
+ else this.render();
231
+ }, FRAME_MS);
232
+ this.introTimer.unref?.();
233
+ }
234
+
235
+ stopIntro() {
236
+ if (this.introTimer) clearInterval(this.introTimer);
237
+ this.introTimer = null;
238
+ if (!this.intro) return;
239
+ this.intro = 0;
240
+ if (!this.closed) this.render();
189
241
  }
190
242
 
191
243
  stop() {
192
244
  this.activity = null;
245
+ if (this.introTimer) clearInterval(this.introTimer);
246
+ this.introTimer = null;
247
+ this.intro = 0;
193
248
  this.stopSpinner();
194
249
  this.stopTimer();
195
250
  this.output.off?.('resize', this.onResize);
@@ -205,27 +260,18 @@ export class Screen {
205
260
  while (this.waiters.length) this.waiters.shift()(null);
206
261
  }
207
262
 
208
- /** Every column the terminal has. Only the start screen, which centres, uses it. */
209
- screenWidth() {
210
- return Math.max(30, this.cols);
211
- }
212
-
213
263
  /**
214
- * The width the interface actually draws to.
215
- *
216
- * On a wide monitor an uncapped frame stretched its boxes to two hundred
217
- * columns and ran prose the same distance, which is past the point a line
218
- * can be read without losing the start of it — and reads as the app not
219
- * having an opinion rather than as it filling the space. The cap is the
220
- * width the markdown renderer was already holding answers to, so prose,
221
- * boxes and diffs now end in the same column instead of three.
264
+ * Every column the terminal has.
222
265
  *
223
- * Left, not centred: the shell prompt before and after a session sits on the
224
- * left margin, and a frame that jumps to the middle of the screen reads as a
225
- * different program. What is past the cap is cleared, never written to.
266
+ * Capped at 100 for a release, and it was wrong: on a wide monitor the frame
267
+ * sat in the left half of the screen with the rest of it empty, which reads
268
+ * as the window having failed to open rather than as a measured column. The
269
+ * interface fills what it is given. Prose inside it is still held to 100 by
270
+ * the markdown renderer, which is where that limit belongs — the boxes are
271
+ * the shape of the window, not of a paragraph.
226
272
  */
227
273
  width() {
228
- return Math.min(this.screenWidth(), MAX_WIDTH);
274
+ return Math.max(30, this.cols);
229
275
  }
230
276
 
231
277
  /** Usable width inside a box: two borders and a space of padding each side. */
@@ -299,25 +345,12 @@ export class Screen {
299
345
  this.endRun();
300
346
  this.add('');
301
347
 
302
- // A bullet on the first line that has words on it, and the rest of the
303
- // answer indented to clear it. Without the mark the reply is white text at
304
- // the same margin as the narration above it, and scrolling back there is
305
- // nothing to aim at — you find where the answer starts by reading until
306
- // the sentences stop being about files.
307
- //
308
- // Wrapped here rather than left to add(), which knows nothing about the
309
- // indent: marked-terminal is told not to reflow, so a long line arrives
310
- // whole, and a row add() broke for itself came back out at column zero
311
- // with the rest of the answer sitting two columns to its right.
312
- const room = Math.max(8, this.width() - 2);
313
- let marked = false;
314
- for (const row of render(this.md, body).split('\n')) {
315
- if (!row.trim()) { this.add(''); continue; }
316
- for (const line of wrapAnsi(row, room)) {
317
- this.add(marked ? ` ${line}` : `${answerMark()} ${line}`);
318
- marked = true;
319
- }
320
- }
348
+ // No bullet, and no indent. A mark on every reply made the answer read as
349
+ // one more step in the list above it, and on "Hey! How can I help you
350
+ // today?" it was a bullet on a greeting. The answer already wins the page
351
+ // by being the only thing on it at full strength; it does not also need to
352
+ // be labelled.
353
+ this.add(render(this.md, body));
321
354
 
322
355
  this.add('');
323
356
  this.render();
@@ -383,7 +416,7 @@ export class Screen {
383
416
  kind, count: 1, at: 0, label: clean,
384
417
  targets: [groupTarget(clean)], added: 0, removed: 0,
385
418
  };
386
- this.push(`${narrationMark()} ${runLine(fresh)}`);
419
+ this.push(`${narrationMark(kind)} ${runLine(fresh)}`);
387
420
  fresh.at = this.lines.length - 1;
388
421
  this.run = fresh;
389
422
  this.segment.set(kind, fresh);
@@ -424,7 +457,7 @@ export class Screen {
424
457
  */
425
458
  paintRun() {
426
459
  if (!this.run) return;
427
- this.lines[this.run.at] = `${narrationMark()} ${runLine(this.run)}`;
460
+ this.lines[this.run.at] = `${narrationMark(this.run.kind)} ${runLine(this.run)}`;
428
461
  this.render();
429
462
  }
430
463
 
@@ -650,6 +683,7 @@ export class Screen {
650
683
 
651
684
  /** Same shape as the plain UI's header(), so the loop needs no branch. */
652
685
  header({ cwd, model, used, limit, title: sessionTitle }) {
686
+ if (cwd && cwd !== this.facts.cwd) this.facts.branch = gitBranch(cwd);
653
687
  this.setFacts({
654
688
  cwd,
655
689
  model,
@@ -684,30 +718,46 @@ export class Screen {
684
718
 
685
719
  // Two spaces of padding, the wordmark, a gap, then the facts column.
686
720
  //
687
- // Only what you cannot work out by looking: where you are, and how to get
688
- // help. How full the window is belongs on the status row next to the model
689
- // it describes, and the session title is already the terminal's own window
690
- // title — repeating either here is a second place to keep in sync for no
691
- // reader who needed it.
721
+ // Only what you cannot work out by looking, and never what the status row
722
+ // already carries: the model and how full the window is live down there,
723
+ // next to each other, and a second copy up here would be a second place to
724
+ // keep in sync for no reader who needed it. What is left is where you are,
725
+ // which branch that is on, which build you are running, and the two keys
726
+ // worth knowing before you have typed anything.
727
+ //
728
+ // Every row has something on it. Three blank rows and a credit floating
729
+ // under them read as a column that was meant to be filled and was not.
692
730
  const room = Math.max(8, inner - BANNER_WIDTH - 6);
731
+ const value = (v) => clip(String(v), Math.max(4, room - 10));
732
+ const branch = this.facts.branch;
693
733
  const facts = [
694
- ['dir', shortenPath(this.facts.cwd ?? this.cwd, room - 9)],
695
- ['keys', '/help · esc interrupts'],
696
- ['', ''],
697
- ['', ''],
734
+ ['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
735
+ branch ? ['branch', value(branch)] : ['', ''],
736
+ VERSION ? ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)] : ['', ''],
698
737
  ['', ''],
738
+ ['keys', value('/help · esc interrupts · ctrl+b plan')],
699
739
  ['', 'made with ❤️ by om dixit'],
700
740
  ];
701
741
 
702
742
  const rows = BANNER.map((art, i) => {
703
- const [label, value] = facts[i] ?? ['', ''];
743
+ const [label, text] = facts[i] ?? ['', ''];
704
744
  const right = label
705
- ? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
706
- : (value ? dim(value) : '');
745
+ ? `${dim(label.padEnd(10))}${chalk.white(text)}`
746
+ : (text ? dim(text) : '');
707
747
  return ` ${bannerPaint(i)(art)} ${right}`;
708
748
  });
709
749
 
710
- return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
750
+ // The frame is lit the way the wordmark inside it is: brightest along the
751
+ // top rule, settling to deep at the bottom. Eight rows of box for six of
752
+ // banner, so the borders take the two ends of the same ramp and the box
753
+ // reads as one object with a light above it rather than as a rule someone
754
+ // drew around a picture.
755
+ const depth = BANNER.length + 2;
756
+ return [
757
+ boxTop(width, bannerPaint(0, depth)),
758
+ ...rows.map((r, i) => boxRow(r, width, bannerPaint(i + 1, depth))),
759
+ boxBottom(width, bannerPaint(depth - 1, depth)),
760
+ ];
711
761
  }
712
762
 
713
763
  // -- input box -----------------------------------------------------------
@@ -831,7 +881,7 @@ export class Screen {
831
881
  // -- status row ----------------------------------------------------------
832
882
 
833
883
  modeChip() {
834
- return this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
884
+ return modeChip(this.mode);
835
885
  }
836
886
 
837
887
  /**
@@ -859,7 +909,7 @@ export class Screen {
859
909
  statusRow(width = this.width()) {
860
910
  const inner = width - 2; // the space between the two borders
861
911
  const chip = this.modeChip();
862
- const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
912
+ const left = ` ${chip} ${chalk.white(this.model || '—')}`;
863
913
  const right = `${this.percentChip()} `;
864
914
 
865
915
  // Where a click on the bottom row still counts as hitting the mode chip.
@@ -1473,19 +1523,18 @@ export class Screen {
1473
1523
 
1474
1524
  /** Where everything on the start screen goes, 0-based rows. */
1475
1525
  welcomeGeometry() {
1476
- // The true width here, not the capped one: the start screen centres itself,
1477
- // and centring inside the cap would park it left of the middle of a wide
1478
- // terminal. The session frame below is the thing that is left-aligned.
1479
- const cols = this.screenWidth();
1526
+ const cols = this.width();
1480
1527
  const boxWidth = Math.max(30, Math.min(cols - 4, 84));
1481
1528
  const left = Math.max(0, Math.floor((cols - boxWidth) / 2));
1482
1529
  const big = cols >= BANNER_WIDTH + 4 && this.rows >= 18;
1483
1530
  const art = big ? BANNER : ['u c o d e'];
1484
1531
  const inputRows = this.inputLines(boxWidth - 4).rows.length;
1485
- const block = art.length + 2 + inputRows + 4; // wordmark, gap, box
1532
+ const boxRows = inputRows + 4; // borders, typed rows, gap, status
1533
+ const suggest = this.rows >= boxRows + art.length + SUGGEST_ROWS + 6;
1534
+ const block = art.length + 2 + boxRows + (suggest ? SUGGEST_ROWS : 0);
1486
1535
  // A touch above true centre reads as centred; exact centre looks low.
1487
1536
  const top = Math.max(0, Math.floor((this.rows - block) / 2) - 1);
1488
- return { cols, boxWidth, left, big, art, inputRows, top, boxTop: top + art.length + 2 };
1537
+ return { cols, boxWidth, left, big, art, inputRows, boxRows, suggest, top, boxTop: top + art.length + 2 };
1489
1538
  }
1490
1539
 
1491
1540
  renderWelcome() {
@@ -1496,9 +1545,12 @@ export class Screen {
1496
1545
  // rows. Across the rows rather than along them — a name split down its
1497
1546
  // middle reads as two words, where a name that fades downward reads as
1498
1547
  // one object with a light on it.
1548
+ const elapsed = this.intro ? Date.now() - this.intro : Infinity;
1499
1549
  g.art.forEach((line, i) => {
1500
1550
  const pad = ' '.repeat(Math.max(0, Math.floor((g.cols - line.length) / 2)));
1501
- frame[g.top + i] = pad + (g.big ? bannerPaint(i, g.art.length)(line) : blue.bold(line));
1551
+ frame[g.top + i] = pad + (g.big
1552
+ ? bannerSweep(line, i, g.art.length, elapsed)
1553
+ : blue.bold(line));
1502
1554
  });
1503
1555
 
1504
1556
  const indent = ' '.repeat(g.left);
@@ -1506,6 +1558,15 @@ export class Screen {
1506
1558
  frame[g.boxTop + i] = indent + row;
1507
1559
  });
1508
1560
 
1561
+ // Three things to try, aligned with the text inside the box above them.
1562
+ if (g.suggest) {
1563
+ const at = g.boxTop + g.boxRows + 1;
1564
+ SUGGESTIONS.forEach((text, i) => {
1565
+ const label = i === 0 ? 'try'.padEnd(SUGGEST_LABEL) : ' '.repeat(SUGGEST_LABEL);
1566
+ frame[at + i] = `${indent} ${dim(sky(label))}${dim(clip(text, Math.max(8, g.boxWidth - SUGGEST_LABEL - 2)))}`;
1567
+ });
1568
+ }
1569
+
1509
1570
  // The version, in the corner, and nothing else on the screen.
1510
1571
  if (VERSION) {
1511
1572
  const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
package/src/ui/theme.js CHANGED
@@ -67,21 +67,6 @@ export const theme = {
67
67
  export const ADDED = chalk.bgHex('#0e2a1a').hex('#7ee2a8');
68
68
  export const REMOVED = chalk.bgHex('#331319').hex('#f2939c');
69
69
 
70
- /**
71
- * The widest the interface draws, however many columns the terminal has.
72
- *
73
- * An uncapped frame stretched its boxes across two hundred columns on a wide
74
- * monitor and ran prose the same distance, which is past the point a line can
75
- * be read without losing the start of it — and reads as the app having no
76
- * opinion rather than as it filling the space. marked-terminal was already
77
- * holding answers to 100, so this is the number the prose in the transcript
78
- * has always obeyed; the boxes and the diffs now obey it too.
79
- *
80
- * Both surfaces read it from here, because a full screen and a piped one
81
- * disagreeing about how wide the product is would be the odder thing.
82
- */
83
- export const MAX_WIDTH = 100;
84
-
85
70
  export const BANNER = [
86
71
  '██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗',
87
72
  '██║ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝',
@@ -105,12 +90,13 @@ export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
105
90
  const GRADIENT_TOP = [0x8f, 0xbc, 0xff]; // sky, at the crown
106
91
  const GRADIENT_BOTTOM = [0x2f, 0x6f, 0xe0]; // deep, in the shadow
107
92
 
108
- export function bannerPaint(row, rows = BANNER.length) {
93
+ export function bannerRGB(row, rows = BANNER.length) {
109
94
  const t = rows > 1 ? Math.min(1, Math.max(0, row / (rows - 1))) : 0;
110
- const hex = GRADIENT_TOP
111
- .map((from, i) => Math.round(from + (GRADIENT_BOTTOM[i] - from) * t))
112
- .map((v) => v.toString(16).padStart(2, '0'))
113
- .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('');
114
100
  return chalk.hex(`#${hex}`);
115
101
  }
116
102
 
@@ -124,14 +110,22 @@ export function bannerPaint(row, rows = BANNER.length) {
124
110
  export const RAIL = '▌';
125
111
 
126
112
  /**
127
- * The bullet beside the answer.
113
+ * Which mode is live, as a filled pill.
128
114
  *
129
- * The same circle as a step, because it is the same conversation, but at full
130
- * strength against the step's faint one. U+25CF and not U+23FA: the latter
131
- * carries emoji presentation, which Windows Terminal draws as a white circle
132
- * on a blue tile.
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.
133
123
  */
134
- export const answerMark = () => blue.bold('●');
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 ');
135
129
 
136
130
  /** The spinner. Braille dots, because they animate in place without jitter. */
137
131
  export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -386,8 +380,32 @@ export function planLine(items) {
386
380
  */
387
381
  export const narration = (text) => chalk.dim(text);
388
382
 
389
- /** The bullet beside a narration line: present, not loud. */
390
- 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
+ };
391
409
 
392
410
  /**
393
411
  * The file or command a step is about, lit so the line can be scanned.