@gavin-hu/my-pi-agent 0.9.0 → 0.10.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 +28 -2
- package/README.zh-CN.md +27 -2
- package/assets/bytefly.schema.json +17 -1
- package/assets/chrome.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
- package/extensions/bytefly/README.md +261 -36
- package/extensions/bytefly/block/component.ts +282 -0
- package/extensions/bytefly/block/config.ts +24 -0
- package/extensions/bytefly/block/fallback.ts +120 -0
- package/extensions/bytefly/block/register.ts +164 -0
- package/extensions/bytefly/chrome.ts +97 -0
- package/extensions/bytefly/doctor/tools.ts +5 -2
- package/extensions/bytefly/header/component.ts +21 -10
- package/extensions/bytefly/header/config.ts +0 -3
- package/extensions/bytefly/header/register.ts +6 -1
- package/extensions/bytefly/index.ts +32 -12
- package/extensions/bytefly/message/config.ts +15 -0
- package/extensions/bytefly/message/format.ts +42 -0
- package/extensions/bytefly/message/register.ts +15 -0
- package/extensions/bytefly/notice/component.ts +107 -0
- package/extensions/bytefly/notice/config.ts +41 -0
- package/extensions/bytefly/notice/register.ts +27 -0
- package/extensions/bytefly/pad.ts +42 -0
- package/extensions/bytefly/separator/component.ts +2 -1
- package/extensions/bytefly/separator/format.ts +17 -10
- package/extensions/bytefly/separator/register.ts +3 -2
- package/extensions/bytefly/working/config.ts +20 -0
- package/extensions/bytefly/working/format.ts +25 -0
- package/extensions/bytefly/working/register.ts +88 -0
- package/extensions/document/README.md +11 -4
- package/extensions/document/paging.ts +10 -10
- package/extensions/document/render.ts +7 -7
- package/extensions/file-browse/README.md +3 -1
- package/extensions/file-browse/client.ts +9 -2
- package/extensions/file-browse/git.ts +4 -7
- package/extensions/file-browse/html.ts +27 -17
- package/extensions/file-open/README.md +6 -0
- package/extensions/goal/README.md +16 -10
- package/extensions/goal/format.ts +7 -20
- package/extensions/goal/tools.ts +5 -17
- package/extensions/goal/tui.ts +12 -40
- package/extensions/job/README.md +21 -12
- package/extensions/job/format.ts +2 -2
- package/extensions/job/index.ts +25 -20
- package/extensions/job/tools.ts +5 -3
- package/extensions/job/tui.ts +13 -8
- package/extensions/memory/README.md +6 -0
- package/extensions/memory/format.ts +5 -3
- package/extensions/plan/README.md +32 -12
- package/extensions/plan/tools.ts +20 -5
- package/extensions/plan/web-client.ts +58 -17
- package/extensions/plan/web-page.ts +34 -9
- package/extensions/plan/web-router.ts +40 -9
- package/extensions/plan/web.ts +22 -2
- package/extensions/question/README.md +13 -1
- package/extensions/question/answers.ts +25 -18
- package/extensions/question/tools.ts +11 -6
- package/extensions/rewind/README.md +2 -0
- package/extensions/rewind/format.ts +6 -1
- package/extensions/rewind/index.ts +3 -1
- package/extensions/rewind/snapshot-run.ts +1 -1
- package/extensions/subagent/README.md +6 -0
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +56 -29
- package/extensions/todo/README.md +19 -12
- package/extensions/todo/format.ts +17 -25
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/tui.ts +3 -2
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/format.ts +2 -2
- package/extensions/watch/index.ts +28 -23
- package/extensions/watch/tools.ts +5 -3
- package/extensions/watch/tui.ts +12 -7
- package/extensions/web-access/README.md +16 -7
- package/extensions/web-access/fetch/tool.ts +1 -1
- package/extensions/web-access/search/tool.ts +3 -3
- package/extensions/wechat/README.md +6 -0
- package/extensions/worktree/README.md +6 -0
- package/lib/README.md +2 -1
- package/lib/format.ts +17 -0
- package/lib/notice.ts +87 -0
- package/lib/tui/chrome.ts +8 -4
- package/lib/tui/cursor.ts +2 -1
- package/lib/tui/glyphs.ts +10 -11
- package/lib/tui/indent.ts +166 -0
- package/lib/tui/index.ts +3 -1
- package/lib/tui/rows.ts +39 -5
- package/lib/tui/rules.ts +9 -3
- package/lib/tui/tokens.ts +14 -0
- package/lib/web/base.ts +2 -1
- package/lib/web/components.ts +3 -1
- package/lib/web/layout.ts +5 -1
- package/package.json +7 -3
- package/themes/bytefly-dark.json +7 -8
- package/themes/bytefly-light.json +10 -11
- package/assets/bytefly-chrome.png +0 -0
|
@@ -25,6 +25,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
25
25
|
var token = typeof data.token === "string" ? data.token : "";
|
|
26
26
|
var annotations = Array.isArray(data.annotations) ? data.annotations.slice() : [];
|
|
27
27
|
var saved = false;
|
|
28
|
+
var approved = false;
|
|
28
29
|
var closed = false;
|
|
29
30
|
var connected = null;
|
|
30
31
|
var everConnected = false;
|
|
@@ -37,6 +38,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
37
38
|
var planEl = document.getElementById("plan");
|
|
38
39
|
var statusEl = document.getElementById("status");
|
|
39
40
|
var stateEl = document.getElementById("review-state");
|
|
41
|
+
var approveButton = document.getElementById("approve");
|
|
40
42
|
var saveButton = document.getElementById("save");
|
|
41
43
|
var cancelButton = document.getElementById("cancel");
|
|
42
44
|
var outlineList = document.getElementById("outline-list");
|
|
@@ -62,9 +64,15 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
62
64
|
if (!stateEl) return;
|
|
63
65
|
var label;
|
|
64
66
|
var tone = "";
|
|
65
|
-
if (
|
|
67
|
+
if (approved) {
|
|
68
|
+
label = "Approved";
|
|
69
|
+
tone = "success";
|
|
70
|
+
} else if (saved) {
|
|
66
71
|
label = "Saved";
|
|
67
72
|
tone = "success";
|
|
73
|
+
} else if (closed) {
|
|
74
|
+
// Settled by the user, not by a dropped stream: never flash Offline.
|
|
75
|
+
label = "Closed";
|
|
68
76
|
} else if (connected === true) {
|
|
69
77
|
label = "Connected";
|
|
70
78
|
tone = "success";
|
|
@@ -78,7 +86,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
78
86
|
}
|
|
79
87
|
var count = annotations.length;
|
|
80
88
|
label += " \u00b7 " + count + (count === 1 ? " comment" : " comments");
|
|
81
|
-
if (dirty && !saved) {
|
|
89
|
+
if (dirty && !saved && !approved && !closed) {
|
|
82
90
|
label += " \u00b7 unsaved";
|
|
83
91
|
if (!tone) tone = "warn";
|
|
84
92
|
}
|
|
@@ -254,9 +262,14 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
254
262
|
return;
|
|
255
263
|
}
|
|
256
264
|
if (block.kind === "list") {
|
|
265
|
+
var items = [];
|
|
257
266
|
lines.forEach(function (line) {
|
|
258
267
|
var match = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
259
268
|
if (!match) {
|
|
269
|
+
// A following unmarked line is a CommonMark lazy continuation of the
|
|
270
|
+
// item above; join it so the paragraph hangs under the item's text.
|
|
271
|
+
var last = items[items.length - 1];
|
|
272
|
+
if (last) { if (line.trim()) last.rest += " " + line.trim(); return; }
|
|
260
273
|
var plain = el("p"); renderInline(plain, line); wrap.appendChild(plain); return;
|
|
261
274
|
}
|
|
262
275
|
var row = el("div", "bf-block__li");
|
|
@@ -272,9 +285,12 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
272
285
|
rest = box[2];
|
|
273
286
|
}
|
|
274
287
|
marker.textContent = markerText;
|
|
275
|
-
var body = el("span");
|
|
288
|
+
var body = el("span");
|
|
276
289
|
row.appendChild(marker); row.appendChild(body); wrap.appendChild(row);
|
|
290
|
+
items.push({ body: body, rest: rest });
|
|
277
291
|
});
|
|
292
|
+
// Render each body once, after its continuation lines are collected.
|
|
293
|
+
items.forEach(function (item) { renderInline(item.body, item.rest); });
|
|
278
294
|
return;
|
|
279
295
|
}
|
|
280
296
|
var paragraph = el("p");
|
|
@@ -600,6 +616,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
600
616
|
}
|
|
601
617
|
|
|
602
618
|
function lockActions() {
|
|
619
|
+
if (approveButton) approveButton.disabled = true;
|
|
603
620
|
if (saveButton) saveButton.disabled = true;
|
|
604
621
|
if (cancelButton) cancelButton.disabled = true;
|
|
605
622
|
}
|
|
@@ -618,51 +635,67 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
618
635
|
controls.appendChild(reload);
|
|
619
636
|
}
|
|
620
637
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
fetch("
|
|
638
|
+
// Save and approve share the same POST shape; only the route, the settled
|
|
639
|
+
// flag, and the wording differ. Both persist the current annotations.
|
|
640
|
+
function submit(url, okText, failText, mark) {
|
|
641
|
+
fetch(url + "?token=" + encodeURIComponent(token), {
|
|
625
642
|
method: "POST",
|
|
626
643
|
headers: { "Content-Type": "application/json" },
|
|
627
644
|
body: JSON.stringify({ annotations: annotations })
|
|
628
645
|
}).then(function (response) {
|
|
629
646
|
if (response.ok) {
|
|
630
|
-
|
|
647
|
+
mark();
|
|
631
648
|
dirty = false;
|
|
632
|
-
setStatus(
|
|
649
|
+
setStatus(okText, "saved");
|
|
633
650
|
lockActions();
|
|
634
651
|
updateState();
|
|
635
652
|
return;
|
|
636
653
|
}
|
|
637
654
|
if (response.status === 409) {
|
|
638
655
|
setStatus("The plan changed on disk \u2014 reload to review again.", "error");
|
|
656
|
+
if (approveButton) approveButton.disabled = true;
|
|
639
657
|
if (saveButton) saveButton.disabled = true;
|
|
640
658
|
offerReload();
|
|
641
659
|
return;
|
|
642
660
|
}
|
|
643
661
|
throw new Error("server returned " + response.status);
|
|
644
662
|
}).catch(function (error) {
|
|
645
|
-
setStatus(
|
|
663
|
+
setStatus(failText + error.message, "error");
|
|
646
664
|
});
|
|
647
665
|
}
|
|
648
666
|
|
|
667
|
+
function save() {
|
|
668
|
+
if (saved || approved || closed) return;
|
|
669
|
+
setStatus("Saving\u2026");
|
|
670
|
+
submit("/api/feedback", "Saved \u2014 return to Pi to continue.", "Could not save: ", function () { saved = true; });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function approve() {
|
|
674
|
+
if (saved || approved || closed) return;
|
|
675
|
+
setStatus("Approving\u2026");
|
|
676
|
+
submit("/api/approve", "Approved \u2014 return to Pi to continue.", "Could not approve: ", function () { approved = true; });
|
|
677
|
+
}
|
|
678
|
+
|
|
649
679
|
function cancel() {
|
|
650
|
-
if (saved || closed) return;
|
|
680
|
+
if (saved || approved || closed) return;
|
|
651
681
|
closed = true;
|
|
652
682
|
lockActions();
|
|
653
683
|
setStatus("Closing\u2026");
|
|
684
|
+
updateState();
|
|
654
685
|
fetch("/api/cancel?token=" + encodeURIComponent(token), { method: "POST" }).catch(function () {}).then(function () {
|
|
655
|
-
setStatus("Closed
|
|
686
|
+
setStatus("Closed \u2014 return to Pi.");
|
|
687
|
+
updateState();
|
|
656
688
|
});
|
|
657
689
|
}
|
|
658
690
|
|
|
691
|
+
if (approveButton) approveButton.addEventListener("click", approve);
|
|
659
692
|
if (saveButton) saveButton.addEventListener("click", save);
|
|
660
693
|
if (cancelButton) cancelButton.addEventListener("click", cancel);
|
|
661
694
|
|
|
662
695
|
// Remember a selection made inside a block so clicking the rail button can
|
|
663
696
|
// still quote it after the button press has collapsed the selection.
|
|
664
697
|
document.addEventListener("mouseup", function (event) {
|
|
665
|
-
if (!planEl || saved) return;
|
|
698
|
+
if (!planEl || saved || approved || closed) return;
|
|
666
699
|
var wrap = event.target && event.target.closest ? event.target.closest(".bf-block") : null;
|
|
667
700
|
var quote = wrap && !wrap.querySelector(".bf-composer") ? selectionIn(wrap) : "";
|
|
668
701
|
pendingQuote = wrap && quote ? { wrap: wrap, quote: quote } : null;
|
|
@@ -692,7 +725,13 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
692
725
|
}
|
|
693
726
|
|
|
694
727
|
document.addEventListener("keydown", function (event) {
|
|
695
|
-
if (event.key === "Escape") {
|
|
728
|
+
if (event.key === "Escape") {
|
|
729
|
+
// Esc peels one layer at a time: the composer, then the sheet, then the review.
|
|
730
|
+
if (planEl && planEl.querySelector(".bf-composer")) { closeComposer(); return; }
|
|
731
|
+
if (keysDialog && !keysDialog.hidden) { toggleKeys(false); return; }
|
|
732
|
+
cancel();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
696
735
|
if (typing(event.target)) return;
|
|
697
736
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
698
737
|
if (event.key === "?") { event.preventDefault(); toggleKeys(); return; }
|
|
@@ -701,11 +740,12 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
701
740
|
if (event.key === "j") { event.preventDefault(); moveActive(1); return; }
|
|
702
741
|
if (event.key === "k") { event.preventDefault(); moveActive(-1); return; }
|
|
703
742
|
if (event.key === "c") { event.preventDefault(); commentOnActive(); return; }
|
|
743
|
+
if (event.key === "a" && approveButton) { event.preventDefault(); approve(); return; }
|
|
704
744
|
if (event.key === "s") { event.preventDefault(); save(); return; }
|
|
705
745
|
});
|
|
706
746
|
|
|
707
747
|
window.addEventListener("beforeunload", function () {
|
|
708
|
-
if (saved || closed) return;
|
|
748
|
+
if (saved || approved || closed) return;
|
|
709
749
|
var url = "/api/cancel?token=" + encodeURIComponent(token);
|
|
710
750
|
if (navigator.sendBeacon) navigator.sendBeacon(url);
|
|
711
751
|
});
|
|
@@ -718,8 +758,9 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
718
758
|
var stream = new EventSource("/events?token=" + encodeURIComponent(token));
|
|
719
759
|
stream.addEventListener("open", function () { connected = true; everConnected = true; updateState(); });
|
|
720
760
|
stream.addEventListener("error", function () {
|
|
721
|
-
// The server ends the stream
|
|
722
|
-
|
|
761
|
+
// The server ends the stream once the review is settled; keep showing the
|
|
762
|
+
// terminal state rather than Offline.
|
|
763
|
+
if (!saved && !approved && !closed) { connected = false; updateState(); }
|
|
723
764
|
});
|
|
724
765
|
} catch (error) {
|
|
725
766
|
// The server still sees the POST; the stream is only for close detection.
|
|
@@ -49,6 +49,8 @@ export interface ReviewPageData {
|
|
|
49
49
|
outline: ReviewOutlineItem[];
|
|
50
50
|
/** Highlighted HTML per line, keyed by the code block's 0-based start line. */
|
|
51
51
|
code: Record<string, string[]>;
|
|
52
|
+
/** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
|
|
53
|
+
canApprove: boolean;
|
|
52
54
|
/** Per-session secret the client echoes on every request. */
|
|
53
55
|
token: string;
|
|
54
56
|
}
|
|
@@ -116,7 +118,7 @@ ${WEB_COMPONENTS_CSS}
|
|
|
116
118
|
.bf-block__li{display:flex;gap:8px;margin:4px 0;line-height:var(--lh-relaxed)}
|
|
117
119
|
.bf-block__marker{flex:0 0 auto;min-width:1.4em;color:var(--muted)}
|
|
118
120
|
.bf-block__marker--done{color:var(--success)}
|
|
119
|
-
.bf-block code{font-family:var(--font-mono);font-size:.9em;background:var(--surface);border
|
|
121
|
+
.bf-block code{font-family:var(--font-mono);font-size:.9em;background:var(--surface);border-radius:var(--radius-sm);padding:.1em .35em}
|
|
120
122
|
.bf-block pre.bf-code{margin:11px 0;padding:var(--space-3) 0;overflow:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);font:var(--fs-code)/1.55 var(--font-mono);tab-size:4;flex:none}
|
|
121
123
|
.bf-block pre.bf-code code{background:none;border:0;padding:0;font-size:inherit}
|
|
122
124
|
.bf-block pre.bf-code .bf-code__line{display:block;padding:0 14px;white-space:pre;min-height:1.55em}
|
|
@@ -170,20 +172,36 @@ ${WEB_COMPONENTS_CSS}
|
|
|
170
172
|
@media (hover:none){.bf-block__add{opacity:1}}
|
|
171
173
|
`.trim();
|
|
172
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The sidebar's one-line keyboard reminder. It names the keys a reviewer reaches
|
|
177
|
+
* for first and defers to the shortcut sheet (`?`) for the rest, so the line does
|
|
178
|
+
* not have to grow every time a binding is added.
|
|
179
|
+
*/
|
|
180
|
+
function reviewHints(canApprove: boolean): string {
|
|
181
|
+
const approve = canApprove ? `, <kbd class="review-kbd">a</kbd> to approve` : "";
|
|
182
|
+
return `<p class="review-hints">Press <kbd class="review-kbd">j</kbd>/<kbd class="review-kbd">k</kbd> to move, <kbd class="review-kbd">c</kbd> to comment${approve}, <kbd class="review-kbd">s</kbd> to save, <kbd class="review-kbd">?</kbd> for all shortcuts.</p>`;
|
|
183
|
+
}
|
|
184
|
+
|
|
173
185
|
/** The keyboard sheet, hidden until the client opens it. */
|
|
174
|
-
|
|
186
|
+
function keysDialog(canApprove: boolean): string {
|
|
187
|
+
const approveRow = canApprove
|
|
188
|
+
? `<div><dt><kbd class="review-kbd">a</kbd></dt><dd>Approve and run the plan</dd></div>
|
|
189
|
+
`
|
|
190
|
+
: "";
|
|
191
|
+
return `<div class="bf-dialog" id="keys-dialog" hidden>
|
|
175
192
|
<div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
|
|
176
193
|
<div class="bf-dialog__head"><h2 id="keys-title">Keyboard shortcuts</h2><span class="bf-spacer"></span><button type="button" class="bf-btn" id="keys-close">Close</button></div>
|
|
177
194
|
<dl class="review-keys">
|
|
178
|
-
<div><dt><kbd class="review-kbd">j</kbd> <kbd class="review-kbd">k</kbd></dt><dd>Next and previous block</dd></div>
|
|
195
|
+
${approveRow}<div><dt><kbd class="review-kbd">j</kbd> <kbd class="review-kbd">k</kbd></dt><dd>Next and previous block</dd></div>
|
|
179
196
|
<div><dt><kbd class="review-kbd">c</kbd></dt><dd>Comment on the current block</dd></div>
|
|
180
197
|
<div><dt><kbd class="review-kbd">Enter</kbd></dt><dd>Add the comment you are writing</dd></div>
|
|
181
198
|
<div><dt><kbd class="review-kbd">s</kbd></dt><dd>Save feedback and return to Pi</dd></div>
|
|
182
|
-
<div><dt><kbd class="review-kbd">Esc</kbd></dt><dd>Close the composer or
|
|
199
|
+
<div><dt><kbd class="review-kbd">Esc</kbd></dt><dd>Close the composer, the sheet, or the review</dd></div>
|
|
183
200
|
<div><dt><kbd class="review-kbd">?</kbd></dt><dd>Toggle this sheet</dd></div>
|
|
184
201
|
</dl>
|
|
185
202
|
</div>
|
|
186
203
|
</div>`;
|
|
204
|
+
}
|
|
187
205
|
|
|
188
206
|
/**
|
|
189
207
|
* Render the review page for `data`. The markup is a fixed shell; the client
|
|
@@ -200,11 +218,18 @@ export function renderReviewPage(data: ReviewPageData): string {
|
|
|
200
218
|
<div class="bf-panel review-panel">
|
|
201
219
|
${reviewFacts()}
|
|
202
220
|
<nav class="review-nav" aria-labelledby="outline-title"><p class="review-nav__title" id="outline-title">Sections</p><ul id="outline-list"></ul></nav>
|
|
203
|
-
|
|
221
|
+
${reviewHints(data.canApprove)}
|
|
204
222
|
</div>
|
|
205
223
|
</details>
|
|
206
224
|
`
|
|
207
225
|
: "";
|
|
226
|
+
// Approve is the decisive action, so it takes the primary button and Save is
|
|
227
|
+
// demoted; a comment-only review has no approve button and keeps Save primary.
|
|
228
|
+
const approveButton = data.canApprove
|
|
229
|
+
? `
|
|
230
|
+
<button type="button" class="bf-btn bf-btn--primary" id="approve">Approve & run</button>`
|
|
231
|
+
: "";
|
|
232
|
+
const saveButton = `<button type="button" class="${data.canApprove ? "bf-btn" : "bf-btn bf-btn--primary"}" id="save">Save feedback</button>`;
|
|
208
233
|
return `<!doctype html>
|
|
209
234
|
<html lang="en">
|
|
210
235
|
<head>
|
|
@@ -221,15 +246,15 @@ ${brandLockup("plan review")}
|
|
|
221
246
|
<span class="bf-status review-status" id="status" role="status" aria-live="polite"></span>
|
|
222
247
|
<div class="review-controls">
|
|
223
248
|
${themeToggle()}
|
|
224
|
-
<button type="button" class="bf-btn bf-btn--icon" id="shortcuts" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button
|
|
225
|
-
|
|
226
|
-
<button type="button" class="bf-btn" id="cancel">
|
|
249
|
+
<button type="button" class="bf-btn bf-btn--icon" id="shortcuts" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>${approveButton}
|
|
250
|
+
${saveButton}
|
|
251
|
+
<button type="button" class="bf-btn" id="cancel">Back to Pi</button>
|
|
227
252
|
</div>
|
|
228
253
|
</div><div class="review-progress" aria-hidden="true"><span class="review-progress__fill" id="progress-fill"></span></div></header>
|
|
229
254
|
<div class="${bodyClass}">
|
|
230
255
|
${outlinePanel}<main class="bf-app__main bf-review" id="plan" aria-label="Plan"></main>
|
|
231
256
|
</div>
|
|
232
|
-
${
|
|
257
|
+
${keysDialog(data.canApprove)}
|
|
233
258
|
<script type="application/json" id="review-data">${island}</script>
|
|
234
259
|
<script src="/client.js" defer></script>
|
|
235
260
|
</body>
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* local page from driving the session.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { REVIEW_CLIENT_JS } from "./web-client.ts";
|
|
16
15
|
import { THEME_JS } from "../../lib/web/index.ts";
|
|
17
16
|
import { coerceAnnotations, type PlanAnnotation } from "./feedback.ts";
|
|
17
|
+
import { REVIEW_CLIENT_JS } from "./web-client.ts";
|
|
18
18
|
import { renderReviewPage, type ReviewPageData } from "./web-page.ts";
|
|
19
19
|
|
|
20
20
|
/** What a save callback reports back. */
|
|
@@ -24,12 +24,16 @@ export type SaveResult = { ok: true } | { ok: false; status: number; message: st
|
|
|
24
24
|
export interface ReviewContext {
|
|
25
25
|
/** Per-session secret required on the page, event stream, and API routes. */
|
|
26
26
|
token: string;
|
|
27
|
+
/** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
|
|
28
|
+
canApprove: boolean;
|
|
27
29
|
/** Build the page data (recomputed per request from the current plan). */
|
|
28
30
|
page: () => Promise<ReviewPageData>;
|
|
29
31
|
/** Persist the annotations and report the outcome. */
|
|
30
32
|
save: (annotations: PlanAnnotation[]) => Promise<SaveResult>;
|
|
31
33
|
/** Called after a successful save; the server resolves the session once the response has flushed. */
|
|
32
34
|
onSaved: (annotations: PlanAnnotation[]) => void;
|
|
35
|
+
/** Called after a successful approve; the server resolves the session once the response has flushed. */
|
|
36
|
+
onApprove: (annotations: PlanAnnotation[]) => void;
|
|
33
37
|
/** Called when the user cancels; the server resolves the session once the response has flushed. */
|
|
34
38
|
onCancel: () => void;
|
|
35
39
|
}
|
|
@@ -80,6 +84,27 @@ function errorResponse(status: number, message: string): ServeResponse {
|
|
|
80
84
|
return jsonResponse(status, { error: message });
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Parse and validate an `{ annotations }` request body. Returns the annotations,
|
|
89
|
+
* or an error response to send instead.
|
|
90
|
+
*/
|
|
91
|
+
function parseAnnotations(body: string): PlanAnnotation[] | ServeResponse {
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(body);
|
|
95
|
+
} catch {
|
|
96
|
+
return errorResponse(400, "The request body must be JSON.");
|
|
97
|
+
}
|
|
98
|
+
const annotations = coerceAnnotations((parsed as { annotations?: unknown } | null)?.annotations);
|
|
99
|
+
if (!annotations) return errorResponse(400, "The annotations are malformed.");
|
|
100
|
+
return annotations;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A parsed body is either the annotations or the error response to send. */
|
|
104
|
+
function isResponse(value: PlanAnnotation[] | ServeResponse): value is ServeResponse {
|
|
105
|
+
return !Array.isArray(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
83
108
|
/** Validate method and `Host`; returns an error response to send instead. */
|
|
84
109
|
function checkEnvelope(
|
|
85
110
|
method: string,
|
|
@@ -139,19 +164,25 @@ export async function handleReviewRequest(
|
|
|
139
164
|
}
|
|
140
165
|
if (pathname === "/api/feedback") {
|
|
141
166
|
if (method.toUpperCase() !== "POST") return errorResponse(405, "Use POST to save feedback.");
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
parsed = JSON.parse(body);
|
|
145
|
-
} catch {
|
|
146
|
-
return errorResponse(400, "The request body must be JSON.");
|
|
147
|
-
}
|
|
148
|
-
const annotations = coerceAnnotations((parsed as { annotations?: unknown } | null)?.annotations);
|
|
149
|
-
if (!annotations) return errorResponse(400, "The annotations are malformed.");
|
|
167
|
+
const annotations = parseAnnotations(body);
|
|
168
|
+
if (isResponse(annotations)) return annotations;
|
|
150
169
|
const result = await ctx.save(annotations);
|
|
151
170
|
if (!result.ok) return errorResponse(result.status, result.message);
|
|
152
171
|
ctx.onSaved(annotations);
|
|
153
172
|
return jsonResponse(200, { ok: true });
|
|
154
173
|
}
|
|
174
|
+
if (pathname === "/api/approve") {
|
|
175
|
+
if (method.toUpperCase() !== "POST") return errorResponse(405, "Use POST to approve.");
|
|
176
|
+
// Only the exit_plan_mode flow owns the approval side effects, so a comment-only
|
|
177
|
+
// review refuses this even if a client posts it directly.
|
|
178
|
+
if (!ctx.canApprove) return errorResponse(403, "This review cannot be approved from the browser.");
|
|
179
|
+
const annotations = parseAnnotations(body);
|
|
180
|
+
if (isResponse(annotations)) return annotations;
|
|
181
|
+
const result = await ctx.save(annotations);
|
|
182
|
+
if (!result.ok) return errorResponse(result.status, result.message);
|
|
183
|
+
ctx.onApprove(annotations);
|
|
184
|
+
return jsonResponse(200, { ok: true });
|
|
185
|
+
}
|
|
155
186
|
|
|
156
187
|
return errorResponse(404, "That page does not exist.");
|
|
157
188
|
}
|
package/extensions/plan/web.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { ReviewPageData } from "./web-page.ts";
|
|
|
24
24
|
/** How a browser review ended. */
|
|
25
25
|
export type PlanReviewResult =
|
|
26
26
|
| { status: "saved"; plan: StoredPlan; annotations: PlanAnnotation[] }
|
|
27
|
+
| { status: "approved"; plan: StoredPlan; annotations: PlanAnnotation[] }
|
|
27
28
|
| { status: "cancelled" };
|
|
28
29
|
|
|
29
30
|
/** A running review the caller awaits and then closes. */
|
|
@@ -38,9 +39,17 @@ export interface PlanReviewSession {
|
|
|
38
39
|
close(): Promise<void>;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/** Per-session options for {@link PlanWebReview.start}. */
|
|
43
|
+
export interface PlanReviewOptions {
|
|
44
|
+
/** Abort signal; aborting closes the session as cancelled. */
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
/** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
|
|
47
|
+
canApprove?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
/** Starts and tracks review sessions. */
|
|
42
51
|
export interface PlanWebReview {
|
|
43
|
-
start(cwd: string, plan: StoredPlan,
|
|
52
|
+
start(cwd: string, plan: StoredPlan, options?: PlanReviewOptions): Promise<PlanReviewSession | undefined>;
|
|
44
53
|
/** Stop every running session (session shutdown). */
|
|
45
54
|
closeAll(): Promise<void>;
|
|
46
55
|
}
|
|
@@ -96,7 +105,13 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
96
105
|
// review turns (and its saved browser theme with it).
|
|
97
106
|
const ports = new Map<string, number>();
|
|
98
107
|
|
|
99
|
-
const start = async (
|
|
108
|
+
const start = async (
|
|
109
|
+
cwd: string,
|
|
110
|
+
plan: StoredPlan,
|
|
111
|
+
options: PlanReviewOptions = {},
|
|
112
|
+
): Promise<PlanReviewSession | undefined> => {
|
|
113
|
+
const { signal } = options;
|
|
114
|
+
const canApprove = options.canApprove === true;
|
|
100
115
|
const token = (deps.token ?? (() => randomBytes(16).toString("hex")))();
|
|
101
116
|
let settled = false;
|
|
102
117
|
let resolveDone!: (result: PlanReviewResult) => void;
|
|
@@ -147,6 +162,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
147
162
|
})),
|
|
148
163
|
code,
|
|
149
164
|
token,
|
|
165
|
+
canApprove,
|
|
150
166
|
};
|
|
151
167
|
};
|
|
152
168
|
|
|
@@ -166,11 +182,15 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
166
182
|
|
|
167
183
|
const context: ReviewContext = {
|
|
168
184
|
token,
|
|
185
|
+
canApprove,
|
|
169
186
|
page,
|
|
170
187
|
save,
|
|
171
188
|
onSaved: (annotations) => {
|
|
172
189
|
pendingResult = savedPlan ? { status: "saved", plan: savedPlan, annotations } : { status: "cancelled" };
|
|
173
190
|
},
|
|
191
|
+
onApprove: (annotations) => {
|
|
192
|
+
pendingResult = savedPlan ? { status: "approved", plan: savedPlan, annotations } : { status: "cancelled" };
|
|
193
|
+
},
|
|
174
194
|
onCancel: () => {
|
|
175
195
|
pendingResult = { status: "cancelled" };
|
|
176
196
|
},
|
|
@@ -4,12 +4,20 @@ A single model-facing tool, `ask_user_question`, that lets the model ask the
|
|
|
4
4
|
human one or more structured questions mid-turn and wait for typed answers.
|
|
5
5
|
Modelled on Claude Code's `AskUserQuestion`.
|
|
6
6
|
|
|
7
|
+
<img src="../../assets/question.png" alt="The ask_user_question dialog: Auth and Scope tabs, a question, four numbered options with descriptions, and the navigation hint" width="100%">
|
|
8
|
+
|
|
7
9
|
```
|
|
8
10
|
pi --extension ./extensions/question # load just this extension
|
|
9
11
|
pi -e . # load the whole @gavin-hu/my-pi-agent package
|
|
10
12
|
pi install ./ # install the package
|
|
11
13
|
```
|
|
12
14
|
|
|
15
|
+
## Tool rendering
|
|
16
|
+
|
|
17
|
+
How the `ask_user_question` call and its result look in the transcript — the call line as a headline and the body indented under it:
|
|
18
|
+
|
|
19
|
+
<img src="../../assets/tools/ask_user_question.png" alt="The ask_user_question tool's call and result: the call line and an indented body with the question headers, the options, and the answers" width="100%">
|
|
20
|
+
|
|
13
21
|
## What it does
|
|
14
22
|
|
|
15
23
|
The model calls `ask_user_question` with 1–4 questions. Each question offers up
|
|
@@ -162,7 +170,11 @@ None in v1 — the tool is either available (a UI exists) or not.
|
|
|
162
170
|
- **Transcript rendering is driven by `details`.** `renderCall` summarises the
|
|
163
171
|
question count and headers and lists one option line per question;
|
|
164
172
|
`renderResult` renders `✓ Auth: 1. OAuth` per answer, `(wrote)` for custom
|
|
165
|
-
text, warning-colored `Cancelled`, and dim `No UI` when unavailable.
|
|
173
|
+
text, warning-colored `Cancelled`, and dim `No UI` when unavailable. The call
|
|
174
|
+
summary reads the call's *streaming* args, so a field that has not arrived yet
|
|
175
|
+
— a question `header`, an option `label` — reads as absent instead of
|
|
176
|
+
throwing; a throwing renderer makes Pi fall back to its own call line, a
|
|
177
|
+
column to the left of the framed one.
|
|
166
178
|
|
|
167
179
|
## Files
|
|
168
180
|
|
|
@@ -80,37 +80,44 @@ export function formatAnswerText(result: AskResult): string {
|
|
|
80
80
|
return lines.join("\n");
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Minimal shape needed to summarize a call, before or after normalization.
|
|
85
|
+
*
|
|
86
|
+
* The transcript renderer reads this from a call's *streaming* args, where a
|
|
87
|
+
* field can be missing or still be a fragment: a partial parse can hand back an
|
|
88
|
+
* option before its label has arrived. Every field is therefore `unknown`, and
|
|
89
|
+
* the helpers below read a non-string as absent rather than throw.
|
|
90
|
+
*/
|
|
84
91
|
export interface CallQuestion {
|
|
85
|
-
header?:
|
|
86
|
-
options?:
|
|
92
|
+
header?: unknown;
|
|
93
|
+
options?: unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A partial value read as a single line; anything but a string reads empty. */
|
|
97
|
+
function asLine(value: unknown): string {
|
|
98
|
+
return typeof value === "string" ? sanitizeLine(value) : "";
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
/** Display headers with positional defaults (`Q1`, `Q2`, …). */
|
|
90
102
|
export function questionHeaders(questions: CallQuestion[]): string[] {
|
|
91
|
-
return questions.map((question, i) =>
|
|
103
|
+
return questions.map((question, i) => asLine(question?.header) || `Q${i + 1}`);
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
/** One `Header: a, b, Other` summary per question that offers options. */
|
|
95
107
|
export function optionSummary(questions: CallQuestion[]): string[] {
|
|
96
108
|
return questions
|
|
97
|
-
.map((question, i) => ({
|
|
98
|
-
.filter(({
|
|
99
|
-
.map(({
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
.map((question, i) => ({ header: asLine(question?.header) || `Q${i + 1}`, options: question?.options }))
|
|
110
|
+
.filter(({ options }) => Array.isArray(options) && options.length > 0)
|
|
111
|
+
.map(({ header, options }) => {
|
|
112
|
+
// A partial option has no label yet; list the ones that have arrived.
|
|
113
|
+
const labels = (options as unknown[])
|
|
114
|
+
.map((option) => (option as { label?: unknown } | undefined)?.label)
|
|
115
|
+
.filter((label): label is string => typeof label === "string")
|
|
116
|
+
.map((label) => sanitizeLine(label));
|
|
117
|
+
return `${header}: ${labels.length > 0 ? `${labels.join(", ")}, Other` : "Other"}`;
|
|
102
118
|
});
|
|
103
119
|
}
|
|
104
120
|
|
|
105
|
-
/** One-line summary of the questions, used by the transcript call renderer. */
|
|
106
|
-
export function formatCallText(questions: CallQuestion[]): string {
|
|
107
|
-
const noun = questions.length === 1 ? "question" : "questions";
|
|
108
|
-
const lines = [`ask_user_question ${questions.length} ${noun} (${questionHeaders(questions).join(", ")})`];
|
|
109
|
-
const options = optionSummary(questions);
|
|
110
|
-
if (options.length > 0) lines.push(` Options: ${options.join(" · ")}`);
|
|
111
|
-
return lines.join("\n");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
121
|
/** Human-readable summary of a selection, used in the Submit tab. */
|
|
115
122
|
export function describeSelection(selection: Selection | undefined): string {
|
|
116
123
|
if (!selection) return "(unanswered)";
|
|
@@ -70,16 +70,21 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
70
70
|
},
|
|
71
71
|
|
|
72
72
|
renderCall(args, theme, context) {
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// The args stream in: `questions` can be absent, and an option can arrive
|
|
74
|
+
// before its label. Read a partial call as a smaller summary and never
|
|
75
|
+
// throw — a throwing renderer makes Pi fall back to its own call line.
|
|
76
|
+
const raw = (args as Partial<AskUserQuestionArgs> | undefined)?.questions;
|
|
77
|
+
const questions = Array.isArray(raw) ? raw : [];
|
|
78
|
+
const count = questions.length;
|
|
75
79
|
const noun = count === 1 ? "question" : "questions";
|
|
76
|
-
const headers = questionHeaders(
|
|
80
|
+
const headers = questionHeaders(questions);
|
|
77
81
|
let text = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "meta", `${count} ${noun}`);
|
|
78
82
|
if (headers.length > 0) text += paint(theme, "faint", ` (${headers.join(", ")})`);
|
|
79
83
|
|
|
80
|
-
// One
|
|
81
|
-
//
|
|
82
|
-
|
|
84
|
+
// One line per question that offers options, so a long option list stays
|
|
85
|
+
// mapped to its question instead of wrapping on a single row. The line
|
|
86
|
+
// carries no indent of its own; the tool-block frame supplies it.
|
|
87
|
+
for (const line of optionSummary(questions)) text += `\n${paint(theme, "meta", line)}`;
|
|
83
88
|
|
|
84
89
|
const component = reuseText(context?.lastComponent);
|
|
85
90
|
component.setText(text);
|
|
@@ -10,6 +10,8 @@ rewind uses Pi's session tree, so the branch you leave is not erased.
|
|
|
10
10
|
This is the `rewind` extension entrypoint; it is one of two sibling git
|
|
11
11
|
extensions in this package (`worktree` is the other).
|
|
12
12
|
|
|
13
|
+
<img src="../../assets/rewind.png" alt="The /rewind screen: prompt rows newest first, a diamond marking prompts with a code snapshot, and the focused prompt's snapshot detail" width="100%">
|
|
14
|
+
|
|
13
15
|
```
|
|
14
16
|
pi --extension ./extensions/rewind # load this extension alone
|
|
15
17
|
pi -e . # load the whole @gavin-hu/my-pi-agent package
|
|
@@ -28,8 +28,13 @@ function truncate(text: string, max = LABEL_WIDTH): string {
|
|
|
28
28
|
/**
|
|
29
29
|
* A short, single-line summary of a user prompt for storage and display.
|
|
30
30
|
* Takes the first non-empty line, collapses whitespace, and truncates.
|
|
31
|
+
*
|
|
32
|
+
* A run started by a custom message (a wake) has no user prompt: `event.prompt`
|
|
33
|
+
* is typed `string` but is `undefined` there, so a missing prompt is accepted
|
|
34
|
+
* and yields no summary rather than throwing.
|
|
31
35
|
*/
|
|
32
|
-
export function summarizePrompt(text: string): string | undefined {
|
|
36
|
+
export function summarizePrompt(text: string | undefined): string | undefined {
|
|
37
|
+
if (typeof text !== "string") return undefined;
|
|
33
38
|
const line = text
|
|
34
39
|
.split(/\r?\n/)
|
|
35
40
|
.map((part) => sanitizeLine(part))
|
|
@@ -37,7 +37,9 @@ export function registerRewind(pi: ExtensionAPI): void {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
// A new prompt begins a task: the run resolves its snapshot decision up
|
|
40
|
-
// front, so the `tool_call` guard can stay synchronous.
|
|
40
|
+
// front, so the `tool_call` guard can stay synchronous. A run started by a
|
|
41
|
+
// custom message (a wake) has no user prompt — `event.prompt` is undefined
|
|
42
|
+
// there despite its type — so `beginRun` accepts a missing prompt.
|
|
41
43
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
42
44
|
await run.beginRun(event.prompt, ctx);
|
|
43
45
|
});
|
|
@@ -54,7 +54,7 @@ export function createSnapshotRun(pi: ExtensionAPI, runtime: RewindRuntime): Sna
|
|
|
54
54
|
runAutoSnapshots = false;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
-
const beginRun = async (prompt: string, ctx: ExtensionContext): Promise<void> => {
|
|
57
|
+
const beginRun = async (prompt: string | undefined, ctx: ExtensionContext): Promise<void> => {
|
|
58
58
|
reset();
|
|
59
59
|
pendingPrompt = summarizePrompt(prompt);
|
|
60
60
|
try {
|
|
@@ -10,6 +10,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent p
|
|
|
10
10
|
pi install ./ # install the package
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
## Tool rendering
|
|
14
|
+
|
|
15
|
+
How the `subagent` call and its result look in the transcript — the call line as a headline and the body indented under it:
|
|
16
|
+
|
|
17
|
+
<img src="../../assets/tools/subagent.png" alt="The subagent tool's call and result: the call line and an indented body with the two tasks, each agent's section rule, its summary, and the usage total" width="100%">
|
|
18
|
+
|
|
13
19
|
## What it does
|
|
14
20
|
|
|
15
21
|
- **Isolated context.** Each subagent is a separate `pi` process with its own
|
|
@@ -168,7 +168,7 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
|
|
|
168
168
|
},
|
|
169
169
|
|
|
170
170
|
renderCall(args, theme, context) {
|
|
171
|
-
return renderSubagentCall(args
|
|
171
|
+
return renderSubagentCall(args, theme, {
|
|
172
172
|
cwd: context.cwd,
|
|
173
173
|
lastComponent: context.lastComponent,
|
|
174
174
|
});
|