@gavin-hu/my-pi-agent 0.20.0 → 0.21.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 +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/extensions/README.md +1 -0
- package/docs/extensions/board.md +57 -42
- package/docs/extensions/bytefly.md +85 -89
- package/docs/extensions/file-browse.md +36 -41
- package/docs/extensions/plan.md +31 -14
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +1 -1
- package/docs/extensions/team.md +11 -11
- package/extensions/board/commands.ts +27 -14
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +53 -83
- package/extensions/board/result.ts +1 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +9 -3
- package/extensions/board/snapshot.ts +7 -6
- package/extensions/board/tools.ts +27 -15
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +0 -10
- package/extensions/board/view/client.ts +3 -2
- package/extensions/board/view/live.ts +1 -7
- package/extensions/board/view/page.ts +16 -5
- package/extensions/board/view/render.ts +22 -17
- package/extensions/board/view/styles.ts +7 -1
- package/extensions/bytefly/footer/config.ts +3 -6
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +4 -4
- package/extensions/bytefly/footer/render/segments.ts +10 -34
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -4
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/html.ts +73 -40
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +13 -14
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +9 -9
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/index.ts +7 -1
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/runtime.ts +12 -2
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +99 -2
- package/extensions/plan/web-collab.ts +4 -4
- package/extensions/plan/web-comments.ts +39 -8
- package/extensions/plan/web-composer.ts +14 -3
- package/extensions/plan/web-keys.ts +149 -25
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +22 -2
- package/extensions/plan/web-page.ts +56 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +23 -13
- package/extensions/plan/web-settle.ts +14 -7
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/team/commands.ts +3 -1
- package/extensions/todo/tui.ts +21 -3
- package/lib/README.md +5 -4
- package/lib/board/page-pointer.ts +29 -4
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +34 -8
- package/lib/board/view.ts +20 -14
- package/lib/bus-request.ts +83 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/plan-entry.ts +9 -13
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-tool.ts +37 -21
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +2 -4
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/markup.ts +18 -1
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
package/extensions/goal/tui.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
13
|
+
import { RenderCache } from "../../lib/tui/render-cache.ts";
|
|
13
14
|
import { goalHeader, goalLine } from "./format.ts";
|
|
14
15
|
import type { AchievedStyle, Goal } from "./types.ts";
|
|
15
16
|
|
|
@@ -47,6 +48,7 @@ export class GoalWidget implements Component {
|
|
|
47
48
|
export class GoalHeader implements Component {
|
|
48
49
|
private goal: Goal;
|
|
49
50
|
private theme: Theme;
|
|
51
|
+
private readonly cache = new RenderCache();
|
|
50
52
|
|
|
51
53
|
constructor(goal: Goal, theme: Theme) {
|
|
52
54
|
this.goal = goal;
|
|
@@ -57,13 +59,21 @@ export class GoalHeader implements Component {
|
|
|
57
59
|
update(goal: Goal, theme: Theme): void {
|
|
58
60
|
this.goal = goal;
|
|
59
61
|
this.theme = theme;
|
|
62
|
+
this.cache.invalidate();
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
invalidate(): void {
|
|
65
|
+
invalidate(): void {
|
|
66
|
+
this.cache.invalidate();
|
|
67
|
+
}
|
|
63
68
|
|
|
64
69
|
render(width: number): string[] {
|
|
70
|
+
const w = Math.max(1, width);
|
|
71
|
+
return this.cache.lines(w, undefined, () => this.build(w));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private build(w: number): string[] {
|
|
65
75
|
// The leading blank line separates the header from the call line, matching
|
|
66
76
|
// the built-ins' header/output gap.
|
|
67
|
-
return ["", truncateToWidth(goalHeader(this.goal, this.theme),
|
|
77
|
+
return ["", truncateToWidth(goalHeader(this.goal, this.theme), w, "…")];
|
|
68
78
|
}
|
|
69
79
|
}
|
package/extensions/job/tools.ts
CHANGED
|
@@ -11,7 +11,15 @@
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { errorResult, textResult } from "../../lib/tool-result.ts";
|
|
13
13
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
callLine,
|
|
16
|
+
detailsOrLine,
|
|
17
|
+
elapsedSuffix,
|
|
18
|
+
isTailResult,
|
|
19
|
+
rejectionLine,
|
|
20
|
+
syncElapsedTimer,
|
|
21
|
+
TailResult,
|
|
22
|
+
} from "../../lib/tui/tool.ts";
|
|
15
23
|
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
16
24
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
17
25
|
import {
|
|
@@ -26,8 +34,8 @@ import {
|
|
|
26
34
|
import type { JobsRuntime } from "./runtime.ts";
|
|
27
35
|
import { JobParams, normalizeCall, type JobArgs, type JobCall } from "./schema.ts";
|
|
28
36
|
import { toRecord } from "./store.ts";
|
|
29
|
-
import {
|
|
30
|
-
import type { JobDetails } from "./types.ts";
|
|
37
|
+
import { jobTail, type JobResultInput } from "./tui.ts";
|
|
38
|
+
import type { JobDetails, JobRecord } from "./types.ts";
|
|
31
39
|
|
|
32
40
|
export const TOOL_NAME = "job";
|
|
33
41
|
|
|
@@ -224,8 +232,10 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
|
|
|
224
232
|
}
|
|
225
233
|
const shown = options.expanded ? all : all.slice(Math.max(0, all.length - RESULT_ROWS));
|
|
226
234
|
const input: JobResultInput = { kind: "logs", lines: shown, earlier: all.length - shown.length };
|
|
227
|
-
const
|
|
228
|
-
|
|
235
|
+
const previous = context.lastComponent;
|
|
236
|
+
const tail = jobTail(input);
|
|
237
|
+
const view = isTailResult<JobRecord>(previous) ? previous : new TailResult<JobRecord>(tail, theme);
|
|
238
|
+
view.setInput(tail, theme);
|
|
229
239
|
return view;
|
|
230
240
|
}
|
|
231
241
|
|
|
@@ -242,8 +252,10 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
|
|
|
242
252
|
more: ordered.length - shown.length,
|
|
243
253
|
sessionId: details.sessionId,
|
|
244
254
|
};
|
|
245
|
-
const
|
|
246
|
-
|
|
255
|
+
const previous = context.lastComponent;
|
|
256
|
+
const tail = jobTail(input);
|
|
257
|
+
const view = isTailResult<JobRecord>(previous) ? previous : new TailResult<JobRecord>(tail, theme);
|
|
258
|
+
view.setInput(tail, theme);
|
|
247
259
|
return view;
|
|
248
260
|
}
|
|
249
261
|
|
package/extensions/job/tui.ts
CHANGED
|
@@ -15,13 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
19
18
|
import { formatDuration, sanitizeUntrustedLine } from "../../lib/format.ts";
|
|
20
19
|
import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
|
|
21
20
|
import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
|
|
22
21
|
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
23
22
|
import { statusRow, type StatusRow } from "../../lib/tui/rows.ts";
|
|
24
|
-
import {
|
|
23
|
+
import type { TailResultInput } from "../../lib/tui/tool.ts";
|
|
25
24
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
26
25
|
import {
|
|
27
26
|
compareJobs,
|
|
@@ -62,7 +61,7 @@ export type JobResultInput =
|
|
|
62
61
|
| { kind: "logs"; lines: string[]; earlier: number };
|
|
63
62
|
|
|
64
63
|
/** Map the job vocabulary onto the shared transcript tail block. */
|
|
65
|
-
function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
|
|
64
|
+
export function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
|
|
66
65
|
if (input.kind === "logs") {
|
|
67
66
|
return { kind: "lines", lines: input.lines, earlier: input.earlier, earlierLabel: "lines" };
|
|
68
67
|
}
|
|
@@ -77,31 +76,6 @@ function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
|
|
|
77
76
|
};
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
/**
|
|
81
|
-
* Transcript result block for the `job` tool: the shared tail block over the
|
|
82
|
-
* `/jobs`-style row rail or the themed log tail. Reused across renders.
|
|
83
|
-
*/
|
|
84
|
-
export class JobResult implements Component {
|
|
85
|
-
private readonly view: TailResult<JobRecord>;
|
|
86
|
-
|
|
87
|
-
constructor(input: JobResultInput, theme: Theme) {
|
|
88
|
-
this.view = new TailResult(jobTail(input), theme);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Update in place so the transcript can reuse this component across renders. */
|
|
92
|
-
setInput(input: JobResultInput, theme: Theme): void {
|
|
93
|
-
this.view.setInput(jobTail(input), theme);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
invalidate(): void {
|
|
97
|
-
this.view.invalidate();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
render(width: number): string[] {
|
|
101
|
-
return this.view.render(width);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
79
|
export interface JobListCallbacks {
|
|
106
80
|
/** This session's id: a record with another id belongs to a peer session. */
|
|
107
81
|
sessionId(): string;
|
|
@@ -19,7 +19,7 @@ import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
|
|
|
19
19
|
import { runBrowserReview } from "./review.ts";
|
|
20
20
|
import type { PlanRuntime } from "./runtime.ts";
|
|
21
21
|
import type { PlanSummary } from "./plans.ts";
|
|
22
|
-
import { createPlanWebReview, type PlanWebReview } from "./web.ts";
|
|
22
|
+
import { createPlanWebReview, NO_SERVER_DEPS, type PlanWebReview } from "./web.ts";
|
|
23
23
|
|
|
24
24
|
const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
|
|
25
25
|
const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
|
|
@@ -219,7 +219,7 @@ async function openPlanIndex(
|
|
|
219
219
|
export function registerCommands(
|
|
220
220
|
pi: ExtensionAPI,
|
|
221
221
|
runtime: PlanRuntime,
|
|
222
|
-
web: PlanWebReview = createPlanWebReview(runtime.plans),
|
|
222
|
+
web: PlanWebReview = createPlanWebReview(runtime.plans, NO_SERVER_DEPS),
|
|
223
223
|
): void {
|
|
224
224
|
pi.registerCommand("plan", {
|
|
225
225
|
description:
|
|
@@ -43,6 +43,13 @@ export interface PlanAnnotation {
|
|
|
43
43
|
* guess, and the page says so instead of presenting a guess as her own anchor.
|
|
44
44
|
*/
|
|
45
45
|
moved?: true;
|
|
46
|
+
/**
|
|
47
|
+
* Set when the note is settled: its writer, or the owner, decided it needs no action, or
|
|
48
|
+
* the revision answered it. The comment stays in the file — it is the record of the
|
|
49
|
+
* question — and a review sends it flagged, so the model reads what is closed without
|
|
50
|
+
* treating it as new.
|
|
51
|
+
*/
|
|
52
|
+
resolved?: true;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
/** Most annotations one save may carry. */
|
|
@@ -59,10 +66,10 @@ export type AuthorClaim = { name: string } | { status: number; message: string }
|
|
|
59
66
|
|
|
60
67
|
/**
|
|
61
68
|
* A stored feedback line, capturing whether a refinement moved it, its author, and its
|
|
62
|
-
* comment. The
|
|
69
|
+
* comment. The flags are inside the marker tag rather than the comment, so the text a
|
|
63
70
|
* reviewer wrote stays exactly what she typed.
|
|
64
71
|
*/
|
|
65
|
-
const MARKER_LINE = /^\s*>\s*\[!REVIEW(
|
|
72
|
+
const MARKER_LINE = /^\s*>\s*\[!REVIEW((?:\s+(?:moved|resolved))*)\]\s?(.*)$/;
|
|
66
73
|
/** The author prefix inside a marker body (`@alice: note`). */
|
|
67
74
|
const AUTHOR_PREFIX = /^@([^:]{1,40}):\s?(.*)$/;
|
|
68
75
|
|
|
@@ -133,6 +140,8 @@ interface StoredMarker {
|
|
|
133
140
|
author: string;
|
|
134
141
|
/** Whether a refinement carried this comment off the text it was written on. */
|
|
135
142
|
moved: boolean;
|
|
143
|
+
/** Whether the note is settled: it stays in the file, and is no longer an open question. */
|
|
144
|
+
resolved: boolean;
|
|
136
145
|
}
|
|
137
146
|
|
|
138
147
|
/** The plan body without markers, plus the markers that were removed from it. */
|
|
@@ -151,17 +160,28 @@ function scanMarkers(plan: string): { clean: string; markers: StoredMarker[] } {
|
|
|
151
160
|
const authored = AUTHOR_PREFIX.exec(body);
|
|
152
161
|
const author = authored ? normalizeAuthor(authored[1]) : "";
|
|
153
162
|
const comment = (authored ? authored[2] : body).trim();
|
|
154
|
-
|
|
163
|
+
// The tag carries a set of flags, not one keyword: a note can be carried off its text
|
|
164
|
+
// and settled in the same write, and an older build reading it still finds the comment.
|
|
165
|
+
const flags = new Set((match[1] ?? "").trim().split(/\s+/).filter(Boolean));
|
|
166
|
+
if (comment) {
|
|
167
|
+
markers.push({
|
|
168
|
+
at: clean.length - 1,
|
|
169
|
+
comment,
|
|
170
|
+
author,
|
|
171
|
+
moved: flags.has("moved"),
|
|
172
|
+
resolved: flags.has("resolved"),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
155
175
|
});
|
|
156
176
|
return { clean: clean.join("\n"), markers };
|
|
157
177
|
}
|
|
158
178
|
|
|
159
179
|
/** One marker line in its stored form. */
|
|
160
|
-
function markerLine(author: string, comment: string, moved = false): string {
|
|
180
|
+
function markerLine(author: string, comment: string, moved = false, resolved = false): string {
|
|
161
181
|
const body = sanitizeLine(comment);
|
|
162
|
-
// The
|
|
163
|
-
// the closing bracket, a comment that begins "moved …" would read as
|
|
164
|
-
const tag = moved ?
|
|
182
|
+
// The flags ride inside the tag, in a fixed order, where they cannot be confused with the
|
|
183
|
+
// comment: after the closing bracket, a comment that begins "moved …" would read as one.
|
|
184
|
+
const tag = moved || resolved ? `[!REVIEW${moved ? " moved" : ""}${resolved ? " resolved" : ""}]` : REVIEW_MARKER;
|
|
165
185
|
return author ? `> ${tag} @${author}: ${body}` : `> ${tag} ${body}`;
|
|
166
186
|
}
|
|
167
187
|
|
|
@@ -236,6 +256,7 @@ export function readFeedback(plan: string): FeedbackRead {
|
|
|
236
256
|
};
|
|
237
257
|
if (marker.author) annotation.author = marker.author;
|
|
238
258
|
if (marker.moved) annotation.moved = true;
|
|
259
|
+
if (marker.resolved) annotation.resolved = true;
|
|
239
260
|
return annotation;
|
|
240
261
|
});
|
|
241
262
|
return { body: clean, blocks, annotations };
|
|
@@ -249,15 +270,21 @@ export function parseFeedback(plan: string): PlanAnnotation[] {
|
|
|
249
270
|
/** Sanitized, non-empty comments with the anchor they land on. */
|
|
250
271
|
function storableComments(
|
|
251
272
|
annotations: readonly PlanAnnotation[],
|
|
252
|
-
): Array<{ anchor: number; comment: string; moved: boolean }> {
|
|
253
|
-
const items: Array<{ anchor: number; comment: string; moved: boolean }> = [];
|
|
273
|
+
): Array<{ anchor: number; comment: string; moved: boolean; resolved: boolean }> {
|
|
274
|
+
const items: Array<{ anchor: number; comment: string; moved: boolean; resolved: boolean }> = [];
|
|
254
275
|
for (const annotation of annotations) {
|
|
255
276
|
const comment = sanitizeLine(annotation.comment);
|
|
256
277
|
if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
|
|
257
278
|
// Editing a moved note's text does not put it back on its old block, so the fact
|
|
258
279
|
// that a refinement placed it where it sits survives the save: the page posts back
|
|
259
|
-
// the annotation it was handed,
|
|
260
|
-
|
|
280
|
+
// the annotation it was handed, flags included, and this keeps them. Settling is the
|
|
281
|
+
// writer's own decision of the same kind.
|
|
282
|
+
items.push({
|
|
283
|
+
anchor: annotation.anchor,
|
|
284
|
+
comment,
|
|
285
|
+
moved: annotation.moved === true,
|
|
286
|
+
resolved: annotation.resolved === true,
|
|
287
|
+
});
|
|
261
288
|
}
|
|
262
289
|
return items;
|
|
263
290
|
}
|
|
@@ -272,13 +299,16 @@ export function formatFeedback(plan: string, annotations: PlanAnnotation[], opti
|
|
|
272
299
|
const { clean, markers } = scanMarkers(plan);
|
|
273
300
|
const kept = markers
|
|
274
301
|
.filter((marker) => marker.author !== author)
|
|
275
|
-
.map((marker) => ({
|
|
302
|
+
.map((marker) => ({
|
|
303
|
+
at: marker.at,
|
|
304
|
+
line: markerLine(marker.author, marker.comment, marker.moved, marker.resolved),
|
|
305
|
+
}));
|
|
276
306
|
const blocks = planBlocks(clean);
|
|
277
307
|
const added = storableComments(annotations).map((item) => ({
|
|
278
308
|
// A marker beside a block, never after a blank line: an anchor that names
|
|
279
309
|
// blank space is written where the page can read it back as a thread.
|
|
280
310
|
at: contentBlockAt(blocks, item.anchor)?.end ?? -1,
|
|
281
|
-
line: markerLine(author, item.comment, item.moved),
|
|
311
|
+
line: markerLine(author, item.comment, item.moved, item.resolved),
|
|
282
312
|
}));
|
|
283
313
|
return compose(clean, [...kept, ...added]);
|
|
284
314
|
}
|
|
@@ -452,7 +482,9 @@ export function carryFeedback(previous: string, next: string): string {
|
|
|
452
482
|
// The flag means "this note is no longer on the text it was written about", so it
|
|
453
483
|
// is decided by the carry in hand: a block whose text survived proves the anchor,
|
|
454
484
|
// and anything after that is a guess the page has to admit to.
|
|
455
|
-
|
|
485
|
+
// Settling is the note's own history, not the carry's decision: a moved note that was
|
|
486
|
+
// resolved stays settled, and one whose text survived stays as it was.
|
|
487
|
+
const keep = (moved: boolean): string => markerLine(marker.author, marker.comment, moved, marker.resolved);
|
|
456
488
|
if (block === undefined) return { at: tail, line: keep(true) };
|
|
457
489
|
if (block.kind !== "blank") {
|
|
458
490
|
const match = queues.get(blockKey(block))?.shift();
|
|
@@ -503,13 +535,17 @@ export function feedbackSummary(annotations: PlanAnnotation[]): string {
|
|
|
503
535
|
// the plan owner chose, so it is normalized like every other author.
|
|
504
536
|
author: normalizeAuthor(annotation.author),
|
|
505
537
|
comment: sanitizeLine(annotation.comment),
|
|
538
|
+
resolved: annotation.resolved === true,
|
|
506
539
|
}))
|
|
507
540
|
.filter((item) => item.comment.length > 0);
|
|
508
541
|
return items
|
|
509
542
|
.map((item, index) => {
|
|
510
543
|
const who = item.author ? `@${item.author}: ` : "";
|
|
511
544
|
const where = item.quote ? `On "${item.quote}": ` : "";
|
|
512
|
-
|
|
545
|
+
// A settled note is still sent — it is the record of the question — but saying so
|
|
546
|
+
// is what stops the model from answering it a second time.
|
|
547
|
+
const settled = item.resolved ? " (resolved)" : "";
|
|
548
|
+
return `${index + 1}. ${who}${where}${item.comment}${settled}`;
|
|
513
549
|
})
|
|
514
550
|
.join("\n");
|
|
515
551
|
}
|
|
@@ -531,12 +567,22 @@ export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined
|
|
|
531
567
|
if (typeof record.comment !== "string") return undefined;
|
|
532
568
|
if (record.quote !== undefined && typeof record.quote !== "string") return undefined;
|
|
533
569
|
if (record.author !== undefined && typeof record.author !== "string") return undefined;
|
|
570
|
+
if (record.resolved !== undefined && typeof record.resolved !== "boolean") return undefined;
|
|
534
571
|
const comment = sanitizeLine(record.comment);
|
|
535
572
|
if (comment.length > MAX_COMMENT_LENGTH) return undefined;
|
|
536
573
|
if (comment.length === 0) continue;
|
|
537
574
|
const quote = typeof record.quote === "string" ? sanitizeLine(record.quote).slice(0, MAX_QUOTE_LENGTH) : undefined;
|
|
538
575
|
const author = normalizeAuthor(record.author);
|
|
539
|
-
|
|
576
|
+
// A note a refinement carried onto another block keeps saying so when the page posts
|
|
577
|
+
// it back, and a settled note keeps saying that too: only this body knows either.
|
|
578
|
+
annotations.push({
|
|
579
|
+
anchor,
|
|
580
|
+
comment,
|
|
581
|
+
...(quote ? { quote } : {}),
|
|
582
|
+
...(author ? { author } : {}),
|
|
583
|
+
...(record.moved === true ? { moved: true } : {}),
|
|
584
|
+
...(record.resolved === true ? { resolved: true } : {}),
|
|
585
|
+
});
|
|
540
586
|
}
|
|
541
587
|
return annotations;
|
|
542
588
|
}
|
package/extensions/plan/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { Key } from "@earendil-works/pi-tui";
|
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { hasPathInput } from "./path-guard.ts";
|
|
23
23
|
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
24
|
+
import { requestMount } from "../../lib/http/index.ts";
|
|
24
25
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
25
26
|
import { lastGoal, isPursuedGoal, type PublishedGoal } from "../../lib/goal-state.ts";
|
|
26
27
|
import { READ_ONLY_SUMMARY, PLAN_ENTER_REQUEST, type PlanEnterRequest } from "../../lib/plan-entry.ts";
|
|
@@ -91,7 +92,12 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
91
92
|
if (!isExtensionEnabled("plan")) return;
|
|
92
93
|
const policy = createPlanPolicy(pi);
|
|
93
94
|
const runtime = createPlanRuntime(pi, policy, { entryPath: extensionEntryPath() });
|
|
94
|
-
|
|
95
|
+
/** The session this review serves as; its prefix lands on that session's one server. */
|
|
96
|
+
let sessionId = "";
|
|
97
|
+
const web = createPlanWebReview(runtime.plans, {
|
|
98
|
+
mount: (spec) => requestMount(pi, spec),
|
|
99
|
+
session: () => sessionId,
|
|
100
|
+
});
|
|
95
101
|
|
|
96
102
|
registerTools(pi, runtime, web);
|
|
97
103
|
registerCommands(pi, runtime, web);
|
|
@@ -17,6 +17,7 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import { stripControlChars } from "../../lib/format.ts";
|
|
18
18
|
import { displayFeedback, stripFeedback } from "./feedback.ts";
|
|
19
19
|
import { extractSections, sectionKeys, type PlanSection } from "./sections.ts";
|
|
20
|
+
import { planBlocks } from "./blocks.ts";
|
|
20
21
|
|
|
21
22
|
/** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
|
|
22
23
|
const TAB_WIDTH = 4;
|
|
@@ -27,6 +28,14 @@ export interface SectionDigest {
|
|
|
27
28
|
title: string;
|
|
28
29
|
/** SHA-256 of the section's normalized text, heading included. */
|
|
29
30
|
digest: string;
|
|
31
|
+
/**
|
|
32
|
+
* Short digests of this section's content blocks, in order.
|
|
33
|
+
*
|
|
34
|
+
* Absent in a baseline a build before block marks wrote, which is why every reader
|
|
35
|
+
* treats it as optional: a section with no block digests marks nothing, and the page
|
|
36
|
+
* says it cannot compare at that depth rather than reading as "nothing changed".
|
|
37
|
+
*/
|
|
38
|
+
blocks?: string[];
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
|
@@ -60,6 +69,17 @@ function digestOf(text: string): string {
|
|
|
60
69
|
return createHash("sha256").update(text.trim()).digest("hex");
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A short, stable digest of one block's lines.
|
|
74
|
+
*
|
|
75
|
+
* Short because it is persisted once per content block in a session entry: eight hex
|
|
76
|
+
* characters tell two blocks apart for the length of one review, which is all a change
|
|
77
|
+
* mark needs, and keep a long plan's baseline small enough to sit in the branch.
|
|
78
|
+
*/
|
|
79
|
+
export function blockDigest(lines: readonly string[]): string {
|
|
80
|
+
return digestOf(lines.join("\n")).slice(0, 8);
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
/**
|
|
64
84
|
* A short, stable id for one plan text. The review page keys this tab's unsaved
|
|
65
85
|
* draft on it, so a draft is restored for the plan it was written against and
|
|
@@ -74,9 +94,103 @@ export function sectionDigests(content: string): SectionDigest[] {
|
|
|
74
94
|
return extractSections(reviewText(content)).map((section) => ({
|
|
75
95
|
title: section.title,
|
|
76
96
|
digest: digestOf(section.text),
|
|
97
|
+
// Blank blocks are separators, not content: a plan that gained a blank line has not
|
|
98
|
+
// changed, and a digest per separator would say it had.
|
|
99
|
+
blocks: planBlocks(section.text)
|
|
100
|
+
.filter((block) => block.kind !== "blank")
|
|
101
|
+
.map((block) => blockDigest(block.lines)),
|
|
77
102
|
}));
|
|
78
103
|
}
|
|
79
104
|
|
|
105
|
+
/** Where the blocks changed, in the document the page is about to render. */
|
|
106
|
+
export interface BlockChangeMarks {
|
|
107
|
+
/** Absolute 0-based start lines of the blocks that are new or edited. */
|
|
108
|
+
starts: number[];
|
|
109
|
+
/** Content blocks each section no longer holds, keyed by the section's heading line. */
|
|
110
|
+
removed: Record<number, number>;
|
|
111
|
+
/**
|
|
112
|
+
* Whether the baseline carried block digests at all.
|
|
113
|
+
*
|
|
114
|
+
* False for a baseline a build before block marks wrote: section marks still mean
|
|
115
|
+
* something, block marks cannot, and the page says which instead of drawing nothing.
|
|
116
|
+
*/
|
|
117
|
+
compared: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Where the blocks changed since the baseline, as the rendered document numbers them.
|
|
122
|
+
*
|
|
123
|
+
* The pages marks what it draws, so every line here is a line of `current`: a section's
|
|
124
|
+
* blocks are numbered from its own heading, which is why the section's line is added
|
|
125
|
+
* back. Sections are paired the way {@link changedSections} pairs them — by occurrence,
|
|
126
|
+
* so a second `## Notes` is never compared with the first — and a section the baseline
|
|
127
|
+
* never held is all new: every block in it marks, because adding or renaming a heading is
|
|
128
|
+
* a refinement rather than a reason to refuse the comparison. Only a baseline section
|
|
129
|
+
* written before block digests is beyond comparing at that depth, and it alone clears
|
|
130
|
+
* `compared`.
|
|
131
|
+
*/
|
|
132
|
+
export function blockChangeMarks(baseline: readonly SectionDigest[] | undefined, current: string): BlockChangeMarks {
|
|
133
|
+
const sections = extractSections(reviewText(current));
|
|
134
|
+
if (baseline === undefined) return { starts: [], removed: {}, compared: false };
|
|
135
|
+
const before = new Map<string, SectionDigest>();
|
|
136
|
+
sectionKeys(baseline).forEach((key, at) => {
|
|
137
|
+
const section = baseline[at];
|
|
138
|
+
if (section !== undefined) before.set(key, section);
|
|
139
|
+
});
|
|
140
|
+
const starts: number[] = [];
|
|
141
|
+
const removed: Record<number, number> = {};
|
|
142
|
+
let compared = true;
|
|
143
|
+
sectionKeys(sections).forEach((key, at) => {
|
|
144
|
+
const section = sections[at];
|
|
145
|
+
if (section === undefined) return;
|
|
146
|
+
const prior = before.get(key);
|
|
147
|
+
if (prior !== undefined && prior.blocks === undefined) {
|
|
148
|
+
compared = false;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const mine = planBlocks(section.text).filter((block) => block.kind !== "blank");
|
|
152
|
+
// An unheld section pairs with no blocks, so every block in it is new.
|
|
153
|
+
const priorBlocks = new Set(prior?.blocks ?? []);
|
|
154
|
+
for (const block of mine) {
|
|
155
|
+
if (!priorBlocks.has(blockDigest(block.lines))) starts.push(section.line + block.start);
|
|
156
|
+
}
|
|
157
|
+
const shrunk = Math.max(0, (prior?.blocks?.length ?? 0) - mine.length);
|
|
158
|
+
if (shrunk > 0) removed[section.line] = shrunk;
|
|
159
|
+
});
|
|
160
|
+
return { starts, removed, compared };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What one section's blocks look like against the baseline's. */
|
|
164
|
+
export interface BlockMarks {
|
|
165
|
+
/** Indexes, among the section's content blocks, of the blocks that are new or edited. */
|
|
166
|
+
changed: number[];
|
|
167
|
+
/** How many content blocks the section no longer holds: the part it shrank by. */
|
|
168
|
+
removed: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Mark the blocks a section gained or edited since the baseline.
|
|
173
|
+
*
|
|
174
|
+
* Membership rather than position: a block is changed when its digest is not among the
|
|
175
|
+
* baseline's, so an inserted step marks itself and leaves every block after it alone —
|
|
176
|
+
* the index-by-index pairing a diff would use would mark the whole rest of the section.
|
|
177
|
+
* A block that left cannot be drawn in place, so the section reports how much shorter it
|
|
178
|
+
* has become instead — the shrinkage, not the set difference, so an edited block reads
|
|
179
|
+
* as one change rather than as one removal beside one addition.
|
|
180
|
+
*
|
|
181
|
+
* An absent or blockless baseline marks nothing: a build before block marks wrote no
|
|
182
|
+
* block digests, and claiming every block changed would be a worse lie than saying the
|
|
183
|
+
* comparison cannot be made at that depth.
|
|
184
|
+
*/
|
|
185
|
+
export function changedBlocks(prior: readonly string[] | undefined, current: readonly string[]): BlockMarks {
|
|
186
|
+
if (prior === undefined) return { changed: [], removed: 0 };
|
|
187
|
+
const before = new Set(prior);
|
|
188
|
+
return {
|
|
189
|
+
changed: current.flatMap((digest, index) => (before.has(digest) ? [] : [index])),
|
|
190
|
+
removed: Math.max(0, prior.length - current.length),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
80
194
|
/** Whether two baselines describe the same content, section for section. */
|
|
81
195
|
export function sameSections(a: readonly SectionDigest[], b: readonly SectionDigest[]): boolean {
|
|
82
196
|
if (a.length !== b.length) return false;
|
|
@@ -100,9 +100,19 @@ function readSections(value: unknown): SectionDigest[] | undefined {
|
|
|
100
100
|
if (!Array.isArray(value)) return undefined;
|
|
101
101
|
const sections: SectionDigest[] = [];
|
|
102
102
|
for (const raw of value) {
|
|
103
|
-
const section = raw as { title?: unknown; digest?: unknown } | null | undefined;
|
|
103
|
+
const section = raw as { title?: unknown; digest?: unknown; blocks?: unknown } | null | undefined;
|
|
104
104
|
if (typeof section?.title !== "string" || typeof section.digest !== "string") return undefined;
|
|
105
|
-
|
|
105
|
+
// Branch data is untrusted, so the block digests are kept only when they are a list
|
|
106
|
+
// of strings: a baseline written before block marks simply has none, and one that
|
|
107
|
+
// holds something else is read at section depth rather than repaired.
|
|
108
|
+
const blocks = Array.isArray(section.blocks)
|
|
109
|
+
? section.blocks.filter((entry): entry is string => typeof entry === "string")
|
|
110
|
+
: undefined;
|
|
111
|
+
sections.push(
|
|
112
|
+
blocks === undefined
|
|
113
|
+
? { title: section.title, digest: section.digest }
|
|
114
|
+
: { title: section.title, digest: section.digest, blocks },
|
|
115
|
+
);
|
|
106
116
|
}
|
|
107
117
|
return sections;
|
|
108
118
|
}
|
package/extensions/plan/tools.ts
CHANGED
|
@@ -28,12 +28,12 @@ import { registerEnterTool } from "./tools/enter.ts";
|
|
|
28
28
|
import { registerExitTool } from "./tools/exit.ts";
|
|
29
29
|
import { registerSetPlanStepsTool } from "./tools/steps.ts";
|
|
30
30
|
import { registerWriteTool } from "./tools/write.ts";
|
|
31
|
-
import { createPlanWebReview, type PlanWebReview } from "./web.ts";
|
|
31
|
+
import { createPlanWebReview, NO_SERVER_DEPS, type PlanWebReview } from "./web.ts";
|
|
32
32
|
|
|
33
33
|
export function registerTools(
|
|
34
34
|
pi: ExtensionAPI,
|
|
35
35
|
runtime: PlanRuntime,
|
|
36
|
-
web: PlanWebReview = createPlanWebReview(runtime.plans),
|
|
36
|
+
web: PlanWebReview = createPlanWebReview(runtime.plans, NO_SERVER_DEPS),
|
|
37
37
|
): void {
|
|
38
38
|
registerEnterTool(pi, runtime);
|
|
39
39
|
registerCancelTool(pi, runtime);
|
|
@@ -59,13 +59,20 @@ export function setActive(planEl: ClientNode | null, index: number): number | nu
|
|
|
59
59
|
* The index one step from `activeIndex`, or `null` when there is nothing to walk.
|
|
60
60
|
*
|
|
61
61
|
* With nothing active yet, `j` starts at the top and `k` at the bottom, which is what a
|
|
62
|
-
* reader pressing either key on a fresh page expects.
|
|
62
|
+
* reader pressing either key on a fresh page expects. A view can hide blocks, and a
|
|
63
|
+
* hidden block is not a place the reader can be sent: the step lands on the next block
|
|
64
|
+
* they can actually see, or nowhere when the filter left none.
|
|
63
65
|
*/
|
|
64
66
|
export function moveActive(planEl: ClientNode | null, activeIndex: number, delta: number): number | null {
|
|
65
67
|
const blocks = blockElements(planEl);
|
|
66
68
|
if (blocks.length === 0) return null;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
+
const step = delta < 0 ? -1 : 1;
|
|
70
|
+
const first =
|
|
71
|
+
activeIndex < 0 ? (step < 0 ? blocks.length - 1 : 0) : Math.min(Math.max(activeIndex + step, 0), blocks.length - 1);
|
|
72
|
+
for (let index = first; index >= 0 && index < blocks.length; index += step) {
|
|
73
|
+
if (blocks[index]?.hidden !== true) return setActive(planEl, index);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/**
|
|
@@ -85,5 +92,69 @@ export function blockAtLine(
|
|
|
85
92
|
return wrap && block ? { wrap, block } : null;
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
/**
|
|
89
|
-
export
|
|
95
|
+
/** What the reader asked the plan to show. */
|
|
96
|
+
export type ReviewView = "all" | "changed" | "comments";
|
|
97
|
+
|
|
98
|
+
/** How much of the plan a view kept, so the page can say what it is hiding. */
|
|
99
|
+
export interface ViewCounts {
|
|
100
|
+
shown: number;
|
|
101
|
+
hidden: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Keep the blocks a view asks for, and hide the rest.
|
|
106
|
+
*
|
|
107
|
+
* A view is a filter over what is drawn, not a second document: `All` restores every
|
|
108
|
+
* block, so the reader is one click from the whole plan, and the page's status line says
|
|
109
|
+
* how many were hidden so a filtered document never reads as a short one. Headings stay
|
|
110
|
+
* in every view — a paragraph without its section is a sentence without a subject — and a
|
|
111
|
+
* view with nothing to show keeps the document whole rather than blanking it.
|
|
112
|
+
*/
|
|
113
|
+
/**
|
|
114
|
+
* Keep the blocks a view asks for, and hide the rest.
|
|
115
|
+
*
|
|
116
|
+
* A view is a filter over what is drawn, not a second document: `All` restores every
|
|
117
|
+
* block, so the reader is one click from the whole plan, and the page's status line says
|
|
118
|
+
* how many were hidden so a filtered document never reads as a short one. Headings stay
|
|
119
|
+
* in every view — a paragraph without its section is a sentence without a subject — and a
|
|
120
|
+
* view with nothing to show keeps the document whole rather than blanking it. A comment
|
|
121
|
+
* thread is hidden with the block it hangs under: a note with no text it refers to reads
|
|
122
|
+
* as a comment on whatever happens to be above it.
|
|
123
|
+
*/
|
|
124
|
+
export function applyView(planEl: ClientNode | null, view: ReviewView): ViewCounts {
|
|
125
|
+
if (!planEl) return { shown: 0, hidden: 0 };
|
|
126
|
+
const blocks = blockElements(planEl);
|
|
127
|
+
const keep = (block: ClientNode): boolean =>
|
|
128
|
+
view === "all" ||
|
|
129
|
+
block.getAttribute("data-kind") === "heading" ||
|
|
130
|
+
(view === "changed"
|
|
131
|
+
? block.getAttribute("data-changed") === "1"
|
|
132
|
+
: (block.className || "").split(" ").indexOf("bf-block--commented") >= 0);
|
|
133
|
+
// A filter that matches nothing but the headings is not a view of the plan: showing an
|
|
134
|
+
// empty page is worse than showing the whole document beside the count.
|
|
135
|
+
const matched = blocks.filter((block) => block.getAttribute("data-kind") !== "heading" && keep(block)).length;
|
|
136
|
+
const whole = view !== "all" && blocks.length > 0 && matched === 0;
|
|
137
|
+
const visible: Record<string, boolean> = {};
|
|
138
|
+
for (const block of blocks) {
|
|
139
|
+
block.hidden = !whole && !keep(block);
|
|
140
|
+
if (!block.hidden) visible[block.getAttribute("data-line") ?? ""] = true;
|
|
141
|
+
}
|
|
142
|
+
const threads = planEl.querySelectorAll(".bf-comments");
|
|
143
|
+
for (let i = 0; i < threads.length; i++) {
|
|
144
|
+
const thread = threads[i];
|
|
145
|
+
if (!thread) continue;
|
|
146
|
+
const line = thread.getAttribute("data-line");
|
|
147
|
+
if (line !== null) thread.hidden = visible[line] !== true;
|
|
148
|
+
}
|
|
149
|
+
const shown = whole ? blocks.length : blocks.filter(keep).length;
|
|
150
|
+
return { shown, hidden: blocks.length - shown };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The block rules as source, for the client bundle.
|
|
155
|
+
*
|
|
156
|
+
* `applyView` travels with them because the page's view control calls it on every render:
|
|
157
|
+
* a name the bundle does not carry is a `ReferenceError` at the first repaint, which
|
|
158
|
+
* aborts the boot before the composer and the progress bar are wired.
|
|
159
|
+
*/
|
|
160
|
+
export const WEB_BLOCKS_JS = [blockElements, setActive, moveActive, blockAtLine, applyView].map(String).join("\n\n");
|
|
@@ -76,6 +76,10 @@ export const WEB_CHROME_JS = String.raw` function setStatus(text, kind) {
|
|
|
76
76
|
if (saveButton) saveButton.disabled = true;
|
|
77
77
|
if (postButton) postButton.disabled = true;
|
|
78
78
|
if (cancelButton) cancelButton.disabled = true;
|
|
79
|
+
// The rail's comment controls go with them: a settled review takes no new comment, and
|
|
80
|
+
// the frame renderer is the one rule that decides that, so this asks for a repaint
|
|
81
|
+
// rather than removing the buttons itself.
|
|
82
|
+
renderAll();
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// A conflict means the file moved under the page; only a reload can re-anchor.
|