@oh-my-pi/pi-coding-agent 15.9.67 → 15.10.1

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 (266) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  4. package/dist/types/cli/gallery-cli.d.ts +43 -0
  5. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  6. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  8. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  9. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  10. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  11. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  12. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  15. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  16. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  17. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  18. package/dist/types/commands/gallery.d.ts +47 -0
  19. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  20. package/dist/types/commit/analysis/summary.d.ts +2 -2
  21. package/dist/types/commit/changelog/generate.d.ts +2 -2
  22. package/dist/types/commit/changelog/index.d.ts +2 -2
  23. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  24. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  25. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  26. package/dist/types/commit/model-selection.d.ts +10 -4
  27. package/dist/types/config/api-key-resolver.d.ts +34 -0
  28. package/dist/types/config/keybindings.d.ts +6 -1
  29. package/dist/types/config/model-id-affixes.d.ts +2 -0
  30. package/dist/types/config/model-registry.d.ts +25 -2
  31. package/dist/types/config/settings-schema.d.ts +41 -6
  32. package/dist/types/dap/config.d.ts +14 -1
  33. package/dist/types/dap/types.d.ts +10 -0
  34. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  35. package/dist/types/lsp/types.d.ts +10 -0
  36. package/dist/types/lsp/utils.d.ts +3 -2
  37. package/dist/types/main.d.ts +3 -2
  38. package/dist/types/memory-backend/index.d.ts +2 -1
  39. package/dist/types/memory-backend/resolve.d.ts +1 -1
  40. package/dist/types/memory-backend/types.d.ts +1 -1
  41. package/dist/types/modes/components/chat-block.d.ts +64 -0
  42. package/dist/types/modes/components/custom-editor.d.ts +5 -1
  43. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  44. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  45. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  46. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  47. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  48. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  49. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  50. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  51. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  52. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  53. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  54. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  55. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  56. package/dist/types/modes/index.d.ts +5 -4
  57. package/dist/types/modes/interactive-mode.d.ts +16 -6
  58. package/dist/types/modes/setup-version.d.ts +11 -0
  59. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  60. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  61. package/dist/types/modes/theme/theme.d.ts +1 -1
  62. package/dist/types/modes/types.d.ts +19 -6
  63. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  64. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  65. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  66. package/dist/types/sdk.d.ts +3 -1
  67. package/dist/types/session/agent-session.d.ts +21 -0
  68. package/dist/types/session/messages.d.ts +12 -0
  69. package/dist/types/session/session-manager.d.ts +3 -1
  70. package/dist/types/slash-commands/types.d.ts +4 -6
  71. package/dist/types/task/executor.d.ts +14 -0
  72. package/dist/types/task/index.d.ts +1 -0
  73. package/dist/types/task/render.d.ts +3 -2
  74. package/dist/types/telemetry-export.d.ts +1 -1
  75. package/dist/types/tools/archive-reader.d.ts +5 -0
  76. package/dist/types/tools/ast-edit.d.ts +3 -0
  77. package/dist/types/tools/ast-grep.d.ts +3 -0
  78. package/dist/types/tools/bash.d.ts +1 -0
  79. package/dist/types/tools/eval-render.d.ts +1 -8
  80. package/dist/types/tools/fetch.d.ts +15 -7
  81. package/dist/types/tools/find.d.ts +8 -4
  82. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  83. package/dist/types/tools/memory-render.d.ts +4 -1
  84. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  85. package/dist/types/tools/render-utils.d.ts +13 -9
  86. package/dist/types/tools/renderers.d.ts +16 -2
  87. package/dist/types/tools/search.d.ts +5 -1
  88. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  89. package/dist/types/tools/todo.d.ts +3 -2
  90. package/dist/types/tools/write.d.ts +5 -0
  91. package/dist/types/tui/output-block.d.ts +16 -4
  92. package/dist/types/tui/status-line.d.ts +3 -0
  93. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  94. package/dist/types/web/scrapers/github.d.ts +22 -0
  95. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  96. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  97. package/dist/types/web/search/types.d.ts +1 -1
  98. package/package.json +9 -9
  99. package/scripts/dev-launch +42 -0
  100. package/scripts/dev-launch-preload.ts +19 -0
  101. package/src/auto-thinking/classifier.ts +5 -1
  102. package/src/cli/args.ts +2 -2
  103. package/src/cli/dry-balance-cli.ts +52 -17
  104. package/src/cli/gallery-cli.ts +226 -0
  105. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  106. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  107. package/src/cli/gallery-fixtures/edit.ts +194 -0
  108. package/src/cli/gallery-fixtures/fs.ts +153 -0
  109. package/src/cli/gallery-fixtures/index.ts +40 -0
  110. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  111. package/src/cli/gallery-fixtures/memory.ts +81 -0
  112. package/src/cli/gallery-fixtures/misc.ts +250 -0
  113. package/src/cli/gallery-fixtures/search.ts +213 -0
  114. package/src/cli/gallery-fixtures/shell.ts +167 -0
  115. package/src/cli/gallery-fixtures/types.ts +41 -0
  116. package/src/cli/gallery-fixtures/web.ts +158 -0
  117. package/src/cli/gallery-screenshot.ts +279 -0
  118. package/src/cli-commands.ts +1 -0
  119. package/src/commands/gallery.ts +52 -0
  120. package/src/commands/launch.ts +1 -1
  121. package/src/commit/analysis/conventional.ts +2 -2
  122. package/src/commit/analysis/summary.ts +2 -2
  123. package/src/commit/changelog/generate.ts +2 -2
  124. package/src/commit/changelog/index.ts +2 -2
  125. package/src/commit/map-reduce/index.ts +3 -3
  126. package/src/commit/map-reduce/map-phase.ts +2 -2
  127. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  128. package/src/commit/model-selection.ts +33 -9
  129. package/src/commit/pipeline.ts +4 -4
  130. package/src/config/api-key-resolver.ts +58 -0
  131. package/src/config/keybindings.ts +15 -6
  132. package/src/config/model-equivalence.ts +35 -12
  133. package/src/config/model-id-affixes.ts +39 -22
  134. package/src/config/model-registry.ts +41 -18
  135. package/src/config/settings-schema.ts +28 -5
  136. package/src/config/settings.ts +31 -2
  137. package/src/dap/client.ts +14 -16
  138. package/src/dap/config.ts +41 -2
  139. package/src/dap/defaults.json +1 -0
  140. package/src/dap/session.ts +1 -0
  141. package/src/dap/types.ts +10 -0
  142. package/src/debug/index.ts +40 -54
  143. package/src/edit/renderer.ts +111 -119
  144. package/src/eval/__tests__/agent-bridge.test.ts +75 -32
  145. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  146. package/src/eval/agent-bridge.ts +34 -7
  147. package/src/eval/llm-bridge.ts +8 -3
  148. package/src/extensibility/extensions/runner.ts +1 -0
  149. package/src/extensibility/plugins/doctor.ts +0 -1
  150. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  151. package/src/goals/tools/goal-tool.ts +37 -27
  152. package/src/internal-urls/docs-index.generated.ts +10 -10
  153. package/src/lsp/client.ts +104 -55
  154. package/src/lsp/types.ts +10 -0
  155. package/src/lsp/utils.ts +3 -2
  156. package/src/main.ts +53 -56
  157. package/src/memories/index.ts +12 -5
  158. package/src/memory-backend/index.ts +13 -1
  159. package/src/memory-backend/resolve.ts +3 -5
  160. package/src/memory-backend/types.ts +1 -1
  161. package/src/mnemopi/backend.ts +5 -1
  162. package/src/modes/acp/acp-agent.ts +33 -26
  163. package/src/modes/components/assistant-message.ts +2 -9
  164. package/src/modes/components/chat-block.ts +111 -0
  165. package/src/modes/components/copy-selector.ts +1 -44
  166. package/src/modes/components/custom-editor.ts +33 -1
  167. package/src/modes/components/custom-message.ts +1 -3
  168. package/src/modes/components/execution-shared.ts +1 -2
  169. package/src/modes/components/hook-message.ts +1 -3
  170. package/src/modes/components/overlay-box.ts +108 -0
  171. package/src/modes/components/plan-review-overlay.ts +799 -0
  172. package/src/modes/components/plan-toc.ts +138 -0
  173. package/src/modes/components/read-tool-group.ts +20 -4
  174. package/src/modes/components/skill-message.ts +0 -1
  175. package/src/modes/components/status-line.ts +3 -5
  176. package/src/modes/components/tips.txt +1 -0
  177. package/src/modes/components/todo-reminder.ts +0 -2
  178. package/src/modes/components/tool-execution.ts +115 -90
  179. package/src/modes/components/transcript-container.ts +84 -24
  180. package/src/modes/components/user-message.ts +1 -2
  181. package/src/modes/controllers/command-controller-shared.ts +7 -6
  182. package/src/modes/controllers/command-controller.ts +70 -57
  183. package/src/modes/controllers/event-controller.ts +41 -40
  184. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  185. package/src/modes/controllers/input-controller.ts +135 -122
  186. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  187. package/src/modes/controllers/selector-controller.ts +25 -27
  188. package/src/modes/controllers/streaming-reveal.ts +212 -0
  189. package/src/modes/controllers/tan-command-controller.ts +173 -0
  190. package/src/modes/index.ts +5 -4
  191. package/src/modes/interactive-mode.ts +171 -82
  192. package/src/modes/setup-version.ts +11 -0
  193. package/src/modes/setup-wizard/index.ts +3 -2
  194. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  195. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  196. package/src/modes/theme/theme-schema.json +1 -1
  197. package/src/modes/theme/theme.ts +8 -4
  198. package/src/modes/types.ts +19 -8
  199. package/src/modes/utils/context-usage.ts +10 -6
  200. package/src/modes/utils/copy-targets.ts +133 -27
  201. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  202. package/src/modes/utils/ui-helpers.ts +44 -46
  203. package/src/plan-mode/approved-plan.ts +66 -43
  204. package/src/plan-mode/plan-protection.ts +4 -4
  205. package/src/prompts/system/background-tan-dispatch.md +8 -0
  206. package/src/prompts/system/plan-mode-active.md +67 -58
  207. package/src/prompts/system/plan-mode-approved.md +1 -1
  208. package/src/sdk.ts +32 -60
  209. package/src/session/agent-session.ts +89 -13
  210. package/src/session/messages.ts +26 -0
  211. package/src/session/session-manager.ts +13 -5
  212. package/src/slash-commands/builtin-registry.ts +37 -10
  213. package/src/slash-commands/helpers/usage-report.ts +2 -0
  214. package/src/slash-commands/types.ts +4 -6
  215. package/src/task/executor.ts +25 -4
  216. package/src/task/index.ts +4 -0
  217. package/src/task/render.ts +212 -148
  218. package/src/telemetry-export.ts +25 -7
  219. package/src/tools/archive-reader.ts +64 -0
  220. package/src/tools/ask.ts +119 -164
  221. package/src/tools/ast-edit.ts +98 -71
  222. package/src/tools/ast-grep.ts +37 -43
  223. package/src/tools/bash.ts +50 -6
  224. package/src/tools/debug.ts +20 -8
  225. package/src/tools/eval-backends.ts +6 -17
  226. package/src/tools/eval-render.ts +21 -18
  227. package/src/tools/eval.ts +5 -4
  228. package/src/tools/fetch.ts +391 -91
  229. package/src/tools/find.ts +44 -30
  230. package/src/tools/gh-renderer.ts +81 -42
  231. package/src/tools/grouped-file-output.ts +272 -48
  232. package/src/tools/image-gen.ts +150 -103
  233. package/src/tools/inspect-image-renderer.ts +63 -41
  234. package/src/tools/inspect-image.ts +8 -1
  235. package/src/tools/job.ts +3 -4
  236. package/src/tools/memory-render.ts +4 -1
  237. package/src/tools/plan-mode-guard.ts +21 -39
  238. package/src/tools/read.ts +23 -16
  239. package/src/tools/render-utils.ts +38 -40
  240. package/src/tools/renderers.ts +16 -1
  241. package/src/tools/report-tool-issue.ts +1 -1
  242. package/src/tools/resolve.ts +14 -0
  243. package/src/tools/search-tool-bm25.ts +36 -23
  244. package/src/tools/search.ts +189 -95
  245. package/src/tools/sqlite-reader.ts +9 -12
  246. package/src/tools/todo.ts +138 -59
  247. package/src/tools/write.ts +100 -60
  248. package/src/tui/output-block.ts +60 -13
  249. package/src/tui/status-line.ts +5 -1
  250. package/src/utils/commit-message-generator.ts +9 -1
  251. package/src/utils/enhanced-paste.ts +202 -0
  252. package/src/utils/title-generator.ts +2 -1
  253. package/src/web/scrapers/github.ts +255 -3
  254. package/src/web/scrapers/youtube.ts +3 -2
  255. package/src/web/search/providers/anthropic.ts +25 -19
  256. package/src/web/search/providers/exa.ts +11 -3
  257. package/src/web/search/providers/kimi.ts +28 -17
  258. package/src/web/search/providers/parallel.ts +35 -24
  259. package/src/web/search/providers/perplexity.ts +199 -51
  260. package/src/web/search/providers/synthetic.ts +8 -6
  261. package/src/web/search/providers/tavily.ts +9 -8
  262. package/src/web/search/providers/zai.ts +8 -6
  263. package/src/web/search/render.ts +39 -54
  264. package/src/web/search/types.ts +5 -1
  265. package/dist/types/eval/__tests__/shared-executors.test.d.ts +0 -1
  266. package/src/eval/__tests__/shared-executors.test.ts +0 -609
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,25 +773,102 @@ 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 {
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"];
787
- const text = renderStatusLine({ icon: "pending", title: "Todo", meta: ops }, uiTheme);
788
- return new Text(text, 0, 0);
824
+ renderCall(args: TodoRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
825
+ // `args` here is the raw partially-parsed JSON from the streaming
826
+ // tool-call delta and may not satisfy `TodoRenderArgs` at runtime:
827
+ // `parseStreamingJson` can hand back `{ ops: "[" }` mid-delta, or
828
+ // entries that are `null` / strings before fields stream. Guard
829
+ // against non-array `ops` and non-object entries so a malformed
830
+ // delta never breaks the TUI render loop (#2005).
831
+ const opsList = Array.isArray(args?.ops) ? args.ops : [];
832
+ const ops =
833
+ opsList.length === 0
834
+ ? ["update"]
835
+ : opsList.map(entry => {
836
+ const e = entry && typeof entry === "object" ? entry : ({} as NonNullable<typeof entry>);
837
+ const parts = [e.op ?? "update"];
838
+ if (e.task) parts.push(e.task);
839
+ if (e.phase) parts.push(e.phase);
840
+ if (Array.isArray(e.items) && e.items.length) {
841
+ parts.push(`${e.items.length} item${e.items.length === 1 ? "" : "s"}`);
842
+ }
843
+ return parts.join(" ");
844
+ });
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);
789
852
  },
