@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.2

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 (238) hide show
  1. package/CHANGELOG.md +142 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  6. package/dist/types/commit/analysis/summary.d.ts +2 -2
  7. package/dist/types/commit/changelog/generate.d.ts +2 -2
  8. package/dist/types/commit/changelog/index.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  10. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  11. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  12. package/dist/types/commit/model-selection.d.ts +10 -4
  13. package/dist/types/config/api-key-resolver.d.ts +34 -0
  14. package/dist/types/config/keybindings.d.ts +2 -2
  15. package/dist/types/config/model-provider-priority.d.ts +1 -0
  16. package/dist/types/config/model-registry.d.ts +17 -1
  17. package/dist/types/config/model-resolver.d.ts +4 -1
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/config/settings.d.ts +7 -2
  20. package/dist/types/dap/config.d.ts +14 -1
  21. package/dist/types/dap/types.d.ts +10 -0
  22. package/dist/types/debug/report-bundle.d.ts +3 -0
  23. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  24. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  25. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  26. package/dist/types/lsp/client.d.ts +10 -0
  27. package/dist/types/lsp/utils.d.ts +3 -2
  28. package/dist/types/main.d.ts +3 -9
  29. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  30. package/dist/types/modes/components/chat-block.d.ts +64 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +4 -1
  32. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  33. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  34. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  35. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  36. package/dist/types/modes/components/status-line.d.ts +2 -0
  37. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  38. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  39. package/dist/types/modes/controllers/event-controller.d.ts +17 -1
  40. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  42. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  43. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  44. package/dist/types/modes/interactive-mode.d.ts +16 -5
  45. package/dist/types/modes/magic-keywords.d.ts +1 -1
  46. package/dist/types/modes/markdown-prose.d.ts +1 -1
  47. package/dist/types/modes/theme/theme.d.ts +1 -1
  48. package/dist/types/modes/types.d.ts +21 -5
  49. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  50. package/dist/types/modes/workflow.d.ts +3 -3
  51. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  52. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  53. package/dist/types/sdk.d.ts +2 -0
  54. package/dist/types/session/agent-session.d.ts +21 -0
  55. package/dist/types/session/auth-storage.d.ts +1 -1
  56. package/dist/types/session/messages.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +8 -3
  58. package/dist/types/slash-commands/types.d.ts +4 -6
  59. package/dist/types/task/executor.d.ts +17 -0
  60. package/dist/types/task/index.d.ts +1 -0
  61. package/dist/types/task/render.d.ts +3 -2
  62. package/dist/types/tools/archive-reader.d.ts +5 -0
  63. package/dist/types/tools/ast-edit.d.ts +3 -0
  64. package/dist/types/tools/ast-grep.d.ts +3 -0
  65. package/dist/types/tools/bash.d.ts +1 -0
  66. package/dist/types/tools/eval.d.ts +8 -0
  67. package/dist/types/tools/find.d.ts +8 -4
  68. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  69. package/dist/types/tools/github-cache.d.ts +12 -0
  70. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  71. package/dist/types/tools/memory-render.d.ts +4 -1
  72. package/dist/types/tools/path-utils.d.ts +8 -0
  73. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  74. package/dist/types/tools/render-utils.d.ts +5 -9
  75. package/dist/types/tools/search.d.ts +6 -2
  76. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  77. package/dist/types/tools/todo.d.ts +3 -2
  78. package/dist/types/tools/write.d.ts +3 -0
  79. package/dist/types/tools/yield.d.ts +8 -0
  80. package/dist/types/tui/output-block.d.ts +16 -4
  81. package/dist/types/tui/status-line.d.ts +3 -0
  82. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  83. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  84. package/package.json +9 -9
  85. package/src/auto-thinking/classifier.ts +5 -1
  86. package/src/cli/args.ts +3 -1
  87. package/src/cli/dry-balance-cli.ts +54 -21
  88. package/src/cli/gallery-cli.ts +4 -1
  89. package/src/cli/gallery-fixtures/misc.ts +29 -0
  90. package/src/cli/startup-cwd.ts +68 -0
  91. package/src/commands/launch.ts +3 -0
  92. package/src/commit/analysis/conventional.ts +2 -2
  93. package/src/commit/analysis/summary.ts +2 -2
  94. package/src/commit/changelog/generate.ts +2 -2
  95. package/src/commit/changelog/index.ts +2 -2
  96. package/src/commit/map-reduce/index.ts +3 -3
  97. package/src/commit/map-reduce/map-phase.ts +2 -2
  98. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  99. package/src/commit/model-selection.ts +36 -11
  100. package/src/commit/pipeline.ts +4 -4
  101. package/src/config/api-key-resolver.ts +58 -0
  102. package/src/config/model-provider-priority.ts +55 -0
  103. package/src/config/model-registry.ts +29 -24
  104. package/src/config/model-resolver.ts +39 -7
  105. package/src/config/settings-schema.ts +10 -0
  106. package/src/config/settings.ts +106 -43
  107. package/src/dap/config.ts +41 -2
  108. package/src/dap/defaults.json +1 -0
  109. package/src/dap/session.ts +1 -0
  110. package/src/dap/types.ts +10 -0
  111. package/src/debug/index.ts +47 -53
  112. package/src/debug/raw-sse-buffer.ts +7 -4
  113. package/src/debug/report-bundle.ts +9 -0
  114. package/src/edit/file-snapshot-store.ts +33 -1
  115. package/src/edit/hashline/filesystem.ts +2 -1
  116. package/src/edit/renderer.ts +82 -78
  117. package/src/eval/__tests__/llm-bridge.test.ts +110 -31
  118. package/src/eval/js/context-manager.ts +32 -15
  119. package/src/eval/llm-bridge.ts +22 -6
  120. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  121. package/src/eval/py/executor.ts +23 -11
  122. package/src/eval/py/prelude.py +1 -1
  123. package/src/extensibility/extensions/types.ts +10 -1
  124. package/src/goals/tools/goal-tool.ts +36 -26
  125. package/src/internal-urls/docs-index.generated.ts +8 -8
  126. package/src/lsp/client.ts +23 -11
  127. package/src/lsp/config.ts +11 -1
  128. package/src/lsp/index.ts +61 -9
  129. package/src/lsp/utils.ts +3 -2
  130. package/src/main.ts +100 -72
  131. package/src/mcp/tool-bridge.ts +2 -0
  132. package/src/memories/index.ts +14 -7
  133. package/src/mnemopi/backend.ts +5 -1
  134. package/src/modes/acp/acp-agent.ts +33 -26
  135. package/src/modes/components/assistant-message.ts +2 -9
  136. package/src/modes/components/chat-block.ts +111 -0
  137. package/src/modes/components/copy-selector.ts +1 -44
  138. package/src/modes/components/custom-editor.ts +164 -109
  139. package/src/modes/components/custom-message.ts +1 -3
  140. package/src/modes/components/execution-shared.ts +1 -2
  141. package/src/modes/components/hook-message.ts +1 -3
  142. package/src/modes/components/model-selector.ts +59 -13
  143. package/src/modes/components/oauth-selector.ts +33 -7
  144. package/src/modes/components/overlay-box.ts +108 -0
  145. package/src/modes/components/plan-review-overlay.ts +799 -0
  146. package/src/modes/components/plan-toc.ts +138 -0
  147. package/src/modes/components/read-tool-group.ts +20 -4
  148. package/src/modes/components/skill-message.ts +0 -1
  149. package/src/modes/components/status-line.ts +19 -4
  150. package/src/modes/components/tips.txt +2 -1
  151. package/src/modes/components/todo-reminder.ts +0 -2
  152. package/src/modes/components/tool-execution.ts +68 -88
  153. package/src/modes/components/transcript-container.ts +84 -24
  154. package/src/modes/components/user-message.ts +2 -3
  155. package/src/modes/controllers/command-controller-shared.ts +7 -6
  156. package/src/modes/controllers/command-controller.ts +57 -55
  157. package/src/modes/controllers/event-controller.ts +67 -40
  158. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  159. package/src/modes/controllers/input-controller.ts +170 -126
  160. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  161. package/src/modes/controllers/selector-controller.ts +23 -25
  162. package/src/modes/controllers/streaming-reveal.ts +212 -0
  163. package/src/modes/controllers/tan-command-controller.ts +173 -0
  164. package/src/modes/interactive-mode.ts +274 -112
  165. package/src/modes/magic-keywords.ts +1 -1
  166. package/src/modes/markdown-prose.ts +1 -1
  167. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  168. package/src/modes/theme/shimmer.ts +20 -9
  169. package/src/modes/theme/theme-schema.json +1 -1
  170. package/src/modes/theme/theme.ts +8 -4
  171. package/src/modes/types.ts +21 -7
  172. package/src/modes/utils/copy-targets.ts +133 -27
  173. package/src/modes/utils/ui-helpers.ts +44 -46
  174. package/src/modes/workflow.ts +10 -10
  175. package/src/plan-mode/approved-plan.ts +66 -43
  176. package/src/plan-mode/plan-protection.ts +4 -4
  177. package/src/prompts/system/background-tan-dispatch.md +8 -0
  178. package/src/prompts/system/plan-mode-active.md +67 -58
  179. package/src/prompts/system/plan-mode-approved.md +1 -1
  180. package/src/prompts/system/workflow-notice.md +1 -1
  181. package/src/prompts/tools/bash.md +9 -0
  182. package/src/prompts/tools/browser.md +1 -1
  183. package/src/prompts/tools/eval.md +2 -1
  184. package/src/prompts/tools/read.md +2 -2
  185. package/src/sdk.ts +37 -46
  186. package/src/session/agent-session.ts +119 -18
  187. package/src/session/auth-storage.ts +2 -0
  188. package/src/session/messages.ts +26 -0
  189. package/src/session/session-manager.ts +109 -28
  190. package/src/slash-commands/builtin-registry.ts +36 -9
  191. package/src/slash-commands/types.ts +4 -6
  192. package/src/task/executor.ts +76 -38
  193. package/src/task/index.ts +4 -0
  194. package/src/task/render.ts +211 -147
  195. package/src/tools/archive-reader.ts +64 -0
  196. package/src/tools/ask.ts +119 -164
  197. package/src/tools/ast-edit.ts +98 -71
  198. package/src/tools/ast-grep.ts +37 -43
  199. package/src/tools/bash.ts +57 -6
  200. package/src/tools/browser/tab-supervisor.ts +13 -1
  201. package/src/tools/browser/tab-worker.ts +33 -4
  202. package/src/tools/debug.ts +20 -8
  203. package/src/tools/eval.ts +13 -2
  204. package/src/tools/fetch.ts +297 -7
  205. package/src/tools/find.ts +51 -30
  206. package/src/tools/gh-cache-invalidation.ts +200 -0
  207. package/src/tools/gh-renderer.ts +81 -42
  208. package/src/tools/github-cache.ts +25 -0
  209. package/src/tools/grouped-file-output.ts +272 -48
  210. package/src/tools/image-gen.ts +150 -103
  211. package/src/tools/inspect-image-renderer.ts +63 -41
  212. package/src/tools/inspect-image.ts +10 -3
  213. package/src/tools/job.ts +3 -4
  214. package/src/tools/memory-render.ts +4 -1
  215. package/src/tools/path-utils.ts +28 -2
  216. package/src/tools/plan-mode-guard.ts +66 -39
  217. package/src/tools/read.ts +48 -28
  218. package/src/tools/render-utils.ts +21 -37
  219. package/src/tools/resolve.ts +14 -0
  220. package/src/tools/search-tool-bm25.ts +36 -23
  221. package/src/tools/search.ts +118 -81
  222. package/src/tools/sqlite-reader.ts +9 -12
  223. package/src/tools/todo.ts +118 -52
  224. package/src/tools/write.ts +83 -64
  225. package/src/tools/yield.ts +10 -1
  226. package/src/tui/output-block.ts +60 -13
  227. package/src/tui/status-line.ts +5 -1
  228. package/src/utils/commit-message-generator.ts +11 -3
  229. package/src/utils/enhanced-paste.ts +230 -0
  230. package/src/utils/title-generator.ts +2 -1
  231. package/src/web/search/providers/anthropic.ts +25 -19
  232. package/src/web/search/providers/codex.ts +37 -8
  233. package/src/web/search/providers/exa.ts +11 -3
  234. package/src/web/search/providers/kimi.ts +28 -17
  235. package/src/web/search/providers/parallel.ts +35 -24
  236. package/src/web/search/providers/synthetic.ts +8 -6
  237. package/src/web/search/providers/tavily.ts +9 -8
  238. package/src/web/search/providers/zai.ts +8 -6
