pi-subagents 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/render.ts CHANGED
@@ -20,9 +20,68 @@ function getTermWidth(): number {
20
20
  return process.stdout.columns || 120;
21
21
  }
22
22
 
23
+ // Grapheme segmenter for proper Unicode handling (shared instance)
24
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
25
+
26
+ /**
27
+ * Truncate a line to maxWidth, preserving ANSI styling through the ellipsis.
28
+ *
29
+ * pi-tui's truncateToWidth adds \x1b[0m before ellipsis which resets all styling,
30
+ * causing background color bleed in the TUI. This implementation tracks active
31
+ * ANSI styles and re-applies them before the ellipsis.
32
+ *
33
+ * Uses Intl.Segmenter for proper Unicode/emoji handling (not char-by-char).
34
+ */
23
35
  function truncLine(text: string, maxWidth: number): string {
24
36
  if (visibleWidth(text) <= maxWidth) return text;
25
- return truncateToWidth(text, maxWidth - 1) + "…";
37
+
38
+ const targetWidth = maxWidth - 1; // Room for single ellipsis character
39
+ let result = "";
40
+ let currentWidth = 0;
41
+ let activeStyles: string[] = []; // Track ALL active styles (not just last)
42
+ let i = 0;
43
+
44
+ while (i < text.length) {
45
+ // Check for ANSI escape code
46
+ const ansiMatch = text.slice(i).match(/^\x1b\[[0-9;]*m/);
47
+ if (ansiMatch) {
48
+ const code = ansiMatch[0];
49
+ result += code;
50
+
51
+ if (code === "\x1b[0m" || code === "\x1b[m") {
52
+ activeStyles = []; // Reset clears all styles
53
+ } else {
54
+ activeStyles.push(code); // Stack styles (bold + color, etc.)
55
+ }
56
+ i += code.length;
57
+ continue;
58
+ }
59
+
60
+ // Find end of non-ANSI text segment
61
+ let end = i;
62
+ while (end < text.length && !text.slice(end).match(/^\x1b\[[0-9;]*m/)) {
63
+ end++;
64
+ }
65
+
66
+ // Segment into graphemes for proper Unicode handling
67
+ const textPortion = text.slice(i, end);
68
+ for (const seg of segmenter.segment(textPortion)) {
69
+ const grapheme = seg.segment;
70
+ const graphemeWidth = visibleWidth(grapheme);
71
+
72
+ if (currentWidth + graphemeWidth > targetWidth) {
73
+ // Re-apply all active styles before ellipsis to preserve background/colors
74
+ return result + activeStyles.join("") + "…";
75
+ }
76
+
77
+ result += grapheme;
78
+ currentWidth += graphemeWidth;
79
+ }
80
+ i = end;
81
+ }
82
+
83
+ // Reached end without exceeding width (shouldn't happen given initial check)
84
+ return result + activeStyles.join("") + "…";
26
85
  }
27
86
 
28
87
  // Track last rendered widget state to avoid no-op re-renders
@@ -319,7 +378,9 @@ export function renderSubagentResult(
319
378
  c.addChild(new Text(truncLine(stepHeader, w), 0, 0));
320
379
 
321
380
  const taskMaxLen = Math.max(20, w - 12);
322
- const taskPreview = r.task.slice(0, taskMaxLen) + (r.task.length > taskMaxLen ? "..." : "");
381
+ const taskPreview = r.task.length > taskMaxLen
382
+ ? `${r.task.slice(0, taskMaxLen)}...`
383
+ : r.task;
323
384
  c.addChild(new Text(truncLine(theme.fg("dim", ` task: ${taskPreview}`), w), 0, 0));
324
385
 
325
386
  const outputTarget = extractOutputTarget(r.task);
@@ -340,22 +401,31 @@ export function renderSubagentResult(
340
401
  }
341
402
  // Current tool for running step
342
403
  if (rProg.currentTool) {
343
- const toolLine = rProg.currentToolArgs
344
- ? `${rProg.currentTool}: ${rProg.currentToolArgs.slice(0, 100)}${rProg.currentToolArgs.length > 100 ? "..." : ""}`
404
+ const maxToolArgsLen = Math.max(50, w - 20);
405
+ const toolArgsPreview = rProg.currentToolArgs
406
+ ? (rProg.currentToolArgs.length > maxToolArgsLen
407
+ ? `${rProg.currentToolArgs.slice(0, maxToolArgsLen)}...`
408
+ : rProg.currentToolArgs)
409
+ : "";
410
+ const toolLine = toolArgsPreview
411
+ ? `${rProg.currentTool}: ${toolArgsPreview}`
345
412
  : rProg.currentTool;
346
413
  c.addChild(new Text(truncLine(theme.fg("warning", ` > ${toolLine}`), w), 0, 0));
347
414
  }
348
415
  // Recent tools
349
416
  if (rProg.recentTools?.length) {
350
417
  for (const t of rProg.recentTools.slice(0, 3)) {
351
- const args = t.args.slice(0, 90) + (t.args.length > 90 ? "..." : "");
352
- c.addChild(new Text(truncLine(theme.fg("dim", ` ${t.tool}: ${args}`), w), 0, 0));
418
+ const maxArgsLen = Math.max(40, w - 30);
419
+ const argsPreview = t.args.length > maxArgsLen
420
+ ? `${t.args.slice(0, maxArgsLen)}...`
421
+ : t.args;
422
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ${t.tool}: ${argsPreview}`), w), 0, 0));
353
423
  }
354
424
  }
355
- // Recent output (limited)
425
+ // Recent output - let truncLine handle truncation entirely
356
426
  const recentLines = (rProg.recentOutput ?? []).slice(-5);
357
427
  for (const line of recentLines) {
358
- c.addChild(new Text(truncLine(theme.fg("dim", ` ${line.slice(0, 100)}${line.length > 100 ? "..." : ""}`), w), 0, 0));
428
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ${line}`), w), 0, 0));
359
429
  }
