@oh-my-pi/pi-coding-agent 15.9.5 → 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 (192) hide show
  1. package/CHANGELOG.md +98 -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 +10 -2
  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 +43 -7
  22. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  23. package/dist/types/eval/backend.d.ts +6 -6
  24. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  25. package/dist/types/eval/idle-timeout.d.ts +16 -14
  26. package/dist/types/eval/js/executor.d.ts +3 -3
  27. package/dist/types/eval/py/executor.d.ts +2 -2
  28. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  29. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  30. package/dist/types/lsp/types.d.ts +10 -0
  31. package/dist/types/main.d.ts +3 -2
  32. package/dist/types/memory-backend/index.d.ts +2 -1
  33. package/dist/types/memory-backend/resolve.d.ts +1 -1
  34. package/dist/types/memory-backend/types.d.ts +1 -1
  35. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  36. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  37. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  38. package/dist/types/modes/components/model-selector.d.ts +1 -0
  39. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  42. package/dist/types/modes/index.d.ts +5 -4
  43. package/dist/types/modes/interactive-mode.d.ts +2 -2
  44. package/dist/types/modes/setup-version.d.ts +11 -0
  45. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  46. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  47. package/dist/types/modes/types.d.ts +2 -2
  48. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  49. package/dist/types/sdk.d.ts +1 -1
  50. package/dist/types/task/executor.d.ts +7 -0
  51. package/dist/types/telemetry-export.d.ts +1 -1
  52. package/dist/types/tools/eval-render.d.ts +1 -0
  53. package/dist/types/tools/fetch.d.ts +15 -7
  54. package/dist/types/tools/render-utils.d.ts +33 -0
  55. package/dist/types/tools/renderers.d.ts +16 -2
  56. package/dist/types/tools/search.d.ts +1 -1
  57. package/dist/types/tools/write.d.ts +2 -0
  58. package/dist/types/tui/code-cell.d.ts +6 -0
  59. package/dist/types/tui/output-block.d.ts +11 -0
  60. package/dist/types/web/scrapers/github.d.ts +22 -0
  61. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +9 -9
  64. package/scripts/dev-launch +42 -0
  65. package/scripts/dev-launch-preload.ts +19 -0
  66. package/src/autoresearch/dashboard.ts +11 -21
  67. package/src/cli/args.ts +2 -2
  68. package/src/cli/claude-trace-cli.ts +13 -1
  69. package/src/cli/gallery-cli.ts +223 -0
  70. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  71. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  72. package/src/cli/gallery-fixtures/edit.ts +194 -0
  73. package/src/cli/gallery-fixtures/fs.ts +153 -0
  74. package/src/cli/gallery-fixtures/index.ts +40 -0
  75. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  76. package/src/cli/gallery-fixtures/memory.ts +81 -0
  77. package/src/cli/gallery-fixtures/misc.ts +221 -0
  78. package/src/cli/gallery-fixtures/search.ts +213 -0
  79. package/src/cli/gallery-fixtures/shell.ts +167 -0
  80. package/src/cli/gallery-fixtures/types.ts +41 -0
  81. package/src/cli/gallery-fixtures/web.ts +158 -0
  82. package/src/cli/gallery-screenshot.ts +279 -0
  83. package/src/cli-commands.ts +1 -0
  84. package/src/commands/gallery.ts +52 -0
  85. package/src/commands/launch.ts +1 -1
  86. package/src/config/keybindings.ts +68 -2
  87. package/src/config/model-equivalence.ts +35 -12
  88. package/src/config/model-id-affixes.ts +39 -22
  89. package/src/config/model-registry.ts +16 -16
  90. package/src/config/settings-schema.ts +29 -6
  91. package/src/config/settings.ts +11 -0
  92. package/src/dap/client.ts +14 -16
  93. package/src/debug/raw-sse.ts +18 -4
  94. package/src/edit/file-snapshot-store.ts +1 -1
  95. package/src/edit/index.ts +1 -1
  96. package/src/edit/renderer.ts +43 -55
  97. package/src/edit/streaming.ts +1 -1
  98. package/src/eval/__tests__/agent-bridge.test.ts +102 -58
  99. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  100. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  101. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  102. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  103. package/src/eval/agent-bridge.ts +38 -12
  104. package/src/eval/backend.ts +6 -6
  105. package/src/eval/bridge-timeout.ts +44 -0
  106. package/src/eval/idle-timeout.ts +33 -15
  107. package/src/eval/js/executor.ts +10 -10
  108. package/src/eval/llm-bridge.ts +4 -5
  109. package/src/eval/py/executor.ts +6 -6
  110. package/src/eval/py/kernel.ts +11 -1
  111. package/src/eval/py/spawn-options.ts +126 -0
  112. package/src/export/ttsr.ts +9 -0
  113. package/src/extensibility/extensions/runner.ts +3 -0
  114. package/src/extensibility/plugins/doctor.ts +0 -1
  115. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  116. package/src/goals/tools/goal-tool.ts +2 -2
  117. package/src/internal-urls/docs-index.generated.ts +7 -6
  118. package/src/lsp/client.ts +179 -52
  119. package/src/lsp/index.ts +38 -4
  120. package/src/lsp/render.ts +3 -3
  121. package/src/lsp/types.ts +10 -0
  122. package/src/main.ts +47 -52
  123. package/src/memory-backend/index.ts +13 -1
  124. package/src/memory-backend/resolve.ts +3 -5
  125. package/src/memory-backend/types.ts +1 -1
  126. package/src/modes/components/agent-dashboard.ts +13 -4
  127. package/src/modes/components/assistant-message.ts +22 -1
  128. package/src/modes/components/copy-selector.ts +249 -0
  129. package/src/modes/components/custom-editor.ts +10 -1
  130. package/src/modes/components/extensions/extension-list.ts +17 -8
  131. package/src/modes/components/history-search.ts +19 -11
  132. package/src/modes/components/model-selector.ts +125 -29
  133. package/src/modes/components/oauth-selector.ts +28 -12
  134. package/src/modes/components/session-observer-overlay.ts +13 -15
  135. package/src/modes/components/session-selector.ts +24 -13
  136. package/src/modes/components/status-line.ts +3 -5
  137. package/src/modes/components/tool-execution.ts +83 -24
  138. package/src/modes/components/tree-selector.ts +19 -7
  139. package/src/modes/components/user-message-selector.ts +25 -14
  140. package/src/modes/controllers/command-controller.ts +13 -118
  141. package/src/modes/controllers/event-controller.ts +26 -10
  142. package/src/modes/controllers/input-controller.ts +11 -3
  143. package/src/modes/controllers/selector-controller.ts +40 -3
  144. package/src/modes/index.ts +5 -4
  145. package/src/modes/interactive-mode.ts +21 -7
  146. package/src/modes/setup-version.ts +11 -0
  147. package/src/modes/setup-wizard/index.ts +3 -2
  148. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  149. package/src/modes/theme/theme.ts +46 -10
  150. package/src/modes/types.ts +2 -2
  151. package/src/modes/utils/context-usage.ts +10 -6
  152. package/src/modes/utils/copy-targets.ts +254 -0
  153. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  154. package/src/prompts/tools/ast-edit.md +1 -1
  155. package/src/prompts/tools/ast-grep.md +1 -1
  156. package/src/prompts/tools/read.md +1 -1
  157. package/src/prompts/tools/search.md +1 -1
  158. package/src/sdk.ts +21 -23
  159. package/src/session/agent-session.ts +13 -9
  160. package/src/slash-commands/builtin-registry.ts +4 -12
  161. package/src/slash-commands/helpers/usage-report.ts +2 -0
  162. package/src/task/executor.ts +20 -2
  163. package/src/task/render.ts +37 -11
  164. package/src/telemetry-export.ts +25 -7
  165. package/src/tools/bash.ts +18 -8
  166. package/src/tools/browser/render.ts +5 -4
  167. package/src/tools/debug.ts +3 -3
  168. package/src/tools/eval-backends.ts +6 -17
  169. package/src/tools/eval-render.ts +28 -10
  170. package/src/tools/eval.ts +19 -23
  171. package/src/tools/fetch.ts +99 -89
  172. package/src/tools/read.ts +7 -7
  173. package/src/tools/render-utils.ts +63 -3
  174. package/src/tools/renderers.ts +16 -1
  175. package/src/tools/report-tool-issue.ts +1 -1
  176. package/src/tools/search.ts +173 -81
  177. package/src/tools/ssh.ts +21 -8
  178. package/src/tools/todo.ts +20 -7
  179. package/src/tools/write.ts +39 -9
  180. package/src/tui/code-cell.ts +19 -4
  181. package/src/tui/output-block.ts +14 -0
  182. package/src/web/scrapers/github.ts +255 -3
  183. package/src/web/scrapers/youtube.ts +3 -2
  184. package/src/web/search/providers/perplexity.ts +199 -51
  185. package/src/web/search/render.ts +42 -57
  186. package/src/web/search/types.ts +5 -1
  187. package/dist/types/eval/heartbeat.d.ts +0 -45
  188. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  189. package/src/eval/__tests__/shared-executors.test.ts +0 -609
  190. package/src/eval/heartbeat.ts +0 -74
  191. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
  192. /package/dist/types/eval/__tests__/{shared-executors.test.d.ts → kernel-spawn.test.d.ts} +0 -0