package/src/tools/todo.ts CHANGED
@@ -9,8 +9,8 @@ import type { Theme } from "../modes/theme/theme";
9
9
  import todoDescription from "../prompts/tools/todo.md" with { type: "text" };
10
10
  import type { ToolSession } from "../sdk";
11
11
  import type { SessionEntry } from "../session/session-manager";
12
- import { renderStatusLine, renderTreeList } from "../tui";
13
- import { PREVIEW_LIMITS } from "./render-utils";
12
+ import { framedBlock, renderStatusLine, renderTreeList } from "../tui";
13
+ import { formatErrorDetail, PREVIEW_LIMITS } from "./render-utils";
14
14
 
15
15
  // =============================================================================
16
16
  // Types
@@ -755,19 +755,17 @@ function formatTodoLine(
755
755
  }
756
756
  }
757
757
 
758
- function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
758
+ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme, indent: string): string[] {
759
759
  const lines: string[] = [];
760
760
  for (const phase of phases) {
761
761
  for (const task of phase.tasks) {
762
762
  if (task.status !== "in_progress" || !task.notes || task.notes.length === 0) continue;
763
- const bar = uiTheme.fg("dim", uiTheme.tree.vertical);
764
- const title = uiTheme.fg("dim", chalk.italic(`§ notes — ${task.content}`));
765
763
  lines.push("");
766
- lines.push(` ${title}`);
764
+ lines.push(`${indent}${uiTheme.fg("dim", chalk.italic(`§ notes — ${task.content}`))}`);
767
765
  for (let j = 0; j < task.notes.length; j++) {
768
- if (j > 0) lines.push(` ${bar}`);
766
+ if (j > 0) lines.push("");
769
767
  for (const noteLine of task.notes[j].split("\n")) {
770
- lines.push(` ${bar} ${uiTheme.fg("dim", noteLine)}`);
768
+ lines.push(`${indent} ${uiTheme.fg("dim", noteLine)}`);
771
769
  }
772
770
  }
773
771
  }
@@ -775,8 +773,55 @@ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
775
773
  return lines;
776
774
  }
777
775
 
776
+ /**
777
+ * Phases the latest update touched, plus the active (in_progress) phase.
778
+ * Returns `null` when there is no usable signal, meaning "render every phase
779
+ * fully" — this preserves the legacy view and the manual-expand path.
780
+ */
781
+ function computeTouchedPhases(
782
+ args: TodoRenderArgs | undefined,
783
+ phases: TodoPhase[],
784
+ completedTasks: TodoCompletionTransition[],
785
+ ): Set<string> | null {
786
+ const touched = new Set<string>();
787
+ // The phase holding the in_progress task is where attention sits after the
788
+ // auto-promotion that follows every completion.
789
+ for (const phase of phases) {
790
+ if (phase.tasks.some(task => task.status === "in_progress")) touched.add(phase.name);
791
+ }
792
+ // Phases with a task that just transitioned to completed in this update.
793
+ for (const transition of completedTasks) touched.add(transition.phase);
794
+ // Phases explicitly named by the ops that ran. `init` replaces the whole
795
+ // list, so the entire plan is fresh and every phase counts as touched.
796
+ const ops = Array.isArray(args?.ops) ? args.ops : [];
797
+ for (const op of ops) {
798
+ if (!op || typeof op !== "object") continue;
799
+ if (op.op === "init") {
800
+ for (const phase of phases) touched.add(phase.name);
801
+ break;
802
+ }
803
+ if (typeof op.phase === "string" && op.phase) {
804
+ const named = phases.find(phase => phase.name === op.phase);
805
+ if (named) touched.add(named.name);
806
+ }
807
+ if (typeof op.task === "string" && op.task) {
808
+ const located = findTaskByContent(phases, op.task);
809
+ if (located) touched.add(located.phase.name);
810
+ }
811
+ }
812
+ return touched.size > 0 ? touched : null;
813
+ }
814
+
815
+ /** One-line summary for a collapsed (untouched) phase: dim header + progress. */
816
+ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, uiTheme: Theme): string {
817
+ const total = phase.tasks.length;
818
+ const done = phase.tasks.filter(task => task.status === "completed").length;
819
+ const name = uiTheme.fg("dim", chalk.bold(formatPhaseDisplayName(phase.name, oneBasedIndex)));
820
+ return `${name}${uiTheme.fg("dim", ` ${done}/${total}`)}`;
821
+ }
822
+
778
823
  export const todoToolRenderer = {
779
- renderCall(args: TodoRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
824
+ renderCall(args: TodoRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
780
825
  // `args` here is the raw partially-parsed JSON from the streaming
781
826
  // tool-call delta and may not satisfy `TodoRenderArgs` at runtime:
782
827
  // `parseStreamingJson` can hand back `{ ops: "[" }` mid-delta, or
@@ -797,16 +842,33 @@ export const todoToolRenderer = {
797
842
  }
798
843
  return parts.join(" ");
799
844
  });
800
- const text = renderStatusLine({ icon: "pending", title: "Todo", meta: ops }, uiTheme);
801
- return new Text(text, 0, 0);
845
+ // No body worth boxing while the call streams a lone status line reads
846
+ // cleaner than an empty frame. The container renders it without chrome.
847
+ const header = renderStatusLine(
848
+ { icon: "pending", spinnerFrame: options?.spinnerFrame, title: "Todo", meta: ops },
849
+ uiTheme,
850
+ );
851
+ return new Text(header, 0, 0);
802
852
  },
