@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
@@ -2,8 +2,8 @@ import type { Component } from "@oh-my-pi/pi-tui";
2
2
  import { Text } from "@oh-my-pi/pi-tui";
3
3
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
4
4
  import type { Theme } from "../modes/theme/theme";
5
- import { renderStatusLine } from "../tui";
6
- import { formatExpandHint, replaceTabs, shortenPath, truncateToWidth } from "./render-utils";
5
+ import { framedBlock, renderStatusLine } from "../tui";
6
+ import { formatErrorDetail, formatExpandHint, replaceTabs, shortenPath, truncateToWidth } from "./render-utils";
7
7
 
8
8
  interface InspectImageRenderArgs {
9
9
  path?: string;
@@ -27,17 +27,21 @@ const INSPECT_OUTPUT_COLLAPSED_LINES = 4;
27
27
  const INSPECT_OUTPUT_EXPANDED_LINES = 16;
28
28
  const INSPECT_OUTPUT_LINE_WIDTH = 120;
29
29
 
30
+ function questionLine(question: string, uiTheme: Theme): string {
31
+ return `${uiTheme.fg("dim", "Question:")} ${uiTheme.fg("accent", truncateToWidth(replaceTabs(question), INSPECT_QUESTION_PREVIEW_WIDTH))}`;
32
+ }
33
+
30
34
  export const inspectImageToolRenderer = {
31
35
  renderCall(args: InspectImageRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
32
36
  const rawPath = args.path ?? "";
33
37
  const pathDisplay = rawPath ? shortenPath(rawPath) : "…";
34
- const header = renderStatusLine({ icon: "pending", title: "Inspect Image", description: pathDisplay }, uiTheme);
38
+ const header = renderStatusLine({ icon: "pending", title: "Inspect", description: pathDisplay }, uiTheme);
35
39
  const question = args.question?.trim();
36
- if (!question) {
37
- return new Text(header, 0, 0);
38
- }
39
- const questionLine = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("dim", "Question:")} ${uiTheme.fg("accent", truncateToWidth(replaceTabs(question), INSPECT_QUESTION_PREVIEW_WIDTH))}`;
40
- return new Text(`${header}\n${questionLine}`, 0, 0);
40
+ // Call is at most a status line plus a one-line question — too small to box.
41
+ // The container renders a lone Text cleanly with no chrome.
42
+ if (!question) return new Text(header, 0, 0);
43
+ const tree = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${questionLine(question, uiTheme)}`;
44
+ return new Text(`${header}\n${tree}`, 0, 0);
41
45
  },
42
46
 
43
47
  renderResult(
@@ -49,55 +53,73 @@ export const inspectImageToolRenderer = {
49
53
  const details = result.details;
50
54
  const rawPath = details?.imagePath ?? args?.path ?? "";
51
55
  const pathDisplay = rawPath ? shortenPath(rawPath) : "image";
52
- const metaParts: string[] = [];
53
- if (details?.model) metaParts.push(details.model);
54
- if (details?.mimeType) metaParts.push(details.mimeType);
55
56
  const header = renderStatusLine(
56
57
  {
57
58
  icon: result.isError ? "error" : "success",
58
- title: "Inspect Image",
59
+ title: "Inspect",
59
60
  description: pathDisplay,
60
61
  },
61
62
  uiTheme,
62
63
  );
63
64
 
64
- const lines: string[] = [header];
65
65
  const question = args?.question?.trim();
66
- if (question) {
67
- lines.push(
68
- ` ${uiTheme.fg("dim", uiTheme.tree.branch)} ${uiTheme.fg("dim", "Question:")} ${uiTheme.fg("accent", truncateToWidth(replaceTabs(question), INSPECT_QUESTION_PREVIEW_WIDTH))}`,
69
- );
70
- }
71
-
72
66
  const outputText = result.content.find(content => content.type === "text")?.text?.trimEnd() ?? "";
73
- if (!outputText) {
74
- lines.push(uiTheme.fg("dim", "(no output)"));
75
- if (metaParts.length > 0) {
76
- lines.push("");
77
- lines.push(uiTheme.fg("dim", metaParts.join(" · ")));
78
- }
79
- return new Text(lines.join("\n"), 0, 0);
80
- }
81
67
 
82
- lines.push("");
83
- const outputLines = replaceTabs(outputText).split("\n");
84
- const maxLines = options.expanded ? INSPECT_OUTPUT_EXPANDED_LINES : INSPECT_OUTPUT_COLLAPSED_LINES;
85
- for (const line of outputLines.slice(0, maxLines)) {
86
- lines.push(uiTheme.fg("toolOutput", truncateToWidth(line, INSPECT_OUTPUT_LINE_WIDTH)));
68
+ if (result.isError) {
69
+ return framedBlock(uiTheme, width => {
70
+ const bodyLines: string[] = [];
71
+ if (question) bodyLines.push(questionLine(question, uiTheme));
72
+ bodyLines.push(formatErrorDetail(outputText || "inspection failed", uiTheme));
73
+ return {
74
+ header,
75
+ sections: [{ lines: bodyLines }],
76
+ state: "error",
77
+ borderColor: "error",
78
+ applyBg: false,
79
+ width,
80
+ };
81
+ });
87
82
  }
88
83
 
89
- if (outputLines.length > maxLines) {
90
- const remaining = outputLines.length - maxLines;
91
- const hint = formatExpandHint(uiTheme, options.expanded, true);
92
- lines.push(`${uiTheme.fg("dim", `… ${remaining} more lines`)}${hint ? ` ${hint}` : ""}`);
93
- }
84
+ const metaParts: string[] = [];
85
+ if (details?.model) metaParts.push(details.model);
86
+ if (details?.mimeType) metaParts.push(details.mimeType);
87
+ const metaLine = metaParts.length > 0 ? uiTheme.fg("dim", metaParts.join(" · ")) : "";
94
88
 
95
- if (metaParts.length > 0) {
96
- lines.push("");
97
- lines.push(uiTheme.fg("dim", metaParts.join(" · ")));
89
+ // No answer text: nothing worth boxing — keep it to a clean status line
90
+ // (plus a trailing meta line, when present).
91
+ if (!outputText) {
92
+ return new Text(metaLine ? `${header}\n${metaLine}` : header, 0, 0);
98
93
  }
99
94
 
100
- return new Text(lines.join("\n"), 0, 0);
95
+ return framedBlock(uiTheme, width => {
96
+ const bodyLines: string[] = [];
97
+ if (question) {
98
+ bodyLines.push(questionLine(question, uiTheme));
99
+ bodyLines.push("");
100
+ }
101
+
102
+ const outputLines = replaceTabs(outputText).split("\n");
103
+ const maxLines = options.expanded ? INSPECT_OUTPUT_EXPANDED_LINES : INSPECT_OUTPUT_COLLAPSED_LINES;
104
+ for (const line of outputLines.slice(0, maxLines)) {
105
+ bodyLines.push(uiTheme.fg("toolOutput", truncateToWidth(line, INSPECT_OUTPUT_LINE_WIDTH)));
106
+ }
107
+ if (outputLines.length > maxLines) {
108
+ const remaining = outputLines.length - maxLines;
109
+ const hint = formatExpandHint(uiTheme, options.expanded, true);
110
+ bodyLines.push(`${uiTheme.fg("dim", `… ${remaining} more lines`)}${hint ? ` ${hint}` : ""}`);
111
+ }
112
+
113
+ return {
114
+ header,
115
+ headerMeta: metaLine || undefined,
116
+ sections: [{ lines: bodyLines }],
117
+ state: "success",
118
+ borderColor: "borderMuted",
119
+ applyBg: false,
120
+ width,
121
+ };
122
+ });
101
123
  },
102
124
  mergeCallAndResult: true,
103
125
  };
@@ -4,7 +4,8 @@ import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import * as z from "zod/v4";
6
6
  import { extractTextContent } from "../commit/utils";
7
- import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
7
+
8
+ import { expandRoleAlias, getModelMatchPreferences, resolveModelFromString } from "../config/model-resolver";
8
9
  import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
9
10
  import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
10
11
  import {
@@ -71,7 +72,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
71
72
  throw new ToolError("No models available for inspect_image.");
72
73
  }
73
74
 
74
- const matchPreferences = { usageOrder: this.session.settings.getStorage()?.getModelUsageOrder() };
75
+ const matchPreferences = getModelMatchPreferences(this.session.settings);
75
76
  const resolvePattern = (pattern: string | undefined): Model<Api> | undefined => {
76
77
  if (!pattern) return undefined;
77
78
  const expanded = expandRoleAlias(pattern, this.session.settings);
@@ -136,7 +137,13 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
136
137
  },
137
138
  ],
138
139
  },
139
- { apiKey, signal },
140
+ {
141
+ apiKey: modelRegistry.resolver(model.provider, {
142
+ sessionId: this.session.getSessionId?.() ?? undefined,
143
+ baseUrl: model.baseUrl,
144
+ }),
145
+ signal,
146
+ },
140
147
  { telemetry, oneshotKind: "inspect_image", completeImpl: this.completeImageRequest },
141
148
  );
