pi-jar 0.2.8 → 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 +20 -21
- package/docs/DIFF-REVIEW.md +5 -4
- package/docs/EFFICIENCY_PLAN.md +8 -9
- package/docs/FLAME.md +19 -16
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/SHOWCASE.md +43 -1
- package/docs/SUBAGENT-TOOLS.md +6 -0
- package/extensions/index.ts +6 -60
- package/package.json +3 -3
- package/src/cache-breaks.ts +12 -1
- package/src/compact-tools.ts +23 -1
- package/src/composer.ts +55 -39
- package/src/delegate.ts +21 -7
- package/src/diff-inline.ts +14 -6
- package/src/diff-view.ts +32 -9
- package/src/footer.ts +0 -4
- package/src/mascot.ts +134 -22
- package/src/profiles.ts +5 -6
- package/src/settings-ui.ts +13 -30
- package/src/settings.ts +1 -21
- package/src/subagent-tools.ts +28 -4
- package/src/task-tool.ts +2 -1
- package/themes/pi-jar-dark-amber.json +2 -1
- package/themes/pi-jar-dark-azure.json +2 -1
- package/themes/pi-jar-dark-gray.json +2 -1
- package/themes/pi-jar-dark-pink.json +2 -1
- package/themes/pi-jar-dark-teal.json +2 -1
- package/themes/pi-jar-dark-violet.json +2 -1
- package/themes/pi-jar-dark.json +2 -1
- package/src/context-budget.ts +0 -226
- package/src/context-diet.ts +0 -235
package/src/compact-tools.ts
CHANGED
|
@@ -13,7 +13,29 @@ import {
|
|
|
13
13
|
} from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { Box, Container, Text } from "@earendil-works/pi-tui";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
|
-
import {
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
interface ReadStubDetails { unchangedSinceRead: number }
|
|
21
|
+
const isReadStub = (details: unknown): details is ReadStubDetails => !!details && typeof details === "object"
|
|
22
|
+
&& typeof (details as ReadStubDetails).unchangedSinceRead === "number";
|
|
23
|
+
|
|
24
|
+
/** Match Pi's native read path spelling without probing the filesystem. */
|
|
25
|
+
function resolveToolPath(path: string, cwd: string): string | undefined {
|
|
26
|
+
let value = path.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
|
|
27
|
+
if (value.startsWith("@")) value = value.slice(1);
|
|
28
|
+
if (value === "~" || value.startsWith("~/")) value = join(homedir(), value.slice(2));
|
|
29
|
+
else if (value.startsWith("file://")) {
|
|
30
|
+
try { value = fileURLToPath(value); } catch { return undefined; }
|
|
31
|
+
}
|
|
32
|
+
return resolve(cwd, value);
|
|
33
|
+
}
|
|
34
|
+
function readRangeKey(offset: unknown, limit: unknown): string | undefined {
|
|
35
|
+
const start = offset === undefined ? 1 : typeof offset === "number" ? Math.max(offset, 1) : undefined;
|
|
36
|
+
if (start === undefined || (limit !== undefined && typeof limit !== "number")) return undefined;
|
|
37
|
+
return `${start}:${limit ?? ""}`;
|
|
38
|
+
}
|
|
17
39
|
|
|
18
40
|
type ToolResult = { content: Array<{ type: string; text?: string }>; details?: unknown };
|
|
19
41
|
|
package/src/composer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CustomEditor, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, type EditorComponent, type EditorTheme, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import { Mascot, MASCOT_FACE_WIDTH, mascotFace, paintFace, paintTip, type MascotMood } from "./mascot.ts";
|
|
3
|
+
import { Mascot, MASCOT_FACE_WIDTH, MASCOT_SPRITE_WIDTH, MASCOT_FRAME_MS, mascotFace, mascotTip, mascotPerch, paintFace, paintTip, type MascotMood } from "./mascot.ts";
|
|
4
4
|
import type { SuggestionState } from "./suggest.ts";
|
|
5
5
|
import type { WorkingPhase } from "./working.ts";
|
|
6
6
|
import { imageChip, imageInfo, imagePaths } from "./attachments.ts";
|
|
@@ -18,7 +18,7 @@ type MouseEvent = Parameters<NonNullable<EditorComponent["handleMouse"]>>[0];
|
|
|
18
18
|
const PHASE_MOODS: Record<WorkingPhase, readonly MascotMood[]> = {
|
|
19
19
|
idle: ["idle"], generating: ["happy", "thinking"], tool: ["tool"], waiting: ["waiting"]
|
|
20
20
|
};
|
|
21
|
-
export const COMPOSER_ACTIVE_INTERVAL_MS =
|
|
21
|
+
export const COMPOSER_ACTIVE_INTERVAL_MS = MASCOT_FRAME_MS;
|
|
22
22
|
/** How long composer image chips trust a previous filesystem check for the same draft. */
|
|
23
23
|
const ATTACHMENT_RECHECK_MS = 1000;
|
|
24
24
|
/** Plain one-line face for a phase; every phase has the same width. */
|
|
@@ -55,7 +55,10 @@ export function sessionDisplayName(name?: string, id?: string): string {
|
|
|
55
55
|
return `${first}-${last}`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** Empty-cell cursor: pi-tui ≤1.1 paints it inline; 1.2+ wraps it in fake-cursor APC markers resolved at render time. */
|
|
58
59
|
const CURSOR = "\x1b[7m \x1b[0m";
|
|
60
|
+
const FAKE_CURSOR = "\x1b_pi:fc\x07 \x1b_pi:/fc\x07";
|
|
61
|
+
const hasCursor = (row: string) => row.includes(FAKE_CURSOR) || row.includes(CURSOR);
|
|
59
62
|
const BORDER = /^─+(?:\s*[↑↓]\s*\d+\s*more\s*─*)?$/;
|
|
60
63
|
/** Index of the editor's bottom border: known row count when available, else the first plain border row. */
|
|
61
64
|
function bottomBorderIndex(lines: readonly string[], visibleRows?: number): number {
|
|
@@ -66,7 +69,7 @@ function bottomBorderIndex(lines: readonly string[], visibleRows?: number): numb
|
|
|
66
69
|
const overflowLabel = (line: string | undefined) => /[↑↓]\s*\d+\s*more/.exec(stripTerminalSequences(line ?? ""))?.[0];
|
|
67
70
|
|
|
68
71
|
export interface RoundedInputOptions {
|
|
69
|
-
/** Active profile shown
|
|
72
|
+
/** Active profile shown beside the human session title. */
|
|
70
73
|
profile?: string;
|
|
71
74
|
/** Dim inline suggestion shown after the cursor when the draft is empty. */
|
|
72
75
|
ghost?: string;
|
|
@@ -105,9 +108,10 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
|
|
|
105
108
|
if (lastFrame?.key === key && lastFrame.border === border && lastFrame.dim === options.dim && sameLines(lastFrame.lines, lines)) return lastFrame.framed.slice();
|
|
106
109
|
const bottom = bottomBorderIndex(lines, options.visibleRows);
|
|
107
110
|
const inner = width - 2;
|
|
111
|
+
const prefix = icon ? `${icon} · ` : "";
|
|
108
112
|
const meta = profile !== undefined
|
|
109
|
-
? `${
|
|
110
|
-
: session ? `${
|
|
113
|
+
? `${prefix}(${profile})${session ? " " + cleanText(session, 120) : ""}`
|
|
114
|
+
: session ? `${prefix}session ${session}` : icon;
|
|
111
115
|
const title = truncateToWidth(` ${meta} `, width - 3);
|
|
112
116
|
const paintedTitle = options.paintedIcon && title.includes(icon) ? title.replace(icon, options.paintedIcon) : title;
|
|
113
117
|
const above = overflowLabel(lines[0]);
|
|
@@ -125,7 +129,7 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
|
|
|
125
129
|
const fit = (text: string) => fittedLines.get(text, (value) => truncateToWidth(value, width));
|
|
126
130
|
const content = lines.slice(1, bottom).map((line, index) => {
|
|
127
131
|
let row = line;
|
|
128
|
-
if (index === 0 && options.ghost && row
|
|
132
|
+
if (index === 0 && options.ghost && hasCursor(row)) {
|
|
129
133
|
const base = row.replace(/ +$/, "");
|
|
130
134
|
const room = inner - visibleWidth(base);
|
|
131
135
|
// ascii's tab "icon" is already the word.
|
|
@@ -143,11 +147,11 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
|
|
|
143
147
|
|
|
144
148
|
export interface ComposerDecor {
|
|
145
149
|
/** Title icon width source (plain text). */
|
|
146
|
-
icon(): string;
|
|
150
|
+
icon(width: number): string;
|
|
147
151
|
/** Painted icon, or undefined to use theme border colors. */
|
|
148
|
-
paintedIcon(): string | undefined;
|
|
149
|
-
/**
|
|
150
|
-
|
|
152
|
+
paintedIcon(width: number): string | undefined;
|
|
153
|
+
/** Pixel sprite (or compact crown) above the frame, omitted when there is no room. */
|
|
154
|
+
perch(width: number): readonly string[] | undefined;
|
|
151
155
|
session(): string;
|
|
152
156
|
profile?(): string;
|
|
153
157
|
ghost(): string | undefined;
|
|
@@ -177,13 +181,13 @@ class RoundedEditor extends CustomEditor {
|
|
|
177
181
|
}
|
|
178
182
|
|
|
179
183
|
/** Let drafts grow to ~60% of the terminal (Pi's editor caps at 30%) before scrolling. */
|
|
180
|
-
private renderTall(width: number): string[] {
|
|
184
|
+
private renderTall(width: number, perchRows: number): string[] {
|
|
181
185
|
const self = this as unknown as { tui?: { terminal?: { rows?: number } } };
|
|
182
186
|
const tui = self.tui;
|
|
183
187
|
const rows = tui?.terminal?.rows;
|
|
184
188
|
if (!tui || !rows || !Number.isFinite(rows)) return super.render(width);
|
|
185
189
|
const tall = new Proxy(tui, { get: (target, key, receiver) => key === "terminal"
|
|
186
|
-
? new Proxy(target.terminal!, { get: (term, name) => name === "rows" ? rows * 2 : Reflect.get(term, name, term) })
|
|
190
|
+
? new Proxy(target.terminal!, { get: (term, name) => name === "rows" ? Math.max(rows, (rows - (perchRows > 1 ? perchRows : 0)) * 2) : Reflect.get(term, name, term) })
|
|
187
191
|
: Reflect.get(target, key, receiver) });
|
|
188
192
|
try {
|
|
189
193
|
self.tui = tall;
|
|
@@ -194,26 +198,25 @@ class RoundedEditor extends CustomEditor {
|
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
override render(width: number): string[] {
|
|
197
|
-
const
|
|
201
|
+
const perch = width >= 8 ? this.decor.perch(width) ?? [] : [];
|
|
202
|
+
this.tipRows = perch.length;
|
|
203
|
+
const inner = this.renderTall(width < 8 ? width : width - 2, perch.length);
|
|
198
204
|
const text = this.getText();
|
|
199
205
|
const empty = text === "";
|
|
200
206
|
const below = this.decor.attachments(text);
|
|
201
207
|
const ghost = empty && !this.isShowingAutocomplete() ? this.decor.ghost() : undefined;
|
|
202
208
|
this.ghostShown = !!ghost;
|
|
203
209
|
const visibleRows = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
|
|
204
|
-
const icon = this.decor.icon();
|
|
210
|
+
const icon = this.decor.icon(width);
|
|
205
211
|
const session = this.decor.session();
|
|
206
212
|
const hint = this.decor.hint();
|
|
207
|
-
const paintedIcon = this.decor.paintedIcon();
|
|
213
|
+
const paintedIcon = this.decor.paintedIcon(width);
|
|
208
214
|
const framed = roundedInput(inner, width, this.focused, this.colors, this.paint, icon, session, {
|
|
209
215
|
...(ghost ? { ghost } : {}), ...(hint ? { hint } : {}), ...(paintedIcon ? { paintedIcon } : {}),
|
|
210
216
|
...(visibleRows != null ? { visibleRows } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
|
|
211
217
|
...(below ? { below } : {})
|
|
212
218
|
});
|
|
213
|
-
|
|
214
|
-
this.tipRows = tip ? 1 : 0;
|
|
215
|
-
// The tip is a few painted cells; measuring (cached) skips re-segmenting it on every frame.
|
|
216
|
-
return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
|
|
219
|
+
return [...perch.map(row => visibleWidth(row) <= width ? row : truncateToWidth(row, width)), ...framed];
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
override handleInput(data: string): void {
|
|
@@ -227,7 +230,9 @@ class RoundedEditor extends CustomEditor {
|
|
|
227
230
|
|
|
228
231
|
override handleMouse(event: MouseEvent) {
|
|
229
232
|
const y = event.y - this.tipRows;
|
|
230
|
-
|
|
233
|
+
const inPet = y < 0 ? event.x >= 3 && event.x < 3 + (this.tipRows > 1 ? MASCOT_SPRITE_WIDTH : MASCOT_FACE_WIDTH)
|
|
234
|
+
: y === 0 && this.decor.icon(event.width).startsWith("▐") && faceColumns(event.x);
|
|
235
|
+
if (inPet) {
|
|
231
236
|
if (event.type === "click" && event.button === "left") { this.decor.poke(); return { handled: true, focus: true }; }
|
|
232
237
|
return undefined;
|
|
233
238
|
}
|
|
@@ -275,16 +280,16 @@ class ThemedEditor implements EditorComponent {
|
|
|
275
280
|
const text = this.base.getText();
|
|
276
281
|
const ghost = text === "" ? this.decor.ghost() : undefined;
|
|
277
282
|
const below = this.decor.attachments(text);
|
|
278
|
-
const icon = this.decor.icon();
|
|
283
|
+
const icon = this.decor.icon(width);
|
|
279
284
|
const session = this.decor.session();
|
|
280
|
-
const paintedIcon = this.decor.paintedIcon();
|
|
285
|
+
const paintedIcon = this.decor.paintedIcon(width);
|
|
281
286
|
const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, icon, session, {
|
|
282
287
|
...(ghost ? { ghost } : {}), ...(paintedIcon ? { paintedIcon } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
|
|
283
288
|
...(below ? { below } : {})
|
|
284
289
|
});
|
|
285
|
-
const
|
|
286
|
-
this.tipRows =
|
|
287
|
-
return
|
|
290
|
+
const perch = width >= 8 ? this.decor.perch(width) ?? [] : [];
|
|
291
|
+
this.tipRows = perch.length;
|
|
292
|
+
return [...perch.map(row => visibleWidth(row) <= width ? row : truncateToWidth(row, width)), ...framed];
|
|
288
293
|
}
|
|
289
294
|
addToHistory(text: string) { this.base.addToHistory?.(text); }
|
|
290
295
|
insertTextAtCursor(text: string) { this.base.insertTextAtCursor?.(text); }
|
|
@@ -294,8 +299,13 @@ class ThemedEditor implements EditorComponent {
|
|
|
294
299
|
setAutocompleteMaxVisible(value: number) { this.base.setAutocompleteMaxVisible?.(value); }
|
|
295
300
|
handleMouse(event: MouseEvent) {
|
|
296
301
|
const y = event.y - this.tipRows;
|
|
302
|
+
if ((y < 0 && event.x >= 3 && event.x < 3 + (this.tipRows > 1 ? MASCOT_SPRITE_WIDTH : MASCOT_FACE_WIDTH))
|
|
303
|
+
|| (y === 0 && this.decor.icon(event.width).startsWith("▐") && faceColumns(event.x))) {
|
|
304
|
+
if (event.type === "click" && event.button === "left") { this.decor.poke(); return { handled: true, focus: true }; }
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
297
307
|
if (y < 0) return undefined;
|
|
298
|
-
return this.base.handleMouse?.({ ...event, y, x: event.x - 1, width: Math.max(1, event.width - 2) });
|
|
308
|
+
return this.base.handleMouse?.({ ...event, y, x: event.x - 1, width: Math.max(1, event.width - 2), height: Math.max(1, event.height - this.tipRows) });
|
|
299
309
|
}
|
|
300
310
|
}
|
|
301
311
|
|
|
@@ -306,7 +316,6 @@ export class ComposerStyle {
|
|
|
306
316
|
private tui?: TUI;
|
|
307
317
|
private timer?: ReturnType<typeof setTimeout>;
|
|
308
318
|
private phase: WorkingPhase = "idle";
|
|
309
|
-
private frame = 0;
|
|
310
319
|
private animations = true;
|
|
311
320
|
private mascotOn = true;
|
|
312
321
|
private session = "";
|
|
@@ -321,18 +330,23 @@ export class ComposerStyle {
|
|
|
321
330
|
private chips?: { text: string; cwd: string; at: number; chips: string[] };
|
|
322
331
|
|
|
323
332
|
private readonly decor: ComposerDecor = {
|
|
324
|
-
icon: () => this.mascotOn ?
|
|
325
|
-
|
|
333
|
+
icon: (width) => !this.mascotOn ? "pi" : this.fullSprite(width) ? ""
|
|
334
|
+
: mascotFace(this.animations ? this.mascot.mood() : "idle"),
|
|
335
|
+
paintedIcon: (width) => {
|
|
326
336
|
if (!this.mascotOn) return undefined;
|
|
337
|
+
if (this.fullSprite(width)) return undefined;
|
|
327
338
|
const mood = this.animations ? this.mascot.mood() : "idle";
|
|
328
339
|
return paintFace(mascotFace(mood), mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
|
|
329
340
|
},
|
|
330
|
-
|
|
341
|
+
perch: (width) => {
|
|
331
342
|
if (!this.mascotOn || width < 40) return undefined;
|
|
332
343
|
const mood = this.animations ? this.mascot.mood() : "idle";
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
344
|
+
const frame = this.animations ? Math.floor(Date.now() / COMPOSER_ACTIVE_INTERVAL_MS) : 0;
|
|
345
|
+
if (this.fullSprite(width)) {
|
|
346
|
+
return mascotPerch(mood, frame, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined)
|
|
347
|
+
.map(row => " " + row);
|
|
348
|
+
}
|
|
349
|
+
return [" " + paintTip(mascotTip(mood, frame), mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined)];
|
|
336
350
|
},
|
|
337
351
|
session: () => this.session,
|
|
338
352
|
profile: () => this.profile,
|
|
@@ -360,6 +374,11 @@ export class ComposerStyle {
|
|
|
360
374
|
}
|
|
361
375
|
};
|
|
362
376
|
|
|
377
|
+
/** Keep short/narrow terminals usable; the pet and mood spark reserve six rows. */
|
|
378
|
+
private fullSprite(width: number): boolean {
|
|
379
|
+
return width >= 60 && (this.tui?.terminal?.rows ?? process.stdout.rows ?? 24) >= 24;
|
|
380
|
+
}
|
|
381
|
+
|
|
363
382
|
/** Share the suggestion state rendered as ghost text. */
|
|
364
383
|
attachSuggestions(state: SuggestionState): void {
|
|
365
384
|
this.unsubscribe?.();
|
|
@@ -395,7 +414,6 @@ export class ComposerStyle {
|
|
|
395
414
|
if (!changed) return;
|
|
396
415
|
this.phase = phase;
|
|
397
416
|
this.animations = animations;
|
|
398
|
-
this.frame = 0;
|
|
399
417
|
this.restartTimer();
|
|
400
418
|
if (this.enabled) this.tui?.requestRender();
|
|
401
419
|
}
|
|
@@ -403,12 +421,12 @@ export class ComposerStyle {
|
|
|
403
421
|
/**
|
|
404
422
|
* Pi's own streaming/tool updates already repaint active sessions. Never add another active
|
|
405
423
|
* animation loop: active frames derive from wall time when Pi naturally renders. Idle only
|
|
406
|
-
* schedules exact blink/sleep
|
|
424
|
+
* schedules exact blink/sleep transitions and bounded reaction frames until a flash expires.
|
|
407
425
|
*/
|
|
408
426
|
private restartTimer(): void {
|
|
409
427
|
this.stopTimer();
|
|
410
428
|
if (!this.enabled || !this.animations || !this.mascotOn) return;
|
|
411
|
-
this.lastKey = this.mascot.key(
|
|
429
|
+
this.lastKey = this.mascot.key();
|
|
412
430
|
this.scheduleTimer();
|
|
413
431
|
}
|
|
414
432
|
private scheduleTimer(): void {
|
|
@@ -417,8 +435,7 @@ export class ComposerStyle {
|
|
|
417
435
|
if (delay === undefined) return;
|
|
418
436
|
this.timer = setTimeout(() => {
|
|
419
437
|
this.timer = undefined;
|
|
420
|
-
this.
|
|
421
|
-
const key = this.mascot.key(Date.now(), this.frame);
|
|
438
|
+
const key = this.mascot.key();
|
|
422
439
|
if (key !== this.lastKey) {
|
|
423
440
|
this.lastKey = key;
|
|
424
441
|
this.tui?.requestRender();
|
|
@@ -468,7 +485,6 @@ export class ComposerStyle {
|
|
|
468
485
|
this.stopTimer();
|
|
469
486
|
this.tui = undefined;
|
|
470
487
|
this.phase = "idle";
|
|
471
|
-
this.frame = 0;
|
|
472
488
|
this.session = "";
|
|
473
489
|
try {
|
|
474
490
|
if (ctx.hasUI && ctx.mode === "tui" && this.owner && ctx.ui.getEditorComponent() === this.owner) {
|
package/src/delegate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { signalProcessTree } from "./async-process.ts";
|
|
3
|
-
import { CHILD_TOOLS_ENV, EMPTY_SUBAGENT_CONFIG, parseRules, RECURSIVE_TOOLS, SHELL_TOOLS, SUBAGENT_CONFIG_FILE, WEB_TOOLS, type RuleList, type SubagentConfig } from "./subagent-tools.ts";
|
|
3
|
+
import { CHILD_SKILL_READS_ENV, CHILD_TOOLS_ENV, skillReadRoots, EMPTY_SUBAGENT_CONFIG, parseRules, RECURSIVE_TOOLS, SHELL_TOOLS, SUBAGENT_CONFIG_FILE, WEB_TOOLS, type RuleList, type SubagentConfig } from "./subagent-tools.ts";
|
|
4
4
|
import { planExtensions, planSkills, type ExtensionPlan, type ResourceCatalog } from "./subagent-resources.ts";
|
|
5
5
|
import { existsSync, mkdtempSync, realpathSync } from "node:fs";
|
|
6
6
|
import { rm } from "node:fs/promises";
|
|
@@ -710,7 +710,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
710
710
|
let child: ChildProcess;
|
|
711
711
|
try {
|
|
712
712
|
const env = { ...process.env };
|
|
713
|
-
for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, CHILD_TOOLS_ENV, ...DISCUSSION_ENV_KEYS]) delete env[key];
|
|
713
|
+
for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, CHILD_TOOLS_ENV, CHILD_SKILL_READS_ENV, ...DISCUSSION_ENV_KEYS]) delete env[key];
|
|
714
714
|
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env?.(), [CHILD_ENV]: "1" } });
|
|
715
715
|
} catch (error) {
|
|
716
716
|
finish("failed", error instanceof Error ? error.message : String(error));
|
|
@@ -1387,12 +1387,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1387
1387
|
pi.on?.("before_agent_start", (_event, ctx) => {
|
|
1388
1388
|
controllerContext = ctx;
|
|
1389
1389
|
const retained = registry.records().filter((record) => isRetained(record.run.state));
|
|
1390
|
-
|
|
1390
|
+
// Give routing guidance before the first delegation, not only after a fleet exists.
|
|
1391
|
+
// Stable system text avoids accumulating another hidden transcript message on every prompt.
|
|
1392
|
+
const systemPrompt = _event.systemPrompt + "\n\n" + [
|
|
1393
|
+
"[PI-JAR DELEGATION]",
|
|
1394
|
+
"For substantial work, identify independent, bounded tasks before starting tool-heavy implementation. Use jar_delegate when parallel ownership saves work; do small, tightly coupled tasks directly. Do not invent a tool-call count or delegate merely to fill the pool.",
|
|
1395
|
+
"Use scout for fresh research, external documentation and cross-repository discovery; fork for read-only review with conversation context; worktree only for isolated implementation. Worktrees cannot use shells or arbitrary paths outside their workspace. Research inaccessible references with a scout or the main agent, then send relevant findings to the worker.",
|
|
1396
|
+
"Grant needed active tools and skills per task, respecting user allowlists/disallows. Reuse retained agents, avoid duplicate work and polling, and stop worktree agents to reconcile changes before verifying them."
|
|
1397
|
+
].join("\n");
|
|
1398
|
+
if (!retained.length) return { systemPrompt };
|
|
1391
1399
|
const fleet = retained.map((record) => {
|
|
1392
1400
|
const unread = discussionUnread(record.key);
|
|
1393
1401
|
return `- ${record.key} · ${record.run.name} · ${record.run.state}${unread ? ` · ${plural(unread, "unread discussion message")}` : ""} · ${record.run.role}: ${cleanText(record.run.task, 140)}`;
|
|
1394
1402
|
}).join("\n");
|
|
1395
|
-
return { message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
1403
|
+
return { systemPrompt, message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
1396
1404
|
"[PI-JAR MODERATOR MODE]",
|
|
1397
1405
|
"Retained subagents own their delegated tasks: don't duplicate their work. Their turn ends and ask/pause/stop results arrive as pi-jar.subagent events; an accepted receipt is not completion, so keep working and never poll peek.",
|
|
1398
1406
|
"Hibernated agents are idle with the process closed; resume and ask relaunch them intact. Stop finished workers to reconcile worktree changes into /diff, then synthesize from their reports.",
|
|
@@ -1491,7 +1499,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1491
1499
|
description: `Start retained subagents and return immediately; the pool limit counts idle, paused and hibernated agents. fork and worktree inherit this conversation; a worktree's isolated Git changes reach /diff only when you stop it, and at most ${RECOVERY_LIMIT} worktree workspaces may be active or awaiting recovery (resolve with jar_subagent stop or discard). Turn completions arrive as pi-jar.subagent events with a short summary and changed files; jar_subagent report returns a full report. Settled agents hibernate (process closed, context kept) until resumed.`,
|
|
1492
1500
|
promptSnippet: "Start retained parallel subagents: scout (cheap read-only discovery), fork (read-only review with your context), worktree (isolated implementation).",
|
|
1493
1501
|
promptGuidelines: [
|
|
1494
|
-
"Delegate
|
|
1502
|
+
"Delegate substantial independent tasks when parallel ownership saves work; handle small or tightly coupled tasks directly. Choose scout for external research, fork for contextual read-only review, and worktree for isolated edits; worktree agents cannot use shells or arbitrary external paths.",
|
|
1495
1503
|
"After delegating, moderate: completions arrive as pi-jar.subagent events, so keep working and never poll; don't redo delegated work; reuse retained agents (jar_subagent resume) instead of spawning; stop finished workers to apply worktree changes into /diff."
|
|
1496
1504
|
],
|
|
1497
1505
|
parameters: Parameters,
|
|
@@ -1564,7 +1572,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1564
1572
|
let extensionPlan: ExtensionPlan | undefined;
|
|
1565
1573
|
const skillPlans: Array<readonly string[] | undefined> = requested.map(() => undefined);
|
|
1566
1574
|
const ruled = (rules: RuleList) => rules.allow.length > 0 || rules.deny.length > 0;
|
|
1567
|
-
if (ruled(config.extensions) || ruled(config.skills) || skillRules.some(Boolean)
|
|
1575
|
+
if (ruled(config.extensions) || ruled(config.skills) || skillRules.some(Boolean)
|
|
1576
|
+
|| (options.loadResources && requested.some(item => (item.mode ?? params.mode) === "worktree"))) {
|
|
1568
1577
|
if (!options.loadResources) {
|
|
1569
1578
|
return { content: [{ type: "text", text: "This Pi host cannot resolve extensions/skills for subagents; remove extension/skill rules." }], details: { runs: [], write: false }, isError: true };
|
|
1570
1579
|
}
|
|
@@ -1576,7 +1585,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1576
1585
|
return { content: [{ type: "text", text: `Cannot plan subagent extensions: ${(error as Error).message}` }], details: { runs: [], write: false }, isError: true };
|
|
1577
1586
|
}
|
|
1578
1587
|
for (const [index, rules] of skillRules.entries()) {
|
|
1579
|
-
try {
|
|
1588
|
+
try {
|
|
1589
|
+
skillPlans[index] = planSkills(config.skills, rules, catalog, SUBAGENT_CONFIG_FILE);
|
|
1590
|
+
if ((requested[index]!.mode ?? params.mode) === "worktree" && skillPlans[index] === undefined)
|
|
1591
|
+
skillPlans[index] = catalog.skills.filter(resource => resource.enabled).map(resource => resource.path);
|
|
1592
|
+
}
|
|
1580
1593
|
catch (error) {
|
|
1581
1594
|
return { content: [{ type: "text", text: `Task ${index + 1}: ${(error as Error).message}` }], details: { runs: [], write: false }, isError: true };
|
|
1582
1595
|
}
|
|
@@ -1751,6 +1764,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1751
1764
|
run.workspace = worktree.root;
|
|
1752
1765
|
cwd = worktree.cwd;
|
|
1753
1766
|
env[CHILD_WORKTREE_ENV] = worktree.root;
|
|
1767
|
+
env[CHILD_SKILL_READS_ENV] = JSON.stringify(skillReadRoots(skillPlans[run.index - 1] ?? []));
|
|
1754
1768
|
} catch (error) {
|
|
1755
1769
|
await cleanupPrivate(run.index, true);
|
|
1756
1770
|
if (discarded.has(index) || stopRequested.has(index)) markStartStopped(run);
|
package/src/diff-inline.ts
CHANGED
|
@@ -61,20 +61,28 @@ const INVERSE_ON = "\x1b[7m";
|
|
|
61
61
|
const INVERSE_OFF = "\x1b[27m";
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* Insert
|
|
64
|
+
* Insert emphasis markers around `spans` of the visible text inside an SGR-styled string.
|
|
65
|
+
* Defaults to inverse video; background markers can restore the surrounding line tint on exit.
|
|
65
66
|
* Offsets count visible code units, so styling (syntax colours) never shifts the emphasis.
|
|
66
67
|
*/
|
|
67
|
-
export function emphasize(styled: string, spans: readonly Span[]): string {
|
|
68
|
+
export function emphasize(styled: string, spans: readonly Span[], markers = { on: INVERSE_ON, off: INVERSE_OFF }): string {
|
|
68
69
|
if (!spans.length) return styled;
|
|
69
70
|
let out = "", visible = 0, span = 0, open = false;
|
|
70
71
|
for (let at = 0; at < styled.length;) {
|
|
71
72
|
SGR.lastIndex = at;
|
|
72
73
|
const escape = SGR.exec(styled);
|
|
73
|
-
if (escape) {
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
if (escape) {
|
|
75
|
+
out += escape[0];
|
|
76
|
+
// Syntax highlighters reset SGR between tokens; keep the changed span visible across them.
|
|
77
|
+
if (open && (escape[0] === "\x1b[0m" || escape[0] === "\x1b[m"
|
|
78
|
+
|| escape[0] === (markers.on === INVERSE_ON ? INVERSE_OFF : "\x1b[49m"))) out += markers.on;
|
|
79
|
+
at += escape[0].length;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
while (span < spans.length && open && visible >= spans[span]![1]) { out += markers.off; open = false; span++; }
|
|
83
|
+
if (span < spans.length && !open && visible >= spans[span]![0]) { out += markers.on; open = true; }
|
|
76
84
|
out += styled[at++];
|
|
77
85
|
visible++;
|
|
78
86
|
}
|
|
79
|
-
return open ? out +
|
|
87
|
+
return open ? out + markers.off : out;
|
|
80
88
|
}
|
package/src/diff-view.ts
CHANGED
|
@@ -57,6 +57,23 @@ export interface DiffPaint {
|
|
|
57
57
|
syntax?: (text: string) => string | undefined;
|
|
58
58
|
/** SGR background sequence that tints a changed line's text. */
|
|
59
59
|
tint?: (kind: "add" | "remove") => string;
|
|
60
|
+
/** Stronger SGR background for changed words; absent hooks keep inverse-video emphasis. */
|
|
61
|
+
strongTint?: (kind: "add" | "remove") => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** OMP-style soft/strong backgrounds, derived from native theme truecolor sequences when available. */
|
|
65
|
+
export function diffBackgrounds(canvas: string, added: string, removed: string): Pick<DiffPaint, "tint" | "strongTint"> | undefined {
|
|
66
|
+
const rgb = (ansi: string) => {
|
|
67
|
+
const match = /\x1b\[(?:38|48);2;(\d+);(\d+);(\d+)m/.exec(ansi);
|
|
68
|
+
return match ? match.slice(1).map(Number) : undefined;
|
|
69
|
+
};
|
|
70
|
+
const base = rgb(canvas), add = rgb(added), remove = rgb(removed);
|
|
71
|
+
if (!base || !add || !remove || [...base, ...add, ...remove].some(value => value > 255)) return undefined;
|
|
72
|
+
const light = base[0]! * 0.2126 + base[1]! * 0.7152 + base[2]! * 0.0722 > 127;
|
|
73
|
+
const mix = (color: number[], amount: number) => `\x1b[48;2;${base.map((value, index) => Math.round(value + (color[index]! - value) * amount)).join(";")}m`;
|
|
74
|
+
const soft = { add: mix(add, light ? 0.24 : 0.18), remove: mix(remove, light ? 0.24 : 0.18) };
|
|
75
|
+
const strong = { add: mix(add, light ? 0.48 : 0.42), remove: mix(remove, light ? 0.48 : 0.42) };
|
|
76
|
+
return { tint: kind => soft[kind], strongTint: kind => strong[kind] };
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
/** Context sizes cycled by ←/→ (and [/]); Infinity shows whole files. */
|
|
@@ -93,7 +110,7 @@ export function renderDiffWindow(change: FileChange, width: number, fg: (color:
|
|
|
93
110
|
const rows = Math.max(1, Math.min(options.rows ?? PREVIEW_WINDOW, PREVIEW_WINDOW));
|
|
94
111
|
const scroll = Math.max(0, options.scroll ?? 0);
|
|
95
112
|
const context = options.context ?? 3;
|
|
96
|
-
const unified = options.unified
|
|
113
|
+
const unified = options.unified === true || width < MIN_SPLIT_WIDTH;
|
|
97
114
|
const wrap = options.wrap === true;
|
|
98
115
|
const paint = options.paint ?? {};
|
|
99
116
|
const total = unified ? diffRowCount(ops, context) : diffSplitRowCount(ops, context);
|
|
@@ -113,10 +130,12 @@ export function renderDiffWindow(change: FileChange, width: number, fg: (color:
|
|
|
113
130
|
const styled = paint.syntax?.(display) ?? fg(KIND_COLOR[kind], display);
|
|
114
131
|
if (bounded || kind === "context" || pair === undefined) return styled;
|
|
115
132
|
const changes = kind === "remove" ? inlineChanges(display, safeLine(pair)) : inlineChanges(safeLine(pair), display);
|
|
116
|
-
|
|
133
|
+
const strong = paint.strongTint?.(kind);
|
|
134
|
+
return changes ? emphasize(styled, kind === "remove" ? changes.before : changes.after,
|
|
135
|
+
strong ? { on: strong, off: paint.tint?.(kind) ?? "\x1b[49m" } : undefined) : styled;
|
|
117
136
|
};
|
|
118
137
|
// Rail, numbers and sign, then text truncated or wrapped into the cell; continuation lines keep
|
|
119
|
-
// the rail only.
|
|
138
|
+
// the rail only. Syntax/wrapping/truncation resets re-apply the surrounding soft tint.
|
|
120
139
|
const cell = (kind: Kind, lead: string, text: string, width: number, pair?: string): string[] => {
|
|
121
140
|
const leadWidth = visibleWidth(lead);
|
|
122
141
|
const textWidth = Math.max(1, width - leadWidth - 2);
|
|
@@ -126,7 +145,7 @@ export function renderDiffWindow(change: FileChange, width: number, fg: (color:
|
|
|
126
145
|
return (wrap ? wrapTextWithAnsi(styled, textWidth) : [truncateToWidth(styled, textWidth, "…")]).map((body, index) => {
|
|
127
146
|
const prefix = index ? " ".repeat(leadWidth + 1) : fg("dim", lead) + fg(KIND_COLOR[kind], SIGN[kind]);
|
|
128
147
|
const content = body + " ".repeat(Math.max(0, textWidth - visibleWidth(body)));
|
|
129
|
-
return rail + prefix + (tint ? tint + content.
|
|
148
|
+
return rail + prefix + (tint ? tint + content.replace(/\x1b\[(?:0|49)?m/g, reset => reset + tint) : content) + RESET;
|
|
130
149
|
});
|
|
131
150
|
};
|
|
132
151
|
const hunkLine = (text: string, hidden: number | undefined) => truncateToWidth(
|
|
@@ -149,7 +168,7 @@ export function renderDiffWindow(change: FileChange, width: number, fg: (color:
|
|
|
149
168
|
: "Coarse preview: replacement groups are approximate."));
|
|
150
169
|
if (!unified) {
|
|
151
170
|
const column = Math.max(8, Math.floor((width - 3) / 2));
|
|
152
|
-
lines.push(truncateToWidth(fg("accent", "
|
|
171
|
+
lines.push(truncateToWidth(fg("accent", "Before"), column, "…", true) + fg("dim", " │ ") + fg("accent", "After"));
|
|
153
172
|
}
|
|
154
173
|
// Fill the viewport with whole logical rows; a first row taller than the viewport is clipped.
|
|
155
174
|
let used = 0, shown = 0;
|
|
@@ -180,7 +199,7 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
180
199
|
let query = "";
|
|
181
200
|
let searchMode = false;
|
|
182
201
|
let fullReview = false;
|
|
183
|
-
let unified =
|
|
202
|
+
let unified = false;
|
|
184
203
|
let contextIndex = CONTEXT_STEPS.indexOf(3);
|
|
185
204
|
let wrap = false;
|
|
186
205
|
let bodyRows = 0;
|
|
@@ -216,7 +235,11 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
216
235
|
const paintFor = (change: FileChange): DiffPaint => {
|
|
217
236
|
if (paintCache?.path !== change.path) {
|
|
218
237
|
const language = getLanguageFromPath(change.path);
|
|
219
|
-
const
|
|
238
|
+
const native = theme as { getBgAnsi?: (color: "toolPendingBg" | "toolSuccessBg" | "toolErrorBg") => string;
|
|
239
|
+
getFgAnsi?: (color: "toolDiffAdded" | "toolDiffRemoved") => string };
|
|
240
|
+
const bg = native.getBgAnsi?.bind(theme);
|
|
241
|
+
const foreground = native.getFgAnsi?.bind(theme);
|
|
242
|
+
const backgrounds = bg && foreground ? diffBackgrounds(bg("toolPendingBg"), foreground("toolDiffAdded"), foreground("toolDiffRemoved")) : undefined;
|
|
220
243
|
paintCache = { path: change.path, paint: {
|
|
221
244
|
// Per-line highlighting is bounded; very long lines, or a host without Pi's highlighter
|
|
222
245
|
// theme initialised, keep plain diff colours instead of failing the review.
|
|
@@ -224,7 +247,7 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
224
247
|
if (text.length > 1000) return undefined;
|
|
225
248
|
try { return highlightCode(text, language)[0]; } catch { return undefined; }
|
|
226
249
|
} } : {}),
|
|
227
|
-
...(bg ? { tint: (kind: "add" | "remove") => bg(kind === "add" ? "toolSuccessBg" : "toolErrorBg") } : {})
|
|
250
|
+
...(backgrounds ?? (bg ? { tint: (kind: "add" | "remove") => bg(kind === "add" ? "toolSuccessBg" : "toolErrorBg") } : {}))
|
|
228
251
|
} };
|
|
229
252
|
}
|
|
230
253
|
return paintCache.paint;
|
|
@@ -378,7 +401,7 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
378
401
|
...optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.key)),
|
|
379
402
|
fg(searchMode ? "accent" : "dim", searchMode ? `/${query} · Enter apply · Esc clear`
|
|
380
403
|
: query ? `/${query} · n/N next match · / new search`
|
|
381
|
-
: `${SEARCH_HINT} · n/p hunk · t split · ←/→ context (${contextLabel()}) · w wrap${wrap ? " on" : ""} · v full`),
|
|
404
|
+
: `${SEARCH_HINT} · n/p hunk · t ${unified ? "split" : "unified"} · ←/→ context (${contextLabel()}) · w wrap${wrap ? " on" : ""} · v full`),
|
|
382
405
|
message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn/Home/End scroll · a accept · r revert · Esc close")
|
|
383
406
|
];
|
|
384
407
|
const split = splitFrame(theme, width, title, list, content.slice(0, bodyRows), footer, bodyRows, listWidth);
|
package/src/footer.ts
CHANGED
|
@@ -18,8 +18,6 @@ export interface FooterView extends JarStatus {
|
|
|
18
18
|
cwd?: string;
|
|
19
19
|
settings?: FooterSettings;
|
|
20
20
|
context: string;
|
|
21
|
-
/** The last call's prompt is past the context budget: the context metric turns amber. */
|
|
22
|
-
overBudget?: boolean;
|
|
23
21
|
goal?: string;
|
|
24
22
|
/** pi-jar's own short indicators (changes to review), shown before extras. */
|
|
25
23
|
chips?: readonly string[];
|
|
@@ -153,8 +151,6 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
|
|
|
153
151
|
// Context is never dropped; optional metrics move to a second line on medium widths.
|
|
154
152
|
const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && memory ? [memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
|
|
155
153
|
let right = theme.fg("dim", metrics.join(" · "));
|
|
156
|
-
// Context always leads the metrics when shown, so only its own color changes past the budget.
|
|
157
|
-
if (view.overBudget && show.context) right = theme.fg("warning", context) + (metrics.length > 1 ? theme.fg("dim", " · " + metrics.slice(1).join(" · ")) : "");
|
|
158
154
|
if (contentWidth < 52 && attention && contentWidth >= 26) {
|
|
159
155
|
const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
|
|
160
156
|
right = theme.fg(primary?.state === "failed" ? "error" : "warning", truncateToWidth(attention, Math.min(room, 14))) + " " + right;
|