@tt-a1i/openpi 0.1.1 → 0.3.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 +65 -28
- package/SETUP.md +8 -6
- package/extensions/ask-user/handoff.ts +5 -1
- package/extensions/ask-user/index.ts +44 -0
- package/extensions/background-terminals/index.ts +118 -29
- package/extensions/background-terminals/src/domain.ts +5 -1
- package/extensions/background-terminals/src/manager.ts +2 -1
- package/extensions/background-terminals/src/prompt.ts +35 -0
- package/extensions/background-terminals/src/result-delivery.ts +76 -3
- package/extensions/background-terminals/src/ui/tool-result.ts +52 -1
- package/extensions/capabilities/index.ts +198 -0
- package/extensions/context-pivot/index.ts +21 -0
- package/extensions/cron/index.ts +42 -15
- package/extensions/execution-convergence/active-evidence.ts +129 -0
- package/extensions/execution-convergence/index.ts +442 -0
- package/extensions/execution-convergence/workspace-provenance.ts +338 -0
- package/extensions/file-search/index.ts +8 -1
- package/extensions/file-search/src/binaries.ts +2 -1
- package/extensions/git-info/src/runtime.ts +1 -1
- package/extensions/goal/controller.ts +2 -1
- package/extensions/goal/index.ts +20 -1
- package/extensions/plan-mode/bash-policy.ts +219 -42
- package/extensions/plan-mode/index.ts +56 -19
- package/extensions/setup/index.ts +96 -10
- package/extensions/shared/child-session.ts +40 -4
- package/extensions/shared/editor-layers.ts +150 -0
- package/extensions/shared/setup-config.ts +26 -15
- package/extensions/shared/setup-episode-state.ts +7 -0
- package/extensions/shared/tool-surface.ts +435 -0
- package/extensions/subagents/index.ts +179 -96
- package/extensions/subagents/src/manager.ts +13 -11
- package/extensions/subagents/src/prompt.ts +7 -7
- package/extensions/subagents/src/ui/takeover.ts +231 -133
- package/extensions/subagents/src/ui/transcript.ts +252 -37
- package/extensions/subagents/src/ui/wait-result.ts +6 -19
- package/extensions/suggestions/index.ts +27 -18
- package/extensions/tasks/index.ts +63 -18
- package/extensions/ui-customization/footer.ts +65 -11
- package/extensions/workflows/graph-projection.ts +6 -4
- package/extensions/workflows/index.ts +44 -21
- package/extensions/workflows/invocation-ledger.ts +8 -2
- package/extensions/workflows/model.ts +5 -1
- package/extensions/workflows/prompt.ts +10 -40
- package/extensions/workflows/replay-safety.ts +9 -8
- package/package.json +10 -10
- package/skills/subagents/SKILL.md +7 -1
- package/skills/workflows/EXAMPLES.md +58 -0
- package/skills/workflows/REFERENCE.md +44 -0
- package/skills/workflows/SKILL.md +39 -0
|
@@ -18,6 +18,29 @@ import type { SubagentSnapshot, TranscriptItem } from "../domain.ts";
|
|
|
18
18
|
|
|
19
19
|
const MAX_CACHED_WIDTHS_PER_ITEM = 2;
|
|
20
20
|
|
|
21
|
+
export const SPINNER_FRAMES = [
|
|
22
|
+
"⠋",
|
|
23
|
+
"⠙",
|
|
24
|
+
"⠹",
|
|
25
|
+
"⠸",
|
|
26
|
+
"⠼",
|
|
27
|
+
"⠴",
|
|
28
|
+
"⠦",
|
|
29
|
+
"⠧",
|
|
30
|
+
"⠇",
|
|
31
|
+
"⠏",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/** Frame cadence, shared with the dashboard and takeover headers. */
|
|
35
|
+
export const SPINNER_INTERVAL_MS = 120;
|
|
36
|
+
|
|
37
|
+
export function spinnerFrame(now: number) {
|
|
38
|
+
const frame = Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length;
|
|
39
|
+
return SPINNER_FRAMES[
|
|
40
|
+
(frame + SPINNER_FRAMES.length) % SPINNER_FRAMES.length
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
/**
|
|
22
45
|
* Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded
|
|
23
46
|
* tabs (and stray escapes) make lines wider than the width we declare to the
|
|
@@ -146,10 +169,82 @@ function renderThinking(theme: Theme, text: string, width: number) {
|
|
|
146
169
|
return out;
|
|
147
170
|
}
|
|
148
171
|
|
|
172
|
+
function renderToolBody(theme: Theme, name: string, argsPreview?: string) {
|
|
173
|
+
const toolName = sanitizeText(name);
|
|
174
|
+
const preview = summarizeToolArgs(toolName, argsPreview);
|
|
175
|
+
// The `$` form only earns its prompt when there is a command to show; a bare
|
|
176
|
+
// `$ ` would read as an empty shell line.
|
|
177
|
+
if (toolName === "bash" && preview) return theme.fg("dim", `$ ${preview}`);
|
|
178
|
+
return (
|
|
179
|
+
theme.fg("dim", "→ ") +
|
|
180
|
+
theme.fg("toolTitle", toolName) +
|
|
181
|
+
(preview ? theme.fg("dim", ` ${preview}`) : "")
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function firstOutputPreview(outputPreview?: string) {
|
|
186
|
+
return (
|
|
187
|
+
sanitizeText(outputPreview ?? "")
|
|
188
|
+
.split("\n")
|
|
189
|
+
.find((line) => line.trim()) ?? ""
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One execution owns exactly one glyph column, on its command line. Output
|
|
195
|
+
* lines are plain indented text so a block keeps identical columns from the
|
|
196
|
+
* moment the command starts to the moment it settles.
|
|
197
|
+
*/
|
|
198
|
+
export type ToolPhase = "live" | "ok" | "error" | "pending";
|
|
199
|
+
|
|
200
|
+
function phaseGlyph(theme: Theme, phase: ToolPhase, now: number) {
|
|
201
|
+
switch (phase) {
|
|
202
|
+
case "live":
|
|
203
|
+
return theme.fg("warning", spinnerFrame(now));
|
|
204
|
+
case "ok":
|
|
205
|
+
return theme.fg("success", "✓");
|
|
206
|
+
case "error":
|
|
207
|
+
return theme.fg("error", "✗");
|
|
208
|
+
case "pending":
|
|
209
|
+
return theme.fg("dim", "·");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Command line: `<glyph> $ cmd` for bash, `<glyph> → name args` otherwise. */
|
|
214
|
+
function renderToolLine(
|
|
215
|
+
theme: Theme,
|
|
216
|
+
phase: ToolPhase,
|
|
217
|
+
name: string,
|
|
218
|
+
argsPreview: string | undefined,
|
|
219
|
+
width: number,
|
|
220
|
+
now: number,
|
|
221
|
+
) {
|
|
222
|
+
return truncateToWidth(
|
|
223
|
+
`${phaseGlyph(theme, phase, now)} ${renderToolBody(theme, name, argsPreview)}`,
|
|
224
|
+
width,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Output line: indented under the command, no second glyph. */
|
|
229
|
+
function renderOutputLine(
|
|
230
|
+
theme: Theme,
|
|
231
|
+
isError: boolean,
|
|
232
|
+
outputPreview: string,
|
|
233
|
+
width: number,
|
|
234
|
+
) {
|
|
235
|
+
const preview = outputPreview || "(no output)";
|
|
236
|
+
const content = isError
|
|
237
|
+
? theme.fg(outputPreview ? "error" : "dim", preview)
|
|
238
|
+
: theme.fg("dim", preview);
|
|
239
|
+
return truncateToWidth(` ${content}`, width);
|
|
240
|
+
}
|
|
241
|
+
|
|
149
242
|
function renderAssistantItem(
|
|
150
243
|
theme: Theme,
|
|
151
244
|
item: Extract<TranscriptItem, { kind: "assistant" }>,
|
|
152
245
|
width: number,
|
|
246
|
+
phases: ReadonlyMap<string, ToolPhase>,
|
|
247
|
+
now: number,
|
|
153
248
|
) {
|
|
154
249
|
const out: string[] = [];
|
|
155
250
|
for (const part of item.parts) {
|
|
@@ -164,12 +259,14 @@ function renderAssistantItem(
|
|
|
164
259
|
),
|
|
165
260
|
);
|
|
166
261
|
} else if (part.type === "toolCall") {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
out.push(
|
|
262
|
+
const phase = phases.get(part.toolId) ?? "pending";
|
|
263
|
+
// A live tool is rendered by the live block, which owns the spinner and
|
|
264
|
+
// the streaming output; rendering the call here too would show the same
|
|
265
|
+
// command twice and make the block reflow when the tool settles.
|
|
266
|
+
if (phase === "live") continue;
|
|
267
|
+
out.push(
|
|
268
|
+
renderToolLine(theme, phase, part.name, part.argsPreview, width, now),
|
|
269
|
+
);
|
|
173
270
|
}
|
|
174
271
|
}
|
|
175
272
|
return out;
|
|
@@ -179,27 +276,117 @@ function renderToolResultItem(
|
|
|
179
276
|
theme: Theme,
|
|
180
277
|
item: Extract<TranscriptItem, { kind: "toolResult" }>,
|
|
181
278
|
width: number,
|
|
279
|
+
paired: boolean,
|
|
280
|
+
now: number,
|
|
182
281
|
) {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
282
|
+
const preview = firstOutputPreview(item.outputPreview);
|
|
283
|
+
// An orphan result (its call is not the previous item) still needs a glyph:
|
|
284
|
+
// there is no command line above it to carry one.
|
|
285
|
+
if (!paired) {
|
|
286
|
+
return [
|
|
287
|
+
renderToolLine(
|
|
288
|
+
theme,
|
|
289
|
+
item.isError ? "error" : "ok",
|
|
290
|
+
item.name,
|
|
291
|
+
undefined,
|
|
292
|
+
width,
|
|
293
|
+
now,
|
|
294
|
+
),
|
|
295
|
+
...(preview
|
|
296
|
+
? [renderOutputLine(theme, item.isError, preview, width)]
|
|
297
|
+
: []),
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
return [renderOutputLine(theme, item.isError, preview, width)];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isPairedToolResult(
|
|
304
|
+
previous: TranscriptItem | undefined,
|
|
305
|
+
current: TranscriptItem,
|
|
306
|
+
) {
|
|
307
|
+
if (
|
|
308
|
+
!previous ||
|
|
309
|
+
previous.kind !== "assistant" ||
|
|
310
|
+
current.kind !== "toolResult"
|
|
311
|
+
) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
const lastPart = previous.parts[previous.parts.length - 1];
|
|
315
|
+
return lastPart?.type === "toolCall" && lastPart.toolId === current.toolId;
|
|
193
316
|
}
|
|
194
317
|
|
|
195
318
|
function renderTranscriptItem(
|
|
196
319
|
theme: Theme,
|
|
197
320
|
item: TranscriptItem,
|
|
198
321
|
width: number,
|
|
322
|
+
context: ItemContext,
|
|
323
|
+
now: number,
|
|
199
324
|
) {
|
|
200
325
|
if (item.kind === "user") return renderUserText(theme, item.text, width);
|
|
201
|
-
if (item.kind === "assistant")
|
|
202
|
-
|
|
326
|
+
if (item.kind === "assistant") {
|
|
327
|
+
return renderAssistantItem(theme, item, width, context.phases, now);
|
|
328
|
+
}
|
|
329
|
+
return renderToolResultItem(theme, item, width, context.paired, now);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface ItemContext {
|
|
333
|
+
readonly phases: ReadonlyMap<string, ToolPhase>;
|
|
334
|
+
readonly paired: boolean;
|
|
335
|
+
/** Cache discriminator: identity plus width is not enough on its own. */
|
|
336
|
+
readonly token: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* An item's rendering depends on its neighbours (does a call have its result
|
|
341
|
+
* yet?) and on live state (is the call still running?), so the cache key has to
|
|
342
|
+
* carry that context or a stale glyph would outlive the phase it described.
|
|
343
|
+
*/
|
|
344
|
+
function itemContext(
|
|
345
|
+
transcript: ReadonlyArray<TranscriptItem>,
|
|
346
|
+
index: number,
|
|
347
|
+
liveIds: ReadonlySet<string>,
|
|
348
|
+
): ItemContext {
|
|
349
|
+
const item = transcript[index]!;
|
|
350
|
+
if (item.kind === "user")
|
|
351
|
+
return { phases: new Map(), paired: false, token: "" };
|
|
352
|
+
if (item.kind === "toolResult") {
|
|
353
|
+
const paired = isPairedToolResult(transcript[index - 1], item);
|
|
354
|
+
return { phases: new Map(), paired, token: paired ? "p" : "o" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const phases = new Map<string, ToolPhase>();
|
|
358
|
+
for (const part of item.parts) {
|
|
359
|
+
if (part.type !== "toolCall") continue;
|
|
360
|
+
if (liveIds.has(part.toolId)) {
|
|
361
|
+
phases.set(part.toolId, "live");
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const result = findResult(transcript, index, part.toolId);
|
|
365
|
+
phases.set(
|
|
366
|
+
part.toolId,
|
|
367
|
+
result ? (result.isError ? "error" : "ok") : "pending",
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
phases,
|
|
372
|
+
paired: false,
|
|
373
|
+
token: [...phases].map(([id, phase]) => `${id}:${phase}`).join(","),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The result for a call, if it has already landed later in the transcript. */
|
|
378
|
+
function findResult(
|
|
379
|
+
transcript: ReadonlyArray<TranscriptItem>,
|
|
380
|
+
callIndex: number,
|
|
381
|
+
toolId: string,
|
|
382
|
+
) {
|
|
383
|
+
for (let index = callIndex + 1; index < transcript.length; index++) {
|
|
384
|
+
const candidate = transcript[index];
|
|
385
|
+
if (candidate?.kind === "toolResult" && candidate.toolId === toolId) {
|
|
386
|
+
return candidate;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return undefined;
|
|
203
390
|
}
|
|
204
391
|
|
|
205
392
|
/**
|
|
@@ -208,24 +395,43 @@ function renderTranscriptItem(
|
|
|
208
395
|
* from their component's invalidate() when Pi changes theme.
|
|
209
396
|
*/
|
|
210
397
|
export class TranscriptRenderer {
|
|
211
|
-
private itemCache = new WeakMap<TranscriptItem, Map<
|
|
398
|
+
private itemCache = new WeakMap<TranscriptItem, Map<string, string[]>>();
|
|
212
399
|
|
|
213
|
-
render(
|
|
400
|
+
render(
|
|
401
|
+
snap: SubagentSnapshot,
|
|
402
|
+
width: number,
|
|
403
|
+
theme: Theme,
|
|
404
|
+
options?: { readonly now?: number },
|
|
405
|
+
) {
|
|
214
406
|
const out: string[] = [];
|
|
407
|
+
const now = options?.now ?? Date.now();
|
|
408
|
+
const liveIds = new Set(snap.liveTools.map((tool) => tool.toolId));
|
|
215
409
|
|
|
216
|
-
for (
|
|
217
|
-
const
|
|
218
|
-
const
|
|
410
|
+
for (let index = 0; index < snap.transcript.length; index++) {
|
|
411
|
+
const item = snap.transcript[index];
|
|
412
|
+
const context = itemContext(snap.transcript, index, liveIds);
|
|
413
|
+
const key = `${width}|${context.token}`;
|
|
414
|
+
const cached = this.itemCache.get(item)?.get(key);
|
|
415
|
+
const lines =
|
|
416
|
+
cached ?? renderTranscriptItem(theme, item, width, context, now);
|
|
219
417
|
if (!cached) {
|
|
220
|
-
const widths = this.itemCache.get(item) ?? new Map<
|
|
418
|
+
const widths = this.itemCache.get(item) ?? new Map<string, string[]>();
|
|
221
419
|
if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) {
|
|
222
420
|
const oldestWidth = widths.keys().next().value;
|
|
223
421
|
if (oldestWidth !== undefined) widths.delete(oldestWidth);
|
|
224
422
|
}
|
|
225
|
-
widths.set(
|
|
423
|
+
widths.set(key, lines);
|
|
226
424
|
this.itemCache.set(item, widths);
|
|
227
425
|
}
|
|
228
|
-
if (lines.length > 0)
|
|
426
|
+
if (lines.length > 0) {
|
|
427
|
+
if (
|
|
428
|
+
out.length > 0 &&
|
|
429
|
+
!isPairedToolResult(snap.transcript[index - 1], item)
|
|
430
|
+
) {
|
|
431
|
+
out.push("");
|
|
432
|
+
}
|
|
433
|
+
out.push(...lines);
|
|
434
|
+
}
|
|
229
435
|
}
|
|
230
436
|
while (out.length > 0 && out[out.length - 1] === "") out.pop();
|
|
231
437
|
|
|
@@ -239,19 +445,22 @@ export class TranscriptRenderer {
|
|
|
239
445
|
if (out.length === before + 1) out.pop();
|
|
240
446
|
}
|
|
241
447
|
|
|
242
|
-
// Live tool executions
|
|
448
|
+
// Live tool executions. The manager drops a live entry when its ToolEnd
|
|
449
|
+
// lands, and the transcript's call line then takes over with the settled
|
|
450
|
+
// glyph in the same column, so the block never reflows.
|
|
243
451
|
for (const tool of snap.liveTools) {
|
|
244
452
|
if (out.length > 0) out.push("");
|
|
245
|
-
const
|
|
453
|
+
const phase: ToolPhase = tool.done
|
|
246
454
|
? tool.isError
|
|
247
|
-
?
|
|
248
|
-
:
|
|
249
|
-
:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
455
|
+
? "error"
|
|
456
|
+
: "ok"
|
|
457
|
+
: "live";
|
|
458
|
+
out.push(
|
|
459
|
+
renderToolLine(theme, phase, tool.name, tool.argsPreview, width, now),
|
|
460
|
+
);
|
|
461
|
+
const preview = firstOutputPreview(tool.outputPreview);
|
|
462
|
+
if (preview)
|
|
463
|
+
out.push(renderOutputLine(theme, !!tool.isError, preview, width));
|
|
255
464
|
}
|
|
256
465
|
|
|
257
466
|
// Queued steering/follow-up messages: show them immediately so Enter
|
|
@@ -288,6 +497,12 @@ export function buildTranscriptLines(
|
|
|
288
497
|
width: number,
|
|
289
498
|
theme: Theme,
|
|
290
499
|
renderer?: TranscriptRenderer,
|
|
500
|
+
options?: { readonly now?: number },
|
|
291
501
|
) {
|
|
292
|
-
return (renderer ?? new TranscriptRenderer()).render(
|
|
502
|
+
return (renderer ?? new TranscriptRenderer()).render(
|
|
503
|
+
snap,
|
|
504
|
+
width,
|
|
505
|
+
theme,
|
|
506
|
+
options,
|
|
507
|
+
);
|
|
293
508
|
}
|
|
@@ -6,17 +6,16 @@ import {
|
|
|
6
6
|
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
7
7
|
import { sanitizeText } from "./transcript.ts";
|
|
8
8
|
|
|
9
|
-
const COLLAPSED_MAX_LINES = 12;
|
|
10
9
|
const MAX_STATUS_ROWS = 4;
|
|
11
10
|
|
|
12
11
|
export interface WaitResultItem {
|
|
13
|
-
id: string;
|
|
14
|
-
title?: string;
|
|
15
|
-
status?: string;
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly title?: string;
|
|
14
|
+
readonly status?: string;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
export interface WaitResultDetails {
|
|
19
|
-
results?: WaitResultItem[];
|
|
18
|
+
readonly results?: readonly WaitResultItem[];
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export function buildWaitResultPreview(
|
|
@@ -46,23 +45,11 @@ export function buildWaitResultPreview(
|
|
|
46
45
|
lines.push(theme.fg("dim", ` … ${results.length - MAX_STATUS_ROWS} more`));
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
.split("\n")
|
|
51
|
-
.map((line) => line.trimEnd())
|
|
52
|
-
.filter((line) => line.trim() && line.trim() !== "---");
|
|
53
|
-
const leadingHeader = cleanLines[0]?.startsWith("## ") ? 1 : 0;
|
|
54
|
-
const body = cleanLines.slice(leadingHeader);
|
|
55
|
-
const available = Math.max(1, COLLAPSED_MAX_LINES - lines.length - 1);
|
|
56
|
-
for (const line of body.slice(0, available)) {
|
|
57
|
-
lines.push(theme.fg("toolOutput", line));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const hidden = Math.max(0, body.length - available);
|
|
61
|
-
if (hidden > 0) {
|
|
48
|
+
if (content.trim()) {
|
|
62
49
|
lines.push(
|
|
63
50
|
theme.fg(
|
|
64
51
|
"dim",
|
|
65
|
-
|
|
52
|
+
`Results passed to main agent · ${keyHint("app.tools.expand", "to expand")}`,
|
|
66
53
|
),
|
|
67
54
|
);
|
|
68
55
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
type ExtensionContext,
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
5
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
registerEditorLayer,
|
|
7
|
+
removeEditorLayer,
|
|
8
|
+
} from "../shared/editor-layers.ts";
|
|
6
9
|
import {
|
|
7
10
|
loadSetupConfig,
|
|
8
11
|
SETUP_CONFIG_CHANGED_CHANNEL,
|
|
@@ -51,6 +54,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
51
54
|
let sessionActive = false;
|
|
52
55
|
let statusContext: ExtensionContext | undefined;
|
|
53
56
|
let requestEditorRender: (() => void) | undefined;
|
|
57
|
+
let editorLayerRegistered = false;
|
|
54
58
|
|
|
55
59
|
const updateStatus = () => {
|
|
56
60
|
statusContext?.ui.setStatus(
|
|
@@ -72,21 +76,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
72
76
|
|
|
73
77
|
const installSuggestionEditor = (ctx: ExtensionContext) => {
|
|
74
78
|
if (ctx.mode !== "tui") return;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
79
|
+
registerEditorLayer(pi, ctx, {
|
|
80
|
+
id: "suggestions",
|
|
81
|
+
order: 200,
|
|
82
|
+
wrap: (base, tui, _theme, keybindings) => {
|
|
83
|
+
requestEditorRender = () => tui.requestRender();
|
|
84
|
+
return new NextActionSuggestionEditor(
|
|
85
|
+
base,
|
|
86
|
+
keybindings,
|
|
87
|
+
suggestionState,
|
|
88
|
+
cancelPrediction,
|
|
89
|
+
requestEditorRender,
|
|
90
|
+
(text) => ctx.ui.theme.fg("dim", text),
|
|
91
|
+
);
|
|
92
|
+
},
|
|
89
93
|
});
|
|
94
|
+
editorLayerRegistered = true;
|
|
90
95
|
};
|
|
91
96
|
|
|
92
97
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -160,6 +165,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
160
165
|
pi.events.on(SETUP_CONFIG_CHANGED_CHANNEL, cancelPrediction);
|
|
161
166
|
|
|
162
167
|
pi.on("session_shutdown", async () => {
|
|
168
|
+
if (editorLayerRegistered) {
|
|
169
|
+
removeEditorLayer(pi, "suggestions");
|
|
170
|
+
editorLayerRegistered = false;
|
|
171
|
+
}
|
|
163
172
|
sessionActive = false;
|
|
164
173
|
runBoundary.reset();
|
|
165
174
|
const task = activePrediction?.task;
|
|
@@ -6,6 +6,10 @@ import type {
|
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Key, Text } from "@earendil-works/pi-tui";
|
|
8
8
|
import { Type } from "typebox";
|
|
9
|
+
import {
|
|
10
|
+
OPENPI_TOOL_SURFACE,
|
|
11
|
+
patchOwnedTools,
|
|
12
|
+
} from "../shared/tool-surface.ts";
|
|
9
13
|
import {
|
|
10
14
|
TASKS_ENTRY_TYPE,
|
|
11
15
|
TASKS_LIMITS,
|
|
@@ -95,8 +99,18 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
95
99
|
let notifiedProblem: string | undefined;
|
|
96
100
|
let taskWidgetVisible = true;
|
|
97
101
|
let taskWidgetExpanded = false;
|
|
102
|
+
let taskWidgetMounted = false;
|
|
103
|
+
let requestTaskWidgetRender: (() => void) | undefined;
|
|
98
104
|
let ui: ExtensionContext["ui"] | undefined;
|
|
99
105
|
let uiMode: ExtensionContext["mode"] | undefined;
|
|
106
|
+
const hideLifecycleTools = () =>
|
|
107
|
+
patchOwnedTools(pi, "tasks", {
|
|
108
|
+
disable: OPENPI_TOOL_SURFACE.tasks.deferred,
|
|
109
|
+
});
|
|
110
|
+
const showLifecycleTools = () =>
|
|
111
|
+
patchOwnedTools(pi, "tasks", {
|
|
112
|
+
enable: OPENPI_TOOL_SURFACE.tasks.deferred,
|
|
113
|
+
});
|
|
100
114
|
|
|
101
115
|
const snapshot = () => tasks.snapshot();
|
|
102
116
|
|
|
@@ -112,22 +126,36 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
112
126
|
|
|
113
127
|
const updateTaskWidget = (ctx?: ExtensionContext) => {
|
|
114
128
|
if (ctx?.hasUI) {
|
|
129
|
+
if (ui !== ctx.ui) {
|
|
130
|
+
taskWidgetMounted = false;
|
|
131
|
+
requestTaskWidgetRender = undefined;
|
|
132
|
+
}
|
|
115
133
|
ui = ctx.ui;
|
|
116
134
|
uiMode = ctx.mode;
|
|
117
135
|
}
|
|
118
136
|
if (!ui || uiMode !== "tui") return false;
|
|
119
|
-
const current = snapshot();
|
|
120
137
|
const shown =
|
|
121
138
|
taskWidgetVisible && !problemMessage() && hasActionableTasks();
|
|
122
139
|
if (!shown) {
|
|
140
|
+
if (!taskWidgetMounted) return false;
|
|
123
141
|
ui.setWidget(TASK_WIDGET_KEY, undefined);
|
|
142
|
+
taskWidgetMounted = false;
|
|
143
|
+
requestTaskWidgetRender = undefined;
|
|
124
144
|
return false;
|
|
125
145
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
if (taskWidgetMounted) {
|
|
147
|
+
requestTaskWidgetRender?.();
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
ui.setWidget(TASK_WIDGET_KEY, (tui, theme) => {
|
|
151
|
+
requestTaskWidgetRender = () => tui.requestRender();
|
|
152
|
+
return {
|
|
153
|
+
render: (width) =>
|
|
154
|
+
renderTaskWidget(snapshot(), theme, width, taskWidgetExpanded),
|
|
155
|
+
invalidate() {},
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
taskWidgetMounted = true;
|
|
131
159
|
return true;
|
|
132
160
|
};
|
|
133
161
|
|
|
@@ -199,13 +227,15 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
199
227
|
items,
|
|
200
228
|
total: snapshot().items.length,
|
|
201
229
|
revision: snapshot().revision,
|
|
202
|
-
// From the live snapshot, not `items`: a tools_update carries only the one
|
|
203
|
-
// row it touched, and a header counted from that would claim the batch is
|
|
204
|
-
// a single task.
|
|
205
230
|
counts: taskCounts(snapshot().items),
|
|
206
231
|
...(batchClosed ? { batchClosed: true } : {}),
|
|
207
232
|
});
|
|
208
233
|
|
|
234
|
+
const mutationResultText = (summary: string) => {
|
|
235
|
+
const current = snapshot();
|
|
236
|
+
return `${summary}\nCurrent task snapshot (${current.items.length} ${current.items.length === 1 ? "item" : "items"}):\n${tasks.render()}`;
|
|
237
|
+
};
|
|
238
|
+
|
|
209
239
|
const registerTools = () => {
|
|
210
240
|
if (toolsRegistered || conflict) return;
|
|
211
241
|
toolsRegistered = true;
|
|
@@ -218,6 +248,7 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
218
248
|
"Add stable work-intent items to the current session tasks",
|
|
219
249
|
promptGuidelines: [
|
|
220
250
|
"Use tasks_add only for work spanning multiple agent runs or user turns, or when the user explicitly provides a task list; do not use it as a per-step scratchpad within one run.",
|
|
251
|
+
"Before starting each tracked item, call tasks_update to mark it in_progress; concurrent work may have multiple in_progress items.",
|
|
221
252
|
"Task tools record advisory intent only; Subagents and Workflows execute work, while files, git, tests, tool results, artifacts, and user confirmation remain truth.",
|
|
222
253
|
],
|
|
223
254
|
parameters: Type.Object({
|
|
@@ -239,14 +270,17 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
239
270
|
assertAvailable();
|
|
240
271
|
const mutation = applyTaskAdd(snapshot(), params.items);
|
|
241
272
|
persistThenCommit(mutation.snapshot);
|
|
273
|
+
showLifecycleTools();
|
|
242
274
|
return Promise.resolve({
|
|
243
275
|
content: [
|
|
244
276
|
{
|
|
245
277
|
type: "text" as const,
|
|
246
|
-
text:
|
|
278
|
+
text: mutationResultText(
|
|
279
|
+
`Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
|
|
280
|
+
),
|
|
247
281
|
},
|
|
248
282
|
],
|
|
249
|
-
details: toolDetails("add",
|
|
283
|
+
details: toolDetails("add", snapshot().items),
|
|
250
284
|
});
|
|
251
285
|
},
|
|
252
286
|
renderCall(args, theme) {
|
|
@@ -273,7 +307,9 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
273
307
|
description: `${TOOL_PURPOSE} Patch one task item by numeric ID. blocked, done, and dropped status changes require a fresh note explaining the blocker, observable evidence, or drop reason.`,
|
|
274
308
|
promptSnippet: "Update one session task item by stable ID",
|
|
275
309
|
promptGuidelines: [
|
|
276
|
-
"
|
|
310
|
+
"Immediately after each tracked item reaches a real outcome, call tasks_update to set done, blocked, or dropped before moving to the next tracked item.",
|
|
311
|
+
"Before sending a final answer, reconcile every task touched in the current request; do not leave completed work pending or in_progress.",
|
|
312
|
+
"A commit, passing test, or authorization is task-scoped evidence only; it does not by itself prove a task is done or identify which task to update.",
|
|
277
313
|
"Before setting a task item to done, include a note citing an observable check, artifact, commit, tool result, or user confirmation; Tasks record this claim but do not verify it.",
|
|
278
314
|
],
|
|
279
315
|
parameters: Type.Object({
|
|
@@ -305,18 +341,21 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
305
341
|
);
|
|
306
342
|
const mutation = applyTaskUpdate(before, params);
|
|
307
343
|
const changed = persistThenCommit(mutation.snapshot);
|
|
344
|
+
if (changed && closesBatch) hideLifecycleTools();
|
|
308
345
|
return Promise.resolve({
|
|
309
346
|
content: [
|
|
310
347
|
{
|
|
311
348
|
type: "text" as const,
|
|
312
|
-
text:
|
|
313
|
-
|
|
314
|
-
?
|
|
315
|
-
|
|
316
|
-
|
|
349
|
+
text: mutationResultText(
|
|
350
|
+
changed
|
|
351
|
+
? closesBatch
|
|
352
|
+
? `${params.status === "dropped" ? "Dropped" : "Completed"} T${params.id}. Task batch closed; the next tasks_add starts again at T1.`
|
|
353
|
+
: `Updated T${params.id}.`
|
|
354
|
+
: `T${params.id} already has that state; no update recorded.`,
|
|
355
|
+
),
|
|
317
356
|
},
|
|
318
357
|
],
|
|
319
|
-
details: toolDetails("update",
|
|
358
|
+
details: toolDetails("update", snapshot().items, closesBatch),
|
|
320
359
|
});
|
|
321
360
|
},
|
|
322
361
|
renderCall(args, theme) {
|
|
@@ -448,12 +487,16 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
448
487
|
taskWidgetVisible = true;
|
|
449
488
|
taskWidgetExpanded = false;
|
|
450
489
|
registerTools();
|
|
490
|
+
if (hasActionableTasks()) showLifecycleTools();
|
|
491
|
+
else hideLifecycleTools();
|
|
451
492
|
notifyProblem(ctx);
|
|
452
493
|
updateTaskWidget(ctx);
|
|
453
494
|
});
|
|
454
495
|
|
|
455
496
|
pi.on("session_tree", (_event, ctx) => {
|
|
456
497
|
restore(ctx);
|
|
498
|
+
if (hasActionableTasks()) showLifecycleTools();
|
|
499
|
+
else hideLifecycleTools();
|
|
457
500
|
taskWidgetExpanded = false;
|
|
458
501
|
coldRun = true;
|
|
459
502
|
activeRun = false;
|
|
@@ -492,6 +535,8 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
492
535
|
} catch {
|
|
493
536
|
// The interactive UI may already be disposed.
|
|
494
537
|
}
|
|
538
|
+
taskWidgetMounted = false;
|
|
539
|
+
requestTaskWidgetRender = undefined;
|
|
495
540
|
ui = undefined;
|
|
496
541
|
uiMode = undefined;
|
|
497
542
|
});
|