@stevecastaneda/paseo-progress 1.10.0 → 1.12.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.
@@ -0,0 +1,483 @@
1
+ // A Markdown file has its relative links and images pointed at its own folder,
2
+ // because Paseo's Markdown resolves them from the workspace and plugins can't
3
+ // tell it otherwise.
4
+
5
+ const LINE_SEARCH_BYTES = 8 * 1024;
6
+
7
+ export function previewText(bytes: Uint8Array, truncated: boolean): string {
8
+ return new TextDecoder().decode(truncated ? cleanCut(bytes) : bytes);
9
+ }
10
+
11
+ function cleanCut(bytes: Uint8Array): Uint8Array {
12
+ let end = wholeCharacters(bytes);
13
+ const newline = end ? bytes.lastIndexOf(0x0a, end - 1) : -1;
14
+ if (newline >= 0 && end - (newline + 1) <= LINE_SEARCH_BYTES) end = newline + 1;
15
+ return bytes.subarray(0, end);
16
+ }
17
+
18
+ function wholeCharacters(bytes: Uint8Array): number {
19
+ const end = bytes.length;
20
+ for (let back = 1; back <= Math.min(4, end); back++) {
21
+ const byte = bytes[end - back];
22
+ if (isContinuationByte(byte)) continue;
23
+ return sequenceLength(byte) > back ? end - back : end;
24
+ }
25
+ return end;
26
+ }
27
+
28
+ function isContinuationByte(byte: number): boolean {
29
+ return (byte & 0xc0) === 0x80;
30
+ }
31
+
32
+ function sequenceLength(leadByte: number): number {
33
+ return leadByte >= 0xf0 ? 4 : leadByte >= 0xe0 ? 3 : leadByte >= 0xc0 ? 2 : 1;
34
+ }
35
+
36
+ // `path` is the daemon's node:path, passed in because Paseo refuses Node modules
37
+ // in shared code.
38
+ export interface FileLocation {
39
+ root: string;
40
+ folder: string;
41
+ path: { resolve(...paths: string[]): string; relative(from: string, to: string): string; isAbsolute(path: string): boolean; sep: string };
42
+ }
43
+
44
+ export function prepareMarkdown(bytes: Uint8Array, truncated: boolean, where: FileLocation): string {
45
+ const text = previewText(bytes, truncated).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
46
+ return rewriteTargets(fenceFrontMatter(text), where);
47
+ }
48
+
49
+ // Paseo's Markdown would draw front matter as a rule and a heading.
50
+ function fenceFrontMatter(text: string): string {
51
+ const close = /^(?:---|\.\.\.)[ \t]*$/m;
52
+ const open = /^---[ \t]*\n/.exec(text);
53
+ if (!open) return text;
54
+ const end = close.exec(text.slice(open[0].length));
55
+ if (!end) return text;
56
+ const body = text.slice(open[0].length, open[0].length + end.index);
57
+ const fence = "`".repeat(Math.max(3, ...Array.from(body.matchAll(/^[ \t]*(`+)/gm), (match) => match[1].length + 1)));
58
+ return `${fence}yaml\n${body}${fence}${text.slice(open[0].length + end.index + end[0].length)}`;
59
+ }
60
+
61
+ type LineKind = "blank" | "text" | "fence-open" | "fence-body" | "fence-close" | "indented-code" | "comment";
62
+
63
+ interface Line {
64
+ start: number;
65
+ end: number;
66
+ kind: LineKind;
67
+ topLevelItem: boolean;
68
+ definition: boolean;
69
+ headingLevel: number;
70
+ }
71
+
72
+ type Fence = { run: string; quotes: number; indent: number };
73
+
74
+ const QUOTES = /^(?:[ ]{0,3}>[ ]?)*/;
75
+ const FENCE = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})/;
76
+ const ITEM = /^[ \t]*(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/;
77
+ const DEFINITION = /^ {0,3}\[(?!\^)(?:[^\\\]]|\\.)+\]:/;
78
+
79
+ function scanLines(source: string): Line[] {
80
+ const lines: Line[] = [];
81
+ let fence: Fence | null = null;
82
+ let comment = false;
83
+ let list = false;
84
+ let previous: Line | undefined;
85
+ for (let start = 0; start <= source.length;) {
86
+ const newline = source.indexOf("\n", start);
87
+ const end = newline < 0 ? source.length : newline;
88
+ const raw = source.slice(start, end);
89
+ const quotePrefix = QUOTES.exec(raw)![0];
90
+ const quotes = quotePrefix.split(">").length - 1;
91
+ const rest = raw.slice(quotePrefix.length);
92
+ const blank = !rest.trim();
93
+ const indent = indentOf(rest);
94
+ const line: Line = { start, end, kind: "text", topLevelItem: false, definition: false, headingLevel: 0 };
95
+ if (fence && (quotes < fence.quotes || (!blank && indent < fence.indent))) fence = null;
96
+ if (fence) {
97
+ const close = FENCE.exec(rest);
98
+ const closes = close !== null && rest.trim() === close[1] && close[1][0] === fence.run[0] && close[1].length >= fence.run.length;
99
+ line.kind = closes ? "fence-close" : "fence-body";
100
+ if (closes) fence = null;
101
+ } else if (comment) {
102
+ line.kind = "comment";
103
+ comment = !raw.includes("-->") || codeAndCommentMask(raw.slice(raw.lastIndexOf("-->") + 3)).comment;
104
+ } else if (blank) {
105
+ line.kind = "blank";
106
+ } else {
107
+ const item = ITEM.test(rest);
108
+ const heading = /^ {0,3}(#{1,6})(?:[ \t]|$)/.exec(rest)?.[1].length ?? 0;
109
+ if (list && indent === 0 && !item && (previous?.kind === "blank" || heading)) list = false;
110
+ const paragraphAbove = previous?.kind === "text" && !previous.headingLevel && !previous.definition;
111
+ if (indent >= 4 && !list && !paragraphAbove) line.kind = "indented-code";
112
+ else if (FENCE.test(rest) && (indent < 4 || list)) {
113
+ line.kind = "fence-open";
114
+ fence = { run: FENCE.exec(rest)![1], quotes, indent: list ? indent : 0 };
115
+ } else {
116
+ if (item) list = true;
117
+ line.topLevelItem = item && quotes === 0 && indent < 2;
118
+ line.headingLevel = heading;
119
+ line.definition = DEFINITION.test(rest) && !paragraphAbove;
120
+ comment = codeAndCommentMask(raw).comment;
121
+ }
122
+ }
123
+ lines.push(line);
124
+ previous = line;
125
+ start = end + 1;
126
+ }
127
+ return lines;
128
+ }
129
+
130
+ function indentOf(text: string): number {
131
+ let columns = 0;
132
+ for (const character of text) {
133
+ if (character === " ") columns++;
134
+ else if (character === "\t") columns += 4 - (columns % 4);
135
+ else break;
136
+ }
137
+ return columns;
138
+ }
139
+
140
+ function codeAndCommentMask(text: string): { mask: Uint8Array; comment: boolean } {
141
+ const mask = new Uint8Array(text.length);
142
+ for (let index = 0; index < text.length;) {
143
+ if (text[index] === "\\") {
144
+ index += 2;
145
+ } else if (text[index] === "`") {
146
+ const run = runLength(text, index);
147
+ let close = text.indexOf("`", index + run);
148
+ while (close >= 0 && runLength(text, close) !== run) close = text.indexOf("`", close + runLength(text, close));
149
+ if (close < 0) {
150
+ index += run;
151
+ continue;
152
+ }
153
+ mask.fill(1, index, close + run);
154
+ index = close + run;
155
+ } else if (text.startsWith("<!--", index)) {
156
+ const close = text.indexOf("-->", index + 4);
157
+ if (close < 0) {
158
+ mask.fill(1, index);
159
+ return { mask, comment: true };
160
+ }
161
+ mask.fill(1, index, close + 3);
162
+ index = close + 3;
163
+ } else {
164
+ index++;
165
+ }
166
+ }
167
+ return { mask, comment: false };
168
+ }
169
+
170
+ function runLength(text: string, index: number): number {
171
+ let end = index;
172
+ while (text[end] === "`") end++;
173
+ return end - index;
174
+ }
175
+
176
+ type Edit = { start: number; end: number; text: string };
177
+
178
+ // HTML <img src> and <a href> count too: Paseo turns them into Markdown first.
179
+ function rewriteTargets(source: string, where: FileLocation): string {
180
+ const lines = scanLines(source);
181
+ const edits: Edit[] = [];
182
+ for (let first = 0; first < lines.length; first++) {
183
+ if (lines[first].kind !== "text") continue;
184
+ let last = first;
185
+ while (last + 1 < lines.length && lines[last + 1].kind === "text") last++;
186
+ const offset = lines[first].start;
187
+ const text = source.slice(offset, lines[last].end);
188
+ const { mask } = codeAndCommentMask(text);
189
+ const add = (start: number, end: number, target: string, image: boolean) => {
190
+ const replacement = localTarget(target, where, image);
191
+ if (replacement !== null) edits.push({ start: offset + start, end: offset + end, text: replacement });
192
+ };
193
+ inlineTargets(text, mask, add);
194
+ for (let index = first; index <= last; index++) {
195
+ if (!lines[index].definition) continue;
196
+ const lineText = source.slice(lines[index].start, lines[index].end);
197
+ const target = definitionTarget(lineText);
198
+ if (target && !mask[lines[index].start - offset + target.start]) add(lines[index].start - offset + target.start, lines[index].start - offset + target.end, target.target, false);
199
+ }
200
+ for (const tag of text.matchAll(/<(img|a)\b[^>]*>/gi)) {
201
+ if (mask[tag.index]) continue;
202
+ const name = tag[1].toLowerCase() === "img" ? "src" : "href";
203
+ const attribute = new RegExp(`(?<=\\s)${name}[ \\t\\n]*=[ \\t\\n]*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, "i").exec(tag[0]);
204
+ if (!attribute) continue;
205
+ const value = attribute[1] ?? attribute[2] ?? attribute[3];
206
+ const start = tag.index + attribute.index + attribute[0].length - value.length - (attribute[3] === undefined ? 1 : 0);
207
+ add(start, start + value.length, value, name === "src");
208
+ }
209
+ first = last;
210
+ }
211
+ let result = "";
212
+ let last = 0;
213
+ for (const edit of edits.sort((a, b) => a.start - b.start)) {
214
+ if (edit.start < last) continue;
215
+ result += source.slice(last, edit.start) + edit.text;
216
+ last = edit.end;
217
+ }
218
+ return result + source.slice(last);
219
+ }
220
+
221
+ function inlineTargets(text: string, mask: Uint8Array, add: (start: number, end: number, target: string, image: boolean) => void) {
222
+ for (let close = text.indexOf("]("); close >= 0; close = text.indexOf("](", close + 1)) {
223
+ if (mask[close] || escaped(text, close)) continue;
224
+ const open = linkOpen(text, mask, close);
225
+ if (open < 0) continue;
226
+ const destination = inlineDestination(text, close + 2);
227
+ if (destination) add(destination.start, destination.end, destination.target, open > 0 && text[open - 1] === "!" && !escaped(text, open - 1));
228
+ }
229
+ }
230
+
231
+ function linkOpen(text: string, mask: Uint8Array, close: number): number {
232
+ let depth = 0;
233
+ for (let index = close - 1; index >= 0; index--) {
234
+ if (mask[index] || escaped(text, index)) continue;
235
+ if (text[index] === "]") depth++;
236
+ else if (text[index] === "[" && depth-- === 0) return index;
237
+ }
238
+ return -1;
239
+ }
240
+
241
+ function escaped(text: string, index: number): boolean {
242
+ let slashes = 0;
243
+ while (index - slashes - 1 >= 0 && text[index - slashes - 1] === "\\") slashes++;
244
+ return slashes % 2 === 1;
245
+ }
246
+
247
+ function inlineDestination(text: string, from: number): { start: number; end: number; target: string } | null {
248
+ let index = skipSpace(text, from);
249
+ const start = index;
250
+ if (text[index] === "<") {
251
+ const close = angleEnd(text, index);
252
+ if (close < 0) return null;
253
+ index = close + 1;
254
+ } else {
255
+ let depth = 0;
256
+ for (; index < text.length; index++) {
257
+ const character = text[index];
258
+ if (character === "\\" && isPunctuation(text[index + 1])) index++;
259
+ else if (character === "(") depth++;
260
+ else if (character === ")" && depth-- === 0) break;
261
+ else if (/[\s\x00-\x1f]/.test(character)) break;
262
+ }
263
+ if (depth > 0) return null;
264
+ }
265
+ const end = index;
266
+ index = skipSpace(text, index);
267
+ if (index > end && /["'(]/.test(text[index] ?? "")) {
268
+ index = titleEnd(text, index);
269
+ if (index < 0) return null;
270
+ index = skipSpace(text, index);
271
+ }
272
+ if (text[index] !== ")") return null;
273
+ return { start, end, target: unescape(text[start] === "<" ? text.slice(start + 1, end - 1) : text.slice(start, end)) };
274
+ }
275
+
276
+ function definitionTarget(line: string): { start: number; end: number; target: string } | null {
277
+ const label = DEFINITION.exec(line.slice(QUOTES.exec(line)![0].length));
278
+ if (!label) return null;
279
+ const start = skipSpace(line, QUOTES.exec(line)![0].length + label[0].length);
280
+ let end = start;
281
+ if (line[start] === "<") end = angleEnd(line, start) + 1;
282
+ else while (end < line.length && !/\s/.test(line[end])) end++;
283
+ if (end <= start) return null;
284
+ const after = line.slice(end);
285
+ if (after.trim() && !/^\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\((?:[^)\\]|\\.)*\))\s*$/.test(after)) return null;
286
+ return { start, end, target: unescape(line[start] === "<" ? line.slice(start + 1, end - 1) : line.slice(start, end)) };
287
+ }
288
+
289
+ function angleEnd(text: string, open: number): number {
290
+ for (let index = open + 1; index < text.length; index++) {
291
+ if (text[index] === "\\") index++;
292
+ else if (text[index] === ">") return index;
293
+ else if (text[index] === "<" || text[index] === "\n") return -1;
294
+ }
295
+ return -1;
296
+ }
297
+
298
+ function titleEnd(text: string, open: number): number {
299
+ const close = text[open] === "(" ? ")" : text[open];
300
+ for (let index = open + 1; index < text.length; index++) {
301
+ if (text[index] === "\\") index++;
302
+ else if (text[index] === close) return index + 1;
303
+ }
304
+ return -1;
305
+ }
306
+
307
+ function skipSpace(text: string, index: number): number {
308
+ let newlines = 0;
309
+ while (index < text.length && /[ \t\n]/.test(text[index]) && (text[index] !== "\n" || newlines++ === 0)) index++;
310
+ return index;
311
+ }
312
+
313
+ function isPunctuation(character: string | undefined): boolean {
314
+ return character !== undefined && /[!-/:-@[-`{-~]/.test(character);
315
+ }
316
+
317
+ function unescape(text: string): string {
318
+ return text.replace(/\\([!-/:-@[-`{-~])/g, "$1");
319
+ }
320
+
321
+ // A link keeps its fragment (`#L12` jumps to a line); an image doesn't need one.
322
+ // A query never reaches a local file.
323
+ function localTarget(written: string, where: FileLocation, image: boolean): string | null {
324
+ if (/^(?:[a-z][a-z0-9+.-]*:|[#/\\~])/i.test(written)) return null;
325
+ const split = written.search(/[?#]/);
326
+ const path = split < 0 ? written : written.slice(0, split);
327
+ if (!path) return null;
328
+ const hash = written.indexOf("#");
329
+ const fragment = image || hash < 0 ? "" : written.slice(hash + 1);
330
+ const { resolve, relative, isAbsolute, sep } = where.path;
331
+ const decoded = decode(path);
332
+ const absolute = resolve(where.folder, decoded);
333
+ const inside = relative(where.root, absolute);
334
+ if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return null;
335
+ const folder = decoded.endsWith("/") || decoded.endsWith(sep);
336
+ const slashed = absolute.split(sep).join("/") + (folder && !absolute.endsWith(sep) ? "/" : "");
337
+ // Paseo reads `//server/share` as a web address, so a file on a network
338
+ // share keeps its relative targets.
339
+ if (slashed.startsWith("//")) return null;
340
+ return encodeTarget(slashed) + (fragment ? `#${encodeTarget(decode(fragment))}` : "");
341
+ }
342
+
343
+ function decode(text: string): string {
344
+ try {
345
+ return decodeURIComponent(text);
346
+ } catch {
347
+ return text;
348
+ }
349
+ }
350
+
351
+ // A Windows drive's colon stays, so Paseo still sees `C:/`.
352
+ function encodeTarget(path: string): string {
353
+ const drive = /^[A-Za-z]:\//.exec(path)?.[0] ?? "";
354
+ let encoded = drive;
355
+ for (const character of path.slice(drive.length)) {
356
+ if (/[A-Za-z0-9\-._~!$*+,;@/]/.test(character)) encoded += character;
357
+ else {
358
+ const escape = encodeURIComponent(character);
359
+ encoded += escape === character ? `%${character.charCodeAt(0).toString(16).toUpperCase()}` : escape;
360
+ }
361
+ }
362
+ return encoded;
363
+ }
364
+
365
+ export function flattenHeadings(markdown: string, level: number): string {
366
+ let result = "";
367
+ let last = 0;
368
+ for (const line of scanLines(markdown)) {
369
+ if (!line.headingLevel) continue;
370
+ const marks = /^((?:[ ]{0,3}>[ ]?)*[ ]{0,3})#+/.exec(markdown.slice(line.start, line.end))!;
371
+ result += markdown.slice(last, line.start + marks[1].length) + "#".repeat(level);
372
+ last = line.start + marks[0].length;
373
+ }
374
+ return result + markdown.slice(last);
375
+ }
376
+
377
+ export function dropTitle(markdown: string): string {
378
+ const lines = scanLines(markdown);
379
+ let index = lines.findIndex((line) => line.kind !== "blank");
380
+ if (isFrontMatter(markdown, lines[index])) {
381
+ while (index < lines.length && lines[index].kind !== "fence-close") index++;
382
+ while (++index < lines.length && lines[index].kind === "blank");
383
+ }
384
+ const title = lines[index];
385
+ if (!title || title.kind !== "text") return markdown;
386
+ const underline = lines[index + 1]?.kind === "text" && /^ {0,3}=+[ \t]*$/.test(markdown.slice(lines[index + 1].start, lines[index + 1].end));
387
+ if (title.headingLevel !== 1 && (title.headingLevel || title.topLevelItem || !underline)) return markdown;
388
+ let next = index + (underline && title.headingLevel !== 1 ? 2 : 1);
389
+ while (lines[next]?.kind === "blank") next++;
390
+ return markdown.slice(0, title.start) + (next < lines.length ? markdown.slice(lines[next].start) : "");
391
+ }
392
+
393
+ function isFrontMatter(markdown: string, line: Line | undefined): boolean {
394
+ return line?.kind === "fence-open" && /^```+yaml\s*$/.test(markdown.slice(line.start, line.end));
395
+ }
396
+
397
+ type Unit = { kind: "paragraph" | "item" | "fence" | "code"; start: number; end: number; visible: number; heading: boolean; lines: Line[] };
398
+
399
+ export function markdownExcerpt(markdown: string, maxChars: number): { text: string; more: boolean } {
400
+ const body = dropTitle(markdown);
401
+ const { units, definitions } = blockUnits(body);
402
+ if (!units.length) return { text: "", more: false };
403
+ for (const each of units) each.visible = visibleLength(body, each, maxChars);
404
+ let taken = 0;
405
+ for (let total = 0; taken < units.length && (taken === 0 || total + units[taken].visible <= maxChars); taken++) total += units[taken].visible;
406
+ if (taken > 1 && units[taken - 1].heading) taken--;
407
+ const first = units[0];
408
+ const { end, closing } = taken === 1 && first.visible > 2 * maxChars ? cutFirstBlock(body, first, maxChars) : { end: units[taken - 1].end, closing: "" };
409
+ const references = definitions.filter((line) => line.start < first.start || line.start >= end).map((line) => body.slice(line.start, line.end));
410
+ const text = body.slice(first.start, end) + closing + (references.length ? `\n\n${references.join("\n")}` : "");
411
+ return { text, more: taken < units.length || end < units[taken - 1].end };
412
+ }
413
+
414
+ function blockUnits(body: string): { units: Unit[]; definitions: Line[] } {
415
+ const units: Unit[] = [];
416
+ const definitions: Line[] = [];
417
+ let unit: Unit | null = null;
418
+ let afterBlank = false;
419
+ const close = () => {
420
+ if (unit) units.push(unit);
421
+ unit = null;
422
+ };
423
+ for (const line of scanLines(body)) {
424
+ const text = body.slice(line.start, line.end);
425
+ const current = unit as Unit | null;
426
+ const inFence = line.kind === "fence-body" || line.kind === "fence-close";
427
+ const continues = current !== null && (
428
+ (current.kind === "fence" && inFence)
429
+ || (current.kind === "code" && line.kind === "indented-code")
430
+ || (current.kind === "item" && !line.topLevelItem && line.kind !== "blank" && (inFence || indentOf(text) >= 2 || (!afterBlank && line.kind === "text" && !line.headingLevel)))
431
+ || (current.kind === "paragraph" && line.kind === "text" && !line.headingLevel && !line.topLevelItem && !line.definition));
432
+ if (continues) {
433
+ current.end = line.end;
434
+ current.lines.push(line);
435
+ if (current.kind === "fence" && line.kind === "fence-close") close();
436
+ } else if (line.definition) {
437
+ definitions.push(line);
438
+ } else if (line.kind === "blank") {
439
+ if (current?.kind === "paragraph" || current?.kind === "fence") close();
440
+ } else if (line.kind !== "comment") {
441
+ close();
442
+ const kind = line.kind === "fence-open" || inFence ? "fence" : line.kind === "indented-code" ? "code" : line.topLevelItem ? "item" : "paragraph";
443
+ unit = { kind, start: line.start, end: line.end, visible: 0, heading: line.headingLevel > 0, lines: [line] };
444
+ if (line.headingLevel) close();
445
+ }
446
+ afterBlank = line.kind === "blank";
447
+ }
448
+ close();
449
+ if (units.length && isFrontMatter(body, units[0].lines[0])) units.shift();
450
+ return { units, definitions };
451
+ }
452
+
453
+ function cutFirstBlock(body: string, first: Unit, maxChars: number): { end: number; closing: string } {
454
+ const counted = first.kind === "fence" ? first.lines.filter((line) => line.kind === "fence-body") : first.lines;
455
+ let kept = 0;
456
+ for (let visible = 0; kept < counted.length; kept++) {
457
+ visible += counted[kept].end - counted[kept].start;
458
+ if (kept && visible > maxChars) break;
459
+ }
460
+ if (kept === counted.length) return { end: first.end, closing: "" };
461
+ const fence = first.kind === "fence" ? /^([ \t>]*)(`{3,}|~{3,})/.exec(body.slice(first.start, first.lines[0].end)) : null;
462
+ return { end: counted[kept - 1].end, closing: fence ? `\n${fence[1]}${fence[2]}` : "" };
463
+ }
464
+
465
+ function visibleLength(body: string, unit: Unit, maxChars: number): number {
466
+ const text = body.slice(unit.start, unit.end);
467
+ if (unit.kind === "fence" || unit.kind === "code") return (unit.kind === "fence" ? unit.lines.slice(1, -1) : unit.lines).reduce((sum, line) => sum + line.end - line.start, 0);
468
+ if (/!\[[^\]]*\]\(|<img\b/i.test(text)) return maxChars;
469
+ return text
470
+ .replace(/<!--[\s\S]*?-->|<[^>]+>/g, "")
471
+ .replace(/\]\([^)]*\)/g, "]")
472
+ .replace(/^[ \t>]*(?:(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?|#{1,6}[ \t]+)?/gm, "")
473
+ .replace(/[*_`~[\]|\\]/g, "").length;
474
+ }
475
+
476
+ export function textExcerpt(text: string, maxChars: number): { text: string; more: boolean } {
477
+ const whole = text.trimEnd();
478
+ if (whole.length <= maxChars) return { text: whole, more: false };
479
+ const line = whole.lastIndexOf("\n", maxChars);
480
+ if (line > 0) return { text: whole.slice(0, line).trimEnd(), more: true };
481
+ const space = whole.lastIndexOf(" ", maxChars);
482
+ return { text: `${whole.slice(0, space > 0 ? space : maxChars)}…`, more: true };
483
+ }
package/shared/rpc.ts CHANGED
@@ -142,8 +142,9 @@ export const canOpenOnHost = defineRpc({
142
142
  output: z.object({ canOpen: z.boolean() }),
143
143
  });
144
144
 
145
- // An image, text or HTML file's contents, for the preview dialog. A video comes
146
- // back as its type and size only; its bytes follow in pieces (readDeliverablePiece).
145
+ // An image, text, Markdown or HTML file's contents, for the preview dialog. A
146
+ // video comes back as its type and size only; its bytes follow in pieces
147
+ // (readDeliverablePiece).
147
148
  export const previewDeliverable = defineRpc({
148
149
  name: "progress.deliverable.preview",
149
150
  input: z.object({
@@ -156,6 +157,7 @@ export const previewDeliverable = defineRpc({
156
157
  z.object({ kind: z.literal("image"), dataUri: z.string(), bytes: z.number() }),
157
158
  z.object({ kind: z.literal("video"), mimeType: z.string(), bytes: z.number() }),
158
159
  z.object({ kind: z.literal("text"), text: z.string(), bytes: z.number(), truncated: z.boolean() }),
160
+ z.object({ kind: z.literal("markdown"), markdown: z.string(), bytes: z.number(), truncated: z.boolean() }),
159
161
  z.object({ kind: z.literal("html"), html: z.string(), bytes: z.number() }),
160
162
  z.object({ kind: z.literal("folder"), entries: z.array(z.object({ name: z.string(), folder: z.boolean() })), truncated: z.boolean() }),
161
163
  ]),
@@ -41,7 +41,7 @@ When a decision belongs to the user, ask it with `question ask`, lettered `--opt
41
41
  The user picks the question mode in Paseo's Settings, and `question ask` prints what that mode means for this question. Do what it prints. Record the user's reply ("Q7 A", or their own words) with `question answer Q7 A --words "<their words>"`, and do what that prints too.
42
42
 
43
43
  - When the question is about how something looks, attach the screenshot or link with `--file`.
44
- - When the user asks about a question, answer in chat and add the explanation with `question update Q7 --background`.
44
+ - When the user asks about a question, answer in chat and add the explanation with `question update Q7 --background`. If explaining it changes your mind, also pass `--recommend` with the new option, say in `--background` what changed, and do what it prints.
45
45
 
46
46
  ## 4. Finish
47
47