pi-jar 0.2.9 → 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.
@@ -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 { isReadStub, readRangeKey, resolveToolPath, type ReadStubDetails } from "./context-diet.ts";
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 = 600;
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. */
@@ -69,7 +69,7 @@ function bottomBorderIndex(lines: readonly string[], visibleRows?: number): numb
69
69
  const overflowLabel = (line: string | undefined) => /[↑↓]\s*\d+\s*more/.exec(stripTerminalSequences(line ?? ""))?.[0];
70
70
 
71
71
  export interface RoundedInputOptions {
72
- /** Active profile shown between the mascot and human session title. */
72
+ /** Active profile shown beside the human session title. */
73
73
  profile?: string;
74
74
  /** Dim inline suggestion shown after the cursor when the draft is empty. */
75
75
  ghost?: string;
@@ -108,9 +108,10 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
108
108
  if (lastFrame?.key === key && lastFrame.border === border && lastFrame.dim === options.dim && sameLines(lastFrame.lines, lines)) return lastFrame.framed.slice();
109
109
  const bottom = bottomBorderIndex(lines, options.visibleRows);
110
110
  const inner = width - 2;
111
+ const prefix = icon ? `${icon} · ` : "";
111
112
  const meta = profile !== undefined
112
- ? `${icon} · (${profile})${session ? " " + cleanText(session, 120) : ""}`
113
- : session ? `${icon} · session ${session}` : icon;
113
+ ? `${prefix}(${profile})${session ? " " + cleanText(session, 120) : ""}`
114
+ : session ? `${prefix}session ${session}` : icon;
114
115
  const title = truncateToWidth(` ${meta} `, width - 3);
115
116
  const paintedTitle = options.paintedIcon && title.includes(icon) ? title.replace(icon, options.paintedIcon) : title;
116
117
  const above = overflowLabel(lines[0]);
@@ -146,11 +147,11 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
146
147
 
147
148
  export interface ComposerDecor {
148
149
  /** Title icon width source (plain text). */
149
- icon(): string;
150
+ icon(width: number): string;
150
151
  /** Painted icon, or undefined to use theme border colors. */
151
- paintedIcon(): string | undefined;
152
- /** Flame-tip row above the frame, or undefined when there is no room. */
153
- tip(width: number): string | undefined;
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;
154
155
  session(): string;
155
156
  profile?(): string;
156
157
  ghost(): string | undefined;
@@ -180,13 +181,13 @@ class RoundedEditor extends CustomEditor {
180
181
  }
181
182
 
182
183
  /** Let drafts grow to ~60% of the terminal (Pi's editor caps at 30%) before scrolling. */
183
- private renderTall(width: number): string[] {
184
+ private renderTall(width: number, perchRows: number): string[] {
184
185
  const self = this as unknown as { tui?: { terminal?: { rows?: number } } };
185
186
  const tui = self.tui;
186
187
  const rows = tui?.terminal?.rows;
187
188
  if (!tui || !rows || !Number.isFinite(rows)) return super.render(width);
188
189
  const tall = new Proxy(tui, { get: (target, key, receiver) => key === "terminal"
189
- ? 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) })
190
191
  : Reflect.get(target, key, receiver) });
191
192
  try {
192
193
  self.tui = tall;
@@ -197,26 +198,25 @@ class RoundedEditor extends CustomEditor {
197
198
  }
198
199
 
199
200
  override render(width: number): string[] {
200
- const inner = this.renderTall(width < 8 ? width : width - 2);
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);
201
204
  const text = this.getText();
202
205
  const empty = text === "";
203
206
  const below = this.decor.attachments(text);
204
207
  const ghost = empty && !this.isShowingAutocomplete() ? this.decor.ghost() : undefined;
205
208
  this.ghostShown = !!ghost;
206
209
  const visibleRows = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
207
- const icon = this.decor.icon();
210
+ const icon = this.decor.icon(width);
208
211
  const session = this.decor.session();
209
212
  const hint = this.decor.hint();
210
- const paintedIcon = this.decor.paintedIcon();
213
+ const paintedIcon = this.decor.paintedIcon(width);
211
214
  const framed = roundedInput(inner, width, this.focused, this.colors, this.paint, icon, session, {
212
215
  ...(ghost ? { ghost } : {}), ...(hint ? { hint } : {}), ...(paintedIcon ? { paintedIcon } : {}),
213
216
  ...(visibleRows != null ? { visibleRows } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
214
217
  ...(below ? { below } : {})
215
218
  });
216
- const tip = width >= 8 ? this.decor.tip(width) : undefined;
217
- this.tipRows = tip ? 1 : 0;
218
- // The tip is a few painted cells; measuring (cached) skips re-segmenting it on every frame.
219
- return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
219
+ return [...perch.map(row => visibleWidth(row) <= width ? row : truncateToWidth(row, width)), ...framed];
220
220
  }
221
221
 
222
222
  override handleInput(data: string): void {
@@ -230,7 +230,9 @@ class RoundedEditor extends CustomEditor {
230
230
 
231
231
  override handleMouse(event: MouseEvent) {
232
232
  const y = event.y - this.tipRows;
233
- if ((y === 0 || y < 0) && faceColumns(event.x)) {
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) {
234
236
  if (event.type === "click" && event.button === "left") { this.decor.poke(); return { handled: true, focus: true }; }
235
237
  return undefined;
236
238
  }
@@ -278,16 +280,16 @@ class ThemedEditor implements EditorComponent {
278
280
  const text = this.base.getText();
279
281
  const ghost = text === "" ? this.decor.ghost() : undefined;
280
282
  const below = this.decor.attachments(text);
281
- const icon = this.decor.icon();
283
+ const icon = this.decor.icon(width);
282
284
  const session = this.decor.session();
283
- const paintedIcon = this.decor.paintedIcon();
285
+ const paintedIcon = this.decor.paintedIcon(width);
284
286
  const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, icon, session, {
285
287
  ...(ghost ? { ghost } : {}), ...(paintedIcon ? { paintedIcon } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
286
288
  ...(below ? { below } : {})
287
289
  });
288
- const tip = width >= 8 ? this.decor.tip(width) : undefined;
289
- this.tipRows = tip ? 1 : 0;
290
- return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
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];
291
293
  }
292
294
  addToHistory(text: string) { this.base.addToHistory?.(text); }
293
295
  insertTextAtCursor(text: string) { this.base.insertTextAtCursor?.(text); }
@@ -297,8 +299,13 @@ class ThemedEditor implements EditorComponent {
297
299
  setAutocompleteMaxVisible(value: number) { this.base.setAutocompleteMaxVisible?.(value); }
298
300
  handleMouse(event: MouseEvent) {
299
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
+ }
300
307
  if (y < 0) return undefined;
301
- 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) });
302
309
  }
303
310
  }
