@oh-my-pi/pi-coding-agent 15.9.67 → 15.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/gallery-cli.d.ts +43 -0
  4. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  5. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  6. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  8. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  9. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  10. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  11. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  12. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  15. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  16. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  17. package/dist/types/commands/gallery.d.ts +47 -0
  18. package/dist/types/config/keybindings.d.ts +6 -1
  19. package/dist/types/config/model-id-affixes.d.ts +2 -0
  20. package/dist/types/config/model-registry.d.ts +8 -1
  21. package/dist/types/config/settings-schema.d.ts +32 -6
  22. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  23. package/dist/types/lsp/types.d.ts +10 -0
  24. package/dist/types/main.d.ts +3 -2
  25. package/dist/types/memory-backend/index.d.ts +2 -1
  26. package/dist/types/memory-backend/resolve.d.ts +1 -1
  27. package/dist/types/memory-backend/types.d.ts +1 -1
  28. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  29. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  30. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  31. package/dist/types/modes/index.d.ts +5 -4
  32. package/dist/types/modes/interactive-mode.d.ts +1 -1
  33. package/dist/types/modes/setup-version.d.ts +11 -0
  34. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  36. package/dist/types/modes/types.d.ts +1 -1
  37. package/dist/types/sdk.d.ts +1 -1
  38. package/dist/types/task/executor.d.ts +7 -0
  39. package/dist/types/telemetry-export.d.ts +1 -1
  40. package/dist/types/tools/eval-render.d.ts +1 -8
  41. package/dist/types/tools/fetch.d.ts +15 -7
  42. package/dist/types/tools/render-utils.d.ts +8 -0
  43. package/dist/types/tools/renderers.d.ts +16 -2
  44. package/dist/types/tools/search.d.ts +1 -1
  45. package/dist/types/tools/write.d.ts +2 -0
  46. package/dist/types/web/scrapers/github.d.ts +22 -0
  47. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  48. package/dist/types/web/search/types.d.ts +1 -1
  49. package/package.json +9 -9
  50. package/scripts/dev-launch +42 -0
  51. package/scripts/dev-launch-preload.ts +19 -0
  52. package/src/cli/args.ts +2 -2
  53. package/src/cli/gallery-cli.ts +223 -0
  54. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  56. package/src/cli/gallery-fixtures/edit.ts +194 -0
  57. package/src/cli/gallery-fixtures/fs.ts +153 -0
  58. package/src/cli/gallery-fixtures/index.ts +40 -0
  59. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  60. package/src/cli/gallery-fixtures/memory.ts +81 -0
  61. package/src/cli/gallery-fixtures/misc.ts +221 -0
  62. package/src/cli/gallery-fixtures/search.ts +213 -0
  63. package/src/cli/gallery-fixtures/shell.ts +167 -0
  64. package/src/cli/gallery-fixtures/types.ts +41 -0
  65. package/src/cli/gallery-fixtures/web.ts +158 -0
  66. package/src/cli/gallery-screenshot.ts +279 -0
  67. package/src/cli-commands.ts +1 -0
  68. package/src/commands/gallery.ts +52 -0
  69. package/src/commands/launch.ts +1 -1
  70. package/src/config/keybindings.ts +15 -6
  71. package/src/config/model-equivalence.ts +35 -12
  72. package/src/config/model-id-affixes.ts +39 -22
  73. package/src/config/model-registry.ts +16 -16
  74. package/src/config/settings-schema.ts +18 -5
  75. package/src/config/settings.ts +11 -0
  76. package/src/dap/client.ts +14 -16
  77. package/src/edit/renderer.ts +36 -48
  78. package/src/eval/__tests__/agent-bridge.test.ts +75 -32
  79. package/src/eval/agent-bridge.ts +34 -7
  80. package/src/extensibility/extensions/runner.ts +1 -0
  81. package/src/extensibility/plugins/doctor.ts +0 -1
  82. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  83. package/src/goals/tools/goal-tool.ts +2 -2
  84. package/src/internal-urls/docs-index.generated.ts +5 -5
  85. package/src/lsp/client.ts +104 -55
  86. package/src/lsp/types.ts +10 -0
  87. package/src/main.ts +44 -49
  88. package/src/memory-backend/index.ts +13 -1
  89. package/src/memory-backend/resolve.ts +3 -5
  90. package/src/memory-backend/types.ts +1 -1
  91. package/src/modes/components/custom-editor.ts +10 -1
  92. package/src/modes/components/status-line.ts +3 -5
  93. package/src/modes/components/tool-execution.ts +61 -16
  94. package/src/modes/controllers/command-controller.ts +13 -2
  95. package/src/modes/controllers/input-controller.ts +11 -3
  96. package/src/modes/controllers/selector-controller.ts +2 -2
  97. package/src/modes/index.ts +5 -4
  98. package/src/modes/interactive-mode.ts +17 -3
  99. package/src/modes/setup-version.ts +11 -0
  100. package/src/modes/setup-wizard/index.ts +3 -2
  101. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  102. package/src/modes/types.ts +1 -1
  103. package/src/modes/utils/context-usage.ts +10 -6
  104. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  105. package/src/sdk.ts +21 -23
  106. package/src/session/agent-session.ts +7 -7
  107. package/src/slash-commands/builtin-registry.ts +1 -1
  108. package/src/slash-commands/helpers/usage-report.ts +2 -0
  109. package/src/task/executor.ts +20 -2
  110. package/src/task/render.ts +1 -2
  111. package/src/telemetry-export.ts +25 -7
  112. package/src/tools/eval-backends.ts +6 -17
  113. package/src/tools/eval-render.ts +21 -18
  114. package/src/tools/eval.ts +5 -4
  115. package/src/tools/fetch.ts +94 -84
  116. package/src/tools/render-utils.ts +17 -3
  117. package/src/tools/renderers.ts +16 -1
  118. package/src/tools/report-tool-issue.ts +1 -1
  119. package/src/tools/search.ts +173 -81
  120. package/src/tools/todo.ts +20 -7
  121. package/src/tools/write.ts +22 -1
  122. package/src/web/scrapers/github.ts +255 -3
  123. package/src/web/scrapers/youtube.ts +3 -2
  124. package/src/web/search/providers/perplexity.ts +199 -51
  125. package/src/web/search/render.ts +39 -54
  126. package/src/web/search/types.ts +5 -1
  127. package/dist/types/eval/__tests__/shared-executors.test.d.ts +0 -1
  128. package/src/eval/__tests__/shared-executors.test.ts +0 -609