803
853
 
804
854
  renderResult(
805
- result: { content: Array<{ type: string; text?: string }>; details?: TodoToolDetails },
855
+ result: { content: Array<{ type: string; text?: string }>; details?: TodoToolDetails; isError?: boolean },
806
856
  options: RenderResultOptions,
807
857
  uiTheme: Theme,
808
- _args?: TodoRenderArgs,
858
+ args?: TodoRenderArgs,
809
859
  ): Component {
860
+ if (result.isError) {
861
+ const errorText = result.content?.find(content => content.type === "text")?.text ?? "Todo operation failed";
862
+ const header = renderStatusLine({ icon: "error", title: "Todo" }, uiTheme);
863
+ return framedBlock(uiTheme, width => ({
864
+ header,
865
+ sections: [{ lines: formatErrorDetail(errorText, uiTheme).split("\n") }],
866
+ state: "error",
867
+ borderColor: "error",
868
+ width,
869
+ }));
870
+ }
871
+
810
872
  const phases = (result.details?.phases ?? []).filter(phase => phase.tasks.length > 0);
811
873
  const completedTasks = result.details?.completedTasks ?? [];
812
874
  const completionKeysByPhase = new Map<string, Set<string>>();
@@ -822,48 +884,52 @@ export const todoToolRenderer = {
822
884
  const header = renderStatusLine({ icon: "success", title: "Todo", meta: [`${allTasks.length} tasks`] }, uiTheme);
823
885
  if (allTasks.length === 0) {
824
886
  const fallback = result.content?.find(content => content.type === "text")?.text ?? "No todos";
825
- return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
887
+ return new Text(`${header}\n ${uiTheme.fg("dim", fallback)}`, 0, 0);
826
888
  }
827
889
 
828
- let cachedKey: string | undefined;
829
- let cachedLines: string[] | undefined;
830
- return {
831
- invalidate(): void {
832
- cachedKey = undefined;
833
- cachedLines = undefined;
834
- },
835
- render(width: number): string[] {
836
- const { expanded, spinnerFrame } = options;
837
- const key = `${expanded ? 1 : 0}:${spinnerFrame ?? -1}:${width}`;
838
- if (cachedKey === key && cachedLines) return cachedLines;
839
-
840
- const lines: string[] = [header];
841
- for (let p = 0; p < phases.length; p++) {
842
- const phase = phases[p];
843
- if (phases.length > 1) {
844
- lines.push(uiTheme.fg("accent", chalk.bold(` ${formatPhaseDisplayName(phase.name, p + 1)}`)));
845
- }
846
- const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS;
847
- const treeLines = renderTreeList(
848
- {
849
- items: phase.tasks,
850
- expanded,
851
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
852
- itemType: "todo",
853
- renderItem: todo => formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame),
854
- },
855
- uiTheme,
856
- );
857
- for (const line of treeLines) {
858
- lines.push(` ${line}`);
859
- }
890
+ return framedBlock(uiTheme, width => {
891
+ const { expanded, spinnerFrame } = options;
892
+ const multiPhase = phases.length > 1;
893
+ const indent = multiPhase ? " " : "";
894
+ // Collapse phases this update didn't touch down to a one-line summary so
895
+ // a single task flip doesn't redraw every phase's full task list. The
896
+ // manual expand toggle (and the no-signal fallback) still shows all.
897
+ const touched = expanded || !multiPhase ? null : computeTouchedPhases(args, phases, completedTasks);
898
+ const bodyLines: string[] = [];
899
+ for (let p = 0; p < phases.length; p++) {
900
+ const phase = phases[p];
901
+ if (touched && !touched.has(phase.name)) {
902
+ bodyLines.push(formatPhaseSummary(phase, p + 1, uiTheme));
903
+ continue;
860
904
  }
861
- lines.push(...renderNoteAttachments(phases, uiTheme));
862
- cachedKey = key;
863
- cachedLines = lines;
864
- return lines;
865
- },
866
- };
905
+ if (multiPhase) {
906
+ bodyLines.push(uiTheme.fg("accent", chalk.bold(formatPhaseDisplayName(phase.name, p + 1))));
907
+ }
908
+ const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS;
909
+ const treeLines = renderTreeList(
910
+ {
911
+ items: phase.tasks,
912
+ expanded,
913
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
914
+ itemType: "todo",
915
+ renderItem: todo => formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame),
916
+ },
917
+ uiTheme,
918
+ );
919
+ for (const line of treeLines) {
920
+ bodyLines.push(`${indent}${line}`);
921
+ }
922
+ }
923
+ bodyLines.push(...renderNoteAttachments(phases, uiTheme, indent));
924
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
925
+ return {
926
+ header,
927
+ sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
928
+ state: options.isPartial ? "pending" : "success",
929
+ borderColor: "borderMuted",
930
+ width,
931
+ };
932
+ });
867
933
  },