360
430
  }
361
431
 
package/schemas.ts CHANGED
@@ -83,7 +83,7 @@ export const SubagentParams = Type.Object({
83
83
  maxOutput: MaxOutputSchema,
84
84
  artifacts: Type.Optional(Type.Boolean({ description: "Write debug artifacts (default: true)" })),
85
85
  includeProgress: Type.Optional(Type.Boolean({ description: "Include full progress in result (default: false)" })),
86
- share: Type.Optional(Type.Boolean({ description: "Create shareable session log (default: true)", default: true })),
86
+ share: Type.Optional(Type.Boolean({ description: "Upload session to GitHub Gist for sharing (default: false)" })),
87
87
  sessionDir: Type.Optional(
88
88
  Type.String({ description: "Directory to store session logs (default: temp; enables sessions even if share=false)" }),
89
89
  ),
package/settings.ts CHANGED
@@ -213,7 +213,7 @@ export function resolveStepBehavior(
213
213
  * Resolve a file path: absolute paths pass through, relative paths get chainDir prepended.
214
214
  */
215
215
  function resolveChainPath(filePath: string, chainDir: string): string {
216
- return path.isAbsolute(filePath) ? filePath : `${chainDir}/${filePath}`;
216
+ return path.isAbsolute(filePath) ? filePath : path.join(chainDir, filePath);
217
217
  }
218
218
 
219
219
  /**
@@ -243,7 +243,7 @@ export function buildChainInstructions(
243
243
 
244
244
  // Progress instructions in suffix (less critical)
245
245
  if (behavior.progress) {
246
- const progressPath = `${chainDir}/progress.md`;
246
+ const progressPath = path.join(chainDir, "progress.md");
247
247
  if (isFirstProgressAgent) {
248
248
  suffixParts.push(`Create and maintain progress at: ${progressPath}`);
249
249
  } else {
@@ -288,7 +288,7 @@ export function resolveParallelBehaviors(
288
288
  }
289
289
 
290
290
  // Build subdirectory path for this parallel task
291
- const subdir = `parallel-${stepIndex}/${taskIndex}-${task.agent}`;
291
+ const subdir = path.join(`parallel-${stepIndex}`, `${taskIndex}-${task.agent}`);
292
292
 
293
293
  // Output: task override > agent default (namespaced) > false
294
294
  // Absolute paths pass through unchanged; relative paths get namespaced under subdir
@@ -299,11 +299,11 @@ export function resolveParallelBehaviors(
299
299
  } else if (path.isAbsolute(task.output)) {
300
300
  output = task.output; // Absolute path: use as-is
301
301
  } else {
302
- output = `${subdir}/${task.output}`; // Relative: namespace under subdir
302
+ output = path.join(subdir, task.output); // Relative: namespace under subdir
303
303
  }
304
304
  } else if (config.output) {
305
305
  // Agent defaults are always relative, so namespace them
306
- output = `${subdir}/${config.output}`;
306
+ output = path.join(subdir, config.output);
307
307
  }
308
308
 
309
309
  // Reads: task override > agent default > false
@@ -372,15 +372,17 @@ export function aggregateParallelOutputs(results: ParallelTaskResult[]): string
372
372
  .map((r, i) => {
373
373
  const header = `=== Parallel Task ${i + 1} (${r.agent}) ===`;
374
374
  const hasTextOutput = Boolean(r.output?.trim());
375
- const status = r.exitCode !== 0
376
- ? `⚠️ FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
377
- : r.error
378
- ? `⚠️ WARNING: ${r.error}`
379
- : !hasTextOutput && r.outputTargetPath && r.outputTargetExists === false
380
- ? `⚠️ EMPTY OUTPUT (expected output file missing: ${r.outputTargetPath})`
381
- : !hasTextOutput && !r.outputTargetPath
382
- ? "⚠️ EMPTY OUTPUT (no textual response returned)"
383
- : "";
375
+ const status = r.exitCode === -1
376
+ ? "⏭️ SKIPPED"
377
+ : r.exitCode !== 0
378
+ ? `⚠️ FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
379
+ : r.error
380
+ ? `⚠️ WARNING: ${r.error}`
381
+ : !hasTextOutput && r.outputTargetPath && r.outputTargetExists === false
382
+ ? `⚠️ EMPTY OUTPUT (expected output file missing: ${r.outputTargetPath})`
383
+ : !hasTextOutput && !r.outputTargetPath
384
+ ? "⚠️ EMPTY OUTPUT (no textual response returned)"
385
+ : "";
384
386
  const body = status
385
387
  ? (hasTextOutput ? `${status}\n${r.output}` : status)
386
388
  : r.output;
package/skills.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Skill resolution and caching for subagent extension
3
3
  */
4
4
 
5
+ import { execSync } from "node:child_process";
5
6
  import * as fs from "node:fs";
6
7
  import * as os from "node:os";
7
8
  import * as path from "node:path";
@@ -89,11 +90,31 @@ function getPackageSkillPaths(packageRoot: string): string[] {
89
90
  }
90
91
  }
91
92
 
93
+ let cachedGlobalNpmRoot: string | null = null;
94
+
95
+ function getGlobalNpmRoot(): string | null {
96
+ if (cachedGlobalNpmRoot !== null) return cachedGlobalNpmRoot;
97
+ try {
98
+ cachedGlobalNpmRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
99
+ return cachedGlobalNpmRoot;
100
+ } catch {
101
+ cachedGlobalNpmRoot = ""; // Empty string means "tried but failed"
102
+ return null;
103
+ }
104
+ }
105
+
92
106
  function collectPackageSkillPaths(cwd: string): string[] {
93
107
  const dirs = [
94
108
  path.join(cwd, CONFIG_DIR, "npm", "node_modules"),
95
109
  path.join(AGENT_DIR, "npm", "node_modules"),
96
110
  ];
111
+
112
+ // Add global npm root if available (where pi installs global packages)
113
+ const globalRoot = getGlobalNpmRoot();
114
+ if (globalRoot) {
115
+ dirs.push(globalRoot);
116
+ }
117
+
97
118
  const results: string[] = [];
98
119
 
99
120
  for (const dir of dirs) {
@@ -178,6 +199,8 @@ function inferSkillSource(rawSource: unknown, filePath: string, cwd: string): Sk
178
199
  const projectRoot = path.resolve(cwd, CONFIG_DIR);
179
200
  const isProjectScoped = isWithinPath(filePath, projectRoot);
180
201
  const isUserScoped = isWithinPath(filePath, AGENT_DIR);
202
+ const globalRoot = getGlobalNpmRoot();
203
+ const isGlobalPackage = globalRoot ? isWithinPath(filePath, globalRoot) : false;
181
204
 
182
205
  if (source === "project") return "project";
183
206
  if (source === "user") return "user";
@@ -188,7 +211,7 @@ function inferSkillSource(rawSource: unknown, filePath: string, cwd: string): Sk
188
211
  }
189
212
  if (source === "package") {
190
213
  if (isProjectScoped) return "project-package";
191
- if (isUserScoped) return "user-package";
214
+ if (isUserScoped || isGlobalPackage) return "user-package";
192
215
  return "unknown";
193
216
  }
194
217
  if (source === "extension") return "extension";
@@ -196,6 +219,7 @@ function inferSkillSource(rawSource: unknown, filePath: string, cwd: string): Sk
196
219
 
197
220
  if (isProjectScoped) return "project";
198
221
  if (isUserScoped) return "user";
222
+ if (isGlobalPackage) return "user-package";
199
223
  return "unknown";
200
224
  }
201
225