@gavin-hu/my-pi-agent 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/README.zh-CN.md +153 -0
- package/extensions/bytefly/README.md +129 -122
- package/extensions/bytefly/doctor/format.ts +3 -22
- package/extensions/bytefly/doctor/register.ts +10 -8
- package/extensions/bytefly/footer/register.ts +17 -91
- package/extensions/bytefly/header/component.ts +8 -4
- package/extensions/bytefly/header/config.ts +11 -6
- package/extensions/bytefly/header/register.ts +7 -27
- package/extensions/bytefly/index.ts +15 -14
- package/extensions/bytefly/panel/commands.ts +147 -0
- package/extensions/bytefly/panel/format.ts +135 -0
- package/extensions/bytefly/panel/join.ts +103 -0
- package/extensions/bytefly/panel/tui.ts +416 -0
- package/extensions/bytefly/panel/types.ts +44 -0
- package/extensions/bytefly/picker/resources.ts +0 -11
- package/extensions/file-browse/README.md +12 -5
- package/extensions/file-browse/markdown.ts +227 -45
- package/extensions/plan/README.md +16 -12
- package/extensions/plan/plans.ts +16 -17
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/steps.ts +31 -19
- package/extensions/plan/tools.ts +62 -32
- package/extensions/plan/types.ts +6 -6
- package/lib/README.md +1 -1
- package/lib/requirements.ts +3 -3
- package/lib/tui.ts +7 -8
- package/lib/web/components.ts +11 -0
- package/package.json +1 -1
- package/extensions/bytefly/doctor/commands.ts +0 -68
- package/extensions/bytefly/doctor/tui.ts +0 -140
- package/extensions/bytefly/picker/commands.ts +0 -73
- package/extensions/bytefly/picker/tui.ts +0 -260
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* It is deliberately not CommonMark: it covers the block and inline constructs
|
|
5
5
|
* that show up in repository READMEs — headings, paragraphs, fenced code,
|
|
6
|
-
* blockquotes, nested and task lists, tables, code spans, emphasis,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* blockquotes, nested and task lists, tables, code spans, emphasis, inline and
|
|
7
|
+
* reference links, and images. Named and numeric character references are
|
|
8
|
+
* decoded as literal text. Everything is escaped and only a known set of tags
|
|
9
|
+
* is emitted; raw HTML in the source is shown as text, never executed.
|
|
9
10
|
*
|
|
10
11
|
* Relative links become `/view/<path>` and relative images become `/raw/<path>`
|
|
11
12
|
* so they resolve from any page URL. The `/view` and `/raw` routes re-vet the
|
|
@@ -26,10 +27,68 @@ import { encodePath } from "./paths.ts";
|
|
|
26
27
|
const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
27
28
|
const SAFE_LINK_SCHEME = /^(?:https?|mailto):/i;
|
|
28
29
|
const HTTP_SCHEME = /^https?:/i;
|
|
30
|
+
/** A link reference definition: `[id]: dest "title"`. */
|
|
31
|
+
const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]*)["')])?\s*$/;
|
|
32
|
+
|
|
33
|
+
interface LinkDefinition {
|
|
34
|
+
dest: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Per-document render state: the resolution base and the reference definitions. */
|
|
39
|
+
interface Context {
|
|
40
|
+
baseDir: string;
|
|
41
|
+
defs: Map<string, LinkDefinition>;
|
|
42
|
+
}
|
|
29
43
|
|
|
30
44
|
/** Render a Markdown source string to a safe HTML fragment. */
|
|
31
45
|
export function renderMarkdown(source: string, baseDir = ""): string {
|
|
32
|
-
|
|
46
|
+
const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/));
|
|
47
|
+
return renderBlocks(body, { baseDir, defs });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fold a reference label the way CommonMark does: trim, collapse, case-fold. */
|
|
51
|
+
function normalizeLabel(label: string): string {
|
|
52
|
+
return label.trim().replace(/\s+/g, " ").toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lift link reference definitions out of the source before block rendering, so
|
|
57
|
+
* a `[id]: dest` line becomes a definition rather than a paragraph and can be
|
|
58
|
+
* referenced from anywhere in the document. Fences are skipped so a
|
|
59
|
+
* definition-shaped line inside code stays literal. Definitions are
|
|
60
|
+
* single-line; one inside a blockquote or list item is left in place as text.
|
|
61
|
+
*/
|
|
62
|
+
function collectDefinitions(lines: string[]): { defs: Map<string, LinkDefinition>; body: string[] } {
|
|
63
|
+
const defs = new Map<string, LinkDefinition>();
|
|
64
|
+
const body: string[] = [];
|
|
65
|
+
let fence: Fence | undefined;
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (fence) {
|
|
68
|
+
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
|
|
69
|
+
if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = undefined;
|
|
70
|
+
body.push(line);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const open = matchFence(line);
|
|
74
|
+
if (open) {
|
|
75
|
+
fence = open;
|
|
76
|
+
body.push(line);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const match = DEFINITION.exec(line);
|
|
80
|
+
if (!match) {
|
|
81
|
+
body.push(line);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const key = normalizeLabel(match[1]);
|
|
85
|
+
if (!defs.has(key)) {
|
|
86
|
+
const dest = match[2].startsWith("<") && match[2].endsWith(">") ? match[2].slice(1, -1) : match[2];
|
|
87
|
+
defs.set(key, { dest, title: match[3] });
|
|
88
|
+
}
|
|
89
|
+
body.push("");
|
|
90
|
+
}
|
|
91
|
+
return { defs, body };
|
|
33
92
|
}
|
|
34
93
|
|
|
35
94
|
function isBlank(line: string): boolean {
|
|
@@ -56,7 +115,7 @@ function stripIndent(line: string, columns: number): string {
|
|
|
56
115
|
return line.slice(index);
|
|
57
116
|
}
|
|
58
117
|
|
|
59
|
-
function renderBlocks(lines: string[],
|
|
118
|
+
function renderBlocks(lines: string[], ctx: Context): string {
|
|
60
119
|
const out: string[] = [];
|
|
61
120
|
let i = 0;
|
|
62
121
|
while (i < lines.length) {
|
|
@@ -76,7 +135,7 @@ function renderBlocks(lines: string[], baseDir: string): string {
|
|
|
76
135
|
|
|
77
136
|
const heading = matchHeading(line);
|
|
78
137
|
if (heading) {
|
|
79
|
-
out.push(`<h${heading.level}>${renderInline(heading.text,
|
|
138
|
+
out.push(`<h${heading.level}>${renderInline(heading.text, ctx)}</h${heading.level}>`);
|
|
80
139
|
i++;
|
|
81
140
|
continue;
|
|
82
141
|
}
|
|
@@ -88,27 +147,29 @@ function renderBlocks(lines: string[], baseDir: string): string {
|
|
|
88
147
|
}
|
|
89
148
|
|
|
90
149
|
if (isBlockquoteLine(line)) {
|
|
91
|
-
const [html, next] = renderBlockquote(lines, i,
|
|
150
|
+
const [html, next] = renderBlockquote(lines, i, ctx);
|
|
92
151
|
out.push(html);
|
|
93
152
|
i = next;
|
|
94
153
|
continue;
|
|
95
154
|
}
|
|
96
155
|
|
|
97
|
-
|
|
98
|
-
|
|
156
|
+
// A list marker wins over a pipe, so `- | a | b |` stays a list item
|
|
157
|
+
// instead of becoming a table whose first cell is `-`.
|
|
158
|
+
if (matchListItem(line)) {
|
|
159
|
+
const [html, next] = renderList(lines, i, ctx);
|
|
99
160
|
out.push(html);
|
|
100
161
|
i = next;
|
|
101
162
|
continue;
|
|
102
163
|
}
|
|
103
164
|
|
|
104
|
-
if (
|
|
105
|
-
const [html, next] =
|
|
165
|
+
if (i + 1 < lines.length && isTableStart(line, lines[i + 1])) {
|
|
166
|
+
const [html, next] = renderTable(lines, i, ctx);
|
|
106
167
|
out.push(html);
|
|
107
168
|
i = next;
|
|
108
169
|
continue;
|
|
109
170
|
}
|
|
110
171
|
|
|
111
|
-
const [html, next] = renderParagraph(lines, i,
|
|
172
|
+
const [html, next] = renderParagraph(lines, i, ctx);
|
|
112
173
|
out.push(html);
|
|
113
174
|
i = next;
|
|
114
175
|
}
|
|
@@ -128,7 +189,7 @@ function isBlockStart(lines: string[], i: number): boolean {
|
|
|
128
189
|
);
|
|
129
190
|
}
|
|
130
191
|
|
|
131
|
-
function renderParagraph(lines: string[], start: number,
|
|
192
|
+
function renderParagraph(lines: string[], start: number, ctx: Context): [string, number] {
|
|
132
193
|
const collected: string[] = [];
|
|
133
194
|
let i = start;
|
|
134
195
|
for (; i < lines.length; i++) {
|
|
@@ -141,7 +202,7 @@ function renderParagraph(lines: string[], start: number, baseDir: string): [stri
|
|
|
141
202
|
let text = line.trimEnd();
|
|
142
203
|
const hard = / {2}$/.test(line) || /\\$/.test(text);
|
|
143
204
|
if (text.endsWith("\\")) text = text.slice(0, -1);
|
|
144
|
-
const rendered = renderInline(text.trim(),
|
|
205
|
+
const rendered = renderInline(text.trim(), ctx);
|
|
145
206
|
return hard ? `${rendered}<br>` : rendered;
|
|
146
207
|
})
|
|
147
208
|
.join("\n");
|
|
@@ -192,7 +253,7 @@ function isBlockquoteLine(line: string): boolean {
|
|
|
192
253
|
return /^ {0,3}>/.test(line);
|
|
193
254
|
}
|
|
194
255
|
|
|
195
|
-
function renderBlockquote(lines: string[], start: number,
|
|
256
|
+
function renderBlockquote(lines: string[], start: number, ctx: Context): [string, number] {
|
|
196
257
|
const inner: string[] = [];
|
|
197
258
|
let i = start;
|
|
198
259
|
for (; i < lines.length; i++) {
|
|
@@ -207,7 +268,7 @@ function renderBlockquote(lines: string[], start: number, baseDir: string): [str
|
|
|
207
268
|
}
|
|
208
269
|
break;
|
|
209
270
|
}
|
|
210
|
-
return [`<blockquote>${renderBlocks(inner,
|
|
271
|
+
return [`<blockquote>${renderBlocks(inner, ctx)}</blockquote>`, i];
|
|
211
272
|
}
|
|
212
273
|
|
|
213
274
|
function splitTableRow(line: string): string[] {
|
|
@@ -237,7 +298,10 @@ function splitTableRow(line: string): string[] {
|
|
|
237
298
|
function isTableStart(header: string, delimiter: string): boolean {
|
|
238
299
|
if (!header.includes("|")) return false;
|
|
239
300
|
const cells = splitTableRow(delimiter);
|
|
240
|
-
|
|
301
|
+
// GFM requires the delimiter row to match the header row's cell count;
|
|
302
|
+
// otherwise the lines are a paragraph, not a table.
|
|
303
|
+
if (cells.length === 0 || cells.length !== splitTableRow(header).length) return false;
|
|
304
|
+
return cells.every((cell) => /^:?-+:?$/.test(cell));
|
|
241
305
|
}
|
|
242
306
|
|
|
243
307
|
function alignmentClass(cell: string): string {
|
|
@@ -249,10 +313,12 @@ function alignmentClass(cell: string): string {
|
|
|
249
313
|
return "";
|
|
250
314
|
}
|
|
251
315
|
|
|
252
|
-
function renderTable(lines: string[], start: number,
|
|
316
|
+
function renderTable(lines: string[], start: number, ctx: Context): [string, number] {
|
|
253
317
|
const header = splitTableRow(lines[start]);
|
|
254
318
|
const align = splitTableRow(lines[start + 1]).map(alignmentClass);
|
|
255
|
-
const head = header
|
|
319
|
+
const head = header
|
|
320
|
+
.map((cell, index) => `<th scope="col"${align[index] ?? ""}>${renderInline(cell, ctx)}</th>`)
|
|
321
|
+
.join("");
|
|
256
322
|
const rows: string[] = [];
|
|
257
323
|
let i = start + 2;
|
|
258
324
|
for (; i < lines.length; i++) {
|
|
@@ -260,11 +326,12 @@ function renderTable(lines: string[], start: number, baseDir: string): [string,
|
|
|
260
326
|
const cells = splitTableRow(lines[i]);
|
|
261
327
|
const tds: string[] = [];
|
|
262
328
|
for (let column = 0; column < header.length; column++) {
|
|
263
|
-
tds.push(`<td${align[column] ?? ""}>${renderInline(cells[column] ?? "",
|
|
329
|
+
tds.push(`<td${align[column] ?? ""}>${renderInline(cells[column] ?? "", ctx)}</td>`);
|
|
264
330
|
}
|
|
265
331
|
rows.push(`<tr>${tds.join("")}</tr>`);
|
|
266
332
|
}
|
|
267
|
-
|
|
333
|
+
const table = `<table class="md-table"><thead><tr>${head}</tr></thead><tbody>${rows.join("")}</tbody></table>`;
|
|
334
|
+
return [`<div class="md-table-wrap">${table}</div>`, i];
|
|
268
335
|
}
|
|
269
336
|
|
|
270
337
|
interface ListItem {
|
|
@@ -285,9 +352,9 @@ function matchListItem(line: string): ListItem | undefined {
|
|
|
285
352
|
};
|
|
286
353
|
}
|
|
287
354
|
|
|
288
|
-
function renderList(lines: string[], start: number,
|
|
355
|
+
function renderList(lines: string[], start: number, ctx: Context): [string, number] {
|
|
289
356
|
const first = matchListItem(lines[start]);
|
|
290
|
-
if (!first) return renderParagraph(lines, start,
|
|
357
|
+
if (!first) return renderParagraph(lines, start, ctx);
|
|
291
358
|
const baseIndent = first.indent;
|
|
292
359
|
const ordered = first.ordered;
|
|
293
360
|
const items: string[] = [];
|
|
@@ -323,18 +390,18 @@ function renderList(lines: string[], start: number, baseDir: string): [string, n
|
|
|
323
390
|
}
|
|
324
391
|
break;
|
|
325
392
|
}
|
|
326
|
-
items.push(renderListItem(contentLines,
|
|
393
|
+
items.push(renderListItem(contentLines, ctx));
|
|
327
394
|
}
|
|
328
395
|
const tag = ordered ? "ol" : "ul";
|
|
329
396
|
return [`<${tag}>${items.join("")}</${tag}>`, i];
|
|
330
397
|
}
|
|
331
398
|
|
|
332
|
-
function renderListItem(contentLines: string[],
|
|
399
|
+
function renderListItem(contentLines: string[], ctx: Context): string {
|
|
333
400
|
const task = /^\[([ xX])\]\s*(.*)$/.exec(contentLines[0] ?? "");
|
|
334
401
|
const done = task ? task[1].toLowerCase() === "x" : false;
|
|
335
402
|
const source = task ? [task[2], ...contentLines.slice(1)] : contentLines;
|
|
336
403
|
const tight = !source.some(isBlank);
|
|
337
|
-
let body = renderBlocks(source,
|
|
404
|
+
let body = renderBlocks(source, ctx);
|
|
338
405
|
if (tight) body = body.replace(/<\/?p>/g, "");
|
|
339
406
|
const mark = task ? `<span class="md-check" aria-hidden="true">${done ? "☑" : "☐"}</span> ` : "";
|
|
340
407
|
return `<li${task ? (done ? ` class="task done"` : ` class="task"`) : ""}>${mark}${body}</li>`;
|
|
@@ -347,11 +414,10 @@ interface ParsedLink {
|
|
|
347
414
|
end: number;
|
|
348
415
|
}
|
|
349
416
|
|
|
350
|
-
|
|
351
|
-
|
|
417
|
+
/** The index of the `]` closing the bracket run opened at `start`, or -1. */
|
|
418
|
+
function matchBracket(text: string, start: number): number {
|
|
352
419
|
let depth = 0;
|
|
353
|
-
let i = start;
|
|
354
|
-
for (; i < text.length; i++) {
|
|
420
|
+
for (let i = start; i < text.length; i++) {
|
|
355
421
|
const char = text[i];
|
|
356
422
|
if (char === "\\") {
|
|
357
423
|
i++;
|
|
@@ -360,10 +426,16 @@ function parseInlineLink(text: string, start: number): ParsedLink | undefined {
|
|
|
360
426
|
if (char === "[") depth++;
|
|
361
427
|
else if (char === "]") {
|
|
362
428
|
depth--;
|
|
363
|
-
if (depth === 0)
|
|
429
|
+
if (depth === 0) return i;
|
|
364
430
|
}
|
|
365
431
|
}
|
|
366
|
-
|
|
432
|
+
return -1;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function parseInlineLink(text: string, start: number): ParsedLink | undefined {
|
|
436
|
+
if (text[start] !== "[") return undefined;
|
|
437
|
+
const i = matchBracket(text, start);
|
|
438
|
+
if (i === -1 || text[i + 1] !== "(") return undefined;
|
|
367
439
|
const label = text.slice(start + 1, i);
|
|
368
440
|
let j = i + 2;
|
|
369
441
|
while (j < text.length && /\s/.test(text[j])) j++;
|
|
@@ -409,6 +481,29 @@ function parseInlineLink(text: string, start: number): ParsedLink | undefined {
|
|
|
409
481
|
return { label, dest, title, end: j + 1 };
|
|
410
482
|
}
|
|
411
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Resolve `[label][id]`, `[label][]`, or `[label]` against the document's
|
|
486
|
+
* reference definitions, preferring the full form and falling back to the
|
|
487
|
+
* shortcut so `[foo][bar]` still resolves `[foo]` when only that is defined.
|
|
488
|
+
*/
|
|
489
|
+
function parseReferenceLink(text: string, start: number, ctx: Context): ParsedLink | undefined {
|
|
490
|
+
if (text[start] !== "[") return undefined;
|
|
491
|
+
const close = matchBracket(text, start);
|
|
492
|
+
if (close === -1 || text[close + 1] === "(") return undefined;
|
|
493
|
+
const label = text.slice(start + 1, close);
|
|
494
|
+
if (text[close + 1] === "[") {
|
|
495
|
+
const end = matchBracket(text, close + 1);
|
|
496
|
+
if (end !== -1) {
|
|
497
|
+
const raw = text.slice(close + 2, end);
|
|
498
|
+
const def = ctx.defs.get(normalizeLabel(raw === "" ? label : raw));
|
|
499
|
+
if (def) return { label, dest: def.dest, title: def.title, end: end + 1 };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const def = ctx.defs.get(normalizeLabel(label));
|
|
503
|
+
if (def) return { label, dest: def.dest, title: def.title, end: close + 1 };
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
412
507
|
interface ResolvedLink {
|
|
413
508
|
href: string;
|
|
414
509
|
external: boolean;
|
|
@@ -459,18 +554,18 @@ function resolveImage(dest: string, baseDir: string): ResolvedImage | undefined
|
|
|
459
554
|
return { href: `/raw/${encodePath(local.rel)}${local.suffix}`, image: true };
|
|
460
555
|
}
|
|
461
556
|
|
|
462
|
-
function renderLink(parsed: ParsedLink,
|
|
463
|
-
const label = renderInline(parsed.label,
|
|
464
|
-
const target = resolveLink(parsed.dest, baseDir);
|
|
557
|
+
function renderLink(parsed: ParsedLink, ctx: Context): string {
|
|
558
|
+
const label = renderInline(parsed.label, ctx);
|
|
559
|
+
const target = resolveLink(parsed.dest, ctx.baseDir);
|
|
465
560
|
if (!target) return label;
|
|
466
561
|
const title = parsed.title ? ` title="${escapeHtml(parsed.title)}"` : "";
|
|
467
562
|
const external = target.external ? ` target="_blank" rel="noopener noreferrer"` : "";
|
|
468
563
|
return `<a href="${escapeHtml(target.href)}"${title}${external}>${label}</a>`;
|
|
469
564
|
}
|
|
470
565
|
|
|
471
|
-
function renderImage(parsed: ParsedLink,
|
|
566
|
+
function renderImage(parsed: ParsedLink, ctx: Context): string {
|
|
472
567
|
const alt = escapeHtml(parsed.label);
|
|
473
|
-
const target = resolveImage(parsed.dest, baseDir);
|
|
568
|
+
const target = resolveImage(parsed.dest, ctx.baseDir);
|
|
474
569
|
if (!target) return alt;
|
|
475
570
|
if (!target.image) {
|
|
476
571
|
return `<a href="${escapeHtml(target.href)}" target="_blank" rel="noopener noreferrer">${alt || escapeHtml(parsed.dest)}</a>`;
|
|
@@ -504,8 +599,86 @@ function findEmphasisClose(text: string, start: number, char: string): number {
|
|
|
504
599
|
return -1;
|
|
505
600
|
}
|
|
506
601
|
|
|
602
|
+
/**
|
|
603
|
+
* The named character references common enough to matter in a README. Anything
|
|
604
|
+
* outside this set stays literal; numeric references are always decoded.
|
|
605
|
+
*/
|
|
606
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
607
|
+
amp: "&",
|
|
608
|
+
lt: "<",
|
|
609
|
+
gt: ">",
|
|
610
|
+
quot: '"',
|
|
611
|
+
apos: "'",
|
|
612
|
+
nbsp: "\u00a0",
|
|
613
|
+
copy: "©",
|
|
614
|
+
reg: "®",
|
|
615
|
+
trade: "™",
|
|
616
|
+
hellip: "…",
|
|
617
|
+
mdash: "—",
|
|
618
|
+
ndash: "–",
|
|
619
|
+
lsquo: "‘",
|
|
620
|
+
rsquo: "’",
|
|
621
|
+
ldquo: "“",
|
|
622
|
+
rdquo: "”",
|
|
623
|
+
laquo: "«",
|
|
624
|
+
raquo: "»",
|
|
625
|
+
times: "×",
|
|
626
|
+
divide: "÷",
|
|
627
|
+
middot: "·",
|
|
628
|
+
bull: "•",
|
|
629
|
+
deg: "°",
|
|
630
|
+
plusmn: "±",
|
|
631
|
+
dagger: "†",
|
|
632
|
+
sect: "§",
|
|
633
|
+
para: "¶",
|
|
634
|
+
euro: "€",
|
|
635
|
+
pound: "£",
|
|
636
|
+
yen: "¥",
|
|
637
|
+
cent: "¢",
|
|
638
|
+
larr: "←",
|
|
639
|
+
rarr: "→",
|
|
640
|
+
harr: "↔",
|
|
641
|
+
le: "≤",
|
|
642
|
+
ge: "≥",
|
|
643
|
+
ne: "≠",
|
|
644
|
+
asymp: "≈",
|
|
645
|
+
sup2: "²",
|
|
646
|
+
sup3: "³",
|
|
647
|
+
frac12: "½",
|
|
648
|
+
frac14: "¼",
|
|
649
|
+
frac34: "¾",
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
interface Entity {
|
|
653
|
+
value: string;
|
|
654
|
+
length: number;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** A code point, or U+FFFD when the reference is a surrogate or out of range. */
|
|
658
|
+
function codePoint(value: number): string {
|
|
659
|
+
if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
|
|
660
|
+
return "\uFFFD";
|
|
661
|
+
return String.fromCodePoint(value);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Match a numeric or named character reference at `start`. Decoding happens in
|
|
666
|
+
* the inline scanner rather than a pre-pass so an entity cannot become Markdown
|
|
667
|
+
* syntax: `*x*` stays the literal text `*x*`.
|
|
668
|
+
*/
|
|
669
|
+
function matchEntity(text: string, start: number): Entity | undefined {
|
|
670
|
+
const match = /^&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/.exec(text.slice(start));
|
|
671
|
+
if (!match) return undefined;
|
|
672
|
+
let value: string | undefined;
|
|
673
|
+
if (match[1] !== undefined) value = codePoint(Number(match[1]));
|
|
674
|
+
else if (match[2] !== undefined) value = codePoint(Number.parseInt(match[2], 16));
|
|
675
|
+
else if (match[3] !== undefined && Object.hasOwn(NAMED_ENTITIES, match[3])) value = NAMED_ENTITIES[match[3]];
|
|
676
|
+
if (value === undefined) return undefined;
|
|
677
|
+
return { value, length: match[0].length };
|
|
678
|
+
}
|
|
679
|
+
|
|
507
680
|
/** Render inline Markdown within a block, escaping every literal character. */
|
|
508
|
-
function renderInline(text: string,
|
|
681
|
+
function renderInline(text: string, ctx: Context): string {
|
|
509
682
|
let out = "";
|
|
510
683
|
let i = 0;
|
|
511
684
|
while (i < text.length) {
|
|
@@ -517,6 +690,15 @@ function renderInline(text: string, baseDir: string): string {
|
|
|
517
690
|
continue;
|
|
518
691
|
}
|
|
519
692
|
|
|
693
|
+
if (char === "&") {
|
|
694
|
+
const entity = matchEntity(text, i);
|
|
695
|
+
if (entity) {
|
|
696
|
+
out += escapeHtml(entity.value);
|
|
697
|
+
i += entity.length;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
520
702
|
if (char === "`") {
|
|
521
703
|
const run = matchRun(text, i, "`");
|
|
522
704
|
const close = text.indexOf("`".repeat(run), i + run);
|
|
@@ -530,18 +712,18 @@ function renderInline(text: string, baseDir: string): string {
|
|
|
530
712
|
}
|
|
531
713
|
|
|
532
714
|
if (char === "!" && text[i + 1] === "[") {
|
|
533
|
-
const parsed = parseInlineLink(text, i + 1);
|
|
715
|
+
const parsed = parseInlineLink(text, i + 1) ?? parseReferenceLink(text, i + 1, ctx);
|
|
534
716
|
if (parsed) {
|
|
535
|
-
out += renderImage(parsed,
|
|
717
|
+
out += renderImage(parsed, ctx);
|
|
536
718
|
i = parsed.end;
|
|
537
719
|
continue;
|
|
538
720
|
}
|
|
539
721
|
}
|
|
540
722
|
|
|
541
723
|
if (char === "[") {
|
|
542
|
-
const parsed = parseInlineLink(text, i);
|
|
724
|
+
const parsed = parseInlineLink(text, i) ?? parseReferenceLink(text, i, ctx);
|
|
543
725
|
if (parsed) {
|
|
544
|
-
out += renderLink(parsed,
|
|
726
|
+
out += renderLink(parsed, ctx);
|
|
545
727
|
i = parsed.end;
|
|
546
728
|
continue;
|
|
547
729
|
}
|
|
@@ -561,7 +743,7 @@ function renderInline(text: string, baseDir: string): string {
|
|
|
561
743
|
if ((char === "*" || char === "_") && text[i + 1] === char) {
|
|
562
744
|
const close = text.indexOf(char + char, i + 2);
|
|
563
745
|
if (close > i + 2) {
|
|
564
|
-
out += `<strong>${renderInline(text.slice(i + 2, close),
|
|
746
|
+
out += `<strong>${renderInline(text.slice(i + 2, close), ctx)}</strong>`;
|
|
565
747
|
i = close + 2;
|
|
566
748
|
continue;
|
|
567
749
|
}
|
|
@@ -570,7 +752,7 @@ function renderInline(text: string, baseDir: string): string {
|
|
|
570
752
|
if (char === "~" && text[i + 1] === "~") {
|
|
571
753
|
const close = text.indexOf("~~", i + 2);
|
|
572
754
|
if (close > i + 2) {
|
|
573
|
-
out += `<del>${renderInline(text.slice(i + 2, close),
|
|
755
|
+
out += `<del>${renderInline(text.slice(i + 2, close), ctx)}</del>`;
|
|
574
756
|
i = close + 2;
|
|
575
757
|
continue;
|
|
576
758
|
}
|
|
@@ -579,7 +761,7 @@ function renderInline(text: string, baseDir: string): string {
|
|
|
579
761
|
if (char === "*" || char === "_") {
|
|
580
762
|
const close = findEmphasisClose(text, i, char);
|
|
581
763
|
if (close > i + 1) {
|
|
582
|
-
out += `<em>${renderInline(text.slice(i + 1, close),
|
|
764
|
+
out += `<em>${renderInline(text.slice(i + 1, close), ctx)}</em>`;
|
|
583
765
|
i = close + 1;
|
|
584
766
|
continue;
|
|
585
767
|
}
|
|
@@ -42,8 +42,9 @@ pi --plan # start in plan mode
|
|
|
42
42
|
them into `todo`.
|
|
43
43
|
- **A navigable review.** The review screen splits the body at its headings,
|
|
44
44
|
names the section you are in, and jumps between sections with `n`/`p`.
|
|
45
|
-
- **Kept current.** After approval, `
|
|
46
|
-
the file, so the plan mirrors what has actually been
|
|
45
|
+
- **Kept current.** After approval, `set_plan_steps` flips the checkboxes of
|
|
46
|
+
the steps you name in the file, so the plan mirrors what has actually been
|
|
47
|
+
done.
|
|
47
48
|
- **Reviewable plan.** `exit_plan_mode` reads the plan file back and presents
|
|
48
49
|
it for approval. The user approves, keeps planning, or asks for a refinement.
|
|
49
50
|
- **Browser review on demand.** Press `w` on the review screen (or run
|
|
@@ -68,7 +69,7 @@ pi --plan # start in plan mode
|
|
|
68
69
|
| `enter_plan_mode` | normal mode | Ask to enter plan mode; empty schema, confirmed with `ctx.ui.confirm`. |
|
|
69
70
|
| `write_plan` | plan mode | Save the plan: `title` (file-name slug), `content` (full markdown in the documented outline), optional `plan_path` to overwrite a refinement. Returns the absolute path. |
|
|
70
71
|
| `exit_plan_mode` | plan mode | Read the plan file (`plan_path`) and present it for approval. |
|
|
71
|
-
| `
|
|
72
|
+
| `set_plan_steps` | normal mode | Keep an approved plan current: set the checkboxes of the given top-level steps (`updates`, each a 1-based `step` in the order `exit_plan_mode` listed, plus `status`) to `pending` or `completed` in one write. Only the checkboxes change, and a reopened step drops its `[DONE:n]` marker. |
|
|
72
73
|
|
|
73
74
|
### Commands and flags
|
|
74
75
|
|
|
@@ -249,6 +250,8 @@ agent file that overrides it.
|
|
|
249
250
|
|
|
250
251
|
## Browser review
|
|
251
252
|
|
|
253
|
+
<img src="../../assets/plan-review.png" alt="The browser-based plan review, showing the plan's sections, a rendered code block, and an inline comment box" width="100%">
|
|
254
|
+
|
|
252
255
|
Press `w` on the review screen — or run `/plan review [path]` — to open the plan
|
|
253
256
|
in the default browser on `127.0.0.1`. The page is a review surface: a sticky
|
|
254
257
|
header with the plan title, theme toggle, and Save/Close; a sticky **section
|
|
@@ -314,7 +317,7 @@ returns them to the model.
|
|
|
314
317
|
|
|
315
318
|
- **Not a sandbox.** Extensions share Pi's OS permissions.
|
|
316
319
|
- **No progress tracker of record.** `todo` owns completion state; the plan
|
|
317
|
-
file only mirrors it (`
|
|
320
|
+
file only mirrors it (`set_plan_steps`), and plan mode does not read completion
|
|
318
321
|
back out.
|
|
319
322
|
- **No auto-execution.** Approval hands control back to the model.
|
|
320
323
|
|
|
@@ -322,7 +325,7 @@ returns them to the model.
|
|
|
322
325
|
|
|
323
326
|
| Integration point | How |
|
|
324
327
|
|---|---|
|
|
325
|
-
| Tools | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `
|
|
328
|
+
| Tools | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps` registered with `executionMode: "sequential"`. `enter_plan_mode` is active normally with `readOnlyHint: true`; `write_plan` (`readOnlyHint: false`) and `exit_plan_mode` (`readOnlyHint: true`) are `defaultActive: false`. `set_plan_steps` is active normally (`readOnlyHint: false`, `idempotentHint: true`); the read-only policy default-denies it in plan mode. Each returns typed `details` (`EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepsDetails`); no `outputSchema`/`structuredContent`. |
|
|
326
329
|
| Commands and flags | `/plan` (toggle, or enter and send a task), `/plan review [path]` (browser review), `--plan` launch flag, `Ctrl+Alt+P` shortcut. |
|
|
327
330
|
| State | Enabled flag and the tools it hid persisted with `pi.appendEntry("plan-mode", { enabled, removed })` and rebuilt from `ctx.sessionManager.getBranch()`; the last plan path lives in runtime memory for the footer chip. |
|
|
328
331
|
| Hooks | `tool_call` blocks non-read-only calls and forces `subagent` read-only; `before_agent_start` injects the hidden `[PLAN MODE ACTIVE]` context; `context` keeps only the newest plan context while enabled and drops stale ones otherwise. |
|
|
@@ -360,10 +363,11 @@ returns them to the model.
|
|
|
360
363
|
source-line map, so `tui.ts` splits the body at its headings and renders each
|
|
361
364
|
section separately. That gives exact offsets for the current-section label and
|
|
362
365
|
`n`/`p`; the single-section case is byte-for-byte the old render.
|
|
363
|
-
- **`
|
|
364
|
-
|
|
365
|
-
(`isContainedPlan`),
|
|
366
|
-
|
|
366
|
+
- **`set_plan_steps` is a second contained write.** It changes only the named
|
|
367
|
+
steps' checkboxes, in an existing markdown plan inside the plans directory
|
|
368
|
+
(`isContainedPlan`), in a single all-or-nothing write — refusing every entry
|
|
369
|
+
if any step is out of range — and refuses while plan mode is on, so the
|
|
370
|
+
read-only guarantee and the path backstop are unchanged.
|
|
367
371
|
- **Direction: enforce capabilities at the tool boundary.** (1) A shared
|
|
368
372
|
default-deny policy classifies every call as read-only or mutating instead of
|
|
369
373
|
maintaining a mutator list. (2) Read-only delegation is enforced in the child
|
|
@@ -394,13 +398,13 @@ returns them to the model.
|
|
|
394
398
|
| File | Responsibility |
|
|
395
399
|
|---|---|
|
|
396
400
|
| `index.ts` | Wiring: tools, command, flag, shortcut, events, context injection. |
|
|
397
|
-
| `types.ts` | `PlanModeEntry`, `EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `
|
|
401
|
+
| `types.ts` | `PlanModeEntry`, `EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepsDetails`. |
|
|
398
402
|
| `policy.ts` | Plan mode's read-only policy, the shared prompt summary, and the bash-refusal guidance. |
|
|
399
403
|
| `git-bash.ts` | `checkReadOnlyGit`: the pure allowlist that restricts `bash` to read-only git commands. |
|
|
400
|
-
| `plans.ts` | Plan-file store: slug/stamp naming, directory resolution, containment, write/read/`
|
|
404
|
+
| `plans.ts` | Plan-file store: slug/stamp naming, directory resolution, containment, write/read/`setSteps`/`saveFeedback`. |
|
|
401
405
|
| `steps.ts` | Plan-document structure (pure): the outline, step extraction, heading sections, and the checkbox edit. |
|
|
402
406
|
| `runtime.ts` | Enabled state, tool gating, persistence, control-tool identity, footer status. |
|
|
403
|
-
| `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `
|
|
407
|
+
| `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps`. |
|
|
404
408
|
| `commands.ts` | `/plan` (enter, optional task) and the `/plan review [path]` browser subcommand. |
|
|
405
409
|
| `tui.ts` | `PlanViewComponent` (sectioned Markdown body, section jump, scroll gauge, approve/refine/browser/keep). |
|
|
406
410
|
| `blocks.ts` | Source-line block model (fence-aware headings, paragraphs, list items, code, quotes, blanks). |
|
package/extensions/plan/plans.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works
|
|
|
20
20
|
import { repoRootFor } from "../../lib/git/index.ts";
|
|
21
21
|
import { isInside, isInsideReal } from "../../lib/path.ts";
|
|
22
22
|
import { formatFeedback, type PlanAnnotation } from "./feedback.ts";
|
|
23
|
-
import { extractPlanSteps,
|
|
23
|
+
import { extractPlanSteps, setStepStatuses, type StepUpdate } from "./steps.ts";
|
|
24
24
|
|
|
25
25
|
/** Largest plan accepted, in UTF-8 bytes. */
|
|
26
26
|
export const MAX_PLAN_BYTES = 256 * 1024;
|
|
@@ -57,8 +57,8 @@ export interface PlanStore {
|
|
|
57
57
|
write(cwd: string, input: WritePlanInput): Promise<WrittenPlan>;
|
|
58
58
|
/** Read a plan inside the plans directory, or undefined when missing/outside. */
|
|
59
59
|
read(cwd: string, planPath: string): Promise<StoredPlan | undefined>;
|
|
60
|
-
/** Set
|
|
61
|
-
|
|
60
|
+
/** Set the given top-level steps' checkboxes in an existing plan. Throws when the file or any step is invalid. */
|
|
61
|
+
setSteps(cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan>;
|
|
62
62
|
/** Replace the plan's stored browser feedback. Throws when the file is missing or outside the plans directory. */
|
|
63
63
|
saveFeedback(cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan>;
|
|
64
64
|
}
|
|
@@ -222,26 +222,25 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
|
|
|
222
222
|
}
|
|
223
223
|
};
|
|
224
224
|
|
|
225
|
-
const
|
|
226
|
-
cwd: string,
|
|
227
|
-
planPath: string,
|
|
228
|
-
step: number,
|
|
229
|
-
status: PlanStep["status"],
|
|
230
|
-
): Promise<StoredPlan> => {
|
|
225
|
+
const setSteps = async (cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan> => {
|
|
231
226
|
const plan = await read(cwd, planPath);
|
|
232
227
|
if (!plan) throw new Error("No plan file there; pass a plan_path written by write_plan.");
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
228
|
+
const total = extractPlanSteps(plan.content).length;
|
|
229
|
+
const outOfRange = updates.find(
|
|
230
|
+
(update) => !Number.isInteger(update.step) || update.step < 1 || update.step > total,
|
|
231
|
+
);
|
|
232
|
+
if (outOfRange) {
|
|
236
233
|
const range = total === 0 ? "it has no steps" : `valid steps are 1 to ${total}`;
|
|
237
|
-
throw new Error(`Step ${step} is out of range; ${range}.`);
|
|
234
|
+
throw new Error(`Step ${outOfRange.step} is out of range; ${range}.`);
|
|
238
235
|
}
|
|
239
|
-
|
|
236
|
+
const updated = setStepStatuses(plan.content, updates);
|
|
237
|
+
if (!updated) throw new Error("Could not update the plan steps; pass steps from the plan file.");
|
|
238
|
+
writeFileSync(plan.path, updated, "utf-8");
|
|
240
239
|
return {
|
|
241
240
|
path: plan.path,
|
|
242
241
|
relativePath: plan.relativePath,
|
|
243
|
-
content: updated
|
|
244
|
-
bytes: Buffer.byteLength(updated
|
|
242
|
+
content: updated,
|
|
243
|
+
bytes: Buffer.byteLength(updated, "utf-8"),
|
|
245
244
|
};
|
|
246
245
|
};
|
|
247
246
|
|
|
@@ -263,5 +262,5 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
|
|
|
263
262
|
};
|
|
264
263
|
};
|
|
265
264
|
|
|
266
|
-
return { dirFor, write, read,
|
|
265
|
+
return { dirFor, write, read, setSteps, saveFeedback };
|
|
267
266
|
}
|
|
@@ -31,7 +31,7 @@ export const ENTER_TOOL = "enter_plan_mode";
|
|
|
31
31
|
/** Tool the model calls to save the plan artifact. */
|
|
32
32
|
export const WRITE_PLAN_TOOL = "write_plan";
|
|
33
33
|
/** Tool the model calls to keep an approved plan's steps current. */
|
|
34
|
-
export const
|
|
34
|
+
export const SET_PLAN_STEPS_TOOL = "set_plan_steps";
|
|
35
35
|
/** Custom-entry type used for persistence. */
|
|
36
36
|
export const STATE_TYPE = "plan-mode";
|
|
37
37
|
|