@@ -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/ssh.ts CHANGED
@@ -13,11 +13,11 @@ import type { SSHHostInfo } from "../ssh/connection-manager";
13
13
  import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager";
14
14
  import { executeSSH } from "../ssh/ssh-executor";
15
15
  import { renderStatusLine } from "../tui";
16
- import { CachedOutputBlock } from "../tui/output-block";
16
+ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
17
17
  import type { ToolSession } from ".";
18
18
  import { truncateForPrompt } from "./approval";
19
19
  import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
20
- import { replaceTabs } from "./render-utils";
20
+ import { capPreviewLines, replaceTabs } from "./render-utils";
21
21
  import { ToolError } from "./tool-errors";
22
22
  import { toolResult } from "./tool-result";
23
23
  import { clampTimeout } from "./tool-timeouts";
@@ -244,16 +244,22 @@ export const sshToolRenderer = {
244
244
  const header = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}]` }, uiTheme);
245
245
  const cmdLines = formatSshCommandLines(command, uiTheme);
246
246
  const outputBlock = new CachedOutputBlock();
247
- return {
247
+ return markFramedBlockComponent({
248
248
  render: (width: number): string[] =>
249
249
  outputBlock.render(
250
- { header, state: "pending", sections: [{ lines: cmdLines }], width, animate: true },
250
+ {
251
+ header,
252
+ state: "pending",
253
+ sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: _options.expanded }) }],
254
+ width,
255
+ animate: true,
256
+ },
251
257
  uiTheme,
252
258
  ),
253
259
  invalidate: () => {
254
260
  outputBlock.invalidate();
255
261
  },
256
- };
262
+ });
257
263
  },
258
264
 
259
265
  renderResult(
@@ -273,7 +279,7 @@ export const sshToolRenderer = {
273
279
  const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
274
280
  const outputBlock = new CachedOutputBlock();
275
281
 
276
- return {
282
+ return markFramedBlockComponent({
277
283
  render: (width: number): string[] => {
278
284
  // REACTIVE: read mutable options at render time
279
285
  const { expanded, renderContext } = options;
@@ -319,7 +325,14 @@ export const sshToolRenderer = {
319
325
  {
320
326
  header,
321
327
  state: "success",
322
- sections: [{ lines: cmdLines }, { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }],
328
+ sections: [
329
+ {
330
+ lines: options.isPartial
331
+ ? capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded })
332
+ : cmdLines,
333
+ },
334
+ { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
335
+ ],
323
336
  width,
324
337
  },
325
338
  uiTheme,
@@ -328,7 +341,7 @@ export const sshToolRenderer = {
328
341
  invalidate: () => {
329
342
  outputBlock.invalidate();
330
343
  },
331
- };
344
+ });
332
345
  },
333
346
  mergeCallAndResult: true,
334
347
  };
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,
@@ -58,7 +59,7 @@ import {
58
59
  import { ToolError } from "./tool-errors";
59
60
  import { toolResult } from "./tool-result";
60
61
 
61
- const LOOSE_HASHLINE_HEADER_RE = /^\s*¶\S+#[^ \t\r\n]*\s*$/;
62
+ const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
62
63
 
63
64
  let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
64
65
  async function loadFflate(): Promise<typeof import("fflate")> {
@@ -109,7 +110,7 @@ function stripWriteContentWithPotentialLooseHeader(lines: string[]): { text: str
109
110
  /**
110
111
  * Strip hashline display prefixes from write content.
111
112
  *
112
- * Only active when hashline edit mode is enabled — the model sees PATH#HASH`
113
+ * Only active when hashline edit mode is enabled — the model sees `[PATH#HASH]`
113
114
  * headers plus `LINE:` prefixes in read output and sometimes copies them into write content.
