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 +20 -15
- package/package.json +1 -1
- package/src/core/git.js +65 -0
- package/src/ui/activity.js +259 -206
- package/src/ui/plain.js +4 -4
- package/src/ui/screen.js +108 -22
- package/src/ui/theme.js +50 -7
package/README.md
CHANGED
|
@@ -19,32 +19,37 @@ corner:
|
|
|
19
19
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
|
20
20
|
│ › Ask anything… │
|
|
21
21
|
│ │
|
|
22
|
-
│
|
|
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
|
-
|
|
29
|
+
|
|
30
|
+
v1.29.0
|
|
27
31
|
```
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
│
|
|
52
|
+
│ BUILD North Mini Code 4% │
|
|
48
53
|
╰──────────────────────────────────────────────────────────────────────────────────╯
|
|
49
54
|
```
|
|
50
55
|
|
package/package.json
CHANGED
package/src/core/git.js
ADDED
|
@@ -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
|
+
}
|
package/src/ui/activity.js
CHANGED
|
@@ -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
|
|
113
|
-
*
|
|
114
|
-
* The
|
|
115
|
-
*
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
export function
|
|
205
|
-
|
|
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
|
|
136
|
-
const left = ` ${chip}
|
|
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(`${
|
|
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
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
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 -
|
|
673
|
-
['
|
|
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,
|
|
743
|
+
const [label, text] = facts[i] ?? ['', ''];
|
|
682
744
|
const right = label
|
|
683
|
-
? `${dim(label.padEnd(
|
|
684
|
-
: (
|
|
745
|
+
? `${dim(label.padEnd(10))}${chalk.white(text)}`
|
|
746
|
+
: (text ? dim(text) : '');
|
|
685
747
|
return ` ${bannerPaint(i)(art)} ${right}`;
|
|
686
748
|
});
|
|
687
749
|
|
|
688
|
-
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
/**
|
|
365
|
-
|
|
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.
|