@fx-/chat-ink 0.0.0-stage → 0.1.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/dist/images.d-KUBzdbd9.d.ts +383 -0
- package/dist/index.d.ts +806 -0
- package/dist/index.js +3918 -0
- package/dist/kitty-jpeg.d.ts +26 -0
- package/dist/kitty-jpeg.js +84 -0
- package/dist/kitty.d.ts +86 -0
- package/dist/kitty.js +704 -0
- package/package.json +71 -4
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,3918 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import { createCoreHooks } from "@fx-/chat-core/hooks";
|
|
3
|
+
import { approxTokenCount, countActivity, earlierLabel, formatClockTime, formatDuration, formatElapsed, formatTokensPerSecond, getBlockTimelineBehavior, isBlockActive, isCoreBlockType, livePhase, lookupExtension, planFooter, planGroupHeader, planMessage, positiveTimestamp, shouldShowTimer, statusLabel, summarizeBlock, toolDisplayInput, toolHeadline, toolLabel, toolResultText } from "@fx-/chat-core";
|
|
4
|
+
import { layoutJson, looksLikeJson, parseLooseJson } from "@fx-/chat-core/json";
|
|
5
|
+
import { Buffer } from "node:buffer";
|
|
6
|
+
|
|
7
|
+
//#region src/lines/measure.ts
|
|
8
|
+
const segmenter = new Intl.Segmenter();
|
|
9
|
+
/**
|
|
10
|
+
* `width`'s results (default: `string-width`, which Ink measures with), with
|
|
11
|
+
* each distinct grapheme cluster measured once. The package sums independent
|
|
12
|
+
* per-cluster widths, so caching changes no result; on Bun its emoji check is
|
|
13
|
+
* pathologically slow and the same few clusters are measured over and over.
|
|
14
|
+
* Text with ANSI escapes goes to `width` as it is (stripping spans clusters).
|
|
15
|
+
* The cache is bounded and starts over when full.
|
|
16
|
+
*/
|
|
17
|
+
function cachedMeasure(width$2 = stringWidth, limit = 4096) {
|
|
18
|
+
const cache = new Map();
|
|
19
|
+
return (text) => {
|
|
20
|
+
if (/^[\u0020-\u007E]*$/.test(text)) return text.length;
|
|
21
|
+
if (text.includes("\x1B") || text.includes("")) return width$2(text);
|
|
22
|
+
let total = 0;
|
|
23
|
+
for (const { segment } of segmenter.segment(text)) {
|
|
24
|
+
let cluster = cache.get(segment);
|
|
25
|
+
if (cluster === void 0) {
|
|
26
|
+
cluster = width$2(segment);
|
|
27
|
+
if (cache.size >= limit) cache.clear();
|
|
28
|
+
cache.set(segment, cluster);
|
|
29
|
+
}
|
|
30
|
+
total += cluster;
|
|
31
|
+
}
|
|
32
|
+
return total;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const defaultMeasure = cachedMeasure();
|
|
36
|
+
/** Grapheme clusters of `text` (so a wide or combined character is never split). */
|
|
37
|
+
function graphemes(text) {
|
|
38
|
+
return Array.from(segmenter.segment(text), (s) => s.segment);
|
|
39
|
+
}
|
|
40
|
+
/** The first grapheme cluster of `text` ('' when empty), segmenting no further. */
|
|
41
|
+
function firstGrapheme(text) {
|
|
42
|
+
for (const { segment } of segmenter.segment(text)) return segment;
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
/** Cut `text` to at most `cells`, ending with `…` when cut. */
|
|
46
|
+
function truncate(text, cells$1, measure = defaultMeasure) {
|
|
47
|
+
if (measure(text) <= cells$1) return text;
|
|
48
|
+
if (cells$1 <= 0) return "";
|
|
49
|
+
let out = "";
|
|
50
|
+
let width$2 = 0;
|
|
51
|
+
for (const g of graphemes(text)) {
|
|
52
|
+
const w = measure(g);
|
|
53
|
+
if (width$2 + w > cells$1 - 1) break;
|
|
54
|
+
out += g;
|
|
55
|
+
width$2 += w;
|
|
56
|
+
}
|
|
57
|
+
return `${out}…`;
|
|
58
|
+
}
|
|
59
|
+
/** Split `text` into pieces of at most `cells` each, never inside a grapheme. */
|
|
60
|
+
function hardSplit(text, cells$1, measure = defaultMeasure) {
|
|
61
|
+
const pieces = [];
|
|
62
|
+
let current = "";
|
|
63
|
+
let width$2 = 0;
|
|
64
|
+
for (const g of graphemes(text)) {
|
|
65
|
+
const w = measure(g);
|
|
66
|
+
if (width$2 + w > cells$1 && current) {
|
|
67
|
+
pieces.push(current);
|
|
68
|
+
current = "";
|
|
69
|
+
width$2 = 0;
|
|
70
|
+
}
|
|
71
|
+
current += g;
|
|
72
|
+
width$2 += w;
|
|
73
|
+
}
|
|
74
|
+
if (current || pieces.length === 0) pieces.push(current);
|
|
75
|
+
return pieces;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Wrap one paragraph (no newlines) to `width` cells at spaces. A word longer
|
|
79
|
+
* than the line is split with a `hard` join; other continuation rows are
|
|
80
|
+
* `soft` (a copy rejoins them with a space).
|
|
81
|
+
*/
|
|
82
|
+
function wrapText(text, width$2, measure = defaultMeasure) {
|
|
83
|
+
const max = Math.max(1, width$2);
|
|
84
|
+
const words$1 = text.split(" ");
|
|
85
|
+
const rows = [];
|
|
86
|
+
let current = "";
|
|
87
|
+
let currentWidth = 0;
|
|
88
|
+
let join;
|
|
89
|
+
const push = (next) => {
|
|
90
|
+
rows.push(join ? {
|
|
91
|
+
text: current,
|
|
92
|
+
wrap: join
|
|
93
|
+
} : { text: current });
|
|
94
|
+
current = "";
|
|
95
|
+
currentWidth = 0;
|
|
96
|
+
join = next;
|
|
97
|
+
};
|
|
98
|
+
for (const word of words$1) {
|
|
99
|
+
const w = measure(word);
|
|
100
|
+
const gap = current ? 1 : 0;
|
|
101
|
+
if (current && currentWidth + gap + w > max) push("soft");
|
|
102
|
+
if (w > max) {
|
|
103
|
+
const pieces = hardSplit(word, max, measure);
|
|
104
|
+
pieces.forEach((piece, i) => {
|
|
105
|
+
if (i > 0) push("hard");
|
|
106
|
+
current = piece;
|
|
107
|
+
currentWidth = measure(piece);
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
current = current ? `${current} ${word}` : word;
|
|
112
|
+
currentWidth += (current === word ? 0 : 1) + w;
|
|
113
|
+
}
|
|
114
|
+
push(void 0);
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
/** Wrap text with newlines: each line wrapped on its own (hard line breaks stay). */
|
|
118
|
+
function wrapLines(text, width$2, measure = defaultMeasure) {
|
|
119
|
+
return text.split("\n").flatMap((line) => wrapText(line, width$2, measure));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/editor.ts
|
|
124
|
+
const emptyEditor = {
|
|
125
|
+
value: "",
|
|
126
|
+
caret: 0
|
|
127
|
+
};
|
|
128
|
+
const clamp = (n, max) => Math.max(0, Math.min(n, max));
|
|
129
|
+
/** Start and end offsets of the line containing `caret`. */
|
|
130
|
+
function lineBounds(value, caret) {
|
|
131
|
+
const start = value.lastIndexOf("\n", caret - 1) + 1;
|
|
132
|
+
const nl = value.indexOf("\n", caret);
|
|
133
|
+
return [start, nl === -1 ? value.length : nl];
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The grapheme boundaries of the line holding `at`, both line ends included.
|
|
137
|
+
* A newline always separates graphemes, so only the one line is segmented.
|
|
138
|
+
*/
|
|
139
|
+
function boundaries(value, at) {
|
|
140
|
+
const [start, end] = lineBounds(value, at);
|
|
141
|
+
const out = [start];
|
|
142
|
+
let offset = start;
|
|
143
|
+
for (const g of graphemes(value.slice(start, end))) {
|
|
144
|
+
offset += g.length;
|
|
145
|
+
out.push(offset);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
/** `caret` clamped to the value and moved back to the start of the grapheme it is inside. */
|
|
150
|
+
function caretBoundary(value, caret) {
|
|
151
|
+
const at = clamp(caret, value.length);
|
|
152
|
+
return lastBelow(boundaries(value, at), at + 1);
|
|
153
|
+
}
|
|
154
|
+
/** The last of ascending `list` (which starts below `limit`) that is below `limit`. */
|
|
155
|
+
function lastBelow(list$1, limit) {
|
|
156
|
+
let found = list$1[0];
|
|
157
|
+
for (const b of list$1) if (b < limit) found = b;
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
/** The boundary one grapheme before `caret` (a boundary), or 0. */
|
|
161
|
+
function before(value, caret) {
|
|
162
|
+
if (caret === 0) return 0;
|
|
163
|
+
if (value[caret - 1] === "\n") return caret - 1;
|
|
164
|
+
return lastBelow(boundaries(value, caret), caret);
|
|
165
|
+
}
|
|
166
|
+
/** The boundary one grapheme after `caret` (a boundary), or the end. */
|
|
167
|
+
function after(value, caret) {
|
|
168
|
+
if (caret >= value.length) return value.length;
|
|
169
|
+
if (value[caret] === "\n") return caret + 1;
|
|
170
|
+
return boundaries(value, caret).find((b) => b > caret);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Split `text` at a caret for drawing: before it, the grapheme under it ('' at
|
|
174
|
+
* the end) and after it. The grapheme is whole, so the caret cell covers all
|
|
175
|
+
* of it (two cells for a wide one) and no character is split around it.
|
|
176
|
+
*/
|
|
177
|
+
function splitAtCaret(text, caret) {
|
|
178
|
+
const at = caretBoundary(text, caret);
|
|
179
|
+
const under = firstGrapheme(text.slice(at));
|
|
180
|
+
return [
|
|
181
|
+
text.slice(0, at),
|
|
182
|
+
under,
|
|
183
|
+
text.slice(at + under.length)
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
const snapped = (state) => {
|
|
187
|
+
const caret = caretBoundary(state.value, state.caret);
|
|
188
|
+
return caret === state.caret ? state : {
|
|
189
|
+
...state,
|
|
190
|
+
caret
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Insert at the caret. Text that joins the grapheme after it (a combining
|
|
195
|
+
* mark's base, half a flag) leaves the caret after the joined grapheme.
|
|
196
|
+
*/
|
|
197
|
+
function insert(state, text) {
|
|
198
|
+
const { caret } = snapped(state);
|
|
199
|
+
const value = state.value.slice(0, caret) + text + state.value.slice(caret);
|
|
200
|
+
const end = caret + text.length;
|
|
201
|
+
const at = caretBoundary(value, end);
|
|
202
|
+
return {
|
|
203
|
+
value,
|
|
204
|
+
caret: at === end ? end : after(value, at)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function backspace(state) {
|
|
208
|
+
const { caret } = snapped(state);
|
|
209
|
+
if (caret === 0) return {
|
|
210
|
+
...state,
|
|
211
|
+
caret
|
|
212
|
+
};
|
|
213
|
+
const start = before(state.value, caret);
|
|
214
|
+
return {
|
|
215
|
+
value: state.value.slice(0, start) + state.value.slice(caret),
|
|
216
|
+
caret: start
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function deleteForward(state) {
|
|
220
|
+
const { caret } = snapped(state);
|
|
221
|
+
const end = after(state.value, caret);
|
|
222
|
+
return {
|
|
223
|
+
value: state.value.slice(0, caret) + state.value.slice(end),
|
|
224
|
+
caret
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** Move the caret `delta` graphemes (negative: left), stopping at the ends. */
|
|
228
|
+
function moveBy(state, delta) {
|
|
229
|
+
let { caret } = snapped(state);
|
|
230
|
+
const step = delta < 0 ? before : after;
|
|
231
|
+
for (let n = Math.abs(delta); n > 0; n--) {
|
|
232
|
+
const next = step(state.value, caret);
|
|
233
|
+
if (next === caret) break;
|
|
234
|
+
caret = next;
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
...state,
|
|
238
|
+
caret
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function lineStart(state) {
|
|
242
|
+
return {
|
|
243
|
+
...state,
|
|
244
|
+
caret: lineBounds(state.value, clamp(state.caret, state.value.length))[0]
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function lineEnd(state) {
|
|
248
|
+
return {
|
|
249
|
+
...state,
|
|
250
|
+
caret: lineBounds(state.value, clamp(state.caret, state.value.length))[1]
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** Delete from the line start to the caret. */
|
|
254
|
+
function killToLineStart(state) {
|
|
255
|
+
const { caret } = snapped(state);
|
|
256
|
+
const [start] = lineBounds(state.value, caret);
|
|
257
|
+
return {
|
|
258
|
+
value: state.value.slice(0, start) + state.value.slice(caret),
|
|
259
|
+
caret: start
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** Delete from the caret to the line end. */
|
|
263
|
+
function killToLineEnd(state) {
|
|
264
|
+
const { caret } = snapped(state);
|
|
265
|
+
const [, end] = lineBounds(state.value, caret);
|
|
266
|
+
return {
|
|
267
|
+
value: state.value.slice(0, caret) + state.value.slice(end),
|
|
268
|
+
caret
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const blank$2 = (g) => /^\s+$/.test(g);
|
|
272
|
+
/** The start of the whitespace-separated word before `caret` (skipping whitespace first). */
|
|
273
|
+
function wordStart(value, caret) {
|
|
274
|
+
let i = caret;
|
|
275
|
+
const skip = (word) => {
|
|
276
|
+
while (i > 0) {
|
|
277
|
+
const prev = before(value, i);
|
|
278
|
+
if (blank$2(value.slice(prev, i)) === word) return;
|
|
279
|
+
i = prev;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
skip(false);
|
|
283
|
+
skip(true);
|
|
284
|
+
return i;
|
|
285
|
+
}
|
|
286
|
+
/** Delete the word before the caret (and the whitespace after it). */
|
|
287
|
+
function deleteWordBack(state) {
|
|
288
|
+
const { caret } = snapped(state);
|
|
289
|
+
const i = wordStart(state.value, caret);
|
|
290
|
+
return {
|
|
291
|
+
value: state.value.slice(0, i) + state.value.slice(caret),
|
|
292
|
+
caret: i
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Move the caret one line up (`-1`) or down (`+1`), keeping its cell column
|
|
297
|
+
* where the target line is long enough. Returns `undefined` at the first/last
|
|
298
|
+
* line, so the caller can fall through to history navigation.
|
|
299
|
+
*/
|
|
300
|
+
function moveLine(state, direction, measure = defaultMeasure) {
|
|
301
|
+
return moveVisual(state, visualRows(state.value), direction, measure);
|
|
302
|
+
}
|
|
303
|
+
/** Split into lines, each with the caret column (an offset into the line) when the caret is on it. */
|
|
304
|
+
function caretLines(state) {
|
|
305
|
+
const { caret } = snapped(state);
|
|
306
|
+
const out = [];
|
|
307
|
+
let offset = 0;
|
|
308
|
+
for (const text of state.value.split("\n")) {
|
|
309
|
+
const end = offset + text.length;
|
|
310
|
+
out.push(caret >= offset && caret <= end ? {
|
|
311
|
+
text,
|
|
312
|
+
caret: caret - offset
|
|
313
|
+
} : { text });
|
|
314
|
+
offset = end + 1;
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
/** Jump to the start of the previous whitespace-separated word. */
|
|
319
|
+
function wordLeft(state) {
|
|
320
|
+
return {
|
|
321
|
+
...state,
|
|
322
|
+
caret: wordStart(state.value, snapped(state).caret)
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/** Jump past the end of the next whitespace-separated word. */
|
|
326
|
+
function wordRight(state) {
|
|
327
|
+
const { value } = state;
|
|
328
|
+
let i = snapped(state).caret;
|
|
329
|
+
const skip = (word) => {
|
|
330
|
+
while (i < value.length) {
|
|
331
|
+
const next = after(value, i);
|
|
332
|
+
if (blank$2(value.slice(i, next)) === word) return;
|
|
333
|
+
i = next;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
skip(false);
|
|
337
|
+
skip(true);
|
|
338
|
+
return {
|
|
339
|
+
...state,
|
|
340
|
+
caret: i
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Lay the value out in rows: at newlines, and (with a width) wherever the
|
|
345
|
+
* next grapheme would pass `width` cells — never inside a wide character.
|
|
346
|
+
*/
|
|
347
|
+
function visualRows(value, width$2, measure = defaultMeasure) {
|
|
348
|
+
const rows = [];
|
|
349
|
+
let offset = 0;
|
|
350
|
+
for (const line of value.split("\n")) {
|
|
351
|
+
let start = offset;
|
|
352
|
+
let cells$1 = 0;
|
|
353
|
+
if (width$2 !== void 0) {
|
|
354
|
+
let at = offset;
|
|
355
|
+
for (const g of graphemes(line)) {
|
|
356
|
+
const w = measure(g);
|
|
357
|
+
if (cells$1 + w > Math.max(1, width$2) && at > start) {
|
|
358
|
+
rows.push({
|
|
359
|
+
start,
|
|
360
|
+
end: at,
|
|
361
|
+
soft: true
|
|
362
|
+
});
|
|
363
|
+
start = at;
|
|
364
|
+
cells$1 = 0;
|
|
365
|
+
}
|
|
366
|
+
cells$1 += w;
|
|
367
|
+
at += g.length;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
rows.push({
|
|
371
|
+
start,
|
|
372
|
+
end: offset + line.length,
|
|
373
|
+
soft: false
|
|
374
|
+
});
|
|
375
|
+
offset += line.length + 1;
|
|
376
|
+
}
|
|
377
|
+
return rows;
|
|
378
|
+
}
|
|
379
|
+
/** The row holding the caret: a soft row's end belongs to the next row. */
|
|
380
|
+
function caretRow(rows, caret) {
|
|
381
|
+
const i = rows.findIndex((row) => caret >= row.start && (caret < row.end || caret === row.end && !row.soft));
|
|
382
|
+
return i === -1 ? rows.length - 1 : i;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Move the caret one visual row up or down, keeping its cell column (landing
|
|
386
|
+
* at the end of a shorter row). Undefined at the first or last row.
|
|
387
|
+
*/
|
|
388
|
+
function moveVisual(state, rows, direction, measure = defaultMeasure) {
|
|
389
|
+
const at = caretBoundary(state.value, state.caret);
|
|
390
|
+
const from = caretRow(rows, at);
|
|
391
|
+
const target = rows[from + direction];
|
|
392
|
+
if (!target) return void 0;
|
|
393
|
+
const row = rows[from];
|
|
394
|
+
const column = measure(state.value.slice(row.start, at));
|
|
395
|
+
let caret = target.start;
|
|
396
|
+
let cells$1 = 0;
|
|
397
|
+
for (const g of graphemes(state.value.slice(target.start, target.end))) {
|
|
398
|
+
const w = measure(g);
|
|
399
|
+
if (cells$1 + w > column) break;
|
|
400
|
+
if (target.soft && caret + g.length === target.end) break;
|
|
401
|
+
cells$1 += w;
|
|
402
|
+
caret += g.length;
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
...state,
|
|
406
|
+
caret
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const toEntry = (entry) => typeof entry === "string" ? { text: entry } : entry;
|
|
410
|
+
function createHistory(entries = []) {
|
|
411
|
+
const list$1 = entries.map(toEntry);
|
|
412
|
+
return {
|
|
413
|
+
entries: list$1,
|
|
414
|
+
index: list$1.length,
|
|
415
|
+
draft: { text: "" }
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function pushHistory(history, entry) {
|
|
419
|
+
const next = toEntry(entry);
|
|
420
|
+
const last = history.entries.at(-1);
|
|
421
|
+
const attached = Boolean(next.attachments?.length);
|
|
422
|
+
const repeat = last?.text === next.text && !attached && !last.attachments?.length;
|
|
423
|
+
const entries = (next.text.trim() || attached) && !repeat ? [...history.entries, next] : history.entries;
|
|
424
|
+
return {
|
|
425
|
+
entries,
|
|
426
|
+
index: entries.length,
|
|
427
|
+
draft: { text: "" }
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Replace the entries (the host's list changed), keeping the draft and a
|
|
432
|
+
* browse in progress (clamped to the new list).
|
|
433
|
+
*/
|
|
434
|
+
function withEntries(history, entries) {
|
|
435
|
+
const list$1 = entries.map(toEntry);
|
|
436
|
+
const browsing = history.index < history.entries.length;
|
|
437
|
+
return {
|
|
438
|
+
entries: list$1,
|
|
439
|
+
index: browsing ? Math.min(history.index, list$1.length) : list$1.length,
|
|
440
|
+
draft: history.draft
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/** Step through history; returns the new history and the entry to show, or undefined at an end. */
|
|
444
|
+
function stepHistory(history, direction, current) {
|
|
445
|
+
const index = history.index + direction;
|
|
446
|
+
if (index < 0 || index > history.entries.length) return void 0;
|
|
447
|
+
const draft = history.index === history.entries.length ? toEntry(current) : history.draft;
|
|
448
|
+
const entry = index === history.entries.length ? draft : history.entries[index];
|
|
449
|
+
return {
|
|
450
|
+
history: {
|
|
451
|
+
...history,
|
|
452
|
+
index,
|
|
453
|
+
draft
|
|
454
|
+
},
|
|
455
|
+
value: entry.text,
|
|
456
|
+
...entry.attachments?.length ? { attachments: entry.attachments } : {}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region src/runtime.ts
|
|
462
|
+
const UNBOUND = "@fx-/chat-ink is not bound to React and Ink yet: call createChatInk({ React, ink }) first.";
|
|
463
|
+
/** A stand-in that names the missing call on any use. */
|
|
464
|
+
function unbound() {
|
|
465
|
+
return new Proxy({}, { get() {
|
|
466
|
+
throw new Error(UNBOUND);
|
|
467
|
+
} });
|
|
468
|
+
}
|
|
469
|
+
let React = unbound();
|
|
470
|
+
let ink = unbound();
|
|
471
|
+
/** The core render hooks, bound to the host's React. */
|
|
472
|
+
let hooks = unbound();
|
|
473
|
+
let bound;
|
|
474
|
+
/** Bind the package to the host's React and Ink. */
|
|
475
|
+
function bindRuntime(deps) {
|
|
476
|
+
if (bound) {
|
|
477
|
+
if (bound.React === deps.React && bound.ink === deps.ink) return;
|
|
478
|
+
throw new Error("@fx-/chat-ink is already bound to other React and Ink instances; one process renders with one.");
|
|
479
|
+
}
|
|
480
|
+
bound = deps;
|
|
481
|
+
React = deps.React;
|
|
482
|
+
ink = deps.ink;
|
|
483
|
+
hooks = createCoreHooks(deps.React);
|
|
484
|
+
}
|
|
485
|
+
/** Whether `createChatInk` has run. */
|
|
486
|
+
function isBound() {
|
|
487
|
+
return bound !== void 0;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region src/sanitize.ts
|
|
492
|
+
/**
|
|
493
|
+
* Content must never drive the terminal. Model output, tool output, titles
|
|
494
|
+
* and other host- or model-provided strings may carry escape sequences (an
|
|
495
|
+
* OSC 52 clipboard write, an OSC 8 link, SGR colours, cursor moves) that Ink
|
|
496
|
+
* would pass straight through. Everything this package draws is cleaned
|
|
497
|
+
* here first; its own styling travels as Ink props, never as escapes in text.
|
|
498
|
+
*
|
|
499
|
+
* Removed: whole ESC/C1 sequences — CSI, OSC, DCS, SOS, PM, APC (a string
|
|
500
|
+
* command up to its terminator: BEL, `ESC \` or ST), and any other ESC
|
|
501
|
+
* sequence — then every remaining C0 control but `\n` and `\t` (so `\r` and
|
|
502
|
+
* BEL go), DEL, and the C1 controls U+0080–U+009F. An unterminated string
|
|
503
|
+
* command loses its introducer only: the rest is plain text once no control
|
|
504
|
+
* can read it, and the scan stays linear.
|
|
505
|
+
*/
|
|
506
|
+
const ESC$2 = "\\u001B";
|
|
507
|
+
const BEL = "\\u0007";
|
|
508
|
+
const ST = "\\u009C";
|
|
509
|
+
const STRING_START = `(?:${ESC$2}[P\\]X^_]|[\\u0090\\u0098\\u009D-\\u009F])`;
|
|
510
|
+
const STRING_BODY = `[^${BEL}${ESC$2}\\u0090\\u0098\\u009C-\\u009F]*`;
|
|
511
|
+
const STRING_END = `(?:${BEL}|${ESC$2}\\\\|${ST})`;
|
|
512
|
+
const CSI = `(?:${ESC$2}\\[|\\u009B)[\\u0030-\\u003F]*[\\u0020-\\u002F]*[\\u0040-\\u007E]`;
|
|
513
|
+
const OTHER_ESC = `${ESC$2}[\\u0020-\\u002F]*[\\u0030-\\u007E]`;
|
|
514
|
+
const CONTROL = "[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F]";
|
|
515
|
+
const SEQUENCES = new RegExp(`${STRING_START}${STRING_BODY}${STRING_END}|${CSI}|${OTHER_ESC}|${CONTROL}`, "g");
|
|
516
|
+
const HAS_CONTROL = new RegExp(CONTROL);
|
|
517
|
+
/** `text` without terminal control sequences or control characters (bar `\n` and `\t`). */
|
|
518
|
+
function stripControls(text) {
|
|
519
|
+
return HAS_CONTROL.test(text) ? text.replace(SEQUENCES, "") : text;
|
|
520
|
+
}
|
|
521
|
+
const isPlain = (value) => {
|
|
522
|
+
const proto = Object.getPrototypeOf(value);
|
|
523
|
+
return proto === Object.prototype || proto === null;
|
|
524
|
+
};
|
|
525
|
+
const CONTROLS = new RegExp(CONTROL, "g");
|
|
526
|
+
/** `key` with each control character spelled as visible `\uXXXX` text. */
|
|
527
|
+
const escapeControls = (key) => key.replace(CONTROLS, (c) => `\\u${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`);
|
|
528
|
+
/**
|
|
529
|
+
* Clean keys for `keys`, one per key, all distinct. A clean key keeps its
|
|
530
|
+
* name; a dirty one takes its stripped form unless that is taken, then its
|
|
531
|
+
* visibly escaped form, then that with ` (2)`, ` (3)`… — so no value is ever
|
|
532
|
+
* dropped by two keys cleaning to the same name.
|
|
533
|
+
*/
|
|
534
|
+
const cleanKeys = (keys) => {
|
|
535
|
+
const taken = new Set(keys.filter((key) => stripControls(key) === key));
|
|
536
|
+
return keys.map((key) => {
|
|
537
|
+
if (stripControls(key) === key) return key;
|
|
538
|
+
let name = stripControls(key);
|
|
539
|
+
if (taken.has(name)) name = escapeControls(key);
|
|
540
|
+
const base = name;
|
|
541
|
+
for (let n = 2; taken.has(name); n++) name = `${base} (${n})`;
|
|
542
|
+
taken.add(name);
|
|
543
|
+
return name;
|
|
544
|
+
});
|
|
545
|
+
};
|
|
546
|
+
/**
|
|
547
|
+
* `value` with `stripControls` applied to every string in it (object keys
|
|
548
|
+
* included), through arrays and plain objects; other objects (Map, Set,
|
|
549
|
+
* Date, class instances) pass through as they are. Anything unchanged keeps
|
|
550
|
+
* its identity, so clean content costs a scan and no copies. Keys that clean
|
|
551
|
+
* to the same name all survive (see `cleanKeys`); a `__proto__` key stays an
|
|
552
|
+
* own property, never a prototype; a cycle is left as it is where it closes.
|
|
553
|
+
*/
|
|
554
|
+
function stripControlsDeep(value) {
|
|
555
|
+
return deep(value, new Set());
|
|
556
|
+
}
|
|
557
|
+
function deep(value, path) {
|
|
558
|
+
if (typeof value === "string") return stripControls(value);
|
|
559
|
+
if (value === null || typeof value !== "object" || path.has(value)) return value;
|
|
560
|
+
if (!Array.isArray(value) && !isPlain(value)) return value;
|
|
561
|
+
path.add(value);
|
|
562
|
+
try {
|
|
563
|
+
if (Array.isArray(value)) {
|
|
564
|
+
let out;
|
|
565
|
+
value.forEach((item, i) => {
|
|
566
|
+
const next = deep(item, path);
|
|
567
|
+
if (next !== item) {
|
|
568
|
+
out ??= value.slice();
|
|
569
|
+
out[i] = next;
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
return out ?? value;
|
|
573
|
+
}
|
|
574
|
+
const entries = Object.entries(value);
|
|
575
|
+
const keys = cleanKeys(entries.map(([key]) => key));
|
|
576
|
+
let changed = false;
|
|
577
|
+
const values = entries.map(([key, item], i) => {
|
|
578
|
+
const next = deep(item, path);
|
|
579
|
+
if (next !== item || keys[i] !== key) changed = true;
|
|
580
|
+
return next;
|
|
581
|
+
});
|
|
582
|
+
if (!changed) return value;
|
|
583
|
+
return Object.fromEntries(keys.map((key, i) => [key, values[i]]));
|
|
584
|
+
} finally {
|
|
585
|
+
path.delete(value);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
//#endregion
|
|
590
|
+
//#region src/text.tsx
|
|
591
|
+
function clean(node) {
|
|
592
|
+
if (typeof node === "string") return stripControls(node);
|
|
593
|
+
if (Array.isArray(node)) return node.map(clean);
|
|
594
|
+
if (React.isValidElement(node) && node.type === React.Fragment) return withChildren(node.props.children, (kids) => React.cloneElement(node, void 0, ...kids));
|
|
595
|
+
return node;
|
|
596
|
+
}
|
|
597
|
+
/** Rebuild with cleaned children, passed as arguments (as JSX does) so none needs a key. */
|
|
598
|
+
function withChildren(children, build) {
|
|
599
|
+
return build(Array.isArray(children) ? children.map(clean) : [clean(children)]);
|
|
600
|
+
}
|
|
601
|
+
function Text({ children,...props }) {
|
|
602
|
+
return withChildren(children, (kids) => React.createElement(ink.Text, props, ...kids));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
//#endregion
|
|
606
|
+
//#region src/theme.ts
|
|
607
|
+
/** Greyscale defaults: no hue anywhere. */
|
|
608
|
+
const plainTheme = {
|
|
609
|
+
content: {},
|
|
610
|
+
muted: { dim: true },
|
|
611
|
+
strong: { bold: true },
|
|
612
|
+
marker: { dim: true },
|
|
613
|
+
chrome: { dim: true },
|
|
614
|
+
cursor: { inverse: true },
|
|
615
|
+
positive: {},
|
|
616
|
+
caution: {},
|
|
617
|
+
danger: { bold: true },
|
|
618
|
+
user: { bold: true },
|
|
619
|
+
assistant: { dim: true },
|
|
620
|
+
accent: {},
|
|
621
|
+
code: {},
|
|
622
|
+
link: { underline: true },
|
|
623
|
+
selection: { inverse: true }
|
|
624
|
+
};
|
|
625
|
+
/**
|
|
626
|
+
* The default glyphs: monochrome text, one cell each. No glyph is
|
|
627
|
+
* emoji-capable (Unicode `Emoji=No`), so terminals cannot widen or colour one,
|
|
628
|
+
* and every glyph is one that common monospace fonts carry.
|
|
629
|
+
*/
|
|
630
|
+
const plainGlyphs = {
|
|
631
|
+
collapsed: "▸",
|
|
632
|
+
expanded: "▾",
|
|
633
|
+
brain: "✻",
|
|
634
|
+
wrench: "ƒ",
|
|
635
|
+
gear: "§",
|
|
636
|
+
checklist: "☐",
|
|
637
|
+
planDone: "✓",
|
|
638
|
+
planActive: "…",
|
|
639
|
+
planPending: "·",
|
|
640
|
+
success: "✓",
|
|
641
|
+
error: "✗",
|
|
642
|
+
user: "›",
|
|
643
|
+
assistant: "•",
|
|
644
|
+
prompt: "›",
|
|
645
|
+
spinner: [
|
|
646
|
+
"⠋",
|
|
647
|
+
"⠙",
|
|
648
|
+
"⠹",
|
|
649
|
+
"⠸",
|
|
650
|
+
"⠼",
|
|
651
|
+
"⠴",
|
|
652
|
+
"⠦",
|
|
653
|
+
"⠧",
|
|
654
|
+
"⠇",
|
|
655
|
+
"⠏"
|
|
656
|
+
],
|
|
657
|
+
separator: " · ",
|
|
658
|
+
tool: "ƒ",
|
|
659
|
+
earlier: "⋮",
|
|
660
|
+
toolSuccess: "✓",
|
|
661
|
+
toolError: "✗",
|
|
662
|
+
toolRunning: "…",
|
|
663
|
+
toolIdle: "·",
|
|
664
|
+
boxTopLeft: "╭",
|
|
665
|
+
boxTopRight: "╮",
|
|
666
|
+
boxBottomLeft: "╰",
|
|
667
|
+
boxBottomRight: "╯",
|
|
668
|
+
boxJunction: "├",
|
|
669
|
+
boxHorizontal: "─",
|
|
670
|
+
boxVertical: "│",
|
|
671
|
+
footer: "╰",
|
|
672
|
+
outputBar: "┊",
|
|
673
|
+
attachment: "⊕",
|
|
674
|
+
image: "▣",
|
|
675
|
+
pending: "‖",
|
|
676
|
+
pulse: [
|
|
677
|
+
"●··",
|
|
678
|
+
"·●·",
|
|
679
|
+
"··●"
|
|
680
|
+
],
|
|
681
|
+
bullet: "•",
|
|
682
|
+
taskOpen: "☐",
|
|
683
|
+
taskDone: "☒",
|
|
684
|
+
rule: "─",
|
|
685
|
+
quoteBar: "│",
|
|
686
|
+
codeBar: "│",
|
|
687
|
+
tableColumn: "│",
|
|
688
|
+
tableCross: "┼"
|
|
689
|
+
};
|
|
690
|
+
/** Glyphs using only ASCII, for terminals without box-drawing or emoji symbols. */
|
|
691
|
+
const asciiGlyphs = {
|
|
692
|
+
collapsed: ">",
|
|
693
|
+
expanded: "v",
|
|
694
|
+
brain: "~",
|
|
695
|
+
wrench: "*",
|
|
696
|
+
gear: "#",
|
|
697
|
+
checklist: "=",
|
|
698
|
+
planDone: "x",
|
|
699
|
+
planActive: ">",
|
|
700
|
+
planPending: "-",
|
|
701
|
+
success: "+",
|
|
702
|
+
error: "x",
|
|
703
|
+
user: ">",
|
|
704
|
+
assistant: "*",
|
|
705
|
+
prompt: ">",
|
|
706
|
+
spinner: [
|
|
707
|
+
"|",
|
|
708
|
+
"/",
|
|
709
|
+
"-",
|
|
710
|
+
"\\"
|
|
711
|
+
],
|
|
712
|
+
separator: " | ",
|
|
713
|
+
tool: "*",
|
|
714
|
+
earlier: "^",
|
|
715
|
+
toolSuccess: "+",
|
|
716
|
+
toolError: "x",
|
|
717
|
+
toolRunning: "~",
|
|
718
|
+
toolIdle: ".",
|
|
719
|
+
boxTopLeft: "+",
|
|
720
|
+
boxTopRight: "+",
|
|
721
|
+
boxBottomLeft: "+",
|
|
722
|
+
boxBottomRight: "+",
|
|
723
|
+
boxJunction: "+",
|
|
724
|
+
boxHorizontal: "-",
|
|
725
|
+
boxVertical: "|",
|
|
726
|
+
footer: "+",
|
|
727
|
+
outputBar: ":",
|
|
728
|
+
attachment: "@",
|
|
729
|
+
image: "[img]",
|
|
730
|
+
pending: "..",
|
|
731
|
+
pulse: [
|
|
732
|
+
"o..",
|
|
733
|
+
".o.",
|
|
734
|
+
"..o"
|
|
735
|
+
],
|
|
736
|
+
bullet: "-",
|
|
737
|
+
taskOpen: "[ ]",
|
|
738
|
+
taskDone: "[x]",
|
|
739
|
+
rule: "-",
|
|
740
|
+
quoteBar: "|",
|
|
741
|
+
codeBar: "|",
|
|
742
|
+
tableColumn: "|",
|
|
743
|
+
tableCross: "+"
|
|
744
|
+
};
|
|
745
|
+
/**
|
|
746
|
+
* Opt-in colour icons over {@link plainGlyphs}: only code points that are
|
|
747
|
+
* emoji by default (`Emoji_Presentation=Yes`), which terminals and the
|
|
748
|
+
* default measure both treat as two cells. Turn it on with
|
|
749
|
+
* `<InkChatProvider components={{ glyphs: emojiGlyphs }}>`.
|
|
750
|
+
*/
|
|
751
|
+
const emojiGlyphs = {
|
|
752
|
+
...plainGlyphs,
|
|
753
|
+
brain: "🧠",
|
|
754
|
+
wrench: "🔧",
|
|
755
|
+
tool: "🔧",
|
|
756
|
+
gear: "📜",
|
|
757
|
+
checklist: "📋",
|
|
758
|
+
attachment: "📎",
|
|
759
|
+
image: "📷",
|
|
760
|
+
pending: "⏳"
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
//#endregion
|
|
764
|
+
//#region src/registry.tsx
|
|
765
|
+
/** Prints markdown source as-is. */
|
|
766
|
+
function PlainMarkdown({ content }) {
|
|
767
|
+
return /* @__PURE__ */ React.createElement(Styled, { tone: "content" }, content);
|
|
768
|
+
}
|
|
769
|
+
const plainComponents = {
|
|
770
|
+
theme: plainTheme,
|
|
771
|
+
glyphs: plainGlyphs,
|
|
772
|
+
Markdown: PlainMarkdown,
|
|
773
|
+
spinnerIntervalMs: 80,
|
|
774
|
+
flashComplete: true
|
|
775
|
+
};
|
|
776
|
+
let context;
|
|
777
|
+
const inkContext = () => {
|
|
778
|
+
context ??= React.createContext(plainComponents);
|
|
779
|
+
return context;
|
|
780
|
+
};
|
|
781
|
+
/** Merge a partial registry over a base. */
|
|
782
|
+
function mergeInkComponents(base, partial = {}) {
|
|
783
|
+
return {
|
|
784
|
+
theme: {
|
|
785
|
+
...base.theme,
|
|
786
|
+
...partial.theme
|
|
787
|
+
},
|
|
788
|
+
glyphs: {
|
|
789
|
+
...base.glyphs,
|
|
790
|
+
...partial.glyphs
|
|
791
|
+
},
|
|
792
|
+
Markdown: partial.Markdown ?? base.Markdown,
|
|
793
|
+
spinnerIntervalMs: partial.spinnerIntervalMs ?? base.spinnerIntervalMs,
|
|
794
|
+
flashComplete: partial.flashComplete ?? base.flashComplete
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function InkChatProvider({ components, children }) {
|
|
798
|
+
const value = React.useMemo(() => mergeInkComponents(plainComponents, components), [components]);
|
|
799
|
+
const InkContext = inkContext();
|
|
800
|
+
return /* @__PURE__ */ React.createElement(InkContext.Provider, { value }, children);
|
|
801
|
+
}
|
|
802
|
+
function useInkComponents() {
|
|
803
|
+
return React.useContext(inkContext());
|
|
804
|
+
}
|
|
805
|
+
/** Ink `<Text>` props for an appearance. */
|
|
806
|
+
function appearanceProps(appearance) {
|
|
807
|
+
return {
|
|
808
|
+
...appearance.color ? { color: appearance.color } : {},
|
|
809
|
+
...appearance.dim ? { dimColor: true } : {},
|
|
810
|
+
...appearance.bold ? { bold: true } : {},
|
|
811
|
+
...appearance.italic ? { italic: true } : {},
|
|
812
|
+
...appearance.inverse ? { inverse: true } : {},
|
|
813
|
+
...appearance.underline ? { underline: true } : {},
|
|
814
|
+
...appearance.strikethrough ? { strikethrough: true } : {}
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
/** Text drawn in a theme role (`tone`). */
|
|
818
|
+
function Styled({ tone, wrap, children }) {
|
|
819
|
+
const { theme } = useInkComponents();
|
|
820
|
+
return /* @__PURE__ */ React.createElement(Text, {
|
|
821
|
+
...appearanceProps(theme[tone]),
|
|
822
|
+
...wrap ? { wrap } : {}
|
|
823
|
+
}, children);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
//#endregion
|
|
827
|
+
//#region src/terminal.ts
|
|
828
|
+
/**
|
|
829
|
+
* Terminal protocol helpers shared by the composer and the viewport.
|
|
830
|
+
*/
|
|
831
|
+
const ESC$1 = String.fromCharCode(27);
|
|
832
|
+
const TERMINAL_REPORT = new RegExp(`^(?:${ESC$1})?\\[(?:<\\d+;\\d+;\\d+[Mm]|\\d+;\\d+;\\d+t|\\?\\d[\\d;]*[a-zA-Z])`);
|
|
833
|
+
/** Mouse reports and terminal replies arrive as input; key handlers ignore them. */
|
|
834
|
+
function isTerminalReport(input) {
|
|
835
|
+
return TERMINAL_REPORT.test(input);
|
|
836
|
+
}
|
|
837
|
+
/** The longest unfinished report followed across key events; a longer one is noise. */
|
|
838
|
+
const MAX_TAIL = 64;
|
|
839
|
+
const CSI_HEAD = /^\[[\x30-\x3f]*[\x20-\x2f]*/;
|
|
840
|
+
/**
|
|
841
|
+
* Where the CSI sequence opening `text` (ESC stripped) ends: past its final
|
|
842
|
+
* byte, 0 while it is unfinished, or -1 when `text` opens none.
|
|
843
|
+
*/
|
|
844
|
+
function csiEnd(text) {
|
|
845
|
+
const head = CSI_HEAD.exec(text);
|
|
846
|
+
if (!head) return -1;
|
|
847
|
+
const at = head[0].length;
|
|
848
|
+
if (at === text.length) return 0;
|
|
849
|
+
const code = text.charCodeAt(at);
|
|
850
|
+
return code >= 64 && code <= 126 ? at + 1 : -1;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* A reader of terminal reports across key events. Ink holds an unfinished
|
|
854
|
+
* escape sequence only briefly, then delivers it with its ESC stripped (so a
|
|
855
|
+
* report split across stdin chunks arrives as `[<0;3` and then `;4M`). Each
|
|
856
|
+
* call classifies one event's input, following such a start into the next
|
|
857
|
+
* event; the start itself cannot be told from typing (a typed `[` looks the
|
|
858
|
+
* same), so it is `partial` and the caller decides what to hold back.
|
|
859
|
+
*/
|
|
860
|
+
function createKeyReportReader() {
|
|
861
|
+
let tail = "";
|
|
862
|
+
return (input) => {
|
|
863
|
+
const held = tail;
|
|
864
|
+
tail = "";
|
|
865
|
+
if (isTerminalReport(input)) return {
|
|
866
|
+
kind: "report",
|
|
867
|
+
joined: false,
|
|
868
|
+
rest: ""
|
|
869
|
+
};
|
|
870
|
+
const text = input.startsWith(ESC$1) ? input.slice(1) : input;
|
|
871
|
+
if (held && text) {
|
|
872
|
+
const joined = held + text;
|
|
873
|
+
const end = csiEnd(joined);
|
|
874
|
+
if (end > 0 && isTerminalReport(joined.slice(0, end))) return {
|
|
875
|
+
kind: "report",
|
|
876
|
+
joined: true,
|
|
877
|
+
rest: joined.slice(end)
|
|
878
|
+
};
|
|
879
|
+
if (end === 0 && joined.length <= MAX_TAIL) {
|
|
880
|
+
tail = joined;
|
|
881
|
+
return {
|
|
882
|
+
kind: "partial",
|
|
883
|
+
continued: true
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (csiEnd(text) === 0 && text.length <= MAX_TAIL) {
|
|
888
|
+
tail = text;
|
|
889
|
+
return {
|
|
890
|
+
kind: "partial",
|
|
891
|
+
continued: false
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
return { kind: "text" };
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
/** Kitty keyboard protocol: push the disambiguate flag (Shift+Enter is then reported). */
|
|
898
|
+
const KITTY_PUSH = `${ESC$1}[>1u`;
|
|
899
|
+
/** Kitty keyboard protocol: pop the flags pushed last. */
|
|
900
|
+
const KITTY_POP = `${ESC$1}[<u`;
|
|
901
|
+
|
|
902
|
+
//#endregion
|
|
903
|
+
//#region src/Composer.tsx
|
|
904
|
+
const emptyComposerValue = {
|
|
905
|
+
text: "",
|
|
906
|
+
caret: 0,
|
|
907
|
+
attachments: []
|
|
908
|
+
};
|
|
909
|
+
function findMatch(providers, state) {
|
|
910
|
+
for (const provider of providers ?? []) {
|
|
911
|
+
if (!state.value.slice(0, state.caret).includes(provider.trigger)) continue;
|
|
912
|
+
const match = provider.match(state.value, state.caret);
|
|
913
|
+
if (match) return {
|
|
914
|
+
provider,
|
|
915
|
+
match
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
return void 0;
|
|
919
|
+
}
|
|
920
|
+
/** Stable identity of a matched token (provider, start, query). */
|
|
921
|
+
function keyOf(found) {
|
|
922
|
+
return found ? `${found.provider.id}:${found.match.range[0]}:${found.match.query}` : void 0;
|
|
923
|
+
}
|
|
924
|
+
/** State plus a ref that always holds the latest value, updated synchronously. */
|
|
925
|
+
function useLive(initial) {
|
|
926
|
+
const [value, setValue] = React.useState(initial);
|
|
927
|
+
const ref = React.useRef(value);
|
|
928
|
+
const set = React.useCallback((next) => {
|
|
929
|
+
ref.current = next;
|
|
930
|
+
setValue(() => next);
|
|
931
|
+
}, []);
|
|
932
|
+
return [
|
|
933
|
+
value,
|
|
934
|
+
ref,
|
|
935
|
+
set
|
|
936
|
+
];
|
|
937
|
+
}
|
|
938
|
+
const toEditor = (v) => ({
|
|
939
|
+
value: v.text,
|
|
940
|
+
caret: v.caret
|
|
941
|
+
});
|
|
942
|
+
const sameEditor = (a, b) => a.value === b.value && a.caret === b.caret;
|
|
943
|
+
/**
|
|
944
|
+
* Cells left for text at `width`: the prompt, the border and padding of the
|
|
945
|
+
* boxed variant, and one cell kept for the caret come off.
|
|
946
|
+
*/
|
|
947
|
+
function composerInnerWidth(width$2, variant = "boxed", promptWidth = 2) {
|
|
948
|
+
return Math.max(1, width$2 - promptWidth - (variant === "boxed" ? 4 : 0) - 1);
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Rows the composer takes at `width` for `value`: its input rows (up to
|
|
952
|
+
* `maxRows`), the attachments row, and the boxed variant's border — not the
|
|
953
|
+
* hint or completion lines under it. Lets a host size the rest of the screen.
|
|
954
|
+
*/
|
|
955
|
+
function composerRows(value, width$2, options = {}) {
|
|
956
|
+
const { variant = "boxed", maxRows, promptWidth = 2, measure = defaultMeasure } = options;
|
|
957
|
+
const rows = visualRows(value.text, composerInnerWidth(width$2, variant, promptWidth), measure).length;
|
|
958
|
+
const shown = maxRows === void 0 ? rows : Math.min(rows, Math.max(1, maxRows));
|
|
959
|
+
return shown + (value.attachments.length > 0 ? 1 : 0) + (variant === "boxed" ? 2 : 0);
|
|
960
|
+
}
|
|
961
|
+
/** Keep the caret's row inside a window of `max` rows, moving the window as little as possible. */
|
|
962
|
+
function scrollTop(top, row, rows, max) {
|
|
963
|
+
const last = Math.max(0, rows - max);
|
|
964
|
+
if (row < top) return row;
|
|
965
|
+
if (row >= top + max) return Math.min(row - max + 1, last);
|
|
966
|
+
return Math.min(top, last);
|
|
967
|
+
}
|
|
968
|
+
function Composer({ onSubmit, onEscape, providers, history: initialHistory, placeholder = "Message", disabled = false, hint, maxItems = 6, value: controlled, onChange, variant = "boxed", width: width$2, maxRows, focused = true, attachmentHint, killLine = "line", onKey, onPaste, inputSource = "own", ref, kittyKeyboard = false, measure = defaultMeasure }) {
|
|
969
|
+
const { glyphs, theme } = useInkComponents();
|
|
970
|
+
const { stdout } = ink.useStdout();
|
|
971
|
+
const [own, valueRef, setOwn] = useLive(controlled ?? emptyComposerValue);
|
|
972
|
+
const seen = React.useRef(controlled);
|
|
973
|
+
if (controlled && controlled !== seen.current) {
|
|
974
|
+
seen.current = controlled;
|
|
975
|
+
valueRef.current = controlled;
|
|
976
|
+
}
|
|
977
|
+
const current = controlled ?? own;
|
|
978
|
+
const [, historyRef, setHistory] = useLive(() => createHistory(initialHistory));
|
|
979
|
+
const historyLength = initialHistory?.length ?? 0;
|
|
980
|
+
const seenHistory = React.useRef(initialHistory);
|
|
981
|
+
React.useEffect(() => {
|
|
982
|
+
if (seenHistory.current === initialHistory && historyLength === historyRef.current.entries.length) return;
|
|
983
|
+
seenHistory.current = initialHistory;
|
|
984
|
+
setHistory(withEntries(historyRef.current, initialHistory ?? []));
|
|
985
|
+
}, [
|
|
986
|
+
initialHistory,
|
|
987
|
+
historyLength,
|
|
988
|
+
historyRef,
|
|
989
|
+
setHistory
|
|
990
|
+
]);
|
|
991
|
+
const [completion, completionRef, setCompletion] = useLive(void 0);
|
|
992
|
+
const [dismissed, dismissedRef, setDismissed] = useLive(void 0);
|
|
993
|
+
const recalled = React.useRef(false);
|
|
994
|
+
const active = focused && !disabled;
|
|
995
|
+
const prompt = `${glyphs.prompt} `;
|
|
996
|
+
const promptWidth = measure(prompt);
|
|
997
|
+
const inner = width$2 === void 0 ? void 0 : composerInnerWidth(width$2, variant, promptWidth);
|
|
998
|
+
const rowsOf = (text) => visualRows(text, inner, measure);
|
|
999
|
+
const found = findMatch(providers, toEditor(current));
|
|
1000
|
+
const tokenKey = keyOf(found);
|
|
1001
|
+
const latest = React.useRef(found);
|
|
1002
|
+
latest.current = found;
|
|
1003
|
+
React.useEffect(() => {
|
|
1004
|
+
const match = latest.current;
|
|
1005
|
+
if (!tokenKey || !match || tokenKey === dismissed) {
|
|
1006
|
+
setCompletion(void 0);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const controller = new AbortController();
|
|
1010
|
+
Promise.resolve(match.provider.items(match.match.query, controller.signal)).then((items) => {
|
|
1011
|
+
if (controller.signal.aborted) return;
|
|
1012
|
+
setCompletion(items.length > 0 ? {
|
|
1013
|
+
provider: match.provider,
|
|
1014
|
+
match: match.match,
|
|
1015
|
+
items,
|
|
1016
|
+
active: 0
|
|
1017
|
+
} : void 0);
|
|
1018
|
+
}, () => {
|
|
1019
|
+
if (!controller.signal.aborted) setCompletion(void 0);
|
|
1020
|
+
});
|
|
1021
|
+
return () => controller.abort();
|
|
1022
|
+
}, [tokenKey, dismissed]);
|
|
1023
|
+
React.useEffect(() => {
|
|
1024
|
+
if (!kittyKeyboard || !active) return;
|
|
1025
|
+
stdout.write(KITTY_PUSH);
|
|
1026
|
+
const pop = () => stdout.write(KITTY_POP);
|
|
1027
|
+
process.once("exit", pop);
|
|
1028
|
+
return () => {
|
|
1029
|
+
process.off("exit", pop);
|
|
1030
|
+
pop();
|
|
1031
|
+
};
|
|
1032
|
+
}, [
|
|
1033
|
+
kittyKeyboard,
|
|
1034
|
+
active,
|
|
1035
|
+
stdout
|
|
1036
|
+
]);
|
|
1037
|
+
const commit = (next) => {
|
|
1038
|
+
setOwn(next);
|
|
1039
|
+
onChange?.(next);
|
|
1040
|
+
};
|
|
1041
|
+
const setEditor = (state, attachments$1 = valueRef.current.attachments) => commit({
|
|
1042
|
+
text: state.value,
|
|
1043
|
+
caret: state.caret,
|
|
1044
|
+
attachments: attachments$1
|
|
1045
|
+
});
|
|
1046
|
+
/** A change from the user: an edit ends a recall and reopens completion. */
|
|
1047
|
+
const edit = (next) => {
|
|
1048
|
+
if (dismissedRef.current !== void 0) setDismissed(void 0);
|
|
1049
|
+
if (next.value !== valueRef.current.text) recalled.current = false;
|
|
1050
|
+
setEditor(next);
|
|
1051
|
+
};
|
|
1052
|
+
const apply = (c) => {
|
|
1053
|
+
const item = c.items[c.active];
|
|
1054
|
+
const [start, end] = c.match.range;
|
|
1055
|
+
const text = valueRef.current.text;
|
|
1056
|
+
const result = c.provider.apply(item, text.slice(0, start), text.slice(end));
|
|
1057
|
+
const next = {
|
|
1058
|
+
value: result.value,
|
|
1059
|
+
caret: result.caret
|
|
1060
|
+
};
|
|
1061
|
+
setEditor(next);
|
|
1062
|
+
setCompletion(void 0);
|
|
1063
|
+
setDismissed(keyOf(findMatch(providers, next)));
|
|
1064
|
+
};
|
|
1065
|
+
/** Submit `text` with the current attachments (if either has content); returns whether it was sent. */
|
|
1066
|
+
const send = (text) => {
|
|
1067
|
+
const attachments$1 = valueRef.current.attachments;
|
|
1068
|
+
if (!text.trim() && attachments$1.length === 0) return false;
|
|
1069
|
+
if (attachments$1.length > 0) onSubmit(text, attachments$1);
|
|
1070
|
+
else onSubmit(text);
|
|
1071
|
+
setHistory(pushHistory(historyRef.current, {
|
|
1072
|
+
text,
|
|
1073
|
+
attachments: attachments$1
|
|
1074
|
+
}));
|
|
1075
|
+
setCompletion(void 0);
|
|
1076
|
+
recalled.current = false;
|
|
1077
|
+
return true;
|
|
1078
|
+
};
|
|
1079
|
+
const cleared = {
|
|
1080
|
+
value: "",
|
|
1081
|
+
caret: 0
|
|
1082
|
+
};
|
|
1083
|
+
const submit = () => {
|
|
1084
|
+
if (send(valueRef.current.text)) setEditor(cleared, []);
|
|
1085
|
+
};
|
|
1086
|
+
/**
|
|
1087
|
+
* A chunk of typed characters. Terminals and multiplexers may deliver
|
|
1088
|
+
* several keystrokes at once (e.g. `"hi\r"`), which arrive as text without
|
|
1089
|
+
* the return flag: a `\r` inside the chunk submits what precedes it and a
|
|
1090
|
+
* `\n` inserts a newline, as if each key had arrived alone.
|
|
1091
|
+
*/
|
|
1092
|
+
const typeChunk = (chunk) => {
|
|
1093
|
+
let state = toEditor(valueRef.current);
|
|
1094
|
+
for (const part of chunk.split(/(\r\n?|\n)/)) if (part === "\n") state = insert(state, "\n");
|
|
1095
|
+
else if (part.startsWith("\r")) {
|
|
1096
|
+
if (send(state.value)) {
|
|
1097
|
+
state = cleared;
|
|
1098
|
+
setEditor(state, []);
|
|
1099
|
+
}
|
|
1100
|
+
} else if (part) state = insert(state, stripControls(part));
|
|
1101
|
+
edit(state);
|
|
1102
|
+
};
|
|
1103
|
+
const paste = (text) => {
|
|
1104
|
+
if (onPaste?.(text)) return;
|
|
1105
|
+
edit(insert(toEditor(valueRef.current), stripControls(text.replace(/\r\n?/g, "\n"))));
|
|
1106
|
+
};
|
|
1107
|
+
const stepThroughHistory = (direction) => {
|
|
1108
|
+
const value = valueRef.current;
|
|
1109
|
+
const stepped = stepHistory(historyRef.current, direction, {
|
|
1110
|
+
text: value.text,
|
|
1111
|
+
attachments: value.attachments
|
|
1112
|
+
});
|
|
1113
|
+
if (!stepped) return;
|
|
1114
|
+
setHistory(stepped.history);
|
|
1115
|
+
recalled.current = stepped.history.index < stepped.history.entries.length;
|
|
1116
|
+
setEditor({
|
|
1117
|
+
value: stepped.value,
|
|
1118
|
+
caret: stepped.value.length
|
|
1119
|
+
}, stepped.attachments ?? []);
|
|
1120
|
+
};
|
|
1121
|
+
const [readReport] = React.useState(createKeyReportReader);
|
|
1122
|
+
const held = React.useRef(void 0);
|
|
1123
|
+
const handleKey = (input$1, key) => {
|
|
1124
|
+
const report = readReport(input$1);
|
|
1125
|
+
const start = held.current;
|
|
1126
|
+
held.current = void 0;
|
|
1127
|
+
if (report.kind === "report") {
|
|
1128
|
+
const now = toEditor(valueRef.current);
|
|
1129
|
+
if (report.joined && sameEditor(now, start.after) && !sameEditor(now, start.before)) edit(start.before);
|
|
1130
|
+
if (report.rest) handleKey(report.rest, key);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const from = report.kind === "partial" && report.continued ? start.before : toEditor(valueRef.current);
|
|
1134
|
+
if (report.kind === "partial") held.current = {
|
|
1135
|
+
before: from,
|
|
1136
|
+
after: from
|
|
1137
|
+
};
|
|
1138
|
+
handleOwnKey(input$1, key);
|
|
1139
|
+
if (held.current) held.current.after = toEditor(valueRef.current);
|
|
1140
|
+
};
|
|
1141
|
+
const handleOwnKey = (input$1, key) => {
|
|
1142
|
+
if (onKey?.(input$1, key)) return;
|
|
1143
|
+
const completion$1 = completionRef.current;
|
|
1144
|
+
const editor = toEditor(valueRef.current);
|
|
1145
|
+
if (completion$1) {
|
|
1146
|
+
if (key.upArrow || key.downArrow) {
|
|
1147
|
+
const n = completion$1.items.length;
|
|
1148
|
+
setCompletion({
|
|
1149
|
+
...completion$1,
|
|
1150
|
+
active: (completion$1.active + (key.upArrow ? n - 1 : 1)) % n,
|
|
1151
|
+
navigated: true
|
|
1152
|
+
});
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (key.tab || key.return && completion$1.navigated) {
|
|
1156
|
+
apply(completion$1);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (key.escape) {
|
|
1160
|
+
setDismissed(keyOf(findMatch(providers, editor)));
|
|
1161
|
+
setCompletion(void 0);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
if (key.escape) {
|
|
1166
|
+
onEscape?.();
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
if (key.return) {
|
|
1170
|
+
if (key.meta || key.shift) edit(insert(editor, "\n"));
|
|
1171
|
+
else if (editor.value.slice(0, editor.caret).endsWith("\\")) edit(insert(backspace(editor), "\n"));
|
|
1172
|
+
else submit();
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (key.backspace) return edit(backspace(editor));
|
|
1176
|
+
if (key.delete) return edit(deleteForward(editor));
|
|
1177
|
+
if ((key.ctrl || key.meta) && key.leftArrow) return edit(wordLeft(editor));
|
|
1178
|
+
if ((key.ctrl || key.meta) && key.rightArrow) return edit(wordRight(editor));
|
|
1179
|
+
if (key.meta && input$1 === "b") return edit(wordLeft(editor));
|
|
1180
|
+
if (key.meta && input$1 === "f") return edit(wordRight(editor));
|
|
1181
|
+
if (key.leftArrow) return edit(moveBy(editor, -1));
|
|
1182
|
+
if (key.rightArrow) return edit(moveBy(editor, 1));
|
|
1183
|
+
if (key.home || key.ctrl && input$1 === "a") return edit(lineStart(editor));
|
|
1184
|
+
if (key.end || key.ctrl && input$1 === "e") return edit(lineEnd(editor));
|
|
1185
|
+
if (key.ctrl && input$1 === "u") return edit(killLine === "input" ? cleared : killToLineStart(editor));
|
|
1186
|
+
if (key.ctrl && input$1 === "k") return edit(killToLineEnd(editor));
|
|
1187
|
+
if (key.ctrl && input$1 === "w") return edit(deleteWordBack(editor));
|
|
1188
|
+
if (key.upArrow || key.downArrow) {
|
|
1189
|
+
const direction = key.upArrow ? -1 : 1;
|
|
1190
|
+
const moved = recalled.current ? void 0 : moveVisual(editor, rowsOf(editor.value), direction, measure);
|
|
1191
|
+
if (moved) return edit(moved);
|
|
1192
|
+
stepThroughHistory(direction);
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (key.ctrl || key.meta || key.tab || !input$1) return;
|
|
1196
|
+
typeChunk(input$1);
|
|
1197
|
+
};
|
|
1198
|
+
React.useImperativeHandle(ref, () => ({
|
|
1199
|
+
key: handleKey,
|
|
1200
|
+
paste
|
|
1201
|
+
}));
|
|
1202
|
+
const listening = active && inputSource === "own";
|
|
1203
|
+
ink.usePaste(paste, { isActive: listening });
|
|
1204
|
+
ink.useInput(handleKey, { isActive: listening });
|
|
1205
|
+
const rows = rowsOf(current.text);
|
|
1206
|
+
const at = caretBoundary(current.text, current.caret);
|
|
1207
|
+
const row = caretRow(rows, at);
|
|
1208
|
+
const top = React.useRef(0);
|
|
1209
|
+
top.current = maxRows === void 0 ? 0 : scrollTop(top.current, row, rows.length, Math.max(1, maxRows));
|
|
1210
|
+
const shown = rows.slice(top.current, maxRows === void 0 ? void 0 : top.current + maxRows);
|
|
1211
|
+
const empty = current.text === "" && current.attachments.length === 0;
|
|
1212
|
+
const input = empty ? /* @__PURE__ */ React.createElement(Text, null, focused ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Text, appearanceProps(theme.cursor), placeholder.slice(0, 1) || " "), /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, placeholder.slice(1))) : /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, placeholder)) : shown.map((r, i) => {
|
|
1213
|
+
const text = current.text.slice(r.start, r.end);
|
|
1214
|
+
const caret = focused && top.current + i === row ? splitAtCaret(text, at - r.start) : void 0;
|
|
1215
|
+
return /* @__PURE__ */ React.createElement(Text, {
|
|
1216
|
+
key: i,
|
|
1217
|
+
wrap: inner === void 0 ? "wrap" : "truncate"
|
|
1218
|
+
}, caret === void 0 ? text || " " : /* @__PURE__ */ React.createElement(React.Fragment, null, caret[0], /* @__PURE__ */ React.createElement(Text, appearanceProps(theme.cursor), caret[1] || " "), caret[2]));
|
|
1219
|
+
});
|
|
1220
|
+
const attachments = current.attachments.length > 0 ? /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, /* @__PURE__ */ React.createElement(Styled, { tone: "link" }, `${glyphs.attachment} `), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, truncate([current.attachments.map((a) => a.description ?? a.name ?? a.mediaType).join(glyphs.separator), attachmentHint].filter(Boolean).join(" "), Math.max(1, (width$2 ?? Number.POSITIVE_INFINITY) - measure(`${glyphs.attachment} `)), measure))) : null;
|
|
1221
|
+
const promptTone = focused ? "marker" : "chrome";
|
|
1222
|
+
const below = completion ? /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1223
|
+
flexDirection: "column",
|
|
1224
|
+
paddingLeft: 2
|
|
1225
|
+
}, completion.items.slice(0, maxItems).map((item, i) => /* @__PURE__ */ React.createElement(Text, { key: item.id }, /* @__PURE__ */ React.createElement(Text, i === completion.active ? appearanceProps(theme.cursor) : {}, item.label), item.description ? /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, " ", item.description) : null))) : hint ? /* @__PURE__ */ React.createElement(ink.Box, { paddingLeft: 2 }, /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, hint)) : null;
|
|
1226
|
+
if (variant === "plain") return /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column" }, attachments, /* @__PURE__ */ React.createElement(ink.Box, null, /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1227
|
+
flexDirection: "column",
|
|
1228
|
+
width: promptWidth,
|
|
1229
|
+
flexShrink: 0
|
|
1230
|
+
}, /* @__PURE__ */ React.createElement(Styled, { tone: promptTone }, prompt)), /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1231
|
+
flexDirection: "column",
|
|
1232
|
+
flexGrow: 1
|
|
1233
|
+
}, input)), below);
|
|
1234
|
+
return /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column" }, attachments, /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1235
|
+
...width$2 === void 0 ? {} : { width: width$2 },
|
|
1236
|
+
borderStyle: "round",
|
|
1237
|
+
borderColor: theme.chrome.color ?? "gray",
|
|
1238
|
+
borderDimColor: Boolean(theme.chrome.dim),
|
|
1239
|
+
paddingX: 1
|
|
1240
|
+
}, /* @__PURE__ */ React.createElement(ink.Box, { marginRight: 1 }, /* @__PURE__ */ React.createElement(Styled, { tone: promptTone }, glyphs.prompt)), /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1241
|
+
flexDirection: "column",
|
|
1242
|
+
flexGrow: 1
|
|
1243
|
+
}, input)), below);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
//#endregion
|
|
1247
|
+
//#region src/Spinner.tsx
|
|
1248
|
+
/**
|
|
1249
|
+
* An animated spinner in the `marker` role, using the injected frames. It
|
|
1250
|
+
* steps once per tick of the shared clock when a ClockProvider is in scope,
|
|
1251
|
+
* else every `spinnerIntervalMs`.
|
|
1252
|
+
*/
|
|
1253
|
+
function Spinner() {
|
|
1254
|
+
const { glyphs, spinnerIntervalMs } = useInkComponents();
|
|
1255
|
+
const frame = hooks.useTicks(true, spinnerIntervalMs);
|
|
1256
|
+
const frames = glyphs.spinner;
|
|
1257
|
+
return /* @__PURE__ */ React.createElement(Styled, { tone: "marker" }, frames[frame % frames.length] ?? "");
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
//#endregion
|
|
1261
|
+
//#region src/Row.tsx
|
|
1262
|
+
function Row({ label, detail, qualifier, iconName, prefix: prefix$1, isActive = false, startedAt, durationMs, showTimer = false, tokensPerSecond, error = false, expanded = false, children }) {
|
|
1263
|
+
const { glyphs, flashComplete } = useInkComponents();
|
|
1264
|
+
const flash = hooks.useFlashComplete(isActive, 1200, flashComplete);
|
|
1265
|
+
const elapsed = hooks.useElapsedTimer({
|
|
1266
|
+
startedAt,
|
|
1267
|
+
isActive: isActive && showTimer,
|
|
1268
|
+
durationMs
|
|
1269
|
+
});
|
|
1270
|
+
const hasBody = children != null && children !== false;
|
|
1271
|
+
const timer = shouldShowTimer({
|
|
1272
|
+
showTimer,
|
|
1273
|
+
isActive,
|
|
1274
|
+
startedAt,
|
|
1275
|
+
durationMs
|
|
1276
|
+
});
|
|
1277
|
+
const tps = formatTokensPerSecond(tokensPerSecond);
|
|
1278
|
+
let affordance;
|
|
1279
|
+
if (isActive) affordance = /* @__PURE__ */ React.createElement(Spinner, null);
|
|
1280
|
+
else if (flash) affordance = /* @__PURE__ */ React.createElement(Styled, { tone: "positive" }, glyphs.success);
|
|
1281
|
+
else if (hasBody) affordance = /* @__PURE__ */ React.createElement(Styled, { tone: "marker" }, expanded ? glyphs.expanded : glyphs.collapsed);
|
|
1282
|
+
else affordance = /* @__PURE__ */ React.createElement(Text, null, " ");
|
|
1283
|
+
return /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(ink.Box, { gap: 1 }, affordance, prefix$1, iconName ? /* @__PURE__ */ React.createElement(Styled, { tone: "marker" }, glyphs[iconName]) : null, /* @__PURE__ */ React.createElement(Styled, {
|
|
1284
|
+
tone: error ? "danger" : "strong",
|
|
1285
|
+
wrap: "truncate-end"
|
|
1286
|
+
}, label), detail ? /* @__PURE__ */ React.createElement(Styled, {
|
|
1287
|
+
tone: "muted",
|
|
1288
|
+
wrap: "truncate-end"
|
|
1289
|
+
}, detail) : null, error ? /* @__PURE__ */ React.createElement(Styled, { tone: "danger" }, glyphs.error, " Error") : null, qualifier ? /* @__PURE__ */ React.createElement(Styled, { tone: "danger" }, "· ", qualifier) : null, timer ? /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, formatDuration(elapsed)) : null, tps ? /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, tps) : null), hasBody && expanded ? /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1290
|
+
flexDirection: "column",
|
|
1291
|
+
paddingLeft: 2
|
|
1292
|
+
}, children) : null);
|
|
1293
|
+
}
|
|
1294
|
+
/** Clip long text to `maxLines`, noting how many lines were hidden. */
|
|
1295
|
+
function clipLines(text, maxLines) {
|
|
1296
|
+
const lines = text.split("\n");
|
|
1297
|
+
if (lines.length <= maxLines) return text;
|
|
1298
|
+
return [...lines.slice(0, maxLines), `… ${lines.length - maxLines} more lines`].join("\n");
|
|
1299
|
+
}
|
|
1300
|
+
/** Pretty JSON, tolerating values JSON cannot encode. */
|
|
1301
|
+
function toJson(value) {
|
|
1302
|
+
if (typeof value === "string") return value;
|
|
1303
|
+
try {
|
|
1304
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
1305
|
+
} catch {
|
|
1306
|
+
return String(value);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
//#endregion
|
|
1311
|
+
//#region src/MessageView.tsx
|
|
1312
|
+
const APPROVAL_TEXT = {
|
|
1313
|
+
denied: "denied",
|
|
1314
|
+
unavailable: "refused",
|
|
1315
|
+
pending: "awaiting approval"
|
|
1316
|
+
};
|
|
1317
|
+
function BlockRow({ block, ctx }) {
|
|
1318
|
+
const { Markdown } = useInkComponents();
|
|
1319
|
+
const live = isBlockActive(block, ctx);
|
|
1320
|
+
if (!isCoreBlockType(block.type)) {
|
|
1321
|
+
const custom = block;
|
|
1322
|
+
const Renderer = lookupExtension(ctx.extensions?.blockRenderers, custom.type);
|
|
1323
|
+
if (Renderer) return /* @__PURE__ */ React.createElement(Renderer, {
|
|
1324
|
+
block: custom,
|
|
1325
|
+
message: ctx.message,
|
|
1326
|
+
isStreaming: live
|
|
1327
|
+
});
|
|
1328
|
+
return /* @__PURE__ */ React.createElement(Row, { label: summarizeBlock(custom).label });
|
|
1329
|
+
}
|
|
1330
|
+
const core = block;
|
|
1331
|
+
switch (core.type) {
|
|
1332
|
+
case "text": return ctx.message.role === "user" ? /* @__PURE__ */ React.createElement(Styled, { tone: "user" }, core.text) : /* @__PURE__ */ React.createElement(Markdown, {
|
|
1333
|
+
content: core.text,
|
|
1334
|
+
...live ? { streaming: true } : {}
|
|
1335
|
+
});
|
|
1336
|
+
case "reasoning": {
|
|
1337
|
+
const summary = summarizeBlock(core, { isActive: live });
|
|
1338
|
+
return /* @__PURE__ */ React.createElement(Row, {
|
|
1339
|
+
label: summary.label,
|
|
1340
|
+
iconName: "brain",
|
|
1341
|
+
isActive: live,
|
|
1342
|
+
startedAt: positiveTimestamp(core.startedAt) ?? ctx.startedAt,
|
|
1343
|
+
durationMs: core.durationMs,
|
|
1344
|
+
showTimer: summary.showTimer,
|
|
1345
|
+
expanded: ctx.expanded
|
|
1346
|
+
}, core.text ? /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, clipLines(core.text, ctx.bodyLines)) : null);
|
|
1347
|
+
}
|
|
1348
|
+
case "tool_call": {
|
|
1349
|
+
const isError = core.status === "error";
|
|
1350
|
+
const input = toolDisplayInput(core);
|
|
1351
|
+
const approval = core.approval ? APPROVAL_TEXT[core.approval.state] : void 0;
|
|
1352
|
+
return /* @__PURE__ */ React.createElement(Row, {
|
|
1353
|
+
label: toolLabel(core) || "tool",
|
|
1354
|
+
...input ? { detail: input } : {},
|
|
1355
|
+
...approval ? { qualifier: approval } : {},
|
|
1356
|
+
iconName: "wrench",
|
|
1357
|
+
isActive: live,
|
|
1358
|
+
startedAt: core.startedAt,
|
|
1359
|
+
durationMs: core.durationMs,
|
|
1360
|
+
showTimer: true,
|
|
1361
|
+
error: isError,
|
|
1362
|
+
expanded: ctx.expanded
|
|
1363
|
+
}, /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, "input"), /* @__PURE__ */ React.createElement(Styled, { tone: "content" }, clipLines(toJson(core.arguments), ctx.bodyLines)), core.result !== void 0 ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, "output"), /* @__PURE__ */ React.createElement(Styled, { tone: isError ? "danger" : "content" }, clipLines(toolResultText(core.result), ctx.bodyLines))) : null));
|
|
1364
|
+
}
|
|
1365
|
+
case "status": return /* @__PURE__ */ React.createElement(Row, {
|
|
1366
|
+
label: statusLabel(core.phase, live),
|
|
1367
|
+
isActive: live,
|
|
1368
|
+
startedAt: ctx.startedAt,
|
|
1369
|
+
durationMs: ctx.durationMs,
|
|
1370
|
+
showTimer: true,
|
|
1371
|
+
...live ? { tokensPerSecond: ctx.tokensPerSecond } : {}
|
|
1372
|
+
});
|
|
1373
|
+
case "plan": {
|
|
1374
|
+
const summary = summarizeBlock(core);
|
|
1375
|
+
return /* @__PURE__ */ React.createElement(Row, {
|
|
1376
|
+
label: summary.label,
|
|
1377
|
+
iconName: "checklist",
|
|
1378
|
+
expanded: ctx.expanded
|
|
1379
|
+
}, core.entries.length > 0 ? /* @__PURE__ */ React.createElement(PlanEntries, {
|
|
1380
|
+
entries: core.entries,
|
|
1381
|
+
live: ctx.isActive
|
|
1382
|
+
}) : null);
|
|
1383
|
+
}
|
|
1384
|
+
case "system": return /* @__PURE__ */ React.createElement(Row, {
|
|
1385
|
+
label: "System prompt",
|
|
1386
|
+
iconName: "gear",
|
|
1387
|
+
expanded: ctx.expanded
|
|
1388
|
+
}, /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, clipLines(core.text, ctx.bodyLines)));
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
function PlanEntries({ entries, live }) {
|
|
1392
|
+
const { glyphs } = useInkComponents();
|
|
1393
|
+
return /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column" }, entries.map((entry, index) => {
|
|
1394
|
+
const marker = entry.status === "completed" ? glyphs.planDone : entry.status === "in_progress" ? glyphs.planActive : glyphs.planPending;
|
|
1395
|
+
const tone = entry.status === "completed" ? "muted" : entry.status === "in_progress" && live ? "caution" : "content";
|
|
1396
|
+
return /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1397
|
+
key: entry.id ?? index,
|
|
1398
|
+
gap: 1
|
|
1399
|
+
}, entry.status === "in_progress" && live ? /* @__PURE__ */ React.createElement(Spinner, null) : /* @__PURE__ */ React.createElement(Styled, { tone: entry.status === "completed" ? "positive" : "marker" }, marker), /* @__PURE__ */ React.createElement(Styled, { tone }, entry.content));
|
|
1400
|
+
}));
|
|
1401
|
+
}
|
|
1402
|
+
function GroupRow({ blocks, ctx }) {
|
|
1403
|
+
const plan = planGroupHeader(blocks, ctx);
|
|
1404
|
+
const tokensPerSecond = plan.showRate ? ctx.tokensPerSecond : void 0;
|
|
1405
|
+
const only = plan.tools?.only;
|
|
1406
|
+
const approval = only?.approval ? APPROVAL_TEXT[only.approval.state] : void 0;
|
|
1407
|
+
return /* @__PURE__ */ React.createElement(Row, {
|
|
1408
|
+
label: plan.summary.label,
|
|
1409
|
+
...approval ? { qualifier: approval } : {},
|
|
1410
|
+
error: only !== void 0 && plan.tools?.failed === 1,
|
|
1411
|
+
iconName: plan.summary.iconName,
|
|
1412
|
+
prefix: plan.hiddenCount > 0 ? /* @__PURE__ */ React.createElement(Styled, { tone: "marker" }, "(+", plan.hiddenCount, ")") : null,
|
|
1413
|
+
isActive: plan.isActive,
|
|
1414
|
+
startedAt: plan.startedAt,
|
|
1415
|
+
durationMs: plan.durationMs,
|
|
1416
|
+
showTimer: plan.showTimer,
|
|
1417
|
+
tokensPerSecond,
|
|
1418
|
+
expanded: ctx.expanded
|
|
1419
|
+
}, blocks.map((block) => /* @__PURE__ */ React.createElement(BlockRow, {
|
|
1420
|
+
key: block.id,
|
|
1421
|
+
block,
|
|
1422
|
+
ctx
|
|
1423
|
+
})));
|
|
1424
|
+
}
|
|
1425
|
+
function MessageView({ message: original, isActive = false, expanded = false, minimal = false, showFooter = true, bodyLines = 12, extensions, retryHint, activityPlacement, liveTail, liveFooter = false }) {
|
|
1426
|
+
const { glyphs } = useInkComponents();
|
|
1427
|
+
const message = stripControlsDeep(original);
|
|
1428
|
+
const live = isActive && liveFooter;
|
|
1429
|
+
const now = hooks.useElapsedTimer({
|
|
1430
|
+
startedAt: 0,
|
|
1431
|
+
isActive: live,
|
|
1432
|
+
intervalMs: 1e3
|
|
1433
|
+
});
|
|
1434
|
+
const tokensPerSecond = hooks.useLiveTokensPerSecond({
|
|
1435
|
+
startedAt: message.createdAt,
|
|
1436
|
+
isActive,
|
|
1437
|
+
tokenCount: isActive ? approxTokenCount(message.blocks) : void 0,
|
|
1438
|
+
metrics: message.metrics
|
|
1439
|
+
});
|
|
1440
|
+
const plan = planMessage(message, {
|
|
1441
|
+
isActive,
|
|
1442
|
+
minimal,
|
|
1443
|
+
showFooter,
|
|
1444
|
+
...activityPlacement ? { activityPlacement } : {},
|
|
1445
|
+
...liveTail !== void 0 && !expanded ? { liveTail } : {}
|
|
1446
|
+
});
|
|
1447
|
+
const ctx = {
|
|
1448
|
+
message,
|
|
1449
|
+
isActive,
|
|
1450
|
+
activeBlockId: plan.activeBlockId,
|
|
1451
|
+
startedAt: message.createdAt,
|
|
1452
|
+
durationMs: message.metrics?.durationMs,
|
|
1453
|
+
tokensPerSecond,
|
|
1454
|
+
expanded,
|
|
1455
|
+
bodyLines,
|
|
1456
|
+
extensions,
|
|
1457
|
+
outcome: message.outcome
|
|
1458
|
+
};
|
|
1459
|
+
const showsSomething$1 = message.blocks.length > 0 || message.outcome !== void 0;
|
|
1460
|
+
const footerFacts = plan.showFooter || live && showsSomething$1 ? planFooter(message, {
|
|
1461
|
+
isActive,
|
|
1462
|
+
detail: expanded ? "details" : "collapsed",
|
|
1463
|
+
...live ? { now } : {}
|
|
1464
|
+
}) : void 0;
|
|
1465
|
+
const factGlyph = {
|
|
1466
|
+
reasoning: glyphs.brain,
|
|
1467
|
+
plan: glyphs.checklist,
|
|
1468
|
+
tools: glyphs.wrench
|
|
1469
|
+
};
|
|
1470
|
+
const footer = footerFacts ? [...footerFacts.map((fact) => factGlyph[fact.key] ? `${factGlyph[fact.key]} ${fact.text}` : fact.text), ...message.outcome && retryHint ? [retryHint] : []].join(glyphs.separator) || void 0 : void 0;
|
|
1471
|
+
const isUser = message.role === "user";
|
|
1472
|
+
const attachments = message.attachments ?? [];
|
|
1473
|
+
return /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1474
|
+
flexDirection: "column",
|
|
1475
|
+
marginBottom: 1
|
|
1476
|
+
}, /* @__PURE__ */ React.createElement(ink.Box, { gap: 1 }, /* @__PURE__ */ React.createElement(Styled, { tone: isUser ? "user" : "assistant" }, isUser ? glyphs.user : glyphs.assistant), /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1477
|
+
flexDirection: "column",
|
|
1478
|
+
flexGrow: 1
|
|
1479
|
+
}, plan.rows.map((row) => row.kind === "pending" ? /* @__PURE__ */ React.createElement(BlockRow, {
|
|
1480
|
+
key: row.id,
|
|
1481
|
+
block: {
|
|
1482
|
+
type: "status",
|
|
1483
|
+
id: row.id,
|
|
1484
|
+
phase: "pre-stream"
|
|
1485
|
+
},
|
|
1486
|
+
ctx: {
|
|
1487
|
+
...ctx,
|
|
1488
|
+
activeBlockId: row.id
|
|
1489
|
+
}
|
|
1490
|
+
}) : row.kind === "bare" ? /* @__PURE__ */ React.createElement(BlockRow, {
|
|
1491
|
+
key: row.block.id,
|
|
1492
|
+
block: row.block,
|
|
1493
|
+
ctx
|
|
1494
|
+
}) : row.kind === "earlier" ? /* @__PURE__ */ React.createElement(Styled, {
|
|
1495
|
+
key: row.key,
|
|
1496
|
+
tone: "muted"
|
|
1497
|
+
}, glyphs.earlier ? `${glyphs.earlier} ${earlierLabel(row)}` : earlierLabel(row)) : row.kind === "outcome" ? /* @__PURE__ */ React.createElement(Styled, {
|
|
1498
|
+
key: "outcome",
|
|
1499
|
+
tone: "danger"
|
|
1500
|
+
}, glyphs.error, " ", row.outcome.message) : /* @__PURE__ */ React.createElement(GroupRow, {
|
|
1501
|
+
key: row.key,
|
|
1502
|
+
blocks: row.blocks,
|
|
1503
|
+
ctx
|
|
1504
|
+
})), attachments.map((a, i) => /* @__PURE__ */ React.createElement(Styled, {
|
|
1505
|
+
key: `${a.url}-${i}`,
|
|
1506
|
+
tone: "muted"
|
|
1507
|
+
}, "[", a.kind, "] ", a.name ?? a.mediaType)), footer ? /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, footer) : null)));
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
//#endregion
|
|
1511
|
+
//#region src/Transcript.tsx
|
|
1512
|
+
function Transcript({ messages, isStreaming = false, streamingMessageId, expanded = false, expandFinished = false, minimal = false, showFooter = true, bodyLines, extensions, retryHint, activityPlacement, liveTail, liveFooter }) {
|
|
1513
|
+
const liveIndex = isStreaming ? messages.findIndex((message) => message.id === streamingMessageId) : -1;
|
|
1514
|
+
const finished = liveIndex === -1 ? messages : messages.slice(0, liveIndex);
|
|
1515
|
+
const live = liveIndex === -1 ? [] : messages.slice(liveIndex);
|
|
1516
|
+
const common = {
|
|
1517
|
+
minimal,
|
|
1518
|
+
showFooter,
|
|
1519
|
+
...bodyLines !== void 0 ? { bodyLines } : {},
|
|
1520
|
+
...extensions ? { extensions } : {},
|
|
1521
|
+
...retryHint ? { retryHint } : {},
|
|
1522
|
+
...activityPlacement ? { activityPlacement } : {},
|
|
1523
|
+
...liveTail !== void 0 ? { liveTail } : {},
|
|
1524
|
+
...liveFooter !== void 0 ? { liveFooter } : {}
|
|
1525
|
+
};
|
|
1526
|
+
return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(ink.Static, { items: finished }, (message) => /* @__PURE__ */ React.createElement(MessageView, {
|
|
1527
|
+
key: message.id,
|
|
1528
|
+
message,
|
|
1529
|
+
expanded: expandFinished,
|
|
1530
|
+
...common
|
|
1531
|
+
})), live.length > 0 ? /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column" }, live.map((message) => /* @__PURE__ */ React.createElement(MessageView, {
|
|
1532
|
+
key: message.id,
|
|
1533
|
+
message,
|
|
1534
|
+
isActive: message.id === streamingMessageId,
|
|
1535
|
+
expanded,
|
|
1536
|
+
...common
|
|
1537
|
+
}))) : null);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
//#endregion
|
|
1541
|
+
//#region src/lines/Lines.tsx
|
|
1542
|
+
/** A span's resolved text props. */
|
|
1543
|
+
function spanProps(s, theme, highlight) {
|
|
1544
|
+
const base = {
|
|
1545
|
+
...s.role ? theme[s.role] : {},
|
|
1546
|
+
...s.style,
|
|
1547
|
+
...s.bold !== void 0 ? { bold: s.bold } : {},
|
|
1548
|
+
...s.italic ? { italic: true } : {},
|
|
1549
|
+
...s.dim ? { dim: true } : {},
|
|
1550
|
+
...s.underline ? { underline: true } : {},
|
|
1551
|
+
...s.strike ? { strikethrough: true } : {},
|
|
1552
|
+
...s.inverse ? { inverse: true } : {},
|
|
1553
|
+
...s.color ? { color: s.color } : {},
|
|
1554
|
+
...highlight
|
|
1555
|
+
};
|
|
1556
|
+
return appearanceProps(base);
|
|
1557
|
+
}
|
|
1558
|
+
const CHUNK = 8;
|
|
1559
|
+
/** Split text into runs that keep width measurement cheap. */
|
|
1560
|
+
function splitRuns(text) {
|
|
1561
|
+
const runs = [];
|
|
1562
|
+
let ascii = "";
|
|
1563
|
+
const chars = Array.from(text);
|
|
1564
|
+
let i = 0;
|
|
1565
|
+
while (i < chars.length) {
|
|
1566
|
+
const ch = chars[i];
|
|
1567
|
+
if (ch.charCodeAt(0) < 128) {
|
|
1568
|
+
ascii += ch;
|
|
1569
|
+
i++;
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
if (ascii) {
|
|
1573
|
+
runs.push(ascii);
|
|
1574
|
+
ascii = "";
|
|
1575
|
+
}
|
|
1576
|
+
let j = i;
|
|
1577
|
+
while (j < chars.length && chars[j] === ch) j++;
|
|
1578
|
+
const repeat = j - i;
|
|
1579
|
+
if (repeat > 1) {
|
|
1580
|
+
for (let k = 0; k < repeat; k += CHUNK) runs.push(ch.repeat(Math.min(CHUNK, repeat - k)));
|
|
1581
|
+
i = j;
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
let stretch = "";
|
|
1585
|
+
while (i < chars.length && chars[i].charCodeAt(0) >= 128 && chars[i] !== chars[i + 1]) {
|
|
1586
|
+
stretch += chars[i];
|
|
1587
|
+
i++;
|
|
1588
|
+
}
|
|
1589
|
+
runs.push(stretch);
|
|
1590
|
+
}
|
|
1591
|
+
if (ascii) runs.push(ascii);
|
|
1592
|
+
return runs;
|
|
1593
|
+
}
|
|
1594
|
+
const runCache = new WeakMap();
|
|
1595
|
+
function lineNode(line, theme) {
|
|
1596
|
+
const cached$1 = runCache.get(line);
|
|
1597
|
+
if (cached$1 && cached$1.theme === theme) return cached$1.node;
|
|
1598
|
+
const node = /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1599
|
+
height: 1,
|
|
1600
|
+
overflow: "hidden",
|
|
1601
|
+
flexShrink: 0
|
|
1602
|
+
}, line.spans.length === 0 ? /* @__PURE__ */ React.createElement(Text, null, " ") : line.spans.flatMap((s, si) => {
|
|
1603
|
+
const props = spanProps(s, theme);
|
|
1604
|
+
return splitRuns(s.text).map((run, ri) => /* @__PURE__ */ React.createElement(Text, {
|
|
1605
|
+
key: `${si}:${ri}`,
|
|
1606
|
+
wrap: "truncate",
|
|
1607
|
+
...props
|
|
1608
|
+
}, run));
|
|
1609
|
+
}));
|
|
1610
|
+
runCache.set(line, {
|
|
1611
|
+
theme,
|
|
1612
|
+
node
|
|
1613
|
+
});
|
|
1614
|
+
return node;
|
|
1615
|
+
}
|
|
1616
|
+
function Lines({ lines, theme, override }) {
|
|
1617
|
+
return /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1618
|
+
flexDirection: "column",
|
|
1619
|
+
flexShrink: 0
|
|
1620
|
+
}, lines.map((line, i) => /* @__PURE__ */ React.createElement(React.Fragment, { key: i }, override?.(line, i) ?? lineNode(line, theme))));
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
//#endregion
|
|
1624
|
+
//#region src/lines/Status.tsx
|
|
1625
|
+
function defaultPhaseLabel(phase, headline) {
|
|
1626
|
+
switch (phase.kind) {
|
|
1627
|
+
case "waiting": return "waiting";
|
|
1628
|
+
case "thinking": return "thinking";
|
|
1629
|
+
case "writing": return "writing";
|
|
1630
|
+
case "tool": return `running ${headline}`;
|
|
1631
|
+
case "approval": return `awaiting approval ${headline}`;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
function StatusLine({ width: width$2, live, notice, idle, below = 0, extras = [], phaseLabel, slowHint, accent, measure = defaultMeasure }) {
|
|
1635
|
+
const { glyphs, theme } = useInkComponents();
|
|
1636
|
+
const ticks = hooks.useTicks(live !== void 0, 1e3);
|
|
1637
|
+
const now = hooks.useClock(false);
|
|
1638
|
+
const rule = glyphs.boxHorizontal;
|
|
1639
|
+
const phase = live ? livePhase(live.message) : void 0;
|
|
1640
|
+
const call = phase?.kind === "tool" || phase?.kind === "approval" ? phase.call : void 0;
|
|
1641
|
+
const headline = React.useMemo(() => call ? truncate(toolHeadline(call), 32) : void 0, [
|
|
1642
|
+
call,
|
|
1643
|
+
call?.title,
|
|
1644
|
+
call?.name,
|
|
1645
|
+
call?.inputSummary,
|
|
1646
|
+
call?.arguments
|
|
1647
|
+
]);
|
|
1648
|
+
let label = null;
|
|
1649
|
+
let labelText = "";
|
|
1650
|
+
if (live) {
|
|
1651
|
+
const current = phase;
|
|
1652
|
+
const elapsed = now - live.startedAt;
|
|
1653
|
+
const facts = [phaseLabel?.(current) ?? defaultPhaseLabel(current, headline), formatElapsed(elapsed, { live: true })];
|
|
1654
|
+
const tools = countActivity(live.message.blocks).tools.total;
|
|
1655
|
+
if (tools > 0) facts.push(`${tools} tool${tools === 1 ? "" : "s"}`);
|
|
1656
|
+
if (slowHint && elapsed >= slowHint.afterMs && slowHint.phases.includes(current.kind)) facts.push(slowHint.text);
|
|
1657
|
+
facts.push(...extras);
|
|
1658
|
+
const pulse = glyphs.pulse[ticks % glyphs.pulse.length] ?? "";
|
|
1659
|
+
const factText = stripControls(facts.join(glyphs.separator));
|
|
1660
|
+
labelText = ` ${pulse} ${factText} `;
|
|
1661
|
+
label = /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Text, null, " "), /* @__PURE__ */ React.createElement(Text, appearanceProps(accent ?? theme.accent), pulse), /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, " ", factText, " "));
|
|
1662
|
+
} else if (notice) {
|
|
1663
|
+
const text = ` ${notice.tone === "positive" ? glyphs.success : glyphs.error} ${stripControls(notice.text)} `;
|
|
1664
|
+
labelText = text;
|
|
1665
|
+
label = /* @__PURE__ */ React.createElement(Styled, { tone: notice.tone }, text);
|
|
1666
|
+
} else if (idle || below > 0) {
|
|
1667
|
+
labelText = ` ${[idle && stripControls(idle), below > 0 ? `↓ ${below} more` : void 0].filter(Boolean).join(glyphs.separator)} `;
|
|
1668
|
+
label = /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, labelText);
|
|
1669
|
+
}
|
|
1670
|
+
const lead = rule.repeat(2);
|
|
1671
|
+
const rest = Math.max(0, width$2 - measure(lead) - measure(labelText));
|
|
1672
|
+
if (measure(lead) + measure(labelText) > width$2) return /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1673
|
+
height: 1,
|
|
1674
|
+
overflow: "hidden"
|
|
1675
|
+
}, /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, /* @__PURE__ */ React.createElement(Styled, { tone: "chrome" }, lead), truncate(labelText, Math.max(0, width$2 - measure(lead)), measure)));
|
|
1676
|
+
return /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1677
|
+
height: 1,
|
|
1678
|
+
overflow: "hidden"
|
|
1679
|
+
}, /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, /* @__PURE__ */ React.createElement(Styled, { tone: "chrome" }, lead), label, /* @__PURE__ */ React.createElement(Styled, { tone: "chrome" }, rule.repeat(rest))));
|
|
1680
|
+
}
|
|
1681
|
+
/** Messages queued while an answer streams (the queue itself is host state). */
|
|
1682
|
+
function PendingRows({ items, width: width$2, maxRows = 3, measure = defaultMeasure }) {
|
|
1683
|
+
const { glyphs } = useInkComponents();
|
|
1684
|
+
if (items.length === 0) return null;
|
|
1685
|
+
const shown = items.slice(-maxRows);
|
|
1686
|
+
const hidden = items.length - shown.length;
|
|
1687
|
+
return /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column" }, shown.map((item, i) => {
|
|
1688
|
+
const more = i === 0 && hidden > 0 ? `+${hidden} · ` : "";
|
|
1689
|
+
const clip = item.attachments ? `${glyphs.attachment}${item.attachments} ` : "";
|
|
1690
|
+
const text = `${more}${clip}${stripControls(item.text).replace(/\s+/g, " ").trim()}`;
|
|
1691
|
+
return /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1692
|
+
key: i,
|
|
1693
|
+
height: 1,
|
|
1694
|
+
overflow: "hidden"
|
|
1695
|
+
}, /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, /* @__PURE__ */ React.createElement(Styled, { tone: "caution" }, glyphs.pending, " "), /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, truncate(text, Math.max(1, width$2 - measure(glyphs.pending) - 1), measure))));
|
|
1696
|
+
}));
|
|
1697
|
+
}
|
|
1698
|
+
/** A centred empty state; pass it as the viewport's `empty`. */
|
|
1699
|
+
function EmptyState({ title, subtitle, notes = [], accent }) {
|
|
1700
|
+
const { theme } = useInkComponents();
|
|
1701
|
+
return /* @__PURE__ */ React.createElement(ink.Box, {
|
|
1702
|
+
flexDirection: "column",
|
|
1703
|
+
alignItems: "center"
|
|
1704
|
+
}, /* @__PURE__ */ React.createElement(Text, {
|
|
1705
|
+
...appearanceProps(accent ?? theme.accent),
|
|
1706
|
+
bold: true
|
|
1707
|
+
}, title), subtitle ? /* @__PURE__ */ React.createElement(Styled, { tone: "muted" }, subtitle) : null, notes.map((note) => /* @__PURE__ */ React.createElement(Styled, {
|
|
1708
|
+
key: note,
|
|
1709
|
+
tone: "muted"
|
|
1710
|
+
}, note)));
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
//#endregion
|
|
1714
|
+
//#region src/lines/model.ts
|
|
1715
|
+
/** A span. */
|
|
1716
|
+
function span(text, extra = {}) {
|
|
1717
|
+
return {
|
|
1718
|
+
text,
|
|
1719
|
+
...extra
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
/** Prepend spans, keeping the line's metadata. */
|
|
1723
|
+
function prefix(line, spans) {
|
|
1724
|
+
return {
|
|
1725
|
+
...line,
|
|
1726
|
+
spans: [...spans, ...line.spans]
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
/** Append spans, keeping the line's metadata. */
|
|
1730
|
+
function suffix(line, spans) {
|
|
1731
|
+
return {
|
|
1732
|
+
...line,
|
|
1733
|
+
spans: [...line.spans, ...spans]
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
/** The line's text as drawn. */
|
|
1737
|
+
function lineText(line) {
|
|
1738
|
+
return line.spans.map((s) => s.text).join("");
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
//#endregion
|
|
1742
|
+
//#region src/lines/frames.ts
|
|
1743
|
+
const blank$1 = () => ({
|
|
1744
|
+
spans: [],
|
|
1745
|
+
frame: true
|
|
1746
|
+
});
|
|
1747
|
+
/**
|
|
1748
|
+
* Compact: a role glyph before the first row (`›` user, `•` answer), rows
|
|
1749
|
+
* indented under it, a dim footer line, a blank line after each message.
|
|
1750
|
+
*/
|
|
1751
|
+
const compactFrame = {
|
|
1752
|
+
id: "compact",
|
|
1753
|
+
innerWidth: (_message, width$2) => Math.max(1, width$2 - 2),
|
|
1754
|
+
layout({ message, body, footer, ctx, accent }) {
|
|
1755
|
+
const user = message.role === "user";
|
|
1756
|
+
const glyph = user ? ctx.glyphs.user : ctx.glyphs.assistant;
|
|
1757
|
+
const lead = span(`${glyph} `, user ? {
|
|
1758
|
+
role: "user",
|
|
1759
|
+
chrome: true
|
|
1760
|
+
} : {
|
|
1761
|
+
style: accent,
|
|
1762
|
+
chrome: true
|
|
1763
|
+
});
|
|
1764
|
+
const pad$1 = span(" ", { chrome: true });
|
|
1765
|
+
const lines = body.map((line, i) => ({
|
|
1766
|
+
...line,
|
|
1767
|
+
spans: [i === 0 ? lead : pad$1, ...line.spans]
|
|
1768
|
+
}));
|
|
1769
|
+
if (lines.length === 0 && footer) lines.push({
|
|
1770
|
+
spans: [lead],
|
|
1771
|
+
frame: true
|
|
1772
|
+
});
|
|
1773
|
+
if (footer) lines.push({
|
|
1774
|
+
spans: [pad$1, ...footer],
|
|
1775
|
+
frame: true
|
|
1776
|
+
});
|
|
1777
|
+
if (lines.length > 0) lines.push(blank$1());
|
|
1778
|
+
return lines;
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
/**
|
|
1782
|
+
* Threaded: the user's message in a round-cornered box with its time set into
|
|
1783
|
+
* the top edge; an answer hangs from the box on a gutter in its accent and
|
|
1784
|
+
* ends with a footer corner (bare when the answer has no footer facts). A live answer that shows nothing yet draws
|
|
1785
|
+
* nothing.
|
|
1786
|
+
*/
|
|
1787
|
+
const threadedFrame = {
|
|
1788
|
+
id: "threaded",
|
|
1789
|
+
innerWidth: (message, width$2) => Math.max(1, width$2 - (message.role === "user" ? 4 : 2)),
|
|
1790
|
+
layout(input) {
|
|
1791
|
+
return input.message.role === "user" ? userBox(input) : answerGutter(input);
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
function userBox({ message, ctx, nextShows }) {
|
|
1795
|
+
const g = ctx.glyphs;
|
|
1796
|
+
const max = Math.max(1, ctx.width - 4);
|
|
1797
|
+
const text = message.blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
1798
|
+
const rows = wrapLines(text, max, ctx.measure);
|
|
1799
|
+
for (const attachment of message.attachments ?? []) {
|
|
1800
|
+
const label = attachment.description ?? attachment.name ?? attachment.mediaType;
|
|
1801
|
+
rows.push({
|
|
1802
|
+
text: truncate(`${g.attachment} ${label}`, max, ctx.measure),
|
|
1803
|
+
dim: true
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
const time = message.createdAt !== void 0 ? formatClockTime(message.createdAt).slice(0, 5) : "";
|
|
1807
|
+
const timeLabel = time ? ` ${time} ` : "";
|
|
1808
|
+
const inner = Math.min(max, Math.max(ctx.measure(timeLabel) + 2, ...rows.map((row) => ctx.measure(row.text))));
|
|
1809
|
+
const edge = (n) => g.boxHorizontal.repeat(Math.max(0, n));
|
|
1810
|
+
const chrome = (text$1) => span(text$1, {
|
|
1811
|
+
role: "chrome",
|
|
1812
|
+
chrome: true
|
|
1813
|
+
});
|
|
1814
|
+
const topFill = inner + 2 - ctx.measure(timeLabel) - 1;
|
|
1815
|
+
const lines = [
|
|
1816
|
+
{
|
|
1817
|
+
spans: [chrome(`${g.boxTopLeft}${edge(topFill)}${timeLabel}${edge(1)}${g.boxTopRight}`)],
|
|
1818
|
+
frame: true
|
|
1819
|
+
},
|
|
1820
|
+
...rows.map((row) => ({
|
|
1821
|
+
spans: [
|
|
1822
|
+
chrome(`${g.boxVertical} `),
|
|
1823
|
+
span(row.text, {
|
|
1824
|
+
role: "user",
|
|
1825
|
+
...row.dim ? {
|
|
1826
|
+
dim: true,
|
|
1827
|
+
bold: false
|
|
1828
|
+
} : {}
|
|
1829
|
+
}),
|
|
1830
|
+
chrome(`${" ".repeat(Math.max(0, inner - ctx.measure(row.text)))} ${g.boxVertical}`)
|
|
1831
|
+
],
|
|
1832
|
+
...row.wrap ? { wrap: row.wrap } : {}
|
|
1833
|
+
})),
|
|
1834
|
+
{
|
|
1835
|
+
spans: [chrome(`${nextShows ? g.boxJunction : g.boxBottomLeft}${edge(inner + 2)}${g.boxBottomRight}`)],
|
|
1836
|
+
frame: true
|
|
1837
|
+
}
|
|
1838
|
+
];
|
|
1839
|
+
if (!nextShows) lines.push(blank$1());
|
|
1840
|
+
return lines;
|
|
1841
|
+
}
|
|
1842
|
+
function answerGutter({ body, footer, shows, accent, ctx }) {
|
|
1843
|
+
if (!shows) return [];
|
|
1844
|
+
const g = ctx.glyphs;
|
|
1845
|
+
const gutter = span(`${g.boxVertical} `, {
|
|
1846
|
+
style: accent,
|
|
1847
|
+
chrome: true
|
|
1848
|
+
});
|
|
1849
|
+
const bare = {
|
|
1850
|
+
spans: [span(g.boxVertical, {
|
|
1851
|
+
style: accent,
|
|
1852
|
+
chrome: true
|
|
1853
|
+
})],
|
|
1854
|
+
frame: true
|
|
1855
|
+
};
|
|
1856
|
+
const lines = [bare, ...body.map((line) => ({
|
|
1857
|
+
...line,
|
|
1858
|
+
spans: [gutter, ...line.spans]
|
|
1859
|
+
}))];
|
|
1860
|
+
if (footer) lines.push(bare, {
|
|
1861
|
+
spans: [span(`${g.footer} `, {
|
|
1862
|
+
style: accent,
|
|
1863
|
+
chrome: true
|
|
1864
|
+
}), ...footer],
|
|
1865
|
+
frame: true
|
|
1866
|
+
});
|
|
1867
|
+
else lines.push({
|
|
1868
|
+
spans: [span(g.footer, {
|
|
1869
|
+
style: accent,
|
|
1870
|
+
chrome: true
|
|
1871
|
+
})],
|
|
1872
|
+
frame: true
|
|
1873
|
+
});
|
|
1874
|
+
lines.push(blank$1());
|
|
1875
|
+
return lines;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
//#endregion
|
|
1879
|
+
//#region src/lines/markdown/inline.ts
|
|
1880
|
+
const PUNCTUATION = /[!-/:-@[-`{-~]/;
|
|
1881
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
1882
|
+
const SPACE = /\s/;
|
|
1883
|
+
const ENTITY = /^&(?:#(\d{1,7})|#[xX]([\da-fA-F]{1,6})|(amp|lt|gt|quot|apos|nbsp));/;
|
|
1884
|
+
const NAMED = {
|
|
1885
|
+
amp: "&",
|
|
1886
|
+
lt: "<",
|
|
1887
|
+
gt: ">",
|
|
1888
|
+
quot: "\"",
|
|
1889
|
+
apos: "'",
|
|
1890
|
+
nbsp: "\xA0"
|
|
1891
|
+
};
|
|
1892
|
+
/**
|
|
1893
|
+
* The character reference at the start of `text` and its length, or
|
|
1894
|
+
* undefined. A numeric one naming a control character (or no character)
|
|
1895
|
+
* stays as written: decoding must not smuggle controls past sanitising.
|
|
1896
|
+
*/
|
|
1897
|
+
function entity(text) {
|
|
1898
|
+
const match = ENTITY.exec(text);
|
|
1899
|
+
if (!match) return void 0;
|
|
1900
|
+
if (match[3]) return [NAMED[match[3]], match[0].length];
|
|
1901
|
+
const code = match[1] ? Number.parseInt(match[1], 10) : Number.parseInt(match[2], 16);
|
|
1902
|
+
const control = code < 32 || code >= 127 && code < 160;
|
|
1903
|
+
if (control || code > 1114111 || code >= 55296 && code < 57344) return void 0;
|
|
1904
|
+
return [String.fromCodePoint(code), match[0].length];
|
|
1905
|
+
}
|
|
1906
|
+
/** Show a source the way a reader can use it: a `data:` URL as its type. */
|
|
1907
|
+
function describeSource(src) {
|
|
1908
|
+
if (!src.startsWith("data:")) return src;
|
|
1909
|
+
return src.slice(5).split(/[;,]/)[0] || "data";
|
|
1910
|
+
}
|
|
1911
|
+
/** Merge adjacent spans with identical attributes. */
|
|
1912
|
+
function mergeSpans(spans) {
|
|
1913
|
+
const out = [];
|
|
1914
|
+
for (const s of spans) {
|
|
1915
|
+
if (s.text === "") continue;
|
|
1916
|
+
const last = out.at(-1);
|
|
1917
|
+
if (last && sameStyle(last, s)) out[out.length - 1] = {
|
|
1918
|
+
...last,
|
|
1919
|
+
text: last.text + s.text
|
|
1920
|
+
};
|
|
1921
|
+
else out.push(s);
|
|
1922
|
+
}
|
|
1923
|
+
return out;
|
|
1924
|
+
}
|
|
1925
|
+
function sameStyle(a, b) {
|
|
1926
|
+
return a.role === b.role && a.style === b.style && a.bold === b.bold && a.italic === b.italic && a.dim === b.dim && a.underline === b.underline && a.strike === b.strike && a.inverse === b.inverse && a.color === b.color && a.chrome === b.chrome && a.copyEach === b.copyEach;
|
|
1927
|
+
}
|
|
1928
|
+
/** End of the code span opening at `i` (index after its closing run), or -1. */
|
|
1929
|
+
function codeSpanEnd(text, i) {
|
|
1930
|
+
let run = 0;
|
|
1931
|
+
while (text[i + run] === "`") run++;
|
|
1932
|
+
const fence$1 = "`".repeat(run);
|
|
1933
|
+
let j = text.indexOf(fence$1, i + run);
|
|
1934
|
+
while (j !== -1) {
|
|
1935
|
+
let k = 0;
|
|
1936
|
+
while (text[j + k] === "`") k++;
|
|
1937
|
+
if (k === run) return j + run;
|
|
1938
|
+
j = text.indexOf(fence$1, j + k);
|
|
1939
|
+
}
|
|
1940
|
+
return -1;
|
|
1941
|
+
}
|
|
1942
|
+
/** Index of the bracket closing the one at `open`, skipping code spans and escapes. */
|
|
1943
|
+
function matching(text, open, left, right) {
|
|
1944
|
+
let depth = 0;
|
|
1945
|
+
for (let i = open; i < text.length; i++) {
|
|
1946
|
+
const ch = text[i];
|
|
1947
|
+
if (ch === "\\") i++;
|
|
1948
|
+
else if (ch === "`") {
|
|
1949
|
+
const end = codeSpanEnd(text, i);
|
|
1950
|
+
if (end !== -1) i = end - 1;
|
|
1951
|
+
} else if (ch === left) depth++;
|
|
1952
|
+
else if (ch === right && --depth === 0) return i;
|
|
1953
|
+
}
|
|
1954
|
+
return -1;
|
|
1955
|
+
}
|
|
1956
|
+
function runLength(text, i) {
|
|
1957
|
+
let n = 0;
|
|
1958
|
+
while (text[i + n] === text[i]) n++;
|
|
1959
|
+
return n;
|
|
1960
|
+
}
|
|
1961
|
+
const isWordChar = (ch) => ch !== void 0 && WORD.test(ch);
|
|
1962
|
+
const isSpace = (ch) => ch === void 0 || SPACE.test(ch);
|
|
1963
|
+
/** Where the emphasis run opening at `i` (length `run`) closes, or -1. */
|
|
1964
|
+
function closer(text, i, run) {
|
|
1965
|
+
const marker = text[i];
|
|
1966
|
+
for (let j = i + run; j < text.length; j++) {
|
|
1967
|
+
const ch = text[j];
|
|
1968
|
+
if (ch === "\\") {
|
|
1969
|
+
j++;
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1972
|
+
if (ch === "`") {
|
|
1973
|
+
const end = codeSpanEnd(text, j);
|
|
1974
|
+
if (end !== -1) j = end - 1;
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
if (ch !== marker) continue;
|
|
1978
|
+
const n = runLength(text, j);
|
|
1979
|
+
if (n === run && !isSpace(text[j - 1]) && !isWordChar(text[j + n]) && j > i + run) return j;
|
|
1980
|
+
j += n - 1;
|
|
1981
|
+
}
|
|
1982
|
+
return -1;
|
|
1983
|
+
}
|
|
1984
|
+
/** A bare `http(s)://` URL at `i`, trailing punctuation excluded. */
|
|
1985
|
+
function bareUrl(text, i) {
|
|
1986
|
+
const match = /^https?:\/\/[^\s<>]+/.exec(text.slice(i));
|
|
1987
|
+
if (!match) return void 0;
|
|
1988
|
+
return match[0].replace(/[.,;:!?'")\]]+$/, "");
|
|
1989
|
+
}
|
|
1990
|
+
/** Parse inline markdown into spans, every span starting from `base`. */
|
|
1991
|
+
function parseInline(text, base, glyphs) {
|
|
1992
|
+
const out = [];
|
|
1993
|
+
let plain = "";
|
|
1994
|
+
const flush = () => {
|
|
1995
|
+
if (plain) out.push({
|
|
1996
|
+
text: plain,
|
|
1997
|
+
...base
|
|
1998
|
+
});
|
|
1999
|
+
plain = "";
|
|
2000
|
+
};
|
|
2001
|
+
const emit = (spans) => {
|
|
2002
|
+
flush();
|
|
2003
|
+
out.push(...spans);
|
|
2004
|
+
};
|
|
2005
|
+
let i = 0;
|
|
2006
|
+
while (i < text.length) {
|
|
2007
|
+
const ch = text[i];
|
|
2008
|
+
const prev = text[i - 1];
|
|
2009
|
+
if (ch === "\\" && i + 1 < text.length && PUNCTUATION.test(text[i + 1])) {
|
|
2010
|
+
plain += text[i + 1];
|
|
2011
|
+
i += 2;
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
if (ch === "`") {
|
|
2015
|
+
const end = codeSpanEnd(text, i);
|
|
2016
|
+
if (end !== -1) {
|
|
2017
|
+
const run$1 = runLength(text, i);
|
|
2018
|
+
let code = text.slice(i + run$1, end - run$1);
|
|
2019
|
+
if (code.startsWith(" ") && code.endsWith(" ") && code.trim()) code = code.slice(1, -1);
|
|
2020
|
+
emit([{
|
|
2021
|
+
text: code,
|
|
2022
|
+
...base,
|
|
2023
|
+
role: "code"
|
|
2024
|
+
}]);
|
|
2025
|
+
i = end;
|
|
2026
|
+
continue;
|
|
2027
|
+
}
|
|
2028
|
+
const run = runLength(text, i);
|
|
2029
|
+
plain += text.slice(i, i + run);
|
|
2030
|
+
i += run;
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
if ((ch === "[" || ch === "!" && text[i + 1] === "[") && text.length > i + 1) {
|
|
2034
|
+
const image = ch === "!";
|
|
2035
|
+
const open = image ? i + 1 : i;
|
|
2036
|
+
const close = matching(text, open, "[", "]");
|
|
2037
|
+
if (close !== -1 && text[close + 1] === "(") {
|
|
2038
|
+
const end = matching(text, close + 1, "(", ")");
|
|
2039
|
+
if (end !== -1) {
|
|
2040
|
+
const label = text.slice(open + 1, close);
|
|
2041
|
+
const url = text.slice(close + 2, end).trim().replace(/\s+"[^"]*"$/, "").replace(/^<(.*)>$/, "$1");
|
|
2042
|
+
if (image) emit([
|
|
2043
|
+
{
|
|
2044
|
+
text: `${glyphs.image} `,
|
|
2045
|
+
...base
|
|
2046
|
+
},
|
|
2047
|
+
...label ? [{
|
|
2048
|
+
text: `${label} `,
|
|
2049
|
+
...base
|
|
2050
|
+
}] : [],
|
|
2051
|
+
{
|
|
2052
|
+
text: `(${describeSource(url)})`,
|
|
2053
|
+
...base,
|
|
2054
|
+
role: "muted"
|
|
2055
|
+
}
|
|
2056
|
+
]);
|
|
2057
|
+
else {
|
|
2058
|
+
emit(parseInline(label, {
|
|
2059
|
+
...base,
|
|
2060
|
+
role: "link"
|
|
2061
|
+
}, glyphs));
|
|
2062
|
+
if (url && url !== label) emit([{
|
|
2063
|
+
text: ` (${url})`,
|
|
2064
|
+
...base,
|
|
2065
|
+
role: "muted"
|
|
2066
|
+
}]);
|
|
2067
|
+
}
|
|
2068
|
+
i = end + 1;
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
if (ch === "<") {
|
|
2074
|
+
const match = /^<((?:https?|mailto):[^\s<>]+)>/.exec(text.slice(i));
|
|
2075
|
+
if (match) {
|
|
2076
|
+
emit([{
|
|
2077
|
+
text: match[1],
|
|
2078
|
+
...base,
|
|
2079
|
+
role: "link"
|
|
2080
|
+
}]);
|
|
2081
|
+
i += match[0].length;
|
|
2082
|
+
continue;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
if (ch === "h" && (prev === void 0 || /[\s(]/.test(prev))) {
|
|
2086
|
+
const url = bareUrl(text, i);
|
|
2087
|
+
if (url) {
|
|
2088
|
+
emit([{
|
|
2089
|
+
text: url,
|
|
2090
|
+
...base,
|
|
2091
|
+
role: "link"
|
|
2092
|
+
}]);
|
|
2093
|
+
i += url.length;
|
|
2094
|
+
continue;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
if (ch === "*" || ch === "_" || ch === "~") {
|
|
2098
|
+
const run = runLength(text, i);
|
|
2099
|
+
const valid = ch === "~" ? run === 2 : run <= 3;
|
|
2100
|
+
if (valid && !isWordChar(prev) && !isSpace(text[i + run])) {
|
|
2101
|
+
const end = closer(text, i, run);
|
|
2102
|
+
if (end !== -1) {
|
|
2103
|
+
const style = ch === "~" ? { strike: true } : run === 1 ? { italic: true } : run === 2 ? { bold: true } : {
|
|
2104
|
+
bold: true,
|
|
2105
|
+
italic: true
|
|
2106
|
+
};
|
|
2107
|
+
emit(parseInline(text.slice(i + run, end), {
|
|
2108
|
+
...base,
|
|
2109
|
+
...style
|
|
2110
|
+
}, glyphs));
|
|
2111
|
+
i = end + run;
|
|
2112
|
+
continue;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
plain += text.slice(i, i + run);
|
|
2116
|
+
i += run;
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
if (ch === "&") {
|
|
2120
|
+
const ref = entity(text.slice(i, i + 12));
|
|
2121
|
+
if (ref) {
|
|
2122
|
+
plain += ref[0];
|
|
2123
|
+
i += ref[1];
|
|
2124
|
+
continue;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
plain += ch;
|
|
2128
|
+
i++;
|
|
2129
|
+
}
|
|
2130
|
+
flush();
|
|
2131
|
+
return mergeSpans(out);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
//#endregion
|
|
2135
|
+
//#region src/lines/markdown/wrap.ts
|
|
2136
|
+
function words(spans, measure) {
|
|
2137
|
+
const out = [{
|
|
2138
|
+
pieces: [],
|
|
2139
|
+
width: 0
|
|
2140
|
+
}];
|
|
2141
|
+
for (const s of spans) s.text.split(" ").forEach((part, i) => {
|
|
2142
|
+
if (i > 0) out.push({
|
|
2143
|
+
pieces: [],
|
|
2144
|
+
width: 0,
|
|
2145
|
+
gap: {
|
|
2146
|
+
...s,
|
|
2147
|
+
text: " "
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
const word = out.at(-1);
|
|
2151
|
+
if (part) {
|
|
2152
|
+
word.pieces.push({
|
|
2153
|
+
...s,
|
|
2154
|
+
text: part
|
|
2155
|
+
});
|
|
2156
|
+
word.width += measure(part);
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
return out;
|
|
2160
|
+
}
|
|
2161
|
+
/** Cut a word's pieces into chunks of at most `max` cells, never inside a grapheme. */
|
|
2162
|
+
function split(word, max, measure) {
|
|
2163
|
+
const chunks$1 = [{
|
|
2164
|
+
pieces: [],
|
|
2165
|
+
width: 0
|
|
2166
|
+
}];
|
|
2167
|
+
for (const piece of word.pieces) for (const g of graphemes(piece.text)) {
|
|
2168
|
+
const w = measure(g);
|
|
2169
|
+
let chunk = chunks$1.at(-1);
|
|
2170
|
+
if (chunk.width + w > max && chunk.width > 0) {
|
|
2171
|
+
chunk = {
|
|
2172
|
+
pieces: [],
|
|
2173
|
+
width: 0
|
|
2174
|
+
};
|
|
2175
|
+
chunks$1.push(chunk);
|
|
2176
|
+
}
|
|
2177
|
+
chunk.pieces.push({
|
|
2178
|
+
...piece,
|
|
2179
|
+
text: g
|
|
2180
|
+
});
|
|
2181
|
+
chunk.width += w;
|
|
2182
|
+
}
|
|
2183
|
+
return chunks$1;
|
|
2184
|
+
}
|
|
2185
|
+
function wrapSpans(spans, width$2, measure) {
|
|
2186
|
+
const max = Math.max(1, width$2);
|
|
2187
|
+
const rows = [];
|
|
2188
|
+
let current = [];
|
|
2189
|
+
let currentWidth = 0;
|
|
2190
|
+
let join;
|
|
2191
|
+
let started = false;
|
|
2192
|
+
const push = (next) => {
|
|
2193
|
+
const spans$1 = mergeSpans(current);
|
|
2194
|
+
rows.push(join ? {
|
|
2195
|
+
spans: spans$1,
|
|
2196
|
+
wrap: join
|
|
2197
|
+
} : { spans: spans$1 });
|
|
2198
|
+
current = [];
|
|
2199
|
+
currentWidth = 0;
|
|
2200
|
+
started = false;
|
|
2201
|
+
join = next;
|
|
2202
|
+
};
|
|
2203
|
+
for (const word of words(spans, measure)) {
|
|
2204
|
+
const gap = started && word.gap ? 1 : 0;
|
|
2205
|
+
if (started && currentWidth + gap + word.width > max) push("soft");
|
|
2206
|
+
else if (started && word.gap) {
|
|
2207
|
+
current.push(word.gap);
|
|
2208
|
+
currentWidth += 1;
|
|
2209
|
+
}
|
|
2210
|
+
if (word.width > max) {
|
|
2211
|
+
split(word, max, measure).forEach((chunk, i) => {
|
|
2212
|
+
if (i > 0) push("hard");
|
|
2213
|
+
current.push(...chunk.pieces);
|
|
2214
|
+
currentWidth = chunk.width;
|
|
2215
|
+
started = true;
|
|
2216
|
+
});
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
current.push(...word.pieces);
|
|
2220
|
+
currentWidth += word.width;
|
|
2221
|
+
started = true;
|
|
2222
|
+
}
|
|
2223
|
+
push(void 0);
|
|
2224
|
+
return rows;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
//#endregion
|
|
2228
|
+
//#region src/lines/images.ts
|
|
2229
|
+
/** The largest thumbnail, in cells. */
|
|
2230
|
+
const THUMBNAIL = {
|
|
2231
|
+
columns: 32,
|
|
2232
|
+
rows: 8
|
|
2233
|
+
};
|
|
2234
|
+
const HINT = {
|
|
2235
|
+
ask: () => "click to show",
|
|
2236
|
+
loading: () => "loading…",
|
|
2237
|
+
failed: (state) => `✗ ${state.error}`,
|
|
2238
|
+
ready: (_, expanded) => expanded ? "click to shrink" : "click to enlarge"
|
|
2239
|
+
};
|
|
2240
|
+
/** Lay out a block image: its ready rows, then a wrapped caption; every line targets the source. */
|
|
2241
|
+
function layoutImage(src, alt, width$2, { provider, expanded, maxHeight, glyphs, measure }) {
|
|
2242
|
+
const box = expanded ? {
|
|
2243
|
+
columns: Math.max(1, width$2),
|
|
2244
|
+
rows: Math.max(1, maxHeight)
|
|
2245
|
+
} : {
|
|
2246
|
+
columns: Math.max(1, Math.min(THUMBNAIL.columns, width$2)),
|
|
2247
|
+
rows: THUMBNAIL.rows
|
|
2248
|
+
};
|
|
2249
|
+
const state = provider?.get(src, box);
|
|
2250
|
+
const target = {
|
|
2251
|
+
kind: "image",
|
|
2252
|
+
src
|
|
2253
|
+
};
|
|
2254
|
+
const image = state?.kind === "ready" ? state.rows.map((row) => ({
|
|
2255
|
+
spans: [span(row, state.color ? { color: state.color } : {})],
|
|
2256
|
+
target
|
|
2257
|
+
})) : [];
|
|
2258
|
+
const caption = [
|
|
2259
|
+
span(`${glyphs.image} `, { role: "muted" }),
|
|
2260
|
+
...alt ? [span(alt, {
|
|
2261
|
+
role: "content",
|
|
2262
|
+
italic: true
|
|
2263
|
+
}), span(" ")] : [],
|
|
2264
|
+
span(describeSource(src), { role: "link" }),
|
|
2265
|
+
...state ? [span(` ${HINT[state.kind](state, expanded)}`, {
|
|
2266
|
+
role: "muted",
|
|
2267
|
+
dim: true
|
|
2268
|
+
})] : []
|
|
2269
|
+
];
|
|
2270
|
+
const clean$1 = caption.map((s) => ({
|
|
2271
|
+
...s,
|
|
2272
|
+
text: stripControls(s.text)
|
|
2273
|
+
}));
|
|
2274
|
+
return [...image, ...wrapSpans(clean$1, width$2, measure).map((row) => ({
|
|
2275
|
+
...row,
|
|
2276
|
+
target
|
|
2277
|
+
}))];
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
//#endregion
|
|
2281
|
+
//#region src/lines/layout.ts
|
|
2282
|
+
const NOTHING_OPEN = {
|
|
2283
|
+
version: 0,
|
|
2284
|
+
isOpen: () => false
|
|
2285
|
+
};
|
|
2286
|
+
/** The path a collapsible row's open state is kept under. */
|
|
2287
|
+
const ROW_PATH = "#row";
|
|
2288
|
+
/** The default markdown layout: plain text, wrapped, in the `content` role. */
|
|
2289
|
+
const plainMarkdownLayout = (source, width$2, { measure }) => wrapLines(source, width$2, measure).map((row) => ({
|
|
2290
|
+
spans: [span(row.text, { role: "content" })],
|
|
2291
|
+
...row.wrap ? { wrap: row.wrap } : {}
|
|
2292
|
+
}));
|
|
2293
|
+
const width$1 = (spans, measure) => spans.reduce((sum, s) => sum + measure(s.text), 0);
|
|
2294
|
+
/** Resolve an accent to an explicit appearance. */
|
|
2295
|
+
function resolveAccent(message, ctx) {
|
|
2296
|
+
const accent = ctx.accentFor?.(message) ?? "assistant";
|
|
2297
|
+
return typeof accent === "string" ? ctx.theme[accent] : accent;
|
|
2298
|
+
}
|
|
2299
|
+
function wrapped(text, w, measure, extra) {
|
|
2300
|
+
return wrapLines(text, w, measure).map((row) => ({
|
|
2301
|
+
spans: [span(row.text, extra)],
|
|
2302
|
+
...row.wrap ? { wrap: row.wrap } : {}
|
|
2303
|
+
}));
|
|
2304
|
+
}
|
|
2305
|
+
function indent(lines, cells$1) {
|
|
2306
|
+
const pad$1 = span(" ".repeat(cells$1), { chrome: true });
|
|
2307
|
+
return lines.map((line) => ({
|
|
2308
|
+
...line,
|
|
2309
|
+
spans: [pad$1, ...line.spans]
|
|
2310
|
+
}));
|
|
2311
|
+
}
|
|
2312
|
+
function owned(lines, owner) {
|
|
2313
|
+
return lines.map((line) => line.owner ? line : {
|
|
2314
|
+
...line,
|
|
2315
|
+
owner
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* Cells of label (up to its whole width) a header keeps before it gives up its
|
|
2320
|
+
* time, then its `after` spans.
|
|
2321
|
+
*/
|
|
2322
|
+
const LABEL_MIN = 16;
|
|
2323
|
+
/**
|
|
2324
|
+
* One header row, truncated to `w` (affordance, icon and qualifier exempt). The
|
|
2325
|
+
* timer and the `after` spans are exempt too, until the label would be cut
|
|
2326
|
+
* below {@link LABEL_MIN} cells: then the timer goes, then the `after` spans.
|
|
2327
|
+
*/
|
|
2328
|
+
function headerRow(parts, w, ctx) {
|
|
2329
|
+
const fixed = [parts.affordance, span(" ")];
|
|
2330
|
+
if (parts.prefix) fixed.push(parts.prefix, span(" "));
|
|
2331
|
+
if (parts.icon) fixed.push(span(parts.icon, { role: "marker" }), span(" "));
|
|
2332
|
+
const label = parts.label.replace(/\s+/g, " ").trim();
|
|
2333
|
+
const least = Math.min(LABEL_MIN, ctx.measure(label));
|
|
2334
|
+
let trailing = parts.trailing;
|
|
2335
|
+
let after$1 = parts.after ?? [];
|
|
2336
|
+
const tailOf = () => [...parts.qualifier ? [span(" "), parts.qualifier] : [], ...trailing ? [span(` ${trailing}`, { role: "muted" })] : []];
|
|
2337
|
+
const room = () => w - width$1(fixed, ctx.measure) - width$1(after$1, ctx.measure) - width$1(tailOf(), ctx.measure);
|
|
2338
|
+
if (room() < least) trailing = void 0;
|
|
2339
|
+
if (room() < least) after$1 = [];
|
|
2340
|
+
const tail = tailOf();
|
|
2341
|
+
const budget = Math.max(1, room());
|
|
2342
|
+
const detail = parts.detail ? parts.detail.replace(/\s+/g, " ").trim() : void 0;
|
|
2343
|
+
const full = detail ? `${label} ${detail}` : label;
|
|
2344
|
+
const cut = truncate(full, budget, ctx.measure);
|
|
2345
|
+
const labelPart = cut.slice(0, Math.min(label.length, cut.length));
|
|
2346
|
+
const detailPart = cut.slice(labelPart.length);
|
|
2347
|
+
const error = parts.error || parts.errorWithoutAfter && after$1.length === 0;
|
|
2348
|
+
const middle = [span(labelPart, { role: error ? "danger" : "strong" }), ...after$1];
|
|
2349
|
+
if (detailPart) middle.push(span(detailPart, { role: "muted" }));
|
|
2350
|
+
return {
|
|
2351
|
+
spans: [
|
|
2352
|
+
...fixed,
|
|
2353
|
+
...middle,
|
|
2354
|
+
...tail
|
|
2355
|
+
],
|
|
2356
|
+
...parts.target ? { target: parts.target } : {}
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
function spinnerFrame(ctx) {
|
|
2360
|
+
const frames = ctx.glyphs.spinner;
|
|
2361
|
+
return frames[Math.floor(ctx.now / 1e3) % frames.length] ?? "";
|
|
2362
|
+
}
|
|
2363
|
+
function liveTimer(startedAt, durationMs, live, ctx) {
|
|
2364
|
+
if (durationMs !== void 0) return formatDuration(durationMs);
|
|
2365
|
+
if (live && startedAt !== void 0 && Number.isFinite(startedAt)) return formatElapsed(ctx.now - startedAt, { live: true });
|
|
2366
|
+
return void 0;
|
|
2367
|
+
}
|
|
2368
|
+
function toolGlyph(tool, live, ctx) {
|
|
2369
|
+
const g = ctx.glyphs;
|
|
2370
|
+
if (tool.status === "success") return span(g.toolSuccess, {
|
|
2371
|
+
role: "positive",
|
|
2372
|
+
dim: true
|
|
2373
|
+
});
|
|
2374
|
+
if (tool.status === "error") return span(g.toolError, { role: "danger" });
|
|
2375
|
+
return live ? span(spinnerFrame(ctx), { role: "marker" }) : span(g.toolIdle, { role: "caution" });
|
|
2376
|
+
}
|
|
2377
|
+
const APPROVAL = {
|
|
2378
|
+
denied: ["· denied", "danger"],
|
|
2379
|
+
unavailable: ["· refused", "danger"],
|
|
2380
|
+
pending: ["· awaiting approval", "caution"]
|
|
2381
|
+
};
|
|
2382
|
+
function approvalQualifier(tool) {
|
|
2383
|
+
const approval = tool?.approval ? APPROVAL[tool.approval.state] : void 0;
|
|
2384
|
+
return approval ? span(approval[0], { role: approval[1] }) : void 0;
|
|
2385
|
+
}
|
|
2386
|
+
function toolOutput(tool, b) {
|
|
2387
|
+
if (tool.result === void 0) return [];
|
|
2388
|
+
const { ctx } = b;
|
|
2389
|
+
const text = toolResultText(tool.result);
|
|
2390
|
+
const bar = span(` ${ctx.glyphs.outputBar} `, {
|
|
2391
|
+
chrome: true,
|
|
2392
|
+
dim: true
|
|
2393
|
+
});
|
|
2394
|
+
const inner = Math.max(1, b.width - ctx.measure(bar.text));
|
|
2395
|
+
const target = {
|
|
2396
|
+
kind: "tool-output",
|
|
2397
|
+
blockId: tool.id
|
|
2398
|
+
};
|
|
2399
|
+
let rows;
|
|
2400
|
+
if (looksLikeJson(text)) {
|
|
2401
|
+
const parsed = parseLooseJson(text);
|
|
2402
|
+
const full = ctx.detail === "full";
|
|
2403
|
+
const jsonRows = layoutJson(parsed.node, {
|
|
2404
|
+
...full ? {} : { maxString: 80 },
|
|
2405
|
+
collapsed: (path) => !full && !ctx.opened.isOpen(tool.id, path)
|
|
2406
|
+
});
|
|
2407
|
+
rows = jsonRows.flatMap((row) => {
|
|
2408
|
+
const pad$1 = " ".repeat(row.depth);
|
|
2409
|
+
if (row.toggle) {
|
|
2410
|
+
const marker = row.toggle.open ? ctx.glyphs.expanded : ctx.glyphs.collapsed;
|
|
2411
|
+
const lead = " ".repeat(row.depth - 1);
|
|
2412
|
+
const markerSpan = span(`${marker} `, {
|
|
2413
|
+
role: "link",
|
|
2414
|
+
chrome: true,
|
|
2415
|
+
copyEach: " "
|
|
2416
|
+
});
|
|
2417
|
+
const size = row.toggle.open ? "" : ` · ${row.toggle.size}`;
|
|
2418
|
+
const available = Math.max(1, inner - ctx.measure(lead) - 2 - ctx.measure(size));
|
|
2419
|
+
return [{
|
|
2420
|
+
spans: [
|
|
2421
|
+
span(lead),
|
|
2422
|
+
markerSpan,
|
|
2423
|
+
span(truncate(row.text, available, ctx.measure), {
|
|
2424
|
+
role: "content",
|
|
2425
|
+
dim: true
|
|
2426
|
+
}),
|
|
2427
|
+
...size ? [span(size, {
|
|
2428
|
+
chrome: true,
|
|
2429
|
+
dim: true
|
|
2430
|
+
})] : []
|
|
2431
|
+
],
|
|
2432
|
+
target: {
|
|
2433
|
+
kind: "json-toggle",
|
|
2434
|
+
blockId: tool.id,
|
|
2435
|
+
path: row.toggle.path,
|
|
2436
|
+
open: row.toggle.open
|
|
2437
|
+
}
|
|
2438
|
+
}];
|
|
2439
|
+
}
|
|
2440
|
+
const pieces = wrapLines(row.text, Math.max(1, inner - ctx.measure(pad$1)), ctx.measure);
|
|
2441
|
+
return pieces.map((piece, i) => ({
|
|
2442
|
+
spans: [span(i === 0 ? pad$1 : `${pad$1} `), span(piece.text, {
|
|
2443
|
+
role: "content",
|
|
2444
|
+
dim: true
|
|
2445
|
+
})],
|
|
2446
|
+
...piece.wrap ? { wrap: piece.wrap } : {},
|
|
2447
|
+
target
|
|
2448
|
+
}));
|
|
2449
|
+
});
|
|
2450
|
+
if (parsed.rest) rows.push(...wrapped(parsed.rest, inner, ctx.measure, {
|
|
2451
|
+
role: "content",
|
|
2452
|
+
dim: true
|
|
2453
|
+
}).map((r) => ({
|
|
2454
|
+
...r,
|
|
2455
|
+
target
|
|
2456
|
+
})));
|
|
2457
|
+
} else rows = wrapped(text, inner, ctx.measure, {
|
|
2458
|
+
role: "content",
|
|
2459
|
+
dim: true
|
|
2460
|
+
}).map((r) => ({
|
|
2461
|
+
...r,
|
|
2462
|
+
target
|
|
2463
|
+
}));
|
|
2464
|
+
return rows.map((row) => ({
|
|
2465
|
+
...row,
|
|
2466
|
+
spans: [bar, ...row.spans]
|
|
2467
|
+
}));
|
|
2468
|
+
}
|
|
2469
|
+
function toolRow(tool, b) {
|
|
2470
|
+
const live = isBlockActive(tool, {
|
|
2471
|
+
isActive: b.live,
|
|
2472
|
+
outcome: b.message.outcome
|
|
2473
|
+
});
|
|
2474
|
+
const row = headerRow({
|
|
2475
|
+
affordance: toolGlyph(tool, live, b.ctx),
|
|
2476
|
+
label: toolLabel(tool) || "tool",
|
|
2477
|
+
detail: toolDisplayInput(tool),
|
|
2478
|
+
qualifier: approvalQualifier(tool),
|
|
2479
|
+
trailing: liveTimer(positiveTimestamp(tool.startedAt), tool.durationMs, live, b.ctx),
|
|
2480
|
+
error: tool.status === "error",
|
|
2481
|
+
target: {
|
|
2482
|
+
kind: "row-toggle",
|
|
2483
|
+
rowKey: tool.id
|
|
2484
|
+
}
|
|
2485
|
+
}, b.width, b.ctx);
|
|
2486
|
+
const showOutput = b.ctx.detail !== "collapsed" || b.ctx.opened.isOpen(tool.id, ROW_PATH);
|
|
2487
|
+
return [row, ...showOutput ? toolOutput(tool, b) : []];
|
|
2488
|
+
}
|
|
2489
|
+
function planRows(plan, b) {
|
|
2490
|
+
const g = b.ctx.glyphs;
|
|
2491
|
+
return plan.entries.flatMap((entry) => {
|
|
2492
|
+
const done = entry.status === "completed";
|
|
2493
|
+
const active = entry.status === "in_progress";
|
|
2494
|
+
const marker = done ? span(g.planDone, {
|
|
2495
|
+
role: "positive",
|
|
2496
|
+
dim: true
|
|
2497
|
+
}) : active ? b.live ? span(spinnerFrame(b.ctx), { role: "marker" }) : span(g.planActive, {
|
|
2498
|
+
role: "caution",
|
|
2499
|
+
dim: true
|
|
2500
|
+
}) : span(g.planPending, { dim: true });
|
|
2501
|
+
const lead = b.ctx.measure(marker.text) + 1;
|
|
2502
|
+
const rows = wrapLines(entry.content, Math.max(1, b.width - lead), b.ctx.measure);
|
|
2503
|
+
return rows.map((row, i) => ({
|
|
2504
|
+
spans: [
|
|
2505
|
+
i === 0 ? marker : span(" ".repeat(b.ctx.measure(marker.text)), { chrome: true }),
|
|
2506
|
+
span(" ", { chrome: true }),
|
|
2507
|
+
span(row.text, {
|
|
2508
|
+
role: "content",
|
|
2509
|
+
...done ? { dim: true } : {}
|
|
2510
|
+
})
|
|
2511
|
+
],
|
|
2512
|
+
...row.wrap ? { wrap: row.wrap } : {}
|
|
2513
|
+
}));
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2516
|
+
function blockLines(block, b) {
|
|
2517
|
+
const { ctx } = b;
|
|
2518
|
+
const live = isBlockActive(block, {
|
|
2519
|
+
isActive: b.live,
|
|
2520
|
+
activeBlockId: b.activeBlockId,
|
|
2521
|
+
outcome: b.message.outcome
|
|
2522
|
+
});
|
|
2523
|
+
if (!isCoreBlockType(block.type)) {
|
|
2524
|
+
const custom = block;
|
|
2525
|
+
const layout = lookupExtension(ctx.blockLayouts, custom.type);
|
|
2526
|
+
if (layout) return layout(custom, {
|
|
2527
|
+
width: b.width,
|
|
2528
|
+
isLive: live,
|
|
2529
|
+
measure: ctx.measure
|
|
2530
|
+
});
|
|
2531
|
+
return [{ spans: [span(summarizeBlock(custom).label, { role: "marker" })] }];
|
|
2532
|
+
}
|
|
2533
|
+
const core = block;
|
|
2534
|
+
switch (core.type) {
|
|
2535
|
+
case "text": return b.message.role === "user" ? wrapped(core.text, b.width, ctx.measure, { role: "user" }) : ctx.markdown(core.text, b.width, {
|
|
2536
|
+
...live ? { streaming: true } : {},
|
|
2537
|
+
measure: ctx.measure,
|
|
2538
|
+
glyphs: ctx.glyphs,
|
|
2539
|
+
image: ctx.image
|
|
2540
|
+
});
|
|
2541
|
+
case "reasoning": {
|
|
2542
|
+
const summary = summarizeBlock(core, { isActive: live });
|
|
2543
|
+
const open = ctx.detail !== "collapsed" || ctx.opened.isOpen(core.id, ROW_PATH);
|
|
2544
|
+
const header = headerRow({
|
|
2545
|
+
affordance: live ? span(spinnerFrame(ctx), { role: "marker" }) : span(open ? ctx.glyphs.expanded : ctx.glyphs.collapsed, { role: "marker" }),
|
|
2546
|
+
icon: ctx.glyphs.brain,
|
|
2547
|
+
label: summary.label,
|
|
2548
|
+
trailing: summary.showTimer ? liveTimer(positiveTimestamp(core.startedAt) ?? b.message.createdAt, core.durationMs, live, ctx) : void 0,
|
|
2549
|
+
target: {
|
|
2550
|
+
kind: "row-toggle",
|
|
2551
|
+
rowKey: core.id
|
|
2552
|
+
}
|
|
2553
|
+
}, b.width, ctx);
|
|
2554
|
+
const text = wrapped(core.text, Math.max(1, b.width - 2), ctx.measure, {
|
|
2555
|
+
role: "muted",
|
|
2556
|
+
italic: true
|
|
2557
|
+
});
|
|
2558
|
+
if (open) return [header, ...indent(text, 2)];
|
|
2559
|
+
return live ? [header, ...indent(text.slice(-3), 2)] : [header];
|
|
2560
|
+
}
|
|
2561
|
+
case "tool_call": return toolRow(core, b);
|
|
2562
|
+
case "plan": {
|
|
2563
|
+
const summary = summarizeBlock(core);
|
|
2564
|
+
const header = headerRow({
|
|
2565
|
+
affordance: span(" "),
|
|
2566
|
+
icon: ctx.glyphs.checklist,
|
|
2567
|
+
label: summary.label
|
|
2568
|
+
}, b.width, ctx);
|
|
2569
|
+
return [header, ...indent(planRows(core, {
|
|
2570
|
+
...b,
|
|
2571
|
+
width: Math.max(1, b.width - 2)
|
|
2572
|
+
}), 2)];
|
|
2573
|
+
}
|
|
2574
|
+
case "status": return [headerRow({
|
|
2575
|
+
affordance: live ? span(spinnerFrame(ctx), { role: "marker" }) : span(" "),
|
|
2576
|
+
label: statusLabel(core.phase, live),
|
|
2577
|
+
trailing: liveTimer(b.message.createdAt, b.live ? void 0 : b.message.metrics?.durationMs, live, ctx)
|
|
2578
|
+
}, b.width, ctx)];
|
|
2579
|
+
case "system": return [headerRow({
|
|
2580
|
+
affordance: span(" "),
|
|
2581
|
+
icon: ctx.glyphs.gear,
|
|
2582
|
+
label: "System prompt"
|
|
2583
|
+
}, b.width, ctx), ...ctx.detail !== "collapsed" ? indent(wrapped(core.text, Math.max(1, b.width - 2), ctx.measure, { role: "muted" }), 2) : []];
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
function groupLines(key, blocks, b) {
|
|
2587
|
+
const { ctx } = b;
|
|
2588
|
+
const g = ctx.glyphs;
|
|
2589
|
+
const plan = planGroupHeader(blocks, {
|
|
2590
|
+
isActive: b.live,
|
|
2591
|
+
activeBlockId: b.activeBlockId,
|
|
2592
|
+
outcome: b.message.outcome,
|
|
2593
|
+
startedAt: b.message.createdAt,
|
|
2594
|
+
durationMs: b.message.metrics?.durationMs
|
|
2595
|
+
});
|
|
2596
|
+
const open = ctx.detail !== "collapsed" || ctx.opened.isOpen(key, ROW_PATH);
|
|
2597
|
+
const icon = plan.summary.iconName ? g[plan.summary.iconName] : void 0;
|
|
2598
|
+
const rate = plan.showRate && !b.live ? formatTokensPerSecond(b.message.metrics?.tokensPerSecond) : void 0;
|
|
2599
|
+
const timer = plan.showTimer ? liveTimer(plan.startedAt, plan.durationMs, plan.isActive, ctx) : void 0;
|
|
2600
|
+
const affordance = plan.isActive ? span(spinnerFrame(ctx), { role: "marker" }) : span(open ? g.expanded : g.collapsed, { role: "marker" });
|
|
2601
|
+
const target = {
|
|
2602
|
+
kind: "row-toggle",
|
|
2603
|
+
rowKey: key
|
|
2604
|
+
};
|
|
2605
|
+
let header;
|
|
2606
|
+
if (plan.tools) {
|
|
2607
|
+
const t = plan.tools;
|
|
2608
|
+
const sep = g.separator;
|
|
2609
|
+
const counts = !t.only && t.failed > 0 ? [
|
|
2610
|
+
span(sep, { role: "muted" }),
|
|
2611
|
+
span(`${t.succeeded} ${g.success}`, { role: "positive" }),
|
|
2612
|
+
span(" "),
|
|
2613
|
+
span(`${t.failed} ${g.error}`, { role: "danger" })
|
|
2614
|
+
] : [];
|
|
2615
|
+
const detail = t.only ? toolDisplayInput(t.only) : t.running ? `${sep.trimStart()}${toolHeadline(t.running)}` : void 0;
|
|
2616
|
+
header = headerRow({
|
|
2617
|
+
affordance,
|
|
2618
|
+
icon: g.wrench,
|
|
2619
|
+
label: t.only ? toolLabel(t.only) || "tool" : `${t.total} tool calls`,
|
|
2620
|
+
after: counts,
|
|
2621
|
+
detail,
|
|
2622
|
+
qualifier: approvalQualifier(t.only),
|
|
2623
|
+
trailing: timer ? `${sep.trimStart()}${timer}` : void 0,
|
|
2624
|
+
error: t.only !== void 0 && t.failed > 0,
|
|
2625
|
+
errorWithoutAfter: t.failed > 0,
|
|
2626
|
+
target
|
|
2627
|
+
}, b.width, ctx);
|
|
2628
|
+
} else header = headerRow({
|
|
2629
|
+
affordance,
|
|
2630
|
+
...plan.hiddenCount > 0 ? { prefix: span(`(+${plan.hiddenCount})`, { role: "marker" }) } : {},
|
|
2631
|
+
...icon ? { icon } : {},
|
|
2632
|
+
label: plan.summary.label,
|
|
2633
|
+
trailing: [timer, rate].filter(Boolean).join(" ") || void 0,
|
|
2634
|
+
target
|
|
2635
|
+
}, b.width, ctx);
|
|
2636
|
+
if (!open) return [header];
|
|
2637
|
+
const inner = {
|
|
2638
|
+
...b,
|
|
2639
|
+
width: Math.max(1, b.width - 2)
|
|
2640
|
+
};
|
|
2641
|
+
return [header, ...indent(blocks.flatMap((block) => blockLines(block, inner)), 2)];
|
|
2642
|
+
}
|
|
2643
|
+
/** Which side of the content/activity boundary a planned row is on. */
|
|
2644
|
+
function rowSide(row) {
|
|
2645
|
+
if (row.kind === "bare") return getBlockTimelineBehavior(row.block).auxEligible ? "activity" : "content";
|
|
2646
|
+
return row.kind === "outcome" ? "content" : "activity";
|
|
2647
|
+
}
|
|
2648
|
+
/** Lay out a message's body (no frame) at `w` cells. */
|
|
2649
|
+
function layoutBody(original, w, ctx) {
|
|
2650
|
+
const live = ctx.isActive(original);
|
|
2651
|
+
const message = stripControlsDeep(original);
|
|
2652
|
+
const plan = planMessage(message, {
|
|
2653
|
+
isActive: live,
|
|
2654
|
+
activityPlacement: ctx.placement,
|
|
2655
|
+
...live && ctx.detail === "collapsed" ? { liveTail: ctx.liveTail } : {},
|
|
2656
|
+
...ctx.detail === "collapsed" && ctx.finishedActivity ? { finishedActivity: ctx.finishedActivity } : {}
|
|
2657
|
+
});
|
|
2658
|
+
const b = {
|
|
2659
|
+
message,
|
|
2660
|
+
live,
|
|
2661
|
+
activeBlockId: plan.activeBlockId,
|
|
2662
|
+
width: w,
|
|
2663
|
+
ctx
|
|
2664
|
+
};
|
|
2665
|
+
const out = [];
|
|
2666
|
+
const spacing = ctx.activitySpacing ?? true;
|
|
2667
|
+
let lastWasText = false;
|
|
2668
|
+
let lastSide;
|
|
2669
|
+
for (const row of plan.rows) {
|
|
2670
|
+
if (row.kind === "bare" && row.block.type === "text" && !row.block.text.trim()) continue;
|
|
2671
|
+
if (row.kind === "bare" && row.block.type === "text" && lastWasText) out.push({ spans: [] });
|
|
2672
|
+
lastWasText = row.kind === "bare" && row.block.type === "text";
|
|
2673
|
+
const side = rowSide(row);
|
|
2674
|
+
if (spacing && lastSide !== void 0 && side !== lastSide) out.push({
|
|
2675
|
+
spans: [],
|
|
2676
|
+
frame: true
|
|
2677
|
+
});
|
|
2678
|
+
lastSide = side;
|
|
2679
|
+
switch (row.kind) {
|
|
2680
|
+
case "pending":
|
|
2681
|
+
out.push(...blockLines({
|
|
2682
|
+
type: "status",
|
|
2683
|
+
id: row.id,
|
|
2684
|
+
phase: "pre-stream"
|
|
2685
|
+
}, {
|
|
2686
|
+
...b,
|
|
2687
|
+
activeBlockId: row.id
|
|
2688
|
+
}));
|
|
2689
|
+
break;
|
|
2690
|
+
case "bare":
|
|
2691
|
+
out.push(...owned(blockLines(row.block, b), {
|
|
2692
|
+
messageId: message.id,
|
|
2693
|
+
blockId: row.block.id
|
|
2694
|
+
}));
|
|
2695
|
+
break;
|
|
2696
|
+
case "group":
|
|
2697
|
+
out.push(...groupLines(row.key, row.blocks, b));
|
|
2698
|
+
break;
|
|
2699
|
+
case "earlier":
|
|
2700
|
+
out.push({ spans: [...ctx.glyphs.earlier ? [span(`${ctx.glyphs.earlier} `, {
|
|
2701
|
+
role: "marker",
|
|
2702
|
+
chrome: true
|
|
2703
|
+
})] : [], span(earlierLabel(row), { role: "muted" })] });
|
|
2704
|
+
break;
|
|
2705
|
+
case "outcome":
|
|
2706
|
+
out.push(...wrapped(`${ctx.glyphs.error} ${row.outcome.message}`, w, ctx.measure, { role: "danger" }));
|
|
2707
|
+
break;
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
for (const attachment of message.attachments ?? []) {
|
|
2711
|
+
const label = attachment.description ?? attachment.name ?? attachment.mediaType;
|
|
2712
|
+
out.push({ spans: [span(`${ctx.glyphs.attachment} `, {
|
|
2713
|
+
role: "link",
|
|
2714
|
+
chrome: true
|
|
2715
|
+
}), span(truncate(label, Math.max(1, w - 3), ctx.measure), { dim: true })] });
|
|
2716
|
+
}
|
|
2717
|
+
return out;
|
|
2718
|
+
}
|
|
2719
|
+
/** The footer spans of a message: planned facts with injected glyphs, then hints. */
|
|
2720
|
+
function footerSpans(original, ctx) {
|
|
2721
|
+
const live = ctx.isActive(original);
|
|
2722
|
+
const message = stripControlsDeep(original);
|
|
2723
|
+
const plan = planMessage(message, { isActive: live });
|
|
2724
|
+
const shows = showsSomething(message);
|
|
2725
|
+
if (!(plan.showFooter || live && ctx.liveFooter && shows)) return void 0;
|
|
2726
|
+
const facts = planFooter(message, {
|
|
2727
|
+
isActive: live,
|
|
2728
|
+
detail: ctx.detail,
|
|
2729
|
+
now: ctx.now
|
|
2730
|
+
});
|
|
2731
|
+
const glyph = {
|
|
2732
|
+
reasoning: ctx.glyphs.brain,
|
|
2733
|
+
plan: ctx.glyphs.checklist,
|
|
2734
|
+
tools: ctx.glyphs.wrench
|
|
2735
|
+
};
|
|
2736
|
+
const texts = facts.map((fact) => glyph[fact.key] ? `${glyph[fact.key]} ${fact.text}` : fact.text);
|
|
2737
|
+
texts.push(...ctx.footerExtras?.(original, ctx.detail) ?? []);
|
|
2738
|
+
const counts = countActivity(message.blocks);
|
|
2739
|
+
const hasActivity = counts.reasoningChars > 0 || counts.tools.total > 0 || counts.plan !== void 0;
|
|
2740
|
+
const hasOutput = message.blocks.some((block) => block.type === "tool_call" && block.result !== void 0);
|
|
2741
|
+
const { hints } = ctx;
|
|
2742
|
+
if (message.outcome && hints.retry) texts.push(hints.retry);
|
|
2743
|
+
if (ctx.detail === "collapsed" && hasActivity && hints.details) texts.push(hints.details);
|
|
2744
|
+
if (ctx.detail === "details" && hasOutput && hints.openAll) texts.push(hints.openAll);
|
|
2745
|
+
if (ctx.detail !== "collapsed" && hints.close) texts.push(hints.close);
|
|
2746
|
+
if (texts.length === 0) return void 0;
|
|
2747
|
+
return [span(stripControls(texts.join(ctx.glyphs.separator)), {
|
|
2748
|
+
role: "muted",
|
|
2749
|
+
chrome: true
|
|
2750
|
+
})];
|
|
2751
|
+
}
|
|
2752
|
+
/** Whether an answer shows anything: content, reasoning, an outcome, a tool call or a plan. */
|
|
2753
|
+
function showsSomething(message) {
|
|
2754
|
+
return message.outcome !== void 0 || message.blocks.some((block) => block.type !== "status");
|
|
2755
|
+
}
|
|
2756
|
+
/** Lay out one message inside its frame. */
|
|
2757
|
+
function layoutMessage(original, ctx, next) {
|
|
2758
|
+
const live = ctx.isActive(original);
|
|
2759
|
+
const message = stripControlsDeep(original);
|
|
2760
|
+
const inner = ctx.frame.innerWidth(message, ctx.width);
|
|
2761
|
+
const footer = footerSpans(original, ctx);
|
|
2762
|
+
const drafts = ctx.frame.layout({
|
|
2763
|
+
message,
|
|
2764
|
+
body: layoutBody(original, inner, ctx),
|
|
2765
|
+
...footer ? { footer } : {},
|
|
2766
|
+
shows: message.role !== "assistant" || showsSomething(message),
|
|
2767
|
+
nextShows: next !== void 0 && next.role === "assistant" && showsSomething(next),
|
|
2768
|
+
live,
|
|
2769
|
+
accent: resolveAccent(original, ctx),
|
|
2770
|
+
ctx
|
|
2771
|
+
});
|
|
2772
|
+
return owned(drafts, { messageId: original.id });
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
//#endregion
|
|
2776
|
+
//#region src/lines/markdown/table.ts
|
|
2777
|
+
/** The narrowest a wrapped column may get before the table falls back. */
|
|
2778
|
+
const MIN_COLUMN = 6;
|
|
2779
|
+
const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
|
2780
|
+
/** Whether `line` is a GFM table separator row. */
|
|
2781
|
+
function isSeparator(line) {
|
|
2782
|
+
return line.includes("-") && SEPARATOR.test(line);
|
|
2783
|
+
}
|
|
2784
|
+
/** Split a row into cells on pipes that are neither escaped nor inside a code span. */
|
|
2785
|
+
function splitRow(line) {
|
|
2786
|
+
let row = line.trim();
|
|
2787
|
+
if (row.startsWith("|")) row = row.slice(1);
|
|
2788
|
+
if (row.endsWith("|") && !row.endsWith("\\|")) row = row.slice(0, -1);
|
|
2789
|
+
const cells$1 = [];
|
|
2790
|
+
let cell = "";
|
|
2791
|
+
let code = 0;
|
|
2792
|
+
for (let i = 0; i < row.length; i++) {
|
|
2793
|
+
const ch = row[i];
|
|
2794
|
+
if (ch === "\\" && row[i + 1] === "|") {
|
|
2795
|
+
cell += "|";
|
|
2796
|
+
i++;
|
|
2797
|
+
} else if (ch === "`") {
|
|
2798
|
+
let run = 0;
|
|
2799
|
+
while (row[i + run] === "`") run++;
|
|
2800
|
+
code = code === run ? 0 : code || run;
|
|
2801
|
+
cell += row.slice(i, i + run);
|
|
2802
|
+
i += run - 1;
|
|
2803
|
+
} else if (ch === "|" && code === 0) {
|
|
2804
|
+
cells$1.push(cell.trim());
|
|
2805
|
+
cell = "";
|
|
2806
|
+
} else cell += ch;
|
|
2807
|
+
}
|
|
2808
|
+
cells$1.push(cell.trim());
|
|
2809
|
+
return cells$1;
|
|
2810
|
+
}
|
|
2811
|
+
/** Column alignments from a separator row. */
|
|
2812
|
+
function alignments(line) {
|
|
2813
|
+
return splitRow(line).map((cell) => cell.startsWith(":") && cell.endsWith(":") ? "center" : cell.endsWith(":") ? "right" : "left");
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Fit natural widths into `available` cells: columns within a fair share keep
|
|
2817
|
+
* theirs, the rest share what remains. Undefined when a column would fall
|
|
2818
|
+
* below its minimum (the minimums together exceed the available cells).
|
|
2819
|
+
*/
|
|
2820
|
+
function fitColumns(natural, available) {
|
|
2821
|
+
const minimum = natural.map((w) => Math.min(w, MIN_COLUMN));
|
|
2822
|
+
if (minimum.reduce((a, b) => a + b, 0) > available) return void 0;
|
|
2823
|
+
const widths = [...natural];
|
|
2824
|
+
let open = natural.map((_, i) => i);
|
|
2825
|
+
let budget = available;
|
|
2826
|
+
for (;;) {
|
|
2827
|
+
const fair = Math.floor(budget / open.length);
|
|
2828
|
+
const small = open.filter((i) => natural[i] <= fair);
|
|
2829
|
+
if (small.length === 0 || small.length === open.length) break;
|
|
2830
|
+
for (const i of small) budget -= natural[i];
|
|
2831
|
+
open = open.filter((i) => !small.includes(i));
|
|
2832
|
+
}
|
|
2833
|
+
if (open.reduce((sum, i) => sum + natural[i], 0) <= budget) return widths;
|
|
2834
|
+
const share = Math.floor(budget / open.length);
|
|
2835
|
+
open.forEach((i, k) => {
|
|
2836
|
+
widths[i] = share + (k < budget - share * open.length ? 1 : 0);
|
|
2837
|
+
});
|
|
2838
|
+
return widths;
|
|
2839
|
+
}
|
|
2840
|
+
const width = (spans, measure) => spans.reduce((sum, s) => sum + measure(s.text), 0);
|
|
2841
|
+
function pad(spans, cells$1, align, last, measure) {
|
|
2842
|
+
if (last && spans.length === 0) return [];
|
|
2843
|
+
const room = Math.max(0, cells$1 - width(spans, measure));
|
|
2844
|
+
const before$1 = align === "right" ? room : align === "center" ? Math.floor(room / 2) : 0;
|
|
2845
|
+
const after$1 = last ? 0 : room - before$1;
|
|
2846
|
+
return [
|
|
2847
|
+
...before$1 ? [span(" ".repeat(before$1), { chrome: true })] : [],
|
|
2848
|
+
...spans,
|
|
2849
|
+
...after$1 ? [span(" ".repeat(after$1), { chrome: true })] : []
|
|
2850
|
+
];
|
|
2851
|
+
}
|
|
2852
|
+
/** Drop trailing spaces (an empty last cell leaves the column joint's). */
|
|
2853
|
+
function trimEnd(spans) {
|
|
2854
|
+
const merged = mergeSpans(spans);
|
|
2855
|
+
const last = merged.at(-1);
|
|
2856
|
+
if (last?.text.endsWith(" ")) merged[merged.length - 1] = {
|
|
2857
|
+
...last,
|
|
2858
|
+
text: last.text.trimEnd()
|
|
2859
|
+
};
|
|
2860
|
+
return mergeSpans(merged);
|
|
2861
|
+
}
|
|
2862
|
+
/** Lay out a table from its header, separator and body source rows. */
|
|
2863
|
+
function layoutTable(header, separator, body, maxWidth, { glyphs, measure }) {
|
|
2864
|
+
const heads = splitRow(header);
|
|
2865
|
+
const columns = heads.length;
|
|
2866
|
+
const align = alignments(separator);
|
|
2867
|
+
const cells$1 = (row, style) => Array.from({ length: columns }, (_, c) => parseInline(splitRow(row)[c] ?? "", style, glyphs));
|
|
2868
|
+
const head = cells$1(header, {
|
|
2869
|
+
role: "strong",
|
|
2870
|
+
bold: true
|
|
2871
|
+
});
|
|
2872
|
+
const rows = body.map((row) => cells$1(row, { role: "content" }));
|
|
2873
|
+
const natural = Array.from({ length: columns }, (_, c) => Math.max(1, ...[head, ...rows].map((row) => width(row[c], measure))));
|
|
2874
|
+
const joint = 3;
|
|
2875
|
+
const widths = fitColumns(natural, maxWidth - joint * (columns - 1));
|
|
2876
|
+
if (!widths) return fallback(head, rows, maxWidth, measure);
|
|
2877
|
+
const column = [
|
|
2878
|
+
span(" ", { chrome: true }),
|
|
2879
|
+
span(glyphs.tableColumn, {
|
|
2880
|
+
role: "chrome",
|
|
2881
|
+
chrome: true,
|
|
2882
|
+
copyEach: " "
|
|
2883
|
+
}),
|
|
2884
|
+
span(" ", { chrome: true })
|
|
2885
|
+
];
|
|
2886
|
+
const drawRow = (row) => {
|
|
2887
|
+
const wrapped$1 = row.map((cell, c) => wrapSpans(cell, widths[c], measure));
|
|
2888
|
+
const height = Math.max(...wrapped$1.map((w) => w.length));
|
|
2889
|
+
return Array.from({ length: height }, (_, r) => ({ spans: trimEnd(wrapped$1.flatMap((w, c) => [...c > 0 ? column : [], ...pad(w[r]?.spans ?? [], widths[c], align[c] ?? "left", c === columns - 1, measure)])) }));
|
|
2890
|
+
};
|
|
2891
|
+
const rule = widths.map((w) => glyphs.rule.repeat(w)).join(`${glyphs.rule}${glyphs.tableCross}${glyphs.rule}`);
|
|
2892
|
+
return [
|
|
2893
|
+
...drawRow(head),
|
|
2894
|
+
{
|
|
2895
|
+
spans: [span(rule, { role: "chrome" })],
|
|
2896
|
+
frame: true
|
|
2897
|
+
},
|
|
2898
|
+
...rows.flatMap(drawRow)
|
|
2899
|
+
];
|
|
2900
|
+
}
|
|
2901
|
+
function fallback(head, rows, maxWidth, measure) {
|
|
2902
|
+
return rows.flatMap((row, r) => [...r > 0 ? [{ spans: [] }] : [], ...row.flatMap((cell, c) => wrapSpans([
|
|
2903
|
+
...head[c],
|
|
2904
|
+
span(": ", { role: "muted" }),
|
|
2905
|
+
...cell
|
|
2906
|
+
], maxWidth, measure))]);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
//#endregion
|
|
2910
|
+
//#region src/lines/markdown/blocks.ts
|
|
2911
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/;
|
|
2912
|
+
const HEADING = /^ {0,3}(#{1,6})(?:\s+(.*?))?(?:\s+#+)?\s*$/;
|
|
2913
|
+
const RULE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/;
|
|
2914
|
+
const QUOTE = /^ {0,3}> ?/;
|
|
2915
|
+
const ITEM = /^( *)([-*+]|\d{1,9}[.)])(?: +(.*))?$/;
|
|
2916
|
+
const IMAGE_LINE = /^!\[([^\]]*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)$/;
|
|
2917
|
+
const blank = (line) => line === void 0 || line.trim() === "";
|
|
2918
|
+
function startsBlock(lines, i) {
|
|
2919
|
+
const line = lines[i];
|
|
2920
|
+
return FENCE.test(line) || HEADING.test(line) || RULE.test(line) || QUOTE.test(line) || ITEM.test(line) || isTableStart(lines, i);
|
|
2921
|
+
}
|
|
2922
|
+
function isTableStart(lines, i) {
|
|
2923
|
+
return lines[i].includes("|") && isSeparator(lines[i + 1] ?? "");
|
|
2924
|
+
}
|
|
2925
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
2926
|
+
/** Prefix laid-out lines: `first` on the first line, `rest` on the others. */
|
|
2927
|
+
function hang(lines, first, rest) {
|
|
2928
|
+
return lines.map((line, i) => ({
|
|
2929
|
+
...line,
|
|
2930
|
+
spans: [...i === 0 ? first : rest, ...line.spans]
|
|
2931
|
+
}));
|
|
2932
|
+
}
|
|
2933
|
+
function paragraph(source, width$2, env) {
|
|
2934
|
+
const only = source.length === 1 ? IMAGE_LINE.exec(source[0].trim()) : null;
|
|
2935
|
+
if (only && env.image) {
|
|
2936
|
+
const lines = env.image(only[2], only[1], width$2);
|
|
2937
|
+
if (lines) return lines;
|
|
2938
|
+
}
|
|
2939
|
+
const segments = [""];
|
|
2940
|
+
source.forEach((raw, i) => {
|
|
2941
|
+
const hard = i < source.length - 1 && /(?: {2,}|\\)$/.test(raw);
|
|
2942
|
+
const text = raw.trim().replace(/\\$/, "");
|
|
2943
|
+
const at = segments.length - 1;
|
|
2944
|
+
segments[at] = segments[at] ? `${segments[at]} ${text}` : text;
|
|
2945
|
+
if (hard) segments.push("");
|
|
2946
|
+
});
|
|
2947
|
+
return segments.flatMap((text) => wrapSpans(parseInline(text, { role: "content" }, env.glyphs), width$2, env.measure));
|
|
2948
|
+
}
|
|
2949
|
+
function fence(lines, i, width$2, env) {
|
|
2950
|
+
const [, marker, lang] = FENCE.exec(lines[i]);
|
|
2951
|
+
const indent$1 = indentOf(lines[i]);
|
|
2952
|
+
const close = new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`);
|
|
2953
|
+
let j = i + 1;
|
|
2954
|
+
const code = [];
|
|
2955
|
+
while (j < lines.length && !close.test(lines[j])) {
|
|
2956
|
+
const line = lines[j];
|
|
2957
|
+
code.push(line.slice(Math.min(indent$1, indentOf(line))));
|
|
2958
|
+
j++;
|
|
2959
|
+
}
|
|
2960
|
+
const bar = span(`${env.glyphs.codeBar} `, {
|
|
2961
|
+
role: "chrome",
|
|
2962
|
+
dim: true,
|
|
2963
|
+
chrome: true
|
|
2964
|
+
});
|
|
2965
|
+
const inner = Math.max(1, width$2 - 2);
|
|
2966
|
+
const out = [];
|
|
2967
|
+
if (lang) out.push({
|
|
2968
|
+
spans: [bar, span(lang, {
|
|
2969
|
+
role: "muted",
|
|
2970
|
+
dim: true
|
|
2971
|
+
})],
|
|
2972
|
+
frame: true
|
|
2973
|
+
});
|
|
2974
|
+
for (const line of code) hardSplit(line.replace(/\t/g, " "), inner, env.measure).forEach((piece, k) => {
|
|
2975
|
+
out.push({
|
|
2976
|
+
spans: [bar, ...piece ? [span(piece, { role: "code" })] : []],
|
|
2977
|
+
...k > 0 ? { wrap: "hard" } : {}
|
|
2978
|
+
});
|
|
2979
|
+
});
|
|
2980
|
+
return [out, j < lines.length ? j + 1 : j];
|
|
2981
|
+
}
|
|
2982
|
+
function list(lines, i, width$2, env) {
|
|
2983
|
+
const first = ITEM.exec(lines[i]);
|
|
2984
|
+
const indent$1 = first[1].length;
|
|
2985
|
+
const ordered$1 = /\d/.test(first[2]);
|
|
2986
|
+
let number = ordered$1 ? Number.parseInt(first[2], 10) : 0;
|
|
2987
|
+
const out = [];
|
|
2988
|
+
let j = i;
|
|
2989
|
+
while (j < lines.length) {
|
|
2990
|
+
const match = ITEM.exec(lines[j]);
|
|
2991
|
+
if (!match || match[1].length !== indent$1) break;
|
|
2992
|
+
if (/\d/.test(match[2]) !== ordered$1) break;
|
|
2993
|
+
const content = indent$1 + match[2].length + 1;
|
|
2994
|
+
const body = [match[3] ?? ""];
|
|
2995
|
+
j++;
|
|
2996
|
+
while (j < lines.length) {
|
|
2997
|
+
const line = lines[j];
|
|
2998
|
+
if (blank(line)) {
|
|
2999
|
+
const next = lines[j + 1];
|
|
3000
|
+
if (next !== void 0 && !blank(next) && indentOf(next) >= content) {
|
|
3001
|
+
body.push("");
|
|
3002
|
+
j++;
|
|
3003
|
+
continue;
|
|
3004
|
+
}
|
|
3005
|
+
break;
|
|
3006
|
+
}
|
|
3007
|
+
if (indentOf(line) > indent$1) body.push(line.slice(Math.min(content, indentOf(line))));
|
|
3008
|
+
else if (!startsBlock(lines, j)) body.push(line.trim());
|
|
3009
|
+
else break;
|
|
3010
|
+
j++;
|
|
3011
|
+
}
|
|
3012
|
+
let marker;
|
|
3013
|
+
if (ordered$1) marker = `${number++}${match[2].slice(-1)}`;
|
|
3014
|
+
else marker = env.glyphs.bullet;
|
|
3015
|
+
const task = /^\[([ xX])\] /.exec(body[0]);
|
|
3016
|
+
if (task) {
|
|
3017
|
+
marker = task[1] === " " ? env.glyphs.taskOpen : env.glyphs.taskDone;
|
|
3018
|
+
body[0] = body[0].slice(4);
|
|
3019
|
+
}
|
|
3020
|
+
const markerWidth = env.measure(marker) + 1;
|
|
3021
|
+
const inner = layoutBlocks(body, Math.max(1, width$2 - markerWidth), env, true);
|
|
3022
|
+
out.push(...hang(inner.length ? inner : [{ spans: [] }], [span(`${marker} `, {
|
|
3023
|
+
role: "marker",
|
|
3024
|
+
chrome: true
|
|
3025
|
+
})], [span(" ".repeat(markerWidth), { chrome: true })]));
|
|
3026
|
+
if (blank(lines[j]) && ITEM.test(lines[j + 1] ?? "") && indentOf(lines[j + 1]) === indent$1) j++;
|
|
3027
|
+
}
|
|
3028
|
+
return [out, j];
|
|
3029
|
+
}
|
|
3030
|
+
/** Lay out markdown source lines as blocks; `tight` drops the blank lines between blocks. */
|
|
3031
|
+
function layoutBlocks(lines, width$2, env, tight = false) {
|
|
3032
|
+
const out = [];
|
|
3033
|
+
const add = (block) => {
|
|
3034
|
+
if (out.length && !tight) out.push({ spans: [] });
|
|
3035
|
+
out.push(...block);
|
|
3036
|
+
};
|
|
3037
|
+
let i = 0;
|
|
3038
|
+
while (i < lines.length) {
|
|
3039
|
+
const line = lines[i];
|
|
3040
|
+
if (blank(line)) {
|
|
3041
|
+
i++;
|
|
3042
|
+
continue;
|
|
3043
|
+
}
|
|
3044
|
+
if (FENCE.test(line)) {
|
|
3045
|
+
const [block, next] = fence(lines, i, width$2, env);
|
|
3046
|
+
add(block);
|
|
3047
|
+
i = next;
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
const heading = HEADING.exec(line);
|
|
3051
|
+
if (heading) {
|
|
3052
|
+
add(wrapSpans(parseInline(heading[2] ?? "", {
|
|
3053
|
+
role: "strong",
|
|
3054
|
+
bold: true
|
|
3055
|
+
}, env.glyphs), width$2, env.measure));
|
|
3056
|
+
i++;
|
|
3057
|
+
continue;
|
|
3058
|
+
}
|
|
3059
|
+
if (RULE.test(line)) {
|
|
3060
|
+
add([{
|
|
3061
|
+
spans: [span(env.glyphs.rule.repeat(width$2), {
|
|
3062
|
+
role: "chrome",
|
|
3063
|
+
dim: true
|
|
3064
|
+
})],
|
|
3065
|
+
frame: true
|
|
3066
|
+
}]);
|
|
3067
|
+
i++;
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
if (QUOTE.test(line)) {
|
|
3071
|
+
const quoted = [];
|
|
3072
|
+
while (i < lines.length && QUOTE.test(lines[i])) {
|
|
3073
|
+
quoted.push(lines[i].replace(QUOTE, ""));
|
|
3074
|
+
i++;
|
|
3075
|
+
}
|
|
3076
|
+
const bar = span(`${env.glyphs.quoteBar} `, {
|
|
3077
|
+
role: "chrome",
|
|
3078
|
+
dim: true,
|
|
3079
|
+
chrome: true
|
|
3080
|
+
});
|
|
3081
|
+
const inner = layoutBlocks(quoted, Math.max(1, width$2 - 2), env);
|
|
3082
|
+
add(hang(inner, [bar], [bar]));
|
|
3083
|
+
continue;
|
|
3084
|
+
}
|
|
3085
|
+
if (ITEM.test(line)) {
|
|
3086
|
+
const [block, next] = list(lines, i, width$2, env);
|
|
3087
|
+
add(block);
|
|
3088
|
+
i = next;
|
|
3089
|
+
continue;
|
|
3090
|
+
}
|
|
3091
|
+
if (isTableStart(lines, i)) {
|
|
3092
|
+
const body = [];
|
|
3093
|
+
let j = i + 2;
|
|
3094
|
+
while (j < lines.length && !blank(lines[j]) && lines[j].includes("|")) {
|
|
3095
|
+
body.push(lines[j]);
|
|
3096
|
+
j++;
|
|
3097
|
+
}
|
|
3098
|
+
add(layoutTable(line, lines[i + 1], body, width$2, env));
|
|
3099
|
+
i = j;
|
|
3100
|
+
continue;
|
|
3101
|
+
}
|
|
3102
|
+
const source = [line];
|
|
3103
|
+
i++;
|
|
3104
|
+
while (i < lines.length && !blank(lines[i]) && !startsBlock(lines, i)) {
|
|
3105
|
+
source.push(lines[i]);
|
|
3106
|
+
i++;
|
|
3107
|
+
}
|
|
3108
|
+
add(paragraph(source, width$2, env));
|
|
3109
|
+
}
|
|
3110
|
+
return out;
|
|
3111
|
+
}
|
|
3112
|
+
/**
|
|
3113
|
+
* Split source lines into top-level chunks at blank lines that end a block:
|
|
3114
|
+
* never inside a fence, and not where an indented line or a list item
|
|
3115
|
+
* continues the block before. Chunks lay out independently.
|
|
3116
|
+
*/
|
|
3117
|
+
function chunks(lines) {
|
|
3118
|
+
const out = [];
|
|
3119
|
+
let current = [];
|
|
3120
|
+
let fence$1;
|
|
3121
|
+
const nextText = [];
|
|
3122
|
+
for (let i = lines.length - 1, seen; i >= 0; i--) {
|
|
3123
|
+
nextText[i] = seen;
|
|
3124
|
+
if (!blank(lines[i])) seen = lines[i];
|
|
3125
|
+
}
|
|
3126
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3127
|
+
const line = lines[i];
|
|
3128
|
+
if (fence$1) {
|
|
3129
|
+
if (fence$1.test(line)) fence$1 = void 0;
|
|
3130
|
+
current.push(line);
|
|
3131
|
+
continue;
|
|
3132
|
+
}
|
|
3133
|
+
const open = FENCE.exec(line);
|
|
3134
|
+
if (open) {
|
|
3135
|
+
const marker = open[1];
|
|
3136
|
+
fence$1 = new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`);
|
|
3137
|
+
current.push(line);
|
|
3138
|
+
continue;
|
|
3139
|
+
}
|
|
3140
|
+
if (blank(line)) {
|
|
3141
|
+
if (current.length === 0) continue;
|
|
3142
|
+
const next = nextText[i];
|
|
3143
|
+
if (next !== void 0 && indentOf(next) === 0 && !ITEM.test(next)) {
|
|
3144
|
+
out.push(current);
|
|
3145
|
+
current = [];
|
|
3146
|
+
continue;
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
current.push(line);
|
|
3150
|
+
}
|
|
3151
|
+
if (current.length) out.push(current);
|
|
3152
|
+
return out;
|
|
3153
|
+
}
|
|
3154
|
+
/** Laid-out chunks per glyph set and measure, keyed by width and source. */
|
|
3155
|
+
const chunkCache = new WeakMap();
|
|
3156
|
+
const CHUNK_CACHE_SIZE = 4e3;
|
|
3157
|
+
function cached(env) {
|
|
3158
|
+
let byMeasure = chunkCache.get(env.glyphs);
|
|
3159
|
+
if (!byMeasure) {
|
|
3160
|
+
byMeasure = new WeakMap();
|
|
3161
|
+
chunkCache.set(env.glyphs, byMeasure);
|
|
3162
|
+
}
|
|
3163
|
+
let map = byMeasure.get(env.measure);
|
|
3164
|
+
if (!map) {
|
|
3165
|
+
map = new Map();
|
|
3166
|
+
byMeasure.set(env.measure, map);
|
|
3167
|
+
}
|
|
3168
|
+
return map;
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* The default terminal markdown: dependency-free, injected roles and glyphs.
|
|
3172
|
+
* Finished blocks are cached, so a streamed delta lays out only the tail.
|
|
3173
|
+
*/
|
|
3174
|
+
const terminalMarkdown = (source, width$2, options) => {
|
|
3175
|
+
const w = Math.max(1, width$2);
|
|
3176
|
+
const cache = cached(options);
|
|
3177
|
+
const out = [];
|
|
3178
|
+
for (const chunk of chunks(source.replace(/\r\n?/g, "\n").split("\n"))) {
|
|
3179
|
+
const text = chunk.join("\n");
|
|
3180
|
+
const cacheable = !(options.image && text.includes("!["));
|
|
3181
|
+
const key = `${w}\n${text}`;
|
|
3182
|
+
let lines = cacheable ? cache.get(key) : void 0;
|
|
3183
|
+
if (!lines) {
|
|
3184
|
+
lines = layoutBlocks(chunk, w, options);
|
|
3185
|
+
if (cacheable) {
|
|
3186
|
+
if (cache.size >= CHUNK_CACHE_SIZE) cache.clear();
|
|
3187
|
+
cache.set(key, lines);
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
if (lines.length === 0) continue;
|
|
3191
|
+
if (out.length) out.push({ spans: [] });
|
|
3192
|
+
out.push(...lines);
|
|
3193
|
+
}
|
|
3194
|
+
return out;
|
|
3195
|
+
};
|
|
3196
|
+
|
|
3197
|
+
//#endregion
|
|
3198
|
+
//#region src/lines/mouse.ts
|
|
3199
|
+
const ESC = String.fromCharCode(27);
|
|
3200
|
+
/** Button-event tracking with SGR encoding, on and off. */
|
|
3201
|
+
const MOUSE_ON = `${ESC}[?1002h${ESC}[?1006h`;
|
|
3202
|
+
const MOUSE_OFF = `${ESC}[?1006l${ESC}[?1002l`;
|
|
3203
|
+
const BUTTONS = [
|
|
3204
|
+
"left",
|
|
3205
|
+
"middle",
|
|
3206
|
+
"right",
|
|
3207
|
+
"none"
|
|
3208
|
+
];
|
|
3209
|
+
const SGR = new RegExp(`${ESC}\\[<(\\d+);(\\d+);(\\d+)([Mm])`, "g");
|
|
3210
|
+
/**
|
|
3211
|
+
* Every SGR mouse report in a chunk of terminal input. Horizontal wheel
|
|
3212
|
+
* reports (buttons 6 and 7) are dropped: the viewport only scrolls vertically.
|
|
3213
|
+
*/
|
|
3214
|
+
function parseMouse(data) {
|
|
3215
|
+
const reports = Array.from(data.matchAll(SGR)).filter((m) => (Number(m[1]) & 66) !== 66);
|
|
3216
|
+
return reports.map((m) => {
|
|
3217
|
+
const code = Number(m[1]);
|
|
3218
|
+
const wheel = (code & 64) !== 0;
|
|
3219
|
+
const motion = (code & 32) !== 0;
|
|
3220
|
+
const kind = wheel ? code & 1 ? "wheel-down" : "wheel-up" : m[4] === "m" ? "release" : motion ? "drag" : "press";
|
|
3221
|
+
return {
|
|
3222
|
+
kind,
|
|
3223
|
+
button: wheel ? "none" : BUTTONS[code & 3],
|
|
3224
|
+
x: Number(m[2]) - 1,
|
|
3225
|
+
y: Number(m[3]) - 1,
|
|
3226
|
+
ctrl: (code & 16) !== 0,
|
|
3227
|
+
shift: (code & 4) !== 0,
|
|
3228
|
+
meta: (code & 8) !== 0
|
|
3229
|
+
};
|
|
3230
|
+
});
|
|
3231
|
+
}
|
|
3232
|
+
/** The longest incomplete report kept for the next chunk; a longer one is noise. */
|
|
3233
|
+
const MAX_PENDING = 64;
|
|
3234
|
+
const CSI_SEQUENCE = new RegExp(`${ESC}\\[[\\x30-\\x3f]*[\\x20-\\x2f]*[\\x40-\\x7e]`, "g");
|
|
3235
|
+
const CSI_PARTIAL = new RegExp(`${ESC}(?:\\[[\\x30-\\x3f]*[\\x20-\\x2f]*)?$`);
|
|
3236
|
+
/**
|
|
3237
|
+
* A reader of terminal reports across input chunks: each call returns the
|
|
3238
|
+
* complete CSI sequences in what has arrived, keeping an unfinished one at
|
|
3239
|
+
* the end for the next chunk (a terminal may split a report anywhere). The
|
|
3240
|
+
* kept tail is bounded; past the bound it is dropped as noise.
|
|
3241
|
+
*/
|
|
3242
|
+
function createReportReader() {
|
|
3243
|
+
let pending = "";
|
|
3244
|
+
return (data) => {
|
|
3245
|
+
let text = pending + data;
|
|
3246
|
+
pending = "";
|
|
3247
|
+
const partial = CSI_PARTIAL.exec(text);
|
|
3248
|
+
if (partial) {
|
|
3249
|
+
text = text.slice(0, partial.index);
|
|
3250
|
+
if (partial[0].length <= MAX_PENDING) pending = partial[0];
|
|
3251
|
+
}
|
|
3252
|
+
return Array.from(text.matchAll(CSI_SEQUENCE), (m) => m[0]);
|
|
3253
|
+
};
|
|
3254
|
+
}
|
|
3255
|
+
/** An OSC 52 clipboard write of `text`. */
|
|
3256
|
+
function osc52(text) {
|
|
3257
|
+
return `${ESC}]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`;
|
|
3258
|
+
}
|
|
3259
|
+
/**
|
|
3260
|
+
* The element's position on screen, in cells. This relies on the renderer's
|
|
3261
|
+
* layout internals (each node's computed offset from its parent), so it is
|
|
3262
|
+
* isolated here.
|
|
3263
|
+
*/
|
|
3264
|
+
function screenOrigin(node) {
|
|
3265
|
+
let x = 0;
|
|
3266
|
+
let y = 0;
|
|
3267
|
+
for (let n = node ?? void 0; n; n = n.parentNode) {
|
|
3268
|
+
x += n.yogaNode?.getComputedLeft() ?? 0;
|
|
3269
|
+
y += n.yogaNode?.getComputedTop() ?? 0;
|
|
3270
|
+
}
|
|
3271
|
+
return {
|
|
3272
|
+
x,
|
|
3273
|
+
y
|
|
3274
|
+
};
|
|
3275
|
+
}
|
|
3276
|
+
/** The two ends of a selection, in reading order. */
|
|
3277
|
+
function ordered(a, b) {
|
|
3278
|
+
return a.line < b.line || a.line === b.line && a.col <= b.col ? [a, b] : [b, a];
|
|
3279
|
+
}
|
|
3280
|
+
function cells(line, measure) {
|
|
3281
|
+
const out = [];
|
|
3282
|
+
let at = 0;
|
|
3283
|
+
for (const s of line.spans) for (const g of graphemes(s.text)) {
|
|
3284
|
+
const width$2 = measure(g);
|
|
3285
|
+
out.push({
|
|
3286
|
+
text: g,
|
|
3287
|
+
start: at,
|
|
3288
|
+
width: width$2,
|
|
3289
|
+
span: s
|
|
3290
|
+
});
|
|
3291
|
+
at += width$2;
|
|
3292
|
+
}
|
|
3293
|
+
return out;
|
|
3294
|
+
}
|
|
3295
|
+
/**
|
|
3296
|
+
* Whether a grapheme's cells overlap the columns `start`..`end` (inclusive):
|
|
3297
|
+
* a point inside a wide grapheme's later cell still takes the whole grapheme.
|
|
3298
|
+
*/
|
|
3299
|
+
const within = (cell, start, end) => cell.start <= end && cell.start + Math.max(cell.width, 1) > start;
|
|
3300
|
+
/** What a span's grapheme copies as: its replacement, nothing for chrome, else itself. */
|
|
3301
|
+
const copied = (cell) => cell.span.copyEach !== void 0 ? cell.span.copyEach : cell.span.chrome ? "" : cell.text;
|
|
3302
|
+
/**
|
|
3303
|
+
* Copy text between two points (inclusive of the end cell): content spans
|
|
3304
|
+
* only, decoration lines skipped, lines right-trimmed, soft joins as a space,
|
|
3305
|
+
* hard joins as nothing, other lines by newlines.
|
|
3306
|
+
*/
|
|
3307
|
+
function copyText(lines, a, b, measure) {
|
|
3308
|
+
const [from, to] = ordered(a, b);
|
|
3309
|
+
let out = "";
|
|
3310
|
+
let first = true;
|
|
3311
|
+
for (let i = from.line; i <= to.line; i++) {
|
|
3312
|
+
const line = lines[i];
|
|
3313
|
+
if (!line || line.frame) continue;
|
|
3314
|
+
const start = i === from.line ? from.col : 0;
|
|
3315
|
+
const end = i === to.line ? to.col : Number.POSITIVE_INFINITY;
|
|
3316
|
+
const text = cells(line, measure).filter((c) => within(c, start, end)).map(copied).join("").trimEnd();
|
|
3317
|
+
if (!first) out += line.wrap === "soft" ? " " : line.wrap === "hard" ? "" : "\n";
|
|
3318
|
+
out += text;
|
|
3319
|
+
first = false;
|
|
3320
|
+
}
|
|
3321
|
+
return out;
|
|
3322
|
+
}
|
|
3323
|
+
/**
|
|
3324
|
+
* Highlight the content cells of a line from `start` to `end` (inclusive) with
|
|
3325
|
+
* the selection appearance; selecting past the line's end highlights one more
|
|
3326
|
+
* cell. Decoration lines are returned as they are.
|
|
3327
|
+
*/
|
|
3328
|
+
function highlightLine(line, start, end, selection, measure) {
|
|
3329
|
+
if (line.frame) return line;
|
|
3330
|
+
const all = cells(line, measure);
|
|
3331
|
+
const runs = [];
|
|
3332
|
+
for (const cell of all) {
|
|
3333
|
+
const selected = !cell.span.chrome && within(cell, start, end);
|
|
3334
|
+
const last = runs.at(-1);
|
|
3335
|
+
if (last && last.span === cell.span && last.selected === selected) last.text += cell.text;
|
|
3336
|
+
else runs.push({
|
|
3337
|
+
span: cell.span,
|
|
3338
|
+
selected,
|
|
3339
|
+
text: cell.text
|
|
3340
|
+
});
|
|
3341
|
+
}
|
|
3342
|
+
const spans = runs.map(({ span: span$1, selected, text }) => selected ? {
|
|
3343
|
+
...span$1,
|
|
3344
|
+
text,
|
|
3345
|
+
style: {
|
|
3346
|
+
...span$1.style,
|
|
3347
|
+
...selection
|
|
3348
|
+
}
|
|
3349
|
+
} : {
|
|
3350
|
+
...span$1,
|
|
3351
|
+
text
|
|
3352
|
+
});
|
|
3353
|
+
const width$2 = all.reduce((sum, c) => sum + c.width, 0);
|
|
3354
|
+
if (end >= width$2 && start <= width$2) spans.push({
|
|
3355
|
+
text: " ",
|
|
3356
|
+
style: selection
|
|
3357
|
+
});
|
|
3358
|
+
return {
|
|
3359
|
+
...line,
|
|
3360
|
+
spans
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
/** Call `handler` with every chunk of raw terminal input while active. */
|
|
3364
|
+
function useTerminalData(handler, active) {
|
|
3365
|
+
const { stdin } = ink.useStdin();
|
|
3366
|
+
const ref = React.useRef(handler);
|
|
3367
|
+
ref.current = handler;
|
|
3368
|
+
React.useEffect(() => {
|
|
3369
|
+
if (!active) return;
|
|
3370
|
+
const listener = (data) => ref.current(String(data));
|
|
3371
|
+
stdin.on("data", listener);
|
|
3372
|
+
return () => {
|
|
3373
|
+
stdin.off("data", listener);
|
|
3374
|
+
};
|
|
3375
|
+
}, [active, stdin]);
|
|
3376
|
+
}
|
|
3377
|
+
/** Enable mouse reporting while active; disable on deactivation, unmount and process exit. */
|
|
3378
|
+
function useMouseReporting(active, write) {
|
|
3379
|
+
const ref = React.useRef(write);
|
|
3380
|
+
ref.current = write;
|
|
3381
|
+
React.useEffect(() => {
|
|
3382
|
+
if (!active) return;
|
|
3383
|
+
ref.current(MOUSE_ON);
|
|
3384
|
+
const off = () => ref.current(MOUSE_OFF);
|
|
3385
|
+
process.once("exit", off);
|
|
3386
|
+
return () => {
|
|
3387
|
+
process.off("exit", off);
|
|
3388
|
+
off();
|
|
3389
|
+
};
|
|
3390
|
+
}, [active]);
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
//#endregion
|
|
3394
|
+
//#region src/lines/transcript.ts
|
|
3395
|
+
const identities = new WeakMap();
|
|
3396
|
+
let nextIdentity = 0;
|
|
3397
|
+
/** A stable token per object or function: injected inputs compare by identity. */
|
|
3398
|
+
function identity(value) {
|
|
3399
|
+
if (value === void 0) return 0;
|
|
3400
|
+
let id = identities.get(value);
|
|
3401
|
+
if (id === void 0) {
|
|
3402
|
+
id = ++nextIdentity;
|
|
3403
|
+
identities.set(value, id);
|
|
3404
|
+
}
|
|
3405
|
+
return id;
|
|
3406
|
+
}
|
|
3407
|
+
/**
|
|
3408
|
+
* Everything a message's layout reads, as a string. Plain values (hint labels,
|
|
3409
|
+
* the resolved accent, host footer facts) count by value; injected functions
|
|
3410
|
+
* and the glyph set count by identity, so hosts memoise them.
|
|
3411
|
+
*/
|
|
3412
|
+
function layoutSignature(message, ctx, next, imagesVersion = 0) {
|
|
3413
|
+
const live = ctx.isActive(message);
|
|
3414
|
+
return [
|
|
3415
|
+
ctx.width,
|
|
3416
|
+
ctx.detail,
|
|
3417
|
+
ctx.placement,
|
|
3418
|
+
ctx.frame.id,
|
|
3419
|
+
live ? `live:${Math.floor(ctx.now / 1e3)}` : "done",
|
|
3420
|
+
ctx.opened.version,
|
|
3421
|
+
imagesVersion,
|
|
3422
|
+
next !== void 0 && next.role === "assistant" && showsSomething(next) ? "n" : "-",
|
|
3423
|
+
ctx.liveTail,
|
|
3424
|
+
ctx.liveFooter ? "f" : "-",
|
|
3425
|
+
ctx.finishedActivity ?? "row",
|
|
3426
|
+
ctx.activitySpacing === false ? "-" : "s",
|
|
3427
|
+
ctx.footerExtras?.(message, ctx.detail).join("\0") ?? "",
|
|
3428
|
+
JSON.stringify(ctx.hints),
|
|
3429
|
+
JSON.stringify(resolveAccent(message, ctx)),
|
|
3430
|
+
[
|
|
3431
|
+
ctx.glyphs,
|
|
3432
|
+
ctx.markdown,
|
|
3433
|
+
ctx.measure,
|
|
3434
|
+
ctx.image,
|
|
3435
|
+
ctx.blockLayouts
|
|
3436
|
+
].map(identity).join(",")
|
|
3437
|
+
].join("|");
|
|
3438
|
+
}
|
|
3439
|
+
function createLayoutCache(imagesVersion = () => 0) {
|
|
3440
|
+
const entries = new WeakMap();
|
|
3441
|
+
let misses = 0;
|
|
3442
|
+
return {
|
|
3443
|
+
get(message, ctx, next) {
|
|
3444
|
+
const signature = layoutSignature(message, ctx, next, imagesVersion());
|
|
3445
|
+
const hit = entries.get(message);
|
|
3446
|
+
if (hit && hit.signature === signature) return hit.lines;
|
|
3447
|
+
misses++;
|
|
3448
|
+
const lines = layoutMessage(message, ctx, next);
|
|
3449
|
+
entries.set(message, {
|
|
3450
|
+
signature,
|
|
3451
|
+
lines
|
|
3452
|
+
});
|
|
3453
|
+
return lines;
|
|
3454
|
+
},
|
|
3455
|
+
get misses() {
|
|
3456
|
+
return misses;
|
|
3457
|
+
}
|
|
3458
|
+
};
|
|
3459
|
+
}
|
|
3460
|
+
/** Lay out a transcript: all lines, and where each message starts. */
|
|
3461
|
+
function layoutTranscript(messages, ctx, cache = createLayoutCache()) {
|
|
3462
|
+
const lines = [];
|
|
3463
|
+
const messageStart = [];
|
|
3464
|
+
messages.forEach((message, i) => {
|
|
3465
|
+
messageStart.push(lines.length);
|
|
3466
|
+
lines.push(...cache.get(message, ctx, messages[i + 1]));
|
|
3467
|
+
});
|
|
3468
|
+
return {
|
|
3469
|
+
lines,
|
|
3470
|
+
messageStart
|
|
3471
|
+
};
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
//#endregion
|
|
3475
|
+
//#region src/lines/Viewport.tsx
|
|
3476
|
+
const scrollHome = {
|
|
3477
|
+
follow: false,
|
|
3478
|
+
top: 0
|
|
3479
|
+
};
|
|
3480
|
+
const scrollEnd = { follow: true };
|
|
3481
|
+
/** The window's first line for a scroll state. */
|
|
3482
|
+
function firstLine(scroll, total, rows) {
|
|
3483
|
+
const last = Math.max(0, total - rows);
|
|
3484
|
+
return scroll.follow ? last : Math.max(0, Math.min(scroll.top, last));
|
|
3485
|
+
}
|
|
3486
|
+
/** Scroll by `delta` lines, clamped; reaching the bottom returns to follow. */
|
|
3487
|
+
function scrollBy(scroll, delta, g) {
|
|
3488
|
+
const last = Math.max(0, g.total - g.rows);
|
|
3489
|
+
const top = Math.max(0, Math.min(firstLine(scroll, g.total, g.rows) + delta, last));
|
|
3490
|
+
return top >= last ? scrollEnd : {
|
|
3491
|
+
follow: false,
|
|
3492
|
+
top
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
/** Scroll by pages; a page keeps two lines of context. */
|
|
3496
|
+
function pageBy(scroll, pages, g) {
|
|
3497
|
+
return scrollBy(scroll, pages * Math.max(1, g.rows - 2), g);
|
|
3498
|
+
}
|
|
3499
|
+
const NEXT_DETAIL = {
|
|
3500
|
+
collapsed: "details",
|
|
3501
|
+
details: "full",
|
|
3502
|
+
full: "collapsed"
|
|
3503
|
+
};
|
|
3504
|
+
/** Uncontrolled open state: a version plus the set of open `blockId␟path` keys. */
|
|
3505
|
+
function useOpenState() {
|
|
3506
|
+
const [state, setState] = React.useState({
|
|
3507
|
+
version: 0,
|
|
3508
|
+
open: new Set()
|
|
3509
|
+
});
|
|
3510
|
+
const toggle = React.useCallback((blockId, path) => {
|
|
3511
|
+
setState((current) => {
|
|
3512
|
+
const open = new Set(current.open);
|
|
3513
|
+
const key = `${blockId}␟${path}`;
|
|
3514
|
+
if (open.has(key)) open.delete(key);
|
|
3515
|
+
else open.add(key);
|
|
3516
|
+
return {
|
|
3517
|
+
version: current.version + 1,
|
|
3518
|
+
open
|
|
3519
|
+
};
|
|
3520
|
+
});
|
|
3521
|
+
}, []);
|
|
3522
|
+
const opened = React.useMemo(() => ({
|
|
3523
|
+
version: state.version,
|
|
3524
|
+
isOpen: (b, p) => state.open.has(`${b}␟${p}`)
|
|
3525
|
+
}), [state]);
|
|
3526
|
+
return [opened, toggle];
|
|
3527
|
+
}
|
|
3528
|
+
/** Lay out the transcript and compute the window (for hosts that draw extra chrome). */
|
|
3529
|
+
function useTranscriptLayout(props) {
|
|
3530
|
+
const { theme, glyphs } = useInkComponents();
|
|
3531
|
+
const live = props.streamingMessageId !== void 0;
|
|
3532
|
+
const now = Math.floor(hooks.useClock(live, 1e3) / 1e3) * 1e3;
|
|
3533
|
+
const [ownOpened, ownToggle] = useOpenState();
|
|
3534
|
+
const opened = props.opened ?? ownOpened;
|
|
3535
|
+
const provider = props.images;
|
|
3536
|
+
const [, bump] = React.useState(0);
|
|
3537
|
+
React.useEffect(() => provider?.subscribe(() => bump((n) => n + 1)), [provider]);
|
|
3538
|
+
const [expanded, setExpanded] = React.useState({
|
|
3539
|
+
version: 0,
|
|
3540
|
+
open: new Set()
|
|
3541
|
+
});
|
|
3542
|
+
const toggleExpanded = React.useCallback((src) => {
|
|
3543
|
+
setExpanded((current) => {
|
|
3544
|
+
const open = new Set(current.open);
|
|
3545
|
+
if (open.has(src)) open.delete(src);
|
|
3546
|
+
else open.add(src);
|
|
3547
|
+
return {
|
|
3548
|
+
version: current.version + 1,
|
|
3549
|
+
open
|
|
3550
|
+
};
|
|
3551
|
+
});
|
|
3552
|
+
}, []);
|
|
3553
|
+
const imageStates = React.useRef(new Map());
|
|
3554
|
+
const imagesVersion = (props.imagesVersion ?? 0) + (provider?.version ?? 0) + expanded.version;
|
|
3555
|
+
const cache = React.useRef(null);
|
|
3556
|
+
const versionRef = React.useRef(imagesVersion);
|
|
3557
|
+
versionRef.current = imagesVersion;
|
|
3558
|
+
cache.current ??= createLayoutCache(() => versionRef.current);
|
|
3559
|
+
const maxHeight = props.maxImageHeight ?? Math.max(1, props.height - 8);
|
|
3560
|
+
const measure = props.measure ?? defaultMeasure;
|
|
3561
|
+
const image = React.useMemo(() => props.image ?? (provider ? (src, alt, width$2) => layoutImage(src, alt, width$2, {
|
|
3562
|
+
provider: {
|
|
3563
|
+
...provider,
|
|
3564
|
+
get: (source, box) => {
|
|
3565
|
+
const state = provider.get(source, box);
|
|
3566
|
+
imageStates.current.set(source, state);
|
|
3567
|
+
return state;
|
|
3568
|
+
}
|
|
3569
|
+
},
|
|
3570
|
+
expanded: expanded.open.has(src),
|
|
3571
|
+
maxHeight,
|
|
3572
|
+
glyphs,
|
|
3573
|
+
measure
|
|
3574
|
+
}) : void 0), [
|
|
3575
|
+
props.image,
|
|
3576
|
+
provider,
|
|
3577
|
+
expanded,
|
|
3578
|
+
maxHeight,
|
|
3579
|
+
glyphs,
|
|
3580
|
+
measure
|
|
3581
|
+
]);
|
|
3582
|
+
const ctx = {
|
|
3583
|
+
width: props.width,
|
|
3584
|
+
detail: props.detail ?? "collapsed",
|
|
3585
|
+
placement: props.activityPlacement ?? "inline",
|
|
3586
|
+
liveTail: props.liveTail ?? 3,
|
|
3587
|
+
isActive: (message) => message.id === props.streamingMessageId,
|
|
3588
|
+
now,
|
|
3589
|
+
opened,
|
|
3590
|
+
markdown: props.markdown ?? terminalMarkdown,
|
|
3591
|
+
glyphs,
|
|
3592
|
+
theme,
|
|
3593
|
+
hints: props.hints ?? {},
|
|
3594
|
+
liveFooter: props.liveFooter ?? true,
|
|
3595
|
+
...props.blockLayouts ? { blockLayouts: props.blockLayouts } : {},
|
|
3596
|
+
frame: props.frame ?? compactFrame,
|
|
3597
|
+
accentFor: props.accentFor,
|
|
3598
|
+
measure,
|
|
3599
|
+
footerExtras: props.footerExtras,
|
|
3600
|
+
finishedActivity: props.finishedActivity,
|
|
3601
|
+
activitySpacing: props.activitySpacing,
|
|
3602
|
+
image
|
|
3603
|
+
};
|
|
3604
|
+
const { lines } = layoutTranscript(props.messages, ctx, cache.current);
|
|
3605
|
+
const rows = Math.max(0, props.height);
|
|
3606
|
+
const first = firstLine(props.scroll, lines.length, rows);
|
|
3607
|
+
const geometry = {
|
|
3608
|
+
total: lines.length,
|
|
3609
|
+
first,
|
|
3610
|
+
rows,
|
|
3611
|
+
below: Math.max(0, lines.length - first - rows)
|
|
3612
|
+
};
|
|
3613
|
+
return {
|
|
3614
|
+
lines,
|
|
3615
|
+
geometry,
|
|
3616
|
+
window: lines.slice(first, first + rows),
|
|
3617
|
+
toggle: props.onToggle ?? ownToggle,
|
|
3618
|
+
now,
|
|
3619
|
+
imageStates: imageStates.current,
|
|
3620
|
+
toggleExpanded
|
|
3621
|
+
};
|
|
3622
|
+
}
|
|
3623
|
+
/** Contiguous lines around `index` in the same tool output box, if it is in one. */
|
|
3624
|
+
function outputBox(lines, index) {
|
|
3625
|
+
const blockOf = (line) => line?.target && (line.target.kind === "tool-output" || line.target.kind === "json-toggle") ? `${line.owner.messageId}␟${line.target.blockId}` : void 0;
|
|
3626
|
+
const box = blockOf(lines[index]);
|
|
3627
|
+
if (!box) return void 0;
|
|
3628
|
+
let a = index;
|
|
3629
|
+
let b = index;
|
|
3630
|
+
while (blockOf(lines[a - 1]) === box) a--;
|
|
3631
|
+
while (blockOf(lines[b + 1]) === box) b++;
|
|
3632
|
+
return [a, b];
|
|
3633
|
+
}
|
|
3634
|
+
/** A tool's whole output, JSON pretty-printed. */
|
|
3635
|
+
function outputText(tool) {
|
|
3636
|
+
const text = toolResultText(tool.result);
|
|
3637
|
+
try {
|
|
3638
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
3639
|
+
} catch {
|
|
3640
|
+
return text;
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
/** A message as written (an answer's raw markdown). */
|
|
3644
|
+
function messageText(message) {
|
|
3645
|
+
return message.blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n\n");
|
|
3646
|
+
}
|
|
3647
|
+
const ids = new WeakMap();
|
|
3648
|
+
let nextId = 0;
|
|
3649
|
+
/** A stable id per line object (lines are cached per message, so identity is content). */
|
|
3650
|
+
function lineId(line) {
|
|
3651
|
+
let id = ids.get(line);
|
|
3652
|
+
if (id === void 0) {
|
|
3653
|
+
id = ++nextId;
|
|
3654
|
+
ids.set(line, id);
|
|
3655
|
+
}
|
|
3656
|
+
return id;
|
|
3657
|
+
}
|
|
3658
|
+
/** What a key does in the viewport (pure, so every binding is testable). */
|
|
3659
|
+
function viewportKeyAction(input, key, keys, scroll, geometry, detail) {
|
|
3660
|
+
if (keys === false || isTerminalReport(input)) return void 0;
|
|
3661
|
+
if (keys.page !== false && key.pageUp) return { scroll: pageBy(scroll, -1, geometry) };
|
|
3662
|
+
if (keys.page !== false && key.pageDown) return { scroll: pageBy(scroll, 1, geometry) };
|
|
3663
|
+
if (keys.lines !== false && key.shift && key.upArrow) return { scroll: scrollBy(scroll, -3, geometry) };
|
|
3664
|
+
if (keys.lines !== false && key.shift && key.downArrow) return { scroll: scrollBy(scroll, 3, geometry) };
|
|
3665
|
+
if (keys.homeEnd && key.home) return { scroll: scrollHome };
|
|
3666
|
+
if (keys.homeEnd && key.end) return { scroll: scrollEnd };
|
|
3667
|
+
if (keys.detail !== false && key.ctrl && input === "t") return { detail: NEXT_DETAIL[detail] };
|
|
3668
|
+
return void 0;
|
|
3669
|
+
}
|
|
3670
|
+
function TranscriptViewport(props) {
|
|
3671
|
+
const { theme } = useInkComponents();
|
|
3672
|
+
const { lines, geometry, window, toggle, imageStates, toggleExpanded } = useTranscriptLayout(props);
|
|
3673
|
+
const { onGeometry, onScroll, onDetailChange, scroll, focused = true, keys = {} } = props;
|
|
3674
|
+
const detail = props.detail ?? "collapsed";
|
|
3675
|
+
const measure = props.measure ?? defaultMeasure;
|
|
3676
|
+
const { stdout } = ink.useStdout();
|
|
3677
|
+
const last = React.useRef("");
|
|
3678
|
+
React.useEffect(() => {
|
|
3679
|
+
const key = `${geometry.total}:${geometry.first}:${geometry.rows}:${geometry.below}`;
|
|
3680
|
+
if (key === last.current) return;
|
|
3681
|
+
last.current = key;
|
|
3682
|
+
onGeometry?.(geometry);
|
|
3683
|
+
});
|
|
3684
|
+
const [selection, setSelection] = React.useState(void 0);
|
|
3685
|
+
ink.useInput((input, key) => {
|
|
3686
|
+
const action = viewportKeyAction(input, key, keys, scroll, geometry, detail);
|
|
3687
|
+
if (action && "scroll" in action) onScroll(action.scroll);
|
|
3688
|
+
else if (action) onDetailChange?.(action.detail);
|
|
3689
|
+
}, { isActive: focused && keys !== false });
|
|
3690
|
+
const [readReport] = React.useState(createKeyReportReader);
|
|
3691
|
+
ink.useInput((input) => {
|
|
3692
|
+
const report = readReport(input);
|
|
3693
|
+
if (report.kind === "text" || report.kind === "report" && report.rest) setSelection(void 0);
|
|
3694
|
+
}, { isActive: focused && selection !== void 0 });
|
|
3695
|
+
const mouse = props.mouse;
|
|
3696
|
+
const mouseActive = mouse !== void 0 && focused;
|
|
3697
|
+
const write = mouse?.write ?? props.write ?? ((data) => stdout.write(data));
|
|
3698
|
+
useMouseReporting(mouseActive, write);
|
|
3699
|
+
const box = React.useRef(null);
|
|
3700
|
+
const press = React.useRef(void 0);
|
|
3701
|
+
const lastClick = React.useRef(void 0);
|
|
3702
|
+
const copy = (text, description) => {
|
|
3703
|
+
write(osc52(text));
|
|
3704
|
+
mouse?.onCopy?.(text, description);
|
|
3705
|
+
};
|
|
3706
|
+
/** The point under a screen cell, rows clamped to the visible window. */
|
|
3707
|
+
const pointAt = (e) => {
|
|
3708
|
+
const origin = screenOrigin(box.current);
|
|
3709
|
+
const row = e.y - origin.y;
|
|
3710
|
+
const inside = row >= 0 && row < window.length;
|
|
3711
|
+
const clamped = Math.max(0, Math.min(row, window.length - 1));
|
|
3712
|
+
return {
|
|
3713
|
+
line: geometry.first + clamped,
|
|
3714
|
+
col: Math.max(0, e.x - origin.x),
|
|
3715
|
+
inside
|
|
3716
|
+
};
|
|
3717
|
+
};
|
|
3718
|
+
const click = (at, e) => {
|
|
3719
|
+
const line = lines[at.line];
|
|
3720
|
+
const target = line.target;
|
|
3721
|
+
if (target?.kind === "json-toggle") return toggle(target.blockId, target.path);
|
|
3722
|
+
if (target?.kind === "row-toggle") return toggle(target.rowKey, ROW_PATH);
|
|
3723
|
+
if (target?.kind === "image") {
|
|
3724
|
+
if (e.ctrl) return mouse?.onOpenUrl?.(target.src);
|
|
3725
|
+
const state = imageStates.get(target.src);
|
|
3726
|
+
if (state?.kind === "ask") props.images?.allow(target.src);
|
|
3727
|
+
else if (state?.kind === "ready") toggleExpanded(target.src);
|
|
3728
|
+
return;
|
|
3729
|
+
}
|
|
3730
|
+
const now = Date.now();
|
|
3731
|
+
const previous = lastClick.current;
|
|
3732
|
+
if (previous?.line === at.line && now - previous.at <= (mouse?.doubleClickMs ?? 400)) {
|
|
3733
|
+
lastClick.current = void 0;
|
|
3734
|
+
const message = props.messages.find((m) => m.id === line.owner.messageId);
|
|
3735
|
+
if (target?.kind === "tool-output") {
|
|
3736
|
+
const tool = message.blocks.find((b) => b.type === "tool_call" && b.id === target.blockId);
|
|
3737
|
+
copy(outputText(tool), "tool output");
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3740
|
+
copy(messageText(message), "message");
|
|
3741
|
+
return;
|
|
3742
|
+
}
|
|
3743
|
+
lastClick.current = {
|
|
3744
|
+
line: at.line,
|
|
3745
|
+
at: now
|
|
3746
|
+
};
|
|
3747
|
+
};
|
|
3748
|
+
const onMouse = (e) => {
|
|
3749
|
+
if (window.length === 0) return;
|
|
3750
|
+
if (e.kind === "wheel-up" || e.kind === "wheel-down") {
|
|
3751
|
+
const step = mouse?.wheelStep ?? 3;
|
|
3752
|
+
onScroll(scrollBy(scroll, e.kind === "wheel-up" ? -step : step, geometry));
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
if (e.shift) {
|
|
3756
|
+
if (e.kind === "release") press.current = void 0;
|
|
3757
|
+
return;
|
|
3758
|
+
}
|
|
3759
|
+
if (e.button !== "left" && e.kind !== "release") return;
|
|
3760
|
+
const point = pointAt(e);
|
|
3761
|
+
if (e.kind === "press") {
|
|
3762
|
+
if (!point.inside) return;
|
|
3763
|
+
const clamp$1 = outputBox(lines, point.line);
|
|
3764
|
+
press.current = {
|
|
3765
|
+
at: point,
|
|
3766
|
+
moved: false,
|
|
3767
|
+
...clamp$1 ? { clamp: clamp$1 } : {}
|
|
3768
|
+
};
|
|
3769
|
+
setSelection(void 0);
|
|
3770
|
+
return;
|
|
3771
|
+
}
|
|
3772
|
+
const current = press.current;
|
|
3773
|
+
if (!current) return;
|
|
3774
|
+
const head = current.clamp ? {
|
|
3775
|
+
line: Math.max(current.clamp[0], Math.min(point.line, current.clamp[1])),
|
|
3776
|
+
col: point.col
|
|
3777
|
+
} : {
|
|
3778
|
+
line: point.line,
|
|
3779
|
+
col: point.col
|
|
3780
|
+
};
|
|
3781
|
+
if (e.kind === "drag") {
|
|
3782
|
+
current.moved = true;
|
|
3783
|
+
setSelection({
|
|
3784
|
+
anchor: current.at,
|
|
3785
|
+
head
|
|
3786
|
+
});
|
|
3787
|
+
return;
|
|
3788
|
+
}
|
|
3789
|
+
press.current = void 0;
|
|
3790
|
+
if (!current.moved && head.line === current.at.line && head.col === current.at.col) {
|
|
3791
|
+
click(current.at, e);
|
|
3792
|
+
return;
|
|
3793
|
+
}
|
|
3794
|
+
const text = copyText(lines, current.at, head, measure);
|
|
3795
|
+
setSelection({
|
|
3796
|
+
anchor: current.at,
|
|
3797
|
+
head
|
|
3798
|
+
});
|
|
3799
|
+
const count = text.split("\n").length;
|
|
3800
|
+
if (text) copy(text, `${count} line${count === 1 ? "" : "s"}`);
|
|
3801
|
+
};
|
|
3802
|
+
const images = props.images;
|
|
3803
|
+
const writeRef = React.useRef(write);
|
|
3804
|
+
writeRef.current = write;
|
|
3805
|
+
React.useEffect(() => {
|
|
3806
|
+
if (images?.query) writeRef.current(images.query);
|
|
3807
|
+
}, [images]);
|
|
3808
|
+
const [readReports] = React.useState(createReportReader);
|
|
3809
|
+
useTerminalData((data) => {
|
|
3810
|
+
for (const report of readReports(data)) {
|
|
3811
|
+
if (images?.takeReply?.(report)) continue;
|
|
3812
|
+
if (mouseActive) for (const e of parseMouse(report)) onMouse(e);
|
|
3813
|
+
}
|
|
3814
|
+
}, mouseActive || images?.takeReply !== void 0);
|
|
3815
|
+
const [from, to] = selection ? ordered(selection.anchor, selection.head) : [];
|
|
3816
|
+
const shown = from && to ? window.map((line, i) => {
|
|
3817
|
+
const at = geometry.first + i;
|
|
3818
|
+
if (at < from.line || at > to.line) return line;
|
|
3819
|
+
const start = at === from.line ? from.col : 0;
|
|
3820
|
+
const end = at === to.line ? to.col : Number.POSITIVE_INFINITY;
|
|
3821
|
+
return highlightLine(line, start, end, theme.selection, measure);
|
|
3822
|
+
}) : window;
|
|
3823
|
+
const selected = from && to ? `${from.line}.${from.col}-${to.line}.${to.col}` : "";
|
|
3824
|
+
const signature = `${geometry.first}:${lines.length}:${props.width}:${selected}:${window.map(lineId).join(",")}`;
|
|
3825
|
+
const body = React.useMemo(() => lines.length === 0 && props.empty ? /* @__PURE__ */ React.createElement(ink.Box, {
|
|
3826
|
+
height: props.height,
|
|
3827
|
+
width: props.width,
|
|
3828
|
+
alignItems: "center",
|
|
3829
|
+
justifyContent: "center"
|
|
3830
|
+
}, props.empty) : /* @__PURE__ */ React.createElement(Lines, {
|
|
3831
|
+
lines: shown,
|
|
3832
|
+
theme,
|
|
3833
|
+
...props.override ? { override: (line, i) => props.override?.(line, i, geometry.first + i) } : {}
|
|
3834
|
+
}), [
|
|
3835
|
+
signature,
|
|
3836
|
+
theme,
|
|
3837
|
+
props.override,
|
|
3838
|
+
props.empty,
|
|
3839
|
+
props.height
|
|
3840
|
+
]);
|
|
3841
|
+
return /* @__PURE__ */ React.createElement(ink.Box, {
|
|
3842
|
+
ref: box,
|
|
3843
|
+
height: props.height,
|
|
3844
|
+
width: props.width,
|
|
3845
|
+
flexDirection: "column",
|
|
3846
|
+
overflow: "hidden",
|
|
3847
|
+
flexShrink: 0
|
|
3848
|
+
}, body);
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
//#endregion
|
|
3852
|
+
//#region src/create.ts
|
|
3853
|
+
/**
|
|
3854
|
+
* One shared clock for every timer, rate, spinner and pulse this package
|
|
3855
|
+
* draws (the core hooks' provider, on the host's React).
|
|
3856
|
+
*/
|
|
3857
|
+
function ClockProvider(props) {
|
|
3858
|
+
return React.createElement(hooks.ClockProvider, props);
|
|
3859
|
+
}
|
|
3860
|
+
function createChatInk(deps) {
|
|
3861
|
+
bindRuntime(deps);
|
|
3862
|
+
return {
|
|
3863
|
+
React: deps.React,
|
|
3864
|
+
ink: deps.ink,
|
|
3865
|
+
hooks,
|
|
3866
|
+
ClockProvider,
|
|
3867
|
+
Composer,
|
|
3868
|
+
EmptyState,
|
|
3869
|
+
InkChatProvider,
|
|
3870
|
+
Lines,
|
|
3871
|
+
MessageView,
|
|
3872
|
+
PendingRows,
|
|
3873
|
+
PlainMarkdown,
|
|
3874
|
+
Row,
|
|
3875
|
+
Spinner,
|
|
3876
|
+
StatusLine,
|
|
3877
|
+
Styled,
|
|
3878
|
+
Transcript,
|
|
3879
|
+
TranscriptViewport,
|
|
3880
|
+
useInkComponents,
|
|
3881
|
+
useMouseReporting,
|
|
3882
|
+
useOpenState,
|
|
3883
|
+
useTerminalData,
|
|
3884
|
+
useTranscriptLayout
|
|
3885
|
+
};
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
//#endregion
|
|
3889
|
+
//#region src/lines/Markdown.tsx
|
|
3890
|
+
function TerminalMarkdown({ content, streaming, width: width$2, measure = defaultMeasure }) {
|
|
3891
|
+
const { theme, glyphs } = useInkComponents();
|
|
3892
|
+
const ref = React.useRef(null);
|
|
3893
|
+
const box = ink.useBoxMetrics(ref);
|
|
3894
|
+
const { columns } = ink.useWindowSize();
|
|
3895
|
+
const cells$1 = width$2 ?? (box.hasMeasured && box.width > 0 ? box.width : columns);
|
|
3896
|
+
const lines = React.useMemo(() => terminalMarkdown(stripControls(content), cells$1, {
|
|
3897
|
+
measure,
|
|
3898
|
+
glyphs,
|
|
3899
|
+
...streaming ? { streaming } : {}
|
|
3900
|
+
}), [
|
|
3901
|
+
content,
|
|
3902
|
+
cells$1,
|
|
3903
|
+
measure,
|
|
3904
|
+
glyphs,
|
|
3905
|
+
streaming
|
|
3906
|
+
]);
|
|
3907
|
+
return /* @__PURE__ */ React.createElement(ink.Box, {
|
|
3908
|
+
ref,
|
|
3909
|
+
flexDirection: "column",
|
|
3910
|
+
flexGrow: 1
|
|
3911
|
+
}, lines.length > 0 && /* @__PURE__ */ React.createElement(Lines, {
|
|
3912
|
+
lines,
|
|
3913
|
+
theme
|
|
3914
|
+
}));
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
//#endregion
|
|
3918
|
+
export { ClockProvider, Composer, EmptyState, InkChatProvider, KITTY_POP, KITTY_PUSH, Lines, MIN_COLUMN, MOUSE_OFF, MOUSE_ON, MessageView, NOTHING_OPEN, PendingRows, PlainMarkdown, ROW_PATH, React, Row, Spinner, StatusLine, Styled, THUMBNAIL, TerminalMarkdown, Transcript, TranscriptViewport, alignments, appearanceProps, asciiGlyphs, backspace, cachedMeasure, caretBoundary, caretLines, caretRow, chunks, clipLines, compactFrame, composerInnerWidth, composerRows, copyText, createChatInk, createHistory, createLayoutCache, createReportReader, defaultMeasure, deleteForward, deleteWordBack, describeSource, emojiGlyphs, emptyComposerValue, emptyEditor, firstLine, fitColumns, footerSpans, graphemes, hardSplit, highlightLine, hooks, ink, insert, isBound, isSeparator, isTerminalReport, killToLineEnd, killToLineStart, layoutBlocks, layoutBody, layoutImage, layoutMessage, layoutSignature, layoutTable, layoutTranscript, lineEnd, lineStart, lineText, mergeInkComponents, mergeSpans, moveBy, moveLine, moveVisual, ordered, osc52, pageBy, parseInline, parseMouse, plainComponents, plainGlyphs, plainMarkdownLayout, plainTheme, prefix, pushHistory, resolveAccent, screenOrigin, scrollBy, scrollEnd, scrollHome, showsSomething, span, spanProps, splitAtCaret, splitRow, splitRuns, stepHistory, stripControls, stripControlsDeep, suffix, terminalMarkdown, threadedFrame, toJson, truncate, useInkComponents, useMouseReporting, useOpenState, useTerminalData, useTranscriptLayout, viewportKeyAction, visualRows, withEntries, wordLeft, wordRight, wrapLines, wrapSpans, wrapText };
|