ucode-agent 1.28.1 → 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.1",
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,
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,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);
@@ -361,7 +416,7 @@ export class Screen {
361
416
  kind, count: 1, at: 0, label: clean,
362
417
  targets: [groupTarget(clean)], added: 0, removed: 0,
363
418
  };
364
- this.push(`${narrationMark()} ${runLine(fresh)}`);
419
+ this.push(`${narrationMark(kind)} ${runLine(fresh)}`);
365
420
  fresh.at = this.lines.length - 1;
366
421
  this.run = fresh;
367
422
  this.segment.set(kind, fresh);
@@ -402,7 +457,7 @@ export class Screen {
402
457
  */
403
458
  paintRun() {
404
459
  if (!this.run) return;
405
- this.lines[this.run.at] = `${narrationMark()} ${runLine(this.run)}`;
460
+ this.lines[this.run.at] = `${narrationMark(this.run.kind)} ${runLine(this.run)}`;
406
461
  this.render();
407
462
  }
408
463
 
@@ -628,6 +683,7 @@ export class Screen {
628
683
 
629
684
  /** Same shape as the plain UI's header(), so the loop needs no branch. */
630
685
  header({ cwd, model, used, limit, title: sessionTitle }) {
686
+ if (cwd && cwd !== this.facts.cwd) this.facts.branch = gitBranch(cwd);
631
687
  this.setFacts({
632
688
  cwd,
633
689
  model,
@@ -662,30 +718,46 @@ export class Screen {
662
718
 
663
719
  // Two spaces of padding, the wordmark, a gap, then the facts column.
664
720
  //
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.
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.
670
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;
671
733
  const facts = [
672
- ['dir', shortenPath(this.facts.cwd ?? this.cwd, room - 9)],
673
- ['keys', '/help · esc interrupts'],
674
- ['', ''],
675
- ['', ''],
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)] : ['', ''],
676
737
  ['', ''],
738
+ ['keys', value('/help · esc interrupts · ctrl+b plan')],
677
739
  ['', 'made with ❤️ by om dixit'],
678
740
  ];
679
741
 
680
742
  const rows = BANNER.map((art, i) => {
681
- const [label, value] = facts[i] ?? ['', ''];
743
+ const [label, text] = facts[i] ?? ['', ''];
682
744
  const right = label
683
- ? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
684
- : (value ? dim(value) : '');
745
+ ? `${dim(label.padEnd(10))}${chalk.white(text)}`
746
+ : (text ? dim(text) : '');
685
747
  return ` ${bannerPaint(i)(art)} ${right}`;
686
748
  });
687
749
 
688
- 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
+ ];
689
761
  }
690
762
 
691
763
  // -- input box -----------------------------------------------------------
@@ -809,7 +881,7 @@ export class Screen {
809
881
  // -- status row ----------------------------------------------------------
810
882
 
811
883
  modeChip() {
812
- return this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
884
+ return modeChip(this.mode);
813
885
  }
814
886
 
815
887
  /**
@@ -837,7 +909,7 @@ export class Screen {
837
909
  statusRow(width = this.width()) {
838
910
  const inner = width - 2; // the space between the two borders
839
911
  const chip = this.modeChip();
840
- const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
912
+ const left = ` ${chip} ${chalk.white(this.model || '—')}`;
841
913
  const right = `${this.percentChip()} `;
842
914
 
843
915
  // Where a click on the bottom row still counts as hitting the mode chip.
@@ -1457,10 +1529,12 @@ export class Screen {
1457
1529
  const big = cols >= BANNER_WIDTH + 4 && this.rows >= 18;
1458
1530
  const art = big ? BANNER : ['u c o d e'];
1459
1531
  const inputRows = this.inputLines(boxWidth - 4).rows.length;
1460
- 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);
1461
1535
  // A touch above true centre reads as centred; exact centre looks low.
1462
1536
  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 };
1537
+ return { cols, boxWidth, left, big, art, inputRows, boxRows, suggest, top, boxTop: top + art.length + 2 };
1464
1538
  }
1465
1539
 
1466
1540
  renderWelcome() {
@@ -1471,9 +1545,12 @@ export class Screen {
1471
1545
  // rows. Across the rows rather than along them — a name split down its
1472
1546
  // middle reads as two words, where a name that fades downward reads as
1473
1547
  // one object with a light on it.
1548
+ const elapsed = this.intro ? Date.now() - this.intro : Infinity;
1474
1549
  g.art.forEach((line, i) => {
1475
1550
  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));
1551
+ frame[g.top + i] = pad + (g.big
1552
+ ? bannerSweep(line, i, g.art.length, elapsed)
1553
+ : blue.bold(line));
1477
1554
  });
1478
1555
 
1479
1556
  const indent = ' '.repeat(g.left);
@@ -1481,6 +1558,15 @@ export class Screen {
1481
1558
  frame[g.boxTop + i] = indent + row;
1482
1559
  });
1483
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
+
1484
1570
  // The version, in the corner, and nothing else on the screen.
1485
1571
  if (VERSION) {
1486
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
@@ -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.