114
115
  */
115
116
  function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } {
@@ -122,7 +123,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
122
123
  /**
123
124
  * Record a snapshot of the freshly-written `content` for `absolutePath`
124
125
  * so subsequent hashline edits address the new file with a current tag,
125
- * and return the matching displayPath#TAG` header. Returns `undefined`
126
+ * and return the matching `[displayPath#TAG]` header. Returns `undefined`
126
127
  * when the session is not in hashline mode so callers can no-op cheaply.
127
128
  *
128
129
  * Mirrors the post-commit snapshot recording the hashline patcher performs
@@ -770,7 +771,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
770
771
  context?: AgentToolContext,
771
772
  ): Promise<AgentToolResult<WriteToolDetails>> {
772
773
  return untilAborted(signal, async () => {
773
- // Strip hashline display prefixes (PATH#HASH + LINE:) if the model copied them from read output
774
+ // Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
774
775
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
775
776
  const internalRouter = InternalUrlRouter.instance();
776
777
  if (internalRouter.canHandle(path)) {
@@ -935,11 +936,20 @@ function normalizeDisplayText(text: string): string {
935
936
  return text.replace(/\r/g, "");
936
937
  }
937
938
 
938
- function formatStreamingContent(content: string, language: string | undefined, uiTheme: Theme): string {
939
+ function formatStreamingContent(
940
+ content: string,
941
+ expanded: boolean,
942
+ language: string | undefined,
943
+ uiTheme: Theme,
944
+ ): string {
939
945
  if (!content) return "";
940
946
  const lines = normalizeDisplayText(content).split("\n");
941
947
  const totalLines = lines.length;
942
- const startIndex = Math.max(0, totalLines - WRITE_STREAMING_PREVIEW_LINES);
948
+ // Collapsed: follow the streaming edge with a bounded tail window so the box
949
+ // stays short enough not to strand its scrolled-off head above the viewport
950
+ // while the block is volatile. `Ctrl+O` (expanded) lifts the cap for a
951
+ // deliberate full view — matching the eval streaming preview.
952
+ const startIndex = expanded ? 0 : Math.max(0, totalLines - WRITE_STREAMING_PREVIEW_LINES);
943
953
  const visibleLines = lines.slice(startIndex);
944
954
  const hidden = startIndex;
945
955
  const highlighted = highlightCode(visibleLines.join("\n"), language);
@@ -1005,14 +1015,25 @@ export const writeToolRenderer = {
1005
1015
  return new Text(text, 0, 0);
1006
1016
  }
1007
1017
 
1008
- // Show streaming preview of content (tail)
1009
- text += formatStreamingContent(args.content, lang, uiTheme);
1018
+ // Show streaming preview of content — bounded tail while collapsed, full on Ctrl+O.
1019
+ text += formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme);
1010
1020
 
1011
1021
  return new Text(text, 0, 0);
1012
1022
  },
1013
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
+
1014
1035
  renderResult(
1015
- result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails },
1036
+ result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails; isError?: boolean },
1016
1037
  options: RenderResultOptions,
1017
1038
  uiTheme: Theme,
1018
1039
  args?: WriteRenderArgs,
@@ -1023,6 +1044,15 @@ export const writeToolRenderer = {
1023
1044
  const lang = getLanguageFromPath(rawPath);
1024
1045
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1025
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
+ }
1026
1056
  const lineCount = countLines(fileContent);
1027
1057
  const lineSuffix = formatLineCountSuffix(lineCount, uiTheme);
1028
1058
  const execSuffix = result.details?.madeExecutable
@@ -25,6 +25,12 @@ export interface CodeCellOptions {
25
25
  output?: string;
26
26
  outputMaxLines?: number;
27
27
  codeMaxLines?: number;
28
+ /**
29
+ * Show the LAST `codeMaxLines` rows (the live streaming edge) instead of the
30
+ * first, with a "… N earlier lines" marker on top. Lets a pending preview
31
+ * follow code as it is written while staying bounded. Ignored when `expanded`.
32
+ */
33
+ codeTail?: boolean;
28
34
  expanded?: boolean;
29
35
  /** Animate the cell border with a sweeping segment while pending/running. */
30
36
  animate?: boolean;
@@ -102,13 +108,22 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
102
108
  const normalizedCode = replaceTabs(code ?? "");
103
109
  const rawCodeLines = sanitizeTerminalLines(normalizedCode);
104
110
  const maxCodeLines = expanded ? rawCodeLines.length : Math.min(rawCodeLines.length, codeMaxLines);
105
- const visibleCode = rawCodeLines.slice(0, maxCodeLines).join("\n");
106
- const codeLines = highlightCode(visibleCode, language);
107
111
  const hiddenCodeLines = rawCodeLines.length - maxCodeLines;
112
+ const tail = options.codeTail === true && !expanded && hiddenCodeLines > 0;
113
+ const startIndex = tail ? rawCodeLines.length - maxCodeLines : 0;
114
+ const visibleCode = rawCodeLines.slice(startIndex, startIndex + maxCodeLines).join("\n");
115
+ const codeLines = highlightCode(visibleCode, language);
108
116
  if (hiddenCodeLines > 0) {
109
117
  const hint = formatExpandHint(theme, expanded, hiddenCodeLines > 0);
110
- const moreLine = `${formatMoreItems(hiddenCodeLines, "line")}${hint ? ` ${hint}` : ""}`;
111
- codeLines.push(theme.fg("dim", moreLine));
118
+ if (tail) {
119
+ // Earlier rows scrolled above the live tail window — mark them on top so
120
+ // the newest streamed line stays pinned to the bottom of the box.
121
+ const earlier = `… ${hiddenCodeLines} earlier line${hiddenCodeLines === 1 ? "" : "s"}${hint ? ` ${hint}` : ""}`;
122
+ codeLines.unshift(theme.fg("dim", earlier));
123
+ } else {
124
+ const moreLine = `${formatMoreItems(hiddenCodeLines, "line")}${hint ? ` ${hint}` : ""}`;
125
+ codeLines.push(theme.fg("dim", moreLine));
126
+ }
112
127
  }
113
128
 
114
129
  const outputLines: string[] = [];
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Bordered output container with optional header and sections.
3
3
  */
4
+ import type { Component } from "@oh-my-pi/pi-tui";
4
5
  import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
5
6
  import type { Theme } from "../modes/theme/theme";
6
7
  import { getSixelLineMask } from "../utils/sixel";
@@ -19,6 +20,19 @@ export interface OutputBlockOptions {
19
20
  animate?: boolean;
20
21
  }
21
22
 
23
+ const FRAMED_BLOCK_COMPONENT = Symbol("framedBlockComponent");
24
+
25
+ export type FramedBlockComponent = Component & { [FRAMED_BLOCK_COMPONENT]?: true };
26
+
27
+ export function markFramedBlockComponent<T extends Component>(component: T): T & FramedBlockComponent {
28
+ (component as T & FramedBlockComponent)[FRAMED_BLOCK_COMPONENT] = true;
29
+ return component as T & FramedBlockComponent;
30
+ }
31
+
32
+ export function isFramedBlockComponent(component: Component): boolean {
33
+ return (component as FramedBlockComponent)[FRAMED_BLOCK_COMPONENT] === true;
34
+ }
35
+
22
36
  const BORDER_SHIMMER_TICK_MS = 16;
23
37
  /** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
24
38
  * ms. Position is derived from the wall clock against this fixed cycle so a