868
934
  mergeCallAndResult: true,
869
935
  };
@@ -5,11 +5,10 @@ import * as path from "node:path";
5
5
  import { formatHashlineHeader, stripHashlinePrefixes } from "@oh-my-pi/hashline";
6
6
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
7
  import type { Component } from "@oh-my-pi/pi-tui";
8
- import { Text } from "@oh-my-pi/pi-tui";
9
8
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
9
  import * as z from "zod/v4";
11
10
 
12
- import { getFileSnapshotStore } from "../edit/file-snapshot-store";
11
+ import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store";
13
12
  import { normalizeToLF } from "../edit/normalize";
14
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
15
14
  import { InternalUrlRouter } from "../internal-urls";
@@ -19,7 +18,7 @@ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
19
18
  import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
20
19
  import writeDescription from "../prompts/tools/write.md" with { type: "text" };
21
20
  import type { ToolSession } from "../sdk";
22
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
21
+ import { fileHyperlink, framedBlock, renderStatusLine } from "../tui";
23
22
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
24
23
  import { truncateForPrompt } from "./approval";
25
24
  import { parseArchivePathCandidates } from "./archive-reader";
@@ -40,8 +39,6 @@ import {
40
39
  formatErrorDetail,
41
40
  formatExpandHint,
42
41
  formatMoreItems,
43
- formatStatusIcon,
44
- formatTitle,
45
42
  getLspBatchRequest,
46
43
  replaceTabs,
47
44
  shortenPath,
@@ -80,6 +77,9 @@ export interface WriteToolDetails {
80
77
  meta?: OutputMeta;
81
78
  /** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */
82
79
  madeExecutable?: boolean;
80
+ /** Absolute filesystem path the write resolved to. Used by the renderer to wrap
81
+ * the (possibly cwd-relative) header path in an OSC 8 `file://` hyperlink. */
82
+ resolvedPath?: string;
83
83
  }
84
84
 
85
85
  /**
@@ -132,7 +132,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
132
132
  function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, content: string): string | undefined {
133
133
  if (!resolveFileDisplayMode(session).hashLines) return undefined;
134
134
  const normalized = normalizeToLF(content);
135
- const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
135
+ const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
136
136
  return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag);
137
137
  }
138
138
 
@@ -419,7 +419,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
419
419
  }`;
420
420
  return {
421
421
  content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${outputPath}` }],
422
- details: {},
422
+ details: { resolvedPath: resolvedArchivePath.absolutePath },
423
423
  };
424
424
  }
425
425
 
@@ -535,7 +535,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
535
535
  }
536
536
 
537
537
  invalidateFsScanAfterWrite(resolvedSqlitePath.absolutePath);
538
- return toolResult<WriteToolDetails>({}).text(resultText).sourcePath(resolvedSqlitePath.absolutePath).done();
538
+ return toolResult<WriteToolDetails>({ resolvedPath: resolvedSqlitePath.absolutePath })
539
+ .text(resultText)
540
+ .sourcePath(resolvedSqlitePath.absolutePath)
541
+ .done();
539
542
  } catch (error) {
540
543
  if (isEnoent(error)) {
541
544
  throw new ToolError(`SQLite database '${displayPath}' not found`);
@@ -596,12 +599,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
596
599
  if (!diagnostics) {
597
600
  return {
598
601
  content: [{ type: "text", text: resultText }],
599
- details: {},
602
+ details: { resolvedPath: absolutePath },
600
603
  };
601
604
  }
602
605
  return {
603
606
  content: [{ type: "text", text: resultText }],
604
607
  details: {
608
+ resolvedPath: absolutePath,
605
609
  diagnostics,
606
610
  meta: outputMeta()
607
611
  .diagnostics(diagnostics.summary, diagnostics.messages ?? [])
@@ -874,7 +878,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
874
878
  if (stripped) {
875
879
  resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
876
880
  }
877
- return { content: [{ type: "text", text: resultText }], details: {} };
881
+ return {
882
+ content: [{ type: "text", text: resultText }],
883
+ details: { resolvedPath: absolutePath },
884
+ };
878
885
  }
879
886
 
880
887
  const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
@@ -891,13 +898,14 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
891
898
  if (!diagnostics) {
892
899
  return {
893
900
  content: [{ type: "text", text: resultText }],
894
- details: { madeExecutable: madeExecutable || undefined },
901
+ details: { resolvedPath: absolutePath, madeExecutable: madeExecutable || undefined },
895
902
  };
896
903
  }
897
904
 
898
905
  return {
899
906
  content: [{ type: "text", text: resultText }],
900
907
  details: {
908
+ resolvedPath: absolutePath,
901
909
  diagnostics,
902
910
  madeExecutable: madeExecutable || undefined,
903
911
  meta: outputMeta()
@@ -961,9 +969,9 @@ function formatStreamingContent(
961
969
  }
962
970
  for (let i = 0; i < highlighted.length; i++) {
963
971
  const lineNum = startIndex + i + 1;
964
- const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")}│`);
972
+ const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
965
973
  const body = replaceTabs(highlighted[i] ?? "");
966
- text += ` ${gutter}${body}\n`;
974
+ text += `${gutter}${body}\n`;
967
975
  }
968
976
  text += uiTheme.fg("dim", `… (streaming)`);
969
977
  return text;
@@ -987,9 +995,9 @@ function renderContentPreview(
987
995
  let text = "\n\n";
988
996
  for (let i = 0; i < highlighted.length; i++) {
989
997
  const lineNum = i + 1;
990
- const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")}│`);
998
+ const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
991
999
  const body = replaceTabs(highlighted[i] ?? "");
992
- text += ` ${gutter}${body}\n`;
1000
+ text += `${gutter}${body}\n`;
993
1001
  }
994
1002
  if (!expanded && hidden > 0) {
995
1003
  const hint = formatExpandHint(uiTheme, expanded, hidden > 0);
@@ -1006,19 +1014,29 @@ export const writeToolRenderer = {
1006
1014
  const lang = getLanguageFromPath(rawPath) ?? "text";
1007
1015
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1008
1016
  const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
1009
- const spinner =
1010
- options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
1011
-
1012
- let text = `${formatTitle("Write", uiTheme)} ${spinner ? `${spinner} ` : ""}${langIcon} ${pathDisplay}`;
1013
-
1014
- if (!args.content) {
1015
- return new Text(text, 0, 0);
1016
- }
1017
-
1018
- // Show streaming preview of content — bounded tail while collapsed, full on Ctrl+O.
1019
- text += formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme);
1020
-
1021
- return new Text(text, 0, 0);
1017
+ const header = renderStatusLine(
1018
+ {
1019
+ icon: "pending",
1020
+ spinnerFrame: options?.spinnerFrame,
1021
+ title: "Write",
1022
+ description: `${langIcon} ${pathDisplay}`,
1023
+ },
1024
+ uiTheme,
1025
+ );
1026
+ return framedBlock(uiTheme, width => {
1027
+ const body = args.content
1028
+ ? formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme)
1029
+ : "";
1030
+ const bodyLines = body ? body.split("\n") : [];
1031
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
1032
+ return {
1033
+ header,
1034
+ sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
1035
+ state: "pending",
1036
+ borderColor: "borderMuted",
1037
+ width,
1038
+ };
1039
+ });
1022
1040
  },
1023
1041
 
1024
1042
  // Only the expanded (Ctrl+O) preview is append-only: it renders the whole
@@ -1043,23 +1061,33 @@ export const writeToolRenderer = {
1043
1061
  const fileContent = args?.content || "";
1044
1062
  const lang = getLanguageFromPath(rawPath);
1045
1063
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1046
- const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
1064
+ // The header shows the cwd-relative path but links to the absolute path the
1065
+ // write resolved to (args.path may be relative, which would yield a broken
1066
+ // `file://` URI). Falls back to plain text when the result lacks a path.
1067
+ const linkTarget = result.details?.resolvedPath;
1068
+ const styledPath = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
1069
+ const pathDisplay = filePath && linkTarget ? fileHyperlink(linkTarget, styledPath) : styledPath;
1047
1070
 
1048
1071
  if (result.isError) {
1049
1072
  const errorText = result.content?.find(c => c.type === "text")?.text ?? "";
1050
- const errorHeader = renderStatusLine(
1073
+ const header = renderStatusLine(
1051
1074
  { icon: "error", title: "Write", description: `${langIcon} ${pathDisplay}` },
1052
1075
  uiTheme,
1053
1076
  );
1054
- return new Text(`${errorHeader}\n${formatErrorDetail(errorText, uiTheme)}`, 0, 0);
1077
+ return framedBlock(uiTheme, width => ({
1078
+ header,
1079
+ sections: [{ lines: formatErrorDetail(errorText, uiTheme).split("\n") }],
1080
+ state: "error",
1081
+ borderColor: "error",
1082
+ width,
1083
+ }));
1055
1084
  }
1085
+
1056
1086
  const lineCount = countLines(fileContent);
1057
1087
  const lineSuffix = formatLineCountSuffix(lineCount, uiTheme);
1058
1088
  const execSuffix = result.details?.madeExecutable
1059
1089
  ? `${uiTheme.fg("dim", " · ")}${uiTheme.fg("success", "made executable!")}`
1060
1090
  : "";
1061
-
1062
- // Build header with status icon
1063
1091
  const header = renderStatusLine(
1064
1092
  {
1065
1093
  icon: "success",
@@ -1070,38 +1098,29 @@ export const writeToolRenderer = {
1070
1098
  );
1071
1099
  const diagnostics = result.details?.diagnostics;
1072
1100
 
1073
- let cached: RenderCache | undefined;
1074
-
1075
- return {
1076
- render(width: number) {
1077
- const { expanded } = options;
1078
- const key = new Hasher().bool(expanded).u32(width).digest();
1079
- if (cached?.key === key) return cached.lines;
1080
-
1081
- let text = header;
1082
- text += renderContentPreview(fileContent, expanded, lang, uiTheme);
1083
-
1084
- if (diagnostics) {
1085
- const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
1086
- uiTheme.getLangIcon(getLanguageFromPath(fp)),
1087
- );
1088
- if (diagText.trim()) {
1089
- const diagLines = diagText.split("\n");
1090
- const firstNonEmpty = diagLines.findIndex(line => line.trim());
1091
- if (firstNonEmpty >= 0) {
1092
- text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
1093
- }
1094
- }
1101
+ return framedBlock(uiTheme, width => {
1102
+ const { expanded } = options;
1103
+ let body = renderContentPreview(fileContent, expanded, lang, uiTheme);
1104
+ if (diagnostics) {
1105
+ const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
1106
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
1107
+ );
1108
+ if (diagText.trim()) {
1109
+ const diagLines = diagText.split("\n");
1110
+ const firstNonEmpty = diagLines.findIndex(line => line.trim());
1111
+ if (firstNonEmpty >= 0) body += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
1095
1112
  }
1096
-
1097
- const lines = text.split("\n").map(l => truncateToWidth(l, width, Ellipsis.Omit));
1098
- cached = { key, lines };
1099
- return lines;
1100
- },
1101
- invalidate() {
1102
- cached = undefined;
1103
- },
1104
- };
1113
+ }
1114
+ const bodyLines = body.split("\n");
1115
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
1116
+ return {
1117
+ header,
1118
+ sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
1119
+ state: "success",
1120
+ borderColor: "borderMuted",
1121
+ width,
1122
+ };
1123
+ });
1105
1124
  },
1106
1125
  mergeCallAndResult: true,
1107
1126
  };
@@ -20,6 +20,14 @@ export interface YieldDetails {
20
20
  data: unknown;
21
21
  status: "success" | "aborted";
22
22
  error?: string;
23
+ /**
24
+ * Set when the yield tool exhausted its in-tool schema-retry budget
25
+ * (MAX_SCHEMA_RETRIES) and accepted the data anyway. Surfaced so the
26
+ * executor's post-mortem finalizer can honor the override instead of
27
+ * re-rejecting the same payload with `schema_violation` — keeping the
28
+ * subagent's acceptance and the parent's view of the result in lockstep.
29
+ */
30
+ schemaOverridden?: boolean;
23
31
  }
24
32
 
25
33
  function formatSchema(schema: unknown): string {
@@ -237,7 +245,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
237
245
  : "Result submitted.";
238
246
  return {
239
247
  content: [{ type: "text", text: responseText }],
240
- details: { data, status, error: errorMessage },
248
+ details: { data, status, error: errorMessage, schemaOverridden: schemaValidationOverridden || undefined },
241
249
  };
242
250
  }
243
251
  }
@@ -254,6 +262,7 @@ subprocessToolRegistry.register<YieldDetails>("yield", {
254
262
  data: record.data,
255
263
  status,
256
264
  error: typeof record.error === "string" ? record.error : undefined,
265
+ schemaOverridden: record.schemaOverridden === true ? true : undefined,
257
266
  };
258
267
  },
259
268
  shouldTerminate: event => !event.isError,