@gavin-hu/my-pi-agent 0.9.0 → 0.11.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 +113 -35
- package/extensions/job/commands.ts +1 -0
- package/extensions/job/config.ts +4 -0
- package/extensions/job/format.ts +19 -7
- package/extensions/job/index.ts +25 -20
- package/extensions/job/process.ts +24 -2
- package/extensions/job/registry.ts +3 -0
- package/extensions/job/runtime.ts +253 -18
- package/extensions/job/schema.ts +34 -0
- package/extensions/job/store.ts +13 -1
- package/extensions/job/tools.ts +55 -15
- package/extensions/job/tui.ts +41 -15
- package/extensions/job/types.ts +9 -1
- 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
|
@@ -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
|
});
|
|
@@ -15,8 +15,9 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
|
15
15
|
import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
16
16
|
import { sanitizeLine, shortenPath, stripControlChars } from "../../lib/format.ts";
|
|
17
17
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
18
|
-
import { expandHint, GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
18
|
+
import { expandHint, GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
19
19
|
import { centeredRule } from "../../lib/tui/rules.ts";
|
|
20
|
+
import { BODY_INDENT } from "../../lib/tui/indent.ts";
|
|
20
21
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
21
22
|
import { aggregateUsage, clip, formatToolCall, formatUsageStats } from "./format.ts";
|
|
22
23
|
import { COLLAPSED_ERROR_MAX, COLLAPSED_ITEM_COUNT, COLLAPSED_TEXT_LINES, type SubagentArgs } from "./schema.ts";
|
|
@@ -187,7 +188,7 @@ function resultHeader(
|
|
|
187
188
|
stepPrefix: string,
|
|
188
189
|
now: number,
|
|
189
190
|
): string {
|
|
190
|
-
let header = `${statusIcon(status, theme, now)} ${theme
|
|
191
|
+
let header = `${statusIcon(status, theme, now)} ${paint(theme, "accent", theme.bold(sanitizeLine(result.agent)))}`;
|
|
191
192
|
if (result.agentSource && result.agentSource !== "builtin" && result.agentSource !== "unknown") {
|
|
192
193
|
header += paint(theme, "faint", ` [${sanitizeLine(result.agentSource)}]`);
|
|
193
194
|
}
|
|
@@ -195,12 +196,13 @@ function resultHeader(
|
|
|
195
196
|
return header + statusSuffix(result, status, theme, now);
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
/** Prefix each line of intermediate assistant text with
|
|
199
|
-
function
|
|
199
|
+
/** Prefix each line of intermediate assistant text with the block's body indent. */
|
|
200
|
+
function indentBody(text: string, theme: Theme): string {
|
|
201
|
+
const indent = " ".repeat(BODY_INDENT);
|
|
200
202
|
return stripControlChars(text)
|
|
201
203
|
.trim()
|
|
202
204
|
.split("\n")
|
|
203
|
-
.map((line) =>
|
|
205
|
+
.map((line) => indent + theme.fg("toolOutput", line))
|
|
204
206
|
.join("\n");
|
|
205
207
|
}
|
|
206
208
|
|
|
@@ -233,47 +235,72 @@ export interface SubagentCallContext {
|
|
|
233
235
|
lastComponent?: Component;
|
|
234
236
|
}
|
|
235
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The call's arguments as Pi hands them to `renderCall`. While the model streams
|
|
240
|
+
* a call, a field can still be missing, or arrive with a type the schema will
|
|
241
|
+
* later reject, so the call line reads every one defensively and never throws: a
|
|
242
|
+
* throwing renderer makes Pi fall back to its own generic call line.
|
|
243
|
+
*/
|
|
244
|
+
export type StreamedSubagentArgs = Partial<Record<keyof SubagentArgs, unknown>>;
|
|
245
|
+
|
|
246
|
+
/** One field of a partially-streamed element, or undefined when it has not arrived. */
|
|
247
|
+
function streamedText(element: unknown, key: "agent" | "task" | "cwd"): string | undefined {
|
|
248
|
+
if (typeof element !== "object" || element === null) return undefined;
|
|
249
|
+
const value = (element as Record<string, unknown>)[key];
|
|
250
|
+
return typeof value === "string" ? value : undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
236
253
|
/** Preview the requested mode and its agents, reusing the slot's `Text`. */
|
|
237
|
-
export function renderSubagentCall(
|
|
254
|
+
export function renderSubagentCall(
|
|
255
|
+
args: StreamedSubagentArgs | undefined,
|
|
256
|
+
theme: Theme,
|
|
257
|
+
context?: SubagentCallContext,
|
|
258
|
+
): Component {
|
|
238
259
|
const text = reuseText(context?.lastComponent);
|
|
239
|
-
text.setText(callText(args, theme, context));
|
|
260
|
+
text.setText(callText(args ?? {}, theme, context));
|
|
240
261
|
return text;
|
|
241
262
|
}
|
|
242
263
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
264
|
+
/**
|
|
265
|
+
* The call-line string for the requested mode. Continuation lines carry no
|
|
266
|
+
* indent of their own: the tool-block frame headlines the first line and
|
|
267
|
+
* indents the rest by `BODY_INDENT`.
|
|
268
|
+
*/
|
|
269
|
+
function callText(args: StreamedSubagentArgs, theme: Theme, context?: { cwd?: string }): string {
|
|
245
270
|
const title =
|
|
246
271
|
theme.fg("toolTitle", theme.bold("subagent ")) + (args.readOnly ? paint(theme, "faint", "read-only ") : "");
|
|
247
|
-
const cwdNote = (cwd?:
|
|
248
|
-
if (!cwd || cwd === context?.cwd) return "";
|
|
272
|
+
const cwdNote = (cwd?: unknown) => {
|
|
273
|
+
if (typeof cwd !== "string" || !cwd || cwd === context?.cwd) return "";
|
|
249
274
|
return paint(theme, "faint", ` in ${shortenPath(cwd)}`);
|
|
250
275
|
};
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
let text =
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
276
|
+
const chain = Array.isArray(args.chain) ? args.chain : [];
|
|
277
|
+
if (chain.length > 0) {
|
|
278
|
+
let text =
|
|
279
|
+
title + paint(theme, "label", `chain${SEPARATORS.item}${chain.length} step${chain.length > 1 ? "s" : ""}`);
|
|
280
|
+
for (let i = 0; i < Math.min(chain.length, 3); i++) {
|
|
281
|
+
const step = chain[i];
|
|
282
|
+
const task = (streamedText(step, "task") ?? "…").replace(/\{previous\}/g, "");
|
|
283
|
+
text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(step, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(task), 40)}`)}${cwdNote(streamedText(step, "cwd"))}`;
|
|
258
284
|
}
|
|
259
|
-
if (
|
|
285
|
+
if (chain.length > 3) text += `\n${paint(theme, "meta", `… +${chain.length - 3} more`)}`;
|
|
260
286
|
return text;
|
|
261
287
|
}
|
|
262
288
|
|
|
263
|
-
|
|
289
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : [];
|
|
290
|
+
if (tasks.length > 0) {
|
|
264
291
|
let text =
|
|
265
|
-
title + paint(theme, "
|
|
266
|
-
for (let i = 0; i < Math.min(
|
|
267
|
-
const task =
|
|
268
|
-
text += `\n
|
|
292
|
+
title + paint(theme, "label", `parallel${SEPARATORS.item}${tasks.length} task${tasks.length > 1 ? "s" : ""}`);
|
|
293
|
+
for (let i = 0; i < Math.min(tasks.length, 3); i++) {
|
|
294
|
+
const task = tasks[i];
|
|
295
|
+
text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(task, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(streamedText(task, "task") ?? "…"), 40)}`)}${cwdNote(streamedText(task, "cwd"))}`;
|
|
269
296
|
}
|
|
270
|
-
if (
|
|
297
|
+
if (tasks.length > 3) text += `\n${paint(theme, "meta", `… +${tasks.length - 3} more`)}`;
|
|
271
298
|
return text;
|
|
272
299
|
}
|
|
273
300
|
|
|
274
|
-
const agentName = sanitizeLine(args
|
|
275
|
-
let text = title + paint(theme, "
|
|
276
|
-
text += `\n
|
|
301
|
+
const agentName = sanitizeLine(streamedText(args, "agent") || "…");
|
|
302
|
+
let text = title + paint(theme, "label", agentName) + cwdNote(streamedText(args, "cwd"));
|
|
303
|
+
text += `\n${paint(theme, "meta", clip(sanitizeLine(streamedText(args, "task") ?? "…"), 60))}`;
|
|
277
304
|
return text;
|
|
278
305
|
}
|
|
279
306
|
|
|
@@ -316,7 +343,7 @@ function addExpandedResult(
|
|
|
316
343
|
);
|
|
317
344
|
renderedOutput = true;
|
|
318
345
|
} else if (item.text.trim() && item.text !== trailingOutput) {
|
|
319
|
-
container.addChild(new Text(
|
|
346
|
+
container.addChild(new Text(indentBody(item.text, theme), 0, 0));
|
|
320
347
|
renderedOutput = true;
|
|
321
348
|
}
|
|
322
349
|
}
|