304
311
 
@@ -309,7 +316,6 @@ export class ComposerStyle {
309
316
  private tui?: TUI;
310
317
  private timer?: ReturnType<typeof setTimeout>;
311
318
  private phase: WorkingPhase = "idle";
312
- private frame = 0;
313
319
  private animations = true;
314
320
  private mascotOn = true;
315
321
  private session = "";
@@ -324,18 +330,23 @@ export class ComposerStyle {
324
330
  private chips?: { text: string; cwd: string; at: number; chips: string[] };
325
331
 
326
332
  private readonly decor: ComposerDecor = {
327
- icon: () => this.mascotOn ? mascotFace(this.animations ? this.mascot.mood() : "idle") : "pi",
328
- paintedIcon: () => {
333
+ icon: (width) => !this.mascotOn ? "pi" : this.fullSprite(width) ? ""
334
+ : mascotFace(this.animations ? this.mascot.mood() : "idle"),
335
+ paintedIcon: (width) => {
329
336
  if (!this.mascotOn) return undefined;
337
+ if (this.fullSprite(width)) return undefined;
330
338
  const mood = this.animations ? this.mascot.mood() : "idle";
331
339
  return paintFace(mascotFace(mood), mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
332
340
  },
333
- tip: (width) => {
341
+ perch: (width) => {
334
342
  if (!this.mascotOn || width < 40) return undefined;
335
343
  const mood = this.animations ? this.mascot.mood() : "idle";
336
- const animationFrame = this.phase === "idle" ? this.frame : Math.floor(Date.now() / COMPOSER_ACTIVE_INTERVAL_MS);
337
- const tip = this.animations ? this.mascot.tip(Date.now(), animationFrame) : this.mascot.tip(0, 0);
338
- return " ".repeat(3) + paintTip(tip, mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
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)];
339
350
  },
340
351
  session: () => this.session,
341
352
  profile: () => this.profile,
@@ -363,6 +374,11 @@ export class ComposerStyle {
363
374
  }
364
375
  };
365
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
+
366
382
  /** Share the suggestion state rendered as ghost text. */
367
383
  attachSuggestions(state: SuggestionState): void {
368
384
  this.unsubscribe?.();
@@ -398,7 +414,6 @@ export class ComposerStyle {
398
414
  if (!changed) return;
399
415
  this.phase = phase;
400
416
  this.animations = animations;
401
- this.frame = 0;
402
417
  this.restartTimer();
403
418
  if (this.enabled) this.tui?.requestRender();
404
419
  }
@@ -406,12 +421,12 @@ export class ComposerStyle {
406
421
  /**
407
422
  * Pi's own streaming/tool updates already repaint active sessions. Never add another active
408
423
  * animation loop: active frames derive from wall time when Pi naturally renders. Idle only
409
- * schedules exact blink/sleep/flash transitions.
424
+ * schedules exact blink/sleep transitions and bounded reaction frames until a flash expires.
410
425
  */
411
426
  private restartTimer(): void {
412
427
  this.stopTimer();
413
428
  if (!this.enabled || !this.animations || !this.mascotOn) return;
414
- this.lastKey = this.mascot.key(Date.now(), this.frame);
429
+ this.lastKey = this.mascot.key();
415
430
  this.scheduleTimer();
416
431
  }
417
432
  private scheduleTimer(): void {
@@ -420,8 +435,7 @@ export class ComposerStyle {
420
435
  if (delay === undefined) return;
421
436
  this.timer = setTimeout(() => {
422
437
  this.timer = undefined;
423
- this.frame++;
424
- const key = this.mascot.key(Date.now(), this.frame);
438
+ const key = this.mascot.key();
425
439
  if (key !== this.lastKey) {
426
440
  this.lastKey = key;
427
441
  this.tui?.requestRender();
@@ -471,7 +485,6 @@ export class ComposerStyle {
471
485
  this.stopTimer();
472
486
  this.tui = undefined;
473
487
  this.phase = "idle";
474
- this.frame = 0;
475
488
  this.session = "";
476
489
  try {
477
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
- if (!retained.length) return;
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 only when there are ≥2 independent chunks that each need ≥10 tool calls; otherwise do the work yourself.",
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 { skillPlans[index] = planSkills(config.skills, rules, catalog, SUBAGENT_CONFIG_FILE); }
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);
@@ -61,20 +61,28 @@ const INVERSE_ON = "\x1b[7m";
61
61
  const INVERSE_OFF = "\x1b[27m";
62
62
 
63
63
  /**
64
- * Insert inverse-video markers around `spans` of the visible text inside an SGR-styled string.
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) { out += escape[0]; at += escape[0].length; continue; }
74
- while (span < spans.length && open && visible >= spans[span]![1]) { out += INVERSE_OFF; open = false; span++; }
75
- if (span < spans.length && !open && visible >= spans[span]![0]) { out += INVERSE_ON; open = true; }
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 + INVERSE_OFF : 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 !== false;
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
- return changes ? emphasize(styled, kind === "remove" ? changes.before : changes.after) : styled;
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. Truncation emits full resets, so a tint is re-applied after each one.
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.replaceAll(RESET, RESET + tint) : content) + RESET;
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", "Original"), column, "…", true) + fg("dim", " │ ") + fg("accent", "Updated"));
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 = true;
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 bg = (theme as { getBgAnsi?: (color: "toolSuccessBg" | "toolErrorBg") => string }).getBgAnsi?.bind(theme);
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;