142
149
 
package/src/tools/job.ts CHANGED
@@ -396,7 +396,7 @@ export const jobToolRenderer = {
396
396
  inline: true,
397
397
 
398
398
  renderCall(args: JobRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
399
- const text = renderStatusLine({ icon: "pending", title: "Job", description: describeTarget(args) }, uiTheme);
399
+ const text = renderStatusLine({ icon: "pending", title: describeTarget(args) || "Job" }, uiTheme);
400
400
  return new Text(text, 0, 0);
401
401
  },
402
402
 
@@ -410,7 +410,7 @@ export const jobToolRenderer = {
410
410
 
411
411
  if (jobs.length === 0) {
412
412
  const fallback = result.content?.find(c => c.type === "text")?.text || "No jobs to process";
413
- const header = renderStatusLine({ icon: "warning", title: "Job", description: describeTarget(args) }, uiTheme);
413
+ const header = renderStatusLine({ icon: "warning", title: describeTarget(args) || "Job" }, uiTheme);
414
414
  return new Text([header, formatEmptyMessage(fallback, uiTheme)].join("\n"), 0, 0);
415
415
  }
416
416
 
@@ -433,8 +433,7 @@ export const jobToolRenderer = {
433
433
  {
434
434
  icon: headerIcon,
435
435
  spinnerFrame: counts.running > 0 ? options.spinnerFrame : undefined,
436
- title: "Job",
437
- description,
436
+ title: description,
438
437
  meta,
439
438
  },
440
439
  uiTheme,
@@ -4,7 +4,10 @@
4
4
  *
5
5
  * These keep the transcript terse — one status line plus, for `retain`, one
6
6
  * `Remember: …` line per stored item — instead of the generic JSON arg tree,
7
- * which exploded multi-line memory blobs into an unreadable wall.
7
+ * which exploded multi-line memory blobs into an unreadable wall. The tool
8
+ * container is a transparent passthrough, so these renderers stay frameless:
9
+ * a status line with a couple of dim bullets reads far cleaner than boxing a
10
+ * one-line memory note.
8
11
  */
9
12
  import type { Component } from "@oh-my-pi/pi-tui";
10
13
  import { Text } from "@oh-my-pi/pi-tui";
@@ -601,6 +601,23 @@ export function parseSearchPath(filePath: string): ParsedSearchPath {
601
601
  };
602
602
  }
603
603
 
604
+ /**
605
+ * Async sibling of {@link parseSearchPath} that prefers literal interpretation
606
+ * when a path containing glob metacharacters resolves to an existing entry on
607
+ * disk. Disambiguates Next.js/SvelteKit routes like `apps/[id]/page.tsx` —
608
+ * without this, `[id]` is parsed as a glob character class and silently
609
+ * matches nothing.
610
+ */
611
+ export async function parseSearchPathPreferringLiteral(filePath: string, cwd: string): Promise<ParsedSearchPath> {
612
+ if (!hasGlobPathChars(filePath) || isInternalUrlPath(filePath)) return parseSearchPath(filePath);
613
+ try {
614
+ await fs.promises.stat(resolveToCwd(filePath, cwd));
615
+ return { basePath: filePath };
616
+ } catch {
617
+ return parseSearchPath(filePath);
618
+ }
619
+ }
620
+
604
621
  // Parse a find pattern into a base directory path and a glob pattern.
605
622
  // Examples:
606
623
  // src/app/**/\*.tsx -> { basePath: "src/app", globPattern: "**/*.tsx", hasGlob: true }
@@ -707,7 +724,7 @@ async function resolveSearchPathItems(
707
724
 
708
725
  const parsedItems = await Promise.all(
709
726
  pathItems.map(async item => {
710
- const parsedPath = parseSearchPath(item);
727
+ const parsedPath = await parseSearchPathPreferringLiteral(item, cwd);
711
728
  const absoluteBasePath = resolveToCwd(parsedPath.basePath, cwd);
712
729
  const stat = await fs.promises.stat(absoluteBasePath);
713
730
  return { raw: item, parsedPath, absoluteBasePath, stat };
@@ -946,6 +963,15 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
946
963
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
947
964
  throw new ToolError("`paths` must contain non-empty paths or globs");
948
965
  }
966
+ // External (http/https/ftp/file) URLs are not searchable; route the caller
967
+ // to `read` instead of letting the path-resolver surface a confusing
968
+ // "Path not found" for a slash-stripped URL.
969
+ const externalUrl = rawPaths.find(rawPath => /^(?:https?|ftp|file|ws|wss):\/\//i.test(rawPath));
970
+ if (externalUrl) {
971
+ throw new ToolError(
972
+ `Cannot ${internalUrlAction} external URL: ${externalUrl}. Use \`read\` to fetch web content, then search the returned text.`,
973
+ );
974
+ }
949
975
  const internalRouter = InternalUrlRouter.instance();
950
976
  const resolvedPathInputs: string[] = [];
951
977
  const immutableSourcePaths = new Set<string>();
@@ -989,7 +1015,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
989
1015
  let multiTargets: ResolvedSearchTarget[] | undefined;
990
1016
  let exactFilePaths: string[] | undefined;
991
1017
  if (effectivePaths.length === 1) {
992
- const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
1018
+ const parsedPath = await parseSearchPathPreferringLiteral(effectivePaths[0] ?? ".", cwd);
993
1019
  searchPath = resolveToCwd(parsedPath.basePath, cwd);
994
1020
  globFilter = parsedPath.glob;
995
1021
  scopePath = formatPathRelativeToCwd(searchPath, cwd);
@@ -1,15 +1,68 @@
1
+ import * as fs from "node:fs";
1
2
  import * as path from "node:path";
2
- import { resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
3
+ import { resolveLocalRoot, resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
3
4
  import type { ToolSession } from ".";
4
5
  import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
5
6
  import { ToolError } from "./tool-errors";
6
7
 
7
8
  const VAULT_SCHEME_PREFIX = "vault:";
8
9
  const LOCAL_SCHEME_PREFIX = "local:";
9
- const PLAN_ALIAS_FILE = "PLAN.md";
10
- const LOCAL_PLAN_ALIAS = "local://PLAN.md";
11
10
 
12
- function resolveRawPath(session: ToolSession, targetPath: string): string {
11
+ /** Resolve the absolute path of the session's `local://` artifact sandbox.
12
+ * Returns `null` when the session has no artifact wiring (e.g. tests). */
13
+ function localSandboxRoot(session: ToolSession): string | null {
14
+ try {
15
+ return path.resolve(
16
+ resolveLocalRoot({
17
+ getArtifactsDir: session.getArtifactsDir,
18
+ getSessionId: session.getSessionId,
19
+ }),
20
+ );
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /** True when `absolutePath` resolves inside `root` (== root or under it). */
27
+ function isWithinRoot(absolutePath: string, root: string): boolean {
28
+ if (absolutePath === root) return true;
29
+ const sep = `${root}${path.sep}`;
30
+ return absolutePath.startsWith(sep);
31
+ }
32
+
33
+ /** True when `targetPath` addresses the session-local artifact sandbox.
34
+ * Accepts both `local://…` URLs and absolute paths pointing inside the
35
+ * resolved sandbox root — the latter is what `read local://…` echoes back
36
+ * in the `[path#tag]` header. Those files are not part of the working tree,
37
+ * so plan mode treats them as freely writable scratch/plan space. */
38
+ function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean {
39
+ const normalized = normalizeLocalScheme(targetPath);
40
+ if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) return true;
41
+ if (!path.isAbsolute(normalized)) return false;
42
+ const root = localSandboxRoot(session);
43
+ if (!root) return false;
44
+ // Compare both raw and realpath-normalized forms so that
45
+ // `/tmp/…` vs `/private/tmp/…` (macOS) and other symlink-collapsed
46
+ // roots both resolve to the same sandbox identity.
47
+ const resolved = path.resolve(normalized);
48
+ if (isWithinRoot(resolved, root)) return true;
49
+ try {
50
+ const realRoot = fs.realpathSync.native(root);
51
+ if (isWithinRoot(resolved, realRoot)) return true;
52
+ // `resolved` itself may live in `/tmp/...` while `realRoot` is `/private/tmp/...`;
53
+ // realpath the parent dir of `resolved` so we catch that direction too.
54
+ const realParent = fs.realpathSync.native(path.dirname(resolved));
55
+ return isWithinRoot(path.join(realParent, path.basename(resolved)), realRoot);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve a write/edit target to its absolute filesystem path, honoring the
63
+ * `local://` and `vault://` schemes. Plain paths resolve against the session cwd.
64
+ */
65
+ export function resolvePlanPath(session: ToolSession, targetPath: string): string {
13
66
  const normalized = normalizeLocalScheme(targetPath);
14
67
  if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) {
15
68
  return resolveLocalUrlToPath(normalized, {
@@ -25,37 +78,12 @@ function resolveRawPath(session: ToolSession, targetPath: string): string {
25
78
  return resolveToCwd(normalized, session.cwd);
26
79
  }
27
80
 
28
- function isPlanAliasTarget(session: ToolSession, targetPath: string, resolved: string): boolean {
29
- const normalized = normalizeLocalScheme(targetPath);
30
- if (normalized === LOCAL_PLAN_ALIAS) return true;
31
- return resolved === resolveToCwd(PLAN_ALIAS_FILE, session.cwd);
32
- }
33
-
34
81
  /**
35
- * Resolve a write/edit target to its absolute filesystem path.
36
- *
37
- * In plan mode, transparently redirects `PLAN.md` aliases and targets whose
38
- * basename matches the plan file's basename to the canonical plan file
39
- * location at `state.planFilePath`. This lets `write` and `edit` accept the
40
- * habitual plan filename after approval even when the active artifact has a
41
- * titled path such as `local://APPROVED.md`.
42
- *
43
- * Outside plan mode (or when the basename does not match) this is a no-op.
82
+ * Plan mode keeps the working tree read-only while letting the agent draft its
83
+ * plan. Writes and edits to the `local://` artifact sandbox are allowed (that is
84
+ * where the plan and any scratch notes live); anything that would touch the
85
+ * working tree or rename/delete a file is rejected.
44
86
  */
45
- export function resolvePlanPath(session: ToolSession, targetPath: string): string {
46
- const resolved = resolveRawPath(session, targetPath);
47
-
48
- const state = session.getPlanModeState?.();
49
- if (!state?.enabled) return resolved;
50
-
51
- const planResolved = resolveRawPath(session, state.planFilePath);
52
- if (resolved === planResolved) return resolved;
53
- if (isPlanAliasTarget(session, targetPath, resolved)) return planResolved;
54
- if (path.basename(resolved) !== path.basename(planResolved)) return resolved;
55
-
56
- return planResolved;
57
- }
58
-
59
87
  export function enforcePlanModeWrite(
60
88
  session: ToolSession,
61
89
  targetPath: string,
@@ -64,9 +92,6 @@ export function enforcePlanModeWrite(
64
92
  const state = session.getPlanModeState?.();
65
93
  if (!state?.enabled) return;
66
94
 
67
- const resolvedTarget = resolvePlanPath(session, targetPath);
68
- const resolvedPlan = resolvePlanPath(session, state.planFilePath);
69
-
70
95
  if (options?.move) {
71
96
  throw new ToolError("Plan mode: renaming files is not allowed.");
72
97
  }
@@ -75,7 +100,9 @@ export function enforcePlanModeWrite(
75
100
  throw new ToolError("Plan mode: deleting files is not allowed.");
76
101
  }
77
102
 
78
- if (resolvedTarget !== resolvedPlan) {
79
- throw new ToolError(`Plan mode: only the plan file may be modified (${state.planFilePath}).`);
80
- }
103
+ if (targetsLocalSandbox(session, targetPath)) return;
104
+
105
+ throw new ToolError(
106
+ "Plan mode: the working tree is read-only. Write your plan to a local://<slug>-plan.md file instead.",
107
+ );
81
108
  }
package/src/tools/read.ts CHANGED
@@ -9,7 +9,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
10
  import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
11
11
  import * as z from "zod/v4";
12
- import { getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
12
+ import { canonicalSnapshotKey, getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
13
13
  import { normalizeToLF } from "../edit/normalize";
14
14
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
15
15
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -34,7 +34,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
34
34
  import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
35
35
  import { convertFileWithMarkit } from "../utils/markit";
36
36
  import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
37
- import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader";
37
+ import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "./archive-reader";
38
38
  import {
39
39
  type ConflictEntry,
40
40
  type ConflictScope,
@@ -131,7 +131,7 @@ function recordFullHashlineContext(
131
131
  ): HashlineHeaderContext | undefined {
132
132
  if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
133
133
  const normalized = normalizeToLF(fullText);
134
- const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
134
+ const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
135
135
  return {
136
136
  header: formatHashlineHeader(displayPath, tag),
137
137
  tag,
@@ -1154,17 +1154,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1154
1154
  const limitedEntries = listLimit.items;
1155
1155
  const limitMeta = listLimit.meta;
1156
1156
 
1157
- const results: string[] = [];
1158
- for (const entry of limitedEntries) {
1157
+ for (let index = 0; index < limitedEntries.length; index++) {
1159
1158
  throwIfAborted(signal);
1160
- if (entry.isDirectory) {
1161
- results.push(`${entry.name}/`);
1162
- continue;
1163
- }
1164
-
1165
- const sizeSuffix = entry.size > 0 ? ` (${formatBytes(entry.size)})` : "";
1166
- results.push(`${entry.name}${sizeSuffix}`);
1167
1159
  }
1160
+ const results = formatArchiveEntryLines(limitedEntries);
1168
1161
 
1169
1162
  const output = results.length > 0 ? results.join("\n") : "(empty archive directory)";
1170
1163
  const text = prependSuffixResolutionNotice(output, details.suffixResolution);
@@ -1757,15 +1750,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1757
1750
  // Convert document via markit.
1758
1751
  const result = await convertFileWithMarkit(absolutePath, signal);
1759
1752
  if (result.ok) {
1760
- // Apply truncation to converted content
1761
- const truncation = truncateHead(result.content);
1762
- const outputText = truncation.content;
1763
-
1764
- details = { truncation };
1765
- sourcePath = absolutePath;
1766
- truncationInfo = { result: truncation, options: { direction: "head", startLine: 1 } };
1767
-
1768
- content = [{ type: "text", text: outputText }];
1753
+ // Route the converted markdown through the in-memory text builder
1754
+ // so line-range selectors (`file.pdf:50-100`, `:5-16,40-80`) and
1755
+ // raw mode apply against the converted output. Without this,
1756
+ // `file.pdf:50-100` silently returned the head of the document
1757
+ // because only `truncateHead` was being applied.
1758
+ if (isMultiRange(parsed) && parsed.kind === "lines") {
1759
+ return this.#buildInMemoryMultiRangeResult(result.content, parsed.ranges, {
1760
+ details: { resolvedPath: absolutePath },
1761
+ sourcePath: absolutePath,
1762
+ entityLabel: "document",
1763
+ });
1764
+ }
1765
+ const { offset, limit } = selToOffsetLimit(parsed);
1766
+ return this.#buildInMemoryTextResult(result.content, offset, limit, {
1767
+ details: { resolvedPath: absolutePath },
1768
+ sourcePath: absolutePath,
1769
+ entityLabel: "document",
1770
+ raw: isRawSelector(parsed),
1771
+ });
1769
1772
  } else if (result.error) {
1770
1773
  content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
1771
1774
  } else {
@@ -1951,7 +1954,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1951
1954
  // full file and any anchor validates while the file is unchanged.
1952
1955
  const isWholeFile = offset === undefined && limit === undefined && !wasTruncated;
1953
1956
  const tag = isWholeFile
1954
- ? getFileSnapshotStore(this.session).record(absolutePath, normalizeToLF(collectedLines.join("\n")))
1957
+ ? getFileSnapshotStore(this.session).record(
1958
+ canonicalSnapshotKey(absolutePath),
1959
+ normalizeToLF(collectedLines.join("\n")),
1960
+ )
1955
1961
  : await recordFileSnapshot(this.session, absolutePath);
1956
1962
  if (tag) {
1957
1963
  hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
@@ -2337,10 +2343,20 @@ function firstReadSelectorLine(sel: string | undefined): number | undefined {
2337
2343
  }
2338
2344
  }
2339
2345
 
2346
+ /** Absolute fs path the read result actually resolved to, used as the OSC 8 link
2347
+ * target when the structured `resolvedPath` isn't set (the common plain-file and
2348
+ * image reads only record the path in `meta.source`). URL/internal sources are
2349
+ * not fs paths, so only `type: "path"` qualifies. */
2350
+ function readSourceFsPath(details: ReadToolDetails | undefined): string | undefined {
2351
+ const source = details?.meta?.source;
2352
+ return source?.type === "path" ? source.value : undefined;
2353
+ }
2354
+
2340
2355
  function formatReadPathLink(
2341
2356
  rawPath: string,
2342
2357
  options: {
2343
2358
  resolvedPath?: string;
2359
+ sourcePath?: string;
2344
2360
  suffixResolution?: { from: string; to: string };
2345
2361
  offset?: number;
2346
2362
  fallbackLabel?: string;
@@ -2352,7 +2368,7 @@ function formatReadPathLink(
2352
2368
  const plainDisplayPath = options.suffixResolution
2353
2369
  ? shortenPath(options.suffixResolution.to)
2354
2370
  : shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
2355
- const target = options.resolvedPath ?? tryResolveInternalUrlSync(basePath);
2371
+ const target = options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath);
2356
2372
  const line = firstReadSelectorLine(split.sel) ?? options.offset;
2357
2373
  const linkOptions = line !== undefined ? { line } : undefined;
2358
2374
  const displayPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
@@ -2403,7 +2419,9 @@ export const readToolRenderer = {
2403
2419
  const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
2404
2420
  const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
2405
2421
  const rawPath = args?.file_path || args?.path || "";
2406
- const filePath = formatReadPathLink(rawPath, { offset: args?.offset }) || shortenPath(rawPath);
2422
+ const filePath =
2423
+ formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) ||
2424
+ shortenPath(rawPath);
2407
2425
  let title = filePath ? `Read ${filePath}` : "Read";
2408
2426
  if (args?.offset !== undefined || args?.limit !== undefined) {
2409
2427
  const startLine = args.offset ?? 1;
@@ -2454,6 +2472,7 @@ export const readToolRenderer = {
2454
2472
  const suffix = details?.suffixResolution;
2455
2473
  const displayPath = formatReadPathLink(rawPath, {
2456
2474
  resolvedPath: details?.resolvedPath,
2475
+ sourcePath: readSourceFsPath(details),
2457
2476
  suffixResolution: suffix,
2458
2477
  fallbackLabel: "image",
2459
2478
  });
@@ -2486,12 +2505,13 @@ export const readToolRenderer = {
2486
2505
  }
2487
2506
 
2488
2507
  const suffix = details?.suffixResolution;
2489
- // resolvedPath is the absolute fs path for fs-backed reads (regular files plus
2490
- // local:// / memory:// / skill:// / artifact:// resources). Fall back to a sync
2491
- // resolver for fs-backed internal URLs so the title is clickable even before the
2492
- // result lands or if the handler didn't populate resolvedPath.
2508
+ // resolvedPath is the absolute fs path when a read resolved/corrected the
2509
+ // input (suffix match, internal URL, archive/sqlite/notebook); plain file
2510
+ // reads only record the absolute path in meta.source, so fall back to that
2511
+ // (and then to a sync internal-URL resolver) to keep the title clickable.
2493
2512
  const displayPath = formatReadPathLink(rawPath, {
2494
2513
  resolvedPath: details?.resolvedPath,
2514
+ sourcePath: readSourceFsPath(details),
2495
2515
  suffixResolution: suffix,
2496
2516
  offset: args?.offset,
2497
2517
  });