790
853
 
791
854
  renderResult(
792
- result: { content: Array<{ type: string; text?: string }>; details?: TodoToolDetails },
855
+ result: { content: Array<{ type: string; text?: string }>; details?: TodoToolDetails; isError?: boolean },
793
856
  options: RenderResultOptions,
794
857
  uiTheme: Theme,
795
- _args?: TodoRenderArgs,
858
+ args?: TodoRenderArgs,
796
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
+
797
872
  const phases = (result.details?.phases ?? []).filter(phase => phase.tasks.length > 0);
798
873
  const completedTasks = result.details?.completedTasks ?? [];
799
874
  const completionKeysByPhase = new Map<string, Set<string>>();
@@ -809,48 +884,52 @@ export const todoToolRenderer = {
809
884
  const header = renderStatusLine({ icon: "success", title: "Todo", meta: [`${allTasks.length} tasks`] }, uiTheme);
810
885
  if (allTasks.length === 0) {
811
886
  const fallback = result.content?.find(content => content.type === "text")?.text ?? "No todos";
812
- return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
887
+ return new Text(`${header}\n ${uiTheme.fg("dim", fallback)}`, 0, 0);
813
888
  }
814
889
 
815
- let cachedKey: string | undefined;
816
- let cachedLines: string[] | undefined;
817
- return {
818
- invalidate(): void {
819
- cachedKey = undefined;
820
- cachedLines = undefined;
821
- },
822
- render(width: number): string[] {
823
- const { expanded, spinnerFrame } = options;
824
- const key = `${expanded ? 1 : 0}:${spinnerFrame ?? -1}:${width}`;
825
- if (cachedKey === key && cachedLines) return cachedLines;
826
-
827
- const lines: string[] = [header];
828
- for (let p = 0; p < phases.length; p++) {
829
- const phase = phases[p];
830
- if (phases.length > 1) {
831
- lines.push(uiTheme.fg("accent", chalk.bold(` ${formatPhaseDisplayName(phase.name, p + 1)}`)));
832
- }
833
- const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS;
834
- const treeLines = renderTreeList(
835
- {
836
- items: phase.tasks,
837
- expanded,
838
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
839
- itemType: "todo",
840
- renderItem: todo => formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame),
841
- },
842
- uiTheme,
843
- );
844
- for (const line of treeLines) {
845
- lines.push(` ${line}`);
846
- }
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;
847
904
  }
848
- lines.push(...renderNoteAttachments(phases, uiTheme));
849
- cachedKey = key;
850
- cachedLines = lines;
851
- return lines;
852
- },
853
- };
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
+ });
854
933
  },