@@ -19,6 +19,8 @@ import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead, truncateLine }
19
19
  import {
20
20
  Ellipsis,
21
21
  fileHyperlink,
22
+ getTreeBranch,
23
+ getTreeContinuePrefix,
22
24
  renderStatusLine,
23
25
  renderTreeList,
24
26
  truncateToWidth,
@@ -36,7 +38,7 @@ import {
36
38
  import { createFileRecorder, formatResultPath } from "./file-recorder";
37
39
  import { formatGroupedFiles } from "./grouped-file-output";
38
40
  import { formatMatchLine } from "./match-line-format";
39
- import { formatFullOutputReference, type OutputMeta } from "./output-meta";
41
+ import type { OutputMeta } from "./output-meta";
40
42
  import {
41
43
  expandDelimitedPathEntries,
42
44
  hasGlobPathChars,
@@ -56,7 +58,9 @@ import {
56
58
  formatCount,
57
59
  formatEmptyMessage,
58
60
  formatErrorMessage,
61
+ formatMoreItems,
59
62
  PREVIEW_LIMITS,
63
+ replaceTabs,
60
64
  splitGroupsByBlankLine,
61
65
  } from "./render-utils";
62
66
  import { ToolError } from "./tool-errors";
@@ -1169,6 +1173,10 @@ interface SearchRenderArgs {
1169
1173
  }
1170
1174
 
1171
1175
  const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
1176
+ /** Line budget for the expanded view. Larger than collapsed so expanding
1177
+ * reveals more matches with context, but still bounded so a single hot file
1178
+ * whose matches span the whole file can't dump its entire length. */
1179
+ const EXPANDED_TEXT_LIMIT = PREVIEW_LIMITS.EXPANDED_LINES * 2;
1172
1180
 
1173
1181
  const SEARCH_CODE_FRAME_LINE_RE = /^\s*\*?(\d+)│/;
1174
1182
 
@@ -1190,6 +1198,160 @@ function parseSearchDisplayLineNumber(line: string): number | undefined {
1190
1198
  return Number.parseInt(match[1]!, 10);
1191
1199
  }
1192
1200
 
1201
+ const SEARCH_MATCH_LINE_RE = /^\s*\*\d+(?:│|[:|])/;
1202
+
1203
+ interface RenderedSearchLine {
1204
+ raw: string;
1205
+ styled: string;
1206
+ }
1207
+
1208
+ function isSearchMatchLine(line: string): boolean {
1209
+ return SEARCH_MATCH_LINE_RE.test(line);
1210
+ }
1211
+
1212
+ function isSearchHeaderLine(line: string): boolean {
1213
+ return line.startsWith("# ") || line.startsWith("## ");
1214
+ }
1215
+
1216
+ function renderSearchDisplayGroup(
1217
+ group: string[],
1218
+ searchBase: string | undefined,
1219
+ uiTheme: Theme,
1220
+ ): RenderedSearchLine[] {
1221
+ // Track directory/file context within a group so headers and code-frame
1222
+ // lines link to the backing file, with line-specific links for matches.
1223
+ let contextDir = searchBase ?? "";
1224
+ const hasFileHeader = group.some(line => line.startsWith("# "));
1225
+ let currentFilePath: string | undefined = hasFileHeader ? undefined : searchBase;
1226
+ return group.map(line => {
1227
+ if (line.startsWith("## ")) {
1228
+ // Strip optional ` (suffix)` and `#hash` before resolving.
1229
+ const fileName = line
1230
+ .slice(3)
1231
+ .trimEnd()
1232
+ .replace(/\s+\([^)]*\)\s*$/, "")
1233
+ .replace(/#[0-9a-f]+$/, "");
1234
+ const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
1235
+ currentFilePath = absPath;
1236
+ const styled = uiTheme.fg("dim", line);
1237
+ return { raw: line, styled: absPath ? fileHyperlink(absPath, styled) : styled };
1238
+ }
1239
+ if (line.startsWith("# ")) {
1240
+ const raw = line
1241
+ .slice(2)
1242
+ .trimEnd()
1243
+ .replace(/\s+\([^)]*\)\s*$/, "");
1244
+ if (INTERNAL_URL_DISPLAY_RE.test(raw)) {
1245
+ contextDir = "";
1246
+ const styled = uiTheme.fg("accent", line);
1247
+ const linked = linkUrlLikeSearchHeader(raw, styled);
1248
+ currentFilePath = linked.absPath;
1249
+ return { raw: line, styled: linked.line };
1250
+ }
1251
+ const isDirectory = raw.endsWith("/");
1252
+ const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
1253
+ if (isDirectory) {
1254
+ const absPath = searchBase ? (name === "." ? searchBase : path.join(searchBase, name)) : undefined;
1255
+ if (absPath) {
1256
+ contextDir = absPath;
1257
+ }
1258
+ currentFilePath = undefined;
1259
+ const styled = uiTheme.fg("accent", line);
1260
+ return { raw: line, styled: absPath ? fileHyperlink(absPath, styled) : styled };
1261
+ }
1262
+ // Root-level file emitted by formatGroupedFiles when the directory is `.`.
1263
+ const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
1264
+ currentFilePath = absPath;
1265
+ const styled = uiTheme.fg("accent", line);
1266
+ return { raw: line, styled: absPath ? fileHyperlink(absPath, styled) : styled };
1267
+ }
1268
+ const styled = uiTheme.fg("toolOutput", line);
1269
+ const lineNumber = parseSearchDisplayLineNumber(line);
1270
+ return {
1271
+ raw: line,
1272
+ styled:
1273
+ currentFilePath && lineNumber !== undefined
1274
+ ? fileHyperlink(currentFilePath, styled, { line: lineNumber })
1275
+ : styled,
1276
+ };
1277
+ });
1278
+ }
1279
+
1280
+ function compactSearchPreviewGroup(group: RenderedSearchLine[]): RenderedSearchLine[] {
1281
+ const compact = group.filter(line => isSearchHeaderLine(line.raw) || isSearchMatchLine(line.raw));
1282
+ return compact.length > 0 ? compact : group;
1283
+ }
1284
+
1285
+ function countPreviewMatches(lines: readonly RenderedSearchLine[], hasMarkedMatches: boolean): number {
1286
+ if (hasMarkedMatches) return lines.reduce((count, line) => count + (isSearchMatchLine(line.raw) ? 1 : 0), 0);
1287
+ return lines.reduce((count, line) => count + (!isSearchHeaderLine(line.raw) && line.raw.length > 0 ? 1 : 0), 0);
1288
+ }
1289
+
1290
+ function renderBudgetedSearchGroups(
1291
+ groups: string[][],
1292
+ maxLines: number,
1293
+ matchCount: number,
1294
+ searchBase: string | undefined,
1295
+ uiTheme: Theme,
1296
+ compact: boolean,
1297
+ ): string[] {
1298
+ if (maxLines <= 0) return [];
1299
+ const renderedGroups = groups
1300
+ .map(group => {
1301
+ const rendered = renderSearchDisplayGroup(group, searchBase, uiTheme);
1302
+ return compact ? compactSearchPreviewGroup(rendered) : rendered;
1303
+ })
1304
+ .filter(group => group.length > 0);
1305
+ if (renderedGroups.length === 0) return [];
1306
+
1307
+ let totalLines = 0;
1308
+ let totalMarkedMatches = 0;
1309
+ let totalFallbackMatches = 0;
1310
+ for (const group of renderedGroups) {
1311
+ totalLines += group.length;
1312
+ totalMarkedMatches += countPreviewMatches(group, true);
1313
+ totalFallbackMatches += countPreviewMatches(group, false);
1314
+ }
1315
+ const hasMarkedMatches = totalMarkedMatches > 0;
1316
+ const needsSummary = totalLines > maxLines;
1317
+ const contentBudget = needsSummary ? Math.max(maxLines - 1, 0) : maxLines;
1318
+ const visibleGroups: RenderedSearchLine[][] = [];
1319
+ let visibleLineCount = 0;
1320
+ let visibleMatches = 0;
1321
+ for (const group of renderedGroups) {
1322
+ if (visibleLineCount >= contentBudget) break;
1323
+ const available = contentBudget - visibleLineCount;
1324
+ const take = Math.min(group.length, available);
1325
+ if (take <= 0) break;
1326
+ const visibleGroup = group.slice(0, take);
1327
+ visibleGroups.push(visibleGroup);
1328
+ visibleLineCount += visibleGroup.length;
1329
+ visibleMatches += countPreviewMatches(visibleGroup, hasMarkedMatches);
1330
+ }
1331
+
1332
+ const totalMatches = hasMarkedMatches ? totalMarkedMatches : Math.max(matchCount, totalFallbackMatches);
1333
+ const hiddenMatches = Math.max(totalMatches - visibleMatches, 0);
1334
+ const hiddenLines = Math.max(totalLines - visibleLineCount, 0);
1335
+ const hasSummary = needsSummary && (hiddenMatches > 0 || hiddenLines > 0);
1336
+ const lines: string[] = [];
1337
+ for (let i = 0; i < visibleGroups.length; i++) {
1338
+ const group = visibleGroups[i]!;
1339
+ const isLast = !hasSummary && i === visibleGroups.length - 1;
1340
+ const prefix = `${uiTheme.fg("dim", getTreeBranch(isLast, uiTheme))} `;
1341
+ const continuePrefix = uiTheme.fg("dim", getTreeContinuePrefix(isLast, uiTheme));
1342
+ lines.push(`${prefix}${replaceTabs(group[0]!.styled)}`);
1343
+ for (let j = 1; j < group.length; j++) {
1344
+ lines.push(`${continuePrefix}${replaceTabs(group[j]!.styled)}`);
1345
+ }
1346
+ }
1347
+ if (hasSummary) {
1348
+ const hiddenLabel =
1349
+ hiddenMatches > 0 ? formatMoreItems(hiddenMatches, "match") : formatMoreItems(hiddenLines, "line");
1350
+ lines.push(`${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", hiddenLabel)}`);
1351
+ }
1352
+ return lines;
1353
+ }
1354
+
1193
1355
  export const searchToolRenderer = {
1194
1356
  inline: true,
1195
1357
  renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
@@ -1291,94 +1453,24 @@ export const searchToolRenderer = {
1291
1453
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
1292
1454
  const matchGroups = splitGroupsByBlankLine(textContent.split("\n"));
1293
1455
 
1294
- const renderedFileLimit = details?.fileLimitReached;
1295
- const renderedPerFileLimit = details?.perFileLimitReached;
1296
- const truncationReasons: string[] = [];
1297
- if (renderedFileLimit) truncationReasons.push(`first ${renderedFileLimit} files (skip to paginate)`);
1298
- if (renderedPerFileLimit) truncationReasons.push(`first ${renderedPerFileLimit} matches per file`);
1299
- if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
1300
- if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`);
1301
- if (truncation?.artifactId) truncationReasons.push(formatFullOutputReference(truncation.artifactId));
1302
-
1303
1456
  const extraLines: string[] = [];
1304
- if (truncationReasons.length > 0) {
1305
- extraLines.push(uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`));
1306
- }
1307
1457
  if (missingNote) extraLines.push(missingNote);
1308
1458
 
1309
1459
  return createCachedComponent(
1310
1460
  () => options.expanded,
1311
1461
  width => {
1312
- const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
1462
+ const budget = Math.max(
1463
+ (options.expanded ? EXPANDED_TEXT_LIMIT : COLLAPSED_TEXT_LIMIT) - extraLines.length,
1464
+ 0,
1465
+ );
1313
1466
  const searchBase = details?.searchPath;
1314
- const matchLines = renderTreeList(
1315
- {
1316
- items: matchGroups,
1317
- expanded: options.expanded,
1318
- maxCollapsed: matchGroups.length,
1319
- maxCollapsedLines: collapsedMatchLineBudget,
1320
- itemType: "match",
1321
- renderItem: group => {
1322
- // Track directory/file context within a group so headers and code-frame
1323
- // lines link to the backing file, with line-specific links for matches.
1324
- let contextDir = searchBase ?? "";
1325
- const hasFileHeader = group.some(line => line.startsWith("# "));
1326
- let currentFilePath: string | undefined = hasFileHeader ? undefined : searchBase;
1327
- return group.map(line => {
1328
- if (line.startsWith("## ")) {
1329
- // Strip optional ` (suffix)` and `#hash` before resolving.
1330
- const fileName = line
1331
- .slice(3)
1332
- .trimEnd()
1333
- .replace(/\s+\([^)]*\)\s*$/, "")
1334
- .replace(/#[0-9a-f]+$/, "");
1335
- const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
1336
- currentFilePath = absPath;
1337
- const styled = uiTheme.fg("dim", line);
1338
- return absPath ? fileHyperlink(absPath, styled) : styled;
1339
- }
1340
- if (line.startsWith("# ")) {
1341
- const raw = line
1342
- .slice(2)
1343
- .trimEnd()
1344
- .replace(/\s+\([^)]*\)\s*$/, "");
1345
- if (INTERNAL_URL_DISPLAY_RE.test(raw)) {
1346
- contextDir = "";
1347
- const styled = uiTheme.fg("accent", line);
1348
- const linked = linkUrlLikeSearchHeader(raw, styled);
1349
- currentFilePath = linked.absPath;
1350
- return linked.line;
1351
- }
1352
- const isDirectory = raw.endsWith("/");
1353
- const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
1354
- if (isDirectory) {
1355
- const absPath = searchBase
1356
- ? name === "."
1357
- ? searchBase
1358
- : path.join(searchBase, name)
1359
- : undefined;
1360
- if (absPath) {
1361
- contextDir = absPath;
1362
- }
1363
- currentFilePath = undefined;
1364
- const styled = uiTheme.fg("accent", line);
1365
- return absPath ? fileHyperlink(absPath, styled) : styled;
1366
- }
1367
- // Root-level file emitted by formatGroupedFiles when the directory is `.`.
1368
- const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
1369
- currentFilePath = absPath;
1370
- const styled = uiTheme.fg("accent", line);
1371
- return absPath ? fileHyperlink(absPath, styled) : styled;
1372
- }
1373
- const styled = uiTheme.fg("toolOutput", line);
1374
- const lineNumber = parseSearchDisplayLineNumber(line);
1375
- return currentFilePath && lineNumber !== undefined
1376
- ? fileHyperlink(currentFilePath, styled, { line: lineNumber })
1377
- : styled;
1378
- });
1379
- },
1380
- },
1467
+ const matchLines = renderBudgetedSearchGroups(
1468
+ matchGroups,
1469
+ budget,
1470
+ matchCount,
1471
+ searchBase,
1381
1472
  uiTheme,
1473
+ !options.expanded,
1382
1474
  );
1383
1475
  return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
1384
1476
  },
package/src/tools/todo.ts CHANGED
@@ -777,13 +777,26 @@ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
777
777
 
778
778
  export const todoToolRenderer = {
779
779
  renderCall(args: TodoRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
780
- const ops = args?.ops?.map(entry => {
781
- const parts = [entry.op ?? "update"];
782
- if (entry.task) parts.push(entry.task);
783
- if (entry.phase) parts.push(entry.phase);
784
- if (entry.items?.length) parts.push(`${entry.items.length} item${entry.items.length === 1 ? "" : "s"}`);
785
- return parts.join(" ");
786
- }) ?? ["update"];
780
+ // `args` here is the raw partially-parsed JSON from the streaming
781
+ // tool-call delta and may not satisfy `TodoRenderArgs` at runtime:
782
+ // `parseStreamingJson` can hand back `{ ops: "[" }` mid-delta, or
783
+ // entries that are `null` / strings before fields stream. Guard
784
+ // against non-array `ops` and non-object entries so a malformed
785
+ // delta never breaks the TUI render loop (#2005).
786
+ const opsList = Array.isArray(args?.ops) ? args.ops : [];
787
+ const ops =
788
+ opsList.length === 0
789
+ ? ["update"]
790
+ : opsList.map(entry => {
791
+ const e = entry && typeof entry === "object" ? entry : ({} as NonNullable<typeof entry>);
792
+ const parts = [e.op ?? "update"];
793
+ if (e.task) parts.push(e.task);
794
+ if (e.phase) parts.push(e.phase);
795
+ if (Array.isArray(e.items) && e.items.length) {
796
+ parts.push(`${e.items.length} item${e.items.length === 1 ? "" : "s"}`);
797
+ }
798
+ return parts.join(" ");
799
+ });
787
800
  const text = renderStatusLine({ icon: "pending", title: "Todo", meta: ops }, uiTheme);
788
801
  return new Text(text, 0, 0);
789
802
  },
@@ -37,6 +37,7 @@ import { formatPathRelativeToCwd, isInternalUrlPath } from "./path-utils";
37
37
  import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
38
38
  import {
39
39
  formatDiagnostics,
40
+ formatErrorDetail,
40
41
  formatExpandHint,
41
42
  formatMoreItems,
42
43
  formatStatusIcon,
@@ -1020,8 +1021,19 @@ export const writeToolRenderer = {
1020
1021
  return new Text(text, 0, 0);
1021
1022
  },
1022
1023
 
1024
+ // Only the expanded (Ctrl+O) preview is append-only: it renders the whole
1025
+ // content top-anchored, so streamed chunks only append rows at the bottom.
1026
+ // The collapsed preview slides a bounded tail window (`formatStreamingContent`
1027
+ // with `WRITE_STREAMING_PREVIEW_LINES`) whose visible rows re-layout as the
1028
+ // window moves — not append-only, but it never overflows the viewport, so its
1029
+ // head is never at risk of being dropped regardless. `write` has no partial
1030
+ // result (content streams as args), so `result` is ignored here.
1031
+ isStreamingPreviewAppendOnly(args: WriteRenderArgs, options: RenderResultOptions, _result?: unknown): boolean {
1032
+ return Boolean(options?.expanded && args.content);
1033
+ },
1034
+
1023
1035
  renderResult(
1024
- result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails },
1036
+ result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails; isError?: boolean },
1025
1037
  options: RenderResultOptions,
1026
1038
  uiTheme: Theme,
1027
1039
  args?: WriteRenderArgs,
@@ -1032,6 +1044,15 @@ export const writeToolRenderer = {
1032
1044
  const lang = getLanguageFromPath(rawPath);
1033
1045
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1034
1046
  const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
1047
+
1048
+ if (result.isError) {
1049
+ const errorText = result.content?.find(c => c.type === "text")?.text ?? "";
1050
+ const errorHeader = renderStatusLine(
1051
+ { icon: "error", title: "Write", description: `${langIcon} ${pathDisplay}` },
1052
+ uiTheme,
1053
+ );
1054
+ return new Text(`${errorHeader}\n${formatErrorDetail(errorText, uiTheme)}`, 0, 0);
1055
+ }
1035
1056
  const lineCount = countLines(fileContent);
1036
1057
  const lineSuffix = formatLineCountSuffix(lineCount, uiTheme);
1037
1058
  const execSuffix = result.details?.madeExecutable
@@ -1,14 +1,28 @@
1
1
  import { $env, ptree } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
- import { buildResult, loadPage } from "./types";
3
+ import { buildResult, formatMediaDuration, loadPage } from "./types";
4
4
 
5
5
  interface GitHubUrl {
6
- type: "blob" | "tree" | "repo" | "issue" | "issues" | "pull" | "pulls" | "discussion" | "discussions" | "other";
6
+ type:
7
+ | "blob"
8
+ | "tree"
9
+ | "repo"
10
+ | "issue"
11
+ | "issues"
12
+ | "pull"
13
+ | "pulls"
14
+ | "discussion"
15
+ | "discussions"
16
+ | "actions-run"
17
+ | "actions-job"
18
+ | "other";
7
19
  owner: string;
8
20
  repo: string;
9
21
  ref?: string;
10
22
  path?: string;
11
23
  number?: number;
24
+ runId?: number;
25
+ jobId?: number;
12
26
  }
13
27
 
14
28
  interface GitHubIssueComment {
@@ -20,7 +34,7 @@ interface GitHubIssueComment {
20
34
  /**
21
35
  * Parse GitHub URL into components
22
36
  */
23
- function parseGitHubUrl(url: string): GitHubUrl | null {
37
+ export function parseGitHubUrl(url: string): GitHubUrl | null {
24
38
  try {
25
39
  const parsed = new URL(url);
26
40
  if (parsed.hostname !== "github.com") return null;
@@ -54,6 +68,20 @@ function parseGitHubUrl(url: string): GitHubUrl | null {
54
68
  return { type: "pulls", owner, repo };
55
69
  case "pulls":
56
70
  return { type: "pulls", owner, repo };
71
+ case "actions": {
72
+ // /actions/runs/{runId} → run summary + jobs
73
+ // /actions/runs/{runId}/job/{jobId} → single job (web URL uses singular "job")
74
+ // /actions/runs/{runId}/jobs/{jobId} → single job (API-style plural)
75
+ if (subParts[0] === "runs" && /^\d+$/.test(subParts[1] ?? "")) {
76
+ const runId = parseInt(subParts[1], 10);
77
+ const seg = subParts[2];
78
+ if ((seg === "job" || seg === "jobs") && /^\d+$/.test(subParts[3] ?? "")) {
79
+ return { type: "actions-job", owner, repo, runId, jobId: parseInt(subParts[3], 10) };
80
+ }
81
+ return { type: "actions-run", owner, repo, runId };
82
+ }
83
+ return { type: "other", owner, repo };
84
+ }
57
85
  case "discussions":
58
86
  if (subParts.length > 0 && /^\d+$/.test(subParts[0])) {
59
87
  return { type: "discussion", owner, repo, number: parseInt(subParts[0], 10) };
@@ -371,6 +399,212 @@ async function renderGitHubRepo(
371
399
  return { content: md, ok: true };
372
400
  }
373
401
 
402
+ interface GitHubActionsStep {
403
+ name: string;
404
+ status: string;
405
+ conclusion: string | null;
406
+ number: number;
407
+ started_at: string | null;
408
+ completed_at: string | null;
409
+ }
410
+
411
+ interface GitHubActionsJob {
412
+ id: number;
413
+ run_id: number;
414
+ name: string;
415
+ status: string;
416
+ conclusion: string | null;
417
+ started_at: string | null;
418
+ completed_at: string | null;
419
+ html_url: string | null;
420
+ steps?: GitHubActionsStep[];
421
+ runner_name?: string | null;
422
+ labels?: string[];
423
+ workflow_name?: string | null;
424
+ head_branch?: string | null;
425
+ head_sha?: string;
426
+ }
427
+
428
+ interface GitHubActionsRun {
429
+ id: number;
430
+ name?: string | null;
431
+ display_title?: string;
432
+ run_number: number;
433
+ run_attempt?: number;
434
+ event: string;
435
+ status: string;
436
+ conclusion: string | null;
437
+ head_branch?: string | null;
438
+ head_sha?: string;
439
+ html_url: string;
440
+ created_at: string;
441
+ updated_at: string;
442
+ run_started_at?: string;
443
+ actor?: { login: string };
444
+ triggering_actor?: { login: string };
445
+ }
446
+
447
+ /** Combine status + conclusion into a single label, e.g. `completed (failure)`. */
448
+ function statusLabel(status: string, conclusion: string | null | undefined): string {
449
+ return conclusion ? `${status} (${conclusion})` : status;
450
+ }
451
+
452
+ /** Wall-clock duration between two ISO timestamps, formatted HH:MM:SS / MM:SS. Empty when unknown. */
453
+ function actionDuration(start?: string | null, end?: string | null): string {
454
+ if (!start || !end) return "";
455
+ const ms = Date.parse(end) - Date.parse(start);
456
+ if (!Number.isFinite(ms) || ms < 0) return "";
457
+ return formatMediaDuration(Math.round(ms / 1000));
458
+ }
459
+
460
+ /** Escape `|` so step/job names can't break a markdown table row. */
461
+ function escapeCell(text: string): string {
462
+ return text.replaceAll("|", "\\|");
463
+ }
464
+
465
+ /**
466
+ * Strip the per-line ISO-8601 timestamp prefix GitHub prepends to every job log line.
467
+ * Cuts ~28 bytes/line of noise while preserving the message text. Also drops the leading
468
+ * UTF-8 BOM GitHub puts at the start of the log file (otherwise the first line's timestamp
469
+ * survives because `^` no longer sits before a digit).
470
+ */
471
+ export function stripActionsLogTimestamps(logs: string): string {
472
+ return logs.replace(/^\uFEFF/, "").replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z /gm, "");
473
+ }
474
+
475
+ /** Render a job's steps as a markdown table. Empty string when there are no steps. */
476
+ function renderActionsSteps(steps?: GitHubActionsStep[]): string {
477
+ if (!steps || steps.length === 0) return "";
478
+ let md = "| # | Step | Status | Conclusion | Duration |\n";
479
+ md += "|---|------|--------|------------|----------|\n";
480
+ for (const step of steps) {
481
+ const dur = actionDuration(step.started_at, step.completed_at) || "-";
482
+ md += `| ${step.number} | ${escapeCell(step.name)} | ${step.status} | ${step.conclusion ?? "-"} | ${dur} |\n`;
483
+ }
484
+ return `${md}\n`;
485
+ }
486
+
487
+ /** Run-level metadata lines shared by the run and job renderers. */
488
+ function renderActionsRunMeta(run: GitHubActionsRun): string {
489
+ let md = `**Workflow:** ${run.name ?? "(unknown)"}\n`;
490
+ md += `**Run:** #${run.run_number}`;
491
+ if (run.run_attempt && run.run_attempt > 1) md += ` (attempt ${run.run_attempt})`;
492
+ md += ` · ${statusLabel(run.status, run.conclusion)}\n`;
493
+ if (run.head_branch) {
494
+ md += `**Branch:** ${run.head_branch}${run.head_sha ? ` @ ${run.head_sha.slice(0, 7)}` : ""}\n`;
495
+ }
496
+ const actor = run.triggering_actor?.login ?? run.actor?.login;
497
+ md += `**Event:** ${run.event}${actor ? ` · by @${actor}` : ""}\n`;
498
+ const started = run.run_started_at ?? run.created_at;
499
+ const dur = actionDuration(started, run.updated_at);
500
+ md += `Started: ${started}${dur ? ` · Duration: ${dur}` : ""}\n`;
501
+ md += `URL: ${run.html_url}\n`;
502
+ return md;
503
+ }
504
+
505
+ /** Fetch a job's plain-text logs. Returns null when unavailable (no token / expired / private). */
506
+ async function fetchGitHubJobLogs(
507
+ owner: string,
508
+ repo: string,
509
+ jobId: number,
510
+ timeout: number,
511
+ signal?: AbortSignal,
512
+ ): Promise<string | null> {
513
+ const headers: Record<string, string> = {
514
+ Accept: "application/vnd.github+json",
515
+ "X-GitHub-Api-Version": "2022-11-28",
516
+ };
517
+ const token = $env.GITHUB_TOKEN || $env.GH_TOKEN;
518
+ if (token) headers.Authorization = `Bearer ${token}`;
519
+
520
+ // 302 → signed log URL on a different origin; fetch strips Authorization on the cross-origin hop.
521
+ const result = await loadPage(`https://api.github.com/repos/${owner}/${repo}/actions/jobs/${jobId}/logs`, {
522
+ timeout,
523
+ headers,
524
+ signal,
525
+ });
526
+ return result.ok && result.content ? result.content : null;
527
+ }
528
+
529
+ /**
530
+ * Render a workflow run: run metadata plus a per-job breakdown. Steps are listed for any job that
531
+ * did not succeed (the debugging-relevant ones); successful jobs collapse to a single line.
532
+ */
533
+ async function renderGitHubActionsRun(
534
+ gh: GitHubUrl,
535
+ timeout: number,
536
+ signal?: AbortSignal,
537
+ ): Promise<{ content: string; ok: boolean }> {
538
+ const runResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/actions/runs/${gh.runId}`, timeout, signal);
539
+ if (!runResult.ok || !runResult.data) return { content: "", ok: false };
540
+
541
+ const run = runResult.data as GitHubActionsRun;
542
+ let md = `# ${run.display_title || run.name || `Run #${run.run_number}`}\n\n`;
543
+ md += renderActionsRunMeta(run);
544
+ md += `\n---\n\n`;
545
+
546
+ const jobsResult = await fetchGitHubApi(
547
+ `/repos/${gh.owner}/${gh.repo}/actions/runs/${gh.runId}/jobs?per_page=100`,
548
+ timeout,
549
+ signal,
550
+ );
551
+ if (jobsResult.ok && jobsResult.data) {
552
+ const jobs = (jobsResult.data as { jobs?: GitHubActionsJob[] }).jobs ?? [];
553
+ md += `## Jobs (${jobs.length})\n\n`;
554
+ for (const job of jobs) {
555
+ const dur = actionDuration(job.started_at, job.completed_at);
556
+ md += `### ${escapeCell(job.name)} — ${statusLabel(job.status, job.conclusion)}${dur ? ` (${dur})` : ""}\n\n`;
557
+ if (job.conclusion !== "success") {
558
+ md += renderActionsSteps(job.steps);
559
+ }
560
+ }
561
+ }
562
+
563
+ return { content: md, ok: true };
564
+ }
565
+
566
+ /**
567
+ * Render a single workflow job: run context, step table, and the full job logs.
568
+ */
569
+ async function renderGitHubActionsJob(
570
+ gh: GitHubUrl,
571
+ timeout: number,
572
+ signal?: AbortSignal,
573
+ ): Promise<{ content: string; ok: boolean }> {
574
+ const jobResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/actions/jobs/${gh.jobId}`, timeout, signal);
575
+ if (!jobResult.ok || !jobResult.data) return { content: "", ok: false };
576
+
577
+ const job = jobResult.data as GitHubActionsJob;
578
+
579
+ // Best-effort run context for nicer headers; the job render stands on its own without it.
580
+ const runResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/actions/runs/${job.run_id}`, timeout, signal);
581
+ const run = runResult.ok && runResult.data ? (runResult.data as GitHubActionsRun) : null;
582
+
583
+ let md = `# ${escapeCell(job.name)}\n\n`;
584
+ if (run) {
585
+ md += renderActionsRunMeta(run);
586
+ } else if (job.workflow_name) {
587
+ md += `**Workflow:** ${job.workflow_name}\n`;
588
+ if (job.head_branch) md += `**Branch:** ${job.head_branch}\n`;
589
+ }
590
+ const dur = actionDuration(job.started_at, job.completed_at);
591
+ md += `**Job:** ${escapeCell(job.name)} · ${statusLabel(job.status, job.conclusion)}${dur ? ` · ${dur}` : ""}\n`;
592
+ if (job.runner_name) md += `**Runner:** ${job.runner_name}\n`;
593
+ if (job.html_url) md += `URL: ${job.html_url}\n`;
594
+ md += `\n---\n\n`;
595
+
596
+ const steps = renderActionsSteps(job.steps);
597
+ if (steps) md += `## Steps\n\n${steps}`;
598
+
599
+ const logs = await fetchGitHubJobLogs(gh.owner, gh.repo, job.id, timeout, signal);
600
+ md += `## Logs\n\n`;
601
+ md += logs
602
+ ? stripActionsLogTimestamps(logs)
603
+ : "*Logs unavailable — requires a GITHUB_TOKEN/GH_TOKEN with read access, or the run's logs have expired.*\n";
604
+
605
+ return { content: md, ok: true };
606
+ }
607
+
374
608
  /**
375
609
  * Handle GitHub URLs specially
376
610
  */
@@ -445,6 +679,24 @@ export const handleGitHub: SpecialHandler = async (
445
679
  }
446
680
  break;
447
681
  }
682
+
683
+ case "actions-run": {
684
+ notes.push(`Fetched via GitHub API`);
685
+ const result = await renderGitHubActionsRun(gh, timeout, signal);
686
+ if (result.ok) {
687
+ return buildResult(result.content, { url, method: "github-actions-run", fetchedAt, notes });
688
+ }
689
+ break;
690
+ }
691
+
692
+ case "actions-job": {
693
+ notes.push(`Fetched via GitHub API`);
694
+ const result = await renderGitHubActionsJob(gh, timeout, signal);
695
+ if (result.ok) {
696
+ return buildResult(result.content, { url, method: "github-actions-job", fetchedAt, notes });
697
+ }
698
+ break;
699
+ }
448
700
  }
449
701
 
450
702
  // Fall back to null (let normal rendering handle it)