ucode-agent 1.26.2 → 1.28.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 +438 -399
- package/package.json +1 -1
- package/skills/build-app/DIGEST.md +94 -0
- package/skills/build-app/SKILL.md +222 -181
- package/skills/ui-ux/DIGEST.md +135 -0
- package/src/core/context.js +164 -151
- package/src/core/loop.js +189 -24
- package/src/core/provider.js +6 -0
- package/src/core/skills.js +189 -165
- package/src/core/window.js +27 -2
- package/src/tools/blocks.js +117 -27
- package/src/tools/index.js +71 -23
- package/src/tools/scaffold.js +104 -14
- package/src/ui/plain.js +358 -351
- package/src/ui/screen.js +1574 -1475
- package/src/ui/theme.js +587 -412
- package/templates/blocks/plain/filter-bar.js +133 -0
- package/templates/blocks/plain/item-list.js +249 -0
- package/templates/blocks/plain/modal.js +141 -0
- package/templates/blocks/plain/store.js +93 -0
- package/templates/blocks/plain/theme-toggle.js +116 -0
- package/templates/blocks/plain/toast.js +107 -0
- package/templates/plain-html/styles.css +4 -0
package/src/ui/theme.js
CHANGED
|
@@ -1,416 +1,515 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* theme.js — colour, boxes, and the string maths that keeps a terminal frame
|
|
3
|
-
* from tearing.
|
|
4
|
-
*
|
|
5
|
-
* Everything visual comes from here so the whole interface can be re-tinted by
|
|
6
|
-
* editing one block. ucode is blue: a single hue, three steps of it, and
|
|
7
|
-
* nothing else decorative. Red, amber and green are reserved — they mean
|
|
8
|
-
* failed, careful, and done, and they never appear for any other reason.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import chalk from 'chalk';
|
|
12
|
-
|
|
13
|
-
// One hue, three weights. Anything that needs a fourth is asking for emphasis
|
|
14
|
-
// it has not earned.
|
|
15
|
-
export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
|
|
16
|
-
export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
|
|
17
|
-
export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
|
|
18
|
-
export const dim = chalk.dim;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The input box's own edge: the same blue, drawn bold.
|
|
22
|
-
*
|
|
23
|
-
* The input is the one thing on screen you act on, so it is the one box that
|
|
24
|
-
* gets the heavier line. Bold box-drawing renders brighter, and in most
|
|
25
|
-
* terminal fonts visibly thicker, which is enough to separate "where you type"
|
|
26
|
-
* from "what you are reading" without a second colour.
|
|
27
|
-
*/
|
|
28
|
-
export const edge = chalk.hex('#4d8dff').bold;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The colour level to use for a stream, or null to leave chalk's guess alone.
|
|
32
|
-
*
|
|
33
|
-
* chalk decides from the environment, and some environments lie: TERM=dumb
|
|
34
|
-
* from an embedding shell, or a wrapper that strips COLORTERM. The result is a
|
|
35
|
-
* UI with every colour silently gone — a grey box where a blue one was drawn.
|
|
36
|
-
*
|
|
37
|
-
* The full-screen interface already depends on a terminal that understands VT
|
|
38
|
-
* sequences — it switches to the alternate screen and moves the cursor — and
|
|
39
|
-
* any terminal that handles those handles colour. So when that interface is
|
|
40
|
-
* running, the guess is overruled. NO_COLOR is still honoured, because that
|
|
41
|
-
* one is a person's explicit choice rather than an environment's accident.
|
|
42
|
-
*/
|
|
43
|
-
export function colourLevel(stream, env = process.env, current = chalk.level) {
|
|
44
|
-
if ('NO_COLOR' in env) return null;
|
|
45
|
-
if (!stream?.isTTY) return null;
|
|
46
|
-
if (current >= 2) return null;
|
|
47
|
-
return env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || process.platform === 'win32' ? 3 : 2;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function ensureColour(stream) {
|
|
51
|
-
const level = colourLevel(stream);
|
|
52
|
-
if (level !== null) chalk.level = level;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export const theme = {
|
|
56
|
-
blue,
|
|
57
|
-
sky,
|
|
58
|
-
deep,
|
|
59
|
-
dim,
|
|
60
|
-
text: chalk.white,
|
|
61
|
-
error: chalk.red,
|
|
62
|
-
warn: chalk.hex('#e0a030'),
|
|
63
|
-
ok: chalk.hex('#3fb950'),
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/** Tints for a diff: enough colour to scan, dim enough to read code through. */
|
|
67
|
-
export const ADDED = chalk.bgHex('#0e2a1a').hex('#7ee2a8');
|
|
68
|
-
export const REMOVED = chalk.bgHex('#331319').hex('#f2939c');
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
export
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
export
|
|
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
|
-
if (
|
|
193
|
-
return
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
*
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
*
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
|
|
1
|
+
/**
|
|
2
|
+
* theme.js — colour, boxes, and the string maths that keeps a terminal frame
|
|
3
|
+
* from tearing.
|
|
4
|
+
*
|
|
5
|
+
* Everything visual comes from here so the whole interface can be re-tinted by
|
|
6
|
+
* editing one block. ucode is blue: a single hue, three steps of it, and
|
|
7
|
+
* nothing else decorative. Red, amber and green are reserved — they mean
|
|
8
|
+
* failed, careful, and done, and they never appear for any other reason.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
|
|
13
|
+
// One hue, three weights. Anything that needs a fourth is asking for emphasis
|
|
14
|
+
// it has not earned.
|
|
15
|
+
export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
|
|
16
|
+
export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
|
|
17
|
+
export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
|
|
18
|
+
export const dim = chalk.dim;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The input box's own edge: the same blue, drawn bold.
|
|
22
|
+
*
|
|
23
|
+
* The input is the one thing on screen you act on, so it is the one box that
|
|
24
|
+
* gets the heavier line. Bold box-drawing renders brighter, and in most
|
|
25
|
+
* terminal fonts visibly thicker, which is enough to separate "where you type"
|
|
26
|
+
* from "what you are reading" without a second colour.
|
|
27
|
+
*/
|
|
28
|
+
export const edge = chalk.hex('#4d8dff').bold;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The colour level to use for a stream, or null to leave chalk's guess alone.
|
|
32
|
+
*
|
|
33
|
+
* chalk decides from the environment, and some environments lie: TERM=dumb
|
|
34
|
+
* from an embedding shell, or a wrapper that strips COLORTERM. The result is a
|
|
35
|
+
* UI with every colour silently gone — a grey box where a blue one was drawn.
|
|
36
|
+
*
|
|
37
|
+
* The full-screen interface already depends on a terminal that understands VT
|
|
38
|
+
* sequences — it switches to the alternate screen and moves the cursor — and
|
|
39
|
+
* any terminal that handles those handles colour. So when that interface is
|
|
40
|
+
* running, the guess is overruled. NO_COLOR is still honoured, because that
|
|
41
|
+
* one is a person's explicit choice rather than an environment's accident.
|
|
42
|
+
*/
|
|
43
|
+
export function colourLevel(stream, env = process.env, current = chalk.level) {
|
|
44
|
+
if ('NO_COLOR' in env) return null;
|
|
45
|
+
if (!stream?.isTTY) return null;
|
|
46
|
+
if (current >= 2) return null;
|
|
47
|
+
return env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || process.platform === 'win32' ? 3 : 2;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ensureColour(stream) {
|
|
51
|
+
const level = colourLevel(stream);
|
|
52
|
+
if (level !== null) chalk.level = level;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const theme = {
|
|
56
|
+
blue,
|
|
57
|
+
sky,
|
|
58
|
+
deep,
|
|
59
|
+
dim,
|
|
60
|
+
text: chalk.white,
|
|
61
|
+
error: chalk.red,
|
|
62
|
+
warn: chalk.hex('#e0a030'),
|
|
63
|
+
ok: chalk.hex('#3fb950'),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Tints for a diff: enough colour to scan, dim enough to read code through. */
|
|
67
|
+
export const ADDED = chalk.bgHex('#0e2a1a').hex('#7ee2a8');
|
|
68
|
+
export const REMOVED = chalk.bgHex('#331319').hex('#f2939c');
|
|
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
|
+
export const BANNER = [
|
|
86
|
+
'██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗',
|
|
87
|
+
'██║ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝',
|
|
88
|
+
'██║ ██║██║ ██║ ██║██║ ██║█████╗ ',
|
|
89
|
+
'██║ ██║██║ ██║ ██║██║ ██║██╔══╝ ',
|
|
90
|
+
'╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗',
|
|
91
|
+
' ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝',
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The wordmark, lit from the top.
|
|
98
|
+
*
|
|
99
|
+
* Six identical rows of one blue read as ASCII art that happened to be lying
|
|
100
|
+
* there. The same six stepped from sky down to deep read as a mark someone
|
|
101
|
+
* drew: the crown catches the light, and the two shadow rows settle back into
|
|
102
|
+
* the page. chalk downshifts the hex to whatever the terminal actually has, so
|
|
103
|
+
* on a 16-colour terminal this is flat blue again rather than nothing.
|
|
104
|
+
*/
|
|
105
|
+
const GRADIENT_TOP = [0x8f, 0xbc, 0xff]; // sky, at the crown
|
|
106
|
+
const GRADIENT_BOTTOM = [0x2f, 0x6f, 0xe0]; // deep, in the shadow
|
|
107
|
+
|
|
108
|
+
export function bannerPaint(row, rows = BANNER.length) {
|
|
109
|
+
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('');
|
|
114
|
+
return chalk.hex(`#${hex}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The rail beside something you said.
|
|
119
|
+
*
|
|
120
|
+
* A box around every user message draws two full-width rules per turn, and a
|
|
121
|
+
* long session becomes a ladder. A half-block in the left column is the same
|
|
122
|
+
* landmark — findable at a glance, scrollable to — for a fortieth of the ink.
|
|
123
|
+
*/
|
|
124
|
+
export const RAIL = '▌';
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The bullet beside the answer.
|
|
128
|
+
*
|
|
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.
|
|
133
|
+
*/
|
|
134
|
+
export const answerMark = () => blue.bold('●');
|
|
135
|
+
|
|
136
|
+
/** The spinner. Braille dots, because they animate in place without jitter. */
|
|
137
|
+
export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Boxes
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export const BOX = {
|
|
144
|
+
topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
|
|
145
|
+
h: '─', v: '│',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const boxTop = (width, paint = blue) =>
|
|
149
|
+
paint(BOX.topLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.topRight);
|
|
150
|
+
|
|
151
|
+
export const boxBottom = (width, paint = blue) =>
|
|
152
|
+
paint(BOX.bottomLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
|
|
153
|
+
|
|
154
|
+
/** One row inside a box, padded so the right border lands in the same column. */
|
|
155
|
+
export const boxRow = (content, width, paint = blue) =>
|
|
156
|
+
paint(BOX.v) + padVis(content, Math.max(0, width - 2)) + paint(BOX.v);
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Widths, with escape codes discounted
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/** The string with its colour codes stripped — what the terminal actually shows. */
|
|
163
|
+
export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
164
|
+
export const visLen = (s) => bare(s).length;
|
|
165
|
+
|
|
166
|
+
/** The first `width` visible characters, with escape sequences left intact. */
|
|
167
|
+
export function sliceVis(s, width) {
|
|
168
|
+
let out = '';
|
|
169
|
+
let seen = 0;
|
|
170
|
+
for (let i = 0; i < s.length; i++) {
|
|
171
|
+
if (s[i] === '\x1b') {
|
|
172
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
173
|
+
if (m) { out += m[0]; i += m[0].length - 1; continue; }
|
|
174
|
+
}
|
|
175
|
+
if (seen >= width) break;
|
|
176
|
+
out += s[i];
|
|
177
|
+
seen++;
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Pad or hard-cut a possibly-coloured string to an exact visible width. */
|
|
183
|
+
export function padVis(s, width) {
|
|
184
|
+
const len = visLen(s);
|
|
185
|
+
if (len === width) return s;
|
|
186
|
+
if (len < width) return s + ' '.repeat(width - len);
|
|
187
|
+
return `${sliceVis(s, width)}\x1b[0m`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function clip(text, max) {
|
|
191
|
+
const s = String(text ?? '');
|
|
192
|
+
if (max <= 1) return '';
|
|
193
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Word-wrap text that may already be coloured.
|
|
198
|
+
*
|
|
199
|
+
* Escape sequences have no width, and whichever styles are open at a break get
|
|
200
|
+
* reopened on the next line — otherwise a wrapped sentence loses its colour
|
|
201
|
+
* halfway through.
|
|
202
|
+
*/
|
|
203
|
+
export function wrapAnsi(text, width) {
|
|
204
|
+
if (width < 4) return [text];
|
|
205
|
+
|
|
206
|
+
const lines = [];
|
|
207
|
+
let line = '';
|
|
208
|
+
let seen = 0;
|
|
209
|
+
let open = '';
|
|
210
|
+
let lastSpace = -1;
|
|
211
|
+
let lastSpaceSeen = 0;
|
|
212
|
+
|
|
213
|
+
const flush = (upto = null) => {
|
|
214
|
+
if (upto === null) {
|
|
215
|
+
lines.push(line);
|
|
216
|
+
line = open;
|
|
217
|
+
seen = 0;
|
|
218
|
+
} else {
|
|
219
|
+
lines.push(line.slice(0, upto));
|
|
220
|
+
const carry = line.slice(upto).replace(/^ +/, '');
|
|
221
|
+
line = open + carry;
|
|
222
|
+
seen = visLen(carry);
|
|
223
|
+
}
|
|
224
|
+
lastSpace = -1;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
for (let i = 0; i < text.length; i++) {
|
|
228
|
+
if (text[i] === '\x1b') {
|
|
229
|
+
const m = /^\x1b\[[0-9;]*m/.exec(text.slice(i));
|
|
230
|
+
if (m) {
|
|
231
|
+
line += m[0];
|
|
232
|
+
open = m[0] === '\x1b[0m' ? '' : open + m[0];
|
|
233
|
+
i += m[0].length - 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
|
|
238
|
+
line += text[i];
|
|
239
|
+
seen++;
|
|
240
|
+
if (seen >= width) {
|
|
241
|
+
// Break at a word boundary unless that would leave a stub behind.
|
|
242
|
+
if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
|
|
243
|
+
else flush();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (visLen(line)) lines.push(line);
|
|
248
|
+
return lines.length ? lines : [''];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Small formatters
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
export function formatTokens(n) {
|
|
256
|
+
if (!n) return '0';
|
|
257
|
+
if (n < 1000) return String(n);
|
|
258
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
|
259
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function today() {
|
|
263
|
+
return new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Shorten a path for display: home becomes ~, a long middle collapses. */
|
|
267
|
+
export function shortenPath(p, max = 40) {
|
|
268
|
+
let out = String(p);
|
|
269
|
+
const home = process.env.USERPROFILE || process.env.HOME || '';
|
|
270
|
+
if (home && out.startsWith(home)) out = `~${out.slice(home.length)}`;
|
|
271
|
+
if (out.length <= max) return out;
|
|
272
|
+
|
|
273
|
+
const parts = out.split(/[\\/]/);
|
|
274
|
+
if (parts.length <= 3) return `…${out.slice(-(max - 1))}`;
|
|
275
|
+
const sep = out.includes('\\') ? '\\' : '/';
|
|
276
|
+
return `${parts[0]}${sep}…${sep}${parts.slice(-2).join(sep)}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function relativeTime(iso) {
|
|
280
|
+
if (!iso) return 'unknown';
|
|
281
|
+
const then = new Date(iso).getTime();
|
|
282
|
+
if (Number.isNaN(then)) return 'unknown';
|
|
283
|
+
|
|
284
|
+
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
285
|
+
if (secs < 60) return 'just now';
|
|
286
|
+
const mins = Math.round(secs / 60);
|
|
287
|
+
if (mins < 60) return `${mins}m ago`;
|
|
288
|
+
const hours = Math.round(mins / 60);
|
|
289
|
+
if (hours < 24) return `${hours}h ago`;
|
|
290
|
+
const days = Math.round(hours / 24);
|
|
291
|
+
return days < 30 ? `${days}d ago` : new Date(iso).toISOString().slice(0, 10);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Trim a trailing full stop off a live status line.
|
|
296
|
+
*
|
|
297
|
+
* "Listing src" is a label on work in progress. "Listing src." is a sentence,
|
|
298
|
+
* and a sentence that ends while the thing it describes is still happening
|
|
299
|
+
* reads as finished when it is not. Models add the full stop by habit; this
|
|
300
|
+
* takes it back off.
|
|
301
|
+
*
|
|
302
|
+
* Only after a word, though. A dot that follows a space is the whole point of
|
|
303
|
+
* the line — "Listing ." names the current directory — and trimming that turns
|
|
304
|
+
* a label into a fragment.
|
|
305
|
+
*/
|
|
306
|
+
export function asLabel(text) {
|
|
307
|
+
return String(text ?? '')
|
|
308
|
+
.trim()
|
|
309
|
+
.replace(/\s+/g, ' ')
|
|
310
|
+
// A trailing stop, from a model's sentence or a tool's own output
|
|
311
|
+
// ("Building…", "Completing…"), is noise on a one-line label. A dot that
|
|
312
|
+
// is the argument itself — "Listing ." — is not, so a word has to come
|
|
313
|
+
// before it.
|
|
314
|
+
.replace(/(?<=[\w)\]"'`])[.。…]+$/, '');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The model's checklist, as one short line — done ticked, the current item
|
|
319
|
+
* marked, the rest dim — so progress is visible without taking over the screen.
|
|
320
|
+
*/
|
|
321
|
+
/**
|
|
322
|
+
* The plan, as a block rather than a sentence.
|
|
323
|
+
*
|
|
324
|
+
* Six steps joined with separators made one line far wider than any terminal,
|
|
325
|
+
* so it wrapped — and a wrapped checklist has its ticks in the middle of the
|
|
326
|
+
* text, which is unreadable. Down the page each step keeps its own row, its
|
|
327
|
+
* mark stays in the left column, and the eye can find the one in progress
|
|
328
|
+
* without reading any of the others.
|
|
329
|
+
*
|
|
330
|
+
* Returns the rows; the caller pushes them.
|
|
331
|
+
*/
|
|
332
|
+
/**
|
|
333
|
+
* How far along, as a bar rather than as arithmetic.
|
|
334
|
+
*
|
|
335
|
+
* "2/4" is a sum the reader has to do; a bar is the answer to it, read at a
|
|
336
|
+
* glance. Ten cells whatever the plan's length, so the row does not change
|
|
337
|
+
* width as steps are added and the eye keeps one edge to measure against.
|
|
338
|
+
*
|
|
339
|
+
* Heavy and light box-drawing, not block shading: those two are already the
|
|
340
|
+
* frame of every box on screen, so they are the two glyphs this app can be
|
|
341
|
+
* certain the terminal has and draws one cell wide.
|
|
342
|
+
*/
|
|
343
|
+
export const BAR_CELLS = 10;
|
|
344
|
+
|
|
345
|
+
export function progressBar(done, total, cells = BAR_CELLS) {
|
|
346
|
+
const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 0;
|
|
347
|
+
const fill = Math.round(ratio * cells);
|
|
348
|
+
return blue('━'.repeat(fill)) + dim('─'.repeat(Math.max(0, cells - fill)));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function planRows(items) {
|
|
352
|
+
const list = (Array.isArray(items) ? items : []).slice(0, 8);
|
|
353
|
+
if (!list.length) return [];
|
|
354
|
+
const done = list.filter((i) => i?.done).length;
|
|
355
|
+
const current = list.findIndex((i) => !i?.done);
|
|
356
|
+
|
|
357
|
+
const rows = [` ${progressBar(done, list.length)} ${sky(`${done}/${list.length}`)}`];
|
|
358
|
+
list.forEach((item, i) => {
|
|
359
|
+
const text = clip(String(item?.text ?? '').trim(), 64);
|
|
360
|
+
if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
|
|
361
|
+
else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
|
|
362
|
+
else rows.push(` ${dim('○')} ${dim(text)}`);
|
|
363
|
+
});
|
|
364
|
+
return rows;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Kept for the plain interface, which has one line to work with. */
|
|
368
|
+
export function planLine(items) {
|
|
369
|
+
const list = (Array.isArray(items) ? items : []).slice(0, 6);
|
|
370
|
+
if (!list.length) return '';
|
|
371
|
+
const done = list.filter((i) => i?.done).length;
|
|
372
|
+
const current = list.findIndex((i) => !i?.done);
|
|
373
|
+
const now = current === -1 ? 'done' : clip(String(list[current]?.text ?? '').trim(), 40);
|
|
374
|
+
return ` ${sky(`plan ${done}/${list.length}`)} ${chalk.white(now)}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Narration: what the agent is doing, as opposed to what it has to say.
|
|
379
|
+
*
|
|
380
|
+
* These lines are scaffolding — "Reading screen.js", "Checking types". They
|
|
381
|
+
* are worth seeing and not worth reading, and at full strength they compete
|
|
382
|
+
* with the answer, which is the thing the user is actually here for. A
|
|
383
|
+
* terminal has no smaller size to set, so the only axis available is weight:
|
|
384
|
+
* faint, and a step down in colour. The answer stays at full strength and
|
|
385
|
+
* wins the page by contrast rather than by shouting.
|
|
386
|
+
*/
|
|
387
|
+
export const narration = (text) => chalk.dim(text);
|
|
388
|
+
|
|
389
|
+
/** The bullet beside a narration line: present, not loud. */
|
|
390
|
+
export const narrationMark = () => chalk.dim(deep('●'));
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The file or command a step is about, lit so the line can be scanned.
|
|
394
|
+
*
|
|
395
|
+
* "Which file did it touch" is the one question a reader puts to a transcript
|
|
396
|
+
* of tool calls, and dimming the whole line made the answer as faint as the
|
|
397
|
+
* verb in front of it. The verb stays faint — there are only a dozen of them
|
|
398
|
+
* and they repeat — and the part that differs every time carries the colour.
|
|
399
|
+
*/
|
|
400
|
+
const paintStep = (label) => {
|
|
401
|
+
const text = String(label ?? '');
|
|
402
|
+
const space = text.indexOf(' ');
|
|
403
|
+
if (space < 0) return narration(text);
|
|
404
|
+
|
|
405
|
+
const target = text.slice(space + 1);
|
|
406
|
+
// "Read 2 files" is a tally, not a path. Lighting it up would point the eye
|
|
407
|
+
// at a number that says nothing about where the work happened.
|
|
408
|
+
if (/^\d/.test(target)) return narration(text);
|
|
409
|
+
|
|
410
|
+
return `${narration(text.slice(0, space))} ${blue(target)}`;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* How a run of the same kind of step reads once it is over.
|
|
415
|
+
*
|
|
416
|
+
* While it happens, "Running npm test" is the useful thing to show. Once
|
|
417
|
+
* three of them have happened, three near-identical lines are just noise
|
|
418
|
+
* between the reader and the answer, so they fold into one: "Ran 3 commands".
|
|
419
|
+
* The present tense belongs to the thing happening now; the past tense to the
|
|
420
|
+
* summary of what did.
|
|
421
|
+
*/
|
|
422
|
+
const GROUPS = {
|
|
423
|
+
Running: ['Ran', 'command', 'commands'],
|
|
424
|
+
Reading: ['Read', 'file', 'files'],
|
|
425
|
+
Searching: ['Searched', 'time', 'times'],
|
|
426
|
+
Finding: ['Found', 'pattern', 'patterns'],
|
|
427
|
+
Listing: ['Listed', 'directory', 'directories'],
|
|
428
|
+
Writing: ['Wrote', 'file', 'files'],
|
|
429
|
+
Editing: ['Edited', 'file', 'files'],
|
|
430
|
+
Checking: ['Checked', 'thing', 'things'],
|
|
431
|
+
Looking: ['Looked up', 'name', 'names'],
|
|
432
|
+
Asking: ['Asked about', 'name', 'names'],
|
|
433
|
+
Mapping: ['Mapped', 'folder', 'folders'],
|
|
434
|
+
Adding: ['Added', 'block', 'blocks'],
|
|
435
|
+
Renaming: ['Renamed', 'name', 'names'],
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/** The first word of a label, which is what decides whether two steps match. */
|
|
439
|
+
export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
|
|
440
|
+
|
|
441
|
+
/** One line standing in for `count` steps that all began with the same word. */
|
|
442
|
+
export function groupLabel(label, count) {
|
|
443
|
+
if (count <= 1) return String(label ?? '');
|
|
444
|
+
const g = GROUPS[groupKind(label)];
|
|
445
|
+
if (!g) return `${label} (+${count - 1} more)`;
|
|
446
|
+
const [past, one, many] = g;
|
|
447
|
+
return `${past} ${count} ${count === 1 ? one : many}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The part of a label after its opening word: the file or command it is about. */
|
|
451
|
+
export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* One narration line, standing for everything that happened under it.
|
|
455
|
+
*
|
|
456
|
+
* The transcript is a record of what was done, not a copy of what was
|
|
457
|
+
* written. A 539-line file printed into it buries the answer and tells the
|
|
458
|
+
* reader nothing they could not get from the file itself, so a change is its
|
|
459
|
+
* two numbers. Several steps on one file stay one line naming that file;
|
|
460
|
+
* several files become a count.
|
|
461
|
+
*
|
|
462
|
+
* The line comes back painted, so the label handed in has to be through
|
|
463
|
+
* asLabel() already: run that over this and its regexes would be reading
|
|
464
|
+
* escape sequences instead of the last word.
|
|
465
|
+
*/
|
|
466
|
+
export function runLine({ label, count = 1, targets = [], added = 0, removed = 0 }) {
|
|
467
|
+
const counts = added || removed
|
|
468
|
+
? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
|
|
469
|
+
: '';
|
|
470
|
+
if (count <= 1) return `${paintStep(label)}${counts}`;
|
|
471
|
+
|
|
472
|
+
const g = GROUPS[groupKind(label)];
|
|
473
|
+
const unique = [...new Set(targets.filter(Boolean))];
|
|
474
|
+
if (g && unique.length === 1) return `${paintStep(`${g[0]} ${unique[0]}`)}${counts}`;
|
|
475
|
+
if (!g) return `${paintStep(`${label} (+${count - 1} more)`)}${counts}`;
|
|
476
|
+
return `${paintStep(`${g[0]} ${count} ${count === 1 ? g[1] : g[2]}`)}${counts}`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The reply, with any pasted code taken out of it.
|
|
481
|
+
*
|
|
482
|
+
* The model is asked not to paste code into its answer, and mostly does not.
|
|
483
|
+
* When it does, a fenced block of forty lines pushes the two sentences worth
|
|
484
|
+
* reading off the screen — and the code is already in the file it just wrote.
|
|
485
|
+
* A fence becomes a note of what it was, and the prose stays.
|
|
486
|
+
*
|
|
487
|
+
* A short block is left alone: three lines showing a command to run, or the
|
|
488
|
+
* one line that changed, is the kind of thing worth having in the answer.
|
|
489
|
+
*/
|
|
490
|
+
/**
|
|
491
|
+
* A sentence that exists only to introduce what comes next.
|
|
492
|
+
*
|
|
493
|
+
* "Here's the complete app:" followed by forty lines of code, with the code
|
|
494
|
+
* taken out, is a colon pointing at nothing — which reads as the reply having
|
|
495
|
+
* been cut off mid-thought. The lead-in goes with what it was leading to.
|
|
496
|
+
*/
|
|
398
497
|
const LEAD_IN = /(?:^|\n)[^\n]{0,80}:[ \t]*\n+$/;
|
|
399
|
-
|
|
400
|
-
export function withoutCodeBlocks(text, keepLines = 4) {
|
|
401
|
-
const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
|
|
402
|
-
return String(text ?? '').replace(FENCE, (all, lang, body) => {
|
|
403
|
-
const lines = body.replace(/\n+$/, '').split('\n');
|
|
404
|
-
if (lines.length <= keepLines) return all;
|
|
405
|
-
const what = lang ? `${lang} ` : '';
|
|
406
|
-
return `_[${lines.length} lines of ${what}code — it is in the file, not worth repeating here]_`;
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* The reply as it should be read: no pasted code, and no sentence left
|
|
412
|
-
* pointing at code that is no longer there.
|
|
413
|
-
*/
|
|
498
|
+
|
|
499
|
+
export function withoutCodeBlocks(text, keepLines = 4) {
|
|
500
|
+
const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
|
|
501
|
+
return String(text ?? '').replace(FENCE, (all, lang, body) => {
|
|
502
|
+
const lines = body.replace(/\n+$/, '').split('\n');
|
|
503
|
+
if (lines.length <= keepLines) return all;
|
|
504
|
+
const what = lang ? `${lang} ` : '';
|
|
505
|
+
return `_[${lines.length} lines of ${what}code — it is in the file, not worth repeating here]_`;
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The reply as it should be read: no pasted code, and no sentence left
|
|
511
|
+
* pointing at code that is no longer there.
|
|
512
|
+
*/
|
|
414
513
|
/**
|
|
415
514
|
* The reply as it should be read.
|
|
416
515
|
*
|
|
@@ -434,3 +533,79 @@ export function tidyReply(text, keepLines = 4) {
|
|
|
434
533
|
.replace(new RegExp("\\n{3,}", "g"), "\n\n")
|
|
435
534
|
.trim();
|
|
436
535
|
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The closing message, cut to what a terminal can take.
|
|
539
|
+
*
|
|
540
|
+
* A model that finishes a build by walking back through the request — every
|
|
541
|
+
* feature ticked off, every file listed — leaves that as the last thing on
|
|
542
|
+
* screen, and the whole session then reads like a status report. Eight lines
|
|
543
|
+
* is the whole of it: what it is, and how to try it.
|
|
544
|
+
*
|
|
545
|
+
* What goes: an opening that reads the request back, and the middle of a list
|
|
546
|
+
* too long to be worth reading. What stays: the first lines, the line that
|
|
547
|
+
* admits something is unfinished, and the line naming a file or a command —
|
|
548
|
+
* the two the user actually acts on, and both of them live at the end.
|
|
549
|
+
*/
|
|
550
|
+
export const ANSWER_LINES = 8;
|
|
551
|
+
const ANSWER_ROOM = 600; // eight wrapped lines of prose, for a reply with no line breaks in it
|
|
552
|
+
|
|
553
|
+
const RESTATED = /^(?:you (?:asked|wanted|requested|said)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
|
|
554
|
+
const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did not|isn't|is not|doesn't|does not|not (?:yet|wired|working|done|implemented)|missing|unfinished|except)\b/i;
|
|
555
|
+
const ACTIONABLE = /\b(?:open|run|serve|visit|try|start|npm|npx|node|pnpm|yarn)\b|https?:\/\/|\.(?:html?|css|jsx?|tsx?|md|json|py|rs|go)\b/i;
|
|
556
|
+
const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
|
|
557
|
+
|
|
558
|
+
export function trimAnswer(text, max = ANSWER_LINES) {
|
|
559
|
+
const all = String(text ?? '').replace(/\r/g, '').split('\n');
|
|
560
|
+
|
|
561
|
+
let start = 0;
|
|
562
|
+
while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;
|
|
563
|
+
const rows = all.slice(start);
|
|
564
|
+
|
|
565
|
+
const body = rows.map((row, i) => ({ row, i })).filter((r) => r.row.trim());
|
|
566
|
+
if (!body.length) return '';
|
|
567
|
+
|
|
568
|
+
let kept;
|
|
569
|
+
if (body.length <= max) {
|
|
570
|
+
kept = body.map((r) => r.i);
|
|
571
|
+
} else {
|
|
572
|
+
// Searched from the end: the caveat and the how-to-try-it line are the
|
|
573
|
+
// last things written, and they are the two worth pulling out of the part
|
|
574
|
+
// being dropped.
|
|
575
|
+
const tail = body.slice(Math.max(1, max - 2));
|
|
576
|
+
const pick = (re) => [...tail].reverse().find((r) => re.test(r.row))?.i;
|
|
577
|
+
const rescued = [...new Set([pick(CAVEAT), pick(ACTIONABLE)])].filter((i) => i !== undefined);
|
|
578
|
+
const head = body.slice(0, max - rescued.length).map((r) => r.i);
|
|
579
|
+
kept = [...new Set([...head, ...rescued])].sort((a, b) => a - b);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const out = [];
|
|
583
|
+
let previous = -1;
|
|
584
|
+
for (const i of kept) {
|
|
585
|
+
if (previous >= 0 && i > previous + 1) out.push(''); // a gap in the middle is a paragraph break
|
|
586
|
+
// A line lifted out of a list is no longer in one.
|
|
587
|
+
out.push(previous >= 0 && i > previous + 1 ? rows[i].replace(BULLET, '') : rows[i]);
|
|
588
|
+
previous = i;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return withinRoom(out.join('\n').replace(/\n{3,}/g, '\n\n').trim());
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* One long paragraph is one line and fills the screen anyway. Whole sentences
|
|
596
|
+
* only: a reply cut mid-clause reads as a crash rather than as an ending.
|
|
597
|
+
*/
|
|
598
|
+
function withinRoom(text, room = ANSWER_ROOM) {
|
|
599
|
+
if (text.length <= room) return text;
|
|
600
|
+
|
|
601
|
+
const parts = text.split(/(?<=[.!?])(\s+)/);
|
|
602
|
+
let out = '';
|
|
603
|
+
let sentences = 0;
|
|
604
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
605
|
+
const next = out + parts[i] + (parts[i + 1] ?? '');
|
|
606
|
+
if (sentences >= 2 && next.trimEnd().length > room) break;
|
|
607
|
+
out = next;
|
|
608
|
+
sentences++;
|
|
609
|
+
}
|
|
610
|
+
return (out.trim() || text.slice(0, room)).trim();
|
|
611
|
+
}
|