855
934
  mergeCallAndResult: true,
856
935
  };
@@ -5,7 +5,6 @@ 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
 
@@ -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";
@@ -37,10 +36,9 @@ import { formatPathRelativeToCwd, isInternalUrlPath } from "./path-utils";
37
36
  import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
38
37
  import {
39
38
  formatDiagnostics,
39
+ formatErrorDetail,
40
40
  formatExpandHint,
41
41
  formatMoreItems,
42
- formatStatusIcon,
43
- formatTitle,
44
42
  getLspBatchRequest,
45
43
  replaceTabs,
46
44
  shortenPath,
@@ -79,6 +77,9 @@ export interface WriteToolDetails {
79
77
  meta?: OutputMeta;
80
78
  /** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */
81
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;
82
83
  }
83
84
 
84
85
  /**
@@ -418,7 +419,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
418
419
  }`;
419
420
  return {
420
421
  content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${outputPath}` }],
421
- details: {},
422
+ details: { resolvedPath: resolvedArchivePath.absolutePath },
422
423
  };
423
424
  }
424
425
 
@@ -534,7 +535,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
534
535
  }
535
536
 
536
537
  invalidateFsScanAfterWrite(resolvedSqlitePath.absolutePath);
537
- 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();
538
542
  } catch (error) {
539
543
  if (isEnoent(error)) {
540
544
  throw new ToolError(`SQLite database '${displayPath}' not found`);
@@ -595,12 +599,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
595
599
  if (!diagnostics) {
596
600
  return {
597
601
  content: [{ type: "text", text: resultText }],
598
- details: {},
602
+ details: { resolvedPath: absolutePath },
599
603
  };
600
604
  }
601
605
  return {
602
606
  content: [{ type: "text", text: resultText }],
603
607
  details: {
608
+ resolvedPath: absolutePath,
604
609
  diagnostics,
605
610
  meta: outputMeta()
606
611
  .diagnostics(diagnostics.summary, diagnostics.messages ?? [])
@@ -873,7 +878,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
873
878
  if (stripped) {
874
879
  resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
875
880
  }
876
- return { content: [{ type: "text", text: resultText }], details: {} };
881
+ return {
882
+ content: [{ type: "text", text: resultText }],
883
+ details: { resolvedPath: absolutePath },
884
+ };
877
885
  }
878
886
 
879
887
  const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
@@ -890,13 +898,14 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
890
898
  if (!diagnostics) {
891
899
  return {
892
900
  content: [{ type: "text", text: resultText }],
893
- details: { madeExecutable: madeExecutable || undefined },
901
+ details: { resolvedPath: absolutePath, madeExecutable: madeExecutable || undefined },
894
902
  };
895
903
  }
896
904
 
897
905
  return {
898
906
  content: [{ type: "text", text: resultText }],
899
907
  details: {
908
+ resolvedPath: absolutePath,
900
909
  diagnostics,
901
910
  madeExecutable: madeExecutable || undefined,
902
911
  meta: outputMeta()
@@ -960,9 +969,9 @@ function formatStreamingContent(
960
969
  }
961
970
  for (let i = 0; i < highlighted.length; i++) {
962
971
  const lineNum = startIndex + i + 1;
963
- const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")}│`);
972
+ const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
964
973
  const body = replaceTabs(highlighted[i] ?? "");
965
- text += ` ${gutter}${body}\n`;
974
+ text += `${gutter}${body}\n`;
966
975
  }
967
976
  text += uiTheme.fg("dim", `… (streaming)`);
968
977
  return text;
@@ -986,9 +995,9 @@ function renderContentPreview(
986
995
  let text = "\n\n";
987
996
  for (let i = 0; i < highlighted.length; i++) {
988
997
  const lineNum = i + 1;
989
- const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")}│`);
998
+ const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
990
999
  const body = replaceTabs(highlighted[i] ?? "");
991
- text += ` ${gutter}${body}\n`;
1000
+ text += `${gutter}${body}\n`;
992
1001
  }
993
1002
  if (!expanded && hidden > 0) {
994
1003
  const hint = formatExpandHint(uiTheme, expanded, hidden > 0);
@@ -1005,23 +1014,44 @@ export const writeToolRenderer = {
1005
1014
  const lang = getLanguageFromPath(rawPath) ?? "text";
1006
1015
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1007
1016
  const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
1008
- const spinner =
1009
- options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
1010
-
1011
- let text = `${formatTitle("Write", uiTheme)} ${spinner ? `${spinner} ` : ""}${langIcon} ${pathDisplay}`;
1012
-
1013
- if (!args.content) {
1014
- return new Text(text, 0, 0);
1015
- }
1016
-
1017
- // Show streaming preview of content — bounded tail while collapsed, full on Ctrl+O.
1018
- text += formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme);
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
+ });
1040
+ },
1019
1041
 
1020
- return new Text(text, 0, 0);
1042
+ // Only the expanded (Ctrl+O) preview is append-only: it renders the whole
1043
+ // content top-anchored, so streamed chunks only append rows at the bottom.
1044
+ // The collapsed preview slides a bounded tail window (`formatStreamingContent`
1045
+ // with `WRITE_STREAMING_PREVIEW_LINES`) whose visible rows re-layout as the
1046
+ // window moves — not append-only, but it never overflows the viewport, so its
1047
+ // head is never at risk of being dropped regardless. `write` has no partial
1048
+ // result (content streams as args), so `result` is ignored here.
1049
+ isStreamingPreviewAppendOnly(args: WriteRenderArgs, options: RenderResultOptions, _result?: unknown): boolean {
1050
+ return Boolean(options?.expanded && args.content);
1021
1051
  },
1022
1052
 
1023
1053
  renderResult(
1024
- result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails },
1054
+ result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails; isError?: boolean },
1025
1055
  options: RenderResultOptions,
1026
1056
  uiTheme: Theme,
1027
1057
  args?: WriteRenderArgs,
@@ -1031,14 +1061,33 @@ export const writeToolRenderer = {
1031
1061
  const fileContent = args?.content || "";
1032
1062
  const lang = getLanguageFromPath(rawPath);
1033
1063
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1034
- 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;
1070
+
1071
+ if (result.isError) {
1072
+ const errorText = result.content?.find(c => c.type === "text")?.text ?? "";
1073
+ const header = renderStatusLine(
1074
+ { icon: "error", title: "Write", description: `${langIcon} ${pathDisplay}` },
1075
+ uiTheme,
1076
+ );
1077
+ return framedBlock(uiTheme, width => ({
1078
+ header,
1079
+ sections: [{ lines: formatErrorDetail(errorText, uiTheme).split("\n") }],
1080
+ state: "error",
1081
+ borderColor: "error",
1082
+ width,
1083
+ }));
1084
+ }
1085
+
1035
1086
  const lineCount = countLines(fileContent);
1036
1087
  const lineSuffix = formatLineCountSuffix(lineCount, uiTheme);
1037
1088
  const execSuffix = result.details?.madeExecutable
1038
1089
  ? `${uiTheme.fg("dim", " · ")}${uiTheme.fg("success", "made executable!")}`
1039
1090
  : "";
1040
-
1041
- // Build header with status icon
1042
1091
  const header = renderStatusLine(
1043
1092
  {
1044
1093
  icon: "success",
@@ -1049,38 +1098,29 @@ export const writeToolRenderer = {
1049
1098
  );
1050
1099
  const diagnostics = result.details?.diagnostics;
1051
1100
 
1052
- let cached: RenderCache | undefined;
1053
-
1054
- return {
1055
- render(width: number) {
1056
- const { expanded } = options;
1057
- const key = new Hasher().bool(expanded).u32(width).digest();
1058
- if (cached?.key === key) return cached.lines;
1059
-
1060
- let text = header;
1061
- text += renderContentPreview(fileContent, expanded, lang, uiTheme);
1062
-
1063
- if (diagnostics) {
1064
- const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
1065
- uiTheme.getLangIcon(getLanguageFromPath(fp)),
1066
- );
1067
- if (diagText.trim()) {
1068
- const diagLines = diagText.split("\n");
1069
- const firstNonEmpty = diagLines.findIndex(line => line.trim());
1070
- if (firstNonEmpty >= 0) {
1071
- text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
1072
- }
1073
- }
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")}`;
1074
1112
  }
1075
-
1076
- const lines = text.split("\n").map(l => truncateToWidth(l, width, Ellipsis.Omit));
1077
- cached = { key, lines };
1078
- return lines;
1079
- },
1080
- invalidate() {
1081
- cached = undefined;
1082
- },
1083
- };
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
+ });
1084
1124
  },
1085
1125
  mergeCallAndResult: true,
1086
1126
  };